diff --git a/solitaire_server/web/replay.css b/solitaire_server/web/replay.css index 528e264..f63e6a8 100644 --- a/solitaire_server/web/replay.css +++ b/solitaire_server/web/replay.css @@ -56,41 +56,29 @@ main { align-items: center; } +/* Board: a positioning context for both the dashed empty-pile slots + and the absolutely-positioned card sprites. Width matches the + 7-column grid (7*card-w + 6 inter-column gaps), height covers the + top row plus a worst-case 13-card tableau fan. Cards live as + siblings of the slot placeholders so they can move between piles + without ever changing parent — the transform-based `transition` + then animates the flight. */ #board { + position: relative; background: var(--felt); border-radius: 12px; padding: 24px; - width: min(100%, calc(7 * var(--card-w) + 8 * var(--gap))); - display: grid; - grid-template-columns: repeat(7, var(--card-w)); - grid-template-rows: var(--card-h) auto; - gap: var(--gap); - column-gap: var(--gap); - row-gap: 32px; + width: calc(7 * var(--card-w) + 6 * var(--gap)); + /* Top row + a generous fan budget (12 fan steps + the card's + own height) so a king-down-to-ace column never overflows. */ + height: calc(var(--card-h) + 32px + var(--card-h) + 12 * 28px); } -/* Top row: stock, waste, [skip], 4 foundations. */ -.pile-stock { grid-column: 1; grid-row: 1; } -.pile-waste { grid-column: 2; grid-row: 1; } -.pile-foundation-0 { grid-column: 4; grid-row: 1; } -.pile-foundation-1 { grid-column: 5; grid-row: 1; } -.pile-foundation-2 { grid-column: 6; grid-row: 1; } -.pile-foundation-3 { grid-column: 7; grid-row: 1; } -.pile-tableau-0 { grid-column: 1; grid-row: 2; } -.pile-tableau-1 { grid-column: 2; grid-row: 2; } -.pile-tableau-2 { grid-column: 3; grid-row: 2; } -.pile-tableau-3 { grid-column: 4; grid-row: 2; } -.pile-tableau-4 { grid-column: 5; grid-row: 2; } -.pile-tableau-5 { grid-column: 6; grid-row: 2; } -.pile-tableau-6 { grid-column: 7; grid-row: 2; } - -.pile { - position: relative; - width: var(--card-w); - /* Tableau columns let cards stack downward. */ -} - -.pile-empty { +/* Empty-pile slot placeholders are absolutely positioned at the same + coordinates the renderer uses for cards, so they line up perfectly + when the pile is empty. */ +.slot { + position: absolute; width: var(--card-w); height: var(--card-h); border: 2px dashed rgba(255, 255, 255, 0.15); @@ -99,6 +87,13 @@ main { .card { position: absolute; + /* `top: 0; left: 0` plus a per-card `transform: translate(...)` + gives us a single transformed property to animate. Using + `transform` (rather than `top` / `left`) lets the browser run + the animation on the compositor — smooth even on the + low-spec laptops the player tests on. */ + top: 0; + left: 0; width: var(--card-w); height: var(--card-h); background: var(--card-bg); @@ -110,18 +105,9 @@ main { font-weight: 600; line-height: 1; user-select: none; - transition: top 180ms ease, opacity 180ms ease; -} - -/* Tableau fan: cards beneath the top one peek through ~28 px down. */ -.pile-tableau-0 .card, -.pile-tableau-1 .card, -.pile-tableau-2 .card, -.pile-tableau-3 .card, -.pile-tableau-4 .card, -.pile-tableau-5 .card, -.pile-tableau-6 .card { - /* Per-card top set inline by JS (offset = idx * 28 px). */ + transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 200ms ease; + will-change: transform; } .card.face-down { diff --git a/solitaire_server/web/replay.js b/solitaire_server/web/replay.js index e899a63..f73cf11 100644 --- a/solitaire_server/web/replay.js +++ b/solitaire_server/web/replay.js @@ -5,11 +5,44 @@ // `GameState` compiled to WebAssembly), and renders each step's pile // snapshot as plain HTML cards. The WASM module is the single source // of truth for the rules engine — we don't re-implement Klondike in JS. +// +// Card flight animation: each card's DOM element persists across +// re-renders, keyed by `card.id`. `render()` updates each card's +// `transform: translate(...)` to its new (pile, index) coordinates; +// the CSS `transition` on `transform` animates the flight. Cards that +// disappear from the snapshot fade and remove; new cards fade in at +// their target position. import init, { ReplayPlayer } from "/web/pkg/solitaire_wasm.js"; const STEP_INTERVAL_MS = 600; const FAN_OFFSET_PX = 28; +const CARD_W = 80; +const CARD_H = 112; +const GAP = 12; + +// Pile origin (top-left of the slot, in board-relative pixels). +// Top row: stock at column 0, waste at column 1, foundations at 3-6. +// Bottom row: tableau columns 0-6. +const TOP_ROW_Y = 0; +const TABLEAU_ROW_Y = CARD_H + 32; +const colX = (col) => col * (CARD_W + GAP); + +const PILE_ORIGIN = { + stock: { x: colX(0), y: TOP_ROW_Y }, + waste: { x: colX(1), y: TOP_ROW_Y }, + "foundation-0": { x: colX(3), y: TOP_ROW_Y }, + "foundation-1": { x: colX(4), y: TOP_ROW_Y }, + "foundation-2": { x: colX(5), y: TOP_ROW_Y }, + "foundation-3": { x: colX(6), y: TOP_ROW_Y }, + "tableau-0": { x: colX(0), y: TABLEAU_ROW_Y }, + "tableau-1": { x: colX(1), y: TABLEAU_ROW_Y }, + "tableau-2": { x: colX(2), y: TABLEAU_ROW_Y }, + "tableau-3": { x: colX(3), y: TABLEAU_ROW_Y }, + "tableau-4": { x: colX(4), y: TABLEAU_ROW_Y }, + "tableau-5": { x: colX(5), y: TABLEAU_ROW_Y }, + "tableau-6": { x: colX(6), y: TABLEAU_ROW_Y }, +}; const SUIT_GLYPHS = { clubs: "♣", @@ -17,12 +50,8 @@ const SUIT_GLYPHS = { hearts: "♥", spades: "♠", }; - const RED_SUITS = new Set(["diamonds", "hearts"]); - -const RANK_LABELS = [ - "", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", -]; +const RANK_LABELS = ["", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]; const board = document.getElementById("board"); const captionEl = document.getElementById("caption"); @@ -38,8 +67,12 @@ let player = null; let replayJson = null; let playInterval = null; +// Persistent map: card.id → DOM element. Reused across renders so the +// browser interpolates the `transform` change rather than rebuilding +// nodes every step. +const cardEls = new Map(); + async function bootstrap() { - // /replays/ — pull the id off the path so we can fetch the JSON. const id = window.location.pathname.split("/").pop(); if (!id) { captionEl.textContent = "No replay id in URL."; @@ -65,10 +98,22 @@ async function bootstrap() { `· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` + `· final score ${replay.final_score}`; + spawnEmptySlots(); await init(); resetPlayer(); } +/// Spawn the dashed empty-pile placeholders once. They never move and +/// never get keyed to card ids, so they're outside the cardEls map. +function spawnEmptySlots() { + Object.entries(PILE_ORIGIN).forEach(([name, { x, y }]) => { + const slot = document.createElement("div"); + slot.className = `slot slot-${name}`; + slot.style.transform = `translate(${x}px, ${y}px)`; + board.appendChild(slot); + }); +} + function resetPlayer() { if (playInterval) { clearInterval(playInterval); @@ -103,15 +148,74 @@ function finish() { btnStep.disabled = true; } +/// Apply `snap` to the persistent card-element map. +/// +/// Phase 1: collect every card present in this snapshot, computing its +/// target board-relative (x, y) from its pile + index. +/// Phase 2: for each card, find or create its DOM element and update +/// its visual state + transform. Persistent elements interpolate via +/// CSS transition; freshly-created ones fade in. +/// Phase 3: any card present in `cardEls` but absent from `snap` (rare +/// but happens during stat resets) fades out and is removed. function render(snap) { if (!snap) return; - board.replaceChildren(); - renderPile("stock", snap.stock, false); - renderPile("waste", snap.waste, false); + + const targets = new Map(); // card.id → { card, x, y } + + function placePile(name, cards, fan) { + const origin = PILE_ORIGIN[name]; + cards.forEach((card, idx) => { + const yOffset = fan ? idx * FAN_OFFSET_PX : 0; + targets.set(card.id, { + card, + x: origin.x, + y: origin.y + yOffset, + z: idx, + }); + }); + } + + placePile("stock", snap.stock, false); + placePile("waste", snap.waste, false); snap.foundations.forEach((cards, idx) => - renderPile(`foundation-${idx}`, cards, false)); + placePile(`foundation-${idx}`, cards, false)); snap.tableaus.forEach((cards, idx) => - renderPile(`tableau-${idx}`, cards, true)); + placePile(`tableau-${idx}`, cards, true)); + + // Apply or create. + targets.forEach(({ card, x, y, z }) => { + let el = cardEls.get(card.id); + if (!el) { + el = createCardElement(card); + // Spawn off-screen with opacity 0 so the entry transition + // fades in at the destination rather than popping. + el.style.transform = `translate(${x}px, ${y}px)`; + el.style.opacity = "0"; + board.appendChild(el); + cardEls.set(card.id, el); + // Force the browser to commit the off-screen frame before + // we set the visible state, so the transition runs. + requestAnimationFrame(() => { + el.style.opacity = "1"; + }); + } else { + updateCardElement(el, card); + el.style.transform = `translate(${x}px, ${y}px)`; + } + el.style.zIndex = String(z + 1); + }); + + // Drop any cards no longer in play (e.g. on player reset). + cardEls.forEach((el, id) => { + if (!targets.has(id)) { + el.style.opacity = "0"; + // Remove after the fade transition completes. + setTimeout(() => { + el.remove(); + cardEls.delete(id); + }, 220); + } + }); progressEl.textContent = `step ${snap.step_idx} / ${snap.total_steps}`; scoreEl.textContent = `Score ${snap.score}`; @@ -125,50 +229,51 @@ function render(snap) { } } -function renderPile(name, cards, fan) { - const pile = document.createElement("div"); - pile.className = `pile pile-${name}`; - if (cards.length === 0) { - const empty = document.createElement("div"); - empty.className = "pile-empty"; - pile.appendChild(empty); - board.appendChild(pile); - return; - } - cards.forEach((card, idx) => { - const top = fan ? idx * FAN_OFFSET_PX : 0; - pile.appendChild(buildCard(card, top)); - }); - board.appendChild(pile); -} - -function buildCard(card, top) { +function createCardElement(card) { const el = document.createElement("div"); el.className = "card"; - el.style.top = `${top}px`; + el.dataset.cardId = String(card.id); + populateCardFace(el, card); + return el; +} + +/// Cheap "is this still the same visual state" check. Face-up cards +/// only need a re-paint if their face_up flag flipped (rank/suit are +/// immutable per id), so we can skip rebuilding the inner DOM for the +/// 99% case where only the transform changed. +function updateCardElement(el, card) { + const wasFaceDown = el.classList.contains("face-down"); + const isFaceDown = !card.face_up; + if (wasFaceDown !== isFaceDown) { + el.replaceChildren(); + el.classList.remove("red", "black", "face-down"); + populateCardFace(el, card); + } +} + +function populateCardFace(el, card) { if (!card.face_up) { el.classList.add("face-down"); - return el; + return; } el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black"); const label = RANK_LABELS[card.rank] || "?"; const glyph = SUIT_GLYPHS[card.suit] || "?"; - const top_corner = document.createElement("span"); - top_corner.className = "corner top"; - top_corner.textContent = `${label}\n${glyph}`; - el.appendChild(top_corner); + const top = document.createElement("span"); + top.className = "corner top"; + top.textContent = `${label}\n${glyph}`; + el.appendChild(top); const center = document.createElement("span"); center.className = "center"; center.textContent = glyph; el.appendChild(center); - const bottom_corner = document.createElement("span"); - bottom_corner.className = "corner bottom"; - bottom_corner.textContent = `${label}\n${glyph}`; - el.appendChild(bottom_corner); - return el; + const bottom = document.createElement("span"); + bottom.className = "corner bottom"; + bottom.textContent = `${label}\n${glyph}`; + el.appendChild(bottom); } function formatDuration(seconds) { @@ -197,7 +302,15 @@ btnPlay.addEventListener("click", () => { }); btnPrev.addEventListener("click", () => { - if (replayJson) resetPlayer(); + if (!replayJson) return; + // Drop every existing card so the next render fades them all in + // at the freshly-dealt positions. Without this, cards from the + // current state would slide to wherever the new deal puts them + // — confusing since the deal is supposed to look like a fresh + // start, not a continuation. + cardEls.forEach((el) => el.remove()); + cardEls.clear(); + resetPlayer(); }); bootstrap();