add animations, mobile responsiveness, and drag-and-drop improvements

This commit is contained in:
chris 2026-05-25 00:35:32 -04:00
parent 65f74bd0c6
commit f28b0fa62e
3 changed files with 364 additions and 46 deletions

View File

@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Klondike Solitaire</title>
<link rel="stylesheet" href="style.css"/>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
</head>
<body>
<header class="topbar">

347
script.js
View File

@ -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)=>{

View File

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