7 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 6e49fc9020 Credibility pass: comments say what the code can't, and the seams are gone
Engine: pit-rim exits live in one helper (pitRimExits) shared by the
resolver and the automaton; chargeReach names the six-stride cap; dust
sight is inDust + dustAtEnd; PushPending is exported once; the force-field
counter builds its events in one list; drawUnderSlowDeath says what it
draws under. Test groups are named for the rule they pin, not the
revision that introduced it.

Client: one smoothstep; one edgeScar per battle-scarred edge; one
board-zone rule (which also seats the caption strip on phones); the
compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery
replaces two hand-rolled matchMedia listeners; sprites carry their sizes
as plain numbers and their CSS in one rule each; canvas helpers are
formatted like the rest of the tree. Comments that told how a cel or a
fallback used to look now say what it draws. The actions-over notice no
longer blames a pickup when slime ended the turn.

Server and ops: dataRoot() is the one data directory; headlineOf builds
its deeds without a cast; the rollup and skills read the same way the
code behaves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 00:21:34 -04:00
Eric WagonerandClaude Fable 5.1 85690f0e23 Rev 21: LIFESAVER saves, and FORCE FIELD stands for the turn
An audit of every card in the deck, prompted by the Alter Ego, found
two more whose cast worked but whose promise was never kept.
LIFESAVER — "immune to the effects of losing both of your treasures"
— was cast and remembered and never asked where a wizard is
eliminated for exactly that; twenty-three recorded casts never
mattered. FORCE FIELD stopped the spell and vanished, though the card
keeps it standing until the opponent's turn ends, barring them from
entering its caster's square or casting on or past them. Both are
honored from rev 21, the field on every side where the card says one.
Older games replay as they were played. Tests pin both readings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:57:38 -04:00
Eric WagonerandClaude Fable 5.1 10d5c5815d The Alter Ego casts from its own square; the first-stride cue leaves the maze
Randy raised his double, selected it, and could cast nothing through
it: the card's whole point — "it may use any of the spells in your
hand, and you need not be in its L.O.S." — had never been wired. A
cast may now name the double, and the double's square becomes the
spell's origin: sight, reach, the launch point the reel films, and
the direction a knockback or rout pushes. The hand, the attack, and
every answer to it stay the caster's. Spells that move the caster's
own body — a goat's charge, a swap — do not travel through it. On the
board, the double's sight lights the eligible squares; selecting the
double and then a card aims from where it stands. A test hides the
caster and fires through the double.

