Compare commits

..
17 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 93463a529b The glass board's figures are cached for a week: fifteen files that seldom change no longer go back to the server on every opening of the view
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-28 10:06:22 -04:00
Eric WagonerandClaude Fable 5.1 8c03fd624c A position under review leads the status strip and is announced; the glass sheets' rims carry the square names
Picking a move from the chronicle now puts the review first in the strip, with Earlier, Later and Back to now on the same line, and labels the live game beneath it; the piece counts and the move line beside the boards describe the position as drawn. On a phone the chosen board scrolls to sit under the strip once the layout has settled, so a pick made from the chronicle on a phone held sideways no longer leaves the board and its controls above the viewport. Each review step says the position and its move to the live region, and returning says the game as it stands; a button that vanished or went grey under the keyboard hands focus to the grid's own square, or to Back to now.

The glass board's sheets carry the files' letters and the ranks' numbers in their rims when the flat boards show them, the far edges turned about so that each seat reads its own; the rim is a little wider for them and the posts stand a little further out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-28 09:36:20 -04:00
Eric WagonerandClaude Fable 5.1 f2be3b0055 From the two testers' lists: on a phone the status strip carries a jump to each board, with a mark on any board the chosen piece can reach, and a phone on its side shows one board at a time, as wide as the screen, with Page Up and Page Down bringing the others out; a figure held by a Basilisk stands in a pale ring on the glass; the hall says which side and setup are chosen in one line and keeps the choices behind it until asked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 23:21:27 -04:00
Eric WagonerandClaude Fable 5.1 0f4ae0dca4 A move picked from the chronicle puts the boards back as they stood after it, on the flat boards and the glass alike, with Earlier, Later and Back to now in the status strip; one move on plays itself, any other leap sets the pieces out where they stood, and a tap on the boards brings the game back to now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 23:11:52 -04:00
Eric WagonerandClaude Fable 5.1 13fc4c5ef3 What an extended playtest found at the table: a move awaiting confirmation is asked in the status strip, which stays in view, and Confirm takes the focus, with Escape to cancel and the selection kept; the squares are one Tab stop, entered at the square last visited; the masthead's buttons wrap at tablet widths instead of running off the page; the glass canvas fits the window's height, seats its camera afresh when the frame turns wide, remembers its view across a spell on the flat boards, keeps its ways to look above the canvas on a phone, and sits a little further from the seat so the far rank fits; a Basilisk's gaze reaches the status line; a move picked from the chronicle is named in the status as lit on the boards as they stand, and its board is brought into view; the end of a game offers a fresh drake, a rematch and the hall together under the result, with the status no longer repeated in the story
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 22:43:04 -04:00
Eric WagonerandClaude Fable 5.1 b88c57f350 The told game on a phone leaves room beneath the boards for the Back and Next bar, measured, plus the screen's safe-area inset; the underworld's file letters were under it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 21:10:13 -04:00
Eric WagonerandClaude Fable 5.1 36508f29ca The table on a phone: the masthead keeps the title, who is playing and the room code, and folds the rest behind a Table button; the piece panel opens as a summary and unfolds the lore on request; the told game brings the move's board into view and keeps Back and Next at the foot of the screen with the move's name; a game against a drake can be started over at any time and played again when it ends
The masthead on a 390-pixel screen was 300 pixels tall, with seven buttons before the status line; it is 132 now. The piece panel was 30 percent of the screen with the lore scrolling inside; the summary is a third less, and the lore is a tap away. The told game's words were sticky on a phone as on a wide screen, and pinned themselves over the boards; they scroll with the page now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 20:57:14 -04:00
Eric WagonerandClaude Fable 5.1 7dd113b1ff Six things a first-time tester found: the rules counted five kinds that cross boards and named nine; a lesson's screen-reader line kept describing a piece after the move, and the outcome was never read; the chronicle's move links were too faint and small to look live; the hall's Play button sat disabled with no word about the name it waits for, and nothing said the drake is the computer; the lessons forgot which scenes were done
Scenes played right are remembered in this browser, ticked on their tabs, and the page opens on the first one left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 20:49:37 -04:00
Eric WagonerandClaude Fable 5.1 2de6e1f6ba The status line on your move says what the other side just did, in place of "Checkmate their King", which told the player nothing
"Vritra’s Warrior went to 2a2.", "took the Hero on 2e5", "struck 2g4 from the air", "and is a Hero now"; "The first move is yours." before any move.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 18:03:02 -04:00
Eric WagonerandClaude Fable 5.1 3c453c86b2 The share sheet shows the glass board at the opening, from the corner, in place of two cropped flat boards
docs/og-card.html is rebuilt around docs/og-board.png, the shot the card was made from, and says how to take it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 17:35:48 -04:00
Eric WagonerandClaude Fable 5.1 5099cf67e7 The glass board is open to everyone: the gate and its word are gone, and the toggle shows under the flat boards at every table and on the told game
The flat boards stay the default; the glass board is a tap away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 12:54:54 -04:00
Eric WagonerandClaude Fable 5.1 9a43952ad1 A table's tab says whose move it is, who is playing, or who won, in place of "a game"
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 10:58:12 -04:00
Eric WagonerandClaude Fable 5.1 65bdf4ecfb 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
2026-10-27 10:40:22 -04:00
Eric WagonerandClaude Fable 5.1 6be076e3fc The glass board arrives in seconds: the figures' textures are 256 px (3.8 MB for all fifteen, down from 30), each kind takes its places as its file lands behind a progress bar, the toggle says it is fetching, and the squares sit exactly under the figures now that the checker covers the sheet's rim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 09:39:59 -04:00
Eric WagonerandClaude Fable 5.1 cf92c9bd27 The glass board's marks are drawn over the glass and the figures: a dot on an empty square a piece may go to, a ring around a figure it may take or that is chosen, a halo for the last move, each on a dark rim so it reads on the pale air as well as the underworld
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 08:08:38 -04:00
Eric WagonerandClaude Fable 5.1 ff69d53e8c On a phone the piece panel opens short, the name and the situation, and the lore scrolls within it, so the boards stay in view
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 01:27:08 -04:00
Eric WagonerandClaude Fable 5.1 8744072dda 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
2026-10-27 01:13:00 -04:00
35 changed files with 1422 additions and 115 deletions

No files matched your search

