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:
co-authored by
Claude Fable 5.1
parent
eeecfa1326
commit
4f8bbbc3cb
@@ -17,9 +17,6 @@
|
|||||||
const corners = $derived(set.markedCorners ? cornersOf(size) : []);
|
const corners = $derived(set.markedCorners ? cornersOf(size) : []);
|
||||||
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
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 nameOfSide = (side: Side) => {
|
||||||
const seat = g.seats.find((id) => g.players[id].side === side);
|
const seat = g.seats.find((id) => g.players[id].side === side);
|
||||||
return seat ? g.players[seat].name : side;
|
return seat ? g.players[seat].name : side;
|
||||||
@@ -30,6 +27,67 @@
|
|||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
const targets = $derived(selected === null ? [] : movesFrom(g, selected));
|
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.
|
// A new position clears any half-made move.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void g.moves.length;
|
void g.moves.length;
|
||||||
@@ -37,7 +95,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function tap(i: number) {
|
async function tap(i: number) {
|
||||||
if (!yourMove || room.sending) return;
|
if (!yourMove || room.sending || animating) return;
|
||||||
if (selected !== null && targets.includes(i)) {
|
if (selected !== null && targets.includes(i)) {
|
||||||
const from = selected;
|
const from = selected;
|
||||||
selected = null;
|
selected = null;
|
||||||
@@ -89,9 +147,9 @@
|
|||||||
class="square"
|
class="square"
|
||||||
class:throne={i === throne}
|
class:throne={i === throne}
|
||||||
class:corner={corners.includes(i)}
|
class:corner={corners.includes(i)}
|
||||||
class:last={lastSquares.includes(i)}
|
class:last={shownSquares.includes(i)}
|
||||||
class:target={targets.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)}
|
class:edge={set.escape === 'edge' && isEdge(size, i)}
|
||||||
{x}
|
{x}
|
||||||
{y}
|
{y}
|
||||||
@@ -112,19 +170,23 @@
|
|||||||
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
|
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#each g.cells as piece, i (i)}
|
{#each tokens as t (t.id)}
|
||||||
{#if piece !== '.'}
|
{@const c = cellOf(size, t.at)}
|
||||||
{@const c = cellOf(size, i)}
|
|
||||||
{@const cx = PAD + c.x * CELL + CELL / 2}
|
{@const cx = PAD + c.x * CELL + CELL / 2}
|
||||||
{@const cy = PAD + c.y * 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">
|
<g
|
||||||
<ellipse class="shadow" cx={cx + 1.5} cy={cy + 2.5} rx={CELL * 0.34} ry={CELL * 0.3} />
|
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
|
||||||
<circle class="body" {cx} {cy} r={CELL * 0.33} />
|
class:selected={selected === t.at}
|
||||||
{#if piece === 'k'}
|
class:dying={t.dying}
|
||||||
<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" />
|
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}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -286,6 +348,16 @@
|
|||||||
opacity: 0.85;
|
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 {
|
.piece .shadow {
|
||||||
fill: rgba(0, 0, 0, 0.35);
|
fill: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user