treasureDropped and objectDropped played as nothing — the gold simply teleported from chest to floor between frames. A drop now glints (the spark blooming where it lands, a beat after the blow when combat knocked it loose), and a wizard dropping at their own feet takes the cutaway: a third-person shot of them setting the prize down — or spilling it. When the reel's wizard strikes the spill loose from someone else, the attack-facing already has the victim in frame, so the treasure falls on camera. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
15 KiB
TypeScript
310 lines
15 KiB
TypeScript
// 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 { 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;
|
|
/** This leg flies in a warp mouth's virtual space: visible only
|
|
* through THAT warp's opening, beyond its mouth. */
|
|
warped?: boolean; warpId?: number }
|
|
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number;
|
|
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
|
|
rise?: 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: "door"; edge: string; t0: number; dur: number }
|
|
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
|
|
|
/** Eased growth 0..1 for a conjuration's rise. */
|
|
export function growProgress(p: number): number {
|
|
if (p <= 0) return 0.01;
|
|
if (p >= 1) return 1;
|
|
return Math.max(0.01, p * p * (3 - 2 * p));
|
|
}
|
|
|
|
/** 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", "rubble",
|
|
] 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",
|
|
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
|
|
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
|
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
|
shield: "shield", absorb: "shield",
|
|
"ooze-slip": "splash", "slime-stuck": "splash",
|
|
"pit-fall": "dust", "dust-puff": "dust",
|
|
"edge-dust": "rubble",
|
|
// fireworks and die-drop are staged by the raw-event pass below.
|
|
};
|
|
|
|
/** One flight, possibly seen from two rooms: a projectile whose straight
|
|
* line is broken but whose sight line runs through a warp flies as TWO
|
|
* simultaneous legs — one in the near mouth's virtual space (visible
|
|
* through the opening), one in the far room's real space. Each leg is
|
|
* 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 }; warped?: boolean; warpId?: number }[] {
|
|
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));
|
|
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
|
|
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
|
const w = view.board.warps[wi]!;
|
|
const motion = warpMotion(w);
|
|
const vt = motion.toVirtual(b);
|
|
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
|
|
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, warped: true, warpId: wi },
|
|
{ from: motion.toReal(a), to: b },
|
|
];
|
|
}
|
|
}
|
|
return [{ from: a, to: b }];
|
|
}
|
|
|
|
/** 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;
|
|
for (const leg of projectileLegs(
|
|
view, { x: fx.a.x / CELL, y: fx.a.y / CELL }, { x: fx.b.x / CELL, y: fx.b.y / CELL },
|
|
)) {
|
|
out.push({
|
|
fx: {
|
|
id: nextId++, kind: "projectile", art,
|
|
from: leg.from, to: leg.to, warped: leg.warped, warpId: leg.warpId,
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
// 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);
|
|
const edgeState = view.board.edges[key] ?? "open";
|
|
if (via === "step" && edgeState === "door") {
|
|
out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
|
|
}
|
|
if (via === "step" && edgeState === "wall") {
|
|
// A body just stepped through what THIS viewer holds solid — an
|
|
// illusion the mover disbelieves. The viewer keeps their false
|
|
// belief, but the crossing must read as magic, not a glitch:
|
|
// shimmer both faces, and wash the screen if these are the eyes.
|
|
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.type === "moved" && e.player === povId) {
|
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, 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 === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) {
|
|
// Conjured from nothing: the edge RISES from the floor, wrapped in
|
|
// dust and shimmer. (An illusion rises too — for the deceived, it
|
|
// is a wall in every way.)
|
|
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
|
const key = edgeKey(edge.cell, edge.side);
|
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 });
|
|
const at = edgeMid(edge.cell, edge.side);
|
|
if (e.type === "wallCreated") {
|
|
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
|
|
}
|
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 });
|
|
}
|
|
if (e.type === "creatureCreated" && "at" in e) {
|
|
const at = (e as { at: { x: number; y: number } }).at;
|
|
const cid = (e as { creatureId: string }).creatureId;
|
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 });
|
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
|
}
|
|
if (e.type === "squareFilled" && "cell" in e) {
|
|
const cell = (e as { cell: { x: number; y: number } }).cell;
|
|
const kind = (e as { kind: string }).kind;
|
|
const cellK = `${cell.x},${cell.y}`;
|
|
out.push({
|
|
fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 },
|
|
delay: 0,
|
|
});
|
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
|
}
|
|
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
|
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
|
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
|
|
}
|
|
if (e.type === "thumbOfGod" && "landedAt" in e) {
|
|
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
|
|
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
|
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
|
|
}
|
|
if (e.type === "gameWon" && "player" in e) {
|
|
// Victory earns real fireworks: bursts climbing over the winner's
|
|
// home, gold washing the sky, again and again.
|
|
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
|
|
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
|
|
for (let i = 0; i < 6; i++) {
|
|
if (home) {
|
|
out.push({
|
|
fx: {
|
|
id: nextId++, kind: "impact", art: "burst",
|
|
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
|
|
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
|
|
},
|
|
delay: i * 300,
|
|
});
|
|
}
|
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
|
|
}
|
|
}
|
|
if ((e.type === "treasureDropped" || e.type === "objectDropped") && "at" in e) {
|
|
// Gold hits the flags: a glint where it lands, a beat after the
|
|
// blow when one knocked it loose.
|
|
const at = (e as { at: { x: number; y: number } }).at;
|
|
const spilled = events.some((x) => x.type === "attackResolved" || x.type === "damaged");
|
|
out.push({
|
|
fx: { id: nextId++, kind: "impact", art: "spark", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 500 },
|
|
delay: spilled ? 400 : 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;
|
|
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 === "died" && "player" in e && (e as { player: string }).player === povId) {
|
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
|
|
} else if (e.type === "fellInPit" && e.player === povId) {
|
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
|
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
|
|
} else if (
|
|
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
|
|
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
|
|
"player" in e && (e as { player: string }).player === povId
|
|
) {
|
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
|
} 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"],
|
|
rubble: ["#b0a898", "#5c544a"],
|
|
};
|
|
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!;
|
|
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;
|
|
}
|