The reel through your own eyes: first-person replay (stage two)

Sight now runs THROUGH the warps as the rules say: a ray reaching a
mouth re-enters at its pair and marches on, the far side swimming in
violet haze. The floor's furniture joins the scene — bushes stand tall,
safes squat, slime and tacks and pits lie low, dropped daggers glint.
And the replay reel gains a 👁 toggle: relive the catch-up or the whole
tale through your wizard's eyes, the camera turning before it strides,
easing between cells, cutting on teleports, and turning toward whoever
acted while you stood still. The /?fpv workshop grows &demo=1 — two
automatons play a stretch and the real Replay component reruns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 12:29:11 -04:00
co-authored by Claude Fable 5
parent dae01969b0
commit 65092f50af
4 changed files with 223 additions and 51 deletions
+91 -46
View File
@@ -7,6 +7,7 @@
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine";
import { objectArt, TERRAIN_ART } from "../art";
export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */
@@ -17,8 +18,13 @@ export interface Hit {
u: number;
/** Vertical faces get a different shade than horizontal ones. */
axis: "x" | "y";
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
}
const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
/** Is this edge passable to the EYE (rays), and if not, what is it? */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open";
@@ -38,57 +44,76 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
* is a warp mouth (then a shimmering opening).
*/
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
let cy = Math.floor(oy);
const stepX = dx > 0 ? 1 : -1;
const stepY = dy > 0 ? 1 : -1;
// Distance along the ray between successive x / y grid lines.
const dDistX = Math.abs(1 / (dx || 1e-9));
const dDistY = Math.abs(1 / (dy || 1e-9));
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0;
let warped = false;
for (let traversal = 0; traversal < 3; traversal++) {
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
let cy = Math.floor(oy);
const stepX = dx > 0 ? 1 : -1;
const stepY = dy > 0 ? 1 : -1;
// Distance along the ray between successive x / y grid lines.
const dDistX = Math.abs(1 / (dx || 1e-9));
const dDistY = Math.abs(1 / (dy || 1e-9));
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
for (let i = 0; i < 64; i++) {
const crossingX = sideDistX < sideDistY;
const dist = crossingX ? sideDistX : sideDistY;
// The edge being crossed lives between the current cell and the next.
let key: string;
let nx = cx, ny = cy;
if (crossingX) {
key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E");
nx = cx + stepX;
sideDistX += dDistX;
} else {
key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S");
ny = cy + stepY;
sideDistY += dDistY;
}
const axis: Hit["axis"] = crossingX ? "x" : "y";
const u = crossingX
? (oy + dist * dy) % 1
: (ox + dist * dx) % 1;
const texU = u < 0 ? u + 1 : u;
for (let i = 0; i < 64; i++) {
const crossingX = sideDistX < sideDistY;
const dist = crossingX ? sideDistX : sideDistY;
// The edge being crossed lives between the current cell and the next.
let key: string;
let nx = cx, ny = cy;
if (crossingX) {
key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E");
nx = cx + stepX;
sideDistX += dDistX;
} else {
key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S");
ny = cy + stepY;
sideDistY += dDistY;
}
const axis: Hit["axis"] = crossingX ? "x" : "y";
const u = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1;
const texU = u < 0 ? u + 1 : u;
const blocked = edgeObstacle(view, key);
if (blocked) return { dist, kind: blocked, u: texU, axis };
const blocked = edgeObstacle(view, key);
if (blocked) return { dist: baseDist + dist, kind: blocked, u: texU, axis, warped };
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) {
const mouth = view.board.warps.some(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) &&
w.from.side === (crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N")),
);
return { dist, kind: mouth ? "warp" : "rim", u: texU, axis };
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) {
const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
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 };
// 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; }
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 };
}
cx = nx;
cy = ny;
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist, kind: "stone", u: texU, axis };
}
cx = nx;
cy = ny;
if (!warped || traversal === 2) break;
}
return { dist: 64, kind: "rim", u: 0, axis: "x" };
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -149,5 +174,25 @@ export function billboards(
scale: 0.4, rise: 0, label: "treasure",
});
}
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
// a wall to the rays and needs no sprite.
const TERRAIN_SCALE: Record<string, number> = {
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
tacks: 0.25, pit: 0.35, dust: 0.6,
};
for (const [k, content] of Object.entries(view.squareContents)) {
if (content.kind === "stone") continue;
const file = TERRAIN_ART[content.kind];
const scale = TERRAIN_SCALE[content.kind];
if (!file || !scale) continue;
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind });
}
for (const [k, cards] of Object.entries(view.groundObjects)) {
const file = cards[0] ? objectArt(cards[0].cardId) : null;
if (!file) continue;
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 });
}
return out;
}