diff --git a/packages/web/src/FxGallery.svelte b/packages/web/src/FxGallery.svelte
index 4a5d716..006dc30 100644
--- a/packages/web/src/FxGallery.svelte
+++ b/packages/web/src/FxGallery.svelte
@@ -14,9 +14,9 @@
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([
- { kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
- { kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
- { kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } },
+ { kind: "fireball", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
+ { kind: "waterbolt", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
+ { kind: "bolt", a: { x: 24, y: 24 }, b: { x: 120, y: 120 } },
{ kind: "burst", at: mid },
{ kind: "splash", at: mid },
{ kind: "shimmer", at: mid },
@@ -29,7 +29,7 @@
{ kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid },
- { kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
+ { kind: "streak", a: { x: 24, y: 120 }, b: { x: 120, y: 24 } },
{ kind: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
diff --git a/packages/web/src/fx-sprites/Bolt.svelte b/packages/web/src/fx-sprites/Bolt.svelte
index 7f96605..2df1f96 100644
--- a/packages/web/src/fx-sprites/Bolt.svelte
+++ b/packages/web/src/fx-sprites/Bolt.svelte
@@ -1,13 +1,12 @@
diff --git a/packages/web/src/fx-sprites/Streak.svelte b/packages/web/src/fx-sprites/Streak.svelte
index 307b902..1307a2d 100644
--- a/packages/web/src/fx-sprites/Streak.svelte
+++ b/packages/web/src/fx-sprites/Streak.svelte
@@ -1,10 +1,9 @@
diff --git a/packages/web/src/fx-sprites/Waterbolt.svelte b/packages/web/src/fx-sprites/Waterbolt.svelte
index 60b2881..f4be33f 100644
--- a/packages/web/src/fx-sprites/Waterbolt.svelte
+++ b/packages/web/src/fx-sprites/Waterbolt.svelte
@@ -1,12 +1,11 @@
diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts
index 6cbc1d4..3d8ef11 100644
--- a/packages/web/src/fx.ts
+++ b/packages/web/src/fx.ts
@@ -5,10 +5,17 @@
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";
+const CELL = 48;
+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 });
+
type FxShape =
- | { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
+ | { 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 }
@@ -53,6 +60,48 @@ export function fxForEvents(
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 });
@@ -69,7 +118,7 @@ export function fxForEvents(
const to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId];
if (projectile && to) {
- push({ kind: projectile, from: e.from, 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++;
@@ -119,7 +168,7 @@ export function fxForEvents(
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, from: stand, to: back });
+ push({ kind, a: anchorAt(stand, e.defender), b: anchorAt(back, e.attacker) });
push({ kind: "hit", at: back }, 380);
beat++;
} else if (e.fullyStopped && stand) {
@@ -177,7 +226,7 @@ export function fxForEvents(
}
case "washedBack":
// The collapsing wave carries them: a streak per victim.
- push({ kind: "streak", from: e.from, to: e.to });
+ push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "died": {
@@ -197,15 +246,15 @@ export function fxForEvents(
}
case "knockedBack":
case "shoved":
- push({ kind: "streak", from: e.from, to: e.to });
+ push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "objectDragged":
- push({ kind: "streak", from: e.from, to: e.to });
+ push({ kind: "streak", a: cellMid(e.from), b: anchorAt(e.to, e.what) });
beat++;
break;
case "jumpedPit":
- push({ kind: "streak", from: e.from, to: e.to });
+ push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "fellInPit":