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:
co-authored by
Claude Fable 5
parent
8a27c06b55
commit
cdfae39398
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user