From 0ebe87a4110391553eae51cc89cd955fac28c945 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 13 May 2026 10:27:05 -0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20browser=20game=20UX=20pass=20?= =?UTF-8?q?=E2=80=94=20shake=20feedback,=20timer,=20stock=20count,=20HUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game.js fully rewritten: correct coordinate system (PAD baked into PILE_ORIGIN), undo driven by undo_stack_len, flashIllegal shake with --card-tx CSS variable, game timer, stock count HUD, URL seed persist, foundation suit hints, auto-complete step loop - game.html: adds hud-timer, hud-stock, win-time elements - game.css: @keyframes illegal-shake, .slot-hint, overflow-x on main - solitaire_wasm: adds undo_stack_len to GameSnapshot Co-Authored-By: Claude Sonnet 4.6 --- solitaire_server/web/game.css | 28 ++ solitaire_server/web/game.html | 3 + solitaire_server/web/game.js | 409 ++++++++---------- .../web/pkg/solitaire_wasm_bg.wasm | Bin 194966 -> 195136 bytes solitaire_wasm/src/lib.rs | 3 + 5 files changed, 225 insertions(+), 218 deletions(-) diff --git a/solitaire_server/web/game.css b/solitaire_server/web/game.css index 964065b..ec5afe4 100644 --- a/solitaire_server/web/game.css +++ b/solitaire_server/web/game.css @@ -83,6 +83,7 @@ main { justify-content: center; align-items: flex-start; padding: 20px; + overflow-x: auto; } #board { @@ -237,3 +238,30 @@ main { padding: 12px 32px; font-size: 16px; } + +/* ── Illegal-move shake ──────────────────────────────────────────────────── */ + +@keyframes illegal-shake { + 0% { transform: var(--card-tx) translateX(0); } + 20% { transform: var(--card-tx) translateX(-6px); } + 40% { transform: var(--card-tx) translateX(6px); } + 60% { transform: var(--card-tx) translateX(-4px); } + 80% { transform: var(--card-tx) translateX(4px); } + 100% { transform: var(--card-tx) translateX(0); } +} + +.card.illegal { + animation: illegal-shake 320ms ease; +} + +/* ── Foundation slot suit hints ──────────────────────────────────────────── */ + +.slot-hint { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + font-size: 26px; + color: rgba(255,255,255,0.18); + pointer-events: none; +} + diff --git a/solitaire_server/web/game.html b/solitaire_server/web/game.html index 1914594..f66ea14 100644 --- a/solitaire_server/web/game.html +++ b/solitaire_server/web/game.html @@ -15,6 +15,8 @@
Score: 0 Moves: 0 + 0:00 + Stock: 24
@@ -34,6 +36,7 @@
You Won!
+
diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js index b82f9c7..6f0cc48 100644 --- a/solitaire_server/web/game.js +++ b/solitaire_server/web/game.js @@ -12,22 +12,20 @@ import init, { SolitaireGame } from "/web/pkg/solitaire_wasm.js"; -// ── Layout constants (must match game.css --card-w / --card-h / --gap) ────── -const CARD_W = 80; -const CARD_H = 112; -const GAP = 12; -const PAD = 20; // board padding -const FAN = 28; // vertical offset per fanned tableau card -const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan +// ── Layout constants (must match game.css --card-w / --card-h / --gap / --pad) +const CARD_W = 80; +const CARD_H = 112; +const GAP = 12; +const PAD = 20; // board inner padding — cards start at (PAD, PAD) +const FAN = 28; // vertical offset per fanned tableau card +const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan -// Top-row Y origin (relative to board interior = after padding). -const TOP_Y = 0; -const BOTTOM_Y = CARD_H + 28; // tableau row +// Pile origins in board-element coordinates (include PAD so (0,0) = board edge). +const TOP_Y = PAD; +const BOTTOM_Y = PAD + CARD_H + 28; -const colX = (c) => c * (CARD_W + GAP); +const colX = (c) => PAD + c * (CARD_W + GAP); -// Absolute position of each pile's origin (top-left of its slot), -// relative to the board's padded interior (0, 0). const PILE_ORIGIN = { stock: { x: colX(0), y: TOP_Y }, waste: { x: colX(1), y: TOP_Y }, @@ -44,35 +42,43 @@ const PILE_ORIGIN = { "tableau-6": { x: colX(6), y: BOTTOM_Y }, }; +// Foundation suit hints shown when the slot is empty. +const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"]; + const SUIT_GLYPH = { clubs: "♣", diamonds: "♦", hearts: "♥", spades: "♠" }; const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"]; const RED_SUITS = new Set(["diamonds", "hearts"]); // ── State ──────────────────────────────────────────────────────────────────── -let game = null; -let snap = null; // last rendered GameSnapshot +let game = null; +let snap = null; // last rendered GameSnapshot let drawThree = false; -// Persistent card → DOM element map (keyed by card.id). +// Persistent card-id → DOM element map. const cardEls = new Map(); // Drag state let drag = null; // drag = { // fromPile: string, -// fromIndex: number, // index of bottom card of the dragged run in its pile -// cardIds: number[], // ids of cards being dragged (bottom → top) -// startX: number, startY: number, // pointer start (board-relative) -// offsetX: number, offsetY: number, // cursor offset within the grabbed card +// fromIndex: number, // index of bottom dragged card in its pile +// cardIds: number[], // ids bottom→top +// startX: number, startY: number, // board-relative pointer start // } -// Auto-complete timer handle +// Timer +let timerInterval = null; +let elapsedSecs = 0; + +// Auto-complete let acTimer = null; // ── DOM refs ───────────────────────────────────────────────────────────────── const board = document.getElementById("board"); const hudScore = document.getElementById("hud-score"); const hudMoves = document.getElementById("hud-moves"); +const hudTimer = document.getElementById("hud-timer"); +const hudStock = document.getElementById("hud-stock"); const hudSeed = document.getElementById("hud-seed"); const btnUndo = document.getElementById("btn-undo"); const btnNew = document.getElementById("btn-new"); @@ -80,16 +86,16 @@ const chkDraw3 = document.getElementById("chk-draw3"); const winOverlay = document.getElementById("win-overlay"); const winScore = document.getElementById("win-score"); const winMoves = document.getElementById("win-moves"); +const winTime = document.getElementById("win-time"); const btnWinNew = document.getElementById("btn-win-new"); // ── Bootstrap ──────────────────────────────────────────────────────────────── async function bootstrap() { await init(); - // Seed from URL param ?seed=N, otherwise random. - const params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(window.location.search); const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed(); - drawThree = params.has("draw3"); + drawThree = params.has("draw3"); chkDraw3.checked = drawThree; buildSlots(); @@ -98,107 +104,135 @@ async function bootstrap() { } function randomSeed() { - // Math.random gives a float in [0,1); multiply to get a large integer. return Math.floor(Math.random() * 9007199254740991); } function startGame(seed) { - if (acTimer) { clearInterval(acTimer); acTimer = null; } + if (acTimer) { clearInterval(acTimer); acTimer = null; } + if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } + elapsedSecs = 0; + updateTimerDisplay(); + game = new SolitaireGame(seed, drawThree); snap = game.state(); - hudSeed.textContent = `seed ${Math.round(game.seed())}`; + + const displaySeed = Math.round(game.seed()); + hudSeed.textContent = `seed ${displaySeed}`; winOverlay.classList.add("hidden"); cardEls.clear(); - board.querySelectorAll(".card").forEach(el => el.remove()); + board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove()); + + // Persist seed in URL so the game can be shared / refreshed. + const url = new URL(window.location); + url.searchParams.set("seed", displaySeed); + if (drawThree) url.searchParams.set("draw3", ""); + else url.searchParams.delete("draw3"); + history.replaceState(null, "", url); + render(snap); + startTimer(); } -// ── Slot placeholders ──────────────────────────────────────────────────────── +// ── Timer ──────────────────────────────────────────────────────────────────── +function startTimer() { + timerInterval = setInterval(() => { + elapsedSecs++; + updateTimerDisplay(); + }, 1000); +} + +function stopTimer() { + if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } +} + +function updateTimerDisplay() { + const m = Math.floor(elapsedSecs / 60); + const s = elapsedSecs % 60; + if (hudTimer) hudTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`; +} + +// ── Slot placeholders ───────────────────────────────────────────────────────── function buildSlots() { for (const [pile, origin] of Object.entries(PILE_ORIGIN)) { const el = document.createElement("div"); el.className = "slot"; el.dataset.pile = pile; el.style.transform = `translate(${origin.x}px, ${origin.y}px)`; + + if (pile.startsWith("foundation-")) { + const slot = parseInt(pile.split("-")[1]); + const hint = document.createElement("div"); + hint.className = "slot-hint"; + hint.textContent = FOUND_SUIT_HINT[slot]; + el.appendChild(hint); + } board.appendChild(el); } } // ── Card position math ──────────────────────────────────────────────────────── -function cardPos(pileName, indexInPile, pileLength, pileCards) { +function cardPos(pileName, indexInPile, pileLength) { const origin = PILE_ORIGIN[pileName]; let x = origin.x; let y = origin.y; if (pileName === "waste" && drawThree && pileLength >= 2) { - // Show top-3 of waste fanned horizontally. const fanStart = Math.max(0, pileLength - 3); const fanPos = indexInPile - fanStart; - if (fanPos >= 0) { - x += fanPos * WASTE_FAN; - } else { - // Cards below the fan window are stacked at origin. - } + if (fanPos > 0) x += fanPos * WASTE_FAN; } else if (pileName.startsWith("tableau-")) { y += indexInPile * FAN; } - // Stock, foundations: stack (no offset). return { x, y }; } -// Z-index: higher index in pile = drawn on top. -function cardZ(pileName, indexInPile, total) { - if (pileName === "stock") return 10 + indexInPile; - if (pileName === "waste") return 10 + indexInPile; - if (pileName.startsWith("found")) return 10 + indexInPile; +function cardZ(pileName, indexInPile) { return 10 + indexInPile; } -// ── Renderer ───────────────────────────────────────────────────────────────── +// ── Renderer ────────────────────────────────────────────────────────────────── function render(s) { snap = s; - // Update HUD hudScore.textContent = `Score: ${s.score}`; hudMoves.textContent = `Moves: ${s.move_count}`; - btnUndo.disabled = s.move_count === 0; + hudStock.textContent = `Stock: ${s.stock.length}`; + btnUndo.disabled = s.undo_stack_len === 0; - // Collect all cards visible in this snapshot, keyed by id → {pile, idx}. const visible = new Map(); - const addPile = (pileName, cards) => { - cards.forEach((c, i) => visible.set(c.id, { pile: pileName, idx: i, card: c, total: cards.length })); - }; - addPile("stock", s.stock); - addPile("waste", s.waste); + const addPile = (name, cards) => + cards.forEach((c, i) => visible.set(c.id, { pile: name, idx: i, card: c, total: cards.length })); + + addPile("stock", s.stock); + addPile("waste", s.waste); s.foundations.forEach((f, i) => addPile(`foundation-${i}`, f)); s.tableaus.forEach((t, i) => addPile(`tableau-${i}`, t)); - // Create or update card elements. for (const [id, info] of visible) { let el = cardEls.get(id); if (!el) { - el = createCardEl(info.card); + el = document.createElement("div"); + el.dataset.cardId = id; cardEls.set(id, el); board.appendChild(el); } - - updateCardEl(el, info.card, info.pile, info.idx, info.total, s); + updateCardEl(el, info.card, info.pile, info.idx, info.total); } - // Remove cards no longer in the snapshot (shouldn't happen in solitaire - // but guards against stale state after undo). for (const [id, el] of cardEls) { - if (!visible.has(id)) { - el.remove(); - cardEls.delete(id); - } + if (!visible.has(id)) { el.remove(); cardEls.delete(id); } } - // Update slot drop-active highlights (cleared on every render). - board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active")); - board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target")); + // Foundation suit hints: hide when pile has cards. + s.foundations.forEach((f, i) => { + const slotEl = board.querySelector(`.slot[data-pile="foundation-${i}"]`); + if (slotEl) { + const hint = slotEl.querySelector(".slot-hint"); + if (hint) hint.style.visibility = f.length > 0 ? "hidden" : ""; + } + }); - // Show recycle indicator on empty stock. + // Recycle indicator on empty stock. let recycleEl = board.querySelector(".recycle-label"); if (s.stock.length === 0 && s.waste.length > 0) { if (!recycleEl) { @@ -208,52 +242,41 @@ function render(s) { board.appendChild(recycleEl); } const o = PILE_ORIGIN.stock; - recycleEl.style.left = `${o.x + CARD_W / 2}px`; - recycleEl.style.top = `${o.y + CARD_H / 2}px`; + recycleEl.style.transform = `translate(${o.x + CARD_W / 2}px, ${o.y + CARD_H / 2}px)`; } else if (recycleEl) { recycleEl.remove(); } - // Trigger auto-complete if applicable. + // Clear drag highlights left from pointer-move. + board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active")); + board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target")); + if (s.is_auto_completable && !s.is_won && !acTimer) { - acTimer = setInterval(doAutoCompleteStep, 400); + acTimer = setInterval(doAutoCompleteStep, 380); } if (s.is_won) { + stopTimer(); if (acTimer) { clearInterval(acTimer); acTimer = null; } showWin(s); } } -function createCardEl(card) { - const el = document.createElement("div"); - el.dataset.cardId = card.id; - return el; -} - -function updateCardEl(el, card, pileName, idx, total, s) { - const pos = cardPos(pileName, idx, total, null); - const z = cardZ(pileName, idx, total); - +function updateCardEl(el, card, pileName, idx, total) { + const pos = cardPos(pileName, idx, total); el.style.transform = `translate(${pos.x}px, ${pos.y}px)`; - el.style.zIndex = z; - - const isTop = idx === total - 1; + el.style.zIndex = cardZ(pileName, idx); if (!card.face_up) { el.className = "card face-down"; - if (pileName === "stock") el.classList.add("stock-card"); - el.innerHTML = ""; + el.innerHTML = ""; } else { const isRed = RED_SUITS.has(card.suit); el.className = `card ${isRed ? "red" : "black"}`; - if (pileName === "stock") el.classList.add("stock-card"); - - const rankLabel = RANK_LABELS[card.rank]; - const suit = SUIT_GLYPH[card.suit]; - el.innerHTML = ` -
${rankLabel}
${suit}
-
${suit}
-
${rankLabel}
${suit}
`; + const r = RANK_LABELS[card.rank]; + const s = SUIT_GLYPH[card.suit]; + el.innerHTML = `
${r}
${s}
+
${s}
+
${r}
${s}
`; } } @@ -261,28 +284,39 @@ function updateCardEl(el, card, pileName, idx, total, s) { function showWin(s) { winScore.textContent = `Score: ${s.score}`; winMoves.textContent = `${s.move_count} moves`; + const m = Math.floor(elapsedSecs / 60); + const sec = elapsedSecs % 60; + if (winTime) winTime.textContent = `${m}:${sec.toString().padStart(2, "0")}`; winOverlay.classList.remove("hidden"); } // ── Auto-complete ───────────────────────────────────────────────────────────── function doAutoCompleteStep() { - if (!game || !snap || !snap.is_auto_completable) { - clearInterval(acTimer); - acTimer = null; - return; + if (!game || !snap?.is_auto_completable) { + clearInterval(acTimer); acTimer = null; return; } const result = game.auto_complete_step(); - if (result && result.ok) { - render(result.snapshot); - } else { - clearInterval(acTimer); - acTimer = null; + if (result?.ok) render(result.snapshot); + else { clearInterval(acTimer); acTimer = null; } +} + +// ── Illegal move flash ──────────────────────────────────────────────────────── +function flashIllegal(cardIds) { + for (const id of cardIds) { + const el = cardEls.get(id); + if (!el) continue; + // Store current translate so the shake keyframe can reference it. + el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)"); + el.classList.add("illegal"); + el.addEventListener("animationend", () => { + el.classList.remove("illegal"); + el.style.removeProperty("--card-tx"); + }, { once: true }); } } -// ── Input handling ──────────────────────────────────────────────────────────── +// ── Input ───────────────────────────────────────────────────────────────────── function attachHandlers() { - // Buttons btnUndo.addEventListener("click", () => { const r = game.undo(); if (r.ok) render(r.snapshot); @@ -294,39 +328,29 @@ function attachHandlers() { startGame(randomSeed()); }); - // Keyboard shortcuts document.addEventListener("keydown", (e) => { - if (e.key === "z" || e.key === "Z") { - const r = game.undo(); - if (r.ok) render(r.snapshot); - } - if (e.key === "n" || e.key === "N") { - startGame(randomSeed()); - } + if (e.target.tagName === "INPUT") return; + if (e.key === "z" || e.key === "Z") { const r = game.undo(); if (r.ok) render(r.snapshot); } + if (e.key === "n" || e.key === "N") startGame(randomSeed()); }); - // Board pointer events (handles both mouse and touch via PointerEvents API) - board.addEventListener("pointerdown", onPointerDown); - board.addEventListener("pointermove", onPointerMove); - board.addEventListener("pointerup", onPointerUp); + board.addEventListener("pointerdown", onPointerDown); + board.addEventListener("pointermove", onPointerMove); + board.addEventListener("pointerup", onPointerUp); board.addEventListener("pointercancel", onPointerCancel); - board.addEventListener("click", onBoardClick); - board.addEventListener("dblclick", onBoardDblClick); + board.addEventListener("click", onBoardClick); + board.addEventListener("dblclick", onBoardDblClick); } // ── Coordinate helpers ──────────────────────────────────────────────────────── +// Returns cursor position in board-element coordinates +// (0,0 = board element top-left corner, which is the padding edge). function boardRelative(clientX, clientY) { const rect = board.getBoundingClientRect(); - // Subtract board padding to get interior coordinates. - return { - x: clientX - rect.left - PAD, - y: clientY - rect.top - PAD, - }; + return { x: clientX - rect.left, y: clientY - rect.top }; } function hitTestCard(bx, by) { - // Walk all visible piles, find the topmost card at (bx, by). - // Returns { pileName, cardIndex, cardId } or null. const pileOrder = [ "waste", "foundation-0","foundation-1","foundation-2","foundation-3", @@ -334,21 +358,19 @@ function hitTestCard(bx, by) { "stock", ]; - let best = null; - let bestZ = -1; + let best = null, bestZ = -1; for (const pileName of pileOrder) { const cards = getPileCards(pileName); if (!cards) continue; - for (let i = 0; i < cards.length; i++) { - const pos = cardPos(pileName, i, cards.length, cards); + const pos = cardPos(pileName, i, cards.length); if (bx >= pos.x && bx <= pos.x + CARD_W && by >= pos.y && by <= pos.y + CARD_H) { - const z = cardZ(pileName, i, cards.length); + const z = cardZ(pileName, i); if (z > bestZ) { bestZ = z; - best = { pileName, cardIndex: i, cardId: cards[i].id, card: cards[i] }; + best = { pileName, cardIndex: i, card: cards[i] }; } } } @@ -360,83 +382,53 @@ function getPileCards(pileName) { if (!snap) return null; if (pileName === "stock") return snap.stock; if (pileName === "waste") return snap.waste; - if (pileName.startsWith("foundation-")) { - const slot = parseInt(pileName.split("-")[1]); - return snap.foundations[slot]; - } - if (pileName.startsWith("tableau-")) { - const col = parseInt(pileName.split("-")[1]); - return snap.tableaus[col]; - } + if (pileName.startsWith("foundation-")) return snap.foundations[parseInt(pileName.split("-")[1])]; + if (pileName.startsWith("tableau-")) return snap.tableaus [parseInt(pileName.split("-")[1])]; return null; } -function hitTestSlot(bx, by) { - // Returns the pile name whose slot rect contains (bx, by), favouring - // tableau column slots over top-row slots when both overlap. - for (const [pile, origin] of Object.entries(PILE_ORIGIN)) { - if (bx >= origin.x && bx <= origin.x + CARD_W && - by >= origin.y && by <= origin.y + CARD_H) { - return pile; - } - } - return null; -} - -// For a tableau pile, hit-test for drop: the drop zone extends to the last -// card's bottom edge (or the slot if empty). +// Drop-target: tableau has tall hit areas; foundations use their slot box. function findDropTarget(bx, by) { - // Check tableau columns first (they have tall hit areas). for (let c = 0; c < 7; c++) { - const pile = `tableau-${c}`; - const cards = snap.tableaus[c]; - const origin = PILE_ORIGIN[pile]; - // Top boundary: origin.y. Bottom boundary: last card bottom or empty slot. + const pile = `tableau-${c}`; + const cards = snap.tableaus[c]; + const origin = PILE_ORIGIN[pile]; const bottomY = cards.length > 0 ? origin.y + (cards.length - 1) * FAN + CARD_H : origin.y + CARD_H; - if (bx >= origin.x && bx <= origin.x + CARD_W && - by >= origin.y && by <= bottomY) { + if (bx >= origin.x && bx <= origin.x + CARD_W && by >= origin.y && by <= bottomY) return pile; - } } - // Foundation slots (top row). for (let s = 0; s < 4; s++) { - const pile = `foundation-${s}`; + const pile = `foundation-${s}`; const origin = PILE_ORIGIN[pile]; if (bx >= origin.x && bx <= origin.x + CARD_W && - by >= origin.y && by <= origin.y + CARD_H) { + by >= origin.y && by <= origin.y + CARD_H) return pile; - } } return null; } -// ── Pointer event handlers ──────────────────────────────────────────────────── +// ── Pointer handlers ────────────────────────────────────────────────────────── function onPointerDown(e) { if (e.button !== 0 && e.pointerType === "mouse") return; - if (drag) return; // ignore second finger + if (drag) return; const { x: bx, y: by } = boardRelative(e.clientX, e.clientY); const hit = hitTestCard(bx, by); - if (!hit) return; - if (!hit.card.face_up) return; // can't drag face-down cards + if (!hit || !hit.card.face_up) return; const cards = getPileCards(hit.pileName); if (!cards) return; - // For tableau, allow dragging a run from any face-up card. - // For waste/foundation, only the top card. let fromIndex = hit.cardIndex; if (!hit.pileName.startsWith("tableau-")) { - fromIndex = cards.length - 1; // only top card + fromIndex = cards.length - 1; if (hit.cardIndex !== fromIndex) return; } const draggedCards = cards.slice(fromIndex); - if (draggedCards.some(c => !c.face_up)) return; // face-down in run — blocked - - const cardOriginPos = cardPos(hit.pileName, fromIndex, cards.length, cards); + if (draggedCards.some(c => !c.face_up)) return; drag = { fromPile: hit.pileName, @@ -444,17 +436,11 @@ function onPointerDown(e) { cardIds: draggedCards.map(c => c.id), startX: bx, startY: by, - offsetX: bx - cardOriginPos.x, - offsetY: by - cardOriginPos.y, }; - // Lift the dragged cards visually. drag.cardIds.forEach((id, i) => { const el = cardEls.get(id); - if (el) { - el.classList.add("selected"); - el.style.zIndex = 500 + i; - } + if (el) { el.classList.add("selected"); el.style.zIndex = 500 + i; } }); board.setPointerCapture(e.pointerId); @@ -471,21 +457,21 @@ function onPointerMove(e) { drag.cardIds.forEach((id, i) => { const el = cardEls.get(id); if (!el) return; - const basePos = cardPos(drag.fromPile, drag.fromIndex + i, (cards ? cards.length : 1), null); - el.style.transform = `translate(${basePos.x + dx}px, ${basePos.y + dy}px)`; + const base = cardPos(drag.fromPile, drag.fromIndex + i, cards ? cards.length : 1); + el.style.transform = `translate(${base.x + dx}px, ${base.y + dy}px)`; }); // Highlight drop target. - board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active")); - board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target")); + board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active")); + board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target")); const targetPile = findDropTarget(bx, by); if (targetPile) { const slotEl = board.querySelector(`.slot[data-pile="${targetPile}"]`); if (slotEl) slotEl.classList.add("drop-active"); const targetCards = getPileCards(targetPile); - if (targetCards && targetCards.length > 0) { - const topCard = cardEls.get(targetCards[targetCards.length - 1].id); - if (topCard) topCard.classList.add("drop-target"); + if (targetCards?.length > 0) { + const topEl = cardEls.get(targetCards[targetCards.length - 1].id); + if (topEl) topEl.classList.add("drop-target"); } } @@ -494,58 +480,47 @@ function onPointerMove(e) { function onPointerUp(e) { if (!drag) return; - board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active")); - board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target")); + board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active")); + board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target")); const { x: bx, y: by } = boardRelative(e.clientX, e.clientY); const targetPile = findDropTarget(bx, by); let moved = false; if (targetPile && targetPile !== drag.fromPile) { - const count = drag.cardIds.length; - const r = game.move_cards(drag.fromPile, targetPile, count); + const r = game.move_cards(drag.fromPile, targetPile, drag.cardIds.length); if (r.ok) { - render(r.snapshot); moved = true; + drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected")); + render(r.snapshot); + } else { + flashIllegal(drag.cardIds); } } if (!moved) { - // Snap cards back to their original positions. - drag.cardIds.forEach(id => { - const el = cardEls.get(id); - if (el) el.classList.remove("selected"); - }); - render(snap); // re-render restores transforms + drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected")); + render(snap); // snap cards back to their pre-drag positions } drag = null; } function onPointerCancel() { - if (drag) { - drag.cardIds.forEach(id => { - const el = cardEls.get(id); - if (el) el.classList.remove("selected"); - }); - render(snap); - drag = null; - } + if (!drag) return; + drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected")); + render(snap); + drag = null; } -// ── Click handlers ──────────────────────────────────────────────────────────── +// ── Click / dblclick ────────────────────────────────────────────────────────── function onBoardClick(e) { - if (drag) return; // swallowed by pointer-up - + if (drag) return; const { x: bx, y: by } = boardRelative(e.clientX, e.clientY); - - // Stock click → draw. - const stockOrigin = PILE_ORIGIN.stock; - if (bx >= stockOrigin.x && bx <= stockOrigin.x + CARD_W && - by >= stockOrigin.y && by <= stockOrigin.y + CARD_H) { + const stock = PILE_ORIGIN.stock; + if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) { const r = game.draw(); if (r.ok) render(r.snapshot); - return; } } @@ -554,11 +529,9 @@ function onBoardDblClick(e) { const hit = hitTestCard(bx, by); if (!hit || !hit.card.face_up) return; - // Only try to move the top card of its pile. const cards = getPileCards(hit.pileName); if (!cards || hit.cardIndex !== cards.length - 1) return; - // Try each foundation slot. for (let s = 0; s < 4; s++) { const r = game.move_cards(hit.pileName, `foundation-${s}`, 1); if (r.ok) { render(r.snapshot); return; } diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm index 7ddcbff62f116286f601eaa3e3ba41943c7b8988..4a4ace66abfccdffd45c20edd83c709aacf734b0 100644 GIT binary patch delta 15889 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 diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 0fb7ffd..e602282 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -234,6 +234,8 @@ pub struct GameSnapshot { pub is_won: bool, pub is_auto_completable: bool, pub undo_count: u32, + /// Number of snapshots currently on the undo stack; 0 means undo is unavailable. + pub undo_stack_len: usize, pub stock: Vec, pub waste: Vec, pub foundations: [Vec; 4], @@ -275,6 +277,7 @@ impl SolitaireGame { is_won: self.game.is_won, is_auto_completable: self.game.is_auto_completable, undo_count: self.game.undo_count, + undo_stack_len: self.game.undo_stack_len(), stock: cards(PileType::Stock), waste: cards(PileType::Waste), foundations: [