Compare commits

..
4 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 93463a529b The glass board's figures are cached for a week: fifteen files that seldom change no longer go back to the server on every opening of the view
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-28 10:06:22 -04:00
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
Eric WagonerandClaude Fable 5.1 f2be3b0055 From the two testers' lists: on a phone the status strip carries a jump to each board, with a mark on any board the chosen piece can reach, and a phone on its side shows one board at a time, as wide as the screen, with Page Up and Page Down bringing the others out; a figure held by a Basilisk stands in a pale ring on the glass; the hall says which side and setup are chosen in one line and keeps the choices behind it until asked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 23:21:27 -04:00
Eric WagonerandClaude Fable 5.1 0f4ae0dca4 A move picked from the chronicle puts the boards back as they stood after it, on the flat boards and the glass alike, with Earlier, Later and Back to now in the status strip; one move on plays itself, any other leap sets the pieces out where they stood, and a tap on the boards brings the game back to now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 23:11:52 -04:00
7 changed files with 267 additions and 46 deletions

No files matched your search

+6 -3
View File
@@ -36,12 +36,15 @@ __HOST__ {
# Everything else is the static site. SvelteKit writes each route as
# <route>.html, so /rules is tried as /rules.html before falling back to the
# app shell, which serves the rooms. Hashed assets under _app/immutable are
# cached for a year; every other response is revalidated so a deploy shows
# up on the next load.
# cached for a year, and the glass board's figures, which weigh a few
# megabytes and seldom change, for a week; every other response is
# revalidated so a deploy shows up on the next load.
handle {
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/*
@figures path /figures/*
header @figures Cache-Control "public, max-age=604800"
@mutable not path /_app/immutable/* /figures/*
header @mutable Cache-Control "no-cache"
try_files {path} {path}.html /index.html
file_server
+161 -25
View File
@@ -44,29 +44,32 @@
};
const flipped = $derived(mySide === 'scarlet');
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
let tokens = $state<Token[]>([]);
/** How many of the game's moves the tokens have played through. */
/** How many of the moves the tokens have played through. */
let shown = $state(0);
let animating = $state(false);
/** Bumped when the tokens are set out afresh, so a catch-up in flight lets go of them. */
let epoch = 0;
const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false);
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
function resetTokens() {
epoch += 1;
let id = 0;
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = g.moves.length;
tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = v.moves.length;
}
async function catchUp() {
if (animating) return;
animating = true;
const mine = epoch;
try {
while (shown < g.moves.length) {
const m = g.moves[shown];
while (shown < v.moves.length) {
const m = v.moves[shown];
const mover = tokens.find((t) => t.at === m.from && !t.dying);
if (!mover) {
resetTokens();
@@ -80,12 +83,15 @@
if (victim) victim.dying = true;
mover.at = m.to;
await wait(380);
if (mine !== epoch) return;
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
}
if (mine !== epoch) return;
if (victim) tokens = tokens.filter((t) => !t.dying);
shown += 1;
announce(storyOf(shown));
if (shown < g.moves.length) await wait(260);
if (reviewing === null) announce(storyOf(shown));
if (shown < v.moves.length) await wait(260);
if (mine !== epoch) return;
}
} finally {
animating = false;
@@ -93,33 +99,105 @@
}
$effect(() => {
const total = g.moves.length;
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
const total = v.moves.length;
if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
else if (total > shown) void catchUp();
});
// --- What the boards show: the last move played, or a move picked from the record ---
// --- What the boards show: the game as it stands, or as it stood after a move picked from the record ---
let reviewing = $state<number | null>(null);
$effect(() => {
void g.moves.length;
reviewing = null;
selected = null;
});
/** The state the boards draw: the game's own, or the position after the move under review, with that many moves played. */
const v = $derived.by((): typeof g => {
if (reviewing === null) return g;
const t = tellings[reviewing - 1];
return { ...g, cells: t.after, moves: g.moves.slice(0, reviewing), toMove: t.move.by === 'gold' ? 'scarlet' : 'gold' };
});
/** The King in check on the boards as drawn: the game's, or the position under review. */
const checkSquare = $derived(reviewing !== null ? (inCheck(v.cells, v.toMove) ? kingSquare(v.cells, v.toMove) : -1) : inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
const shownPly = $derived(reviewing ?? shown);
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
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 lights its squares on the boards as they stand; the pieces stay where they are. */
/** 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;
if (ply === null) return;
const m = g.moves[ply - 1];
requestAnimationFrame(() => {
selected = null;
inspected = null;
// One move on plays itself; any other leap sets the pieces out where they stood.
if (step !== 1) resetTokens();
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: matchMedia('(max-width: 860px)').matches ? '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<HTMLButtonElement | null>(null);
let artwork = $state<ReturnType<typeof BoardArtwork>>();
let glassWay = $state<HTMLButtonElement | null>(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);
let oneBoard = $state(false);
$effect(() => {
const narrow = matchMedia('(max-width: 860px)');
const short = matchMedia('(max-width: 1000px) and (max-height: 540px)');
const read = () => {
phone = narrow.matches;
oneBoard = short.matches;
};
read();
narrow.addEventListener('change', read);
short.addEventListener('change', read);
return () => {
narrow.removeEventListener('change', read);
short.removeEventListener('change', read);
};
});
/** The board shown when there is room for one at a time; a move made on another brings that one forward. */
let level = $state(2);
$effect(() => {
const m = g.moves.at(-1);
if (m) level = levelOf(m.to);
});
function goLevel(lv: number) {
if (oneBoard) {
level = lv;
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');
const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
scrollTo({ top, behavior: calm ? 'auto' : 'smooth' });
}
/** The game told move by move, replayed from the ledger's moves. */
const tellings = $derived(narrate(g));
@@ -152,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<number | null>(null);
@@ -209,7 +295,11 @@
async function tap(i: number) {
if (room.sending || animating) return;
reviewing = null;
if (reviewing !== null) {
// The boards are showing an earlier position: a tap brings them back to now, and nothing more.
review(null);
return;
}
if (!yourMove) {
// Not your move, or no seat: a tap on a piece still opens its panel.
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
@@ -282,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;
</script>
<main class="board">
@@ -292,6 +382,11 @@
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
{:else if reviewing !== null}
<strong>After move {reviewing} of {g.moves.length}</strong> · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0}&nbsp;· <span class="check">in check</span>{/if} ·
<span class="marked"><button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
<button type="button" class="link" disabled={reviewing >= g.moves.length} onclick={() => review(reviewing! + 1)}>Later</button> ·
<button type="button" class="link" bind:this={backToNow} onclick={() => review(null)}>Back to now</button></span>
{:else if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating}
@@ -303,8 +398,11 @@
{:else}
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
{/if}
{#if reviewing !== null && !pending}
<span class="marked">Move {reviewing} is lit on the boards as they stand now; no piece has moved back. <button type="button" class="link" onclick={() => review(null)}>Clear it</button></span>
{#if phone && !onGlass && !pending}
<span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span>
{/if}
{#if reviewing !== null}
<span class="now muted">The game now, after move {g.moves.length}: {liveLine}</span>
{/if}
</div>
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
@@ -330,13 +428,13 @@
</div>
{/if}
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
<p class="muted small glass-way"><button type="button" class="link plain" bind:this={glassWay} disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
{#if fault}<p class="warning">{fault}</p>{/if}
{#if onGlass && Glass}
<Glass {g} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
<Glass g={v} {mySide} {showCoords} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
{:else}
<div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
<BoardArtwork bind:this={artwork} g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div>
{/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p>
@@ -356,14 +454,14 @@
<h2>At the table</h2>
<ul>
{#each g.seats as id (id)}
<li class:to-move={g.players[id].side === g.toMove && !g.over}>
<li class:to-move={g.players[id].side === v.toMove && !(g.over && reviewing === null)}>
<span class="dot {g.players[id].side}"></span>
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
</li>
{/each}
</ul>
<p class="muted small">Move {g.moves.length + 1}.</p>
<p class="muted small">{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}</p>
</section>
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
<TableTalk {room} />
@@ -423,11 +521,42 @@
}
.marked {
display: inline-block;
margin-left: 0.2rem;
color: var(--frost);
}
.now {
display: block;
margin-top: 0.2rem;
}
/* The jumps: one a board, and a mark on any board the chosen piece can reach. */
.jump {
display: flex;
gap: 1rem;
margin-top: 0.25rem;
font-size: 0.85rem;
}
.jump .current {
color: var(--ember);
}
.jump .lit::after {
content: ' ●';
font-size: 0.6em;
vertical-align: middle;
color: var(--frost);
}
.marked .link:disabled {
color: var(--bone-faint);
text-decoration: none;
cursor: default;
}
.check {
color: var(--ember);
font-weight: 600;
@@ -454,6 +583,13 @@
margin: 0 auto;
}
/* A phone on its side has no height for three boards: one shows at a time, as wide as the screen allows. */
@media (max-width: 1000px) and (max-height: 540px) {
.frame {
max-width: none;
}
}
.glass-way {
max-width: 640px;
margin: 0 auto 0.5rem;
+20 -3
View File
@@ -15,10 +15,11 @@
// 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 = () => {}
calm = true, decorative = false, tap = () => {}, escape = () => {}, climb
}: {
g: State;
tokens?: Token[];
@@ -40,9 +41,12 @@
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. */
@@ -57,7 +61,13 @@
const f = fileOf(i) + df;
const r = rankOf(i) + dr;
const lv = levelOf(i) + dl;
if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector<SVGRectElement>(`rect.square[data-i="${idx(lv, f, r)}"]`)?.focus();
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);
@@ -88,7 +98,14 @@
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(visited ?? selected ?? (levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0]));
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) => {
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
// The game told top to bottom, oldest first, each move with what came of
// it, and the newest scrolled into view. A move's heading is a button that
// shows that move on the boards.
// puts the boards back as they stood after it.
import { tick } from 'svelte';
import type { Telling } from '$lib/game/chronicle';
import { notation, type Side } from '$lib/game';
@@ -25,7 +25,7 @@
{#each tellings as t (t.ply)}
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
<h3>
<button type="button" class="link plain" title="Light this move's squares on the boards as they stand" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
<button type="button" class="link plain" title="Show the boards as they stood after this move" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
Move {t.ply} <span class="notation">{notation(t.move)}</span>
</button>
</h3>
+62 -11
View File
@@ -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<void>;
} = $props();
@@ -37,12 +39,20 @@
/** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */
const SQUARES: Record<number, [string, string]> = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] };
const LIVERY: Record<Side, string> = { gold: '#d8a63e', scarlet: '#a4302a' };
const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c' };
const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c', frozen: '#b9c9d2' };
/** 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<number, string> = { 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 });
}
@@ -447,6 +495,8 @@
});
$effect(() => {
marks.clear();
// A figure held by a Basilisk stands in a pale ring, since its dimmed livery is easy to miss among forty.
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && frozen(g.cells, i) && i !== selected) mark(i, 'ring', MARK.frozen, 0.8);
for (const i of reach) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.reach, 0.85);
for (const i of shownSquares) mark(i, 'halo', MARK.last);
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
@@ -469,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();
+13 -1
View File
@@ -24,6 +24,8 @@
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
);
let prefsOpen = $state(false);
/** The side and the Sylphs' setup wait behind one line until asked for. */
let setupOpen = $state(false);
/** The opening as the host's choices lay it, drawn beside the buttons. */
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
let busy = $state(false);
@@ -218,7 +220,9 @@
</label>
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
{#if game.options?.length}
<div class="options">
{@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)}
<p class="setup muted small">{chosenLabels.join(' ')} <button type="button" class="link plain" aria-expanded={setupOpen} onclick={() => (setupOpen = !setupOpen)}>{setupOpen ? 'Done' : 'Change'}</button></p>
<div class="options" hidden={!setupOpen}>
{#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div>
@@ -660,6 +664,14 @@
margin: 0.2rem 0 0.6rem;
}
.options[hidden] {
display: none;
}
.setup {
margin: 0.2rem 0 0.6rem;
}
.option {
display: flex;
align-items: center;
+3 -1
View File
@@ -20,6 +20,8 @@ export interface Telling {
ply: number;
move: Move;
lines: Line[];
/** The boards as they stood once the move was made. */
after: Cell[];
}
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
@@ -127,7 +129,7 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
state.moves.forEach((move, n) => {
const { lines, after } = tellOne(cells, move, owner);
cells = after;
out.push({ ply: n + 1, move, lines });
out.push({ ply: n + 1, move, lines, after });
});
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
return out;