Travelling effects fly token to token

Projectiles flew cell-center to cell-center, launching from the
caster's feet and landing under the target's chin — tokens sit high
in their squares and fan sideways when crowded. Every travelling
effect (fireball, waterbolt, bolt, streak, and the reflection's
return flight) now resolves token anchors that mirror the board's
exact positioning, fan-out included: caster's center to target's
center, wizard or creature. Spots with no token to aim at fall back
to sensible cell points, and wash/knockback streaks end on the
carried wizard wherever they fanned to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 11:00:09 -04:00
co-authored by Claude Fable 5
parent 126f003156
commit 93bdbb403a
6 changed files with 67 additions and 22 deletions
+4 -4
View File
@@ -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 },
+1 -2
View File
@@ -1,13 +1,12 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"bolt"> } = $props();
const uid = $props.id();
/** A fresh jagged path every strike, plus two forks off mid-joints. */
const geom = $derived.by(() => {
const a = center(fx.from), b = center(fx.to);
const a = fx.a, b = fx.b;
const segs = 6;
const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) {
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
</script>
<defs>
+2 -3
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
</script>
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
</script>
<defs>
+56 -7
View File
@@ -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":