The maze performs: doors swing, walls die loudly, homes wear their colors

Entertainment polish for the instant replay, all through one wizard's
eyes. Doors now slide open along their own edge — Wolf3D style — when
anyone steps through in a reel, hold for the crossing, and swing shut;
the raycaster takes a per-edge openness map and doors carry their
texture with them, leaving a sliver of jamb at the far post. A wall
that dies bursts into stone and shakes the camera, then leaves a mound
of rubble at the fallen edge for the rest of the reel (a tenth
paintable sprite, drawn opaque — debris, not light). Jammed locks
seethe: a rusty pulse across the wood, keyed off the view's own door
states. Walking THROUGH a wall shimmers on both faces and washes the
walker's own screen stone-gray. And every home base wears its owner's
emblem on the flagstones — a near-white paintable overlay tinted by
wizard color per cell in the floor cast.

Testing surfaced a real ghost: a body standing near a far warp mouth
also rendered at its virtual position, which can overlap real
corridors — the depth buffer alone cannot clip it. Every column now
remembers whether its ray bent, and a sprite draws only where its warp
side matches the column's; projectile legs carry the same flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 16:30:35 -04:00
co-authored by Claude Fable 5
parent 8a27c06b55
commit cdfae39398
9 changed files with 238 additions and 37 deletions
+35 -12
View File
@@ -21,6 +21,8 @@ export interface Hit {
/** 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 edge struck, for state the renderer dresses (jammed locks). */
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
}
@@ -28,15 +30,13 @@ export interface Hit {
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export 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? */
/** Is this edge passable to the EYE (rays), and if not, what is it?
* Doors always report as doors; how far each stands open — fully for an
* open or held door, mid-swing during an animation — is castRay's call. */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open";
if (e === "open") return null;
if (e === "door") {
// An open or held door is a doorway; the eye passes through the gap.
if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null;
return "door";
}
if (e === "door") return "door";
if (e === "firewall") return "firewall";
return "wall";
}
@@ -69,7 +69,13 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
export function castRay(
view: GameView, ox: number, oy: number, angle: number,
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
* view holds open stand at 1 without an entry. Wolf3D-style: an open
* door has slid along its own edge, the gap growing from u=0. */
doors?: Record<string, number>,
): Hit {
// 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;
@@ -90,12 +96,25 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
// 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"] } | null = null;
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side));
const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
if (!kind) return;
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
if (h && h.t <= exit + 0.15 && (!best || h.t < best.t)) best = { ...h, kind };
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
let slide = 0;
if (kind === "door") {
const open = doors?.[key] ??
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
if (open > 0) {
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
if (u < open - 0.02) return; // the ray sails through the opening
slide = open;
}
}
best = { ...h, kind, slide, edge: key };
};
const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
@@ -104,10 +123,14 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
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"] };
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
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, worldU: along, warped };
// 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,
};
}
// No slab in this cell: advance through the open boundary.