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>
330 lines
12 KiB
TypeScript
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);
|
|
};
|
|
}
|