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));
});