From 8c03fd624c12c74915a481aa2b255d51f5a4d96f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 28 Oct 2026 09:36:20 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx --- src/lib/components/Board.svelte | 82 ++++++++++++++++++-------- src/lib/components/BoardArtwork.svelte | 4 ++ src/lib/components/Glass.svelte | 69 ++++++++++++++++++---- 3 files changed, 120 insertions(+), 35 deletions(-) diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index d3e3697..1b0dac2 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -89,7 +89,7 @@ if (mine !== epoch) return; if (victim) tokens = tokens.filter((t) => !t.dying); shown += 1; - announce(storyOf(shown)); + if (reviewing === null) announce(storyOf(shown)); if (shown < v.moves.length) await wait(260); if (mine !== epoch) return; } @@ -124,26 +124,41 @@ 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 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) { if (ply !== null && (ply < 1 || ply > g.moves.length)) return; const step = (ply ?? g.moves.length) - shown; + const had = document.activeElement; 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]; - if (oneBoard && !onGlass) { - level = levelOf(m.to); - return; - } - requestAnimationFrame(() => { + 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 = ply === null ? null : g.moves[ply - 1]; + if (m && oneBoard && !onGlass) level = levelOf(m.to); + void tick().then(() => { + // 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(); + } + if (!m) return; 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(null); + let artwork = $state>(); + let glassWay = $state(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 --- let phone = $state(false); @@ -175,9 +190,12 @@ return; } 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'); - if (!world) return; - const top = world.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14); + const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14); 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.'; 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 --- let selected = $state(null); @@ -272,7 +298,6 @@ 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) { @@ -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;
@@ -357,6 +382,11 @@ Move {squareName(pending.from)} to {squareName(pending.to)}? + {:else if reviewing !== null} + After move {reviewing} of {g.moves.length} · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0} · in check{/if} · + · + · + {:else if g.over} {verdict} {g.over.reason} {:else if room.spectating} @@ -371,11 +401,8 @@ {#if phone && !onGlass && !pending} {#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}{/each} {/if} - {#if reviewing !== null && !pending} - The boards as they stood after move {reviewing} of {g.moves.length}. - · - · - + {#if reviewing !== null} + The game now, after move {g.moves.length}: {liveLine} {/if} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)} @@ -401,13 +428,13 @@ {/if} -

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

+

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

{#if fault}

{fault}

{/if} {#if onGlass && Glass} - + {:else}
- (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} /> + (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
{/if}

{liveText}

@@ -427,14 +454,14 @@

At the table

    {#each g.seats as id (id)} -
  • +
  • {g.players[id].name}{id === room.me ? ' (you)' : ''} {SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces
  • {/each}
-

Move {g.moves.length + 1}.

+

{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}

@@ -494,9 +521,14 @@ } .marked { + display: inline-block; + margin-left: 0.2rem; + color: var(--frost); + } + + .now { display: block; margin-top: 0.2rem; - color: var(--frost); } /* The jumps: one a board, and a mark on any board the chosen piece can reach. */ diff --git a/src/lib/components/BoardArtwork.svelte b/src/lib/components/BoardArtwork.svelte index f48debb..78627a6 100644 --- a/src/lib/components/BoardArtwork.svelte +++ b/src/lib/components/BoardArtwork.svelte @@ -102,6 +102,10 @@ 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]; }); + /** 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 rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n); const describe = (i: number) => { diff --git a/src/lib/components/Glass.svelte b/src/lib/components/Glass.svelte index bceaa52..d718732 100644 --- a/src/lib/components/Glass.svelte +++ b/src/lib/components/Glass.svelte @@ -17,7 +17,7 @@ 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'; - 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; mySide?: Side | null; selected?: number | null; @@ -27,6 +27,8 @@ shownCaptured?: number[]; checkSquare?: number; 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; } = $props(); @@ -41,8 +43,16 @@ /** Air between the sheets, in squares: enough for a Dragon's wings. */ const GAP = 4.2; const CELL = 1; - /** The sheet's rim beyond the outer squares, in squares. */ - const RIM = 0.3; + /** The sheet's rim beyond the outer squares, in squares: room for the square names. */ + 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 = { 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; /** 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. const slabs: THREE.Mesh[] = []; - for (const level of [1, 2, 3]) { - // The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures. - const PX = 40; - const c = document.createElement('canvas'); - c.width = (FILES + 2 * RIM) * PX; - c.height = (RANKS + 2 * RIM) * PX; + 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 canvas's bottom edge is Gold's, rank 1; its left edge is file a. + const PX = 64; + function paint(level: number, c: HTMLCanvasElement) { const ctx = c.getContext('2d')!; ctx.fillStyle = SQUARES[level][1]; ctx.fillRect(0, 0, c.width, c.height); @@ -119,8 +128,47 @@ ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1]; 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); map.colorSpace = THREE.SRGBColorSpace; + map.anisotropy = 8; + sheets.push({ level, c, map }); const slab = new THREE.Mesh( 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 }) @@ -142,7 +190,7 @@ const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone); tip.position.y = GAP * 2 + 1.9; 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); posts.push({ group, stone }); } @@ -471,6 +519,7 @@ return () => { kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom }; + repaint = null; removeEventListener('resize', resize); watcher.disconnect(); controls.dispose();