8 Commits
Author SHA1 Message Date
Eric Wagoner fe0318b99b The clockwork quickens its step
Bot pacing from 1500ms to 1000ms per command — the table's preferred
tempo after living with both.
2026-08-17 09:43:48 -04:00
Eric WagonerandClaude Fable 5 a59ecfa615 Every flourish gets its own file, and a workshop to watch them in
The effects move out of Board.svelte into src/fx-sprites/ — one
component per flourish, markup and animation CSS together, so editing
the fireball means opening Fireball.svelte and nothing else. A
registry (index.ts) maps kind to sprite; Board renders through it.
The event mapping stays in fx.ts.

And a viewer: /?fx opens the Flourish Workshop — every sprite on a
labeled parchment tile, replaying on a 2.2-second beat. Under the dev
server, editing any sprite file hot-reloads the gallery mid-loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:27:32 -04:00
Eric Wagoner 0fcbff4dc6 Merge branch 'main' into flourishes 2026-08-17 09:18:40 -04:00
Eric WagonerandClaude Fable 5 a82b25b0e8 Warp travel shimmers at the threshold, not around the traveler
Green rings at the traveler's squares become a portal curtain: the
opening LINE itself shimmers on both boards — resolved from the warp
table via the step's direction — in iridescent cyan over a soft
violet breath, dashes drifting like light through a veil. Dimensional
warp tokens, whose mouths are whole squares, wrap in the same veil
around the square instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:11:29 -04:00
Eric WagonerandClaude Fable 5 68b0da6301 Flourishes third wave: the grind, the grave, and the pratfalls
The showstopper: sectors visibly move — a dashed parchment ghost
rotates through its quarter-turn or slides the whole way to its new
ground (corrected for rev-11 renormalization, since the event records
pre-shift origins and the view holds the truth). Death sends a small
soul drifting up from the token; victory bursts fireworks over the
winner's home, three volleys; CHAOS spins a triple spiral over the
whole maze and stands alone in its batch.

And the slapstick tier: knockbacks, shoves, drags, and pit-jumps
leave a motion streak; falling in a pit swallows dark with dust;
ooze gets a wobbling pratfall ellipse; tacks sting in three hopping
asterisks; thornbush snaps green; slime sinks its ring; a successful
pit climb puffs dust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 01:12:22 -04:00
Eric WagonerandClaude Fable 5 6da350fd46 Flourishes second wave: reflections, warps, claws, and ongoing looks
The reflected spell turns in the air and flies home wearing its own
colors, striking its caster with the hit it meant for someone else.
Warp travel glows green at both mouths. Punches land as a proper
burst star; creature blows rake three red gashes; an absorbed spell
is a little card swallowed into the absorber, spinning as it shrinks.

