A position under review leads the status strip and is announced; the glass sheets' rims carry the square names

Picking a move from the chronicle now puts the review first in the strip, with Earlier, Later and Back to now on the same line, and labels the live game beneath it; the piece counts and the move line beside the boards describe the position as drawn. On a phone the chosen board scrolls to sit under the strip once the layout has settled, so a pick made from the chronicle on a phone held sideways no longer leaves the board and its controls above the viewport. Each review step says the position and its move to the live region, and returning says the game as it stands; a button that vanished or went grey under the keyboard hands focus to the grid's own square, or to Back to now.

The glass board's sheets carry the files' letters and the ranks' numbers in their rims when the flat boards show them, the far edges turned about so that each seat reads its own; the rim is a little wider for them and the posts stand a little further out.

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-28 09:36:20 -04:00
1 parent f2be3b0055
commit 8c03fd624c
3 files changed
+118 -33

No files matched your search

+56 -24
View File
@@ -89,7 +89,7 @@
if (mine !== epoch) return; if (mine !== epoch) return;
if (victim) tokens = tokens.filter((t) => !t.dying); if (victim) tokens = tokens.filter((t) => !t.dying);
shown += 1; shown += 1;
announce(storyOf(shown)); if (reviewing === null) announce(storyOf(shown));
if (shown < v.moves.length) await wait(260); if (shown < v.moves.length) await wait(260);
if (mine !== epoch) return; if (mine !== epoch) return;
} }
@@ -124,26 +124,41 @@
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []); const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []); const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
/** A move picked from the record puts the boards back as they stood after it, and brings its board into view. */ /** A move picked from the record puts the boards back as they stood after it, brings its board into view, and says so. */
function review(ply: number | null) { function review(ply: number | null) {
if (ply !== null && (ply < 1 || ply > g.moves.length)) return; if (ply !== null && (ply < 1 || ply > g.moves.length)) return;
const step = (ply ?? g.moves.length) - shown; const step = (ply ?? g.moves.length) - shown;
const had = document.activeElement;
reviewing = ply; reviewing = ply;
selected = null; selected = null;
inspected = null; inspected = null;
// One move on plays itself; any other leap sets the pieces out where they stood. // One move on plays itself; any other leap sets the pieces out where they stood.
if (step !== 1) resetTokens(); if (step !== 1) resetTokens();
if (ply === null) return; announce(ply === null ? `Back to the game as it stands, after move ${g.moves.length}. ${liveLine}` : `The boards after move ${ply} of ${g.moves.length}. ${storyOf(ply)}`);
const m = g.moves[ply - 1]; const m = ply === null ? null : g.moves[ply - 1];
if (oneBoard && !onGlass) { if (m && oneBoard && !onGlass) level = levelOf(m.to);
level = levelOf(m.to); void tick().then(() => {
return; // A button that went with the mode, or went grey under the keyboard, leaves it nowhere: it goes back to the boards, or stays with the review.
const lost = document.activeElement === document.body || (document.activeElement === had && had instanceof HTMLButtonElement && had.disabled);
if (lost) {
if (ply === null) focusBoards();
else backToNow?.focus();
} }
requestAnimationFrame(() => { if (!m) return;
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`); const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
target?.scrollIntoView({ block: phone ? 'center' : 'nearest', behavior: calm ? 'auto' : 'smooth' }); if (!target) return;
if (phone) scrollUnderStrip(target);
else target.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
}); });
} }
let backToNow = $state<HTMLButtonElement | null>(null);
let artwork = $state<ReturnType<typeof BoardArtwork>>();
let glassWay = $state<HTMLButtonElement | null>(null);
/** The keyboard goes to the boards: the grid's own square, or, on the glass, the way back to the flat boards. */
function focusBoards() {
if (artwork) artwork.focusGrid();
else glassWay?.focus();
}
// --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time --- // --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time ---
let phone = $state(false); let phone = $state(false);
@@ -175,9 +190,12 @@
return; return;
} }
const world = document.querySelector(`.play g.world[data-level="${lv}"]`); const world = document.querySelector(`.play g.world[data-level="${lv}"]`);
if (world) scrollUnderStrip(world);
}
/** Scrolls a board to sit just under the status strip, which stays in view above it. */
function scrollUnderStrip(el: Element) {
const strip = document.querySelector('.play .status'); const strip = document.querySelector('.play .status');
if (!world) return; const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
const top = world.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
scrollTo({ top, behavior: calm ? 'auto' : 'smooth' }); scrollTo({ top, behavior: calm ? 'auto' : 'smooth' });
} }
@@ -212,6 +230,14 @@
} else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.'; } else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.';
return line; return line;
}); });
/** The game as it stands, in a few words: shown beside a position under review, and said when the review ends. */
const liveLine = $derived.by(() => {
if (g.over) return `${verdict} ${g.over.reason}`;
if (room.spectating) return `${nameOfSide(g.toMove)} to move.`;
if (yourMove) return `Your move${inCheckNow ? ', and your King is in check' : ''}.`;
if (room.view.playingOn) return 'The drakes play on.';
return `${nameOfSide(g.toMove)} is thinking.`;
});
// --- Choosing and moving --- // --- Choosing and moving ---
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
@@ -272,7 +298,6 @@
if (reviewing !== null) { if (reviewing !== null) {
// The boards are showing an earlier position: a tap brings them back to now, and nothing more. // The boards are showing an earlier position: a tap brings them back to now, and nothing more.
review(null); review(null);
announce('Back to the game as it stands.');
return; return;
} }
if (!yourMove) { if (!yourMove) {
@@ -347,7 +372,7 @@
} }
} }
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length; const pieceCount = (side: Side) => v.cells.filter((p) => p !== '' && sideOf(p) === side).length;
</script> </script>
<main class="board"> <main class="board">
@@ -357,6 +382,11 @@
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong> <strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button> <button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button> <button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
{:else if reviewing !== null}
<strong>After move {reviewing} of {g.moves.length}</strong> · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0}&nbsp;· <span class="check">in check</span>{/if} ·
<span class="marked"><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" bind:this={backToNow} onclick={() => review(null)}>Back to now</button></span>
{:else if g.over} {:else if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span> <strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating} {:else if room.spectating}
@@ -371,11 +401,8 @@
{#if phone && !onGlass && !pending} {#if phone && !onGlass && !pending}
<span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span> <span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span>
{/if} {/if}
{#if reviewing !== null && !pending} {#if reviewing !== null}
<span class="marked">The boards as they stood after move {reviewing} of {g.moves.length}. <span class="now muted">The game now, after move {g.moves.length}: {liveLine}</span>
<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} {/if}
</div> </div>
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
@@ -401,13 +428,13 @@
</div> </div>
{/if} {/if}
<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> <p class="muted small glass-way"><button type="button" class="link plain" bind:this={glassWay} 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 fault}<p class="warning">{fault}</p>{/if}
{#if onGlass && Glass} {#if onGlass && Glass}
<Glass g={v} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} /> <Glass g={v} {mySide} {showCoords} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
{:else} {:else}
<div class="frame"> <div class="frame">
<BoardArtwork g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} /> <BoardArtwork bind:this={artwork} g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div> </div>
{/if} {/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p> <p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -427,14 +454,14 @@
<h2>At the table</h2> <h2>At the table</h2>
<ul> <ul>
{#each g.seats as id (id)} {#each g.seats as id (id)}
<li class:to-move={g.players[id].side === g.toMove && !g.over}> <li class:to-move={g.players[id].side === v.toMove && !(g.over && reviewing === null)}>
<span class="dot {g.players[id].side}"></span> <span class="dot {g.players[id].side}"></span>
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span> <span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span> <span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
</li> </li>
{/each} {/each}
</ul> </ul>
<p class="muted small">Move {g.moves.length + 1}.</p> <p class="muted small">{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}</p>
</section> </section>
<Chronicle {tellings} {mySide} {reviewing} onReview={review} /> <Chronicle {tellings} {mySide} {reviewing} onReview={review} />
<TableTalk {room} /> <TableTalk {room} />
@@ -494,9 +521,14 @@
} }
.marked { .marked {
display: inline-block;
margin-left: 0.2rem;
color: var(--frost);
}
.now {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;
color: var(--frost);
} }
/* The jumps: one a board, and a mark on any board the chosen piece can reach. */ /* The jumps: one a board, and a mark on any board the chosen piece can reach. */
+4
View File
@@ -102,6 +102,10 @@
for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c; for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c;
return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0]; return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0];
}); });
/** Puts the keyboard on the grid, at the square Tab would reach. */
export function focusGrid() {
focusSquare(entry);
}
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n]; const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n); const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
const describe = (i: number) => { const describe = (i: number) => {
+58 -9
View File
@@ -17,7 +17,7 @@
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FILES, RANKS, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, type Kind, type Side, type State } from '$lib/game'; import { FILES, RANKS, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, type Kind, type Side, type State } from '$lib/game';
let { g, mySide = null, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, calm = true, tap = () => {} }: { let { g, mySide = null, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, calm = true, showCoords = true, tap = () => {} }: {
g: State; g: State;
mySide?: Side | null; mySide?: Side | null;
selected?: number | null; selected?: number | null;
@@ -27,6 +27,8 @@
shownCaptured?: number[]; shownCaptured?: number[];
checkSquare?: number; checkSquare?: number;
calm?: boolean; calm?: boolean;
/** Whether the sheets' rims carry the files' letters and the ranks' numbers, as the flat boards do. */
showCoords?: boolean;
tap?: (i: number) => void | Promise<void>; tap?: (i: number) => void | Promise<void>;
} = $props(); } = $props();
@@ -41,8 +43,16 @@
/** Air between the sheets, in squares: enough for a Dragon's wings. */ /** Air between the sheets, in squares: enough for a Dragon's wings. */
const GAP = 4.2; const GAP = 4.2;
const CELL = 1; const CELL = 1;
/** The sheet's rim beyond the outer squares, in squares. */ /** The sheet's rim beyond the outer squares, in squares: room for the square names. */
const RIM = 0.3; const RIM = 0.45;
/** The ink the rim's letters are written in, dark on the pale sheets and pale on the red one. */
const INK: Record<number, string> = { 3: '#2a3b48', 2: '#33391f', 1: '#f3dfd0' };
/** Repaints the sheets when the preference for square names changes; set once the sheets exist. */
let repaint: (() => void) | null = null;
$effect(() => {
void showCoords;
repaint?.();
});
const levelY = (level: number) => (level - 1) * GAP; const levelY = (level: number) => (level - 1) * GAP;
/** Where a square's centre sits: Gold's side towards +z, file a at -x. */ /** Where a square's centre sits: Gold's side towards +z, file a at -x. */
@@ -106,12 +116,11 @@
// The sheets, tinted by level, and the posts that hold them. // The sheets, tinted by level, and the posts that hold them.
const slabs: THREE.Mesh[] = []; const slabs: THREE.Mesh[] = [];
for (const level of [1, 2, 3]) { const sheets: { level: number; c: HTMLCanvasElement; map: THREE.CanvasTexture }[] = [];
// The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures. // The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures.
const PX = 40; // The canvas's bottom edge is Gold's, rank 1; its left edge is file a.
const c = document.createElement('canvas'); const PX = 64;
c.width = (FILES + 2 * RIM) * PX; function paint(level: number, c: HTMLCanvasElement) {
c.height = (RANKS + 2 * RIM) * PX;
const ctx = c.getContext('2d')!; const ctx = c.getContext('2d')!;
ctx.fillStyle = SQUARES[level][1]; ctx.fillStyle = SQUARES[level][1];
ctx.fillRect(0, 0, c.width, c.height); ctx.fillRect(0, 0, c.width, c.height);
@@ -119,8 +128,47 @@
ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1]; ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1];
ctx.fillRect((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX); ctx.fillRect((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX);
} }
if (!showCoords) return;
// The names run along every edge, the far ones turned about, so that each seat reads its own.
ctx.fillStyle = INK[level];
ctx.font = `600 ${Math.round(PX * 0.34)}px 'Iowan Old Style', Georgia, serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const inset = (RIM * PX) / 2;
const write = (text: string, x: number, y: number, turned: boolean) => {
ctx.save();
ctx.translate(x, y);
if (turned) ctx.rotate(Math.PI);
ctx.fillText(text, 0, 1);
ctx.restore();
};
for (let f = 0; f < FILES; f++) {
const letter = 'abcdefghijkl'[f];
const x = (RIM + f + 0.5) * PX;
write(letter, x, c.height - inset, false);
write(letter, x, inset, true);
}
for (let r = 0; r < RANKS; r++) {
const y = (RIM + RANKS - 1 - r + 0.5) * PX;
write(String(r + 1), inset, y, false);
write(String(r + 1), c.width - inset, y, true);
}
}
repaint = () => {
for (const sh of sheets) {
paint(sh.level, sh.c);
sh.map.needsUpdate = true;
}
};
for (const level of [1, 2, 3]) {
const c = document.createElement('canvas');
c.width = (FILES + 2 * RIM) * PX;
c.height = (RANKS + 2 * RIM) * PX;
paint(level, c);
const map = new THREE.CanvasTexture(c); const map = new THREE.CanvasTexture(c);
map.colorSpace = THREE.SRGBColorSpace; map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = 8;
sheets.push({ level, c, map });
const slab = new THREE.Mesh( const slab = new THREE.Mesh(
new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL), new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL),
new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0, clearcoat: 1 }) new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0, clearcoat: 1 })
@@ -142,7 +190,7 @@
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone); const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone);
tip.position.y = GAP * 2 + 1.9; tip.position.y = GAP * 2 + 1.9;
group.add(post, tip); group.add(post, tip);
group.position.set(x * (FILES / 2 + 0.6), 0, z * (RANKS / 2 + 0.6)); group.position.set(x * (FILES / 2 + 0.75), 0, z * (RANKS / 2 + 0.75));
scene.add(group); scene.add(group);
posts.push({ group, stone }); posts.push({ group, stone });
} }
@@ -471,6 +519,7 @@
return () => { return () => {
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom }; kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
repaint = null;
removeEventListener('resize', resize); removeEventListener('resize', resize);
watcher.disconnect(); watcher.disconnect();
controls.dispose(); controls.dispose();