// 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/.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 = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 }; /** Board-effect kinds that land here as an impact billboard. */ const IMPACT_ART: Record = { 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 = { 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(); 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; }