diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1e7c28e..9f177d1 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -6,6 +6,7 @@ import Card from "./Card.svelte"; import Help from "./Help.svelte"; import Replay from "./Replay.svelte"; + import { fxForEvents, fxTtl, type BoardFx } from "./fx"; import { local } from "./local.svelte"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; @@ -27,6 +28,22 @@ } /** Which pending attack the player has already acknowledged (modal dismissed). */ let attackNoticeSeen = $state(null); + /** Live spell flourishes on the board (cosmetic, self-expiring). */ + let boardFx = $state([]); + function playFx(events: Parameters[0]) { + if (!view) return; + for (const { fx, delay } of fxForEvents(events, view)) { + setTimeout(() => { + boardFx = [...boardFx, fx]; + setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)); + }, delay); + } + } + $effect(() => { + net.onFx = playFx; + local.onFx = playFx; + return () => { net.onFx = null; local.onFx = null; }; + }); const openingRolls = $derived(net.openingRolls ?? local.openingRolls); function dismissRolls() { net.openingRolls = null; @@ -1251,6 +1268,7 @@ onWarpClick={clickWarp} markedCell={tradeFrom ?? pendingTeleport} markedCells={trapCells.length > 0 ? trapCells : null} + effects={boardFx} markedSector={pendingSectorOrigin} ghostSlots={relocateGhosts} onGhostClick={clickGhostSlot} diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 9dd67b6..345cfc2 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -22,6 +22,7 @@ markedSector = null, ghostSlots = null, onGhostClick, + effects = null, }: { view: GameView; edgeSelectMode?: boolean; @@ -46,8 +47,33 @@ * beyond the maze, since an empty slot has no floor of its own to click. */ ghostSlots?: { x: number; y: number }[] | null; onGhostClick?: (origin: { x: number; y: number }) => void; + /** Short-lived spell flourishes; purely cosmetic. */ + effects?: import("./fx").BoardFx[] | null; } = $props(); + const center = (c: { x: number; y: number }) => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 }); + /** A lightning bolt's jagged path between two squares. */ + function boltPoints(from: { x: number; y: number }, to: { x: number; y: number }): string { + const a = center(from), b = center(to); + const segs = 6; + const pts: string[] = [`${a.x},${a.y}`]; + for (let i = 1; i < segs; i++) { + const t = i / segs; + const nx = a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16; + const ny = a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16; + pts.push(`${nx.toFixed(1)},${ny.toFixed(1)}`); + } + pts.push(`${b.x},${b.y}`); + return pts.join(" "); + } + function edgeMid(cell: { x: number; y: number }, side: string): { x: number; y: number } { + const c = center(cell); + if (side === "N") return { x: c.x, y: cell.y * CELL }; + if (side === "S") return { x: c.x, y: (cell.y + 1) * CELL }; + if (side === "W") return { x: cell.x * CELL, y: c.y }; + return { x: (cell.x + 1) * CELL, y: c.y }; + } + const SECTOR = 5; /** Ghost slots can lie beyond the assembled maze — on any side, including * negative coordinates (the maze renormalizes after the landing). The @@ -566,6 +592,53 @@ {/if} {/each} {/if} + +