diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 4419a63..efa3df8 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -359,6 +359,10 @@ margin: 0.8rem auto 0; } + .verdict .commit { + display: inline-block; + } + .panel { position: sticky; top: 0.5rem; diff --git a/src/lib/components/Glass.svelte b/src/lib/components/Glass.svelte index 5169be1..546e7d6 100644 --- a/src/lib/components/Glass.svelte +++ b/src/lib/components/Glass.svelte @@ -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; 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 @@ {/if}

- · - · - - Drag to turn, scroll to draw near. Tap a piece to read about it. + {#each [['quarter', 'From the corner'], ['seat', 'From your seat'], ['above', 'From above']] as [which, label], k (which)} + {#if k > 0}·{/if} + + {/each} + {#if view === 'above'} + + Looking at + {#each [[3, 'the air'], [2, 'the land'], [1, 'the underworld']] as [level, label], k (level)} + {#if k > 0}·{/if} + + {/each} + + {/if} + 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.

@@ -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;