document.addEventListener('DOMContentLoaded', () => { // ====== CONSTANTS & STATE ====== const suits = ['♥', '♦', '♣', '♠']; const values = ['A','2','3','4','5','6','7','8','9','10','J','Q','K']; const PILE_BORDER_WIDTH = 2; const SPACING_FACE_DOWN = 10; const SPACING_FACE_UP = 32; let DRAW_COUNT = 3; let deck, stock, waste, foundations, tableau; let cardElements = {}; let dragged = { cards: [], sourcePile: null, startIndex: -1 }; let selected = { card: null, sourcePile: null, cardIndex: -1 }; let lastClick = { time: 0, cardId: null }; const history = []; let isAutoCompleting = false; let score = 0; let highScore = 0; const CARD_BACKS = [ { id: 'waves', name: 'Blue Waves', className: 'card-back-waves' }, { id: 'herringbone', name: 'Green Herringbone', className: 'card-back-herringbone' }, { id: 'sunset', name: 'Gradient Sunset', className: 'card-back-sunset' }, { id: 'linen', name: 'Cozy Linen', className: 'card-back-linen' }, { id: 'nordic', name: 'Nordic Cross', className: 'card-back-nordic' }, { id: 'midnight', name: 'Midnight Grid', className: 'card-back-midnight' }, { id: 'diamond', name: 'Diamond Weave', className: 'card-back-diamond' }, { id: 'royal', name: 'Royal Blue Stripe', className: 'card-back-royal' }, { id: 'fade', name: 'Matte Gradient Fade', className: 'card-back-fade' } ]; // ====== DOM ====== const gameBoard = document.querySelector('.game-board'); const restartBtn = document.getElementById('restart-btn'); const winMessage = document.getElementById('win-message'); const undoBtn = document.getElementById('undo-btn'); const drawSelect = document.getElementById('draw-select'); const cardBackModal = document.getElementById('card-back-modal'); const openModalBtn = document.getElementById('change-back-btn'); const closeModalBtn = document.getElementById('modal-close-btn'); const modalOverlay = document.querySelector('.modal-overlay'); const cardBackOptionsContainer = document.getElementById('card-back-options'); const checkMovesBtn = document.getElementById('check-moves-btn'); const autoCompleteBtn = document.getElementById('autocomplete-btn'); const scoreEl = document.getElementById('current-score'); const highScoreEl = document.getElementById('high-score'); // ====== SAVE / LOAD ====== function saveGame() { // Also save score in the game state const gameState = { stock, waste, foundations, tableau, history, drawCount: DRAW_COUNT, score }; localStorage.setItem('solitaire-save-game', JSON.stringify(gameState)); } function loadGame() { const savedGame = localStorage.getItem('solitaire-save-game'); if (!savedGame) return false; try { const gameState = JSON.parse(savedGame); stock = gameState.stock; waste = gameState.waste; foundations = gameState.foundations; tableau = gameState.tableau; history.length = 0; history.push(...gameState.history); DRAW_COUNT = gameState.drawCount || 3; score = gameState.score || 0; // Load score drawSelect.value = DRAW_COUNT; Object.values(cardElements).forEach(el => el.remove()); cardElements = {}; const allCards = [...stock, ...waste, ...foundations.flat(), ...tableau.flat()]; allCards.forEach(cardData => { if (!cardElements[cardData.id]) { const el = createCardElement(cardData); cardElements[cardData.id] = el; gameBoard.appendChild(el); } }); winMessage.classList.add('hidden'); updateScoreDisplay(); updateBoard(true); return true; } catch (e) { console.error("Failed to load saved game:", e); localStorage.removeItem('solitaire-save-game'); return false; } } // ====== UNDO SNAPSHOTS ====== const snapshotState = () => JSON.stringify({ stock, waste, foundations, tableau, score }); // Include score in snapshots const restoreState = (json) => { const s = JSON.parse(json); stock = s.stock; waste = s.waste; foundations = s.foundations; tableau = s.tableau; score = s.score !== undefined ? s.score : score; // Restore score }; const pushHistory = () => { if (history.length > 50) history.shift(); history.push(snapshotState()); }; // ====== SETUP ====== function initState() { score = 0; highScore = localStorage.getItem('solitaire-high-score') || 0; updateScoreDisplay(); isAutoCompleting = false; Object.values(cardElements).forEach(el => el.remove()); cardElements = {}; deck = []; stock = []; waste = []; foundations = [[],[],[],[]]; tableau = Array(7).fill(null).map(()=>[]); suits.forEach(suit => values.forEach(value => { deck.push({ suit, value, faceUp:false, id:`${value}${suit}-${Math.random().toString(36).slice(2,8)}` }); })); 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]]; } deck.forEach(cardData => { const el = createCardElement(cardData); cardElements[cardData.id] = el; gameBoard.appendChild(el); }); for (let i=0; i<7; i++){ for (let j=i; j<7; j++) tableau[j].push(deck.pop()); } tableau.forEach(pile => { if (pile.length) pile[pile.length-1].faceUp = true; }); stock = deck; winMessage.classList.add('hidden'); history.length = 0; pushHistory(); // Initial state for undo updateBoard(true); saveGame(); } // ====== RENDER ====== function updateBoard(initial=false) { updateScoreDisplay(); // Keep score display fresh on every update const gameBoardRect = gameBoard.getBoundingClientRect(); const updatePile = (pileData, pileEl, type) => { if (!pileEl) return; const { top, left } = pileEl.getBoundingClientRect(); const baseTop = top - gameBoardRect.top + PILE_BORDER_WIDTH; const baseLeft = left - gameBoardRect.left + PILE_BORDER_WIDTH; let currentOffset = 0; pileData.forEach((cardData, i) => { const cardEl = cardElements[cardData.id]; if(!cardEl) return; if (type === 'tableau') { cardEl.style.top = `${baseTop + currentOffset}px`; currentOffset += cardData.faceUp ? SPACING_FACE_UP : SPACING_FACE_DOWN; } else { cardEl.style.top = `${baseTop}px`; } cardEl.style.left = `${baseLeft}px`; cardEl.style.pointerEvents = 'auto'; cardEl.classList.toggle('is-stacked', i < pileData.length - 1 && type !== 'tableau'); if (cardData.faceUp) { cardEl.classList.add('is-flipped'); cardEl.draggable = true; } else { cardEl.classList.remove('is-flipped'); cardEl.draggable = false; } cardEl.classList.toggle('selected', selected.card?.id === cardData.id); const zBase = type === 'tableau' ? 100 : type === 'foundation' ? 400 : 600; cardEl.style.zIndex = zBase + i; }); }; tableau.forEach((p,i)=>updatePile(p, document.getElementById(`tableau-${i}`), 'tableau')); foundations.forEach((p,i)=>updatePile(p, document.getElementById(`foundation-${i}`), 'foundation')); updatePile(stock, document.getElementById('stock'), 'stock'); stock.forEach(cardData => { if(cardElements[cardData.id]) cardElements[cardData.id].style.pointerEvents = 'none'; }); const wasteEl = document.getElementById('waste'); if (wasteEl) { const { top:wt, left:wl } = wasteEl.getBoundingClientRect(); const baseTop = wt - gameBoardRect.top + PILE_BORDER_WIDTH; const baseLeft = wl - gameBoardRect.left + PILE_BORDER_WIDTH; waste.forEach((cardData, i) => { const cardEl = cardElements[cardData.id]; if(!cardEl) return; const isTopCard = i === waste.length - 1; const visibleFanIndexStart = Math.max(0, waste.length - DRAW_COUNT); let leftOffset = 0; if (i >= visibleFanIndexStart) { leftOffset = (i - visibleFanIndexStart) * 18; } cardEl.style.top = `${baseTop}px`; cardEl.style.left = `${baseLeft + leftOffset}px`; cardEl.style.zIndex = 800 + i; cardEl.classList.add('is-flipped'); cardEl.classList.toggle('is-stacked', !isTopCard); cardEl.draggable = isTopCard; cardEl.style.pointerEvents = isTopCard ? 'auto' : 'none'; }); } if (!initial) checkWin(); } 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; } // ====== THEMES & MODAL ====== function applyCardBack(backId) { const back = CARD_BACKS.find(b => b.id === backId) || CARD_BACKS[0]; CARD_BACKS.forEach(b => document.body.classList.remove(b.className)); document.body.classList.add(back.className); document.querySelectorAll('.back-option').forEach(el => el.classList.toggle('active', el.dataset.id === backId)); } function setupThemeSelector() { if (!cardBackOptionsContainer) return; cardBackOptionsContainer.innerHTML = ''; CARD_BACKS.forEach(back => { const optionEl = document.createElement('div'); optionEl.className = `back-option ${back.className}`; optionEl.dataset.id = back.id; optionEl.title = back.name; const previewInner = document.createElement('div'); previewInner.className = 'card-face card-face--back'; optionEl.appendChild(previewInner); cardBackOptionsContainer.appendChild(optionEl); }); cardBackOptionsContainer.addEventListener('click', e => { const target = e.target.closest('.back-option'); if (target) { const backId = target.dataset.id; applyCardBack(backId); localStorage.setItem('solitaire-card-back', backId); cardBackModal.classList.add('hidden'); } }); const savedBack = localStorage.getItem('solitaire-card-back'); applyCardBack(savedBack || 'waves'); } // ====== HELPERS ====== function updateScoreDisplay() { scoreEl.textContent = score; highScoreEl.textContent = highScore; } function getValueRank(v) { if (v==='A') return 1; if (v==='K') return 13; if (v==='Q') return 12; if (v==='J') return 11; return parseInt(v,10); } function getPileArrayFromElement(el) { if (!el) return null; const [type, index] = el.id.split('-'); if (type === 'tableau') return tableau[Number(index)]; if (type === 'foundation') return foundations[Number(index)]; if (type === 'waste') return waste; if (type === 'stock') return stock; return null; } function findCardData(cardEl) { const id = cardEl.dataset.id; let cardIndex; const findIn = (pile) => pile.findIndex(c => c.id === id); if ((cardIndex = findIn(waste)) > -1 && cardIndex === waste.length - 1) { return { sourcePile: waste, cardIndex, card: waste[cardIndex], cardsToMove: [waste[cardIndex]] }; } for (const pile of tableau) { if ((cardIndex = findIn(pile)) > -1 && pile[cardIndex].faceUp) { const cardsToMove = pile.slice(cardIndex); return { sourcePile: pile, cardIndex, card: cardsToMove[0], cardsToMove }; } } for (const pile of foundations) { if ((cardIndex = findIn(pile)) > -1 && cardIndex === pile.length - 1) { return { sourcePile: pile, cardIndex, card: pile[cardIndex], cardsToMove: [pile[cardIndex]] }; } } return {}; } function canMove(card, destPile) { if (!card || !destPile) return false; const destType = (foundations.includes(destPile)) ? 'foundation' : (tableau.includes(destPile)) ? 'tableau' : 'other'; if (destType === 'other') return false; if (destPile.length === 0) { return (destType==='tableau' && card.value==='K') || (destType==='foundation' && card.value==='A'); } const top = destPile[destPile.length - 1]; if (destType === 'tableau') { const opposite = ((['♥','♦'].includes(card.suit) && ['♣','♠'].includes(top.suit)) || (['♣','♠'].includes(card.suit) && ['♥','♦'].includes(top.suit))); return opposite && getValueRank(card.value) === getValueRank(top.value) - 1; } else { return card.suit === top.suit && getValueRank(card.value) === getValueRank(top.value) + 1; } } function clearSelection() { if (selected.card) cardElements[selected.card.id]?.classList.remove('selected'); selected = { card:null, sourcePile:null, cardIndex:-1 }; } function moveSequence(sourcePile, startIndex, destPile) { const seq = sourcePile.splice(startIndex); destPile.push(...seq); } function performMoveCleanup(sourcePile) { if (tableau.includes(sourcePile) && sourcePile.length > 0) { const topCard = sourcePile[sourcePile.length - 1]; if (!topCard.faceUp) { topCard.faceUp = true; score += 5; // +5 points for revealing a card } } clearSelection(); updateBoard(); saveGame(); if (!isAutoCompleting) { checkAndTriggerAutoComplete(); } } function checkWin() { if (foundations.every(p=>p.length===13)) { if (score > highScore) { highScore = score; localStorage.setItem('solitaire-high-score', highScore); updateScoreDisplay(); } winMessage.classList.remove('hidden'); try { confetti({ particleCount: 180, spread: 75, origin:{y:0.35} }); setTimeout(()=>confetti({ particleCount: 140, spread: 65, origin:{y:.2} }), 350); } catch(_) {} } } // ====== ACTIONS ====== function handleStockClick() { if (isAutoCompleting) return; pushHistory(); if (stock.length > 0) { const cardsToMove = stock.splice(stock.length - DRAW_COUNT, DRAW_COUNT).reverse(); cardsToMove.forEach(c => c.faceUp = true); waste.push(...cardsToMove); } else if (waste.length > 0) { score = Math.max(0, score - 20); // Penalty for recycling, but not below 0 stock = waste.reverse(); stock.forEach(c => c.faceUp = false); waste = []; } clearSelection(); updateBoard(); saveGame(); } function handleCardDoubleClick(cardEl) { if (isAutoCompleting) return; const { sourcePile, cardIndex, cardsToMove } = findCardData(cardEl); if (!sourcePile || !cardsToMove || cardsToMove.length === 0) return; const cardToMove = cardsToMove[0]; // Try moving single cards to foundation first if (cardsToMove.length === 1) { for (const f of foundations) { if (canMove(cardToMove, f)) { score += 10; updateScoreDisplay(); pushHistory(); moveSequence(sourcePile, cardIndex, f); performMoveCleanup(sourcePile); return; } } } // Then try moving any valid stack to another tableau for (const t of tableau) { if (t !== sourcePile && canMove(cardToMove, t)) { // --- SCORING LOGIC --- if (sourcePile === waste) score += 5; // FIX: Added +5 for waste to tableau move if (foundations.includes(sourcePile)) score -= 15; updateScoreDisplay(); // --- END SCORING LOGIC --- pushHistory(); moveSequence(sourcePile, cardIndex, t); performMoveCleanup(sourcePile); return; } } } function undo() { if (isAutoCompleting) return; if (history.length <= 1) return; history.pop(); restoreState(history[history.length-1]); clearSelection(); winMessage.classList.add('hidden'); updateBoard(true); saveGame(); } function checkAndTriggerAutoComplete() { if (isAutoCompleting) return; const isWinnable = stock.length === 0 && tableau.flat().every(card => card.faceUp); if (isWinnable) { autoComplete(); } } function autoComplete() { const isWinnable = stock.length === 0 && tableau.flat().every(card => card.faceUp); if (!isWinnable) { alert("You can only auto-complete when all cards are face-up and the stock is empty."); return; } isAutoCompleting = true; const intervalId = setInterval(() => { let movedCard = false; let source = null; // First check waste pile if (waste.length > 0) { const card = waste[waste.length - 1]; for (const f of foundations) { if (canMove(card, f)) { score += 10; source = waste; moveSequence(source, source.length - 1, f); movedCard = true; break; } } } // Then check tableau piles if (!movedCard) { for (const t of tableau) { if (t.length > 0) { const card = t[t.length - 1]; for (const f of foundations) { if (canMove(card, f)) { score += 10; source = t; moveSequence(source, source.length - 1, f); movedCard = true; break; } } } if (movedCard) break; } } if (source) performMoveCleanup(source); if (!movedCard) { clearInterval(intervalId); isAutoCompleting = false; checkWin(); // Final check } }, 120); } function checkForAnyAvailableMove() { const topWasteCard = waste.length > 0 ? waste[waste.length - 1] : null; if (topWasteCard) { for (const f of foundations) if (canMove(topWasteCard, f)) return true; for (const t of tableau) if (canMove(topWasteCard, t)) return true; } for (const sourcePile of tableau) { if (sourcePile.length > 0) { const topCard = sourcePile[sourcePile.length-1]; for(const f of foundations) if(canMove(topCard, f)) return true; const movableStackIndex = sourcePile.findIndex(card => card.faceUp); if (movableStackIndex > 0) { const cardToMove = sourcePile[movableStackIndex]; for (const destPile of tableau) { if (sourcePile !== destPile && canMove(cardToMove, destPile)) return true; } } } } return false; } function handleStuckCheck() { if (checkForAnyAvailableMove()) { alert("Hint: A move is available on the board!"); return; } let tempStock = JSON.parse(JSON.stringify(stock)); let tempWaste = JSON.parse(JSON.stringify(waste)); const originalStockSize = tempStock.length + tempWaste.length; if (originalStockSize === 0) { alert("No more moves available. The game appears to be unwinnable from this state."); return; } let cardsCycled = 0; while (cardsCycled < originalStockSize) { if (tempStock.length === 0) { tempStock = tempWaste.reverse(); tempWaste = []; } const draw = Math.min(tempStock.length, DRAW_COUNT); const drawnCards = tempStock.splice(tempStock.length - draw, draw); tempWaste.push(...drawnCards); cardsCycled += drawnCards.length; const newTopWasteCard = tempWaste.length > 0 ? tempWaste[tempWaste.length - 1] : null; if (newTopWasteCard) { for (const f of foundations) if (canMove(newTopWasteCard, f)) { alert("Hint: A move will become available by drawing from the stock!"); return; } for (const t of tableau) if (canMove(newTopWasteCard, t)) { alert("Hint: A move will become available by drawing from the stock!"); return; } } } alert("No more moves available. The game appears to be unwinnable from this state."); } // ====== INPUT HANDLERS ====== gameBoard.addEventListener('click', e => { if (isAutoCompleting) return; const pileEl = e.target.closest('.pile'); const cardEl = e.target.closest('.card'); if (pileEl && pileEl.id === 'stock' && !cardEl) { handleStockClick(); return; } if (cardEl) { const { sourcePile, cardIndex, card } = findCardData(cardEl); if (!sourcePile || !card) return; const now = Date.now(); if (now - lastClick.time < 300 && lastClick.cardId === card.id) { handleCardDoubleClick(cardEl); clearSelection(); lastClick = { time: 0, cardId: null }; return; } lastClick = { time: now, cardId: card.id }; if (selected.card) { if (canMove(selected.card, sourcePile)) { // --- SCORING LOGIC --- if (foundations.includes(sourcePile)) score += 10; if (tableau.includes(sourcePile) && selected.sourcePile === waste) score += 5; if (tableau.includes(sourcePile) && foundations.includes(selected.sourcePile)) score -= 15; // --- END SCORING LOGIC --- pushHistory(); moveSequence(selected.sourcePile, selected.cardIndex, sourcePile); performMoveCleanup(selected.sourcePile); } else { if (selected.card.id === card.id) { clearSelection(); } else { selected = { card, sourcePile, cardIndex }; } updateBoard(); } } else { selected = { card, sourcePile, cardIndex }; updateBoard(); } return; } if (pileEl && selected.card) { const destPile = getPileArrayFromElement(pileEl); if (destPile && canMove(selected.card, destPile)) { // --- SCORING LOGIC --- if (foundations.includes(destPile)) score += 10; if (tableau.includes(destPile) && selected.sourcePile === waste) score += 5; if (tableau.includes(destPile) && foundations.includes(selected.sourcePile)) score -= 15; // --- END SCORING LOGIC --- pushHistory(); moveSequence(selected.sourcePile, selected.cardIndex, destPile); performMoveCleanup(selected.sourcePile); } else { clearSelection(); updateBoard(); } return; } clearSelection(); updateBoard(); }); gameBoard.addEventListener('dragstart', e => { if (isAutoCompleting) { e.preventDefault(); return; } const cardEl = e.target.closest('.card'); if (!cardEl) { e.preventDefault(); return; } const { sourcePile, cardIndex, cardsToMove } = findCardData(cardEl); if (!sourcePile || !cardsToMove.length) { e.preventDefault(); return; } dragged = { cards: cardsToMove, sourcePile, startIndex: cardIndex }; setTimeout(() => cardsToMove.forEach(c => cardElements[c.id]?.classList.add('dragging')), 0); }); gameBoard.addEventListener('dragover', e => { e.preventDefault(); document.querySelectorAll('.drag-over').forEach(p => p.classList.remove('drag-over')); const targetEl = e.target.closest('.pile, .card'); if (targetEl) targetEl.closest('.pile')?.classList.add('drag-over'); }); gameBoard.addEventListener('dragend', () => { if (dragged.cards?.length) { dragged.cards.forEach(c => cardElements[c.id]?.classList.remove('dragging')); } dragged = { cards: [], sourcePile: null, startIndex: -1 }; document.querySelectorAll('.drag-over').forEach(p => p.classList.remove('drag-over')); }); gameBoard.addEventListener('drop', e => { e.preventDefault(); if (!dragged.cards.length) return; const dropTargetEl = e.target.closest('.card, .pile'); if (!dropTargetEl) return; const destPile = getPileArrayFromElement(dropTargetEl.closest('.pile')); if (destPile && canMove(dragged.cards[0], destPile)) { // --- SCORING LOGIC --- if (foundations.includes(destPile)) score += 10; if (tableau.includes(destPile) && dragged.sourcePile === waste) score += 5; if (tableau.includes(destPile) && foundations.includes(dragged.sourcePile)) score -= 15; // --- END SCORING LOGIC --- pushHistory(); moveSequence(dragged.sourcePile, dragged.startIndex, destPile); performMoveCleanup(dragged.sourcePile); } }); document.addEventListener('keydown', (e)=>{ if (isAutoCompleting) return; if (e.code === 'Space') { e.preventDefault(); handleStockClick(); } if (e.key.toLowerCase() === 'u' || (e.ctrlKey && e.key.toLowerCase() === 'z')) { e.preventDefault(); undo(); } }); // ====== BUTTONS & MODAL LISTENERS ====== restartBtn?.addEventListener('click', initState); undoBtn?.addEventListener('click', undo); drawSelect?.addEventListener('change', () => { DRAW_COUNT = parseInt(drawSelect.value, 10) || 3; initState(); }); openModalBtn?.addEventListener('click', () => cardBackModal.classList.remove('hidden')); closeModalBtn?.addEventListener('click', () => cardBackModal.classList.add('hidden')); modalOverlay?.addEventListener('click', () => cardBackModal.classList.add('hidden')); checkMovesBtn?.addEventListener('click', handleStuckCheck); autoCompleteBtn?.addEventListener('click', autoComplete); // ====== INIT ====== setupThemeSelector(); if (!loadGame()) { initState(); } window.addEventListener('resize', () => updateBoard(true)); });