Files
wizwar6e/packages/web/src/fx.ts
T
Eric WagonerandClaude Fable 5 d2b40ec6f8 Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.

Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.

Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:12:03 -04:00

330 lines
12 KiB
TypeScript

// 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<K extends BoardFx["kind"]> = 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<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;
};
/** 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<typeof setTimeout>[] = [];
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);
};
}