From 93bdbb403a3d42812e6a431a5ba26d92b48a690b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 11:00:09 -0400 Subject: [PATCH] Travelling effects fly token to token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/src/FxGallery.svelte | 8 +-- packages/web/src/fx-sprites/Bolt.svelte | 3 +- packages/web/src/fx-sprites/Fireball.svelte | 5 +- packages/web/src/fx-sprites/Streak.svelte | 5 +- packages/web/src/fx-sprites/Waterbolt.svelte | 5 +- packages/web/src/fx.ts | 63 +++++++++++++++++--- 6 files changed, 67 insertions(+), 22 deletions(-) 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":