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();