diff --git a/README.md b/README.md index d6f2f11..a2af100 100644 --- a/README.md +++ b/README.md @@ -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 same position always gives the same move and a ledger replays without 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 running site at 960 pixels wide. diff --git a/docs/figures-brief.md b/docs/figures-brief.md new file mode 100644 index 0000000..42dbc22 --- /dev/null +++ b/docs/figures-brief.md @@ -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? diff --git a/package-lock.json b/package-lock.json index 40a3381..2a883a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "wyrm", "version": "0.0.1", "dependencies": { + "@types/three": "^0.180.0", + "three": "^0.180.0", "ws": "^8.21.3" }, "devDependencies": { @@ -24,6 +26,12 @@ "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": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -904,6 +912,12 @@ "@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": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -946,6 +960,33 @@ "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": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1004,6 +1045,12 @@ "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": { "version": "8.18.0", "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1545,6 +1598,12 @@ "@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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1813,6 +1872,12 @@ "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": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", diff --git a/package.json b/package.json index 3d95a7f..f974420 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "vitest": "^5.0.1" }, "dependencies": { + "@types/three": "^0.180.0", + "three": "^0.180.0", "ws": "^8.21.3" } } diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 7a4dee7..7b39d58 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -14,9 +14,18 @@ } from '$lib/game'; import type { Room } from '$lib/net/room.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte'; + import { glass } from '$lib/glass.svelte'; 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(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 mySide = $derived(room.spectating ? null : (g.players[room.me]?.side ?? null)); const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over); @@ -201,9 +210,16 @@ {/if} {#if room.error}

{room.error}

{/if} -
- -
+ {#if glass.open} +

+ {/if} + {#if onGlass && Glass} + + {:else} +
+ +
+ {/if}

{liveText}

{#if g.over} @@ -320,6 +336,11 @@ margin: 0 auto; } + .glass-way { + max-width: 640px; + margin: 0 auto 0.5rem; + } + .how { max-width: 640px; margin: 0.5rem auto 0; diff --git a/src/lib/components/Glass.svelte b/src/lib/components/Glass.svelte new file mode 100644 index 0000000..52df6e8 --- /dev/null +++ b/src/lib/components/Glass.svelte @@ -0,0 +1,421 @@ + + +
+ + {#if status}

{status}

{/if} +

+ · + · + + Drag to turn, scroll to draw near. Tap a piece to read about it. +

+
+ + diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 5cf2802..132d14d 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -8,6 +8,7 @@ import TallyBoard from './Tally.svelte'; import Preferences from './Preferences.svelte'; import { prefs, setPref } from '$lib/prefs.svelte'; + import '$lib/glass.svelte'; import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; diff --git a/src/lib/glass.svelte.ts b/src/lib/glass.svelte.ts new file mode 100644 index 0000000..660c547 --- /dev/null +++ b/src/lib/glass.svelte.ts @@ -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= +// 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 { + 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(); diff --git a/src/routes/learn/game/+page.svelte b/src/routes/learn/game/+page.svelte index 5977bc6..bb233da 100644 --- a/src/routes/learn/game/+page.svelte +++ b/src/routes/learn/game/+page.svelte @@ -7,6 +7,15 @@ import { narrate } from '$lib/game/chronicle'; import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game'; 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(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 last = states[states.length - 1]; @@ -62,7 +71,14 @@
- (inspected = null)} /> + {#if glass.open} +

+ {/if} + {#if onGlass && Glass} + + {:else} + (inspected = null)} /> + {/if}

{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}

diff --git a/static/figures/basilisk.glb b/static/figures/basilisk.glb new file mode 100644 index 0000000..573f426 Binary files /dev/null and b/static/figures/basilisk.glb differ diff --git a/static/figures/cleric.glb b/static/figures/cleric.glb new file mode 100644 index 0000000..3e578d1 Binary files /dev/null and b/static/figures/cleric.glb differ diff --git a/static/figures/dragon.glb b/static/figures/dragon.glb new file mode 100644 index 0000000..2225757 Binary files /dev/null and b/static/figures/dragon.glb differ diff --git a/static/figures/dwarf.glb b/static/figures/dwarf.glb new file mode 100644 index 0000000..920f45d Binary files /dev/null and b/static/figures/dwarf.glb differ diff --git a/static/figures/elemental.glb b/static/figures/elemental.glb new file mode 100644 index 0000000..66b9f96 Binary files /dev/null and b/static/figures/elemental.glb differ diff --git a/static/figures/griffon.glb b/static/figures/griffon.glb new file mode 100644 index 0000000..d4e7529 Binary files /dev/null and b/static/figures/griffon.glb differ diff --git a/static/figures/hero.glb b/static/figures/hero.glb new file mode 100644 index 0000000..b9f019c Binary files /dev/null and b/static/figures/hero.glb differ diff --git a/static/figures/king.glb b/static/figures/king.glb new file mode 100644 index 0000000..f1020de Binary files /dev/null and b/static/figures/king.glb differ diff --git a/static/figures/mage.glb b/static/figures/mage.glb new file mode 100644 index 0000000..bcbba52 Binary files /dev/null and b/static/figures/mage.glb differ diff --git a/static/figures/oliphant.glb b/static/figures/oliphant.glb new file mode 100644 index 0000000..cbf71c8 Binary files /dev/null and b/static/figures/oliphant.glb differ diff --git a/static/figures/paladin.glb b/static/figures/paladin.glb new file mode 100644 index 0000000..4a4a65e Binary files /dev/null and b/static/figures/paladin.glb differ diff --git a/static/figures/sylph.glb b/static/figures/sylph.glb new file mode 100644 index 0000000..b2d1824 Binary files /dev/null and b/static/figures/sylph.glb differ diff --git a/static/figures/thief.glb b/static/figures/thief.glb new file mode 100644 index 0000000..711c07d Binary files /dev/null and b/static/figures/thief.glb differ diff --git a/static/figures/unicorn.glb b/static/figures/unicorn.glb new file mode 100644 index 0000000..ec72e48 Binary files /dev/null and b/static/figures/unicorn.glb differ diff --git a/static/figures/warrior.glb b/static/figures/warrior.glb new file mode 100644 index 0000000..cab8b95 Binary files /dev/null and b/static/figures/warrior.glb differ