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). // One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 }; const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([ const SAMPLES: BoardFx[] = ([
{ kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } }, { kind: "fireball", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
{ kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } }, { kind: "waterbolt", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
{ kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }, { kind: "bolt", a: { x: 24, y: 24 }, b: { x: 120, y: 120 } },
{ kind: "burst", at: mid }, { kind: "burst", at: mid },
{ kind: "splash", at: mid }, { kind: "splash", at: mid },
{ kind: "shimmer", at: mid }, { kind: "shimmer", at: mid },
@@ -29,7 +29,7 @@
{ kind: "absorb", at: mid }, { kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" }, { kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid }, { 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: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid }, { kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid }, { kind: "chaos-swirl", at: mid },
+1 -2
View File
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { FxOf } from "../fx"; import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"bolt"> } = $props(); let { fx }: { fx: FxOf<"bolt"> } = $props();
const uid = $props.id(); const uid = $props.id();
/** A fresh jagged path every strike, plus two forks off mid-joints. */ /** A fresh jagged path every strike, plus two forks off mid-joints. */
const geom = $derived.by(() => { const geom = $derived.by(() => {
const a = center(fx.from), b = center(fx.to); const a = fx.a, b = fx.b;
const segs = 6; const segs = 6;
const joints: { x: number; y: number }[] = [a]; const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) { for (let i = 1; i < segs; i++) {
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { FxOf } from "../fx"; import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props(); let { fx }: { fx: FxOf<"fireball"> } = $props();
const uid = $props.id(); const uid = $props.id();
const a = $derived(center(fx.from)); const a = $derived(fx.a);
const b = $derived(center(fx.to)); const b = $derived(fx.b);
</script> </script>
<defs> <defs>
+2 -3
View File
@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { FxOf } from "../fx"; import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props(); let { fx }: { fx: FxOf<"streak"> } = $props();
const a = $derived(center(fx.from)); const a = $derived(fx.a);
const b = $derived(center(fx.to)); const b = $derived(fx.b);
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`); const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
</script> </script>
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { FxOf } from "../fx"; import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props(); let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const uid = $props.id(); const uid = $props.id();
const a = $derived(center(fx.from)); const a = $derived(fx.a);
const b = $derived(center(fx.to)); const b = $derived(fx.b);
</script> </script>
<defs> <defs>
+56 -7
View File
@@ -5,10 +5,17 @@
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
type Cell = { x: number; y: number }; 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"; 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 = 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" | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl" | "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell } | "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); const p = view.players.find((p) => p.id === playerId);
return p ? { ...p.position } : null; 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 let beat = 0; // successive visuals from one command stagger slightly
const push = (fx: FxShape, extraDelay = 0) => { const push = (fx: FxShape, extraDelay = 0) => {
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay }); 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 to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId]; const projectile = PROJECTILES[e.cardId];
if (projectile && to) { 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 === "fireball") push({ kind: "burst", at: to }, 380);
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380); if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
beat++; beat++;
@@ -119,7 +168,7 @@ export function fxForEvents(
if ((e.redirected || e.reflectedDamage > 0) && back && stand) { if ((e.redirected || e.reflectedDamage > 0) && back && stand) {
// The spell turns in the air and goes home. // The spell turns in the air and goes home.
const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt"; 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); push({ kind: "hit", at: back }, 380);
beat++; beat++;
} else if (e.fullyStopped && stand) { } else if (e.fullyStopped && stand) {
@@ -177,7 +226,7 @@ export function fxForEvents(
} }
case "washedBack": case "washedBack":
// The collapsing wave carries them: a streak per victim. // 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++; beat++;
break; break;
case "died": { case "died": {
@@ -197,15 +246,15 @@ export function fxForEvents(
} }
case "knockedBack": case "knockedBack":
case "shoved": 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++; beat++;
break; break;
case "objectDragged": 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++; beat++;
break; break;
case "jumpedPit": 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++; beat++;
break; break;
case "fellInPit": case "fellInPit":