The glass board: a game drawn as three sheets of tinted glass on four posts with figures standing on them, in three.js, taking the same taps as the flat boards, shown only to a browser let in by a word

Fifteen figures in static/figures, one per kind, made to docs/figures-brief.md:
a round base, a livery material the view recolours per side, and a base
material left alone. The view loads three.js and the figures only when it
is opened. It plays a move as a walk and a capture as a sink into the glass,
dims a piece a Basilisk holds, marks the last move, the targets and a King
in check, and lets a tap reach a figure or a marked square through the
glass above it. Three cameras: from the corner, from the player's seat,
from above. The table and the told game offer it behind the gate in
src/lib/glass.svelte.ts, which opens for a browser that visits with
?glass=<word> whose hash it keeps.

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 01:13:00 -04:00
1 parent 0be89a1dfe
commit 8744072dda
24 files changed
+670 -4

No files matched your search

+24 -3
View File
@@ -14,9 +14,18 @@
} from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
import { glass } from '$lib/glass.svelte';
let { room }: { room: Room } = $props();
/** 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);
async function seeGlass(open: boolean) {
if (open && !Glass) Glass = (await import('./Glass.svelte')).default;
onGlass = open;
}
const g = $derived(room.state!);
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
@@ -201,9 +210,16 @@
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div>
{#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>
{/if}
{#if onGlass && Glass}
<Glass {g} {mySide} {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} />
</div>
{/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p>
{#if g.over}
@@ -320,6 +336,11 @@
margin: 0 auto;
}
.glass-way {
max-width: 640px;
margin: 0 auto 0.5rem;
}
.how {
max-width: 640px;
margin: 0.5rem auto 0;
+421
View File
@@ -0,0 +1,421 @@
<script lang="ts">
// The glass board: the three boards as sheets of tinted glass one above
// the other on four posts, the pieces as figures standing on them, lit
// and seen from wherever the viewer turns the camera. It draws the same
// state as BoardArtwork and takes the same taps, but it is for looking;
// the flat boards remain the way to play by keyboard, and the panel and
// the chronicle sit beside both. three.js and the figures load with this
// component and not before, so a table that never opens it pays nothing.
import { onMount } from 'svelte';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
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 = () => {} }: {
g: State;
mySide?: Side | null;
selected?: number | null;
targets?: number[];
reach?: number[];
shownSquares?: number[];
shownCaptured?: number[];
checkSquare?: number;
calm?: boolean;
tap?: (i: number) => void | Promise<void>;
} = $props();
/** The figure file for each kind, in static/figures. */
const FIGURE: Record<Kind, string> = {
S: 'sylph', G: 'griffon', R: 'dragon', W: 'warrior', O: 'oliphant', U: 'unicorn', H: 'hero', T: 'thief', C: 'cleric', M: 'mage', K: 'king', P: 'paladin', D: 'dwarf', B: 'basilisk', E: 'elemental'
};
/** 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' };
/** Air between the sheets, in squares: enough for a Dragon's wings. */
const GAP = 4.2;
const CELL = 1;
const levelY = (level: number) => (level - 1) * GAP;
/** Where a square's centre sits: Gold's side towards +z, file a at -x. */
const at = (i: number) => new THREE.Vector3((fileOf(i) - (FILES - 1) / 2) * CELL, levelY(levelOf(i)), -(rankOf(i) - (RANKS - 1) / 2) * CELL);
const squareAt = (level: number, x: number, z: number): number | null => {
const file = Math.round(x / CELL + (FILES - 1) / 2);
const rank = Math.round(-z / CELL + (RANKS - 1) / 2);
return file >= 0 && file < FILES && rank >= 0 && rank < RANKS ? idx(level, file, rank) : null;
};
let frame: HTMLDivElement;
let canvas: HTMLCanvasElement;
let status = $state('Setting out the figures…');
let view = $state<'quarter' | 'seat' | 'above'>('quarter');
type Livery = Record<Side, { plain: THREE.Material; held: THREE.Material }>;
interface Proto { scene: THREE.Group; livery: Livery }
const protos = new Map<Kind, Proto>();
/** The figure standing on each square. */
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;
onMount(() => {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#1c2830');
const perspective = new THREE.PerspectiveCamera(32, 4 / 3, 0.1, 200);
const above = new THREE.OrthographicCamera(-8, 8, 6, -6, 0.1, 200);
let camera: THREE.Camera = perspective;
let controls = new OrbitControls(perspective, canvas);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.49;
controls.minDistance = 8;
controls.maxDistance = 60;
controls.target.set(0, levelY(2), 0);
scene.add(new THREE.HemisphereLight('#dfe8f0', '#3a3028', 1.2));
const sun = new THREE.DirectionalLight('#fff4e0', 2.2);
sun.position.set(8, 18, 10);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -12, right: 12, top: 12, bottom: -12, near: 1, far: 60 });
scene.add(sun);
// The sheets, tinted by level, and the posts that hold them.
const slabs: THREE.Mesh[] = [];
for (const level of [1, 2, 3]) {
const c = document.createElement('canvas');
c.width = FILES * 32;
c.height = RANKS * 32;
const ctx = c.getContext('2d')!;
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);
}
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.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.74, roughness: 0.15, metalness: 0, clearcoat: 1 })
);
slab.position.set(0, levelY(level) - 0.06, 0);
slab.receiveShadow = true;
slab.userData.level = level;
scene.add(slab);
slabs.push(slab);
}
const stone = new THREE.MeshStandardMaterial({ color: '#8c8c88', roughness: 0.9 });
for (const [x, z] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.28, GAP * 2 + 1.6, 12), stone);
post.position.set(x * (FILES / 2 + 0.6), GAP + 0.6, z * (RANKS / 2 + 0.6));
post.castShadow = true;
scene.add(post);
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.3, 1.2, 12), stone);
tip.position.set(post.position.x, GAP * 2 + 2.0, post.position.z);
scene.add(tip);
}
// Marks on squares: what is selected, where it may go, the last move, a King in check.
const marks = new THREE.Group();
scene.add(marks);
const markGeometry = new THREE.PlaneGeometry(CELL * 0.92, CELL * 0.92);
function mark(i: number, colour: string, opacity: number) {
const m = new THREE.Mesh(markGeometry, new THREE.MeshBasicMaterial({ color: colour, transparent: true, opacity, depthWrite: false }));
m.userData.square = i;
m.rotation.x = -Math.PI / 2;
m.position.copy(at(i)).add(new THREE.Vector3(0, 0.015, 0));
marks.add(m);
}
// Figures: one scene per kind, shared geometry, a livery per side and a dimmed one for a piece held by a Basilisk.
const loader = new GLTFLoader();
function liveries(scene: THREE.Group): Livery {
let source: THREE.MeshStandardMaterial | null = null;
scene.traverse((o) => {
if ((o as THREE.Mesh).isMesh && ((o as THREE.Mesh).material as THREE.Material).name === 'livery') source = (o as THREE.Mesh).material as THREE.MeshStandardMaterial;
});
const make = (side: Side, held: boolean) => {
const m = (source ?? new THREE.MeshStandardMaterial()).clone();
m.color.set(LIVERY[side]);
if (held) m.color.lerp(new THREE.Color('#6a6e70'), 0.6);
return m;
};
return { gold: { plain: make('gold', false), held: make('gold', true) }, scarlet: { plain: make('scarlet', false), held: make('scarlet', true) } };
}
function figure(i: number): THREE.Group {
const cell = g.cells[i];
const proto = protos.get(kindOf(cell))!;
const f = proto.scene.clone(true);
f.userData.square = i;
f.userData.kind = kindOf(cell);
f.userData.side = sideOf(cell);
f.traverse((o) => {
if (!(o as THREE.Mesh).isMesh) return;
o.castShadow = o.receiveShadow = true;
});
f.position.copy(at(i));
// The figures are modelled facing +z, which is Gold's own side of the table: Gold turns about to face Scarlet.
if (sideOf(cell) === 'gold') f.rotation.y = Math.PI;
dress(f);
scene.add(f);
figures.set(i, f);
return f;
}
/** The livery a figure wears now: its side's, dimmed while a Basilisk holds it. */
function dress(f: THREE.Group) {
const proto = protos.get(f.userData.kind as Kind)!;
const held = frozen(g.cells, f.userData.square as number);
const livery = proto.livery[f.userData.side as Side][held ? 'held' : 'plain'];
f.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh && (mesh.material as THREE.Material).name === 'livery') mesh.material = livery;
});
}
function clearFigures() {
for (const f of figures.values()) scene.remove(f);
figures.clear();
}
function setOut() {
clearFigures();
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '') figure(i);
played = g.moves.length;
wake();
}
// Motion: a figure walks to its square, a taken one sinks into the glass.
interface Tween { f: THREE.Group; from: THREE.Vector3; to: THREE.Vector3; start: number; ms: number; sink: boolean; done?: () => void }
const tweens: Tween[] = [];
function tween(t: Omit<Tween, 'start'>) {
if (calm) {
t.f.position.copy(t.to);
t.done?.();
return;
}
tweens.push({ ...t, start: performance.now() });
wake();
}
function playLast() {
const move = g.moves[g.moves.length - 1];
const taken = figures.get(move.to);
const finishTaken = () => {
if (taken) scene.remove(taken);
};
if (taken && (move.afar || move.from !== move.to)) {
figures.delete(move.to);
tween({ f: taken, from: taken.position.clone(), to: taken.position.clone().setY(taken.position.y - 0.9), ms: 500, sink: true, done: finishTaken });
}
if (!move.afar) {
const mover = figures.get(move.from);
if (mover) {
figures.delete(move.from);
const to = at(move.to);
const lift = to.clone().setY(Math.max(mover.position.y, to.y) + 0.6);
tween({
f: mover, from: mover.position.clone(), to: lift, ms: 260, sink: false,
done: () => tween({
f: mover, from: lift, to, ms: 260, sink: false,
done: () => {
mover.userData.square = move.to;
figures.set(move.to, mover);
if (move.promoted) {
scene.remove(mover);
figures.delete(move.to);
figure(move.to);
}
for (const f of figures.values()) dress(f);
}
})
});
}
} else for (const f of figures.values()) dress(f);
played = g.moves.length;
}
// Picking: a tap on a square or a figure, as on the flat boards; a drag turns the camera instead.
const raycaster = new THREE.Raycaster();
let down: { x: number; y: number } | null = null;
canvas.addEventListener('pointerdown', (e) => (down = { x: e.clientX, y: e.clientY }));
canvas.addEventListener('pointerup', (e) => {
if (!down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 6) return;
down = null;
const r = canvas.getBoundingClientRect();
raycaster.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera);
// A figure or a marked square is taken through the glass above it; bare glass only where the ray first lands.
const hits = raycaster.intersectObjects([...slabs, ...marks.children, ...figures.values()], true);
const owner = (h: THREE.Intersection) => {
let o: THREE.Object3D | null = h.object;
while (o && o.userData.square === undefined && o.userData.level === undefined) o = o.parent;
return o;
};
const named = hits.find((h) => owner(h)?.userData.square !== undefined);
if (named) {
void tap(owner(named)!.userData.square as number);
return;
}
const glassHit = hits.find((h) => owner(h)?.userData.level !== undefined);
if (!glassHit) return;
const square = squareAt(owner(glassHit)!.userData.level as number, glassHit.point.x, glassHit.point.z);
if (square !== null) void tap(square);
});
// The camera: three ways to look, and the seat flips for Scarlet.
function look(which: typeof view) {
const s = mySide === 'scarlet' ? -1 : 1;
controls.dispose();
if (which === 'above') {
camera = above;
above.position.set(0, 40, 0);
above.up.set(0, 0, -s);
above.lookAt(0, 0, 0);
} else {
camera = perspective;
if (which === 'quarter') perspective.position.set(16 * s, 17, 19 * s);
else perspective.position.set(0, 13, 24 * s);
perspective.up.set(0, 1, 0);
}
controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.49;
controls.minDistance = 8;
controls.maxDistance = 60;
controls.target.set(0, levelY(2), 0);
controls.addEventListener('change', wake);
controls.update();
wake();
}
// Drawing only while something moves: the camera, a figure, a change of state.
let awake = false;
function wake() {
if (awake) return;
awake = true;
requestAnimationFrame(tick);
}
function tick(now: number) {
let busy = false;
for (const t of [...tweens]) {
const k = Math.min(1, (now - t.start) / t.ms);
const ease = t.sink ? k * k : 1 - (1 - k) * (1 - k);
t.f.position.lerpVectors(t.from, t.to, ease);
if (t.sink) t.f.scale.setScalar(1 - 0.6 * k);
if (k >= 1) {
tweens.splice(tweens.indexOf(t), 1);
t.done?.();
} else busy = true;
}
if (controls.update()) busy = true;
renderer.render(scene, camera);
awake = busy || tweens.length > 0;
if (awake) requestAnimationFrame(tick);
}
function resize() {
const w = frame.clientWidth;
const h = Math.round(w * 0.75);
renderer.setSize(w, h, false);
perspective.aspect = w / h;
perspective.updateProjectionMatrix();
const half = 7.2;
above.left = -half * (w / h);
above.right = half * (w / h);
above.top = half;
above.bottom = -half;
above.updateProjectionMatrix();
wake();
}
const watcher = new ResizeObserver(resize);
watcher.observe(frame);
resize();
look(view);
controls.addEventListener('change', wake);
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))
)
).then(() => {
ready = true;
status = '';
setOut();
}).catch(() => (status = 'The figures could not be fetched.'));
// What the state and the marks ask for, whenever they change.
$effect(() => {
void g.cells;
const n = g.moves.length;
if (!ready) return;
if (n === played + 1 && tweens.length === 0) playLast();
else if (n !== played) setOut();
else for (const f of figures.values()) dress(f);
wake();
});
$effect(() => {
marks.clear();
for (const i of reach) mark(i, MARK.reach, 0.22);
for (const i of shownSquares) mark(i, MARK.last, 0.4);
for (const i of shownCaptured) mark(i, MARK.fallen, 0.45);
for (const i of targets) mark(i, MARK.target, 0.5);
if (selected !== null) mark(selected, MARK.selected, 0.6);
if (checkSquare >= 0) mark(checkSquare, MARK.check, 0.6);
wake();
});
$effect(() => look(view));
return () => {
watcher.disconnect();
controls.dispose();
renderer.dispose();
};
});
</script>
<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}
<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> ·
<button type="button" class="link plain" class:current={view === 'above'} onclick={() => (view = 'above')}>From above</button>
<span class="hint">Drag to turn, scroll to draw near. Tap a piece to read about it.</span>
</p>
</div>
<style>
.glass {
position: relative;
max-width: 960px;
margin: 0 auto;
}
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 6px;
}
.status {
position: absolute;
top: 1rem;
left: 1rem;
margin: 0;
}
.ways {
margin: 0.4rem 0 0;
}
.ways .current {
color: var(--ember);
}
.hint {
display: block;
margin-top: 0.2rem;
}
</style>
+1
View File
@@ -8,6 +8,7 @@
import TallyBoard from './Tally.svelte';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import '$lib/glass.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
+37
View File
@@ -0,0 +1,37 @@
// The glass board, the three-dimensional view of a game, is shown only to
// a browser that has been let in. A visit to any page with ?glass=<word>
// opens it for that browser, if the word's hash is the one kept here; the
// word itself is nowhere in the site. Until the keeper decides the view is
// ready for everyone, this is the whole of the gate.
const KEY = 'wyrm:glass';
const GATE = 'eec81ab3bd646536b3464bb0411aea9b2e92e6387c6a04ef15743191ac4d142f';
function remembered(): boolean {
try {
return localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
/** Whether this browser may open the glass board. */
export const glass = $state({ open: typeof localStorage !== 'undefined' && remembered() });
/** Let the browser in if the address carries the word, and remember it. */
export async function claimGlass(): Promise<void> {
if (glass.open || typeof location === 'undefined') return;
const word = new URLSearchParams(location.search).get('glass');
if (!word) return;
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(word));
const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
if (hex !== GATE) return;
glass.open = true;
try {
localStorage.setItem(KEY, '1');
} catch {
// Opened for this visit only.
}
}
if (typeof location !== 'undefined') void claimGlass();
+17 -1
View File
@@ -7,6 +7,15 @@
import { narrate } from '$lib/game/chronicle';
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
import { glass } from '$lib/glass.svelte';
/** 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);
async function seeGlass(open: boolean) {
if (open && !Glass) Glass = (await import('$lib/components/Glass.svelte')).default;
onGlass = open;
}
const states = toldStates();
const last = states[states.length - 1];
@@ -62,7 +71,14 @@
<section class="stage">
<div class="boards">
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} escape={() => (inspected = null)} />
{#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>
{/if}
{#if onGlass && Glass}
<Glass {g} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} />
{:else}
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} escape={() => (inspected = null)} />
{/if}
</div>
<div class="words">
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</p>