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:
co-authored by
Claude Fable 5
parent
b01e27c644
commit
bca34ae9e4
@@ -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<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);
|
||||
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}
|
||||
|
||||
@@ -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}
|
||||
<!-- 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>
|
||||
|
||||
<style>
|
||||
@@ -705,6 +778,127 @@
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.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 {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
@@ -719,6 +913,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
}
|
||||
.wizard { cursor: pointer; }
|
||||
.wizard-label {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,6 +51,8 @@ class LocalGame {
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
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);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
@@ -220,6 +222,7 @@ class LocalGame {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
return;
|
||||
}
|
||||
this.onFx?.(result.events);
|
||||
this.gameState = result.state;
|
||||
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
||||
const now = Date.now();
|
||||
|
||||
@@ -253,6 +253,8 @@ class Net {
|
||||
/** A catch-up reel delivered by the server. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
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);
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
@@ -342,6 +344,7 @@ class Net {
|
||||
break;
|
||||
case "events": {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") talk++;
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
|
||||
Reference in New Issue
Block a user