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:
1 parent
1ef5b23712
commit
b9efe304a0
29 files changed
+1116
-333
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user