diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 0a98c19..4046c4b 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -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([]); - /** 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(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(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 @@ {nameOfSide(g.toMove)} is thinking · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · {nameOfSide(g.toMove)} is in check{/if} {/if} {#if reviewing !== null && !pending} - Move {reviewing} is lit on the boards as they stand now; no piece has moved back. + The boards as they stood after move {reviewing} of {g.moves.length}. + · + · + {/if} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)} @@ -333,10 +361,10 @@

{#if drake && !g.over} · {/if}

{#if fault}

{fault}

{/if} {#if onGlass && Glass} - + {:else}
- +
{/if}

{liveText}

@@ -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; diff --git a/src/lib/components/Chronicle.svelte b/src/lib/components/Chronicle.svelte index 514c4f1..1d159bd 100644 --- a/src/lib/components/Chronicle.svelte +++ b/src/lib/components/Chronicle.svelte @@ -1,7 +1,7 @@