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
+83 -6
View File
@@ -5,8 +5,9 @@
// by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast";
import { materialTextures } from "./textures";
import { fxFallback, type FpFx } from "./fx3d";
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors";
import type { GameView } from "@wizwar/engine";
let {
@@ -19,6 +20,7 @@
height = 440,
fx = [],
posOverride,
rubble = [],
}: {
view: GameView;
povId: string;
@@ -33,6 +35,8 @@
fx?: FpFx[];
/** Bodies mid-glide: world positions that override the view's cells. */
posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[];
} = $props();
const FOV = Math.PI / 2.9;
@@ -84,6 +88,33 @@
}
let frame: ImageData | null = null;
// Home bases wear their owner's emblem on the floor: a per-view grid of
// owner tints, indexed by integer cell for the per-pixel pass.
const homeCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
function homesOf(v: GameView) {
let h = homeCache.get(v);
if (!h) {
const bw = v.board.width, bh = v.board.height;
const grid = new Int16Array(bw * bh).fill(-1);
const tints: [number, number, number][] = [];
for (const p of v.players) {
if (!p.home) continue;
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
tints.push([
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
]);
if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) {
grid[p.home.y * bw + p.home.x] = tints.length - 1;
}
}
h = { grid, bw, bh, tints };
homeCache.set(v, h);
}
return h;
}
function draw(time: number) {
const ctx = canvas?.getContext("2d");
if (!ctx) return;
@@ -100,6 +131,14 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
}
// Doors mid-swing this frame, from the animated fx.
let doors: Record<string, number> | undefined;
for (const f of fx) {
if (f.kind !== "door") continue;
const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
}
// The ground and the vault overhead, cast per pixel: each screen row
// below (or above) the horizon lies at one fixed depth, so the row is
// walked with a constant world-space step and sampled from the floor
@@ -123,6 +162,8 @@
continue;
}
const d = (H / 2) / dz;
const homes = below ? homesOf(view) : null;
const hp = below ? pixelsOf(textures.home!) : null;
const tex = below ? fl : ce;
const tw = tex.width, th = tex.height;
const tp = tex.data;
@@ -142,9 +183,28 @@
let u = wx % 1; if (u < 0) u += 1;
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
buf[o] = tp[ti]! * shade;
buf[o + 1] = tp[ti + 1]! * shade;
buf[o + 2] = tp[ti + 2]! * shade;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
if (homes) {
const hx = (wx - u) | 0, hy = (wy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) {
const owner = homes.grid[hy * homes.bw + hx]!;
if (owner >= 0 && hp) {
// The emblem sits over the flagstones, wearing its
// owner's color.
const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
const ha = hp.data[hi + 3]! / 255;
if (ha > 0) {
const [tr, tg, tb] = homes.tints[owner]!;
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
}
}
}
}
buf[o] = r * shade;
buf[o + 1] = g * shade;
buf[o + 2] = b * shade;
}
buf[o + 3] = 255;
wx += stepX;
@@ -153,13 +213,18 @@
}
ctx.putImageData(frame, 0, 0);
// Walls, one ray per column; remember each column's depth for sprites.
// Walls, one ray per column; remember each column's depth for
// sprites, and whether its ray bent through a warp — a sprite's warp
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const warpedCol = new Uint8Array(W);
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle);
const hit = castRay(view, ex, ey, rayAngle, doors);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
warpedCol[col] = hit.warped ? 1 : 0;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!;
@@ -185,6 +250,12 @@
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH);
}
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood.
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
}
if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(col, top, 1, wallH);
@@ -201,6 +272,10 @@
const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
@@ -212,6 +287,7 @@
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: 0.3,
warped: f.kind === "projectile" ? f.warped : undefined,
}, ex, ey);
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
@@ -225,6 +301,7 @@
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
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 ? 1 : 0) !== warpedCol[col]!) continue;
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(