feat(web): card flight animations between piles

The replay viewer's renderer used to wipe and rebuild every card
from scratch on every step (`board.replaceChildren()`). Each step
was a discrete redraw — fine for correctness, abrupt for the eye.

Restructured to a persistent card-element model:

- `#board` is now a positioned context (relative) instead of a
  CSS grid. The dashed empty-pile placeholders are absolutely-
  positioned `.slot` elements painted once at bootstrap.
- Each card lives as a sibling of the slots, absolutely-positioned
  with `transform: translate(x, y)`. The CSS transition on
  `transform` (280 ms cubic-bezier) runs every move as a flight
  rather than a redraw.
- `cardEls: Map<id, HTMLElement>` persists across renders. Cards
  unchanged between steps don't re-create their DOM at all.
- Z-index is set per-render from the card's pile index so a card
  flying out from the bottom of a tableau passes behind the cards
  above it.
- Newly-spawned cards (rare — only on Restart) fade in at their
  target position via a `requestAnimationFrame` opacity flip;
  cards that disappear (also rare) fade out and despawn after the
  220 ms fade.
- `will-change: transform` lets the browser composite the
  animation, keeping it smooth on low-spec hardware.

Restart now drops every existing card before resetting so the
fresh deal looks like a new game, not a continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 19:01:02 +00:00
parent 3081505a3d
commit 1fcd032b0a
2 changed files with 181 additions and 82 deletions
+27 -41
View File
@@ -56,41 +56,29 @@ main {
align-items: center; 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 { #board {
position: relative;
background: var(--felt); background: var(--felt);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
width: min(100%, calc(7 * var(--card-w) + 8 * var(--gap))); width: calc(7 * var(--card-w) + 6 * var(--gap));
display: grid; /* Top row + a generous fan budget (12 fan steps + the card's
grid-template-columns: repeat(7, var(--card-w)); own height) so a king-down-to-ace column never overflows. */
grid-template-rows: var(--card-h) auto; height: calc(var(--card-h) + 32px + var(--card-h) + 12 * 28px);
gap: var(--gap);
column-gap: var(--gap);
row-gap: 32px;
} }
/* Top row: stock, waste, [skip], 4 foundations. */ /* Empty-pile slot placeholders are absolutely positioned at the same
.pile-stock { grid-column: 1; grid-row: 1; } coordinates the renderer uses for cards, so they line up perfectly
.pile-waste { grid-column: 2; grid-row: 1; } when the pile is empty. */
.pile-foundation-0 { grid-column: 4; grid-row: 1; } .slot {
.pile-foundation-1 { grid-column: 5; grid-row: 1; } position: absolute;
.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 {
width: var(--card-w); width: var(--card-w);
height: var(--card-h); height: var(--card-h);
border: 2px dashed rgba(255, 255, 255, 0.15); border: 2px dashed rgba(255, 255, 255, 0.15);
@@ -99,6 +87,13 @@ main {
.card { .card {
position: absolute; 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); width: var(--card-w);
height: var(--card-h); height: var(--card-h);
background: var(--card-bg); background: var(--card-bg);
@@ -110,18 +105,9 @@ main {
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
user-select: none; user-select: none;
transition: top 180ms ease, opacity 180ms ease; transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1),
} opacity 200ms ease;
will-change: transform;
/* 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). */
} }
.card.face-down { .card.face-down {
+154 -41
View File
@@ -5,11 +5,44 @@
// `GameState` compiled to WebAssembly), and renders each step's pile // `GameState` compiled to WebAssembly), and renders each step's pile
// snapshot as plain HTML cards. The WASM module is the single source // 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. // 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"; import init, { ReplayPlayer } from "/web/pkg/solitaire_wasm.js";
const STEP_INTERVAL_MS = 600; const STEP_INTERVAL_MS = 600;
const FAN_OFFSET_PX = 28; 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 = { const SUIT_GLYPHS = {
clubs: "♣", clubs: "♣",
@@ -17,12 +50,8 @@ const SUIT_GLYPHS = {
hearts: "♥", hearts: "♥",
spades: "♠", spades: "♠",
}; };
const RED_SUITS = new Set(["diamonds", "hearts"]); 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 board = document.getElementById("board");
const captionEl = document.getElementById("caption"); const captionEl = document.getElementById("caption");
@@ -38,8 +67,12 @@ let player = null;
let replayJson = null; let replayJson = null;
let playInterval = 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() { async function bootstrap() {
// /replays/<id> — pull the id off the path so we can fetch the JSON.
const id = window.location.pathname.split("/").pop(); const id = window.location.pathname.split("/").pop();
if (!id) { if (!id) {
captionEl.textContent = "No replay id in URL."; captionEl.textContent = "No replay id in URL.";
@@ -65,10 +98,22 @@ async function bootstrap() {
`· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` + `· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` +
`· final score ${replay.final_score}`; `· final score ${replay.final_score}`;
spawnEmptySlots();
await init(); await init();
resetPlayer(); 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() { function resetPlayer() {
if (playInterval) { if (playInterval) {
clearInterval(playInterval); clearInterval(playInterval);
@@ -103,15 +148,74 @@ function finish() {
btnStep.disabled = true; 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) { function render(snap) {
if (!snap) return; if (!snap) return;
board.replaceChildren();
renderPile("stock", snap.stock, false); const targets = new Map(); // card.id → { card, x, y }
renderPile("waste", snap.waste, false);
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) => snap.foundations.forEach((cards, idx) =>
renderPile(`foundation-${idx}`, cards, false)); placePile(`foundation-${idx}`, cards, false));
snap.tableaus.forEach((cards, idx) => 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}`; progressEl.textContent = `step ${snap.step_idx} / ${snap.total_steps}`;
scoreEl.textContent = `Score ${snap.score}`; scoreEl.textContent = `Score ${snap.score}`;
@@ -125,50 +229,51 @@ function render(snap) {
} }
} }
function renderPile(name, cards, fan) { function createCardElement(card) {
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) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "card"; 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) { if (!card.face_up) {
el.classList.add("face-down"); el.classList.add("face-down");
return el; return;
} }
el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black"); el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black");
const label = RANK_LABELS[card.rank] || "?"; const label = RANK_LABELS[card.rank] || "?";
const glyph = SUIT_GLYPHS[card.suit] || "?"; const glyph = SUIT_GLYPHS[card.suit] || "?";
const top_corner = document.createElement("span"); const top = document.createElement("span");
top_corner.className = "corner top"; top.className = "corner top";
top_corner.textContent = `${label}\n${glyph}`; top.textContent = `${label}\n${glyph}`;
el.appendChild(top_corner); el.appendChild(top);
const center = document.createElement("span"); const center = document.createElement("span");
center.className = "center"; center.className = "center";
center.textContent = glyph; center.textContent = glyph;
el.appendChild(center); el.appendChild(center);
const bottom_corner = document.createElement("span"); const bottom = document.createElement("span");
bottom_corner.className = "corner bottom"; bottom.className = "corner bottom";
bottom_corner.textContent = `${label}\n${glyph}`; bottom.textContent = `${label}\n${glyph}`;
el.appendChild(bottom_corner); el.appendChild(bottom);
return el;
} }
function formatDuration(seconds) { function formatDuration(seconds) {
@@ -197,7 +302,15 @@ btnPlay.addEventListener("click", () => {
}); });
btnPrev.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(); bootstrap();