From f28b0fa62e04a1f00fd8168d5f3161af8536f549 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 25 May 2026 00:35:32 -0400 Subject: [PATCH] add animations, mobile responsiveness, and drag-and-drop improvements --- index.html | 1 - script.js | 347 +++++++++++++++++++++++++++++++++++++++++++++++------ style.css | 62 ++++++++-- 3 files changed, 364 insertions(+), 46 deletions(-) diff --git a/index.html b/index.html index 1713128..d9ca9ef 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,6 @@ Klondike Solitaire -
diff --git a/script.js b/script.js index 97b145f..3cafe7d 100644 --- a/script.js +++ b/script.js @@ -10,11 +10,12 @@ document.addEventListener('DOMContentLoaded', () => { let deck, stock, waste, foundations, tableau; let cardElements = {}; - let dragged = { cards: [], sourcePile: null, startIndex: -1 }; - // 'selected' state is no longer needed for click controls and has been removed. + 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; @@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', () => { 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 = {}; @@ -102,10 +104,12 @@ document.addEventListener('DOMContentLoaded', () => { // ====== SETUP ====== function initState() { score = 0; - highScore = localStorage.getItem('solitaire-high-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 = []; @@ -122,16 +126,54 @@ document.addEventListener('DOMContentLoaded', () => { 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++) tableau[j].push(deck.pop()); + for (let j=i; j<7; j++) { + const card = deck.pop(); + tableau[j].push(card); + tableauCards.push({ card, pileIndex: j }); + } } - tableau.forEach(pile => { if (pile.length) pile[pile.length-1].faceUp = true; }); stock = deck; + winMessage.classList.add('hidden'); history.length = 0; pushHistory(); - updateBoard(true); + + // 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(); } @@ -148,6 +190,14 @@ document.addEventListener('DOMContentLoaded', () => { 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; @@ -164,7 +214,6 @@ document.addEventListener('DOMContentLoaded', () => { cardEl.classList.remove('is-flipped'); cardEl.draggable = false; } - // The 'selected' class is no longer applied via click. const zBase = type === 'tableau' ? 100 : type === 'foundation' ? 400 : 600; cardEl.style.zIndex = zBase + i; }); @@ -181,6 +230,14 @@ document.addEventListener('DOMContentLoaded', () => { 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; @@ -266,30 +323,61 @@ document.addEventListener('DOMContentLoaded', () => { 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; - 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]] }; + + // 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) { - if ((cardIndex = findIn(pile)) > -1 && pile[cardIndex].faceUp) { + 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 ((cardIndex = findIn(pile)) > -1 && cardIndex === pile.length - 1) { - return { sourcePile: pile, cardIndex, card: pile[cardIndex], cardsToMove: [pile[cardIndex]] }; + 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 {}; // 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') return false; + 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'); } @@ -297,13 +385,12 @@ document.addEventListener('DOMContentLoaded', () => { 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 { + } else if (destType === 'foundation') { return card.suit === top.suit && getValueRank(card.value) === getValueRank(top.value) + 1; } + return false; } - // The `clearSelection` function is no longer needed. - function moveSequence(sourcePile, startIndex, destPile) { const seq = sourcePile.splice(startIndex); destPile.push(...seq); @@ -330,19 +417,92 @@ document.addEventListener('DOMContentLoaded', () => { 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(_) {} + 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 cardsToMove = stock.splice(stock.length - DRAW_COUNT, DRAW_COUNT).reverse(); + 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) { @@ -355,10 +515,6 @@ document.addEventListener('DOMContentLoaded', () => { saveGame(); } - /** - * This new function handles the single-click auto-move logic. - * It finds the best valid move for a card and executes it. - */ function performAutoMove(cardEl) { if (isAutoCompleting) return; const { sourcePile, cardIndex, cardsToMove } = findCardData(cardEl); @@ -374,7 +530,7 @@ document.addEventListener('DOMContentLoaded', () => { pushHistory(); moveSequence(sourcePile, cardIndex, f); performMoveCleanup(sourcePile); - return; // Move executed, action is complete. + return; } } } @@ -388,7 +544,7 @@ document.addEventListener('DOMContentLoaded', () => { pushHistory(); moveSequence(sourcePile, cardIndex, t); performMoveCleanup(sourcePile); - return; // Move executed, action is complete. + return; } } } @@ -422,7 +578,7 @@ document.addEventListener('DOMContentLoaded', () => { return; } isAutoCompleting = true; - const intervalId = setInterval(() => { + autoCompleteInterval = setInterval(() => { let movedCard = false; let source = null; @@ -460,7 +616,8 @@ document.addEventListener('DOMContentLoaded', () => { if (source) performMoveCleanup(source); if (!movedCard) { - clearInterval(intervalId); + clearInterval(autoCompleteInterval); + autoCompleteInterval = null; isAutoCompleting = false; checkWin(); } @@ -479,7 +636,7 @@ document.addEventListener('DOMContentLoaded', () => { for(const f of foundations) if(canMove(topCard, f)) return true; const movableStackIndex = sourcePile.findIndex(card => card.faceUp); - if (movableStackIndex > -1) { // Fixed: check should be > -1 + if (movableStackIndex > -1) { const cardToMove = sourcePile[movableStackIndex]; for (const destPile of tableau) { if (sourcePile !== destPile && canMove(cardToMove, destPile)) return true; @@ -562,31 +719,147 @@ document.addEventListener('DOMContentLoaded', () => { // 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; } - dragged = { cards: cardsToMove, sourcePile, startIndex: cardIndex }; - setTimeout(() => cardsToMove.forEach(c => cardElements[c.id]?.classList.add('dragging')), 0); + + 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('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('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; - const destPile = getPileArrayFromElement(dropTargetEl.closest('.pile')); + + 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)=>{ diff --git a/style.css b/style.css index d3e1535..d9888ef 100644 --- a/style.css +++ b/style.css @@ -174,9 +174,10 @@ h1 { position: absolute; user-select: none; touch-action: none; - transition: top .18s ease, left .18s ease, transform .18s ease, filter .2s ease; + transition: top .25s cubic-bezier(0.2, 0.8, 0.2, 1), left .25s cubic-bezier(0.2, 0.8, 0.2, 1), transform .25s cubic-bezier(0.2, 0.8, 0.2, 1), filter .2s ease; will-change: top, left, transform; filter: drop-shadow(0 2px 5px var(--shadow-color)); + font-size: calc(var(--card-width) * 0.28); /* Scalable base font size */ } .card.is-stacked { filter: none; } .card-inner { @@ -196,7 +197,7 @@ h1 { .card-face--front { background: linear-gradient(160deg, #ffffff, #f4f4f4); transform: rotateY(180deg); - padding: 8px; + padding: calc(var(--card-width) * 0.1); cursor: grab; border: 1px solid #d0d0d0; } @@ -206,8 +207,8 @@ h1 { .card-value-display--top { position: absolute; - top: 8px; - left: 8px; + top: calc(var(--card-width) * 0.1); + left: calc(var(--card-width) * 0.1); display: flex; flex-direction: column; align-items: center; @@ -215,8 +216,8 @@ h1 { } .card-value-display--bottom { position: absolute; - bottom: 8px; - right: 8px; + bottom: calc(var(--card-width) * 0.1); + right: calc(var(--card-width) * 0.1); display: flex; flex-direction: column; align-items: center; @@ -418,8 +419,29 @@ body.card-back-fade .card .card-face--back, /* For Mobile Screens (anything below 600px wide) */ @media (max-width: 600px) { - .foundations { - gap: 5px; + :root { + /* Dynamically size cards so 7 columns fit perfectly within the 96vw container */ + --gap: 1.5vw; + --card-width: calc((96vw - 20px - (6 * var(--gap))) / 7); + } + .topbar { + padding: 10px 8px; + gap: 8px; + } + .topbar h1 { + font-size: 20px; + } + .btn { + padding: 6px 10px; + font-size: 13px; + } + .draw-toggle { + padding: 4px 8px; + font-size: 13px; + } + .scores { + font-size: 14px; + gap: 12px; } } @@ -435,4 +457,28 @@ body.card-back-fade .card .card-face--back, :root { --card-width: max(7.5vmin, 90px); } +} + + + +/* Add this to your style.css */ +.card-ghost-stack { + position: fixed; + pointer-events: none; + z-index: 2000; +} + +.card-ghost-stack .card { + position: absolute !important; + box-shadow: 0 12px 30px rgba(0,0,0,0.4); + transition: none !important; +} + +/* Ensure ghost cards use the current theme's back */ +.card-ghost-stack .card-face--back { + border-color: inherit; +} + +.card.dragging { + visibility: hidden; } \ No newline at end of file