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
+65 -13
View File
@@ -6,21 +6,35 @@
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
import { castRay, warpMotion } from "./raycast";
import { castRay, edgeMid, warpMotion } from "./raycast";
import { edgeKey } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx =
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number }
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
/** This leg flies in a warp mouth's virtual space: visible only
* through the opening, on columns whose rays bent. */
warped?: boolean }
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
| { id: number; kind: "shake"; mag: number; t0: number; dur: number };
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
| { id: number; kind: "door"; edge: string; t0: number; dur: number };
/** How far open an animated door stands at progress p: swings open,
* holds for the crossing, swings shut. */
export function doorOpenness(p: number): number {
if (p < 0 || p >= 1) return 0;
if (p < 0.3) return p / 0.3;
if (p < 0.7) return 1;
return (1 - p) / 0.3;
}
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
* transparent ground, drawn as a billboard mid-air. A procedural glow
* stands in wherever a file is missing. */
export const FX_ART = [
"fireball", "bolt", "waterbolt", "spark",
"burst", "splash", "hit", "shimmer", "shield",
"burst", "splash", "hit", "shimmer", "shield", "rubble",
] as const;
let nextId = 1;
@@ -42,7 +56,7 @@ const IMPACT_ART: Record<string, string> = {
* clipped by the depth buffer to the side the camera stands on. */
function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
): { from: { x: number; y: number }; to: { x: number; y: number } }[] {
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }];
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
@@ -54,7 +68,7 @@ function projectileLegs(
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
if (hit.warped && hit.dist >= vlen - 0.4) {
return [
{ from: a, to: vt },
{ from: a, to: vt, warped: true },
{ from: motion.toReal(a), to: b },
];
}
@@ -76,7 +90,7 @@ export function fpFxForEvents(
out.push({
fx: {
id: nextId++, kind: "projectile", art,
from: leg.from, to: leg.to,
from: leg.from, to: leg.to, warped: leg.warped,
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
@@ -94,6 +108,30 @@ export function fpFxForEvents(
});
}
}
// The maze itself performs: doors swing for whoever steps through,
// walls die into rubble, and walking THROUGH stone shimmers.
for (const e of events) {
if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) {
const via = e.type === "moved" ? e.via : "step";
const key = edgeKey(e.from, e.direction);
if (via === "step" && view.board.edges[key] === "door") {
out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
}
if (e.type === "moved" && e.via === "passWall") {
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
if (e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
}
}
}
if (e.type === "wallDestroyed" && "edge" in e) {
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 700 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
}
}
// Blows that land on the point of view: give impacts a beat to arrive
// when something visibly flew first.
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
@@ -129,6 +167,7 @@ const FX_COLORS: Record<string, [string, string]> = {
hit: ["#ffd0c0", "#c02020"],
shimmer: ["#f0e0ff", "#7040c0"],
shield: ["#ffffff", "#4060d0"],
rubble: ["#b0a898", "#5c544a"],
};
const baked = new Map<string, HTMLCanvasElement>();
export function fxFallback(name: string): HTMLCanvasElement {
@@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement {
t.height = 64;
const c = t.getContext("2d")!;
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, core);
g.addColorStop(0.55, rim);
g.addColorStop(1, "rgba(0,0,0,0)");
c.fillStyle = g;
c.fillRect(0, 0, 64, 64);
if (name === "rubble") {
// A mound of broken stone, opaque — this one is debris, not light.
for (let i = 0; i < 9; i++) {
const jx = 12 + ((i * 37) % 40);
const jy = 40 + ((i * 23) % 18);
const r = 6 + ((i * 13) % 8);
c.fillStyle = i % 2 ? core : rim;
c.beginPath();
c.arc(jx, jy, r, 0, Math.PI * 2);
c.fill();
}
} else {
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, core);
g.addColorStop(0.55, rim);
g.addColorStop(1, "rgba(0,0,0,0)");
c.fillStyle = g;
c.fillRect(0, 0, 64, 64);
}
baked.set(name, t);
}
return t;