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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user