Stage three: spells fly through your own eyes

The reel's first-person mode now shows the fight, not just the rooms.
The board flourishes' event map is recast into world space (fx3d):
fireballs, bolts, and thrown things streak caster to target as glowing
billboards; landings swell and fade; blows that land on YOU wash the
screen red and jolt the camera; warp-steps and teleports flash violet;
a hurled body glides fast and straight with its eyes held steady. Nine
paintable conjuration sprites ship at public/fx3d (spec for the artist
in research/), shown in the token workshop beside the masonry.

Three rough edges sharpened along the way: sprites now project through
warp mouths under the same rigid motion the rays use, clipped by the
depth buffer to the opening; fire and warp surfaces pull a different
texture slice per world cell so a long blaze reads as one; and the
replay camera only turns toward an actor its eyes could actually see.

Two more gifts: the reel can record its first-person canvas straight
to a downloadable WebM (⏺ save video), and the About tab now shows
visitors the way into the three workshops.

One buried mine defused: rAF hands frame-start times that can precede
the tween's own clock, and a zero-length turn divided by it — one NaN
facing poisoned every ray black and re-triggered the camera effect
forever. The tween clamps and guards its phases now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 14:51:04 -04:00
co-authored by Claude Fable 5
parent fcafdb06de
commit e8c6b93c71
16 changed files with 412 additions and 37 deletions
+49 -10
View File
@@ -18,6 +18,9 @@ export interface Hit {
u: number;
/** Vertical faces get a different shade than horizontal ones. */
axis: "x" | "y";
/** The un-wrapped along-face coordinate: u plus the world cell it lies
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
worldU: number;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
}
@@ -104,7 +107,7 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] };
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, warped };
return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped };
}
// No slab in this cell: advance through the open boundary.
@@ -114,7 +117,8 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
if (crossingX) { nx = cx + stepX; sideDistX += dDistX; }
else { ny = cy + stepY; sideDistY += dDistY; }
const axis: Hit["axis"] = crossingX ? "x" : "y";
const u0 = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1;
const along = crossingX ? oy + dist * dy : ox + dist * dx;
const u0 = along % 1;
const texU = u0 < 0 ? u0 + 1 : u0;
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
@@ -123,31 +127,31 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
const warp = view.board.warps.find(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, warped };
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped };
// Step through: re-enter at the paired mouth, heading inward, the
// offset along the edge preserved (a wraparound keeps its lane).
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
const c = warp.to.cell;
const along = texU;
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; }
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; }
else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; }
else { ox = c.x + along; oy = c.y + 1e-4; }
const lane = texU;
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
else { ox = c.x + lane; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
i = 64; // restart the DDA from the far mouth
break;
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist: baseDist + dist, kind: "stone", u: texU, axis, warped };
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped };
}
cx = nx;
cy = ny;
}
if (!warped || traversal === 2) break;
}
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped };
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -178,6 +182,18 @@ export interface Billboard {
/** Lifted off the floor (0 = feet on the ground). */
rise: number;
label: string;
/** A reflection seen through a warp mouth, not the thing itself. */
warped?: boolean;
}
/** The along=0 corner of a warp mouth — the anchor castRay's lane
* preservation measures from. */
function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } {
const c = m.cell;
if (m.side === "E") return { x: c.x + 1, y: c.y };
if (m.side === "W") return { x: c.x, y: c.y };
if (m.side === "S") return { x: c.x, y: c.y + 1 };
return { x: c.x, y: c.y };
}
/** Everything standing in the maze that the reel should draw as a sprite. */
@@ -228,5 +244,28 @@ export function billboards(
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
}
// Rays see through warp mouths; the standing world should follow. Each
// sprite near a far mouth also appears in the near mouth's frame, under
// the same rigid motion the rays use — the zbuffer clips it to the
// opening, since everything around the mouth is nearer wall.
const real = out.slice();
for (const w of view.board.warps) {
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
const a1 = mouthAnchor(w.from);
const a2 = mouthAnchor(w.to);
const cosD = Math.cos(-delta), sinD = Math.sin(-delta);
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
const inX = Math.cos(inward), inY = Math.sin(inward);
for (const b of real) {
const rx = b.x - a2.x, ry = b.y - a2.y;
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
out.push({
...b,
x: a1.x + rx * cosD - ry * sinD,
y: a1.y + rx * sinD + ry * cosD,
warped: true,
});
}
}
return out;
}