+8
View File
@@ -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. The flat boards are the
default; the glass board is offered under them at the table and on the
told game.
- 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.
+6 -3
View File
@@ -36,12 +36,15 @@ __HOST__ {
# Everything else is the static site. SvelteKit writes each route as # Everything else is the static site. SvelteKit writes each route as
# <route>.html, so /rules is tried as /rules.html before falling back to the # <route>.html, so /rules is tried as /rules.html before falling back to the
# app shell, which serves the rooms. Hashed assets under _app/immutable are # app shell, which serves the rooms. Hashed assets under _app/immutable are
# cached for a year; every other response is revalidated so a deploy shows # cached for a year, and the glass board's figures, which weigh a few
# up on the next load. # megabytes and seldom change, for a week; every other response is
# revalidated so a deploy shows up on the next load.
handle { handle {
@immutable path /_app/immutable/* @immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/* @figures path /figures/*
header @figures Cache-Control "public, max-age=604800"
@mutable not path /_app/immutable/* /figures/*
header @mutable Cache-Control "no-cache" header @mutable Cache-Control "no-cache"
try_files {path} {path}.html /index.html try_files {path} {path}.html /index.html
file_server file_server
+95
View File
@@ -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?
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+10 -8
View File
@@ -1,21 +1,23 @@
<!doctype html> <!doctype html>
<!-- The share card, 1200 by 630. Open it with the site running at localhost:5173 and screenshot the body at 1200 by 630 into static/. --> <!-- The share card, 1200 by 630. og-board.png beside it is the glass board at
the opening, shot from the corner on the told game with the canvas
widened to 2400 pixels and the camera one wheel step back, then cropped
to 650 by 630. Open this file and screenshot the body into static/og.png. -->
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<style> <style>
body { margin: 0; width: 1200px; height: 630px; background: #121a20; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; } body { margin: 0; width: 1200px; height: 630px; background: #1c2830; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; }
.boards { position: absolute; left: 56px; top: 44px; width: 480px; height: 542px; overflow: hidden; border-radius: 8px; border: 1px solid rgba(233, 226, 210, 0.2); } .board { position: absolute; left: 0; top: 0; width: 650px; height: 630px; display: block; }
.boards img { width: 480px; display: block; } .words { position: absolute; left: 672px; top: 112px; width: 490px; }
.words { position: absolute; left: 600px; top: 120px; width: 540px; }
h1 { font-size: 110px; font-weight: 300; margin: 0; line-height: 1; letter-spacing: -0.01em; } h1 { font-size: 110px; font-weight: 300; margin: 0; line-height: 1; letter-spacing: -0.01em; }
.pitch { font-size: 30px; font-style: italic; margin: 22px 0 0; line-height: 1.3; } .pitch { font-size: 29px; font-style: italic; margin: 22px 0 0; line-height: 1.3; }
.tag { font-size: 23px; color: #98a4ab; margin: 26px 0 0; line-height: 1.4; } .tag { font-size: 22px; color: #a3aeb4; margin: 26px 0 0; line-height: 1.4; }
.site { font-size: 22px; color: #f0a53a; margin: 34px 0 0; letter-spacing: 0.04em; } .site { font-size: 22px; color: #f0a53a; margin: 34px 0 0; letter-spacing: 0.04em; }
</style> </style>
</head> </head>
<body> <body>
<div class="boards"><img src="http://localhost:5173/guide/boards.png" alt="" /></div> <img class="board" src="og-board.png" alt="" />
<div class="words"> <div class="words">
<h1>Wyrm</h1> <h1>Wyrm</h1>
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p> <p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
+65
View File
@@ -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",
+2
View File
@@ -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"
} }
} }
+336 -51
View File
@@ -9,14 +9,32 @@
import { narrate } from '$lib/game/chronicle'; import { narrate } from '$lib/game/chronicle';
import BoardArtwork, { type Token } from './BoardArtwork.svelte'; import BoardArtwork, { type Token } from './BoardArtwork.svelte';
import { import {
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES, legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES, PER_LEVEL,
type Move, type Side type Move, type Side
} from '$lib/game'; } from '$lib/game';
import type { Room } from '$lib/net/room.svelte'; import { Room } from '$lib/net/room.svelte';
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.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);
let fetching = $state(false);
async function seeGlass(open: boolean) {
if (open && !Glass) {
fetching = true;
try {
Glass = (await import('./Glass.svelte')).default;
} finally {
fetching = false;
}
}
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);
@@ -26,29 +44,32 @@
}; };
const flipped = $derived(mySide === 'scarlet'); const flipped = $derived(mySide === 'scarlet');
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove)); const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero --- // --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
let tokens = $state<Token[]>([]); let tokens = $state<Token[]>([]);
/** How many of the game's moves the tokens have played through. */ /** How many of the moves the tokens have played through. */
let shown = $state(0); let shown = $state(0);
let animating = $state(false); let animating = $state(false);
/** Bumped when the tokens are set out afresh, so a catch-up in flight lets go of them. */
let epoch = 0;
const calm = $derived(reducedMotion()); const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false); const showCoords = $derived(prefs.coordinates !== false);
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms)); const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
function resetTokens() { function resetTokens() {
epoch += 1;
let id = 0; let id = 0;
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }])); tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
shown = g.moves.length; shown = v.moves.length;
} }
async function catchUp() { async function catchUp() {
if (animating) return; if (animating) return;
animating = true; animating = true;
const mine = epoch;
try { try {
while (shown < g.moves.length) { while (shown < v.moves.length) {
const m = g.moves[shown]; const m = v.moves[shown];
const mover = tokens.find((t) => t.at === m.from && !t.dying); const mover = tokens.find((t) => t.at === m.from && !t.dying);
if (!mover) { if (!mover) {
resetTokens(); resetTokens();
@@ -62,12 +83,15 @@
if (victim) victim.dying = true; if (victim) victim.dying = true;
mover.at = m.to; mover.at = m.to;
await wait(380); await wait(380);
if (mine !== epoch) return;
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece; if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
} }
if (mine !== epoch) return;
if (victim) tokens = tokens.filter((t) => !t.dying); if (victim) tokens = tokens.filter((t) => !t.dying);
shown += 1; shown += 1;
announce(storyOf(shown)); if (reviewing === null) announce(storyOf(shown));
if (shown < g.moves.length) await wait(260); if (shown < v.moves.length) await wait(260);
if (mine !== epoch) return;
} }
} finally { } finally {
animating = false; animating = false;
@@ -75,30 +99,145 @@
} }
$effect(() => { $effect(() => {
const total = g.moves.length; const total = v.moves.length;
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens(); if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
else if (total > shown) void catchUp(); else if (total > shown) void catchUp();
}); });
// --- What the boards show: the last move played, or a move picked from the record --- // --- What the boards show: the game as it stands, or as it stood after a move picked from the record ---
let reviewing = $state<number | null>(null); let reviewing = $state<number | null>(null);
$effect(() => { $effect(() => {
void g.moves.length; void g.moves.length;
reviewing = null; reviewing = null;
selected = null; selected = null;
}); });
/** The state the boards draw: the game's own, or the position after the move under review, with that many moves played. */
const v = $derived.by((): typeof g => {
if (reviewing === null) return g;
const t = tellings[reviewing - 1];
return { ...g, cells: t.after, moves: g.moves.slice(0, reviewing), toMove: t.move.by === 'gold' ? 'scarlet' : 'gold' };
});
/** The King in check on the boards as drawn: the game's, or the position under review. */
const checkSquare = $derived(reviewing !== null ? (inCheck(v.cells, v.toMove) ? kingSquare(v.cells, v.toMove) : -1) : inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
const shownPly = $derived(reviewing ?? shown); const shownPly = $derived(reviewing ?? shown);
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null); const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []); const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []); const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
/** A move picked from the record puts the boards back as they stood after it, brings its board into view, and says so. */
function review(ply: number | null) {
if (ply !== null && (ply < 1 || ply > g.moves.length)) return;
const step = (ply ?? g.moves.length) - shown;
const had = document.activeElement;
reviewing = ply;
selected = null;
inspected = null;
// One move on plays itself; any other leap sets the pieces out where they stood.
if (step !== 1) resetTokens();
announce(ply === null ? `Back to the game as it stands, after move ${g.moves.length}. ${liveLine}` : `The boards after move ${ply} of ${g.moves.length}. ${storyOf(ply)}`);
const m = ply === null ? null : g.moves[ply - 1];
if (m && oneBoard && !onGlass) level = levelOf(m.to);
void tick().then(() => {
// A button that went with the mode, or went grey under the keyboard, leaves it nowhere: it goes back to the boards, or stays with the review.
const lost = document.activeElement === document.body || (document.activeElement === had && had instanceof HTMLButtonElement && had.disabled);
if (lost) {
if (ply === null) focusBoards();
else backToNow?.focus();
}
if (!m) return;
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
if (!target) return;
if (phone) scrollUnderStrip(target);
else target.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
});
}
let backToNow = $state<HTMLButtonElement | null>(null);
let artwork = $state<ReturnType<typeof BoardArtwork>>();
let glassWay = $state<HTMLButtonElement | null>(null);
/** The keyboard goes to the boards: the grid's own square, or, on the glass, the way back to the flat boards. */
function focusBoards() {
if (artwork) artwork.focusGrid();
else glassWay?.focus();
}
// --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time ---
let phone = $state(false);
let oneBoard = $state(false);
$effect(() => {
const narrow = matchMedia('(max-width: 860px)');
const short = matchMedia('(max-width: 1000px) and (max-height: 540px)');
const read = () => {
phone = narrow.matches;
oneBoard = short.matches;
};
read();
narrow.addEventListener('change', read);
short.addEventListener('change', read);
return () => {
narrow.removeEventListener('change', read);
short.removeEventListener('change', read);
};
});
/** The board shown when there is room for one at a time; a move made on another brings that one forward. */
let level = $state(2);
$effect(() => {
const m = g.moves.at(-1);
if (m) level = levelOf(m.to);
});
function goLevel(lv: number) {
if (oneBoard) {
level = lv;
return;
}
const world = document.querySelector(`.play g.world[data-level="${lv}"]`);
if (world) scrollUnderStrip(world);
}
/** Scrolls a board to sit just under the status strip, which stays in view above it. */
function scrollUnderStrip(el: Element) {
const strip = document.querySelector('.play .status');
const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
scrollTo({ top, behavior: calm ? 'auto' : 'smooth' });
}
/** The game told move by move, replayed from the ledger's moves. */ /** The game told move by move, replayed from the ledger's moves. */
const tellings = $derived(narrate(g)); const tellings = $derived(narrate(g));
function storyOf(ply: number): string { function storyOf(ply: number): string {
const t = tellings[ply - 1]; const t = tellings[ply - 1];
return t ? t.lines.map((l) => l.text).join(' ') : ''; return t ? t.lines.map((l) => l.text).join(' ') : '';
} }
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : ''); const shownStory = $derived.by(() => {
const t = tellings[shownPly - 1];
if (!t) return '';
// Once the game is over the status says how it ended; the story keeps to the move.
const lines = g.over && reviewing === null ? t.lines.filter((l) => l.kind !== 'end') : t.lines;
return lines.map((l) => l.text).join(' ');
});
const shownFreeze = $derived(tellings[shownPly - 1]?.lines.some((l) => l.kind === 'freeze') ?? false);
/** What the other side just did, in a few words, for the status line when it is your move. */
const lastLine = $derived.by(() => {
const m = g.moves.at(-1);
if (!m) return '';
const who = nameOfSide(m.by);
if (m.afar) return `${who}’s Dragon struck ${squareName(m.to)} from the air.`;
const did = m.captured ? `took the ${NAMES[m.captured]} on` : 'went to';
let line = `${who}’s ${NAMES[m.kind]} ${did} ${squareName(m.to)}${m.promoted ? ' and is a Hero now' : ''}.`;
// A gaze that falls changes what the player can do as much as a capture, so it is said here too.
const over = m.to + PER_LEVEL;
if (m.kind === 'B' && levelOf(m.to) === 1 && frozen(g.cells, over)) {
const owner = sideOf(g.cells[over]) === mySide ? 'Your' : `${nameOfSide(sideOf(g.cells[over])!)}’s`;
line += ` ${owner} ${NAMES[kindOf(g.cells[over])]} on ${squareName(over)} is frozen under it.`;
} else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.';
return line;
});
/** The game as it stands, in a few words: shown beside a position under review, and said when the review ends. */
const liveLine = $derived.by(() => {
if (g.over) return `${verdict} ${g.over.reason}`;
if (room.spectating) return `${nameOfSide(g.toMove)} to move.`;
if (yourMove) return `Your move${inCheckNow ? ', and your King is in check' : ''}.`;
if (room.view.playingOn) return 'The drakes play on.';
return `${nameOfSide(g.toMove)} is thinking.`;
});
// --- Choosing and moving --- // --- Choosing and moving ---
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
@@ -117,12 +256,38 @@
liveText = text; liveText = text;
} }
/** A move awaiting the player's word, when they asked to confirm. */ /** A move awaiting the player's word, when they asked to confirm. It is put in the status strip, which stays in view, and takes the keyboard's focus. */
let pending = $state<{ from: number; to: number } | null>(null); let pending = $state<{ from: number; to: number } | null>(null);
let confirmButton = $state<HTMLButtonElement | null>(null);
/** The square that had the focus before the question was asked, to give it back afterwards. */
let askedFrom: Element | null = null;
$effect(() => {
if (pending && confirmButton) {
askedFrom = document.activeElement;
confirmButton.focus();
}
});
async function answered() {
await tick();
if (askedFrom instanceof HTMLElement || askedFrom instanceof SVGElement) askedFrom.focus();
askedFrom = null;
}
function cancel() {
if (!pending) return;
const { from } = pending;
pending = null;
announce(`Move cancelled. ${squareName(from)} is still selected.`);
void answered();
}
function cancelOnEscape(e: KeyboardEvent) {
if (e.key === 'Escape') cancel();
}
async function send(from: number, to: number) { async function send(from: number, to: number) {
const asked = pending !== null;
pending = null; pending = null;
selected = null; selected = null;
if (asked) void answered();
await room.submit({ from, to }); await room.submit({ from, to });
} }
@@ -130,7 +295,11 @@
async function tap(i: number) { async function tap(i: number) {
if (room.sending || animating) return; if (room.sending || animating) return;
reviewing = null; if (reviewing !== null) {
// The boards are showing an earlier position: a tap brings them back to now, and nothing more.
review(null);
return;
}
if (!yourMove) { if (!yourMove) {
// Not your move, or no seat: a tap on a piece still opens its panel. // Not your move, or no seat: a tap on a piece still opens its panel.
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null; inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
@@ -140,7 +309,7 @@
if (selected !== null && targets.includes(i)) { if (selected !== null && targets.includes(i)) {
if (prefs.confirmMoves === true) { if (prefs.confirmMoves === true) {
pending = { from: selected, to: i }; pending = { from: selected, to: i };
announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel below.`); announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel.`);
return; return;
} }
await send(selected, i); await send(selected, i);
@@ -173,44 +342,104 @@
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`; return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
}); });
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length; /** A game against a drake can be started over at any time: a fresh table, the same side and setup. */
const drake = $derived(!room.spectating && room.view.seats.some((s) => s.bot && !s.standIn));
const foes = $derived(room.others.map((s) => s.name).join(' and '));
let starting = $state(false);
let fault = $state('');
async function again() {
starting = true;
fault = '';
try {
const next = await Room.createWithBot(room.nameOf(room.me!), room.view.options);
await goto(`/join/${next.roomId}`);
} catch (e) {
fault = e instanceof Error ? e.message : 'A new table could not be opened.';
} finally {
starting = false;
}
}
/** A rematch keeps this table's company: a new table, with a seat held for each of them. */
async function rematch() {
starting = true;
fault = '';
try {
await goto(`/join/${await room.rematch()}`);
} catch (e) {
fault = e instanceof Error ? e.message : 'The rematch could not be called.';
} finally {
starting = false;
}
}
const pieceCount = (side: Side) => v.cells.filter((p) => p !== '' && sideOf(p) === side).length;
</script> </script>
<main class="board"> <main class="board">
<section class="play"> <section class="play">
<div class="status" class:yours={yourMove}> <div class="status" class:yours={yourMove}>
{#if g.over} {#if pending}
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
{:else if reviewing !== null}
<strong>After move {reviewing} of {g.moves.length}</strong> · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0}&nbsp;· <span class="check">in check</span>{/if} ·
<span class="marked"><button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
<button type="button" class="link" disabled={reviewing >= g.moves.length} onclick={() => review(reviewing! + 1)}>Later</button> ·
<button type="button" class="link" bind:this={backToNow} onclick={() => review(null)}>Back to now</button></span>
{:else if g.over}
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span> <strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
{:else if room.spectating} {:else if room.spectating}
<strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span> <strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow}&nbsp;· <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
{:else if yourMove} {:else if yourMove}
<strong>Your move</strong> · {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">your King is in check</span>{/if} · <span class="goal">Checkmate {nameOfSide(g.toMove === 'gold' ? 'scarlet' : 'gold')}’s King.</span> <strong>Your move</strong> · {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">your King is in check</span>{/if} · <span class="last">{lastLine || 'The first move is yours.'}</span>
{:else if room.view.playingOn} {:else if room.view.playingOn}
<strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>&nbsp;<button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button> <strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>&nbsp;<button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>
{:else} {:else}
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if} <strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow}&nbsp;· <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
{/if} {/if}
{#if phone && !onGlass && !pending}
<span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span>
{/if}
{#if reviewing !== null}
<span class="now muted">The game now, after move {g.moves.length}: {liveLine}</span>
{/if}
</div> </div>
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)} {#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null}&nbsp;<button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p> <p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</p>
{/if}
{#if pending}
<p class="confirm">Move {squareName(pending.from)} to {squareName(pending.to)}?
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
<button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="frame">
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div>
<p class="visually-hidden" aria-live="polite">{liveText}</p>
{#if g.over} {#if g.over}
<div class="verdict"> <div class="verdict">
<a class="commit" href="/">Back to the hall</a> {#if room.spectating}
<a class="commit" href="/">Back to the hall</a>
{:else if room.view.rematch}
<p>{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}. <a href={`/join/${room.view.rematch.to}`}>Take your seat</a></p>
{:else if drake}
<button type="button" class="commit" disabled={starting} onclick={again}>Play a fresh drake</button>
<button type="button" class="quiet" disabled={starting} onclick={rematch}>Rematch {foes}</button>
<a class="quiet" href="/">Back to the hall</a>
<p class="muted small">A fresh drake is a new table at once, your side and setup kept. A rematch seats this table's company at a new one, and waits for everyone to begin.</p>
{:else}
<button type="button" class="commit" disabled={starting} onclick={rematch}>Rematch with this table</button>
<a class="quiet" href="/">Back to the hall</a>
<p class="muted small">The same company sits down at a new table, with a seat held for everyone here.</p>
{/if}
</div> </div>
{:else if yourMove} {/if}
<p class="muted small glass-way"><button type="button" class="link plain" bind:this={glassWay} disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
{#if fault}<p class="warning">{fault}</p>{/if}
{#if onGlass && Glass}
<Glass g={v} {mySide} {showCoords} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
{:else}
<div class="frame">
<BoardArtwork bind:this={artwork} g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
</div>
{/if}
<p class="visually-hidden" aria-live="polite">{liveText}</p>
{#if yourMove}
<p class="muted small how">Tap a piece, then a square on any board. Tap any piece, yours or not, to read what it is and what it can do. With a keyboard, the arrows move about a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p> <p class="muted small how">Tap a piece, then a square on any board. Tap any piece, yours or not, to read what it is and what it can do. With a keyboard, the arrows move about a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p>
{/if} {/if}
</section> </section>
@@ -225,16 +454,16 @@
<h2>At the table</h2> <h2>At the table</h2>
<ul> <ul>
{#each g.seats as id (id)} {#each g.seats as id (id)}
<li class:to-move={g.players[id].side === g.toMove && !g.over}> <li class:to-move={g.players[id].side === v.toMove && !(g.over && reviewing === null)}>
<span class="dot {g.players[id].side}"></span> <span class="dot {g.players[id].side}"></span>
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span> <span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span> <span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
</li> </li>
{/each} {/each}
</ul> </ul>
<p class="muted small">Move {g.moves.length + 1}.</p> <p class="muted small">{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}</p>
</section> </section>
<Chronicle {tellings} {mySide} {reviewing} onReview={(ply) => (reviewing = ply)} /> <Chronicle {tellings} {mySide} {reviewing} onReview={review} />
<TableTalk {room} /> <TableTalk {room} />
</aside> </aside>
</main> </main>
@@ -281,10 +510,53 @@
color: var(--ember); color: var(--ember);
} }
.goal { .last {
color: var(--bone); color: var(--bone);
} }
.status .commit,
.status .quiet {
margin-left: 0.3rem;
vertical-align: baseline;
}
.marked {
display: inline-block;
margin-left: 0.2rem;
color: var(--frost);
}
.now {
display: block;
margin-top: 0.2rem;
}
/* The jumps: one a board, and a mark on any board the chosen piece can reach. */
.jump {
display: flex;
gap: 1rem;
margin-top: 0.25rem;
font-size: 0.85rem;
}
.jump .current {
color: var(--ember);
}
.jump .lit::after {
content: ' ●';
font-size: 0.6em;
vertical-align: middle;
color: var(--frost);
}
.marked .link:disabled {
color: var(--bone-faint);
text-decoration: none;
cursor: default;
}
.check { .check {
color: var(--ember); color: var(--ember);
font-weight: 600; font-weight: 600;
@@ -306,20 +578,23 @@
margin: 0 auto 0.5rem; margin: 0 auto 0.5rem;
} }
.confirm {
max-width: 640px;
margin: 0 auto 0.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.frame { .frame {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }
/* A phone on its side has no height for three boards: one shows at a time, as wide as the screen allows. */
@media (max-width: 1000px) and (max-height: 540px) {
.frame {
max-width: none;
}
}
.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;
@@ -327,7 +602,17 @@
.verdict { .verdict {
max-width: 640px; max-width: 640px;
margin: 0.8rem auto 0; margin: 0 auto 0.8rem;
}
.verdict .commit,
.verdict .quiet {
display: inline-block;
margin: 0 0.3rem 0.4rem 0;
}
.verdict p {
margin: 0;
} }
.panel { .panel {
@@ -338,7 +623,7 @@
} }
@media (max-width: 860px) { @media (max-width: 860px) {
/* On a phone the boards run long: the panel rises from the bottom over them, and scrolls within itself. */ /* On a phone the panel rises from the bottom over the boards, short enough to leave them in view: the piece's name and its situation show, and the lore scrolls within the sheet. */
.panel { .panel {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -346,7 +631,7 @@
bottom: 0; bottom: 0;
top: auto; top: auto;
margin: 0; margin: 0;
max-height: 48vh; max-height: 30vh;
overflow-y: auto; overflow-y: auto;
padding: 0 var(--gutter) 0.5rem; padding: 0 var(--gutter) 0.5rem;
background: var(--slate-deep); background: var(--slate-deep);
+24 -3
View File
@@ -15,10 +15,11 @@
// underworld, each twelve by eight in the colours Gygax suggested, with // underworld, each twelve by eight in the colours Gygax suggested, with
// the pieces as discs bearing his letters. Shared by the table, the // the pieces as discs bearing his letters. Shared by the table, the
// lessons and the hall's still of the opening. // lessons and the hall's still of the opening.
import { tick } from 'svelte';
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game'; import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
let { let {
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
calm = true, decorative = false, tap = () => {}, escape = () => {} calm = true, decorative = false, tap = () => {}, escape = () => {}, climb
}: { }: {
g: State; g: State;
tokens?: Token[]; tokens?: Token[];
@@ -40,9 +41,14 @@
tap?: (i: number) => void | Promise<void>; tap?: (i: number) => void | Promise<void>;
/** Escape pressed on a square: the caller lets go of whatever it was doing. */ /** Escape pressed on a square: the caller lets go of whatever it was doing. */
escape?: () => void; escape?: () => void;
/** Page Up or Page Down towards a board that is not drawn: the caller may bring it out. */
climb?: (level: number) => void;
} = $props(); } = $props();
let svg: SVGSVGElement | undefined = $state(); let svg: SVGSVGElement | undefined = $state();
const focusSquare = (i: number) => svg?.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
/** The one square Tab reaches: the grid is entered once, at the square last visited, and the keys walk it from there. */
let visited = $state<number | null>(null);
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */ /** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
function key(e: KeyboardEvent, i: number) { function key(e: KeyboardEvent, i: number) {
const step: Record<string, [number, number, number]> = { const step: Record<string, [number, number, number]> = {
@@ -55,7 +61,13 @@
const f = fileOf(i) + df; const f = fileOf(i) + df;
const r = rankOf(i) + dr; const r = rankOf(i) + dr;
const lv = levelOf(i) + dl; const lv = levelOf(i) + dl;
if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector<SVGRectElement>(`rect.square[data-i="${idx(lv, f, r)}"]`)?.focus(); if (f < 0 || f >= FILES || r < 0 || r >= RANKS || lv < 1 || lv > 3) return;
const to = idx(lv, f, r);
if (levels.includes(lv)) focusSquare(to);
else if (climb) {
climb(lv);
void tick().then(() => focusSquare(to));
}
} else if (e.key === 'Enter' || e.key === ' ') { } else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
void tap(i); void tap(i);
@@ -86,6 +98,14 @@
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL }; return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
} }
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n))); const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
const entry = $derived.by(() => {
for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c;
return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0];
});
/** Puts the keyboard on the grid, at the square Tab would reach. */
export function focusGrid() {
focusSquare(entry);
}
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n]; const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n); const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
const describe = (i: number) => { const describe = (i: number) => {
@@ -135,7 +155,8 @@
role={decorative ? undefined : 'gridcell'} role={decorative ? undefined : 'gridcell'}
aria-label={decorative ? undefined : describe(i)} aria-label={decorative ? undefined : describe(i)}
aria-selected={decorative ? undefined : selected === i} aria-selected={decorative ? undefined : selected === i}
tabindex={decorative ? undefined : 0} tabindex={decorative ? undefined : entry === i ? 0 : -1}
onfocus={decorative ? undefined : () => (visited = i)}
onclick={decorative ? undefined : () => tap(i)} onclick={decorative ? undefined : () => tap(i)}
onkeydown={decorative ? undefined : (e) => key(e, i)} onkeydown={decorative ? undefined : (e) => key(e, i)}
/> />
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
// The game told top to bottom, oldest first, each move with what came of // The game told top to bottom, oldest first, each move with what came of
// it, and the newest scrolled into view. A move's heading is a button that // it, and the newest scrolled into view. A move's heading is a button that
// shows that move on the boards. // puts the boards back as they stood after it.
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { Telling } from '$lib/game/chronicle'; import type { Telling } from '$lib/game/chronicle';
import { notation, type Side } from '$lib/game'; import { notation, type Side } from '$lib/game';
@@ -25,7 +25,7 @@
{#each tellings as t (t.ply)} {#each tellings as t (t.ply)}
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}> <article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
<h3> <h3>
<button type="button" class="link plain" title="Show this move on the boards" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}> <button type="button" class="link plain" title="Show the boards as they stood after this move" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
Move {t.ply} <span class="notation">{notation(t.move)}</span> Move {t.ply} <span class="notation">{notation(t.move)}</span>
</button> </button>
</h3> </h3>
@@ -52,8 +52,8 @@
} }
h3 { h3 {
font-size: 0.8rem; font-size: 0.85rem;
color: var(--bone-faint); color: var(--bone-dim);
margin: 0.6rem 0 0.2rem; margin: 0.6rem 0 0.2rem;
font-weight: 500; font-weight: 500;
} }
+626
View File
@@ -0,0 +1,626 @@
<script module lang="ts">
/** Where the camera was left, so a spell on the flat boards and back finds the view as it was. */
let kept: { view: 'quarter' | 'seat' | 'above'; sheet: 1 | 2 | 3; side: string | null; position: number[]; target: number[]; zoom: number } | null = null;
</script>
<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, showCoords = true, tap = () => {} }: {
g: State;
mySide?: Side | null;
selected?: number | null;
targets?: number[];
reach?: number[];
shownSquares?: number[];
shownCaptured?: number[];
checkSquare?: number;
calm?: boolean;
/** Whether the sheets' rims carry the files' letters and the ranks' numbers, as the flat boards do. */
showCoords?: 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', frozen: '#b9c9d2' };
/** Air between the sheets, in squares: enough for a Dragon's wings. */
const GAP = 4.2;
const CELL = 1;
/** The sheet's rim beyond the outer squares, in squares: room for the square names. */
const RIM = 0.45;
/** The ink the rim's letters are written in, dark on the pale sheets and pale on the red one. */
const INK: Record<number, string> = { 3: '#2a3b48', 2: '#33391f', 1: '#f3dfd0' };
/** Repaints the sheets when the preference for square names changes; set once the sheets exist. */
let repaint: (() => void) | null = null;
$effect(() => {
void showCoords;
repaint?.();
});
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…');
/** How many of the fifteen kinds of figure have arrived. */
let arrived = $state(0);
/** The view left last time, if it was from the same side of the table: the side at mounting is the one that matters. */
// svelte-ignore state_referenced_locally
let back = kept && kept.side === mySide ? kept : null;
let view = $state<'quarter' | 'seat' | 'above'>(back?.view ?? '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>(back?.sheet ?? 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 }
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 = $state(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[] = [];
const sheets: { level: number; c: HTMLCanvasElement; map: THREE.CanvasTexture }[] = [];
// The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures.
// The canvas's bottom edge is Gold's, rank 1; its left edge is file a.
const PX = 64;
function paint(level: number, c: HTMLCanvasElement) {
const ctx = c.getContext('2d')!;
ctx.fillStyle = SQUARES[level][1];
ctx.fillRect(0, 0, c.width, c.height);
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((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX);
}
if (!showCoords) return;
// The names run along every edge, the far ones turned about, so that each seat reads its own.
ctx.fillStyle = INK[level];
ctx.font = `600 ${Math.round(PX * 0.34)}px 'Iowan Old Style', Georgia, serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const inset = (RIM * PX) / 2;
const write = (text: string, x: number, y: number, turned: boolean) => {
ctx.save();
ctx.translate(x, y);
if (turned) ctx.rotate(Math.PI);
ctx.fillText(text, 0, 1);
ctx.restore();
};
for (let f = 0; f < FILES; f++) {
const letter = 'abcdefghijkl'[f];
const x = (RIM + f + 0.5) * PX;
write(letter, x, c.height - inset, false);
write(letter, x, inset, true);
}
for (let r = 0; r < RANKS; r++) {
const y = (RIM + RANKS - 1 - r + 0.5) * PX;
write(String(r + 1), inset, y, false);
write(String(r + 1), c.width - inset, y, true);
}
}
repaint = () => {
for (const sh of sheets) {
paint(sh.level, sh.c);
sh.map.needsUpdate = true;
}
};
for (const level of [1, 2, 3]) {
const c = document.createElement('canvas');
c.width = (FILES + 2 * RIM) * PX;
c.height = (RANKS + 2 * RIM) * PX;
paint(level, c);
const map = new THREE.CanvasTexture(c);
map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = 8;
sheets.push({ level, c, map });
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.62, 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);
}
// 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 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;
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.75), 0, z * (RANKS / 2 + 0.75));
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.
// They are drawn over everything, so that neither the glass above nor a figure on the
// square can hide them, and each sits on a dark rim so it reads on the pale air as well as
// the underworld. A dot is an empty square a piece may go to; a ring hugs a figure's base.
const marks = new THREE.Group();
scene.add(marks);
const SHAPE = {
dot: [new THREE.CircleGeometry(CELL * 0.18, 24), new THREE.CircleGeometry(CELL * 0.23, 24)],
ring: [new THREE.RingGeometry(CELL * 0.5, CELL * 0.6, 48), new THREE.RingGeometry(CELL * 0.46, CELL * 0.64, 48)],
halo: [new THREE.RingGeometry(CELL * 0.66, CELL * 0.74, 48), new THREE.RingGeometry(CELL * 0.63, CELL * 0.77, 48)]
};
function mark(i: number, shape: keyof typeof SHAPE, colour: string, opacity = 1) {
const [face, rim] = SHAPE[shape];
const layers: [THREE.BufferGeometry, string, number][] = [[rim, '#1b1410', opacity * 0.75], [face, colour, opacity]];
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));
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));
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);
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();
}
/** 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 }
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.
// 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 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(squareOf(named)!);
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.
// On a narrow screen the camera starts nearer, so the squares are big enough for a finger; the frame turning wide again seats it afresh.
const nearness = () => (frame.clientWidth < 560 ? 0.7 : 1);
let nearUsed = 0;
function look(which: typeof view) {
const s = mySide === 'scarlet' ? -1 : 1;
const near = nearness();
nearUsed = near;
controls.dispose();
if (which === 'above') {
camera = above;
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 * near, 17 * near, 19 * s * near);
else perspective.position.set(0, 23 * near, 21.5 * s * near);
perspective.up.set(0, 1, 0);
}
controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.49;
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);
if (back) {
camera.position.fromArray(back.position);
controls.target.fromArray(back.target);
above.zoom = back.zoom;
above.updateProjectionMatrix();
back = null;
}
controls.update();
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;
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;
fadeNearPost();
renderer.render(scene, camera);
awake = busy || tweens.length > 0;
if (awake) requestAnimationFrame(tick);
}
function resize() {
// A narrow frame gets a taller canvas, but never taller than the window less the status strip above and the ways beneath, so a phone on its side still sees the whole table. Both cameras open wide enough to take in the sheets' full width.
const w = frame.clientWidth;
const h = Math.min(Math.round(w * (w < 560 ? 1.1 : 0.75)), Math.max(220, innerHeight - 120));
const aspect = w / h;
renderer.setSize(w, h, false);
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 = Math.max(5.4, 7.0 / aspect);
above.left = -half * aspect;
above.right = half * aspect;
above.top = half;
above.bottom = -half;
above.updateProjectionMatrix();
if (nearUsed && nearness() !== nearUsed) look(view);
wake();
}
const watcher = new ResizeObserver(resize);
watcher.observe(frame);
addEventListener('resize', resize);
resize();
look(view);
// Each kind's figures take their places as its file arrives, so the board fills in while the rest are fetched.
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) });
arrived++;
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && kindOf(g.cells[i]) === kind && !figures.has(i)) figure(i);
wake();
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();
// A figure held by a Basilisk stands in a pale ring, since its dimmed livery is easy to miss among forty.
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && frozen(g.cells, i) && i !== selected) mark(i, 'ring', MARK.frozen, 0.8);
for (const i of reach) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.reach, 0.85);
for (const i of shownSquares) mark(i, 'halo', MARK.last);
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
for (const i of targets) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.target);
if (selected !== null) mark(selected, 'ring', MARK.selected);
if (checkSquare >= 0 && checkSquare !== selected) mark(checkSquare, 'ring', MARK.check);
wake();
});
let looked = false;
$effect(() => {
void reset;
void view;
if (looked) look(view);
looked = true;
});
$effect(() => {
void sheet;
shade();
});
return () => {
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
repaint = null;
removeEventListener('resize', resize);
watcher.disconnect();
controls.dispose();
renderer.dispose();
};
});
</script>
<div class="glass" bind:this={frame}>
<div class="stage">
<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}
<div class="status" role="status">
<p>{status}</p>
{#if !ready}<progress max={Object.keys(FIGURE).length} value={arrived}>{arrived} of {Object.keys(FIGURE).length}</progress>{/if}
</div>
{/if}
</div>
<p class="ways muted small">
{#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}
</p>
<p class="hint muted small">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.</p>
</div>
<style>
.glass {
display: flex;
flex-direction: column;
max-width: 960px;
margin: 0 auto;
}
.stage {
position: relative;
}
/* On a phone the piece's sheet rises over the foot of the screen, so the ways to look sit above the canvas, out from under it. */
@media (max-width: 860px) {
.ways {
order: -1;
margin: 0 0 0.4rem;
}
}
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 6px;
}
.status {
position: absolute;
inset: 0 0 auto;
margin: 0;
padding: 2rem 1rem 0;
text-align: center;
}
.status p {
margin: 0 0 0.6rem;
font-size: 1.15rem;
color: var(--bone);
}
progress {
width: min(60%, 320px);
height: 6px;
accent-color: var(--ember);
}
.ways {
margin: 0.4rem 0 0;
}
.ways .current {
color: var(--ember);
}
.sheets {
display: block;
margin-top: 0.2rem;
}
.sep {
margin: 0 0.35em;
}
.hint {
margin: 0.2rem 0 0;
}
</style>
+14 -1
View File
@@ -24,6 +24,8 @@
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
); );
let prefsOpen = $state(false); let prefsOpen = $state(false);
/** The side and the Sylphs' setup wait behind one line until asked for. */
let setupOpen = $state(false);
/** The opening as the host's choices lay it, drawn beside the buttons. */ /** The opening as the host's choices lay it, drawn beside the buttons. */
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options)); const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
let busy = $state(false); let busy = $state(false);
@@ -216,8 +218,11 @@
<span>Your name</span> <span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" /> <input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label> </label>
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
{#if game.options?.length} {#if game.options?.length}
<div class="options"> {@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)}
<p class="setup muted small">{chosenLabels.join(' ')} <button type="button" class="link plain" aria-expanded={setupOpen} onclick={() => (setupOpen = !setupOpen)}>{setupOpen ? 'Done' : 'Change'}</button></p>
<div class="options" hidden={!setupOpen}>
{#each game.options as o (o.key)} {#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])} {@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div> <div>
@@ -659,6 +664,14 @@
margin: 0.2rem 0 0.6rem; margin: 0.2rem 0 0.6rem;
} }
.options[hidden] {
display: none;
}
.setup {
margin: 0.2rem 0 0.6rem;
}
.option { .option {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -1
View File
@@ -23,12 +23,14 @@
const checkSquare = $derived(inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : -1); const checkSquare = $derived(inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : -1);
const calm = $derived(reducedMotion()); const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false); const showCoords = $derived(prefs.coordinates !== false);
let liveText = $state('');
$effect(() => { $effect(() => {
// A move or a new scene leaves nothing selected, and nothing to say about one.
void g.cells; void g.cells;
selected = null; selected = null;
inspected = null; inspected = null;
liveText = '';
}); });
let liveText = $state('');
function tap(i: number) { function tap(i: number) {
if (selected !== null && targets.includes(i)) { if (selected !== null && targets.includes(i)) {
+49 -7
View File
@@ -50,6 +50,13 @@
return lines; return lines;
}); });
const letter = $derived(LETTERS[kind]); const letter = $derived(LETTERS[kind]);
/** On a phone the panel opens as a summary, and the lore unfolds on request. */
let more = $state(false);
$effect(() => {
void at;
more = false;
});
</script> </script>
<aside class="lore" aria-label="About this piece"> <aside class="lore" aria-label="About this piece">
@@ -62,13 +69,16 @@
<button type="button" class="dismiss" title="Close" aria-label="Close" onclick={close}>×</button> <button type="button" class="dismiss" title="Close" aria-label="Close" onclick={close}>×</button>
</header> </header>
<p class="now">{situation.join(' ')}</p> <p class="now">{situation.join(' ')}</p>
<p>{lore.is}</p> <button type="button" class="link plain unfold" aria-expanded={more} onclick={() => (more = !more)}>{more ? 'Less' : 'How it moves, why it is here, how to play it'}</button>
<h3>How it moves</h3> <div class="rest" class:open={more}>
<p>{lore.moves}</p> <p>{lore.is}</p>
<h3>Why it is here</h3> <h3>How it moves</h3>
<p>{lore.why}</p> <p>{lore.moves}</p>
<h3>How to play it</h3> <h3>Why it is here</h3>
<p>{lore.play}</p> <p>{lore.why}</p>
<h3>How to play it</h3>
<p>{lore.play}</p>
</div>
</aside> </aside>
<style> <style>
@@ -107,6 +117,38 @@
font-weight: 500; font-weight: 500;
} }
.unfold {
display: none;
}
@media (max-width: 860px) {
.lore {
padding-top: 0.6rem;
}
header {
margin-bottom: 0.3rem;
}
.now {
margin-bottom: 0.4rem;
}
.unfold {
display: inline;
font-size: 0.85rem;
}
.rest {
display: none;
}
.rest.open {
display: block;
margin-top: 0.5rem;
}
}
.now { .now {
color: var(--bone); color: var(--bone);
border-left: 2px solid var(--ember); border-left: 2px solid var(--ember);
+3 -1
View File
@@ -20,6 +20,8 @@ export interface Telling {
ply: number; ply: number;
move: Move; move: Move;
lines: Line[]; lines: Line[];
/** The boards as they stood once the move was made. */
after: Cell[];
} }
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' }; const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
@@ -127,7 +129,7 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
state.moves.forEach((move, n) => { state.moves.forEach((move, n) => {
const { lines, after } = tellOne(cells, move, owner); const { lines, after } = tellOne(cells, move, owner);
cells = after; cells = after;
out.push({ ply: n + 1, move, lines }); out.push({ ply: n + 1, move, lines, after });
}); });
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason }); if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
return out; return out;
+61 -27
View File
@@ -18,10 +18,10 @@
let error = $state(''); let error = $state('');
let copied = $state(false); let copied = $state(false);
let reporting = $state(false); let reporting = $state(false);
let tableOpen = $state(false);
let prefsOpen = $state(false); let prefsOpen = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */ /** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
let seatsOpen = $state(false); let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */ /** Why this browser's seat no longer opens, when the server said so; shown while watching. */
let seatLost = $state(''); let seatLost = $state('');
@@ -30,6 +30,16 @@
const link = $derived(`${location.origin}/join/${roomId}`); const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false); const watching = $derived(room?.spectating ?? false);
/** The tab's title: whose move it is, or how the game stands, so a glance at the tab bar says whether it is your turn. */
const title = $derived.by(() => {
const g = room?.state;
if (!room || !g) return `table ${roomId}`;
const seat = g.seats.find((id) => g.players[id].side === g.toMove);
const toMove = seat ? g.players[seat].name : g.toMove;
if (g.over) return g.over.winner === null ? 'a draw' : g.over.winner === room.me ? 'you win' : `${g.players[g.over.winner].name} wins`;
if (watching) return `${room.others.map((s) => s.name).join(' and ')}, ${toMove} to move`;
return seat === room.me ? 'your move' : `${toMove} to move`;
});
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat: // Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that // it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
@@ -143,22 +153,10 @@
} }
} }
/** Call for a rematch, or take the seat held at one already called. */
async function rematch() {
if (!room) return;
rematching = true;
try {
await goto(`/join/${await room.rematch()}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The rematch could not be called.';
} finally {
rematching = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
<title>Wyrm: a game</title> <title>Wyrm: {title}</title>
</svelte:head> </svelte:head>
<header class="masthead"> <header class="masthead">
@@ -180,6 +178,10 @@
{#if room.view.audience > 0} {#if room.view.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span> <span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
{/if} {/if}
{/if}
<button type="button" class="quiet more" aria-expanded={tableOpen} onclick={() => (tableOpen = !tableOpen)}>Table {tableOpen ? '▴' : '▾'}</button>
<div class="rest">
{#if room}
{#if !watching} {#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button> <button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if} {/if}
@@ -194,6 +196,7 @@
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button> <button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
{/if} {/if}
<a class="quiet" href="/">The hall</a> <a class="quiet" href="/">The hall</a>
</div>
</nav> </nav>
</header> </header>
@@ -211,18 +214,6 @@
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p> <p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
{/if} {/if}
{#if room?.view.over && !watching}
<p class="notice muted">
{#if room.view.rematch}
{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}.
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
{/if}
</p>
{/if}
{#if joining && room?.seatEmpty} {#if joining && room?.seatEmpty}
<section class="seat"> <section class="seat">
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
@@ -294,11 +285,44 @@
color: var(--bone); color: var(--bone);
} }
.masthead > div {
flex: 1 1 16em;
min-width: 0;
}
/* The buttons wrap onto a second row before they push the title's column aside or run off the page. */
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0; flex: 0 1 auto;
}
/* On a phone the masthead keeps the title, who is playing and the room code; the rest waits behind one button. */
.more {
display: none;
}
.rest {
display: contents;
}
@media (max-width: 860px) {
.more {
display: inline-block;
}
.rest {
display: none;
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.more[aria-expanded='true'] + .rest {
display: flex;
}
} }
.quiet.room b { .quiet.room b {
@@ -379,6 +403,16 @@
@media (max-width: 860px) { @media (max-width: 860px) {
.masthead { .masthead {
flex-direction: column; flex-direction: column;
gap: 0.6rem;
padding: 0.6rem var(--gutter) 0.4rem;
}
.masthead > div {
flex: 0 0 auto;
}
.standfirst {
margin-top: 0.15rem;
} }
} }
</style> </style>
+41 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
// Learn the pieces: one scene a piece. The boards the scene needs, what to // Learn the pieces: one scene a piece. The boards the scene needs, what to
// look at, the move made by the reader, and what came of it. // look at, the move made by the reader, and what came of it.
import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import LessonBoard from '$lib/components/LessonBoard.svelte'; import LessonBoard from '$lib/components/LessonBoard.svelte';
import PiecePanel from '$lib/components/PiecePanel.svelte'; import PiecePanel from '$lib/components/PiecePanel.svelte';
@@ -12,6 +13,27 @@
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path); const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
let n = $state(0); let n = $state(0);
/** The scenes this browser has already played right, so a reader can come back to the next one. */
const DONE = 'wyrm:learn:done';
let done = $state<string[]>([]);
onMount(() => {
try {
done = JSON.parse(localStorage.getItem(DONE) ?? '[]');
} catch {
done = [];
}
const next = SCENES.findIndex((s) => !done.includes(s.id));
if (next > 0) setScene(next);
});
function finished(id: string) {
if (done.includes(id)) return;
done = [...done, id];
try {
localStorage.setItem(DONE, JSON.stringify(done));
} catch {
// Remembered for this visit only.
}
}
const scene = $derived(SCENES[n]); const scene = $derived(SCENES[n]);
/** The scene's position, moved on by the reader or set again. */ /** The scene's position, moved on by the reader or set again. */
let cells = $state<Cell[]>(sceneCells(SCENES[0])); let cells = $state<Cell[]>(sceneCells(SCENES[0]));
@@ -36,6 +58,7 @@
if (mated) told += ' Checkmate.'; if (mated) told += ' Checkmate.';
outcome = { told, text: right ? scene.after : (scene.aside ?? scene.after), right }; outcome = { told, text: right ? scene.after : (scene.aside ?? scene.after), right };
cells = played.cells; cells = played.cells;
if (right) finished(scene.id);
} }
/** The board and the panel read a game; this one is the scene's, with the reader on the scene's side. */ /** The board and the panel read a game; this one is the scene's, with the reader on the scene's side. */
@@ -60,7 +83,7 @@
<nav class="scenes" aria-label="Scenes"> <nav class="scenes" aria-label="Scenes">
{#each SCENES as s, i (s.id)} {#each SCENES as s, i (s.id)}
<button type="button" class="scene-tab" class:current={i === n} onclick={() => setScene(i)}>{s.title}</button> <button type="button" class="scene-tab" class:current={i === n} class:done={done.includes(s.id)} aria-current={i === n ? 'true' : undefined} onclick={() => setScene(i)}>{#if done.includes(s.id)}<span class="tick" aria-label="done">✓</span>{/if}{s.title}</button>
{/each} {/each}
</nav> </nav>
@@ -73,8 +96,10 @@
<h2>{scene.title}</h2> <h2>{scene.title}</h2>
<p>{scene.before}</p> <p>{scene.before}</p>
{#if outcome} {#if outcome}
<p class="told" class:right={outcome.right}>{outcome.told}</p> <div aria-live="polite">
<p>{outcome.text}</p> <p class="told" class:right={outcome.right}>{outcome.told}</p>
<p>{outcome.text}</p>
</div>
<div class="ways"> <div class="ways">
<button type="button" class="quiet" onclick={() => setScene(n)}>Set the scene again</button> <button type="button" class="quiet" onclick={() => setScene(n)}>Set the scene again</button>
{#if n < SCENES.length - 1}<button type="button" class="commit small" onclick={() => setScene(n + 1)}>Next: {SCENES[n + 1].title}</button>{:else}<a class="commit small" href="/learn/game">Next: a game told</a>{/if} {#if n < SCENES.length - 1}<button type="button" class="commit small" onclick={() => setScene(n + 1)}>Next: {SCENES[n + 1].title}</button>{:else}<a class="commit small" href="/learn/game">Next: a game told</a>{/if}
@@ -117,6 +142,19 @@
color: var(--ember); color: var(--ember);
} }
.scene-tab.done {
color: var(--bone);
}
.scene-tab.done.current {
color: var(--ember);
}
.tick {
color: var(--frost);
margin-right: 0.3em;
}
.stage { .stage {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
+74 -5
View File
@@ -5,9 +5,25 @@
import PiecePanel from '$lib/components/PiecePanel.svelte'; import PiecePanel from '$lib/components/PiecePanel.svelte';
import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told'; import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told';
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, levelOf } from '$lib/game';
import { prefs, reducedMotion } from '$lib/prefs.svelte'; import { prefs, reducedMotion } from '$lib/prefs.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);
let fetching = $state(false);
async function seeGlass(open: boolean) {
if (open && !Glass) {
fetching = true;
try {
Glass = (await import('$lib/components/Glass.svelte')).default;
} finally {
fetching = false;
}
}
onGlass = open;
}
const states = toldStates(); const states = toldStates();
const last = states[states.length - 1]; const last = states[states.length - 1];
const tellings = narrate(last); const tellings = narrate(last);
@@ -22,6 +38,8 @@
const calm = $derived(reducedMotion()); const calm = $derived(reducedMotion());
const showCoords = $derived(prefs.coordinates !== false); const showCoords = $derived(prefs.coordinates !== false);
const marks = Object.keys(CALLOUTS).map(Number).filter((n) => n > 0); const marks = Object.keys(CALLOUTS).map(Number).filter((n) => n > 0);
/** The height of the Back and Next bar, which on a phone is fixed at the foot of the screen and must leave room beneath the boards. */
let barHeight = $state(0);
/** A piece tapped to read about it and to see where it could go from here, before the game shows what it did. */ /** A piece tapped to read about it and to see where it could go from here, before the game shows what it did. */
let inspected = $state<number | null>(null); let inspected = $state<number | null>(null);
@@ -33,11 +51,22 @@
function go(n: number) { function go(n: number) {
ply = Math.max(0, Math.min(TOLD_MOVES.length, n)); ply = Math.max(0, Math.min(TOLD_MOVES.length, n));
inspected = null; inspected = null;
showMove();
} }
function tap(i: number) { function tap(i: number) {
inspected = g.cells[i] !== '' && inspected !== i ? i : null; inspected = g.cells[i] !== '' && inspected !== i ? i : null;
} }
/** Left and right step the game, unless the keyboard is on a square, where the arrows walk the board. */ /** Left and right step the game, unless the keyboard is on a square, where the arrows walk the board. */
/** On a phone the boards stack below the words, so the board the move was played on is brought into view. */
function showMove() {
const m = ply > 0 ? last.moves[ply - 1] : null;
if (!m || typeof matchMedia === 'undefined' || !matchMedia('(max-width: 900px)').matches) return;
requestAnimationFrame(() => {
const target = onGlass ? document.querySelector('.boards canvas') : document.querySelector(`.boards g.world[data-level="${levelOf(m.to)}"]`);
target?.scrollIntoView({ block: 'center', behavior: calm ? 'auto' : 'smooth' });
});
}
function key(e: KeyboardEvent) { function key(e: KeyboardEvent) {
if ((e.target as HTMLElement | null)?.tagName === 'rect') return; if ((e.target as HTMLElement | null)?.tagName === 'rect') return;
if (e.key === 'ArrowRight') go(ply + 1); if (e.key === 'ArrowRight') go(ply + 1);
@@ -53,7 +82,7 @@
<svelte:window onkeydown={key} /> <svelte:window onkeydown={key} />
<main class="reading wide"> <main class="reading wide" style:--bar="{barHeight}px">
<header> <header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/learn">Learn the pieces</a> · <a class="back" href="/guide">How to play</a> · <a class="back" href="/rules">The rules</a> <a class="back" href="/">Back to the hall</a> · <a class="back" href="/learn">Learn the pieces</a> · <a class="back" href="/guide">How to play</a> · <a class="back" href="/rules">The rules</a>
<h1>A game told</h1> <h1>A game told</h1>
@@ -62,7 +91,12 @@
<section class="stage"> <section class="stage">
<div class="boards"> <div class="boards">
<BoardArtwork {g} {showCoords} {shownSquares} {shownCaptured} {checkSquare} selected={inspected} {reach} {calm} {tap} escape={() => (inspected = null)} /> <p class="muted small"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button></p>
{#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)} />
{/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}&nbsp;· {notation(move)}{/if}</p> <p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</p>
@@ -72,10 +106,11 @@
</ul> </ul>
{/if} {/if}
{#if callout}<p class="callout">{callout}</p>{/if} {#if callout}<p class="callout">{callout}</p>{/if}
<div class="ways"> <div class="ways" bind:clientHeight={barHeight}>
<button type="button" class="quiet" disabled={ply === 0} onclick={() => go(ply - 1)}>Back</button> <button type="button" class="quiet" disabled={ply === 0} onclick={() => go(ply - 1)}>Back</button>
<button type="button" class="commit small" disabled={ply === TOLD_MOVES.length} onclick={() => go(ply + 1)}>Next move</button> <button type="button" class="commit small" disabled={ply === TOLD_MOVES.length} onclick={() => go(ply + 1)}>Next move</button>
{#if ply === TOLD_MOVES.length}<a class="quiet" href="/">Play a drake</a>{/if} {#if ply === TOLD_MOVES.length}<a class="quiet" href="/">Play a drake</a>{/if}
<span class="where">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</span>
</div> </div>
<p class="muted small marks"> <p class="muted small marks">
Jump to a callout: Jump to a callout:
@@ -102,13 +137,47 @@
align-items: start; align-items: start;
} }
.where {
display: none;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.stage { .stage {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.words { /* The words come first and scroll with the page; the stickiness that keeps them beside the boards on a wide screen would pin them over the boards here. */
.stage .words {
order: -1; order: -1;
position: static;
max-height: none;
overflow: visible;
}
/* Back and Next stay at the foot of the screen with the move's name, so the boards can be looked at without losing the thread. */
.ways {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
margin: 0;
padding: 0.55rem var(--gutter) calc(0.55rem + env(safe-area-inset-bottom, 0px));
background: var(--slate-deep);
border-top: 1px solid var(--rule);
}
.where {
display: block;
flex-basis: 100%;
order: -1;
font-size: 0.85rem;
color: var(--bone-dim);
font-variant-numeric: tabular-nums;
}
main {
padding-bottom: calc(var(--bar, 6rem) + 1.5rem);
} }
} }
+1 -1
View File
@@ -110,7 +110,7 @@
<section id="between"> <section id="between">
<h2>Between the boards</h2> <h2>Between the boards</h2>
<p> <p>
Five kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself. Nine kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself.
</p> </p>
</section> </section>
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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 359 KiB