Flourishes: the spells become visible (branch only — not for the droplet yet)

A cosmetic effects layer on the board svg, fed by the same event
stream that writes the log. Fireballs streak and burst; waterbolts
arc and splash; lightning jags and flickers; teleports shimmer at
both ends; walls rise and fall in dust; stopped attacks flash a
golden shield; hits ring red; misses whiff; other attack spells
sparkle at the caster. Successive visuals from one command stagger by
a beat, everything self-expires, reduced-motion hides the layer
whole, and nothing here touches game state — the effects are mapped
from events in fx.ts and drawn in Board.svelte, online and hotseat
alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 23:43:16 -04:00
co-authored by Claude Fable 5
parent b01e27c644
commit bca34ae9e4
5 changed files with 326 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Whimsical flourishes: game events become short-lived board effects.
// Purely cosmetic — nothing here touches game state, and reduced-motion
// hides the whole layer.
import type { GameEvent, GameView } from "@wizwar/engine";
type Cell = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt"; from: Cell; to: Cell }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"; at: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
export type BoardFx = FxShape & { id: number };
let nextId = 1;
/** How long each kind stays mounted (animation length + a little grace). */
export function fxTtl(kind: BoardFx["kind"]): number {
switch (kind) {
case "fireball": case "waterbolt": return 700;
case "bolt": return 600;
default: return 900;
}
}
const PROJECTILES: Record<string, "fireball" | "bolt" | "waterbolt"> = {
fireball: "fireball",
"sudden-death": "fireball",
"blaster-wand": "fireball",
"lightning-blast": "bolt",
"power-drain": "bolt",
waterbolt: "waterbolt",
};
/** Map one command's events to effects, each with a start delay in ms. */
export function fxForEvents(
events: GameEvent[], view: GameView,
): { fx: BoardFx; delay: number }[] {
const out: { fx: BoardFx; delay: number }[] = [];
const posOf = (playerId: string | null): Cell | null => {
if (!playerId) return null;
const p = view.players.find((p) => p.id === playerId);
return p ? { ...p.position } : null;
};
let beat = 0; // successive visuals from one command stagger slightly
const push = (fx: FxShape, extraDelay = 0) => {
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay });
};
for (const e of events) {
switch (e.type) {
case "spellCast": {
const to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId];
if (projectile && to) {
push({ kind: projectile, from: e.from, to });
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
beat++;
} else if (to && e.target) {
push({ kind: "sparkle", at: to });
beat++;
}
break;
}
case "punched":
push({ kind: "hit", at: e.at });
beat++;
break;
case "damaged": {
const at = posOf(e.player);
if (at) { push({ kind: "hit", at }); beat++; }
break;
}
case "attackMissed": {
const at = posOf(e.defender);
if (at) { push({ kind: "whiff", at }); beat++; }
break;
}
case "attackResolved": {
if (e.fullyStopped) {
const at = posOf(e.defender);
if (at) { push({ kind: "shield", at }); beat++; }
}
break;
}
case "teleported":
push({ kind: "shimmer", at: e.from });
push({ kind: "shimmer", at: e.to }, 200);
beat++;
break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
case "sectorRotated":
case "sectorRelocated":
// The whole maze moved; any effect would point at stale ground.
return out;
default:
break;
}
}
return out;
}