Files
wyrm/src/lib/components/BoardArtwork.svelte
T
Eric WagonerandClaude Fable 5.1 8c03fd624c 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
2026-10-28 09:36:20 -04:00

406 lines
11 KiB
Svelte

<script module lang="ts">
import type { Cell } from '$lib/game';
/** A piece with a life of its own, so a move slides and a capture fades. */
export interface Token {
id: number;
piece: Cell;
at: number;
dying: boolean;
}
</script>
<script lang="ts">
// The three boards, drawn one above the other: the air, the land and the
// underworld, each twelve by eight in the colours Gygax suggested, with
// the pieces as discs bearing his letters. Shared by the table, the
// lessons and the hall's still of the opening.
import { tick } from 'svelte';
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
let {
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
calm = true, decorative = false, tap = () => {}, escape = () => {}, climb
}: {
g: State;
tokens?: Token[];
/** Which boards to draw, top first. */
levels?: number[];
showCoords?: boolean;
/** Scarlet's view: rank 8 nearest, file l on the left. */
flipped?: boolean;
selected?: number | null;
targets?: number[];
/** Where a piece being read about could go: shown faintly, since it is not the viewer's to move. */
reach?: number[];
shownSquares?: number[];
shownCaptured?: number[];
checkSquare?: number;
calm?: boolean;
/** An illustration: no roles, no tab stops, nothing to tap. */
decorative?: boolean;
tap?: (i: number) => void | Promise<void>;
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
escape?: () => void;
/** Page Up or Page Down towards a board that is not drawn: the caller may bring it out. */
climb?: (level: number) => void;
} = $props();
let svg: SVGSVGElement | undefined = $state();
const focusSquare = (i: number) => svg?.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
/** The one square Tab reaches: the grid is entered once, at the square last visited, and the keys walk it from there. */
let visited = $state<number | null>(null);
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
function key(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = {
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
};
if (e.key in step) {
e.preventDefault();
const [df, dr, dl] = step[e.key];
const f = fileOf(i) + df;
const r = rankOf(i) + dr;
const lv = levelOf(i) + dl;
if (f < 0 || f >= FILES || r < 0 || r >= RANKS || lv < 1 || lv > 3) return;
const to = idx(lv, f, r);
if (levels.includes(lv)) focusSquare(to);
else if (climb) {
climb(lv);
void tick().then(() => focusSquare(to));
}
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void tap(i);
} else if (e.key === 'Escape') escape();
}
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
const CELL = 40;
const PAD_L = $derived(showCoords ? 18 : 6);
const PAD_R = 6;
const PAD_T = 20;
const PAD_B = $derived(showCoords ? 16 : 6);
const BOARD_W = FILES * CELL;
const BOARD_H = RANKS * CELL;
const GAP = 14;
const width = $derived(PAD_L + BOARD_W + PAD_R);
const boardHeight = $derived(PAD_T + BOARD_H + PAD_B);
const height = $derived(levels.length * boardHeight + (levels.length - 1) * GAP);
/** Each board's title carries its number, the first character of every square name: 3b4 is the air, file b, rank 4. */
const WORLD: Record<number, string> = { 3: 'The air \u00b7 board 3', 2: 'The land \u00b7 board 2', 1: 'The underworld \u00b7 board 1' };
/** Where a cell sits on the drawing: its board's slot, then file and rank from the viewer's side. */
function place(i: number): { x: number; y: number } | null {
const slot = levels.indexOf(levelOf(i));
if (slot < 0) return null;
const f = flipped ? FILES - 1 - fileOf(i) : fileOf(i);
const r = flipped ? rankOf(i) : RANKS - 1 - rankOf(i);
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
}
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
const entry = $derived.by(() => {
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) => {
const piece = g.cells[i];
let text = squareName(i);
if (piece !== '') text += `, ${SIDE_NAMES[sideOf(piece)!]} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
if (targets.includes(i)) text += ', a legal move';
if (i === checkSquare) text += ', in check';
return text;
};
</script>
<svg xmlns="http://www.w3.org/2000/svg" bind:this={svg} class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}>
{#each levels as lv, slot (lv)}
{@const top = slot * (boardHeight + GAP)}
<g class="world" data-level={lv}>
<rect class="ground" x="1" y={top + 1} width={width - 2} height={boardHeight - 2} rx="5" />
<text class="title" x={PAD_L} y={top + 14}>{WORLD[lv]}</text>
{#if showCoords}
{#each Array.from({ length: FILES }, (_, n) => n) as n (n)}
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={top + boardHeight - 4} text-anchor="middle">{fileLetter(n)}</text>
{/each}
{#each Array.from({ length: RANKS }, (_, n) => n) as n (n)}
<text class="coord" x={PAD_L - 4} y={top + PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{rankNumber(n)}</text>
{/each}
{/if}
</g>
{/each}
<g>
{#each drawn as i (i)}
{@const p = place(i)!}
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<rect
class="square"
class:light={isLight(i)}
class:last={shownSquares.includes(i)}
class:target={targets.includes(i)}
class:fallen={shownCaptured.includes(i)}
class:check={i === checkSquare}
data-i={i}
data-level={levelOf(i)}
x={p.x}
y={p.y}
width={CELL}
height={CELL}
role={decorative ? undefined : 'gridcell'}
aria-label={decorative ? undefined : describe(i)}
aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : entry === i ? 0 : -1}
onfocus={decorative ? undefined : () => (visited = i)}
onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)}
/>
{/each}
{#each artworkTokens as t (t.id)}
{@const p = place(t.at)}
{#if p}
<g
class="piece {sideOf(t.piece)}"
class:selected={selected === t.at}
class:dying={t.dying}
class:frozen={!t.dying && g.cells[t.at] === t.piece && frozen(g.cells, t.at)}
style="transform: translate({p.x + CELL / 2}px, {p.y + CELL / 2}px)"
pointer-events="none"
>
<circle class="shadow" cx="1" cy="2" r="15" />
<circle class="body" r="14.5" />
<circle class="rim" r="12.2" />
<text class="letter" class:wide={LETTERS[kindOf(t.piece)].length > 1} y="5.5" text-anchor="middle">{LETTERS[kindOf(t.piece)]}</text>
<circle class="ice" r="16.5" />
</g>
{/if}
{/each}
{#each reach as i (i)}
{@const p = place(i)}
{#if p}
<rect class="reach" x={p.x + 4} y={p.y + 4} width={CELL - 8} height={CELL - 8} rx="3" pointer-events="none" />
{/if}
{/each}
{#each targets as i (i)}
{@const p = place(i)}
{#if p}
{#if g.cells[i] === ''}
<circle class="hint" cx={p.x + CELL / 2} cy={p.y + CELL / 2} r={CELL * 0.13} pointer-events="none" />
{:else}
<rect class="hint-ring" x={p.x + 2} y={p.y + 2} width={CELL - 4} height={CELL - 4} rx="4" pointer-events="none" />
{/if}
{/if}
{/each}
</g>
</svg>
<style>
.artwork {
width: 100%;
height: auto;
display: block;
--air-light: #e4ecf2;
--air-dark: #9fb8cc;
--land-light: #e6d7a3;
--land-dark: #8fa66a;
--under-light: #c9937a;
--under-dark: #7a4a3a;
--ground: #2a2522;
--title: #c9bca5;
--coord: #d8cdb6;
--line: rgba(20, 14, 10, 0.35);
--gold: #d8a63e;
--gold-rim: #8a6218;
--gold-ink: #2b1d05;
--scarlet: #a4302a;
--scarlet-rim: #5e1612;
--scarlet-ink: #fbe9dc;
}
.ground {
fill: var(--ground);
stroke: rgba(255, 240, 210, 0.12);
stroke-width: 0.8;
}
.title {
fill: var(--title);
font-family: var(--font);
font-size: 10px;
font-style: italic;
letter-spacing: 0.04em;
pointer-events: none;
}
.coord {
fill: var(--coord);
font-family: var(--font);
font-size: 8.5px;
font-weight: 600;
pointer-events: none;
opacity: 0.8;
}
.square {
stroke: var(--line);
stroke-width: 0.5;
cursor: default;
transition: fill 0.3s ease;
}
.square[data-level='3'] {
fill: var(--air-dark);
}
.square[data-level='3'].light {
fill: var(--air-light);
}
.square[data-level='2'] {
fill: var(--land-dark);
}
.square[data-level='2'].light {
fill: var(--land-light);
}
.square[data-level='1'] {
fill: var(--under-dark);
}
.square[data-level='1'].light {
fill: var(--under-light);
}
.square.last {
fill: #86b5d6;
}
.square.last.light {
fill: #b7d6ea;
}
.square.target {
cursor: pointer;
}
.square.fallen,
.square.fallen.light {
fill: #d9736a;
}
.square.check,
.square.check.light {
fill: #e0484a;
}
.square:focus-visible {
outline: none;
stroke: var(--frost);
stroke-width: 2.5;
}
.hint {
fill: var(--frost);
opacity: 0.9;
}
.reach {
fill: none;
stroke: #1b1410;
stroke-width: 1.2;
stroke-dasharray: 3 2;
opacity: 0.55;
}
.hint-ring {
fill: none;
stroke: var(--frost);
stroke-width: 2.6;
opacity: 0.95;
}
.piece {
transition:
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;
}
.piece.dying {
opacity: 0;
}
.piece .shadow {
fill: #0d0906;
opacity: 0.3;
}
.piece .body {
fill: var(--gold);
stroke: var(--gold-rim);
stroke-width: 1;
}
.piece .rim {
fill: none;
stroke: var(--gold-rim);
stroke-width: 0.6;
opacity: 0.55;
}
.piece .letter {
fill: var(--gold-ink);
font-family: var(--font);
font-size: 15px;
font-weight: 700;
}
.piece .letter.wide {
font-size: 12.5px;
}
.piece.scarlet .body {
fill: var(--scarlet);
stroke: var(--scarlet-rim);
}
.piece.scarlet .rim {
stroke: var(--scarlet-ink);
}
.piece.scarlet .letter {
fill: var(--scarlet-ink);
}
.piece .ice {
fill: none;
stroke: #bfe6ff;
stroke-width: 1.6;
stroke-dasharray: 3 2.5;
opacity: 0;
}
.piece.frozen .ice {
opacity: 0.95;
}
.piece.frozen .body {
opacity: 0.6;
}
.piece.selected .body {
stroke: var(--frost);
stroke-width: 3;
}
.calm .piece,
.calm .square {
transition: none;
}
</style>