Compare commits
@@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
|
|||||||
any seq to inspect full state. To ask why a bot did something, rebuild
|
any seq to inspect full state. To ask why a bot did something, rebuild
|
||||||
the state at its turn and call `automatonCommand(viewFor(state, id),
|
the state at its turn and call `automatonCommand(viewFor(state, id),
|
||||||
style, tier)` — and if its choice differs from the ledger, the engine
|
style, tier)` — and if its choice differs from the ledger, the engine
|
||||||
refused it and the fallback burned the turn (the X2XN pattern).
|
refused it and the fallback burned the turn.
|
||||||
|
|
||||||
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
||||||
each, and report phase / round / humans vs bots / last command's `at`.
|
each, and report phase / round / humans vs bots / last command's `at`.
|
||||||
@@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`.
|
|||||||
Strict-replays every production ledger against the local engine; one
|
Strict-replays every production ledger against the local engine; one
|
||||||
refused command fails. A room whose ledger no longer replays becomes
|
refused command fails. A room whose ledger no longer replays becomes
|
||||||
unreachable after restart. Rules changes while games are live need a
|
unreachable after restart. Rules changes while games are live need a
|
||||||
`deckRev` bump plus an engine gate (the convention survives the 2026-08
|
`deckRev` bump plus an engine gate. Production deckRev is currently 1,
|
||||||
reset to rev 1).
|
so low numbers are not stale.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: wizwar-duel
|
||||||
|
description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playing Wiz-War against Eric
|
||||||
|
|
||||||
|
You are a PLAYER, not the automaton. Read the board, reason, and choose
|
||||||
|
every command yourself. Eric plays in his browser; you play through the
|
||||||
|
CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves —
|
||||||
|
each of his messages is your cue to look and act.
|
||||||
|
|
||||||
|
## The seat client
|
||||||
|
|
||||||
|
Run from the repo root (needs the workspace's `ws` package):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/claude-seat.mjs join <ROOM> Claude # take a seat (token saved)
|
||||||
|
node tools/claude-seat.mjs view # board + hand + last events
|
||||||
|
node tools/claude-seat.mjs do '<command json>' # one engine command
|
||||||
|
node tools/claude-seat.mjs chat "text" # table talk
|
||||||
|
```
|
||||||
|
|
||||||
|
Seat state persists in `/tmp/wizwar-claude-seat.json` (override:
|
||||||
|
`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point
|
||||||
|
elsewhere). Eric creates the room, gives you the code, and starts the
|
||||||
|
game after you join.
|
||||||
|
|
||||||
|
Commands are the engine's `Command` union (packages/engine/src/game.ts):
|
||||||
|
`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional
|
||||||
|
`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`,
|
||||||
|
`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`,
|
||||||
|
`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing —
|
||||||
|
probing legality is free, so when unsure, try it and read the error.
|
||||||
|
|
||||||
|
## Reading the map
|
||||||
|
|
||||||
|
- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1.
|
||||||
|
- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes,
|
||||||
|
two-letter codes are square contents (SA safe, RO rosebush, ST stone,
|
||||||
|
DU dust). `D` on a line is a door, `—` a wall, blank is open.
|
||||||
|
- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and
|
||||||
|
moving N carries you to (2,9). **The warp rides one specific side of
|
||||||
|
its square** — check which before you walk (a wrong guess burns moves;
|
||||||
|
ask me how I know).
|
||||||
|
- Dimensional-warp TOKENS (cast by players) are separate: stand on one
|
||||||
|
and `warpStep`. Creatures use `creatureWarpStep`.
|
||||||
|
|
||||||
|
## Hard-won rules knowledge (each cost me something in game one)
|
||||||
|
|
||||||
|
- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with
|
||||||
|
the grab as your last act, never mid-plan. `dropTreasure` at home too.
|
||||||
|
- You can pick up ANY floor treasure, including your own stolen-and-
|
||||||
|
delivered one sitting on the enemy's home square. Repossession is real.
|
||||||
|
- THIEF and punches need same-square; THIEF steals a *named* card and
|
||||||
|
fizzles (card spent) on a wrong guess.
|
||||||
|
- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked
|
||||||
|
doors shrink its reach; rev 5+ refuses impossible destinations.
|
||||||
|
- Doors relock behind you when you pass through (rev 4+). Your own
|
||||||
|
escape route can seal itself — and seal pursuers out.
|
||||||
|
- LOS threads through rim warps: you can be seen (and shot) through a
|
||||||
|
warp mouth from the far side of the board. Camping one square off the
|
||||||
|
mouth keeps you hidden.
|
||||||
|
- lock-in-place freezes movement AND warpStep for a full turn; REUSE
|
||||||
|
SPELL retrieves only your LAST cast spell, enabling one re-lock.
|
||||||
|
- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack)
|
||||||
|
triggers on LOS *entry* transitions only — someone already in sight
|
||||||
|
never springs it. It survives forever and fires out of turn.
|
||||||
|
- Walking-dead bleeds ½ life per space moved, permanently. Once cursed,
|
||||||
|
every plan must be priced in steps.
|
||||||
|
|
||||||
|
## Game-two scars (each cost me the game or nearly)
|
||||||
|
|
||||||
|
- **RECOUNT THE SCOREBOARD EVERY TURN**: how many treasures does each
|
||||||
|
player have banked AT HOME right now? I sprinted a "winning" delivery
|
||||||
|
in game two that was actually my FIRST of two — Eric had un-banked me
|
||||||
|
five rounds earlier and let me monologue. Banked counts, not vibes.
|
||||||
|
- A banked treasure is NOT safe: anyone can pick it up off your home
|
||||||
|
square and carry it away. Guard the bank or count it as contested.
|
||||||
|
- LOS is FREE-ANGLE center-to-center (the rulebook's rule), not rows
|
||||||
|
and columns: razor diagonals threading open gaps are legal for
|
||||||
|
everyone — including at you. The board draws bent/straight traces.
|
||||||
|
- ILLUSIONARY ATTACK deals its imitated spell's damage if NOT
|
||||||
|
countered; ANY counteraction dispels it entirely. Counter it with
|
||||||
|
the cheapest thing you hold; never "call the bluff" by passing.
|
||||||
|
- Never trust your ASCII parse of the map for multi-step plans: run a
|
||||||
|
BFS over `view.board.edges` (see tools/claude-seat.mjs view JSON, or
|
||||||
|
write a quick router) before spending a number card on a sprint. I
|
||||||
|
burned a 7-move turn bouncing off six misread walls.
|
||||||
|
- Eric counter-plays YOUR bot upgrades: he summoned a troll bodyguard
|
||||||
|
specifically against the archmage's exile-the-winner instinct, and
|
||||||
|
relocated MY home sector mid-carry. Expect meta-warfare.
|
||||||
|
- In 4p with automatons: the bots are chaos agents — they stun, exile,
|
||||||
|
disease, and con. Herd them at Eric; never assume they're on script.
|
||||||
|
|
||||||
|
## Game-three scars (lost in four rounds)
|
||||||
|
|
||||||
|
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
|
||||||
|
won two games
|
||||||
|
with quietly-placed wormholes ending beside his home. The client
|
||||||
|
prints a !! line for them; treat any new token pair as a five-alarm
|
||||||
|
fire and recompute both players' delivery distances through it.
|
||||||
|
- **Never lead the attack against Kestrel.** Three games, three saved
|
||||||
|
counters sprung at the perfect moment (anti-anti, full-shield bait,
|
||||||
|
full-reflection held three rounds for my lightning). Force his
|
||||||
|
counters out with throwaways or positional threats before committing
|
||||||
|
a real spell; his patience is perfect and he telegraphs nothing.
|
||||||
|
- DESTROY WALL showers the ADJACENT caster with rubble (4 damage) —
|
||||||
|
cast it from range, never on your own square's edge.
|
||||||
|
- Wading a rosebush costs 3. A reflected LIGHTNING BLAST still stuns
|
||||||
|
its caster even at 1 reflected damage — the stun is the payload, and
|
||||||
|
a lost turn against Kestrel is a lost game.
|
||||||
|
- Grab-turn-one openings feel great and prove nothing: deliveries are
|
||||||
|
the only score. He banked quietly both times while I showboated.
|
||||||
|
|
||||||
|
## Playing well
|
||||||
|
|
||||||
|
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
||||||
|
my full shield, and won game one with a home-to-home dimensional-warp
|
||||||
|
superhighway. Infrastructure beats sprinting — watch what he builds,
|
||||||
|
and consider your own wormhole early.
|
||||||
|
- Win = 2 enemy treasures delivered to your home, or last wizard alive.
|
||||||
|
Track BOTH players' step-counted delivery timelines every turn; the
|
||||||
|
race is decided in tempo, not damage.
|
||||||
|
- Hold counters (full-shield, absorb) for permanent curses and lethal
|
||||||
|
damage. Absorb soaks 3 points; it cannot touch durations.
|
||||||
|
- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or
|
||||||
|
draw, and never announce plans. (Game one: I narrated my hand like a
|
||||||
|
debug log until he asked why I was making it easy.) Trash talk freely
|
||||||
|
— about the board, never your hand.
|
||||||
|
- Send `chat` for table talk at dramatic moments; it's half the fun.
|
||||||
|
|
||||||
|
## Cadence
|
||||||
|
|
||||||
|
1. On "go": `view`, read HIS events since your last turn (the client
|
||||||
|
prints only NEW events; `view` shows a short history tail).
|
||||||
|
2. Think about the whole board — his timeline, yours, threats, LOS.
|
||||||
|
3. Execute your commands one at a time, checking errors.
|
||||||
|
4. `endTurn` with a draw that respects the 7-card hand limit.
|
||||||
|
5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are
|
||||||
|
not. When a stack waits on him, say so and wait for "go".
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { cardDef, type CardInstance } from "./cards";
|
import { cardDef, type CardInstance } from "./cards";
|
||||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||||
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
||||||
|
import { wallIgnoringDistance } from "./game";
|
||||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||||
|
|
||||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||||
@@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null {
|
|||||||
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
|
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
||||||
|
// raider closing on the bank gets the same road-lengthening treatment.
|
||||||
|
const self = me(view);
|
||||||
|
const banked = view.treasures.find(
|
||||||
|
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
|
||||||
|
if (banked) {
|
||||||
|
for (const e of livingEnemies(view)) {
|
||||||
|
const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y);
|
||||||
|
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (threats.length === 0) return null;
|
if (threats.length === 0) return null;
|
||||||
|
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
@@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Never brick the square the treasure needs to stay reachable on.
|
// Never brick the square the treasure needs to stay reachable on.
|
||||||
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
// TACKS are scattered at the caster's feet ("you must be adjacent"),
|
||||||
|
// not thrown — offering a distant square wedges the whole brain on
|
||||||
|
// an eternally refused cast.
|
||||||
|
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
|
||||||
|
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
|
||||||
|
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||||
consider(
|
consider(
|
||||||
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
|
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
|
||||||
{ avoidCell: cellKey(b) },
|
{ avoidCell: cellKey(b) },
|
||||||
@@ -597,7 +614,7 @@ function pathToward(
|
|||||||
const hazard = view.squareContents[k]?.kind;
|
const hazard = view.squareContents[k]?.kind;
|
||||||
if (!opts.throughHazards &&
|
if (!opts.throughHazards &&
|
||||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||||
hazard === "rosebush" || hazard === "slime")) continue;
|
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||||
next.push(to);
|
next.push(to);
|
||||||
}
|
}
|
||||||
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
|
|||||||
}
|
}
|
||||||
for (const t of view.treasures) {
|
for (const t of view.treasures) {
|
||||||
if (!t.position || t.carriedBy) continue;
|
if (!t.position || t.carriedBy) continue;
|
||||||
if (t.owner === view.you) continue;
|
if (t.owner === view.you) {
|
||||||
|
// REPOSSESSION: my own gold banked at an enemy's home is a point on
|
||||||
|
// THEIR scoreboard. Marching to take it back is worth the detour
|
||||||
|
// only when that enemy is one delivery from winning — constant
|
||||||
|
// re-stealing farms nothing and stalls the whole table.
|
||||||
|
const banker = view.players.find(
|
||||||
|
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
|
||||||
|
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||||
goals.add(cellKey(t.position));
|
goals.add(cellKey(t.position));
|
||||||
}
|
}
|
||||||
@@ -645,6 +671,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
|
|||||||
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
|
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Would this wizard's delivery, landed, win them the game right now? */
|
||||||
|
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
|
||||||
|
if (!p.carriedTreasureId) return false;
|
||||||
|
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
|
||||||
|
if (!carried || carried.owner === p.id) return false;
|
||||||
|
return view.treasures.some(
|
||||||
|
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
|
||||||
|
}
|
||||||
|
|
||||||
/** The wizard making off with MY gold, if any. */
|
/** The wizard making off with MY gold, if any. */
|
||||||
function thiefOfMine(view: GameView) {
|
function thiefOfMine(view: GameView) {
|
||||||
const carriers = new Set(
|
const carriers = new Set(
|
||||||
@@ -722,7 +757,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
return { type: "pass" };
|
return { type: "pass" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
|
// The LAST counter in hand is a treasure of its own: at a healthy life
|
||||||
|
// total it waits for something big rather than answering every jab.
|
||||||
|
// (Lethal blows override this below.)
|
||||||
|
const counterCount = view.yourHand.filter((c) => {
|
||||||
|
const t = cardDef(c.cardId).cardType;
|
||||||
|
return t === "counteraction" || t === "neutral/counteraction";
|
||||||
|
}).length;
|
||||||
|
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
|
||||||
|
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
|
||||||
const attackId = stack.attackCard?.cardId ?? null;
|
const attackId = stack.attackCard?.cardId ?? null;
|
||||||
const atk = attackId ? ATTACKS[attackId] : null;
|
const atk = attackId ? ATTACKS[attackId] : null;
|
||||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||||
@@ -749,19 +792,26 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
? Math.max(2, stack.numberValue ?? 2)
|
? Math.max(2, stack.numberValue ?? 2)
|
||||||
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||||
: 1;
|
: 1;
|
||||||
|
// A blow that would be the last one outranks every thrift threshold:
|
||||||
|
// when the incoming points reach the clockwork's life, any counter
|
||||||
|
// that can save it is cheap at the price.
|
||||||
|
const lethal = !affliction && incoming >= me(view).life;
|
||||||
|
// 9 clears every thrift threshold below: a killing blow is answered
|
||||||
|
// with whatever can answer it, price be damned.
|
||||||
|
const weight = lethal ? Math.max(incoming, 9) : incoming;
|
||||||
if (stack.kind === "spell") {
|
if (stack.kind === "spell") {
|
||||||
// REVERSE eats points; a pure duration offers it nothing.
|
// REVERSE eats points; a pure duration offers it nothing.
|
||||||
const reverse = find("reverse");
|
const reverse = find("reverse");
|
||||||
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||||
// ABSORB SPELL steals the good ones for later.
|
// ABSORB SPELL steals the good ones for later.
|
||||||
const absorbSpell = find("absorb-spell");
|
const absorbSpell = find("absorb-spell");
|
||||||
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||||
const shield = find("full-shield");
|
const shield = find("full-shield");
|
||||||
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||||
return { type: "counteract", instanceId: shield.instanceId };
|
return { type: "counteract", instanceId: shield.instanceId };
|
||||||
}
|
}
|
||||||
const reflect = find("full-reflection");
|
const reflect = find("full-reflection");
|
||||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||||
// A curse in flight is best refused at the door.
|
// A curse in flight is best refused at the door.
|
||||||
if (affliction) {
|
if (affliction) {
|
||||||
const cleanse = find("remove-curse");
|
const cleanse = find("remove-curse");
|
||||||
@@ -777,9 +827,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
const permanentCurse = affliction &&
|
const permanentCurse = affliction &&
|
||||||
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
|
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
|
||||||
const absorb = find("absorb");
|
const absorb = find("absorb");
|
||||||
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
|
||||||
|
return { type: "counteract", instanceId: absorb.instanceId };
|
||||||
|
}
|
||||||
const blunt = find("blunt");
|
const blunt = find("blunt");
|
||||||
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||||
// The berserker shares its pain out of spite — but only pain: a
|
// The berserker shares its pain out of spite — but only pain: a
|
||||||
// damage-less curse gives EMPATHY nothing to mirror.
|
// damage-less curse gives EMPATHY nothing to mirror.
|
||||||
const empathy = find("empathy");
|
const empathy = find("empathy");
|
||||||
@@ -788,13 +840,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
}
|
}
|
||||||
// Nothing to block with: teleport clear of the big ones.
|
// Nothing to block with: teleport clear of the big ones.
|
||||||
const tp = find("teleport");
|
const tp = find("teleport");
|
||||||
if (tp && incoming >= 4 - flinch) {
|
if (tp && weight >= 4 - flinch) {
|
||||||
const out = escapeCell(view, me(view).position);
|
const out = escapeCell(view, me(view).position);
|
||||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||||
}
|
}
|
||||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||||
const vanish = find("invisible");
|
const vanish = find("invisible");
|
||||||
if (vanish && incoming >= 3 - flinch) {
|
if (vanish && weight >= 3 - flinch) {
|
||||||
const num = numbersInHand(view)[0];
|
const num = numbersInHand(view)[0];
|
||||||
return {
|
return {
|
||||||
type: "counteract", instanceId: vanish.instanceId,
|
type: "counteract", instanceId: vanish.instanceId,
|
||||||
@@ -814,12 +866,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
/** The best attack available against a visible target, numbers and amplify included. */
|
/** The best attack available against a visible target, numbers and amplify included. */
|
||||||
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
|
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
|
||||||
const numbers = numbersInHand(view);
|
const numbers = numbersInHand(view);
|
||||||
|
// TURN THEFT: against a carrier one delivery from winning, a
|
||||||
|
// LIGHTNING BLAST's stolen turn outranks any point total — the number
|
||||||
|
// rides along so soaks cannot zero the damage and void the stun.
|
||||||
|
{
|
||||||
|
const carrier = view.players.find((p) => p.id === targetId);
|
||||||
|
const bolt = inHand(view, "lightning-blast");
|
||||||
|
const biggestNum = numbers[numbers.length - 1];
|
||||||
|
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
|
||||||
|
return {
|
||||||
|
damage: cardDef(biggestNum.cardId).value ?? 1,
|
||||||
|
cmd: {
|
||||||
|
type: "cast", instanceId: bolt.instanceId,
|
||||||
|
target: { kind: "player", playerId: targetId },
|
||||||
|
numberInstanceIds: [biggestNum.instanceId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
const biggest = numbers[numbers.length - 1];
|
const biggest = numbers[numbers.length - 1];
|
||||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||||
const target = view.players.find((p) => p.id === targetId)!;
|
const target = view.players.find((p) => p.id === targetId)!;
|
||||||
const together = cellKey(target.position) === cellKey(me(view).position);
|
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||||
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
||||||
let best: { cmd: Command; damage: number } | null = null;
|
// A killing blow at minimal spend outranks maximum splash — but only
|
||||||
|
// when it clears the target's life with soak to spare: an exact-lethal
|
||||||
|
// blow dies to one ABSORB, so thrift begins three points past the kill.
|
||||||
|
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
|
||||||
|
let best: BestPick | null = null;
|
||||||
|
const offer = (damage: number, cost: number, cmd: Command) => {
|
||||||
|
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
|
||||||
|
const r = rank(damage);
|
||||||
|
const better = !best ? true
|
||||||
|
: r !== rank(best.damage) ? r > rank(best.damage)
|
||||||
|
: r === 2 ? cost < best.cost
|
||||||
|
: damage !== best.damage ? damage > best.damage
|
||||||
|
: cost < best.cost;
|
||||||
|
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
|
||||||
|
};
|
||||||
for (const c of view.yourHand) {
|
for (const c of view.yourHand) {
|
||||||
const atk = ATTACKS[c.cardId];
|
const atk = ATTACKS[c.cardId];
|
||||||
if (!atk) continue;
|
if (!atk) continue;
|
||||||
@@ -831,25 +915,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
|||||||
const isWand = c.cardId === "blaster-wand";
|
const isWand = c.cardId === "blaster-wand";
|
||||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||||
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
||||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
const mustNumber = uncharged || atk.needsNumber === true;
|
||||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||||
if (damage <= 0) continue;
|
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
|
||||||
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
? [undefined, ...numbers]
|
||||||
const amplified = amplify && damage >= 4 && !isWand &&
|
: [mustNumber ? biggest : undefined];
|
||||||
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
for (const num of numberChoices) {
|
||||||
if (amplified) damage *= 2;
|
if (mustNumber && !num) continue;
|
||||||
if (!best || damage > best.damage) {
|
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
|
||||||
best = {
|
const base = atk.base + (atk.perNumber && num ? value : 0);
|
||||||
damage,
|
if (base <= 0) continue;
|
||||||
cmd: {
|
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
|
||||||
|
const damage = amp ? base * 2 : base;
|
||||||
|
offer(damage, value + (amp ? 5 : 0), {
|
||||||
type: "cast", instanceId: c.instanceId,
|
type: "cast", instanceId: c.instanceId,
|
||||||
target: { kind: "player", playerId: targetId },
|
target: { kind: "player", playerId: targetId },
|
||||||
// An illusion needs a spell to imitate; a fireball sells best.
|
// An illusion needs a spell to imitate; a fireball sells best.
|
||||||
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
|
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
|
||||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
|
||||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||||
},
|
});
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// STONE DEAD: number times the stones the victim carries — the displayed
|
// STONE DEAD: number times the stones the victim carries — the displayed
|
||||||
@@ -858,9 +944,11 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
|||||||
if (sd && biggest) {
|
if (sd && biggest) {
|
||||||
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
||||||
const dmg = biggestValue * shown;
|
const dmg = biggestValue * shown;
|
||||||
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
|
// `best` is assigned only inside offer(), so TS narrows it to null here.
|
||||||
|
const cur = best as BestPick | null;
|
||||||
|
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
|
||||||
best = {
|
best = {
|
||||||
damage: dmg,
|
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
|
||||||
cmd: {
|
cmd: {
|
||||||
type: "cast", instanceId: sd.instanceId,
|
type: "cast", instanceId: sd.instanceId,
|
||||||
target: { kind: "player", playerId: targetId },
|
target: { kind: "player", playerId: targetId },
|
||||||
@@ -869,7 +957,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best;
|
const picked = best as BestPick | null;
|
||||||
|
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An affliction worth casting when no damage lands, mid number attached.
|
/** An affliction worth casting when no damage lands, mid number attached.
|
||||||
@@ -957,6 +1046,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
|||||||
return { type: "cast", instanceId: c.instanceId };
|
return { type: "cast", instanceId: c.instanceId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// So does the MASTER KEY: displayed, every workable lock in the maze
|
||||||
|
// opens under its bearer's hand without another cast.
|
||||||
|
{
|
||||||
|
const mk = inHand(view, "master-key");
|
||||||
|
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
|
||||||
|
return { type: "cast", instanceId: mk.instanceId };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||||
// A curse on the clockwork gets scrubbed off.
|
// A curse on the clockwork gets scrubbed off.
|
||||||
const cursed = view.sustained.some(
|
const cursed = view.sustained.some(
|
||||||
@@ -1036,6 +1133,42 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
||||||
|
// delivery into a three-move stroll. Placed early, from home's
|
||||||
|
// doorstep, far mouth by the richest distant gold.
|
||||||
|
{
|
||||||
|
const dwarp = inHand(view, "dimensional-warp");
|
||||||
|
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
|
||||||
|
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
|
||||||
|
const legal = (c: Cell) =>
|
||||||
|
view.board.cells[cellKey(c)] !== undefined &&
|
||||||
|
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
|
||||||
|
!view.squareContents[cellKey(c)] &&
|
||||||
|
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
|
||||||
|
(view.groundObjects[cellKey(c)] ?? []).length === 0;
|
||||||
|
const goals = treasureGoals(view);
|
||||||
|
if (legal(self.position) && goals.size > 0) {
|
||||||
|
const dHome = distancesFrom(view, [self.home], false);
|
||||||
|
const sighted = sightedCellsFor(view);
|
||||||
|
let far: { cell: Cell; worth: number } | null = null;
|
||||||
|
for (const g of goals) {
|
||||||
|
const [gx, gy] = g.split(",").map(Number) as [number, number];
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const c = neighbor({ x: gx, y: gy }, side);
|
||||||
|
if (!legal(c) || !sighted.has(cellKey(c))) continue;
|
||||||
|
const worth = dHome.get(cellKey(c)) ?? 0;
|
||||||
|
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (far) {
|
||||||
|
return {
|
||||||
|
type: "cast", instanceId: dwarp.instanceId,
|
||||||
|
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||||
const myFloorTreasure = tier.guardGold
|
const myFloorTreasure = tier.guardGold
|
||||||
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||||
@@ -1124,15 +1257,17 @@ export function automatonCommand(
|
|||||||
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||||
const thief = thiefOfMine(view);
|
const thief = thiefOfMine(view);
|
||||||
if (!cursedIdiot) {
|
if (!cursedIdiot) {
|
||||||
|
|
||||||
// Deliver or grab treasure underfoot.
|
// Deliver or grab treasure underfoot.
|
||||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||||
if (!self.carriedTreasureId) {
|
if (!self.carriedTreasureId) {
|
||||||
const prize = view.treasures.find(
|
const prize = view.treasures.find(
|
||||||
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
|
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
|
||||||
here !== cellKey(self.home),
|
here !== cellKey(self.home) &&
|
||||||
|
(t.owner !== you ||
|
||||||
|
// Repossessing my own gold off an enemy's home square.
|
||||||
|
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
|
||||||
);
|
);
|
||||||
if (prize) return { type: "pickUpTreasure" };
|
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wounded clockwork teleports clear of visible hunters.
|
// Wounded clockwork teleports clear of visible hunters.
|
||||||
@@ -1187,8 +1322,6 @@ export function automatonCommand(
|
|||||||
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
||||||
view.turn.round > 1) {
|
view.turn.round > 1) {
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
// A wizard under the clockwork's own BUDDY pact is off the target list:
|
|
||||||
// attacking them would tear up the pact it just paid a card for.
|
|
||||||
// Whoever cast BUDDY on you is safe from your first strike — the
|
// Whoever cast BUDDY on you is safe from your first strike — the
|
||||||
// engine refuses the attack outright. Your OWN pacts are merely
|
// engine refuses the attack outright. Your OWN pacts are merely
|
||||||
// precious: striking someone you pacted tears up your protection.
|
// precious: striking someone you pacted tears up your protection.
|
||||||
@@ -1203,13 +1336,16 @@ export function automatonCommand(
|
|||||||
const target = thief && visible.some((p) => p.id === thief.id)
|
const target = thief && visible.some((p) => p.id === thief.id)
|
||||||
? thief
|
? thief
|
||||||
: visible.sort((a, b) => a.life - b.life)[0]!;
|
: visible.sort((a, b) => a.life - b.life)[0]!;
|
||||||
// DROP OBJECT shakes my treasure out of the thief's hands.
|
// DROP OBJECT shakes the treasure out of the hands that matter:
|
||||||
if (thief && target.id === thief.id) {
|
// the thief of my gold, or any carrier whose delivery wins.
|
||||||
const dob = inHand(view, "drop-object");
|
{
|
||||||
if (dob) {
|
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
|
||||||
|
: visible.find((p) => deliveryWins(view, p));
|
||||||
|
const dob = mark ? inHand(view, "drop-object") : undefined;
|
||||||
|
if (mark && dob) {
|
||||||
return {
|
return {
|
||||||
type: "cast", instanceId: dob.instanceId,
|
type: "cast", instanceId: dob.instanceId,
|
||||||
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
|
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1355,7 @@ export function automatonCommand(
|
|||||||
if (exile) {
|
if (exile) {
|
||||||
const carrier = visible.find((p) => {
|
const carrier = visible.find((p) => {
|
||||||
if (thief && p.id === thief.id) return true;
|
if (thief && p.id === thief.id) return true;
|
||||||
|
if (deliveryWins(view, p)) return true;
|
||||||
if (!p.carriedTreasureId) return false;
|
if (!p.carriedTreasureId) return false;
|
||||||
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
|
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
|
||||||
});
|
});
|
||||||
@@ -1368,6 +1505,24 @@ export function automatonCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
|
||||||
|
// but farther by boot — blink in and drop next command. The jump beats
|
||||||
|
// any ambush camped on the walking road.
|
||||||
|
if (self.carriedTreasureId && !view.turn.actionsEnded) {
|
||||||
|
const tpHome = inHand(view, "teleport");
|
||||||
|
if (tpHome) {
|
||||||
|
const blink = wallIgnoringDistance(view.board, self.position, self.home);
|
||||||
|
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||||
|
const dWalk = blink >= 1 && blink <= 4
|
||||||
|
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
|
||||||
|
.get(cellKey(self.home)) ?? Infinity
|
||||||
|
: 0;
|
||||||
|
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
|
||||||
|
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// March. The thief-chase outranks everything; the berserker hunts wizards
|
// March. The thief-chase outranks everything; the berserker hunts wizards
|
||||||
// over gold; the worrier keeps its distance; the hunter goes to the gold.
|
// over gold; the worrier keeps its distance; the hunter goes to the gold.
|
||||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||||
@@ -1377,19 +1532,38 @@ export function automatonCommand(
|
|||||||
const ownGoldCells = new Set(view.treasures
|
const ownGoldCells = new Set(view.treasures
|
||||||
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||||
.map((t) => cellKey(t.position!)));
|
.map((t) => cellKey(t.position!)));
|
||||||
// A thief already underfoot is not a destination — with the chase
|
// The chase exists to deliver a blow, so it claims the march only
|
||||||
// moot, march on the real goals (deliver, grab) instead of standing
|
// while that blow is still live: an attack in hand, this turn's
|
||||||
// on them forever.
|
// attack unspent, casting not yet closed. Those gates hold still as
|
||||||
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
|
// the clockwork walks — a goal keyed to the thief's nearness flips
|
||||||
|
// underfoot and shuttles the walker on and off their square. Caught
|
||||||
|
// with the strike still live, it stands its ground (a goal underfoot
|
||||||
|
// ends the march); otherwise the gold has its legs for the turn.
|
||||||
|
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
|
||||||
|
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
|
||||||
|
const chasing = thief !== null && strikeLive;
|
||||||
|
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
|
||||||
|
// anyone may snatch it off the floor. A raider nearer my bank than I
|
||||||
|
// am, with the bank stocked, outranks the next grab — run home.
|
||||||
|
const bankedCount = view.treasures.filter(
|
||||||
|
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
||||||
|
// The predicate reads only ENEMY positions — my own steps must not
|
||||||
|
// flip it mid-march or the walker shuttles between goals.
|
||||||
|
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||||
|
livingEnemies(view).some((p) =>
|
||||||
|
!p.carriedTreasureId &&
|
||||||
|
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
|
||||||
const objectives = cursedIdiot && ownGoldCells.size > 0
|
const objectives = cursedIdiot && ownGoldCells.size > 0
|
||||||
? ownGoldCells
|
? ownGoldCells
|
||||||
: thiefAfar
|
: chasing
|
||||||
? new Set([cellKey(thiefAfar.position)])
|
? new Set([cellKey(thief.position)])
|
||||||
|
: bankThreatened
|
||||||
|
? new Set([cellKey(self.home)])
|
||||||
: style === "berserker" && !self.carriedTreasureId
|
: style === "berserker" && !self.carriedTreasureId
|
||||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||||
: gold;
|
: gold;
|
||||||
const path =
|
const path =
|
||||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ??
|
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
||||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||||
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||||
// No clean road to anything: grit the teeth and wade the hazards,
|
// No clean road to anything: grit the teeth and wade the hazards,
|
||||||
@@ -1472,7 +1646,12 @@ export function automatonCommand(
|
|||||||
// The war chest: when an attack in hand wants a number, the
|
// The war chest: when an attack in hand wants a number, the
|
||||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||||
// march, and walking is free next turn.
|
// march, and walking is free next turn.
|
||||||
const wantsNumber = view.yourHand.some((c) => {
|
// — but a chest hoarded with no enemy in reach just slows the
|
||||||
|
// march, and no reserve outbids the delivery that wins the game.
|
||||||
|
const foesClose = livingEnemies(view).some((p) =>
|
||||||
|
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
|
||||||
|
const wantsNumber = foesClose && !deliveryWins(view, self) &&
|
||||||
|
view.yourHand.some((c) => {
|
||||||
const atk = ATTACKS[c.cardId];
|
const atk = ATTACKS[c.cardId];
|
||||||
if (!atk) return false;
|
if (!atk) return false;
|
||||||
return atk.perNumber || atk.needsNumber === true ||
|
return atk.perNumber || atk.needsNumber === true ||
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ export interface CastStack {
|
|||||||
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
|
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
|
||||||
cardId?: string }[];
|
cardId?: string }[];
|
||||||
waitingOn: PlayerId;
|
waitingOn: PlayerId;
|
||||||
|
/** AROUND THE CORNER carried this attack: sight bent through a middle
|
||||||
|
* square, so the straight line the table would look for does not exist. */
|
||||||
|
bentCorner?: true;
|
||||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||||
defenderShielded?: boolean;
|
defenderShielded?: boolean;
|
||||||
/** Set when a creature, not a wizard, delivers the attack. */
|
/** Set when a creature, not a wizard, delivers the attack. */
|
||||||
@@ -225,6 +228,9 @@ export interface CastParams {
|
|||||||
hold?: boolean;
|
hold?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
|
export const CURRENT_RULES_REV = 5;
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -235,17 +241,18 @@ export interface GameConfig {
|
|||||||
colors?: number[];
|
colors?: number[];
|
||||||
/**
|
/**
|
||||||
* Rules revision, frozen per game so stored games replay unchanged.
|
* Rules revision, frozen per game so stored games replay unchanged.
|
||||||
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
|
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
||||||
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
|
* Rev 2: a wizard beside a door they can open (lock removed, door
|
||||||
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
|
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
|
||||||
* creature blows open a counteraction window like any attack. Rev 5:
|
* the doorway; the hallway behind them still cannot.
|
||||||
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
|
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
|
||||||
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
|
* asks of its bearer — creations and utility spells included — not
|
||||||
* teleport escape ("does not work against escape" — the card face).
|
* only direct attacks.
|
||||||
* Rev 7: no behavioral change — every board opening carries warp sight
|
* Rev 4: "the door will relock behind you" means it — passing through
|
||||||
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
|
* an unlocked door shuts it at the walker's back unless a hand holds
|
||||||
* warp." — the card face). Rev 8: nothing can be created on a warp token,
|
* it; unpassed, it relocks at turn's end as before.
|
||||||
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
|
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
||||||
|
* in three spaces, instead of eating the card silently at resolution.
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -348,19 +355,79 @@ export function losBlockers(state: GameState): Record<string, true> {
|
|||||||
return blockers;
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
|
/** A door HELD open is propped ajar for every eye in the hallway. Any
|
||||||
* considered to block L.O.S." speaks of a CLOSED door, and
|
* other door hangs shut, and a shut door blocks L.O.S. — see doorsAjar
|
||||||
* the table holds doors open precisely to cast back through them. */
|
* for the one wizard who may pull it open and peek. */
|
||||||
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||||
if (state.heldDoors.length === 0) return board;
|
if (state.heldDoors.length === 0) return board;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
for (const h of state.heldDoors) delete edges[h.key];
|
for (const h of state.heldDoors) delete edges[h.key];
|
||||||
return { ...board, edges };
|
return { ...board, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A wizard at a door's threshold may pull it open and look through
|
||||||
|
* without stepping through: any wizard beside a door whose lock is
|
||||||
|
* REMOVED or that stands unlocked this turn, and a holder of PICK LOCK
|
||||||
|
* or MASTER KEY beside any workable lock. The door still hangs shut to
|
||||||
|
* everyone down the hallway. (Rules rev 2; earlier games replay with
|
||||||
|
* doors dark.) */
|
||||||
|
function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard {
|
||||||
|
if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board;
|
||||||
|
const viewer = state.players.find((p) => p.id === viewerId);
|
||||||
|
if (!viewer || !viewer.alive) return board;
|
||||||
|
const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
||||||
|
let edges: Record<string, EdgeState> | null = null;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const key = edgeKey(viewer.position, side);
|
||||||
|
if (board.edges[key] !== "door") continue;
|
||||||
|
const workable = carriesKey && state.doorStates[key] !== "jammed";
|
||||||
|
if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue;
|
||||||
|
if (!edges) edges = { ...board.edges };
|
||||||
|
delete edges[key];
|
||||||
|
}
|
||||||
|
return edges ? { ...board, edges } : board;
|
||||||
|
}
|
||||||
|
|
||||||
/** LOS including square-filling blockers. */
|
/** LOS including square-filling blockers. */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
||||||
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
|
* whole power, shared by every path that honors it. */
|
||||||
|
function sightThroughOneEdge(
|
||||||
|
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
||||||
|
): boolean {
|
||||||
|
for (const key of Object.keys(board.edges)) {
|
||||||
|
if ((board.edges[key] ?? "open") === "open") continue;
|
||||||
|
const edges = { ...board.edges };
|
||||||
|
delete edges[key];
|
||||||
|
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||||
|
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||||
|
const blockers = losBlockers(state);
|
||||||
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
|
if (!stone) return false;
|
||||||
|
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
||||||
|
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
|
||||||
|
return sightThroughOneEdge(board, from, to, blockers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
|
||||||
|
* sight wherever sight is asked of them — creations, dispels, utility
|
||||||
|
* spells — not just attacks: one wall or door, any type, falls away.
|
||||||
|
* Ungated by design: validation may widen without a rev bump, because
|
||||||
|
* refused commands never entered any ledger. */
|
||||||
|
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
||||||
|
return losWith(state, from, to, viewerId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The eye of an armed ambush. Springing is decided mid-move and lands
|
||||||
|
* in the ledger's flow, so this sight is frozen per game: before rev 3
|
||||||
|
* an ambush never looked through its owner's VISIONSTONE, and stored
|
||||||
|
* games must replay that blindness. */
|
||||||
|
function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean {
|
||||||
|
return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse an edge key back into its north/west cell and side. */
|
/** Parse an edge key back into its north/west cell and side. */
|
||||||
@@ -409,17 +476,11 @@ function casterLos(
|
|||||||
from: Cell,
|
from: Cell,
|
||||||
to: Cell,
|
to: Cell,
|
||||||
): boolean {
|
): boolean {
|
||||||
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
|
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
|
||||||
const blockers = losBlockers(state);
|
const blockers = losBlockers(state);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
if (!displays(caster, "visionstone")) return false;
|
if (!displays(caster, "visionstone")) return false;
|
||||||
for (const key of Object.keys(board.edges)) {
|
return sightThroughOneEdge(board, from, to, blockers);
|
||||||
if ((board.edges[key] ?? "open") === "open") continue;
|
|
||||||
const edges = { ...board.edges };
|
|
||||||
delete edges[key];
|
|
||||||
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||||
@@ -525,6 +586,8 @@ export type GameEvent =
|
|||||||
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||||
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
|
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
|
||||||
|
| { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId }
|
||||||
|
| { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell }
|
||||||
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
|
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
|
||||||
/** Origins in FINAL coordinates (the maze may renormalize after the
|
/** Origins in FINAL coordinates (the maze may renormalize after the
|
||||||
* landing); from/to record the pre-shift request. */
|
* landing); from/to record the pre-shift request. */
|
||||||
@@ -641,6 +704,7 @@ export type Command =
|
|||||||
| { type: "wardChoice"; play: boolean }
|
| { type: "wardChoice"; play: boolean }
|
||||||
| { type: "warpStep" }
|
| { type: "warpStep" }
|
||||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||||
|
| { type: "creatureWarpStep"; creatureId: string }
|
||||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||||
| {
|
| {
|
||||||
type: "cast";
|
type: "cast";
|
||||||
@@ -693,7 +757,7 @@ type AttackEffect = {
|
|||||||
sustains?: boolean;
|
sustains?: boolean;
|
||||||
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
|
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
|
||||||
keepInHand?: boolean;
|
keepInHand?: boolean;
|
||||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null;
|
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
||||||
onResolved?: (ctx: ResolutionContext) => void;
|
onResolved?: (ctx: ResolutionContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1182,8 +1246,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
keepInHand: true,
|
keepInHand: true,
|
||||||
// "Unlocks any door (door relocks behind you). Do not discard when used.
|
// "Unlocks any door (door relocks behind you). Do not discard when used.
|
||||||
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
|
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
|
||||||
|
// Cast bare, it simply goes on display, and a DISPLAYED key turns in
|
||||||
|
// every lock its bearer walks through (see doMove) — no further casts.
|
||||||
|
// Cast at a door, it works that one lock like PICK LOCK and may hold
|
||||||
|
// the door open for others.
|
||||||
resolve: (state, events, caster, cmd) =>
|
resolve: (state, events, caster, cmd) =>
|
||||||
unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }),
|
cmd.target
|
||||||
|
? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true })
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
"remove-lock": {
|
"remove-lock": {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
@@ -1306,7 +1376,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
if (!target || !target.alive) return "no such living player";
|
if (!target || !target.alive) return "no such living player";
|
||||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||||
// Effectively permanent: broken by the caster attacking the target.
|
// Effectively permanent: broken by the caster attacking the target.
|
||||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||||
return null;
|
return null;
|
||||||
@@ -1474,14 +1544,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
const creature = creatureAt(state, cmd.target.cell);
|
const creature = creatureAt(state, cmd.target.cell);
|
||||||
if (creature) {
|
if (creature) {
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
destroyCreature(state, events, creature, "dispel creation");
|
destroyCreature(state, events, creature, "dispel creation");
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = state.squareContents[key];
|
const content = state.squareContents[key];
|
||||||
if (!content) return "nothing created there";
|
if (!content) return "nothing created there";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
delete state.squareContents[key];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||||
return null;
|
return null;
|
||||||
@@ -1498,7 +1568,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
if (!target || !target.alive) return "no such living player";
|
if (!target || !target.alive) return "no such living player";
|
||||||
if (target.id === caster.id) return "you cannot drag yourself";
|
if (target.id === caster.id) return "you cannot drag yourself";
|
||||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
|
||||||
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||||
const from = target.position;
|
const from = target.position;
|
||||||
dragToward(state, target, caster.position);
|
dragToward(state, target, caster.position);
|
||||||
@@ -1507,7 +1577,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
const objects = state.groundObjects[key];
|
const objects = state.groundObjects[key];
|
||||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||||
if (objects && objects.length > 0) {
|
if (objects && objects.length > 0) {
|
||||||
@@ -1584,7 +1654,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
resolve: (state, events, caster) => {
|
resolve: (state, events, caster) => {
|
||||||
for (const opp of state.players) {
|
for (const opp of state.players) {
|
||||||
if (!opp.alive || opp.id === caster.id) continue;
|
if (!opp.alive || opp.id === caster.id) continue;
|
||||||
if (!gameLos(state, caster.position, opp.position)) continue;
|
if (!gameLos(state, caster.position, opp.position, caster.id)) continue;
|
||||||
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
|
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
|
||||||
retreatFromSight(state, events, opp, caster.position);
|
retreatFromSight(state, events, opp, caster.position);
|
||||||
}
|
}
|
||||||
@@ -1680,7 +1750,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||||
if (!creature) return "no such monster";
|
if (!creature) return "no such monster";
|
||||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
|
||||||
if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
|
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
|
||||||
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||||
else creature.maxDamage *= 2;
|
else creature.maxDamage *= 2;
|
||||||
@@ -1882,7 +1952,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to glue down";
|
if (!hasObject) return "there is nothing there to glue down";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
state.gluedCells[key] = true;
|
state.gluedCells[key] = true;
|
||||||
// "a duration equal to twice the NUMBER card played"
|
// "a duration equal to twice the NUMBER card played"
|
||||||
const turns = magnitude.duration * 2;
|
const turns = magnitude.duration * 2;
|
||||||
@@ -1906,7 +1976,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to lock up";
|
if (!hasObject) return "there is nothing there to lock up";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||||
return null;
|
return null;
|
||||||
@@ -1924,7 +1994,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ka === kb) return "pick two different squares";
|
if (ka === kb) return "pick two different squares";
|
||||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
||||||
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
|
||||||
if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight";
|
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
|
||||||
const itemsA = state.groundObjects[ka] ?? [];
|
const itemsA = state.groundObjects[ka] ?? [];
|
||||||
const itemsB = state.groundObjects[kb] ?? [];
|
const itemsB = state.groundObjects[kb] ?? [];
|
||||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
||||||
@@ -1987,7 +2057,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
||||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
|
||||||
delete state.squareContents[key];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
||||||
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
|
||||||
@@ -2103,11 +2173,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
"mental-force": {
|
"mental-force": {
|
||||||
kind: "attack",
|
kind: "attack",
|
||||||
baseDamage: () => 0, // no LOS printed
|
baseDamage: () => 0, // no LOS printed
|
||||||
validate: (state, cmd) => {
|
validate: (state, cmd, caster, target) => {
|
||||||
const cell = cmd.params?.cell;
|
const cell = cmd.params?.cell;
|
||||||
if (!cell) return "say where they go (within three moved spaces)";
|
if (!cell) return "say where they go (within three moved spaces)";
|
||||||
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
|
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
|
||||||
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
||||||
|
// The victim WALKS those three spaces — a destination the maze's
|
||||||
|
// walls put out of reach is refused up front, not swallowed at
|
||||||
|
// resolution with the card already spent. (Rev 5; the victim may
|
||||||
|
// still slip out of range before it resolves — see onResolved.)
|
||||||
|
if ((state.config.deckRev ?? 1) >= 5 && target &&
|
||||||
|
walkingDistance(state, target.position, cell) > 3) {
|
||||||
|
return "the walls put that square beyond three moved spaces";
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
@@ -2115,7 +2193,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||||
const to = ctx.stack.params?.cell;
|
const to = ctx.stack.params?.cell;
|
||||||
if (!to) return; // an ambush armed without a destination fizzles
|
if (!to) return; // an ambush armed without a destination fizzles
|
||||||
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) {
|
||||||
|
// The victim slipped beyond reach: the force strains and fails,
|
||||||
|
// and the table sees it fail rather than wondering.
|
||||||
|
ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const from = ctx.defender.position;
|
const from = ctx.defender.position;
|
||||||
ctx.defender.position = to;
|
ctx.defender.position = to;
|
||||||
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
|
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
|
||||||
@@ -2536,7 +2619,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
if (!other || !other.alive) return "no such living player";
|
if (!other || !other.alive) return "no such living player";
|
||||||
if (other.id === caster.id) return "that is already your home";
|
if (other.id === caster.id) return "that is already your home";
|
||||||
if (!gameLos(state, caster.position, other.position)) return "no line of sight to them";
|
if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them";
|
||||||
const onHome = (home: Cell) =>
|
const onHome = (home: Cell) =>
|
||||||
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
|
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
|
||||||
if (onHome(caster.home) !== onHome(other.home)) {
|
if (onHome(caster.home) !== onHome(other.home)) {
|
||||||
@@ -2727,7 +2810,7 @@ function emptySquareTarget(
|
|||||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||||
return "the warp shimmers there — nothing can form on it";
|
return "the warp shimmers there — nothing can form on it";
|
||||||
}
|
}
|
||||||
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2860,6 +2943,66 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
|||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A creature arriving in a player's square touches: the wraith's chill
|
||||||
|
* and the democratic monster's claw open a counteraction window ("REFLECTIONs
|
||||||
|
* used on the wraith's touch will damage the wraith" — the card assumes
|
||||||
|
* exactly this window). True = a stack opened and the turn should return.
|
||||||
|
* "May attack only one player per round of turns" spends attackUsed. */
|
||||||
|
function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean {
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||||
|
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||||
|
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||||
|
creature.attackUsed = true;
|
||||||
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
|
openCreatureStack(state, creature, p, 2, "wraith");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
||||||
|
creature.attackUsed = true;
|
||||||
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
|
openCreatureStack(state, creature, p, 2, "claw");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A creature standing on a DIMENSIONAL WARP token steps through it like
|
||||||
|
* any walker: its commander spends one of its moves, solid stone on the
|
||||||
|
* far side refuses it, and a monster is no braver than a wizard about
|
||||||
|
* FEAR. (Monsters obey the same maze the wizards do.) */
|
||||||
|
function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
const state = clone(prev);
|
||||||
|
const active = activePlayer(state);
|
||||||
|
const creature = creatureById(state, creatureId);
|
||||||
|
if (!creature) return err("no such creature");
|
||||||
|
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
|
||||||
|
return err("that creature does not obey you");
|
||||||
|
}
|
||||||
|
if (creature.movesPerTurn === 0) return err("that creature cannot move");
|
||||||
|
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
|
||||||
|
const here = cellKey(creature.position);
|
||||||
|
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
|
||||||
|
if (!pair) return err("it is not standing on a warp token");
|
||||||
|
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
||||||
|
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
|
||||||
|
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
|
||||||
|
sustainedOn(state, o.id, "big-man").length > 0)) {
|
||||||
|
return err("a giant fills that corridor");
|
||||||
|
}
|
||||||
|
const from = creature.position;
|
||||||
|
// FEAR holds monsters off too: "no player or monster".
|
||||||
|
if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast");
|
||||||
|
creature.position = { ...dest };
|
||||||
|
creature.movementUsed++;
|
||||||
|
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
|
||||||
|
creatureTouchOnArrival(state, events, creature);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
|
||||||
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
|
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
|
||||||
const blocked = requireActionsAvailable(prev);
|
const blocked = requireActionsAvailable(prev);
|
||||||
if (blocked) return err(blocked);
|
if (blocked) return err(blocked);
|
||||||
@@ -2914,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
|||||||
}
|
}
|
||||||
creature.movementUsed++;
|
creature.movementUsed++;
|
||||||
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
||||||
|
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
|
||||||
// Touch effects on entering a player's square.
|
|
||||||
for (const p of state.players) {
|
|
||||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
|
||||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
|
||||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
|
||||||
creature.attackUsed = true;
|
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
|
||||||
// The victim may counteract ("REFLECTIONs used on the wraith's touch
|
|
||||||
// will damage the wraith" — the card assumes exactly this window).
|
|
||||||
openCreatureStack(state, creature, p, 2, "wraith");
|
|
||||||
return { ok: true, state, events };
|
|
||||||
}
|
|
||||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
|
||||||
creature.attackUsed = true; // "may attack only one player per round of turns"
|
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
|
||||||
openCreatureStack(state, creature, p, 2, "claw");
|
|
||||||
return { ok: true, state, events };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
@@ -3361,7 +3485,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** BFS steps between cells ignoring walls (teleport distance). */
|
/** BFS steps between cells ignoring walls (teleport distance). */
|
||||||
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||||
if (cellKey(from) === cellKey(to)) return 0;
|
if (cellKey(from) === cellKey(to)) return 0;
|
||||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||||
const queue: Cell[] = [from];
|
const queue: Cell[] = [from];
|
||||||
@@ -3370,8 +3494,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
|||||||
const d = seen.get(cellKey(cur))!;
|
const d = seen.get(cellKey(cur))!;
|
||||||
if (d >= 8) break; // teleport range is 4; stop early
|
if (d >= 8) break; // teleport range is 4; stop early
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
const n = neighbor(cur, side);
|
let n = neighbor(cur, side);
|
||||||
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue;
|
if (!board.cells[cellKey(n)]) {
|
||||||
|
// The maze wraps for walkers; it wraps for teleporters too —
|
||||||
|
// a warp mouth is one step, same as any doorway.
|
||||||
|
const w = board.warps.find(
|
||||||
|
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
|
||||||
|
);
|
||||||
|
if (!w) continue;
|
||||||
|
n = w.to.cell;
|
||||||
|
}
|
||||||
|
if (seen.has(cellKey(n))) continue;
|
||||||
seen.set(cellKey(n), d + 1);
|
seen.set(cellKey(n), d + 1);
|
||||||
if (cellKey(n) === cellKey(to)) return d + 1;
|
if (cellKey(n) === cellKey(to)) return d + 1;
|
||||||
queue.push(n);
|
queue.push(n);
|
||||||
@@ -3384,6 +3517,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
|
|||||||
// Setup
|
// Setup
|
||||||
|
|
||||||
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
||||||
|
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
|
||||||
const n = config.playerIds.length;
|
const n = config.playerIds.length;
|
||||||
let rng = createRng(config.seed);
|
let rng = createRng(config.seed);
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
@@ -3669,6 +3803,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
case "wardChoice": return err("no grab is hanging on your Ward");
|
case "wardChoice": return err("no grab is hanging on your Ward");
|
||||||
case "warpStep": return doWarpStep(state);
|
case "warpStep": return doWarpStep(state);
|
||||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||||
|
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
||||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||||
case "cast": return doCast(state, command);
|
case "cast": return doCast(state, command);
|
||||||
case "setAmbush": return doSetAmbush(state, command);
|
case "setAmbush": return doSetAmbush(state, command);
|
||||||
@@ -3747,11 +3882,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to:
|
|||||||
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
||||||
const d = dreadDistance(board, other.position, to);
|
const d = dreadDistance(board, other.position, to);
|
||||||
const dBefore = dreadDistance(board, other.position, from);
|
const dBefore = dreadDistance(board, other.position, from);
|
||||||
if (d <= 3 && d < dBefore) return true;
|
if (d <= 3 && d < dBefore) {
|
||||||
|
// Approaching from outside the dread is never willing.
|
||||||
|
if (dBefore > 3) return true;
|
||||||
|
// Already caged inside it: the maze's corners may force a step
|
||||||
|
// that closes the crow-flies distance — permitted only when the
|
||||||
|
// step walks the shortest way OUT of the bubble.
|
||||||
|
const eFrom = escapeSteps(state, board, other.position, from);
|
||||||
|
const eTo = escapeSteps(state, board, other.position, to);
|
||||||
|
if (!(eTo < eFrom)) return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walking steps (walls and shut doors respected, warp mouths crossed,
|
||||||
|
* fire braved) from `start` to the nearest cell beyond a dread bubble
|
||||||
|
* centered on `dreadFrom`. Infinity when no way out exists.
|
||||||
|
*/
|
||||||
|
function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number {
|
||||||
|
if (dreadDistance(board, dreadFrom, start) > 3) return 0;
|
||||||
|
const passable = (cell: Cell, side: Side): Cell | null => {
|
||||||
|
const key = edgeKey(cell, side);
|
||||||
|
const e = board.edges[key] ?? "open";
|
||||||
|
if (e === "wall") return null;
|
||||||
|
if (e === "door" &&
|
||||||
|
!state.openDoorEdges.includes(key) &&
|
||||||
|
!state.heldDoors.some((h) => h.key === key)) return null;
|
||||||
|
let n = neighbor(cell, side);
|
||||||
|
if (!board.cells[cellKey(n)]) {
|
||||||
|
const w = board.warps.find(
|
||||||
|
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
|
||||||
|
);
|
||||||
|
if (!w) return null;
|
||||||
|
n = w.to.cell;
|
||||||
|
}
|
||||||
|
if (state.squareContents[cellKey(n)]?.kind === "stone") return null;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const seen = new Set<string>([cellKey(start)]);
|
||||||
|
const queue: [Cell, number][] = [[start, 0]];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const [cur, d] = queue.shift()!;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const n = passable(cur, side);
|
||||||
|
if (!n || seen.has(cellKey(n))) continue;
|
||||||
|
if (dreadDistance(board, dreadFrom, n) > 3) return d + 1;
|
||||||
|
seen.add(cellKey(n));
|
||||||
|
queue.push([n, d + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
|
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
|
||||||
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
|
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
@@ -3831,9 +4015,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
p.position = { ...warp.to.cell };
|
p.position = { ...warp.to.cell };
|
||||||
via = "warp";
|
via = "warp";
|
||||||
crossedFirewall = true;
|
crossedFirewall = true;
|
||||||
} else if (edge === "door" && doorIsOpen(state, key)) {
|
} else if (edge === "door" &&
|
||||||
|
(doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) {
|
||||||
|
// The displayed MASTER KEY turns in every lock it meets: the walker
|
||||||
|
// passes without another cast, and the door RELOCKS BEHIND THEM at
|
||||||
|
// once — "door relocks behind you" grants no lingering opening, so
|
||||||
|
// bystanders see only a shut door (the bearer's own threshold peek
|
||||||
|
// rides the key itself). A JAMmed LOCK still refuses it. To hold a
|
||||||
|
// door open, cast the key at it as ever.
|
||||||
|
if (!doorIsOpen(state, key)) {
|
||||||
|
events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" });
|
||||||
|
}
|
||||||
p.position = dest;
|
p.position = dest;
|
||||||
via = "step";
|
via = "step";
|
||||||
|
// "The door will relock behind you": stepping through an unlocked
|
||||||
|
// door shuts it at the walker's back unless a hand holds it open.
|
||||||
|
// (Rev 4; earlier games kept their turn-long openings.)
|
||||||
|
if ((state.config.deckRev ?? 1) >= 4 &&
|
||||||
|
state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) {
|
||||||
|
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
|
||||||
|
events.push({ type: "doorsRelocked", count: 1 });
|
||||||
|
}
|
||||||
} else if (edge === "firewall") {
|
} else if (edge === "firewall") {
|
||||||
// "Passing through it does four points of magical damage." Firewalls
|
// "Passing through it does four points of magical damage." Firewalls
|
||||||
// burn even a MIST-BODY.
|
// burn even a MIST-BODY.
|
||||||
@@ -4316,7 +4518,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
|
|||||||
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
|
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
|
||||||
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
const board = openHeldDoors(state, perceivedBoard(state, p.id));
|
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
|
||||||
if (!isAdjacentToEdge(p.position, cell, side) &&
|
if (!isAdjacentToEdge(p.position, cell, side) &&
|
||||||
!losToEdge(board, p.position, cell, side)) {
|
!losToEdge(board, p.position, cell, side)) {
|
||||||
return err("you cannot see that wall from here");
|
return err("you cannot see that wall from here");
|
||||||
@@ -4627,6 +4829,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
|
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
|
||||||
return err(`${def.name} is already displayed`);
|
return err(`${def.name} is already displayed`);
|
||||||
}
|
}
|
||||||
|
if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) {
|
||||||
|
return err("the key is already on display");
|
||||||
|
}
|
||||||
|
|
||||||
// Magic wands: charged on first use by the number card(s) played; one
|
// Magic wands: charged on first use by the number card(s) played; one
|
||||||
// charge per use, one use per turn; discarded when the last charge goes.
|
// charge per use, one use per turn; discarded when the last charge goes.
|
||||||
@@ -4823,6 +5028,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
}
|
}
|
||||||
return { ok: true, state, events: events2 };
|
return { ok: true, state, events: events2 };
|
||||||
}
|
}
|
||||||
|
// CHAOS aims at no one: cast bare, it sweeps every other living
|
||||||
|
// wizard into the pile, each offered their FULL SHIELD sit-out in
|
||||||
|
// turn order. (Full shields only exclude; nothing in the set cancels
|
||||||
|
// a zero-point storm, so the queue is the whole defense.) The older
|
||||||
|
// player-targeted form is still accepted below, so stored games
|
||||||
|
// replay unchanged.
|
||||||
|
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
|
||||||
|
consumeCast(state, caster, inHand, mods, false);
|
||||||
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
|
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,
|
||||||
|
}];
|
||||||
|
const queue = turnOrderFrom(state, caster.id).filter((id) =>
|
||||||
|
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
|
||||||
|
state.chaosPending = { casterId: caster.id, excluded: [], queue };
|
||||||
|
finishChaosIfReady(state, events);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
|
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
|
||||||
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
|
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
|
||||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
@@ -4844,7 +5071,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
|
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
|
||||||
);
|
);
|
||||||
if (effect.validate) {
|
if (effect.validate) {
|
||||||
const problem = effect.validate(state, cmd);
|
const problem = effect.validate(state, cmd, caster, target);
|
||||||
if (problem) return err(problem);
|
if (problem) return err(problem);
|
||||||
}
|
}
|
||||||
if (inHand.cardId === "waterbolt") {
|
if (inHand.cardId === "waterbolt") {
|
||||||
@@ -4932,6 +5159,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
kind: effect.physical ? "physical" : "spell",
|
kind: effect.physical ? "physical" : "spell",
|
||||||
counters: [],
|
counters: [],
|
||||||
waitingOn: target.id,
|
waitingOn: target.id,
|
||||||
|
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
|
||||||
};
|
};
|
||||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||||
@@ -5089,8 +5317,8 @@ function checkAmbushes(
|
|||||||
sprung = context.pickedUpTreasure === true;
|
sprung = context.pickedUpTreasure === true;
|
||||||
} else if (context.movedFrom) {
|
} else if (context.movedFrom) {
|
||||||
if (ambush.trigger.kind === "los") {
|
if (ambush.trigger.kind === "los") {
|
||||||
const before = gameLos(state, owner.position, context.movedFrom);
|
const before = ambushLos(state, owner, owner.position, context.movedFrom);
|
||||||
const now = gameLos(state, owner.position, actor.position);
|
const now = ambushLos(state, owner, owner.position, actor.position);
|
||||||
sprung = now && !before;
|
sprung = now && !before;
|
||||||
} else if (ambush.trigger.kind === "near") {
|
} else if (ambush.trigger.kind === "near") {
|
||||||
const dist = (c: Cell) =>
|
const dist = (c: Cell) =>
|
||||||
@@ -5102,7 +5330,7 @@ function checkAmbushes(
|
|||||||
|
|
||||||
// The committed spell must be legal right now, or the ambush stays armed.
|
// The committed spell must be legal right now, or the ambush stays armed.
|
||||||
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
|
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
|
||||||
if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue;
|
if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue;
|
||||||
|
|
||||||
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
|
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
|
||||||
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
|
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// The server sends this after every state change; clients never see the
|
// The server sends this after every state change; clients never see the
|
||||||
// deck order or other players' hands.
|
// deck order or other players' hands.
|
||||||
|
|
||||||
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||||
import { cardDef, type CardInstance } from "./cards";
|
import { cardDef, type CardInstance } from "./cards";
|
||||||
import {
|
import {
|
||||||
boardView,
|
boardView,
|
||||||
@@ -62,6 +62,8 @@ export interface GameView {
|
|||||||
/** Safes standing open (their combination entered this turn). */
|
/** Safes standing open (their combination entered this turn). */
|
||||||
openSafes: string[];
|
openSafes: string[];
|
||||||
groundObjects: Record<string, CardInstance[]>;
|
groundObjects: Record<string, CardInstance[]>;
|
||||||
|
/** The rules revision this game was dealt under. */
|
||||||
|
deckRev: number;
|
||||||
doorStates: Record<string, "jammed" | "removed">;
|
doorStates: Record<string, "jammed" | "removed">;
|
||||||
/** Accumulated attack damage per edge (public — cracks show). */
|
/** Accumulated attack damage per edge (public — cracks show). */
|
||||||
wallDamage: Record<string, number>;
|
wallDamage: Record<string, number>;
|
||||||
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
groundObjects: Object.fromEntries(
|
groundObjects: Object.fromEntries(
|
||||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||||
),
|
),
|
||||||
|
deckRev: state.config.deckRev ?? 1,
|
||||||
doorStates: { ...state.doorStates },
|
doorStates: { ...state.doorStates },
|
||||||
wallDamage: { ...state.wallDamage },
|
wallDamage: { ...state.wallDamage },
|
||||||
openDoorEdges: [...state.openDoorEdges],
|
openDoorEdges: [...state.openDoorEdges],
|
||||||
@@ -197,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
if (sightBetween(board, me.position, { 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
|
||||||
|
// reveals it, mirroring the engine's casterLos.
|
||||||
|
if (me.displayed.some((c) => c.cardId === "visionstone")) {
|
||||||
|
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
|
||||||
|
for (const edgeK of Object.keys(board.edges)) {
|
||||||
|
if (unseen.length === 0) break;
|
||||||
|
if ((board.edges[edgeK] ?? "open") === "open") continue;
|
||||||
|
const edges = { ...board.edges };
|
||||||
|
delete edges[edgeK];
|
||||||
|
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)) {
|
||||||
|
out.add(unseen[i]!);
|
||||||
|
unseen.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||||
// Held-open doors are open doorways to the eye.
|
// Held-open doors are open doorways to every eye. Beyond them, the
|
||||||
|
// viewer at a door's threshold may pull it open and peek (rules rev 2):
|
||||||
|
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
|
||||||
|
// hand — mirroring the engine's doorsAjar.
|
||||||
let board = view.board;
|
let board = view.board;
|
||||||
if (view.heldDoorEdges.length > 0) {
|
const openKeys = new Set(view.heldDoorEdges);
|
||||||
|
const me = view.players.find((p) => p.id === view.you);
|
||||||
|
if (view.deckRev >= 2 && me?.alive) {
|
||||||
|
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const key = edgeKey(me.position, side);
|
||||||
|
if (board.edges[key] !== "door") continue;
|
||||||
|
const workable = carriesKey && view.doorStates[key] !== "jammed";
|
||||||
|
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openKeys.size > 0) {
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
for (const k of view.heldDoorEdges) delete edges[k];
|
for (const k of openKeys) delete edges[k];
|
||||||
board = { ...board, edges };
|
board = { ...board, edges };
|
||||||
}
|
}
|
||||||
const blockers: Record<string, true> = {};
|
const blockers: Record<string, true> = {};
|
||||||
@@ -259,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
|||||||
*/
|
*/
|
||||||
export function stackSightTrace(
|
export function stackSightTrace(
|
||||||
view: GameView,
|
view: GameView,
|
||||||
): { from: Cell; to: Cell; trace: SightTrace } | null {
|
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
|
||||||
const stack = view.stack;
|
const stack = view.stack;
|
||||||
if (!stack || stack.creatureId) return null;
|
if (!stack || stack.creatureId) return null;
|
||||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||||
@@ -268,7 +305,23 @@ export function stackSightTrace(
|
|||||||
if (!a || !d) return null;
|
if (!a || !d) return null;
|
||||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||||
const trace = traceSightFor(view, a.position, d.position);
|
const trace = traceSightFor(view, a.position, d.position);
|
||||||
return trace ? { from: a.position, to: d.position, trace } : null;
|
if (trace) return { from: a.position, to: d.position, trace };
|
||||||
|
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||||
|
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||||
|
// needle instead of doubting it.
|
||||||
|
if (stack.bentCorner) {
|
||||||
|
for (const key of Object.keys(view.board.cells)) {
|
||||||
|
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||||
|
const mid = { x: mx, y: my };
|
||||||
|
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
||||||
|
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
||||||
|
const leg1 = traceSightFor(view, a.position, mid);
|
||||||
|
if (!leg1) continue;
|
||||||
|
const leg2 = traceSightFor(view, mid, d.position);
|
||||||
|
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREATION_CARD_IDS = new Set([
|
const CREATION_CARD_IDS = new Set([
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
|
|||||||
/** Whose input does the maze want right now? */
|
/** Whose input does the maze want right now? */
|
||||||
function actingSeat(state: GameState): PlayerId {
|
function actingSeat(state: GameState): PlayerId {
|
||||||
return (
|
return (
|
||||||
|
state.wardPending?.ownerId ??
|
||||||
state.stack?.waitingOn ??
|
state.stack?.waitingOn ??
|
||||||
state.pendingDiscard ??
|
state.pendingDiscard ??
|
||||||
state.chaosPending?.queue[0] ??
|
state.chaosPending?.queue[0] ??
|
||||||
@@ -348,9 +349,8 @@ describe("the clockwork respects the bush's shelter", () => {
|
|||||||
|
|
||||||
it("never attacks out of its own bush", () => {
|
it("never attacks out of its own bush", () => {
|
||||||
const { state, bot, foe } = faceOffWithFireball();
|
const { state, bot, foe } = faceOffWithFireball();
|
||||||
foe.position = { x: bot.position.x, y: bot.position.y };
|
|
||||||
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
|
||||||
foe.position = { ...bot.position };
|
foe.position = { ...bot.position };
|
||||||
|
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||||
@@ -736,3 +736,246 @@ describe("the clockwork flees the dread", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the thief-chase holds one goal per turn", () => {
|
||||||
|
it("a clockwork beside its thief never shuttles on and off their square", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
// Round 2, thief's turn burned; the bot acts with a full allowance.
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
const thief = state.players.find((p) => p.id === "thief")!;
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
// The thief carries the bot's gold and stands one step away.
|
||||||
|
const mine = state.treasures.find((t) => t.owner === "bot")!;
|
||||||
|
mine.carriedBy = "thief";
|
||||||
|
mine.position = null;
|
||||||
|
thief.position = { x: 2, y: 4 };
|
||||||
|
bot.position = { x: 2, y: 5 };
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
||||||
|
// A number card and no attacks: the blow the chase serves cannot land.
|
||||||
|
bot.hand = [];
|
||||||
|
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
|
||||||
|
const visited = [cellKey(bot.position)];
|
||||||
|
for (let guard = 0; guard < 40; guard++) {
|
||||||
|
const view = viewFor(state, "bot");
|
||||||
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||||
|
if (cmd.type === "endTurn") break;
|
||||||
|
const r = applyCommand(state, "bot", cmd);
|
||||||
|
if (!r.ok) break;
|
||||||
|
state = r.state;
|
||||||
|
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
|
||||||
|
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
|
||||||
|
}
|
||||||
|
// No step may return to the square just departed: A-B-A is the shuttle.
|
||||||
|
for (let i = 2; i < visited.length; i++) {
|
||||||
|
expect(visited[i]).not.toBe(visited[i - 2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the archmage's key, shield, and race discipline", () => {
|
||||||
|
function botTurn() {
|
||||||
|
const seed = 42;
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a MASTER KEY drawn goes straight on display", () => {
|
||||||
|
const state = botTurn();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lethal small blow is countered, thrift be damned", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
bot.position = { ...foe.position };
|
||||||
|
bot.life = 2;
|
||||||
|
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
|
||||||
|
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
|
||||||
|
const r = applyCommand(state, "foe", {
|
||||||
|
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
// Base fireball, 2 points: below every thrift threshold, but fatal at
|
||||||
|
// 2 life — the shield comes out.
|
||||||
|
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no number is hoarded against the delivery that wins the game", () => {
|
||||||
|
let state = botTurn();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
// One of the foe's treasures already rests at the bot's home; the bot
|
||||||
|
// carries the second, four squares out with three legs — only the
|
||||||
|
// number bridges it home this turn.
|
||||||
|
const gold = state.treasures.filter((t) => t.owner === "foe");
|
||||||
|
expect(gold.length).toBeGreaterThanOrEqual(2);
|
||||||
|
gold[0]!.position = { ...bot.home };
|
||||||
|
gold[0]!.carriedBy = null;
|
||||||
|
gold[1]!.carriedBy = "bot";
|
||||||
|
gold[1]!.position = null;
|
||||||
|
bot.carriedTreasureId = gold[1]!.id;
|
||||||
|
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
||||||
|
// Legs only: an attack in hand would fire at the foe in sight and
|
||||||
|
// open a stack this single-seat loop cannot answer.
|
||||||
|
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
||||||
|
// Carve a straight, open four-square run to home; the rig owns its
|
||||||
|
// geometry rather than praying the seed provides it.
|
||||||
|
const home = bot.home;
|
||||||
|
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
|
||||||
|
for (const c of column) {
|
||||||
|
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
|
||||||
|
throw new Error("setup: the column north of home runs off the board");
|
||||||
|
}
|
||||||
|
delete state.squareContents[cellKey(c)];
|
||||||
|
}
|
||||||
|
bot.position = { ...column[0]! };
|
||||||
|
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
|
||||||
|
for (let guard = 0; guard < 12; guard++) {
|
||||||
|
const view = viewFor(state, "bot");
|
||||||
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||||
|
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
|
||||||
|
if (cmd.type === "endTurn") break;
|
||||||
|
const r = applyCommand(state, "bot", cmd);
|
||||||
|
if (!r.ok) break;
|
||||||
|
state = r.state;
|
||||||
|
if (state.phase !== "playing") return; // delivered and won outright
|
||||||
|
}
|
||||||
|
throw new Error("the clockwork hoarded its number instead of winning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bank guarding, teleport delivery, and turn theft", () => {
|
||||||
|
function rig() {
|
||||||
|
const players = ["foe", "bot"];
|
||||||
|
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a raider at the stocked bank's gates pulls the clockwork home", () => {
|
||||||
|
const state = rig();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
const gold = state.treasures.find((t) => t.owner === "foe")!;
|
||||||
|
gold.position = { ...bot.home };
|
||||||
|
gold.carriedBy = null;
|
||||||
|
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
||||||
|
bot.position = { ...foe.home };
|
||||||
|
bot.hand = [];
|
||||||
|
// The march may wind through the maze; what matters is that it ENDS
|
||||||
|
// at the threatened bank, not out on the gold map.
|
||||||
|
let cur = state;
|
||||||
|
for (let guard = 0; guard < 60; guard++) {
|
||||||
|
const seat = actingSeat(cur);
|
||||||
|
if (seat !== "bot") {
|
||||||
|
// The foe answers stacks with a pass and burns its turns.
|
||||||
|
let r2 = applyCommand(cur, seat, { type: "pass" });
|
||||||
|
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
|
||||||
|
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
|
||||||
|
cur = r2.state;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const view = viewFor(cur, "bot");
|
||||||
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||||
|
const r = applyCommand(cur, "bot", cmd);
|
||||||
|
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||||
|
cur = r.state;
|
||||||
|
const at = cur.players.find((p) => p.id === "bot")!;
|
||||||
|
if (cellKey(at.position) === cellKey(bot.home)) return;
|
||||||
|
}
|
||||||
|
throw new Error("the clockwork never came home to its threatened bank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carrying with home a blink away, it teleports the delivery", () => {
|
||||||
|
const state = rig();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const prize = state.treasures.find((t) => t.owner === "foe")!;
|
||||||
|
prize.carriedBy = "bot";
|
||||||
|
prize.position = null;
|
||||||
|
bot.carriedTreasureId = prize.id;
|
||||||
|
// Two squares from home as the spell flies, but walled off on foot.
|
||||||
|
bot.position = { x: bot.home.x, y: bot.home.y + 2 };
|
||||||
|
for (const side of ["N", "S", "E", "W"] as const) {
|
||||||
|
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||||
|
}
|
||||||
|
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a would-win carrier eats the lightning, not the fireball", () => {
|
||||||
|
let state = rig();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
// Foe has one banked and carries the second: one delivery from winning.
|
||||||
|
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
||||||
|
banked.position = { ...foe.home };
|
||||||
|
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
||||||
|
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
|
||||||
|
carried.carriedBy = "foe";
|
||||||
|
carried.position = null;
|
||||||
|
foe.carriedTreasureId = carried.id;
|
||||||
|
foe.position = { ...bot.position };
|
||||||
|
bot.hand = [
|
||||||
|
{ cardId: "fireball", instanceId: "FB" },
|
||||||
|
{ cardId: "lightning-blast", instanceId: "LB" },
|
||||||
|
{ cardId: "number-4", instanceId: "N4" },
|
||||||
|
];
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the denial planner offers only castable blocks", () => {
|
||||||
|
it("tacks are offered only at the caster's feet", () => {
|
||||||
|
// TACKS demand adjacency; a planner that proposes them at range has
|
||||||
|
// its cast refused every turn, and the refusal-fallback loop reads
|
||||||
|
// as an idle bot.
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const foe = state.players.find((p) => p.id === "foe")!;
|
||||||
|
// The bot's floor gold with a raider closing on it, and only TACKS
|
||||||
|
// in hand to deny the road — from a stand-off distance.
|
||||||
|
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||||
|
foe.position = { ...gold.position! };
|
||||||
|
bot.position = { ...bot.home };
|
||||||
|
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
|
||||||
|
for (let guard = 0; guard < 6; guard++) {
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
if (!cmd) break;
|
||||||
|
const r = applyCommand(state, "bot", cmd);
|
||||||
|
// Whatever the brain proposes, the engine must accept it.
|
||||||
|
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||||
|
state = r.state;
|
||||||
|
if (cmd.type === "endTurn") break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
|
||||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { viewFor } from "../src/view";
|
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
|
||||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||||
|
|
||||||
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||||
@@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => {
|
|||||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
|
||||||
|
let { state } = threeGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
|
||||||
|
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
||||||
|
const shielder = others[0]!;
|
||||||
|
giveCard(state, shielder, "full-shield", "S", 0);
|
||||||
|
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
|
||||||
|
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
|
||||||
|
|
||||||
|
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
|
||||||
|
// No stack: the queue opens at once, in turn order from the caster.
|
||||||
|
expect(state.stack).toBeNull();
|
||||||
|
expect(state.chaosPending?.queue.length).toBe(2);
|
||||||
|
const first = state.chaosPending!.queue[0]!;
|
||||||
|
state = first === shielder
|
||||||
|
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
|
||||||
|
: must(state, first, { type: "pass" });
|
||||||
|
const second = state.chaosPending!.queue[0]!;
|
||||||
|
state = second === shielder
|
||||||
|
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
|
||||||
|
: must(state, second, { type: "pass" });
|
||||||
|
expect(state.chaosPending).toBeNull();
|
||||||
|
// The shielded hand rode out the storm untouched.
|
||||||
|
expect(shielderHand()).toEqual(kept);
|
||||||
|
});
|
||||||
|
|
||||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||||
let { state } = threeGame();
|
let { state } = threeGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
@@ -500,6 +529,83 @@ describe("zero-damage utility attacks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the VISIONSTONE pierces one wall", () => {
|
||||||
|
function stoneRig() {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const a = activePlayer(state);
|
||||||
|
const b = state.players.find((p) => p.id !== a.id)!;
|
||||||
|
a.position = { x: 2, y: 4 };
|
||||||
|
b.position = { x: 2, y: 5 };
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall";
|
||||||
|
return { state, a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("its bearer casts an attack straight through the wall", () => {
|
||||||
|
const { state, a, b } = stoneRig();
|
||||||
|
const fb = giveCard(state, a.id, "fireball", "FB", 1);
|
||||||
|
const refused = applyCommand(state, a.id, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
const stone = giveCard(state, a.id, "visionstone", "VS");
|
||||||
|
a.displayed.push(stone.instanceId);
|
||||||
|
const cast = applyCommand(state, a.id, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
|
||||||
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("its bearer plants a THORNBUSH through the wall", () => {
|
||||||
|
const { state, a } = stoneRig();
|
||||||
|
// The square past the wall must be bare ground for a creation.
|
||||||
|
const cell = { x: 2, y: 5 };
|
||||||
|
for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 };
|
||||||
|
const bush = giveCard(state, a.id, "thornbush", "TB");
|
||||||
|
const refused = applyCommand(state, a.id, {
|
||||||
|
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||||
|
const stone = giveCard(state, a.id, "visionstone", "VS", 1);
|
||||||
|
a.displayed.push(stone.instanceId);
|
||||||
|
const cast = applyCommand(state, a.id, {
|
||||||
|
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
|
||||||
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the client's sighted squares agree with the stone", () => {
|
||||||
|
const { state, a, b } = stoneRig();
|
||||||
|
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);
|
||||||
|
const stone = giveCard(state, a.id, "visionstone", "VS");
|
||||||
|
a.displayed.push(stone.instanceId);
|
||||||
|
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("teleport wraps the maze's rim", () => {
|
||||||
|
it("four spaces counted through a warp mouth reach the far side", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
// Stand the active wizard at a wraparound mouth; the paired cell is
|
||||||
|
// ONE teleport step away, exactly as it is one walking step.
|
||||||
|
const active = activePlayer(state);
|
||||||
|
const warp = boardView(state).warps[0]!;
|
||||||
|
active.position = { ...warp.from.cell };
|
||||||
|
const tp = giveCard(state, active.id, "teleport", "TP", 0);
|
||||||
|
const r = applyCommand(state, active.id, {
|
||||||
|
type: "cast", instanceId: tp.instanceId,
|
||||||
|
target: { kind: "cell", cell: { ...warp.to.cell } },
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
const after = r.state.players.find((p) => p.id === active.id)!;
|
||||||
|
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("teleport as a counteraction", () => {
|
describe("teleport as a counteraction", () => {
|
||||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
@@ -1034,3 +1140,59 @@ describe("stone dead counts only the stones in play", () => {
|
|||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
|
||||||
|
it("a destination beyond the walls is refused up front, card kept", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||||
|
caster.position = { x: 0, y: 0 };
|
||||||
|
victim.position = { x: 2, y: 4 };
|
||||||
|
// Seal the victim into their square: every walked space is out of
|
||||||
|
// reach, so ANY other destination is beyond three moved spaces.
|
||||||
|
for (const side of SIDES) {
|
||||||
|
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
|
||||||
|
}
|
||||||
|
const mf = giveCard(state, caster.id, "mental-force", "MF");
|
||||||
|
const far = { x: 4, y: 9 };
|
||||||
|
const refused = applyCommand(state, caster.id, {
|
||||||
|
type: "cast", instanceId: mf.instanceId,
|
||||||
|
target: { kind: "player", playerId: victim.id }, params: { cell: far },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
|
||||||
|
// The card is still in hand — nothing was spent on the refusal.
|
||||||
|
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
||||||
|
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const a = activePlayer(state);
|
||||||
|
const d = state.players.find((p) => p.id !== a.id)!;
|
||||||
|
// Diagonal neighbors: center-to-center sight grazes the shared corner
|
||||||
|
// (blocked, strict reading), but the legs through (3,2) are opened.
|
||||||
|
a.position = { x: 2, y: 2 };
|
||||||
|
d.position = { x: 3, y: 3 };
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
|
||||||
|
state.stack = {
|
||||||
|
attackerId: a.id, defenderId: d.id,
|
||||||
|
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
|
||||||
|
numberValue: null, amplifyFactor: 1, extendFactor: 1,
|
||||||
|
powerAttackPoints: 0, params: null, kind: "spell",
|
||||||
|
counters: [], waitingOn: d.id, bentCorner: true,
|
||||||
|
};
|
||||||
|
// Wall the straight diagonal shut so only the bend can answer.
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
|
||||||
|
const traced = stackSightTrace(viewFor(state, d.id));
|
||||||
|
expect(traced).not.toBeNull();
|
||||||
|
// The engine promises A workable corner, not which one.
|
||||||
|
expect(traced?.bend).toBeDefined();
|
||||||
|
expect(traced?.bend?.mid).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
import {
|
||||||
|
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||||
@@ -709,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
|||||||
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
||||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
||||||
scorchedThisTurn: [],
|
scorchedThisTurn: [],
|
||||||
} as never);
|
});
|
||||||
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
||||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||||
@@ -720,4 +721,69 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) expect(r.error).toContain("dread");
|
if (!r.ok) expect(r.error).toContain("dread");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a wizard caged inside the dread may round a corner to escape", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
const a = state.players.find((p) => p.id === "a")!;
|
||||||
|
const b = state.players.find((p) => p.id === "b")!;
|
||||||
|
pushSustained(state, {
|
||||||
|
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
||||||
|
remainingTurns: 5, data: {},
|
||||||
|
});
|
||||||
|
// b radiates dread from (2,5). a stands in a dead-end pocket at
|
||||||
|
// (2,3): walls on three sides, so the only way out steps SOUTH —
|
||||||
|
// closer to b — before the corridor east leads clear of the dread.
|
||||||
|
b.position = { x: 2, y: 5 };
|
||||||
|
a.position = { x: 2, y: 3 };
|
||||||
|
for (const side of ["N", "E", "W"] as const) {
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
|
||||||
|
}
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
|
||||||
|
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||||
|
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||||
|
if (!r0.ok) throw new Error(r0.error);
|
||||||
|
state = r0.state;
|
||||||
|
}
|
||||||
|
// The closer step is the only way out: permitted.
|
||||||
|
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
||||||
|
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("creatures and the dimensional warp", () => {
|
||||||
|
it("a commanded troll steps through the warp tokens", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
const you = state.players[state.turn.activeIndex]!.id;
|
||||||
|
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||||
|
state.creatures.push({
|
||||||
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||||
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||||
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||||
|
});
|
||||||
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
|
||||||
|
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
|
||||||
|
expect(troll.movementUsed).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("solid stone on the far side refuses the beast", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
const you = state.players[state.turn.activeIndex]!.id;
|
||||||
|
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||||
|
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
||||||
|
state.creatures.push({
|
||||||
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||||
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||||
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||||
|
});
|
||||||
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toContain("stone");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||||
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
|
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ describe("doors", () => {
|
|||||||
throw new Error("no door on this board");
|
throw new Error("no door on this board");
|
||||||
}
|
}
|
||||||
|
|
||||||
it("pick lock opens an adjacent door until end of turn", () => {
|
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const door = findDoor(state);
|
const door = findDoor(state);
|
||||||
@@ -130,9 +130,8 @@ describe("doors", () => {
|
|||||||
state = must(state, me.id, { type: "move", direction: dir });
|
state = must(state, me.id, { type: "move", direction: dir });
|
||||||
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
||||||
|
|
||||||
// Relocks when the turn ends.
|
// "It will relock behind you": shut at the walker's back at once —
|
||||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
// the way back is barred without another pick.
|
||||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
||||||
expect(state.openDoorEdges.length).toBe(0);
|
expect(state.openDoorEdges.length).toBe(0);
|
||||||
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -445,7 +444,7 @@ describe("a held door is an open doorway to the eye", () => {
|
|||||||
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
|
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an unheld unlocked door still blocks sight", () => {
|
it("an unlocked door is an open doorway to the wizard at its threshold", () => {
|
||||||
let { state, cell, side, holder, pursuer } = sightRig();
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
const pick = giveCard(state, holder, "pick-lock");
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
state = must(state, holder, {
|
state = must(state, holder, {
|
||||||
@@ -453,9 +452,118 @@ describe("a held door is an open doorway to the eye", () => {
|
|||||||
target: { kind: "edge", cell, side },
|
target: { kind: "edge", cell, side },
|
||||||
});
|
});
|
||||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
const refused = applyCommand(state, holder, {
|
const cast = applyCommand(state, holder, {
|
||||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
});
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lock REMOVED lets any wizard beside the door peek through", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
|
const rm = giveCard(state, holder, "remove-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: rm.instanceId,
|
||||||
|
target: { kind: "edge", cell, side },
|
||||||
|
});
|
||||||
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
|
const cast = applyCommand(state, holder, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PICK LOCK in hand is enough to ease a locked door open a crack", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
|
giveCard(state, holder, "pick-lock");
|
||||||
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
||||||
|
const cast = applyCommand(state, holder, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
||||||
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the wizard down the hallway sees no further than the shut door", () => {
|
||||||
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
|
// Pull the pursuer one square further down the hall, so the door is
|
||||||
|
// no longer on their threshold; clear their path to the doorway.
|
||||||
|
const near = neighbor(cell, side);
|
||||||
|
const far = neighbor(near, side);
|
||||||
|
if (!boardView(state).cells[cellKey(far)]) {
|
||||||
|
throw new Error("setup: seed 42 lost the hallway this rig stands in");
|
||||||
|
}
|
||||||
|
state.players.find((p) => p.id === pursuer)!.position = far;
|
||||||
|
state.edgeOverrides[edgeKey(near, side)] = "open";
|
||||||
|
const rm = giveCard(state, holder, "remove-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: rm.instanceId,
|
||||||
|
target: { kind: "edge", cell, side },
|
||||||
|
});
|
||||||
|
state = must(state, holder, { type: "endTurn", draw: 0 });
|
||||||
|
const fb = giveCard(state, pursuer, "fireball", "FB", 1);
|
||||||
|
const refused = applyCommand(state, pursuer, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder },
|
||||||
|
});
|
||||||
expect(refused.ok).toBe(false);
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the displayed MASTER KEY", () => {
|
||||||
|
function keyRig() {
|
||||||
|
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
|
||||||
|
state = toRound2(state);
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
|
if (edge !== "door") continue;
|
||||||
|
const [kind, coords] = key.split(":") as [string, string];
|
||||||
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||||
|
const cell = { x, y };
|
||||||
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||||
|
const keeper = activePlayer(state);
|
||||||
|
keeper.position = { ...cell };
|
||||||
|
return { state, cell, side, key, keeper };
|
||||||
|
}
|
||||||
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("cast bare, the key goes on display — once", () => {
|
||||||
|
const { state, keeper } = keyRig();
|
||||||
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||||
|
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
if (cast.ok) {
|
||||||
|
const after = cast.state.players.find((p) => p.id === keeper.id)!;
|
||||||
|
expect(after.displayed).toContain(mk.instanceId);
|
||||||
|
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
|
||||||
|
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||||
|
expect(again.ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("its bearer walks through locked doors, which relock at their back", () => {
|
||||||
|
const { state, cell, side, key, keeper } = keyRig();
|
||||||
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||||
|
keeper.displayed.push(mk.instanceId);
|
||||||
|
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
const after = r.state.players.find((p) => p.id === keeper.id)!;
|
||||||
|
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
|
||||||
|
// "Door relocks behind you": no lingering opening for bystanders.
|
||||||
|
expect(r.state.openDoorEdges).not.toContain(key);
|
||||||
|
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
|
||||||
|
// The key turns again on the way back.
|
||||||
|
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
|
||||||
|
expect(back.ok).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a JAMmed LOCK refuses even the master key", () => {
|
||||||
|
const { state, side, key, keeper } = keyRig();
|
||||||
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||||
|
keeper.displayed.push(mk.instanceId);
|
||||||
|
state.doorStates[key] = "jammed";
|
||||||
|
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => {
|
|||||||
// One attack per turn: a second wrench is refused.
|
// One attack per turn: a second wrench is refused.
|
||||||
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||||
}
|
}
|
||||||
expect(torn + kept).toBe(12);
|
|
||||||
expect(torn).toBeGreaterThan(0);
|
expect(torn).toBeGreaterThan(0);
|
||||||
expect(kept).toBeGreaterThan(0);
|
expect(kept).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
|
|||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
||||||
// (an optional `target?.kind === ...` branch is not a requirement).
|
// (an optional `target?.kind === ...` branch is not a requirement).
|
||||||
if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
||||||
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
||||||
if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
||||||
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
||||||
}
|
}
|
||||||
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ import {
|
|||||||
runCommand,
|
runCommand,
|
||||||
seatTokenValid,
|
seatTokenValid,
|
||||||
SPECTATOR,
|
SPECTATOR,
|
||||||
|
type CatchUpStep,
|
||||||
startGame,
|
startGame,
|
||||||
summarize,
|
summarize,
|
||||||
viewForPlayer,
|
viewForPlayer,
|
||||||
type Room,
|
type Room,
|
||||||
|
kickSeat,
|
||||||
|
abandonRoom,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import { engagementStats, recordHotseat } from "./stats";
|
import { engagementStats, recordHotseat } from "./stats";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
import { getShare, loadShares, mintShare } from "./shares";
|
||||||
@@ -105,9 +108,11 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR))
|
|||||||
// Every lookup is a full-game replay, so results rest briefly in memory.
|
// Every lookup is a full-game replay, so results rest briefly in memory.
|
||||||
|
|
||||||
interface ShareData {
|
interface ShareData {
|
||||||
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
|
steps: CatchUpStep[];
|
||||||
actor: string;
|
actor: string;
|
||||||
round: number;
|
round: number;
|
||||||
|
/** A whole finished game rather than one turn. */
|
||||||
|
whole?: boolean;
|
||||||
}
|
}
|
||||||
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
|
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
|
||||||
function shareData(id: string): ShareData | null {
|
function shareData(id: string): ShareData | null {
|
||||||
@@ -117,20 +122,21 @@ function shareData(id: string): ShareData | null {
|
|||||||
const share = getShare(id);
|
const share = getShare(id);
|
||||||
const room = share ? getRoom(share.roomId) : undefined;
|
const room = share ? getRoom(share.roomId) : undefined;
|
||||||
if (share && room?.state) {
|
if (share && room?.state) {
|
||||||
const steps = momentSteps(room, SPECTATOR, share.turn);
|
if (share.turn < 0) {
|
||||||
|
// The whole tale: a finished game from the deal to the crown.
|
||||||
|
const steps = catchUpSteps(room, SPECTATOR, 0, true);
|
||||||
if (!("error" in steps) && steps.length > 0) {
|
if (!("error" in steps) && steps.length > 0) {
|
||||||
let actor: string = steps[steps.length - 1]!.actor;
|
let winner = "";
|
||||||
let round = 0;
|
for (const st of steps) {
|
||||||
outer: for (const st of steps) {
|
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
|
||||||
for (const e of st.events) {
|
|
||||||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
|
||||||
actor = e.player;
|
|
||||||
if (e.type === "turnStarted") round = e.round;
|
|
||||||
break outer;
|
|
||||||
}
|
}
|
||||||
|
data = { steps, actor: winner, round: 0, whole: true };
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const reel = momentSteps(room, SPECTATOR, share.turn);
|
||||||
|
if (!("error" in reel) && reel.steps.length > 0) {
|
||||||
|
data = { steps: reel.steps, actor: reel.owner, round: reel.round };
|
||||||
}
|
}
|
||||||
data = { steps: steps as unknown as ShareData["steps"], actor, round };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shareCache.size > 50) shareCache.clear();
|
if (shareCache.size > 50) shareCache.clear();
|
||||||
@@ -154,8 +160,12 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
|
|||||||
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
||||||
.replace(/<title>[^<]*<\/title>\s*/, "");
|
.replace(/<title>[^<]*<\/title>\s*/, "");
|
||||||
const base = `${proto}://${host}`;
|
const base = `${proto}://${host}`;
|
||||||
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
const title = data.whole
|
||||||
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
? "The whole tale — a game of Wiz-War, replayed"
|
||||||
|
: `${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.`
|
||||||
|
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
||||||
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
|
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
|
||||||
const metas = [
|
const metas = [
|
||||||
`<title>${title}</title>`,
|
`<title>${title}</title>`,
|
||||||
@@ -204,7 +214,7 @@ const httpServer = createServer((req, res) => {
|
|||||||
"cache-control": "public, max-age=60",
|
"cache-control": "public, max-age=60",
|
||||||
"access-control-allow-origin": "*",
|
"access-control-allow-origin": "*",
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
|
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
|
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
|
||||||
@@ -212,8 +222,7 @@ const httpServer = createServer((req, res) => {
|
|||||||
const data = shareData(watch[1]!);
|
const data = shareData(watch[1]!);
|
||||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||||
if (watch[2]) {
|
if (watch[2]) {
|
||||||
const last = data.steps[data.steps.length - 1]!;
|
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||||
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
|
|
||||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
||||||
res.end(png);
|
res.end(png);
|
||||||
return;
|
return;
|
||||||
@@ -387,6 +396,17 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spoken when a clockwork's chosen command was refused by the engine —
|
||||||
|
* the table sees a stumble instead of an unexplained idle turn. */
|
||||||
|
const HESITATION_LINES = [
|
||||||
|
"RECALCULATING.",
|
||||||
|
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
|
||||||
|
"THE MAZE REFUSES MY GENIUS.",
|
||||||
|
"ERROR LOGGED. DIGNITY INTACT.",
|
||||||
|
"I MEANT TO DO THAT.",
|
||||||
|
];
|
||||||
|
|
||||||
const pumping = new Set<string>();
|
const pumping = new Set<string>();
|
||||||
function runBots(room: Room): void {
|
function runBots(room: Room): void {
|
||||||
if (pumping.has(room.id)) return;
|
if (pumping.has(room.id)) return;
|
||||||
@@ -401,6 +421,14 @@ function runBots(room: Room): void {
|
|||||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||||
// The game's end unmasks the mystery machines in the roster.
|
// The game's end unmasks the mystery machines in the roster.
|
||||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||||
|
// A refused brain-choice becomes a visible stumble, in character —
|
||||||
|
// an idle bot turn should read as hesitation, never as nothing.
|
||||||
|
if (step.hesitated) {
|
||||||
|
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
||||||
|
if (!("error" in said)) {
|
||||||
|
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||||
|
}
|
||||||
|
}
|
||||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||||
setTimeout(tick, BOT_STEP_MS);
|
setTimeout(tick, BOT_STEP_MS);
|
||||||
};
|
};
|
||||||
@@ -519,6 +547,39 @@ wss.on("connection", (socket) => {
|
|||||||
session.token = null;
|
session.token = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "kickSeat": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const kickName = String(msg.name ?? "");
|
||||||
|
const problem = kickSeat(room, session.playerId, kickName);
|
||||||
|
if (problem) return send(socket, { type: "error", message: problem });
|
||||||
|
// A kicked live socket is set adrift so it cannot act on a seat it lost.
|
||||||
|
for (const other of sessions) {
|
||||||
|
if (other.roomId === room.id && other.playerId === kickName) {
|
||||||
|
other.playerId = null;
|
||||||
|
other.roomId = null;
|
||||||
|
other.token = null;
|
||||||
|
send(other.socket, { type: "kicked", roomId: room.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcastRoomState(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "abandonRoom": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const problem = abandonRoom(room, session.playerId);
|
||||||
|
if (problem) return send(socket, { type: "error", message: problem });
|
||||||
|
for (const other of sessions) {
|
||||||
|
if (other.roomId === room.id) {
|
||||||
|
other.playerId = null;
|
||||||
|
other.roomId = null;
|
||||||
|
other.token = null;
|
||||||
|
send(other.socket, { type: "roomAbandoned", roomId: room.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "addBot": {
|
case "addBot": {
|
||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
@@ -618,14 +679,17 @@ wss.on("connection", (socket) => {
|
|||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
const turn = Number(msg.turn);
|
const turn = Number(msg.turn);
|
||||||
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" });
|
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||||
return send(socket, { type: "error", message: "one moment" });
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
const steps = momentSteps(room, session.playerId, turn);
|
// turn -1 shares the whole finished game; anything else, one turn.
|
||||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
const check = turn === -1
|
||||||
|
? catchUpSteps(room, session.playerId, 0, true)
|
||||||
|
: momentSteps(room, session.playerId, turn);
|
||||||
|
if ("error" in check) return send(socket, { type: "error", message: check.error });
|
||||||
const share = mintShare(room.id, turn);
|
const share = mintShare(room.id, turn);
|
||||||
send(socket, { type: "share", id: share.id, turn });
|
send(socket, { type: "share", id: share.id, turn });
|
||||||
break;
|
break;
|
||||||
@@ -639,9 +703,9 @@ wss.on("connection", (socket) => {
|
|||||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
||||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
|
||||||
send(socket, { type: "moment", steps });
|
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "pickColor": {
|
case "pickColor": {
|
||||||
@@ -670,18 +734,26 @@ wss.on("connection", (socket) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "myGames": {
|
case "myGames": {
|
||||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
|
||||||
|
// plus explicit verdicts on seats PROVEN dead: the room exists
|
||||||
|
// and refused this exact token. A room this server simply does
|
||||||
|
// not know earns no verdict — absence is not evidence (a
|
||||||
|
// restarting or wrong server knows nothing about anything).
|
||||||
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
||||||
const games = [];
|
const games = [];
|
||||||
|
const voided: string[] = [];
|
||||||
for (const seat of seats) {
|
for (const seat of seats) {
|
||||||
if (typeof seat !== "object" || seat === null) continue;
|
if (typeof seat !== "object" || seat === null) continue;
|
||||||
const room = getRoom(String(seat.roomId ?? ""));
|
const room = getRoom(String(seat.roomId ?? ""));
|
||||||
if (!room) continue;
|
if (!room) continue;
|
||||||
const name = String(seat.name ?? "");
|
const name = String(seat.name ?? "");
|
||||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
|
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
|
||||||
|
voided.push(`${room.id}:${name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
games.push(summarize(room, name));
|
games.push(summarize(room, name));
|
||||||
}
|
}
|
||||||
send(socket, { type: "games", games });
|
send(socket, { type: "games", games, voided });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type GameView,
|
type GameView,
|
||||||
type PlayerId,
|
type PlayerId,
|
||||||
|
CURRENT_RULES_REV,
|
||||||
} from "@wizwar/engine";
|
} from "@wizwar/engine";
|
||||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||||
import { recordRoom } from "./stats";
|
import { recordRoom } from "./stats";
|
||||||
|
|
||||||
export interface LoggedCommand {
|
export interface LoggedCommand {
|
||||||
@@ -53,11 +54,6 @@ export interface Room {
|
|||||||
|
|
||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/** Rules revision new games are dealt under (stored games keep their own).
|
|
||||||
* A rules change while games are live must bump this and gate the engine;
|
|
||||||
* local hotseat games ride the engine's default and follow in lockstep. */
|
|
||||||
const RULES_REV = 1;
|
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||||
@@ -152,6 +148,33 @@ export function joinRoom(
|
|||||||
return { token: fresh };
|
return { token: fresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
|
||||||
|
* once the deal happens, every seat is a player in the game's record. */
|
||||||
|
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
|
||||||
|
if (byId !== room.hostId) return "only the host may clear a seat";
|
||||||
|
if (room.state) return "the game has started — seats are settled";
|
||||||
|
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
|
||||||
|
if (!room.players.includes(name)) return "no such seat";
|
||||||
|
room.players = room.players.filter((p) => p !== name);
|
||||||
|
room.tokens.delete(name);
|
||||||
|
room.bots.delete(name);
|
||||||
|
room.colorChoices.delete(name);
|
||||||
|
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
|
||||||
|
recordRoom(room);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host dissolves the room: a lobby that never dealt, or a finished
|
||||||
|
* game done being remembered. The ledger is archived, never deleted. */
|
||||||
|
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
||||||
|
if (byId !== room.hostId) return "only the host may abandon the room";
|
||||||
|
if (room.state && room.state.phase === "playing") return "the game is still being played";
|
||||||
|
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
|
||||||
|
archiveRoomFile(room.id);
|
||||||
|
rooms.delete(room.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Everyone gets their chosen standee; the undecided get the first free one. */
|
/** Everyone gets their chosen standee; the undecided get the first free one. */
|
||||||
export function resolveColors(room: Room): number[] {
|
export function resolveColors(room: Room): number[] {
|
||||||
const taken = new Set<number>();
|
const taken = new Set<number>();
|
||||||
@@ -200,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
|
|||||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||||
if (room.state) return { error: "already started" };
|
if (room.state) return { error: "already started" };
|
||||||
const colors = resolveColors(room);
|
const colors = resolveColors(room);
|
||||||
const result = startInMemory(room, expansion, colors, RULES_REV);
|
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
|
||||||
if ("error" in result) return result;
|
if ("error" in result) return result;
|
||||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
|
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -290,17 +313,28 @@ function actingSeat(room: Room): PlayerId | null {
|
|||||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
* One automaton command, if the maze is waiting on clockwork. Null when a
|
||||||
* human holds the floor (or the game is over, or the clockwork is wedged).
|
* human holds the floor (or the game is over, or the clockwork is wedged).
|
||||||
*/
|
*/
|
||||||
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
|
export function driveOneAutomaton(
|
||||||
|
room: Room,
|
||||||
|
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
|
||||||
const seat = actingSeat(room);
|
const seat = actingSeat(room);
|
||||||
if (!seat || !room.bots.has(seat)) return null;
|
if (!seat || !room.bots.has(seat)) return null;
|
||||||
const view = viewFor(room.state!, seat);
|
const view = viewFor(room.state!, seat);
|
||||||
const bot = room.bots.get(seat);
|
const bot = room.bots.get(seat);
|
||||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
||||||
|
let hesitated: true | undefined;
|
||||||
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||||
if ("error" in r) {
|
if ("error" in r) {
|
||||||
// A refused choice retries with the fallback — unless the fallback IS
|
// A refused choice retries with the fallback — unless the fallback IS
|
||||||
// what just failed — then burns down the ladder to endTurn and pass.
|
// what just failed — then burns down the ladder to endTurn and pass.
|
||||||
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
// The refusal is remembered: a brain whose choice the engine rejects
|
||||||
|
// is a bug in the brain, and the table deserves to see the stumble
|
||||||
|
// rather than an unexplained idle turn.
|
||||||
|
if (chosen) {
|
||||||
|
hesitated = true;
|
||||||
|
console.warn(
|
||||||
|
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
||||||
|
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||||
|
}
|
||||||
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
||||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||||
if ("error" in r) {
|
if ("error" in r) {
|
||||||
@@ -308,7 +342,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { seat, events: r.events };
|
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSummary {
|
export interface GameSummary {
|
||||||
@@ -384,7 +418,10 @@ export interface CatchUpStep {
|
|||||||
*/
|
*/
|
||||||
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
|
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
|
||||||
if (!room.state) return { error: "game not started" };
|
if (!room.state) return { error: "game not started" };
|
||||||
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
// The SPECTATOR builds whole-game share pages: public knowledge, no seat.
|
||||||
|
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
||||||
|
return { error: "you hold no seat in this room" };
|
||||||
|
}
|
||||||
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
|
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
|
||||||
const MAX_STEPS = 200;
|
const MAX_STEPS = 200;
|
||||||
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
|
||||||
@@ -420,13 +457,22 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
|
|||||||
* identically, so a turn number names the same stretch on both ends. */
|
* identically, so a turn number names the same stretch on both ends. */
|
||||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
|
export interface MomentReel {
|
||||||
|
steps: CatchUpStep[];
|
||||||
|
/** The wizard whose turn this is — the reel's eyes. */
|
||||||
|
owner: PlayerId;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One turn's reel: every command whose events touch turn `turnIndex`
|
* One turn's reel: every command whose events touch turn `turnIndex`
|
||||||
* (0-counted across boundary events). A command that ends one turn and
|
* (0-counted across boundary events). A command that ends one turn and
|
||||||
* starts the next belongs to both, so a reel opens with the blow that
|
* starts the next belongs to both, so a reel opens with the blow that
|
||||||
* began it and closes on the handover.
|
* began it and closes on the handover. The owner comes from the very
|
||||||
|
* boundary that opened the turn — which for turn 0 lives in the DEAL,
|
||||||
|
* before any command, so no scan of the steps could find it.
|
||||||
*/
|
*/
|
||||||
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
|
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
|
||||||
if (!room.state) return { error: "game not started" };
|
if (!room.state) return { error: "game not started" };
|
||||||
// The SPECTATOR builds share pages: public knowledge only, no seat.
|
// The SPECTATOR builds share pages: public knowledge only, no seat.
|
||||||
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
||||||
@@ -435,19 +481,41 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
const MAX_STEPS = 80;
|
const MAX_STEPS = 80;
|
||||||
const { state: fresh, events: dealt } = createGame(room.state.config);
|
const { state: fresh, events: dealt } = createGame(room.state.config);
|
||||||
let current = fresh;
|
let current = fresh;
|
||||||
// The deal's own events open the first turn — count them, or every
|
// The deal's own events open the first turn — count them so turn
|
||||||
// turn number would sit one behind the chronicle's.
|
// numbers match the client chronicle's.
|
||||||
let counter = -1;
|
let counter = -1;
|
||||||
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
|
let owner: PlayerId | null = null;
|
||||||
|
let round = 0;
|
||||||
|
const bump = (e: GameEvent) => {
|
||||||
|
if (!TURN_BOUNDARY.has(e.type)) return;
|
||||||
|
counter++;
|
||||||
|
if (counter === turnIndex && owner === null && "player" in e) {
|
||||||
|
owner = (e as { player: PlayerId }).player;
|
||||||
|
if ("round" in e) round = (e as { round: number }).round;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const e of dealt) bump(e);
|
||||||
const steps: CatchUpStep[] = [];
|
const steps: CatchUpStep[] = [];
|
||||||
for (const entry of room.log) {
|
for (const entry of room.log) {
|
||||||
const result = applyCommand(current, entry.playerId, entry.command);
|
const result = applyCommand(current, entry.playerId, entry.command);
|
||||||
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||||
current = result.state;
|
current = result.state;
|
||||||
const before = counter;
|
const before = counter;
|
||||||
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
|
// The straddle commands hold more than this turn: the opener carries
|
||||||
|
// the previous wizard's last breaths, the closer the NEXT wizard's
|
||||||
|
// first. Trim both so the reel is exactly one turn — it opens on
|
||||||
|
// "X's turn begins" and stops at X's turnEnded.
|
||||||
|
let sliceStart = 0;
|
||||||
|
let sliceEnd = result.events.length;
|
||||||
|
result.events.forEach((e, i) => {
|
||||||
|
if (!TURN_BOUNDARY.has(e.type)) return;
|
||||||
|
bump(e);
|
||||||
|
if (counter === turnIndex && before < turnIndex) sliceStart = i;
|
||||||
|
if (counter === turnIndex + 1 && sliceEnd === result.events.length) sliceEnd = i;
|
||||||
|
});
|
||||||
if (before > turnIndex) break;
|
if (before > turnIndex) break;
|
||||||
if (before <= turnIndex && turnIndex <= counter) {
|
if (before <= turnIndex && turnIndex <= counter) {
|
||||||
|
const kept = result.events.slice(sliceStart, sliceEnd);
|
||||||
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||||
const said = room.chat
|
const said = room.chat
|
||||||
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
||||||
@@ -455,7 +523,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
steps.push({
|
steps.push({
|
||||||
seq: entry.seq,
|
seq: entry.seq,
|
||||||
actor: entry.playerId,
|
actor: entry.playerId,
|
||||||
events: redactFor(result.events, playerId),
|
events: redactFor(kept, playerId),
|
||||||
view: viewFor(current, playerId),
|
view: viewFor(current, playerId),
|
||||||
...(said.length > 0 ? { chat: said } : {}),
|
...(said.length > 0 ? { chat: said } : {}),
|
||||||
});
|
});
|
||||||
@@ -463,7 +531,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (steps.length === 0) return { error: "no such turn yet" };
|
if (steps.length === 0) return { error: "no such turn yet" };
|
||||||
return steps;
|
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -566,8 +634,14 @@ export function loadPersistedRooms(): void {
|
|||||||
chat: [],
|
chat: [],
|
||||||
bots: new Map(),
|
bots: new Map(),
|
||||||
};
|
};
|
||||||
|
if (lines.some((l) => l.kind === "abandon")) continue;
|
||||||
for (const line of lines.slice(1)) {
|
for (const line of lines.slice(1)) {
|
||||||
if (line.kind === "join") {
|
if (line.kind === "kick") {
|
||||||
|
room.players = room.players.filter((p) => p !== line.name);
|
||||||
|
room.tokens.delete(line.name);
|
||||||
|
room.bots.delete(line.name);
|
||||||
|
room.colorChoices.delete(line.name);
|
||||||
|
} else if (line.kind === "join") {
|
||||||
if (line.bot) {
|
if (line.bot) {
|
||||||
room.players.push(line.name);
|
room.players.push(line.name);
|
||||||
room.bots.set(line.name, {
|
room.bots.set(line.name, {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// command. Because the engine is deterministic, replaying a file rebuilds
|
// command. Because the engine is deterministic, replaying a file rebuilds
|
||||||
// the exact game state — server restarts lose nothing.
|
// the exact game state — server restarts lose nothing.
|
||||||
|
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export interface RoomMetaLine {
|
export interface RoomMetaLine {
|
||||||
@@ -58,7 +58,20 @@ export interface ChatLine {
|
|||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
export interface KickLine {
|
||||||
|
kind: "kick";
|
||||||
|
/** The seat the host removed from an unstarted room. */
|
||||||
|
name: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbandonLine {
|
||||||
|
kind: "abandon";
|
||||||
|
by: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||||
|
|
||||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||||
|
|
||||||
@@ -103,3 +116,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
|||||||
}
|
}
|
||||||
return rooms;
|
return rooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
||||||
|
* only moved out of the living rooms directory. */
|
||||||
|
export function archiveRoomFile(roomId: string): void {
|
||||||
|
const src = fileFor(roomId);
|
||||||
|
if (!existsSync(src)) return;
|
||||||
|
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||||
|
mkdirSync(graveyard, { recursive: true });
|
||||||
|
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { attentionLabel, net, spellName } from "./net.svelte";
|
import { attentionLabel, net, spellName } from "./net.svelte";
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
|
import LiveFirstPerson from "./LiveFirstPerson.svelte";
|
||||||
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
|
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
|
||||||
import Faq from "./Faq.svelte";
|
import Faq from "./Faq.svelte";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
import { local } from "./local.svelte";
|
import { local } from "./local.svelte";
|
||||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||||
import type { CardInstance, Side } from "@wizwar/engine";
|
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
net.connect();
|
net.connect();
|
||||||
net.startGamePolling();
|
net.startGamePolling();
|
||||||
@@ -51,6 +52,12 @@
|
|||||||
});
|
});
|
||||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||||
let boardFx = $state<BoardFx[]>([]);
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
|
/** The live pane's feed: each event batch, numbered, with its view. */
|
||||||
|
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
|
||||||
|
/** The pane camera's compass quadrant, worn by the keymap token. */
|
||||||
|
let fpvFacing = $state<Side | null>(null);
|
||||||
|
/** The creature the pane is riding, if any: the ribbon moves to it. */
|
||||||
|
let fpvBody = $state<string | null>(null);
|
||||||
let fxCancels: (() => void)[] = [];
|
let fxCancels: (() => void)[] = [];
|
||||||
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
|
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
|
||||||
let trapNotice = $state<string | null>(null);
|
let trapNotice = $state<string | null>(null);
|
||||||
@@ -126,6 +133,7 @@
|
|||||||
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
|
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
|
||||||
if (!prefs.flourishes) return;
|
if (!prefs.flourishes) return;
|
||||||
fxCancels.push(scheduleFx(
|
fxCancels.push(scheduleFx(
|
||||||
events, view,
|
events, view,
|
||||||
@@ -161,7 +169,7 @@
|
|||||||
/** Leafing through the face-up discard pile. */
|
/** Leafing through the face-up discard pile. */
|
||||||
let showDiscards = $state(false);
|
let showDiscards = $state(false);
|
||||||
let chatDraft = $state("");
|
let chatDraft = $state("");
|
||||||
let botTier = $state<string>(prefs.botTier);
|
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
|
||||||
/** Card whose official FAQ rulings are open. */
|
/** Card whose official FAQ rulings are open. */
|
||||||
let faqCardId = $state<string | null>(null);
|
let faqCardId = $state<string | null>(null);
|
||||||
/** A discard-pile card enlarged above the pile. */
|
/** A discard-pile card enlarged above the pile. */
|
||||||
@@ -338,7 +346,7 @@
|
|||||||
"wall-of-fire": "click a corridor line for the fire",
|
"wall-of-fire": "click a corridor line for the fire",
|
||||||
"waterwall": "click a corridor line — the wave collapses at once",
|
"waterwall": "click a corridor line — the wave collapses at once",
|
||||||
"pick-lock": "click a locked door",
|
"pick-lock": "click a locked door",
|
||||||
"master-key": "click a locked door",
|
"master-key": "click a locked door — or Display it once and walk through every lock",
|
||||||
"jam-lock": "click a door to jam its lock solid",
|
"jam-lock": "click a door to jam its lock solid",
|
||||||
"remove-lock": "click a door to strip its lock for good",
|
"remove-lock": "click a door to strip its lock for good",
|
||||||
"create-door": "click a wall for the new door",
|
"create-door": "click a wall for the new door",
|
||||||
@@ -716,6 +724,15 @@
|
|||||||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
|
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Standing on a DIMENSIONAL WARP token: clicking the paired token
|
||||||
|
// steps it through.
|
||||||
|
const dw = view.dimWarps.find((d) =>
|
||||||
|
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
|
||||||
|
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
|
||||||
|
if (dw) {
|
||||||
|
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
selectedCreature = null;
|
selectedCreature = null;
|
||||||
return;
|
return;
|
||||||
@@ -890,6 +907,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A click in the first-person pane, resolved by the renderer to the
|
||||||
|
* board's own vocabulary — then handled by the very same functions the
|
||||||
|
* top-down board uses, so the pane and the map can never disagree. */
|
||||||
|
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
|
||||||
|
if (t.kind === "player") clickPlayer(t.id);
|
||||||
|
else if (t.kind === "creature") clickCreature(t.id);
|
||||||
|
else if (t.kind === "edge") clickEdge(t.cell, t.side);
|
||||||
|
else clickCell(t.cell);
|
||||||
|
}
|
||||||
|
|
||||||
function clickPlayer(playerId: string) {
|
function clickPlayer(playerId: string) {
|
||||||
if (!view || !yourMoment) return;
|
if (!view || !yourMoment) return;
|
||||||
if (selectedCreature) {
|
if (selectedCreature) {
|
||||||
@@ -946,8 +973,10 @@
|
|||||||
if (view && me) {
|
if (view && me) {
|
||||||
const left = view.turn.movementAllowance - view.turn.movementUsed;
|
const left = view.turn.movementAllowance - view.turn.movementUsed;
|
||||||
// Unspent legs are no regret when the walk ended ON someone's gold —
|
// Unspent legs are no regret when the walk ended ON someone's gold —
|
||||||
// stopping there was the point.
|
// stopping there was the point — nor when a pickup or drop already
|
||||||
if (left > 0 && !treasuresHere.some((t) => t.owner !== view.you)) {
|
// forfeited them: those points cannot be spent, only mourned.
|
||||||
|
if (left > 0 && !view.turn.actionsEnded &&
|
||||||
|
!treasuresHere.some((t) => t.owner !== view.you)) {
|
||||||
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
|
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
|
||||||
}
|
}
|
||||||
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||||
@@ -1252,6 +1281,7 @@
|
|||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<span class="mast-title">Wiz-War</span>
|
<span class="mast-title">Wiz-War</span>
|
||||||
<span class="mast-sub">sixth edition</span>
|
<span class="mast-sub">sixth edition</span>
|
||||||
|
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
|
||||||
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}>⚙ preferences</button>
|
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}>⚙ preferences</button>
|
||||||
{#if local.active}
|
{#if local.active}
|
||||||
<span class="mast-room">hotseat</span>
|
<span class="mast-room">hotseat</span>
|
||||||
@@ -1371,33 +1401,38 @@
|
|||||||
<button class="stamp tiny" class:lit={prefs.art === "photo"}
|
<button class="stamp tiny" class:lit={prefs.art === "photo"}
|
||||||
onclick={() => setPref("art", "photo")}>cardboard</button>
|
onclick={() => setPref("art", "photo")}>cardboard</button>
|
||||||
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
|
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
|
||||||
onclick={() => setPref("art", "drawn")}>hand-drawn</button>
|
onclick={() => setPref("art", "drawn")}>alternate</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="pref-row">
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.autoGrab}
|
<input type="checkbox" checked={prefs.autoGrab}
|
||||||
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
|
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
|
||||||
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
|
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="pref-row">
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.flourishes}
|
<input type="checkbox" checked={prefs.flourishes}
|
||||||
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
|
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
|
||||||
<span>Spell flourishes (the animated effects)</span>
|
<span>Spell flourishes (the animated effects)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="pref-row">
|
<label class="pref-row pref-check">
|
||||||
|
<input type="checkbox" checked={prefs.liveFp}
|
||||||
|
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
|
||||||
|
<span>Through your eyes: a first-person pane above the board during play</span>
|
||||||
|
</label>
|
||||||
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.instantReplay}
|
<input type="checkbox" checked={prefs.instantReplay}
|
||||||
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
||||||
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="pref-row">
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.cautions}
|
<input type="checkbox" checked={prefs.cautions}
|
||||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||||
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
|
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="pref-row">
|
<div class="pref-row">
|
||||||
<span>Automatons default to</span>
|
<span>Automatons default to</span>
|
||||||
{#each ["apprentice", "adept", "archmage"] as t (t)}
|
{#each ["apprentice", "adept", "archmage"] as const as t (t)}
|
||||||
<button class="stamp tiny" class:lit={prefs.botTier === t}
|
<button class="stamp tiny" class:lit={prefs.botTier === t}
|
||||||
onclick={() => { setPref("botTier", t as typeof prefs.botTier); botTier = t; }}>{t}</button>
|
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<label class="pref-row">
|
<label class="pref-row">
|
||||||
@@ -1619,10 +1654,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if net.moment && net.moment.length > 0}
|
{#if net.moment && net.moment.steps.length > 0}
|
||||||
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
|
||||||
|
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
||||||
{:else if net.catchUp && net.catchUp.length > 0}
|
{:else if net.catchUp && net.catchUp.length > 0}
|
||||||
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
|
<Replay steps={net.catchUp}
|
||||||
|
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
|
||||||
|
onclose={() => net.closeCatchUp()} />
|
||||||
{:else if local.replaySteps && local.replaySteps.length > 0}
|
{:else if local.replaySteps && local.replaySteps.length > 0}
|
||||||
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1799,6 +1837,10 @@
|
|||||||
<span class="dot" style:background="#b3a687"></span>
|
<span class="dot" style:background="#b3a687"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
||||||
|
{#if net.you === net.hostId && p !== net.hostId}
|
||||||
|
<button class="stamp tiny kick" title="clear this seat"
|
||||||
|
onclick={() => net.kickSeat(p)}>✕</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1822,11 +1864,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if net.you === net.hostId}
|
{#if net.you === net.hostId}
|
||||||
|
<button class="stamp tiny abandon" title="dissolve this room for everyone"
|
||||||
|
onclick={() => net.abandonRoom()}>abandon room</button>
|
||||||
{#if net.players.length < 6}
|
{#if net.players.length < 6}
|
||||||
<span class="bot-row">
|
<span class="bot-row">
|
||||||
<span class="bot-label">⚙ seat a</span>
|
<span class="bot-label">⚙ seat a</span>
|
||||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
||||||
onchange={() => setPref("botTier", botTier as typeof prefs.botTier)}>
|
onchange={() => setPref("botTier", botTier)}>
|
||||||
<option value="apprentice">apprentice</option>
|
<option value="apprentice">apprentice</option>
|
||||||
<option value="adept">adept</option>
|
<option value="adept">adept</option>
|
||||||
<option value="archmage">archmage</option>
|
<option value="archmage">archmage</option>
|
||||||
@@ -1857,6 +1901,21 @@
|
|||||||
{:else if view}
|
{:else if view}
|
||||||
<div class="game">
|
<div class="game">
|
||||||
<section class="board-zone">
|
<section class="board-zone">
|
||||||
|
<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)}
|
||||||
|
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
||||||
|
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
|
||||||
|
onpickup={(w) => dispatch(w.kind === "treasure"
|
||||||
|
? { type: "pickUpTreasure", treasureId: w.id }
|
||||||
|
: { type: "pickUpObject", instanceId: w.id })}
|
||||||
|
onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })}
|
||||||
|
onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })}
|
||||||
|
onbody={(id) => (fpvBody = id)}
|
||||||
|
onWarpStep={(creatureId) => dispatch(creatureId
|
||||||
|
? { type: "creatureWarpStep", creatureId }
|
||||||
|
: { type: "warpStep" })} />
|
||||||
|
{/if}
|
||||||
<Board
|
<Board
|
||||||
{view}
|
{view}
|
||||||
edgeSelectMode={edgeSelectMode && yourMoment}
|
edgeSelectMode={edgeSelectMode && yourMoment}
|
||||||
@@ -1877,7 +1936,10 @@
|
|||||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||||
{litCells}
|
{litCells}
|
||||||
{sightTrace}
|
{sightTrace}
|
||||||
|
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||||
|
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="paper-rail">
|
<aside class="paper-rail">
|
||||||
@@ -2201,6 +2263,10 @@
|
|||||||
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
|
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
|
||||||
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
|
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
|
||||||
|
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
|
||||||
|
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
|
||||||
|
{/if}
|
||||||
{#if selectedCard?.cardId === "rotate-sector"}
|
{#if selectedCard?.cardId === "rotate-sector"}
|
||||||
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
||||||
<span>— click the sector</span>
|
<span>— click the sector</span>
|
||||||
@@ -2445,6 +2511,14 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #8d8672;
|
color: #8d8672;
|
||||||
}
|
}
|
||||||
|
.kick { margin-left: 0.4rem; color: #a04545; }
|
||||||
|
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
|
||||||
|
.mast-version {
|
||||||
|
font-family: "Courier Prime", monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #6b6454;
|
||||||
|
}
|
||||||
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
|
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
|
||||||
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
||||||
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
||||||
@@ -2762,11 +2836,52 @@
|
|||||||
.pref-row {
|
.pref-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.6rem;
|
||||||
margin: 0.55rem 0;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.55rem 0.1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #43331f;
|
color: #43331f;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
/* Rules divide the checkbox stanza only — never framing it, never
|
||||||
|
around the control rows. */
|
||||||
|
.pref-check + .pref-check { border-top: 1.5px solid #6b5a41; }
|
||||||
|
/* Checkboxes wear the table's own ink: a stamp-bordered box, checked
|
||||||
|
solid with a parchment tick — the native widget never matches the
|
||||||
|
parchment. */
|
||||||
|
.pref-row input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
flex: none;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
border: 1.5px solid #43331f;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f6efdd;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pref-row input[type="checkbox"]:checked {
|
||||||
|
background: #43331f;
|
||||||
|
}
|
||||||
|
.pref-row input[type="checkbox"]:checked::before {
|
||||||
|
content: "✓";
|
||||||
|
color: #f6efdd;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.pref-row span { flex: 1; }
|
||||||
|
/* Control rows read as a two-column table: the label holds the left
|
||||||
|
column, its controls right-align into the second. */
|
||||||
|
.pref-row > span:first-child { flex: 0 1 auto; }
|
||||||
|
.pref-row > span:first-child + .stamp,
|
||||||
|
.pref-row > span:first-child + input,
|
||||||
|
.pref-row > span:first-child + .pref-swatch { margin-left: auto; }
|
||||||
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
|
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
|
||||||
.pref-name { max-width: 11rem; }
|
.pref-name { max-width: 11rem; }
|
||||||
.pref-swatch {
|
.pref-swatch {
|
||||||
@@ -2799,6 +2914,30 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||||||
|
/* 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
|
||||||
|
* keeps the pane out of the intrinsic sizing so the BOARD alone
|
||||||
|
* decides how wide the column is. */
|
||||||
|
.table-stack {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.table-stack :global(.live-fp) { width: 0; min-width: 100%; }
|
||||||
|
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
|
||||||
|
/* FPV play: the 3D view is the primary window into the game; the
|
||||||
|
* top-down board shrinks to a keymap beneath it. */
|
||||||
|
.table-stack.fpv-primary {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 64rem;
|
||||||
|
}
|
||||||
|
.board-zone .table-stack.fpv-primary :global(svg.board) {
|
||||||
|
max-height: 30dvh;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
.board-zone :global(svg.board) {
|
.board-zone :global(svg.board) {
|
||||||
max-height: calc(100dvh - 21.5rem);
|
max-height: calc(100dvh - 21.5rem);
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
onCellClick,
|
onCellClick,
|
||||||
onEdgeClick,
|
onEdgeClick,
|
||||||
onPlayerClick,
|
onPlayerClick,
|
||||||
|
povFacing = null,
|
||||||
|
povCreatureId = null,
|
||||||
onCreatureClick,
|
onCreatureClick,
|
||||||
onWarpClick,
|
onWarpClick,
|
||||||
onCellPeek,
|
onCellPeek,
|
||||||
@@ -43,6 +45,11 @@
|
|||||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||||
onPlayerClick?: (playerId: string) => void;
|
onPlayerClick?: (playerId: string) => void;
|
||||||
|
/** Which way YOUR first-person camera faces: a wedge on your token
|
||||||
|
* keeps the keymap and the pane pointing the same way. */
|
||||||
|
povFacing?: "N" | "E" | "S" | "W" | null;
|
||||||
|
/** While the pane rides a creature, the ribbon moves to ITS token. */
|
||||||
|
povCreatureId?: string | null;
|
||||||
onCreatureClick?: (creatureId: string) => void;
|
onCreatureClick?: (creatureId: string) => void;
|
||||||
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||||
/** Long-press on a square: read the card behind whatever occupies it. */
|
/** Long-press on a square: read the card behind whatever occupies it. */
|
||||||
@@ -68,7 +75,7 @@
|
|||||||
effects?: BoardFx[] | null;
|
effects?: BoardFx[] | null;
|
||||||
/** The sight line an attack in progress traveled — proof against "how
|
/** The sight line an attack in progress traveled — proof against "how
|
||||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
|
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +120,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
||||||
|
/** The facing ribbon: a shallow golden triangle hugging a token's
|
||||||
|
* leading edge — base the token's width, rising a quarter of it. */
|
||||||
|
function facingWedge(side: "N" | "E" | "S" | "W", half: number): string {
|
||||||
|
const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2;
|
||||||
|
const fx = Math.cos(a), fy = Math.sin(a);
|
||||||
|
const px = -fy, py = fx;
|
||||||
|
const rise = half * 0.5;
|
||||||
|
return `${fx * half + px * half},${fy * half + py * half} ` +
|
||||||
|
`${fx * half - px * half},${fy * half - py * half} ` +
|
||||||
|
`${fx * (half + rise)},${fy * (half + rise)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function wizardScale(id: string): number {
|
function wizardScale(id: string): number {
|
||||||
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
||||||
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
||||||
@@ -390,14 +409,20 @@
|
|||||||
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
|
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
|
||||||
width={CELL * 0.4} height={CELL * 0.4}
|
width={CELL * 0.4} height={CELL * 0.4}
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
class="token-art small"
|
class="token-art small peekable"
|
||||||
|
role="button" tabindex="-1"
|
||||||
|
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||||
|
onkeydown={() => {}}
|
||||||
>
|
>
|
||||||
<title>{o.cardId}</title>
|
<title>{o.cardId}</title>
|
||||||
</image>
|
</image>
|
||||||
{:else}
|
{:else}
|
||||||
<rect
|
<rect
|
||||||
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
||||||
width={12} height={10} rx="2" class="ground-object"
|
width={12} height={10} rx="2" class="ground-object peekable"
|
||||||
|
role="button" tabindex="-1"
|
||||||
|
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||||
|
onkeydown={() => {}}
|
||||||
>
|
>
|
||||||
<title>{o.cardId}</title>
|
<title>{o.cardId}</title>
|
||||||
</rect>
|
</rect>
|
||||||
@@ -582,6 +607,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
|
{#if povFacing && !povCreatureId && p.id === view.you}
|
||||||
|
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
|
||||||
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -628,6 +656,10 @@
|
|||||||
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
||||||
{/if}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
|
{#if povFacing && povCreatureId === c.id}
|
||||||
|
<!-- The rider's facing ribbon, worn by the mount. -->
|
||||||
|
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
|
||||||
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -661,8 +693,15 @@
|
|||||||
|
|
||||||
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
||||||
{#if sightTrace}
|
{#if sightTrace}
|
||||||
|
{#if sightTrace.bend}
|
||||||
|
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
|
||||||
|
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
|
||||||
|
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
|
||||||
|
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
|
||||||
|
{:else}
|
||||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||||
<g class="fx-layer" aria-hidden="true">
|
<g class="fx-layer" aria-hidden="true">
|
||||||
{#each effects ?? [] as fx (fx.id)}
|
{#each effects ?? [] as fx (fx.id)}
|
||||||
@@ -752,6 +791,23 @@
|
|||||||
fill: #6a5c44;
|
fill: #6a5c44;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.pov-wedge {
|
||||||
|
fill: #ffd23e;
|
||||||
|
stroke: #43331f;
|
||||||
|
stroke-width: 1.2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.peekable { cursor: pointer; pointer-events: all; }
|
||||||
|
.sight-corner {
|
||||||
|
fill: none;
|
||||||
|
stroke: #c9a72a;
|
||||||
|
stroke-width: 2;
|
||||||
|
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sight-pulse {
|
||||||
|
0%, 100% { opacity: 0.9; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
.dim-cell {
|
.dim-cell {
|
||||||
fill: rgba(12, 9, 5, 0.55);
|
fill: rgba(12, 9, 5, 0.55);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The living maze through your own eyes: a pane that rides above the
|
||||||
|
// board during play. The board below stays the tactical truth — this
|
||||||
|
// is the experience. The camera carries the reel's instincts: it
|
||||||
|
// walks your strides, turns to your aims and to actors you can see,
|
||||||
|
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
|
||||||
|
// Every live event batch plays through the same fx pipeline the
|
||||||
|
// instant replay uses, so spells, doors, and conjurations perform
|
||||||
|
// here first.
|
||||||
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
|
import type { FpvTarget } from "./fpv/raycast";
|
||||||
|
import { objectArt, tokenArt } from "./art";
|
||||||
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
|
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
||||||
|
import { deepestFacing } from "./fpv/director";
|
||||||
|
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
|
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
|
||||||
|
view: GameView;
|
||||||
|
/** The latest live event batch, numbered so each plays once, with
|
||||||
|
* the view the server sent alongside it. */
|
||||||
|
batch: { n: number; events: GameEvent[]; view: GameView } | null;
|
||||||
|
onhide: () => void;
|
||||||
|
/** Issue a real move command: the pane is the cockpit on your turn. */
|
||||||
|
onstride?: ((side: Side) => void) | null;
|
||||||
|
canStride?: boolean;
|
||||||
|
/** Clicks in the pane resolve to board targets: the pane is not just
|
||||||
|
* the window but the wand hand. */
|
||||||
|
ontarget?: ((t: FpvTarget) => void) | null;
|
||||||
|
/** Reports the camera's compass quadrant whenever it settles on a new
|
||||||
|
* one — the keymap wears it as a wedge on your token. */
|
||||||
|
onfacing?: ((side: Side) => void) | null;
|
||||||
|
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||||
|
litCells?: Set<string> | null;
|
||||||
|
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||||
|
* lies in your own square shows as clickable icons instead. */
|
||||||
|
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
|
||||||
|
/** Possession: ride a creature's eyes and drive it from the pane. */
|
||||||
|
onCreatureMove?: ((creatureId: string, side: Side) => void) | null;
|
||||||
|
onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null;
|
||||||
|
/** Reports which creature (if any) the pane is riding, so the keymap
|
||||||
|
* can move the facing ribbon to the mount's token. */
|
||||||
|
onbody?: ((creatureId: string | null) => void) | null;
|
||||||
|
/** A dimensional-warp step for whichever body the pane is: the
|
||||||
|
* wizard (null) or the ridden creature (its id). */
|
||||||
|
onWarpStep?: ((creatureId: string | null) => void) | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const povId = $derived(view.you);
|
||||||
|
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
|
||||||
|
|
||||||
|
/** What lies in YOUR square, grabbable: floor treasures and dropped
|
||||||
|
* objects. The raycaster has no downward pitch — the tray is the eye's
|
||||||
|
* substitute for looking at your own boots. */
|
||||||
|
const atFeet = $derived.by(() => {
|
||||||
|
if (!me || me.carriedTreasureId) return [];
|
||||||
|
const k = `${me.position.x},${me.position.y}`;
|
||||||
|
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
|
||||||
|
for (const t of view.treasures) {
|
||||||
|
if (!t.position || t.carriedBy) continue;
|
||||||
|
if (`${t.position.x},${t.position.y}` !== k) continue;
|
||||||
|
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
|
||||||
|
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
|
||||||
|
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
|
||||||
|
}
|
||||||
|
for (const o of view.groundObjects[k] ?? []) {
|
||||||
|
const file = objectArt(o.cardId);
|
||||||
|
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Riding a creature: the pane looks through ITS eyes and the helm
|
||||||
|
* drives ITS legs, until you step back into your own skull. */
|
||||||
|
let possessedId = $state<string | null>(null);
|
||||||
|
const possessed = $derived(
|
||||||
|
possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null);
|
||||||
|
/** Creatures you may ride right now: yours (and the democratic
|
||||||
|
* monster), with movement or an attack still unspent. */
|
||||||
|
const rideable = $derived(canStride
|
||||||
|
? view.creatures.filter((c) =>
|
||||||
|
(c.controllerId === view.you || c.kind === "democratic-monster") &&
|
||||||
|
!c.justCreated &&
|
||||||
|
(c.movementUsed < c.movesPerTurn || !c.attackUsed))
|
||||||
|
: []);
|
||||||
|
/** The rider's own stance, held while riding: dismounting must hand
|
||||||
|
* back the exact view you left — same feet, same facing, no cut. */
|
||||||
|
let preMount: { x: number; y: number; facing: number } | null = null;
|
||||||
|
function dismount() {
|
||||||
|
possessedId = null;
|
||||||
|
if (preMount) {
|
||||||
|
cam.x = preMount.x; cam.y = preMount.y; cam.facing = preMount.facing;
|
||||||
|
preMount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dead or vanished mount — or the turn passing — dismounts you.
|
||||||
|
$effect(() => {
|
||||||
|
if (possessedId && (!possessed || !canStride)) dismount();
|
||||||
|
});
|
||||||
|
// Mounting seats the eyes in the creature's skull facing its longest
|
||||||
|
// corridor; the rider steers from there.
|
||||||
|
let seatedMount: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if (!possessedId || !possessed) { seatedMount = null; return; }
|
||||||
|
if (seatedMount === possessedId) return;
|
||||||
|
seatedMount = possessedId;
|
||||||
|
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
|
||||||
|
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
|
||||||
|
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What the ACTIVE body stands in or on: hazards it cannot see below
|
||||||
|
* itself, and the dimensional token it could step through. */
|
||||||
|
const underfoot = $derived.by(() => {
|
||||||
|
const body = possessed ?? me;
|
||||||
|
if (!body || !canStride) return { hazard: null as string | null, warpHere: false };
|
||||||
|
const k = `${body.position.x},${body.position.y}`;
|
||||||
|
const HAZARD_NAMES: Record<string, string> = {
|
||||||
|
tacks: "tacks underfoot", slime: "green slime", dust: "a dust cloud",
|
||||||
|
pit: "an open pit", ooze: "the killer ooze",
|
||||||
|
};
|
||||||
|
const kind = view.squareContents[k]?.kind;
|
||||||
|
return {
|
||||||
|
hazard: kind ? HAZARD_NAMES[kind] ?? null : null,
|
||||||
|
warpHere: view.dimWarps.some((w) =>
|
||||||
|
(w.a.x === body.position.x && w.a.y === body.position.y) ||
|
||||||
|
(w.b.x === body.position.x && w.b.y === body.position.y)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bodies sharing the active eyes' square: inside the near plane, so
|
||||||
|
* the renderer cannot show them — the tray stands in for the sprite,
|
||||||
|
* and clicking routes exactly as clicking the sprite would. */
|
||||||
|
const withYou = $derived.by(() => {
|
||||||
|
const body = possessed ?? me;
|
||||||
|
if (!body || !canStride) return [];
|
||||||
|
const bx = body.position.x, by = body.position.y;
|
||||||
|
const items: { kind: "player" | "creature"; id: string; art: string; label: string }[] = [];
|
||||||
|
for (const p of view.players) {
|
||||||
|
if (!p.alive || p.id === view.you) continue;
|
||||||
|
if (p.position.x !== bx || p.position.y !== by) continue;
|
||||||
|
items.push({ kind: "player", id: p.id, art: tokenArt(`wizard-${p.colorIndex}`, "players"), label: p.id });
|
||||||
|
}
|
||||||
|
for (const c of view.creatures) {
|
||||||
|
if (c.id === possessedId) continue;
|
||||||
|
if (c.position.x !== bx || c.position.y !== by) continue;
|
||||||
|
items.push({ kind: "creature", id: c.id, art: tokenArt(c.kind, "creatures"), label: c.kind.replace(/-/g, " ") });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||||
|
let camReady = false;
|
||||||
|
/** While the player steers, the director keeps its hands off the
|
||||||
|
* idle facings (actor-watching, corridor rescue); aims and cutaways
|
||||||
|
* still fire — your own magic always turns your head. */
|
||||||
|
let manualUntil = 0;
|
||||||
|
let turning = false;
|
||||||
|
|
||||||
|
/** Manual steering holds the director's idle aims off this long. */
|
||||||
|
const STEER_HOLD_MS = 4000;
|
||||||
|
/** Mounting holds them off for the whole ride. */
|
||||||
|
const RIDE_HOLD_MS = 60000;
|
||||||
|
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||||||
|
function manualTurn(dir: -1 | 1) {
|
||||||
|
if (turning) return;
|
||||||
|
turning = true;
|
||||||
|
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||||
|
const from = cam.facing;
|
||||||
|
const to = from + dir * (Math.PI / 2);
|
||||||
|
const t0 = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const w = Math.min(1, (now - t0) / 180);
|
||||||
|
cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
|
||||||
|
if (w < 1) requestAnimationFrame(tick);
|
||||||
|
else turning = false;
|
||||||
|
};
|
||||||
|
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
|
||||||
|
* own eyes pass clicks through to the board's handlers. */
|
||||||
|
function handleTarget(t: FpvTarget) {
|
||||||
|
if (!possessed) { ontarget?.(t); return; }
|
||||||
|
const c = possessed;
|
||||||
|
if (t.kind === "player" && onCreatureAttack) {
|
||||||
|
const p = view.players.find((q) => q.id === t.id);
|
||||||
|
if (p && p.position.x === c.position.x && p.position.y === c.position.y) {
|
||||||
|
onCreatureAttack(c.id, t.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.kind === "cell" && onCreatureMove) {
|
||||||
|
const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y;
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) === 1) {
|
||||||
|
onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manualStride(back: boolean) {
|
||||||
|
if (!canStride) return;
|
||||||
|
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||||
|
const q = Math.round(cam.facing / (Math.PI / 2));
|
||||||
|
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
|
||||||
|
if (possessed) onCreatureMove?.(possessed.id, side);
|
||||||
|
else onstride?.(side);
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
|
||||||
|
else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
|
||||||
|
else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
|
||||||
|
else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
|
||||||
|
}
|
||||||
|
let cutawayShot = $state(false);
|
||||||
|
let fpFx = $state<FpFx[]>([]);
|
||||||
|
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||||
|
|
||||||
|
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||||
|
let playedBatch = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const b = batch;
|
||||||
|
if (!b || b.n === playedBatch || !me) return;
|
||||||
|
playedBatch = b.n;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const started: number[] = [];
|
||||||
|
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||||
|
timers.push(setTimeout(() => {
|
||||||
|
started.push(fx.id);
|
||||||
|
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||||
|
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||||
|
}, delay));
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Other bodies glide between views. -------------------------------
|
||||||
|
let prevView: GameView | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const v = view;
|
||||||
|
const before = prevView;
|
||||||
|
prevView = v;
|
||||||
|
if (!before || before === v || !me) return;
|
||||||
|
const moves = gatherGlides(before, v, povId);
|
||||||
|
if (moves.length === 0) { actorPos = {}; return; }
|
||||||
|
const t0 = performance.now();
|
||||||
|
const dur = 400;
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||||||
|
const ease = w * w * (3 - 2 * w);
|
||||||
|
const next: Record<string, { x: number; y: number }> = {};
|
||||||
|
for (const m of moves) {
|
||||||
|
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||||||
|
}
|
||||||
|
actorPos = next;
|
||||||
|
if (w < 1) raf = requestAnimationFrame(tick);
|
||||||
|
else actorPos = {};
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- The camera: the director's ladder, live. ------------------------
|
||||||
|
let directedBatch = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const b = batch;
|
||||||
|
const m = me;
|
||||||
|
if (!m) { camReady = false; return; }
|
||||||
|
// Possessed: the director stands aside. The camera sits in the
|
||||||
|
// mount's skull, keeps whatever facing the rider has chosen, and
|
||||||
|
// hard-follows the mount's steps; dismounting reseats the wizard.
|
||||||
|
const mount = possessed;
|
||||||
|
if (mount) {
|
||||||
|
if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them
|
||||||
|
const mx = mount.position.x + 0.5, my = mount.position.y + 0.5;
|
||||||
|
if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) {
|
||||||
|
cam.x = mx; cam.y = my;
|
||||||
|
}
|
||||||
|
return; // dismount() restores the rider's own stance, uncut
|
||||||
|
}
|
||||||
|
const v = view;
|
||||||
|
const tx = m.position.x + 0.5;
|
||||||
|
const ty = m.position.y + 0.5;
|
||||||
|
// Untracked camera reads: this effect's own tween writes cam every
|
||||||
|
// frame, and a tracked read would restart the ease per frame.
|
||||||
|
const camX = untrack(() => cam.x);
|
||||||
|
const camY = untrack(() => cam.y);
|
||||||
|
const camF = untrack(() => cam.facing);
|
||||||
|
const dx = tx - camX;
|
||||||
|
const dy = ty - camY;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const events = b && b.n !== directedBatch ? b.events : [];
|
||||||
|
if (b) directedBatch = b.n;
|
||||||
|
const hurled = hurledIn(events, povId);
|
||||||
|
// Stepping through a warp keeps your HEADING, not your bearing to a
|
||||||
|
// point: you emerge walking out of the far mouth, so the exit facing
|
||||||
|
// is the far mouth's inward direction — travel preserved through a
|
||||||
|
// plain wrap, properly rotated where the boards were laid rotated.
|
||||||
|
// (A dimensional token has no mouths: facing simply carries over.)
|
||||||
|
let warpFacing: number | null = null;
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.type === "moved" && e.player === povId && e.via === "warp") {
|
||||||
|
const w = v.board.warps.find((wp) =>
|
||||||
|
wp.from.cell.x === e.from.x && wp.from.cell.y === e.from.y &&
|
||||||
|
wp.to.cell.x === e.to.x && wp.to.cell.y === e.to.y);
|
||||||
|
if (w) warpFacing = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
||||||
|
} else if (e.type === "warpStepped" && e.player === povId) {
|
||||||
|
warpFacing = camF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||||
|
cutawayShot = false;
|
||||||
|
const takeCutaway = (ax: number, ay: number) => {
|
||||||
|
const a = deepestFacing(v, ax, ay);
|
||||||
|
const d = castRay(v, ax, ay, a).dist;
|
||||||
|
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||||
|
cam.x = ax + Math.cos(a) * back;
|
||||||
|
cam.y = ay + Math.sin(a) * back;
|
||||||
|
cam.facing = a + Math.PI;
|
||||||
|
camReady = true;
|
||||||
|
cutawayShot = true;
|
||||||
|
};
|
||||||
|
const actor = v.activePlayerId;
|
||||||
|
const aim = aimOfEvents(events, v, povId, m.position, actor);
|
||||||
|
let targetFacing = camF;
|
||||||
|
let aimed = false;
|
||||||
|
if (warpFacing !== null) {
|
||||||
|
targetFacing = warpFacing;
|
||||||
|
aimed = true;
|
||||||
|
} else if (camReady && !willCut && dist > 0.05 && !hurled) {
|
||||||
|
targetFacing = Math.atan2(dy, dx);
|
||||||
|
aimed = true;
|
||||||
|
} else if (aim?.self && (actor !== povId || hurled)) {
|
||||||
|
// A cutaway to your own body is replay theater: in the cockpit it
|
||||||
|
// plays only for things done TO you, never for your own hands —
|
||||||
|
// dropping a dagger must not fling your eyes out of your skull.
|
||||||
|
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
|
||||||
|
} else if (aim && !aim.self) {
|
||||||
|
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||||
|
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||||
|
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||||
|
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||||||
|
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||||
|
aimed = true;
|
||||||
|
} else if (performance.now() >= manualUntil) {
|
||||||
|
// Another wizard acting in your sight line turns your head.
|
||||||
|
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
|
||||||
|
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
|
||||||
|
const ax = other.position.x + 0.5, ay = other.position.y + 0.5;
|
||||||
|
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||||
|
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||||
|
if (!ray.warped && ray.dist > toActor - 0.2) {
|
||||||
|
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||||
|
aimed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!aimed && willCut && performance.now() >= manualUntil) {
|
||||||
|
// A fresh CUT with nothing asking to be watched faces the deepest
|
||||||
|
// corridor — but a STANDING camera is never "rescued": where you
|
||||||
|
// pointed your own head is where it stays, brick or no brick.
|
||||||
|
targetFacing = deepestFacing(v, tx, ty);
|
||||||
|
}
|
||||||
|
if (willCut) {
|
||||||
|
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||||
|
camReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fromX = camX, fromY = camY, fromF = camF;
|
||||||
|
const arc = shortestArc(fromF, targetFacing);
|
||||||
|
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
|
||||||
|
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
|
||||||
|
const t0 = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.max(0, now - t0);
|
||||||
|
if (turnMs > 0 && t < turnMs) {
|
||||||
|
cam.facing = fromF + arc * (t / turnMs);
|
||||||
|
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||||
|
cam.facing = fromF + arc;
|
||||||
|
const w = (t - turnMs) / walkMs;
|
||||||
|
const ease = w * w * (3 - 2 * w);
|
||||||
|
cam.x = fromX + (tx - fromX) * ease;
|
||||||
|
cam.y = fromY + (ty - fromY) * ease;
|
||||||
|
} else {
|
||||||
|
cam.facing = fromF + arc;
|
||||||
|
cam.x = tx; cam.y = ty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
});
|
||||||
|
$effect(() => { onbody?.(possessedId); });
|
||||||
|
let lastQuad = -99;
|
||||||
|
$effect(() => {
|
||||||
|
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
|
||||||
|
if (q !== lastQuad) {
|
||||||
|
lastQuad = q;
|
||||||
|
onfacing?.(SIDES4[q]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
{#if me}
|
||||||
|
<div class="live-fp">
|
||||||
|
<!-- The bezel: instruments live in the frame, never on the glass —
|
||||||
|
every canvas pixel is target. -->
|
||||||
|
<div class="bezel-top">
|
||||||
|
<span class="bezel-zone">
|
||||||
|
{#if possessed}
|
||||||
|
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
|
||||||
|
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
|
||||||
|
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
|
||||||
|
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
||||||
|
{#each rideable as c (c.id)}
|
||||||
|
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
|
||||||
|
👁 ride the {c.kind.replace(/-/g, " ")}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="bezel-zone bezel-center">
|
||||||
|
{#if canStride}
|
||||||
|
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="bezel-zone bezel-right">
|
||||||
|
{#if withYou.length > 0}
|
||||||
|
<span class="feet-label">sharing your square</span>
|
||||||
|
{#each withYou as body (body.id)}
|
||||||
|
<button class="feet-item" title={body.label}
|
||||||
|
onclick={() => handleTarget(body.kind === "player" ? { kind: "player", id: body.id } : { kind: "creature", id: body.id })}>
|
||||||
|
<img src={body.art} alt={body.label} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bezel-mid">
|
||||||
|
<button class="drive" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
||||||
|
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||||
|
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||||
|
fx={fpFx} posOverride={actorPos}
|
||||||
|
ontarget={handleTarget} {litCells} />
|
||||||
|
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="bezel-bottom">
|
||||||
|
<span class="bezel-zone">
|
||||||
|
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
|
||||||
|
<span class="feet-label">at your feet</span>
|
||||||
|
{#if !possessed && onpickup}
|
||||||
|
{#each atFeet as item (item.id)}
|
||||||
|
<button class="feet-item" title={item.label}
|
||||||
|
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
|
||||||
|
{#if item.art}
|
||||||
|
<img src={item.art} alt={item.label} />
|
||||||
|
{:else}
|
||||||
|
<span class="feet-fallback">{item.label}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if underfoot.hazard}
|
||||||
|
<span class="feet-hazard">⚠ {underfoot.hazard}</span>
|
||||||
|
{/if}
|
||||||
|
{#if underfoot.warpHere && onWarpStep}
|
||||||
|
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}>⇋ step through the warp</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="bezel-zone bezel-center">
|
||||||
|
{#if canStride}
|
||||||
|
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="bezel-zone bezel-right">
|
||||||
|
<span class="live-fp-hint">← → turn · ↑ ↓ walk</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.live-fp {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
border: 1px solid #3a3428;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #14121a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bezel-top, .bezel-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
.bezel-top { border-bottom: 1px solid #2a2620; }
|
||||||
|
.bezel-bottom { border-top: 1px solid #2a2620; }
|
||||||
|
.bezel-spacer { flex: 1; }
|
||||||
|
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.bezel-center { justify-content: center; }
|
||||||
|
.bezel-right { justify-content: flex-end; }
|
||||||
|
.bezel-mid {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.bezel-mid :global(.fpv-canvas) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.drive {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #8d8672;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.drive:hover { color: #e9e1cb; background: rgba(201, 167, 42, 0.08); }
|
||||||
|
.stride { font-size: 0.8rem; letter-spacing: 0.06em; }
|
||||||
|
.live-fp-hide {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #8d8672;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.live-fp-hide:hover { color: #e9e1cb; }
|
||||||
|
.live-fp-hint {
|
||||||
|
font-family: "Courier Prime", monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #5d5748;
|
||||||
|
}
|
||||||
|
.ride-label {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #c9a72a;
|
||||||
|
}
|
||||||
|
.ride-stats { font-size: 0.7rem; color: #8d8672; }
|
||||||
|
.feet-label {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8d8672;
|
||||||
|
}
|
||||||
|
.feet-item {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #4a4536;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.feet-item:hover { border-color: #c9a72a; }
|
||||||
|
.feet-item img { width: 26px; height: 26px; object-fit: contain; }
|
||||||
|
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
|
||||||
|
.feet-hazard { font-size: 0.7rem; color: #d98a4a; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
|
import { tokenArt } from "./art";
|
||||||
import { scheduleFx, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay, edgeMid } from "./fpv/raycast";
|
import { castRay, edgeMid } from "./fpv/raycast";
|
||||||
|
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
import { prefs } from "./prefs.svelte";
|
import { prefs } from "./prefs.svelte";
|
||||||
import { stackSightTrace } from "@wizwar/engine";
|
import { stackSightTrace } from "@wizwar/engine";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
@@ -14,6 +16,7 @@
|
|||||||
steps,
|
steps,
|
||||||
onclose,
|
onclose,
|
||||||
moment = false,
|
moment = false,
|
||||||
|
pov = null,
|
||||||
onshare = null,
|
onshare = null,
|
||||||
endLabel = null,
|
endLabel = null,
|
||||||
}: {
|
}: {
|
||||||
@@ -22,6 +25,9 @@
|
|||||||
/** An instant replay of one turn: open straight into first person,
|
/** An instant replay of one turn: open straight into first person,
|
||||||
* through the eyes of the wizard whose turn it is. */
|
* through the eyes of the wizard whose turn it is. */
|
||||||
moment?: boolean;
|
moment?: boolean;
|
||||||
|
/** The turn-owner, when the server already knows it (moment reels):
|
||||||
|
* the fallback when no boundary has scrolled past yet. */
|
||||||
|
pov?: string | null;
|
||||||
/** Mint a public link to this turn; resolves to the URL. */
|
/** Mint a public link to this turn; resolves to the URL. */
|
||||||
onshare?: (() => Promise<string>) | null;
|
onshare?: (() => Promise<string>) | null;
|
||||||
/** What the leave button says once the reel has run out. */
|
/** What the leave button says once the reel has run out. */
|
||||||
@@ -49,25 +55,50 @@
|
|||||||
/** Watch the board from above, or relive it through your own eyes. */
|
/** Watch the board from above, or relive it through your own eyes. */
|
||||||
let fp = $state(moment);
|
let fp = $state(moment);
|
||||||
|
|
||||||
/** Whose eyes the first-person camera wears: normally your own; in a
|
/** Whose eyes the first-person camera wears: the wizard whose turn it
|
||||||
* moment reel, the turn-owner's — their position seen with YOUR
|
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||||||
* knowledge of the maze, so nothing private leaks. */
|
* maze, so nothing private leaks. The owner is whoever the last turn
|
||||||
|
* boundary at or before the current step named; before any boundary
|
||||||
|
* scrolls past, the server-known owner (moment reels) or the viewer. */
|
||||||
const povId = $derived.by(() => {
|
const povId = $derived.by(() => {
|
||||||
if (!moment) return steps[0]!.view.you;
|
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
|
||||||
for (const s of steps) {
|
const evs = steps[i]!.events;
|
||||||
const started = s.events.find(
|
for (let j = evs.length - 1; j >= 0; j--) {
|
||||||
(e) => e.type === "turnStarted" || e.type === "extraTurnStarted",
|
const e = evs[j]!;
|
||||||
);
|
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
||||||
if (started && "player" in started) return started.player as string;
|
return e.player as string;
|
||||||
}
|
}
|
||||||
return steps[0]!.actor;
|
}
|
||||||
|
}
|
||||||
|
// No boundary yet: these are the FIRST turn's steps, whose opening
|
||||||
|
// turnStarted fired in the deal before any command. Its owner is
|
||||||
|
// whoever the reel's first turnEnded names.
|
||||||
|
for (const st of steps) {
|
||||||
|
for (const e of st.events) {
|
||||||
|
if (e.type === "turnEnded" && "player" in e) return (e as { player: string }).player;
|
||||||
|
if (e.type === "turnStarted" || e.type === "extraTurnStarted") break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pov ?? steps[0]!.view.you;
|
||||||
});
|
});
|
||||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||||
|
/** What you drew belongs in the chronicle, not on a reel that may be
|
||||||
|
* recorded and passed around — captions keep the count, not the cards. */
|
||||||
|
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
|
||||||
const lines = $derived(
|
const lines = $derived(
|
||||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
step.events
|
||||||
|
.filter((e) => !CAPTION_SILENT.has(e.type))
|
||||||
|
.map(humanize)
|
||||||
|
.filter((l): l is string => l !== null),
|
||||||
);
|
);
|
||||||
const atEnd = $derived(idx >= steps.length - 1);
|
const atEnd = $derived(idx >= steps.length - 1);
|
||||||
|
|
||||||
|
/** The acting wizard's standee, shown beside their words. */
|
||||||
|
const actorArt = $derived.by(() => {
|
||||||
|
const p = step.view.players.find((x) => x.id === step.actor);
|
||||||
|
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
|
||||||
|
});
|
||||||
|
|
||||||
// The reel draws the same sight line the live table shows for an LOS
|
// The reel draws the same sight line the live table shows for an LOS
|
||||||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||||
const sightTrace = $derived(stackSightTrace(step.view));
|
const sightTrace = $derived(stackSightTrace(step.view));
|
||||||
@@ -103,12 +134,24 @@
|
|||||||
return () => { cancel(); boardFx = []; };
|
return () => { cancel(); boardFx = []; };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The director's slate: the camera effect writes how long this step's
|
||||||
|
* outcome stays HIDDEN — the old world held on screen — while the eyes
|
||||||
|
* turn to face the recipient. The fx and the view reveal together. */
|
||||||
|
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
|
||||||
|
let heldView = $state<GameView | null>(null);
|
||||||
|
/** A cutaway frame shows the reel's own wizard too. */
|
||||||
|
let cutawayShot = $state(false);
|
||||||
|
|
||||||
/** In first person the same events become projectiles, impacts,
|
/** In first person the same events become projectiles, impacts,
|
||||||
* flashes, and shakes — scheduled on this step's beat. */
|
* flashes, and shakes — scheduled on this step's beat, after the
|
||||||
|
* camera's turn has brought the recipient into frame. */
|
||||||
let fpFx = $state<FpFx[]>([]);
|
let fpFx = $state<FpFx[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const st = steps[Math.min(idx, steps.length - 1)];
|
const i = Math.min(idx, steps.length - 1);
|
||||||
|
const st = steps[i];
|
||||||
if (!fp || !st || !prefs.flourishes) return;
|
if (!fp || !st || !prefs.flourishes) return;
|
||||||
|
const plan = camPlan;
|
||||||
|
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
const started: number[] = [];
|
const started: number[] = [];
|
||||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
|
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
|
||||||
@@ -116,7 +159,7 @@
|
|||||||
started.push(fx.id);
|
started.push(fx.id);
|
||||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||||
}, delay / speed));
|
}, plan.holdMs + delay / speed));
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
timers.forEach(clearTimeout);
|
timers.forEach(clearTimeout);
|
||||||
@@ -145,28 +188,7 @@
|
|||||||
const before = prevView;
|
const before = prevView;
|
||||||
prevView = v;
|
prevView = v;
|
||||||
if (!before || before === v) return;
|
if (!before || before === v) return;
|
||||||
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
|
const moves = gatherGlides(before, v, povId);
|
||||||
const gather = (
|
|
||||||
id: string,
|
|
||||||
now: { x: number; y: number },
|
|
||||||
was: { x: number; y: number } | undefined,
|
|
||||||
) => {
|
|
||||||
if (!was) return;
|
|
||||||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
|
||||||
// A single stride or shove glides; a leap across the maze is a
|
|
||||||
// teleport and should simply be there.
|
|
||||||
if (d > 0.05 && d <= 3.5) {
|
|
||||||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (const p of v.players) {
|
|
||||||
if (!p.alive || p.id === povId) continue;
|
|
||||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
|
||||||
gather(p.id, p.position, was?.position);
|
|
||||||
}
|
|
||||||
for (const c of v.creatures) {
|
|
||||||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
|
||||||
}
|
|
||||||
if (moves.length === 0) { actorPos = {}; return; }
|
if (moves.length === 0) { actorPos = {}; return; }
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const dur = 400 / speed;
|
const dur = 400 / speed;
|
||||||
@@ -192,12 +214,6 @@
|
|||||||
// still, the eye turns toward whoever acted.
|
// still, the eye turns toward whoever acted.
|
||||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||||
let camReady = false;
|
let camReady = false;
|
||||||
function shortestArc(from: number, to: number): number {
|
|
||||||
let d = (to - from) % (2 * Math.PI);
|
|
||||||
if (d > Math.PI) d -= 2 * Math.PI;
|
|
||||||
if (d < -Math.PI) d += 2 * Math.PI;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fp) { camReady = false; return; }
|
if (!fp) { camReady = false; return; }
|
||||||
const v = step.view;
|
const v = step.view;
|
||||||
@@ -219,34 +235,54 @@
|
|||||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||||
// A blow that hurls the body glides fast and straight, however far,
|
// A blow that hurls the body glides fast and straight, however far,
|
||||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||||
const hurled = step.events.some((e) =>
|
const hurled = hurledIn(step.events, povId);
|
||||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
|
||||||
e.type === "retreatedInHorror") && e.player === povId);
|
|
||||||
const actor = v.players.find((p) => p.id === step.actor);
|
const actor = v.players.find((p) => p.id === step.actor);
|
||||||
// Where the eyes' own magic went this step: a wizard looks where
|
// Where the eyes' own magic went this step: a wizard looks where
|
||||||
// they aim, so the camera turns to watch its own spells land.
|
// they aim, so the camera turns to watch its own spells land.
|
||||||
const aim = (() => {
|
const aim = aimOfEvents(step.events, v, povId, me.position, step.actor);
|
||||||
for (const e of step.events) {
|
// A leap the legs cannot explain (first frame, teleport, the return
|
||||||
let cell: { x: number; y: number } | null = null;
|
// from a cutaway) is a CUT, and a cut is not a stride: the direction
|
||||||
if (e.type === "spellCast" && e.caster === povId) {
|
// of travel means nothing at the far end.
|
||||||
cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null;
|
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||||
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
cutawayShot = false;
|
||||||
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
|
/** The broadcast cutaway: stand back down the target cell's deepest
|
||||||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
* corridor, facing it, holding a beat of the world-before so the
|
||||||
cell = e.landedAt;
|
* deed happens ON screen — with the reel's own wizard visible. */
|
||||||
|
const takeCutaway = (ax: number, ay: number) => {
|
||||||
|
const a = deepestFacing(v, ax, ay);
|
||||||
|
const d = castRay(v, ax, ay, a).dist;
|
||||||
|
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||||
|
cam.x = ax + Math.cos(a) * back;
|
||||||
|
cam.y = ay + Math.sin(a) * back;
|
||||||
|
cam.facing = a + Math.PI;
|
||||||
|
camReady = true;
|
||||||
|
cutawayShot = true;
|
||||||
|
const i2 = Math.min(idx, steps.length - 1);
|
||||||
|
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||||||
|
heldView = pv;
|
||||||
|
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
|
||||||
|
if (pv) {
|
||||||
|
const t = setTimeout(() => (heldView = null), 420 / speed);
|
||||||
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
|
return;
|
||||||
}
|
};
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
let targetFacing = camF;
|
let targetFacing = camF;
|
||||||
let aimed = false;
|
let aimed = false;
|
||||||
// A stride turns the eyes along it — but only a REAL stride: on the
|
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||||||
// reel's first run the camera sits at its uninitialized origin, and
|
else if (aim?.self) {
|
||||||
// that phantom distance must not pick the opening shot's direction.
|
// Summoned at your own feet: no turn can show it — step outside
|
||||||
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
// yourself and watch it grow beside you.
|
||||||
|
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
|
||||||
|
}
|
||||||
else if (aim) {
|
else if (aim) {
|
||||||
targetFacing = Math.atan2(aim.y + 0.5 - ty, aim.x + 0.5 - tx);
|
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||||
|
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||||
|
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||||
|
if (sight.warped || sight.dist < toAim - 0.4) {
|
||||||
|
return takeCutaway(ax, ay);
|
||||||
|
}
|
||||||
|
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||||
aimed = true;
|
aimed = true;
|
||||||
} else if (actor && actor.id !== povId &&
|
} else if (actor && actor.id !== povId &&
|
||||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||||
@@ -260,26 +296,93 @@
|
|||||||
aimed = true;
|
aimed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!aimed && castRay(v, tx, ty, targetFacing).dist < 0.8) {
|
// How good is the view down heading `a` from (px,py)? Corridor depth,
|
||||||
// Nothing aims the eyes and they'd idle nose-to-brick (the opening
|
// and at the reel's end, a bonus for visible subjects.
|
||||||
// cut, or a beat after pacing into a dead end): face the deepest
|
const viewScoreAt = (px: number, py: number, a: number): number => {
|
||||||
// corridor from where the wizard stands instead.
|
let score = Math.min(castRay(v, px, py, a).dist, 8);
|
||||||
let deepest = -1;
|
if (!atEnd) return score;
|
||||||
|
const bonus = (cell: { x: number; y: number }, worth: number) => {
|
||||||
|
const bx = cell.x + 0.5, by = cell.y + 0.5;
|
||||||
|
const bearing = Math.atan2(by - py, bx - px);
|
||||||
|
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
|
||||||
|
const d = Math.hypot(bx - px, by - py);
|
||||||
|
if (d < 0.1) return 0;
|
||||||
|
const sight = castRay(v, px, py, bearing);
|
||||||
|
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
|
||||||
|
};
|
||||||
|
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
|
||||||
|
for (const c of v.creatures) score += bonus(c.position, 5);
|
||||||
|
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
if (!aimed) {
|
||||||
|
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
|
||||||
|
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
|
||||||
|
// higher standard), find a better view: the deepest corridor, and
|
||||||
|
// on the final frame, preferably one with something in it.
|
||||||
|
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
|
||||||
|
// A cut lands with whatever stale facing it carried, so a cut with
|
||||||
|
// nothing aimed always re-picks its view from the new position.
|
||||||
|
const staring = willCut ||
|
||||||
|
castRay(v, tx, ty, targetFacing).dist < (atEnd ? 1.2 : 0.8);
|
||||||
|
if (staring) {
|
||||||
|
const current = viewScore(targetFacing);
|
||||||
|
let bestA = targetFacing, bestScore = current;
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||||
const h = castRay(v, tx, ty, a);
|
const sc = viewScore(a);
|
||||||
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
|
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||||||
|
}
|
||||||
|
// One evaluation, one turn — a wizard walled in on all sides
|
||||||
|
// keeps whatever view it has rather than hunting forever.
|
||||||
|
targetFacing = bestA;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!camReady || (dist > 1.6 && !hurled)) {
|
// The reel's LAST step may be a stride into a corner: the walk keeps
|
||||||
// First frame, or a leap the legs cannot explain: cut.
|
// its own facing, then the camera turns once more to a view worth
|
||||||
|
// ending on.
|
||||||
|
let closingArc = 0;
|
||||||
|
if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
|
||||||
|
const current = viewScoreAt(tx, ty, targetFacing);
|
||||||
|
let bestA = targetFacing, bestScore = current;
|
||||||
|
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||||
|
const sc = viewScoreAt(tx, ty, a);
|
||||||
|
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||||||
|
}
|
||||||
|
closingArc = shortestArc(targetFacing, bestA);
|
||||||
|
}
|
||||||
|
const stepIdx = Math.min(idx, steps.length - 1);
|
||||||
|
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
|
||||||
|
/** Hold the OLD world on screen this long, so the outcome lands only
|
||||||
|
* once the eyes face its recipient: a cutaway gets a beat of the
|
||||||
|
* "before"; a turn holds until the turn is done. */
|
||||||
|
const setSlate = (holdMs: number) => {
|
||||||
|
// Locals only: reading heldView back here would register it as a
|
||||||
|
// dependency of this very effect, and the timer clearing it would
|
||||||
|
// re-trigger us into a ping-pong.
|
||||||
|
const hold = holdMs > 60 && prevView ? prevView : null;
|
||||||
|
heldView = hold;
|
||||||
|
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
||||||
|
if (hold) {
|
||||||
|
const t = setTimeout(() => (heldView = null), holdMs);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// rolling; the opening frame reveals at once.
|
||||||
|
const wasReady = camReady;
|
||||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||||
camReady = true;
|
camReady = true;
|
||||||
return;
|
return setSlate(wasReady && aim ? 380 / speed : 0);
|
||||||
}
|
}
|
||||||
const fromX = camX, fromY = camY, fromF = camF;
|
const fromX = camX, fromY = camY, fromF = camF;
|
||||||
const arc = shortestArc(fromF, targetFacing);
|
const arc = shortestArc(fromF, targetFacing);
|
||||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||||
|
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
||||||
|
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
@@ -295,15 +398,22 @@
|
|||||||
const ease = w * w * (3 - 2 * w);
|
const ease = w * w * (3 - 2 * w);
|
||||||
cam.x = fromX + (tx - fromX) * ease;
|
cam.x = fromX + (tx - fromX) * ease;
|
||||||
cam.y = fromY + (ty - fromY) * ease;
|
cam.y = fromY + (ty - fromY) * ease;
|
||||||
|
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
|
||||||
|
cam.x = tx; cam.y = ty;
|
||||||
|
const w = (t - turnMs - walkMs) / closingMs;
|
||||||
|
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
||||||
} else {
|
} else {
|
||||||
cam.facing = fromF + arc;
|
cam.facing = fromF + arc + closingArc;
|
||||||
cam.x = tx; cam.y = ty;
|
cam.x = tx; cam.y = ty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearSlate();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Save a video: the first-person view composited into a shareable
|
// --- Save a video: the first-person view composited into a shareable
|
||||||
@@ -315,30 +425,47 @@
|
|||||||
if (recorder) { recorder.stop(); return; }
|
if (recorder) { recorder.stop(); return; }
|
||||||
const cv = stageEl?.querySelector("canvas");
|
const cv = stageEl?.querySelector("canvas");
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
// Recording means capturing the WHOLE reel: rewind to the top and
|
// Recording means capturing the WHOLE reel: rewind to the top and roll.
|
||||||
// roll. (Hitting record at the end used to auto-stop a beat later —
|
|
||||||
// a one-second clip of the final frame.)
|
|
||||||
idx = 0;
|
idx = 0;
|
||||||
const comp = document.createElement("canvas");
|
const comp = document.createElement("canvas");
|
||||||
comp.width = 1280;
|
comp.width = 1280;
|
||||||
comp.height = 720;
|
comp.height = 720;
|
||||||
const g = comp.getContext("2d")!;
|
const g = comp.getContext("2d")!;
|
||||||
|
const faces = new Map<string, HTMLImageElement>();
|
||||||
|
const faceFor = (src: string): HTMLImageElement | null => {
|
||||||
|
let img = faces.get(src);
|
||||||
|
if (!img) {
|
||||||
|
img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
faces.set(src, img);
|
||||||
|
}
|
||||||
|
return img.complete && img.naturalWidth > 0 ? img : null;
|
||||||
|
};
|
||||||
let compRaf = 0;
|
let compRaf = 0;
|
||||||
const drawComp = () => {
|
const drawComp = () => {
|
||||||
g.imageSmoothingEnabled = false;
|
g.imageSmoothingEnabled = false;
|
||||||
g.drawImage(cv, 0, 0, 1280, 720);
|
g.drawImage(cv, 0, 0, 1280, 720);
|
||||||
g.imageSmoothingEnabled = true;
|
g.imageSmoothingEnabled = true;
|
||||||
// The caption bar: who did what, in the reel's own words.
|
// The caption bar: who did what, in the reel's own words — their
|
||||||
|
// standee at the left.
|
||||||
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||||||
g.fillRect(0, 720 - 96, 1280, 96);
|
g.fillRect(0, 720 - 96, 1280, 96);
|
||||||
|
const face = actorArt ? faceFor(actorArt) : null;
|
||||||
|
const textX = face ? 110 : 28;
|
||||||
|
if (face) {
|
||||||
|
g.drawImage(face, 24, 720 - 96 + 14, 68, 68);
|
||||||
|
g.strokeStyle = "#43331f";
|
||||||
|
g.lineWidth = 2;
|
||||||
|
g.strokeRect(24, 720 - 96 + 14, 68, 68);
|
||||||
|
}
|
||||||
g.fillStyle = "#e0b34a";
|
g.fillStyle = "#e0b34a";
|
||||||
g.font = "600 22px Oswald, sans-serif";
|
g.font = "600 22px Oswald, sans-serif";
|
||||||
g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400);
|
g.fillText(step.actor.toUpperCase(), textX, 720 - 60, 400);
|
||||||
g.fillStyle = "#efe8d4";
|
g.fillStyle = "#efe8d4";
|
||||||
g.font = "21px 'Courier Prime', monospace";
|
g.font = "21px 'Courier Prime', monospace";
|
||||||
const said = lines.slice(0, 2);
|
const said = lines.slice(0, 2);
|
||||||
if (said.length === 0) said.push("…considers the maze.");
|
if (said.length === 0) said.push("…considers the maze.");
|
||||||
said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224));
|
said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 1224 - (textX - 28)));
|
||||||
// The colophon corner.
|
// The colophon corner.
|
||||||
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
||||||
g.font = "600 20px Oswald, sans-serif";
|
g.font = "600 20px Oswald, sans-serif";
|
||||||
@@ -399,7 +526,7 @@
|
|||||||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||||||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if moment && onshare}
|
{#if onshare}
|
||||||
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
|
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
|
||||||
{shareState === "idle" ? "🔗 share link"
|
{shareState === "idle" ? "🔗 share link"
|
||||||
: shareState === "minting" ? "…"
|
: shareState === "minting" ? "…"
|
||||||
@@ -409,7 +536,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="replay-board" bind:this={stageEl}>
|
<div class="replay-board" bind:this={stageEl}>
|
||||||
{#if fp}
|
{#if fp}
|
||||||
<FirstPerson view={step.view} {povId}
|
<FirstPerson view={heldView ?? step.view} povId={cutawayShot ? "" : povId}
|
||||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -417,6 +544,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-caption">
|
<div class="replay-caption">
|
||||||
|
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
|
||||||
|
<div class="caption-lines">
|
||||||
<strong>{step.actor}</strong>
|
<strong>{step.actor}</strong>
|
||||||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||||||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||||||
@@ -424,6 +553,7 @@
|
|||||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="replay-controls">
|
<div class="replay-controls">
|
||||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||||
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||||||
@@ -513,7 +643,19 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
min-height: 3.4rem;
|
min-height: 3.4rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.caption-face {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1.5px solid #43331f;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.caption-lines { min-width: 0; }
|
||||||
.replay-controls .speed {
|
.replay-controls .speed {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
||||||
actor: string;
|
actor: string;
|
||||||
round: number;
|
round: number;
|
||||||
|
whole?: boolean;
|
||||||
}
|
}
|
||||||
let data = $state<ShareSteps | null>(null);
|
let data = $state<ShareSteps | null>(null);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
<header class="share-head">
|
<header class="share-head">
|
||||||
<a class="share-brand" href="/">WIZ-WAR</a>
|
<a class="share-brand" href="/">WIZ-WAR</a>
|
||||||
{#if data}
|
{#if data}
|
||||||
<div class="share-title">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</div>
|
<div class="share-title">
|
||||||
|
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="share-stage">
|
<div class="share-stage">
|
||||||
{#key reelKey}
|
{#key reelKey}
|
||||||
<Replay steps={data.steps} moment endLabel="⟲ watch it again"
|
<Replay steps={data.steps} moment pov={data.actor} endLabel="⟲ watch it again"
|
||||||
onclose={() => (reelKey += 1)} />
|
onclose={() => (reelKey += 1)} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import FirewallEdge from "./FirewallEdge.svelte";
|
import FirewallEdge from "./FirewallEdge.svelte";
|
||||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||||
import { FX_ART } from "./fpv/fx3d";
|
import { FX_ART } from "./fpv/fx3d";
|
||||||
|
import { MATERIALS } from "./fpv/textures";
|
||||||
import { TERRAIN3D } from "./fpv/terrain3d";
|
import { TERRAIN3D } from "./fpv/terrain3d";
|
||||||
|
|
||||||
const GROUPS: { title: string; files: string[] }[] = [
|
const GROUPS: { title: string; files: string[] }[] = [
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
wherever a file is missing.
|
wherever a file is missing.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp"] as name (name)}
|
{#each MATERIALS as name (name)}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||||
<figcaption>{name}</figcaption>
|
<figcaption>{name}</figcaption>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
// GameView. Columns of wall shaded by distance and facing; token art
|
// GameView. Columns of wall shaded by distance and facing; token art
|
||||||
// billboarded for whatever stands in the corridors, occluded per column
|
// billboarded for whatever stands in the corridors, occluded per column
|
||||||
// by the same depth buffer the walls wrote.
|
// by the same depth buffer the walls wrote.
|
||||||
import { castRay, billboards } from "./raycast";
|
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
|
||||||
import { materialTextures } from "./textures";
|
import { materialTextures } from "./textures";
|
||||||
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||||
import { tokenArt } from "../art";
|
import { tokenArt } from "../art";
|
||||||
import { PLAYER_COLORS } from "../colors";
|
import { PLAYER_COLORS } from "../colors";
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
fx = [],
|
fx = [],
|
||||||
posOverride,
|
posOverride,
|
||||||
rubble = [],
|
rubble = [],
|
||||||
|
ontarget,
|
||||||
|
litCells = null,
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
povId: string;
|
povId: string;
|
||||||
@@ -38,11 +40,44 @@
|
|||||||
posOverride?: Record<string, { x: number; y: number }>;
|
posOverride?: Record<string, { x: number; y: number }>;
|
||||||
/** Where walls have died: a mound of stone marks each fallen edge. */
|
/** Where walls have died: a mound of stone marks each fallen edge. */
|
||||||
rubble?: { x: number; y: number }[];
|
rubble?: { x: number; y: number }[];
|
||||||
|
/** Present = the pane is an instrument: clicks resolve to targets. */
|
||||||
|
ontarget?: (t: FpvTarget) => void;
|
||||||
|
/** Squares a selected cell-target card may aim at: the pane dims the
|
||||||
|
* ineligible ground exactly as the board dims its squares. */
|
||||||
|
litCells?: Set<string> | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const FOV = Math.PI / 2.9;
|
const FOV = Math.PI / 2.9;
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
/** Everything the LAST drawn frame knew, kept for click resolution:
|
||||||
|
* the pane is an instrument only because the renderer remembers what
|
||||||
|
* stood under every pixel. */
|
||||||
|
let hitFrame: {
|
||||||
|
W: number; H: number; ex: number; ey: number; facing: number;
|
||||||
|
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
||||||
|
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
||||||
|
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
||||||
|
sprites: Projected[];
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
/** The one visibility rule a sprite obeys in a column — shared by the
|
||||||
|
* draw pass and the hover hit test so they can never disagree. */
|
||||||
|
function spriteVisibleInCol(
|
||||||
|
sp: Projected, col: number,
|
||||||
|
zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array,
|
||||||
|
): boolean {
|
||||||
|
if (sp.depth >= zbuf[col]!) return false;
|
||||||
|
if (sp.warped) {
|
||||||
|
if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false;
|
||||||
|
} else if (sp.depth >= warpDistCol[col]!) return false;
|
||||||
|
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** Crosshair position in canvas pixels, while the pointer is over the
|
||||||
|
* pane and the pane is an instrument. */
|
||||||
|
let mouse: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
// Token art loads lazily; a sprite draws once its image has arrived.
|
// Token art loads lazily; a sprite draws once its image has arrived.
|
||||||
const images = new Map<string, HTMLImageElement>();
|
const images = new Map<string, HTMLImageElement>();
|
||||||
function imageFor(src: string): HTMLImageElement | null {
|
function imageFor(src: string): HTMLImageElement | null {
|
||||||
@@ -55,16 +90,6 @@
|
|||||||
return img.complete && img.naturalWidth > 0 ? img : null;
|
return img.complete && img.naturalWidth > 0 ? img : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base colors per face (fallback + tinting basis). */
|
|
||||||
const FACE: Record<string, [number, number, number]> = {
|
|
||||||
wall: [126, 118, 100],
|
|
||||||
stone: [96, 96, 104],
|
|
||||||
door: [130, 92, 48],
|
|
||||||
rim: [82, 76, 66],
|
|
||||||
firewall: [214, 92, 28],
|
|
||||||
warp: [96, 60, 160],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Materials come from /textures/*.png when those files exist — the
|
// Materials come from /textures/*.png when those files exist — the
|
||||||
// independently paintable set — with the baked procedurals underneath
|
// independently paintable set — with the baked procedurals underneath
|
||||||
// so a missing or still-loading file never leaves a wall naked.
|
// so a missing or still-loading file never leaves a wall naked.
|
||||||
@@ -114,10 +139,15 @@
|
|||||||
parseInt(hex.slice(5, 7), 16) / 255,
|
parseInt(hex.slice(5, 7), 16) / 255,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
const DECAL_KIND: Record<string, string> = {
|
||||||
|
pit: "pit", slime: "slime", tacks: "tacks",
|
||||||
|
thornbush: "underbrush", rosebush: "underbrush",
|
||||||
|
};
|
||||||
for (const [k, content] of Object.entries(v.squareContents)) {
|
for (const [k, content] of Object.entries(v.squareContents)) {
|
||||||
if (content.kind !== "pit" && content.kind !== "slime" && content.kind !== "tacks") continue;
|
const tex = DECAL_KIND[content.kind];
|
||||||
|
if (!tex) continue;
|
||||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||||
put(cx, cy, content.kind);
|
put(cx, cy, tex);
|
||||||
}
|
}
|
||||||
for (const w of v.dimWarps) {
|
for (const w of v.dimWarps) {
|
||||||
put(w.a.x, w.a.y, "dimwarp");
|
put(w.a.x, w.a.y, "dimwarp");
|
||||||
@@ -145,12 +175,21 @@
|
|||||||
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doors mid-swing this frame, from the animated fx.
|
// Doors mid-swing and conjurations mid-growth this frame.
|
||||||
let doors: Record<string, number> | undefined;
|
let doors: Record<string, number> | undefined;
|
||||||
|
let growing: Record<string, number> | undefined;
|
||||||
|
const spawn: Record<string, number> = {};
|
||||||
for (const f of fx) {
|
for (const f of fx) {
|
||||||
if (f.kind !== "door") continue;
|
if (f.kind === "door") {
|
||||||
const a = doorOpenness((time - f.t0) / f.dur);
|
const a = doorOpenness((time - f.t0) / f.dur);
|
||||||
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
||||||
|
} else if (f.kind === "grow") {
|
||||||
|
const p = (time - f.t0) / f.dur;
|
||||||
|
if (p < 0 || p >= 1) continue;
|
||||||
|
const g = growProgress(p);
|
||||||
|
if (f.slot === "solid") (growing ??= {})[f.key] = g;
|
||||||
|
else spawn[f.key] = g;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ground and the vault overhead, cast per pixel: each screen row
|
// The ground and the vault overhead, cast per pixel: each screen row
|
||||||
@@ -164,6 +203,17 @@
|
|||||||
const fl = pixelsOf(textures.floor!);
|
const fl = pixelsOf(textures.floor!);
|
||||||
const ce = pixelsOf(textures.ceiling!);
|
const ce = pixelsOf(textures.ceiling!);
|
||||||
const FOG = 13;
|
const FOG = 13;
|
||||||
|
// A selected cell-card's eligibility, rasterized once for the pixel
|
||||||
|
// loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit.
|
||||||
|
let litGrid: Uint8Array | null = null;
|
||||||
|
const litBw = view.board.width, litBh = view.board.height;
|
||||||
|
if (litCells) {
|
||||||
|
litGrid = new Uint8Array(litBw * litBh);
|
||||||
|
for (const k of litCells) {
|
||||||
|
const [lx, ly] = k.split(",").map(Number) as [number, number];
|
||||||
|
if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let row = 0; row < H; row++) {
|
for (let row = 0; row < H; row++) {
|
||||||
const below = row > half;
|
const below = row > half;
|
||||||
const dz = below ? row - half : half - row;
|
const dz = below ? row - half : half - row;
|
||||||
@@ -197,6 +247,11 @@
|
|||||||
let v = wy % 1; if (v < 0) v += 1;
|
let v = wy % 1; if (v < 0) v += 1;
|
||||||
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
||||||
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
||||||
|
let sh = shade;
|
||||||
|
if (litGrid && below) {
|
||||||
|
const gx = (wx - u) | 0, gy = (wy - v) | 0;
|
||||||
|
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
|
||||||
|
}
|
||||||
if (dec) {
|
if (dec) {
|
||||||
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
||||||
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
||||||
@@ -217,9 +272,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buf[o] = r * shade;
|
buf[o] = r * sh;
|
||||||
buf[o + 1] = g * shade;
|
buf[o + 1] = g * sh;
|
||||||
buf[o + 2] = b * shade;
|
buf[o + 2] = b * sh;
|
||||||
}
|
}
|
||||||
buf[o + 3] = 255;
|
buf[o + 3] = 255;
|
||||||
wx += stepX;
|
wx += stepX;
|
||||||
@@ -233,34 +288,72 @@
|
|||||||
// side must MATCH its column's, or bodies near a far mouth would
|
// side must MATCH its column's, or bodies near a far mouth would
|
||||||
// ghost into real corridors the unrolled space happens to overlap.
|
// ghost into real corridors the unrolled space happens to overlap.
|
||||||
const zbuf = new Float64Array(W);
|
const zbuf = new Float64Array(W);
|
||||||
const warpedCol = new Uint8Array(W);
|
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
|
||||||
|
// Per column: which warp (if any) the ray bent through, and how far
|
||||||
|
// away that mouth stood. A REAL body paints a column only if it is
|
||||||
|
// nearer than the mouth (it stands in front of the window); a
|
||||||
|
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
|
||||||
|
const warpIdCol = new Int32Array(W).fill(-1);
|
||||||
|
const warpDistCol = new Float64Array(W).fill(Infinity);
|
||||||
|
// Each mouth a ray passed through hangs a translucent veil of the
|
||||||
|
// warp texture at its own depth, drawn with the other overlays.
|
||||||
|
const veils: { col: number; depth: number; u: number }[] = [];
|
||||||
// Known illusions: the ray passes, but a translucent ghost of a wall
|
// Known illusions: the ray passes, but a translucent ghost of a wall
|
||||||
// stands at the crossing — drawn after the sprites so bodies show
|
// stands at the crossing — drawn after the sprites so bodies show
|
||||||
// through it, shimmering so nobody mistakes it for stone.
|
// through it, shimmering so nobody mistakes it for stone.
|
||||||
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
|
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
|
||||||
|
// Open doorways collect their lintels, hung after the sprites; and
|
||||||
|
// conjurations still rising collect their growing columns.
|
||||||
|
const lintels: { col: number; depth: number; u: number }[] = [];
|
||||||
|
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
|
||||||
for (let col = 0; col < W; col++) {
|
for (let col = 0; col < W; col++) {
|
||||||
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
||||||
const hit = castRay(view, ex, ey, rayAngle, doors);
|
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
|
||||||
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
||||||
zbuf[col] = depth;
|
zbuf[col] = depth;
|
||||||
warpedCol[col] = hit.warped ? 1 : 0;
|
if (hit.warpId !== undefined) {
|
||||||
|
warpIdCol[col] = hit.warpId;
|
||||||
|
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
||||||
|
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
|
||||||
|
}
|
||||||
if (hit.ghost) {
|
if (hit.ghost) {
|
||||||
ghosts.push({
|
ghosts.push({
|
||||||
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
|
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
|
||||||
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (hit.doorway) {
|
||||||
|
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
|
||||||
|
}
|
||||||
|
if (hit.rising) {
|
||||||
|
risings.push({
|
||||||
|
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
|
||||||
|
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
|
||||||
|
});
|
||||||
|
}
|
||||||
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||||
const top = half - wallH / 2;
|
const top = half - wallH / 2;
|
||||||
const tex = textures[hit.kind] ?? textures.wall!;
|
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
|
||||||
|
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||||
// Sample by the texture's own size: painted files may be any scale.
|
// Sample by the texture's own size: painted files may be any scale.
|
||||||
// Fire and warps shift their slice per world cell, so a blaze
|
// Fire shifts its slice per world cell, so a blaze spanning edges
|
||||||
// spanning edges reads as one long fire, not a repeated flame.
|
// reads as one long fire, not a repeated flame.
|
||||||
const texU = hit.kind === "firewall" || hit.kind === "warp"
|
const texU = hit.kind === "firewall"
|
||||||
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
||||||
: hit.u;
|
: hit.u;
|
||||||
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
||||||
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
||||||
|
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||||
|
// Harm shows: accumulated damage wears fractures into the face.
|
||||||
|
const dmg = view.wallDamage[hit.edge];
|
||||||
|
if (dmg) {
|
||||||
|
const ck = textures.cracks!;
|
||||||
|
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
|
||||||
|
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
|
||||||
|
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Distance and orientation carve the light; overlays animate it.
|
// Distance and orientation carve the light; overlays animate it.
|
||||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||||
@@ -270,11 +363,6 @@
|
|||||||
ctx.fillRect(col, top, 1, wallH);
|
ctx.fillRect(col, top, 1, wallH);
|
||||||
dark *= 0.5; // the fire lights itself
|
dark *= 0.5; // the fire lights itself
|
||||||
}
|
}
|
||||||
if (hit.kind === "warp") {
|
|
||||||
const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
|
|
||||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
|
||||||
}
|
|
||||||
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||||
// The same tell the board gives: an untested illusion's face
|
// The same tell the board gives: an untested illusion's face
|
||||||
// shimmers faintly — maybe stone, maybe not.
|
// shimmers faintly — maybe stone, maybe not.
|
||||||
@@ -302,10 +390,13 @@
|
|||||||
|
|
||||||
// Sprites, far to near, sliced against the depth buffer.
|
// Sprites, far to near, sliced against the depth buffer.
|
||||||
const sprites = billboards(view, povId, tokenArt, posOverride)
|
const sprites = billboards(view, povId, tokenArt, posOverride)
|
||||||
.map((b) => project(b, ex, ey))
|
.map((b) => {
|
||||||
|
const g = b.key !== undefined ? spawn[b.key] : undefined;
|
||||||
|
return project(g !== undefined ? { ...b, scale: b.scale * g } : b, ex, ey);
|
||||||
|
})
|
||||||
.filter((s): s is Projected => s !== null);
|
.filter((s): s is Projected => s !== null);
|
||||||
for (const spot of rubble) {
|
for (const spot of rubble) {
|
||||||
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.55, rise: 0 }, ex, ey);
|
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
|
||||||
if (s) sprites.push({ ...s, fallback: "rubble" });
|
if (s) sprites.push({ ...s, fallback: "rubble" });
|
||||||
}
|
}
|
||||||
for (const f of fx) {
|
for (const f of fx) {
|
||||||
@@ -318,12 +409,33 @@
|
|||||||
const s = project({
|
const s = project({
|
||||||
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
||||||
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
||||||
rise: 0.3,
|
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
|
||||||
warped: f.kind === "projectile" ? f.warped : undefined,
|
warped: f.kind === "projectile" ? f.warped : undefined,
|
||||||
|
warpId: f.kind === "projectile" ? f.warpId : undefined,
|
||||||
}, ex, ey);
|
}, ex, ey);
|
||||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||||
}
|
}
|
||||||
sprites.sort((a, b) => b.depth - a.depth);
|
sprites.sort((a, b) => b.sort - a.sort);
|
||||||
|
hitFrame = {
|
||||||
|
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
||||||
|
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
||||||
|
};
|
||||||
|
// The nearest sprite each column carries — depth AND vertical span —
|
||||||
|
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
||||||
|
// around a body standing in front of them instead of over it.
|
||||||
|
const spriteZ = new Float64Array(W).fill(Infinity);
|
||||||
|
const sprTop = new Float64Array(W);
|
||||||
|
const sprBot = new Float64Array(W);
|
||||||
|
/** The parts of an overlay column NOT hidden behind the column's
|
||||||
|
* nearest sprite: whole, split around it, or nothing. */
|
||||||
|
const maskedSegs = (col: number, depth: number, top: number, h: number): [number, number][] => {
|
||||||
|
if (depth < spriteZ[col]!) return [[top, h]];
|
||||||
|
const segs: [number, number][] = [];
|
||||||
|
const bottom = top + h;
|
||||||
|
if (sprTop[col]! > top) segs.push([top, Math.min(h, sprTop[col]! - top)]);
|
||||||
|
if (sprBot[col]! < bottom) segs.push([sprBot[col]!, bottom - sprBot[col]!]);
|
||||||
|
return segs;
|
||||||
|
};
|
||||||
for (const s of sprites) {
|
for (const s of sprites) {
|
||||||
const img = imageFor(s.src) ??
|
const img = imageFor(s.src) ??
|
||||||
(s.fallback
|
(s.fallback
|
||||||
@@ -337,8 +449,12 @@
|
|||||||
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
||||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
||||||
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||||
if (s.depth >= zbuf[col]!) continue;
|
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
|
||||||
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
|
if (s.depth < spriteZ[col]!) {
|
||||||
|
spriteZ[col] = s.depth;
|
||||||
|
sprTop[col] = s.top;
|
||||||
|
sprBot[col] = s.bottom;
|
||||||
|
}
|
||||||
const texX = ((col - s.left) / (s.right - s.left));
|
const texX = ((col - s.left) / (s.right - s.left));
|
||||||
if (img) {
|
if (img) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
@@ -360,19 +476,83 @@
|
|||||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warp mouths wear their veil: the warp texture at the opening,
|
||||||
|
// translucent, swirling — the doorway between rooms that are not
|
||||||
|
// neighbors announces itself.
|
||||||
|
for (const vl of veils) {
|
||||||
|
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
|
||||||
|
const vTop = half - vh / 2;
|
||||||
|
const wt = textures.warp!;
|
||||||
|
const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
|
||||||
|
for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) {
|
||||||
|
ctx.globalAlpha = 0.3;
|
||||||
|
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width),
|
||||||
|
((segTop - vTop) / vh) * wt.height,
|
||||||
|
Math.max(1, wt.width / 96), (segH / vh) * wt.height,
|
||||||
|
vl.col, segTop, 1, segH);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
|
ctx.fillRect(vl.col, segTop, 1, segH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conjurations rising: the wall (or stone) grows bottom-up where it
|
||||||
|
// will stand, glowing swirls running over the young face.
|
||||||
|
for (const ri of risings) {
|
||||||
|
const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05));
|
||||||
|
const grown = full * ri.g;
|
||||||
|
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));
|
||||||
|
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),
|
||||||
|
((segTop - top0) / grown) * tex.height,
|
||||||
|
Math.max(1, tex.width / 96), (segH / grown) * tex.height,
|
||||||
|
ri.col, segTop, 1, segH);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
|
ctx.fillRect(ri.col, segTop, 1, segH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doorway lintels: the frame's top rows hung across each opening.
|
||||||
|
for (const li of lintels) {
|
||||||
|
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
|
||||||
|
const lTop = half - lh / 2;
|
||||||
|
const band = lh * 0.16;
|
||||||
|
const ft = textures.doorframe!;
|
||||||
|
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
|
||||||
|
for (const [segTop, segH] of maskedSegs(li.col, li.depth, lTop, band)) {
|
||||||
|
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width),
|
||||||
|
((segTop - lTop) / band) * ft.height * 0.16,
|
||||||
|
Math.max(1, ft.width / 96), (segH / band) * ft.height * 0.16,
|
||||||
|
li.col, segTop, 1, segH);
|
||||||
|
if (dark > 0.02) {
|
||||||
|
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
||||||
|
ctx.fillRect(li.col, segTop, 1, segH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The ghosts of walls you know are lies: drawn over everything at
|
// The ghosts of walls you know are lies: drawn over everything at
|
||||||
// their columns, thin as breath, rippling.
|
// their columns, thin as breath, rippling.
|
||||||
for (const gh of ghosts) {
|
for (const gh of ghosts) {
|
||||||
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
||||||
const gTop = half - gHft / 2;
|
const gTop = half - gHft / 2;
|
||||||
const tex = textures.wall!;
|
const tex = textures.wall!;
|
||||||
ctx.globalAlpha = 0.2;
|
|
||||||
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), 0,
|
|
||||||
Math.max(1, tex.width / 96), tex.height, gh.col, gTop, 1, gHft);
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
|
const ripple = 0.10 + 0.07 * Math.sin(time / 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),
|
||||||
|
((segTop - gTop) / gHft) * tex.height,
|
||||||
|
Math.max(1, tex.width / 96), (segH / gHft) * tex.height,
|
||||||
|
gh.col, segTop, 1, segH);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
|
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
|
||||||
ctx.fillRect(gh.col, gTop, 1, gHft);
|
ctx.fillRect(gh.col, segTop, 1, segH);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A whisper of vignette holds the torchlit mood together.
|
// A whisper of vignette holds the torchlit mood together.
|
||||||
@@ -392,6 +572,71 @@
|
|||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The crosshair's answer, before the click commits: what the pane
|
||||||
|
// would target here, outlined in the table's gold with its name.
|
||||||
|
if (ontarget && mouse) drawHover(ctx, W, H, half);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the hover cue for whatever stands under the crosshair. */
|
||||||
|
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
||||||
|
const f = hitFrame;
|
||||||
|
if (!f || !mouse) return;
|
||||||
|
const found = resolveHover(mouse.x, mouse.y);
|
||||||
|
if (!found) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "#c9a72a";
|
||||||
|
ctx.fillStyle = "rgba(201,167,42,0.14)";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
let label = "";
|
||||||
|
if (found.shape.kind === "rect") {
|
||||||
|
const r = found.shape;
|
||||||
|
ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
||||||
|
ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
||||||
|
label = found.label;
|
||||||
|
} else if (found.shape.kind === "face") {
|
||||||
|
// Tint every column that shows THIS edge: the whole face answers.
|
||||||
|
for (let col = 0; col < f.W; col++) {
|
||||||
|
const c = f.cols[col];
|
||||||
|
if (!c || c.edge !== found.shape.edge) continue;
|
||||||
|
ctx.fillRect(col, c.top, 1, c.h);
|
||||||
|
}
|
||||||
|
label = found.label;
|
||||||
|
} else if (found.shape.kind === "none") {
|
||||||
|
label = found.label;
|
||||||
|
} else {
|
||||||
|
// A ground square: its four corners projected onto the floor plane.
|
||||||
|
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||||
|
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
||||||
|
const { x: cx0, y: cy0 } = found.shape.cell;
|
||||||
|
const pts: [number, number][] = [];
|
||||||
|
for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
|
||||||
|
const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey;
|
||||||
|
const depth = rx * cosF + ry * sinF;
|
||||||
|
if (depth < 0.12) { pts.length = 0; break; }
|
||||||
|
const side = -rx * sinF + ry * cosF;
|
||||||
|
pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]);
|
||||||
|
}
|
||||||
|
if (pts.length === 4) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]![0], pts[0]![1]);
|
||||||
|
for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
ctx.font = "12px 'Courier Prime', monospace";
|
||||||
|
const wTxt = ctx.measureText(label).width;
|
||||||
|
const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10));
|
||||||
|
const ly = Math.max(16, mouse.y - 8);
|
||||||
|
ctx.fillStyle = "rgba(13,12,18,0.85)";
|
||||||
|
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
|
||||||
|
ctx.fillStyle = "#e9e1cb";
|
||||||
|
ctx.fillText(label, lx, ly);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Projected {
|
interface Projected {
|
||||||
@@ -402,14 +647,26 @@
|
|||||||
top: number;
|
top: number;
|
||||||
bottom: number;
|
bottom: number;
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
|
warpId?: number;
|
||||||
glow?: boolean;
|
glow?: boolean;
|
||||||
alpha?: number;
|
alpha?: number;
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
|
clampL?: number;
|
||||||
|
clampR?: number;
|
||||||
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
|
cell?: { x: number; y: number };
|
||||||
|
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
|
||||||
|
* hedge over the wizard standing in it) but must never let one
|
||||||
|
* cheat past a wall — occlusion always uses the true depth. */
|
||||||
|
sort: number;
|
||||||
}
|
}
|
||||||
function project(
|
function project(
|
||||||
b: { x: number; y: number; src: string; scale: number; rise: number;
|
b: { x: number; y: number; src: string; scale: number; rise: number;
|
||||||
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
||||||
fallback?: string; warped?: boolean },
|
fallback?: string; warped?: boolean; warpId?: number;
|
||||||
|
clip?: { x: number; y: number };
|
||||||
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
|
cell?: { x: number; y: number } },
|
||||||
ex: number, ey: number,
|
ex: number, ey: number,
|
||||||
): Projected | null {
|
): Projected | null {
|
||||||
const relX = b.x - ex, relY = b.y - ey;
|
const relX = b.x - ex, relY = b.y - ey;
|
||||||
@@ -420,16 +677,168 @@
|
|||||||
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
||||||
const wallH = H / depth;
|
const wallH = H / depth;
|
||||||
const size = wallH * b.scale;
|
const size = wallH * b.scale;
|
||||||
const wide = size * (b.aspect ?? 1);
|
// Vertical pixels scale by H/depth, horizontal by focal/depth — and
|
||||||
|
// they differ. A sprite that declares an aspect means WORLD width
|
||||||
|
// (a hedge one cell wide must touch both posts), so its width uses
|
||||||
|
// the horizontal scale; plain square sprites keep their pixel shape.
|
||||||
|
const wide = b.aspect
|
||||||
|
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
||||||
|
: size;
|
||||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
const bottom = half + wallH / 2 - b.rise * wallH;
|
||||||
|
// A cell-bound volume is windowed to its own square: the billboard's
|
||||||
|
// camera-facing plane is intersected with the cell, and only that
|
||||||
|
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
||||||
|
// poke its ends through the neighboring walls. (Virtual warp copies
|
||||||
|
// skip this — their clip cell lives in another frame.)
|
||||||
|
let clampL: number | undefined;
|
||||||
|
let clampR: number | undefined;
|
||||||
|
if (b.clip && !b.warped) {
|
||||||
|
const sinF = Math.sin(facing), cosF = Math.cos(facing);
|
||||||
|
let sMin = -Infinity, sMax = Infinity;
|
||||||
|
if (Math.abs(sinF) > 1e-6) {
|
||||||
|
const a1 = (b.x - b.clip.x) / sinF;
|
||||||
|
const a2 = (b.x - (b.clip.x + 1)) / sinF;
|
||||||
|
sMin = Math.max(sMin, Math.min(a1, a2));
|
||||||
|
sMax = Math.min(sMax, Math.max(a1, a2));
|
||||||
|
}
|
||||||
|
if (Math.abs(cosF) > 1e-6) {
|
||||||
|
const a1 = (b.clip.y - b.y) / cosF;
|
||||||
|
const a2 = (b.clip.y + 1 - b.y) / cosF;
|
||||||
|
sMin = Math.max(sMin, Math.min(a1, a2));
|
||||||
|
sMax = Math.min(sMax, Math.max(a1, a2));
|
||||||
|
}
|
||||||
|
if (sMin <= sMax && Number.isFinite(sMin) && Number.isFinite(sMax)) {
|
||||||
|
const sideC = -relX * sinF + relY * cosF;
|
||||||
|
const fl = (W / 2) / Math.tan(FOV / 2);
|
||||||
|
const e1 = W / 2 + ((sideC + sMin) / depth) * fl;
|
||||||
|
const e2 = W / 2 + ((sideC + sMax) / depth) * fl;
|
||||||
|
clampL = Math.min(e1, e2);
|
||||||
|
clampR = Math.max(e1, e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped,
|
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
||||||
alpha: b.alpha, glow: b.glow, fallback: b.fallback,
|
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
||||||
|
hit: b.hit, cell: b.cell,
|
||||||
left: screenX - wide / 2, right: screenX + wide / 2,
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||||
top: bottom - size, bottom,
|
top: bottom - size, bottom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a canvas-pixel click to what stood under it: the nearest
|
||||||
|
* visible sprite, else the struck wall or door face, else the floor
|
||||||
|
* (or vault) square the pixel lies on — warp-bent columns mapping
|
||||||
|
* their virtual ground back to real cells through the warp's own
|
||||||
|
* rigid motion. */
|
||||||
|
function hitTest(px: number, py: number): FpvTarget | null {
|
||||||
|
return resolveHover(px, py)?.target ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full answer for a canvas pixel: the target, the screen shape to
|
||||||
|
* highlight, and the name to whisper beside the crosshair. */
|
||||||
|
function resolveHover(px: number, py: number): {
|
||||||
|
target: FpvTarget;
|
||||||
|
label: string;
|
||||||
|
shape:
|
||||||
|
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
||||||
|
| { kind: "face"; edge: string }
|
||||||
|
| { kind: "ground"; cell: { x: number; y: number } }
|
||||||
|
| { kind: "none" };
|
||||||
|
} | null {
|
||||||
|
const f = hitFrame;
|
||||||
|
if (!f) return null;
|
||||||
|
const col = Math.max(0, Math.min(f.W - 1, px | 0));
|
||||||
|
const half = f.H / 2;
|
||||||
|
|
||||||
|
// Sprites first, nearest first, honoring the draw pass's own
|
||||||
|
// visibility rules for this column.
|
||||||
|
for (const sp of f.sprites) {
|
||||||
|
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
|
||||||
|
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
|
||||||
|
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
|
||||||
|
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
|
||||||
|
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
|
||||||
|
if (sp.hit) return { target: sp.hit, label, shape };
|
||||||
|
return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wall span: doors and walls answer as their EDGE.
|
||||||
|
const c = f.cols[col];
|
||||||
|
if (c && py >= c.top && py <= c.top + c.h) {
|
||||||
|
if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) {
|
||||||
|
const [kind, coords] = c.edge.split(":") as [string, string];
|
||||||
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||||
|
return {
|
||||||
|
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
|
||||||
|
label: c.kind === "firewall" ? "wall of fire" : c.kind,
|
||||||
|
shape: { kind: "face", edge: c.edge },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (c.kind === "stone") {
|
||||||
|
// A stone fill is a square, not an edge: the cell just past the
|
||||||
|
// struck face along this column's ray.
|
||||||
|
const t = f.zbuf[col]! + 0.05;
|
||||||
|
const pt = groundPoint(f, col, t);
|
||||||
|
return pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
|
||||||
|
}
|
||||||
|
return null; // rims and frame posts are nobody's target
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ground (or vault): each row below the horizon lies at one depth.
|
||||||
|
const dz = py > half ? py - half : half - py;
|
||||||
|
if (dz < 1) return null;
|
||||||
|
const d = (f.H / 2) / dz;
|
||||||
|
if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click
|
||||||
|
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
||||||
|
// Warp-bent ground still TARGETS truly, but the highlight quad
|
||||||
|
// cannot be drawn in this frame's geometry: label it instead.
|
||||||
|
const pt = groundPoint(f, col, d);
|
||||||
|
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null;
|
||||||
|
}
|
||||||
|
const pt = groundPoint(f, col, d);
|
||||||
|
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sprite's spoken name: the creature or thing under the crosshair. */
|
||||||
|
function labelOf(sp: Projected): string {
|
||||||
|
if (sp.hit?.kind === "creature") {
|
||||||
|
const c = view.creatures.find((k) => k.id === sp.hit!.id);
|
||||||
|
return c ? c.kind.replace(/-/g, " ") : "creature";
|
||||||
|
}
|
||||||
|
if (sp.fallback) return sp.fallback.replace(/-/g, " ");
|
||||||
|
const m = sp.src.match(/\/([a-z0-9-]+)\.png/i);
|
||||||
|
return m ? m[1]!.replace(/-/g, " ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real-world square at perpendicular depth d down column col —
|
||||||
|
* mapping through the column's warp when the ray bent. */
|
||||||
|
function groundPoint(
|
||||||
|
f: NonNullable<typeof hitFrame>, col: number, d: number,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||||
|
const side = (col - f.W / 2) * (d / flen);
|
||||||
|
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
||||||
|
let wx = f.ex + d * cosF - side * sinF;
|
||||||
|
let wy = f.ey + d * sinF + side * cosF;
|
||||||
|
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
||||||
|
const w = view.board.warps[f.warpIdCol[col]!];
|
||||||
|
if (!w) return null;
|
||||||
|
const real = warpMotion(w).toReal({ x: wx, y: wy });
|
||||||
|
wx = real.x; wy = real.y;
|
||||||
|
}
|
||||||
|
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
|
||||||
|
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasClick(e: MouseEvent) {
|
||||||
|
if (!ontarget || !canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - rect.left) * (width / rect.width);
|
||||||
|
const py = (e.clientY - rect.top) * (height / rect.height);
|
||||||
|
const t = hitTest(px, py);
|
||||||
|
if (t) ontarget(t);
|
||||||
|
}
|
||||||
|
|
||||||
// Redraw every frame: the fire flickers and the warps swirl even when
|
// Redraw every frame: the fire flickers and the warps swirl even when
|
||||||
// the camera holds still.
|
// the camera holds still.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -440,9 +849,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<canvas bind:this={canvas} {width} {height} class="fpv-canvas"></canvas>
|
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
|
||||||
|
onclick={onCanvasClick}
|
||||||
|
onpointermove={(e) => {
|
||||||
|
if (!ontarget || !canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) };
|
||||||
|
}}
|
||||||
|
onpointerleave={() => (mouse = null)}></canvas>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.targeting { cursor: crosshair; }
|
||||||
.fpv-canvas {
|
.fpv-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -14,8 +14,11 @@
|
|||||||
/** ?demo=1: two automatons play a stretch, then the reel replays it —
|
/** ?demo=1: two automatons play a stretch, then the reel replays it —
|
||||||
* the real Replay component, toggleable into first person. */
|
* the real Replay component, toggleable into first person. */
|
||||||
const demoReel = q.has("demo");
|
const demoReel = q.has("demo");
|
||||||
|
// ?players=2..6 sizes the table (5-player boards carry the corner
|
||||||
|
// warps whose traversals rotate the world a quarter-turn).
|
||||||
|
const count = Math.min(6, Math.max(2, Number(q.get("players") ?? 3)));
|
||||||
const { state: dealt } = createGame({
|
const { state: dealt } = createGame({
|
||||||
playerIds: ["Wanderer", "Rival", "Stranger"],
|
playerIds: ["Wanderer", "Rival", "Stranger", "Pilgrim", "Vagabond", "Drifter"].slice(0, count),
|
||||||
seed,
|
seed,
|
||||||
sets: ["basic", "expansion1"],
|
sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
@@ -27,6 +30,20 @@
|
|||||||
view.knownIllusionEdges.push(k);
|
view.knownIllusionEdges.push(k);
|
||||||
delete view.board.edges[k];
|
delete view.board.edges[k];
|
||||||
}
|
}
|
||||||
|
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
|
||||||
|
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
|
||||||
|
view.openDoorEdges.push(k);
|
||||||
|
}
|
||||||
|
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
|
||||||
|
const [k, n] = spec.split("@") as [string, string?];
|
||||||
|
view.wallDamage[k] = Number(n ?? 1) || 1;
|
||||||
|
}
|
||||||
|
// ?carry=Rival hands the first treasure to that wizard, for viewing
|
||||||
|
// what a laden thief looks like.
|
||||||
|
const carrier = q.get("carry");
|
||||||
|
if (carrier && view.treasures[0]) {
|
||||||
|
view.treasures[0] = { ...view.treasures[0], carriedBy: carrier, position: null };
|
||||||
|
}
|
||||||
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
|
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
|
||||||
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
|
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
|
||||||
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
|
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
|
||||||
@@ -39,10 +56,9 @@
|
|||||||
const start = view.players.find((p) => p.id === povId)!.position;
|
const start = view.players.find((p) => p.id === povId)!.position;
|
||||||
|
|
||||||
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
||||||
const DIR_ANGLE: Record<string, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
|
||||||
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
|
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
|
||||||
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
|
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
|
||||||
let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
|
let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
|
||||||
|
|
||||||
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
|
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
|
||||||
// camera moves the same way — a tap glides one square, a turn swings a
|
// camera moves the same way — a tap glides one square, a turn swings a
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// The directorial eye the reel and the live pane share: given a batch
|
||||||
|
// of events, where should the camera look — and which bodies glide
|
||||||
|
// between one view and the next. One ladder, so the replay and the
|
||||||
|
// table never learn different instincts.
|
||||||
|
|
||||||
|
import { edgeMid } from "./raycast";
|
||||||
|
import { fpFxForEvents } from "./fx3d";
|
||||||
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
import { castRay } from "./raycast";
|
||||||
|
|
||||||
|
export function shortestArc(from: number, to: number): number {
|
||||||
|
let d = (to - from) % (2 * Math.PI);
|
||||||
|
if (d > Math.PI) d -= 2 * Math.PI;
|
||||||
|
if (d < -Math.PI) d += 2 * Math.PI;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Aim {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** The deed lands on the wizard's OWN square — a summon at their
|
||||||
|
* feet, invisible from inside their own eyes. Take the cutaway. */
|
||||||
|
self?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this batch's deeds should point the pov wizard's eyes: their
|
||||||
|
* own casts, attacks, throws, summons, and edge-deeds first; then, when
|
||||||
|
* the pov is the acting player, wherever the fx layer will visibly play.
|
||||||
|
* Null when nothing asks to be watched.
|
||||||
|
*/
|
||||||
|
export function aimOfEvents(
|
||||||
|
events: GameEvent[], view: GameView, povId: string,
|
||||||
|
myCell: { x: number; y: number }, actor: string,
|
||||||
|
): Aim | null {
|
||||||
|
for (const e of events) {
|
||||||
|
let cell: { x: number; y: number } | null = null;
|
||||||
|
if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) {
|
||||||
|
cell = (e as { at: { x: number; y: number } }).at;
|
||||||
|
} else if (e.type === "spellCast" && e.caster === povId) {
|
||||||
|
cell = e.targetCell ?? view.players.find((p) => p.id === e.target)?.position ?? null;
|
||||||
|
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
||||||
|
cell = view.players.find((p) => p.id === e.defender)?.position ?? null;
|
||||||
|
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||||||
|
cell = e.landedAt;
|
||||||
|
} else if (
|
||||||
|
// Deeds done to an EDGE — a lock picked or jammed, a door
|
||||||
|
// unlocked, a wall raised, razed, or drowned — turn the eyes
|
||||||
|
// to the edge's midpoint just the same.
|
||||||
|
"edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge &&
|
||||||
|
(("caster" in e && e.caster === povId) || ("player" in e && e.player === povId))
|
||||||
|
) {
|
||||||
|
const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" };
|
||||||
|
const m = edgeMid(edge.cell, edge.side);
|
||||||
|
// edgeMid sits ON the boundary; step half a texel toward the
|
||||||
|
// cell so the same-cell check below behaves.
|
||||||
|
if (Math.abs(m.x - (myCell.x + 0.5)) > 0.3 || Math.abs(m.y - (myCell.y + 0.5)) > 0.3) {
|
||||||
|
return { x: m.x - 0.5, y: m.y - 0.5 };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cell && (cell.x !== myCell.x || cell.y !== myCell.y)) return cell;
|
||||||
|
if (cell) return { ...cell, self: true };
|
||||||
|
}
|
||||||
|
// Last resort, only for the pov wizard's own step: wherever the fx
|
||||||
|
// layer is about to play something visible — an impact, a
|
||||||
|
// projectile's landing — the eyes should already be.
|
||||||
|
if (actor === povId) {
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||||
|
const to = (e as { to: { x: number; y: number } }).to;
|
||||||
|
if (to.x !== myCell.x || to.y !== myCell.y) return to;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") &&
|
||||||
|
"player" in e && (e as { player: string }).player === povId
|
||||||
|
) {
|
||||||
|
const at = (e as { at: { x: number; y: number } }).at;
|
||||||
|
return { ...at, self: at.x === myCell.x && at.y === myCell.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { fx } of fpFxForEvents(events, view, povId)) {
|
||||||
|
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null;
|
||||||
|
if (!spot) continue;
|
||||||
|
const cx = Math.floor(spot.x), cy = Math.floor(spot.y);
|
||||||
|
if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true };
|
||||||
|
return { x: cx, y: cy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this batch hurl the pov wizard bodily — a blow to glide with,
|
||||||
|
* eyes held steady, rather than a stride or a cut? */
|
||||||
|
export function hurledIn(events: GameEvent[], povId: string): boolean {
|
||||||
|
return events.some((e) =>
|
||||||
|
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||||
|
e.type === "retreatedInHorror") && e.player === povId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Glide {
|
||||||
|
id: string;
|
||||||
|
fx: number;
|
||||||
|
fy: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bodies that walked between two views: each glides rather than
|
||||||
|
* blinking cell to cell. A leap across the maze is a teleport and
|
||||||
|
* simply arrives. */
|
||||||
|
export function gatherGlides(before: GameView, after: GameView, povId: string): Glide[] {
|
||||||
|
const moves: Glide[] = [];
|
||||||
|
const gather = (
|
||||||
|
id: string,
|
||||||
|
now: { x: number; y: number },
|
||||||
|
was: { x: number; y: number } | undefined,
|
||||||
|
) => {
|
||||||
|
if (!was) return;
|
||||||
|
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||||||
|
if (d > 0.05 && d <= 3.5) {
|
||||||
|
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const p of after.players) {
|
||||||
|
if (!p.alive || p.id === povId) continue;
|
||||||
|
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||||
|
gather(p.id, p.position, was?.position);
|
||||||
|
}
|
||||||
|
for (const c of after.creatures) {
|
||||||
|
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||||||
|
}
|
||||||
|
return moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The facing that stares down the longest corridor from a square — the
|
||||||
|
* default view wherever nothing asks to be watched. */
|
||||||
|
export function deepestFacing(view: GameView, x: number, y: number): number {
|
||||||
|
let deepest = -1, face = 0;
|
||||||
|
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||||
|
const h = castRay(view, x, y, a);
|
||||||
|
if (h.dist > deepest) { deepest = h.dist; face = a; }
|
||||||
|
}
|
||||||
|
return face;
|
||||||
|
}
|
||||||
@@ -13,12 +13,22 @@ import type { GameEvent, GameView } from "@wizwar/engine";
|
|||||||
export type FpFx =
|
export type FpFx =
|
||||||
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
|
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
|
||||||
/** This leg flies in a warp mouth's virtual space: visible only
|
/** This leg flies in a warp mouth's virtual space: visible only
|
||||||
* through the opening, on columns whose rays bent. */
|
* through THAT warp's opening, beyond its mouth. */
|
||||||
warped?: boolean }
|
warped?: boolean; warpId?: number }
|
||||||
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
|
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number;
|
||||||
|
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
|
||||||
|
rise?: number }
|
||||||
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
|
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
|
||||||
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
|
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
|
||||||
| { id: number; kind: "door"; edge: string; t0: number; dur: number };
|
| { id: number; kind: "door"; edge: string; t0: number; dur: number }
|
||||||
|
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
||||||
|
|
||||||
|
/** Eased growth 0..1 for a conjuration's rise. */
|
||||||
|
export function growProgress(p: number): number {
|
||||||
|
if (p <= 0) return 0.01;
|
||||||
|
if (p >= 1) return 1;
|
||||||
|
return Math.max(0.01, p * p * (3 - 2 * p));
|
||||||
|
}
|
||||||
|
|
||||||
/** How far open an animated door stands at progress p: swings open,
|
/** How far open an animated door stands at progress p: swings open,
|
||||||
* holds for the crossing, swings shut. */
|
* holds for the crossing, swings shut. */
|
||||||
@@ -42,11 +52,15 @@ let nextId = 1;
|
|||||||
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
||||||
/** Board-effect kinds that land here as an impact billboard. */
|
/** Board-effect kinds that land here as an impact billboard. */
|
||||||
const IMPACT_ART: Record<string, string> = {
|
const IMPACT_ART: Record<string, string> = {
|
||||||
burst: "burst", splash: "splash", fireworks: "burst",
|
burst: "burst", splash: "splash",
|
||||||
hit: "hit", pow: "hit", claw: "hit",
|
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
|
||||||
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
||||||
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
||||||
shield: "shield", absorb: "shield",
|
shield: "shield", absorb: "shield",
|
||||||
|
"ooze-slip": "splash", "slime-stuck": "splash",
|
||||||
|
"pit-fall": "dust", "dust-puff": "dust",
|
||||||
|
"edge-dust": "rubble",
|
||||||
|
// fireworks and die-drop are staged by the raw-event pass below.
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One flight, possibly seen from two rooms: a projectile whose straight
|
/** One flight, possibly seen from two rooms: a projectile whose straight
|
||||||
@@ -56,19 +70,20 @@ const IMPACT_ART: Record<string, string> = {
|
|||||||
* clipped by the depth buffer to the side the camera stands on. */
|
* clipped by the depth buffer to the side the camera stands on. */
|
||||||
function projectileLegs(
|
function projectileLegs(
|
||||||
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
|
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
|
||||||
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
|
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean; warpId?: number }[] {
|
||||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
if (len < 0.05) return [{ from: a, to: b }];
|
if (len < 0.05) return [{ from: a, to: b }];
|
||||||
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
|
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
|
||||||
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
|
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
|
||||||
for (const w of view.board.warps) {
|
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
||||||
|
const w = view.board.warps[wi]!;
|
||||||
const motion = warpMotion(w);
|
const motion = warpMotion(w);
|
||||||
const vt = motion.toVirtual(b);
|
const vt = motion.toVirtual(b);
|
||||||
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
|
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
|
||||||
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
|
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
|
||||||
if (hit.warped && hit.dist >= vlen - 0.4) {
|
if (hit.warped && hit.dist >= vlen - 0.4) {
|
||||||
return [
|
return [
|
||||||
{ from: a, to: vt, warped: true },
|
{ from: a, to: vt, warped: true, warpId: wi },
|
||||||
{ from: motion.toReal(a), to: b },
|
{ from: motion.toReal(a), to: b },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -90,7 +105,7 @@ export function fpFxForEvents(
|
|||||||
out.push({
|
out.push({
|
||||||
fx: {
|
fx: {
|
||||||
id: nextId++, kind: "projectile", art,
|
id: nextId++, kind: "projectile", art,
|
||||||
from: leg.from, to: leg.to, warped: leg.warped,
|
from: leg.from, to: leg.to, warped: leg.warped, warpId: leg.warpId,
|
||||||
t0: 0, dur: PROJECTILE_DUR[art]!,
|
t0: 0, dur: PROJECTILE_DUR[art]!,
|
||||||
},
|
},
|
||||||
delay,
|
delay,
|
||||||
@@ -137,6 +152,74 @@ export function fpFxForEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ((e.type === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) {
|
||||||
|
// Conjured from nothing: the edge RISES from the floor, wrapped in
|
||||||
|
// dust and shimmer. (An illusion rises too — for the deceived, it
|
||||||
|
// is a wall in every way.)
|
||||||
|
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||||||
|
const key = edgeKey(edge.cell, edge.side);
|
||||||
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
const at = edgeMid(edge.cell, edge.side);
|
||||||
|
if (e.type === "wallCreated") {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
|
||||||
|
}
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "creatureCreated" && "at" in e) {
|
||||||
|
const at = (e as { at: { x: number; y: number } }).at;
|
||||||
|
const cid = (e as { creatureId: string }).creatureId;
|
||||||
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "squareFilled" && "cell" in e) {
|
||||||
|
const cell = (e as { cell: { x: number; y: number } }).cell;
|
||||||
|
const kind = (e as { kind: string }).kind;
|
||||||
|
const cellK = `${cell.x},${cell.y}`;
|
||||||
|
out.push({
|
||||||
|
fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 },
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
||||||
|
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
||||||
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "thumbOfGod" && "landedAt" in e) {
|
||||||
|
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "gameWon" && "player" in e) {
|
||||||
|
// Victory earns real fireworks: bursts climbing over the winner's
|
||||||
|
// home, gold washing the sky, again and again.
|
||||||
|
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
|
||||||
|
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (home) {
|
||||||
|
out.push({
|
||||||
|
fx: {
|
||||||
|
id: nextId++, kind: "impact", art: "burst",
|
||||||
|
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
|
||||||
|
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
|
||||||
|
},
|
||||||
|
delay: i * 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((e.type === "treasureDropped" || e.type === "objectDropped") && "at" in e) {
|
||||||
|
// Gold hits the flags: a glint where it lands, a beat after the
|
||||||
|
// blow when one knocked it loose.
|
||||||
|
const at = (e as { at: { x: number; y: number } }).at;
|
||||||
|
const spilled = events.some((x) => x.type === "attackResolved" || x.type === "damaged");
|
||||||
|
out.push({
|
||||||
|
fx: { id: nextId++, kind: "impact", art: "spark", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 500 },
|
||||||
|
delay: spilled ? 400 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (e.type === "wallDestroyed" && "edge" in e) {
|
if (e.type === "wallDestroyed" && "edge" in e) {
|
||||||
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
|
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
|
||||||
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
|
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
|
||||||
@@ -153,6 +236,17 @@ export function fpFxForEvents(
|
|||||||
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
|
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
|
||||||
} else if (e.type === "lifeGained" && e.player === povId) {
|
} else if (e.type === "lifeGained" && e.player === povId) {
|
||||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
|
||||||
|
} else if (e.type === "died" && "player" in e && (e as { player: string }).player === povId) {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
|
||||||
|
} else if (e.type === "fellInPit" && e.player === povId) {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
|
||||||
|
} else if (
|
||||||
|
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
|
||||||
|
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
|
||||||
|
"player" in e && (e as { player: string }).player === povId
|
||||||
|
) {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
||||||
} else if (e.type === "teleported" && e.player === povId) {
|
} else if (e.type === "teleported" && e.player === povId) {
|
||||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
|
||||||
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
|
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
|
|
||||||
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
import { objectArt, TERRAIN_ART } from "../art";
|
import { objectArt } from "../art";
|
||||||
|
|
||||||
export interface Hit {
|
export interface Hit {
|
||||||
/** Distance along the ray (perpendicular-corrected by the caller). */
|
/** Distance along the ray (perpendicular-corrected by the caller). */
|
||||||
dist: number;
|
dist: number;
|
||||||
/** What the ray struck. */
|
/** What the ray struck. */
|
||||||
kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
|
kind: "wall" | "door" | "firewall" | "stone" | "rim";
|
||||||
/** 0..1 across the struck face (texture coordinate). */
|
/** 0..1 across the struck face (texture coordinate). */
|
||||||
u: number;
|
u: number;
|
||||||
/** Vertical faces get a different shade than horizontal ones. */
|
/** Vertical faces get a different shade than horizontal ones. */
|
||||||
@@ -26,14 +26,48 @@ export interface Hit {
|
|||||||
edge?: string;
|
edge?: string;
|
||||||
/** The eye reached this through a warp: haze it other-worldly. */
|
/** The eye reached this through a warp: haze it other-worldly. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
|
/** Which warp (index in board.warps) the ray first bent through, how
|
||||||
|
* far along the ray that mouth stood, and where across it — the gate a
|
||||||
|
* sprite must be beyond (its own warp) or in front of (any) to paint
|
||||||
|
* this column, and the veil the renderer hangs in the opening. */
|
||||||
|
warpId?: number;
|
||||||
|
warpDist?: number;
|
||||||
|
warpU?: number;
|
||||||
/** The first known-illusion edge the ray crossed before its solid hit:
|
/** The first known-illusion edge the ray crossed before its solid hit:
|
||||||
* the eye passes, but a translucent ghost of a wall stands there. */
|
* the eye passes, but a translucent ghost of a wall stands there. */
|
||||||
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
|
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
|
||||||
|
/** The first OPEN doorway the ray passed through: the renderer hangs
|
||||||
|
* the lintel of the frame across it. */
|
||||||
|
doorway?: { dist: number; u: number };
|
||||||
|
/** Something conjured is still RISING here: the ray passes over it,
|
||||||
|
* and the renderer draws it growing bottom-up where it stood. */
|
||||||
|
rising?: { dist: number; u: number; worldU: number; kind: Hit["kind"]; g: number };
|
||||||
|
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
|
||||||
|
frame?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||||
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
||||||
|
|
||||||
|
/** Unit vector along an edge's lane axis (the direction `along` grows). */
|
||||||
|
const LANE_AXIS: Record<Side, [number, number]> = {
|
||||||
|
E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Does this warp pair MIRROR the lane? A traversal is a proper rigid
|
||||||
|
* rotation; for half the side pairings the rotated entry axis points
|
||||||
|
* AGAINST the exit edge's axis, and preserving the raw lane there would
|
||||||
|
* smuggle in a reflection — rays crossing over at the mouth, parallax
|
||||||
|
* inverted, the far room stretching as the eye moves. */
|
||||||
|
export function warpLaneMirrored(from: Side, to: Side): boolean {
|
||||||
|
const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from];
|
||||||
|
const [x, y] = LANE_AXIS[from];
|
||||||
|
const rx = x * Math.cos(d) - y * Math.sin(d);
|
||||||
|
const ry = x * Math.sin(d) + y * Math.cos(d);
|
||||||
|
const [ux, uy] = LANE_AXIS[to];
|
||||||
|
return rx * ux + ry * uy < 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
||||||
* Doors always report as doors; how far each stands open — fully for an
|
* Doors always report as doors; how far each stands open — fully for an
|
||||||
* open or held door, mid-swing during an animation — is castRay's call. */
|
* open or held door, mid-swing during an animation — is castRay's call. */
|
||||||
@@ -45,11 +79,6 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
|||||||
return "wall";
|
return "wall";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* March one ray from (ox, oy) at `angle` and return the first thing that
|
|
||||||
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
|
||||||
* is a warp mouth (then a shimmering opening).
|
|
||||||
*/
|
|
||||||
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
||||||
* sheet. The slab gives every wall END a visible cap face, so corners and
|
* sheet. The slab gives every wall END a visible cap face, so corners and
|
||||||
* doorways read as three-dimensional stone rather than paper. */
|
* doorways read as three-dimensional stone rather than paper. */
|
||||||
@@ -73,19 +102,36 @@ function slabHit(
|
|||||||
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a slab test found: the strike, and how the door slid under it. */
|
||||||
|
type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* March one ray from (ox, oy) at `angle` and return the first thing that
|
||||||
|
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
||||||
|
* is a warp mouth — the ray passes through those, remembering the first.
|
||||||
|
*/
|
||||||
export function castRay(
|
export function castRay(
|
||||||
view: GameView, ox: number, oy: number, angle: number,
|
view: GameView, ox: number, oy: number, angle: number,
|
||||||
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
||||||
* view holds open stand at 1 without an entry. Wolf3D-style: an open
|
* view holds open stand at 1 without an entry. Wolf3D-style: an open
|
||||||
* door has slid along its own edge, the gap growing from u=0. */
|
* door has slid along its own edge, the gap growing from u=0. */
|
||||||
doors?: Record<string, number>,
|
doors?: Record<string, number>,
|
||||||
|
/** Conjurations mid-growth (0..1), keyed by edge key or stone cell
|
||||||
|
* key: while below 1 the ray passes, and the hit records a rising. */
|
||||||
|
growing?: Record<string, number>,
|
||||||
): Hit {
|
): Hit {
|
||||||
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
||||||
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
||||||
let baseDist = 0;
|
let baseDist = 0;
|
||||||
let warped = false;
|
let warped = false;
|
||||||
|
let warpId: number | undefined;
|
||||||
|
let warpDist: number | undefined;
|
||||||
|
let warpU: number | undefined;
|
||||||
let ghost: Hit["ghost"];
|
let ghost: Hit["ghost"];
|
||||||
|
let doorway: Hit["doorway"];
|
||||||
|
let rising: Hit["rising"];
|
||||||
for (let traversal = 0; traversal < 3; traversal++) {
|
for (let traversal = 0; traversal < 3; traversal++) {
|
||||||
|
let traversed = false;
|
||||||
const dx = Math.cos(angle);
|
const dx = Math.cos(angle);
|
||||||
const dy = Math.sin(angle);
|
const dy = Math.sin(angle);
|
||||||
let cx = Math.floor(ox);
|
let cx = Math.floor(ox);
|
||||||
@@ -101,25 +147,46 @@ export function castRay(
|
|||||||
// Every blocked edge of the current cell stands as a slab; the
|
// Every blocked edge of the current cell stands as a slab; the
|
||||||
// nearest strike inside this cell's span wins.
|
// nearest strike inside this cell's span wins.
|
||||||
const exit = Math.min(sideDistX, sideDistY);
|
const exit = Math.min(sideDistX, sideDistY);
|
||||||
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
|
let best: SlabStrike | null = null;
|
||||||
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||||
const key = edgeKey({ x: cx, y: cy }, side);
|
const key = edgeKey({ x: cx, y: cy }, side);
|
||||||
const kind = edgeObstacle(view, key);
|
const kind = edgeObstacle(view, key);
|
||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
||||||
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
||||||
|
const g = growing?.[key];
|
||||||
|
if (g !== undefined && g < 1) {
|
||||||
|
// Still being conjured: the eye passes; the growth is drawn.
|
||||||
|
if (!rising) {
|
||||||
|
const along0 = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||||
|
rising = { dist: baseDist + h.t, u: along0 - Math.floor(along0), worldU: along0, kind, g };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let slide = 0;
|
let slide = 0;
|
||||||
|
let frame = false;
|
||||||
if (kind === "door") {
|
if (kind === "door") {
|
||||||
const open = doors?.[key] ??
|
const open = doors?.[key] ??
|
||||||
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
||||||
if (open > 0) {
|
if (open >= 0.98) {
|
||||||
|
// A door standing open is a DOORWAY: posts at both ends, a
|
||||||
|
// lintel hung across, and the way through open to the eye.
|
||||||
|
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||||
|
const u = along - Math.floor(along);
|
||||||
|
const JAMB = 0.08;
|
||||||
|
if (u > JAMB && u < 1 - JAMB) {
|
||||||
|
if (!doorway) doorway = { dist: baseDist + h.t, u };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frame = true;
|
||||||
|
} else if (open > 0) {
|
||||||
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||||
const u = along - Math.floor(along);
|
const u = along - Math.floor(along);
|
||||||
if (u < open - 0.02) return; // the ray sails through the opening
|
if (u < open - 0.02) return; // the ray sails through the opening
|
||||||
slide = open;
|
slide = open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best = { ...h, kind, slide, edge: key };
|
best = { ...h, kind, slide, edge: key, frame };
|
||||||
};
|
};
|
||||||
const hw = (side: Side) =>
|
const hw = (side: Side) =>
|
||||||
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
||||||
@@ -128,13 +195,14 @@ export function castRay(
|
|||||||
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
||||||
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
||||||
if (best !== null) {
|
if (best !== null) {
|
||||||
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
|
const b = best as SlabStrike;
|
||||||
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
||||||
const u = along - Math.floor(along);
|
const u = along - Math.floor(along);
|
||||||
// A sliding door carries its texture with it: sample past the gap.
|
// A sliding door carries its texture with it: sample past the gap.
|
||||||
return {
|
return {
|
||||||
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
|
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
|
||||||
axis: b.axis, worldU: along, edge: b.edge, warped, ghost,
|
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
|
||||||
|
...(b.frame ? { frame: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,34 +224,42 @@ export function castRay(
|
|||||||
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
||||||
if (offBoard) {
|
if (offBoard) {
|
||||||
const side: Side = crossSide;
|
const side: Side = crossSide;
|
||||||
const warp = view.board.warps.find(
|
const wIdx = view.board.warps.findIndex(
|
||||||
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
||||||
);
|
);
|
||||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost };
|
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
||||||
|
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
|
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
|
||||||
// Step through: re-enter at the paired mouth, heading inward, the
|
// Step through: re-enter at the paired mouth, heading inward, the
|
||||||
// offset along the edge preserved (a wraparound keeps its lane).
|
// lane carried by the same proper rotation the heading turns by —
|
||||||
|
// mirrored for the pairings whose axes land head-to-head.
|
||||||
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
||||||
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
||||||
const c = warp.to.cell;
|
const c = warp.to.cell;
|
||||||
const lane = texU;
|
const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : texU;
|
||||||
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
|
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
|
||||||
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
|
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
|
||||||
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
|
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
|
||||||
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
||||||
baseDist += dist;
|
baseDist += dist;
|
||||||
warped = true;
|
warped = true;
|
||||||
i = 64; // restart the DDA from the far mouth
|
traversed = true;
|
||||||
break;
|
break; // restart the DDA from the far mouth
|
||||||
}
|
}
|
||||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost };
|
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
||||||
|
if (sg !== undefined && sg < 1) {
|
||||||
|
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
||||||
|
} else {
|
||||||
|
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cx = nx;
|
cx = nx;
|
||||||
cy = ny;
|
cy = ny;
|
||||||
}
|
}
|
||||||
if (!warped || traversal === 2) break;
|
if (!traversed) break;
|
||||||
}
|
}
|
||||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost };
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
||||||
@@ -216,6 +292,14 @@ export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
|
|||||||
return { x: cell.x + 0.5, y: cell.y };
|
return { x: cell.x + 0.5, y: cell.y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a click in the first-person pane resolved to — the pane speaks
|
||||||
|
* the board's own click vocabulary. */
|
||||||
|
export type FpvTarget =
|
||||||
|
| { kind: "player"; id: string }
|
||||||
|
| { kind: "creature"; id: string }
|
||||||
|
| { kind: "edge"; cell: Cell; side: Side }
|
||||||
|
| { kind: "cell"; cell: Cell };
|
||||||
|
|
||||||
export interface Billboard {
|
export interface Billboard {
|
||||||
/** World position (cell-centered). */
|
/** World position (cell-centered). */
|
||||||
x: number;
|
x: number;
|
||||||
@@ -238,8 +322,23 @@ export interface Billboard {
|
|||||||
bias?: number;
|
bias?: number;
|
||||||
/** Procedural stand-in name while the painted file loads. */
|
/** Procedural stand-in name while the painted file loads. */
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
/** A reflection seen through a warp mouth, not the thing itself. */
|
/** Identity for growth animation: a creature's id, or a cell key. */
|
||||||
|
key?: string;
|
||||||
|
/** Cell-bound volumes never paint outside their own square: a
|
||||||
|
* camera-facing hedge viewed obliquely would otherwise poke its ends
|
||||||
|
* through the neighboring walls. */
|
||||||
|
clip?: { x: number; y: number };
|
||||||
|
/** A reflection seen through a warp mouth, not the thing itself —
|
||||||
|
* visible only through THAT warp's columns, beyond its mouth. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
|
warpId?: number;
|
||||||
|
/** Click identity: the specific body this sprite is (a wizard, a
|
||||||
|
* creature) — the pane's hit test hands it straight to the board's
|
||||||
|
* own click handlers. */
|
||||||
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
|
/** Click fallback: the REAL square this sprite stands in (a virtual
|
||||||
|
* warp copy keeps its source's square, not its drawn position). */
|
||||||
|
cell?: Cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
||||||
@@ -261,7 +360,15 @@ export function warpMotion(w: GameView["board"]["warps"][number]): {
|
|||||||
} {
|
} {
|
||||||
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
|
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
|
||||||
const a1 = mouthAnchor(w.from);
|
const a1 = mouthAnchor(w.from);
|
||||||
const a2 = mouthAnchor(w.to);
|
// Mirrored pairings anchor the far mouth at its OTHER corner — the
|
||||||
|
// lane runs backwards there, and this keeps the motion a pure
|
||||||
|
// rotation matching castRay's traversal.
|
||||||
|
const a2 = { ...mouthAnchor(w.to) };
|
||||||
|
if (warpLaneMirrored(w.from.side, w.to.side)) {
|
||||||
|
const [lx, ly] = LANE_AXIS[w.to.side];
|
||||||
|
a2.x += lx;
|
||||||
|
a2.y += ly;
|
||||||
|
}
|
||||||
const c = Math.cos(delta), s = Math.sin(delta);
|
const c = Math.cos(delta), s = Math.sin(delta);
|
||||||
return {
|
return {
|
||||||
toVirtual(p) {
|
toVirtual(p) {
|
||||||
@@ -292,21 +399,38 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
||||||
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
||||||
|
hit: { kind: "player", id: p.id }, cell: { ...p.position },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const c of view.creatures) {
|
for (const c of view.creatures) {
|
||||||
|
if (c.id === povId) continue; // possessed: you do not see your own hide
|
||||||
const o = posOverride?.[c.id];
|
const o = posOverride?.[c.id];
|
||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
|
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
|
||||||
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind,
|
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
||||||
|
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const t of view.treasures) {
|
for (const t of view.treasures) {
|
||||||
if (!t.position || t.carriedBy) continue;
|
const src = art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects");
|
||||||
|
if (t.carriedBy) {
|
||||||
|
// A carried treasure rides its carrier at chest height, drawn just
|
||||||
|
// in front of them — the most important status in the game should
|
||||||
|
// be visible on the thief's own body.
|
||||||
|
const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive);
|
||||||
|
if (!carrier || carrier.id === povId) continue;
|
||||||
|
const o = posOverride?.[carrier.id];
|
||||||
|
out.push({
|
||||||
|
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
|
||||||
|
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
|
||||||
|
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!t.position) continue;
|
||||||
out.push({
|
out.push({
|
||||||
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
||||||
src: art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects"),
|
src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
|
||||||
scale: 0.4, rise: 0, label: "treasure",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The floor's furniture, standing in the room. Bushes fill the square
|
// The floor's furniture, standing in the room. Bushes fill the square
|
||||||
@@ -320,20 +444,27 @@ export function billboards(
|
|||||||
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
||||||
scale: 0.5, aspect: 2, rise: 0, bias: 0.18, label: content.kind,
|
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
||||||
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "ooze") {
|
} else if (content.kind === "ooze") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
||||||
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze",
|
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
||||||
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "dust") {
|
} else if (content.kind === "dust") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
||||||
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust",
|
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
||||||
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "safe") {
|
} else if (content.kind === "safe") {
|
||||||
out.push({ ...at, src: art(TERRAIN_ART["safe"]!, "terrain"), scale: 0.55, rise: 0, label: "safe" });
|
out.push({
|
||||||
|
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||||
|
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||||
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dimensional warp mouths throw pillars of light from their floor rings.
|
// Dimensional warp mouths throw pillars of light from their floor rings.
|
||||||
@@ -357,7 +488,8 @@ export function billboards(
|
|||||||
// the same rigid motion the rays use — the zbuffer clips it to the
|
// the same rigid motion the rays use — the zbuffer clips it to the
|
||||||
// opening, since everything around the mouth is nearer wall.
|
// opening, since everything around the mouth is nearer wall.
|
||||||
const real = out.slice();
|
const real = out.slice();
|
||||||
for (const w of view.board.warps) {
|
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
||||||
|
const w = view.board.warps[wi]!;
|
||||||
const motion = warpMotion(w);
|
const motion = warpMotion(w);
|
||||||
const a2 = mouthAnchor(w.to);
|
const a2 = mouthAnchor(w.to);
|
||||||
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
||||||
@@ -365,7 +497,7 @@ export function billboards(
|
|||||||
for (const b of real) {
|
for (const b of real) {
|
||||||
const rx = b.x - a2.x, ry = b.y - a2.y;
|
const rx = b.x - a2.x, ry = b.y - a2.y;
|
||||||
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
|
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
|
||||||
out.push({ ...b, ...motion.toVirtual(b), warped: true });
|
out.push({ ...b, ...motion.toVirtual(b), warped: true, warpId: wi });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
|
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
|
||||||
// size); the bakes below stand in until painted.
|
// size); the bakes below stand in until painted.
|
||||||
|
|
||||||
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow"] as const;
|
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
|
||||||
|
|
||||||
const baked = new Map<string, HTMLCanvasElement>();
|
const baked = new Map<string, HTMLCanvasElement>();
|
||||||
export function terrainFallback(name: string): HTMLCanvasElement {
|
export function terrainFallback(name: string): HTMLCanvasElement {
|
||||||
@@ -70,6 +70,28 @@ export function terrainFallback(name: string): HTMLCanvasElement {
|
|||||||
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
|
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
|
||||||
g2.fill();
|
g2.fill();
|
||||||
}
|
}
|
||||||
|
} else if (name === "safe") {
|
||||||
|
// A squat iron strongbox with its combination dial.
|
||||||
|
t.width = 128;
|
||||||
|
t.height = 128;
|
||||||
|
const g2 = t.getContext("2d")!;
|
||||||
|
g2.fillStyle = "#3c3f46";
|
||||||
|
g2.fillRect(14, 34, 100, 90);
|
||||||
|
g2.fillStyle = "#54585f";
|
||||||
|
g2.fillRect(20, 40, 88, 78);
|
||||||
|
g2.strokeStyle = "#23252a";
|
||||||
|
g2.lineWidth = 4;
|
||||||
|
g2.strokeRect(16, 36, 96, 86);
|
||||||
|
g2.fillStyle = "#8b8f96";
|
||||||
|
g2.beginPath();
|
||||||
|
g2.arc(64, 78, 16, 0, Math.PI * 2);
|
||||||
|
g2.fill();
|
||||||
|
g2.fillStyle = "#2a2c31";
|
||||||
|
g2.beginPath();
|
||||||
|
g2.arc(64, 78, 10, 0, Math.PI * 2);
|
||||||
|
g2.fill();
|
||||||
|
g2.fillStyle = "#8b8f96";
|
||||||
|
g2.fillRect(96, 66, 8, 24);
|
||||||
} else {
|
} else {
|
||||||
// warpglow: a pillar of violet light, brightest at its core.
|
// warpglow: a pillar of violet light, brightest at its core.
|
||||||
t.width = 64;
|
t.width = 64;
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ const FACE: Record<string, [number, number, number]> = {
|
|||||||
stone: [96, 96, 104],
|
stone: [96, 96, 104],
|
||||||
door: [130, 92, 48],
|
door: [130, 92, 48],
|
||||||
rim: [82, 76, 66],
|
rim: [82, 76, 66],
|
||||||
firewall: [214, 92, 28],
|
|
||||||
warp: [96, 60, 160],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Textures are baked once at 64x64 and sampled one column at a time —
|
// Textures are baked once at 64x64 and sampled one column at a time —
|
||||||
// perspective-correct verticals for the price of a single drawImage.
|
// perspective-correct verticals for the price of a single drawImage.
|
||||||
export const TEX = 64;
|
const TEX = 64;
|
||||||
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
||||||
const t = document.createElement("canvas");
|
const t = document.createElement("canvas");
|
||||||
t.width = TEX; t.height = TEX;
|
t.width = TEX; t.height = TEX;
|
||||||
@@ -42,10 +40,10 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
|
|||||||
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp"] as const;
|
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
|
||||||
|
|
||||||
/** The built-in bake: what ships when no painted file overrides it. */
|
/** The built-in bake: what ships when no painted file overrides it. */
|
||||||
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||||
return {
|
return {
|
||||||
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
||||||
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
||||||
@@ -127,6 +125,54 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
|||||||
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
||||||
c.fill();
|
c.fill();
|
||||||
}),
|
}),
|
||||||
|
underbrush: bake((c) => {
|
||||||
|
// The ground beneath a hedge: leaf litter and roots to the square's
|
||||||
|
// edges, so the standing tangle above never floats on bare flags.
|
||||||
|
for (let i = 0; i < 46; i++) {
|
||||||
|
const g = 40 + (h2(i, 61) * 45 | 0);
|
||||||
|
c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`;
|
||||||
|
c.beginPath();
|
||||||
|
c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4,
|
||||||
|
h2(i, 89) * Math.PI, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
doorframe: bake((c) => {
|
||||||
|
// An empty doorway's dressing: stone posts up both sides and a
|
||||||
|
// lintel across the top, transparent in the middle where the way
|
||||||
|
// through stands open. Posts live in the outer ~8% columns; the
|
||||||
|
// lintel in the top ~16% rows.
|
||||||
|
const stone = (x: number, y: number, w: number, h: number, jit: number) => {
|
||||||
|
const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0;
|
||||||
|
c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`;
|
||||||
|
c.fillRect(x, y, w, h);
|
||||||
|
c.strokeStyle = "rgba(30,24,16,0.8)";
|
||||||
|
c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||||
|
};
|
||||||
|
for (let r = 0; r < 5; r++) {
|
||||||
|
stone(0, 10 + r * 11, 6, 10, r);
|
||||||
|
stone(TEX - 6, 10 + r * 11, 6, 10, r + 7);
|
||||||
|
}
|
||||||
|
stone(0, 0, TEX / 2, 9, 13);
|
||||||
|
stone(TEX / 2, 0, TEX / 2, 9, 17);
|
||||||
|
}),
|
||||||
|
cracks: bake((c) => {
|
||||||
|
// Battle damage, worn over any wall by accumulated harm: dark
|
||||||
|
// fractures on transparency, drawn heavier as damage mounts.
|
||||||
|
c.strokeStyle = "rgba(14,10,6,0.85)";
|
||||||
|
c.lineWidth = 1.6;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
let x = h2(i, 21) * TEX, y = 0;
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(x, y);
|
||||||
|
while (y < TEX) {
|
||||||
|
x += (h2(x, y) - 0.5) * 14;
|
||||||
|
y += 6 + h2(y, x) * 8;
|
||||||
|
c.lineTo(x, y);
|
||||||
|
}
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
}),
|
||||||
pit: bake((c) => {
|
pit: bake((c) => {
|
||||||
// A hole in the world: black heart, ragged broken-flag edge, a rim
|
// A hole in the world: black heart, ragged broken-flag edge, a rim
|
||||||
// highlight on the lit side selling the depth.
|
// highlight on the lit side selling the depth.
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "doorHeld": return `${e.player} holds the door open.`;
|
case "doorHeld": return `${e.player} holds the door open.`;
|
||||||
case "doorReleased": return `The held door swings shut.`;
|
case "doorReleased": return `The held door swings shut.`;
|
||||||
case "doorsRelocked": return `The door swings shut and relocks.`;
|
case "doorsRelocked": return `The door swings shut and relocks.`;
|
||||||
|
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
|
||||||
|
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
|
||||||
|
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
|
||||||
|
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
|
||||||
|
case "cardsStolenPrivate": return e.cards.length > 0
|
||||||
|
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
|
||||||
|
: null;
|
||||||
|
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
|
||||||
|
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
|
||||||
|
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
|
||||||
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||||
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
||||||
@@ -215,19 +225,6 @@ export interface LogLine {
|
|||||||
* momentSteps, so a chronicle turn number addresses the same commands. */
|
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
/** Does this event make a turn worth reliving? Combat and spectacle. */
|
|
||||||
export function isNotableEvent(e: GameEvent): boolean {
|
|
||||||
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
|
|
||||||
return NOTABLE_EVENTS.has(e.type);
|
|
||||||
}
|
|
||||||
const NOTABLE_EVENTS = new Set([
|
|
||||||
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
|
|
||||||
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
|
|
||||||
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
|
|
||||||
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
|
|
||||||
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const SEAT_KEY = "wizwar-seat";
|
const SEAT_KEY = "wizwar-seat";
|
||||||
const SEATS_KEY = "wizwar-seats";
|
const SEATS_KEY = "wizwar-seats";
|
||||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||||
@@ -314,16 +311,16 @@ class Net {
|
|||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
/** A catch-up reel delivered by the server. */
|
/** A catch-up reel delivered by the server. */
|
||||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||||
/** One turn's reel, summoned from a chronicle line's instant-replay eye. */
|
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||||
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||||
|
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
||||||
/** Turns witnessed so far: counts the same boundary events the server
|
/** Turns witnessed so far: counts the same boundary events the server
|
||||||
* counts, so a chronicle line can name the turn it belongs to. */
|
* counts, so a chronicle line can name the turn it belongs to. */
|
||||||
private turnCounter = -1;
|
private turnCounter = -1;
|
||||||
/** The turn that already carries an instant-replay eye (one per turn). */
|
|
||||||
private eyeTurn = -1;
|
|
||||||
/** The turn whose moment reel is open (share links point at it). */
|
/** The turn whose moment reel is open (share links point at it). */
|
||||||
private momentTurn: number | null = null;
|
private momentTurn: number | null = null;
|
||||||
private shareResolve: ((url: string) => void) | null = null;
|
private shareResolve: ((url: string) => void) | null = null;
|
||||||
|
private shareReject: ((e: Error) => void) | null = null;
|
||||||
private seen: Record<string, number> = loadSeen();
|
private seen: Record<string, number> = loadSeen();
|
||||||
/** Room whose live stream this connection has already shown once: states
|
/** Room whose live stream this connection has already shown once: states
|
||||||
* after the first mark themselves seen while the tab is visible. */
|
* after the first mark themselves seen while the tab is visible. */
|
||||||
@@ -424,11 +421,12 @@ class Net {
|
|||||||
this.catchUp = msg.steps;
|
this.catchUp = msg.steps;
|
||||||
break;
|
break;
|
||||||
case "moment":
|
case "moment":
|
||||||
this.moment = msg.steps;
|
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||||
break;
|
break;
|
||||||
case "share":
|
case "share":
|
||||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||||
this.shareResolve = null;
|
this.shareResolve = null;
|
||||||
|
this.shareReject = null;
|
||||||
break;
|
break;
|
||||||
case "events": {
|
case "events": {
|
||||||
let talk = 0;
|
let talk = 0;
|
||||||
@@ -441,8 +439,11 @@ class Net {
|
|||||||
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||||
const line = humanize(e);
|
const line = humanize(e);
|
||||||
if (line) {
|
if (line) {
|
||||||
const notable = this.turnCounter >= 0 && this.turnCounter !== this.eyeTurn && isNotableEvent(e);
|
// Every turn wears its eye, on the header line that opens
|
||||||
if (notable) this.eyeTurn = this.turnCounter;
|
// it — the players judge what is share-worthy. The game-
|
||||||
|
// winning line carries its own besides.
|
||||||
|
const notable = this.turnCounter >= 0 &&
|
||||||
|
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,6 +453,14 @@ class Net {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "kicked":
|
||||||
|
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
|
this.leaveLocal();
|
||||||
|
break;
|
||||||
|
case "roomAbandoned":
|
||||||
|
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
|
this.leaveLocal();
|
||||||
|
break;
|
||||||
case "transferCode":
|
case "transferCode":
|
||||||
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
||||||
break;
|
break;
|
||||||
@@ -480,24 +489,31 @@ class Net {
|
|||||||
if (g.yourTurn && !was) this.notifyTurn(g);
|
if (g.yourTurn && !was) this.notifyTurn(g);
|
||||||
this.lastYourTurn.set(key, g.yourTurn);
|
this.lastYourTurn.set(key, g.yourTurn);
|
||||||
}
|
}
|
||||||
// A seat the server answered for but did not list is gone — the
|
// Prune ONLY seats the server proved dead: the room exists and
|
||||||
// room was deleted (or the seat's token voided). Quietly drop it
|
// refused this exact token. A seat merely ABSENT from the reply
|
||||||
// from the ledger rather than showing "unreachable" forever.
|
// stays — a restarting server, a stale restore, or the wrong
|
||||||
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
|
// backend all answer with ignorance, and ignorance is not
|
||||||
// never prune blind past that cap.)
|
// deletion. The wallet is capped by age instead of by trust.
|
||||||
if (this.seats.length <= 50) {
|
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||||
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
|
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||||
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
|
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||||
if (kept.length !== this.seats.length) {
|
if (kept.length !== this.seats.length) {
|
||||||
this.seats = kept;
|
this.seats = kept;
|
||||||
saveSeats(this.seats);
|
saveSeats(this.seats);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "error":
|
case "error":
|
||||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
// "Name is taken" means THIS token was tried and refused: the
|
||||||
|
// credential itself is dead, and holding it helps nobody. But
|
||||||
|
// "no such room" proves nothing about the seat — a restarting
|
||||||
|
// server, a stale restore, or the wrong backend all say it.
|
||||||
|
// The seat stays; the error shows; a later reconnect against
|
||||||
|
// the right server walks back in.
|
||||||
|
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
}
|
||||||
|
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||||
this.roomIdPending = null;
|
this.roomIdPending = null;
|
||||||
}
|
}
|
||||||
this.error = msg.message;
|
this.error = msg.message;
|
||||||
@@ -522,9 +538,7 @@ class Net {
|
|||||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||||
watch(roomId: string): void {
|
watch(roomId: string): void {
|
||||||
this.you = null;
|
this.you = null;
|
||||||
this.log = [];
|
this.resetChronicle();
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,9 +558,7 @@ class Net {
|
|||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.token = seat.token;
|
this.token = seat.token;
|
||||||
this.roomIdPending = seat.roomId;
|
this.roomIdPending = seat.roomId;
|
||||||
this.log = [];
|
this.resetChronicle();
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +586,14 @@ class Net {
|
|||||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kickSeat(name: string): void {
|
||||||
|
this.send({ type: "kickSeat", name });
|
||||||
|
}
|
||||||
|
|
||||||
|
abandonRoom(): void {
|
||||||
|
this.send({ type: "abandonRoom" });
|
||||||
|
}
|
||||||
|
|
||||||
rollTableDie(): void {
|
rollTableDie(): void {
|
||||||
this.send({ type: "rollDie" });
|
this.send({ type: "rollDie" });
|
||||||
}
|
}
|
||||||
@@ -644,23 +664,28 @@ class Net {
|
|||||||
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget the remembered seat and return to the lobby. */
|
/** Client-side teardown when the SERVER detached us (kick, abandon).
|
||||||
leave(): void {
|
* The chronicle survives: the detachment appended its own explanatory
|
||||||
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
* line, and wiping it would erase the only notice of why. */
|
||||||
|
leaveLocal(): void {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
this.roomId = null;
|
this.roomId = null;
|
||||||
this.roomIdPending = null;
|
this.roomIdPending = null;
|
||||||
this.view = null;
|
this.view = null;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.players = [];
|
this.players = [];
|
||||||
this.log = [];
|
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.audience = 0;
|
this.audience = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Walk away from the table (or gallery) by choice. */
|
||||||
|
leave(): void {
|
||||||
|
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
||||||
|
this.leaveLocal();
|
||||||
|
this.resetChronicle();
|
||||||
|
}
|
||||||
|
|
||||||
start(expansion: boolean): void {
|
start(expansion: boolean): void {
|
||||||
this.send({ type: "start", expansion });
|
this.send({ type: "start", expansion });
|
||||||
}
|
}
|
||||||
@@ -693,21 +718,32 @@ class Net {
|
|||||||
this.markSeen();
|
this.markSeen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chronicle and its turn count reset together, always. */
|
||||||
|
private resetChronicle(): void {
|
||||||
|
this.log = [];
|
||||||
|
this.turnCounter = -1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Summon one turn's reel by its chronicle turn number. */
|
/** Summon one turn's reel by its chronicle turn number. */
|
||||||
requestMoment(turn: number): void {
|
requestMoment(turn: number): void {
|
||||||
this.momentTurn = turn;
|
this.momentTurn = turn;
|
||||||
this.send({ type: "moment", turn });
|
this.send({ type: "moment", turn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mint a public link to the open moment's turn. */
|
/** Mint a public link: the open moment's turn, or -1 for the whole
|
||||||
requestShare(): Promise<string> {
|
* finished game. One mint in flight at a time — a newcomer supersedes
|
||||||
|
* a stranded predecessor rather than leaving it pending forever. */
|
||||||
|
requestShare(turn: number | null = this.momentTurn): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.momentTurn === null) return reject(new Error("no turn open"));
|
if (turn === null) return reject(new Error("no turn open"));
|
||||||
|
this.shareReject?.(new Error("superseded"));
|
||||||
this.shareResolve = resolve;
|
this.shareResolve = resolve;
|
||||||
this.send({ type: "share", turn: this.momentTurn });
|
this.shareReject = reject;
|
||||||
|
this.send({ type: "share", turn });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.shareResolve === resolve) {
|
if (this.shareResolve === resolve) {
|
||||||
this.shareResolve = null;
|
this.shareResolve = null;
|
||||||
|
this.shareReject = null;
|
||||||
reject(new Error("share timed out"));
|
reject(new Error("share timed out"));
|
||||||
}
|
}
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
@@ -716,6 +752,7 @@ class Net {
|
|||||||
|
|
||||||
closeMoment(): void {
|
closeMoment(): void {
|
||||||
this.moment = null;
|
this.moment = null;
|
||||||
|
this.momentTurn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
command(command: Command): void {
|
command(command: Command): void {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface Prefs {
|
|||||||
botTier: "apprentice" | "adept" | "archmage";
|
botTier: "apprentice" | "adept" | "archmage";
|
||||||
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
||||||
instantReplay: boolean;
|
instantReplay: boolean;
|
||||||
|
/** The live first-person pane above the board during play. */
|
||||||
|
liveFp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "wizwar-prefs";
|
const KEY = "wizwar-prefs";
|
||||||
@@ -27,7 +29,7 @@ const KEY = "wizwar-prefs";
|
|||||||
function load(): Prefs {
|
function load(): Prefs {
|
||||||
const fallback: Prefs = {
|
const fallback: Prefs = {
|
||||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||||
cautions: true, botTier: "adept", instantReplay: false,
|
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(KEY);
|
const raw = localStorage.getItem(KEY);
|
||||||
@@ -41,7 +43,8 @@ function load(): Prefs {
|
|||||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||||
cautions: p.cautions !== false,
|
cautions: p.cautions !== false,
|
||||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||||
instantReplay: p.instantReplay === true,
|
instantReplay: p.instantReplay !== false,
|
||||||
|
liveFp: p.liveFp === true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
|
||||||
|
/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */
|
||||||
|
declare const __BUILD_STAMP__: string;
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
// Deploys build locally moments before rsync, so build time IS deploy time.
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const stamp = [
|
||||||
|
now.getFullYear(),
|
||||||
|
pad(now.getMonth() + 1),
|
||||||
|
pad(now.getDate()),
|
||||||
|
pad(now.getHours()),
|
||||||
|
pad(now.getMinutes()),
|
||||||
|
].join(".");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
|
define: {
|
||||||
|
__BUILD_STAMP__: JSON.stringify(stamp),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
||||||
spell moments through the air as **billboard sprites**: flat images that
|
spell moments through the air as **billboard sprites**: flat images that
|
||||||
always face the camera, drawn glowing in mid-air, occluded by walls, and
|
always face the camera, drawn in mid-air, occluded by walls, and scaled
|
||||||
scaled by distance. Nine sprites cover every spell moment in the game.
|
by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
|
||||||
Each lives at `packages/web/public/textures/../fx3d/<name>.png` — replace
|
moment in the game. Each lives at `packages/web/public/fx3d/<name>.png`
|
||||||
the file, refresh, done. The current files are placeholder radial glows;
|
— replace the file, refresh, done. Missing files fall back to built-in
|
||||||
all nine are shown side by side at `/?tokens` under "The conjurations."
|
procedural glows; the whole set is shown side by side at `/?tokens`
|
||||||
|
under "The conjurations."
|
||||||
|
|
||||||
## Technical requirements
|
## Technical requirements
|
||||||
|
|
||||||
@@ -15,10 +16,9 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
|
|||||||
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
|
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
|
||||||
renderer scales freely and pixelates on magnification (the whole view
|
renderer scales freely and pixelates on magnification (the whole view
|
||||||
is deliberately chunky, like the wall textures).
|
is deliberately chunky, like the wall textures).
|
||||||
- **Drawn additively:** sprites are composited in "lighter" (additive)
|
- **Compositing:** painted sprites draw normally — inks stay true, dark
|
||||||
mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
|
outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
|
||||||
Paint light-on-transparent; pure black will be invisible. Midtones read
|
light.) Impacts fade out by alpha as they swell.
|
||||||
as translucent light.
|
|
||||||
- **No animation frames.** Each sprite is a single still. Motion comes
|
- **No animation frames.** Each sprite is a single still. Motion comes
|
||||||
from the renderer: projectiles streak across the room, impacts swell
|
from the renderer: projectiles streak across the room, impacts swell
|
||||||
from small to large while fading out. Radially symmetric (or nearly so)
|
from small to large while fading out. Radially symmetric (or nearly so)
|
||||||
@@ -39,11 +39,11 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
|
|||||||
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
||||||
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
||||||
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
||||||
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
|
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
|
||||||
|
|
||||||
Projectiles hold their size in flight (about a third of a wizard's
|
Projectiles hold their size in flight (about a third of a wizard's
|
||||||
height). Impacts start small and swell to roughly double while fading —
|
height). Impacts start small and swell past triple while fading — so an
|
||||||
so an impact painted as a ring or burst reads especially well.
|
impact painted as a ring or burst reads especially well.
|
||||||
|
|
||||||
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
||||||
hit (red), healed (green), or teleported (violet), the camera shake, the
|
hit (red), healed (green), or teleported (violet), the camera shake, the
|
||||||
@@ -69,6 +69,18 @@ pillar of light drawn ADDITIVELY (like the old glow sprites): bright
|
|||||||
means bright, black vanishes. Rehearse any of them in place:
|
means bright, black vanishes. Rehearse any of them in place:
|
||||||
`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`.
|
`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`.
|
||||||
|
|
||||||
|
## Walls' dressing and damage
|
||||||
|
|
||||||
|
`public/textures/doorframe.png` dresses OPEN doorways: paint an empty
|
||||||
|
stone doorway on transparency — posts in the outer ~8% columns, the
|
||||||
|
lintel in the top ~16% rows, nothing in the middle (the way through).
|
||||||
|
`public/textures/cracks.png` is battle damage: dark fractures on
|
||||||
|
transparency, laid over any damaged wall with opacity rising as the
|
||||||
|
harm mounts. `public/textures/underbrush.png` is the ground beneath a
|
||||||
|
hedge — leaf litter to the square's edges, a floor decal like the pit.
|
||||||
|
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
|
||||||
|
strongbox (square, transparent ground).
|
||||||
|
|
||||||
## One more paintable surface: the home tile
|
## One more paintable surface: the home tile
|
||||||
|
|
||||||
`public/textures/home.png` is a floor overlay for home-base cells,
|
`public/textures/home.png` is a floor overlay for home-base cells,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Claude's seat at the table: a one-shot websocket client. Each run
|
||||||
|
// connects, resumes the seat by token, performs one verb, prints, exits.
|
||||||
|
// node claude-seat.mjs join <ROOM> <NAME>
|
||||||
|
// node claude-seat.mjs view
|
||||||
|
// node claude-seat.mjs do '<command json>'
|
||||||
|
// node claude-seat.mjs chat "text"
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
|
|
||||||
|
// Seat state (room, name, token) persists between one-shot invocations.
|
||||||
|
const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json";
|
||||||
|
const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws";
|
||||||
|
const [verb, ...args] = process.argv.slice(2);
|
||||||
|
const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null;
|
||||||
|
|
||||||
|
const ws = new WebSocket(URL_WS);
|
||||||
|
const send = (m) => ws.send(JSON.stringify(m));
|
||||||
|
const die = (msg, code = 1) => { console.log(msg); process.exit(code); };
|
||||||
|
setTimeout(() => die("timeout: no answer from the maze"), 20000);
|
||||||
|
|
||||||
|
let view = null;
|
||||||
|
let pendingEvents = [];
|
||||||
|
let historyEvents = [];
|
||||||
|
|
||||||
|
function cellK(c) { return `${c.x},${c.y}`; }
|
||||||
|
|
||||||
|
function renderView(v) {
|
||||||
|
const B = v.board;
|
||||||
|
const lines = [];
|
||||||
|
const label = new Map(); // cellKey -> 2-char code
|
||||||
|
const codes = {};
|
||||||
|
v.players.forEach((p, i) => {
|
||||||
|
codes[p.id] = String(i + 1);
|
||||||
|
label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1));
|
||||||
|
});
|
||||||
|
const treas = new Map();
|
||||||
|
for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t);
|
||||||
|
// top border row by row
|
||||||
|
for (let y = 0; y < B.height; y++) {
|
||||||
|
let top = "";
|
||||||
|
let mid = "";
|
||||||
|
for (let x = 0; x < B.width; x++) {
|
||||||
|
const k = `${x},${y}`;
|
||||||
|
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
||||||
|
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
|
||||||
|
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
|
||||||
|
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
|
||||||
|
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
|
||||||
|
const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " ";
|
||||||
|
let body = label.get(k) ?? "";
|
||||||
|
if (!body) {
|
||||||
|
const sq = v.squareContents[k];
|
||||||
|
if (sq) body = sq.kind.slice(0, 2).toUpperCase();
|
||||||
|
else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?");
|
||||||
|
else if (B.homes.some((h) => cellK(h) === k)) {
|
||||||
|
const who = v.players.find((p) => cellK(p.home) === k);
|
||||||
|
body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?");
|
||||||
|
} else if ((v.groundObjects[k] ?? []).length) body = "ob";
|
||||||
|
else if (v.creatures.some((c) => cellK(c.position) === k)) {
|
||||||
|
body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2);
|
||||||
|
} else body = " ";
|
||||||
|
}
|
||||||
|
mid += wc + " " + body.padEnd(2, " ") + " ";
|
||||||
|
}
|
||||||
|
lines.push(top + "+");
|
||||||
|
lines.push(mid + "|");
|
||||||
|
}
|
||||||
|
let bottom = "";
|
||||||
|
for (let x = 0; x < B.width; x++) bottom += "+————";
|
||||||
|
lines.push(bottom + "+");
|
||||||
|
const out = [];
|
||||||
|
out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`);
|
||||||
|
out.push(lines.join("\n"));
|
||||||
|
out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}→${cellK(w.to.cell)}`).join(" "));
|
||||||
|
if (v.dimWarps.length) {
|
||||||
|
out.push("!! DIMENSIONAL WARP TOKENS (player wormholes — CHECK EVERY TURN): " +
|
||||||
|
v.dimWarps.map((w) => `${cellK(w.a)}↔${cellK(w.b)}`).join(" "));
|
||||||
|
}
|
||||||
|
for (const p of v.players) {
|
||||||
|
const t = p.carriedTreasureId ? " CARRYING TREASURE" : "";
|
||||||
|
out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`);
|
||||||
|
}
|
||||||
|
out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" "));
|
||||||
|
if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" "));
|
||||||
|
const sq = Object.entries(v.squareContents);
|
||||||
|
if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" "));
|
||||||
|
if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" "));
|
||||||
|
out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`);
|
||||||
|
out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" "));
|
||||||
|
if (v.stack) out.push("STACK! " + JSON.stringify(v.stack));
|
||||||
|
if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending));
|
||||||
|
if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending));
|
||||||
|
if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow));
|
||||||
|
if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`);
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLine(e) {
|
||||||
|
const skip = new Set(["cardsDealtPrivate"]);
|
||||||
|
if (skip.has(e.type)) return null;
|
||||||
|
return JSON.stringify(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.type === "error") die(`SERVER: ${msg.message}`);
|
||||||
|
if (msg.type === "seat") {
|
||||||
|
// A join names its room explicitly; only a resume inherits the file's.
|
||||||
|
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, name: msg.playerId, token: msg.token };
|
||||||
|
writeFileSync(SEAT_FILE, JSON.stringify(s));
|
||||||
|
if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); }
|
||||||
|
}
|
||||||
|
if (msg.type === "events") {
|
||||||
|
// A join replays the whole chronicle (replayed: true); only live
|
||||||
|
// events are news. `view` prints the tail of history instead.
|
||||||
|
if (!msg.replayed) {
|
||||||
|
for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); }
|
||||||
|
} else if (verb === "view") {
|
||||||
|
for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.type === "state") { view = msg.view; finishAfterState(); }
|
||||||
|
if (msg.type === "catchUp") {
|
||||||
|
const steps = msg.steps;
|
||||||
|
if (steps.length) view = steps[steps.length - 1].view;
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`);
|
||||||
|
if (msg.type === "room") { /* roster updates, ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
function finish() {
|
||||||
|
if (done) return; done = true;
|
||||||
|
if (verb === "view" && historyEvents.length) {
|
||||||
|
console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n");
|
||||||
|
}
|
||||||
|
if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n");
|
||||||
|
if (view) console.log(renderView(view));
|
||||||
|
else console.log("(no game running yet — waiting for the start)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
let stateTimer = null;
|
||||||
|
function finishAfterState() {
|
||||||
|
// commands produce one state per player broadcast; give trailing events a beat
|
||||||
|
if (stateTimer) clearTimeout(stateTimer);
|
||||||
|
stateTimer = setTimeout(finish, 700);
|
||||||
|
}
|
||||||
|
function afterJoin() {
|
||||||
|
send({ type: "catchUp", sinceSeq: 0 });
|
||||||
|
setTimeout(() => { if (!done) finish(); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
if (verb === "join") {
|
||||||
|
if (!args[0] || !args[1]) die("usage: join <ROOM> <NAME>");
|
||||||
|
send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null });
|
||||||
|
} else if (!seat) {
|
||||||
|
die("no seat yet — join first");
|
||||||
|
} else {
|
||||||
|
send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (verb === "view") {
|
||||||
|
send({ type: "catchUp", sinceSeq: 0 });
|
||||||
|
} else if (verb === "do") {
|
||||||
|
send({ type: "command", command: JSON.parse(args[0]) });
|
||||||
|
setTimeout(() => { if (!done) finish(); }, 8000);
|
||||||
|
} else if (verb === "chat") {
|
||||||
|
send({ type: "chat", text: args[0] });
|
||||||
|
setTimeout(() => { console.log("said."); process.exit(0); }, 800);
|
||||||
|
} else die(`unknown verb: ${verb}`);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on("error", (e) => die(`socket error: ${e.message}`));
|
||||||