The table, the hall, the rules and the guide: three boards drawn in Gygax's colours, a drake to play, the article in full, and the pages that walk a player in

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-26 16:39:50 -04:00
1 parent 1ef5b23712
commit b9efe304a0
29 files changed
+1116 -333

No files matched your search

+346
View File
@@ -0,0 +1,346 @@
<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 hall's
// still of the opening and the share artwork.
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, type State } from '$lib/game';
let {
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {}
}: {
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[];
shownSquares?: number[];
shownCaptured?: number[];
checkSquare?: number;
yourMove?: boolean;
calm?: boolean;
decorative?: boolean;
tap?: (i: number) => void | Promise<void>;
key?: (e: KeyboardEvent, i: number) => void;
} = $props();
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);
const WORLD: Record<number, string> = { 3: 'The air', 2: 'The land', 1: 'The underworld' };
/** 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 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 += `, ${sideOf(piece) === 'gold' ? 'Gold' : 'Scarlet'} ${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" 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 : yourMove ? 0 : -1}
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 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;
}
.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>