First person play arrives: the pane is the game

The first-person-play branch merges. The 3D pane becomes a full
cockpit — click-to-act with hover answers, eligibility dimming in the
world, tappable and keyable movement in a bezel of instruments, the
at-your-feet and sharing-your-square trays, creature possession with
the facing ribbon riding the mount, warp-preserved headings, and the
keymap wearing the camera's compass. Ships opt-in via preferences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-27 15:58:00 -04:00
co-authored by Claude Fable 5
20 changed files with 1300 additions and 276 deletions
+3 -2
View File
@@ -95,8 +95,9 @@ probing legality is free, so when unsure, try it and read the error.
## Game-three scars (lost in four rounds) ## Game-three scars (lost in four rounds)
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games - **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
with quietly-placed wormholes ending beside his home. The client now 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 prints a !! line for them; treat any new token pair as a five-alarm
fire and recompute both players' delivery distances through it. fire and recompute both players' delivery distances through it.
- **Never lead the attack against Kestrel.** Three games, three saved - **Never lead the attack against Kestrel.** Three games, three saved
+12 -11
View File
@@ -296,13 +296,13 @@ function pathDenial(view: GameView): Command | null {
} }
// Enemy gold banked at MY home: my score, snatchable by anyone. A // Enemy gold banked at MY home: my score, snatchable by anyone. A
// raider closing on the bank gets the same road-lengthening treatment. // raider closing on the bank gets the same road-lengthening treatment.
const me2 = me(view); const self = me(view);
const banked = view.treasures.find( const banked = view.treasures.find(
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home)); (t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
if (banked) { if (banked) {
for (const e of livingEnemies(view)) { for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y); 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: me2.home }); if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
} }
} }
if (threats.length === 0) return null; if (threats.length === 0) return null;
@@ -758,9 +758,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
} }
// The LAST counter in hand is a treasure of its own: at a healthy life // 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 // total it waits for something big rather than answering every jab.
// patience the clockwork's best opponent taught it, three counters at // (Lethal blows override this below.)
// a time. (Lethal blows override this below.)
const counterCount = view.yourHand.filter((c) => { const counterCount = view.yourHand.filter((c) => {
const t = cardDef(c.cardId).cardType; const t = cardDef(c.cardId).cardType;
return t === "counteraction" || t === "neutral/counteraction"; return t === "counteraction" || t === "neutral/counteraction";
@@ -797,6 +796,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
// when the incoming points reach the clockwork's life, any counter // when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price. // that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life; 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; 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.
@@ -943,6 +944,7 @@ 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;
// `best` is assigned only inside offer(), so TS narrows it to null here.
const cur = best as BestPick | null; const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) { if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = { best = {
@@ -1132,9 +1134,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
} }
} }
// INFRASTRUCTURE: a wormhole anchored beside home turns every future // INFRASTRUCTURE: a wormhole anchored beside home turns every future
// delivery into a three-move stroll — the pattern that won its best // delivery into a three-move stroll. Placed early, from home's
// opponent two games. Placed early, from home's doorstep, far mouth // doorstep, far mouth by the richest distant gold.
// by the richest distant gold.
{ {
const dwarp = inHand(view, "dimensional-warp"); 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; const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
@@ -1547,7 +1548,7 @@ export function automatonCommand(
const bankedCount = view.treasures.filter( const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length; (t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
// The predicate reads only ENEMY positions — my own steps must not // The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles (the thief-chase lesson). // flip it mid-march or the walker shuttles between goals.
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId && const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
livingEnemies(view).some((p) => livingEnemies(view).some((p) =>
!p.carriedTreasureId && !p.carriedTreasureId &&
+46 -54
View File
@@ -389,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
} }
/** LOS including square-filling blockers. */ /** LOS including square-filling blockers. */
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { /** Sight granted by removing any ONE closed edge the VISIONSTONE's
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); * whole power, shared by every path that honors it. */
const blockers = losBlockers(state); function sightThroughOneEdge(
if (sightBetween(board, from, to, blockers)) return true; board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
if (!stone) return false; ): boolean {
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
for (const key of Object.keys(board.edges)) { for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue; if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges }; const edges = { ...board.edges };
@@ -405,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un
return false; 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 /** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them creations, dispels, utility * sight wherever sight is asked of them creations, dispels, utility
* spells not just attacks: one wall or door, any type, falls away. * spells not just attacks: one wall or door, any type, falls away.
* Safe at every revision: widening what a cast may target never changes * Ungated by design: validation may widen without a rev bump, because
* how a recorded command replays. */ * refused commands never entered any ledger. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true); return losWith(state, from, to, viewerId, true);
} }
@@ -472,13 +480,7 @@ function casterLos(
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. */
@@ -2941,6 +2943,31 @@ 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 /** 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 * 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 * far side refuses it, and a monster is no braver than a wizard about
@@ -2964,7 +2991,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); 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) && if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
sustainedOn(state, o.id, "big-man").length > 0)) { sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that square"); return err("a giant fills that corridor");
} }
const from = creature.position; const from = creature.position;
// FEAR holds monsters off too: "no player or monster". // FEAR holds monsters off too: "no player or monster".
@@ -2972,23 +2999,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
creature.position = { ...dest }; creature.position = { ...dest };
creature.movementUsed++; creature.movementUsed++;
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }]; const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
// Touch effects on arriving in a player's square, as any step has. creatureTouchOnArrival(state, events, creature);
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;
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 { ok: true, state, events };
}
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 { ok: true, state, events };
}
}
return { ok: true, state, events }; return { ok: true, state, events };
} }
@@ -3046,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 };
} }
+56 -31
View File
@@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => {
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open"; state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land. // A number card and no attacks: the blow the chase serves cannot land.
bot.hand = []; bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never); bot.hand.push({ cardId: "number-2", instanceId: "N2" });
const visited = [cellKey(bot.position)]; const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) { for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
@@ -776,8 +776,9 @@ describe("the thief-chase holds one goal per turn", () => {
}); });
}); });
describe("the archmage's sharpened instincts", () => { describe("the archmage's key, shield, and race discipline", () => {
function botTurn(seed = 42) { function botTurn() {
const seed = 42;
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -790,7 +791,7 @@ describe("the archmage's sharpened instincts", () => {
it("a MASTER KEY drawn goes straight on display", () => { it("a MASTER KEY drawn goes straight on display", () => {
const state = botTurn(); const state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!; const bot = state.players.find((p) => p.id === "bot")!;
bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never); bot.hand.push({ cardId: "master-key", instanceId: "MK" });
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "MK" }); expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
}); });
@@ -806,8 +807,8 @@ describe("the archmage's sharpened instincts", () => {
const bot = state.players.find((p) => p.id === "bot")!; const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...foe.position }; bot.position = { ...foe.position };
bot.life = 2; bot.life = 2;
bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never]; bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never); foe.hand.push({ cardId: "fireball", instanceId: "FB" });
const r = applyCommand(state, "foe", { const r = applyCommand(state, "foe", {
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" }, type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
}); });
@@ -833,15 +834,20 @@ describe("the archmage's sharpened instincts", () => {
gold[1]!.position = null; gold[1]!.position = null;
bot.carriedTreasureId = gold[1]!.id; bot.carriedTreasureId = gold[1]!.id;
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
bot.hand = [ // Legs only: an attack in hand would fire at the foe in sight and
{ cardId: "fireball", instanceId: "FB" } as never, // open a stack this single-seat loop cannot answer.
{ cardId: "number-3", instanceId: "N3" } as never, bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
]; // Carve a straight, open four-square run to home; the rig owns its
// Stand the bot a straight, open four squares from home. // geometry rather than praying the seed provides it.
const home = bot.home; const home = bot.home;
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d })); const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return; for (const c of column) {
bot.position = { ...column[4]! }; 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 (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
for (let guard = 0; guard < 12; guard++) { for (let guard = 0; guard < 12; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
@@ -857,8 +863,9 @@ describe("the archmage's sharpened instincts", () => {
}); });
}); });
describe("lessons from the human duels", () => { describe("bank guarding, teleport delivery, and turn theft", () => {
function rig(players = ["foe", "bot"]) { function rig() {
const players = ["foe", "bot"];
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -878,9 +885,28 @@ describe("lessons from the human duels", () => {
foe.position = { x: bot.home.x, y: bot.home.y + 1 }; foe.position = { x: bot.home.x, y: bot.home.y + 1 };
bot.position = { ...foe.home }; bot.position = { ...foe.home };
bot.hand = []; bot.hand = [];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); // The march may wind through the maze; what matters is that it ENDS
// Whatever step it picks, the march must be TOWARD home, not the gold map. // at the threatened bank, not out on the gold map.
expect(cmd?.type).toBe("move"); 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", () => { it("carrying with home a blink away, it teleports the delivery", () => {
@@ -895,7 +921,7 @@ describe("lessons from the human duels", () => {
for (const side of ["N", "S", "E", "W"] as const) { for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall"; state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
} }
bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never]; bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } }); expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
}); });
@@ -908,15 +934,15 @@ describe("lessons from the human duels", () => {
const banked = state.treasures.find((t) => t.owner === "bot")!; const banked = state.treasures.find((t) => t.owner === "bot")!;
banked.position = { ...foe.home }; banked.position = { ...foe.home };
const carried = state.treasures.filter((t) => t.owner === "bot")[1]; const carried = state.treasures.filter((t) => t.owner === "bot")[1];
if (!carried) return; if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
carried.carriedBy = "foe"; carried.carriedBy = "foe";
carried.position = null; carried.position = null;
foe.carriedTreasureId = carried.id; foe.carriedTreasureId = carried.id;
foe.position = { ...bot.position }; foe.position = { ...bot.position };
bot.hand = [ bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never, { cardId: "fireball", instanceId: "FB" },
{ cardId: "lightning-blast", instanceId: "LB" } as never, { cardId: "lightning-blast", instanceId: "LB" },
{ cardId: "number-4", instanceId: "N4" } as never, { cardId: "number-4", instanceId: "N4" },
]; ];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" }); expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
@@ -924,10 +950,10 @@ describe("lessons from the human duels", () => {
}); });
describe("the denial planner offers only castable blocks", () => { describe("the denial planner offers only castable blocks", () => {
it("tacks at range are never proposed — the RNRX coma", () => { it("tacks are offered only at the caster's feet", () => {
// Room RNRX froze Automaton II for three turns: pathDenial proposed // TACKS demand adjacency; a planner that proposes them at range has
// scattering tacks four squares away, the engine refused ("you must // its cast refused every turn, and the refusal-fallback loop reads
// be adjacent"), and the refusal-fallback loop read as a coma. // as an idle bot.
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -941,14 +967,13 @@ describe("the denial planner offers only castable blocks", () => {
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!; const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
foe.position = { ...gold.position! }; foe.position = { ...gold.position! };
bot.position = { ...bot.home }; bot.position = { ...bot.home };
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never]; bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
for (let guard = 0; guard < 6; guard++) { for (let guard = 0; guard < 6; guard++) {
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
if (!cmd) break; if (!cmd) break;
const r = applyCommand(state, "bot", cmd); const r = applyCommand(state, "bot", cmd);
// Whatever the brain proposes, the engine must accept it. // Whatever the brain proposes, the engine must accept it.
expect(r.ok, `refused: ${JSON.stringify(cmd)}${!r.ok ? r.error : ""}`).toBe(true); if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)}${r.error}`);
if (!r.ok) break;
state = r.state; state = r.state;
if (cmd.type === "endTurn") break; if (cmd.type === "endTurn") break;
} }
+6 -5
View File
@@ -1186,12 +1186,13 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
powerAttackPoints: 0, params: null, kind: "spell", powerAttackPoints: 0, params: null, kind: "spell",
counters: [], waitingOn: d.id, bentCorner: true, 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)); const traced = stackSightTrace(viewFor(state, d.id));
expect(traced).not.toBeNull(); expect(traced).not.toBeNull();
if (traced) { // The engine promises A workable corner, not which one.
// Whether sight ran straight (free-angle found a gap) or bent, the expect(traced?.bend).toBeDefined();
// overlay has something to draw; a bend names its middle square. expect(traced?.bend?.mid).toBeDefined();
if (traced.bend) expect(traced.bend.mid).toBeDefined();
}
}); });
}); });
+9 -9
View File
@@ -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 });
@@ -749,7 +750,6 @@ describe("fear holds off monsters and unwilling feet alike", () => {
// The closer step is the only way out: permitted. // The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" }); const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error); if (!r.ok) throw new Error("escape step refused: " + r.error);
expect(r.ok).toBe(true);
}); });
}); });
@@ -760,9 +760,9 @@ describe("creatures and the dimensional warp", () => {
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.creatures.push({ state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
} as never); });
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (r.ok) { if (r.ok) {
@@ -779,9 +779,9 @@ describe("creatures and the dimensional warp", () => {
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" }; state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
state.creatures.push({ state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
} as never); });
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("stone"); if (!r.ok) expect(r.error).toContain("stone");
@@ -488,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => {
// no longer on their threshold; clear their path to the doorway. // no longer on their threshold; clear their path to the doorway.
const near = neighbor(cell, side); const near = neighbor(cell, side);
const far = neighbor(near, side); const far = neighbor(near, side);
if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable 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.players.find((p) => p.id === pursuer)!.position = far;
state.edgeOverrides[edgeKey(near, side)] = "open"; state.edgeOverrides[edgeKey(near, side)] = "open";
const rm = giveCard(state, holder, "remove-lock"); const rm = giveCard(state, holder, "remove-lock");
+15 -5
View File
@@ -396,6 +396,7 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
} }
} }
} }
/** Spoken when a clockwork's chosen command was refused by the engine /** Spoken when a clockwork's chosen command was refused by the engine
* the table sees a stumble instead of an unexplained idle turn. */ * the table sees a stumble instead of an unexplained idle turn. */
const HESITATION_LINES = [ const HESITATION_LINES = [
@@ -549,11 +550,12 @@ wss.on("connection", (socket) => {
case "kickSeat": { case "kickSeat": {
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 problem = kickSeat(room, session.playerId, String(msg.name ?? "")); const kickName = String(msg.name ?? "");
const problem = kickSeat(room, session.playerId, kickName);
if (problem) return send(socket, { type: "error", message: problem }); 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. // A kicked live socket is set adrift so it cannot act on a seat it lost.
for (const other of sessions) { for (const other of sessions) {
if (other.roomId === room.id && other.playerId === msg.name) { if (other.roomId === room.id && other.playerId === kickName) {
other.playerId = null; other.playerId = null;
other.roomId = null; other.roomId = null;
other.token = null; other.token = null;
@@ -732,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:
+7 -12
View File
@@ -54,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 = CURRENT_RULES_REV;
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. */
@@ -164,7 +159,7 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
room.tokens.delete(name); room.tokens.delete(name);
room.bots.delete(name); room.bots.delete(name);
room.colorChoices.delete(name); room.colorChoices.delete(name);
appendLine(room.id, { kind: "kick", name }); appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
recordRoom(room); recordRoom(room);
return null; return null;
} }
@@ -228,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;
} }
@@ -320,22 +315,22 @@ function actingSeat(room: Room): PlayerId | null {
*/ */
export function driveOneAutomaton( export function driveOneAutomaton(
room: Room, room: Room,
): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null { ): { 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: { refused: Command; error: string } | undefined; 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.
// The refusal is remembered: a brain whose choice the engine rejects // 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 // is a bug in the brain, and the table deserves to see the stumble
// rather than an unexplained idle turn (the RNRX coma lesson). // rather than an unexplained idle turn.
if (chosen) { if (chosen) {
hesitated = { refused: chosen, error: r.error }; hesitated = true;
console.warn( console.warn(
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`); `automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
r = runCommand(room, seat, automatonFallback(view, bot?.tier)); r = runCommand(room, seat, automatonFallback(view, bot?.tier));
+2 -1
View File
@@ -62,6 +62,7 @@ export interface KickLine {
kind: "kick"; kind: "kick";
/** The seat the host removed from an unstarted room. */ /** The seat the host removed from an unstarted room. */
name: string; name: string;
at: string;
} }
export interface AbandonLine { export interface AbandonLine {
@@ -119,7 +120,7 @@ export function readAllRooms(): Map<string, RoomLine[]> {
/** Retire an abandoned room's ledger to the graveyard never deleted, /** Retire an abandoned room's ledger to the graveyard never deleted,
* only moved out of the living rooms directory. */ * only moved out of the living rooms directory. */
export function archiveRoomFile(roomId: string): void { export function archiveRoomFile(roomId: string): void {
const src = join(DATA_DIR, `${roomId}.jsonl`); const src = fileFor(roomId);
if (!existsSync(src)) return; if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
mkdirSync(graveyard, { recursive: true }); mkdirSync(graveyard, { recursive: true });
+66 -1
View File
@@ -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,
@@ -899,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) {
@@ -1395,6 +1413,11 @@
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 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"> <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)} />
@@ -1878,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}
@@ -1898,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">
@@ -2873,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;
+32
View File
@@ -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. */
@@ -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;
@@ -588,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}
@@ -634,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}
@@ -765,6 +791,12 @@
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; } .peekable { cursor: pointer; pointer-events: all; }
.sight-corner { .sight-corner {
fill: none; fill: none;
+578
View File
@@ -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>
+10 -100
View File
@@ -7,6 +7,7 @@
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";
@@ -187,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;
@@ -234,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;
@@ -261,72 +235,11 @@
// 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) {
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 ?? v.players.find((p) => p.id === e.target)?.position ?? null;
} else if (e.type === "attackResolved" && e.attacker === povId) {
cell = v.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 - (me.position.x + 0.5)) > 0.3 || Math.abs(m.y - (me.position.y + 0.5)) > 0.3) {
return { x: m.x - 0.5, y: m.y - 0.5 };
}
continue;
}
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
// The deed lands on the wizard's OWN square — a summon at their
// feet, invisible from inside their own eyes. Mark it for the
// cutaway: step outside yourself and watch.
if (cell) return { ...cell, self: true };
}
// Last resort, only for the reel's own wizard's step: wherever the
// fx layer is about to play something visible — an impact, a
// projectile's landing — the eyes should already be.
if (step.actor === povId) {
for (const e of step.events) {
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
const to = (e as { to: { x: number; y: number } }).to;
if (to.x !== me.position.x || to.y !== me.position.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 === me.position.x && at.y === me.position.y };
}
}
for (const { fx } of fpFxForEvents(step.events, v, 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 === me.position.x && cy === me.position.y) return { x: cx, y: cy, self: true };
return { x: cx, y: cy };
}
}
return null;
})() as ({ x: number; y: number; self?: boolean } | null);
// A leap the legs cannot explain (first frame, teleport, the return // A leap the legs cannot explain (first frame, teleport, the return
// from a cutaway) is a CUT, and a cut is not a stride: the direction // from a cutaway) is a CUT, and a cut is not a stride: the direction
// of travel means nothing at the far end. // of travel means nothing at the far end.
@@ -336,15 +249,12 @@
* corridor, facing it, holding a beat of the world-before so the * corridor, facing it, holding a beat of the world-before so the
* deed happens ON screen — with the reel's own wizard visible. */ * deed happens ON screen — with the reel's own wizard visible. */
const takeCutaway = (ax: number, ay: number) => { const takeCutaway = (ax: number, ay: number) => {
let best = { d: -1, a: 0 }; const a = deepestFacing(v, ax, ay);
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const d = castRay(v, ax, ay, a).dist;
const h = castRay(v, ax, ay, a); const back = Math.min(1.6, Math.max(0.35, d - 0.4));
if (h.dist > best.d) best = { d: h.dist, a }; cam.x = ax + Math.cos(a) * back;
} cam.y = ay + Math.sin(a) * back;
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4)); cam.facing = a + Math.PI;
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
camReady = true; camReady = true;
cutawayShot = true; cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1); const i2 = Math.min(idx, steps.length - 1);
+256 -11
View File
@@ -3,7 +3,7 @@
// 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, growProgress, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -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 {
@@ -168,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;
@@ -201,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) {
@@ -221,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;
@@ -237,6 +288,7 @@
// 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 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 // 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 // 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 // nearer than the mouth (it stands in front of the window); a
@@ -281,6 +333,7 @@
} }
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;
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!; 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 shifts its slice per world cell, so a blaze spanning edges // Fire shifts its slice per world cell, so a blaze spanning edges
@@ -363,6 +416,10 @@
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.sort - a.sort); 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 — // The nearest sprite each column carries — depth AND vertical span —
// so the overlay passes (veils, ghosts, lintels, risings) can paint // so the overlay passes (veils, ghosts, lintels, risings) can paint
// around a body standing in front of them instead of over it. // around a body standing in front of them instead of over it.
@@ -392,11 +449,7 @@
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) {
if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue;
} else if (s.depth >= warpDistCol[col]!) continue;
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
if (s.depth < spriteZ[col]!) { if (s.depth < spriteZ[col]!) {
spriteZ[col] = s.depth; spriteZ[col] = s.depth;
sprTop[col] = s.top; sprTop[col] = s.top;
@@ -519,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 {
@@ -535,6 +653,8 @@
fallback?: string; fallback?: string;
clampL?: number; clampL?: number;
clampR?: 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 /** Sort key only: the near-bias orders sprites among THEMSELVES (a
* hedge over the wizard standing in it) but must never let one * hedge over the wizard standing in it) but must never let one
* cheat past a wall — occlusion always uses the true depth. */ * cheat past a wall — occlusion always uses the true depth. */
@@ -544,7 +664,9 @@
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; warpId?: number; fallback?: string; warped?: boolean; warpId?: number;
clip?: { x: number; y: 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;
@@ -597,11 +719,126 @@
return { return {
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR, 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(() => {
@@ -612,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%;
+145
View File
@@ -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;
}
+24 -5
View File
@@ -292,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;
@@ -324,6 +332,13 @@ export interface Billboard {
* visible only through THAT warp's columns, beyond its mouth. */ * visible only through THAT warp's columns, beyond its mouth. */
warped?: boolean; warped?: boolean;
warpId?: number; 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
@@ -384,13 +399,16 @@ 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, key: c.id, 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) {
@@ -405,13 +423,14 @@ export function billboards(
out.push({ out.push({
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5, 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", src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
}); });
continue; continue;
} }
if (!t.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, scale: 0.4, rise: 0, label: "treasure", src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
}); });
} }
// The floor's furniture, standing in the room. Bushes fill the square // The floor's furniture, standing in the room. Bushes fill the square
@@ -426,25 +445,25 @@ export function billboards(
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.1, rise: 0, bias: 0.18, label: content.kind, key: k, scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
clip: { x: tx, y: ty }, 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", key: k, scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
clip: { x: tx, y: ty }, 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", key: k, scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} else if (content.kind === "safe") { } else if (content.kind === "safe") {
out.push({ out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe", ...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k, scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} }
} }
+22 -23
View File
@@ -225,7 +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"]);
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";
@@ -318,7 +317,6 @@ class Net {
/** 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). */
/** 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;
@@ -491,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;
@@ -659,8 +664,9 @@ 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).
/** Client-side teardown when the SERVER detached us (kick, abandon). */ * The chronicle survives: the detachment appended its own explanatory
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void { leaveLocal(): void {
localStorage.removeItem(SEAT_KEY); localStorage.removeItem(SEAT_KEY);
this.roomId = null; this.roomId = null;
@@ -673,18 +679,11 @@ class Net {
this.audience = 0; this.audience = 0;
} }
/** Walk away from the table (or gallery) by choice. */
leave(): void { leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY); this.leaveLocal();
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.resetChronicle(); this.resetChronicle();
this.token = null;
this.spectating = false;
this.audience = 0;
} }
start(expansion: boolean): void { start(expansion: boolean): void {
+4 -1
View File
@@ -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: true, cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
}; };
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
@@ -42,6 +44,7 @@ function load(): Prefs {
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 !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true,
}; };
} catch { } catch {
return fallback; return fallback;
+1 -1
View File
@@ -42,7 +42,7 @@ function renderView(v) {
for (let x = 0; x < B.width; x++) { for (let x = 0; x < B.width; x++) {
const k = `${x},${y}`; const k = `${x},${y}`;
if (!B.cells[k]) { top += " "; mid += " "; continue; } if (!B.cells[k]) { top += " "; mid += " "; continue; }
const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open"); const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " "); top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open"); const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");