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;
}
/* 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 {