// 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 }; /** A point in board pixels (travelling effects anchor to token centers). */ type Pt = { x: number; y: number }; type Side = "N" | "E" | "S" | "W"; import { CELL } from "./fx-sprites/geom"; const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 }); /** Where a wizard token's center sits in a cell (mirrors Board.svelte). */ const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 }); export type FxShape = | { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt } | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit" | "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl" | "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell } | { kind: "portal"; cell: Cell; side: Side } | { kind: "sector-spin"; origin: Cell; clockwise: boolean } | { kind: "sector-slide"; from: Cell; to: Cell } | { kind: "edge-dust"; cell: Cell; side: Side }; export type BoardFx = FxShape & { id: number }; /** The narrow type of one effect kind (for sprite components). */ export type FxOf = BoardFx & { kind: K }; 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": case "streak": return 600; case "portal": case "portal-cell": case "soul": case "fireworks": case "chaos-swirl": case "sector-spin": case "sector-slide": return 1500; default: return 900; } } const PROJECTILES: Record = { 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; }; /** A wizard token's exact center, fan-out included (mirrors Board.svelte). */ const wizardAnchor = (playerId: string): Pt | null => { const p = view.players.find((p) => p.id === playerId && p.alive); if (!p) return null; const group = view.players.filter( (q) => q.alive && q.position.x === p.position.x && q.position.y === p.position.y, ); const i = group.findIndex((q) => q.id === playerId); return { x: p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0), y: p.position.y * CELL + CELL * 0.36, }; }; const creatureAnchor = (creatureId: string): Pt | null => { const c = view.creatures.find((c) => c.id === creatureId); if (!c) return null; const group = view.creatures.filter( (q) => q.position.x === c.position.x && q.position.y === c.position.y, ); const i = group.findIndex((q) => q.id === creatureId); return { x: c.position.x * CELL + CELL * 0.72 - (group.length > 1 ? i * CELL * 0.26 : 0), y: c.position.y * CELL + CELL * 0.7, }; }; /** Best anchor for a spot: the named token if it stands there, else the * token-height point of the cell, else its plain center. */ const anchorAt = (cell: Cell, id?: string | null): Pt => { if (id) { const a = wizardAnchor(id) ?? creatureAnchor(id); if (a) return a; } const standing = view.players.find( (p) => p.alive && p.position.x === cell.x && p.position.y === cell.y, ); if (standing) return wizardAnchor(standing.id) ?? wizMid(cell); const crouching = view.creatures.find( (c) => c.position.x === cell.x && c.position.y === cell.y, ); if (crouching) return creatureAnchor(crouching.id) ?? cellMid(cell); return cellMid(cell); }; 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": { if (e.cardId === "chaos") { push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } }); beat++; break; } const to = e.targetCell ?? posOf(e.target); const projectile = PROJECTILES[e.cardId]; if (projectile && to) { push({ kind: projectile, a: anchorAt(e.from, e.caster), b: anchorAt(to, e.target) }); 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: "pow", at: e.at }); beat++; break; case "creatureAttacked": { // The target may be a wizard or a fellow creature. const at = posOf(e.target) ?? (() => { const c = view.creatures.find((c) => c.id === e.target); return c ? { ...c.position } : null; })(); if (at) { push({ kind: "claw", at }); beat++; } break; } case "creatureTouched": { const at = posOf(e.player); if (at) { push({ kind: "claw", at }); beat++; } break; } case "attackAbsorbedIntoHand": { const at = posOf(e.player); if (at) { push({ kind: "absorb", 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": { const back = posOf(e.attacker); const stand = posOf(e.defender); if ((e.redirected || e.reflectedDamage > 0) && back && stand) { // The spell turns in the air and goes home. const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt"; push({ kind, a: anchorAt(stand, e.defender), b: anchorAt(back, e.attacker) }); push({ kind: "hit", at: back }, 380); beat++; } else if (e.fullyStopped && stand) { push({ kind: "shield", at: stand }); beat++; } break; } case "teleported": push({ kind: "shimmer", at: e.from }); push({ kind: "shimmer", at: e.to }, 200); beat++; break; case "moved": if (e.via === "warp") { // The mouths are edges: shimmer the opening lines on both boards. const w = view.board.warps.find((w) => w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction); if (w) { push({ kind: "portal", cell: w.from.cell, side: w.from.side }); push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150); } else { push({ kind: "portal-cell", at: e.from }); push({ kind: "portal-cell", at: e.to }, 150); } beat++; } break; case "warpStepped": // Dimensional warp tokens fill their squares; the veil wraps them. push({ kind: "portal-cell", at: e.from }); push({ kind: "portal-cell", at: e.to }, 150); beat++; break; case "wallCreated": case "wallDestroyed": push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side }); beat++; break; case "stoneTurnedToWater": { // The wall (or block) bursts into water: splash both sides of the // vanished edge, or the freed square itself. if (e.at) { push({ kind: "splash", at: e.at }); } else if (e.edge) { const n = { x: e.edge.cell.x + (e.edge.side === "E" ? 1 : e.edge.side === "W" ? -1 : 0), y: e.edge.cell.y + (e.edge.side === "S" ? 1 : e.edge.side === "N" ? -1 : 0), }; push({ kind: "splash", at: e.edge.cell }); push({ kind: "splash", at: n }, 120); } beat++; break; } case "washedBack": // The collapsing wave carries them: a streak per victim. push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) }); beat++; break; case "died": { const at = posOf(e.player); if (at) { push({ kind: "soul", at }, 250); beat++; } break; } case "gameWon": { const home = view.players.find((p) => p.id === e.player)?.home; if (home) { push({ kind: "fireworks", at: home }); push({ kind: "fireworks", at: home }, 350); push({ kind: "fireworks", at: home }, 700); beat++; } break; } case "knockedBack": case "shoved": push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) }); beat++; break; case "objectDragged": push({ kind: "streak", a: cellMid(e.from), b: anchorAt(e.to, e.what) }); beat++; break; case "jumpedPit": push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) }); beat++; break; case "fellInPit": push({ kind: "pit-fall", at: e.at }); beat++; break; case "climbedFromPit": { if (e.success) { const at = posOf(e.player); if (at) { push({ kind: "dust-puff", at }); beat++; } } break; } case "slippedInOoze": push({ kind: "ooze-slip", at: e.at }); beat++; break; case "steppedOnTacks": push({ kind: "tacks-ow", at: e.at }); beat++; break; case "enteredThornbush": push({ kind: "thorn-snap", at: e.at }); beat++; break; case "stuckInSlime": push({ kind: "slime-stuck", at: e.at }); beat++; break; case "sectorRotated": { const origin = view.board.placements[e.sectorIndex]?.origin; if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise }); // Everything after the grind points at pre-move ground; stop here. return out; } case "sectorRelocated": { // The event records pre-normalization origins; the view holds the // truth. Shift the recorded start by the same correction. const trueTo = view.board.placements[e.sectorIndex]?.origin; if (trueTo) { const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y; push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } }); } return out; } default: break; } } return out; } /** Schedule a batch's effects into `add`, expiring each after its run. * Returns a cancel that stops pending starts and sweeps what began. */ export function scheduleFx( events: GameEvent[], view: GameView, add: (fx: BoardFx) => void, remove: (id: number) => void, ): () => void { const timers: ReturnType[] = []; const started: number[] = []; for (const { fx, delay } of fxForEvents(events, view)) { timers.push(setTimeout(() => { started.push(fx.id); add(fx); timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind))); }, delay)); } return () => { timers.forEach(clearTimeout); started.forEach(remove); }; }