Stage three: spells fly through your own eyes
The reel's first-person mode now shows the fight, not just the rooms. The board flourishes' event map is recast into world space (fx3d): fireballs, bolts, and thrown things streak caster to target as glowing billboards; landings swell and fade; blows that land on YOU wash the screen red and jolt the camera; warp-steps and teleports flash violet; a hurled body glides fast and straight with its eyes held steady. Nine paintable conjuration sprites ship at public/fx3d (spec for the artist in research/), shown in the token workshop beside the masonry. Three rough edges sharpened along the way: sprites now project through warp mouths under the same rigid motion the rays use, clipped by the depth buffer to the opening; fire and warp surfaces pull a different texture slice per world cell so a long blaze reads as one; and the replay camera only turns toward an actor its eyes could actually see. Two more gifts: the reel can record its first-person canvas straight to a downloadable WebM (⏺ save video), and the About tab now shows visitors the way into the three workshops. One buried mine defused: rAF hands frame-start times that can precede the tween's own clock, and a zero-length turn divided by it — one NaN facing poisoned every ray black and re-triggered the camera effect forever. The tween clamps and guards its phases now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fcafdb06de
commit
e8c6b93c71
@@ -0,0 +1,120 @@
|
||||
// First-person spell moments. The board flourishes (fx.ts) already know
|
||||
// which event hurls what across which cells; this recasts their output
|
||||
// into world-space projectiles, impact bursts, whole-screen flashes, and
|
||||
// camera shakes for the raycaster to draw. What happens ACROSS the room
|
||||
// is watched; what happens to YOU is felt.
|
||||
|
||||
import { fxForEvents } from "../fx";
|
||||
import { CELL } from "../fx-sprites/geom";
|
||||
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: "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 };
|
||||
|
||||
/** 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",
|
||||
] as const;
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
||||
/** Board-effect kinds that land here as an impact billboard. */
|
||||
const IMPACT_ART: Record<string, string> = {
|
||||
burst: "burst", splash: "splash", fireworks: "burst",
|
||||
hit: "hit", pow: "hit", claw: "hit",
|
||||
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
||||
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
||||
shield: "shield", absorb: "shield",
|
||||
};
|
||||
|
||||
/** Translate one step's events into first-person moments with start delays. */
|
||||
export function fpFxForEvents(
|
||||
events: GameEvent[], view: GameView, povId: string,
|
||||
): { fx: FpFx; delay: number }[] {
|
||||
const out: { fx: FpFx; delay: number }[] = [];
|
||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||
if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") {
|
||||
const art = fx.kind === "streak" ? "spark" : fx.kind;
|
||||
out.push({
|
||||
fx: {
|
||||
id: nextId++, kind: "projectile", art,
|
||||
from: { x: fx.a.x / CELL, y: fx.a.y / CELL },
|
||||
to: { x: fx.b.x / CELL, y: fx.b.y / CELL },
|
||||
t0: 0, dur: PROJECTILE_DUR[art]!,
|
||||
},
|
||||
delay,
|
||||
});
|
||||
} else if (fx.kind === "portal") {
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 },
|
||||
delay,
|
||||
});
|
||||
} else if ("at" in fx && IMPACT_ART[fx.kind]) {
|
||||
out.push({
|
||||
fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 },
|
||||
delay,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
for (const e of events) {
|
||||
if (e.type === "damaged" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#c03020", peak: 0.4, t0: 0, dur: 450 }, delay: povDelay });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
|
||||
} else if (e.type === "lifeGained" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
|
||||
} else if (e.type === "teleported" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
|
||||
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.28, t0: 0, dur: 350 }, delay: 0 });
|
||||
} else if (
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") &&
|
||||
e.player === povId
|
||||
) {
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.05, t0: 0, dur: 350 }, delay: povDelay });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Procedural stand-ins for the conjuration sprites: a soft glow in each
|
||||
* art's palette, replaced on screen the moment a painted file loads. */
|
||||
const FX_COLORS: Record<string, [string, string]> = {
|
||||
fireball: ["#ffe27a", "#e0431a"],
|
||||
bolt: ["#ffffff", "#7ab0ff"],
|
||||
waterbolt: ["#cfefff", "#1c6ed0"],
|
||||
spark: ["#fff6d8", "#c0a040"],
|
||||
burst: ["#fff0a0", "#d05010"],
|
||||
splash: ["#e8f8ff", "#2080c0"],
|
||||
hit: ["#ffd0c0", "#c02020"],
|
||||
shimmer: ["#f0e0ff", "#7040c0"],
|
||||
shield: ["#ffffff", "#4060d0"],
|
||||
};
|
||||
const baked = new Map<string, HTMLCanvasElement>();
|
||||
export function fxFallback(name: string): HTMLCanvasElement {
|
||||
let t = baked.get(name);
|
||||
if (!t) {
|
||||
t = document.createElement("canvas");
|
||||
t.width = 64;
|
||||
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);
|
||||
baked.set(name, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
Reference in New Issue
Block a user