The first-stride caption sat on the maze and hid the walls beside the
wizard; it now stands above the board with a color chip for "you."

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:38:58 -04:00
Eric WagonerandClaude Fable 5.1 5f58a0955a A notice taller than the screen scrolls inside its frame
Preferences on a phone ran off the bottom with no way to reach the
rest. Every notice now caps at the window's height and scrolls within.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:29:18 -04:00
Eric WagonerandClaude Fable 5.1 ff35e8ef8e The table fits the window: the hand stays in reach, the maze takes the height
On a desktop the game grid is now exactly the window's height. The
board and the paper rail share the top row, the rail scrolling in
place, and the hand with its End turn sit pinned beneath — nothing a
first turn needs is ever below the fold. The board's window fills
whatever height the row allows instead of a fixed subtraction. A
wide, short window — a laptop in landscape — stands the hand beside
the board on its own until the player chooses a side, since a tall
maze under a bottom hand left a postage stamp. A short window shrinks
the bottom hand's cards a little. The eyes toggle floats over the
board's corner so it costs no height, and the masthead's "invite
friends" hides on phones where it wrapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:19:59 -04:00
Eric WagonerandClaude Fable 5.1 4cc0423846 The first minute, for a stranger: what the game is, what to do, and how to join
The landing says the pitch before it asks for a name — steal treasure,
blast through walls, outwit your friends — and states the goal. The
coach says "select a card, then choose its target," which is what the
cards actually want. The turn banner counts treasures home beside the
moves left. The council speaks once when its three voices agree. A
friend arriving on an invite link meets "Join Eric's game," a name
field, and a seat, with the gallery an afterthought; the masthead
says "invite friends" beside the room code. The first-person view has
a toggle beside the board instead of a checkbox in preferences. How
to play opens with winning. And a shared moment's card is titled by
the turn's story — the treasure carried home, the blow that landed,
the spell cast — instead of whose turn it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:03:17 -04:00
Eric WagonerandClaude Fable 5.1 a071494f4a Reduced motion is honored everywhere the table moves
One shared motion policy: the OS's reduce-motion setting, read live
through Svelte's own media query, folds into the flourishes switch.
Under it the board plays no flourishes, the reel skips its die
interlude and slate wipes, bodies jump between squares instead of
gliding, the canvas holds its ambient shimmers still, and the camera
cuts wherever the director would have panned — in the cockpit as in
the reel, so the eye still lands on what the director chose to watch.
Timers and animation frames are cleared when the policy flips or the
pane closes. A first turn and two reels stepped clean under the
setting with no errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 22:42:08 -04:00
43 changed files with 795 additions and 428 deletions
+12 -15
View File
@@ -34,26 +34,23 @@ quiet, say so in one line before the details.
written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no
addresses): the last 7 rows give requests, human vs bot addresses, addresses): the last 7 rows give requests, human vs bot addresses,
socket connects, path mix (/, /clips, /watch, /ws), external socket connects, path mix (/, /clips, /watch, /ws), external
referrers (reddit.com, boardgamegeek.com after the 2026-09-03 referrers, new rooms, human seats and names, reports/replies,
announcement), new rooms, human seats and names, reports/replies,
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors, and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
serviceStarts, ledgerMB). Report day-over-day direction, not raw serviceStarts, ledgerMB). Report day-over-day direction, not raw
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed
check-in there means the rollup itself is broken). Re-roll a day by check-in there means the rollup itself is broken). Re-roll a day by
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
(one per address per day) whose link carried `?ref=<name>` — the tags (one per address per day) whose link carried a tag the rollup header
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers names (`?ref=<name>`, a utm_source, Facebook's fbclid); referrers
alone can't attribute Reddit or BGG, which send none. alone can't attribute Reddit or BGG, which send none.
**Right now** (on request, or when the announcement is fresh): live **Right now** (on request): `wizwar-visitors.sh` prints it — live
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`, sockets, rooms touched in the last hour, and today's partial row —
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`, see /wizwar-visitors.
and today so far `wizwar-rollup.sh $(date -u +%F)` (a partial row,
replaced again at 00:10; the vitals in it are the moment's).
5. **The table itself**: 5. **The table itself**:
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l` - Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
- New human names this week: room ledgers' join lines minus bots - New human names this week: the rollup rows' `humanNames` (host and
(Automaton*) and known names, on rooms created in the last 7 days joiners alike, bots excluded) minus the regulars the visitors skill
(meta line `createdAt`) lists
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl — - Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
if any, offer to run the reports desk (`/wizwar-reports`) if any, offer to run the reports desk (`/wizwar-reports`)
- Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of - Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of
@@ -71,9 +68,9 @@ action, or "nothing needs you."
- The service restarts cleanly (ledgers replay); restarts in the journal - The service restarts cleanly (ledgers replay); restarts in the journal
during deploys are normal, not incidents. during deploys are normal, not incidents.
- Room memory is evicted after idle (finished 30 min, others 24 h); - Room memory is evicted after idle (finished 30 min, others 24 h), each
boot-restores load everything then shed, so post-deploy memory spikes sleeper leaving a stub on disk; a boot replays only rooms whose stub is
settle within the hour. stale and leaves the rest asleep, so a deploy costs no memory spike.
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
requires it; a reboot there is maintenance, not an outage. requires it; a reboot there is maintenance, not an outage.
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating, - Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
+4 -3
View File
@@ -19,8 +19,9 @@ first, so the traffic line is current to the minute. Its output:
"right now" line. Two sockets at a quiet hour is Eric plus one. "right now" line. Two sockets at a quiet hour is Eric plus one.
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`: - **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
addresses of people (bots split out), front-page vs gallery vs addresses of people (bots split out), front-page vs gallery vs
replay views, campaigns (`?ref=` tags on posted links, Facebook's replay views, campaigns (the tags a link carries: `?ref=`, a
fbclid) and referrers. Reddit and BGG send no referrer. utm_source, Facebook's fbclid) and referrers. Reddit and BGG send no
referrer.
- **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. Known regulars: NEW means the name's first room ever is today. Known regulars:
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat). Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat).
@@ -37,7 +38,7 @@ Lead with new names and whether anyone is at the table now. Then, for
each stranger, say how far they got and where they stopped — the last each stranger, say how far they got and where they stopped — the last
human move and the span tell it: a room that is "lobby only" stopped human move and the span tell it: a room that is "lobby only" stopped
before the start button; a game of under five minutes ending on before the start button; a game of under five minutes ending on
pickUpTreasure is the treasure wall the slips now explain; a long span pickUpTreasure is the treasure wall (the in-game slips explain it); a long span
with many moves is a real session, resumable from their lobby. Quote with many moves is a real session, resumable from their lobby. Quote
human chat verbatim. Then the traffic line as a single sentence with human chat verbatim. Then the traffic line as a single sentence with
the day-over-day direction if a previous row exists. the day-over-day direction if a previous row exists.
+1
View File
@@ -1,2 +1,3 @@
# Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh. # Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh.
# The time is also SCHEDULE in wizwar-rollup.sh, which tells Sentry to expect it; change both.
10 0 * * * root /usr/local/bin/wizwar-rollup.sh 10 0 * * * root /usr/local/bin/wizwar-rollup.sh
+16 -10
View File
@@ -4,22 +4,26 @@
# the room ledgers, stats.json and feedback.jsonl), and the box's vitals. # the room ledgers, stats.json and feedback.jsonl), and the box's vitals.
# Counts only: no addresses are kept. The pulse reads it for trends the # Counts only: no addresses are kept. The pulse reads it for trends the
# self-rotating access log cannot keep. Referrers are what browsers send # self-rotating access log cannot keep. Referrers are what browsers send
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links # (Reddit and BGG send none); campaigns are the tags a link itself carries
# Eric posts, one count per address per day, plus Facebook's fbclid. # — ?ref=<name> on a link Eric posts, a utm_source, or Facebook's fbclid
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both). # one count per address per day.
# Cron: see SCHEDULE below and /etc/cron.d/wizwar-rollup (deploy.sh installs both).
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL # Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
# (absent = no check-ins). # (absent = no check-ins).
set -u set -u
OUT="/var/lib/wizwar/rollup.jsonl" OUT="/var/lib/wizwar/rollup.jsonl"
LOG="/var/log/wizwar/rollup.log" LOG="/var/log/wizwar/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
# The crontab line, as /etc/cron.d/wizwar-rollup spells it: the Sentry
# monitor is told the same shape, so a missed night is noticed.
SCHEDULE="10 0 * * *"
CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true) CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true)
# The check-in carries the schedule: Sentry only keeps a monitor it has # The check-in carries the schedule: Sentry only keeps a monitor it has
# been told the shape of, and a bare ping to an unknown slug is accepted # been told the shape of, and a bare ping to an unknown slug is accepted
# and dropped without a word. # and dropped without a word.
checkin() { checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \ [ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"10 0 * * *\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \ -d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true "$CRON_URL" || true
} }
@@ -31,7 +35,9 @@ d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless") BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless")
# --- traffic: every access log file touched since the day began --- # --- traffic: every access log file touched since the day began. Address
# and request counts split people from bots; the path mix and referrers
# count people only. ---
req = 0; human = 0; bot = 0; hips = set(); bips = set() req = 0; human = 0; bot = 0; hips = set(); bips = set()
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0 paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0
# Where a visit came from, when the link itself says so: ?ref=<name> on a # Where a visit came from, when the link itself says so: ?ref=<name> on a
@@ -63,15 +69,15 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch") key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch")
else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri) else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri)
else uri if uri in ("/", "/ws", "/robots.txt") else "(other)") else uri if uri in ("/", "/ws", "/robots.txt") else "(other)")
if not isbot: paths[key] += 1 # the path mix is what people looked at if not isbot: paths[key] += 1
status[str(r.get("status", 0))[0] + "xx"] += 1 status[str(r.get("status", 0))[0] + "xx"] += 1
ref = " ".join(h.get("Referer", [""])) ref = " ".join(h.get("Referer", [""]))
m = re.match(r"https?://([^/]+)", ref) m = re.match(r"https?://([^/]+)", ref)
host = m.group(1) if m else "" host = m.group(1) if m else ""
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host
# A referrer on /.env is a scanner's costume (a forged google.com), # Scanners forge referrers on paths like /.env, and the app answers
# not a reader — and the app answers unknown paths with the page, # unknown paths with the page itself, so the status cannot tell a
# so the status cannot tell them apart. Only the pages count. # probe from a reader. Only the pages count.
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1 if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
# --- the table --- # --- the table ---
@@ -107,7 +113,7 @@ except OSError: pass
# --- vitals (now, at rollup time) --- # --- vitals (now, at rollup time) ---
def sh(cmd): def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip() try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception: return "" except Exception: return "?"
mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value") mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value")
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%") disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
load = sh("cut -d' ' -f1 /proc/loadavg") load = sh("cut -d' ' -f1 /proc/loadavg")
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
DAY="${1:-$(date -u +%F)}" DAY="${1:-$(date -u +%F)}"
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1 /usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
python3 - "$DAY" <<'PY' python3 - "$DAY" <<'PY'
import json, sys, glob, os, datetime, subprocess import json, sys, glob, datetime, subprocess
day = sys.argv[1] day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day)) print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
+13 -25
View File
@@ -7,7 +7,7 @@
// BERSERKER for blood, the WORRIER for the shadows between the two. // BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side, pitRimExits } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game"; import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -584,31 +584,19 @@ function wallBlastTarget(
return { cell: best.cell, side: best.side }; return { cell: best.cell, side: best.side };
} }
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first, a warp mouth on the rim only when
* no square offers — the one reading legal under every deckRev. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const { floor, mouths } = pitRimExits(view.board, pit, dir, (c) => view.squareContents[cellKey(c)]?.kind === "stone");
return floor.length > 0 ? floor : mouths;
}
/** /**
* BFS over walkable steps toward the nearest goal. Doors count as passable * BFS over walkable steps toward the nearest goal. Doors count as passable
* when the clockwork can unlock them; the first such door is reported so the * when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot. * key gets used before the boot.
*/ */
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
* — the maze's own test: some side other than the way back with a cell
* there, no wall between, and no stone on it. */
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first; a warp mouth on the rim only when
* no square offers, the reading every revision accepts. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const footing = (d: Side) => {
if (d === opposite(dir)) return null;
const t = stepTarget(view.board, pit, d);
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t.kind;
};
const floor = SIDES.filter((d) => footing(d) === "step");
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
}
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
return pitExits(view, pit, dir).length > 0;
}
export function pathToward( export function pathToward(
view: GameView, view: GameView,
@@ -634,12 +622,12 @@ export function pathToward(
const { to, viaDoor } = step; const { to, viaDoor } = step;
const k = cellKey(to); const k = cellKey(to);
if (seen.has(k)) continue; if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (hazard === "stone") continue;
// A pit is entered by leaping to the square beyond it in the same // A pit is entered by leaping to the square beyond it in the same
// direction; with nothing to land on, the maze bounces the leaper // direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road. // back and charges the stride. Goal or waypoint, that is no road.
if (hazard === "pit" && !pitLandable(view, to, dir)) continue; if (hazard === "pit" && pitExits(view, to, dir).length === 0) continue;
// Out of a pit only by one of its exits. // Out of a pit only by one of its exits.
if (view.squareContents[cellKey(c)]?.kind === "pit") { if (view.squareContents[cellKey(c)]?.kind === "pit") {
const entry = cameBy.get(cellKey(c))?.dir; const entry = cameBy.get(cellKey(c))?.dir;
@@ -766,7 +754,7 @@ function overLimit(view: GameView): number {
* clogged hand never refreshes; only cards the brain has no play for are * clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */ * shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const draw = drawUnderCurses(view, tier.draw); const draw = drawUnderSlowDeath(view, tier.draw);
const deficit = draw - (handLimitOf(view) - view.yourHand.length); const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) { if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand] const shed = [...view.yourHand]
@@ -782,7 +770,7 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what /** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
* it can afford, keeping four life in hand, and nothing at all when it * it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */ * cannot — a bare hand beats a bare grave. */
export function drawUnderCurses(view: GameView, draw: number): number { export function drawUnderSlowDeath(view: GameView, draw: number): number {
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed); const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw; if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4)); return Math.max(0, Math.min(draw, me(view).life - 4));
+18
View File
@@ -253,6 +253,24 @@ export function stepTarget(
return { kind: "blocked", by: "wall" }; return { kind: "blocked", by: "wall" };
} }
/** The sides a walker may leave a pit by, having entered it heading
* `entry`: the neighbouring squares that offer footing, and the warp
* mouths on the rim, the way back excluded. `filled` names the squares
* nothing can land on. */
export function pitRimExits(
board: AssembledBoard, pit: Cell, entry: Side, filled: (c: Cell) => boolean,
): { floor: Side[]; mouths: Side[] } {
const floor: Side[] = [];
const mouths: Side[] = [];
for (const d of SIDES) {
if (d === opposite(entry)) continue;
const t = stepTarget(board, pit, d);
if (t.kind === "blocked" || filled(t.to)) continue;
(t.kind === "step" ? floor : mouths).push(d);
}
return { floor, mouths };
}
/** /**
* Line of sight from the center of `from` to the center of `to`, blocked by * Line of sight from the center of `from` to the center of `to`, blocked by
* wall/door/firewall edges the segment crosses and by any `blockedCells` * wall/door/firewall edges the segment crosses and by any `blockedCells`
+123 -71
View File
@@ -27,7 +27,7 @@ import {
bentSightThroughGap, bentSightThroughGap,
neighbor, neighbor,
stepTarget, stepTarget,
opposite, opposite, pitRimExits,
} from "./board"; } from "./board";
import { import {
buildDeck, buildDeck,
@@ -80,6 +80,12 @@ export interface PlayerState {
} }
/** A duration spell in play. Expires at the START of the caster's turns. */ /** A duration spell in play. Expires at the START of the caster's turns. */
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
export interface PushPending {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
}
export interface SustainedEffect { export interface SustainedEffect {
id: string; id: string;
cardId: string; cardId: string;
@@ -212,6 +218,9 @@ export interface CastStack {
reflectedBase?: { damage: number; duration: number }; reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */ /** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw"; creatureTouch?: "wraith" | "claw";
/** Where the spell left from when not the attacker's own square: an
* ALTER EGO's. Knockbacks and routs push away from here. */
origin?: Cell;
/** A spell freed from slime: counteractions cannot touch its caster. */ /** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean; trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" the grab rides the stack /** STRENGTH's tear: "this would be an attack" the grab rides the stack
@@ -238,7 +247,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 20; export const CURRENT_RULES_REV = 21;
/** Every rulings revision since the baseline, newest last the entries a /** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house * game's deckRev freezes it before or after. Shown to players as the house
@@ -263,6 +272,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." }, { rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." }, { rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." }, { rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
{ rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." },
]; ];
export interface GameConfig { export interface GameConfig {
@@ -294,11 +304,7 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD: /** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */ * the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while pushPending: PushPending | null;
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions: /** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it, * the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */ * BLUNT halves it rounding up (table ruling). */
@@ -450,10 +456,24 @@ function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust"; return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
} }
/** A FORCE FIELD raised against `id` on the wizard standing at `cell`,
* if any (rev 21): they may not enter it, nor cast on or past it. */
function fieldAgainst(state: GameState, id: PlayerId, cell: Cell): boolean {
if ((state.config.deckRev ?? 1) < 21) return false;
const idx = state.players.findIndex((q) => q.id === id);
return state.players.some((h) => h.alive && h.id !== id && cellKey(h.position) === cellKey(cell) &&
sustainedOn(state, h.id, "force-field").some((f) => f.data.against === idx));
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
if (dustAtEnd(state, from, to)) return false; if (dustAtEnd(state, from, to)) return false;
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (viewerId) {
for (const h of state.players) {
if (h.alive && h.id !== viewerId && fieldAgainst(state, viewerId, h.position)) blockers[cellKey(h.position)] = true;
}
}
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false; if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
@@ -836,6 +856,10 @@ export type Command =
powerAttackPoints?: number; powerAttackPoints?: number;
target?: CastTarget; target?: CastTarget;
params?: CastParams; params?: CastParams;
/** ALTER EGO: the double's creature id, when the spell leaves from
* its square "it may use any of the spells in your hand, and you
* need not be in its L.O.S. to do so." */
via?: string;
} }
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
| { type: "cancelAmbush"; ambushId: string } | { type: "cancelAmbush"; ambushId: string }
@@ -903,6 +927,8 @@ interface Magnitude {
} }
interface ResolutionContext { interface ResolutionContext {
/** The square the blow came from: the attacker's, or their double's. */
origin: Cell;
state: GameState; state: GameState;
events: GameEvent[]; events: GameEvent[];
attacker: PlayerState; attacker: PlayerState;
@@ -1069,7 +1095,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0; const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return; if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock); knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin);
}, },
}, },
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 }, "sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
@@ -1114,7 +1140,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1; const n = ctx.stack.numberValue ?? 1;
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin);
ctx.defender.lostTurns++; ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
}, },
@@ -2377,11 +2403,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null; if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state); const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed; const legs = state.turn.movementAllowance - state.turn.movementUsed;
// Rev 16: the charge is measured as far as the legs reach. Before it, const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
// the search stopped at six steps and called anything past that
// unreachable — a goat with eight legs refused a seven-step ram.
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(state, caster.position, target.position, reach);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null; return null;
}, },
@@ -2389,8 +2411,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) { if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed; const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6; const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
if (d === 0) return; if (d === 0) return;
if (d > legs) { if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say): // The target slipped beyond the charge (a counter-teleport, say):
@@ -2879,10 +2900,8 @@ function waveForce(range: number, dist: number): number {
/** One line of a wave's sweep: the squares it reaches, nearest first, with /** One line of a wave's sweep: the squares it reaches, nearest first, with
* the force left at each. The victims are named before any is pushed * the force left at each. The victims are named before any is pushed
* (rev 20). Before it the sweep, walking the way it pushed, could find a * (rev 20); older games sweep square by square, so a wizard shoved along
* victim it had just shoved and shove them again with the force left: a * the sweep's own line is caught and shoved again. */
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
function sweepWave( function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string, state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void { ): void {
@@ -3700,9 +3719,16 @@ function attachSustained(
}); });
} }
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ /** How far a BUTT-HEAD charge is measured: as far as the goat's legs
/** Steps along real corridors from one cell to another, searched out to * reach (rev 16); older games search six steps and call the rest
* `limit` steps; beyond that the answer is Infinity. */ * unreachable. */
function chargeReach(state: GameState, legs: number): number {
return (state.config.deckRev ?? 1) >= 16 ? legs : 6;
}
/** Steps along real corridors from one cell to another (MENTAL FORCE's
* three moved spaces, a goat's charge), searched out to `limit` steps;
* beyond that the answer is Infinity. */
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number { export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0; if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state); const view = boardView(state);
@@ -4230,6 +4256,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // move ... bumping into a wall counts as one space of movement."
// A resumed push carries the direction its pushee chose: no stagger.
if (isBlinded(state, p) && shove === undefined) { if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!; direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
} }
@@ -4337,6 +4364,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// Square contents at the destination. // Square contents at the destination.
let content = state.squareContents[cellKey(p.position)]; let content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone"); if (content?.kind === "stone") return err("that square is solid stone");
if (fieldAgainst(state, p.id, p.position)) return err("a force field bars the way");
// BIG MAN: nobody enters his square — except a SHRUNK wizard, small // BIG MAN: nobody enters his square — except a SHRUNK wizard, small
// enough to slip between the giant's boots (table ruling). // enough to slip between the giant's boots (table ruling).
if (sustainedOn(state, p.id, "shrink").length === 0) { if (sustainedOn(state, p.id, "shrink").length === 0) {
@@ -4372,10 +4400,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// the other player gets to decide which direction to go." The // the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came; // pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride // one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only. // hangs until they do. Older games (deckRev < 15) shove straight
// A game dealt before rev 15 shoves straight ahead when it can, as it // ahead when that way is open; rounding a corner is allowed in every
// always did; the corner and the fork were refusals before, so every // game.
// game may round a corner now.
const fork = (state.config.deckRev ?? 1) >= 15; const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null; let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null); const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
@@ -4436,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
let landingWarp = false; let landingWarp = false;
if (ledge) { if (ledge) {
const pitCell = p.position; const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => { const { floor, mouths } = pitRimExits(view, pitCell, direction,
const t = stepTarget(view, pitCell, d); (c) => state.squareContents[cellKey(c)]?.kind === "stone");
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t;
};
const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
// A warp mouth on the rim is a way off it like any square, and may // A warp mouth on the rim is a way off it like any square, and may
// always be named. Only what a bare step does is a matter of // always be named. Only what a bare step does is a matter of
// revision: from rev 17 a square beside a mouth is a fork to name; // revision: from rev 17 a square beside a mouth is a fork to name;
// before it the step took the square, and takes it still, so the // before it a bare step takes the square.
// recorded games replay as they were played.
const exits = [...floor, ...mouths]; const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor; const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) { if (exits.length === 0) {
@@ -4464,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from; p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`); return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
} }
const foot = footing(landing)!; const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to; landingCell = foot.to;
landingWarp = foot.kind === "warp"; landingWarp = foot.kind === "warp";
} }
@@ -5243,6 +5263,19 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId); const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand"); if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId); const def = cardDef(inHand.cardId);
// ALTER EGO: "It may use any of the spells in your hand, and you need
// not be in its L.O.S. to do so." The double's square is where the
// spell leaves from — sight, reach, and the blow's direction are its —
// while the hand, the attack, and any answer to it stay the caster's.
// Spells that move the caster's own body do not travel through it.
let origin: Cell = caster.position;
if (cmd.via) {
const ego = state.creatures.find((c) => c.id === cmd.via);
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
if (!cmd.target) return err("your double casts only at a target");
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId]; const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`); if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") { if (effect.kind === "counter") {
@@ -5328,7 +5361,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration); attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events }; return { ok: true, state, events };
@@ -5342,12 +5375,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "creature") { if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature"); if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position) ? bentLos(state, caster, origin, creature.position)
: casterLos(state, caster, caster.position, creature.position))) { : casterLos(state, caster, origin, creature.position))) {
return err("no line of sight to the creature"); return err("no line of sight to the creature");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5362,7 +5395,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position, from: origin, target: null, targetCell: creature.position,
}]; }];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP). // "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") { if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
@@ -5383,7 +5416,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" && if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
// castSight carries the whole sight law: straight lines, the // castSight carries the whole sight law: straight lines, the
@@ -5405,7 +5438,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: { ...cell }, from: origin, target: null, targetCell: { ...cell },
}]; }];
const box = state.squareContents[cellKey(cell)]!; const box = state.squareContents[cellKey(cell)]!;
box.damage += dmg; box.damage += dmg;
@@ -5424,8 +5457,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell) ? bentLos(state, caster, origin, cell)
: casterLos(state, caster, caster.position, cell))) { : casterLos(state, caster, origin, cell))) {
return err("no line of sight to the slime"); return err("no line of sight to the slime");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5469,7 +5502,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (current !== "wall" && current !== "door") { if (current !== "wall" && current !== "door") {
return err("there is no wall or door on that edge to attack"); return err("there is no wall or door on that edge to attack");
} }
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { if (effect.sameSquare && !touchesEdge(origin, cell, side)) {
return err("you must stand beside the wall"); return err("you must stand beside the wall");
} }
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) { if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
@@ -5495,7 +5528,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events2: GameEvent[] = [...wandEvents, { const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell, from: origin, target: null, targetCell: cell,
}]; }];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId); const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem); if (problem) return err(problem);
@@ -5524,7 +5557,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
const queue = turnOrderFrom(state, caster.id).filter((id) => const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive); id !== caster.id && state.players.find((p) => p.id === id)!.alive);
@@ -5536,16 +5569,17 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player"); if (!target || !target.alive) return err("no such living player");
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
const statusBlock = attackBlockedByStatus(state, caster, target); const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock); if (statusBlock) return err(statusBlock);
if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside");
const preEvents: GameEvent[] = []; const preEvents: GameEvent[] = [];
if (effect.requiresLos) { if (effect.requiresLos) {
const sighted = mods.aroundCorner const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position) ? bentLos(state, caster, origin, target.position)
: casterLos(state, caster, caster.position, target.position); : casterLos(state, caster, origin, target.position);
if (!sighted) return err("no line of sight to the target"); if (!sighted) return err("no line of sight to the target");
} }
// Attacking someone breaks any BUDDY pact you swore to them. // Attacking someone breaks any BUDDY pact you swore to them.
@@ -5579,9 +5613,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// go intended distance" — if the die disagrees with the true direction, // go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates. // the spell hits whoever lies that way, or dissipates.
if (isBlinded(state, caster) && if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) { cellKey(target.position) !== cellKey(origin)) {
const dx = target.position.x - caster.position.x; const dx = target.position.x - origin.x;
const dy = target.position.y - caster.position.y; const dy = target.position.y - origin.y;
const intended: Side = const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng); const [roll, rngNext] = rollDie(state.rng);
@@ -5590,11 +5624,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (rolled !== intended) { if (rolled !== intended) {
const along = state.players.find((p) => { const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false; if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x; const px = p.position.x - origin.x;
const py = p.position.y - caster.position.y; const py = p.position.y - origin.y;
const dirOf: Side | null = const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null; Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position); return dirOf === rolled && casterLos(state, caster, origin, p.position);
}); });
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true; state.turn.attackUsed = true;
@@ -5616,11 +5650,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: along.id, waitingOn: along.id,
...(cmd.via ? { origin } : {}),
}; };
missEvents.push({ missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position, from: origin, target: along.id, targetCell: along.position,
}); });
return { ok: true, state, events: missEvents }; return { ok: true, state, events: missEvents };
} }
@@ -5642,6 +5677,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}), ...(mods.aroundCorner ? { bentCorner: true as const } : {}),
...(cmd.via ? { origin } : {}),
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents]; const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5654,7 +5690,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
amplifies: mods.amplifies.length, amplifies: mods.amplifies.length,
from: caster.position, from: origin,
target: target.id, target: target.id,
targetCell: target.position, targetCell: target.position,
}); });
@@ -5674,7 +5710,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
cardId: inHand.cardId, cardId: inHand.cardId,
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
from: caster.position, from: origin,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null, target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
}); });
@@ -6029,12 +6065,17 @@ function doCounteract(
// any, will be swapped" — the reflector's choice rides the counter. // any, will be swapped" — the reflector's choice rides the counter.
...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}), ...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}),
}); });
// Rev 21: FORCE FIELD stands after it stops the spell — "preventing an
// opponent from entering the space you occupy or casting spells on or
// past you. Lasts only until the end of the opponent's turn." The
// field faces every side, not one: this table's simplification.
const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }];
if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
const facing = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
}
stack.waitingOn = stack.attackerId; stack.waitingOn = stack.attackerId;
return { return { ok: true, state, events };
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
} }
if (playerId === stack.attackerId) { if (playerId === stack.attackerId) {
@@ -6291,6 +6332,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
effect.onResolved({ effect.onResolved({
state, events, state, events,
attacker: defender, defender: attacker, attacker: defender, defender: attacker,
origin: defender.position,
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0, damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } }, stack: { ...stack, params: { ...stack.params, cardId: chosen } },
}); });
@@ -6434,6 +6476,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
events, events,
attacker, attacker,
defender, defender,
origin: stack.origin ?? attacker.position,
damageDealt, damageDealt,
fullyStopped: pipe.fullyStopped, fullyStopped: pipe.fullyStopped,
reversed: pipe.reversed, reversed: pipe.reversed,
@@ -6472,11 +6515,12 @@ function goAwayRout(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
if (isLockedInPlace(state, defender.id)) return; if (isLockedInPlace(state, defender.id)) return;
const from = defender.position; const from = defender.position;
const dist = (c: Cell) => const dist = (c: Cell) =>
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y); Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y);
let heading: Side | null = null; let heading: Side | null = null;
let moved = 0; let moved = 0;
for (let i = 0; i < squares; i++) { for (let i = 0; i < squares; i++) {
@@ -6527,9 +6571,10 @@ function knockBack(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
const dx = defender.position.x - attacker.position.x; const dx = defender.position.x - origin.x;
const dy = defender.position.y - attacker.position.y; const dy = defender.position.y - origin.y;
let dir: Side | null = null; let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N"; else if (dy !== 0) dir = dy > 0 ? "S" : "N";
@@ -6790,7 +6835,11 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null; const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
return owner !== null && owner !== p.id && ownerPlayer?.alive === true; return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
}); });
if (lost) { // Rev 21: LIFESAVER — "immune to the effects of losing both of your
// treasures to other players' home bases." Older games eliminate its
// holder.
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
if (lost && !saved) {
p.alive = false; p.alive = false;
p.finalHand = [...p.hand]; p.finalHand = [...p.hand];
state.discard.push(...p.hand.splice(0)); state.discard.push(...p.hand.splice(0));
@@ -7120,6 +7169,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
} }
state.openSafes = []; state.openSafes = [];
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
const seat = state.players.indexOf(p);
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
events.push({ type: "turnEnded", player: p.id }); events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) { if (p.extraTurns > 0) {
+23 -19
View File
@@ -9,6 +9,7 @@ import {
LOS_BLOCKING_CONTENT, LOS_BLOCKING_CONTENT,
type AmbushState, type AmbushState,
type CastStack, type CastStack,
type PushPending,
type CreatureState, type CreatureState,
type GameState, type GameState,
type PlayerId, type PlayerId,
@@ -93,7 +94,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */ /** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"]; pushPending: PushPending | null;
slowDeathPending: { playerId: PlayerId; points: number } | null; slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */ /** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[]; yourAmbushes: AmbushState[];
@@ -220,20 +221,23 @@ export function fearCells(view: GameView): Set<string> {
* reflects what this player knows (illusion walls they believe in block it). * reflects what this player knows (illusion walls they believe in block it).
* The basis for the client's "dim the ineligible squares" targeting aid. * The basis for the client's "dim the ineligible squares" targeting aid.
*/ */
export function sightedCellsFor(view: GameView): Set<string> { export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = new Set<string>(); const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
// Sight is taken from the viewer's square unless a cast leaves from
// elsewhere — an ALTER EGO's — in which case it is the double's.
const eye = from ?? me.position;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19). // In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) { if (inDust(view, eye)) {
out.add(`${me.position.x},${me.position.y}`); out.add(`${eye.x},${eye.y}`);
return out; return out;
} }
for (const key of Object.keys(board.cells)) { for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue; if (dustAtEnd(view, eye, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
} }
// VISIONSTONE lets its bearer see through exactly one wall or door — // VISIONSTONE lets its bearer see through exactly one wall or door —
// any one — so a square is also sighted if removing a single edge // any one — so a square is also sighted if removing a single edge
@@ -248,7 +252,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
const opened = { ...board, edges }; const opened = { ...board, edges };
for (let i = unseen.length - 1; i >= 0; i--) { for (let i = unseen.length - 1; i >= 0; i--) {
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
if (sightBetween(opened, me.position, { x, y }, blockers)) { if (sightBetween(opened, eye, { x, y }, blockers)) {
out.add(unseen[i]!); out.add(unseen[i]!);
unseen.splice(i, 1); unseen.splice(i, 1);
} }
@@ -263,8 +267,8 @@ export function sightedCellsFor(view: GameView): Set<string> {
* plus every square visible from one of those — the caster looks to a middle * plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos. * cell and the spell turns there, mirroring the engine's bentLos.
*/ */
export function bentSightedCellsFor(view: GameView): Set<string> { export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = sightedCellsFor(view); const out = sightedCellsFor(view, from);
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
const mids = [...out].map((k) => { const mids = [...out].map((k) => {
@@ -330,15 +334,15 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace
return traceSight(board, from, to, blockers); return traceSight(board, from, to, blockers);
} }
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells; /** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
* a square sees itself still. `standing` asks only whether the viewer's function inDust(view: GameView, cell: Cell): boolean {
* own square is dust. */ return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean { }
if (view.deckRev < 19) return false;
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust"; /** Dust at either end kills a sight line; a square sees itself still. */
if (standing) return dust(from); function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
if (from.x === to.x && from.y === to.y) return false; if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to); return inDust(view, from) || inDust(view, to);
} }
/** /**
@@ -431,12 +435,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge. * aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything. * Null = this card's eligibility is not modeled; light everything.
*/ */
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null { export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return null; if (!me) return null;
const cells = Object.keys(view.board.cells); const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`; const key = (x: number, y: number) => `${x},${y}`;
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view); const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
if (CREATION_CARD_IDS.has(cardId)) { if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard, // emptySquareTarget: on the board, unoccupied by content, home, wizard,
+6 -6
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game"; } from "../src/game";
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers"; import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
@@ -1129,14 +1129,14 @@ describe("the clockwork under a curse, and before a pit", () => {
const state = botsTurn(); const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!; const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "bot"); const plain = viewFor(state, "bot");
expect(drawUnderCurses(plain, 2)).toBe(2); expect(drawUnderSlowDeath(plain, 2)).toBe(2);
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }); pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7; bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5; bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4; bot.life = 4;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0);
}); });
it("raises no SHADOW while bleeding, nor with little blood to spare", () => { it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
@@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => {
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) { for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage"); const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
expect(JSON.stringify(cmd)).not.toContain("shadow#T"); expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false);
const r = applyCommand(state, "bot", cmd); const r = applyCommand(state, "bot", cmd);
if (!r.ok) break; if (!r.ok) break;
state = r.state; state = r.state;
+43 -6
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos,
} from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */ /** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -395,7 +396,7 @@ describe("big man", () => {
/** From a giant's square: a step into a square whose exits (all sides but /** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` a corner (1, not straight) or a * the way back) number exactly `want` a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */ * fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork") { function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
const view = boardView(state); const view = boardView(state);
for (const key of Object.keys(view.cells)) { for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
@@ -406,6 +407,7 @@ describe("big man", () => {
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step"); const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits }; if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits }; if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits };
} }
} }
throw new Error(`no ${want} on this board`); throw new Error(`no ${want} on this board`);
@@ -452,15 +454,15 @@ describe("big man", () => {
expect(state.turn.movementUsed).toBe(used + 1); expect(state.turn.movementUsed).toBe(used + 1);
}); });
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => { it("a pre-rev-15 game shoves straight ahead when it can and rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state); state = toRound2(state);
const giant = activePlayer(state); const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} }); state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
const victim = state.players.find((p) => p.id !== giant.id)!; const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is. // A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork"); const fork = pushSite(state, "straight-fork");
if (fork.exits.includes(fork.side)) { {
giant.position = { ...fork.at }; giant.position = { ...fork.at };
victim.position = { ...fork.mid }; victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side }); const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
@@ -961,3 +963,38 @@ describe("the imp's fire is a spell (rev 8)", () => {
expect(after.alive).toBe(true); expect(after.alive).toBe(true);
}); });
}); });
describe("the alter ego casts from its own square", () => {
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
let { state } = newGame(42);
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const ego = giveCard(state, attacker, "alter-ego");
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
expect(cellKey(double.position)).toBe(cellKey(a.position));
// The caster steps out of the defender's sight; the double keeps it.
const view = boardView(state);
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
expect(hidden).toBeDefined();
const me = state.players.find((p) => p.id === attacker)!;
me.position = { ...hidden! };
const fb = giveCard(state, attacker, "fireball");
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(blind.ok).toBe(false);
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
expect(wrong.ok).toBe(false);
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
expect(viaEgo.ok).toBe(true);
if (!viaEgo.ok) return;
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
const cast = viaEgo.events.find((e) => e.type === "spellCast");
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
const done = must(viaEgo.state, defender, { type: "pass" });
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board"; import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -813,7 +813,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
}); });
}); });
describe("butt-head's charge reaches as far as its legs (K4T3)", () => { describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */ /** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } { function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state); const view = boardView(state);
@@ -843,3 +843,55 @@ describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
}); });
} }
}); });
describe("cards that keep their whole promise (rev 21)", () => {
it("LIFESAVER's holder survives losing both treasures", () => {
for (const [deckRev, alive] of [[21, true], [20, false]] as const) {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const others = state.players.filter((p) => p.id !== me.id);
const card = giveCard(state, me.id, "lifesaver");
state = must(state, me.id, { type: "cast", instanceId: card.instanceId });
// Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for.
const mine = state.treasures.filter((t) => t.owner === me.id);
mine[0]!.position = { ...others[0]!.home };
mine[1]!.position = { ...others[1]!.home };
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive);
}
});
it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => {
for (const deckRev of [21, 20]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
// Stand the attacker beside the defender, an open edge between.
const view = boardView(state);
const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!;
const beside = stepTarget(view, d.position, side);
if (beside.kind === "blocked") throw new Error("no open side");
a.position = { ...beside.to };
const fb = giveCard(state, attacker, "fireball");
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
state = must(state, attacker, { type: "pass" });
if (state.stack) state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) });
if (deckRev >= 21) {
expect(step.ok).toBe(false);
if (!step.ok) expect(step.error).toMatch(/force field/);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true);
const after = must(state, attacker, { type: "endTurn", draw: 0 });
expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false);
} else {
expect(step.ok).toBe(true);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false);
}
}
});
});
@@ -561,7 +561,7 @@ describe("the boobytrap keeps its secret", () => {
}); });
}); });
describe("a pit on the board's rim (M4Q7)", () => { describe("a pit on the board's rim (rev 17)", () => {
/** A square on the outer rim with a warp mouth on one side, entered from /** A square on the outer rim with a warp mouth on one side, entered from
* the square opposite the mouth; its other neighbouring squares listed. */ * the square opposite the mouth; its other neighbouring squares listed. */
function rimPitSite(state: GameState) { function rimPitSite(state: GameState) {
@@ -637,7 +637,7 @@ describe("a pit on the board's rim (M4Q7)", () => {
}); });
}); });
describe("a wave names its victims before it pushes (LK97)", () => { describe("a wave names its victims before it pushes (rev 20)", () => {
/** A wall between two squares, a wizard on its near side with one open /** A wall between two squares, a wizard on its near side with one open
* square behind them and a wall beyond it, and a caster's square beside. */ * square behind them and a wall beyond it, and a caster's square beside. */
function site(state: GameState) { function site(state: GameState) {
@@ -692,7 +692,6 @@ describe("spells cast at a slime wait in the gel", () => {
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1); expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
// Nobody is hurt yet; the victim walks in on their own turn. // Nobody is hurt yet; the victim walks in on their own turn.
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 }); state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
victim.position = { ...caster.position };
const v = state.players.find((p) => p.id === victim.id)!; const v = state.players.find((p) => p.id === victim.id)!;
v.position = { ...caster.position }; v.position = { ...caster.position };
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side }); const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
+37 -1
View File
@@ -36,6 +36,7 @@ import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs"; import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path"; import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine"; import type { Command, PlayerId } from "@wizwar/engine";
import { import {
catchUpSteps, catchUpSteps,
@@ -198,11 +199,46 @@ function safeBase(rawHost: string, rawProto: string): string {
return `${proto}://${escapeHtml(rawHost)}`; return `${proto}://${escapeHtml(rawHost)}`;
} }
/** The turn's story in one line, for the share card: the deed a
* stranger would click to see, chosen by weight a crown, a treasure
* carried home, a blow that landed, a spell cast before the bare fact
* of whose turn it was. */
function headlineOf(data: ShareData): string | null {
// A card retired since the share was written keeps its id as its name.
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
const deeds: { rank: number; text: string }[] = [];
const offer = (rank: number, text: string) => { deeds.push({ rank, text }); };
for (const step of data.steps) {
for (const e of step.events) {
switch (e.type) {
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
default: break;
}
}
}
let best: { rank: number; text: string } | null = null;
for (const d of deeds) if (!best || d.rank > best.rank) best = d;
return best?.text ?? null;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto); const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole const title = data.whole
? "The whole tale — a game of Wiz-War, replayed" ? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; : headline
? `${escapeHtml(headline)} — a Wiz-War instant replay`
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.` ? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` + : `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
+4 -2
View File
@@ -165,7 +165,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
evicted++; evicted++;
try { try {
const stat = ledgerStat(id); const stat = ledgerStat(id);
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) }); if (stat) writeStubFile(id, stat.bytes, toStored(stub));
} catch (e) { } catch (e) {
console.error(`could not write stub for ${id}:`, e); console.error(`could not write stub for ${id}:`, e);
} }
@@ -185,6 +185,8 @@ interface RoomStub {
const sleepingStubs = new Map<string, RoomStub>(); const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */ /** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> }; type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
const toStored = (stub: RoomStub): StoredStub => ({ ...stub, tokens: Object.fromEntries(stub.tokens) });
const fromStored = (stored: StoredStub): RoomStub => ({ ...stored, tokens: new Map(Object.entries(stored.tokens)) });
function stubOf(room: Room): RoomStub { function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state); const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -797,7 +799,7 @@ export function loadPersistedRooms(): void {
if (!stat) continue; if (!stat) continue;
const stored = readStubFile<StoredStub>(id); const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) { if (stored && stored.bytes === stat.bytes) {
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) }); sleepingStubs.set(id, fromStored(stored.stub));
asleep++; asleep++;
continue; continue;
} }
+2 -2
View File
@@ -5,7 +5,7 @@
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { randomInt } from "node:crypto"; import { randomInt } from "node:crypto";
import { statsDir } from "./store"; import { dataRoot } from "./store";
export interface Share { export interface Share {
id: string; id: string;
@@ -20,7 +20,7 @@ const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
const shares = new Map<string, Share>(); const shares = new Map<string, Share>();
function sharesFile(): string { function sharesFile(): string {
return join(statsDir(), "shares.jsonl"); return join(dataRoot(), "shares.jsonl");
} }
export function loadShares(): void { export function loadShares(): void {
+2 -2
View File
@@ -6,7 +6,7 @@
import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { statsDir } from "./store"; import { dataRoot } from "./store";
import type { Room } from "./rooms"; import type { Room } from "./rooms";
export interface EngagementStats { export interface EngagementStats {
@@ -35,7 +35,7 @@ interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
roomStages: Record<string, "created" | "started" | "finished">; roomStages: Record<string, "created" | "started" | "finished">;
} }
const FILE = () => join(statsDir(), "stats.json"); const FILE = () => join(dataRoot(), "stats.json");
let data: StatsFile = { let data: StatsFile = {
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, gamesCreated: 0, gamesStarted: 0, gamesFinished: 0,
+9 -7
View File
@@ -86,8 +86,10 @@ function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`); return join(DATA_DIR, `${roomId}.jsonl`);
} }
/** Directory holding stats.json — the parent of the rooms dir. */ /** The data root the parent of the rooms dir where everything that
export function statsDir(): string { * is not a room ledger lives: stats, feedback, stubs, the graveyard, the
* clip vault. */
export function dataRoot(): string {
return join(DATA_DIR, ".."); return join(DATA_DIR, "..");
} }
@@ -149,7 +151,7 @@ export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } |
// ledger only ever grows, so at boot a stub whose size still matches is // ledger only ever grows, so at boot a stub whose size still matches is
// the room's whole truth and the room stays asleep, unreplayed. // the room's whole truth and the room stays asleep, unreplayed.
const stubDir = () => join(DATA_DIR, "..", "stubs"); const stubDir = () => join(dataRoot(), "stubs");
function stubFor(roomId: string): string { function stubFor(roomId: string): string {
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
return join(stubDir(), `${roomId}.json`); return join(stubDir(), `${roomId}.json`);
@@ -168,7 +170,7 @@ export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | nu
} }
} }
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void { export function writeStubFile<T>(roomId: string, bytes: number, stub: T): void {
const file = stubFor(roomId); const file = stubFor(roomId);
mkdirSync(stubDir(), { recursive: true }); mkdirSync(stubDir(), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8");
@@ -182,7 +184,7 @@ export function writeStubFile(roomId: string, bytes: number, stub: unknown): voi
* timestamp. A report line may carry fields (deckRev, player context) * timestamp. A report line may carry fields (deckRev, player context)
* that only the operator's raw read uses; readFeedback keeps the * that only the operator's raw read uses; readFeedback keeps the
* player-facing subset. */ * player-facing subset. */
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl"); const feedbackFile = () => join(dataRoot(), "feedback.jsonl");
export function appendFeedback(entry: Record<string, unknown>): void { export function appendFeedback(entry: Record<string, unknown>): void {
ensureDataDir(); ensureDataDir();
@@ -239,7 +241,7 @@ export function readFeedback(): FeedbackReport[] {
export function archiveRoomFile(roomId: string): void { export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId); const src = fileFor(roomId);
if (!existsSync(src)) return; if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); const graveyard = join(dataRoot(), "rooms-abandoned");
mkdirSync(graveyard, { recursive: true }); mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
} }
@@ -260,7 +262,7 @@ export interface ClipMeta {
height?: number; height?: number;
} }
const clipsDir = () => join(DATA_DIR, "..", "clips"); const clipsDir = () => join(dataRoot(), "clips");
/** A clip's name, which is also its file stem: no separators, no dots, /** A clip's name, which is also its file stem: no separators, no dots,
* so a name can never name a path. */ * so a name can never name a path. */
+183 -75
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte"; import { motion } from "./motion";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx"; import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte"; import LiveFirstPerson from "./LiveFirstPerson.svelte";
@@ -15,7 +17,7 @@
import { prefs, savePrefs } from "./prefs.svelte"; import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -205,7 +207,7 @@
} }
} }
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view }; if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!prefs.flourishes) return; if (!motion.effects) return;
fxCancels.push(scheduleFx( fxCancels.push(scheduleFx(
events, view, events, view,
(fx) => (boardFx = [...boardFx, fx]), (fx) => (boardFx = [...boardFx, fx]),
@@ -222,6 +224,12 @@
fxCancels = []; fxCancels = [];
}; };
}); });
$effect(() => {
if (motion.effects) return;
fxCancels.forEach((cancel) => cancel());
fxCancels = [];
boardFx = [];
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls); const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() { function dismissRolls() {
net.dismissRolls(); net.dismissRolls();
@@ -298,7 +306,6 @@
let rotateCW = $state(true); let rotateCW = $state(true);
/** pick-lock / master-key: prop the door for others once it opens. */ /** pick-lock / master-key: prop the door for others once it opens. */
let holdDoor = $state(false); let holdDoor = $state(false);
/** mega-monster: which stat the chosen monster doubles. */
/** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */ /** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null); let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
function castMega(boost: "life" | "movement") { function castMega(boost: "life" | "movement") {
@@ -338,8 +345,9 @@
); );
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object ends the turn's actions: the hand goes quiet until /** Picking up an object or sliding into slime ends the turn's actions: the
* the draw — but never while a counter, shield, or discard is owed. */ * hand goes quiet until the draw — but never while a counter, shield, or
* discard is owed. */
const actionsSpent = $derived( const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded && view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard, !youMustRespond && !youMustShield && !youMustDiscard,
@@ -458,9 +466,15 @@
* the spell being built (or answered), controls and all — table talk * the spell being built (or answered), controls and all — table talk
* returns the moment the cast resolves. Phones keep the under-board * returns the moment the cast resolves. Phones keep the under-board
* strips; a rail below the fold is no place to aim from. */ * strips; a rail below the fold is no place to aim from. */
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)"); const wideQuery = new MediaQuery("(min-width: 901px)", true);
let wideScreen = $state(wideQuery?.matches ?? true); const wideScreen = $derived(wideQuery.current);
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches)); /** A wide, short screen — a laptop in landscape — stands the hand beside
* the board unless the player has said otherwise: a tall maze under a
* bottom hand leaves a postage stamp, and a first turn deserves better.
* (The narrower "short window" rule in the stylesheet only shrinks the
* cards; this one needs room for a card column, hence the wider floor.) */
const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
const castPanelOn = $derived(wideScreen && !local.active && const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack))); (selectedCard != null || (youMustRespond && !!view?.stack)));
@@ -490,7 +504,7 @@
* width, capped so the full hand still fits without a scrollbar. */ * width, capped so the full hand still fits without a scrollbar. */
let handEl = $state<HTMLDivElement | null>(null); let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) { function fitHandCards(el: HTMLDivElement) {
if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; } if (!handLeftOn) { el.style.removeProperty("--card-zoom"); return; }
// Card.svelte is the source of truth for the card's box: 8.2rem wide, // Card.svelte is the source of truth for the card's box: 8.2rem wide,
// 11.4rem tall. 5rem covers the masthead and the column's padding. // 11.4rem tall. 5rem covers the masthead and the column's padding.
const rem = 16; const rem = 16;
@@ -511,7 +525,7 @@
}); });
$effect(() => { $effect(() => {
void view?.yourHand.length; void view?.yourHand.length;
void prefs.handLeft; void handLeftOn;
const el = handEl; const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el)); if (el) requestAnimationFrame(() => fitHandCards(el));
}); });
@@ -574,10 +588,17 @@
} }
} }
/** Attachment plumbing for a cast command: the chosen mods and the /** Your ALTER EGO, when it is the selected token: casts leave from its square. */
* attached NUMBER ride along (an explicit numberInstanceIds wins). */ const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
/** Attachment plumbing for a cast command: the chosen mods, the attached
* NUMBER (an explicit numberInstanceIds wins), and the double it casts through. */
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T { function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd); applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
if (attachedNumber && !cmd.numberInstanceIds) { if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId]; cmd.numberInstanceIds = [attachedNumber.instanceId];
} }
@@ -648,7 +669,7 @@
discardSelection = next; discardSelection = next;
return; return;
} }
if (actionsSpent) return; // picking up an object ended the turn's actions if (actionsSpent) return;
if (!isYourTurn && !yourMoment) { if (!isYourTurn && !yourMoment) {
// Live interruption: Interrupt / Opportunity Fire may be played during // Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending). // another player's turn (when no attack is pending).
@@ -924,8 +945,7 @@
// A card in hand is aimed, not walked with: a tap on a bare square // A card in hand is aimed, not walked with: a tap on a bare square
// while holding one is a miss, never a stride into whatever is there. // while holding one is a miss, never a stride into whatever is there.
if (selectedCard) { if (selectedCard) {
net.error = `${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`; net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000);
return; return;
} }
// A cell click is a move if the cell is one legal step away (the server // A cell click is a move if the cell is one legal step away (the server
@@ -960,7 +980,7 @@
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue; if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
for (const out of SIDES) { for (const out of SIDES) {
if (out === (side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E")) continue; if (out === opposite(side)) continue;
const foot = stepTarget(view.board, pit, out); const foot = stepTarget(view.board, pit, out);
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; } if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
} }
@@ -1136,7 +1156,7 @@
function clickPlayer(playerId: string) { function clickPlayer(playerId: string) {
if (!view || !yourMoment) return; if (!view || !yourMoment) return;
if (selectedCreature) { if (selectedCreature && !(castingDouble && selectedCard)) {
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId }); dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null; selectedCreature = null;
return; return;
@@ -1485,6 +1505,11 @@
function pass() { dispatch({ type: "pass" }); } function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null); const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
/** Enemy treasures resting on your home square: two wins the game. */
const treasuresHome = $derived(
view && me ? view.treasures.filter((t) => t.owner !== me!.id && t.position &&
t.position.x === me!.home.x && t.position.y === me!.home.y).length : 0,
);
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []); const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
/** Two treasures can share a square: one dispatches, more ask whose. */ /** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived( const treasuresHere = $derived(
@@ -1507,10 +1532,10 @@
// answer to "how can he even see me?" when sight ran through a warp mouth. // answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null); const sightTrace = $derived(view ? stackSightTrace(view) : null);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
/** A first game's first turn lights itself; after that only by choice. */ /** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1); const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
/** The step light: with no card in hand, your square and the squares a
* stride reaches are lit and the rest of the maze falls into shadow. */
const stepLight = $derived( const stepLight = $derived(
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard && !!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0, !net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
@@ -1521,12 +1546,9 @@
.filter((t) => t.kind !== "blocked") .filter((t) => t.kind !== "blocked")
.map((t) => cellKey(t.to)); .map((t) => cellKey(t.to));
}); });
/** The first stride of a first game gets one line on the board itself. */ /** The first stride of a first game gets one line above the board
const stepCaption = $derived( * off the maze, so the walls around the wizard stay in plain sight. */
stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0 const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
? { cell: me.position, text: "This is you — tap a lit square to walk" }
: null,
);
const litCells = $derived.by(() => { const litCells = $derived.by(() => {
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]); if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
if (!view || !selectedDef || !yourMoment) return null; if (!view || !selectedDef || !yourMoment) return null;
@@ -1534,10 +1556,10 @@
if (!me) return null; if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner"); const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) { if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId, bent); return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
} }
if (selectedDef.adjacent === true) { if (selectedDef.adjacent === true) {
const { x, y } = me.position; const { x, y } = castingDouble?.position ?? me.position;
return new Set( return new Set(
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] [[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
.map(([cx, cy]) => `${cx},${cy}`) .map(([cx, cy]) => `${cx},${cy}`)
@@ -1545,7 +1567,7 @@
); );
} }
if (selectedDef.los !== true) return null; if (selectedDef.los !== true) return null;
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view); return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
}); });
/** Creatures awaiting your orders this turn (yours + any democratic monster). */ /** Creatures awaiting your orders this turn (yours + any democratic monster). */
@@ -1628,7 +1650,7 @@
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span> <span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span>
<a class="mast-leave" href="/">← back to the table</a> <a class="mast-leave" href="/">← back to the table</a>
<a class="mast-leave" href="/clips">the clips</a> <a class="mast-leave" href="/clips">the clips</a>
<a class="mast-leave mast-help-solo" href="/?tokens">tokens</a> <a class="mast-leave mast-right" href="/?tokens">tokens</a>
<a class="mast-leave" href="/?fx">flourishes</a> <a class="mast-leave" href="/?fx">flourishes</a>
<a class="mast-leave" href="/?fpv">eyes</a> <a class="mast-leave" href="/?fpv">eyes</a>
</header> </header>
@@ -1653,7 +1675,7 @@
{:else if net.roomId} {:else if net.roomId}
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun" <button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}> onclick={copyInvite}>
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""} {#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button> </button>
{#if net.audience > 0} {#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span> <span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
@@ -1790,7 +1812,7 @@
<div class="attack-actions"> <div class="attack-actions">
{#each view.pushPending.exits as d (d)} {#each view.pushPending.exits as d (d)}
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}> <button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
{d === "N" ? "North" : d === "S" ? "South" : d === "E" ? "East" : "West"} {SIDE_NAMES[d]}
</button> </button>
{/each} {/each}
</div> </div>
@@ -1877,7 +1899,7 @@
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes} <input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} /> onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span> <span>Spell flourishes (the animated effects — off as well whenever your system asks for reduced motion)</span>
</label> </label>
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp} <input type="checkbox" checked={prefs.liveFp}
@@ -1890,9 +1912,9 @@
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span> <span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label> </label>
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.handLeft} <input type="checkbox" checked={handLeftOn}
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} /> onchange={(e) => { setPref("handLeft", e.currentTarget.checked); setPref("handLeftChosen", true); }} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span> <span>Hand at the left: your cards stand in a column beside the board (wide screens; a wide, short window does this on its own until you choose)</span>
</label> </label>
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.stepLightAlways} <input type="checkbox" checked={prefs.stepLightAlways}
@@ -2225,7 +2247,9 @@
<section class="boxlid"> <section class="boxlid">
<div class="boxlid-inner"> <div class="boxlid-inner">
<div class="boxlid-title">Wiz-War</div> <div class="boxlid-title">Wiz-War</div>
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div> <div class="boxlid-pitch">Steal treasure. Blast through walls. Outwit your friends.</div>
<div class="boxlid-tag">A turn-based wizard board game for two to six, free in your browser, with
friends or clockwork opponents. Carry home two treasures that aren't yours, or be the last wizard standing.</div>
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}> <button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
about this game — a labor of love about this game — a labor of love
</button> </button>
@@ -2368,12 +2392,29 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if net.spectating}
{#if net.players.length < 6}
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
<div class="lobby-head">Join {net.hostId ?? "the"}'s game</div>
<div class="boxlid-tag">Choose your wizard's name and take a seat; the boards flip when the host starts.</div>
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp primary" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
Take a seat
</button>
</div>
<p class="waiting">👁 Just watching? You're in the Peanut Gallery until you sit.</p>
{:else}
<p class="waiting">👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{/if}
{/if}
<div class="lobby-head">Fill the table</div> <div class="lobby-head">Fill the table</div>
<div class="boxlid-tag">Two to six wizards enter the maze — friends by code or link, <div class="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
computer wizards seated below, or a mix of both.</div> computer wizards seated below, or a mix of both.</div>
<div class="invite-row"> <div class="invite-row">
<code class="invite-link">{inviteLink}</code> <code class="invite-link">{inviteLink}</code>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button> <button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy invite link"}</button>
</div> </div>
<ul class="roster"> <ul class="roster">
{#each net.players as p (p)} {#each net.players as p (p)}
@@ -2392,18 +2433,7 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{#if net.spectating} {#if net.you === net.hostId}
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{#if net.players.length < 6}
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
…or take a seat
</button>
</div>
{/if}
{:else if net.you === net.hostId}
{#if net.players.length < 6} {#if net.players.length < 6}
<div class="lobby-head">Seat a computer wizard</div> <div class="lobby-head">Seat a computer wizard</div>
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure, <div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
@@ -2445,15 +2475,19 @@
</div> </div>
</section> </section>
{:else if view} {:else if view}
<div class="game" class:hand-left={prefs.handLeft}> <div class="game" class:hand-left={handLeftOn}>
{#snippet tableGuidance()} {#snippet tableGuidance()}
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0} {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip"> <div class="hint-strip">
{#if selectedCreature && !selectedDef} {#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)} {@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc} {#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong>{creatureStats(sc)}. {#if sc.kind === "alter-ego"}
Tap a square beside it to march, a target in its square to attack.</span> <span>Your <strong>double</strong> stands ready — select a card and it casts from the double's square, by the double's sight.</span>
{:else}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong>{creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
{/if}
{#if dmWarnCell} {#if dmWarnCell}
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span> <span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
{/if} {/if}
@@ -2638,6 +2672,20 @@
{/if} {/if}
{/snippet} {/snippet}
<section class="board-zone"> <section class="board-zone">
{#if view.you && !net.spectating}
<div class="view-toggle">
<button class="stamp tiny" class:lit={prefs.liveFp} onclick={() => setPref("liveFp", !prefs.liveFp)}
title="a first-person pane above the board, through your wizard's eyes">
{prefs.liveFp ? "▦ the board only" : "👁 through your eyes"}
</button>
</div>
{/if}
{#if stepCaption && me}
<div class="step-caption">
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you — tap a lit square beside your wizard to walk
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}> <div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating} {#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)} <LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
@@ -2677,7 +2725,6 @@
onEdgePeek={(tip) => (peekEdge = tip)} onEdgePeek={(tip) => (peekEdge = tip)}
{litCells} {litCells}
spotCells={stepCells} spotCells={stepCells}
spotCaption={stepCaption}
{sightTrace} {sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null} povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null} povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
@@ -2755,13 +2802,13 @@
{/if} {/if}
{#if view.turn.actionsEnded} {#if view.turn.actionsEnded}
{#if me?.carriedTreasureId} {#if me?.carriedTreasureId}
picking up ended your actions for this turn, and the treasure comes with you — your actions for this turn are over, and the treasure comes with you —
<strong>End turn</strong> now, then carry it home to score. <strong>End turn</strong> now, then carry it home to score.
{:else} {:else}
picking up ended your actions for this turn — <strong>End turn</strong> when ready. your actions for this turn are over <strong>End turn</strong> when ready.
{/if} {/if}
{:else} {:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} {view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
{/if} {/if}
</div> </div>
{#if !prefs.coached && !view.turn.actionsEnded} {#if !prefs.coached && !view.turn.actionsEnded}
@@ -2770,7 +2817,8 @@
<div class="slip coach"> <div class="slip coach">
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping <strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther. the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
Tap a card to cast it. When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints. Select a card, then choose its target — a wizard, a square, or a wall, as the hint bar says.
When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button> <button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
</div> </div>
{/if} {/if}
@@ -2825,13 +2873,17 @@
<div class="council-head">The council considers your position… <div class="council-head">The council considers your position…
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button> <button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
</div> </div>
{#each councilHints as h (h.name)} {#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
<div class="council-row"> <div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
<span class="council-name">{h.glyph} {h.name}</span> {:else}
<span class="council-mood">({h.mood})</span> {#each councilHints as h (h.name)}
{h.advice} <div class="council-row">
</div> <span class="council-name">{h.glyph} {h.name}</span>
{/each} <span class="council-mood">({h.mood})</span>
{h.advice}
</div>
{/each}
{/if}
<button class="stamp tiny" onclick={askCouncil}>ask again</button> <button class="stamp tiny" onclick={askCouncil}>ask again</button>
</div> </div>
{/if} {/if}
@@ -3319,7 +3371,7 @@
.workshop-mast { margin: 0 1rem 0; } .workshop-mast { margin: 0 1rem 0; }
.mast-home { text-decoration: none; } .mast-home { text-decoration: none; }
.mast-home:hover { color: #fff; } .mast-home:hover { color: #fff; }
.mast-help-solo { margin-left: auto; } .mast-help-solo, .mast-right { margin-left: auto; }
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; } .hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; } .hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
@@ -3438,6 +3490,39 @@
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.boxlid-title.small { font-size: 1.7rem; } .boxlid-title.small { font-size: 1.7rem; }
.boxlid-pitch {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: 0.02em;
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption {
align-self: flex-start;
max-width: calc(100% - 11rem);
margin: 0 0 0.4rem 0.4rem;
padding: 0.3rem 0.8rem;
border-radius: 999px;
background: rgba(28, 22, 12, 0.92);
border: 1.5px solid #f2cf5b;
color: #f7ecc8;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.45rem;
}
.step-chip {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 2px;
border: 1.5px solid #f7ecc8;
}
.boxlid-tag { .boxlid-tag {
font-size: 0.95rem; font-size: 0.95rem;
color: #6b5a41; color: #6b5a41;
@@ -3729,15 +3814,33 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; } .board-zone { position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; }
/* Hand at the left: the cards stand in their own column beside the /* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it — * board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves. * the strip dissolves into the grid so its children place themselves.
* Phones keep the bottom hand; the one-column flow re-orders anyway. */ * Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) { @media (min-width: 901px) {
/* Bottom hand: the table fills the window — board and rail on top,
* the rail scrolling in place, the hand and its buttons pinned in
* view beneath. The board takes whatever height the row allows. */
.game {
flex: 0 0 auto;
height: calc(100dvh - 5.3rem);
grid-template-rows: minmax(0, 1fr) auto;
}
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { min-height: 0; }
.table-stack { flex: 1 1 0; min-height: 0; }
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
.fpv-primary .board-viewport { flex: 0 0 30dvh; }
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
.game.hand-left { .game.hand-left {
grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px); grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px);
grid-template-rows: minmax(0, 1fr) repeat(5, auto);
row-gap: 0.4rem;
} }
.game.hand-left .paper-rail { grid-row: 1 / span 6; }
.game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); } .game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); }
.game.hand-left .table-edge { display: contents; } .game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; } .game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
@@ -3751,17 +3854,12 @@
justify-content: center; justify-content: center;
align-content: start; align-content: start;
align-self: start; align-self: start;
position: sticky; max-height: 100%;
top: 0.5rem;
max-height: calc(100dvh - 5rem);
overflow-y: auto; overflow-y: auto;
padding: 0.3rem 0.3rem 0.9rem; padding: 0.3rem 0.3rem 0.9rem;
} }
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; } .game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; } .game.hand-left .paper-rail { grid-column: 3; }
/* With the hand out of the bottom strip, the map claims the height. */
.game.hand-left .board-viewport { height: calc(100dvh - 11rem); }
.game.hand-left .fpv-primary .board-viewport { height: 30dvh; }
} }
/* The first-person pane wears exactly the board's width, right above /* The first-person pane wears exactly the board's width, right above
* it: the stack shrink-wraps to the board, and width:0 + min-width * it: the stack shrink-wraps to the board, and width:0 + min-width
@@ -3879,7 +3977,6 @@
padding-left: 0.3rem; padding-left: 0.3rem;
border-radius: 2px; border-radius: 2px;
} }
.score-row.active .score-name { font-weight: 700; }
.score-temper { .score-temper {
font-family: "Caveat", cursive; font-family: "Caveat", cursive;
font-size: 0.85rem; font-size: 0.85rem;
@@ -4034,6 +4131,12 @@
gap: 0.8rem; gap: 0.8rem;
max-width: min(22rem, 92vw); max-width: min(22rem, 92vw);
text-align: center; text-align: center;
/* A notice taller than the screen — preferences on a phone — scrolls
* inside its frame rather than running off the bottom uncatchable. */
max-height: calc(100dvh - 2rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
} }
.attack-headline { .attack-headline {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
@@ -4321,6 +4424,10 @@
align-items: flex-end; align-items: flex-end;
} }
/* A short desktop window: smaller cards under the board so the maze keeps its height. */
@media (min-width: 901px) and (max-height: 820px) {
.game:not(.hand-left) .hand :global(.card) { zoom: 0.78; }
}
@media (max-width: 900px) { @media (max-width: 900px) {
/* One-column phone flow: board, then your controls and hand, THEN the /* One-column phone flow: board, then your controls and hand, THEN the
paperwork — cards must never hide below the chronicle. */ paperwork — cards must never hide below the chronicle. */
@@ -4342,6 +4449,7 @@
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; } .hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
.mast-title { font-size: 1.05rem; white-space: nowrap; } .mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; } .mast-sub { display: none; }
.mast-invite { display: none; }
.mast-leave { font-size: 0.72rem; } .mast-leave { font-size: 0.72rem; }
} }
</style> </style>
+5 -33
View File
@@ -35,7 +35,6 @@
markedCells = null, markedCells = null,
litCells = null, litCells = null,
spotCells = [], spotCells = [],
spotCaption = null,
markedSector = null, markedSector = null,
ghostSlots = null, ghostSlots = null,
onGhostClick, onGhostClick,
@@ -70,8 +69,6 @@
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** The step light's squares: a pulsing rim on each. */ /** The step light's squares: a pulsing rim on each. */
spotCells?: string[]; spotCells?: string[];
/** One line hung above a square — the first stride's cue. */
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */ /** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null; markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground /** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
@@ -464,15 +461,11 @@
{@const wx = Number(key.split(":")[1]?.split(",")[0])} {@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])} {@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)} {@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"} {@const scar = kind === "V"
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} /> ? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} : edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path> <path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
{:else} <path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{/if}
{/each} {/each}
<!-- illusions YOU know are fake: ghostly dashed lines --> <!-- illusions YOU know are fake: ghostly dashed lines -->
@@ -697,17 +690,6 @@
{@const sy = Number(key.split(",")[1])} {@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" /> <rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each} {/each}
{#if spotCaption}
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
{@const cw = spotCaption.text.length * 6.6 + 20}
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
{@const above = spotCaption.cell.y > 0}
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
<text x="0" y="4">{spotCaption.text}</text>
</g>
{/if}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim --> <!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)} {#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])} {@const bx = Number(key.split(",")[0])}
@@ -865,15 +847,6 @@
0%, 100% { opacity: 0.35; } 0%, 100% { opacity: 0.35; }
50% { opacity: 1; } 50% { opacity: 1; }
} }
.spot-caption { pointer-events: none; }
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
.spot-caption text {
fill: #f7ecc8;
font-size: 12px;
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.02em;
text-anchor: middle;
}
.marked-cell { .marked-cell {
fill: rgba(211, 133, 43, 0.18); fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b; stroke: #d3852b;
@@ -933,7 +906,6 @@
stroke: rgba(240, 231, 209, 0.9); stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25; stroke-width: 1.25;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
} }
.warp-dest { .warp-dest {
fill: none; fill: none;
+1 -1
View File
@@ -48,7 +48,7 @@
><title>{tip}</title></rect> ><title>{tip}</title></rect>
{/if} {/if}
<!-- Pigment and hardware sit over the original wall/door hit target. --> <!-- stone highlights or wood grain and lock hardware; the rect beneath takes the taps -->
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true"> <g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
{#if state === "wall"} {#if state === "wall"}
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" /> <path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
+1 -1
View File
@@ -43,7 +43,7 @@
fill="#bd3b1b" class="fw-bar" fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} /> role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if} {/if}
<!-- Drawn flame poses share the original bases and hit area. --> <!-- tongues of flame, each on its own beat; the bar beneath is the hit target -->
{#each TONGUES as f (f.t)} {#each TONGUES as f (f.t)}
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art"> <g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}> <g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
+2 -2
View File
@@ -223,8 +223,8 @@
is this table's reading. A slime shows how many it holds, and a is this table's reading. A slime shows how many it holds, and a
peek names them, since every cast into it was seen.</p> peek names them, since every cast into it was seen.</p>
<p>Clockwork wizards — the automatons — play from the same redacted <p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and obey every rule; a tier changes what view a human seat gets and play by the rules the engine enforces on
they draw and know, never how correctly they play.</p> everyone; a tier changes what they draw and know.</p>
<p>A turn need not be taken at once. Games wait on the lobby ledger <p>A turn need not be taken at once. Games wait on the lobby ledger
for days; the browser holds your seat; a replay of any game can be for days; the browser holds your seat; a replay of any game can be
watched from above or through a wizard's eyes and shared by link.</p> watched from above or through a wizard's eyes and shared by link.</p>
-1
View File
@@ -34,7 +34,6 @@
stroke: #9d8cc1; stroke: #9d8cc1;
stroke-width: 0.4; stroke-width: 0.4;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s ease-in-out infinite; animation: shimmer-drift 1.4s ease-in-out infinite;
} }
.illusion-art { pointer-events: none; } .illusion-art { pointer-events: none; }
+24 -6
View File
@@ -7,6 +7,7 @@
// Every live event batch plays through the same fx pipeline the // Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform // instant replay uses, so spells, doors, and conjurations perform
// here first. // here first.
import { motion } from "./motion";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast"; import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art"; import { objectArt, tokenArt } from "./art";
@@ -161,6 +162,8 @@
* still fire — your own magic always turns your head. */ * still fire — your own magic always turns your head. */
let manualUntil = 0; let manualUntil = 0;
let turning = false; let turning = false;
let turnRaf = 0;
onDestroy(() => cancelAnimationFrame(turnRaf));
/** Manual steering holds the director's idle aims off this long. */ /** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000; const STEER_HOLD_MS = 4000;
@@ -173,14 +176,15 @@
manualUntil = performance.now() + STEER_HOLD_MS; manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing; const from = cam.facing;
const to = from + dir * (Math.PI / 2); const to = from + dir * (Math.PI / 2);
if (motion.reduced) { cam.facing = to; turning = false; return; }
const t0 = performance.now(); const t0 = performance.now();
const tick = (now: number) => { const tick = (now: number) => {
const w = Math.min(1, (now - t0) / 180); const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (smoothstep(w)); cam.facing = from + (to - from) * (smoothstep(w));
if (w < 1) requestAnimationFrame(tick); if (w < 1) turnRaf = requestAnimationFrame(tick);
else turning = false; else turning = false;
}; };
requestAnimationFrame(tick); turnRaf = requestAnimationFrame(tick);
} }
/** Pane clicks: a possessed mount claims them first — adjacent floor /** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's * steps it, a same-square enemy takes its blow — and only the rider's
@@ -225,14 +229,23 @@
let actorPos = $state<Record<string, { x: number; y: number }>>({}); let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. ----------------- // --- The fx: each batch plays once, on its own beat. -----------------
// Track ONLY the batch: the server sends `events` then `state` back to // Track the batch and motion policy: the server sends `events` then `state` back to
// back, so a wider dependency set re-runs this within the same frame, // back, so a wider dependency set re-runs this within the same frame,
// before a zero-delay timer can fire. No per-run cleanup for the same // before a zero-delay timer can fire. No per-run cleanup for the same
// reason — timers die only with the component. // reason — timers are cleared when effects are disabled or the pane closes.
let playedBatch = 0; let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>(); const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => { $effect(() => {
const enabled = motion.effects;
const b = batch; const b = batch;
if (!enabled) {
fxTimers.forEach(clearTimeout);
fxTimers.clear();
fpFx = [];
// Consume suppressed batches: enabling effects must not replay them.
if (b) playedBatch = b.n;
return;
}
if (!b) return; if (!b) return;
untrack(() => { untrack(() => {
if (b.n === playedBatch || !me) return; if (b.n === playedBatch || !me) return;
@@ -240,6 +253,7 @@
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
const starter = setTimeout(() => { const starter = setTimeout(() => {
fxTimers.delete(starter); fxTimers.delete(starter);
if (!motion.effects) return;
fpFx = [...fpFx, { ...fx, t0: performance.now() }]; fpFx = [...fpFx, { ...fx, t0: performance.now() }];
const ender = setTimeout(() => { const ender = setTimeout(() => {
fxTimers.delete(ender); fxTimers.delete(ender);
@@ -251,7 +265,7 @@
} }
}); });
}); });
onDestroy(() => fxTimers.forEach(clearTimeout)); onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
// --- Other bodies glide between views. ------------------------------- // --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null; let prevView: GameView | null = null;
@@ -259,6 +273,7 @@
const v = view; const v = view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v || !me) return; if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -283,6 +298,7 @@
// --- The camera: the director's ladder, live. ------------------------ // --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0; let directedBatch = 0;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
const b = batch; const b = batch;
const m = me; const m = me;
if (!m) { camReady = false; return; } if (!m) { camReady = false; return; }
@@ -385,7 +401,9 @@
// pointed your own head is where it stays, brick or no brick. // pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty); targetFacing = deepestFacing(v, tx, ty);
} }
if (willCut) { // Under reduced motion every move is a cut: the director still picks
// where to look, and the eye lands there without the pan or the walk.
if (willCut || reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true; camReady = true;
return; return;
+18 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
@@ -8,7 +9,6 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
@@ -53,7 +53,7 @@
let playing = $state(true); let playing = $state(true);
let speed = $state(1); let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */ /** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment); let fp = $state(untrack(() => moment));
/** Whose eyes the first-person camera wears: the wizard whose turn it /** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the * is, ALWAYS — their position seen with the viewer's knowledge of the
@@ -90,12 +90,12 @@
const dieOf = (st: { events: GameEvent[] } | undefined) => const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled"); st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
/** How long this step's interlude holds the world before it moves. */ /** How long this step's interlude holds the world before it moves. */
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0); const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null); let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]); const d = dieOf(steps[i]);
if (!d || !prefs.flourishes) { die = null; return; } if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now(); const t0 = performance.now();
const dur = DIE_MS / speed; const dur = DIE_MS / speed;
const who = d.player ?? "The maze"; const who = d.player ?? "The maze";
@@ -169,6 +169,7 @@
$effect(() => { $effect(() => {
const who = povId; const who = povId;
if (!fp) { lastPov = null; return; } if (!fp) { lastPov = null; return; }
if (!motion.effects) { lastPov = who; return; }
if (lastPov !== null && lastPov !== who) { if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who); const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = { const fx: FpFx = {
@@ -179,7 +180,7 @@
fpFx = [...untrack(() => fpFx), fx]; fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80); const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who; lastPov = who;
return () => clearTimeout(t); return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
} }
lastPov = who; lastPov = who;
}); });
@@ -210,7 +211,7 @@
let boardFx = $state<BoardFx[]>([]); let boardFx = $state<BoardFx[]>([]);
$effect(() => { $effect(() => {
const step = steps[Math.min(idx, steps.length - 1)]; const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return; if (!step || !motion.effects) return;
let cancel = () => {}; let cancel = () => {};
const wait = setTimeout(() => { const wait = setTimeout(() => {
cancel = scheduleFx( cancel = scheduleFx(
@@ -237,7 +238,7 @@
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const st = steps[i]; const st = steps[i];
if (!fp || !st || !prefs.flourishes) return; if (!fp || !st || !motion.effects) return;
const plan = camPlan; const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = []; const timers: ReturnType<typeof setTimeout>[] = [];
@@ -280,6 +281,7 @@
const v = st.view; const v = st.view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return; if (!before || before === v) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -316,6 +318,7 @@
"treasurePickedUp", "treasureDropped", "objectDropped"]); "treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false; let camReady = false;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
if (!fp) { camReady = false; return; } if (!fp) { camReady = false; return; }
const v = step.view; const v = step.view;
const me = v.players.find((p) => p.id === povId); const me = v.players.find((p) => p.id === povId);
@@ -360,6 +363,7 @@
cutawayShot = true; cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1); const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null; const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv; heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 }; camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) { if (pv) {
@@ -483,6 +487,13 @@
} }
return () => {}; return () => {};
}; };
if (reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
camReady = true;
heldView = null;
camPlan = { idx: stepIdx, holdMs: 0 };
return;
}
if (willCut) { if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed // First frame, or a leap the legs cannot explain: cut. An aimed
// cut still holds a beat of the before-world once the reel is // cut still holds a beat of the before-world once the reel is
+5 -5
View File
@@ -9,23 +9,23 @@ export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset); const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return [0.06, 0.38, 0.71].map((t, i) => { return [0.06, 0.38, 0.71].map((t, i) => {
const sign = i === 1 ? -1 : 1; const sign = i === 1 ? -1 : 1;
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`; return `M ${p(t)} C ${p(t+0.07,-3*sign)} ${p(t+0.2,-2.5*sign)} ${p(t+0.23,0.5*sign)} Q ${p(t+0.18,-0.6*sign)} ${p(t+0.15,1.2*sign)} Q ${p(t+0.12,-1.6*sign)} ${p(t)}`;
}).join(' '); }).join(" ");
}; };
/** An irregular fracture with branches that become longer as damage increases. */ /** An irregular fracture with branches that become longer as damage increases. */
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => { export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4; const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset); const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`; return `M ${p(0.03)} L ${p(0.18,-width)} ${p(0.34,0.6*width)} ${p(0.51,-0.5*width)} ${p(0.7,width)} ${p(0.86,-0.6*width)} ${p(0.98)} M ${p(0.34,0.6*width)} L ${p(0.4,1.25*width)} ${p(0.47,1.4*width)} M ${p(0.7,width)} L ${p(0.64,-1.1*width)} ${p(0.57,-1.35*width)}`;
}; };
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */ /** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
export const webThreads = (r: number) => { export const webThreads = (r: number) => {
const p = (x: number, y: number) => `${x*r} ${y*r}`; const p = (x: number, y: number) => `${x*r} ${y*r}`;
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`; return `M ${p(-1,-0.6)} L ${p(1,0.65)} M ${p(-0.75,1)} L ${p(0.65,-1)} M ${p(-1,0.6)} L ${p(1,-0.4)} M ${p(-0.15,-1)} L ${p(0.18,1)} M ${p(-0.75,-0.45)} Q ${p(-0.15,-0.2)} ${p(0.4,-0.62)} Q ${p(0.3,-0.15)} ${p(0.72,-0.28)} M ${p(-0.65,0.39)} Q ${p(-0.25,0.1)} ${p(-0.47,0.63)} Q ${p(0,0.4)} ${p(0.12,0.7)} Q ${p(0.35,0.35)} ${p(0.72,0.46)}`;
}; };
/** Open painted curls around a warp's precise center. */ /** Open painted curls around a warp's precise center. */
export const warpCurls = (x: number, y: number, r: number) => export const warpCurls = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`; `M ${x-r} ${y+r*0.15} C ${x-r*1.1} ${y-r*0.75} ${x+r*0.4} ${y-r*1.2} ${x+r*0.72} ${y-r*0.4} Q ${x+r*0.05} ${y-r*0.85} ${x-r*0.58} ${y-r*0.25} M ${x+r} ${y-r*0.1} C ${x+r*0.95} ${y+r*0.8} ${x-r*0.3} ${y+r*1.18} ${x-r*0.7} ${y+r*0.45} Q ${x-r*0.1} ${y+r*0.85} ${x+r*0.6} ${y+r*0.25}`;
+24 -25
View File
@@ -3,8 +3,9 @@
// GameView. Columns of wall shaded by distance and facing; token art // GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column // billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast"; import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx"; import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -197,6 +198,10 @@
} }
function draw(time: number) { function draw(time: number) {
// With effects off the frame still draws — walls, bodies, hazards —
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
const activeFx = motion.effects ? fx : [];
const ambientTime = motion.effects ? time : 0;
const ctx = canvas?.getContext("2d"); const ctx = canvas?.getContext("2d");
if (!ctx) return; if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
@@ -207,7 +212,7 @@
// Active shakes wobble the eye a hair off true, fading as they run. // Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y; let ex = x, ey = y;
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "shake") continue; if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -221,7 +226,7 @@
let growing: Record<string, number> | undefined; let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {}; const spawn: Record<string, number> = {};
const surging: Record<number, number> = {}; const surging: Record<number, number> = {};
for (const f of fx) { for (const f of activeFx) {
if (f.kind === "door") { if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur); const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
@@ -321,7 +326,7 @@
// FEAR's aura stains the forbidden ground itself, as the board draws // FEAR's aura stains the forbidden ground itself, as the board draws
// its dashed diamond — the same warp-walking yardstick behind both. // its dashed diamond — the same warp-walking yardstick behind both.
const dreadGrid = dreadGridOf(view); const dreadGrid = dreadGridOf(view);
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(time / 400) : 0; const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
for (let row = 0; row < H; row++) { for (let row = 0; row < H; row++) {
const below = row > half; const below = row > half;
const dz = below ? row - half : half - row; const dz = below ? row - half : half - row;
@@ -440,7 +445,7 @@
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
if (hit.kind === "firewall") { if (hit.kind === "firewall") {
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15); const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 90 + hit.worldU * 17 + col * 0.15);
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`; ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself dark *= 0.5; // the fire lights itself
@@ -448,13 +453,13 @@
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") { if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face // The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not. // shimmers faintly — maybe stone, maybe not.
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13); const tell = 0.06 + 0.05 * Math.sin(ambientTime / 260 + hit.worldU * 13);
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`; ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") { if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood. // A jammed lock seethes: a rusty seal pulsing across the wood.
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22); const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 160 + hit.worldU * 22);
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`; ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3); ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
} }
@@ -464,13 +469,13 @@
} }
if (hit.warped) { if (hit.warped) {
// Seen through a warp: the far side swims in violet haze. // Seen through a warp: the far side swims in violet haze.
const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05); const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
ctx.fillStyle = `rgba(120,70,220,${haze})`; ctx.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) { if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold. // The raised edge-card's invitation: faces breathe gold.
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3); const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`; ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
@@ -487,7 +492,7 @@
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey); const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" }); if (s) sprites.push({ ...s, fallback: "rubble" });
} }
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue; if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -545,13 +550,13 @@
const w = s.right - s.left; const w = s.right - s.left;
if (seen && s.dread && s.hit) { if (seen && s.dread && s.hit) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155), floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
"160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12); "160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
} }
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell && if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
litCells.has(`${s.cell.x},${s.cell.y}`) && litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) { !(s.hit.kind === "player" && s.hit.id === view.you)) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
"232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16); "232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
} }
// Painted art composites normally (inks stay true); light glows // Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies. // additively. Either way translucency applies.
@@ -607,7 +612,7 @@
const vTop = half - vh / 2; const vTop = half - vh / 2;
const wt = textures.warp!; const wt = textures.warp!;
const s = surging[vl.warp] ?? 0; const s = surging[vl.warp] ?? 0;
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02); const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
// The ripple: a ring of light widening from the veil's center over // The ripple: a ring of light widening from the veil's center over
// the surge, one full sweep to the edges; ~a fifth of the veil wide. // the surge, one full sweep to the edges; ~a fifth of the veil wide.
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0; const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
@@ -636,7 +641,7 @@
const top0 = half + full / 2 - grown; const top0 = half + full / 2 - grown;
const tex = textures[ri.kind] ?? textures.wall!; const tex = textures[ri.kind] ?? textures.wall!;
const swirl = (0.35 * (1 - ri.g) + 0.08) * const swirl = (0.35 * (1 - ri.g) + 0.08) *
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21)); (0.7 + 0.3 * Math.sin(ambientTime / 90 + ri.worldU * 21));
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) { for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
ctx.globalAlpha = 0.55 + 0.45 * ri.g; ctx.globalAlpha = 0.55 + 0.45 * ri.g;
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
@@ -674,7 +679,7 @@
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05)); const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
const gTop = half - gHft / 2; const gTop = half - gHft / 2;
const tex = textures.wall!; const tex = textures.wall!;
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01); const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 200 + gh.worldU * 11 + gHft * 0.01);
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) { for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
ctx.globalAlpha = 0.2; ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
@@ -695,7 +700,7 @@
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes. // Being hit is felt: a whole-screen wash that fades as it goes.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "flash") continue; if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -709,7 +714,7 @@
// as it goes, and snaps back; one thrown at you swells from the // as it goes, and snaps back; one thrown at you swells from the
// middle of the pane until it fills it. Either way the blow lands // middle of the pane until it fills it. Either way the blow lands
// with a comic starburst. // with a comic starburst.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "fist") continue; if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -720,7 +725,7 @@
ctx.rotate(pose.rotation); ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing); paintFist(ctx, pose.size, f.swing);
ctx.restore(); ctx.restore();
if (pose.radius > H * 0.19 * 0.01) { if (pose.radius > 0.5) {
ctx.save(); ctx.save();
ctx.translate(pose.impactX, pose.impactY); ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14); ctx.rotate(-0.14);
@@ -731,7 +736,7 @@
// A change of eyes: the slate wipes across, names the wizard whose // A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it. // sight this now is, and wipes away to reveal it.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "slate") continue; if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur); const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue; if (r <= l) continue;
@@ -772,12 +777,6 @@
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half); if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
} }
/** An ellipse of light on the floor at a body's feet: `rgb` as "r,g,b",
* the stroke at `alpha`, the fill fainter. */
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
rgb: string, alpha: number, fill: number) {
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
}
/** Paint the hover cue for whatever stands under the crosshair. */ /** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) { function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
+9 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "../motion";
import { smoothstep } from "./fx3d"; import { smoothstep } from "./fx3d";
// The first-person workshop (/?fpv): a real dealt game, one wizard's // The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where // eyes, free-fly controls. No rules run here — the camera walks where
@@ -145,7 +146,7 @@
const tick = (now: number) => { const tick = (now: number) => {
if (!anim) startNext(now); if (!anim) startNext(now);
if (anim) { if (anim) {
const w = Math.min(1, (now - anim.t0) / anim.dur); const w = motion.reduced ? 1 : Math.min(1, (now - anim.t0) / anim.dur);
if (anim.kind === "glide") { if (anim.kind === "glide") {
x = anim.fx + (anim.tx - anim.fx) * ease(w); x = anim.fx + (anim.tx - anim.fx) * ease(w);
y = anim.fy + (anim.ty - anim.fy) * ease(w); y = anim.fy + (anim.ty - anim.fy) * ease(w);
@@ -205,17 +206,17 @@
// a refused move reports itself instead of a blank stage. // a refused move reports itself instead of a blank stage.
const scriptName = q.get("script"); const scriptName = q.get("script");
const scriptPlay = scriptName ? screenplayByName(scriptName) : null; const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
let scriptSteps: ScreenplayStep[] = []; function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
let scriptError: string | null = null; if (scriptName && !scriptPlay) {
if (scriptName && !scriptPlay) { return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`; }
} else if (scriptPlay) {
try { try {
scriptSteps = buildScreenplaySteps(scriptPlay); return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
} catch (e) { } catch (e) {
scriptError = e instanceof Error ? e.message : String(e); return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
} }
} }
const { scriptSteps, scriptError } = loadScript();
// Minimap geometry (top-down, one small square per cell). // Minimap geometry (top-down, one small square per cell).
const MM = 9; const MM = 9;
+3 -5
View File
@@ -4,7 +4,8 @@
// camera shakes for the raycaster to draw. What happens ACROSS the room // camera shakes for the raycaster to draw. What happens ACROSS the room
// is watched; what happens to YOU is felt. // is watched; what happens to YOU is felt.
import { fallbackCels, paintCels } from "./paintedFx"; import { fallbackCels, paintCels, smoothstep } from "./paintedFx";
export { smoothstep };
import { fxForEvents } from "../fx"; import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom"; import { CELL } from "../fx-sprites/geom";
import { castRay, edgeMid, warpMotion } from "./raycast"; import { castRay, edgeMid, warpMotion } from "./raycast";
@@ -55,9 +56,6 @@ export function slateBand(p: number): [number, number] {
return [smoothstep((p - 0.68) / 0.32), 1]; return [smoothstep((p - 0.68) / 0.32), 1];
} }
/** The one easing every tween here shares. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
/** Eased growth 0..1 for a conjuration's rise. */ /** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number { export function growProgress(p: number): number {
if (p <= 0) return 0.01; if (p <= 0) return 0.01;
@@ -306,7 +304,7 @@ export function fpFxForEvents(
return out; return out;
} }
/** Inked stand-ins in each original palette, replaced when the PNG loads. */ /** Core and rim colors for each conjuration's inked stand-in. */
const FX_COLORS: Record<string, [string, string]> = { const FX_COLORS: Record<string, [string, string]> = {
fireball: ["#ffe27a", "#e0431a"], fireball: ["#ffe27a", "#e0431a"],
bolt: ["#ffffff", "#7ab0ff"], bolt: ["#ffffff", "#7ab0ff"],
+81 -41
View File
@@ -1,10 +1,11 @@
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */ /** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
export type Cel = { d: string; fill?: string; stroke?: string; width?: number }; export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
const INK = "#2b2218"; const INK = "#2b2218";
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e"; const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
/** Poses modeled on the original right-facing and oncoming fist emoji: /** Two fist poses: a punch thrown (wrist left, knuckles right) and one
* folded fingers face the viewer; the thumb stays outside their curl. */ * arriving (knuckles to the viewer). The thumb stays outside the curl. */
export const FISTS: Record<"out" | "in", Cel[]> = { export const FISTS: Record<"out" | "in", Cel[]> = {
out: [ out: [
// Wrist at left, back of hand above, bent index at the right edge. // Wrist at left, back of hand above, bent index at the right edge.
@@ -33,7 +34,7 @@ export const FISTS: Record<"out" | "in", Cel[]> = {
], ],
}; };
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */ /** The POW starburst and its lettering, as paths: the canvas has no font to wait on. */
export const POW: Cel[] = [ export const POW: Cel[] = [
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 }, { d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 }, { d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
ctx.restore(); ctx.restore();
} }
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") { export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore(); ctx.save();
} ctx.scale(size / 100, size / 100);
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) { paintCels(ctx, FISTS[swing]);
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore(); ctx.restore();
} }
/** Same endpoints and landing beats as the original animation. */ export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
ctx.save();
ctx.scale(radius / 100, radius / 100);
paintCels(ctx, POW);
ctx.restore();
}
/** Where the fist is at progress `p`: thrown, it lunges to 0.42 and
* retracts; arriving, it closes to 0.45, holds, and fades. The POW pops
* at the landing beat and shrinks away. */
export function punchPose(p: number, out: boolean, W: number, H: number) { export function punchPose(p: number, out: boolean, W: number, H: number) {
const ease = (v: number) => v * v * (3 - 2 * v); const ease = smoothstep;
// Clamp before easing: otherwise the incoming fist reverses past p=.45. // Clamp before easing: past 0.45 the arriving fist would reverse.
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45)); const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : ease(Math.min(1, p / 0.45));
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55; const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * .56, ty = H * .5; const tx = W * 0.56, ty = H * 0.5;
const land = out ? .36 : .4, q = (p - land) / (1 - land); const land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3); const pop = p < land ? 0 : q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a, return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a), size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1, alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
rotation: out ? -.6 : .15, radius: H * .19 * pop, rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 }; impactX: out ? tx - W * 0.02 : W * 0.5, impactY: H * 0.34 };
} }
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) { export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
const hub = [left + w * .5, top + h * .45]; type Pt = [number, number];
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]); const hub: Pt = [left + w * 0.5, top + h * 0.45];
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; }; const rim: Pt[] = ([[0.02, 0.06], [0.5, 0], [0.98, 0.08], [1, 0.5], [0.96, 0.94], [0.5, 1], [0.04, 0.92], [0, 0.5]] as Pt[])
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round"; .map(([u, v]) => [left + u * w, top + v * h]);
const at = (i: number, t: number): Pt => {
const r = rim[i % 8] as Pt;
return [hub[0] + (r[0] - hub[0]) * t, hub[1] + (r[1] - hub[1]) * t];
};
ctx.save();
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath(); ctx.beginPath();
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); } for (const r of rim) {
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) { ctx.moveTo(hub[0], hub[1]);
const a = at(i,t), b = at(i+1,t); ctx.lineTo(r[0], r[1]);
ctx.moveTo(a[0]!,a[1]!);
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
} }
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke(); // Three sagging rings, each strand bowed toward the hub.
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore(); for (const t of [0.27, 0.53, 0.8]) {
for (let i = 0; i < 8; i++) {
const a = at(i, t), b = at(i + 1, t);
ctx.moveTo(a[0], a[1]);
ctx.quadraticCurveTo((a[0] + b[0]) * 0.36 + hub[0] * 0.28, (a[1] + b[1]) * 0.36 + hub[1] * 0.28, b[0], b[1]);
}
}
ctx.strokeStyle = "rgba(25,25,30,0.65)";
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = "rgba(245,245,240,0.9)";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.restore();
} }
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) { export function paintFloorRing(
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2; ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill(); ) {
ctx.save();
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]]; ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
for (const [start,end] of segments) for (let i=0; i<=20; i++) { ctx.fill();
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5)); // The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k; ctx.beginPath();
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y); const segments: [number, number][] = [[0.06, 1.75], [1.87, 3.48], [3.62, 5.04], [5.18, 6.22]];
for (const [start, end] of segments) {
for (let i = 0; i <= 20; i++) {
const t = start + (end - start) * i / 20;
const k = 1 - 0.018 * (1 + Math.sin(t * 5));
const x = cx + Math.cos(t) * rx * k, y = cy + Math.sin(t) * ry * k;
if (!i) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
} }
ctx.stroke(); ctx.restore(); ctx.stroke();
ctx.restore();
} }
/** Small, legible loading/error sprites using the original fallback palettes. */ /** The inked silhouette drawn in a conjuration's palette until its PNG loads. */
export function fallbackCels(name: string, core: string, rim: string): Cel[] { export function fallbackCels(name: string, core: string, rim: string): Cel[] {
const outer: Record<string, string> = { const outer: Record<string, string> = {
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z", fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
@@ -135,5 +175,5 @@ export function fallbackCels(name: string, core: string, rim: string): Cel[] {
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z", rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
}; };
const kind = outer[name] ? name : "spark"; const kind = outer[name] ? name : "spark";
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}]; return [{ d: outer[kind]!, fill: rim, width: 3 }, { d: inside[kind]!, fill: core, width: 1.5 }];
} }
+6 -6
View File
@@ -19,13 +19,13 @@
</script> </script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}> <g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<path d={puff(c.x - 6, c.y, (7) * 1)} fill="#b7a786" class="puff" /> <path d={puff(c.x - 6, c.y, 7)} fill="#b7a786" class="puff" />
<path d={puff(c.x + 5, c.y - 2, (5) * 1)} fill="#b7a786" class="puff back late" /> <path d={puff(c.x + 5, c.y - 2, 5)} fill="#b7a786" class="puff back late" />
<path d={puff(c.x, c.y + 3, (6) * 1)} fill="#b7a786" class="puff later" /> <path d={puff(c.x, c.y + 3, 6)} fill="#b7a786" class="puff later" />
<g class="grit"> <g class="grit">
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} /> <path d={puff(c.x - 4, c.y - 5, 1.3)} />
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} /> <path d={puff(c.x + 6, c.y + 2, 1.6)} />
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} /> <path d={puff(c.x + 1, c.y - 2, 0.9)} />
</g> </g>
</g> </g>
+2 -3
View File
@@ -35,7 +35,7 @@
style={`transform-origin: ${c.x}px ${c.y}px`} style={`transform-origin: ${c.x}px ${c.y}px`}
/> />
{/each} {/each}
<path d={glint(c.x, c.y, (3.5) * 1.6)} class="heart" /> <path d={glint(c.x, c.y, 5.6)} class="heart" />
</g> </g>
<style> <style>
@@ -47,7 +47,7 @@
.spark { animation: spark-out 1.05s ease-out forwards; } .spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; } .trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; } .trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; } .heart { fill: #fffdf0; transform-box: fill-box; transform-origin: center; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out { @keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); } 0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; } 25% { opacity: 1; }
@@ -63,5 +63,4 @@
0% { opacity: 1; transform: scale(0.5); } 0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); } 100% { opacity: 0; transform: scale(3); }
} }
.heart { transform-box: fill-box; transform-origin: center; }
</style> </style>
+4 -5
View File
@@ -8,9 +8,9 @@
<path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" /> <path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" />
<g class="blobs"> <g class="blobs">
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" /> <path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" /> <path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" /> <path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
</g> </g>
<style> <style>
@@ -19,7 +19,7 @@
transform-origin: center; transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards; animation: splat-wobble 0.7s ease-out forwards;
} }
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; } .blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out both; }
.blob.late { animation-delay: 0.06s; } .blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; } .blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble { @keyframes splat-wobble {
@@ -33,5 +33,4 @@
50% { transform: translateY(-7px); } 50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); } 100% { opacity: 0; transform: translateY(2px); }
} }
.blob { animation-fill-mode: both; }
</style> </style>
+5 -6
View File
@@ -11,9 +11,9 @@
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" /> <path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} /> <circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
<g class="rim-dust"> <g class="rim-dust">
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" /> <path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" /> <path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" /> <path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
</g> </g>
<style> <style>
@@ -32,8 +32,9 @@
stroke-width: 1.6; stroke-width: 1.6;
stroke-linecap: round; stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards; animation: hole-open 0.75s ease-out forwards;
transform-box: fill-box; transform-origin: center;
} }
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; } .dust { fill: rgba(160, 150, 130, 0.75); opacity: 0; transform-box: fill-box; transform-origin: center; animation: dust-drift 0.7s ease-out 0.15s forwards; }
.dust.late { animation-delay: 0.24s; } .dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; } .dust.later { animation-delay: 0.32s; }
@keyframes hole-open { @keyframes hole-open {
@@ -51,6 +52,4 @@
30% { opacity: 0.8; } 30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); } 100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
} }
.lip { transform-box: fill-box; transform-origin: center; }
.dust { transform-box: fill-box; transform-origin: center; }
</style> </style>
@@ -51,7 +51,7 @@
{/if} {/if}
<g class="corner-dust"> <g class="corner-dust">
{#each CORNERS as p, i (i)} {#each CORNERS as p, i (i)}
<path d={puff(p.x, p.y, (3.5) * 1)} class={`dust d${i}`} /> <path d={puff(p.x, p.y, 3.5)} class={`dust d${i}`} />
{/each} {/each}
</g> </g>
@@ -86,6 +86,7 @@
fill: rgba(160, 150, 130, 0.85); fill: rgba(160, 150, 130, 0.85);
opacity: 0; opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards; animation: dust-kick 0.6s ease-out 0.85s forwards;
transform-box: fill-box; transform-origin: center;
} }
.dust.d1 { animation-delay: 0.9s; } .dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; } .dust.d2 { animation-delay: 0.95s; }
@@ -128,5 +129,4 @@
30% { opacity: 0.9; } 30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); } 100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
} }
.dust { transform-box: fill-box; transform-origin: center; }
</style> </style>
+4 -4
View File
@@ -11,11 +11,11 @@
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}> <g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} /> <path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
</g> </g>
<path d={glint(c.x, c.y, (3) * 1.7)} class="core" /> <path d={glint(c.x, c.y, 5.1)} class="core" />
<g class="motes"> <g class="motes">
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} /> <path d={glint(c.x - 10, c.y - 9, 2.21)} />
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" /> <path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" /> <path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
</g> </g>
<style> <style>
+1 -2
View File
@@ -32,7 +32,7 @@
stroke-width: 3.2; stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards; animation: ring-out 0.55s ease-out forwards;
} }
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in forwards; } .drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in both; }
.drop.late { animation-delay: 0.07s; } .drop.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; } .drop.later { animation-delay: 0.13s; }
@keyframes pool-spread { @keyframes pool-spread {
@@ -48,5 +48,4 @@
45% { transform: translateY(-8px); } 45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); } 100% { opacity: 0; transform: translateY(6px); }
} }
.drop { animation-fill-mode: both; }
</style> </style>
+5 -6
View File
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
y: c.y * CELL + CELL / 2, y: c.y * CELL + CELL / 2,
}); });
/** Small cel silhouettes, expressed in board coordinates so their animation /** Small cel silhouettes, expressed in board coordinates so their animation
* origins stay attached to the existing cell and edge anchors. */ * origins stay attached to the existing cell and edge anchors. */
export const glint = (x: number, y: number, r: number) => export const glint = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.13} ${y-r*.13} ${x+r*.84} ${y+.04*r} Q ${x+r*.15} ${y+r*.15} ${x+.06*r} ${y+r} Q ${x-r*.15} ${y+r*.13} ${x-r*.88} ${y} Q ${x-r*.14} ${y-r*.15} ${x} ${y-r} Z`; `M ${x} ${y-r} Q ${x+r*0.13} ${y-r*0.13} ${x+r*0.84} ${y+0.04*r} Q ${x+r*0.15} ${y+r*0.15} ${x+0.06*r} ${y+r} Q ${x-r*0.15} ${y+r*0.13} ${x-r*0.88} ${y} Q ${x-r*0.14} ${y-r*0.15} ${x} ${y-r} Z`;
export const puff = (x: number, y: number, r: number) => export const puff = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.3} C ${x-r*1.2} ${y-r*.15} ${x-r*.85} ${y-r*.6} ${x-r*.5} ${y-r*.5} C ${x-r*.65} ${y-r*1.12} ${x+r*.15} ${y-r*1.1} ${x+r*.32} ${y-r*.55} C ${x+r*.95} ${y-r*.75} ${x+r*1.22} ${y+r*.1} ${x+r*.75} ${y+r*.4} C ${x+r*.58} ${y+r*.9} ${x-r*.72} ${y+r*.8} ${x-r} ${y+r*.3} Z`; `M ${x-r} ${y+r*0.3} C ${x-r*1.2} ${y-r*0.15} ${x-r*0.85} ${y-r*0.6} ${x-r*0.5} ${y-r*0.5} C ${x-r*0.65} ${y-r*1.12} ${x+r*0.15} ${y-r*1.1} ${x+r*0.32} ${y-r*0.55} C ${x+r*0.95} ${y-r*0.75} ${x+r*1.22} ${y+r*0.1} ${x+r*0.75} ${y+r*0.4} C ${x+r*0.58} ${y+r*0.9} ${x-r*0.72} ${y+r*0.8} ${x-r} ${y+r*0.3} Z`;
export const drop = (x: number, y: number, r: number) => export const drop = (x: number, y: number, r: number) =>
`M ${x+r*.2} ${y-r*1.7} C ${x+r*.45} ${y-r*.65} ${x+r*1.12} ${y-r*.1} ${x+r*.8} ${y+r*.6} C ${x+r*.45} ${y+r*1.25} ${x-r*.85} ${y+r*.9} ${x-r*.8} ${y+r*.1} C ${x-r*.75} ${y-r*.6} ${x-r*.1} ${y-r} ${x+r*.2} ${y-r*1.7} Z`; `M ${x+r*0.2} ${y-r*1.7} C ${x+r*0.45} ${y-r*0.65} ${x+r*1.12} ${y-r*0.1} ${x+r*0.8} ${y+r*0.6} C ${x+r*0.45} ${y+r*1.25} ${x-r*0.85} ${y+r*0.9} ${x-r*0.8} ${y+r*0.1} C ${x-r*0.75} ${y-r*0.6} ${x-r*0.1} ${y-r} ${x+r*0.2} ${y-r*1.7} Z`;
export const impact = (x: number, y: number, r: number) => export const impact = (x: number, y: number, r: number) =>
`M ${x-r*.06} ${y-r} L ${x+r*.22} ${y-r*.34} ${x+r*.91} ${y-r*.58} ${x+r*.46} ${y-r*.05} ${x+r} ${y+r*.39} ${x+r*.31} ${y+r*.28} ${x+r*.2} ${y+r*.92} ${x-r*.12} ${y+r*.44} ${x-r*.74} ${y+r*.72} ${x-r*.4} ${y+r*.14} ${x-r} ${y-r*.18} ${x-r*.34} ${y-r*.27} Z`; `M ${x-r*0.06} ${y-r} L ${x+r*0.22} ${y-r*0.34} ${x+r*0.91} ${y-r*0.58} ${x+r*0.46} ${y-r*0.05} ${x+r} ${y+r*0.39} ${x+r*0.31} ${y+r*0.28} ${x+r*0.2} ${y+r*0.92} ${x-r*0.12} ${y+r*0.44} ${x-r*0.74} ${y+r*0.72} ${x-r*0.4} ${y+r*0.14} ${x-r} ${y-r*0.18} ${x-r*0.34} ${y-r*0.27} Z`;
export const ward = (x: number, y: number, r: number) => export const ward = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.45} ${y-r*.63} ${x+r*.9} ${y-r*.68} L ${x+r*.82} ${y+r*.18} Q ${x+r*.7} ${y+r*.66} ${x} ${y+r} Q ${x-r*.75} ${y+r*.59} ${x-r*.81} ${y+r*.15} L ${x-r*.9} ${y-r*.68} Q ${x-r*.42} ${y-r*.65} ${x} ${y-r} Z`; `M ${x} ${y-r} Q ${x+r*0.45} ${y-r*0.63} ${x+r*0.9} ${y-r*0.68} L ${x+r*0.82} ${y+r*0.18} Q ${x+r*0.7} ${y+r*0.66} ${x} ${y+r} Q ${x-r*0.75} ${y+r*0.59} ${x-r*0.81} ${y+r*0.15} L ${x-r*0.9} ${y-r*0.68} Q ${x-r*0.42} ${y-r*0.65} ${x} ${y-r} Z`;
+11
View File
@@ -0,0 +1,11 @@
import { prefersReducedMotion } from "svelte/motion";
import { prefs } from "./prefs.svelte";
// Shared by the board, the reel, and the canvas. Svelte's own media query
// tracks the OS setting live and releases its listener when no reactive
// consumer remains.
export const motion = {
get reduced(): boolean { return prefersReducedMotion.current; },
/** Flourishes play only when the player wants them AND the OS allows motion. */
get effects(): boolean { return prefs.flourishes && !prefersReducedMotion.current; },
};
+17 -6
View File
@@ -1,6 +1,6 @@
// Websocket client + reactive session state (Svelte 5 runes). // Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } from "@wizwar/engine"; import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine";
const SERVER_URL = const SERVER_URL =
@@ -23,6 +23,9 @@ export function spellName(cardId: string): string {
} }
} }
/** The compass, for captions and buttons. */
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
export function humanize(e: GameEvent): string | null { export function humanize(e: GameEvent): string | null {
switch (e.type) { switch (e.type) {
case "gameStarted": { case "gameStarted": {
@@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null {
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`; case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`; case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
case "treasureTorn": return e.toFloor case "treasureTorn": return e.toFloor
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!` ? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
@@ -246,9 +249,11 @@ const ACTING = new Set([
"climbedFromPit", "spellTrapped", "climbedFromPit", "spellTrapped",
]); ]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined { function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
const any = e as unknown as Record<string, unknown>; if ("caster" in e && typeof e.caster === "string") return e.caster;
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string; if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string; if ("by" in e && typeof e.by === "string") return e.by;
if ("owner" in e && typeof e.owner === "string") return e.owner;
if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player;
return turnOwner ?? undefined; return turnOwner ?? undefined;
} }
@@ -620,12 +625,18 @@ class Net {
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break; break;
} }
} }
private send(message: unknown): void { private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
} }
/** A toast the table shows for a moment, as it shows the server's refusals. */
flash(message: string): void {
this.error = message;
setTimeout(() => { if (this.error === message) this.error = null; }, 5000);
}
create(name: string): void { create(name: string): void {
this.you = name; this.you = name;
this.spectating = false; this.spectating = false;
+5 -3
View File
@@ -25,6 +25,9 @@ export interface Prefs {
/** The hand of cards stands in a column at the board's left instead of /** The hand of cards stands in a column at the board's left instead of
* lying along the bottom (wide screens only). */ * lying along the bottom (wide screens only). */
handLeft: boolean; handLeft: boolean;
/** The hand's side was picked by hand; until then a wide, short screen
* stands the cards beside the board on its own. */
handLeftChosen: boolean;
/** The first-turn coaching slip has been read and dismissed. */ /** The first-turn coaching slip has been read and dismissed. */
coached: boolean; coached: boolean;
/** The automaton tier was picked by hand (so a finished game leaves it alone). */ /** The automaton tier was picked by hand (so a finished game leaves it alone). */
@@ -42,7 +45,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs { function load(): Prefs {
const fallback: Prefs = { const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null, art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, handLeftChosen: false,
coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false, coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false,
}; };
try { try {
@@ -56,12 +59,11 @@ function load(): Prefs {
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "", wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null, color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
cautions: p.cautions !== false, cautions: p.cautions !== false,
// A first game is against an apprentice; a browser that has finished
// one is promoted to adept unless the tier was picked by hand.
botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice", botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice",
instantReplay: p.instantReplay !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true, liveFp: p.liveFp === true,
handLeft: p.handLeft === true, handLeft: p.handLeft === true,
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
coached: p.coached === true, coached: p.coached === true,
tierChosen: p.tierChosen === true, tierChosen: p.tierChosen === true,
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0, finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
+7
View File
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
]; ];
export const HOW_TO_PLAY: RulesSection[] = [ export const HOW_TO_PLAY: RulesSection[] = [
{
title: "Winning",
body: [
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
"A turn is: walk, then act. Step up to three squares, select a card and choose its target, and End turn. The banner above the board keeps count of your moves and your treasures home.",
],
},
{ {
title: "Moving", title: "Moving",
body: [ body: [