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:
1 parent
6be076e3fc
commit
65bdf4ecfb
2 files changed
+101
-26
No files matched your search
@@ -359,6 +359,10 @@
|
||||
margin: 0.8rem auto 0;
|
||||
}
|
||||
|
||||
.verdict .commit {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
/** How many of the fifteen kinds of figure have arrived. */
|
||||
let arrived = $state(0);
|
||||
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 }>;
|
||||
interface Proto { scene: THREE.Group; livery: Livery }
|
||||
@@ -109,7 +115,7 @@
|
||||
map.colorSpace = THREE.SRGBColorSpace;
|
||||
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.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.receiveShadow = true;
|
||||
@@ -117,15 +123,30 @@
|
||||
scene.add(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]]) {
|
||||
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));
|
||||
const stone = new THREE.MeshStandardMaterial({ color: '#8c8c88', roughness: 0.9, transparent: true });
|
||||
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;
|
||||
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);
|
||||
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));
|
||||
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.
|
||||
@@ -145,6 +166,7 @@
|
||||
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 }));
|
||||
m.userData.square = i;
|
||||
m.visible = !hidden(levelOf(i));
|
||||
m.renderOrder = 10 + marks.children.length;
|
||||
m.rotation.x = -Math.PI / 2;
|
||||
m.position.copy(at(i)).add(new THREE.Vector3(0, 0.015, 0));
|
||||
@@ -179,6 +201,7 @@
|
||||
o.castShadow = o.receiveShadow = true;
|
||||
});
|
||||
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.
|
||||
if (sideOf(cell) === 'gold') f.rotation.y = Math.PI;
|
||||
dress(f);
|
||||
@@ -206,6 +229,13 @@
|
||||
played = g.moves.length;
|
||||
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.
|
||||
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();
|
||||
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);
|
||||
// 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) => {
|
||||
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);
|
||||
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) {
|
||||
void tap(owner(named)!.userData.square as number);
|
||||
void tap(squareOf(named)!);
|
||||
return;
|
||||
}
|
||||
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.
|
||||
function look(which: typeof view) {
|
||||
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();
|
||||
if (which === 'above') {
|
||||
camera = above;
|
||||
above.position.set(0, 40, 0);
|
||||
above.position.set(0, 40, 9 * s);
|
||||
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);
|
||||
if (which === 'quarter') perspective.position.set(16 * s * near, 17 * near, 19 * s * near);
|
||||
else perspective.position.set(0, 22 * near, 19 * s * near);
|
||||
perspective.up.set(0, 1, 0);
|
||||
}
|
||||
controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.maxPolarAngle = Math.PI * 0.49;
|
||||
controls.minDistance = 8;
|
||||
controls.minDistance = 6;
|
||||
controls.maxDistance = 60;
|
||||
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.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.
|
||||
let awake = false;
|
||||
@@ -329,20 +370,24 @@
|
||||
} else busy = true;
|
||||
}
|
||||
if (controls.update()) busy = true;
|
||||
fadeNearPost();
|
||||
renderer.render(scene, camera);
|
||||
awake = busy || tweens.length > 0;
|
||||
if (awake) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
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 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);
|
||||
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();
|
||||
const half = 7.2;
|
||||
above.left = -half * (w / h);
|
||||
above.right = half * (w / h);
|
||||
const half = Math.max(5.4, 7.0 / aspect);
|
||||
above.left = -half * aspect;
|
||||
above.right = half * aspect;
|
||||
above.top = half;
|
||||
above.bottom = -half;
|
||||
above.updateProjectionMatrix();
|
||||
@@ -391,7 +436,14 @@
|
||||
if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check);
|
||||
wake();
|
||||
});
|
||||
$effect(() => look(view));
|
||||
$effect(() => {
|
||||
void reset;
|
||||
look(view);
|
||||
});
|
||||
$effect(() => {
|
||||
void sheet;
|
||||
shade();
|
||||
});
|
||||
|
||||
return () => {
|
||||
watcher.disconnect();
|
||||
@@ -410,10 +462,20 @@
|
||||
</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> ·
|
||||
<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>
|
||||
{#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)}
|
||||
{#if k > 0}<span class="sep">·</span>{/if}
|
||||
<button type="button" class="link plain" class:current={view === which} onclick={() => (view === which ? reset++ : (view = which as typeof view))}>{label}</button>
|
||||
{/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>
|
||||
</div>
|
||||
|
||||
@@ -459,6 +521,15 @@
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.sheets {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 0.35em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
|
||||
Reference in new issue
Block a user