A move picked from the chronicle puts the boards back as they stood after it, on the flat boards and the glass alike, with Earlier, Later and Back to now in the status strip; one move on plays itself, any other leap sets the pieces out where they stood, and a tap on the boards brings the game back to now

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-27 23:11:52 -04:00
1 parent 13fc4c5ef3
commit 0f4ae0dca4
3 files changed
+54 -18

No files matched your search

+49 -15
View File
@@ -44,29 +44,32 @@
};
const flipped = $derived(mySide === 'scarlet');
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
let tokens = $state<Token[]>([]);
/** How many of the game's moves the tokens have played through. */
/** How many of the moves the tokens have played through. */
let shown = $state(0);
let animating = $state(false);
/** Bumped when the tokens are set out afresh, so a catch-up in flight lets go of them. */
let epoch = 0;
const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false);
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
function resetTokens() {
epoch += 1;
let id = 0;
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = g.moves.length;
tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = v.moves.length;
}
async function catchUp() {
if (animating) return;
animating = true;
const mine = epoch;
try {
while (shown < g.moves.length) {
const m = g.moves[shown];
while (shown < v.moves.length) {
const m = v.moves[shown];
const mover = tokens.find((t) => t.at === m.from && !t.dying);
if (!mover) {
resetTokens();
@@ -80,12 +83,15 @@
if (victim) victim.dying = true;
mover.at = m.to;
await wait(380);
if (mine !== epoch) return;
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
}
if (mine !== epoch) return;
if (victim) tokens = tokens.filter((t) => !t.dying);
shown += 1;
announce(storyOf(shown));
if (shown < g.moves.length) await wait(260);
if (shown < v.moves.length) await wait(260);
if (mine !== epoch) return;
}
} finally {
animating = false;
@@ -93,26 +99,40 @@
}
$effect(() => {
const total = g.moves.length;
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
const total = v.moves.length;
if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
else if (total > shown) void catchUp();
});
// --- What the boards show: the last move played, or a move picked from the record ---
// --- What the boards show: the game as it stands, or as it stood after a move picked from the record ---
let reviewing = $state<number | null>(null);
$effect(() => {
void g.moves.length;
reviewing = null;
selected = null;
});
/** The state the boards draw: the game's own, or the position after the move under review, with that many moves played. */
const v = $derived.by((): typeof g => {
if (reviewing === null) return g;
const t = tellings[reviewing - 1];
return { ...g, cells: t.after, moves: g.moves.slice(0, reviewing), toMove: t.move.by === 'gold' ? 'scarlet' : 'gold' };
});
/** The King in check on the boards as drawn: the game's, or the position under review. */
const checkSquare = $derived(reviewing !== null ? (inCheck(v.cells, v.toMove) ? kingSquare(v.cells, v.toMove) : -1) : inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
const shownPly = $derived(reviewing ?? shown);
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
/** A move picked from the record lights its squares on the boards as they stand; the pieces stay where they are. */
/** A move picked from the record puts the boards back as they stood after it, and brings its board into view. */
function review(ply: number | null) {
if (ply !== null && (ply < 1 || ply > g.moves.length)) return;
const step = (ply ?? g.moves.length) - shown;
reviewing = ply;
selected = null;
inspected = null;
// One move on plays itself; any other leap sets the pieces out where they stood.
if (step !== 1) resetTokens();
if (ply === null) return;
const m = g.moves[ply - 1];
requestAnimationFrame(() => {
@@ -209,7 +229,12 @@
async function tap(i: number) {
if (room.sending || animating) return;
reviewing = null;
if (reviewing !== null) {
// The boards are showing an earlier position: a tap brings them back to now, and nothing more.
review(null);
announce('Back to the game as it stands.');
return;
}
if (!yourMove) {
// Not your move, or no seat: a tap on a piece still opens its panel.
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
@@ -304,7 +329,10 @@
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
{/if}
{#if reviewing !== null && !pending}
<span class="marked">Move {reviewing} is lit on the boards as they stand now; no piece has moved back. <button type="button" class="link" onclick={() => review(null)}>Clear it</button></span>
<span class="marked">The boards as they stood after move {reviewing} of {g.moves.length}.
<button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
<button type="button" class="link" disabled={reviewing >= g.moves.length} onclick={() => review(reviewing! + 1)}>Later</button> ·
<button type="button" class="link" onclick={() => review(null)}>Back to now</button></span>
{/if}
</div>
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
@@ -333,10 +361,10 @@
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
{#if fault}<p class="warning">{fault}</p>{/if}
{#if onGlass && Glass}
<Glass {g} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
<Glass g={v} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
{:else}
<div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
<BoardArtwork g={v} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div>
{/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -428,6 +456,12 @@
color: var(--frost);
}
.marked .link:disabled {
color: var(--bone-faint);
text-decoration: none;
cursor: default;
}
.check {
color: var(--ember);
font-weight: 600;
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
// The game told top to bottom, oldest first, each move with what came of
// it, and the newest scrolled into view. A move's heading is a button that
// shows that move on the boards.
// puts the boards back as they stood after it.
import { tick } from 'svelte';
import type { Telling } from '$lib/game/chronicle';
import { notation, type Side } from '$lib/game';
@@ -25,7 +25,7 @@
{#each tellings as t (t.ply)}
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
<h3>
<button type="button" class="link plain" title="Light this move's squares on the boards as they stand" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
<button type="button" class="link plain" title="Show the boards as they stood after this move" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
Move {t.ply} <span class="notation">{notation(t.move)}</span>
</button>
</h3>
+3 -1
View File
@@ -20,6 +20,8 @@ export interface Telling {
ply: number;
move: Move;
lines: Line[];
/** The boards as they stood once the move was made. */
after: Cell[];
}
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
@@ -127,7 +129,7 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
state.moves.forEach((move, n) => {
const { lines, after } = tellOne(cells, move, owner);
cells = after;
out.push({ ply: n + 1, move, lines });
out.push({ ply: n + 1, move, lines, after });
});
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
return out;