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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 15:03:33 -04:00
co-authored by Claude Fable 5.1
parent eeecfa1326
commit 4f8bbbc3cb
+91 -19
View File
@@ -17,9 +17,6 @@
const corners = $derived(set.markedCorners ? cornersOf(size) : []);
const mySide = $derived<Side | null>(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<number | null>(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<Token[]>([]);
/** 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 @@
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
{/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}
<g class="piece {piece === 'a' ? 'attacker' : piece === 'k' ? 'king' : 'defender'}" class:selected={selected === i} pointer-events="none">
<ellipse class="shadow" cx={cx + 1.5} cy={cy + 2.5} rx={CELL * 0.34} ry={CELL * 0.3} />
<circle class="body" {cx} {cy} r={CELL * 0.33} />
{#if piece === 'k'}
<path class="crown" d="M{cx - 9} {cy + 4} l0 -9 l4.5 5 l4.5 -8 l4.5 8 l4.5 -5 l0 9 z" />
{/if}
</g>
{/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}
<g
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
class:selected={selected === t.at}
class:dying={t.dying}
style="transform: translate({cx}px, {cy}px)"
pointer-events="none"
>
<ellipse class="shadow" cx="1.5" cy="2.5" rx={CELL * 0.34} ry={CELL * 0.3} />
<circle class="body" cx="0" cy="0" r={CELL * 0.33} />
{#if t.piece === 'k'}
<path class="crown" d="M-9 4 l0 -9 l4.5 5 l4.5 -8 l4.5 8 l4.5 -5 l0 9 z" />
{/if}
</g>
{/each}
</g>
</svg>
@@ -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);
}