Compare commits
17
Commits
0be89a1dfe
..
main
No files matched your search
@@ -29,6 +29,14 @@ tables, the Peanut Gallery, the ledgers and the ops tools are the kit's;
|
||||
- The drake's search is bounded by a count of nodes, not a clock, so the
|
||||
same position always gives the same move and a ledger replays without
|
||||
asking it again.
|
||||
- The glass board, `src/lib/components/Glass.svelte`, draws a game as three
|
||||
sheets of tinted glass with figures standing on them, in three.js, and
|
||||
takes the same taps as the flat boards. The figures in `static/figures/`
|
||||
are glTF, one per kind, with a `livery` material the view recolours per
|
||||
side; `docs/figures-brief.md` is what they were made from. three.js and
|
||||
the figures load only when the view is opened. 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
|
||||
running site at 960 pixels wide.
|
||||
|
||||
|
||||
@@ -36,12 +36,15 @@ __HOST__ {
|
||||
# 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
|
||||
# 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
|
||||
# up on the next load.
|
||||
# cached for a year, and the glass board's figures, which weigh a few
|
||||
# megabytes and seldom change, for a week; every other response is
|
||||
# revalidated so a deploy shows up on the next load.
|
||||
handle {
|
||||
@immutable path /_app/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"
|
||||
try_files {path} {path}.html /index.html
|
||||
file_server
|
||||
|
||||
@@ -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?
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
+10
-8
@@ -1,21 +1,23 @@
|
||||
<!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>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { margin: 0; width: 1200px; height: 630px; background: #121a20; 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); }
|
||||
.boards img { width: 480px; display: block; }
|
||||
.words { position: absolute; left: 600px; top: 120px; width: 540px; }
|
||||
body { margin: 0; width: 1200px; height: 630px; background: #1c2830; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; }
|
||||
.board { position: absolute; left: 0; top: 0; width: 650px; height: 630px; display: block; }
|
||||
.words { position: absolute; left: 672px; top: 112px; width: 490px; }
|
||||
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; }
|
||||
.tag { font-size: 23px; color: #98a4ab; margin: 26px 0 0; line-height: 1.4; }
|
||||
.pitch { font-size: 29px; font-style: italic; margin: 22px 0 0; line-height: 1.3; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Generated
+65
@@ -8,6 +8,8 @@
|
||||
"name": "wyrm",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@types/three": "^0.180.0",
|
||||
"three": "^0.180.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,6 +26,12 @@
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -904,6 +912,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -946,6 +960,33 @@
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -1004,6 +1045,12 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
@@ -1232,6 +1279,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1545,6 +1598,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -1813,6 +1872,12 @@
|
||||
"typescript": "^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/three": "^0.180.0",
|
||||
"three": "^0.180.0",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
}
|
||||
+336
-51
@@ -9,14 +9,32 @@
|
||||
import { narrate } from '$lib/game/chronicle';
|
||||
import BoardArtwork, { type Token } from './BoardArtwork.svelte';
|
||||
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
|
||||
} 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';
|
||||
|
||||
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 mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
||||
@@ -26,29 +44,32 @@
|
||||
};
|
||||
const flipped = $derived(mySide === 'scarlet');
|
||||
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 ---
|
||||
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 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 showCoords = $derived(prefs.coordinates !== false);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
||||
|
||||
function resetTokens() {
|
||||
epoch += 1;
|
||||
let id = 0;
|
||||
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
||||
shown = g.moves.length;
|
||||
tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
||||
shown = v.moves.length;
|
||||
}
|
||||
|
||||
async function catchUp() {
|
||||
if (animating) return;
|
||||
animating = true;
|
||||
const mine = epoch;
|
||||
try {
|
||||
while (shown < g.moves.length) {
|
||||
const m = g.moves[shown];
|
||||
while (shown < v.moves.length) {
|
||||
const m = v.moves[shown];
|
||||
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
||||
if (!mover) {
|
||||
resetTokens();
|
||||
@@ -62,12 +83,15 @@
|
||||
if (victim) victim.dying = true;
|
||||
mover.at = m.to;
|
||||
await wait(380);
|
||||
if (mine !== epoch) return;
|
||||
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);
|
||||
shown += 1;
|
||||
announce(storyOf(shown));
|
||||
if (shown < g.moves.length) await wait(260);
|
||||
if (reviewing === null) announce(storyOf(shown));
|
||||
if (shown < v.moves.length) await wait(260);
|
||||
if (mine !== epoch) return;
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
@@ -75,30 +99,145 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const total = g.moves.length;
|
||||
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
|
||||
const total = v.moves.length;
|
||||
if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
|
||||
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);
|
||||
$effect(() => {
|
||||
void g.moves.length;
|
||||
reviewing = 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 shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
|
||||
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, 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. */
|
||||
const tellings = $derived(narrate(g));
|
||||
function storyOf(ply: number): string {
|
||||
const t = tellings[ply - 1];
|
||||
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 ---
|
||||
let selected = $state<number | null>(null);
|
||||
@@ -117,12 +256,38 @@
|
||||
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 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) {
|
||||
const asked = pending !== null;
|
||||
pending = null;
|
||||
selected = null;
|
||||
if (asked) void answered();
|
||||
await room.submit({ from, to });
|
||||
}
|
||||
|
||||
@@ -130,7 +295,11 @@
|
||||
|
||||
async function tap(i: number) {
|
||||
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) {
|
||||
// Not your move, or no seat: a tap on a piece still opens its panel.
|
||||
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
|
||||
@@ -140,7 +309,7 @@
|
||||
if (selected !== null && targets.includes(i)) {
|
||||
if (prefs.confirmMoves === true) {
|
||||
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;
|
||||
}
|
||||
await send(selected, i);
|
||||
@@ -173,44 +342,104 @@
|
||||
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>
|
||||
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<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} · <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>
|
||||
{:else if room.spectating}
|
||||
<strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow} · <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
|
||||
{:else if yourMove}
|
||||
<strong>Your move</strong> · {SIDE_NAMES[mySide!]}{#if inCheckNow} · <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} · <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}
|
||||
<strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span> <button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>
|
||||
{:else}
|
||||
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/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>
|
||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
|
||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null} <button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</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 shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</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}
|
||||
<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>
|
||||
{: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} · <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>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -225,16 +454,16 @@
|
||||
<h2>At the table</h2>
|
||||
<ul>
|
||||
{#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>{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>
|
||||
</li>
|
||||
{/each}
|
||||
</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>
|
||||
<Chronicle {tellings} {mySide} {reviewing} onReview={(ply) => (reviewing = ply)} />
|
||||
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
|
||||
<TableTalk {room} />
|
||||
</aside>
|
||||
</main>
|
||||
@@ -281,10 +510,53 @@
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.goal {
|
||||
.last {
|
||||
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 {
|
||||
color: var(--ember);
|
||||
font-weight: 600;
|
||||
@@ -306,20 +578,23 @@
|
||||
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 {
|
||||
max-width: 640px;
|
||||
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 {
|
||||
max-width: 640px;
|
||||
margin: 0.5rem auto 0;
|
||||
@@ -327,7 +602,17 @@
|
||||
|
||||
.verdict {
|
||||
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 {
|
||||
@@ -338,7 +623,7 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -346,7 +631,7 @@
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
margin: 0;
|
||||
max-height: 48vh;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--gutter) 0.5rem;
|
||||
background: var(--slate-deep);
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
// underworld, each twelve by eight in the colours Gygax suggested, with
|
||||
// the pieces as discs bearing his letters. Shared by the table, the
|
||||
// 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';
|
||||
let {
|
||||
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;
|
||||
tokens?: Token[];
|
||||
@@ -40,9 +41,14 @@
|
||||
tap?: (i: number) => void | Promise<void>;
|
||||
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
|
||||
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();
|
||||
|
||||
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. */
|
||||
function key(e: KeyboardEvent, i: number) {
|
||||
const step: Record<string, [number, number, number]> = {
|
||||
@@ -55,7 +61,13 @@
|
||||
const f = fileOf(i) + df;
|
||||
const r = rankOf(i) + dr;
|
||||
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 === ' ') {
|
||||
e.preventDefault();
|
||||
void tap(i);
|
||||
@@ -86,6 +98,14 @@
|
||||
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 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 rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
||||
const describe = (i: number) => {
|
||||
@@ -135,7 +155,8 @@
|
||||
role={decorative ? undefined : 'gridcell'}
|
||||
aria-label={decorative ? undefined : describe(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)}
|
||||
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
// 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
|
||||
// shows that move on the boards.
|
||||
// puts the boards back as they stood after it.
|
||||
import { tick } from 'svelte';
|
||||
import type { Telling } from '$lib/game/chronicle';
|
||||
import { notation, type Side } from '$lib/game';
|
||||
@@ -25,7 +25,7 @@
|
||||
{#each tellings as t (t.ply)}
|
||||
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
||||
<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>
|
||||
</button>
|
||||
</h3>
|
||||
@@ -52,8 +52,8 @@
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-dim);
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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]))
|
||||
);
|
||||
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. */
|
||||
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
||||
let busy = $state(false);
|
||||
@@ -216,8 +218,11 @@
|
||||
<span>Your name</span>
|
||||
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
||||
</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}
|
||||
<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)}
|
||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||
<div>
|
||||
@@ -659,6 +664,14 @@
|
||||
margin: 0.2rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.options[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.setup {
|
||||
margin: 0.2rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
const checkSquare = $derived(inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : -1);
|
||||
const calm = $derived(reducedMotion());
|
||||
const showCoords = $derived(prefs.coordinates !== false);
|
||||
let liveText = $state('');
|
||||
$effect(() => {
|
||||
// A move or a new scene leaves nothing selected, and nothing to say about one.
|
||||
void g.cells;
|
||||
selected = null;
|
||||
inspected = null;
|
||||
liveText = '';
|
||||
});
|
||||
let liveText = $state('');
|
||||
|
||||
function tap(i: number) {
|
||||
if (selected !== null && targets.includes(i)) {
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
return lines;
|
||||
});
|
||||
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>
|
||||
|
||||
<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>
|
||||
</header>
|
||||
<p class="now">{situation.join(' ')}</p>
|
||||
<p>{lore.is}</p>
|
||||
<h3>How it moves</h3>
|
||||
<p>{lore.moves}</p>
|
||||
<h3>Why it is here</h3>
|
||||
<p>{lore.why}</p>
|
||||
<h3>How to play it</h3>
|
||||
<p>{lore.play}</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>
|
||||
<div class="rest" class:open={more}>
|
||||
<p>{lore.is}</p>
|
||||
<h3>How it moves</h3>
|
||||
<p>{lore.moves}</p>
|
||||
<h3>Why it is here</h3>
|
||||
<p>{lore.why}</p>
|
||||
<h3>How to play it</h3>
|
||||
<p>{lore.play}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
@@ -107,6 +117,38 @@
|
||||
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 {
|
||||
color: var(--bone);
|
||||
border-left: 2px solid var(--ember);
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Telling {
|
||||
ply: number;
|
||||
move: Move;
|
||||
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' };
|
||||
@@ -127,7 +129,7 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
|
||||
state.moves.forEach((move, n) => {
|
||||
const { lines, after } = tellOne(cells, move, owner);
|
||||
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 });
|
||||
return out;
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
let reporting = $state(false);
|
||||
let tableOpen = $state(false);
|
||||
let prefsOpen = $state(false);
|
||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
let rematching = $state(false);
|
||||
let seatsOpen = $state(false);
|
||||
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
||||
let seatLost = $state('');
|
||||
@@ -30,6 +30,16 @@
|
||||
|
||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||
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:
|
||||
// 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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wyrm: a game</title>
|
||||
<title>Wyrm: {title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="masthead">
|
||||
@@ -180,6 +178,10 @@
|
||||
{#if room.view.audience > 0}
|
||||
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<button type="button" class="quiet more" aria-expanded={tableOpen} onclick={() => (tableOpen = !tableOpen)}>Table {tableOpen ? '▴' : '▾'}</button>
|
||||
<div class="rest">
|
||||
{#if room}
|
||||
{#if !watching}
|
||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||
{/if}
|
||||
@@ -194,6 +196,7 @@
|
||||
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
|
||||
{/if}
|
||||
<a class="quiet" href="/">The hall</a>
|
||||
</div>
|
||||
</nav>
|
||||
</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>
|
||||
{/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}
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
@@ -294,11 +285,44 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
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 {
|
||||
@@ -379,6 +403,16 @@
|
||||
@media (max-width: 860px) {
|
||||
.masthead {
|
||||
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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
// 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.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import LessonBoard from '$lib/components/LessonBoard.svelte';
|
||||
import PiecePanel from '$lib/components/PiecePanel.svelte';
|
||||
@@ -12,6 +13,27 @@
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
|
||||
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]);
|
||||
/** The scene's position, moved on by the reader or set again. */
|
||||
let cells = $state<Cell[]>(sceneCells(SCENES[0]));
|
||||
@@ -36,6 +58,7 @@
|
||||
if (mated) told += ' Checkmate.';
|
||||
outcome = { told, text: right ? scene.after : (scene.aside ?? scene.after), right };
|
||||
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. */
|
||||
@@ -60,7 +83,7 @@
|
||||
|
||||
<nav class="scenes" aria-label="Scenes">
|
||||
{#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}
|
||||
</nav>
|
||||
|
||||
@@ -73,8 +96,10 @@
|
||||
<h2>{scene.title}</h2>
|
||||
<p>{scene.before}</p>
|
||||
{#if outcome}
|
||||
<p class="told" class:right={outcome.right}>{outcome.told}</p>
|
||||
<p>{outcome.text}</p>
|
||||
<div aria-live="polite">
|
||||
<p class="told" class:right={outcome.right}>{outcome.told}</p>
|
||||
<p>{outcome.text}</p>
|
||||
</div>
|
||||
<div class="ways">
|
||||
<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}
|
||||
@@ -117,6 +142,19 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||
|
||||
@@ -5,9 +5,25 @@
|
||||
import PiecePanel from '$lib/components/PiecePanel.svelte';
|
||||
import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told';
|
||||
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';
|
||||
|
||||
/** 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 last = states[states.length - 1];
|
||||
const tellings = narrate(last);
|
||||
@@ -22,6 +38,8 @@
|
||||
const calm = $derived(reducedMotion());
|
||||
const showCoords = $derived(prefs.coordinates !== false);
|
||||
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. */
|
||||
let inspected = $state<number | null>(null);
|
||||
@@ -33,11 +51,22 @@
|
||||
function go(n: number) {
|
||||
ply = Math.max(0, Math.min(TOLD_MOVES.length, n));
|
||||
inspected = null;
|
||||
showMove();
|
||||
}
|
||||
function tap(i: number) {
|
||||
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. */
|
||||
/** 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) {
|
||||
if ((e.target as HTMLElement | null)?.tagName === 'rect') return;
|
||||
if (e.key === 'ArrowRight') go(ply + 1);
|
||||
@@ -53,7 +82,7 @@
|
||||
|
||||
<svelte:window onkeydown={key} />
|
||||
|
||||
<main class="reading wide">
|
||||
<main class="reading wide" style:--bar="{barHeight}px">
|
||||
<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>
|
||||
<h1>A game told</h1>
|
||||
@@ -62,7 +91,12 @@
|
||||
|
||||
<section class="stage">
|
||||
<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 class="words">
|
||||
<p class="eyebrow">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}</p>
|
||||
@@ -72,10 +106,11 @@
|
||||
</ul>
|
||||
{/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="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}
|
||||
<span class="where">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move} · {notation(move)}{/if}</span>
|
||||
</div>
|
||||
<p class="muted small marks">
|
||||
Jump to a callout:
|
||||
@@ -102,13 +137,47 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.where {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stage {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<section id="between">
|
||||
<h2>Between the boards</h2>
|
||||
<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>
|
||||
</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.
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 359 KiB |
Reference in new issue
Block a user