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:
1 parent
0be89a1dfe
commit
8744072dda
24 files changed
+666
No files matched your search
@@ -29,6 +29,14 @@ tables, the Peanut Gallery, the ledgers and the ops tools are the kit's;
|
|||||||
- The drake's search is bounded by a count of nodes, not a clock, so the
|
- The drake's search is bounded by a count of nodes, not a clock, so the
|
||||||
same position always gives the same move and a ledger replays without
|
same position always gives the same move and a ledger replays without
|
||||||
asking it again.
|
asking it again.
|
||||||
|
- The glass board, `src/lib/components/Glass.svelte`, draws a game as three
|
||||||
|
sheets of tinted glass with figures standing on them, in three.js, and
|
||||||
|
takes the same taps as the flat boards. The figures in `static/figures/`
|
||||||
|
are glTF, one per kind, with a `livery` material the view recolours per
|
||||||
|
side; `docs/figures-brief.md` is what they were made from. three.js and
|
||||||
|
the figures load only when the view is opened. Until the keeper opens it
|
||||||
|
to everyone, the view shows only to a browser let in through
|
||||||
|
`src/lib/glass.svelte.ts`.
|
||||||
- The guide's figures are screenshots in `static/guide/`, taken from the
|
- The guide's figures are screenshots in `static/guide/`, taken from the
|
||||||
running site at 960 pixels wide.
|
running site at 960 pixels wide.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Figures for Wyrm: a brief for a 3D model generator
|
||||||
|
|
||||||
|
Wyrm is a browser rebuild of Gary Gygax's 1985 Dragonchess: three boards of
|
||||||
|
twelve by eight stacked one above the other, the air, the land and the
|
||||||
|
underworld, with 42 pieces a side in fifteen kinds. The pieces are to be
|
||||||
|
figures standing on round bases, in the manner of painted tabletop
|
||||||
|
miniatures, so that a game can be drawn as a three-level glass board seen
|
||||||
|
from a three-quarter view above.
|
||||||
|
|
||||||
|
## What to make
|
||||||
|
|
||||||
|
Fifteen figures, one per kind, each delivered as a single glTF binary
|
||||||
|
(`.glb`) with these properties:
|
||||||
|
|
||||||
|
- Y up, metres, the base centred on the origin and resting on y = 0.
|
||||||
|
- A round base 1.0 unit across and 0.1 high under every figure. The figure
|
||||||
|
may overhang the base (wings, tails) but never below it.
|
||||||
|
- Height by station: Warriors, Sylphs and Dwarves about 1.6 units tall from
|
||||||
|
the base; the middle kinds about 2.0; the Dragon, Griffon, Oliphant,
|
||||||
|
Elemental and Basilisk may reach 2.6, wingspan up to 2.4.
|
||||||
|
- At most 8,000 triangles a figure, 4,000 for the Warrior, Sylph and Dwarf,
|
||||||
|
since there are twelve or six of each on the board at once.
|
||||||
|
- Two materials only: `livery` for everything that takes the side's colour
|
||||||
|
(cloth, wings, scales, paint) and `base` for the base and anything
|
||||||
|
neutral (steel, wood, stone, skin). The game recolours `livery` for Gold
|
||||||
|
and Scarlet; do not bake the side colour in. One 1024 texture set per
|
||||||
|
material, PBR (base colour, metallic-roughness, normal).
|
||||||
|
- One still pose, readable in silhouette from a three-quarter view above
|
||||||
|
and from directly above. No pose that lies flat or leans off the base.
|
||||||
|
- Style: painted miniature, not photoreal. Clean forms, exaggerated
|
||||||
|
silhouettes, little fine detail that would vanish at 40 pixels tall.
|
||||||
|
Mixed fantasy, medieval and classical, as the pieces below describe.
|
||||||
|
No text, logos or heraldry.
|
||||||
|
|
||||||
|
## The fifteen pieces
|
||||||
|
|
||||||
|
The air (Gygax colours the board light blue and white):
|
||||||
|
|
||||||
|
1. **Sylph.** A slight winged spirit of the wind, androgynous, barefoot,
|
||||||
|
feathered wings raised, carrying nothing or a short spear. Light and
|
||||||
|
quick; the foot soldier of the air.
|
||||||
|
2. **Griffon.** Half eagle and half lion, wings spread, forelegs raised as
|
||||||
|
it lands. Big, the heaviest flier after the Dragon.
|
||||||
|
3. **Dragon.** The great wyrm of the sky, the piece the game is named for.
|
||||||
|
Wings half spread, long neck, head turned down as if to strike at the
|
||||||
|
board beneath. The tallest piece on the board.
|
||||||
|
|
||||||
|
The land (light green and amber):
|
||||||
|
|
||||||
|
4. **Warrior.** A spearman of the levy, shield on the arm, spear upright.
|
||||||
|
Plain, sturdy, the common soldier; twelve of these stand in a wall.
|
||||||
|
5. **Oliphant.** A war elephant with a small howdah, tusks forward. Broad
|
||||||
|
and heavy, a rook with tusks.
|
||||||
|
6. **Unicorn.** A unicorn rearing, no rider. The knight of the land.
|
||||||
|
7. **Hero.** A champion in mail with a long sword raised two-handed. Bold
|
||||||
|
stance, a figure that leaps.
|
||||||
|
8. **Thief.** A hooded figure in dark cloth, crouched, a dagger in each
|
||||||
|
hand, looking sideways.
|
||||||
|
9. **Cleric.** A robed priest with a staff topped by a sun or sigil, calm,
|
||||||
|
upright. The one piece at home on every board.
|
||||||
|
10. **Mage.** A wizard in long robes, staff in one hand, the other raised
|
||||||
|
with a flame or light. The queen of the land.
|
||||||
|
11. **King.** A crowned king in a long cloak with a sceptre, standing. Not
|
||||||
|
the tallest piece, but the most dressed.
|
||||||
|
12. **Paladin.** A knight in full plate on foot, visor down, lance or sword
|
||||||
|
and a tall shield. The holy knight.
|
||||||
|
|
||||||
|
The underworld (red and brown):
|
||||||
|
|
||||||
|
13. **Dwarf.** A squat miner with a pick over the shoulder and a lamp on the
|
||||||
|
helmet, bearded, broad. Six to a side.
|
||||||
|
14. **Basilisk.** A great serpent reared up on its coils, crested, mouth
|
||||||
|
open, looking up as if its gaze reaches the board above. Menacing and
|
||||||
|
low.
|
||||||
|
15. **Elemental.** A bruiser of living stone, a hulking earth elemental,
|
||||||
|
arms raised, no face to speak of. The one heavy thing in the
|
||||||
|
underworld.
|
||||||
|
|
||||||
|
## Try two first
|
||||||
|
|
||||||
|
Make the Warrior and the Dragon before the rest. The Warrior shows whether
|
||||||
|
the model can keep a figure simple and readable at a small size; the Dragon
|
||||||
|
shows whether it can carry a big silhouette with wings inside the triangle
|
||||||
|
budget. If both pass, make the remaining thirteen to the same style.
|
||||||
|
|
||||||
|
## How they will be judged
|
||||||
|
|
||||||
|
- Does each figure read as its kind from a three-quarter view above, at
|
||||||
|
about 40 pixels tall, against the others on the same board?
|
||||||
|
- Do the twelve Warriors, six Sylphs and six Dwarves differ from each
|
||||||
|
other's kinds at a glance?
|
||||||
|
- Do the two materials split cleanly, so that recolouring `livery` to gold
|
||||||
|
and to scarlet gives two armies and nothing else changes?
|
||||||
|
- Does the file open in a standard glTF viewer with no missing textures,
|
||||||
|
within the triangle budget, standing on its base at the origin?
|
||||||
Generated
+65
@@ -8,6 +8,8 @@
|
|||||||
"name": "wyrm",
|
"name": "wyrm",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
|
"three": "^0.180.0",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,6 +26,12 @@
|
|||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
@@ -904,6 +912,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -946,6 +960,33 @@
|
|||||||
"undici-types": "~8.9.0"
|
"undici-types": "~8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.180.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||||
|
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": "*",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~0.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -1004,6 +1045,12 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||||
|
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
@@ -1232,6 +1279,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1545,6 +1598,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -1813,6 +1872,12 @@
|
|||||||
"typescript": "^5.0.0 || ^6.0.0"
|
"typescript": "^5.0.0 || ^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.180.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
|
"three": "^0.180.0",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,18 @@
|
|||||||
} from '$lib/game';
|
} from '$lib/game';
|
||||||
import type { Room } from '$lib/net/room.svelte';
|
import type { Room } from '$lib/net/room.svelte';
|
||||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||||
|
import { glass } from '$lib/glass.svelte';
|
||||||
|
|
||||||
let { room }: { room: Room } = $props();
|
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 g = $derived(room.state!);
|
||||||
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
||||||
@@ -201,9 +210,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||||
|
|
||||||
|
{#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">
|
<div class="frame">
|
||||||
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||||
|
|
||||||
{#if g.over}
|
{#if g.over}
|
||||||
@@ -320,6 +336,11 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glass-way {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.how {
|
.how {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0.5rem auto 0;
|
margin: 0.5rem auto 0;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import TallyBoard from './Tally.svelte';
|
import TallyBoard from './Tally.svelte';
|
||||||
import Preferences from './Preferences.svelte';
|
import Preferences from './Preferences.svelte';
|
||||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||||
|
import '$lib/glass.svelte';
|
||||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||||
import { unreadTalk } from '$lib/net/talk';
|
import { unreadTalk } from '$lib/net/talk';
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
import { narrate } from '$lib/game/chronicle';
|
import { narrate } from '$lib/game/chronicle';
|
||||||
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game';
|
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game';
|
||||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
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 states = toldStates();
|
||||||
const last = states[states.length - 1];
|
const last = states[states.length - 1];
|
||||||
@@ -62,7 +71,14 @@
|
|||||||
|
|
||||||
<section class="stage">
|
<section class="stage">
|
||||||
<div class="boards">
|
<div class="boards">
|
||||||
|
{#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)} />
|
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} escape={() => (inspected = null)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="words">
|
<div class="words">
|
||||||
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}</p>
|
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}</p>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user