From 4f8bbbc3cb69ecb8acb1e40579249533ae8f2a5b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 15:03:33 -0400 Subject: [PATCH] Pieces slide, captures fade, and the bot's reply follows yours after a beat, so an exchange can be watched Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- src/lib/components/Board.svelte | 110 ++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 2c63efb..d7f3c21 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -17,9 +17,6 @@ const corners = $derived(set.markedCorners ? cornersOf(size) : []); const mySide = $derived(room.spectating ? null : (g.players[room.me]?.side ?? null)); const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over); - const last = $derived(g.moves[g.moves.length - 1] ?? null); - const lastSquares = $derived(last ? [last.from, last.to] : []); - const captured = $derived(last?.captured ?? []); const nameOfSide = (side: Side) => { const seat = g.seats.find((id) => g.players[id].side === side); return seat ? g.players[seat].name : side; @@ -30,6 +27,67 @@ let selected = $state(null); const targets = $derived(selected === null ? [] : movesFrom(g, selected)); + // Pieces are shown as tokens with a life of their own, so a move slides + // and a capture fades, one move at a time. The server's state may be + // two moves ahead (yours and the bot's answer); the tokens catch up + // move by move, with a beat between, so an exchange can be watched. + interface Token { + id: number; + piece: string; + at: number; + dying: boolean; + } + let tokens = $state([]); + /** How many of the game's moves the tokens have played through. */ + let shown = $state(0); + let shownSet = $state(''); + let animating = $state(false); + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + function resetTokens() { + let id = 0; + tokens = g.cells.flatMap((piece, at) => (piece === '.' ? [] : [{ id: id++, piece, at, dying: false }])); + shown = g.moves.length; + shownSet = set.id; + } + + async function catchUp() { + if (animating) return; + animating = true; + try { + while (shown < g.moves.length) { + const m = g.moves[shown]; + const mover = tokens.find((t) => t.at === m.from && !t.dying); + if (!mover) { + resetTokens(); + break; + } + mover.at = m.to; + await wait(380); + if (m.captured.length) { + for (const t of tokens) if (m.captured.includes(t.at)) t.dying = true; + await wait(320); + tokens = tokens.filter((t) => !t.dying); + } + shown += 1; + if (shown < g.moves.length) await wait(260); + } + } finally { + animating = false; + } + } + + $effect(() => { + const total = g.moves.length; + if (shownSet !== set.id || total < shown || (tokens.length === 0 && g.cells.some((p) => p !== '.'))) resetTokens(); + else if (total > shown) void catchUp(); + }); + + /** The move the tokens have just played, for the shaded squares. */ + const shownLast = $derived(shown > 0 ? g.moves[shown - 1] : null); + const shownSquares = $derived(shownLast ? [shownLast.from, shownLast.to] : []); + const shownCaptured = $derived(shownLast?.captured ?? []); + // A new position clears any half-made move. $effect(() => { void g.moves.length; @@ -37,7 +95,7 @@ }); async function tap(i: number) { - if (!yourMove || room.sending) return; + if (!yourMove || room.sending || animating) return; if (selected !== null && targets.includes(i)) { const from = selected; selected = null; @@ -89,9 +147,9 @@ class="square" class:throne={i === throne} class:corner={corners.includes(i)} - class:last={lastSquares.includes(i)} + class:last={shownSquares.includes(i)} class:target={targets.includes(i)} - class:fallen={captured.includes(i)} + class:fallen={shownCaptured.includes(i)} class:edge={set.escape === 'edge' && isEdge(size, i)} {x} {y} @@ -112,19 +170,23 @@ {/if} {/each} - {#each g.cells as piece, i (i)} - {#if piece !== '.'} - {@const c = cellOf(size, i)} - {@const cx = PAD + c.x * CELL + CELL / 2} - {@const cy = PAD + c.y * CELL + CELL / 2} - - - - {#if piece === 'k'} - - {/if} - - {/if} + {#each tokens as t (t.id)} + {@const c = cellOf(size, t.at)} + {@const cx = PAD + c.x * CELL + CELL / 2} + {@const cy = PAD + c.y * CELL + CELL / 2} + + + + {#if t.piece === 'k'} + + {/if} + {/each} @@ -286,6 +348,16 @@ opacity: 0.85; } + .piece { + transition: + transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1), + opacity 0.3s ease; + } + + .piece.dying { + opacity: 0; + } + .piece .shadow { fill: rgba(0, 0, 0, 0.35); }