Compare commits
7
Commits
65e88df523
...
6e49fc9020
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e49fc9020 | ||
|
|
85690f0e23 | ||
|
|
10d5c5815d | ||
|
|
5f58a0955a | ||
|
|
ff35e8ef8e | ||
|
|
4cc0423846 | ||
|
|
a071494f4a |
@@ -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
|
||||
addresses): the last 7 rows give requests, human vs bot addresses,
|
||||
socket connects, path mix (/, /clips, /watch, /ws), external
|
||||
referrers (reddit.com, boardgamegeek.com after the 2026-09-03
|
||||
announcement), new rooms, human seats and names, reports/replies,
|
||||
referrers, new rooms, human seats and names, reports/replies,
|
||||
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
|
||||
serviceStarts, ledgerMB). Report day-over-day direction, not raw
|
||||
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
|
||||
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
|
||||
(one per address per day) whose link carried `?ref=<name>` — the tags
|
||||
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers
|
||||
(one per address per day) whose link carried a tag the rollup header
|
||||
names (`?ref=<name>`, a utm_source, Facebook's fbclid); referrers
|
||||
alone can't attribute Reddit or BGG, which send none.
|
||||
**Right now** (on request, or when the announcement is fresh): live
|
||||
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`,
|
||||
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`,
|
||||
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).
|
||||
**Right now** (on request): `wizwar-visitors.sh` prints it — live
|
||||
sockets, rooms touched in the last hour, and today's partial row —
|
||||
see /wizwar-visitors.
|
||||
5. **The table itself**:
|
||||
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
|
||||
- New human names this week: room ledgers' join lines minus bots
|
||||
(Automaton*) and known names, on rooms created in the last 7 days
|
||||
(meta line `createdAt`)
|
||||
- New human names this week: the rollup rows' `humanNames` (host and
|
||||
joiners alike, bots excluded) minus the regulars the visitors skill
|
||||
lists
|
||||
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
|
||||
if any, offer to run the reports desk (`/wizwar-reports`)
|
||||
- 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
|
||||
during deploys are normal, not incidents.
|
||||
- Room memory is evicted after idle (finished 30 min, others 24 h);
|
||||
boot-restores load everything then shed, so post-deploy memory spikes
|
||||
settle within the hour.
|
||||
- Room memory is evicted after idle (finished 30 min, others 24 h), each
|
||||
sleeper leaving a stub on disk; a boot replays only rooms whose stub is
|
||||
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
|
||||
requires it; a reboot there is maintenance, not an outage.
|
||||
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
|
||||
|
||||
@@ -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.
|
||||
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
|
||||
addresses of people (bots split out), front-page vs gallery vs
|
||||
replay views, campaigns (`?ref=` tags on posted links, Facebook's
|
||||
fbclid) and referrers. Reddit and BGG send no referrer.
|
||||
replay views, campaigns (the tags a link carries: `?ref=`, a
|
||||
utm_source, Facebook's fbclid) and referrers. Reddit and BGG send no
|
||||
referrer.
|
||||
- **players today / NEW today** — names seated in rooms created today;
|
||||
NEW means the name's first room ever is today. Known regulars:
|
||||
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
|
||||
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
|
||||
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
|
||||
human chat verbatim. Then the traffic line as a single sentence with
|
||||
the day-over-day direction if a previous row exists.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# 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
|
||||
|
||||
+16
-10
@@ -4,22 +4,26 @@
|
||||
# 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
|
||||
# self-rotating access log cannot keep. Referrers are what browsers send
|
||||
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links
|
||||
# Eric posts, one count per address per day, plus Facebook's fbclid.
|
||||
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both).
|
||||
# (Reddit and BGG send none); campaigns are the tags a link itself carries
|
||||
# — ?ref=<name> on a link Eric posts, a utm_source, or Facebook's fbclid —
|
||||
# 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
|
||||
# (absent = no check-ins).
|
||||
set -u
|
||||
OUT="/var/lib/wizwar/rollup.jsonl"
|
||||
LOG="/var/log/wizwar/rollup.log"
|
||||
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)
|
||||
# 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
|
||||
# and dropped without a word.
|
||||
checkin() {
|
||||
[ -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
|
||||
}
|
||||
|
||||
@@ -31,7 +35,9 @@ d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
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")
|
||||
|
||||
# --- 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()
|
||||
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
|
||||
@@ -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")
|
||||
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)")
|
||||
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
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
m = re.match(r"https?://([^/]+)", ref)
|
||||
host = m.group(1) if m else ""
|
||||
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),
|
||||
# not a reader — and the app answers unknown paths with the page,
|
||||
# so the status cannot tell them apart. Only the pages count.
|
||||
# Scanners forge referrers on paths like /.env, and the app answers
|
||||
# unknown paths with the page itself, so the status cannot tell a
|
||||
# probe from a reader. Only the pages count.
|
||||
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
|
||||
|
||||
# --- the table ---
|
||||
@@ -107,7 +113,7 @@ except OSError: pass
|
||||
# --- vitals (now, at rollup time) ---
|
||||
def sh(cmd):
|
||||
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")
|
||||
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
|
||||
load = sh("cut -d' ' -f1 /proc/loadavg")
|
||||
|
||||
@@ -7,7 +7,7 @@ set -u
|
||||
DAY="${1:-$(date -u +%F)}"
|
||||
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
|
||||
python3 - "$DAY" <<'PY'
|
||||
import json, sys, glob, os, datetime, subprocess
|
||||
import json, sys, glob, datetime, subprocess
|
||||
day = sys.argv[1]
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// BERSERKER for blood, the WORRIER for the shadows between the two.
|
||||
|
||||
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 { wallIgnoringDistance } from "./game";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
@@ -584,31 +584,19 @@ function wallBlastTarget(
|
||||
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
|
||||
* when the clockwork can unlock them; the first such door is reported so the
|
||||
* 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(
|
||||
view: GameView,
|
||||
@@ -634,12 +622,12 @@ export function pathToward(
|
||||
const { to, viaDoor } = step;
|
||||
const k = cellKey(to);
|
||||
if (seen.has(k)) continue;
|
||||
if (view.squareContents[k]?.kind === "stone") continue;
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (hazard === "stone") continue;
|
||||
// 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
|
||||
// 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.
|
||||
if (view.squareContents[cellKey(c)]?.kind === "pit") {
|
||||
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
|
||||
* shed (good counters and attacks are hoarded, as a human would). */
|
||||
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);
|
||||
if (tier.sheds && deficit > 0) {
|
||||
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
|
||||
* it can afford, keeping four life in hand, and nothing at all when it
|
||||
* 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);
|
||||
if (!slow) return draw;
|
||||
return Math.max(0, Math.min(draw, me(view).life - 4));
|
||||
|
||||
@@ -253,6 +253,24 @@ export function stepTarget(
|
||||
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
|
||||
* wall/door/firewall edges the segment crosses and by any `blockedCells`
|
||||
|
||||
+123
-71
@@ -27,7 +27,7 @@ import {
|
||||
bentSightThroughGap,
|
||||
neighbor,
|
||||
stepTarget,
|
||||
opposite,
|
||||
opposite, pitRimExits,
|
||||
} from "./board";
|
||||
import {
|
||||
buildDeck,
|
||||
@@ -80,6 +80,12 @@ export interface PlayerState {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
id: string;
|
||||
cardId: string;
|
||||
@@ -212,6 +218,9 @@ export interface CastStack {
|
||||
reflectedBase?: { damage: number; duration: number };
|
||||
/** The wraith's touch also steals a random card if damage lands. */
|
||||
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. */
|
||||
trapped?: boolean;
|
||||
/** 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. */
|
||||
export const CURRENT_RULES_REV = 20;
|
||||
export const CURRENT_RULES_REV = 21;
|
||||
|
||||
/** 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
|
||||
@@ -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: 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: 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 {
|
||||
@@ -294,11 +304,7 @@ export interface GameState {
|
||||
/** A treasure was just grabbed and its owner holds WARD:
|
||||
* the table waits while they choose to play it "at that time" or not. */
|
||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||
/** 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). */
|
||||
pushPending: {
|
||||
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
|
||||
} | null;
|
||||
pushPending: PushPending | null;
|
||||
/** SLOW DEATH's bites hang while the victim weighs counteractions:
|
||||
* the draw's total is one blow — ABSORB soaks up to three of it,
|
||||
* 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";
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (dustAtEnd(state, from, to)) return false;
|
||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(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 (!stone) return false;
|
||||
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
||||
@@ -836,6 +856,10 @@ export type Command =
|
||||
powerAttackPoints?: number;
|
||||
target?: CastTarget;
|
||||
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: "cancelAmbush"; ambushId: string }
|
||||
@@ -903,6 +927,8 @@ interface Magnitude {
|
||||
}
|
||||
|
||||
interface ResolutionContext {
|
||||
/** The square the blow came from: the attacker's, or their double's. */
|
||||
origin: Cell;
|
||||
state: GameState;
|
||||
events: GameEvent[];
|
||||
attacker: PlayerState;
|
||||
@@ -1069,7 +1095,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
const knock = ctx.stack.params?.knockback ?? 0;
|
||||
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 },
|
||||
@@ -1114,7 +1140,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
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.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;
|
||||
const caster = activePlayer(state);
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
// Rev 16: the charge is measured as far as the legs reach. Before it,
|
||||
// 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);
|
||||
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
|
||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||
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.state.config.deckRev ?? 1) >= 10) {
|
||||
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, reach);
|
||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
|
||||
if (d === 0) return;
|
||||
if (d > legs) {
|
||||
// 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
|
||||
* 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
|
||||
* victim it had just shoved and shove them again with the force left: a
|
||||
* wizard washed one square into a wall was crushed twice. Older games
|
||||
* replay that sweep. */
|
||||
* (rev 20); older games sweep square by square, so a wizard shoved along
|
||||
* the sweep's own line is caught and shoved again. */
|
||||
function sweepWave(
|
||||
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
|
||||
): void {
|
||||
@@ -3700,9 +3719,16 @@ function attachSustained(
|
||||
});
|
||||
}
|
||||
|
||||
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
|
||||
/** Steps along real corridors from one cell to another, searched out to
|
||||
* `limit` steps; beyond that the answer is Infinity. */
|
||||
/** How far a BUTT-HEAD charge is measured: as far as the goat's legs
|
||||
* reach (rev 16); older games search six steps and call the rest
|
||||
* 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 {
|
||||
if (cellKey(from) === cellKey(to)) return 0;
|
||||
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
|
||||
// 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) {
|
||||
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.
|
||||
let content = state.squareContents[cellKey(p.position)];
|
||||
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
|
||||
// enough to slip between the giant's boots (table ruling).
|
||||
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
|
||||
// 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
|
||||
// hangs until they do. Older games shoved straight ahead only.
|
||||
// A game dealt before rev 15 shoves straight ahead when it can, as it
|
||||
// always did; the corner and the fork were refusals before, so every
|
||||
// game may round a corner now.
|
||||
// hangs until they do. Older games (deckRev < 15) shove straight
|
||||
// ahead when that way is open; rounding a corner is allowed in every
|
||||
// game.
|
||||
const fork = (state.config.deckRev ?? 1) >= 15;
|
||||
let landing: Cell | null = 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;
|
||||
if (ledge) {
|
||||
const pitCell = p.position;
|
||||
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
|
||||
const t = stepTarget(view, pitCell, d);
|
||||
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");
|
||||
const { floor, mouths } = pitRimExits(view, pitCell, direction,
|
||||
(c) => state.squareContents[cellKey(c)]?.kind === "stone");
|
||||
// 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
|
||||
// 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
|
||||
// recorded games replay as they were played.
|
||||
// before it a bare step takes the square.
|
||||
const exits = [...floor, ...mouths];
|
||||
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
|
||||
if (exits.length === 0) {
|
||||
@@ -4464,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
p.position = from;
|
||||
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;
|
||||
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);
|
||||
if (!inHand) return err("card not in hand");
|
||||
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];
|
||||
if (!effect) return err(`${def.name} is not implemented yet`);
|
||||
if (effect.kind === "counter") {
|
||||
@@ -5328,7 +5361,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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);
|
||||
return { ok: true, state, events };
|
||||
@@ -5342,12 +5375,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target?.kind === "creature") {
|
||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||
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");
|
||||
}
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, creature.position)
|
||||
: casterLos(state, caster, caster.position, creature.position))) {
|
||||
? bentLos(state, caster, origin, creature.position)
|
||||
: casterLos(state, caster, origin, creature.position))) {
|
||||
return err("no line of sight to the creature");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5362,7 +5395,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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).
|
||||
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" &&
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
|
||||
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");
|
||||
}
|
||||
// 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, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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)]!;
|
||||
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") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, cell)
|
||||
: casterLos(state, caster, caster.position, cell))) {
|
||||
? bentLos(state, caster, origin, cell)
|
||||
: casterLos(state, caster, origin, cell))) {
|
||||
return err("no line of sight to the slime");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5469,7 +5502,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (current !== "wall" && current !== "door") {
|
||||
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");
|
||||
}
|
||||
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, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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);
|
||||
if (problem) return err(problem);
|
||||
@@ -5524,7 +5557,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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) =>
|
||||
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");
|
||||
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 (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");
|
||||
}
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside");
|
||||
const preEvents: GameEvent[] = [];
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position)
|
||||
: casterLos(state, caster, caster.position, target.position);
|
||||
? bentLos(state, caster, origin, target.position)
|
||||
: casterLos(state, caster, origin, target.position);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
// 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,
|
||||
// the spell hits whoever lies that way, or dissipates.
|
||||
if (isBlinded(state, caster) &&
|
||||
cellKey(target.position) !== cellKey(caster.position)) {
|
||||
const dx = target.position.x - caster.position.x;
|
||||
const dy = target.position.y - caster.position.y;
|
||||
cellKey(target.position) !== cellKey(origin)) {
|
||||
const dx = target.position.x - origin.x;
|
||||
const dy = target.position.y - origin.y;
|
||||
const intended: Side =
|
||||
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
@@ -5590,11 +5624,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (rolled !== intended) {
|
||||
const along = state.players.find((p) => {
|
||||
if (!p.alive || p.id === caster.id) return false;
|
||||
const px = p.position.x - caster.position.x;
|
||||
const py = p.position.y - caster.position.y;
|
||||
const px = p.position.x - origin.x;
|
||||
const py = p.position.y - origin.y;
|
||||
const dirOf: Side | 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);
|
||||
state.turn.attackUsed = true;
|
||||
@@ -5616,11 +5650,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: along.id,
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
missEvents.push({
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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 };
|
||||
}
|
||||
@@ -5642,6 +5677,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||
@@ -5654,7 +5690,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifies: mods.amplifies.length,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: target.id,
|
||||
targetCell: target.position,
|
||||
});
|
||||
@@ -5674,7 +5710,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
cardId: inHand.cardId,
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : 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.
|
||||
...(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;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
|
||||
};
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
if (playerId === stack.attackerId) {
|
||||
@@ -6291,6 +6332,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
effect.onResolved({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
origin: defender.position,
|
||||
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
|
||||
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
|
||||
});
|
||||
@@ -6434,6 +6476,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
events,
|
||||
attacker,
|
||||
defender,
|
||||
origin: stack.origin ?? attacker.position,
|
||||
damageDealt,
|
||||
fullyStopped: pipe.fullyStopped,
|
||||
reversed: pipe.reversed,
|
||||
@@ -6472,11 +6515,12 @@ function goAwayRout(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
if (isLockedInPlace(state, defender.id)) return;
|
||||
const from = defender.position;
|
||||
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 moved = 0;
|
||||
for (let i = 0; i < squares; i++) {
|
||||
@@ -6527,9 +6571,10 @@ function knockBack(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
const dx = defender.position.x - attacker.position.x;
|
||||
const dy = defender.position.y - attacker.position.y;
|
||||
const dx = defender.position.x - origin.x;
|
||||
const dy = defender.position.y - origin.y;
|
||||
let dir: Side | null = null;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
|
||||
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;
|
||||
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.finalHand = [...p.hand];
|
||||
state.discard.push(...p.hand.splice(0));
|
||||
@@ -7120,6 +7169,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
}
|
||||
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 });
|
||||
|
||||
if (p.extraTurns > 0) {
|
||||
|
||||
+23
-19
@@ -9,6 +9,7 @@ import {
|
||||
LOS_BLOCKING_CONTENT,
|
||||
type AmbushState,
|
||||
type CastStack,
|
||||
type PushPending,
|
||||
type CreatureState,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
@@ -93,7 +94,7 @@ export interface GameView {
|
||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||
pushPending: GameState["pushPending"];
|
||||
pushPending: PushPending | null;
|
||||
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
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).
|
||||
* 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 me = view.players.find((p) => p.id === view.you);
|
||||
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);
|
||||
// In a dust cloud a wizard sees only their own square (rev 19).
|
||||
if (dustAtEnd(view, me.position, me.position, true)) {
|
||||
out.add(`${me.position.x},${me.position.y}`);
|
||||
if (inDust(view, eye)) {
|
||||
out.add(`${eye.x},${eye.y}`);
|
||||
return out;
|
||||
}
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (dustAtEnd(view, me.position, { x, y })) continue;
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
if (dustAtEnd(view, eye, { x, y })) continue;
|
||||
if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
||||
// 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 };
|
||||
for (let i = unseen.length - 1; i >= 0; i--) {
|
||||
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]!);
|
||||
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
|
||||
* cell and the spell turns there, mirroring the engine's bentLos.
|
||||
*/
|
||||
export function bentSightedCellsFor(view: GameView): Set<string> {
|
||||
const out = sightedCellsFor(view);
|
||||
export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
||||
const out = sightedCellsFor(view, from);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
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);
|
||||
}
|
||||
|
||||
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
|
||||
* a square sees itself still. `standing` asks only whether the viewer's
|
||||
* own square is 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";
|
||||
if (standing) return dust(from);
|
||||
/** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
|
||||
function inDust(view: GameView, cell: Cell): boolean {
|
||||
return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
|
||||
}
|
||||
|
||||
/** Dust at either end kills a sight line; a square sees itself still. */
|
||||
function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
|
||||
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.
|
||||
* 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);
|
||||
if (!me) return null;
|
||||
const cells = Object.keys(view.board.cells);
|
||||
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)) {
|
||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
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";
|
||||
|
||||
/** 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 bot = state.players.find((p) => p.id === "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 });
|
||||
bot.life = 7;
|
||||
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
|
||||
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
|
||||
bot.life = 5;
|
||||
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
|
||||
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
|
||||
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", () => {
|
||||
@@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => {
|
||||
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
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);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 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). */
|
||||
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
|
||||
* 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. */
|
||||
function pushSite(state: GameState, want: "corner" | "fork") {
|
||||
function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
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");
|
||||
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 === "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`);
|
||||
@@ -452,15 +454,15 @@ describe("big man", () => {
|
||||
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 });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
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)!;
|
||||
// A fork where straight ahead is open: no window, straight it is.
|
||||
const fork = pushSite(state, "fork");
|
||||
if (fork.exits.includes(fork.side)) {
|
||||
const fork = pushSite(state, "straight-fork");
|
||||
{
|
||||
giant.position = { ...fork.at };
|
||||
victim.position = { ...fork.mid };
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { 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. */
|
||||
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
|
||||
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
|
||||
* the square opposite the mouth; its other neighbouring squares listed. */
|
||||
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
|
||||
* square behind them and a wall beyond it, and a caster's square beside. */
|
||||
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);
|
||||
// Nobody is hurt yet; the victim walks in on their own turn.
|
||||
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
|
||||
victim.position = { ...caster.position };
|
||||
const v = state.players.find((p) => p.id === victim.id)!;
|
||||
v.position = { ...caster.position };
|
||||
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
|
||||
|
||||
@@ -36,6 +36,7 @@ import { randomBytes } from "node:crypto";
|
||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
||||
import { extname, join, normalize, sep } from "node:path";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import type { Command, PlayerId } from "@wizwar/engine";
|
||||
import {
|
||||
catchUpSteps,
|
||||
@@ -198,10 +199,45 @@ function safeBase(rawHost: string, rawProto: string): string {
|
||||
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 {
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const headline = data.whole ? null : headlineOf(data);
|
||||
const title = data.whole
|
||||
? "The whole tale — a game of Wiz-War, replayed"
|
||||
: headline
|
||||
? `${escapeHtml(headline)} — a Wiz-War instant replay`
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
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.`
|
||||
|
||||
@@ -165,7 +165,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
|
||||
evicted++;
|
||||
try {
|
||||
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) {
|
||||
console.error(`could not write stub for ${id}:`, e);
|
||||
}
|
||||
@@ -185,6 +185,8 @@ interface RoomStub {
|
||||
const sleepingStubs = new Map<string, RoomStub>();
|
||||
/** A stub as it rests on disk — the token map spelled as an object. */
|
||||
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 {
|
||||
const { active, turnHolder, waitKind } = turnFacts(room.state);
|
||||
@@ -797,7 +799,7 @@ export function loadPersistedRooms(): void {
|
||||
if (!stat) continue;
|
||||
const stored = readStubFile<StoredStub>(id);
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { statsDir } from "./store";
|
||||
import { dataRoot } from "./store";
|
||||
|
||||
export interface Share {
|
||||
id: string;
|
||||
@@ -20,7 +20,7 @@ const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
|
||||
const shares = new Map<string, Share>();
|
||||
|
||||
function sharesFile(): string {
|
||||
return join(statsDir(), "shares.jsonl");
|
||||
return join(dataRoot(), "shares.jsonl");
|
||||
}
|
||||
|
||||
export function loadShares(): void {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { statsDir } from "./store";
|
||||
import { dataRoot } from "./store";
|
||||
import type { Room } from "./rooms";
|
||||
|
||||
export interface EngagementStats {
|
||||
@@ -35,7 +35,7 @@ interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
|
||||
roomStages: Record<string, "created" | "started" | "finished">;
|
||||
}
|
||||
|
||||
const FILE = () => join(statsDir(), "stats.json");
|
||||
const FILE = () => join(dataRoot(), "stats.json");
|
||||
|
||||
let data: StatsFile = {
|
||||
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0,
|
||||
|
||||
@@ -86,8 +86,10 @@ function fileFor(roomId: string): string {
|
||||
return join(DATA_DIR, `${roomId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Directory holding stats.json — the parent of the rooms dir. */
|
||||
export function statsDir(): string {
|
||||
/** The data root — the parent of the rooms dir — where everything that
|
||||
* is not a room ledger lives: stats, feedback, stubs, the graveyard, the
|
||||
* clip vault. */
|
||||
export function dataRoot(): string {
|
||||
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
|
||||
// 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 {
|
||||
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
|
||||
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);
|
||||
mkdirSync(stubDir(), { recursive: true });
|
||||
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)
|
||||
* that only the operator's raw read uses; readFeedback keeps the
|
||||
* player-facing subset. */
|
||||
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl");
|
||||
const feedbackFile = () => join(dataRoot(), "feedback.jsonl");
|
||||
|
||||
export function appendFeedback(entry: Record<string, unknown>): void {
|
||||
ensureDataDir();
|
||||
@@ -239,7 +241,7 @@ export function readFeedback(): FeedbackReport[] {
|
||||
export function archiveRoomFile(roomId: string): void {
|
||||
const src = fileFor(roomId);
|
||||
if (!existsSync(src)) return;
|
||||
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||
const graveyard = join(dataRoot(), "rooms-abandoned");
|
||||
mkdirSync(graveyard, { recursive: true });
|
||||
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
|
||||
}
|
||||
@@ -260,7 +262,7 @@ export interface ClipMeta {
|
||||
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,
|
||||
* so a name can never name a path. */
|
||||
|
||||
+174
-66
@@ -1,5 +1,7 @@
|
||||
<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 Board from "./Board.svelte";
|
||||
import LiveFirstPerson from "./LiveFirstPerson.svelte";
|
||||
@@ -15,7 +17,7 @@
|
||||
import { prefs, savePrefs } from "./prefs.svelte";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
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";
|
||||
|
||||
net.connect();
|
||||
@@ -205,7 +207,7 @@
|
||||
}
|
||||
}
|
||||
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
|
||||
if (!prefs.flourishes) return;
|
||||
if (!motion.effects) return;
|
||||
fxCancels.push(scheduleFx(
|
||||
events, view,
|
||||
(fx) => (boardFx = [...boardFx, fx]),
|
||||
@@ -222,6 +224,12 @@
|
||||
fxCancels = [];
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
if (motion.effects) return;
|
||||
fxCancels.forEach((cancel) => cancel());
|
||||
fxCancels = [];
|
||||
boardFx = [];
|
||||
});
|
||||
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
||||
function dismissRolls() {
|
||||
net.dismissRolls();
|
||||
@@ -298,7 +306,6 @@
|
||||
let rotateCW = $state(true);
|
||||
/** pick-lock / master-key: prop the door for others once it opens. */
|
||||
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. */
|
||||
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
|
||||
function castMega(boost: "life" | "movement") {
|
||||
@@ -338,8 +345,9 @@
|
||||
);
|
||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||
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
|
||||
* the draw — but never while a counter, shield, or discard is owed. */
|
||||
/** Picking up an object or sliding into slime ends the turn's actions: the
|
||||
* hand goes quiet until the draw — but never while a counter, shield, or
|
||||
* discard is owed. */
|
||||
const actionsSpent = $derived(
|
||||
view != null && isYourTurn && view.turn.actionsEnded &&
|
||||
!youMustRespond && !youMustShield && !youMustDiscard,
|
||||
@@ -458,9 +466,15 @@
|
||||
* the spell being built (or answered), controls and all — table talk
|
||||
* returns the moment the cast resolves. Phones keep the under-board
|
||||
* strips; a rail below the fold is no place to aim from. */
|
||||
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
|
||||
let wideScreen = $state(wideQuery?.matches ?? true);
|
||||
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
|
||||
const wideQuery = new MediaQuery("(min-width: 901px)", true);
|
||||
const wideScreen = $derived(wideQuery.current);
|
||||
/** 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 &&
|
||||
(selectedCard != null || (youMustRespond && !!view?.stack)));
|
||||
|
||||
@@ -490,7 +504,7 @@
|
||||
* width, capped so the full hand still fits without a scrollbar. */
|
||||
let handEl = $state<HTMLDivElement | null>(null);
|
||||
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,
|
||||
// 11.4rem tall. 5rem covers the masthead and the column's padding.
|
||||
const rem = 16;
|
||||
@@ -511,7 +525,7 @@
|
||||
});
|
||||
$effect(() => {
|
||||
void view?.yourHand.length;
|
||||
void prefs.handLeft;
|
||||
void handLeftOn;
|
||||
const el = handEl;
|
||||
if (el) requestAnimationFrame(() => fitHandCards(el));
|
||||
});
|
||||
@@ -574,10 +588,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment plumbing for a cast command: the chosen mods and the
|
||||
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
|
||||
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
|
||||
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 {
|
||||
applyMods(cmd);
|
||||
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
|
||||
if (attachedNumber && !cmd.numberInstanceIds) {
|
||||
cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
}
|
||||
@@ -648,7 +669,7 @@
|
||||
discardSelection = next;
|
||||
return;
|
||||
}
|
||||
if (actionsSpent) return; // picking up an object ended the turn's actions
|
||||
if (actionsSpent) return;
|
||||
if (!isYourTurn && !yourMoment) {
|
||||
// Live interruption: Interrupt / Opportunity Fire may be played during
|
||||
// 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
|
||||
// while holding one is a miss, never a stride into whatever is there.
|
||||
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`;
|
||||
setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000);
|
||||
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
|
||||
return;
|
||||
}
|
||||
// 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) };
|
||||
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
|
||||
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);
|
||||
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
|
||||
}
|
||||
@@ -1136,7 +1156,7 @@
|
||||
|
||||
function clickPlayer(playerId: string) {
|
||||
if (!view || !yourMoment) return;
|
||||
if (selectedCreature) {
|
||||
if (selectedCreature && !(castingDouble && selectedCard)) {
|
||||
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
||||
selectedCreature = null;
|
||||
return;
|
||||
@@ -1485,6 +1505,11 @@
|
||||
function pass() { dispatch({ type: "pass" }); }
|
||||
|
||||
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) : []);
|
||||
/** Two treasures can share a square: one dispatches, more ask whose. */
|
||||
const treasuresHere = $derived(
|
||||
@@ -1507,10 +1532,10 @@
|
||||
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
||||
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. */
|
||||
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(
|
||||
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
|
||||
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
|
||||
@@ -1521,12 +1546,9 @@
|
||||
.filter((t) => t.kind !== "blocked")
|
||||
.map((t) => cellKey(t.to));
|
||||
});
|
||||
/** The first stride of a first game gets one line on the board itself. */
|
||||
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,
|
||||
);
|
||||
/** The first stride of a first game gets one line above the board —
|
||||
* off the maze, so the walls around the wizard stay in plain sight. */
|
||||
const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
|
||||
const litCells = $derived.by(() => {
|
||||
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
|
||||
if (!view || !selectedDef || !yourMoment) return null;
|
||||
@@ -1534,10 +1556,10 @@
|
||||
if (!me) return null;
|
||||
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
|
||||
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
||||
return eligibleCellsFor(view, selectedCard!.cardId, bent);
|
||||
return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
|
||||
}
|
||||
if (selectedDef.adjacent === true) {
|
||||
const { x, y } = me.position;
|
||||
const { x, y } = castingDouble?.position ?? me.position;
|
||||
return new Set(
|
||||
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
|
||||
.map(([cx, cy]) => `${cx},${cy}`)
|
||||
@@ -1545,7 +1567,7 @@
|
||||
);
|
||||
}
|
||||
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). */
|
||||
@@ -1628,7 +1650,7 @@
|
||||
<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="/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="/?fpv">eyes</a>
|
||||
</header>
|
||||
@@ -1653,7 +1675,7 @@
|
||||
{:else if net.roomId}
|
||||
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
|
||||
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>
|
||||
{#if net.audience > 0}
|
||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||
@@ -1790,7 +1812,7 @@
|
||||
<div class="attack-actions">
|
||||
{#each view.pushPending.exits as d (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>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1877,7 +1899,7 @@
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.flourishes}
|
||||
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 class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.liveFp}
|
||||
@@ -1890,9 +1912,9 @@
|
||||
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.handLeft}
|
||||
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
|
||||
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
|
||||
<input type="checkbox" checked={handLeftOn}
|
||||
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; a wide, short window does this on its own until you choose)</span>
|
||||
</label>
|
||||
<label class="pref-row pref-check">
|
||||
<input type="checkbox" checked={prefs.stepLightAlways}
|
||||
@@ -2225,7 +2247,9 @@
|
||||
<section class="boxlid">
|
||||
<div class="boxlid-inner">
|
||||
<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; }}>
|
||||
about this game — a labor of love
|
||||
</button>
|
||||
@@ -2368,12 +2392,29 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
|
||||
computer wizards seated below, or a mix of both.</div>
|
||||
<div class="invite-row">
|
||||
<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>
|
||||
<ul class="roster">
|
||||
{#each net.players as p (p)}
|
||||
@@ -2392,18 +2433,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if net.spectating}
|
||||
<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.you === net.hostId}
|
||||
{#if net.players.length < 6}
|
||||
<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,
|
||||
@@ -2445,15 +2475,19 @@
|
||||
</div>
|
||||
</section>
|
||||
{:else if view}
|
||||
<div class="game" class:hand-left={prefs.handLeft}>
|
||||
<div class="game" class:hand-left={handLeftOn}>
|
||||
{#snippet tableGuidance()}
|
||||
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
|
||||
<div class="hint-strip">
|
||||
{#if selectedCreature && !selectedDef}
|
||||
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
|
||||
{#if sc}
|
||||
{#if sc.kind === "alter-ego"}
|
||||
<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}
|
||||
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
|
||||
{/if}
|
||||
@@ -2638,6 +2672,20 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<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}>
|
||||
{#if prefs.liveFp && view.you && !net.spectating}
|
||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||
@@ -2677,7 +2725,6 @@
|
||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||
{litCells}
|
||||
spotCells={stepCells}
|
||||
spotCaption={stepCaption}
|
||||
{sightTrace}
|
||||
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||
@@ -2755,13 +2802,13 @@
|
||||
{/if}
|
||||
{#if view.turn.actionsEnded}
|
||||
{#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.
|
||||
{: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}
|
||||
{: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}
|
||||
</div>
|
||||
{#if !prefs.coached && !view.turn.actionsEnded}
|
||||
@@ -2770,7 +2817,8 @@
|
||||
<div class="slip coach">
|
||||
<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.
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2825,6 +2873,9 @@
|
||||
<div class="council-head">The council considers your position…
|
||||
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
|
||||
</div>
|
||||
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
|
||||
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
|
||||
{:else}
|
||||
{#each councilHints as h (h.name)}
|
||||
<div class="council-row">
|
||||
<span class="council-name">{h.glyph} {h.name}</span>
|
||||
@@ -2832,6 +2883,7 @@
|
||||
{h.advice}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3319,7 +3371,7 @@
|
||||
.workshop-mast { margin: 0 1rem 0; }
|
||||
.mast-home { text-decoration: none; }
|
||||
.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-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
|
||||
|
||||
@@ -3438,6 +3490,39 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.95rem;
|
||||
color: #6b5a41;
|
||||
@@ -3729,15 +3814,33 @@
|
||||
flex: 1;
|
||||
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
|
||||
* board while the hint strips and action buttons stay beneath it —
|
||||
* the strip dissolves into the grid so its children place themselves.
|
||||
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
|
||||
@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 {
|
||||
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 .table-edge { display: contents; }
|
||||
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
||||
@@ -3751,17 +3854,12 @@
|
||||
justify-content: center;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
max-height: calc(100dvh - 5rem);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.3rem 0.3rem 0.9rem;
|
||||
}
|
||||
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
|
||||
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; }
|
||||
/* 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; }
|
||||
.game.hand-left .paper-rail { grid-column: 3; }
|
||||
}
|
||||
/* 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
|
||||
@@ -3879,7 +3977,6 @@
|
||||
padding-left: 0.3rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.score-row.active .score-name { font-weight: 700; }
|
||||
.score-temper {
|
||||
font-family: "Caveat", cursive;
|
||||
font-size: 0.85rem;
|
||||
@@ -4034,6 +4131,12 @@
|
||||
gap: 0.8rem;
|
||||
max-width: min(22rem, 92vw);
|
||||
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 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
@@ -4321,6 +4424,10 @@
|
||||
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) {
|
||||
/* One-column phone flow: board, then your controls and hand, THEN the
|
||||
paperwork — cards must never hide below the chronicle. */
|
||||
@@ -4342,6 +4449,7 @@
|
||||
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
|
||||
.mast-title { font-size: 1.05rem; white-space: nowrap; }
|
||||
.mast-sub { display: none; }
|
||||
.mast-invite { display: none; }
|
||||
.mast-leave { font-size: 0.72rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
markedCells = null,
|
||||
litCells = null,
|
||||
spotCells = [],
|
||||
spotCaption = null,
|
||||
markedSector = null,
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
@@ -70,8 +69,6 @@
|
||||
litCells?: Set<string> | null;
|
||||
/** The step light's squares: a pulsing rim on each. */
|
||||
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. */
|
||||
markedSector?: { x: number; y: number } | null;
|
||||
/** 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 wy = Number(key.split(":")[1]?.split(",")[1])}
|
||||
{@const frac = Math.min(1, dmg / 20)}
|
||||
{#if 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} />
|
||||
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{:else}
|
||||
<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}
|
||||
{@const scar = kind === "V"
|
||||
? 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)}
|
||||
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{/each}
|
||||
|
||||
<!-- illusions YOU know are fake: ghostly dashed lines -->
|
||||
@@ -697,17 +690,6 @@
|
||||
{@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" />
|
||||
{/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 -->
|
||||
{#each fearAura as key (key)}
|
||||
{@const bx = Number(key.split(",")[0])}
|
||||
@@ -865,15 +847,6 @@
|
||||
0%, 100% { opacity: 0.35; }
|
||||
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 {
|
||||
fill: rgba(211, 133, 43, 0.18);
|
||||
stroke: #d3852b;
|
||||
@@ -933,7 +906,6 @@
|
||||
stroke: rgba(240, 231, 209, 0.9);
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
><title>{tip}</title></rect>
|
||||
{/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">
|
||||
{#if state === "wall"}
|
||||
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
fill="#bd3b1b" class="fw-bar"
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
|
||||
{/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)}
|
||||
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
|
||||
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
|
||||
|
||||
@@ -223,8 +223,8 @@
|
||||
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>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and obey every rule; a tier changes what
|
||||
they draw and know, never how correctly they play.</p>
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
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
|
||||
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>
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
stroke: #9d8cc1;
|
||||
stroke-width: 0.4;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
animation: shimmer-drift 1.4s ease-in-out infinite;
|
||||
}
|
||||
.illusion-art { pointer-events: none; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Every live event batch plays through the same fx pipeline the
|
||||
// instant replay uses, so spells, doors, and conjurations perform
|
||||
// here first.
|
||||
import { motion } from "./motion";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import type { FpvTarget } from "./fpv/raycast";
|
||||
import { objectArt, tokenArt } from "./art";
|
||||
@@ -161,6 +162,8 @@
|
||||
* still fire — your own magic always turns your head. */
|
||||
let manualUntil = 0;
|
||||
let turning = false;
|
||||
let turnRaf = 0;
|
||||
onDestroy(() => cancelAnimationFrame(turnRaf));
|
||||
|
||||
/** Manual steering holds the director's idle aims off this long. */
|
||||
const STEER_HOLD_MS = 4000;
|
||||
@@ -173,14 +176,15 @@
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
if (motion.reduced) { cam.facing = to; turning = false; return; }
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (smoothstep(w));
|
||||
if (w < 1) requestAnimationFrame(tick);
|
||||
if (w < 1) turnRaf = requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
turnRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
/** Pane clicks: a possessed mount claims them first — adjacent floor
|
||||
* 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 }>>({});
|
||||
|
||||
// --- 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,
|
||||
// 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;
|
||||
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
$effect(() => {
|
||||
const enabled = motion.effects;
|
||||
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;
|
||||
untrack(() => {
|
||||
if (b.n === playedBatch || !me) return;
|
||||
@@ -240,6 +253,7 @@
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
const starter = setTimeout(() => {
|
||||
fxTimers.delete(starter);
|
||||
if (!motion.effects) return;
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
const ender = setTimeout(() => {
|
||||
fxTimers.delete(ender);
|
||||
@@ -251,7 +265,7 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||
onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
@@ -259,6 +273,7 @@
|
||||
const v = view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (motion.reduced) { actorPos = {}; return; }
|
||||
if (!before || before === v || !me) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
@@ -283,6 +298,7 @@
|
||||
// --- The camera: the director's ladder, live. ------------------------
|
||||
let directedBatch = 0;
|
||||
$effect(() => {
|
||||
const reduced = motion.reduced;
|
||||
const b = batch;
|
||||
const m = me;
|
||||
if (!m) { camReady = false; return; }
|
||||
@@ -385,7 +401,9 @@
|
||||
// pointed your own head is where it stays, brick or no brick.
|
||||
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;
|
||||
camReady = true;
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "./motion";
|
||||
import Board from "./Board.svelte";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { untrack } from "svelte";
|
||||
@@ -8,7 +9,6 @@
|
||||
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** 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
|
||||
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||||
@@ -90,12 +90,12 @@
|
||||
const dieOf = (st: { events: GameEvent[] } | undefined) =>
|
||||
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. */
|
||||
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);
|
||||
$effect(() => {
|
||||
const i = Math.min(idx, steps.length - 1);
|
||||
const d = dieOf(steps[i]);
|
||||
if (!d || !prefs.flourishes) { die = null; return; }
|
||||
if (!d || !motion.effects) { die = null; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = DIE_MS / speed;
|
||||
const who = d.player ?? "The maze";
|
||||
@@ -169,6 +169,7 @@
|
||||
$effect(() => {
|
||||
const who = povId;
|
||||
if (!fp) { lastPov = null; return; }
|
||||
if (!motion.effects) { lastPov = who; return; }
|
||||
if (lastPov !== null && lastPov !== who) {
|
||||
const p = step.view.players.find((x) => x.id === who);
|
||||
const fx: FpFx = {
|
||||
@@ -179,7 +180,7 @@
|
||||
fpFx = [...untrack(() => fpFx), fx];
|
||||
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
|
||||
lastPov = who;
|
||||
return () => clearTimeout(t);
|
||||
return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
|
||||
}
|
||||
lastPov = who;
|
||||
});
|
||||
@@ -210,7 +211,7 @@
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
const step = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!step || !prefs.flourishes) return;
|
||||
if (!step || !motion.effects) return;
|
||||
let cancel = () => {};
|
||||
const wait = setTimeout(() => {
|
||||
cancel = scheduleFx(
|
||||
@@ -237,7 +238,7 @@
|
||||
$effect(() => {
|
||||
const i = Math.min(idx, steps.length - 1);
|
||||
const st = steps[i];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
if (!fp || !st || !motion.effects) return;
|
||||
const plan = camPlan;
|
||||
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
@@ -280,6 +281,7 @@
|
||||
const v = st.view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (motion.reduced) { actorPos = {}; return; }
|
||||
if (!before || before === v) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
@@ -316,6 +318,7 @@
|
||||
"treasurePickedUp", "treasureDropped", "objectDropped"]);
|
||||
let camReady = false;
|
||||
$effect(() => {
|
||||
const reduced = motion.reduced;
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
const me = v.players.find((p) => p.id === povId);
|
||||
@@ -360,6 +363,7 @@
|
||||
cutawayShot = true;
|
||||
const i2 = Math.min(idx, steps.length - 1);
|
||||
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||||
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
|
||||
heldView = pv;
|
||||
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
|
||||
if (pv) {
|
||||
@@ -483,6 +487,13 @@
|
||||
}
|
||||
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) {
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
return [0.06, 0.38, 0.71].map((t, i) => {
|
||||
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)}`;
|
||||
}).join(' ');
|
||||
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(" ");
|
||||
};
|
||||
|
||||
/** An irregular fracture with branches that become longer as damage increases. */
|
||||
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 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. */
|
||||
export const webThreads = (r: number) => {
|
||||
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. */
|
||||
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}`;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// GameView. Columns of wall shaded by distance and facing; token art
|
||||
// billboarded for whatever stands in the corridors, occluded per column
|
||||
// by the same depth buffer the walls wrote.
|
||||
import { motion } from "../motion";
|
||||
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 { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
|
||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||
@@ -197,6 +198,10 @@
|
||||
}
|
||||
|
||||
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");
|
||||
if (!ctx) return;
|
||||
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.
|
||||
let ex = x, ey = y;
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "shake") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -221,7 +226,7 @@
|
||||
let growing: Record<string, number> | undefined;
|
||||
const spawn: Record<string, number> = {};
|
||||
const surging: Record<number, number> = {};
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind === "door") {
|
||||
const a = doorOpenness((time - f.t0) / f.dur);
|
||||
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
|
||||
// its dashed diamond — the same warp-walking yardstick behind both.
|
||||
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++) {
|
||||
const below = row > half;
|
||||
const dz = below ? row - half : half - row;
|
||||
@@ -440,7 +445,7 @@
|
||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||
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.fillRect(col, top, 1, wallH);
|
||||
dark *= 0.5; // the fire lights itself
|
||||
@@ -448,13 +453,13 @@
|
||||
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||
// The same tell the board gives: an untested illusion's face
|
||||
// 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.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
||||
// 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.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
|
||||
}
|
||||
@@ -464,13 +469,13 @@
|
||||
}
|
||||
if (hit.warped) {
|
||||
// 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.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||
// 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.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);
|
||||
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;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -545,13 +550,13 @@
|
||||
const w = s.right - s.left;
|
||||
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),
|
||||
"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 &&
|
||||
litCells.has(`${s.cell.x},${s.cell.y}`) &&
|
||||
!(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),
|
||||
"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
|
||||
// additively. Either way translucency applies.
|
||||
@@ -607,7 +612,7 @@
|
||||
const vTop = half - vh / 2;
|
||||
const wt = textures.warp!;
|
||||
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 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;
|
||||
@@ -636,7 +641,7 @@
|
||||
const top0 = half + full / 2 - grown;
|
||||
const tex = textures[ri.kind] ?? textures.wall!;
|
||||
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)) {
|
||||
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
|
||||
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 gTop = half - gHft / 2;
|
||||
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)) {
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
|
||||
@@ -695,7 +700,7 @@
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// 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;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -709,7 +714,7 @@
|
||||
// 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
|
||||
// with a comic starburst.
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "fist") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -720,7 +725,7 @@
|
||||
ctx.rotate(pose.rotation);
|
||||
paintFist(ctx, pose.size, f.swing);
|
||||
ctx.restore();
|
||||
if (pose.radius > H * 0.19 * 0.01) {
|
||||
if (pose.radius > 0.5) {
|
||||
ctx.save();
|
||||
ctx.translate(pose.impactX, pose.impactY);
|
||||
ctx.rotate(-0.14);
|
||||
@@ -731,7 +736,7 @@
|
||||
|
||||
// A change of eyes: the slate wipes across, names the wizard whose
|
||||
// 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;
|
||||
const [l, r] = slateBand((time - f.t0) / f.dur);
|
||||
if (r <= l) continue;
|
||||
@@ -772,12 +777,6 @@
|
||||
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. */
|
||||
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "../motion";
|
||||
import { smoothstep } from "./fx3d";
|
||||
// The first-person workshop (/?fpv): a real dealt game, one wizard's
|
||||
// eyes, free-fly controls. No rules run here — the camera walks where
|
||||
@@ -145,7 +146,7 @@
|
||||
const tick = (now: number) => {
|
||||
if (!anim) startNext(now);
|
||||
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") {
|
||||
x = anim.fx + (anim.tx - anim.fx) * 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.
|
||||
const scriptName = q.get("script");
|
||||
const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
|
||||
let scriptSteps: ScreenplayStep[] = [];
|
||||
let scriptError: string | null = null;
|
||||
function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
|
||||
if (scriptName && !scriptPlay) {
|
||||
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`;
|
||||
} else if (scriptPlay) {
|
||||
return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
|
||||
}
|
||||
try {
|
||||
scriptSteps = buildScreenplaySteps(scriptPlay);
|
||||
return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
|
||||
} 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).
|
||||
const MM = 9;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// camera shakes for the raycaster to draw. What happens ACROSS the room
|
||||
// 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 { CELL } from "../fx-sprites/geom";
|
||||
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];
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function growProgress(p: number): number {
|
||||
if (p <= 0) return 0.01;
|
||||
@@ -306,7 +304,7 @@ export function fpFxForEvents(
|
||||
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]> = {
|
||||
fireball: ["#ffe27a", "#e0431a"],
|
||||
bolt: ["#ffffff", "#7ab0ff"],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** 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 };
|
||||
const INK = "#2b2218";
|
||||
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
|
||||
|
||||
/** Poses modeled on the original right-facing and oncoming fist emoji:
|
||||
* folded fingers face the viewer; the thumb stays outside their curl. */
|
||||
/** Two fist poses: a punch thrown (wrist left, knuckles right) and one
|
||||
* arriving (knuckles to the viewer). The thumb stays outside the curl. */
|
||||
export const FISTS: Record<"out" | "in", Cel[]> = {
|
||||
out: [
|
||||
// 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[] = [
|
||||
{ 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 },
|
||||
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
|
||||
ctx.restore();
|
||||
}
|
||||
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
|
||||
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore();
|
||||
}
|
||||
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
|
||||
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore();
|
||||
ctx.save();
|
||||
ctx.scale(size / 100, size / 100);
|
||||
paintCels(ctx, FISTS[swing]);
|
||||
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) {
|
||||
const ease = (v: number) => v * v * (3 - 2 * v);
|
||||
// Clamp before easing: otherwise the incoming fist reverses past p=.45.
|
||||
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45));
|
||||
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55;
|
||||
const tx = W * .56, ty = H * .5;
|
||||
const land = out ? .36 : .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 ease = smoothstep;
|
||||
// Clamp before easing: past 0.45 the arriving fist would reverse.
|
||||
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 * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
|
||||
const tx = W * 0.56, ty = H * 0.5;
|
||||
const land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
|
||||
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,
|
||||
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a),
|
||||
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1,
|
||||
rotation: out ? -.6 : .15, radius: H * .19 * pop,
|
||||
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 };
|
||||
size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
|
||||
alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
|
||||
rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
|
||||
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) {
|
||||
const hub = [left + w * .5, top + h * .45];
|
||||
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 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]; };
|
||||
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round";
|
||||
type Pt = [number, number];
|
||||
const hub: Pt = [left + w * 0.5, top + h * 0.45];
|
||||
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[])
|
||||
.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();
|
||||
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); }
|
||||
for (const t of [.27,.53,.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]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
|
||||
for (const r of rim) {
|
||||
ctx.moveTo(hub[0], hub[1]);
|
||||
ctx.lineTo(r[0], r[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();
|
||||
// Three sagging rings, each strand bowed toward the hub.
|
||||
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) {
|
||||
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill();
|
||||
export function paintFloorRing(
|
||||
ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(${rgb},${fill})`;
|
||||
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const segments = [[.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, k = 1 - .018 * (1 + Math.sin(t*5));
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
|
||||
ctx.beginPath();
|
||||
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[] {
|
||||
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",
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</script>
|
||||
|
||||
<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 + 5, c.y - 2, (5) * 1)} 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 - 6, c.y, 7)} fill="#b7a786" class="puff" />
|
||||
<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)} fill="#b7a786" class="puff later" />
|
||||
<g class="grit">
|
||||
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} />
|
||||
<path d={puff(c.x - 4, c.y - 5, 1.3)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, 1.6)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, 0.9)} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||
/>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
@@ -47,7 +47,7 @@
|
||||
.spark { animation: spark-out 1.05s ease-out forwards; }
|
||||
.trail.late, .spark.late { animation-delay: 0.07s; }
|
||||
.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 {
|
||||
0% { opacity: 0; transform: scale(0.15); }
|
||||
25% { opacity: 1; }
|
||||
@@ -63,5 +63,4 @@
|
||||
0% { opacity: 1; transform: scale(0.5); }
|
||||
100% { opacity: 0; transform: scale(3); }
|
||||
}
|
||||
.heart { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -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" />
|
||||
<g class="blobs">
|
||||
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" />
|
||||
<path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -19,7 +19,7 @@
|
||||
transform-origin: center;
|
||||
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.later { animation-delay: 0.12s; }
|
||||
@keyframes splat-wobble {
|
||||
@@ -33,5 +33,4 @@
|
||||
50% { transform: translateY(-7px); }
|
||||
100% { opacity: 0; transform: translateY(2px); }
|
||||
}
|
||||
.blob { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<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`} />
|
||||
<g class="rim-dust">
|
||||
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" />
|
||||
<path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -32,8 +32,9 @@
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
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.later { animation-delay: 0.32s; }
|
||||
@keyframes hole-open {
|
||||
@@ -51,6 +52,4 @@
|
||||
30% { opacity: 0.8; }
|
||||
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>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{/if}
|
||||
<g class="corner-dust">
|
||||
{#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}
|
||||
</g>
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
fill: rgba(160, 150, 130, 0.85);
|
||||
opacity: 0;
|
||||
animation: dust-kick 0.6s ease-out 0.85s forwards;
|
||||
transform-box: fill-box; transform-origin: center;
|
||||
}
|
||||
.dust.d1 { animation-delay: 0.9s; }
|
||||
.dust.d2 { animation-delay: 0.95s; }
|
||||
@@ -128,5 +129,4 @@
|
||||
30% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
|
||||
}
|
||||
.dust { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<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`} />
|
||||
</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">
|
||||
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" />
|
||||
<path d={glint(c.x - 10, c.y - 9, 2.21)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
stroke-width: 3.2;
|
||||
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.later { animation-delay: 0.13s; }
|
||||
@keyframes pool-spread {
|
||||
@@ -48,5 +48,4 @@
|
||||
45% { transform: translateY(-8px); }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
.drop { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
|
||||
y: c.y * CELL + CELL / 2,
|
||||
});
|
||||
|
||||
|
||||
/** Small cel silhouettes, expressed in board coordinates so their animation
|
||||
* origins stay attached to the existing cell and edge anchors. */
|
||||
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) =>
|
||||
`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) =>
|
||||
`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) =>
|
||||
`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) =>
|
||||
`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`;
|
||||
|
||||
@@ -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; },
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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";
|
||||
|
||||
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 {
|
||||
switch (e.type) {
|
||||
case "gameStarted": {
|
||||
@@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||
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 "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
|
||||
? `${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!`;
|
||||
@@ -246,9 +249,11 @@ const ACTING = new Set([
|
||||
"climbedFromPit", "spellTrapped",
|
||||
]);
|
||||
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
||||
const any = e as unknown as Record<string, unknown>;
|
||||
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string;
|
||||
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string;
|
||||
if ("caster" in e && typeof e.caster === "string") return e.caster;
|
||||
if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -626,6 +631,12 @@ class Net {
|
||||
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 {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface Prefs {
|
||||
/** The hand of cards stands in a column at the board's left instead of
|
||||
* lying along the bottom (wide screens only). */
|
||||
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. */
|
||||
coached: boolean;
|
||||
/** 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 {
|
||||
const fallback: Prefs = {
|
||||
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,
|
||||
};
|
||||
try {
|
||||
@@ -56,12 +59,11 @@ function load(): Prefs {
|
||||
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
|
||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||
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",
|
||||
instantReplay: p.instantReplay !== false,
|
||||
liveFp: p.liveFp === true,
|
||||
handLeft: p.handLeft === true,
|
||||
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
|
||||
coached: p.coached === true,
|
||||
tierChosen: p.tierChosen === true,
|
||||
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
|
||||
|
||||
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: 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",
|
||||
body: [
|
||||
|
||||
Reference in New Issue
Block a user