The glass board is a place to move, not only to look: a square a held piece may go to is taken through any figure in the way; from above, the sheets above the one you look at are set aside; the post nearest the eye fades; the seat is higher; the wheel scrolls the page unless Ctrl or ⌘ is held, and one finger scrolls past the board on a touchscreen; tapping the view you are in puts the camera back; a narrow screen gets a taller canvas and a nearer start; the hall link after a game no longer spills over the heading below it

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 10:40:22 -04:00
1 parent 6be076e3fc
commit 65bdf4ecfb
2 files changed
+101 -26

No files matched your search

+4
View File
@@ -359,6 +359,10 @@
margin: 0.8rem auto 0; margin: 0.8rem auto 0;
} }
.verdict .commit {
display: inline-block;
}
.panel { .panel {
position: sticky; position: sticky;
top: 0.5rem; top: 0.5rem;
+97 -26
View File
@@ -54,6 +54,12 @@
/** How many of the fifteen kinds of figure have arrived. */ /** How many of the fifteen kinds of figure have arrived. */
let arrived = $state(0); let arrived = $state(0);
let view = $state<'quarter' | 'seat' | 'above'>('quarter'); let view = $state<'quarter' | 'seat' | 'above'>('quarter');
/** Seen from above, the sheets stack; this is the topmost one shown, the ones above it set aside. */
let sheet = $state<1 | 2 | 3>(3);
/** Bumped to put the camera back where the view begins. */
let reset = $state(0);
/** Whether a level is set aside in the view from above. */
const hidden = (level: number) => view === 'above' && level > sheet;
type Livery = Record<Side, { plain: THREE.Material; held: THREE.Material }>; type Livery = Record<Side, { plain: THREE.Material; held: THREE.Material }>;
interface Proto { scene: THREE.Group; livery: Livery } interface Proto { scene: THREE.Group; livery: Livery }
@@ -109,7 +115,7 @@
map.colorSpace = THREE.SRGBColorSpace; map.colorSpace = THREE.SRGBColorSpace;
const slab = new THREE.Mesh( const slab = new THREE.Mesh(
new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL), 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 }) new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0, clearcoat: 1 })
); );
slab.position.set(0, levelY(level) - 0.06, 0); slab.position.set(0, levelY(level) - 0.06, 0);
slab.receiveShadow = true; slab.receiveShadow = true;
@@ -117,15 +123,30 @@
scene.add(slab); scene.add(slab);
slabs.push(slab); slabs.push(slab);
} }
const stone = new THREE.MeshStandardMaterial({ color: '#8c8c88', roughness: 0.9 }); // Each post has its own stone, so the one nearest the camera can fade rather than stand in front of the board.
const posts: { group: THREE.Group; stone: THREE.MeshStandardMaterial }[] = [];
for (const [x, z] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { 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); const stone = new THREE.MeshStandardMaterial({ color: '#8c8c88', roughness: 0.9, transparent: true });
post.position.set(x * (FILES / 2 + 0.6), GAP + 0.6, z * (RANKS / 2 + 0.6)); const group = new THREE.Group();
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.17, GAP * 2 + 1.6, 12), stone);
post.position.y = GAP + 0.6;
post.castShadow = true; post.castShadow = true;
scene.add(post); const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone);
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.3, 1.2, 12), stone); tip.position.y = GAP * 2 + 1.9;
tip.position.set(post.position.x, GAP * 2 + 2.0, post.position.z); group.add(post, tip);
scene.add(tip); group.position.set(x * (FILES / 2 + 0.6), 0, z * (RANKS / 2 + 0.6));
scene.add(group);
posts.push({ group, stone });
}
function fadeNearPost() {
const eye = camera.position;
const far = posts.map((p) => p.group.position.distanceTo(eye));
const nearest = Math.min(...far);
posts.forEach((p, k) => {
const near = far[k] <= nearest * 1.1;
p.stone.opacity = near ? 0.18 : 1;
(p.group.children[0] as THREE.Mesh).castShadow = !near;
});
} }
// Marks on squares: what is selected, where it may go, the last move, a King in check. // Marks on squares: what is selected, where it may go, the last move, a King in check.
@@ -145,6 +166,7 @@
for (const [geometry, color, alpha] of layers) { for (const [geometry, color, alpha] of layers) {
const m = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: alpha, depthWrite: false, depthTest: false, toneMapped: false })); const m = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: alpha, depthWrite: false, depthTest: false, toneMapped: false }));
m.userData.square = i; m.userData.square = i;
m.visible = !hidden(levelOf(i));
m.renderOrder = 10 + marks.children.length; m.renderOrder = 10 + marks.children.length;
m.rotation.x = -Math.PI / 2; m.rotation.x = -Math.PI / 2;
m.position.copy(at(i)).add(new THREE.Vector3(0, 0.015, 0)); m.position.copy(at(i)).add(new THREE.Vector3(0, 0.015, 0));
@@ -179,6 +201,7 @@
o.castShadow = o.receiveShadow = true; o.castShadow = o.receiveShadow = true;
}); });
f.position.copy(at(i)); f.position.copy(at(i));
f.visible = !hidden(levelOf(i));
// The figures are modelled facing +z, which is Gold's own side of the table: Gold turns about to face Scarlet. // 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; if (sideOf(cell) === 'gold') f.rotation.y = Math.PI;
dress(f); dress(f);
@@ -206,6 +229,13 @@
played = g.moves.length; played = g.moves.length;
wake(); wake();
} }
/** Show or set aside each level as the view asks. */
function shade() {
for (const slab of slabs) slab.visible = !hidden(slab.userData.level as number);
for (const f of figures.values()) f.visible = !hidden(levelOf(f.userData.square as number));
for (const m of marks.children) m.visible = !hidden(levelOf(m.userData.square as number));
wake();
}
// Motion: a figure walks to its square, a taken one sinks into the glass. // 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 } interface Tween { f: THREE.Group; from: THREE.Vector3; to: THREE.Vector3; start: number; ms: number; sink: boolean; done?: () => void }
@@ -266,15 +296,19 @@
const r = canvas.getBoundingClientRect(); 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); 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. // 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); // With a piece in hand, a square it may go to wins over whatever figure stands between it and the eye.
const shown = (o: THREE.Object3D) => o.visible;
const hits = raycaster.intersectObjects([...slabs, ...marks.children, ...figures.values()].filter(shown), true);
const owner = (h: THREE.Intersection) => { const owner = (h: THREE.Intersection) => {
let o: THREE.Object3D | null = h.object; let o: THREE.Object3D | null = h.object;
while (o && o.userData.square === undefined && o.userData.level === undefined) o = o.parent; while (o && o.userData.square === undefined && o.userData.level === undefined) o = o.parent;
return o; return o;
}; };
const named = hits.find((h) => owner(h)?.userData.square !== undefined); const squareOf = (h: THREE.Intersection) => owner(h)?.userData.square as number | undefined;
const wanted = selected === null ? undefined : hits.find((h) => targets.includes(squareOf(h) ?? -1));
const named = wanted ?? hits.find((h) => squareOf(h) !== undefined);
if (named) { if (named) {
void tap(owner(named)!.userData.square as number); void tap(squareOf(named)!);
return; return;
} }
const glassHit = hits.find((h) => owner(h)?.userData.level !== undefined); const glassHit = hits.find((h) => owner(h)?.userData.level !== undefined);
@@ -286,28 +320,35 @@
// The camera: three ways to look, and the seat flips for Scarlet. // The camera: three ways to look, and the seat flips for Scarlet.
function look(which: typeof view) { function look(which: typeof view) {
const s = mySide === 'scarlet' ? -1 : 1; const s = mySide === 'scarlet' ? -1 : 1;
// On a narrow screen the camera starts nearer, so the squares are big enough for a finger.
const near = frame.clientWidth < 560 ? 0.7 : 1;
controls.dispose(); controls.dispose();
if (which === 'above') { if (which === 'above') {
camera = above; camera = above;
above.position.set(0, 40, 0); above.position.set(0, 40, 9 * s);
above.up.set(0, 0, -s); above.up.set(0, 0, -s);
above.lookAt(0, 0, 0); above.lookAt(0, 0, 0);
} else { } else {
camera = perspective; camera = perspective;
if (which === 'quarter') perspective.position.set(16 * s, 17, 19 * s); if (which === 'quarter') perspective.position.set(16 * s * near, 17 * near, 19 * s * near);
else perspective.position.set(0, 13, 24 * s); else perspective.position.set(0, 22 * near, 19 * s * near);
perspective.up.set(0, 1, 0); perspective.up.set(0, 1, 0);
} }
controls = new OrbitControls(camera, canvas); controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.49; controls.maxPolarAngle = Math.PI * 0.49;
controls.minDistance = 8; controls.minDistance = 6;
controls.maxDistance = 60; controls.maxDistance = 60;
controls.target.set(0, levelY(2), 0); controls.target.set(0, levelY(2), 0);
// One finger scrolls the page past the board, as anywhere else; two fingers turn it and draw near.
controls.touches = { ONE: -1 as THREE.TOUCH, TWO: THREE.TOUCH.DOLLY_ROTATE };
canvas.style.touchAction = 'pan-y';
controls.addEventListener('change', wake); controls.addEventListener('change', wake);
controls.update(); controls.update();
wake(); shade();
} }
// The wheel scrolls the page unless Ctrl or ⌘ is held (a trackpad's pinch arrives the same way), when it draws near.
canvas.addEventListener('wheel', (e) => (controls.enableZoom = e.ctrlKey || e.metaKey), { capture: true, passive: true });
// Drawing only while something moves: the camera, a figure, a change of state. // Drawing only while something moves: the camera, a figure, a change of state.
let awake = false; let awake = false;
@@ -329,20 +370,24 @@
} else busy = true; } else busy = true;
} }
if (controls.update()) busy = true; if (controls.update()) busy = true;
fadeNearPost();
renderer.render(scene, camera); renderer.render(scene, camera);
awake = busy || tweens.length > 0; awake = busy || tweens.length > 0;
if (awake) requestAnimationFrame(tick); if (awake) requestAnimationFrame(tick);
} }
function resize() { function resize() {
// A narrow frame gets a taller canvas, and both cameras open wide enough to take in the sheets' full width.
const w = frame.clientWidth; const w = frame.clientWidth;
const h = Math.round(w * 0.75); const h = Math.round(w * (w < 560 ? 1.1 : 0.75));
const aspect = w / h;
renderer.setSize(w, h, false); renderer.setSize(w, h, false);
perspective.aspect = w / h; perspective.aspect = aspect;
perspective.fov = Math.max(32, (2 * Math.atan(Math.tan((21 * Math.PI) / 180) / aspect) * 180) / Math.PI);
perspective.updateProjectionMatrix(); perspective.updateProjectionMatrix();
const half = 7.2; const half = Math.max(5.4, 7.0 / aspect);
above.left = -half * (w / h); above.left = -half * aspect;
above.right = half * (w / h); above.right = half * aspect;
above.top = half; above.top = half;
above.bottom = -half; above.bottom = -half;
above.updateProjectionMatrix(); above.updateProjectionMatrix();
@@ -391,7 +436,14 @@
if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check); if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check);
wake(); wake();
}); });
$effect(() => look(view)); $effect(() => {
void reset;
look(view);
});
$effect(() => {
void sheet;
shade();
});
return () => { return () => {
watcher.disconnect(); watcher.disconnect();
@@ -410,10 +462,20 @@
</div> </div>
{/if} {/if}
<p class="ways muted small"> <p class="ways muted small">
<button type="button" class="link plain" class:current={view === 'quarter'} onclick={() => (view = 'quarter')}>From the corner</button> · {#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)}
<button type="button" class="link plain" class:current={view === 'seat'} onclick={() => (view = 'seat')}>From your seat</button> · {#if k > 0}<span class="sep">·</span>{/if}
<button type="button" class="link plain" class:current={view === 'above'} onclick={() => (view = 'above')}>From above</button> <button type="button" class="link plain" class:current={view === which} onclick={() => (view === which ? reset++ : (view = which as typeof view))}>{label}</button>
<span class="hint">Drag to turn, scroll to draw near. Tap a piece to read about it.</span> {/each}
{#if view === 'above'}
<span class="sheets">
Looking at
{#each [[3, 'the air'], [2, 'the land'], [1, 'the underworld']] as [level, label], k (level)}
{#if k > 0}<span class="sep">·</span>{/if}
<button type="button" class="link plain" class:current={sheet === level} onclick={() => (sheet = level as typeof sheet)}>{label}</button>
{/each}
</span>
{/if}
<span class="hint">Drag to turn, or two fingers on a touchscreen. Ctrl or ⌘ with the wheel, or a pinch, draws near. Tap the view you are in to put it back.</span>
</p> </p>
</div> </div>
@@ -459,6 +521,15 @@
color: var(--ember); color: var(--ember);
} }
.sheets {
display: block;
margin-top: 0.2rem;
}
.sep {
margin: 0 0.35em;
}
.hint { .hint {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;