Compare commits
27
Commits
808c27e5d3
..
main
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: wyrm-pulse
|
name: wyrm-pulse
|
||||||
description: The weekly Wyrm operations pulse — game server health, errors, the last week of traffic and rooms from the rollup, backup status, and the box's vitals. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse".
|
description: The weekly Wyrm operations pulse: game server health, errors, the last week of traffic and rooms from the rollup, backup status, and the box's vitals. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse".
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wyrm pulse
|
# Wyrm pulse
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: wyrm-reports
|
name: wyrm-reports
|
||||||
description: Work the Wyrm reports desk — fetch players' bug reports, show the last week's reports and replies, and process the unanswered ones end to end (replay the ledger to the pinned round, check the rules text, fix, reply). Use when Eric asks about bug reports, player feedback, or says "work the reports desk".
|
description: Work the Wyrm reports desk: fetch players' bug reports, show the last week's reports and replies, and process the unanswered ones end to end (replay the ledger to the pinned round, check the rules text, fix, reply). Use when Eric asks about bug reports, player feedback, or says "work the reports desk".
|
||||||
---
|
---
|
||||||
|
|
||||||
# The Wyrm reports desk
|
# The Wyrm reports desk
|
||||||
@@ -54,9 +54,10 @@ to hear his players' voices; never compress their words into a table.
|
|||||||
`game.resolve(state, line.inputs)` per turn line, printing the state
|
`game.resolve(state, line.inputs)` per turn line, printing the state
|
||||||
around the pinned round. The `chat` lines show what the players said
|
around the pinned round. The `chat` lines show what the players said
|
||||||
to each other at the time.
|
to each other at the time.
|
||||||
2. **Check the rules before the code.** The rules are data in
|
2. **Check the rules before the code.** The rules are the article in
|
||||||
`src/lib/game/rules.ts` and prose at /rules, which names the texts it
|
`docs/source.md`, with the engine's readings of its open points under
|
||||||
follows; read them before deciding the engine is wrong. Many reports
|
Decisions; the moves are in `src/lib/game/board.ts` and the ends of a
|
||||||
|
game in `index.ts`. Read them before deciding the engine is wrong. Many reports
|
||||||
are the rules working as written.
|
are the rules working as written.
|
||||||
3. **Verdict.** `by-design` (the engine matches the text; no change),
|
3. **Verdict.** `by-design` (the engine matches the text; no change),
|
||||||
`resolved` (a defect, fixed before replying), or `open` (needs Eric's
|
`resolved` (a defect, fixed before replying), or `open` (needs Eric's
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: wyrm-visitors
|
name: wyrm-visitors
|
||||||
description: The Wyrm visitors report — who is at the table right now, who came today and how far each game got, and today's traffic. Use when Eric asks who has been playing, about new players or visitors, "anyone playing?", or wants a traffic report.
|
description: The Wyrm visitors report: who is at the table right now, who came today and how far each game got, and today's traffic. Use when Eric asks who has been playing, about new players or visitors, "anyone playing?", or wants a traffic report.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wyrm visitors
|
# Wyrm visitors
|
||||||
@@ -23,7 +23,7 @@ spills into the next UTC day. Run yesterday too when the hour is early.
|
|||||||
- **players today / NEW today**: names seated in rooms created today;
|
- **players today / NEW today**: names seated in rooms created today;
|
||||||
NEW means the name's first room ever is today.
|
NEW means the name's first room ever is today.
|
||||||
- **rooms today**: one line each: time, code, humans vs bots, empty
|
- **rooms today**: one line each: time, code, humans vs bots, empty
|
||||||
seats, state (lobby only / started, no turns / in play), turn count,
|
seats, state (lobby only / started, no turns / in play / abandoned after twelve idle hours / who won, or a draw), turn count,
|
||||||
span, the last turn's time, and how many lines of table talk were said.
|
span, the last turn's time, and how many lines of table talk were said.
|
||||||
Talk is a sign people came together; a bot game has none.
|
Talk is a sign people came together; a bot game has none.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ tables, the Peanut Gallery, the ledgers and the ops tools are the kit's;
|
|||||||
- The drake's search is bounded by a count of nodes, not a clock, so the
|
- The drake's search is bounded by a count of nodes, not a clock, so the
|
||||||
same position always gives the same move and a ledger replays without
|
same position always gives the same move and a ledger replays without
|
||||||
asking it again.
|
asking it again.
|
||||||
|
- The glass board, `src/lib/components/Glass.svelte`, draws a game as three
|
||||||
|
sheets of tinted glass with figures standing on them, in three.js, and
|
||||||
|
takes the same taps as the flat boards. The figures in `static/figures/`
|
||||||
|
are glTF, one per kind, with a `livery` material the view recolours per
|
||||||
|
side; `docs/figures-brief.md` is what they were made from. three.js and
|
||||||
|
the figures load only when the view is opened. The flat boards are the
|
||||||
|
default; the glass board is offered under them at the table and on the
|
||||||
|
told game.
|
||||||
- The guide's figures are screenshots in `static/guide/`, taken from the
|
- The guide's figures are screenshots in `static/guide/`, taken from the
|
||||||
running site at 960 pixels wide.
|
running site at 960 pixels wide.
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ __HOST__ {
|
|||||||
# Everything else is the static site. SvelteKit writes each route as
|
# Everything else is the static site. SvelteKit writes each route as
|
||||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||||
# cached for a year; every other response is revalidated so a deploy shows
|
# cached for a year, and the glass board's figures, which weigh a few
|
||||||
# up on the next load.
|
# megabytes and seldom change, for a week; every other response is
|
||||||
|
# revalidated so a deploy shows up on the next load.
|
||||||
handle {
|
handle {
|
||||||
@immutable path /_app/immutable/*
|
@immutable path /_app/immutable/*
|
||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
@mutable not path /_app/immutable/*
|
@figures path /figures/*
|
||||||
|
header @figures Cache-Control "public, max-age=604800"
|
||||||
|
@mutable not path /_app/immutable/* /figures/*
|
||||||
header @mutable Cache-Control "no-cache"
|
header @mutable Cache-Control "no-cache"
|
||||||
try_files {path} {path}.html /index.html
|
try_files {path} {path}.html /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
+4
-3
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
|
|||||||
small Node process capped at 300 MB by its unit file, and a room is a few
|
small Node process capped at 300 MB by its unit file, and a room is a few
|
||||||
kilobytes of ledger, so the host carries several. Each game needs a port
|
kilobytes of ledger, so the host carries several. Each game needs a port
|
||||||
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
||||||
dip 8790.
|
dip 8790, wyrm 8791.
|
||||||
|
|
||||||
## Current production
|
## Current production
|
||||||
|
|
||||||
@@ -65,8 +65,9 @@ runs every game's rollup and backup from /etc/cron.d/gamehall.
|
|||||||
- `wyrm-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/wyrm
|
- `wyrm-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/wyrm
|
||||||
to the `kestrel-wizwar-backups` Space under `wyrm/` (a current copy
|
to the `kestrel-wizwar-backups` Space under `wyrm/` (a current copy
|
||||||
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
|
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
|
||||||
credentials in /root/.config/rclone/rclone.conf, copied by hand from the
|
credentials in /root/.config/rclone/rclone.conf, which the shared host has;
|
||||||
wizwar droplet; until then it logs "skipped".
|
a game on a box of its own copies them over first, and until then the
|
||||||
|
script logs "skipped".
|
||||||
|
|
||||||
Errors from the game server go to Sentry, project `wyrm` in the
|
Errors from the game server go to Sentry, project `wyrm` in the
|
||||||
locallygrownnet organisation; the DSN is in the unit file. On the shared host
|
locallygrownnet organisation; the DSN is in the unit file. On the shared host
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
|||||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
|
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
|
||||||
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||||
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
||||||
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
|
PROJECT_ID="${SENTRY_PROJECT_ID:-4512325594578944}"
|
||||||
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
|
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
|
||||||
|
|
||||||
DETECTOR_ID=$(curl -s "$API/detectors/?project=$PROJECT_ID" -H "$AUTH" | python3 -c '
|
DETECTOR_ID=$(curl -s "$API/detectors/?project=$PROJECT_ID" -H "$AUTH" | python3 -c '
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ Environment=KEEPER_TZ=America/New_York
|
|||||||
Environment=HOST=127.0.0.1
|
Environment=HOST=127.0.0.1
|
||||||
Environment=DATA_DIR=/var/lib/wyrm/rooms
|
Environment=DATA_DIR=/var/lib/wyrm/rooms
|
||||||
# Empty means no error reporting.
|
# Empty means no error reporting.
|
||||||
Environment=SENTRY_DSN=
|
Environment=SENTRY_DSN=https://a5e245adb21413c0b22dfa6b7a10f08a@o4509525984149504.ingest.us.sentry.io/4512325594578944
|
||||||
ExecStart=/opt/wyrm/app/server/node_modules/.bin/tsx src/index.ts
|
ExecStart=/opt/wyrm/app/server/node_modules/.bin/tsx src/index.ts
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Composes the told game (src/lib/game/told.ts): its moves are played at
|
||||||
|
// their plies and the drake plays any ply beyond them, so a longer game can
|
||||||
|
// be sketched and steered. Prints the chronicle, and with a ply and a
|
||||||
|
// square, the legal moves of the piece there at that ply.
|
||||||
|
// npx tsx docs/compose-game.ts [plies] [ply square]
|
||||||
|
import { createGame, applyMove, validateMove } from '../src/lib/game/index.ts';
|
||||||
|
import { chooseMove } from '../src/lib/game/bot.ts';
|
||||||
|
import { narrate } from '../src/lib/game/chronicle.ts';
|
||||||
|
import { square, squareName, legalMovesFrom } from '../src/lib/game/board.ts';
|
||||||
|
import { TOLD_MOVES } from '../src/lib/game/told.ts';
|
||||||
|
const FORCED: Record<number, [string, string]> = Object.fromEntries(TOLD_MOVES.map((m, n) => [n + 1, m]));
|
||||||
|
|
||||||
|
const plies = Number(process.argv[2] ?? 40);
|
||||||
|
const askPly = process.argv[3] ? Number(process.argv[3]) : 0;
|
||||||
|
const askSquare = process.argv[4];
|
||||||
|
let s = createGame({ A: 'Gold', B: 'Scarlet' });
|
||||||
|
for (let ply = 1; ply <= plies && !s.over; ply++) {
|
||||||
|
const seat = s.toMove === 'gold' ? 'A' : 'B';
|
||||||
|
if (ply === askPly && askSquare) {
|
||||||
|
console.log(`ply ${ply}, ${s.toMove} to move, ${askSquare} may go to: ${legalMovesFrom(s.cells, square(askSquare), s.setup).map(squareName).join(' ')}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const forced = FORCED[ply];
|
||||||
|
let move: { from: number; to: number };
|
||||||
|
if (forced) {
|
||||||
|
move = { from: square(forced[0]), to: square(forced[1]) };
|
||||||
|
const why = validateMove(s, seat, move);
|
||||||
|
if (why) {
|
||||||
|
console.log(`ply ${ply}: forced ${forced[0]}-${forced[1]} refused: ${why}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else move = chooseMove(s.cells, s.toMove, s.setup, 40000)!;
|
||||||
|
s = applyMove(s, move.from, move.to);
|
||||||
|
}
|
||||||
|
for (const t of narrate(s)) console.log(`${t.ply}${FORCED[t.ply] ? '*' : ' '} ${t.lines.map((l) => l.text).join(' ')}`);
|
||||||
|
console.log(s.over ? `over: ${s.over.reason}` : `${s.toMove} to move`);
|
||||||
@@ -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>
|
<!doctype html>
|
||||||
<!-- The share card, 1200 by 630. Open it with the site running at localhost:5173 and screenshot the body: docs/artwork.md. -->
|
<!-- The share card, 1200 by 630. og-board.png beside it is the glass board at
|
||||||
|
the opening, shot from the corner on the told game with the canvas
|
||||||
|
widened to 2400 pixels and the camera one wheel step back, then cropped
|
||||||
|
to 650 by 630. Open this file and screenshot the body into static/og.png. -->
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; width: 1200px; height: 630px; background: #121a20; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; }
|
body { margin: 0; width: 1200px; height: 630px; background: #1c2830; color: #e9e2d2; font-family: 'Iowan Old Style', Georgia, serif; overflow: hidden; position: relative; }
|
||||||
.boards { position: absolute; left: 56px; top: 44px; width: 480px; height: 542px; overflow: hidden; border-radius: 8px; border: 1px solid rgba(233, 226, 210, 0.2); }
|
.board { position: absolute; left: 0; top: 0; width: 650px; height: 630px; display: block; }
|
||||||
.boards img { width: 480px; display: block; }
|
.words { position: absolute; left: 672px; top: 112px; width: 490px; }
|
||||||
.words { position: absolute; left: 600px; top: 120px; width: 540px; }
|
|
||||||
h1 { font-size: 110px; font-weight: 300; margin: 0; line-height: 1; letter-spacing: -0.01em; }
|
h1 { font-size: 110px; font-weight: 300; margin: 0; line-height: 1; letter-spacing: -0.01em; }
|
||||||
.pitch { font-size: 30px; font-style: italic; margin: 22px 0 0; line-height: 1.3; }
|
.pitch { font-size: 29px; font-style: italic; margin: 22px 0 0; line-height: 1.3; }
|
||||||
.tag { font-size: 23px; color: #98a4ab; margin: 26px 0 0; line-height: 1.4; }
|
.tag { font-size: 22px; color: #a3aeb4; margin: 26px 0 0; line-height: 1.4; }
|
||||||
.site { font-size: 22px; color: #f0a53a; margin: 34px 0 0; letter-spacing: 0.04em; }
|
.site { font-size: 22px; color: #f0a53a; margin: 34px 0 0; letter-spacing: 0.04em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="boards"><img src="http://localhost:5173/guide/boards.png" alt="" /></div>
|
<img class="board" src="og-board.png" alt="" />
|
||||||
<div class="words">
|
<div class="words">
|
||||||
<h1>Wyrm</h1>
|
<h1>Wyrm</h1>
|
||||||
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||||
|
|||||||
+8
-8
@@ -635,9 +635,9 @@ Good luck, and may all your moves be ones that Rexfelis would envy. . . .
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
Where the text contradicts itself, leaves a case open, or needs a rule
|
Where the text contradicts itself, leaves a case open, or needs a rule
|
||||||
chess players take for granted, the engine does the following. Each is a
|
chess players take for granted, the engine does the following. Each reads the
|
||||||
reading of the text above, not a change to it, except the two marked as
|
text above as it stands, except the two marked as options the host may
|
||||||
options the host may choose.
|
choose.
|
||||||
|
|
||||||
1. **Board numbers.** The notation paragraph makes the upper board 1 and
|
1. **Board numbers.** The notation paragraph makes the upper board 1 and
|
||||||
the lower 3, but every piece description, every example and the
|
the lower 3, but every piece description, every example and the
|
||||||
@@ -652,8 +652,8 @@ options the host may choose.
|
|||||||
are in.
|
are in.
|
||||||
3. **Light and dark.** With a light square in Gold's near right corner,
|
3. **Light and dark.** With a light square in Gold's near right corner,
|
||||||
square l1 is light and a1 dark. Gold's Sylphs therefore start on a2,
|
square l1 is light and a1 dark. Gold's Sylphs therefore start on a2,
|
||||||
c2, e2, g2, i2 and k2 and Scarlet's on a7, c7, e7, g7, i7 and k7 (the
|
c2, e2, g2, i2 and k2 and Scarlet's on a7, c7, e7, g7, i7 and k7, the
|
||||||
same files, as the diagram shows); the Dwarves start on b, d, f, h, j
|
same files, as the diagram shows. The Dwarves start on b, d, f, h, j
|
||||||
and l of their second rank.
|
and l of their second rank.
|
||||||
4. **Captures on board-changing moves.** The text says "move" for the
|
4. **Captures on board-changing moves.** The text says "move" for the
|
||||||
Mage's step on the upper or lower board, for the Paladin's knight-like
|
Mage's step on the upper or lower board, for the Paladin's knight-like
|
||||||
@@ -668,7 +668,7 @@ options the host may choose.
|
|||||||
the square on the middle board vacant. The Elemental's two-square
|
the square on the middle board vacant. The Elemental's two-square
|
||||||
orthogonal step needs the square between vacant, as the general rule
|
orthogonal step needs the square between vacant, as the general rule
|
||||||
says. The Elemental's move up needs its first, sideways square vacant
|
says. The Elemental's move up needs its first, sideways square vacant
|
||||||
and the square above that to hold an enemy; its move down needs the
|
and the square above that to hold an enemy. Its move down needs the
|
||||||
square beneath vacant and lands by a further orthogonal step, with or
|
square beneath vacant and lands by a further orthogonal step, with or
|
||||||
without a capture.
|
without a capture.
|
||||||
6. **The Dragon's capture from afar** is a whole move. The Dragon stays
|
6. **The Dragon's capture from afar** is a whole move. The Dragon stays
|
||||||
@@ -679,12 +679,12 @@ options the host may choose.
|
|||||||
except that it still occupies its square. A frozen King attacked by
|
except that it still occupies its square. A frozen King attacked by
|
||||||
another piece is in check and may be rescued only by capturing the
|
another piece is in check and may be rescued only by capturing the
|
||||||
attacker or interposing.
|
attacker or interposing.
|
||||||
8. **Check, checkmate, stalemate, repetition.** As in chess: a move may
|
8. **Check, checkmate, stalemate, repetition.** As in chess. A move may
|
||||||
not leave one's own King attacked, a King attacked with no escape is
|
not leave one's own King attacked, a King attacked with no escape is
|
||||||
mated and the game is lost, a player with no legal move and not in
|
mated and the game is lost, a player with no legal move and not in
|
||||||
check is stalemated and the game is drawn, and a position seen three
|
check is stalemated and the game is drawn, and a position seen three
|
||||||
times with the same side to move is a draw. The text names only
|
times with the same side to move is a draw. The text names only
|
||||||
checkmate; the other endings follow "as in standard chess".
|
checkmate, and the other endings follow "as in standard chess".
|
||||||
9. **Promotion** happens the moment a Warrior arrives on the far rank, to
|
9. **Promotion** happens the moment a Warrior arrives on the far rank, to
|
||||||
a Hero, with no choice. A Sylph on the far rank keeps its downward
|
a Hero, with no choice. A Sylph on the far rank keeps its downward
|
||||||
capture and comes back to life by moving up into a vacant home square.
|
capture and comes back to life by moving up into a vacant home square.
|
||||||
|
|||||||
Generated
+65
@@ -8,6 +8,8 @@
|
|||||||
"name": "wyrm",
|
"name": "wyrm",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
|
"three": "^0.180.0",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,6 +26,12 @@
|
|||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
@@ -904,6 +912,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -946,6 +960,33 @@
|
|||||||
"undici-types": "~8.9.0"
|
"undici-types": "~8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.180.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||||
|
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": "*",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~0.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -1004,6 +1045,12 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||||
|
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
@@ -1232,6 +1279,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1545,6 +1598,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -1813,6 +1872,12 @@
|
|||||||
"typescript": "^5.0.0 || ^6.0.0"
|
"typescript": "^5.0.0 || ^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.180.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
|
"three": "^0.180.0",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -29,7 +29,7 @@
|
|||||||
//
|
//
|
||||||
// Caddy serves the static site and proxies /api and /ws here. The keeper's
|
// Caddy serves the static site and proxies /api and /ws here. The keeper's
|
||||||
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||||
// (deploy/wyrm-reseat.sh sends it):
|
// (the deploy/wyrm-*.sh scripts send it):
|
||||||
//
|
//
|
||||||
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
||||||
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||||
@@ -389,7 +389,7 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
/**
|
/**
|
||||||
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
|
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
|
||||||
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
|
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
|
||||||
* answers by itself, finds it within two rounds.
|
* answers by itself, finds it: one silent through a whole round is closed.
|
||||||
*/
|
*/
|
||||||
const answered = new WeakMap<WebSocket, boolean>();
|
const answered = new WeakMap<WebSocket, boolean>();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const GOLD_OPENS: Input = { from: square('2c2'), to: square('2c3') };
|
|||||||
const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') };
|
const SCARLET_ANSWERS: Input = { from: square('2c7'), to: square('2c6') };
|
||||||
|
|
||||||
/** The bots move once the request that made their move due has been answered: after this tick. */
|
/** The bots move once the request that made their move due has been answered: after this tick. */
|
||||||
const settled = () => new Promise<void>((r) => setImmediate(r));
|
const answered = () => new Promise<void>((r) => setImmediate(r));
|
||||||
|
|
||||||
/** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */
|
/** A table of two: Ann, who opened it and plays Gold, and Bo, the game begun. */
|
||||||
function table(spec: GameSpec<State, Input> = game) {
|
function table(spec: GameSpec<State, Input> = game) {
|
||||||
@@ -138,7 +138,7 @@ describe('rooms', () => {
|
|||||||
expect(host.room.state!.setup).toBe('shifted');
|
expect(host.room.state!.setup).toBe('shifted');
|
||||||
// Gold moves first, and Gold is the drake: it moves once the host's request has been answered, not inside it.
|
// Gold moves first, and Gold is the drake: it moves once the host's request has been answered, not inside it.
|
||||||
expect(host.room.state!.moves).toHaveLength(0);
|
expect(host.room.state!.moves).toHaveLength(0);
|
||||||
await settled();
|
await answered();
|
||||||
expect(host.room.state!.moves).toHaveLength(1);
|
expect(host.room.state!.moves).toHaveLength(1);
|
||||||
expect(rooms.waitingOn(host.room)).toEqual(['A']);
|
expect(rooms.waitingOn(host.room)).toEqual(['A']);
|
||||||
});
|
});
|
||||||
@@ -205,13 +205,13 @@ describe('the host and the other seats', () => {
|
|||||||
rooms.submit(room, ann, GOLD_OPENS, 0);
|
rooms.submit(room, ann, GOLD_OPENS, 0);
|
||||||
rooms.standIn(room, ann, 'B');
|
rooms.standIn(room, ann, 'B');
|
||||||
// The move was Bo's; the drake makes it as soon as the host has been answered.
|
// The move was Bo's; the drake makes it as soon as the host has been answered.
|
||||||
await settled();
|
await answered();
|
||||||
expect(room.resolved).toBe(2);
|
expect(room.resolved).toBe(2);
|
||||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||||
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a drake/);
|
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a drake/);
|
||||||
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||||
rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2);
|
rooms.submit(room, ann, { from: square('2d2'), to: square('2d3') }, 2);
|
||||||
await settled();
|
await answered();
|
||||||
expect(room.resolved).toBe(4);
|
expect(room.resolved).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -3,6 +3,12 @@
|
|||||||
// same place. Inputs wait in memory until every seat that must move has moved;
|
// same place. Inputs wait in memory until every seat that must move has moved;
|
||||||
// then the round resolves at once, bots included. Nothing here knows the
|
// then the round resolves at once, bots included. Nothing here knows the
|
||||||
// game beyond the GameSpec it is given.
|
// game beyond the GameSpec it is given.
|
||||||
|
//
|
||||||
|
// This is the kit's room server, kept whole so the kit's fixes land by copy.
|
||||||
|
// Wyrm seats two and moves one side at a time, so only one seat ever owes a
|
||||||
|
// move and the paced play of bots left alone at a table never arises here.
|
||||||
|
// The strings a player reads say drake, the game's word for its house
|
||||||
|
// player; the code and its comments keep the kit's word, bot.
|
||||||
|
|
||||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||||
@@ -361,10 +367,14 @@ export class Rooms<State, Input> {
|
|||||||
* to hang and the bot's to come at once. The table is told as each move
|
* to hang and the bot's to come at once. The table is told as each move
|
||||||
* lands, the same as any other.
|
* lands, the same as any other.
|
||||||
*/
|
*/
|
||||||
|
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
|
||||||
|
private isTable(room: Room<State>): boolean {
|
||||||
|
return this.rooms.get(room.id) === room;
|
||||||
|
}
|
||||||
|
|
||||||
private botsAfterAnswering(room: Room<State>): void {
|
private botsAfterAnswering(room: Room<State>): void {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
if (!this.isTable(room)) return;
|
||||||
if (this.rooms.get(room.id) !== room) return;
|
|
||||||
try {
|
try {
|
||||||
this.driveBots(room);
|
this.driveBots(room);
|
||||||
this.botsSpeak(room, 'phase');
|
this.botsSpeak(room, 'phase');
|
||||||
@@ -403,8 +413,7 @@ export class Rooms<State, Input> {
|
|||||||
const hurried = this.hurried.has(room.id);
|
const hurried = this.hurried.has(room.id);
|
||||||
const next = setTimeout(() => {
|
const next = setTimeout(() => {
|
||||||
this.pacing.delete(room.id);
|
this.pacing.delete(room.id);
|
||||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
if (!this.isTable(room)) return;
|
||||||
if (this.rooms.get(room.id) !== room) return;
|
|
||||||
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
||||||
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
||||||
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
||||||
|
|||||||
+25
@@ -196,6 +196,31 @@ button:disabled {
|
|||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A lesson: the boards beside the words. */
|
||||||
|
.reading.wide {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chronicle's lines, coloured by what they tell, at the table and in a lesson. */
|
||||||
|
.telling li.capture {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telling li.check,
|
||||||
|
.telling li.freeze,
|
||||||
|
.telling li.promotion {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telling li.note {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telling li.end {
|
||||||
|
color: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
.reading section {
|
.reading section {
|
||||||
padding: 1.25rem 0 0.5rem;
|
padding: 1.25rem 0 0.5rem;
|
||||||
border-top: 1px solid var(--rule);
|
border-top: 1px solid var(--rule);
|
||||||
|
|||||||
+3
-3
@@ -7,21 +7,21 @@
|
|||||||
<meta name="theme-color" content="#121a20" />
|
<meta name="theme-color" content="#121a20" />
|
||||||
<title>Wyrm</title>
|
<title>Wyrm</title>
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<meta name="description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
<meta name="description" content="Gary Gygax's Dragonchess, free in the browser. Chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||||
<link rel="canonical" href="https://wyrm.kestrelsnest.social/" />
|
<link rel="canonical" href="https://wyrm.kestrelsnest.social/" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="Wyrm" />
|
<meta property="og:site_name" content="Wyrm" />
|
||||||
<meta property="og:title" content="Wyrm" />
|
<meta property="og:title" content="Wyrm" />
|
||||||
<meta property="og:description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
<meta property="og:description" content="Gary Gygax's Dragonchess, free in the browser. Chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||||
<meta property="og:url" content="https://wyrm.kestrelsnest.social/" />
|
<meta property="og:url" content="https://wyrm.kestrelsnest.social/" />
|
||||||
<meta property="og:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
<meta property="og:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="Wyrm" />
|
<meta name="twitter:title" content="Wyrm" />
|
||||||
<meta name="twitter:description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
<meta name="twitter:description" content="Gary Gygax's Dragonchess, free in the browser. Chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||||
<meta name="twitter:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
<meta name="twitter:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+351
-94
@@ -9,14 +9,32 @@
|
|||||||
import { narrate } from '$lib/game/chronicle';
|
import { narrate } from '$lib/game/chronicle';
|
||||||
import BoardArtwork, { type Token } from './BoardArtwork.svelte';
|
import BoardArtwork, { type Token } from './BoardArtwork.svelte';
|
||||||
import {
|
import {
|
||||||
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, fileOf, rankOf, idx, frozen, NAMES, FILES, RANKS, LEVELS,
|
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES, PER_LEVEL,
|
||||||
type Move, type Side
|
type Move, type Side
|
||||||
} from '$lib/game';
|
} from '$lib/game';
|
||||||
import type { Room } from '$lib/net/room.svelte';
|
import { Room } from '$lib/net/room.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||||
|
|
||||||
let { room }: { room: Room } = $props();
|
let { room }: { room: Room } = $props();
|
||||||
|
|
||||||
|
/** The glass board, loaded the first time it is asked for, so three.js stays out of every other visit. */
|
||||||
|
let Glass = $state<typeof import('./Glass.svelte').default | null>(null);
|
||||||
|
let onGlass = $state(false);
|
||||||
|
let fetching = $state(false);
|
||||||
|
async function seeGlass(open: boolean) {
|
||||||
|
if (open && !Glass) {
|
||||||
|
fetching = true;
|
||||||
|
try {
|
||||||
|
Glass = (await import('./Glass.svelte')).default;
|
||||||
|
} finally {
|
||||||
|
fetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onGlass = open;
|
||||||
|
}
|
||||||
|
|
||||||
const g = $derived(room.state!);
|
const g = $derived(room.state!);
|
||||||
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
||||||
@@ -24,33 +42,34 @@
|
|||||||
const seat = g.seats.find((id) => g.players[id].side === side);
|
const seat = g.seats.find((id) => g.players[id].side === side);
|
||||||
return seat ? g.players[seat].name : side;
|
return seat ? g.players[seat].name : side;
|
||||||
};
|
};
|
||||||
const sideName = (side: Side) => (side === 'gold' ? 'Gold' : 'Scarlet');
|
|
||||||
const flipped = $derived(mySide === 'scarlet');
|
const flipped = $derived(mySide === 'scarlet');
|
||||||
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
|
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
|
||||||
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
|
|
||||||
const label = squareName;
|
|
||||||
|
|
||||||
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
|
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
|
||||||
let tokens = $state<Token[]>([]);
|
let tokens = $state<Token[]>([]);
|
||||||
/** How many of the game's moves the tokens have played through. */
|
/** How many of the moves the tokens have played through. */
|
||||||
let shown = $state(0);
|
let shown = $state(0);
|
||||||
let animating = $state(false);
|
let animating = $state(false);
|
||||||
|
/** Bumped when the tokens are set out afresh, so a catch-up in flight lets go of them. */
|
||||||
|
let epoch = 0;
|
||||||
const calm = $derived(reducedMotion());
|
const calm = $derived(reducedMotion());
|
||||||
const showCoords = $derived(prefs.coordinates !== false);
|
const showCoords = $derived(prefs.coordinates !== false);
|
||||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
||||||
|
|
||||||
function resetTokens() {
|
function resetTokens() {
|
||||||
|
epoch += 1;
|
||||||
let id = 0;
|
let id = 0;
|
||||||
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
||||||
shown = g.moves.length;
|
shown = v.moves.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function catchUp() {
|
async function catchUp() {
|
||||||
if (animating) return;
|
if (animating) return;
|
||||||
animating = true;
|
animating = true;
|
||||||
|
const mine = epoch;
|
||||||
try {
|
try {
|
||||||
while (shown < g.moves.length) {
|
while (shown < v.moves.length) {
|
||||||
const m = g.moves[shown];
|
const m = v.moves[shown];
|
||||||
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
||||||
if (!mover) {
|
if (!mover) {
|
||||||
resetTokens();
|
resetTokens();
|
||||||
@@ -64,12 +83,15 @@
|
|||||||
if (victim) victim.dying = true;
|
if (victim) victim.dying = true;
|
||||||
mover.at = m.to;
|
mover.at = m.to;
|
||||||
await wait(380);
|
await wait(380);
|
||||||
|
if (mine !== epoch) return;
|
||||||
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
|
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
|
||||||
}
|
}
|
||||||
|
if (mine !== epoch) return;
|
||||||
if (victim) tokens = tokens.filter((t) => !t.dying);
|
if (victim) tokens = tokens.filter((t) => !t.dying);
|
||||||
shown += 1;
|
shown += 1;
|
||||||
announce(storyOf(shown));
|
if (reviewing === null) announce(storyOf(shown));
|
||||||
if (shown < g.moves.length) await wait(260);
|
if (shown < v.moves.length) await wait(260);
|
||||||
|
if (mine !== epoch) return;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
animating = false;
|
animating = false;
|
||||||
@@ -77,32 +99,147 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const total = g.moves.length;
|
const total = v.moves.length;
|
||||||
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
|
if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
|
||||||
else if (total > shown) void catchUp();
|
else if (total > shown) void catchUp();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- What the boards show: the last move played, or a move picked from the record ---
|
// --- What the boards show: the game as it stands, or as it stood after a move picked from the record ---
|
||||||
let reviewing = $state<number | null>(null);
|
let reviewing = $state<number | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void g.moves.length;
|
void g.moves.length;
|
||||||
reviewing = null;
|
reviewing = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
});
|
});
|
||||||
|
/** The state the boards draw: the game's own, or the position after the move under review, with that many moves played. */
|
||||||
|
const v = $derived.by((): typeof g => {
|
||||||
|
if (reviewing === null) return g;
|
||||||
|
const t = tellings[reviewing - 1];
|
||||||
|
return { ...g, cells: t.after, moves: g.moves.slice(0, reviewing), toMove: t.move.by === 'gold' ? 'scarlet' : 'gold' };
|
||||||
|
});
|
||||||
|
/** The King in check on the boards as drawn: the game's, or the position under review. */
|
||||||
|
const checkSquare = $derived(reviewing !== null ? (inCheck(v.cells, v.toMove) ? kingSquare(v.cells, v.toMove) : -1) : inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
|
||||||
const shownPly = $derived(reviewing ?? shown);
|
const shownPly = $derived(reviewing ?? shown);
|
||||||
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
|
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
|
||||||
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
|
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
|
||||||
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
|
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
|
||||||
|
|
||||||
|
/** A move picked from the record puts the boards back as they stood after it, brings its board into view, and says so. */
|
||||||
|
function review(ply: number | null) {
|
||||||
|
if (ply !== null && (ply < 1 || ply > g.moves.length)) return;
|
||||||
|
const step = (ply ?? g.moves.length) - shown;
|
||||||
|
const had = document.activeElement;
|
||||||
|
reviewing = ply;
|
||||||
|
selected = null;
|
||||||
|
inspected = null;
|
||||||
|
// One move on plays itself; any other leap sets the pieces out where they stood.
|
||||||
|
if (step !== 1) resetTokens();
|
||||||
|
announce(ply === null ? `Back to the game as it stands, after move ${g.moves.length}. ${liveLine}` : `The boards after move ${ply} of ${g.moves.length}. ${storyOf(ply)}`);
|
||||||
|
const m = ply === null ? null : g.moves[ply - 1];
|
||||||
|
if (m && oneBoard && !onGlass) level = levelOf(m.to);
|
||||||
|
void tick().then(() => {
|
||||||
|
// A button that went with the mode, or went grey under the keyboard, leaves it nowhere: it goes back to the boards, or stays with the review.
|
||||||
|
const lost = document.activeElement === document.body || (document.activeElement === had && had instanceof HTMLButtonElement && had.disabled);
|
||||||
|
if (lost) {
|
||||||
|
if (ply === null) focusBoards();
|
||||||
|
else backToNow?.focus();
|
||||||
|
}
|
||||||
|
if (!m) return;
|
||||||
|
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
if (phone) scrollUnderStrip(target);
|
||||||
|
else target.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backToNow = $state<HTMLButtonElement | null>(null);
|
||||||
|
let artwork = $state<ReturnType<typeof BoardArtwork>>();
|
||||||
|
let glassWay = $state<HTMLButtonElement | null>(null);
|
||||||
|
/** The keyboard goes to the boards: the grid's own square, or, on the glass, the way back to the flat boards. */
|
||||||
|
function focusBoards() {
|
||||||
|
if (artwork) artwork.focusGrid();
|
||||||
|
else glassWay?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time ---
|
||||||
|
let phone = $state(false);
|
||||||
|
let oneBoard = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const narrow = matchMedia('(max-width: 860px)');
|
||||||
|
const short = matchMedia('(max-width: 1000px) and (max-height: 540px)');
|
||||||
|
const read = () => {
|
||||||
|
phone = narrow.matches;
|
||||||
|
oneBoard = short.matches;
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
narrow.addEventListener('change', read);
|
||||||
|
short.addEventListener('change', read);
|
||||||
|
return () => {
|
||||||
|
narrow.removeEventListener('change', read);
|
||||||
|
short.removeEventListener('change', read);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
/** The board shown when there is room for one at a time; a move made on another brings that one forward. */
|
||||||
|
let level = $state(2);
|
||||||
|
$effect(() => {
|
||||||
|
const m = g.moves.at(-1);
|
||||||
|
if (m) level = levelOf(m.to);
|
||||||
|
});
|
||||||
|
function goLevel(lv: number) {
|
||||||
|
if (oneBoard) {
|
||||||
|
level = lv;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const world = document.querySelector(`.play g.world[data-level="${lv}"]`);
|
||||||
|
if (world) scrollUnderStrip(world);
|
||||||
|
}
|
||||||
|
/** Scrolls a board to sit just under the status strip, which stays in view above it. */
|
||||||
|
function scrollUnderStrip(el: Element) {
|
||||||
|
const strip = document.querySelector('.play .status');
|
||||||
|
const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
|
||||||
|
scrollTo({ top, behavior: calm ? 'auto' : 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
/** The game told move by move, replayed from the ledger's moves. */
|
/** The game told move by move, replayed from the ledger's moves. */
|
||||||
const tellings = $derived(narrate(g));
|
const tellings = $derived(narrate(g));
|
||||||
function storyOf(ply: number): string {
|
function storyOf(ply: number): string {
|
||||||
const t = tellings[ply - 1];
|
const t = tellings[ply - 1];
|
||||||
return t ? t.lines.map((l) => l.text).join(' ') : '';
|
return t ? t.lines.map((l) => l.text).join(' ') : '';
|
||||||
}
|
}
|
||||||
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : '');
|
const shownStory = $derived.by(() => {
|
||||||
|
const t = tellings[shownPly - 1];
|
||||||
|
if (!t) return '';
|
||||||
|
// Once the game is over the status says how it ended; the story keeps to the move.
|
||||||
|
const lines = g.over && reviewing === null ? t.lines.filter((l) => l.kind !== 'end') : t.lines;
|
||||||
|
return lines.map((l) => l.text).join(' ');
|
||||||
|
});
|
||||||
|
const shownFreeze = $derived(tellings[shownPly - 1]?.lines.some((l) => l.kind === 'freeze') ?? false);
|
||||||
|
|
||||||
// --- Choosing and moving ---------------------------------------------------
|
/** 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);
|
let selected = $state<number | null>(null);
|
||||||
const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup));
|
const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup));
|
||||||
/** Any piece, tapped to read about it; the panel beside the boards tells what it is and what it can do from there. */
|
/** Any piece, tapped to read about it; the panel beside the boards tells what it is and what it can do from there. */
|
||||||
@@ -119,30 +256,60 @@
|
|||||||
liveText = text;
|
liveText = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A move awaiting the player's word, when they asked to confirm. */
|
/** A move awaiting the player's word, when they asked to confirm. It is put in the status strip, which stays in view, and takes the keyboard's focus. */
|
||||||
let pending = $state<{ from: number; to: number } | null>(null);
|
let pending = $state<{ from: number; to: number } | null>(null);
|
||||||
|
let confirmButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
/** The square that had the focus before the question was asked, to give it back afterwards. */
|
||||||
|
let askedFrom: Element | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if (pending && confirmButton) {
|
||||||
|
askedFrom = document.activeElement;
|
||||||
|
confirmButton.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function answered() {
|
||||||
|
await tick();
|
||||||
|
if (askedFrom instanceof HTMLElement || askedFrom instanceof SVGElement) askedFrom.focus();
|
||||||
|
askedFrom = null;
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
if (!pending) return;
|
||||||
|
const { from } = pending;
|
||||||
|
pending = null;
|
||||||
|
announce(`Move cancelled. ${squareName(from)} is still selected.`);
|
||||||
|
void answered();
|
||||||
|
}
|
||||||
|
function cancelOnEscape(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') cancel();
|
||||||
|
}
|
||||||
|
|
||||||
async function send(from: number, to: number) {
|
async function send(from: number, to: number) {
|
||||||
|
const asked = pending !== null;
|
||||||
pending = null;
|
pending = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
|
if (asked) void answered();
|
||||||
await room.submit({ from, to });
|
await room.submit({ from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordFor = (i: number) => `${sideOf(g.cells[i]) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(g.cells[i])]}`;
|
const wordFor = (i: number) => `${SIDE_NAMES[sideOf(g.cells[i])!]} ${NAMES[kindOf(g.cells[i])]}`;
|
||||||
|
|
||||||
async function tap(i: number) {
|
async function tap(i: number) {
|
||||||
if (room.sending || animating) return;
|
if (room.sending || animating) return;
|
||||||
reviewing = null;
|
if (reviewing !== null) {
|
||||||
|
// The boards are showing an earlier position: a tap brings them back to now, and nothing more.
|
||||||
|
review(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!yourMove) {
|
if (!yourMove) {
|
||||||
// Not your move, or no seat: a tap on a piece still opens its panel.
|
// Not your move, or no seat: a tap on a piece still opens its panel.
|
||||||
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
|
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
|
||||||
if (inspected !== null) announce(`${wordFor(i)} on ${label(i)}. About this piece is shown beside the boards.`);
|
if (inspected !== null) announce(`${wordFor(i)} on ${squareName(i)}. About this piece is shown beside the boards.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selected !== null && targets.includes(i)) {
|
if (selected !== null && targets.includes(i)) {
|
||||||
if (prefs.confirmMoves === true) {
|
if (prefs.confirmMoves === true) {
|
||||||
pending = { from: selected, to: i };
|
pending = { from: selected, to: i };
|
||||||
announce(`Move ${label(selected)} to ${label(i)}? Confirm or cancel below.`);
|
announce(`Move ${squareName(selected)} to ${squareName(i)}? Confirm or cancel.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await send(selected, i);
|
await send(selected, i);
|
||||||
@@ -156,43 +323,17 @@
|
|||||||
if (selected !== null) {
|
if (selected !== null) {
|
||||||
const to = legalMovesFrom(g.cells, i, g.setup);
|
const to = legalMovesFrom(g.cells, i, g.setup);
|
||||||
const why = frozen(g.cells, i) ? ' It is frozen by the Basilisk beneath it.' : '';
|
const why = frozen(g.cells, i) ? ' It is frozen by the Basilisk beneath it.' : '';
|
||||||
announce(`Selected ${wordFor(i)} on ${label(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(label).join(', ') : ''}.${why}`);
|
announce(`Selected ${wordFor(i)} on ${squareName(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(squareName).join(', ') : ''}.${why}`);
|
||||||
} else announce('Selection cleared.');
|
} else announce('Selection cleared.');
|
||||||
} else selected = null;
|
} else selected = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusSquare(i: number) {
|
function escape() {
|
||||||
document.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
|
selected = null;
|
||||||
}
|
inspected = null;
|
||||||
|
reviewing = null;
|
||||||
/** Arrows walk a board, Page Up and Page Down climb and descend, Enter picks up and sets down. */
|
pending = null;
|
||||||
function key(e: KeyboardEvent, i: number) {
|
announce('Selection cleared.');
|
||||||
const f = fileOf(i);
|
|
||||||
const r = rankOf(i);
|
|
||||||
const lv = levelOf(i);
|
|
||||||
const step: Record<string, [number, number, number]> = {
|
|
||||||
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
|
|
||||||
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
|
|
||||||
};
|
|
||||||
if (e.key in step) {
|
|
||||||
e.preventDefault();
|
|
||||||
const [df, dr, dl] = step[e.key];
|
|
||||||
const nf = f + df;
|
|
||||||
const nr = r + dr;
|
|
||||||
const nl = lv + dl;
|
|
||||||
if (nf >= 0 && nf < FILES && nr >= 0 && nr < RANKS && nl >= 1 && nl <= LEVELS) focusSquare(idx(nl, nf, nr));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
void tap(i);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
selected = null;
|
|
||||||
inspected = null;
|
|
||||||
reviewing = null;
|
|
||||||
pending = null;
|
|
||||||
announce('Selection cleared.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const verdict = $derived.by(() => {
|
const verdict = $derived.by(() => {
|
||||||
@@ -201,45 +342,105 @@
|
|||||||
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
|
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
|
/** A game against a drake can be started over at any time: a fresh table, the same side and setup. */
|
||||||
|
const drake = $derived(!room.spectating && room.view.seats.some((s) => s.bot && !s.standIn));
|
||||||
|
const foes = $derived(room.others.map((s) => s.name).join(' and '));
|
||||||
|
let starting = $state(false);
|
||||||
|
let fault = $state('');
|
||||||
|
async function again() {
|
||||||
|
starting = true;
|
||||||
|
fault = '';
|
||||||
|
try {
|
||||||
|
const next = await Room.createWithBot(room.nameOf(room.me!), room.view.options);
|
||||||
|
await goto(`/join/${next.roomId}`);
|
||||||
|
} catch (e) {
|
||||||
|
fault = e instanceof Error ? e.message : 'A new table could not be opened.';
|
||||||
|
} finally {
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** A rematch keeps this table's company: a new table, with a seat held for each of them. */
|
||||||
|
async function rematch() {
|
||||||
|
starting = true;
|
||||||
|
fault = '';
|
||||||
|
try {
|
||||||
|
await goto(`/join/${await room.rematch()}`);
|
||||||
|
} catch (e) {
|
||||||
|
fault = e instanceof Error ? e.message : 'The rematch could not be called.';
|
||||||
|
} finally {
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieceCount = (side: Side) => v.cells.filter((p) => p !== '' && sideOf(p) === side).length;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="board">
|
<main class="board">
|
||||||
<section class="play">
|
<section class="play">
|
||||||
<div class="status" class:yours={yourMove}>
|
<div class="status" class:yours={yourMove}>
|
||||||
{#if g.over}
|
{#if pending}
|
||||||
|
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
|
||||||
|
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
|
||||||
|
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
|
||||||
|
{:else if reviewing !== null}
|
||||||
|
<strong>After move {reviewing} of {g.moves.length}</strong> · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0} · <span class="check">in check</span>{/if} ·
|
||||||
|
<span class="marked"><button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
|
||||||
|
<button type="button" class="link" disabled={reviewing >= g.moves.length} onclick={() => review(reviewing! + 1)}>Later</button> ·
|
||||||
|
<button type="button" class="link" bind:this={backToNow} onclick={() => review(null)}>Back to now</button></span>
|
||||||
|
{:else if g.over}
|
||||||
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
|
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
|
||||||
{:else if room.spectating}
|
{:else if room.spectating}
|
||||||
<strong>{nameOfSide(g.toMove)} to move</strong> · {sideName(g.toMove)}{#if inCheckNow} · <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
|
<strong>{nameOfSide(g.toMove)} to move</strong> · {SIDE_NAMES[g.toMove]}{#if inCheckNow} · <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
|
||||||
{:else if yourMove}
|
{:else if yourMove}
|
||||||
<strong>Your move</strong> · {sideName(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}
|
{:else if room.view.playingOn}
|
||||||
<strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>{#if !room.spectating} <button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>{/if}
|
<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}
|
{:else}
|
||||||
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {sideName(mySide!)}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
|
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
||||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null} <button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p>
|
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}</p>
|
||||||
{/if}
|
|
||||||
{#if pending}
|
|
||||||
<p class="confirm">Move {label(pending.from)} to {label(pending.to)}?
|
|
||||||
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
|
|
||||||
<button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} yourMove={true} {calm} {tap} {key} />
|
|
||||||
</div>
|
|
||||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
|
||||||
|
|
||||||
{#if g.over}
|
{#if g.over}
|
||||||
<div class="verdict">
|
<div class="verdict">
|
||||||
<a class="commit" href="/">Back to the hall</a>
|
{#if room.spectating}
|
||||||
|
<a class="commit" href="/">Back to the hall</a>
|
||||||
|
{:else if room.view.rematch}
|
||||||
|
<p>{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}. <a href={`/join/${room.view.rematch.to}`}>Take your seat</a></p>
|
||||||
|
{:else if drake}
|
||||||
|
<button type="button" class="commit" disabled={starting} onclick={again}>Play a fresh drake</button>
|
||||||
|
<button type="button" class="quiet" disabled={starting} onclick={rematch}>Rematch {foes}</button>
|
||||||
|
<a class="quiet" href="/">Back to the hall</a>
|
||||||
|
<p class="muted small">A fresh drake is a new table at once, your side and setup kept. A rematch seats this table's company at a new one, and waits for everyone to begin.</p>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="commit" disabled={starting} onclick={rematch}>Rematch with this table</button>
|
||||||
|
<a class="quiet" href="/">Back to the hall</a>
|
||||||
|
<p class="muted small">The same company sits down at a new table, with a seat held for everyone here.</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if yourMove}
|
{/if}
|
||||||
<p class="muted small 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: arrows to move about a board, Page Up and Page Down to change boards, Enter to pick up and to set down, Escape to cancel.</p>
|
|
||||||
|
<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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -253,16 +454,16 @@
|
|||||||
<h2>At the table</h2>
|
<h2>At the table</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{#each g.seats as id (id)}
|
{#each g.seats as id (id)}
|
||||||
<li class:to-move={g.players[id].side === g.toMove && !g.over}>
|
<li class:to-move={g.players[id].side === v.toMove && !(g.over && reviewing === null)}>
|
||||||
<span class="dot {g.players[id].side}"></span>
|
<span class="dot {g.players[id].side}"></span>
|
||||||
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
||||||
<span class="muted">{sideName(g.players[id].side)}, {pieceCount(g.players[id].side)} pieces</span>
|
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="muted small">Move {g.moves.length + 1}.</p>
|
<p class="muted small">{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}</p>
|
||||||
</section>
|
</section>
|
||||||
<Chronicle {tellings} {mySide} {reviewing} onReview={(ply) => (reviewing = ply)} />
|
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
|
||||||
<TableTalk {room} />
|
<TableTalk {room} />
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
@@ -309,10 +510,53 @@
|
|||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal {
|
.last {
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status .commit,
|
||||||
|
.status .quiet {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marked {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The jumps: one a board, and a mark on any board the chosen piece can reach. */
|
||||||
|
.jump {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump .current {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump .lit::after {
|
||||||
|
content: ' ●';
|
||||||
|
font-size: 0.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.marked .link:disabled {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -334,20 +578,23 @@
|
|||||||
margin: 0 auto 0.5rem;
|
margin: 0 auto 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm {
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame {
|
.frame {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A phone on its side has no height for three boards: one shows at a time, as wide as the screen allows. */
|
||||||
|
@media (max-width: 1000px) and (max-height: 540px) {
|
||||||
|
.frame {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-way {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.how {
|
.how {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0.5rem auto 0;
|
margin: 0.5rem auto 0;
|
||||||
@@ -355,7 +602,17 @@
|
|||||||
|
|
||||||
.verdict {
|
.verdict {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0.8rem auto 0;
|
margin: 0 auto 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict .commit,
|
||||||
|
.verdict .quiet {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0.3rem 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict p {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -366,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
/* On a phone the boards run long: the panel rises from the bottom over them, and scrolls within itself. */
|
/* On a phone the panel rises from the bottom over the boards, short enough to leave them in view: the piece's name and its situation show, and the lore scrolls within the sheet. */
|
||||||
.panel {
|
.panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -374,7 +631,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
top: auto;
|
top: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 48vh;
|
max-height: 30vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 var(--gutter) 0.5rem;
|
padding: 0 var(--gutter) 0.5rem;
|
||||||
background: var(--slate-deep);
|
background: var(--slate-deep);
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The three boards, drawn one above the other: the air, the land and the
|
// The three boards, drawn one above the other: the air, the land and the
|
||||||
// underworld, each twelve by eight in the colours Gygax suggested, with
|
// underworld, each twelve by eight in the colours Gygax suggested, with
|
||||||
// the pieces as discs bearing his letters. Shared by the table, the hall's
|
// the pieces as discs bearing his letters. Shared by the table, the
|
||||||
// still of the opening and the share artwork.
|
// lessons and the hall's still of the opening.
|
||||||
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, type State } from '$lib/game';
|
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 {
|
let {
|
||||||
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
||||||
yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {}
|
calm = true, decorative = false, tap = () => {}, escape = () => {}, climb
|
||||||
}: {
|
}: {
|
||||||
g: State;
|
g: State;
|
||||||
tokens?: Token[];
|
tokens?: Token[];
|
||||||
@@ -34,13 +35,45 @@
|
|||||||
shownSquares?: number[];
|
shownSquares?: number[];
|
||||||
shownCaptured?: number[];
|
shownCaptured?: number[];
|
||||||
checkSquare?: number;
|
checkSquare?: number;
|
||||||
yourMove?: boolean;
|
|
||||||
calm?: boolean;
|
calm?: boolean;
|
||||||
|
/** An illustration: no roles, no tab stops, nothing to tap. */
|
||||||
decorative?: boolean;
|
decorative?: boolean;
|
||||||
tap?: (i: number) => void | Promise<void>;
|
tap?: (i: number) => void | Promise<void>;
|
||||||
key?: (e: KeyboardEvent, i: number) => 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();
|
} = $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]> = {
|
||||||
|
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
|
||||||
|
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
|
||||||
|
};
|
||||||
|
if (e.key in step) {
|
||||||
|
e.preventDefault();
|
||||||
|
const [df, dr, dl] = step[e.key];
|
||||||
|
const f = fileOf(i) + df;
|
||||||
|
const r = rankOf(i) + dr;
|
||||||
|
const lv = levelOf(i) + dl;
|
||||||
|
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);
|
||||||
|
} else if (e.key === 'Escape') escape();
|
||||||
|
}
|
||||||
|
|
||||||
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
|
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
|
||||||
const CELL = 40;
|
const CELL = 40;
|
||||||
const PAD_L = $derived(showCoords ? 18 : 6);
|
const PAD_L = $derived(showCoords ? 18 : 6);
|
||||||
@@ -65,19 +98,27 @@
|
|||||||
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
|
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
|
||||||
}
|
}
|
||||||
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
|
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
|
||||||
|
const entry = $derived.by(() => {
|
||||||
|
for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c;
|
||||||
|
return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0];
|
||||||
|
});
|
||||||
|
/** Puts the keyboard on the grid, at the square Tab would reach. */
|
||||||
|
export function focusGrid() {
|
||||||
|
focusSquare(entry);
|
||||||
|
}
|
||||||
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
||||||
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
||||||
const describe = (i: number) => {
|
const describe = (i: number) => {
|
||||||
const piece = g.cells[i];
|
const piece = g.cells[i];
|
||||||
let text = squareName(i);
|
let text = squareName(i);
|
||||||
if (piece !== '') text += `, ${sideOf(piece) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
|
if (piece !== '') text += `, ${SIDE_NAMES[sideOf(piece)!]} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
|
||||||
if (targets.includes(i)) text += ', a legal move';
|
if (targets.includes(i)) text += ', a legal move';
|
||||||
if (i === checkSquare) text += ', in check';
|
if (i === checkSquare) text += ', in check';
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}>
|
<svg xmlns="http://www.w3.org/2000/svg" bind:this={svg} class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}>
|
||||||
{#each levels as lv, slot (lv)}
|
{#each levels as lv, slot (lv)}
|
||||||
{@const top = slot * (boardHeight + GAP)}
|
{@const top = slot * (boardHeight + GAP)}
|
||||||
<g class="world" data-level={lv}>
|
<g class="world" data-level={lv}>
|
||||||
@@ -114,7 +155,8 @@
|
|||||||
role={decorative ? undefined : 'gridcell'}
|
role={decorative ? undefined : 'gridcell'}
|
||||||
aria-label={decorative ? undefined : describe(i)}
|
aria-label={decorative ? undefined : describe(i)}
|
||||||
aria-selected={decorative ? undefined : selected === i}
|
aria-selected={decorative ? undefined : selected === i}
|
||||||
tabindex={decorative ? undefined : yourMove ? 0 : -1}
|
tabindex={decorative ? undefined : entry === i ? 0 : -1}
|
||||||
|
onfocus={decorative ? undefined : () => (visited = i)}
|
||||||
onclick={decorative ? undefined : () => tap(i)}
|
onclick={decorative ? undefined : () => tap(i)}
|
||||||
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The game told top to bottom, oldest first, each move with what came of
|
// The game told top to bottom, oldest first, each move with what came of
|
||||||
// it, and the newest scrolled into view. A move's heading is a button that
|
// it, and the newest scrolled into view. A move's heading is a button that
|
||||||
// shows that move on the boards.
|
// puts the boards back as they stood after it.
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { Telling } from '$lib/game/chronicle';
|
import type { Telling } from '$lib/game/chronicle';
|
||||||
import { notation, type Side } from '$lib/game';
|
import { notation, type Side } from '$lib/game';
|
||||||
@@ -25,11 +25,11 @@
|
|||||||
{#each tellings as t (t.ply)}
|
{#each tellings as t (t.ply)}
|
||||||
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
||||||
<h3>
|
<h3>
|
||||||
<button type="button" class="link plain" title="Show this move on the boards" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
|
<button type="button" class="link plain" title="Show the boards as they stood after this move" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
|
||||||
Move {t.ply} <span class="notation">{notation(t.move)}</span>
|
Move {t.ply} <span class="notation">{notation(t.move)}</span>
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
<ul>
|
<ul class="telling">
|
||||||
{#each t.lines as l, i (i)}
|
{#each t.lines as l, i (i)}
|
||||||
<li class={l.kind}>{l.text}</li>
|
<li class={l.kind}>{l.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--bone-faint);
|
color: var(--bone-dim);
|
||||||
margin: 0.6rem 0 0.2rem;
|
margin: 0.6rem 0 0.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -97,22 +97,4 @@
|
|||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
li.capture {
|
|
||||||
color: var(--ember);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.check,
|
|
||||||
li.freeze,
|
|
||||||
li.promotion {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.note {
|
|
||||||
color: var(--bone-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.end {
|
|
||||||
color: var(--blood);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -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]))
|
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
|
||||||
);
|
);
|
||||||
let prefsOpen = $state(false);
|
let prefsOpen = $state(false);
|
||||||
|
/** The side and the Sylphs' setup wait behind one line until asked for. */
|
||||||
|
let setupOpen = $state(false);
|
||||||
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
||||||
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -48,14 +50,13 @@
|
|||||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||||
];
|
];
|
||||||
const onCards = new Set<string>([]);
|
|
||||||
const details = [
|
const details = [
|
||||||
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||||
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
|
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
|
||||||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
||||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||||
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||||||
];
|
];
|
||||||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
|
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
|
||||||
@@ -208,7 +209,7 @@
|
|||||||
<h1>Wyrm</h1>
|
<h1>Wyrm</h1>
|
||||||
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||||
<p class="tag">Gary Gygax's Dragonchess, from Dragon Magazine in 1985. Gold and Scarlet each lead 42 pieces across the air, the land and the underworld, and the game ends the way chess does, with a King that cannot escape. Play a drake here, free, or open a table and send a friend the link.</p>
|
<p class="tag">Gary Gygax's Dragonchess, from Dragon Magazine in 1985. Gold and Scarlet each lead 42 pieces across the air, the land and the underworld, and the game ends the way chess does, with a King that cannot escape. Play a drake here, free, or open a table and send a friend the link.</p>
|
||||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
<p class="links"><a href="/guide">how to play</a> · <a href="/learn">learn the pieces</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||||
<Preferences bind:open={prefsOpen} />
|
<Preferences bind:open={prefsOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,8 +218,11 @@
|
|||||||
<span>Your name</span>
|
<span>Your name</span>
|
||||||
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
||||||
</label>
|
</label>
|
||||||
|
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
|
||||||
{#if game.options?.length}
|
{#if game.options?.length}
|
||||||
<div class="options">
|
{@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)}
|
||||||
|
<p class="setup muted small">{chosenLabels.join(' ')} <button type="button" class="link plain" aria-expanded={setupOpen} onclick={() => (setupOpen = !setupOpen)}>{setupOpen ? 'Done' : 'Change'}</button></p>
|
||||||
|
<div class="options" hidden={!setupOpen}>
|
||||||
{#each game.options as o (o.key)}
|
{#each game.options as o (o.key)}
|
||||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||||
<div>
|
<div>
|
||||||
@@ -321,7 +325,7 @@
|
|||||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
||||||
{#snippet aboutGame()}
|
{#snippet aboutGame()}
|
||||||
<div class="about-body">
|
<div class="about-body">
|
||||||
<p>Gary Gygax, who made Dungeons & Dragons, published Dragonchess in the hundredth issue of Dragon Magazine, in August 1985, as seven pages of rules and no board. He played it against himself on three sheets of plexiglass with metal miniatures for pieces, and told readers that three rectangles laid in a row on a table would do. The keeper has that issue on the shelf and built the boards it never came with.</p>
|
<p>Gary Gygax, who made Dungeons & Dragons, published Dragonchess in the hundredth issue of Dragon Magazine, in August 1985, as seven pages of rules and no board. It was playtested on three sheets of plexiglass with metal miniatures for pieces, and he told readers that three rectangles laid in a row on a table would do. The keeper has that issue on the shelf and built the boards it never came with.</p>
|
||||||
<p>Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the <a href="/rules">rules page</a>, where the article is transcribed in full. The house player is a drake, a search that reads a few moves ahead and values the pieces roughly as Gygax did, so it is fair company for a first game and beatable by a careful one. The name is this site's own. Dragonchess was Gygax's and TSR's, and another company sells a game called Dragon Chess that has nothing to do with this one.</p>
|
<p>Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the <a href="/rules">rules page</a>, where the article is transcribed in full. The house player is a drake, a search that reads a few moves ahead and values the pieces roughly as Gygax did, so it is fair company for a first game and beatable by a careful one. The name is this site's own. Dragonchess was Gygax's and TSR's, and another company sells a game called Dragon Chess that has nothing to do with this one.</p>
|
||||||
<p>It is free and keeps no accounts. A game lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and nothing in it is hidden from either player or from the gallery.</p>
|
<p>It is free and keeps no accounts. A game lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and nothing in it is hidden from either player or from the gallery.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -660,6 +664,14 @@
|
|||||||
margin: 0.2rem 0 0.6rem;
|
margin: 0.2rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.options[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup {
|
||||||
|
margin: 0.2rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A board for a lesson: the scene's position, the reader's side to move,
|
||||||
|
// two taps or a keyboard to move, and the move handed back to the page.
|
||||||
|
// No room, no server; the engine judges the move here.
|
||||||
|
import BoardArtwork from './BoardArtwork.svelte';
|
||||||
|
import { legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, NAMES, SIDE_NAMES, type State } from '$lib/game';
|
||||||
|
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||||
|
|
||||||
|
let { g, levels, locked = false, onMove, inspected = $bindable(null) }: {
|
||||||
|
/** The scene as a game: its cells, and the reader's side to move. */
|
||||||
|
g: State;
|
||||||
|
levels: number[];
|
||||||
|
/** The move has been made and told: no more moving until the scene is set again. */
|
||||||
|
locked?: boolean;
|
||||||
|
onMove: (from: number, to: number) => void;
|
||||||
|
/** A piece tapped to read about, which the page shows in its panel. */
|
||||||
|
inspected?: number | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selected = $state<number | null>(null);
|
||||||
|
const targets = $derived(selected === null || locked ? [] : legalMovesFrom(g.cells, selected, 'printed'));
|
||||||
|
const reach = $derived(inspected !== null && inspected !== selected && g.cells[inspected] !== '' ? movesFrom(g.cells, inspected, 'printed') : []);
|
||||||
|
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 = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function tap(i: number) {
|
||||||
|
if (selected !== null && targets.includes(i)) {
|
||||||
|
const from = selected;
|
||||||
|
selected = null;
|
||||||
|
inspected = null;
|
||||||
|
onMove(from, i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inspected = g.cells[i] !== '' ? (inspected === i && selected === i ? null : i) : null;
|
||||||
|
if (!locked && sideOf(g.cells[i]) === g.toMove) {
|
||||||
|
selected = selected === i ? null : i;
|
||||||
|
if (selected === null) inspected = null;
|
||||||
|
if (selected !== null) {
|
||||||
|
const to = legalMovesFrom(g.cells, i, 'printed');
|
||||||
|
liveText = `Selected ${SIDE_NAMES[g.toMove]} ${NAMES[kindOf(g.cells[i])]} on ${squareName(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(squareName).join(', ') : ''}.`;
|
||||||
|
}
|
||||||
|
} else selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape() {
|
||||||
|
selected = null;
|
||||||
|
inspected = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="frame">
|
||||||
|
<BoardArtwork {g} {levels} {showCoords} {selected} {targets} {reach} {checkSquare} {calm} {tap} {escape} />
|
||||||
|
</div>
|
||||||
|
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frame {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
// how to play it. The lore is src/lib/game/lore.ts; the rest is read off
|
// how to play it. The lore is src/lib/game/lore.ts; the rest is read off
|
||||||
// the board.
|
// the board.
|
||||||
import { LORE } from '$lib/game/lore';
|
import { LORE } from '$lib/game/lore';
|
||||||
import { NAMES, LETTERS, kindOf, sideOf, levelOf, rankOf, frozen, movesFrom, legalMovesFrom, squareName, inCheck, type State, type Side } from '$lib/game';
|
import { NAMES, LETTERS, SIDE_NAMES, VALUES, PER_LEVEL, kindOf, sideOf, levelOf, rankOf, frozen, movesFrom, legalMovesFrom, squareName, inCheck, type State, type Side } from '$lib/game';
|
||||||
import { VALUES, PER_LEVEL } from '$lib/game/board';
|
|
||||||
|
|
||||||
let { g, at, mySide, close }: { g: State; at: number; mySide: Side | null; close: () => void } = $props();
|
let { g, at, mySide, close }: { g: State; at: number; mySide: Side | null; close: () => void } = $props();
|
||||||
|
|
||||||
@@ -13,15 +12,14 @@
|
|||||||
const kind = $derived(kindOf(cell));
|
const kind = $derived(kindOf(cell));
|
||||||
const side = $derived(sideOf(cell)!);
|
const side = $derived(sideOf(cell)!);
|
||||||
const lore = $derived(LORE[kind]);
|
const lore = $derived(LORE[kind]);
|
||||||
const sideWord = $derived(side === 'gold' ? 'Gold' : 'Scarlet');
|
const owner = $derived(side === mySide ? 'Your' : `${SIDE_NAMES[side]}’s`);
|
||||||
const owner = $derived(mySide === null ? `${sideWord}’s` : side === mySide ? 'Your' : `${sideWord}’s`);
|
|
||||||
const world = (i: number) => (levelOf(i) === 3 ? 'in the air' : levelOf(i) === 1 ? 'in the underworld' : 'on the land');
|
const world = (i: number) => (levelOf(i) === 3 ? 'in the air' : levelOf(i) === 1 ? 'in the underworld' : 'on the land');
|
||||||
const value = $derived(kind === 'K' ? 'beyond price' : String(VALUES[kind]).replace('.5', '½'));
|
const value = $derived(kind === 'K' ? 'beyond price' : String(VALUES[kind]).replace('.5', '½'));
|
||||||
|
|
||||||
/** Whose piece a victim is, from the viewer's side of the table. */
|
/** Whose piece a victim is, from the viewer's side of the table. */
|
||||||
const whose = (i: number) => {
|
const whose = (i: number) => {
|
||||||
const s = sideOf(g.cells[i])!;
|
const s = sideOf(g.cells[i])!;
|
||||||
return mySide === null ? `${s === 'gold' ? 'Gold' : 'Scarlet'}’s` : s === mySide ? 'your' : 'the';
|
return mySide === null ? `${SIDE_NAMES[s]}’s` : s === mySide ? 'your' : 'the';
|
||||||
};
|
};
|
||||||
const list = (items: string[]) => (items.length <= 1 ? items[0] ?? '' : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
const list = (items: string[]) => (items.length <= 1 ? items[0] ?? '' : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
||||||
|
|
||||||
@@ -52,6 +50,13 @@
|
|||||||
return lines;
|
return lines;
|
||||||
});
|
});
|
||||||
const letter = $derived(LETTERS[kind]);
|
const letter = $derived(LETTERS[kind]);
|
||||||
|
|
||||||
|
/** On a phone the panel opens as a summary, and the lore unfolds on request. */
|
||||||
|
let more = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
void at;
|
||||||
|
more = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="lore" aria-label="About this piece">
|
<aside class="lore" aria-label="About this piece">
|
||||||
@@ -64,13 +69,16 @@
|
|||||||
<button type="button" class="dismiss" title="Close" aria-label="Close" onclick={close}>×</button>
|
<button type="button" class="dismiss" title="Close" aria-label="Close" onclick={close}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<p class="now">{situation.join(' ')}</p>
|
<p class="now">{situation.join(' ')}</p>
|
||||||
<p>{lore.is}</p>
|
<button type="button" class="link plain unfold" aria-expanded={more} onclick={() => (more = !more)}>{more ? 'Less' : 'How it moves, why it is here, how to play it'}</button>
|
||||||
<h3>How it moves</h3>
|
<div class="rest" class:open={more}>
|
||||||
<p>{lore.moves}</p>
|
<p>{lore.is}</p>
|
||||||
<h3>Why it is here</h3>
|
<h3>How it moves</h3>
|
||||||
<p>{lore.why}</p>
|
<p>{lore.moves}</p>
|
||||||
<h3>How to play it</h3>
|
<h3>Why it is here</h3>
|
||||||
<p>{lore.play}</p>
|
<p>{lore.why}</p>
|
||||||
|
<h3>How to play it</h3>
|
||||||
|
<p>{lore.play}</p>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -109,6 +117,38 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unfold {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.lore {
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unfold {
|
||||||
|
display: inline;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest.open {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.now {
|
.now {
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
border-left: 2px solid var(--ember);
|
border-left: 2px solid var(--ember);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a drake plays for {s.name}</button>
|
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a drake plays for {s.name}</button>
|
||||||
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="quiet" title="A drake plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
|
<button type="button" class="quiet" title="A drake plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a drake</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ export const LETTERS: Record<Kind, string> = {
|
|||||||
S: 'S', G: 'G', R: 'Dr', W: 'W', O: 'O', U: 'U', H: 'H', T: 'T', C: 'C', M: 'M', K: 'K', P: 'P', D: 'D', B: 'B', E: 'E'
|
S: 'S', G: 'G', R: 'Dr', W: 'W', O: 'O', U: 'U', H: 'H', T: 'T', C: 'C', M: 'M', K: 'K', P: 'P', D: 'D', B: 'B', E: 'E'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Gygax's values, with the King beyond price. */
|
/** Gygax's values. He gives the King none: 0 here, and the panel says "beyond price". */
|
||||||
export const VALUES: Record<Kind, number> = {
|
export const VALUES: Record<Kind, number> = {
|
||||||
K: 0, M: 11, P: 10, C: 9, R: 8, G: 5, O: 5, H: 4.5, T: 4, E: 4, B: 3, U: 2.5, D: 2, S: 1, W: 1
|
K: 0, M: 11, P: 10, C: 9, R: 8, G: 5, O: 5, H: 4.5, T: 4, E: 4, B: 3, U: 2.5, D: 2, S: 1, W: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDES: Side[] = ['gold', 'scarlet'];
|
export const SIDES: Side[] = ['gold', 'scarlet'];
|
||||||
|
export const SIDE_NAMES: Record<Side, string> = { gold: 'Gold', scarlet: 'Scarlet' };
|
||||||
export const other = (side: Side): Side => (side === 'gold' ? 'scarlet' : 'gold');
|
export const other = (side: Side): Side => (side === 'gold' ? 'scarlet' : 'gold');
|
||||||
|
|
||||||
/** A cell index from level 1 to 3, file 0 to 11 and rank 0 to 7. */
|
/** A cell index from level 1 to 3, file 0 to 11 and rank 0 to 7. */
|
||||||
@@ -128,11 +129,10 @@ export function movesFrom(cells: Cell[], from: number, setup: Setup = 'printed')
|
|||||||
const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side;
|
const enemy = (i: number) => cells[i] !== '' && sideOf(cells[i]) !== side;
|
||||||
const open = (i: number) => cells[i] === '' || sideOf(cells[i]) !== side;
|
const open = (i: number) => cells[i] === '' || sideOf(cells[i]) !== side;
|
||||||
/** Add the square if it is on the board and passes the test. */
|
/** Add the square if it is on the board and passes the test. */
|
||||||
const add = (lv: number, file: number, rank: number, ok: (i: number) => boolean) => {
|
const add = (lv: number, file: number, rank: number, ok: (i: number) => boolean): void => {
|
||||||
if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return false;
|
if (lv < 1 || lv > LEVELS || !inBounds(file, rank)) return;
|
||||||
const i = idx(lv, file, rank);
|
const i = idx(lv, file, rank);
|
||||||
if (ok(i)) out.push(i);
|
if (ok(i)) out.push(i);
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
/** Slide until something is in the way; a piece of the other side may be taken. */
|
/** Slide until something is in the way; a piece of the other side may be taken. */
|
||||||
const slide = (dirs: number[][]) => {
|
const slide = (dirs: number[][]) => {
|
||||||
|
|||||||
+60
-6
@@ -5,8 +5,8 @@
|
|||||||
// replays to the same choice.
|
// replays to the same choice.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CELLS, PER_LEVEL, VALUES, type Cell, type Kind, type Side, type Setup,
|
CELLS, type Cell, type Kind, type Side, type Setup,
|
||||||
frozen, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves
|
frozen, idx, kindOf, levelOf, rankOf, fileOf, movesFrom, other, playOn, sideOf, inCheck, legalMoves
|
||||||
} from './board';
|
} from './board';
|
||||||
|
|
||||||
/** Nodes a move may cost: under two seconds on the one-core host, a fifth of that on a laptop. The server waits on it. */
|
/** Nodes a move may cost: under two seconds on the one-core host, a fifth of that on a laptop. The server waits on it. */
|
||||||
@@ -15,14 +15,46 @@ const MAX_DEPTH = 8;
|
|||||||
const QUIESCE_DEPTH = 4;
|
const QUIESCE_DEPTH = 4;
|
||||||
const MATE = 100_000;
|
const MATE = 100_000;
|
||||||
|
|
||||||
/** Centipawn-ish weights: Gygax's values times twenty, the Dragon raised as Jackman advises. */
|
/**
|
||||||
|
* Centipawn-ish weights: Gygax's values times twenty, except the Dragon,
|
||||||
|
* raised to a Paladin's weight. Edward Jackman's write-up on the Chess
|
||||||
|
* Variant Pages argues Gygax's 8 is too low, and the search bears it out.
|
||||||
|
*/
|
||||||
const WEIGHT: Record<Kind, number> = {
|
const WEIGHT: Record<Kind, number> = {
|
||||||
K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20
|
K: 0, M: 220, P: 200, C: 180, R: 200, G: 100, O: 100, H: 90, T: 80, E: 80, B: 60, U: 50, D: 40, S: 20, W: 20
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How the position looks to `side`: material, a little shape, and the cost of being frozen or off the land. */
|
/** The land pieces fast enough to hunt a Dragon in the air. The Cleric and the King can rise too, but a step at a time. */
|
||||||
|
const HUNTERS: ReadonlySet<Kind> = new Set<Kind>(['G', 'H', 'P', 'M']);
|
||||||
|
|
||||||
|
/** Chebyshev distance between two squares of the air, the Dragon's measure. */
|
||||||
|
const apart = (a: number, b: number): number => Math.max(Math.abs(fileOf(a) - fileOf(b)), Math.abs(rankOf(a) - rankOf(b)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a Dragon means to a King: the cost of a King whose own Dragon has
|
||||||
|
* wandered from the air above it, and the greater cost of an enemy Dragon
|
||||||
|
* near enough to strike the King's square or its neighbours. Gygax's
|
||||||
|
* advice is to keep the Dragon home, and the drake takes it, since a race
|
||||||
|
* of raiding Dragons is won by whoever moved first.
|
||||||
|
*/
|
||||||
|
function roof(cells: Cell[], king: number, own: number, enemy: number): number {
|
||||||
|
if (king < 0) return 0;
|
||||||
|
const above = idx(3, fileOf(king), rankOf(king));
|
||||||
|
let v = 0;
|
||||||
|
if (own >= 0) v -= 25 * Math.max(0, apart(own, above) - 1);
|
||||||
|
if (enemy >= 0) {
|
||||||
|
const near = apart(enemy, above);
|
||||||
|
v -= near <= 1 ? 90 : near === 2 ? 45 : near === 3 ? 15 : 0;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the position looks to `side`: material, a little shape, the cost of being frozen or off the land, and the Dragons' bearing on the Kings. */
|
||||||
export function evaluate(cells: Cell[], side: Side): number {
|
export function evaluate(cells: Cell[], side: Side): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
const king = { gold: -1, scarlet: -1 };
|
||||||
|
const dragon = { gold: -1, scarlet: -1 };
|
||||||
|
const hunters: Record<Side, number[]> = { gold: [], scarlet: [] };
|
||||||
for (let i = 0; i < CELLS; i++) {
|
for (let i = 0; i < CELLS; i++) {
|
||||||
const cell = cells[i];
|
const cell = cells[i];
|
||||||
if (cell === '') continue;
|
if (cell === '') continue;
|
||||||
@@ -36,15 +68,37 @@ export function evaluate(cells: Cell[], side: Side): number {
|
|||||||
const advance = sideOf(cell) === 'gold' ? rank - 1 : 6 - rank;
|
const advance = sideOf(cell) === 'gold' ? rank - 1 : 6 - rank;
|
||||||
v += advance * 4;
|
v += advance * 4;
|
||||||
} else if (kind === 'K') {
|
} else if (kind === 'K') {
|
||||||
|
king[sideOf(cell)!] = i;
|
||||||
if (level !== 2) v -= 40;
|
if (level !== 2) v -= 40;
|
||||||
} else if (level === 2 && kind !== 'D' && kind !== 'S' && kind !== 'E') {
|
} else if (kind === 'R') {
|
||||||
|
dragon[sideOf(cell)!] = i;
|
||||||
|
} else if (HUNTERS.has(kind)) {
|
||||||
|
hunters[sideOf(cell)!].push(i);
|
||||||
|
}
|
||||||
|
if (level === 2 && kind !== 'W' && kind !== 'K' && kind !== 'D' && kind !== 'S' && kind !== 'E') {
|
||||||
// Pieces of the land like the middle of it.
|
// Pieces of the land like the middle of it.
|
||||||
v += 6 - Math.abs(file - 5.5) - Math.abs(rank - 3.5);
|
v += 6 - Math.abs(file - 5.5) - Math.abs(rank - 3.5);
|
||||||
}
|
}
|
||||||
if (frozen(cells, i)) v = Math.floor(v / 3);
|
if (frozen(cells, i)) v = Math.floor(v / 3);
|
||||||
score += own ? v : -v;
|
score += own ? v : -v;
|
||||||
}
|
}
|
||||||
return score;
|
const foe = other(side);
|
||||||
|
return score + roof(cells, king[side], dragon[side], dragon[foe]) - roof(cells, king[foe], dragon[foe], dragon[side])
|
||||||
|
+ hunt(side, hunters[side], dragon[foe]) - hunt(foe, hunters[foe], dragon[side]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Dragon raiding in `side`'s half of the air can be caught only by the
|
||||||
|
* pieces that can rise to meet it, so they are drawn towards it; a Dragon
|
||||||
|
* at home among its own pieces draws nothing.
|
||||||
|
*/
|
||||||
|
function hunt(side: Side, hunters: number[], dragon: number): number {
|
||||||
|
if (dragon < 0) return 0;
|
||||||
|
const rank = rankOf(dragon);
|
||||||
|
if (side === 'gold' ? rank > 3 : rank < 4) return 0;
|
||||||
|
let v = 0;
|
||||||
|
for (const i of hunters) v += 6 * Math.max(0, 3 - apart(i, dragon));
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Search {
|
interface Search {
|
||||||
|
|||||||
+18
-23
@@ -5,10 +5,8 @@
|
|||||||
// replay of the moves, so the chronicle is a function of the game and
|
// replay of the moves, so the chronicle is a function of the game and
|
||||||
// never part of the ledger: rewording it changes no rules.
|
// never part of the ledger: rewording it changes no rules.
|
||||||
|
|
||||||
import {
|
import { type Cell, type Kind, type Side, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName } from './board';
|
||||||
type Cell, type Kind, type Side, CELLS, PER_LEVEL, NAMES, frozen, kindOf, levelOf, opening, other, playOn, rankOf, sideOf, squareName
|
import { playerName, type Move, type State } from './index';
|
||||||
} from './board';
|
|
||||||
import type { Move, State } from './index';
|
|
||||||
|
|
||||||
export type LineKind = 'move' | 'capture' | 'check' | 'freeze' | 'promotion' | 'end' | 'note';
|
export type LineKind = 'move' | 'capture' | 'check' | 'freeze' | 'promotion' | 'end' | 'note';
|
||||||
|
|
||||||
@@ -22,6 +20,8 @@ export interface Telling {
|
|||||||
ply: number;
|
ply: number;
|
||||||
move: Move;
|
move: Move;
|
||||||
lines: Line[];
|
lines: Line[];
|
||||||
|
/** The boards as they stood once the move was made. */
|
||||||
|
after: Cell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
|
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
|
||||||
@@ -48,7 +48,6 @@ function crossing(kind: Kind, from: number, to: number): string {
|
|||||||
/** How a victim is told. */
|
/** How a victim is told. */
|
||||||
function falls(victim: Kind, owner: string, where: string): string {
|
function falls(victim: Kind, owner: string, where: string): string {
|
||||||
switch (victim) {
|
switch (victim) {
|
||||||
case 'K': return `${owner} King is taken`;
|
|
||||||
case 'R': return `${owner} Dragon falls from the sky at ${where}`;
|
case 'R': return `${owner} Dragon falls from the sky at ${where}`;
|
||||||
case 'B': return `${owner} Basilisk is crushed at ${where}, and its gaze is broken`;
|
case 'B': return `${owner} Basilisk is crushed at ${where}, and its gaze is broken`;
|
||||||
case 'S': return `${owner} Sylph is scattered at ${where}`;
|
case 'S': return `${owner} Sylph is scattered at ${where}`;
|
||||||
@@ -57,8 +56,6 @@ function falls(victim: Kind, owner: string, where: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const possessive = (name: string) => `${name}’s`;
|
|
||||||
|
|
||||||
/** The pieces on the land held frozen, by square. */
|
/** The pieces on the land held frozen, by square. */
|
||||||
function frozenSquares(cells: Cell[]): Set<number> {
|
function frozenSquares(cells: Cell[]): Set<number> {
|
||||||
const held = new Set<number>();
|
const held = new Set<number>();
|
||||||
@@ -66,10 +63,13 @@ function frozenSquares(cells: Cell[]): Set<number> {
|
|||||||
return held;
|
return held;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whose a piece is, as a possessive: "Aud’s", "Scarlet’s", or "Your" at a lesson. */
|
||||||
|
export type Owner = (side: Side) => string;
|
||||||
|
|
||||||
/** Tell one move from the position it was made in. */
|
/** Tell one move from the position it was made in. */
|
||||||
function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) => string): { lines: Line[]; after: Cell[] } {
|
function tellOne(before: Cell[], move: Move, owner: Owner): { lines: Line[]; after: Cell[] } {
|
||||||
const who = possessive(name(move.by));
|
const who = owner(move.by);
|
||||||
const foe = possessive(name(other(move.by)));
|
const foe = owner(other(move.by));
|
||||||
const piece = NAMES[move.kind];
|
const piece = NAMES[move.kind];
|
||||||
const from = squareName(move.from);
|
const from = squareName(move.from);
|
||||||
const to = squareName(move.to);
|
const to = squareName(move.to);
|
||||||
@@ -95,12 +95,13 @@ function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) =>
|
|||||||
const heldBefore = frozenSquares(before);
|
const heldBefore = frozenSquares(before);
|
||||||
const heldAfter = frozenSquares(after);
|
const heldAfter = frozenSquares(after);
|
||||||
for (const i of heldAfter) {
|
for (const i of heldAfter) {
|
||||||
if (heldBefore.has(i) && !(move.afar === false && i === move.to)) continue;
|
if (heldBefore.has(i) && (move.afar || i !== move.to)) continue;
|
||||||
const owner = sideOf(after[i]) === move.by ? who : foe;
|
const owner = sideOf(after[i]) === move.by ? who : foe;
|
||||||
lines.push({ kind: 'freeze', text: `The Basilisk’s gaze falls on ${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)}, which cannot stir.` });
|
lines.push({ kind: 'freeze', text: `The Basilisk’s gaze falls on ${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)}, which cannot stir.` });
|
||||||
}
|
}
|
||||||
for (const i of heldBefore) {
|
for (const i of heldBefore) {
|
||||||
if (heldAfter.has(i) || after[i] === '' || i === move.from) continue;
|
// The same piece, still there and no longer held; a piece that has just arrived is another story.
|
||||||
|
if (heldAfter.has(i) || after[i] === '' || after[i] !== before[i] || i === move.from || i === move.to) continue;
|
||||||
const owner = sideOf(after[i]) === move.by ? who : foe;
|
const owner = sideOf(after[i]) === move.by ? who : foe;
|
||||||
lines.push({ kind: 'note', text: `${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)} is free to move again.` });
|
lines.push({ kind: 'note', text: `${owner} ${NAMES[kindOf(after[i])]} at ${squareName(i)} is free to move again.` });
|
||||||
}
|
}
|
||||||
@@ -117,30 +118,24 @@ function tellOne(before: Cell[], move: Move, ply: number, name: (side: Side) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (move.check) lines.push({ kind: 'check', text: `${foe} King is in check.` });
|
if (move.check) lines.push({ kind: 'check', text: `${foe} King is in check.` });
|
||||||
void ply;
|
|
||||||
return { lines, after };
|
return { lines, after };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole game told, one entry a move, from a replay of the moves. A game begun from the opening needs no `start`. */
|
/** The whole game told, one entry a move, from a replay of the moves. A game begun from the opening needs no `start`. */
|
||||||
export function narrate(state: State, start?: Cell[]): Telling[] {
|
export function narrate(state: State, start?: Cell[]): Telling[] {
|
||||||
const name = (side: Side) => {
|
const owner: Owner = (side) => `${playerName(state, side)}’s`;
|
||||||
const seat = state.seats.find((id) => state.players[id].side === side);
|
|
||||||
return seat ? state.players[seat].name : side === 'gold' ? 'Gold' : 'Scarlet';
|
|
||||||
};
|
|
||||||
let cells = start ?? opening(state.setup);
|
let cells = start ?? opening(state.setup);
|
||||||
const out: Telling[] = [];
|
const out: Telling[] = [];
|
||||||
state.moves.forEach((move, n) => {
|
state.moves.forEach((move, n) => {
|
||||||
const { lines, after } = tellOne(cells, move, n + 1, name);
|
const { lines, after } = tellOne(cells, move, owner);
|
||||||
cells = after;
|
cells = after;
|
||||||
out.push({ ply: n + 1, move, lines });
|
out.push({ ply: n + 1, move, lines, after });
|
||||||
});
|
});
|
||||||
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
|
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One move told on its own, from the position it was made in. */
|
/** One move told on its own, from the position it was made in. */
|
||||||
export function tell(before: Cell[], move: Move, name: (side: Side) => string): string {
|
export function tell(before: Cell[], move: Move, owner: Owner): string {
|
||||||
return tellOne(before, move, 0, name).lines.map((l) => l.text).join(' ');
|
return tellOne(before, move, owner).lines.map((l) => l.text).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CELLS };
|
|
||||||
+37
-13
@@ -1,15 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CURRENT_RULES, applyMove, createGame, game, describe as tell, notation, positionKey, resolveRound, validateMove, type State } from './index';
|
import { CURRENT_RULES, applyMove, createGame, game, notation, positionKey, resolveRound, validateMove, type State } from './index';
|
||||||
import { CELLS, census, frozen, inCheck, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board';
|
import { CELLS, census, frozen, inCheck, kindOf, legalMoves, legalMovesFrom, movesFrom, opening, playOn, square, squareName, type Cell, type Side } from './board';
|
||||||
import { chooseMove } from './bot';
|
import { chooseMove, evaluate } from './bot';
|
||||||
|
import { narrate, tell } from './chronicle';
|
||||||
|
|
||||||
|
/** The players' names as the chronicle writes them. */
|
||||||
|
const owner = (side: Side) => (side === 'gold' ? 'Aud’s' : 'Bjorn’s');
|
||||||
|
|
||||||
/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */
|
/** A position from a few named pieces: 'gK' on '2g1' and so on. Kings are added where none is given. */
|
||||||
function position(pieces: Record<string, string>, toMove: Side = 'gold'): State {
|
function position(pieces: Record<string, string>, toMove: Side = 'gold'): State {
|
||||||
const s = createGame({ A: 'Aud', B: 'Bjorn' });
|
const s = createGame({ A: 'Aud', B: 'Bjorn' });
|
||||||
s.cells = new Array(CELLS).fill('');
|
s.cells = new Array(CELLS).fill('');
|
||||||
for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell;
|
for (const [name, cell] of Object.entries(pieces)) s.cells[square(name)] = cell;
|
||||||
if (!s.cells.includes('gK')) s.cells[square('2l1')] = s.cells[square('2l1')] || 'gK';
|
if (!s.cells.includes('gK')) s.cells[square('2l1')] = 'gK';
|
||||||
if (!s.cells.includes('sK')) s.cells[square('2a8')] = s.cells[square('2a8')] || 'sK';
|
if (!s.cells.includes('sK')) s.cells[square('2a8')] = 'sK';
|
||||||
s.toMove = toMove;
|
s.toMove = toMove;
|
||||||
s.seen = { [positionKey(s.cells, toMove)]: 1 };
|
s.seen = { [positionKey(s.cells, toMove)]: 1 };
|
||||||
return s;
|
return s;
|
||||||
@@ -209,7 +213,7 @@ describe('the lower board', () => {
|
|||||||
const s = position({ '1d4': 'gB', '2d4': 'sO', '2d3': 'gW' });
|
const s = position({ '1d4': 'gB', '2d4': 'sO', '2d3': 'gW' });
|
||||||
expect(frozen(s.cells, square('2d4'))).toBe(true);
|
expect(frozen(s.cells, square('2d4'))).toBe(true);
|
||||||
expect(names(s.cells, '2d4')).toEqual([]);
|
expect(names(s.cells, '2d4')).toEqual([]);
|
||||||
// The frozen Oliphant looks along the rank at Gold's King on l... put the King in its line.
|
// Gold's King on the frozen Oliphant's rank is not in check: a held piece threatens nothing.
|
||||||
const line = position({ '1d4': 'gB', '2d4': 'sO', '2l4': 'gK' });
|
const line = position({ '1d4': 'gB', '2d4': 'sO', '2l4': 'gK' });
|
||||||
expect(inCheck(line.cells, 'gold')).toBe(false);
|
expect(inCheck(line.cells, 'gold')).toBe(false);
|
||||||
line.cells[square('1d4')] = '';
|
line.cells[square('1d4')] = '';
|
||||||
@@ -301,17 +305,17 @@ describe('check and the ends', () => {
|
|||||||
const after = resolveRound(s, { A: { from: square('2c2'), to: square('2c3') } });
|
const after = resolveRound(s, { A: { from: square('2c2'), to: square('2c3') } });
|
||||||
expect(after.toMove).toBe('scarlet');
|
expect(after.toMove).toBe('scarlet');
|
||||||
expect(after.moves).toHaveLength(1);
|
expect(after.moves).toHaveLength(1);
|
||||||
expect(tell(after, after.moves[0])).toBe('Aud’s Warrior steps from 2c2 to 2c3.');
|
expect(tell(s.cells, after.moves[0], owner)).toBe('Aud’s Warrior steps from 2c2 to 2c3.');
|
||||||
expect(notation(after.moves[0])).toBe('W/2c2-2c3');
|
expect(notation(after.moves[0])).toBe('W/2c2-2c3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells a capture, a climb and a strike from afar', () => {
|
it('tells a capture, a climb and a strike from afar', () => {
|
||||||
const s = position({ '3c4': 'gR', '2c4': 'sW', '2e4': 'gH' });
|
const s = position({ '3c4': 'gR', '2c4': 'sW', '2e4': 'gH' });
|
||||||
const afar = applyMove(s, square('3c4'), square('2c4'));
|
const afar = applyMove(s, square('3c4'), square('2c4'));
|
||||||
expect(tell(afar, afar.moves[0], s.cells)).toBe('Aud’s Dragon strikes down from 3c4 without leaving the air, and Bjorn\u2019s Warrior falls at 2c4.');
|
expect(tell(s.cells, afar.moves[0], owner)).toBe('Aud’s Dragon strikes down from 3c4 without leaving the air, and Bjorn\u2019s Warrior falls at 2c4.');
|
||||||
expect(notation(afar.moves[0])).toBe('Drx2c4');
|
expect(notation(afar.moves[0])).toBe('Drx2c4');
|
||||||
const climb = applyMove(s, square('2e4'), square('3d5'));
|
const climb = applyMove(s, square('2e4'), square('3d5'));
|
||||||
expect(tell(climb, climb.moves[0], s.cells)).toBe('Aud’s Hero springs up from 2e4 to 3d5, into the air.');
|
expect(tell(s.cells, climb.moves[0], owner)).toBe('Aud’s Hero springs up from 2e4 to 3d5, into the air.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +340,27 @@ describe('the drake', () => {
|
|||||||
expect(move).toEqual({ from: square('2h8'), to: square('2h1') });
|
expect(move).toEqual({ from: square('2h8'), to: square('2h1') });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps its Dragon home and sees a raiding Dragon as the danger it is', () => {
|
||||||
|
const s = createGame({ A: 'Aud', B: 'Bjorn' });
|
||||||
|
// The Dragon can take a Sylph on the first move, without recourse. The drake declines.
|
||||||
|
expect(legalMoves(s.cells, 'gold').some(([from, to]) => from === square('3g1') && to === square('3a7'))).toBe(true);
|
||||||
|
const first = chooseMove(s.cells, 'gold', 'printed', 20000)!;
|
||||||
|
expect(kindOf(s.cells[first.from])).not.toBe('R');
|
||||||
|
// A Dragon that has flown to the far corner is worth less to its side than one at home, Sylph and all.
|
||||||
|
const raided = s.cells.slice();
|
||||||
|
raided[square('3a7')] = raided[square('3g1')];
|
||||||
|
raided[square('3g1')] = '';
|
||||||
|
expect(evaluate(raided, 'gold')).toBeLessThan(evaluate(s.cells, 'gold'));
|
||||||
|
// An enemy Dragon near the air above the King is worse than the same Dragon as far from its own home but off in a corner.
|
||||||
|
const near = s.cells.slice();
|
||||||
|
near[square('3f3')] = near[square('3g8')];
|
||||||
|
near[square('3g8')] = '';
|
||||||
|
const far = s.cells.slice();
|
||||||
|
far[square('3b3')] = far[square('3g8')];
|
||||||
|
far[square('3g8')] = '';
|
||||||
|
expect(evaluate(near, 'gold')).toBeLessThan(evaluate(far, 'gold'));
|
||||||
|
});
|
||||||
|
|
||||||
it('plays a whole game against itself on a small budget and the game ends', { timeout: 120_000 }, () => {
|
it('plays a whole game against itself on a small budget and the game ends', { timeout: 120_000 }, () => {
|
||||||
let s = createGame({ A: 'Aud', B: 'Bjorn' });
|
let s = createGame({ A: 'Aud', B: 'Bjorn' });
|
||||||
let plies = 0;
|
let plies = 0;
|
||||||
@@ -351,8 +376,7 @@ describe('the drake', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('the chronicle', () => {
|
describe('the chronicle', () => {
|
||||||
it('tells a Basilisk’s gaze falling and lifting, a Warrior standing up a Hero, and a King leaving the land', async () => {
|
it('tells a Basilisk’s gaze falling and lifting, a Warrior standing up a Hero, and a King leaving the land', () => {
|
||||||
const { narrate } = await import('./chronicle');
|
|
||||||
const s = position({ '1c3': 'gB', '2d4': 'sT', '2a7': 'gW', '2g1': 'gK' });
|
const s = position({ '1c3': 'gB', '2d4': 'sT', '2a7': 'gW', '2g1': 'gK' });
|
||||||
s.cells[square('2l1')] = '';
|
s.cells[square('2l1')] = '';
|
||||||
const frozen = applyMove(s, square('1c3'), square('1d4'));
|
const frozen = applyMove(s, square('1c3'), square('1d4'));
|
||||||
@@ -365,9 +389,9 @@ describe('the chronicle', () => {
|
|||||||
'Aud’s Basilisk slithers from 1d4 to 1d5.',
|
'Aud’s Basilisk slithers from 1d4 to 1d5.',
|
||||||
'Bjorn’s Thief at 2d4 is free to move again.'
|
'Bjorn’s Thief at 2d4 is free to move again.'
|
||||||
]);
|
]);
|
||||||
const hero = applyMove(applyMove(freed, square('2b7'), square('2a8')), square('2a7'), square('2a8'));
|
const hero = applyMove(applyMove(freed, square('2b7'), square('2c7')), square('2a7'), square('2a8'));
|
||||||
const told = narrate(hero, s.cells)[4].lines;
|
const told = narrate(hero, s.cells)[4].lines;
|
||||||
expect(told[0].text).toBe('Aud’s Warrior steps from 2a7 to 2a8, and Bjorn’s King is taken.');
|
expect(told[0].text).toBe('Aud’s Warrior steps from 2a7 to 2a8.');
|
||||||
expect(told.some((l) => l.kind === 'promotion')).toBe(true);
|
expect(told.some((l) => l.kind === 'promotion')).toBe(true);
|
||||||
const up = applyMove(s, square('2g1'), square('3g1'));
|
const up = applyMove(s, square('2g1'), square('3g1'));
|
||||||
expect(narrate(up, s.cells)[0].lines.map((l) => l.text)).toEqual([
|
expect(narrate(up, s.cells)[0].lines.map((l) => l.text)).toEqual([
|
||||||
|
|||||||
+17
-31
@@ -5,22 +5,21 @@
|
|||||||
// around them: seats, turns, the ends, and the GameSpec the kit runs.
|
// around them: seats, turns, the ends, and the GameSpec the kit runs.
|
||||||
|
|
||||||
import { type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec';
|
import { type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec';
|
||||||
import {
|
import { type Cell, type Kind, type Side, type Setup, LETTERS, NAMES, SIDE_NAMES, hasLegalMove, inCheck, kindOf, legalMovesFrom, levelOf, movesFrom, opening, other, playOn, sideOf, squareName } from './board';
|
||||||
type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom,
|
|
||||||
levelOf, movesFrom, opening, other, playOn, sideOf, squareName
|
|
||||||
} from './board';
|
|
||||||
export { FILES, RANKS, LEVELS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, kingSquare, inCheck, other, square } from './board';
|
|
||||||
import { chooseMove } from './bot';
|
import { chooseMove } from './bot';
|
||||||
import { narrate } from './chronicle';
|
|
||||||
|
// The board's geometry and move generation, re-exported so the browser imports one module.
|
||||||
|
export type { Cell, Kind, Side, Setup };
|
||||||
|
export {
|
||||||
|
CELLS, FILES, LEVELS, PER_LEVEL, RANKS, NAMES, LETTERS, SIDE_NAMES, VALUES, cellOf, fileOf, frozen, hasLegalMove, idx, inCheck, isLight, kindOf, kingSquare,
|
||||||
|
legalMovesFrom, levelOf, movesFrom, other, playOn, rankOf, sideOf, square, squareName
|
||||||
|
} from './board';
|
||||||
|
|
||||||
export const CURRENT_RULES = 1;
|
export const CURRENT_RULES = 1;
|
||||||
|
|
||||||
/** Plies without a capture before the game is drawn: fifty moves by each side. */
|
/** Plies without a capture before the game is drawn: fifty moves by each side. */
|
||||||
export const QUIET_LIMIT = 100;
|
export const QUIET_LIMIT = 100;
|
||||||
|
|
||||||
export type { Cell, Kind, Side, Setup };
|
|
||||||
export { NAMES, LETTERS, squareName, legalMovesFrom, movesFrom };
|
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,18 +61,16 @@ export interface State {
|
|||||||
|
|
||||||
export type Input = { from: number; to: number };
|
export type Input = { from: number; to: number };
|
||||||
|
|
||||||
export const SIDES: { value: Side; label: string; note: string }[] = [
|
export const SIDE_CHOICES: { value: Side; label: string; note: string }[] = [
|
||||||
{ value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' },
|
{ value: 'gold', label: 'Gold, and move first', note: 'Gold opens the game.' },
|
||||||
{ value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' }
|
{ value: 'scarlet', label: 'Scarlet', note: 'Scarlet answers.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SETUPS: { value: Setup; label: string; note: string }[] = [
|
export const SETUP_CHOICES: { value: Setup; label: string; note: string }[] = [
|
||||||
{ value: 'printed', label: 'As printed in 1985', note: 'The Sylphs stand on a, c, e, g, i and k. Gold’s Dragon can take one on the first move.' },
|
{ value: 'printed', label: 'As printed in 1985', note: 'The Sylphs stand on a, c, e, g, i and k. Gold’s Dragon can take one on the first move.' },
|
||||||
{ value: 'shifted', label: 'Sylphs shifted one file', note: 'The Sylphs stand over the Dwarves, on b, d, f, h, j and l, out of the Dragons’ reach.' }
|
{ value: 'shifted', label: 'Sylphs shifted one file', note: 'The Sylphs stand over the Dwarves, on b, d, f, h, j and l, out of the Dragons’ reach.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const sideName = (side: Side): string => (side === 'gold' ? 'Gold' : 'Scarlet');
|
|
||||||
|
|
||||||
export function createGame(names: Record<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State {
|
export function createGame(names: Record<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State {
|
||||||
const seats = Object.keys(names);
|
const seats = Object.keys(names);
|
||||||
const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold';
|
const hostSide: Side = options?.side === 'scarlet' ? 'scarlet' : 'gold';
|
||||||
@@ -94,7 +91,7 @@ export function seatOf(state: State, side: Side): SeatId | null {
|
|||||||
|
|
||||||
export function playerName(state: State, side: Side): string {
|
export function playerName(state: State, side: Side): string {
|
||||||
const seat = seatOf(state, side);
|
const seat = seatOf(state, side);
|
||||||
return seat ? state.players[seat].name : sideName(side);
|
return seat ? state.players[seat].name : SIDE_NAMES[side];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The end of the game in words a player reads, and the key the tally counts. */
|
/** The end of the game in words a player reads, and the key the tally counts. */
|
||||||
@@ -155,13 +152,6 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
|
|||||||
return applyMove(previous, input.from, input.to);
|
return applyMove(previous, input.from, input.to);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The move told as the chronicle tells it, with what came of it. */
|
|
||||||
export function describe(state: State, move: Move, start?: Cell[]): string {
|
|
||||||
const n = state.moves.indexOf(move);
|
|
||||||
const telling = narrate(state, start)[n];
|
|
||||||
return telling ? telling.lines.map((l) => l.text).join(' ') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The move in Gygax's notation: piece, from, x, to, with (H), "afar" and ch as he wrote them. */
|
/** The move in Gygax's notation: piece, from, x, to, with (H), "afar" and ch as he wrote them. */
|
||||||
export function notation(move: Move): string {
|
export function notation(move: Move): string {
|
||||||
const piece = LETTERS[move.kind];
|
const piece = LETTERS[move.kind];
|
||||||
@@ -177,12 +167,11 @@ export const game: GameSpec<State, Input> = {
|
|||||||
botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'],
|
botNames: ['Fafnir', 'Nidhogg', 'Ladon', 'Vritra', 'Apep', 'Jormungand'],
|
||||||
currentRules: CURRENT_RULES,
|
currentRules: CURRENT_RULES,
|
||||||
options: [
|
options: [
|
||||||
{ key: 'side', label: 'You play', choices: SIDES, default: 'gold' },
|
{ key: 'side', label: 'You play', choices: SIDE_CHOICES, default: 'gold' },
|
||||||
{ key: 'setup', label: 'The Sylphs', choices: SETUPS, default: 'printed' }
|
{ key: 'setup', label: 'The Sylphs', choices: SETUP_CHOICES, default: 'printed' }
|
||||||
],
|
],
|
||||||
preferences: [
|
preferences: [
|
||||||
{ key: 'coordinates', label: 'Square names on the boards', kind: 'toggle', default: true },
|
{ key: 'coordinates', label: 'Square names on the boards', kind: 'toggle', default: true }
|
||||||
{ key: 'letters', label: 'Pieces as letters', kind: 'toggle', default: false, note: 'Gygax’s initials instead of the drawn pieces.' }
|
|
||||||
],
|
],
|
||||||
create: createGame,
|
create: createGame,
|
||||||
resolve: resolveRound,
|
resolve: resolveRound,
|
||||||
@@ -191,10 +180,8 @@ export const game: GameSpec<State, Input> = {
|
|||||||
turn: (state) => state.moves.length + 1,
|
turn: (state) => state.moves.length + 1,
|
||||||
// Nothing is hidden on any board, so every viewer sees the whole state.
|
// Nothing is hidden on any board, so every viewer sees the whole state.
|
||||||
view: (state) => state,
|
view: (state) => state,
|
||||||
botInput: (state, seat) => {
|
// A side with no legal move has already ended the game, so the drake is never asked for one.
|
||||||
const side = state.players[seat]?.side ?? state.toMove;
|
botInput: (state, seat) => chooseMove(state.cells, state.players[seat]?.side ?? state.toMove, state.setup)!,
|
||||||
return chooseMove(state.cells, side, state.setup) ?? { from: -1, to: -1 };
|
|
||||||
},
|
|
||||||
cleanInput: (raw) => {
|
cleanInput: (raw) => {
|
||||||
const r = raw as { from?: unknown; to?: unknown };
|
const r = raw as { from?: unknown; to?: unknown };
|
||||||
const n = (v: unknown) => (Number.isInteger(v) ? (v as number) : -1);
|
const n = (v: unknown) => (Number.isInteger(v) ? (v as number) : -1);
|
||||||
@@ -208,11 +195,10 @@ export const game: GameSpec<State, Input> = {
|
|||||||
else if (state.end === 'stalemate') lines['ended in stalemate'] = 1;
|
else if (state.end === 'stalemate') lines['ended in stalemate'] = 1;
|
||||||
else if (state.end === 'repetition') lines['drawn by repetition'] = 1;
|
else if (state.end === 'repetition') lines['drawn by repetition'] = 1;
|
||||||
else if (state.end === 'quiet') lines['drawn after fifty quiet moves'] = 1;
|
else if (state.end === 'quiet') lines['drawn after fifty quiet moves'] = 1;
|
||||||
if (state.over?.winner) lines[`won by ${sideName(state.players[state.over.winner].side)}`] = 1;
|
if (state.over?.winner) lines[`won by ${SIDE_NAMES[state.players[state.over.winner].side]}`] = 1;
|
||||||
lines['moves played'] = state.moves.length;
|
lines['moves played'] = state.moves.length;
|
||||||
return lines;
|
return lines;
|
||||||
},
|
},
|
||||||
tallyBegun: (options) => ({ [options.setup === 'shifted' ? 'begun with the Sylphs shifted' : 'begun as printed']: 1 })
|
tallyBegun: (options) => ({ [options.setup === 'shifted' ? 'begun with the Sylphs shifted' : 'begun as printed']: 1 })
|
||||||
};
|
};
|
||||||
|
|
||||||
export { cellOf };
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SCENES, sceneCells } from './lessons';
|
||||||
|
import { frozen, hasLegalMove, inCheck, kindOf, legalMovesFrom, movesFrom, playOn, square } from './board';
|
||||||
|
import { CALLOUTS, TOLD_MOVES, toldStates } from './told';
|
||||||
|
|
||||||
|
describe('the lessons', () => {
|
||||||
|
it('every scene’s move is legal for the side to move, and only the asked-for moves count', () => {
|
||||||
|
for (const scene of SCENES) {
|
||||||
|
const cells = sceneCells(scene);
|
||||||
|
for (const [from, to] of scene.moves) {
|
||||||
|
const legal = legalMovesFrom(cells, square(from), 'printed');
|
||||||
|
expect(legal, `${scene.id}: ${from} to ${to}`).toContain(square(to));
|
||||||
|
}
|
||||||
|
// The piece asked for is the player's.
|
||||||
|
for (const [from] of scene.moves) expect(cells[square(from)][0], scene.id).toBe(scene.side === 'gold' ? 'g' : 's');
|
||||||
|
// The scene draws every board its move touches.
|
||||||
|
for (const [from, to] of scene.moves) {
|
||||||
|
expect(scene.levels, scene.id).toContain(Number(from[0]));
|
||||||
|
expect(scene.levels, scene.id).toContain(Number(to[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the scenes do what their words say', () => {
|
||||||
|
const at = (id: string) => SCENES.find((s) => s.id === id)!;
|
||||||
|
const play = (id: string) => {
|
||||||
|
const s = at(id);
|
||||||
|
const cells = sceneCells(s);
|
||||||
|
const [from, to] = s.moves[0];
|
||||||
|
return { cells, after: playOn(cells, square(from), square(to)), s };
|
||||||
|
};
|
||||||
|
// The Warrior's step is blocked and its capture is not.
|
||||||
|
expect(movesFrom(sceneCells(at('warrior')), square('2c2'))).toEqual([square('2d3')]);
|
||||||
|
// The Hero leaps the Warrior and takes the Mage.
|
||||||
|
expect(play('hero').after.captured).toBe('M');
|
||||||
|
// The Mage comes down two boards onto the Basilisk.
|
||||||
|
expect(play('mage').after.captured).toBe('B');
|
||||||
|
// The Paladin vaults over the Dwarf at 1c5.
|
||||||
|
expect(play('paladin').after.captured).toBe('D');
|
||||||
|
// The Sylph's dive and the Dragon's strike take what they say.
|
||||||
|
expect(play('sylph').after.captured).toBe('O');
|
||||||
|
const dragon = play('dragon');
|
||||||
|
expect(dragon.after.afar).toBe(true);
|
||||||
|
expect(dragon.after.captured).toBe('M');
|
||||||
|
expect(dragon.after.cells[square('3c4')]).toBe('gR');
|
||||||
|
// The Basilisk's arrival freezes the Mage above.
|
||||||
|
const basilisk = play('basilisk');
|
||||||
|
expect(frozen(basilisk.after.cells, square('2e4'))).toBe(true);
|
||||||
|
expect(movesFrom(basilisk.after.cells, square('2e4'))).toEqual([]);
|
||||||
|
// The Elemental climbs onto the Thief.
|
||||||
|
expect(play('elemental').after.captured).toBe('T');
|
||||||
|
// The King is in check before it moves, and out of it after either move; up or down leaves it off the land.
|
||||||
|
const king = sceneCells(at('king'));
|
||||||
|
expect(inCheck(king, 'gold')).toBe(true);
|
||||||
|
expect(legalMovesFrom(king, square('2g1'), 'printed').sort()).toEqual([square('1g1'), square('2f1'), square('2h1'), square('3g1')].sort());
|
||||||
|
// The last scene is mate in one by either listed move, and by no other.
|
||||||
|
const mate = play('mate');
|
||||||
|
for (const [from, to] of mate.s.moves) {
|
||||||
|
const after = playOn(mate.cells, square(from), square(to)).cells;
|
||||||
|
expect(inCheck(after, 'scarlet'), `${from} to ${to}`).toBe(true);
|
||||||
|
expect(hasLegalMove(after, 'scarlet', 'printed'), `${from} to ${to}`).toBe(false);
|
||||||
|
}
|
||||||
|
const listed = new Set(mate.s.moves.map(([a, b]) => `${square(a)}-${square(b)}`));
|
||||||
|
for (const to of legalMovesFrom(mate.cells, square('2a1'), 'printed')) {
|
||||||
|
if (listed.has(`${square('2a1')}-${to}`)) continue;
|
||||||
|
const after = playOn(mate.cells, square('2a1'), to).cells;
|
||||||
|
expect(inCheck(after, 'scarlet') && !hasLegalMove(after, 'scarlet', 'printed'), `unlisted mate by the Oliphant to ${to}`).toBe(false);
|
||||||
|
}
|
||||||
|
expect(kindOf(mate.cells[square('2d4')])).toBe('K');
|
||||||
|
expect(frozen(mate.cells, square('2d4'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the game told', () => {
|
||||||
|
it('plays through the engine from the opening to a checkmate, and every callout sits on a move', () => {
|
||||||
|
const states = toldStates();
|
||||||
|
expect(states).toHaveLength(TOLD_MOVES.length + 1);
|
||||||
|
const last = states[states.length - 1];
|
||||||
|
expect(last.end).toBe('mate');
|
||||||
|
expect(last.over?.winner).toBe('A');
|
||||||
|
for (const n of Object.keys(CALLOUTS).map(Number)) expect(n).toBeLessThanOrEqual(TOLD_MOVES.length);
|
||||||
|
// The things the callouts say happened.
|
||||||
|
expect(last.moves[8].captured).toBe('R');
|
||||||
|
expect(last.moves[16].kind).toBe('B');
|
||||||
|
expect(last.moves[20].captured).toBe('H');
|
||||||
|
expect(last.moves[23].captured).toBe('H');
|
||||||
|
expect(last.moves[26].captured).toBe('S');
|
||||||
|
expect(last.moves[26].check).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// The lessons: one scene a piece, each a small position with something to
|
||||||
|
// see and a move to make. The page shows the boards the scene needs, says
|
||||||
|
// what to look at, takes the move from the player, and tells what came of
|
||||||
|
// it. A test plays every scene's move through the engine and checks the
|
||||||
|
// captures, the freeze and the mate the words promise.
|
||||||
|
|
||||||
|
import { type Cell, type Side, CELLS, square } from './board';
|
||||||
|
|
||||||
|
export interface Scene {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** The pieces, by square name. Kings are added at 2l1 and 2a8 where the scene gives none. */
|
||||||
|
pieces: Record<string, Cell>;
|
||||||
|
/** Whose move it is: the player's side. */
|
||||||
|
side: Side;
|
||||||
|
/** The boards to draw, top first. */
|
||||||
|
levels: number[];
|
||||||
|
/** What to look at before moving. */
|
||||||
|
before: string;
|
||||||
|
/** The move to make, in the player's words. */
|
||||||
|
task: string;
|
||||||
|
/** The move itself, from and to; any of these counts. */
|
||||||
|
moves: [string, string][];
|
||||||
|
/** What came of it. */
|
||||||
|
after: string;
|
||||||
|
/** Said when a legal move is made that is not the one to look at. */
|
||||||
|
aside?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCENES: Scene[] = [
|
||||||
|
{
|
||||||
|
id: 'warrior',
|
||||||
|
title: 'The Warrior',
|
||||||
|
pieces: { '2c2': 'gW', '2d3': 'sW', '2c3': 'sU' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2],
|
||||||
|
before: 'Twelve of these stand in a wall at the start. A Warrior steps one square straight ahead, never two, and takes one square diagonally ahead, as a pawn does. The Unicorn at 2c3 blocks this one’s step, but the Warrior at 2d3 is on its diagonal.',
|
||||||
|
task: 'Take the Scarlet Warrior. Tap your Warrior at 2c2, then 2d3.',
|
||||||
|
moves: [['2c2', '2d3']],
|
||||||
|
after: 'The Warrior falls, and yours stands on the d file now. A Warrior that reaches the far rank stands up a Hero, with no choice about it, which is a real gain but not a queen. There is no double step and no capture in passing, so every advance is a commitment.',
|
||||||
|
aside: 'That is a legal move, but the Warrior at 2d3 is the one to take. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oliphant',
|
||||||
|
title: 'The Oliphant',
|
||||||
|
pieces: { '2a1': 'gO', '2a6': 'sT', '2d1': 'gT' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2],
|
||||||
|
before: 'The war elephant is a rook by another name. It runs any distance along a rank or a file while the way is clear, and never leaves the land. The a file is open from your corner to the Thief at 2a6.',
|
||||||
|
task: 'Take the Thief. Tap the Oliphant at 2a1, then 2a6.',
|
||||||
|
moves: [['2a1', '2a6']],
|
||||||
|
after: 'On a board twelve squares wide an open file runs a long way, and the Oliphant is the piece that owns it. Look up before you settle on a file, though. The Oliphant cannot strike at the air, so an enemy Dragon over the squares ahead of it takes it at leisure.',
|
||||||
|
aside: 'A legal move, but the Thief at 2a6 is the one to take. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unicorn',
|
||||||
|
title: 'The Unicorn',
|
||||||
|
pieces: { '2b1': 'gU', '2a2': 'gW', '2b2': 'gW', '2c2': 'gW', '2c3': 'sT' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2],
|
||||||
|
before: 'A knight, and one of the three pieces of the land that leap, with the Hero and the Paladin. Your Warriors wall it in, and the Unicorn does not care. It goes one square along a rank or file and one diagonally outward, over whatever is between.',
|
||||||
|
task: 'Leap the wall and take the Thief at 2c3. Tap the Unicorn, then 2c3.',
|
||||||
|
moves: [['2b1', '2c3']],
|
||||||
|
after: 'The Thief is gone and the wall never moved. Early in a game the Unicorns are the pieces to bring out first, for just this reason. Gygax rates them low, below a Basilisk, because a knight is slow on so wide a board, so trade one gladly for a Hero or a Thief.',
|
||||||
|
aside: 'A legal move, but the leap to 2c3 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'thief',
|
||||||
|
title: 'The Thief',
|
||||||
|
pieces: { '2d1': 'gT', '2h5': 'sO' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2],
|
||||||
|
before: 'The bishop of the land, quick along the diagonals. From 2d1 the diagonal runs e2, f3, g4 and on to the Oliphant at 2h5, with nothing in the way.',
|
||||||
|
task: 'Take the Oliphant. Tap the Thief, then 2h5.',
|
||||||
|
moves: [['2d1', '2h5']],
|
||||||
|
after: 'Four squares in one move, and a five-point piece for a four-point one. A Thief’s work is in the middle ranks, which is where the Dragon hunts from the air, so look up before you leave it there. Two Thieves cover both colours of square.',
|
||||||
|
aside: 'A legal move, but the Oliphant at 2h5 is the one to take. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hero',
|
||||||
|
title: 'The Hero',
|
||||||
|
pieces: { '2c1': 'gH', '2d2': 'gW', '2e3': 'sM' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2],
|
||||||
|
before: 'A champion who goes one or two squares along a diagonal and leaps the square between. Your own Warrior stands at 2d2, and beyond it the Scarlet Mage.',
|
||||||
|
task: 'Leap over the Warrior and take the Mage at 2e3. Tap the Hero, then 2e3.',
|
||||||
|
moves: [['2c1', '2e3']],
|
||||||
|
after: 'The Mage, worth eleven, to a Hero worth four and a half. The Hero can also rise to the air or drop to the underworld, onto any of the four squares diagonally beside the square above or below it, and take a piece as it goes. Off the land its only move is back, so it goes up to kill and comes straight home.',
|
||||||
|
aside: 'A legal move, but the leap to 2e3 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cleric',
|
||||||
|
title: 'The Cleric',
|
||||||
|
pieces: { '2e4': 'gC', '3e4': 'sS' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2],
|
||||||
|
before: 'The one piece at home on every board. It moves one square in any direction wherever it stands, and it can step straight up or down to the board above or below. A Scarlet Sylph hovers directly over it.',
|
||||||
|
task: 'Step up into the air and take the Sylph. Tap the Cleric, then 3e4 on the upper board.',
|
||||||
|
moves: [['2e4', '3e4']],
|
||||||
|
after: 'The Cleric is in the air now and moves there exactly as it did on the land. Nothing else that visits all three boards keeps its moves on each, which is why Gygax values it at nine, above the Dragon.',
|
||||||
|
aside: 'A legal move, but the step up to 3e4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mage',
|
||||||
|
title: 'The Mage',
|
||||||
|
pieces: { '3c4': 'gM', '1c4': 'sB' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2, 1],
|
||||||
|
before: 'On the land the Mage is a queen. Here it is in the air, where it can only step one square along a rank or file, or move straight up or down. Straight down two boards is the underworld, and a Scarlet Basilisk sits there at 1c4. The land square between, 2c4, is empty, and it has to be, because the Mage does not vault.',
|
||||||
|
task: 'Descend two boards and take the Basilisk. Tap the Mage, then 1c4 on the lowest board.',
|
||||||
|
moves: [['3c4', '1c4']],
|
||||||
|
after: 'The Basilisk is crushed and the Mage stands in the underworld, where it is a shadow of itself, one step at a time. Gygax warns against bringing the Mage out early, since lesser pieces develop by chasing it.',
|
||||||
|
aside: 'A legal move, but the drop to 1c4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paladin',
|
||||||
|
title: 'The Paladin',
|
||||||
|
pieces: { '2c4': 'gP', '1c6': 'sD', '1c5': 'sD' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2, 1],
|
||||||
|
before: 'On the land the Paladin has the king’s step and the knight’s leap together. Between boards it leaps the way a knight does, one board up or down and two squares along a rank or file, or two boards and one square, over anything in the way. Two Dwarves wait below.',
|
||||||
|
task: 'Vault down to the underworld and take the Dwarf at 1c6. Tap the Paladin, then 1c6.',
|
||||||
|
moves: [['2c4', '1c6']],
|
||||||
|
after: 'One board down and two squares forward, over the Dwarf at 1c5, which never had a say. In the underworld the Paladin keeps only the king’s step. Worth ten by Gygax’s count, it is the piece that can be on any board from any other in one move, and he counsels keeping it back until the lesser pieces are out.',
|
||||||
|
aside: 'A legal move, but the vault to 1c6 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sylph',
|
||||||
|
title: 'The Sylph',
|
||||||
|
pieces: { '3e4': 'gS', '2e4': 'sO', '3e5': 'sW' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2],
|
||||||
|
before: 'A pawn turned inside out. In the air the Sylph drifts one square diagonally forward and captures by stepping straight ahead, so the Warrior at 3e5 is in its sights. But look down. A Scarlet Oliphant stands on the land directly beneath it, and a Sylph can strike straight down.',
|
||||||
|
task: 'Dive and take the Oliphant on the land. Tap the Sylph, then 2e4 on the middle board.',
|
||||||
|
moves: [['3e4', '2e4']],
|
||||||
|
after: 'A one-point piece has taken a five-point one. Now the Sylph is grounded. On the land it cannot move at all except to rise again, to the square above it or to any empty square the Sylphs began on, and it takes nothing on the way up. Spend them this way and climb them home.',
|
||||||
|
aside: 'A legal move, but the dive onto the Oliphant at 2e4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'griffon',
|
||||||
|
title: 'The Griffon',
|
||||||
|
pieces: { '3d4': 'gG', '2e5': 'sT', '3f7': 'sW' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2],
|
||||||
|
before: 'Half eagle and half lion. In the air the Griffon leaps two squares one way and three the other, so the Warrior at 3f7 is one leap away. It can also swoop to the land, onto any of the four squares diagonally beside the square beneath it. The Thief at 2e5 is one of those four.',
|
||||||
|
task: 'Swoop down and take the Thief. Tap the Griffon, then 2e5 on the middle board.',
|
||||||
|
moves: [['3d4', '2e5']],
|
||||||
|
after: 'The Thief is gone, and the Griffon is on the land, where it has lost its wings and creeps one square diagonally until it climbs back. A Griffon that drops is a Griffon that must climb again, so drop for something worth two moves. Nothing on the land sees its leap coming.',
|
||||||
|
aside: 'A legal move, but the swoop onto the Thief at 2e5 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dragon',
|
||||||
|
title: 'The Dragon',
|
||||||
|
pieces: { '3c4': 'gR', '2d4': 'sM', '2c4': 'sW' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2],
|
||||||
|
before: 'The wyrm never leaves the air. It slides the diagonals and steps one square in any direction, and it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. Beneath it at 2c4 stands a Warrior, and beside that, at 2d4, the Scarlet Mage.',
|
||||||
|
task: 'Strike the Mage from afar. Tap the Dragon, then 2d4 on the middle board.',
|
||||||
|
moves: [['3c4', '2d4']],
|
||||||
|
after: 'The Mage is lifted from the land and the Dragon has not moved. That strike was its whole move. Gygax set each Dragon directly over its King for this reason. A Dragon at home is a roof over the five squares beneath it, and a Dragon that flies off to hunt leaves its King under the other Dragon’s shadow.',
|
||||||
|
aside: 'A legal move, but the strike at the Mage on 2d4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dwarf',
|
||||||
|
title: 'The Dwarf',
|
||||||
|
pieces: { '1d4': 'gD', '2d4': 'sH', '1e5': 'sW' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2, 1],
|
||||||
|
before: 'A miner of the underworld. It walks one square forward or sideways without taking, and takes one square diagonally ahead, so the Warrior at 1e5 is in reach. It can also strike straight up at the land. A Scarlet Hero has stopped at 2d4, directly over it.',
|
||||||
|
task: 'Burst up and take the Hero. Tap the Dwarf, then 2d4 on the middle board.',
|
||||||
|
moves: [['1d4', '2d4']],
|
||||||
|
after: 'The square above a Dwarf is a trap for anything that settles there. On the land the Dwarf keeps the same moves, and it comes back down only onto an empty square beneath it. Gygax says the Dwarves play little part early, and make the best outposts late, if you have not hurried them forward.',
|
||||||
|
aside: 'A legal move, but the strike up at the Hero on 2d4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basilisk',
|
||||||
|
title: 'The Basilisk',
|
||||||
|
pieces: { '1e3': 'gB', '2e4': 'sM', '2h7': 'sK' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2, 1],
|
||||||
|
before: 'The serpent whose gaze turns to stone. A Basilisk moves one square forward or diagonally forward, taking what is there, or one square straight back, and never leaves the underworld. Whatever enemy stands on the land directly above it cannot move, take or threaten anything. The Scarlet Mage stands at 2e4. The square beneath it, 1e4, is one step ahead of your Basilisk.',
|
||||||
|
task: 'Slither under the Mage. Tap the Basilisk, then 1e4.',
|
||||||
|
moves: [['1e3', '1e4']],
|
||||||
|
after: 'The Mage is frozen where it stands, and will be until the Basilisk moves or is taken. Gygax says an immobilised piece can be treated as if it did not exist, except that it still blocks its square. Stand a piece of yours over your own Basilisk and the trick runs the other way.',
|
||||||
|
aside: 'A legal move, but 1e4, beneath the Mage, is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elemental',
|
||||||
|
title: 'The Elemental',
|
||||||
|
pieces: { '1d4': 'gE', '2e4': 'sT', '1d6': 'sD' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2, 1],
|
||||||
|
before: 'The one heavy thing in the underworld. It moves one or two squares along a rank or file and takes the same way, so the Dwarf at 1d6 is in reach. It climbs to the land only to capture, one square along a rank or file onto an empty square and then straight up onto an enemy. The square beside it at 1e4 is empty, and above that stands a Thief.',
|
||||||
|
task: 'Heave up and take the Thief. Tap the Elemental, then 2e4 on the middle board.',
|
||||||
|
moves: [['1d4', '2e4']],
|
||||||
|
after: 'The Thief is shattered and the Elemental stands on the land, where it is a rock. Its only move there is back down, straight through an empty square and then one square along a rank or file. Its climb needs an empty square beside it and an enemy above that square, and nothing else will do.',
|
||||||
|
aside: 'A legal move, but the climb onto the Thief at 2e4 is the one to see. Set the scene again and look for it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'king',
|
||||||
|
title: 'The King',
|
||||||
|
pieces: { '2g1': 'gK', '2g8': 'sO', '2f2': 'gW', '2h2': 'gW' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [3, 2, 1],
|
||||||
|
before: 'The King is the whole game. It moves one square in any direction on the land, and one step straight up or down between boards. Here it is in check, because the Scarlet Oliphant looks down the open g file. Nothing of yours can take the Oliphant or step into the file, so the King must move. Two squares on the land are safe, and it could also climb into the air or dig into the underworld.',
|
||||||
|
task: 'Step out of check along the rank. Tap the King, then 2f1 or 2h1.',
|
||||||
|
moves: [['2g1', '2f1'], ['2g1', '2h1']],
|
||||||
|
after: 'Safe, and still on the land. The King could have climbed or dug instead, but off the land a King can do nothing except come straight back, and Gygax says it is easily trapped there. At the table, keep two more things in mind. The square beneath your King is where an enemy Basilisk would freeze it, and your Dragon above it is its roof.',
|
||||||
|
aside: 'That is out of check, but off the land the King can only come back. Set the scene again and stay on the land.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mate',
|
||||||
|
title: 'Checkmate',
|
||||||
|
pieces: { '2d4': 'sK', '1d4': 'gB', '2a1': 'gO', '2g1': 'gK', '2f3': 'sT' },
|
||||||
|
side: 'gold',
|
||||||
|
levels: [2, 1],
|
||||||
|
before: 'Your Basilisk sits beneath the Scarlet King at 2d4, so the King is frozen and cannot step out of anything. Gygax wrote that a frozen King is checkmated by any attack it cannot capture or block. Your Oliphant stands in the corner, and two open lines lead to the King, along the fourth rank and up the d file. But a Scarlet Thief at 2f3 watches 2d1, and an attacker that can be taken gives no mate.',
|
||||||
|
task: 'Give checkmate. Tap the Oliphant, then 2a4.',
|
||||||
|
moves: [['2a1', '2a4']],
|
||||||
|
after: 'Checkmate. The King is attacked along the rank, it cannot move, the Thief can neither take the Oliphant on a4 nor step between, and nothing else of Scarlet’s can either. On d1 the Oliphant would have given check and died for it. That is the Basilisk’s other gift. A well-timed move beneath a King, or beneath the piece that guards it, ends a game.',
|
||||||
|
aside: 'A legal move, but not the mate. The Thief at 2f3 covers 2d1, so set the scene again and look for the line it does not cover.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The scene's position as cells, Kings supplied where the scene gives none. */
|
||||||
|
export function sceneCells(scene: Scene): Cell[] {
|
||||||
|
const cells: Cell[] = new Array(CELLS).fill('');
|
||||||
|
for (const [name, cell] of Object.entries(scene.pieces)) cells[square(name)] = cell;
|
||||||
|
if (!cells.includes('gK')) cells[square('2l1')] = 'gK';
|
||||||
|
if (!cells.includes('sK')) cells[square('2a8')] = 'sK';
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
// What each piece is, in the game's own voice: the panel a player opens by
|
// What each piece is, in the game's own voice: the panel a player opens by
|
||||||
// tapping a piece. The moves are Gygax's, restated for a reader at the
|
// tapping a piece. The moves are Gygax's, restated for a reader at the
|
||||||
// table. The rest is ours, written from his strategic notes and from the
|
// table. The rest is ours, written from his strategic notes and from the
|
||||||
// shape of the rules, and every claim in it is checked against board.ts.
|
// shape of the rules, checked against board.ts before either changes.
|
||||||
|
|
||||||
import type { Kind } from './board';
|
import type { Kind } from './board';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export const LORE: Record<Kind, Lore> = {
|
|||||||
},
|
},
|
||||||
G: {
|
G: {
|
||||||
count: 'two a side',
|
count: 'two a side',
|
||||||
is: 'Half eagle and half lion, and the only piece that leaps through the air. Two of them flank each Dragon, on the c and k files.',
|
is: 'Half eagle and half lion, and the one piece whose leap is across the air itself. Two of them flank each Dragon, on the c and k files.',
|
||||||
moves: 'In the air a Griffon leaps two squares one way and three the other, over anything in between, to as many as eight squares. It can drop to the land onto any of the four squares diagonally beside the square beneath it, taking whatever is there. On the land it loses its wings and creeps one square diagonally, until it climbs back onto one of the four squares diagonally above it.',
|
moves: 'In the air a Griffon leaps two squares one way and three the other, over anything in between, to as many as eight squares. It can drop to the land onto any of the four squares diagonally beside the square beneath it, taking whatever is there. On the land it loses its wings and creeps one square diagonally, until it climbs back onto one of the four squares diagonally above it.',
|
||||||
why: 'The Griffon is the sky’s raider. Its leap crosses three ranks at once, so nothing on the land sees it coming, and its drop lands beside the square beneath it rather than on it, so a piece that guards the sky over its own head is not safe.',
|
why: 'The Griffon is the sky’s raider. Its leap crosses three ranks at once, so nothing on the land sees it coming, and its drop lands beside the square beneath it rather than on it, so a piece that guards the sky over its own head is not safe.',
|
||||||
play: 'Keep it in the air, where it is worth a little more than a Hero. On the land it is a lame Thief. Drop to pick off a defender and climb before the land can gather. A Griffon and a Dragon hunting together can take anything that strays onto the ranks beneath them.'
|
play: 'Keep it in the air, where it is worth a little more than a Hero. On the land it is a lame Thief. Drop to pick off a defender and climb before the land can gather. A Griffon and a Dragon hunting together can take anything that strays onto the ranks beneath them.'
|
||||||
@@ -38,13 +38,13 @@ export const LORE: Record<Kind, Lore> = {
|
|||||||
is: 'The great wyrm of the sky, roosting above its own King on the g file. The game takes its name from this one.',
|
is: 'The great wyrm of the sky, roosting above its own King on the g file. The game takes its name from this one.',
|
||||||
moves: 'It never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. And it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. The victim is lifted away and the Dragon stays where it is.',
|
moves: 'It never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. And it strikes from afar. Without moving, it can take an enemy on the land directly beneath it or on any of the four squares beside that one. The victim is lifted away and the Dragon stays where it is.',
|
||||||
why: 'Gygax set the Dragon directly over the King on purpose. Its strike covers the five land squares beneath it, so a Dragon at home is a roof over the King, and a Dragon that flies off to hunt leaves the King under the other Dragon’s shadow.',
|
why: 'Gygax set the Dragon directly over the King on purpose. Its strike covers the five land squares beneath it, so a Dragon at home is a roof over the King, and a Dragon that flies off to hunt leaves the King under the other Dragon’s shadow.',
|
||||||
play: 'Gygax rated it at eight, and players since have called that too low. The drake values it as a Paladin. Early, keep it home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.'
|
play: 'Gygax rated it at eight, and Edward Jackman, who wrote the game up for the Chess Variant Pages, called that too low. The drake values it as a Paladin, and keeps it home until the other Dragon is dead or far away. Early, keep yours home and let it threaten. Later, a Dragon over the enemy’s back rank is a terror, since only five kinds of land piece can reach the air at all. Mind the Sylphs, which capture straight ahead on its board and cost a point to lose.'
|
||||||
},
|
},
|
||||||
W: {
|
W: {
|
||||||
count: 'twelve a side',
|
count: 'twelve a side',
|
||||||
is: 'The levy of the land, twelve spears in a wall across the second rank.',
|
is: 'The levy of the land, twelve spears in a wall across the second rank.',
|
||||||
moves: 'One square straight ahead, never two, and it captures one square diagonally ahead. On reaching the far rank it stands up as a Hero, with no say in the matter. It never leaves the land.',
|
moves: 'One square straight ahead, never two, and it captures one square diagonally ahead. On reaching the far rank it stands up as a Hero, with no say in the matter. It never leaves the land.',
|
||||||
why: 'A chess pawn with the double step and the capture in passing taken away. Gygax said he limited it to the single step of shatranj, the old game chess came from, which slows the opening and makes every advance a commitment.',
|
why: 'A chess pawn with the double step and the capture in passing taken away, which slows the opening and makes every advance a commitment.',
|
||||||
play: 'Twelve files is a wide front. Push in the middle and leave the wings to the Oliphants. Chains guard one another as in chess. A Warrior that gets through becomes a Hero, worth four and a half, a real gain but not a queen, so do not spend three pieces to get one there.'
|
play: 'Twelve files is a wide front. Push in the middle and leave the wings to the Oliphants. Chains guard one another as in chess. A Warrior that gets through becomes a Hero, worth four and a half, a real gain but not a queen, so do not spend three pieces to get one there.'
|
||||||
},
|
},
|
||||||
O: {
|
O: {
|
||||||
@@ -73,34 +73,34 @@ export const LORE: Record<Kind, Lore> = {
|
|||||||
is: 'The bishop of the land, quick along the diagonals and quick to be elsewhere.',
|
is: 'The bishop of the land, quick along the diagonals and quick to be elsewhere.',
|
||||||
moves: 'Any distance along a diagonal, as far as the way is clear. It never leaves the land.',
|
moves: 'Any distance along a diagonal, as far as the way is clear. It never leaves the land.',
|
||||||
why: 'Two Thieves cover both colours of square, and on a board this wide the diagonals run far.',
|
why: 'Two Thieves cover both colours of square, and on a board this wide the diagonals run far.',
|
||||||
play: 'A Thief is the piece most often lost to a Dragon’s strike, because its work is in the middle ranks where the Dragon hunts. Keep it on a diagonal the Dragon does not cover, and remember that a Basilisk beneath a Thief freezes it mid-flight.'
|
play: 'A Thief works in the middle ranks, where the Dragon hunts, so keep it on a diagonal the Dragon does not cover, and remember that a Basilisk beneath a Thief freezes it mid-flight.'
|
||||||
},
|
},
|
||||||
C: {
|
C: {
|
||||||
count: 'one a side',
|
count: 'one a side',
|
||||||
is: 'The one piece at home on every board.',
|
is: 'The one piece at home on every board.',
|
||||||
moves: 'One square in any direction wherever it stands, and one step straight up or down to the board above or below, taking what is there.',
|
moves: 'One square in any direction wherever it stands, and one step straight up or down to the board above or below, taking what is there.',
|
||||||
why: 'Gygax says not to underestimate it. Of the five kinds of piece that can visit all three boards, the Cleric is the only one that moves and captures the same way on each.',
|
why: 'Gygax says not to underestimate it. Of the five kinds of piece that can visit all three boards, the Cleric is the only one that moves and captures the same way on each.',
|
||||||
play: 'Gygax values it at nine, above the Dragon, because it can go anywhere and threaten everything when it arrives. A Cleric in the underworld hunts Basilisks that few others can reach, and a Cleric in the air harries Sylphs. Walk it rather than hurrying it. It is slow, and every square it passes is one it controls.'
|
play: 'Gygax values it at nine, above the Dragon, because it can go anywhere and threaten everything when it arrives. A Cleric in the underworld hunts Basilisks that few others can reach, and a Cleric in the air harries Sylphs. It is slow, so start it toward the middle early.'
|
||||||
},
|
},
|
||||||
M: {
|
M: {
|
||||||
count: 'one a side',
|
count: 'one a side',
|
||||||
is: 'The queen of the land, and a traveller between worlds with little to do once it has arrived.',
|
is: 'The queen of the land, and a traveller between worlds with little to do once it has arrived.',
|
||||||
moves: 'On the land a Mage slides any distance along a rank, file or diagonal. It can also move straight up or down, one board or two, though to pass through the land the square there must be empty. In the air or the underworld it is reduced to one square sideways, forward or back.',
|
moves: 'On the land a Mage slides any distance along a rank, file or diagonal. It can also move straight up or down, one board or two, though to pass through the land the square there must be empty. In the air or the underworld it is reduced to one square sideways, forward or back.',
|
||||||
why: 'The most valuable piece on the board, worth eleven, and Gygax warns against the queen hunt. A Mage brought out early is chased by lesser pieces that develop as they chase it.',
|
why: 'The most valuable piece on the board, worth eleven, and Gygax warns against the queen hunt. A Mage brought out early is chased by lesser pieces that develop as they chase it.',
|
||||||
play: 'Keep it back until the Unicorns and Heroes are out. Its step between boards is a way to flee a Dragon’s gaze, or to drop into the underworld and take a Basilisk from beside it, but a Mage down there is a shadow of itself and takes three moves to go and come back. Use the vertical move to escape, not to visit.'
|
play: 'Keep it back until the Unicorns and Heroes are out. Its step between boards is a way to flee a Dragon’s gaze, or to drop into the underworld and take a Basilisk from beside it, but a Mage down there is a shadow of itself and takes three moves to go and come back.'
|
||||||
},
|
},
|
||||||
K: {
|
K: {
|
||||||
count: 'one a side',
|
count: 'one a side',
|
||||||
is: 'The King of the land, and the whole game. Lose it and nothing else counts.',
|
is: 'The King of the land. The game is won by trapping the other one and lost by losing this one.',
|
||||||
moves: 'One square in any direction on the land, and one step straight up to the air or down to the underworld, taking what is there. Off the land it can do nothing but come straight back.',
|
moves: 'One square in any direction on the land, and one step straight up to the air or down to the underworld, taking what is there. Off the land it can do nothing but come straight back.',
|
||||||
why: 'As in chess, the object is to checkmate it. A King attacked with no escape loses the game, and a King frozen by a Basilisk beneath it is mated by any attack that cannot be blocked or captured.',
|
why: 'As in chess, the object is to checkmate it. A King attacked with no escape loses the game, and a King frozen by a Basilisk beneath it is mated by any attack that cannot be blocked or captured.',
|
||||||
play: 'Keep the square beneath it in mind. An enemy Basilisk that reaches the underworld under your King freezes it where it stands. Never step off the land unless the land is death, since off it the King can only return, a sitting duck. The Dragon above it is its roof, and the Paladin beside it its shield.'
|
play: 'Keep the square beneath it in mind. An enemy Basilisk that reaches the underworld under your King freezes it where it stands. Keep it on the land unless staying is worse, since off the land it can only return, and Gygax says it is easily trapped there. The Dragon above it is its roof.'
|
||||||
},
|
},
|
||||||
P: {
|
P: {
|
||||||
count: 'one a side',
|
count: 'one a side',
|
||||||
is: 'The holy knight, the piece that can be on any board from any other in a single leap.',
|
is: 'The holy knight, the piece that can be on any board from any other in a single leap.',
|
||||||
moves: 'On the land the Paladin has the king’s step and the knight’s leap together. Between boards it leaps the way a knight does on one, one board up or down and two squares along a rank or file, or two boards and one square, over anything in between. In the air or the underworld it keeps only the king’s step.',
|
moves: 'On the land the Paladin has the king’s step and the knight’s leap together. Between boards it leaps the way a knight does on one, one board up or down and two squares along a rank or file, or two boards and one square, over anything in between. In the air or the underworld it keeps only the king’s step.',
|
||||||
why: 'After the Mage it is the game’s most powerful piece, worth ten, and the only one whose move between boards vaults what is in the way.',
|
why: 'After the Mage it is the game’s most powerful piece, worth ten, and one of the three, with the Griffon and the Hero, whose moves between boards vault what is in the way.',
|
||||||
play: 'Gygax counsels keeping it back until the minor pieces are developed. Then its leaps let it threaten the enemy’s sky and underworld where few can follow, and come back in one move. A Paladin can land beside a Basilisk and take it next turn, or rise beneath a Dragon’s roost and strike it from the air.'
|
play: 'Gygax counsels keeping it back until the minor pieces are developed. Then its leaps let it threaten the enemy’s sky and underworld where few can follow, and come back in one move. A Paladin can land beside a Basilisk and take it next turn, or rise beneath a Dragon’s roost and strike it from the air.'
|
||||||
},
|
},
|
||||||
D: {
|
D: {
|
||||||
@@ -114,7 +114,7 @@ export const LORE: Record<Kind, Lore> = {
|
|||||||
count: 'two a side',
|
count: 'two a side',
|
||||||
is: 'The serpent whose gaze turns to stone. Whatever enemy stands on the land directly above a Basilisk cannot move, cannot take, and threatens nothing, until the Basilisk moves or dies.',
|
is: 'The serpent whose gaze turns to stone. Whatever enemy stands on the land directly above a Basilisk cannot move, cannot take, and threatens nothing, until the Basilisk moves or dies.',
|
||||||
moves: 'It moves one square straight or diagonally forward, taking what is there, or one square straight back onto an empty square. It never leaves the underworld. The freeze is not a move. It simply holds for as long as the Basilisk stays beneath its victim.',
|
moves: 'It moves one square straight or diagonally forward, taking what is there, or one square straight back onto an empty square. It never leaves the underworld. The freeze is not a move. It simply holds for as long as the Basilisk stays beneath its victim.',
|
||||||
why: 'The Basilisk is the game’s one trick that chess does not have. It is how the underworld reaches the land without ever rising.',
|
why: 'The Basilisk is how the underworld reaches the land without ever rising.',
|
||||||
play: 'Gygax’s own advice is to stand a piece of yours over your own Basilisk. Whatever captures it is frozen on the spot, and you take it at your leisure. A Basilisk arriving under a piece that guards the enemy King can be a checkmate on its own, and a Basilisk under the King itself leaves it unable to step out of any check. Walk it forward early. It is slow, and the squares that matter are far away.'
|
play: 'Gygax’s own advice is to stand a piece of yours over your own Basilisk. Whatever captures it is frozen on the spot, and you take it at your leisure. A Basilisk arriving under a piece that guards the enemy King can be a checkmate on its own, and a Basilisk under the King itself leaves it unable to step out of any check. Walk it forward early. It is slow, and the squares that matter are far away.'
|
||||||
},
|
},
|
||||||
E: {
|
E: {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// A game told: twenty-seven moves written to show what a first game should
|
||||||
|
// see, with a callout where there is something to look at. The moves are
|
||||||
|
// replayed through the engine, so the game can never drift from the rules,
|
||||||
|
// and a test says so.
|
||||||
|
|
||||||
|
import { applyMove, createGame, validateMove, type State } from './index';
|
||||||
|
import { square } from './board';
|
||||||
|
|
||||||
|
/** The moves, from and to, in order. */
|
||||||
|
export const TOLD_MOVES: [string, string][] = [
|
||||||
|
['2f2', '2f3'], ['2g7', '2g6'], ['2b1', '2c3'], ['2k8', '2j6'], ['2c1', '2e3'], ['3g8', '3c4'], ['2c3', '2e4'], ['3c4', '3d4'], ['2e3', '3d4'],
|
||||||
|
['2c8', '2e6'], ['3d4', '2e5'], ['2h7', '2h6'], ['1c1', '1c2'], ['2e6', '2c4'], ['1c2', '1c3'], ['2i8', '2h7'], ['1c3', '1c4'], ['2d7', '2d6'],
|
||||||
|
['2b2', '2b3'], ['2j6', '2h5'], ['2b3', '2c4'], ['3c8', '3f6'], ['2e2', '2e3'], ['3f6', '2e5'], ['3g1', '3d4'], ['2a7', '2a6'], ['3d4', '3g7']
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What to look at, by the ply just played; 0 is before the first move. */
|
||||||
|
export const CALLOUTS: Record<number, string> = {
|
||||||
|
0: 'Both armies stand as Gygax drew them, and Gold moves first.',
|
||||||
|
1: 'A Warrior steps one square, never two, so the opening is slower than chess. This one clears the f file for the Mage behind it.',
|
||||||
|
3: 'The Unicorn is the first piece out, because it leaps the wall of Warriors. The Hero and the Paladin can leap it too, and nothing else on the land can.',
|
||||||
|
5: 'The Hero leaps two squares along a diagonal, over its own Warrior at 2d2. That leap is how a Hero gets out early.',
|
||||||
|
6: 'Scarlet’s Dragon leaves its roost, which Gygax warned against. It now threatens the five land squares beneath 3c4, and Gold’s Unicorn at 2c3 stands on one of them. It has also left Scarlet’s King without a roof.',
|
||||||
|
7: 'Gold steps the Unicorn out of the Dragon’s shadow.',
|
||||||
|
8: 'The Dragon follows, hungry, to 3d4, where it threatens the Unicorn again. But it has stepped onto a square a Hero can reach. From 2e3 a Hero rises onto any square diagonally beside 3e3, and 3d4 is one of them.',
|
||||||
|
9: 'The Hero rises and the Dragon falls. Only five kinds of land piece can touch the air at all, and this is how one of them does it. The Hero is in the air now, where it can do nothing but come down.',
|
||||||
|
10: 'Scarlet brings a Hero out in answer, leaping its own Warrior at 2d7.',
|
||||||
|
11: 'Gold’s Hero comes home to the land, onto a square diagonally beside the one beneath it. Off the land, that was its only kind of move.',
|
||||||
|
13: 'Gold’s Basilisk sets out from the underworld on a slow walk, one square a move. A Basilisk never leaves the underworld, but whatever enemy piece stands on the land directly above it is frozen, unable to move, take or threaten anything, for as long as the Basilisk stays beneath. This one wants to be beneath whatever Scarlet sends deep into Gold’s side. Watch where Scarlet’s Hero goes.',
|
||||||
|
14: 'The Hero digs into Gold’s side at 2c4, two leaps from home. A piece that comes this deep should look beneath its feet.',
|
||||||
|
17: 'The Basilisk arrives at 1c4, directly beneath the Hero at 2c4, and the Hero is frozen. It cannot move, take or threaten anything until the Basilisk moves away or is taken. Gold is in no hurry.',
|
||||||
|
19: 'A Warrior comes to stand diagonally behind the frozen Hero.',
|
||||||
|
21: 'Taken, in Gygax’s words, more or less at your leisure. The Warrior is safe above Gold’s own Basilisk, since a Basilisk freezes only enemies.',
|
||||||
|
22: 'Scarlet’s Griffon leaps to 3f6, two squares one way and three the other, over everything. Now look at the four land squares diagonally beside 2f6. One of them is 2e5, where Gold’s Hero stands.',
|
||||||
|
23: 'Gold does not look up.',
|
||||||
|
24: 'The Griffon swoops onto the Hero and takes it. On the land it has lost its wings, and creeps one square at a time until it climbs back.',
|
||||||
|
25: 'Gold’s Dragon leaves its roost, and this time it is safe to do so, since Scarlet’s Dragon is dead and nothing threatens Gold’s King from the air. Look along the diagonal from 3d4 through e5 and f6 to 3g7, where a Scarlet Sylph hovers. That square is not above the King, which stands at 2g8, but one rank in front of it, and a Dragon on 3g7 strikes down at 2g7 beneath it and at the four squares beside that, 2g8 among them.',
|
||||||
|
26: 'Scarlet does not look up either.',
|
||||||
|
27: 'The Dragon takes the Sylph at 3g7 and strikes down at 2g7 beneath it and at 2g8, 2f7 and 2h7 beside that, every square the King could use. The King cannot step up, because the Dragon stands beside 3g8. It cannot step down, because its own Elemental is at 1g8. Its neighbours on the land are its own pieces or struck squares, and nothing of Scarlet’s can reach 3g7. Checkmate.'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The game at every ply, from the opening to the end. The engine refuses a move that is not legal. */
|
||||||
|
export function toldStates(): State[] {
|
||||||
|
let s = createGame({ A: 'Gold', B: 'Scarlet' });
|
||||||
|
const out = [s];
|
||||||
|
TOLD_MOVES.forEach(([from, to], n) => {
|
||||||
|
const seat = s.toMove === 'gold' ? 'A' : 'B';
|
||||||
|
const why = validateMove(s, seat, { from: square(from), to: square(to) });
|
||||||
|
if (why) throw new Error(`told game, ply ${n + 1}, ${from} to ${to}: ${why}`);
|
||||||
|
s = applyMove(s, square(from), square(to));
|
||||||
|
out.push(s);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<h2>The hall</h2>
|
<h2>The hall</h2>
|
||||||
<figure><img src="/guide/hall.png" alt="The hall, with a name field, the two table choices, the button to play the drake, and the opening position drawn beside them" loading="lazy" /></figure>
|
<figure><img src="/guide/hall.png" alt="The hall, with a name field, the two table choices, the button to play the drake, and the opening position drawn beside them" loading="lazy" /></figure>
|
||||||
<p>Write your name and choose your company. <strong>Play now against a drake</strong> seats you across from the house player and begins at once. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send to a friend. <strong>Join one</strong> takes a code someone sent you.</p>
|
<p>Write your name and choose your company. <strong>Play now against a drake</strong> seats you across from the house player and begins at once. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send to a friend. <strong>Join one</strong> takes a code someone sent you.</p>
|
||||||
<p>Two choices sit above the buttons. <em>You play</em> picks your side, and Gold moves first. <em>The Sylphs</em> picks where the six Sylphs start. As Gygax printed it, Gold's Dragon can take an undefended Sylph on the very first move, which a reader of the Chess Variant Pages took for a misprint. The shifted setup moves the Sylphs one file over, out of reach. Beneath the buttons, <em>your games</em> lists every table this browser holds a seat at and whose move it is.</p>
|
<p>Two choices sit above the buttons. <em>You play</em> picks your side, and Gold moves first. <em>The Sylphs</em> picks where the six Sylphs start. As Gygax printed it, Gold's Dragon can take an undefended Sylph on the very first move, which a reader of the Chess Variant Pages took for a misprint. The shifted setup moves the Sylphs one file over, out of reach. <em>Your games</em>, which comes first once you have one, lists every table this browser holds a seat at and whose move it is.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="table">
|
<section id="table">
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<h2>The boards</h2>
|
<h2>The boards</h2>
|
||||||
<figure><img src="/guide/boards.png" alt="The three boards one above the other in the opening position, the air in blue and white, the land in green and amber, the underworld in red and brown" loading="lazy" /></figure>
|
<figure><img src="/guide/boards.png" alt="The three boards one above the other in the opening position, the air in blue and white, the land in green and amber, the underworld in red and brown" loading="lazy" /></figure>
|
||||||
<p>The three boards stand one above the other, the air at the top, the land in the middle and the underworld at the bottom, in the colours Gygax suggested. Your side is nearest you on every board. Each piece is a disc bearing the initial Gygax gave it, with Dr for the Dragon so it is not mistaken for a Dwarf. The square names along the edges are his too, and you can hide them under <em>preferences</em>. A square is named by its board, its file and its rank, so 3b4 is the air, file b, rank 4, and 1g1 is the underworld beneath Gold's King. Each board's title carries its number.</p>
|
<p>The three boards stand one above the other, the air at the top, the land in the middle and the underworld at the bottom, in the colours Gygax suggested. Your side is nearest you on every board. Each piece is a disc bearing the initial Gygax gave it, with Dr for the Dragon so it is not mistaken for a Dwarf. The square names along the edges are his too, and you can hide them under <em>preferences</em>. A square is named by its board, its file and its rank, so 3b4 is the air, file b, rank 4, and 1g1 is the underworld beneath Gold's King. Each board's title carries its number.</p>
|
||||||
<figure><img src="/guide/panel.png" alt="The panel for Scarlet's Dragon: where it stands and what it can take, what it is, how it moves, why it is in the game, and how to play it" loading="lazy" /></figure>
|
<figure><img src="/guide/panel.png" alt="The panel for Scarlet's Dragon, with where it stands and what it can take, what it is, how it moves, why it is in the game, and how to play it" loading="lazy" /></figure>
|
||||||
<p>Tap any piece, yours or the other side's, and a panel opens beside the boards, or rises from the foot of the screen on a phone, saying what the piece is, where it stands and what it can reach from there, how it moves, why Gygax put it in the game, and how to play it. Dotted squares show where a piece that is not yours to move could go. Tap the piece again, or the cross, to close it.</p>
|
<p>Tap any piece, yours or the other side's, and a panel opens beside the boards, or rises from the foot of the screen on a phone, saying what the piece is, where it stands and what it can reach from there, how it moves, why Gygax put it in the game, and how to play it. Dotted squares show where a piece that is not yours to move could go. Tap the piece again, or the cross, to close it.</p>
|
||||||
<p>The strip above the boards says whose move it is and warns when a King is in check. The chronicle beside the boards tells each move and what came of it, who took what, whose gaze fell on whom, and when a King is in check, with Gygax's notation beside each move. Click a move to see it on the boards, and click again, or make your move, to come back to now.</p>
|
<p>The strip above the boards says whose move it is and warns when a King is in check. The chronicle beside the boards tells each move and what came of it, who took what, whose gaze fell on whom, and when a King is in check, with Gygax's notation beside each move. Click a move to see it on the boards, and click again, or make your move, to come back to now.</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
|
|
||||||
<section id="first">
|
<section id="first">
|
||||||
<h2>A first game</h2>
|
<h2>A first game</h2>
|
||||||
|
<p>Before that, <a href="/learn">learn the pieces</a>, fifteen short scenes, one a piece, each a small position with a move for you to make and a word on what came of it, and then <a href="/learn/game">a game told</a>, twenty-seven moves stepped through with a callout wherever there is something to see.</p>
|
||||||
<p>Play a drake first. It plays a fair game and never minds how long you take. Begin as you would at chess, with Warriors stepping forward and the knights of the land, the Unicorns and Heroes, coming out behind them. Gygax's own advice is to hold the Mage and the Paladin back until the lesser pieces are developed, and to keep your Dragon home. It starts directly above your King for a reason. A Dragon that flies off to raid leaves the King open to the other Dragon, which strikes the land from the air without ever coming down.</p>
|
<p>Play a drake first. It plays a fair game and never minds how long you take. Begin as you would at chess, with Warriors stepping forward and the knights of the land, the Unicorns and Heroes, coming out behind them. Gygax's own advice is to hold the Mage and the Paladin back until the lesser pieces are developed, and to keep your Dragon home. It starts directly above your King for a reason. A Dragon that flies off to raid leaves the King open to the other Dragon, which strikes the land from the air without ever coming down.</p>
|
||||||
<p>Watch the Basilisks. Anything you leave on the land directly above an enemy Basilisk is frozen on the spot, and a frozen piece may as well be off the board. The Cleric is stronger than it looks, since it is the only piece that plays the same on all three boards, and the Dwarves, if you do not hurry them forward, make good guards late in the game.</p>
|
<p>Watch the Basilisks. Anything you leave on the land directly above an enemy Basilisk is frozen on the spot, and a frozen piece may as well be off the board. The Cleric is stronger than it looks, since it is the only piece that plays the same on all three boards, and the Dwarves, if you do not hurry them forward, make good guards late in the game.</p>
|
||||||
<p>The full rules, piece by piece, are on the <a href={withRoom('/rules')}>rules page</a>, with Gygax's article beneath them. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>, or use <em>Report</em> in the masthead at the table, which pins what you saw to the move it happened on. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
<p>The full rules, piece by piece, are on the <a href={withRoom('/rules')}>rules page</a>, with Gygax's article beneath them. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>, or use <em>Report</em> in the masthead at the table, which pins what you saw to the move it happened on. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let reporting = $state(false);
|
let reporting = $state(false);
|
||||||
|
let tableOpen = $state(false);
|
||||||
let prefsOpen = $state(false);
|
let prefsOpen = $state(false);
|
||||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||||
let rematching = $state(false);
|
|
||||||
let seatsOpen = $state(false);
|
let seatsOpen = $state(false);
|
||||||
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
||||||
let seatLost = $state('');
|
let seatLost = $state('');
|
||||||
@@ -30,6 +30,16 @@
|
|||||||
|
|
||||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||||
const watching = $derived(room?.spectating ?? false);
|
const watching = $derived(room?.spectating ?? false);
|
||||||
|
/** The tab's title: whose move it is, or how the game stands, so a glance at the tab bar says whether it is your turn. */
|
||||||
|
const title = $derived.by(() => {
|
||||||
|
const g = room?.state;
|
||||||
|
if (!room || !g) return `table ${roomId}`;
|
||||||
|
const seat = g.seats.find((id) => g.players[id].side === g.toMove);
|
||||||
|
const toMove = seat ? g.players[seat].name : g.toMove;
|
||||||
|
if (g.over) return g.over.winner === null ? 'a draw' : g.over.winner === room.me ? 'you win' : `${g.players[g.over.winner].name} wins`;
|
||||||
|
if (watching) return `${room.others.map((s) => s.name).join(' and ')}, ${toMove} to move`;
|
||||||
|
return seat === room.me ? 'your move' : `${toMove} to move`;
|
||||||
|
});
|
||||||
|
|
||||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
||||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
||||||
@@ -143,22 +153,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call for a rematch, or take the seat held at one already called. */
|
|
||||||
async function rematch() {
|
|
||||||
if (!room) return;
|
|
||||||
rematching = true;
|
|
||||||
try {
|
|
||||||
await goto(`/join/${await room.rematch()}`);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'The rematch could not be called.';
|
|
||||||
} finally {
|
|
||||||
rematching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Wyrm: a game</title>
|
<title>Wyrm: {title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
@@ -180,6 +178,10 @@
|
|||||||
{#if room.view.audience > 0}
|
{#if room.view.audience > 0}
|
||||||
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
|
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="quiet more" aria-expanded={tableOpen} onclick={() => (tableOpen = !tableOpen)}>Table {tableOpen ? '▴' : '▾'}</button>
|
||||||
|
<div class="rest">
|
||||||
|
{#if room}
|
||||||
{#if !watching}
|
{#if !watching}
|
||||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -194,6 +196,7 @@
|
|||||||
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
|
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
|
||||||
{/if}
|
{/if}
|
||||||
<a class="quiet" href="/">The hall</a>
|
<a class="quiet" href="/">The hall</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -211,18 +214,6 @@
|
|||||||
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if room?.view.over && !watching}
|
|
||||||
<p class="notice muted">
|
|
||||||
{#if room.view.rematch}
|
|
||||||
{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}.
|
|
||||||
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
|
|
||||||
{:else}
|
|
||||||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
|
|
||||||
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if joining && room?.seatEmpty}
|
{#if joining && room?.seatEmpty}
|
||||||
<section class="seat">
|
<section class="seat">
|
||||||
<p class="eyebrow">room {roomId}</p>
|
<p class="eyebrow">room {roomId}</p>
|
||||||
@@ -294,11 +285,44 @@
|
|||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.masthead > div {
|
||||||
|
flex: 1 1 16em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The buttons wrap onto a second row before they push the title's column aside or run off the page. */
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-shrink: 0;
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the masthead keeps the title, who is playing and the room code; the rest waits behind one button. */
|
||||||
|
.more {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.more {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more[aria-expanded='true'] + .rest {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet.room b {
|
.quiet.room b {
|
||||||
@@ -379,6 +403,16 @@
|
|||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.masthead {
|
.masthead {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem var(--gutter) 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead > div {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst {
|
||||||
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<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';
|
||||||
|
import { SCENES, sceneCells } from '$lib/game/lessons';
|
||||||
|
import { tell } from '$lib/game/chronicle';
|
||||||
|
import { createGame, playOn, square, kindOf, inCheck, hasLegalMove, other, SIDE_NAMES, type Cell, type Move, type Side } from '$lib/game';
|
||||||
|
|
||||||
|
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
|
||||||
|
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]));
|
||||||
|
/** What the reader's move came to: the move told, and the scene's word on it. */
|
||||||
|
let outcome = $state<{ told: string; text: string; right: boolean } | null>(null);
|
||||||
|
let inspected = $state<number | null>(null);
|
||||||
|
|
||||||
|
function setScene(i: number) {
|
||||||
|
n = Math.max(0, Math.min(SCENES.length - 1, i));
|
||||||
|
cells = sceneCells(SCENES[n]);
|
||||||
|
outcome = null;
|
||||||
|
inspected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moved(from: number, to: number) {
|
||||||
|
const right = scene.moves.some(([a, b]) => square(a) === from && square(b) === to);
|
||||||
|
const played = playOn(cells, from, to);
|
||||||
|
const move: Move = { by: scene.side, from, to, kind: kindOf(cells[from]), captured: played.captured, afar: played.afar, promoted: played.promoted, check: inCheck(played.cells, other(scene.side)) };
|
||||||
|
const owner = (s: Side) => (s === scene.side ? 'Your' : `${SIDE_NAMES[s]}’s`);
|
||||||
|
let told = tell(cells, move, owner);
|
||||||
|
const mated = move.check && !hasLegalMove(played.cells, other(scene.side), 'printed');
|
||||||
|
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. */
|
||||||
|
const g = $derived.by(() => {
|
||||||
|
const s = createGame({ A: scene.side === 'gold' ? 'You' : SIDE_NAMES.gold, B: scene.side === 'scarlet' ? 'You' : SIDE_NAMES.scarlet });
|
||||||
|
s.cells = cells;
|
||||||
|
s.toMove = scene.side;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wyrm: learn the pieces</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="reading wide">
|
||||||
|
<header>
|
||||||
|
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
|
||||||
|
<h1>Learn the pieces</h1>
|
||||||
|
<p class="lede">Fifteen pieces, one scene each, and a checkmate to finish. Every scene is a small position with something to see and one move to make. Tap any piece to read about it, as at the table. When the scenes are done, <a href="/learn/game">a game told</a> shows the pieces working together.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="scenes" aria-label="Scenes">
|
||||||
|
{#each SCENES as s, i (s.id)}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="stage">
|
||||||
|
<div class="boards">
|
||||||
|
<LessonBoard {g} levels={scene.levels} locked={outcome !== null} onMove={moved} bind:inspected />
|
||||||
|
</div>
|
||||||
|
<div class="words">
|
||||||
|
<p class="eyebrow">Scene {n + 1} of {SCENES.length}</p>
|
||||||
|
<h2>{scene.title}</h2>
|
||||||
|
<p>{scene.before}</p>
|
||||||
|
{#if outcome}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="task">{scene.task}</p>
|
||||||
|
<div class="ways">
|
||||||
|
{#if n > 0}<button type="button" class="quiet" onclick={() => setScene(n - 1)}>Back</button>{/if}
|
||||||
|
{#if n < SCENES.length - 1}<button type="button" class="quiet" onclick={() => setScene(n + 1)}>Skip</button>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if inspected !== null && cells[inspected] !== ''}
|
||||||
|
<div class="panel">
|
||||||
|
<PiecePanel {g} at={inspected} mySide={scene.side} close={() => (inspected = null)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scenes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 1rem 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-tab {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-tab.current {
|
||||||
|
border-color: var(--ember);
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-tab.done {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-tab.done.current {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
color: var(--frost);
|
||||||
|
margin-right: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||||
|
gap: 1.5rem clamp(1.5rem, 4vw, 3.5rem);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stage {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the words come first, so the reader knows what to look for before the boards. */
|
||||||
|
.words {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.words h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words p + p {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
color: var(--bone);
|
||||||
|
border-left: 2px solid var(--ember);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.told {
|
||||||
|
color: var(--bone);
|
||||||
|
border-left: 2px solid var(--frost);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.told.right {
|
||||||
|
border-color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.8rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways .commit {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A game told: the boards, one move at a time, each move as the chronicle
|
||||||
|
// tells it, and a callout where there is something to see.
|
||||||
|
import BoardArtwork from '$lib/components/BoardArtwork.svelte';
|
||||||
|
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, 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);
|
||||||
|
let ply = $state(0);
|
||||||
|
const g = $derived(states[ply]);
|
||||||
|
const move = $derived(ply > 0 ? last.moves[ply - 1] : null);
|
||||||
|
const telling = $derived(ply > 0 ? tellings[ply - 1] : null);
|
||||||
|
const callout = $derived(CALLOUTS[ply] ?? '');
|
||||||
|
const shownSquares = $derived(move ? (move.afar ? [move.from] : [move.from, move.to]) : []);
|
||||||
|
const shownCaptured = $derived(move?.captured ? [move.to] : []);
|
||||||
|
const checkSquare = $derived(!g.over && ply > 0 && inCheck(g.cells, g.toMove) ? kingSquare(g.cells, g.toMove) : move?.check ? kingSquare(g.cells, g.toMove) : -1);
|
||||||
|
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);
|
||||||
|
const reach = $derived.by(() => {
|
||||||
|
if (inspected === null || g.cells[inspected] === '' || g.over) return [];
|
||||||
|
return sideOf(g.cells[inspected]) === g.toMove ? legalMovesFrom(g.cells, inspected, g.setup) : movesFrom(g.cells, inspected, g.setup);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
else if (e.key === 'ArrowLeft') go(ply - 1);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Wyrm: a game told</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={key} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p class="lede">Twenty-seven moves, written to show what a first game should see. A Dragon leaves its roost too soon and a Hero kills it, a Basilisk walks slowly under a piece that came too deep and freezes it, a Griffon swoops, and a Dragon gives checkmate from the air. Step through it with the buttons or the arrow keys, and tap any piece to see where it could go next and to read about it.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stage">
|
||||||
|
<div class="boards">
|
||||||
|
<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>
|
||||||
|
{#if telling}
|
||||||
|
<ul class="told telling">
|
||||||
|
{#each telling.lines as l, i (i)}<li class={l.kind}>{l.text}</li>{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if callout}<p class="callout">{callout}</p>{/if}
|
||||||
|
<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:
|
||||||
|
{#each marks as n (n)}<button type="button" class="link plain mark" class:current={ply === n} onclick={() => go(n)}>{n}</button>{/each}
|
||||||
|
</p>
|
||||||
|
{#if inspected !== null && g.cells[inspected] !== ''}
|
||||||
|
<div class="panel">
|
||||||
|
<PiecePanel {g} at={inspected} mySide={null} close={() => (inspected = null)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lede {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||||
|
gap: 1.5rem clamp(1.5rem, 4vw, 3.5rem);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.where {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stage {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.boards {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words {
|
||||||
|
position: sticky;
|
||||||
|
top: 0.5rem;
|
||||||
|
max-height: calc(100vh - 1rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.told {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-left: 2px solid var(--frost);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
color: var(--bone);
|
||||||
|
border-left: 2px solid var(--ember);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.8rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways .quiet {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marks {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark.current {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<section id="between">
|
<section id="between">
|
||||||
<h2>Between the boards</h2>
|
<h2>Between the boards</h2>
|
||||||
<p>
|
<p>
|
||||||
Five kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself.
|
Nine kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 359 KiB |
Reference in new issue
Block a user