(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 layoutRect = pyramidLayout.getBoundingClientRect(); // If the board isn't visible yet or dimensions aren't ready, wait if (layoutRect.width === 0) { requestAnimationFrame(renderBoard); return; } // Use the first available card to get the REAL pixel width/height calculated by CSS const firstCardId = Object.keys(cardElements)[0]; if (!firstCardId) return; const sampleCard = cardElements[firstCardId]; const cardRect = sampleCard.getBoundingClientRect(); const cardWidth = cardRect.width; const cardHeight = cardRect.height; // Calculate gap based on 1.5vw or fallback (matching style.css) let horizontalGap = (window.innerWidth * 1.5) / 100; if (window.innerWidth > 600) horizontalGap = (window.innerWidth * 1.2) / 100; // Desktop gap const verticalStep = cardHeight * 0.45; // Row overlap ratio // Position Pyramid Cards let index = 0; for (let row = 0; row < 7; row++) { const numCards = row + 1; const totalRowWidth = numCards * cardWidth + (numCards - 1) * horizontalGap; const startX = (layoutRect.width - totalRowWidth) / 2; const y = row * verticalStep; for (let i = 0; i <= row; i++) { const card = pyramid[index]; if (card) { const el = cardElements[card.id]; // Position relative to the pyramid-layout container // Note: pyramid-layout has position: relative in pyramid.css el.style.top = `${y}px`; el.style.left = `${startX + i * (cardWidth + horizontalGap)}px`; el.style.zIndex = 100 + index; el.classList.add('is-flipped'); const exposed = isExposed(card.id); el.classList.toggle('is-exposed', exposed); } index++; } } // Position Stock, Waste, Discard (these are in a separate container at the bottom) // We'll use a slightly different approach for these to ensure they stay in their piles const sRect = stockEl.getBoundingClientRect(); const wRect = wasteEl.getBoundingClientRect(); const dRect = discardEl.getBoundingClientRect(); const bRect = gameBoard.getBoundingClientRect(); stock.forEach((card, i) => { const el = cardElements[card.id]; el.style.top = `${sRect.top - bRect.top + PILE_BORDER_WIDTH}px`; el.style.left = `${sRect.left - bRect.left + PILE_BORDER_WIDTH}px`; el.style.zIndex = 10 + i; el.classList.remove('is-flipped', 'is-selected', 'is-exposed'); }); waste.forEach((card, i) => { const el = cardElements[card.id]; const isTop = (i === waste.length - 1); el.style.top = `${wRect.top - bRect.top + PILE_BORDER_WIDTH}px`; el.style.left = `${wRect.left - bRect.left + PILE_BORDER_WIDTH}px`; el.style.zIndex = 500 + i; el.classList.add('is-flipped'); el.classList.toggle('is-exposed', isTop); }); discard.forEach((card, i) => { const el = cardElements[card.id]; el.style.top = `${dRect.top - bRect.top + PILE_BORDER_WIDTH}px`; el.style.left = `${dRect.left - bRect.left + PILE_BORDER_WIDTH}px`; el.style.zIndex = 10 + i; el.classList.add('is-flipped'); el.classList.remove('is-selected', '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(); } })();