From 7a32370ccbff7efdb0adb97cab20e59f2dd71153 Mon Sep 17 00:00:00 2001
From: Eric Wagoner
Date: Sun, 23 Aug 2026 21:41:11 -0400
Subject: [PATCH] Credibility pass: the reel's scars sanded, the veil finally
hung
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three blind reviews over the first-person arc, every finding verified
against the source. History-narrating comments made timeless or cut;
the stacked BUDDY comment collapsed to one voice. Dead code out: the
orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter
poke, the impossible-state sentinel. The never-produced "warp" hit
kind resolved the right way — warp mouths now hang a translucent veil
of the painted warp texture, so art that never rendered finally does.
Types tightened (SlabStrike named once, ShareData rides CatchUpStep,
botTier loses its casts), the gallery reads MATERIALS instead of a
hand-copied list, the chronicle resets through one helper, a
superseded share mint rejects instead of stranding, and the conjured
safe gains the growth key its siblings had. Tests lose a triple
assignment, a tautology, and two self-swallowing regex alternatives.
The sprite spec — which still told the artist to paint for additive
compositing the renderer no longer uses — now describes the renderer
that exists.
Co-Authored-By: Claude Fable 5
---
.claude/skills/wizwar-audits/SKILL.md | 6 +--
packages/engine/src/automaton.ts | 3 --
packages/engine/test/automaton.test.ts | 3 +-
packages/engine/test/expansion-combat.test.ts | 1 -
packages/engine/test/ui-coverage.test.ts | 4 +-
packages/server/src/index.ts | 12 ++---
packages/server/src/rooms.ts | 4 +-
packages/web/src/App.svelte | 8 +--
packages/web/src/Replay.svelte | 7 +--
packages/web/src/TokenGallery.svelte | 3 +-
packages/web/src/fpv/FirstPerson.svelte | 45 +++++++++-------
packages/web/src/fpv/FpvWorkshop.svelte | 3 +-
packages/web/src/fpv/raycast.ts | 53 +++++++++++--------
packages/web/src/fpv/textures.ts | 8 ++-
packages/web/src/net.svelte.ts | 28 ++++++----
research/fx3d-sprite-spec.md | 28 +++++-----
16 files changed, 111 insertions(+), 105 deletions(-)
diff --git a/.claude/skills/wizwar-audits/SKILL.md b/.claude/skills/wizwar-audits/SKILL.md
index 60a999c..680b868 100644
--- a/.claude/skills/wizwar-audits/SKILL.md
+++ b/.claude/skills/wizwar-audits/SKILL.md
@@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
any seq to inspect full state. To ask why a bot did something, rebuild
the state at its turn and call `automatonCommand(viewFor(state, id),
style, tier)` — and if its choice differs from the ledger, the engine
-refused it and the fallback burned the turn (the X2XN pattern).
+refused it and the fallback burned the turn.
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
each, and report phase / round / humans vs bots / last command's `at`.
@@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`.
Strict-replays every production ledger against the local engine; one
refused command fails. A room whose ledger no longer replays becomes
unreachable after restart. Rules changes while games are live need a
-`deckRev` bump plus an engine gate (the convention survives the 2026-08
-reset to rev 1).
+`deckRev` bump plus an engine gate. Production deckRev is currently 1,
+so low numbers are not stale.
diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts
index d6271ea..5dc5de8 100644
--- a/packages/engine/src/automaton.ts
+++ b/packages/engine/src/automaton.ts
@@ -1124,7 +1124,6 @@ export function automatonCommand(
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
const thief = thiefOfMine(view);
if (!cursedIdiot) {
-
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
@@ -1187,8 +1186,6 @@ export function automatonCommand(
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
view.turn.round > 1) {
const sighted = sightedCellsFor(view);
- // A wizard under the clockwork's own BUDDY pact is off the target list:
- // attacking them would tear up the pact it just paid a card for.
// Whoever cast BUDDY on you is safe from your first strike — the
// engine refuses the attack outright. Your OWN pacts are merely
// precious: striking someone you pacted tears up your protection.
diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts
index 1debece..938381b 100644
--- a/packages/engine/test/automaton.test.ts
+++ b/packages/engine/test/automaton.test.ts
@@ -348,9 +348,8 @@ describe("the clockwork respects the bush's shelter", () => {
it("never attacks out of its own bush", () => {
const { state, bot, foe } = faceOffWithFireball();
- foe.position = { x: bot.position.x, y: bot.position.y };
- state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
foe.position = { ...bot.position };
+ state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
?? automatonFallback(viewFor(state, "bot"), "archmage");
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts
index f498815..1398422 100644
--- a/packages/engine/test/expansion-combat.test.ts
+++ b/packages/engine/test/expansion-combat.test.ts
@@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => {
// One attack per turn: a second wrench is refused.
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
}
- expect(torn + kept).toBe(12);
expect(torn).toBeGreaterThan(0);
expect(kept).toBeGreaterThan(0);
});
diff --git a/packages/engine/test/ui-coverage.test.ts b/packages/engine/test/ui-coverage.test.ts
index 219f7d2..988d9ef 100644
--- a/packages/engine/test/ui-coverage.test.ts
+++ b/packages/engine/test/ui-coverage.test.ts
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
if (!id) continue;
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
// (an optional `target?.kind === ...` branch is not a requirement).
- if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
+ if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
!cellCards.has(id)) missing.push(`${id} (cell)`);
- if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
+ if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
!edgeCards.has(id)) missing.push(`${id} (edge)`);
}
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 4910b8f..784bae5 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -52,6 +52,7 @@ import {
runCommand,
seatTokenValid,
SPECTATOR,
+ type CatchUpStep,
startGame,
summarize,
viewForPlayer,
@@ -105,7 +106,7 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR))
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
- steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
+ steps: CatchUpStep[];
actor: string;
round: number;
/** A whole finished game rather than one turn. */
@@ -127,12 +128,12 @@ function shareData(id: string): ShareData | null {
for (const st of steps) {
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
}
- data = { steps: steps as unknown as ShareData["steps"], actor: winner, round: 0, whole: true };
+ data = { steps, actor: winner, round: 0, whole: true };
}
} else {
const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) {
- data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
+ data = { steps: reel.steps, actor: reel.owner, round: reel.round };
}
}
}
@@ -219,8 +220,7 @@ const httpServer = createServer((req, res) => {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
- const last = data.steps[data.steps.length - 1]!;
- const png = renderSharePng(last.view as Parameters[0]);
+ const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
@@ -628,7 +628,7 @@ wss.on("connection", (socket) => {
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
- return send(socket, { type: "error", message: "one moment" });
+ return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
// turn -1 shares the whole finished game; anything else, one turn.
diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts
index b284009..76974a5 100644
--- a/packages/server/src/rooms.ts
+++ b/packages/server/src/rooms.ts
@@ -447,8 +447,8 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
- // The deal's own events open the first turn — count them, or every
- // turn number would sit one behind the chronicle's.
+ // The deal's own events open the first turn — count them so turn
+ // numbers match the client chronicle's.
let counter = -1;
let owner: PlayerId | null = null;
let round = 0;
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index a0f4ab8..75b78a7 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -161,7 +161,7 @@
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
- let botTier = $state(prefs.botTier);
+ let botTier = $state(prefs.botTier);
/** Card whose official FAQ rulings are open. */
let faqCardId = $state(null);
/** A discard-pile card enlarged above the pile. */
@@ -1395,9 +1395,9 @@
Automatons default to
- {#each ["apprentice", "adept", "archmage"] as t (t)}
+ {#each ["apprentice", "adept", "archmage"] as const as t (t)}
+ onclick={() => { setPref("botTier", t); botTier = t; }}>{t}
{/each}
- {#each ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as name (name)}
+ {#each MATERIALS as name (name)}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte
index 0a07436..af01bd5 100644
--- a/packages/web/src/fpv/FirstPerson.svelte
+++ b/packages/web/src/fpv/FirstPerson.svelte
@@ -55,16 +55,6 @@
return img.complete && img.naturalWidth > 0 ? img : null;
}
- /** Base colors per face (fallback + tinting basis). */
- const FACE: Record = {
- wall: [126, 118, 100],
- stone: [96, 96, 104],
- door: [130, 92, 48],
- rim: [82, 76, 66],
- firewall: [214, 92, 28],
- warp: [96, 60, 160],
- };
-
// Materials come from /textures/*.png when those files exist — the
// independently paintable set — with the baked procedurals underneath
// so a missing or still-loading file never leaves a wall naked.
@@ -253,6 +243,9 @@
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
const warpIdCol = new Int32Array(W).fill(-1);
const warpDistCol = new Float64Array(W).fill(Infinity);
+ // Each mouth a ray passed through hangs a translucent veil of the
+ // warp texture at its own depth, drawn with the other overlays.
+ const veils: { col: number; depth: number; u: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
@@ -269,6 +262,7 @@
if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
+ veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
}
if (hit.ghost) {
ghosts.push({
@@ -289,9 +283,9 @@
const top = half - wallH / 2;
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale.
- // Fire and warps shift their slice per world cell, so a blaze
- // spanning edges reads as one long fire, not a repeated flame.
- const texU = hit.kind === "firewall" || hit.kind === "warp"
+ // Fire shifts its slice per world cell, so a blaze spanning edges
+ // reads as one long fire, not a repeated flame.
+ const texU = hit.kind === "firewall"
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
: hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
@@ -316,11 +310,6 @@
ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself
}
- if (hit.kind === "warp") {
- const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
- ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
- ctx.fillRect(col, top, 1, wallH);
- }
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not.
@@ -389,7 +378,7 @@
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
if (s.warped) {
- if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue;
+ 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;
const texX = ((col - s.left) / (s.right - s.left));
@@ -413,6 +402,22 @@
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
}
+ // Warp mouths wear their veil: the warp texture at the opening,
+ // translucent, swirling — the doorway between rooms that are not
+ // neighbors announces itself.
+ for (const vl of veils) {
+ const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
+ const vTop = half - vh / 2;
+ const wt = textures.warp!;
+ ctx.globalAlpha = 0.3;
+ ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), 0,
+ Math.max(1, wt.width / 96), wt.height, vl.col, vTop, 1, vh);
+ ctx.globalAlpha = 1;
+ const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
+ ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
+ ctx.fillRect(vl.col, vTop, 1, vh);
+ }
+
// Conjurations rising: the wall (or stone) grows bottom-up where it
// will stand, glowing swirls running over the young face.
for (const ri of risings) {
@@ -521,7 +526,7 @@
const bottom = half + wallH / 2 - b.rise * wallH;
// A cell-bound volume is windowed to its own square: the billboard's
// camera-facing plane is intersected with the cell, and only that
- // lateral segment may paint — an obliquely-viewed hedge can no longer
+ // lateral segment may paint, so an obliquely-viewed hedge cannot
// poke its ends through the neighboring walls. (Virtual warp copies
// skip this — their clip cell lives in another frame.)
let clampL: number | undefined;
diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte
index 74d9d61..56e7c0a 100644
--- a/packages/web/src/fpv/FpvWorkshop.svelte
+++ b/packages/web/src/fpv/FpvWorkshop.svelte
@@ -56,10 +56,9 @@
const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
- const DIR_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
- let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
+ let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
// camera moves the same way — a tap glides one square, a turn swings a
diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts
index f6b8155..39656f4 100644
--- a/packages/web/src/fpv/raycast.ts
+++ b/packages/web/src/fpv/raycast.ts
@@ -13,7 +13,7 @@ export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */
dist: number;
/** What the ray struck. */
- kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
+ kind: "wall" | "door" | "firewall" | "stone" | "rim";
/** 0..1 across the struck face (texture coordinate). */
u: number;
/** Vertical faces get a different shade than horizontal ones. */
@@ -26,11 +26,13 @@ export interface Hit {
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
- /** Which warp (index in board.warps) the ray first bent through, and
- * how far along the ray that mouth stood — the gate a sprite must be
- * beyond (its own warp) or in front of (any) to paint this column. */
+ /** Which warp (index in board.warps) the ray first bent through, how
+ * far along the ray that mouth stood, and where across it — the gate a
+ * sprite must be beyond (its own warp) or in front of (any) to paint
+ * this column, and the veil the renderer hangs in the opening. */
warpId?: number;
warpDist?: number;
+ warpU?: number;
/** The first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
@@ -62,8 +64,8 @@ export function warpLaneMirrored(from: Side, to: Side): boolean {
const [x, y] = LANE_AXIS[from];
const rx = x * Math.cos(d) - y * Math.sin(d);
const ry = x * Math.sin(d) + y * Math.cos(d);
- const [ex2, ey] = LANE_AXIS[to];
- return rx * ex2 + ry * ey < 0;
+ const [ux, uy] = LANE_AXIS[to];
+ return rx * ux + ry * uy < 0;
}
/** Is this edge passable to the EYE (rays), and if not, what is it?
@@ -77,11 +79,6 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
return "wall";
}
-/**
- * March one ray from (ox, oy) at `angle` and return the first thing that
- * stops the eye. Off-board is the maze's rim: a wall, unless the crossing
- * is a warp mouth (then a shimmering opening).
- */
/** Half-thickness per material: walls are masonry, doors joinery, fire a
* sheet. The slab gives every wall END a visible cap face, so corners and
* doorways read as three-dimensional stone rather than paper. */
@@ -105,6 +102,14 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
+/** What a slab test found: the strike, and how the door slid under it. */
+type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
+
+/**
+ * March one ray from (ox, oy) at `angle` and return the first thing that
+ * stops the eye. Off-board is the maze's rim: a wall, unless the crossing
+ * is a warp mouth — the ray passes through those, remembering the first.
+ */
export function castRay(
view: GameView, ox: number, oy: number, angle: number,
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
@@ -121,10 +126,12 @@ export function castRay(
let warped = false;
let warpId: number | undefined;
let warpDist: number | undefined;
+ let warpU: number | undefined;
let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
let rising: Hit["rising"];
for (let traversal = 0; traversal < 3; traversal++) {
+ let traversed = false;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
@@ -140,7 +147,7 @@ export function castRay(
// Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY);
- let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean } | null = null;
+ let best: SlabStrike | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
@@ -188,13 +195,13 @@ export function castRay(
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) {
- const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
+ const b = best as SlabStrike;
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
// A sliding door carries its texture with it: sample past the gap.
return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
- axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, rising,
+ axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
...(b.frame ? { frame: true } : {}),
};
}
@@ -221,8 +228,8 @@ export function castRay(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
- if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
- if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
+ if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
+ if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
// Step through: re-enter at the paired mouth, heading inward, the
// lane carried by the same proper rotation the heading turns by —
// mirrored for the pairings whose axes land head-to-head.
@@ -236,23 +243,23 @@ export function castRay(
else { ox = c.x + lane; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
- i = 64; // restart the DDA from the far mouth
- break;
+ traversed = true;
+ break; // restart the DDA from the far mouth
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
const sg = growing?.[cellKey({ x: nx, y: ny })];
if (sg !== undefined && sg < 1) {
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
} else {
- return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
+ return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
}
cx = nx;
cy = ny;
}
- if (!warped || traversal === 2) break;
+ if (!traversed) break;
}
- return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway, rising };
+ return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -436,8 +443,8 @@ export function billboards(
} else if (content.kind === "safe") {
out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
- scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
- clip: { x: Math.floor(at.x), y: Math.floor(at.y) },
+ scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
+ clip: { x: tx, y: ty },
});
}
}
diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts
index 5c8b7ac..9b8573f 100644
--- a/packages/web/src/fpv/textures.ts
+++ b/packages/web/src/fpv/textures.ts
@@ -8,13 +8,11 @@ const FACE: Record = {
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
- firewall: [214, 92, 28],
- warp: [96, 60, 160],
};
// Textures are baked once at 64x64 and sampled one column at a time —
// perspective-correct verticals for the price of a single drawImage.
-export const TEX = 64;
+const TEX = 64;
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
const t = document.createElement("canvas");
t.width = TEX; t.height = TEX;
@@ -42,10 +40,10 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
});
}
-export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "doorframe", "cracks", "underbrush"] as const;
+export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
-export function proceduralTextures(): Record {
+function proceduralTextures(): Record {
return {
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts
index 57f8666..a214ccf 100644
--- a/packages/web/src/net.svelte.ts
+++ b/packages/web/src/net.svelte.ts
@@ -325,6 +325,7 @@ class Net {
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
+ private shareReject: ((e: Error) => void) | null = null;
private seen: Record = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -430,6 +431,7 @@ class Net {
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
+ this.shareReject = null;
break;
case "events": {
let talk = 0;
@@ -527,9 +529,7 @@ class Net {
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
watch(roomId: string): void {
this.you = null;
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
@@ -549,9 +549,7 @@ class Net {
this.spectating = false;
this.token = seat.token;
this.roomIdPending = seat.roomId;
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
@@ -658,9 +656,7 @@ class Net {
this.view = null;
this.started = false;
this.players = [];
- this.log = [];
- this.turnCounter = -1;
- this.eyeTurn = -1;
+ this.resetChronicle();
this.token = null;
this.spectating = false;
this.audience = 0;
@@ -698,6 +694,13 @@ class Net {
this.markSeen();
}
+ /** The chronicle and its turn count reset together, always. */
+ private resetChronicle(): void {
+ this.log = [];
+ this.turnCounter = -1;
+ this.eyeTurn = -1;
+ }
+
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
@@ -705,15 +708,19 @@ class Net {
}
/** Mint a public link: the open moment's turn, or -1 for the whole
- * finished game. */
+ * finished game. One mint in flight at a time — a newcomer supersedes
+ * a stranded predecessor rather than leaving it pending forever. */
requestShare(turn: number | null = this.momentTurn): Promise {
return new Promise((resolve, reject) => {
if (turn === null) return reject(new Error("no turn open"));
+ this.shareReject?.(new Error("superseded"));
this.shareResolve = resolve;
+ this.shareReject = reject;
this.send({ type: "share", turn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
+ this.shareReject = null;
reject(new Error("share timed out"));
}
}, 10_000);
@@ -722,6 +729,7 @@ class Net {
closeMoment(): void {
this.moment = null;
+ this.momentTurn = null;
}
command(command: Command): void {
diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md
index 233b6a4..9ba24f0 100644
--- a/research/fx3d-sprite-spec.md
+++ b/research/fx3d-sprite-spec.md
@@ -2,11 +2,12 @@
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
spell moments through the air as **billboard sprites**: flat images that
-always face the camera, drawn glowing in mid-air, occluded by walls, and
-scaled by distance. Nine sprites cover every spell moment in the game.
-Each lives at `packages/web/public/textures/../fx3d/.png` — replace
-the file, refresh, done. The current files are placeholder radial glows;
-all nine are shown side by side at `/?tokens` under "The conjurations."
+always face the camera, drawn in mid-air, occluded by walls, and scaled
+by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
+moment in the game. Each lives at `packages/web/public/fx3d/.png`
+— replace the file, refresh, done. Missing files fall back to built-in
+procedural glows; the whole set is shown side by side at `/?tokens`
+under "The conjurations."
## Technical requirements
@@ -15,10 +16,9 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
renderer scales freely and pixelates on magnification (the whole view
is deliberately chunky, like the wall textures).
-- **Drawn additively:** sprites are composited in "lighter" (additive)
- mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
- Paint light-on-transparent; pure black will be invisible. Midtones read
- as translucent light.
+- **Compositing:** painted sprites draw normally — inks stay true, dark
+ outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
+ light.) Impacts fade out by alpha as they swell.
- **No animation frames.** Each sprite is a single still. Motion comes
from the renderer: projectiles streak across the room, impacts swell
from small to large while fading out. Radially symmetric (or nearly so)
@@ -39,11 +39,11 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
| `shield.png` | impact | an attack fully stopped; absorbed spells |
-| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
+| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
Projectiles hold their size in flight (about a third of a wizard's
-height). Impacts start small and swell to roughly double while fading —
-so an impact painted as a ring or burst reads especially well.
+height). Impacts start small and swell past triple while fading — so an
+impact painted as a ring or burst reads especially well.
What the sprites do NOT need to carry: screen-wide flashes when YOU are
hit (red), healed (green), or teleported (violet), the camera shake, the
@@ -81,10 +81,6 @@ hedge — leaf litter to the square's edges, a floor decal like the pit.
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
strongbox (square, transparent ground).
-Note on `rubble.png`: it renders stretched ACROSS the fallen wall's
-whole gap — one cell wide, half a cell tall — so piles painted at the
-canvas's lower corners land at the two posts.
-
## One more paintable surface: the home tile
`public/textures/home.png` is a floor overlay for home-base cells,