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;
|
margin: 0.8rem auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.verdict .commit {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user