(function() { // ====== CONSTANTS & STATE ====== const suits = ['♥', '♦', '♣', '♠']; const values = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']; const PILE_BORDER_WIDTH = 2; let deck, stock, waste, pyramid, discard; let cardElements = {}; let selectedCard = null; let score = 0; let highScore = 0; let isActive = false; // DOM Elements const gameBoard = document.getElementById('pyramid-board'); const pyramidLayout = gameBoard.querySelector('.pyramid-layout'); const stockEl = document.getElementById('pyramid-stock'); const wasteEl = document.getElementById('pyramid-waste'); const discardEl = document.getElementById('pyramid-discard'); const scoreEl = document.getElementById('current-score'); const highScoreEl = document.getElementById('high-score'); const winMessage = document.getElementById('win-message'); // ====== HELPERS ====== function getValueRank(v) { if (v === 'A') return 1; if (v === 'K') return 13; if (v === 'Q') return 12; if (v === 'J') return 11; if (v === '10') return 10; return parseInt(v, 10); } function createCardElement(cardData) { const el = document.createElement('div'); el.classList.add('card'); el.dataset.id = cardData.id; const cardInner = document.createElement('div'); cardInner.classList.add('card-inner'); const color = (cardData.suit === '♥' || cardData.suit === '♦') ? 'red' : 'black'; const front = document.createElement('div'); front.className = `card-face card-face--front ${color}`; front.innerHTML = `
${cardData.value}${cardData.suit}
${cardData.value}${cardData.suit}
${cardData.suit}
`; const back = document.createElement('div'); back.className = 'card-face card-face--back'; cardInner.append(front, back); el.appendChild(cardInner); return el; } function isExposed(cardId) { // A card in the pyramid is exposed if no cards in the row below it are still present in the slots it covers. // Pyramid indexing: // Row 0: 0 // Row 1: 1, 2 // Row 2: 3, 4, 5 // Row 3: 6, 7, 8, 9 // Row 4: 10, 11, 12, 13, 14 // Row 5: 15, 16, 17, 18, 19, 20 // Row 6: 21, 22, 23, 24, 25, 26, 27 const index = pyramid.findIndex(c => c && c.id === cardId); if (index === -1) return true; // Not in pyramid (waste/stock) // Find which row it's in let row = 0; let count = 0; for (let r = 0; r < 7; r++) { if (index >= count && index < count + r + 1) { row = r; break; } count += r + 1; } if (row === 6) return true; // Bottom row is always exposed if present // Check the two cards below it // Row r, index i (within row) covers Row r+1, index i and i+1 const indexInRow = index - count; const nextRowStart = count + row + 1; const leftBelow = nextRowStart + indexInRow; const rightBelow = nextRowStart + indexInRow + 1; return !pyramid[leftBelow] && !pyramid[rightBelow]; } // ====== CORE LOGIC ====== function initPyramid() { score = 0; highScore = parseInt(localStorage.getItem('pyramid-high-score'), 10) || 0; winMessage.classList.add('hidden'); // Clean up Object.values(cardElements).forEach(el => el.remove()); cardElements = {}; deck = []; pyramid = Array(28).fill(null); stock = []; waste = []; discard = []; selectedCard = null; // Create deck suits.forEach(suit => values.forEach(value => { deck.push({ suit, value, faceUp: false, id: `p-${value}${suit}-${Math.random().toString(36).slice(2,8)}` }); })); // Shuffle for (let i = deck.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; } // Create elements deck.forEach(cardData => { const el = createCardElement(cardData); cardElements[cardData.id] = el; gameBoard.appendChild(el); // Set initial position (will be updated by renderBoard) el.style.top = '0px'; el.style.left = '0px'; }); // Fill Pyramid for (let i = 0; i < 28; i++) { pyramid[i] = deck.pop(); pyramid[i].faceUp = true; } stock = deck; updateScoreDisplay(); requestAnimationFrame(renderBoard); saveGame(); } function renderBoard() { if (!isActive) return; const boardRect = gameBoard.getBoundingClientRect(); const layoutRect = pyramidLayout.getBoundingClientRect(); // If the board isn't visible yet or dimensions aren't ready, wait for the next frame if (boardRect.width === 0 || layoutRect.width === 0) { requestAnimationFrame(renderBoard); return; } const rootStyle = getComputedStyle(document.documentElement); let cardWidth = parseFloat(rootStyle.getPropertyValue('--card-width')); if (isNaN(cardWidth) || cardWidth === 0) cardWidth = 80; const cardHeight = cardWidth * 1.4; const rowOverlap = 0.45; const horizontalGap = cardWidth * 0.15; // Proportional gap const boardTop = boardRect.top; const boardLeft = boardRect.left; // Position Pyramid Cards let index = 0; for (let row = 0; row < 7; row++) { // Calculate total width of this row to center it const numCards = row + 1; const totalRowWidth = numCards * cardWidth + (numCards - 1) * horizontalGap; const startX = (layoutRect.width - totalRowWidth) / 2; const y = row * (cardHeight * rowOverlap); for (let i = 0; i <= row; i++) { const card = pyramid[index]; if (card) { const el = cardElements[card.id]; // Absolute position relative to the gameBoard const finalTop = y + (layoutRect.top - boardTop); const finalLeft = startX + i * (cardWidth + horizontalGap) + (layoutRect.left - boardLeft); el.style.top = `${finalTop}px`; el.style.left = `${finalLeft}px`; el.style.zIndex = 100 + index; el.classList.add('is-flipped'); // Mark if it's currently playable for visual feedback const exposed = isExposed(card.id); el.classList.toggle('is-exposed', exposed); el.style.opacity = exposed ? '1' : '0.85'; // el.style.filter = exposed ? 'none' : 'brightness(0.8)'; // Optional: dim unexposed cards } index++; } } // Position Stock const sRect = stockEl.getBoundingClientRect(); stock.forEach((card, i) => { const el = cardElements[card.id]; el.style.top = `${sRect.top - boardTop + PILE_BORDER_WIDTH}px`; el.style.left = `${sRect.left - boardLeft + PILE_BORDER_WIDTH}px`; el.style.zIndex = 10 + i; el.classList.remove('is-flipped'); el.classList.remove('is-selected'); el.classList.remove('is-exposed'); }); // Position Waste const wRect = wasteEl.getBoundingClientRect(); waste.forEach((card, i) => { const el = cardElements[card.id]; const isTop = (i === waste.length - 1); el.style.top = `${wRect.top - boardTop + PILE_BORDER_WIDTH}px`; el.style.left = `${wRect.left - boardLeft + PILE_BORDER_WIDTH}px`; // Base z-index for waste is 500, +i to stack correctly el.style.zIndex = 500 + i; el.classList.add('is-flipped'); el.classList.toggle('is-exposed', isTop); }); // Position Discard const dRect = discardEl.getBoundingClientRect(); discard.forEach((card, i) => { const el = cardElements[card.id]; el.style.top = `${dRect.top - boardTop + PILE_BORDER_WIDTH}px`; el.style.left = `${dRect.left - boardLeft + PILE_BORDER_WIDTH}px`; el.style.zIndex = 10 + i; el.classList.add('is-flipped'); el.classList.remove('is-selected'); el.classList.remove('is-exposed'); }); checkWin(); } function handleCardClick(cardId) { if (!isActive) return; // Find card let card = null; let source = ''; let pIdx = pyramid.findIndex(c => c && c.id === cardId); if (pIdx !== -1) { card = pyramid[pIdx]; source = 'pyramid'; } else if (waste.length > 0 && waste[waste.length - 1].id === cardId) { card = waste[waste.length - 1]; source = 'waste'; } if (!card) return; if (source === 'pyramid' && !isExposed(cardId)) return; const el = cardElements[card.id]; // King is special (13) - remove immediately if (getValueRank(card.value) === 13) { if (selectedCard) { cardElements[selectedCard.id].classList.remove('is-selected'); selectedCard = null; } removeCards([card]); return; } if (!selectedCard) { selectedCard = card; el.classList.add('is-selected'); } else { if (selectedCard.id === card.id) { // Deselect if clicking the same card el.classList.remove('is-selected'); selectedCard = null; } else { // Try to match if (getValueRank(selectedCard.value) + getValueRank(card.value) === 13) { removeCards([selectedCard, card]); selectedCard = null; } else { // Invalid pair: switch selection to the new card cardElements[selectedCard.id].classList.remove('is-selected'); selectedCard = card; el.classList.add('is-selected'); } } } } function removeCards(cards) { cards.forEach(card => { // Remove from pyramid const pIdx = pyramid.findIndex(c => c && c.id === card.id); if (pIdx !== -1) pyramid[pIdx] = null; // Remove from waste const wIdx = waste.findIndex(c => c && c.id === card.id); if (wIdx !== -1) waste.splice(wIdx, 1); discard.push(card); cardElements[card.id].classList.remove('is-selected'); score += 5; }); updateScoreDisplay(); renderBoard(); saveGame(); } function handleStockClick() { // Clear current selection when drawing/recycling if (selectedCard) { cardElements[selectedCard.id].classList.remove('is-selected'); selectedCard = null; } if (stock.length > 0) { const card = stock.pop(); card.faceUp = true; waste.push(card); } else if (waste.length > 0) { // Recycle waste back to stock stock = waste.reverse(); stock.forEach(c => c.faceUp = false); waste = []; } renderBoard(); saveGame(); } function updateScoreDisplay() { if (!isActive) return; scoreEl.textContent = score; highScoreEl.textContent = highScore; } function checkWin() { if (pyramid.every(c => c === null)) { if (score > highScore) { highScore = score; localStorage.setItem('pyramid-high-score', highScore); updateScoreDisplay(); } winMessage.classList.remove('hidden'); // Trigger bouncing cards - reuse logic if available or implement local if (window.triggerBouncingCards) window.triggerBouncingCards(); } } function saveGame() { const state = { pyramid, stock, waste, discard, score }; localStorage.setItem('pyramid-save', JSON.stringify(state)); } function loadGame() { const saved = localStorage.getItem('pyramid-save'); if (!saved) return false; try { const state = JSON.parse(saved); pyramid = state.pyramid; stock = state.stock; waste = state.waste; discard = state.discard; score = state.score || 0; // Rebuild elements Object.values(cardElements).forEach(el => el.remove()); cardElements = {}; const all = [...pyramid.filter(c => c), ...stock, ...waste, ...discard]; all.forEach(c => { const el = createCardElement(c); cardElements[c.id] = el; gameBoard.appendChild(el); }); renderBoard(); updateScoreDisplay(); return true; } catch (e) { return false; } } // ====== EVENT LISTENERS ====== gameBoard.addEventListener('click', (e) => { if (!isActive) return; const cardEl = e.target.closest('.card'); const pileEl = e.target.closest('.pile'); // Check if we clicked the stock pile OR a card that is currently in the stock const isStockClick = (pileEl && pileEl.id === 'pyramid-stock') || (cardEl && stock.some(c => c.id === cardEl.dataset.id)); if (isStockClick) { handleStockClick(); return; } if (cardEl) { handleCardClick(cardEl.dataset.id); return; } // If we click the board background but NOT a card or a pile, clear selection if (selectedCard && !cardEl && !pileEl) { cardElements[selectedCard.id].classList.remove('is-selected'); selectedCard = null; } }); document.addEventListener('gameModeChanged', (e) => { isActive = (e.detail === 'pyramid'); if (isActive) { if (!pyramid) { if (!loadGame()) initPyramid(); } else { updateScoreDisplay(); requestAnimationFrame(renderBoard); } } }); // Handle New Game button from topbar document.getElementById('restart-btn').addEventListener('click', () => { if (isActive) initPyramid(); }); window.addEventListener('resize', () => { if (isActive) renderBoard(); }); // Initial check if (localStorage.getItem('solitaire-mode') === 'pyramid') { isActive = true; if (!loadGame()) initPyramid(); } })();