And ongoing spells now wear their look between events: firewalls lick
and glow, the invisible and misted breathe at a fraction of opacity,
Medusa's victims gray to stone, and the webbed wear their webs. The
pulses stop under reduced motion; the informative looks stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 01:02:37 -04:00
Eric WagonerandClaude Fable 5 70d7b4babb Flourishes play on the reel too
Replay steps carry the same events the live table saw, so the spells
flare on catch-up reels and whole-game replays alike — retroactively:
stored games re-derive their events on replay, so every fireball ever
thrown bursts again. Step changes clear pending effects so scrubbing
backward does not smear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:55:01 -04:00
Eric WagonerandClaude Fable 5 bca34ae9e4 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>
2026-08-16 23:43:16 -04:00
35 changed files with 1264 additions and 2 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
* The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again.
*/
const BOT_STEP_MS = 1500;
const BOT_STEP_MS = 1000;
/** What a step's event means to this bot — its own deed when it acted,
* its own suffering when somebody else did. Null: nothing worth a word. */
+23
View File
@@ -6,6 +6,8 @@
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.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 +29,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;
@@ -869,6 +887,9 @@
}
</script>
{#if new URLSearchParams(location.search).has("fx")}
<FxGallery />
{:else}
<div class="table">
<header class="masthead">
<span class="mast-title">Wiz-War</span>
@@ -1251,6 +1272,7 @@
onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingTeleport}
markedCells={trapCells.length > 0 ? trapCells : null}
effects={boardFx}
markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
@@ -1645,6 +1667,7 @@
</div>
{/if}
</div>
{/if}
<style>
:global(html, body) {
+98
View File
@@ -2,6 +2,7 @@
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48;
const WALL = 7;
@@ -22,6 +23,7 @@
markedSector = null,
ghostSlots = null,
onGhostClick,
effects = null,
}: {
view: GameView;
edgeSelectMode?: boolean;
@@ -46,8 +48,12 @@
* 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 SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The
@@ -100,6 +106,12 @@
if (cardId.endsWith("-wand")) return "magic-wand";
return null;
}
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
// stone-gray under Medusa's gaze.
function hasSpell(id: string, cardId: string): boolean {
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
}
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
@@ -478,12 +490,21 @@
width={half * 2} height={half * 2}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
class:stone-gazed={hasSpell(p.id, "medusa")}
/>
<rect
x={cx - half} y={cy - half}
width={half * 2} height={half * 2}
class="wizard-ring" stroke={playerColor(p.id)}
/>
{#if hasSpell(p.id, "sticky-web")}
<g class="webbing">
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
</g>
{/if}
{:else}
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
@@ -566,6 +587,13 @@
{/if}
{/each}
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
{@const Sprite = FX_SPRITES[fx.kind]}
<Sprite fx={fx as never} />
{/each}
</g>
</svg>
<style>
@@ -612,6 +640,11 @@
fill: #d0342c;
stroke: #7c1a14;
stroke-width: 1.2;
animation: firewall-lick 1.1s ease-in-out infinite alternate;
}
@keyframes firewall-lick {
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
}
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
@@ -705,6 +738,69 @@
animation: warp-pulse 1.6s ease-in-out infinite;
}
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
.fx-layer { pointer-events: none; }
/* --- persistent ambiance: ongoing spells wear their look ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
.webbing line {
stroke: rgba(220, 218, 205, 0.85);
stroke-width: 1.6;
pointer-events: none;
}
.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-pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
@keyframes fx-claw-rake {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
}
@keyframes fx-swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
@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 +815,8 @@
}
@media (prefers-reduced-motion: reduce) {
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.fx-layer { display: none; }
.firewall, .token-art.ghosted { animation: none; }
}
.wizard { cursor: pointer; }
.wizard-label {
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
// The flourish workshop: every effect sprite on a loop, labeled, against a
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx } from "./fx";
let tick = $state(0);
$effect(() => {
const t = setInterval(() => (tick += 1), 2200);
return () => clearInterval(t);
});
// 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: "burst", at: mid },
{ kind: "splash", at: mid },
{ kind: "shimmer", at: mid },
{ kind: "shield", at: mid },
{ kind: "sparkle", at: mid },
{ kind: "whiff", at: mid },
{ kind: "hit", at: mid },
{ kind: "pow", at: mid },
{ kind: "claw", at: mid },
{ 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: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
{ kind: "pit-fall", at: mid },
{ kind: "ooze-slip", at: mid },
{ kind: "tacks-ow", at: mid },
{ kind: "thorn-snap", at: mid },
{ kind: "slime-stuck", at: mid },
{ kind: "dust-puff", at: mid },
{ kind: "edge-dust", cell: mid, side: "S" },
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
<div class="gallery">
<h1>The Flourish Workshop</h1>
<p class="sub">
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
and it hot-reloads here.
</p>
<div class="grid">
{#each SAMPLES as fx (fx.kind)}
{@const Sprite = FX_SPRITES[fx.kind]}
<figure>
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cx) => cx) as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite fx={fx as never} /></g>
{/key}
</svg>
<figcaption>{fx.kind}</figcaption>
</figure>
{/each}
</div>
</div>
<style>
.gallery {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.5rem;
}
h1 {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 1.2rem;
color: #e9e1cb;
margin: 0 0 0.2rem;
}
.sub { color: #8d8672; margin: 0 0 1.2rem; }
.sub code { color: #c9a72a; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
figure {
margin: 0;
background: #efe8d4;
border-radius: 4px;
padding: 0.5rem;
}
svg { width: 100%; display: block; }
.cell {
fill: #efe8d4;
stroke: rgba(95, 74, 51, 0.25);
stroke-width: 1;
}
.fx-layer { pointer-events: none; }
figcaption {
font-family: "Courier Prime", monospace;
font-size: 0.75rem;
color: #43331f;
text-align: center;
padding-top: 0.3rem;
}
</style>
+17 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -20,6 +21,21 @@
);
const atEnd = $derived(idx >= steps.length - 1);
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step) return;
const timers: ReturnType<typeof setTimeout>[] = [];
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
timers.push(setTimeout(() => {
boardFx = [...boardFx, fx];
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
}, delay));
}
return () => { timers.forEach(clearTimeout); boardFx = []; };
});
$effect(() => {
if (!playing) return;
const t = setInterval(() => {
@@ -47,7 +63,7 @@
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header>
<div class="replay-board">
<Board view={step.view} />
<Board view={step.view} effects={boardFx} />
</div>
<div class="replay-caption">
<strong>{step.actor}</strong>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"absorb"> } = $props();
const c = $derived(center(fx.at));
</script>
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="absorb"
style={`transform-origin: ${c.x}px ${c.y}px`} />
<style>
.absorb {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1.5;
animation: swallow 0.65s ease-in forwards;
}
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"bolt"> } = $props();
/** A fresh jagged path every strike. */
const points = $derived.by(() => {
const a = center(fx.from), b = center(fx.to);
const segs = 6;
const pts: string[] = [`${a.x},${a.y}`];
for (let i = 1; i < segs; i++) {
const t = i / segs;
pts.push(`${(a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16).toFixed(1)},${(a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16).toFixed(1)}`);
}
pts.push(`${b.x},${b.y}`);
return pts.join(" ");
});
</script>
<polyline {points} class="bolt" />
<style>
.bolt {
fill: none;
stroke: #ffe94d;
stroke-width: 3;
stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
animation: flicker 0.5s steps(2, jump-none) forwards;
}
@keyframes flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"burst"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="4" class="burst" />
<circle cx={c.x} cy={c.y} r="4" class="burst late" />
<style>
.burst {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ff8c1a;
stroke-width: 4;
animation: ring 0.5s ease-out 0.05s forwards;
opacity: 0;
}
.burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" />
<circle cx={c.x} cy={c.y} r="55" class="mid" />
<circle cx={c.x} cy={c.y} r="80" class="outer" />
</g>
<style>
.chaos circle {
fill: none;
stroke: #b48ae0;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
@keyframes chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"claw"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="claw">
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
</g>
<style>
.claw line {
stroke: #b3372b;
stroke-width: 3;
stroke-linecap: round;
}
.claw { animation: rake 0.55s ease-out forwards; }
@keyframes rake {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
}
</style>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, center } from "./geom";
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
const c = $derived.by(() => {
if (fx.kind === "dust-puff") return center(fx.at);
const mid = center(fx.cell);
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
});
</script>
<circle cx={c.x - 6} cy={c.y} r="4" class="dust" />
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="dust late" />
<circle cx={c.x} cy={c.y + 4} r="3.5" class="dust later" />
<style>
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
.dust.late { animation-delay: 0.09s; }
.dust.later { animation-delay: 0.17s; }
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<circle r="7" class="fireball" cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
<style>
.fireball {
fill: #ff8c1a;
stroke: #ffd27a;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
animation: fade 0.55s ease-in forwards;
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireworks"> } = $props();
const c = $derived(center(fx.at));
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
</script>
<g class="fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
<circle
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
r="3" fill={COLORS[(deg / 45) % 4]}
/>
{/each}
</g>
<style>
.fireworks { animation: boom 1.1s ease-out forwards; }
@keyframes boom {
0% { opacity: 0; transform: scale(0.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="10" class="hit" />
<style>
.hit {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
animation: ring-small 0.45s ease-out forwards;
}
@keyframes ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
const c = $derived(center(fx.at));
</script>
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="ooze" />
<style>
.ooze {
transform-box: fill-box;
transform-origin: center;
fill: rgba(110, 160, 60, 0.55);
animation: wobble 0.7s ease-out forwards;
}
@keyframes wobble {
0% { opacity: 0.9; transform: scaleX(0.6); }
35% { transform: scaleX(1.25); }
65% { transform: scaleX(0.9); }
100% { opacity: 0; transform: scaleX(1.1); }
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="10" class="pitfall" />
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="dust" />
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="dust late" />
<style>
.pitfall {
transform-box: fill-box;
transform-origin: center;
fill: rgba(30, 24, 16, 0.8);
animation: swallow 0.7s ease-in forwards;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
.dust.late { animation-delay: 0.09s; }
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL } from "./geom";
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
</script>
{#if fx.kind === "portal"}
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
<line {x1} {y1} {x2} {y2} class="portal glow" />
<line {x1} {y1} {x2} {y2} class="portal" />
{:else}
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil" />
{/if}
<style>
.portal {
stroke: #9be3e0;
stroke-width: 3;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: shimmer 0.95s ease-in-out forwards;
}
.portal.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-width: 9;
stroke-dasharray: none;
filter: blur(2px);
animation: breathe 0.95s ease-in-out forwards;
}
.veil {
fill: rgba(155, 227, 224, 0.12);
stroke: #9be3e0;
stroke-width: 2.5;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
animation: shimmer 0.95s ease-in-out forwards;
}
@keyframes shimmer {
0% { opacity: 0; stroke-dashoffset: 0; }
20% { opacity: 1; }
80% { opacity: 0.85; }
100% { opacity: 0; stroke-dashoffset: 34; }
}
@keyframes breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pow"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
</g>
<style>
.pow path {
fill: #ffe94d;
stroke: #b3372b;
stroke-width: 1.6;
}
.pow { animation: pow-hit 0.5s ease-out forwards; }
@keyframes pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, SECTOR } from "./geom";
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
</script>
{#if fx.kind === "sector-spin"}
{@const ox = fx.origin.x * CELL}
{@const oy = fx.origin.y * CELL}
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class={`spin ${fx.clockwise ? "cw" : "ccw"}`}
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
{:else}
{@const fxp = fx.from.x * CELL}
{@const fyp = fx.from.y * CELL}
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class="slide"
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
{/if}
<style>
.spin, .slide {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
}
.spin { animation: grind-cw 1.2s ease-in-out forwards; }
.spin.ccw { animation-name: grind-ccw; }
.slide { animation: slide-home 1.2s ease-in-out forwards; }
@keyframes grind-cw {
0% { opacity: 0; transform: rotate(-90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes grind-ccw {
0% { opacity: 0; transform: rotate(90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes slide-home {
0% { opacity: 0; transform: translate(0, 0); }
15% { opacity: 1; }
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shield"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="16" class="shield" />
<style>
.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: pulse 0.7s ease-out forwards;
}
@keyframes 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); }
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shimmer"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="6" class="shimmer" />
<circle cx={c.x} cy={c.y} r="6" class="shimmer late" />
<style>
.shimmer {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: ring 0.6s ease-out forwards;
}
.shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="12" class="slime" />
<style>
.slime {
transform-box: fill-box;
transform-origin: center;
fill: rgba(140, 200, 60, 0.5);
stroke: #7a9e2e;
stroke-width: 3;
animation: sink 0.9s ease-out forwards;
}
@keyframes sink {
0% { opacity: 0.9; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.7); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"soul"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="soul">
<circle cx={c.x} cy={c.y - 4} r="8" />
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
</g>
<style>
.soul circle {
fill: rgba(233, 228, 245, 0.8);
stroke: rgba(160, 150, 200, 0.6);
stroke-width: 1;
}
.soul { animation: ascend 1.3s ease-out forwards; }
@keyframes ascend {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-34px); }
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"sparkle"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="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>
<style>
.sparkle path {
fill: #e8dfc6;
stroke: #c9a72a;
stroke-width: 1;
animation: fade 0.8s ease-in forwards;
}
.sparkle { animation: spin 0.8s linear forwards; }
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
30% { opacity: 1; }
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"splash"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="4" class="splash" />
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="droplet" />
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="droplet late" />
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="droplet later" />
<style>
.splash {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #4fa3e3;
stroke-width: 3.5;
animation: ring 0.55s ease-out forwards;
}
.droplet { fill: #7cc0ee; animation: drop 0.6s ease-out forwards; }
.droplet.late { animation-delay: 0.08s; }
.droplet.later { animation-delay: 0.15s; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="streak" />
<style>
.streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 14 9;
animation: streak-fade 0.55s ease-out forwards;
}
@keyframes streak-fade {
0% { opacity: 0.9; stroke-dashoffset: 46; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
</style>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="tacks">
<text x={c.x - 10} y={c.y - 4}></text>
<text x={c.x + 4} y={c.y - 10} class="late"></text>
<text x={c.x - 2} y={c.y + 6} class="later"></text>
</g>
<style>
.tacks text {
font-size: 13px;
fill: #b3372b;
animation: hop 0.6s ease-out forwards;
}
.tacks .late { animation-delay: 0.1s; opacity: 0; }
.tacks .later { animation-delay: 0.2s; opacity: 0; }
@keyframes hop {
0% { opacity: 0; transform: translateY(2px); }
30% { opacity: 1; transform: translateY(-5px); }
60% { transform: translateY(-1px); }
100% { opacity: 0; transform: translateY(-8px); }
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
const c = $derived(center(fx.at));
</script>
<path class="thorn"
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
<style>
.thorn {
fill: rgba(70, 120, 50, 0.7);
stroke: #2f5423;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: snap 0.55s ease-out forwards;
}
@keyframes snap {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<circle r="6" class="waterbolt" cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
<style>
.waterbolt {
fill: #3b8dd6;
stroke: #b9e2ff;
stroke-width: 2;
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
animation: fade 0.55s ease-in forwards;
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"whiff"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="8" class="whiff" />
<style>
.whiff {
transform-box: fill-box;
transform-origin: center;
fill: rgba(180, 180, 180, 0.4);
animation: drift 0.7s ease-out forwards;
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
+7
View File
@@ -0,0 +1,7 @@
// Shared geometry for effect sprites. CELL must match Board.svelte's grid.
export const CELL = 48;
export const SECTOR = 5;
export const center = (c: { x: number; y: number }) => ({
x: c.x * CELL + CELL / 2,
y: c.y * CELL + CELL / 2,
});
+61
View File
@@ -0,0 +1,61 @@
// One sprite component per effect. To change an effect's look, edit its
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
// register it here.
import type { Component } from "svelte";
import type { BoardFx } from "../fx";
import Absorb from "./Absorb.svelte";
import Bolt from "./Bolt.svelte";
import Burst from "./Burst.svelte";
import ChaosSwirl from "./ChaosSwirl.svelte";
import Claw from "./Claw.svelte";
import DustPuff from "./DustPuff.svelte";
import Fireball from "./Fireball.svelte";
import Fireworks from "./Fireworks.svelte";
import Hit from "./Hit.svelte";
import OozeSlip from "./OozeSlip.svelte";
import PitFall from "./PitFall.svelte";
import Portal from "./Portal.svelte";
import Pow from "./Pow.svelte";
import SectorGrind from "./SectorGrind.svelte";
import Shield from "./Shield.svelte";
import Shimmer from "./Shimmer.svelte";
import SlimeStuck from "./SlimeStuck.svelte";
import Soul from "./Soul.svelte";
import Sparkle from "./Sparkle.svelte";
import Splash from "./Splash.svelte";
import Streak from "./Streak.svelte";
import TacksOw from "./TacksOw.svelte";
import ThornSnap from "./ThornSnap.svelte";
import Waterbolt from "./Waterbolt.svelte";
import Whiff from "./Whiff.svelte";
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
fireball: Fireball,
waterbolt: Waterbolt,
bolt: Bolt,
burst: Burst,
splash: Splash,
shimmer: Shimmer,
shield: Shield,
sparkle: Sparkle,
whiff: Whiff,
hit: Hit,
pow: Pow,
claw: Claw,
absorb: Absorb,
portal: Portal,
"portal-cell": Portal,
streak: Streak,
soul: Soul,
fireworks: Fireworks,
"chaos-swirl": ChaosSwirl,
"pit-fall": PitFall,
"ooze-slip": OozeSlip,
"tacks-ow": TacksOw,
"thorn-snap": ThornSnap,
"slime-stuck": SlimeStuck,
"dust-puff": DustPuff,
"edge-dust": DustPuff,
"sector-spin": SectorGrind,
"sector-slide": SectorGrind,
} as never;
+238
View File
@@ -0,0 +1,238 @@
// 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" | "streak"; from: Cell; to: Cell }
| { 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 }
| { kind: "portal"; cell: Cell; side: Side }
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
| { kind: "sector-slide"; from: Cell; to: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
export type BoardFx = FxShape & { id: number };
/** The narrow type of one effect kind (for sprite components). */
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
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": case "streak": return 600;
case "portal": case "portal-cell":
case "soul": case "fireworks": case "chaos-swirl":
case "sector-spin": case "sector-slide": return 1500;
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": {
if (e.cardId === "chaos") {
push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } });
beat++;
break;
}
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: "pow", at: e.at });
beat++;
break;
case "creatureAttacked": {
// The target may be a wizard or a fellow creature.
const at = posOf(typeof e.target === "string" ? e.target : null) ??
(() => {
const c = view.creatures.find((c) => c.id === e.target);
return c ? { ...c.position } : null;
})();
if (at) { push({ kind: "claw", at }); beat++; }
break;
}
case "creatureTouched": {
const at = posOf(e.player);
if (at) { push({ kind: "claw", at }); beat++; }
break;
}
case "attackAbsorbedIntoHand": {
const at = posOf(e.player);
if (at) { push({ kind: "absorb", 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": {
const back = posOf(e.attacker);
const stand = posOf(e.defender);
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: "hit", at: back }, 380);
beat++;
} else if (e.fullyStopped && stand) {
push({ kind: "shield", at: stand });
beat++;
}
break;
}
case "teleported":
push({ kind: "shimmer", at: e.from });
push({ kind: "shimmer", at: e.to }, 200);
beat++;
break;
case "moved":
if (e.via === "warp") {
// The mouths are edges: shimmer the opening lines on both boards.
const w = view.board.warps.find((w) =>
w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction);
if (w) {
push({ kind: "portal", cell: w.from.cell, side: w.from.side });
push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150);
} else {
push({ kind: "portal-cell", at: e.from });
push({ kind: "portal-cell", at: e.to }, 150);
}
beat++;
}
break;
case "warpStepped":
// Dimensional warp tokens fill their squares; the veil wraps them.
push({ kind: "portal-cell", at: e.from });
push({ kind: "portal-cell", at: e.to }, 150);
beat++;
break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
case "died": {
const at = posOf(e.player);
if (at) { push({ kind: "soul", at }, 250); beat++; }
break;
}
case "gameWon": {
const home = view.players.find((p) => p.id === e.player)?.home;
if (home) {
push({ kind: "fireworks", at: home });
push({ kind: "fireworks", at: home }, 350);
push({ kind: "fireworks", at: home }, 700);
beat++;
}
break;
}
case "knockedBack":
case "shoved":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "objectDragged":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "jumpedPit":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "fellInPit":
push({ kind: "pit-fall", at: e.at });
beat++;
break;
case "climbedFromPit": {
if (e.success) {
const at = posOf(e.player);
if (at) { push({ kind: "dust-puff", at }); beat++; }
}
break;
}
case "slippedInOoze":
push({ kind: "ooze-slip", at: e.at });
beat++;
break;
case "steppedOnTacks":
push({ kind: "tacks-ow", at: e.at });
beat++;
break;
case "enteredThornbush":
push({ kind: "thorn-snap", at: e.at });
beat++;
break;
case "stuckInSlime":
push({ kind: "slime-stuck", at: e.at });
beat++;
break;
case "sectorRotated": {
const origin = view.board.placements[e.sectorIndex]?.origin;
if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise });
// Everything after the grind points at pre-move ground; stop here.
return out;
}
case "sectorRelocated": {
// The event records pre-normalization origins; the view holds the
// truth. Shift the recorded start by the same correction.
const trueTo = view.board.placements[e.sectorIndex]?.origin;
if (trueTo) {
const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y;
push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } });
}
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 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();
+3
View File
@@ -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) {