The glass board arrives in seconds: the figures' textures are 256 px (3.8 MB for all fifteen, down from 30), each kind takes its places as its file lands behind a progress bar, the toggle says it is fetching, and the squares sit exactly under the figures now that the checker covers the sheet's rim

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-27 09:39:59 -04:00
1 parent cf92c9bd27
commit 6be076e3fc
18 files changed
+62 -13

No files matched your search

+10 -2
View File
@@ -21,8 +21,16 @@
/** The glass board, loaded the first time it is asked for, so three.js stays out of every other visit. */
let Glass = $state<typeof import('./Glass.svelte').default | null>(null);
let onGlass = $state(false);
let fetching = $state(false);
async function seeGlass(open: boolean) {
if (open && !Glass) Glass = (await import('./Glass.svelte')).default;
if (open && !Glass) {
fetching = true;
try {
Glass = (await import('./Glass.svelte')).default;
} finally {
fetching = false;
}
}
onGlass = open;
}
@@ -211,7 +219,7 @@
{#if room.error}<p class="warning">{room.error}</p>{/if}
{#if glass.open}
<p class="muted small glass-way"><button type="button" class="link plain" onclick={() => seeGlass(!onGlass)}>{onGlass ? 'Back to the flat boards' : 'See the glass board'}</button></p>
<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></p>
{/if}
{#if onGlass && Glass}
<Glass {g} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
+42 -9
View File
@@ -36,6 +36,8 @@
/** 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;
const levelY = (level: number) => (level - 1) * GAP;
/** Where a square's centre sits: Gold's side towards +z, file a at -x. */
@@ -49,6 +51,8 @@
let frame: HTMLDivElement;
let canvas: HTMLCanvasElement;
let status = $state('Setting out the figures…');
/** How many of the fifteen kinds of figure have arrived. */
let arrived = $state(0);
let view = $state<'quarter' | 'seat' | 'above'>('quarter');
type Livery = Record<Side, { plain: THREE.Material; held: THREE.Material }>;
@@ -58,7 +62,7 @@
const figures = new Map<number, THREE.Group>();
/** How many of the game's moves the figures have played through. */
let played = -1;
let ready = false;
let ready = $state(false);
onMount(() => {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
@@ -89,18 +93,22 @@
// 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 * 32;
c.height = RANKS * 32;
c.width = (FILES + 2 * RIM) * PX;
c.height = (RANKS + 2 * RIM) * PX;
const ctx = c.getContext('2d')!;
ctx.fillStyle = SQUARES[level][1];
ctx.fillRect(0, 0, c.width, c.height);
for (let r = 0; r < RANKS; r++) for (let f = 0; f < FILES; f++) {
ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1];
ctx.fillRect(f * 32, (RANKS - 1 - r) * 32, 32, 32);
ctx.fillRect((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX);
}
const map = new THREE.CanvasTexture(c);
map.colorSpace = THREE.SRGBColorSpace;
const slab = new THREE.Mesh(
new THREE.BoxGeometry(FILES * CELL + 0.6, 0.12, RANKS * CELL + 0.6),
new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL),
new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.74, roughness: 0.15, metalness: 0, clearcoat: 1 })
);
slab.position.set(0, levelY(level) - 0.06, 0);
@@ -346,9 +354,16 @@
look(view);
controls.addEventListener('change', wake);
// Each kind's figures take their places as its file arrives, so the board fills in while the rest are fetched.
Promise.all(
(Object.keys(FIGURE) as Kind[]).map(
(kind) => new Promise<void>((ok, no) => loader.load(`/figures/${FIGURE[kind]}.glb`, (gltf) => { protos.set(kind, { scene: gltf.scene, livery: liveries(gltf.scene) }); ok(); }, undefined, no))
(kind) => new Promise<void>((ok, no) => loader.load(`/figures/${FIGURE[kind]}.glb`, (gltf) => {
protos.set(kind, { scene: gltf.scene, livery: liveries(gltf.scene) });
arrived++;
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && kindOf(g.cells[i]) === kind && !figures.has(i)) figure(i);
wake();
ok();
}, undefined, no))
)
).then(() => {
ready = true;
@@ -388,7 +403,12 @@
<div class="glass" bind:this={frame}>
<canvas bind:this={canvas} aria-label="The three boards as sheets of glass with the pieces standing on them. The flat boards beside this view are the place to play by keyboard."></canvas>
{#if status}<p class="status muted">{status}</p>{/if}
{#if status}
<div class="status" role="status">
<p>{status}</p>
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
</div>
{/if}
<p class="ways muted small">
<button type="button" class="link plain" class:current={view === 'quarter'} onclick={() => (view = 'quarter')}>From the corner</button> ·
<button type="button" class="link plain" class:current={view === 'seat'} onclick={() => (view = 'seat')}>From your seat</button> ·
@@ -413,9 +433,22 @@
.status {
position: absolute;
top: 1rem;
left: 1rem;
inset: 0 0 auto;
margin: 0;
padding: 2rem 1rem 0;
text-align: center;
}
.status p {
margin: 0 0 0.6rem;
font-size: 1.15rem;
color: var(--bone);
}
progress {
width: min(60%, 320px);
height: 6px;
accent-color: var(--ember);
}
.ways {
+10 -2
View File
@@ -12,8 +12,16 @@
/** The glass board, loaded the first time it is asked for. */
let Glass = $state<typeof import('$lib/components/Glass.svelte').default | null>(null);
let onGlass = $state(false);
let fetching = $state(false);
async function seeGlass(open: boolean) {
if (open && !Glass) Glass = (await import('$lib/components/Glass.svelte')).default;
if (open && !Glass) {
fetching = true;
try {
Glass = (await import('$lib/components/Glass.svelte')).default;
} finally {
fetching = false;
}
}
onGlass = open;
}
@@ -72,7 +80,7 @@
<section class="stage">
<div class="boards">
{#if glass.open}
<p class="muted small"><button type="button" class="link plain" onclick={() => seeGlass(!onGlass)}>{onGlass ? 'Back to the flat boards' : 'See the glass board'}</button></p>
<p class="muted small"><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></p>
{/if}
{#if onGlass && Glass}
<Glass {g} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} />