Flourishes: the spells become visible (branch only — not for the droplet yet)

A cosmetic effects layer on the board svg, fed by the same event
stream that writes the log. Fireballs streak and burst; waterbolts
arc and splash; lightning jags and flickers; teleports shimmer at
both ends; walls rise and fall in dust; stopped attacks flash a
golden shield; hits ring red; misses whiff; other attack spells
sparkle at the caster. Successive visuals from one command stagger by
a beat, everything self-expires, reduced-motion hides the layer
whole, and nothing here touches game state — the effects are mapped
from events in fx.ts and drawn in Board.svelte, online and hotseat
alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 23:43:16 -04:00
co-authored by Claude Fable 5
parent b01e27c644
commit bca34ae9e4
5 changed files with 326 additions and 0 deletions
+18
View File
@@ -6,6 +6,7 @@
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte"; import Replay from "./Replay.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
@@ -27,6 +28,22 @@
} }
/** Which pending attack the player has already acknowledged (modal dismissed). */ /** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $state<string | null>(null); let attackNoticeSeen = $state<string | null>(null);
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
function playFx(events: Parameters<typeof fxForEvents>[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); const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() { function dismissRolls() {
net.openingRolls = null; net.openingRolls = null;
@@ -1251,6 +1268,7 @@
onWarpClick={clickWarp} onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingTeleport} markedCell={tradeFrom ?? pendingTeleport}
markedCells={trapCells.length > 0 ? trapCells : null} markedCells={trapCells.length > 0 ? trapCells : null}
effects={boardFx}
markedSector={pendingSectorOrigin} markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts} ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot} onGhostClick={clickGhostSlot}
+195
View File
@@ -22,6 +22,7 @@
markedSector = null, markedSector = null,
ghostSlots = null, ghostSlots = null,
onGhostClick, onGhostClick,
effects = null,
}: { }: {
view: GameView; view: GameView;
edgeSelectMode?: boolean; edgeSelectMode?: boolean;
@@ -46,8 +47,33 @@
* beyond the maze, since an empty slot has no floor of its own to click. */ * beyond the maze, since an empty slot has no floor of its own to click. */
ghostSlots?: { x: number; y: number }[] | null; ghostSlots?: { x: number; y: number }[] | null;
onGhostClick?: (origin: { x: number; y: number }) => void; onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: import("./fx").BoardFx[] | null;
} = $props(); } = $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; const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including /** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The * negative coordinates (the maze renormalizes after the landing). The
@@ -566,6 +592,53 @@
{/if} {/if}
{/each} {/each}
{/if} {/if}
<!-- spell flourishes: short-lived, purely cosmetic -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
{#if fx.kind === "fireball" || fx.kind === "waterbolt"}
{@const a = center(fx.from)}
{@const b = center(fx.to)}
<circle r={fx.kind === "fireball" ? 7 : 6} class={`fx-${fx.kind}`} cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
{:else if fx.kind === "bolt"}
<polyline points={boltPoints(fx.from, fx.to)} class="fx-bolt" />
{:else if fx.kind === "burst"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="4" class="fx-burst" />
<circle cx={c.x} cy={c.y} r="4" class="fx-burst late" />
{:else if fx.kind === "splash"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="4" class="fx-splash" />
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="fx-droplet" />
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="fx-droplet late" />
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="fx-droplet later" />
{:else if fx.kind === "shimmer"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer" />
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer late" />
{:else if fx.kind === "shield"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="16" class="fx-shield" />
{:else if fx.kind === "sparkle"}
{@const c = center(fx.at)}
<g class="fx-sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
</g>
{:else if fx.kind === "whiff"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="8" class="fx-whiff" />
{:else if fx.kind === "hit"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="10" class="fx-hit" />
{:else if fx.kind === "edge-dust"}
{@const m = edgeMid(fx.cell, fx.side)}
<circle cx={m.x - 6} cy={m.y} r="4" class="fx-dust" />
<circle cx={m.x + 5} cy={m.y - 3} r="3" class="fx-dust late" />
<circle cx={m.x} cy={m.y + 4} r="3.5" class="fx-dust later" />
{/if}
{/each}
</g>
</svg> </svg>
<style> <style>
@@ -705,6 +778,127 @@
animation: warp-pulse 1.6s ease-in-out infinite; animation: warp-pulse 1.6s ease-in-out infinite;
} }
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); } .ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
/* --- spell flourishes ---------------------------------------------- */
.fx-layer { pointer-events: none; }
.fx-fireball {
fill: #ff8c1a;
stroke: #ffd27a;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
animation: fx-fade 0.55s ease-in forwards;
}
.fx-waterbolt {
fill: #3b8dd6;
stroke: #b9e2ff;
stroke-width: 2;
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
animation: fx-fade 0.55s ease-in forwards;
}
.fx-bolt {
fill: none;
stroke: #ffe94d;
stroke-width: 3;
stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
animation: fx-flicker 0.5s steps(2, jump-none) forwards;
}
.fx-burst {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ff8c1a;
stroke-width: 4;
animation: fx-ring 0.5s ease-out 0.05s forwards;
opacity: 0;
}
.fx-burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
.fx-splash {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #4fa3e3;
stroke-width: 3.5;
animation: fx-ring 0.55s ease-out forwards;
}
.fx-droplet { fill: #7cc0ee; animation: fx-drop 0.6s ease-out forwards; }
.fx-droplet.late { animation-delay: 0.08s; }
.fx-droplet.later { animation-delay: 0.15s; }
.fx-shimmer {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: fx-ring 0.6s ease-out forwards;
}
.fx-shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
.fx-shield {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c9a72a;
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
animation: fx-shield-pulse 0.7s ease-out forwards;
}
.fx-sparkle path {
fill: #e8dfc6;
stroke: #c9a72a;
stroke-width: 1;
animation: fx-fade 0.8s ease-in forwards;
}
.fx-sparkle { animation: fx-spin 0.8s linear forwards; }
.fx-whiff {
transform-box: fill-box;
transform-origin: center;
fill: rgba(180, 180, 180, 0.4);
animation: fx-drift 0.7s ease-out forwards;
}
.fx-hit {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
animation: fx-ring-small 0.45s ease-out forwards;
}
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
.fx-dust.late { animation-delay: 0.09s; }
.fx-dust.later { animation-delay: 0.17s; }
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes fx-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
@keyframes fx-ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes fx-ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
}
@keyframes fx-shield-pulse {
0% { opacity: 0; transform: scale(0.6); }
30% { opacity: 1; transform: scale(1.1); }
60% { transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.15); }
}
@keyframes fx-drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
}
@keyframes fx-drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
@keyframes fx-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
30% { opacity: 1; }
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
}
.warp-dest { .warp-dest {
fill: none; fill: none;
stroke: #2e7d32; stroke: #2e7d32;
@@ -719,6 +913,7 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; } .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.fx-layer { display: none; }
} }
.wizard { cursor: pointer; } .wizard { cursor: pointer; }
.wizard-label { .wizard-label {
+107
View File
@@ -0,0 +1,107 @@
// Whimsical flourishes: game events become short-lived board effects.
// Purely cosmetic — nothing here touches game state, and reduced-motion
// hides the whole layer.
import type { GameEvent, GameView } from "@wizwar/engine";
type Cell = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt"; from: Cell; to: Cell }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"; at: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
export type BoardFx = FxShape & { id: number };
let nextId = 1;
/** How long each kind stays mounted (animation length + a little grace). */
export function fxTtl(kind: BoardFx["kind"]): number {
switch (kind) {
case "fireball": case "waterbolt": return 700;
case "bolt": return 600;
default: return 900;
}
}
const PROJECTILES: Record<string, "fireball" | "bolt" | "waterbolt"> = {
fireball: "fireball",
"sudden-death": "fireball",
"blaster-wand": "fireball",
"lightning-blast": "bolt",
"power-drain": "bolt",
waterbolt: "waterbolt",
};
/** Map one command's events to effects, each with a start delay in ms. */
export function fxForEvents(
events: GameEvent[], view: GameView,
): { fx: BoardFx; delay: number }[] {
const out: { fx: BoardFx; delay: number }[] = [];
const posOf = (playerId: string | null): Cell | null => {
if (!playerId) return null;
const p = view.players.find((p) => p.id === playerId);
return p ? { ...p.position } : null;
};
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 });
};
for (const e of events) {
switch (e.type) {
case "spellCast": {
const to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId];
if (projectile && to) {
push({ kind: projectile, from: e.from, to });
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
beat++;
} else if (to && e.target) {
push({ kind: "sparkle", at: to });
beat++;
}
break;
}
case "punched":
push({ kind: "hit", at: e.at });
beat++;
break;
case "damaged": {
const at = posOf(e.player);
if (at) { push({ kind: "hit", at }); beat++; }
break;
}
case "attackMissed": {
const at = posOf(e.defender);
if (at) { push({ kind: "whiff", at }); beat++; }
break;
}
case "attackResolved": {
if (e.fullyStopped) {
const at = posOf(e.defender);
if (at) { push({ kind: "shield", at }); beat++; }
}
break;
}
case "teleported":
push({ kind: "shimmer", at: e.from });
push({ kind: "shimmer", at: e.to }, 200);
beat++;
break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
case "sectorRotated":
case "sectorRelocated":
// The whole maze moved; any effect would point at stale ground.
return out;
default:
break;
}
}
return out;
}
+3
View File
@@ -51,6 +51,8 @@ class LocalGame {
/** The finished game as a reel, each step from its actor's own seat. */ /** The finished game as a reel, each step from its actor's own seat. */
/** The opening roll-off, shown once as the boards flip. */ /** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null); openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate command results. */
onFx: ((events: GameEvent[]) => void) | null = null;
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null); replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
view = $derived( view = $derived(
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null, this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
@@ -220,6 +222,7 @@ class LocalGame {
this.log = [...this.log, `${result.error}`]; this.log = [...this.log, `${result.error}`];
return; return;
} }
this.onFx?.(result.events);
this.gameState = result.state; this.gameState = result.state;
this.commands = [...this.commands, { playerId: this.viewerId, command }]; this.commands = [...this.commands, { playerId: this.viewerId, command }];
const now = Date.now(); const now = Date.now();
+3
View File
@@ -253,6 +253,8 @@ class Net {
/** A catch-up reel delivered by the server. */ /** A catch-up reel delivered by the server. */
/** The opening roll-off, shown once as the boards flip. */ /** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null); openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate live event batches. */
onFx: ((events: GameEvent[]) => void) | null = null;
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen(); private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states /** Room whose live stream this connection has already shown once: states
@@ -342,6 +344,7 @@ class Net {
break; break;
case "events": { case "events": {
let talk = 0; let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
for (const e of msg.events as GameEvent[]) { for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") talk++; if (e.type === "tableTalk") talk++;
if (e.type === "gameStarted" && !msg.replayed) { if (e.type === "gameStarted" && !msg.replayed) {