891 lines
36 KiB
JavaScript
891 lines
36 KiB
JavaScript
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, ghostEl: null, offsetX: 0, offsetY: 0 };
|
|
const history = [];
|
|
|
|
let isAutoCompleting = false;
|
|
let autoCompleteInterval = null;
|
|
let isDealing = 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() {
|
|
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;
|
|
highScore = parseInt(localStorage.getItem('solitaire-high-score'), 10) || 0;
|
|
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 });
|
|
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;
|
|
};
|
|
const pushHistory = () => {
|
|
if (history.length > 50) history.shift();
|
|
history.push(snapshotState());
|
|
};
|
|
|
|
// ====== SETUP ======
|
|
function initState() {
|
|
score = 0;
|
|
highScore = parseInt(localStorage.getItem('solitaire-high-score'), 10) || 0;
|
|
updateScoreDisplay();
|
|
|
|
isAutoCompleting = false;
|
|
if (autoCompleteInterval) clearInterval(autoCompleteInterval);
|
|
|
|
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);
|
|
// Initially position cards at the stock location to animate out from there
|
|
const stockEl = document.getElementById('stock');
|
|
if (stockEl) {
|
|
const rect = stockEl.getBoundingClientRect();
|
|
const boardRect = gameBoard.getBoundingClientRect();
|
|
el.style.top = `${rect.top - boardRect.top + PILE_BORDER_WIDTH}px`;
|
|
el.style.left = `${rect.left - boardRect.left + PILE_BORDER_WIDTH}px`;
|
|
}
|
|
});
|
|
|
|
// Separate cards for tableau and stock
|
|
const tableauCards = [];
|
|
for (let i=0; i<7; i++){
|
|
for (let j=i; j<7; j++) {
|
|
const card = deck.pop();
|
|
tableau[j].push(card);
|
|
tableauCards.push({ card, pileIndex: j });
|
|
}
|
|
}
|
|
stock = deck;
|
|
|
|
winMessage.classList.add('hidden');
|
|
history.length = 0;
|
|
pushHistory();
|
|
|
|
// Start dealing animation
|
|
dealTableauSequentially(tableauCards);
|
|
}
|
|
|
|
async function dealTableauSequentially(cardsToDeal) {
|
|
isDealing = true;
|
|
// Turn off interactivity during deal
|
|
gameBoard.style.pointerEvents = 'none';
|
|
|
|
for (let i = 0; i < cardsToDeal.length; i++) {
|
|
const { card, pileIndex } = cardsToDeal[i];
|
|
// Only flip the last card of each pile
|
|
const pile = tableau[pileIndex];
|
|
if (card === pile[pile.length - 1]) {
|
|
card.faceUp = true;
|
|
}
|
|
updateBoard(true);
|
|
await new Promise(r => setTimeout(r, 60));
|
|
}
|
|
|
|
isDealing = false;
|
|
gameBoard.style.pointerEvents = 'auto';
|
|
updateBoard();
|
|
saveGame();
|
|
}
|
|
|
|
// ====== RENDER ======
|
|
function updateBoard(initial=false) {
|
|
updateScoreDisplay();
|
|
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;
|
|
// Don't update position for cards being dragged (ghost)
|
|
if (dragged.ghostEl && dragged.cards.some(c => c.id === cardData.id)) {
|
|
cardEl.style.visibility = 'hidden'; // Hide the original card
|
|
return;
|
|
} else {
|
|
cardEl.style.visibility = 'visible';
|
|
}
|
|
|
|
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;
|
|
}
|
|
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;
|
|
|
|
if (dragged.ghostEl && dragged.cards.some(c => c.id === cardData.id)) {
|
|
cardEl.style.visibility = 'hidden';
|
|
return;
|
|
} else {
|
|
cardEl.style.visibility = 'visible';
|
|
}
|
|
|
|
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 = `
|
|
<div class="card-value-display--top"><span class="card-rank">${cardData.value}</span><span class="card-suit">${cardData.suit}</span></div>
|
|
<div class="card-value-display--bottom"><span class="card-rank">${cardData.value}</span><span class="card-suit">${cardData.suit}</span></div>
|
|
<div class="card-watermark">${cardData.suit}</div>`;
|
|
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;
|
|
}
|
|
|
|
// Helper to get the actual pile that a card belongs to
|
|
function getPileContainingCard(cardId) {
|
|
const checkPile = (pile) => pile.find(c => c.id === cardId);
|
|
|
|
if (checkPile(waste)) return waste;
|
|
for (const pile of tableau) {
|
|
if (checkPile(pile)) return pile;
|
|
}
|
|
for (const pile of foundations) {
|
|
if (checkPile(pile)) return pile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findCardData(cardEl) {
|
|
const id = cardEl.dataset.id;
|
|
let cardIndex;
|
|
|
|
// Check waste (only top card can be moved)
|
|
if (waste.length > 0 && waste[waste.length - 1].id === id) {
|
|
return { sourcePile: waste, cardIndex: waste.length - 1, card: waste[waste.length - 1], cardsToMove: [waste[waste.length - 1]] };
|
|
}
|
|
|
|
// Check tableau piles (any face-up card and cards above it can be moved)
|
|
for (const pile of tableau) {
|
|
cardIndex = pile.findIndex(c => c.id === id);
|
|
if (cardIndex > -1 && pile[cardIndex].faceUp) {
|
|
const cardsToMove = pile.slice(cardIndex);
|
|
return { sourcePile: pile, cardIndex, card: cardsToMove[0], cardsToMove };
|
|
}
|
|
}
|
|
// Check foundations (only top card can be moved)
|
|
for (const pile of foundations) {
|
|
if (pile.length > 0 && pile[pile.length - 1].id === id) {
|
|
return { sourcePile: pile, cardIndex: pile.length - 1, card: pile[pile.length - 1], cardsToMove: [pile[pile.length - 1]] };
|
|
}
|
|
}
|
|
return {}; // Return empty object if card not found or not movable
|
|
}
|
|
|
|
function canMove(card, destPile) {
|
|
if (!card || !destPile) return false;
|
|
|
|
// Prevent moving to the stock pile
|
|
if (destPile === stock) return false;
|
|
|
|
const destType = (foundations.includes(destPile)) ? 'foundation' : (tableau.includes(destPile)) ? 'tableau' : 'other';
|
|
if (destType === 'other' && destPile !== waste) return false; // Only allow tableau, foundation, or waste as actual destinations
|
|
|
|
// Waste pile can only receive cards if it's not the source for the current drag (handled by dragged.sourcePile check)
|
|
// Also, waste doesn't typically accept cards from other piles in Klondike.
|
|
// We ensure we don't try to drop on the waste pile itself unless it's for internal logic like recycling stock.
|
|
if (destPile === waste) return false; // Generally, cards don't move TO waste.
|
|
|
|
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 if (destType === 'foundation') {
|
|
return card.suit === top.suit && getValueRank(card.value) === getValueRank(top.value) + 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
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');
|
|
triggerBouncingCards();
|
|
}
|
|
}
|
|
|
|
function triggerBouncingCards() {
|
|
const boardRect = gameBoard.getBoundingClientRect();
|
|
const cardsToBounce = [];
|
|
|
|
// Gather all top cards from foundations to start the bounce sequence
|
|
// We'll process them one suit at a time for the classic effect
|
|
const animationFoundations = foundations.map(f => [...f]); // Clone
|
|
|
|
function bounceNextCard() {
|
|
let cardFound = false;
|
|
for (let i = 0; i < 4; i++) {
|
|
if (animationFoundations[i].length > 0) {
|
|
const cardData = animationFoundations[i].pop();
|
|
const cardEl = cardElements[cardData.id];
|
|
if (cardEl) {
|
|
startCardPhysics(cardEl);
|
|
cardFound = true;
|
|
break; // One card per trigger
|
|
}
|
|
}
|
|
}
|
|
if (cardFound) {
|
|
setTimeout(bounceNextCard, 180);
|
|
}
|
|
}
|
|
|
|
function startCardPhysics(el) {
|
|
const rect = el.getBoundingClientRect();
|
|
let x = rect.left;
|
|
let y = rect.top;
|
|
let vx = (Math.random() * 4 + 2) * (Math.random() > 0.5 ? 1 : -1);
|
|
let vy = Math.random() * -5 - 2;
|
|
const gravity = 0.5;
|
|
const friction = 0.75;
|
|
|
|
// Remove transition for physics
|
|
el.style.transition = 'none';
|
|
el.style.position = 'fixed';
|
|
el.style.zIndex = 3000;
|
|
el.style.pointerEvents = 'none';
|
|
|
|
function step() {
|
|
x += vx;
|
|
y += vy;
|
|
vy += gravity;
|
|
|
|
if (y + rect.height > window.innerHeight) {
|
|
y = window.innerHeight - rect.height;
|
|
vy *= -friction;
|
|
if (Math.abs(vy) < 2) vy = 0; // Stop vertical bounce if too small
|
|
}
|
|
|
|
if (x + rect.width < 0 || x > window.innerWidth) {
|
|
el.remove();
|
|
return; // Card off screen
|
|
}
|
|
|
|
el.style.left = `${x}px`;
|
|
el.style.top = `${y}px`;
|
|
|
|
if (Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1 || y < window.innerHeight - rect.height) {
|
|
requestAnimationFrame(step);
|
|
} else {
|
|
// Small slide before stopping
|
|
vx *= 0.95;
|
|
if (Math.abs(vx) > 0.1) requestAnimationFrame(step);
|
|
else el.remove();
|
|
}
|
|
}
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
bounceNextCard();
|
|
}
|
|
|
|
// ====== ACTIONS ======
|
|
function handleStockClick() {
|
|
if (isAutoCompleting) return;
|
|
pushHistory();
|
|
if (stock.length > 0) {
|
|
const drawCount = Math.min(stock.length, DRAW_COUNT);
|
|
const cardsToMove = stock.splice(stock.length - drawCount, drawCount).reverse();
|
|
cardsToMove.forEach(c => c.faceUp = true);
|
|
waste.push(...cardsToMove);
|
|
} else if (waste.length > 0) {
|
|
score = Math.max(0, score - 20);
|
|
stock = waste.reverse();
|
|
stock.forEach(c => c.faceUp = false);
|
|
waste = [];
|
|
}
|
|
updateBoard();
|
|
saveGame();
|
|
}
|
|
|
|
function performAutoMove(cardEl) {
|
|
if (isAutoCompleting) return;
|
|
const { sourcePile, cardIndex, cardsToMove } = findCardData(cardEl);
|
|
|
|
if (!sourcePile || !cardsToMove || cardsToMove.length === 0) return;
|
|
const cardToMove = cardsToMove[0];
|
|
|
|
// Priority 1: Try to move a single card to a foundation.
|
|
if (cardsToMove.length === 1) {
|
|
for (const f of foundations) {
|
|
if (canMove(cardToMove, f)) {
|
|
score += 10;
|
|
pushHistory();
|
|
moveSequence(sourcePile, cardIndex, f);
|
|
performMoveCleanup(sourcePile);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 2: Try to move a stack to another tableau pile.
|
|
for (const t of tableau) {
|
|
if (t !== sourcePile && canMove(cardToMove, t)) {
|
|
if (sourcePile === waste) score += 5;
|
|
if (foundations.includes(sourcePile)) score -= 15;
|
|
|
|
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]);
|
|
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) {
|
|
Swal.fire({
|
|
title: 'Cannot Auto-Complete',
|
|
text: 'You can only auto-complete when all cards are face-up and the stock is empty.',
|
|
icon: 'info'
|
|
});
|
|
return;
|
|
}
|
|
isAutoCompleting = true;
|
|
autoCompleteInterval = setInterval(() => {
|
|
let movedCard = false;
|
|
let source = null;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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(autoCompleteInterval);
|
|
autoCompleteInterval = null;
|
|
isAutoCompleting = false;
|
|
checkWin();
|
|
}
|
|
}, 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 > -1) {
|
|
const cardToMove = sourcePile[movableStackIndex];
|
|
for (const destPile of tableau) {
|
|
if (sourcePile !== destPile && canMove(cardToMove, destPile)) return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function handleStuckCheck() {
|
|
if (checkForAnyAvailableMove()) {
|
|
Swal.fire({
|
|
title: 'Hint!',
|
|
text: 'A move is available on the board!',
|
|
icon: 'info'
|
|
});
|
|
return;
|
|
}
|
|
let tempStock = JSON.parse(JSON.stringify(stock));
|
|
let tempWaste = JSON.parse(JSON.stringify(waste));
|
|
const originalStockSize = tempStock.length + tempWaste.length;
|
|
if (originalStockSize === 0) {
|
|
Swal.fire({
|
|
title: 'No Moves Found',
|
|
text: 'The game appears to be unwinnable from this state.',
|
|
icon: 'warning'
|
|
});
|
|
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) {
|
|
const hintText = "A move will become available by drawing from the stock!";
|
|
for (const f of foundations) if (canMove(newTopWasteCard, f)) {
|
|
Swal.fire({ title: 'Hint!', text: hintText, icon: 'info' });
|
|
return;
|
|
}
|
|
for (const t of tableau) if (canMove(newTopWasteCard, t)) {
|
|
Swal.fire({ title: 'Hint!', text: hintText, icon: 'info' });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Swal.fire({
|
|
title: 'No Moves Found',
|
|
text: 'The game appears to be unwinnable from this state.',
|
|
icon: 'warning'
|
|
});
|
|
}
|
|
|
|
// ====== INPUT HANDLERS ======
|
|
gameBoard.addEventListener('click', e => {
|
|
if (isAutoCompleting) return;
|
|
|
|
const clickedCardEl = e.target.closest('.card');
|
|
const clickedPileEl = e.target.closest('.pile');
|
|
|
|
// If the stock pile background was clicked, draw cards.
|
|
if (clickedPileEl && clickedPileEl.id === 'stock' && !clickedCardEl) {
|
|
handleStockClick();
|
|
return;
|
|
}
|
|
|
|
// If a card was clicked, attempt to auto-move it.
|
|
if (clickedCardEl) {
|
|
performAutoMove(clickedCardEl);
|
|
return;
|
|
}
|
|
|
|
// Clicks on anything else (background, empty piles, etc.) will do nothing.
|
|
});
|
|
|
|
// Custom drag and drop functionality
|
|
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; }
|
|
|
|
const rect = cardEl.getBoundingClientRect();
|
|
dragged.offsetX = e.clientX - rect.left;
|
|
dragged.offsetY = e.clientY - rect.top;
|
|
dragged.cards = cardsToMove;
|
|
dragged.sourcePile = sourcePile;
|
|
dragged.startIndex = cardIndex;
|
|
|
|
// Create a "ghost" element for visual feedback
|
|
dragged.ghostEl = document.createElement('div');
|
|
dragged.ghostEl.classList.add('card-ghost-stack');
|
|
// Add classes for card back if applicable
|
|
const currentBackClass = document.body.className.match(/card-back-\w+/);
|
|
if (currentBackClass) {
|
|
dragged.ghostEl.classList.add(currentBackClass[0]);
|
|
}
|
|
|
|
cardsToMove.forEach((cardData, i) => {
|
|
const el = cardElements[cardData.id].cloneNode(true);
|
|
el.style.position = 'absolute'; // Position relative to ghostEl
|
|
el.style.top = `${i * SPACING_FACE_UP}px`;
|
|
el.style.left = '0';
|
|
el.style.zIndex = i + 1; // Stack visually
|
|
el.classList.add('is-flipped'); // Ensure ghost cards are flipped
|
|
el.style.visibility = 'visible';
|
|
dragged.ghostEl.appendChild(el);
|
|
});
|
|
|
|
dragged.ghostEl.style.position = 'fixed';
|
|
dragged.ghostEl.style.pointerEvents = 'none'; // Ensure it doesn't interfere with drop targets
|
|
dragged.ghostEl.style.zIndex = 2000; // Above everything else
|
|
dragged.ghostEl.style.width = cardEl.offsetWidth + 'px'; // Match card width
|
|
dragged.ghostEl.style.height = (cardEl.offsetHeight + (cardsToMove.length - 1) * SPACING_FACE_UP) + 'px';
|
|
|
|
document.body.appendChild(dragged.ghostEl);
|
|
|
|
// Hide the original cards visually while dragging
|
|
cardsToMove.forEach(c => cardElements[c.id].style.visibility = 'hidden');
|
|
|
|
// Set custom drag image (a tiny transparent image) to prevent default ghost
|
|
const img = new Image();
|
|
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
e.dataTransfer.setDragImage(img, 0, 0);
|
|
|
|
// Initial position for the ghost
|
|
dragged.ghostEl.style.left = `${e.clientX - dragged.offsetX}px`;
|
|
dragged.ghostEl.style.top = `${e.clientY - dragged.offsetY}px`;
|
|
});
|
|
|
|
gameBoard.addEventListener('drag', e => {
|
|
if (dragged.ghostEl && e.clientX !== 0) {
|
|
// Update ghost element position to follow the cursor
|
|
dragged.ghostEl.style.left = `${e.clientX - dragged.offsetX}px`;
|
|
dragged.ghostEl.style.top = `${e.clientY - dragged.offsetY}px`;
|
|
}
|
|
});
|
|
|
|
gameBoard.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
|
|
const targetEl = e.target.closest('.card, .pile');
|
|
let potentialDestPileEl = null;
|
|
|
|
if (targetEl) {
|
|
if (targetEl.classList.contains('card')) {
|
|
potentialDestPileEl = targetEl.closest('.pile');
|
|
} else if (targetEl.classList.contains('pile')) {
|
|
potentialDestPileEl = targetEl;
|
|
}
|
|
}
|
|
|
|
const currentlyHighlighted = document.querySelector('.pile.drag-over');
|
|
if (currentlyHighlighted && currentlyHighlighted !== potentialDestPileEl) {
|
|
currentlyHighlighted.classList.remove('drag-over');
|
|
}
|
|
|
|
if (potentialDestPileEl && dragged.cards.length > 0) {
|
|
const destPileData = getPileArrayFromElement(potentialDestPileEl);
|
|
if (canMove(dragged.cards[0], destPileData)) {
|
|
potentialDestPileEl.classList.add('drag-over');
|
|
}
|
|
}
|
|
});
|
|
|
|
gameBoard.addEventListener('dragend', () => {
|
|
// Clean up ghost element and reset state
|
|
if (dragged.ghostEl) {
|
|
dragged.ghostEl.remove();
|
|
dragged.ghostEl = null;
|
|
}
|
|
// Make original cards visible again (updateBoard will re-position them)
|
|
dragged.cards.forEach(c => cardElements[c.id].style.visibility = 'visible');
|
|
dragged = { cards: [], sourcePile: null, startIndex: -1, ghostEl: null };
|
|
document.querySelectorAll('.drag-over').forEach(p => p.classList.remove('drag-over'));
|
|
updateBoard(); // Re-render to ensure everything is correct
|
|
});
|
|
|
|
gameBoard.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
document.querySelectorAll('.drag-over').forEach(p => p.classList.remove('drag-over'));
|
|
|
|
if (!dragged.cards.length) return;
|
|
|
|
const dropTargetEl = e.target.closest('.card, .pile');
|
|
if (!dropTargetEl) return;
|
|
|
|
let destPile = null;
|
|
if (dropTargetEl.classList.contains('card')) {
|
|
// If dropped on a card, find the pile that card belongs to
|
|
const cardId = dropTargetEl.dataset.id;
|
|
const targetCardPile = getPileContainingCard(cardId);
|
|
if (targetCardPile) {
|
|
// We need to ensure it's the TOP card of the pile to drop ONTO it effectively
|
|
if (targetCardPile[targetCardPile.length - 1].id === cardId) {
|
|
destPile = targetCardPile;
|
|
}
|
|
}
|
|
} else if (dropTargetEl.classList.contains('pile')) {
|
|
// If dropped directly on a pile (e.g., an empty tableau slot)
|
|
destPile = getPileArrayFromElement(dropTargetEl);
|
|
}
|
|
|
|
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);
|
|
}
|
|
// dragend will handle cleanup regardless of success
|
|
});
|
|
|
|
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));
|
|
}); |