10 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 ca55f7d00b The fireworks get the stage before the victory modal takes it
Winning raised the modal on the same frame the fireworks lit,
covering them. The modal now waits behind the curtain for 2.4
seconds — three volleys over the winner's home, seen — then takes
the stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:40:11 -04:00
Eric WagonerandClaude Fable 5 c6d73550df The optional final polish: five refinements
The bolt deepens to saturated gold over a dark amber glow; the splash
throws a wider crown of five droplets over a broader pool; the
fireworks lose their protractor — uneven angles, lengths, sizes, and
staggered timing, as a real burst has; the portal-cell's void gains
turning swirl arms so the dark center reads as motion, not blur; the
streak narrows and softens so its peak frame no longer resembles a
drawn white line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:37:57 -04:00
Eric WagonerandClaude Fable 5 e713d69582 Round two of the outside eye: contrast, scale, and travel
The whiff darkens to slate and sweeps longer; the portal-cell opens
a dark void behind its collapsing rings — an opening in space, not a
selected square; the tacks grow half again and recoil harder; the
streak's reveal slows so the travel reads even in a sampled frame;
dust lifts its contrast a step without turning heavy. The sector
ghosts stay board-machinery on purpose — the reviewer allowed it,
and the maze grinding IS an operation, not a spell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:34:02 -04:00
Eric WagonerandClaude Fable 5 bcaa06251a The review lands: twelve sprites get their motion stories
The outside review's diagnosis was right — the weak sprites had one
element doing one motion. Now each tells its story: the whiff is
three air streaks sweeping past a dodger with a wobble; the hit is a
contact flash, a compressed ring punched outward, and flying debris;
the streak's head visibly travels, drawing a tapered trail that
collapses into the destination; the sparkle snaps white-hot between
counter-rotating glints and sheds motes; the tacks are drawn
silhouettes now, points up as a tack on the floor menaces, hopping in
sequence; the claw slashes reveal one by one along their length with
a bright leading edge while the whole strike rakes sideways.

The sector ghosts keep their dashed frames but earn motion arcs,
directional chevrons, corner dust, and a grinding shudder as they
settle. The portal opens wide, ripples along its length, and pinches
shut; cell mouths collapse rings of energy inward. The thornbush
springs actual branches with thorn spurs. The slime is an asymmetric
blob that grips, with elastic strands snapping toward the caught.
Dust gains contrast, varied grain, and blows perpendicular to the
wall that shed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:30:48 -04:00
Eric WagonerandClaude Fable 5 a88cae3a0e The pit reads as depth, not a dot
Its hole was a near-black disc that sat on the parchment like spilled
ink. Now an earthen gradient ellipse, offset toward the far wall,
with the far lip catching the light — a hole you look INTO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:19:28 -04:00
Eric WagonerandClaude Fable 5 308f1b6ff9 The fireball sets the bar, and the rest of the catalog rises to it
Eric's fireball lands as the house style — layered construction,
gradient core, glow halo, a directional tail riding rotate="auto" —
with its defs scoped per instance via $props.id(), so simultaneous
fireballs cannot steal each other's gradients. The rest of the
catalog is rebuilt in its image: the waterbolt becomes its cold
sibling (spray tail, no turbulence — one heavy filter stack is
enough); lightning grows a glowing core and branching forks; the
burst blooms through a fire gradient with a shockwave and flying
embers; the splash throws a crown of droplets; the shield raises a
gradient dome with a rim strike; the pow star flashes white and
radiates speed dashes; claws leave tapered gashes; the absorbed card
spirals in along a pull ring; the soul is a proper little ghost with
eyes, swaying as it rises; fireworks ride colored trails; Chaos
catches card slips in its vortex; the pit opens a gradient hole that
swallows a faller; ooze splats and flings blobs; the thornbush sheds
leaves; slime closes over and pops bubbles; masonry dust goes soft
with falling grit. Whiff, tacks, portal, and the sector ghosts keep
their simpler forms on purpose — restraint is part of the style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:16:01 -04:00
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 13ce8c442c The clockwork throws more spells and fewer fists
Punching was mostly deck arithmetic — nineteen castable damage cards
in a 196-card deck — but three leaks made it worse. The march was
burning the biggest NUMBER card on long walks, starving waterbolts
and lightning; the biggest number is now reserved whenever an attack
in hand wants it. Heave-Ho, priced for defense, was being cast
without a treasure to throw (refused, turn stumbled) — now never
cast. Illusionary Attack was cast without naming a spell to fake —
now it fakes a fireball, the best-selling lie. And STONE DEAD joins
the arsenal against anyone flashing displayed stones: number times
the visible count, cast when the arithmetic beats the alternatives.

Measured across thirty berserker mirrors: attack casts up a quarter,
the tier handicap sharper (25-5), no stalls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:18:32 -04:00
33 changed files with 1826 additions and 424 deletions
+34 -2
View File
@@ -383,6 +383,9 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
// Heave-Ho throws the treasure we are carrying: priced for defense,
// never worth casting.
if (c.cardId === "heave-ho") continue;
if (atk.sameSquare && !together) continue;
// A wand needs its charges set by a number on first use.
const isWand = c.cardId === "blaster-wand";
@@ -401,12 +404,31 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma
cmd: {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
}
}
// STONE DEAD: number times the stones the victim carries — the displayed
// ones are the visible floor of that count.
const sd = view.yourHand.find((c) => c.cardId === "stone-dead");
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
best = {
damage: dmg,
cmd: {
type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggest.instanceId],
},
};
}
}
return best?.cmd ?? null;
}
@@ -690,9 +712,19 @@ export function automatonCommand(
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
const numbers = numbersInHand(view);
const helper = numbers.find(
// The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
const helper = spendable.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
) ?? (path.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
if (helper) {
return { type: "playNumberForMovement", instanceId: helper.instanceId };
}
+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. */
+14 -1
View File
@@ -6,6 +6,7 @@
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";
@@ -20,6 +21,14 @@
let showHelp = $state(false);
/** The finished game whose victory fanfare has been dismissed. */
let victorySeen = $state(false);
/** The fireworks get the stage before the modal takes it. */
let victoryCurtain = $state(false);
$effect(() => {
if (view?.phase === "finished" && view.winner && !victoryCurtain) {
const t = setTimeout(() => (victoryCurtain = true), 2400);
return () => clearTimeout(t);
}
});
/** Quiet mode: skip the attack announcement modal (device preference). */
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
function setFanfare(announce: boolean) {
@@ -886,6 +895,9 @@
}
</script>
{#if new URLSearchParams(location.search).has("fx")}
<FxGallery />
{:else}
<div class="table">
<header class="masthead">
<span class="mast-title">Wiz-War</span>
@@ -945,7 +957,7 @@
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
{/if}
{#if view?.phase === "finished" && view.winner && !victorySeen}
{#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
<div class="attack-notice victory-notice">
<div class="victory-trophy">🏆</div>
@@ -1663,6 +1675,7 @@
</div>
{/if}
</div>
{/if}
<style>
:global(html, body) {
+5 -420
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;
@@ -51,28 +52,7 @@
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
@@ -607,145 +587,11 @@
{/if}
{/each}
{/if}
<!-- spell flourishes: short-lived, purely cosmetic -->
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<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 === "pow"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "claw"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "absorb"}
{@const c = center(fx.at)}
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="fx-absorb"
style={`transform-origin: ${c.x}px ${c.y}px`} />
{:else 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="fx-portal glow" />
<line {x1} {y1} {x2} {y2} class="fx-portal" />
{:else if fx.kind === "portal-cell"}
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="fx-portal-veil" />
{:else if fx.kind === "streak"}
{@const a = center(fx.from)}
{@const b = center(fx.to)}
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="fx-streak" />
{:else if fx.kind === "soul"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "fireworks"}
{@const c = center(fx.at)}
<g class="fx-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={["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"][(deg / 45) % 4]}
/>
{/each}
</g>
{:else if fx.kind === "chaos-swirl"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "pit-fall"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="10" class="fx-pitfall" />
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="fx-dust" />
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="fx-dust late" />
{:else if fx.kind === "ooze-slip"}
{@const c = center(fx.at)}
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="fx-ooze" />
{:else if fx.kind === "tacks-ow"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "thorn-snap"}
{@const c = center(fx.at)}
<path class="fx-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`} />
{:else if fx.kind === "slime-stuck"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="12" class="fx-slime" />
{:else if fx.kind === "dust-puff"}
{@const c = center(fx.at)}
<circle cx={c.x - 6} cy={c.y} r="4" class="fx-dust" />
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="fx-dust late" />
<circle cx={c.x} cy={c.y + 4} r="3.5" class="fx-dust later" />
{:else 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={`fx-sector-spin ${fx.clockwise ? "cw" : "ccw"}`}
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
{:else if fx.kind === "sector-slide"}
{@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="fx-sector-slide"
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
{: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}
{@const Sprite = FX_SPRITES[fx.kind]}
<Sprite fx={fx as never} />
{/each}
</g>
</svg>
@@ -893,268 +739,7 @@
}
.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-pow path {
fill: #ffe94d;
stroke: #b3372b;
stroke-width: 1.6;
}
.fx-pow { animation: fx-pow-hit 0.5s ease-out forwards; }
.fx-claw line {
stroke: #b3372b;
stroke-width: 3;
stroke-linecap: round;
}
.fx-claw { animation: fx-claw-rake 0.55s ease-out forwards; }
.fx-absorb {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1.5;
animation: fx-swallow 0.65s ease-in forwards;
}
.fx-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: fx-portal-shimmer 0.95s ease-in-out forwards;
}
.fx-portal.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-width: 9;
stroke-dasharray: none;
filter: blur(2px);
animation: fx-portal-breathe 0.95s ease-in-out forwards;
}
.fx-portal-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: fx-portal-shimmer 0.95s ease-in-out forwards;
}
@keyframes fx-portal-shimmer {
0% { opacity: 0; stroke-dashoffset: 0; }
20% { opacity: 1; }
80% { opacity: 0.85; }
100% { opacity: 0; stroke-dashoffset: 34; }
}
@keyframes fx-portal-breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
}
.fx-streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 14 9;
animation: fx-streak-fade 0.55s ease-out forwards;
}
@keyframes fx-streak-fade {
0% { opacity: 0.9; stroke-dashoffset: 46; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
.fx-soul circle {
fill: rgba(233, 228, 245, 0.8);
stroke: rgba(160, 150, 200, 0.6);
stroke-width: 1;
}
.fx-soul { animation: fx-ascend 1.3s ease-out forwards; }
@keyframes fx-ascend {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-34px); }
}
.fx-fireworks { animation: fx-fireworks-boom 1.1s ease-out forwards; }
@keyframes fx-fireworks-boom {
0% { opacity: 0; transform: scale(0.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
}
.fx-chaos circle {
fill: none;
stroke: #b48ae0;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.fx-chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.fx-chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.fx-chaos { animation: fx-chaos-spin 1.4s ease-in-out forwards; }
@keyframes fx-chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
}
.fx-pitfall {
transform-box: fill-box;
transform-origin: center;
fill: rgba(30, 24, 16, 0.8);
animation: fx-swallow 0.7s ease-in forwards;
}
.fx-ooze {
transform-box: fill-box;
transform-origin: center;
fill: rgba(110, 160, 60, 0.55);
animation: fx-ooze-wobble 0.7s ease-out forwards;
}
@keyframes fx-ooze-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); }
}
.fx-tacks text {
font-size: 13px;
fill: #b3372b;
animation: fx-hop 0.6s ease-out forwards;
}
.fx-tacks .late { animation-delay: 0.1s; opacity: 0; }
.fx-tacks .later { animation-delay: 0.2s; opacity: 0; }
@keyframes fx-hop {
0% { opacity: 0; transform: translateY(2px); }
30% { opacity: 1; transform: translateY(-5px); }
60% { transform: translateY(-1px); }
100% { opacity: 0; transform: translateY(-8px); }
}
.fx-thorn {
fill: rgba(70, 120, 50, 0.7);
stroke: #2f5423;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: fx-pow-hit 0.55s ease-out forwards;
}
.fx-slime {
transform-box: fill-box;
transform-origin: center;
fill: rgba(140, 200, 60, 0.5);
stroke: #7a9e2e;
stroke-width: 3;
animation: fx-slime-sink 0.9s ease-out forwards;
}
@keyframes fx-slime-sink {
0% { opacity: 0.9; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.7); }
}
.fx-sector-spin {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
animation: fx-grind-cw 1.2s ease-in-out forwards;
}
.fx-sector-spin.ccw { animation-name: fx-grind-ccw; }
@keyframes fx-grind-cw {
0% { opacity: 0; transform: rotate(-90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes fx-grind-ccw {
0% { opacity: 0; transform: rotate(90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
.fx-sector-slide {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
animation: fx-slide-home 1.2s ease-in-out forwards;
}
@keyframes fx-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)); }
}
/* --- persistent ambiance: ongoing spells wear their look ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
+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>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"absorb"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<linearGradient id={`absorb-shine-${uid}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fffdf4" />
<stop offset="0.5" stop-color="#f6f0df" />
<stop offset="1" stop-color="#d8ccb0" />
</linearGradient>
</defs>
<circle cx={c.x} cy={c.y} r="14" class="pull" />
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2"
fill={`url(#absorb-shine-${uid})`} class="card"
style={`transform-origin: ${c.x}px ${c.y}px`} />
<style>
.card {
stroke: #43331f;
stroke-width: 1.5;
animation: swallow 0.65s ease-in forwards;
}
.pull {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: rgba(180, 138, 224, 0.6);
stroke-width: 2;
stroke-dasharray: 5 6;
animation: pull-in 0.6s ease-in forwards;
}
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(140deg); }
}
@keyframes pull-in {
0% { opacity: 0.8; transform: scale(1.6) rotate(0deg); }
100% { opacity: 0; transform: scale(0.2) rotate(-90deg); }
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"bolt"> } = $props();
const uid = $props.id();
/** A fresh jagged path every strike, plus two forks off mid-joints. */
const geom = $derived.by(() => {
const a = center(fx.from), b = center(fx.to);
const segs = 6;
const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) {
const t = i / segs;
joints.push({
x: a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16,
y: a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16,
});
}
joints.push(b);
const points = joints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
const fork = (j: { x: number; y: number }) => {
const dx = (Math.random() - 0.5) * 26, dy = (Math.random() - 0.5) * 26;
return `M ${j.x} ${j.y} l ${dx / 2} ${dy / 2} l ${dx / 2 + (Math.random() - 0.5) * 8} ${dy / 2}`;
};
return { points, forks: [fork(joints[2]!), fork(joints[4]!)] };
});
</script>
<defs>
<filter id={`bolt-glow-${uid}`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g class="bolt" filter={`url(#bolt-glow-${uid})`}>
<polyline points={geom.points} class="wide" />
{#each geom.forks as d, i (i)}
<path {d} class="fork" />
{/each}
<polyline points={geom.points} class="core" />
</g>
<style>
.bolt .wide {
fill: none;
stroke: #e6a812;
stroke-width: 5.5;
stroke-linejoin: round;
opacity: 0.75;
}
.bolt .fork {
fill: none;
stroke: #fff6b0;
stroke-width: 1.6;
stroke-linejoin: round;
}
.bolt .core {
fill: none;
stroke: #fffdf0;
stroke-width: 2;
stroke-linejoin: round;
}
.bolt .fork { stroke: #f5d54a; }
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
@keyframes bolt-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"burst"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const EMBERS = [15, 80, 145, 210, 275, 340];
</script>
<defs>
<radialGradient id={`burst-fire-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.3" stop-color="#ffd34e" />
<stop offset="0.65" stop-color="#ff8c1a" stop-opacity="0.85" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0" />
</radialGradient>
</defs>
<circle cx={c.x} cy={c.y} r="16" fill={`url(#burst-fire-${uid})`} class="bloom" />
<circle cx={c.x} cy={c.y} r="6" class="shockwave" />
{#each EMBERS as deg (deg)}
<circle
cx={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
r={deg % 2 === 0 ? 2 : 1.5}
class="ember"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.bloom {
transform-box: fill-box;
transform-origin: center;
animation: bloom 0.5s ease-out forwards;
}
.shockwave {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ffd27a;
stroke-width: 2.5;
animation: shockwave 0.45s ease-out 0.08s forwards;
opacity: 0;
}
.ember {
fill: #ff9d19;
animation: ember-fly 0.55s ease-out forwards;
}
@keyframes bloom {
0% { opacity: 0; transform: scale(0.2); }
25% { opacity: 1; transform: scale(1.2); }
100% { opacity: 0; transform: scale(2.4); }
}
@keyframes shockwave {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(6); }
}
@keyframes ember-fly {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(2.1) rotate(24deg); }
}
</style>
@@ -0,0 +1,45 @@
<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));
const CARDS = [0, 72, 144, 216, 288];
</script>
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" class="ring inner" />
<circle cx={c.x} cy={c.y} r="55" class="ring mid" />
<circle cx={c.x} cy={c.y} r="80" class="ring outer" />
{#each CARDS as deg (deg)}
<rect
x={c.x + 46 * Math.cos((deg * Math.PI) / 180) - 5}
y={c.y + 46 * Math.sin((deg * Math.PI) / 180) - 7}
width="10" height="14" rx="1.5"
class="slip"
transform={`rotate(${deg + 25} ${c.x + 46 * Math.cos((deg * Math.PI) / 180)} ${c.y + 46 * Math.sin((deg * Math.PI) / 180)})`}
/>
{/each}
</g>
<style>
.ring {
fill: none;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.ring.inner { stroke: #b48ae0; }
.ring.mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.ring.outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.slip {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1;
}
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
@keyframes chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(220deg) scale(1.4); }
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"claw"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const GASHES = [
{ x: -12, cls: "g0" },
{ x: -3, cls: "g1" },
{ x: 6, cls: "g2" },
];
</script>
<defs>
<linearGradient id={`claw-cut-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0604f" />
<stop offset="0.5" stop-color="#b3372b" />
<stop offset="1" stop-color="#6e1a12" />
</linearGradient>
</defs>
<g class="claw">
{#each GASHES as g (g.cls)}
<g class={`gash ${g.cls}`} style={`transform-origin: ${c.x + g.x}px ${c.y - 15}px`}>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26 q -1 1 -2 0 q -7 -13 -11 -25 z`}
fill={`url(#claw-cut-${uid})`}
/>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26`}
class="edge" pathLength="100"
/>
</g>
{/each}
</g>
<style>
/* Each slash reveals downward along its own length, in sequence,
while the whole strike rakes a little sideways. */
.gash { opacity: 0; animation: gash-cut 0.4s ease-out forwards; }
.gash.g1 { animation-delay: 0.06s; }
.gash.g2 { animation-delay: 0.12s; }
.edge {
fill: none;
stroke: #ffd9c4;
stroke-width: 1.4;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
animation: edge-draw 0.28s ease-out forwards;
}
.gash.g1 .edge { animation-delay: 0.06s; }
.gash.g2 .edge { animation-delay: 0.12s; }
.claw { animation: rake 0.55s ease-out forwards; }
@keyframes gash-cut {
0% { opacity: 0; transform: scaleY(0.1); }
30% { opacity: 1; transform: scaleY(1.05); }
100% { opacity: 0.9; transform: scaleY(1); }
}
@keyframes edge-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 0.9; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes rake {
0% { opacity: 1; transform: translate(2px, -3px); }
60% { opacity: 1; transform: translate(-2px, 2px); }
100% { opacity: 0; transform: translate(-3px, 3px); }
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, center } from "./geom";
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
const uid = $props.id();
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 };
});
/** Edge dust blows perpendicular to the wall; loose dust just rises. */
const away = $derived.by(() => {
if (fx.kind !== "edge-dust") return { x: 0, y: -12 };
return fx.side === "N" || fx.side === "S" ? { x: 11, y: -4 } : { x: 4, y: -11 };
});
</script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
<g class="grit">
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
<circle cx={c.x + 1} cy={c.y - 2} r="0.9" />
</g>
</g>
<defs>
<radialGradient id={`dust-soft-${uid}`}>
<stop offset="0" stop-color="#96876a" stop-opacity="1" />
<stop offset="0.75" stop-color="#7d7358" stop-opacity="0.7" />
<stop offset="1" stop-color="#8d8266" stop-opacity="0" />
</radialGradient>
</defs>
<style>
.puff {
transform-box: fill-box;
transform-origin: center;
animation: blow-a 0.8s ease-out forwards;
}
.puff.back { animation-name: blow-b; }
.puff.late { animation-delay: 0.08s; }
.puff.later { animation-delay: 0.16s; }
.grit circle { fill: #4a4232; animation: grit-fall 0.6s ease-in forwards; }
@keyframes blow-a {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--ax), var(--ay)) scale(2); }
}
@keyframes blow-b {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--bx), var(--by)) scale(1.9); }
}
@keyframes grit-fall {
0% { opacity: 1; transform: translateY(-3px); }
100% { opacity: 0; transform: translateY(8px); }
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<defs>
<radialGradient id={`fireball-core-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.28" stop-color="#fff09a" />
<stop offset="0.62" stop-color="#ffad22" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0.15" />
</radialGradient>
<linearGradient id={`fireball-tail-${uid}`} x1="1" x2="0">
<stop offset="0" stop-color="#fff2a1" stop-opacity="0.95" />
<stop offset="0.2" stop-color="#ff9d16" stop-opacity="0.86" />
<stop offset="0.62" stop-color="#ee3907" stop-opacity="0.42" />
<stop offset="1" stop-color="#581002" stop-opacity="0" />
</linearGradient>
<filter id={`fireball-warp-${uid}`} x="-60%" y="-160%" width="230%" height="420%">
<feTurbulence type="fractalNoise" baseFrequency="0.022 0.12" numOctaves="3" seed="7" result="noise">
<animate
attributeName="baseFrequency"
values="0.022 0.12; 0.035 0.17; 0.018 0.1"
dur="0.22s"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="14" xChannelSelector="R" yChannelSelector="B" />
<feGaussianBlur stdDeviation="1.4" />
</filter>
<filter id={`fireball-glow-${uid}`} x="-180%" y="-180%" width="460%" height="460%">
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g class="fireball">
<g>
<!-- Outer orange glow -->
<ellipse cx="-4" cy="0" rx="27" ry="16" fill="#ff5b0a" opacity="0.32" filter={`url(#fireball-glow-${uid})`} />
<!-- Turbulent outer flame -->
<path
d="M -8 -13 C -30 -18, -52 -8, -91 -3 C -60 1, -68 14, -98 20 C -54 17, -28 11, -7 12 Z"
fill={`url(#fireball-tail-${uid})`}
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Hot inner flame -->
<path
d="M -6 -8 C -24 -10, -42 -4, -64 1 C -39 2, -32 8, -10 8 Z"
fill="#ffd34e"
opacity="0.74"
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Fireball body -->
<circle r="15" fill="#ff5c09" opacity="0.72" filter={`url(#fireball-glow-${uid})`} />
<circle r="12" fill={`url(#fireball-core-${uid})`} />
<!-- White-hot highlight -->
<ellipse cx="4" cy="-3" rx="6" ry="5" fill="#fffde7" opacity="0.95" />
<!-- Sparks -->
<g fill="#ff9d19">
<circle cx="-28" cy="-17" r="2.2">
<animate attributeName="cy" values="-17; -23; -17" dur="0.16s" repeatCount="indefinite" />
</circle>
<circle cx="-46" cy="12" r="1.6">
<animate attributeName="cy" values="12; 19; 12" dur="0.2s" repeatCount="indefinite" />
</circle>
<circle cx="-70" cy="-7" r="1.4">
<animate attributeName="opacity" values="1; 0.1; 1" dur="0.14s" repeatCount="indefinite" />
</circle>
</g>
</g>
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</g>
<style>
.fireball {
opacity: 0;
animation: fireball-fade 0.55s ease-in forwards;
}
@keyframes fireball-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,67 @@
<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"];
// Deliberately uneven: a real burst has no protractor.
const RAYS = [
{ deg: 8, len: 22, r: 2.8, cls: "" },
{ deg: 52, len: 17, r: 2.2, cls: "late" },
{ deg: 88, len: 24, r: 3, cls: "" },
{ deg: 141, len: 16, r: 2, cls: "later" },
{ deg: 176, len: 21, r: 2.6, cls: "late" },
{ deg: 224, len: 18, r: 2.3, cls: "" },
{ deg: 267, len: 23, r: 2.8, cls: "later" },
{ deg: 309, len: 15, r: 2, cls: "late" },
];
</script>
<g class="fireworks">
{#each RAYS as ray, i (ray.deg)}
{@const dx = Math.cos((ray.deg * Math.PI) / 180)}
{@const dy = Math.sin((ray.deg * Math.PI) / 180)}
<line
x1={c.x + 4 * dx} y1={c.y + 4 * dy}
x2={c.x + ray.len * dx} y2={c.y + ray.len * dy}
stroke={COLORS[i % 4]}
class={`trail ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<circle
cx={c.x + (ray.len + 1) * dx} cy={c.y + (ray.len + 1) * dy} r={ray.r}
fill={COLORS[i % 4]}
class={`spark ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<circle cx={c.x} cy={c.y} r="3.5" class="heart" />
</g>
<style>
.trail {
stroke-width: 2;
stroke-linecap: round;
animation: trail-out 0.9s ease-out forwards;
}
.spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(1.5); }
}
@keyframes spark-out {
0% { opacity: 0; transform: scale(0.15); }
30% { opacity: 1; }
80% { opacity: 0.9; transform: scale(1.55); }
100% { opacity: 0; transform: scale(1.6) translateY(4px); }
}
@keyframes heart-pop {
0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); }
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
const DEBRIS = [40, 165, 285];
</script>
<!-- Contact flash: an instant of white -->
<path
d={`M ${c.x} ${c.y - 9} l 2.5 6 6.5 -2 -3.5 5.5 5.5 3.5 -6.5 1 1 6.5 -5.5 -4.5 -4.5 5 0 -7 -6.5 0.5 5 -4.5 -4 -5 6.5 1.5 z`}
class="flash" style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<!-- Compressed impact ring: squashed, then out -->
<ellipse cx={c.x} cy={c.y} rx="9" ry="6" class="ring" />
{#each DEBRIS as deg, i (deg)}
<line
x1={c.x + 8 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 8 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
class={`debris d${i}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.flash {
fill: #fffdf0;
stroke: #c0392b;
stroke-width: 1;
animation: flash-snap 0.28s ease-out forwards;
}
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3;
animation: ring-punch 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
}
.debris {
stroke: #7c221a;
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: debris-fly 0.35s ease-out 0.05s forwards;
}
.debris.d1 { animation-delay: 0.08s; }
.debris.d2 { animation-delay: 0.11s; }
@keyframes flash-snap {
0% { opacity: 1; transform: scale(0.4); }
35% { opacity: 1; transform: scale(1.25); }
100% { opacity: 0; transform: scale(0.9); }
}
@keyframes ring-punch {
0% { opacity: 0.95; transform: scale(0.5, 0.35); }
45% { opacity: 0.9; transform: scale(1.6, 1.3); }
100% { opacity: 0; transform: scale(2.2, 1.9); }
}
@keyframes debris-fly {
0% { opacity: 0.9; transform: scale(0.8); }
100% { opacity: 0; transform: scale(1.7); }
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`ooze-splat-${uid}`}>
<stop offset="0" stop-color="#a5cc60" stop-opacity="0.8" />
<stop offset="0.7" stop-color="#6ea03c" stop-opacity="0.55" />
<stop offset="1" stop-color="#4a7024" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y + 8} rx="14" ry="5.5" fill={`url(#ooze-splat-${uid})`} class="splat" />
<g class="blobs">
<circle cx={c.x - 12} cy={c.y + 3} r="2.5" class="blob" />
<circle cx={c.x + 11} cy={c.y + 2} r="2" class="blob late" />
<circle cx={c.x + 3} cy={c.y - 3} r="1.7" class="blob later" />
</g>
<style>
.splat {
transform-box: fill-box;
transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards;
}
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
.blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble {
0% { opacity: 0.95; transform: scale(0.5, 1); }
35% { transform: scale(1.3, 0.85); }
65% { transform: scale(0.9, 1.05); }
100% { opacity: 0; transform: scale(1.15, 0.95); }
}
@keyframes blob-fly {
0% { opacity: 0.9; transform: translateY(0); }
50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); }
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`pit-hole-${uid}`} cy="0.42">
<stop offset="0" stop-color="#1a130a" />
<stop offset="0.55" stop-color="#3a2c1a" stop-opacity="0.9" />
<stop offset="0.85" stop-color="#5f4a33" stop-opacity="0.55" />
<stop offset="1" stop-color="#5f4a33" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y} rx="14" ry="11" fill={`url(#pit-hole-${uid})`} class="hole" />
<!-- The far lip catches the light: reads as depth, not a dot -->
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
<g class="rim-dust">
<circle cx={c.x - 11} cy={c.y - 6} r="3" class="dust" />
<circle cx={c.x + 10} cy={c.y - 7} r="2.5" class="dust late" />
<circle cx={c.x + 1} cy={c.y - 11} r="2" class="dust later" />
</g>
<style>
.hole {
transform-box: fill-box;
transform-origin: center;
animation: hole-open 0.75s ease-out forwards;
}
.faller {
fill: rgba(120, 100, 70, 0.85);
animation: fall-in 0.55s ease-in 0.06s forwards;
}
.lip {
fill: none;
stroke: rgba(233, 225, 203, 0.7);
stroke-width: 1.6;
stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
.dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; }
@keyframes hole-open {
0% { opacity: 0; transform: scale(0.3); }
25% { opacity: 1; transform: scale(1); }
80% { opacity: 1; }
100% { opacity: 0; transform: scale(1); }
}
@keyframes fall-in {
0% { opacity: 1; transform: scale(1) translateY(-6px); }
100% { opacity: 0; transform: scale(0.1) translateY(4px) rotate(60deg); }
}
@keyframes dust-drift {
0% { opacity: 0; transform: translateY(0) scale(1); }
30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL } from "./geom";
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
const uid = $props.id();
</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="curtain glow" />
<line {x1} {y1} {x2} {y2} class="curtain" pathLength="100" />
{:else}
<defs>
<radialGradient id={`portal-void-${uid}`}>
<stop offset="0" stop-color="#0d2430" stop-opacity="0.85" />
<stop offset="0.6" stop-color="#12414a" stop-opacity="0.5" />
<stop offset="1" stop-color="#9be3e0" stop-opacity="0.1" />
</radialGradient>
</defs>
<rect x={fx.at.x * CELL + 5} y={fx.at.y * CELL + 5}
width={CELL - 10} height={CELL - 10} rx="8"
fill={`url(#portal-void-${uid})`} class="void"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{@const vx = fx.at.x * CELL + CELL / 2}
{@const vy = fx.at.y * CELL + CELL / 2}
<g class="swirl" style={`transform-origin: ${vx}px ${vy}px`}>
<path d={`M ${vx + 12} ${vy} A 12 12 0 0 1 ${vx - 6} ${vy + 10}`} class="arm" />
<path d={`M ${vx - 12} ${vy} A 12 12 0 0 1 ${vx + 6} ${vy - 10}`} class="arm" />
<path d={`M ${vx} ${vy + 7} A 7 7 0 0 1 ${vx - 6} ${vy - 4}`} class="arm faint" />
</g>
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil outer"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil inner"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{/if}
<style>
/* The threshold opens, light ripples along it, and it pinches shut. */
.curtain {
stroke: #9be3e0;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: open-ripple-pinch 0.95s ease-in-out forwards;
}
.curtain.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-dasharray: none;
filter: blur(2px);
animation: glow-swell 0.95s ease-in-out forwards;
}
@keyframes open-ripple-pinch {
0% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 0; }
22% { opacity: 1; stroke-width: 6; }
60% { stroke-width: 4.5; stroke-dashoffset: 22; }
88% { opacity: 0.9; stroke-width: 1.5; stroke-dashoffset: 34; }
100% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 38; }
}
@keyframes glow-swell {
0% { opacity: 0; stroke-width: 2; }
25% { opacity: 0.85; stroke-width: 14; }
70% { opacity: 0.6; stroke-width: 9; }
100% { opacity: 0; stroke-width: 2; }
}
/* Cell mouths: rings of energy collapse inward through the token. */
.veil {
fill: none;
stroke: #9be3e0;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
}
.void { animation: void-open 0.9s ease-out forwards; }
.arm {
fill: none;
stroke: rgba(155, 227, 224, 0.7);
stroke-width: 1.8;
stroke-linecap: round;
}
.arm.faint { stroke: rgba(180, 138, 224, 0.55); }
.swirl { animation: swirl-turn 0.9s ease-in forwards; }
@keyframes swirl-turn {
0% { opacity: 0; transform: rotate(0deg) scale(1.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: rotate(160deg) scale(0.4); }
}
.veil.outer { stroke-width: 2.5; animation: veil-in 0.9s ease-in forwards; }
.veil.inner {
stroke: rgba(180, 138, 224, 0.8);
stroke-width: 2;
animation: veil-in 0.9s ease-in 0.2s forwards;
opacity: 0;
}
@keyframes void-open {
0% { opacity: 0; transform: scale(0.4); }
30% { opacity: 1; transform: scale(1); }
75% { opacity: 0.9; transform: scale(0.92); }
100% { opacity: 0; transform: scale(0.5); }
}
@keyframes veil-in {
0% { opacity: 0; transform: scale(1.25); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(0.45); }
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pow"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const DASHES = [20, 95, 160, 250, 320];
</script>
<defs>
<radialGradient id={`pow-star-${uid}`}>
<stop offset="0" stop-color="#fffdf0" />
<stop offset="0.45" stop-color="#ffe94d" />
<stop offset="1" stop-color="#ffb02e" />
</radialGradient>
</defs>
<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`}
fill={`url(#pow-star-${uid})`}
/>
<circle cx={c.x} cy={c.y} r="4" class="flash" />
</g>
{#each DASHES as deg (deg)}
<line
x1={c.x + 16 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 16 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 24 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 24 * Math.sin((deg * Math.PI) / 180)}
class="dash"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.pow path { stroke: #b3372b; stroke-width: 1.6; }
.pow .flash { fill: #ffffff; animation: flash 0.3s ease-out forwards; }
.pow { animation: pow-hit 0.5s ease-out forwards; }
.dash {
stroke: #b3372b;
stroke-width: 2.5;
stroke-linecap: round;
animation: dash-out 0.4s ease-out 0.06s forwards;
opacity: 0;
}
@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); }
}
@keyframes flash {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(3); }
}
@keyframes dash-out {
0% { opacity: 0; transform: scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: scale(1.3); }
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, SECTOR } from "./geom";
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
const S = SECTOR * CELL;
const origin = $derived(fx.kind === "sector-spin" ? fx.origin : fx.from);
const ox = $derived(origin.x * CELL);
const oy = $derived(origin.y * CELL);
const cx = $derived(ox + S / 2);
const cy = $derived(oy + S / 2);
/** Slide direction, unit-ish, for the chevron train. */
const dir = $derived.by(() => {
if (fx.kind !== "sector-slide") return { x: 0, y: 0 };
const dx = fx.to.x - fx.from.x, dy = fx.to.y - fx.from.y;
const m = Math.max(1, Math.hypot(dx, dy));
return { x: dx / m, y: dy / m };
});
const CORNERS = $derived([
{ x: ox + 6, y: oy + 6 }, { x: ox + S - 6, y: oy + 6 },
{ x: ox + 6, y: oy + S - 6 }, { x: ox + S - 6, y: oy + S - 6 },
]);
</script>
{#if fx.kind === "sector-spin"}
<g class={`grind ${fx.clockwise ? "cw" : "ccw"}`} style={`transform-origin: ${cx}px ${cy}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame" />
<!-- Curved motion arcs at the corners, pointing the way around -->
{#each [45, 135, 225, 315] as deg (deg)}
<path
d={`M ${cx + (S / 2 - 14) * Math.cos(((deg - 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg - 16) * Math.PI) / 180)}
A ${S / 2 - 14} ${S / 2 - 14} 0 0 ${fx.clockwise ? 1 : 0}
${cx + (S / 2 - 14) * Math.cos(((deg + 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg + 16) * Math.PI) / 180)}`}
class="arc"
/>
{/each}
</g>
{:else}
<g class="slide-group" style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame slide" />
<!-- Chevron train pointing along the journey -->
{#each [0.3, 0.5, 0.7] as t, i (t)}
<path
d={`M ${cx + dir.x * S * (t - 0.08) - dir.y * 9} ${cy + dir.y * S * (t - 0.08) - dir.x * 9}
L ${cx + dir.x * S * t} ${cy + dir.y * S * t}
L ${cx + dir.x * S * (t - 0.08) + dir.y * 9} ${cy + dir.y * S * (t - 0.08) + dir.x * 9}`}
class={`chevron c${i}`}
/>
{/each}
</g>
{/if}
<g class="corner-dust">
{#each CORNERS as p, i (i)}
<circle cx={p.x} cy={p.y} r="3.5" class={`dust d${i}`} />
{/each}
</g>
<style>
.frame {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
}
.arc {
fill: none;
stroke: #b3691f;
stroke-width: 2.5;
stroke-linecap: round;
}
.chevron {
fill: none;
stroke: #b3691f;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.chevron.c0 { animation: chev 0.5s ease-out 0.15s forwards; }
.chevron.c1 { animation: chev 0.5s ease-out 0.3s forwards; }
.chevron.c2 { animation: chev 0.5s ease-out 0.45s forwards; }
.grind { animation: grind-settle-cw 1.25s ease-in-out forwards; }
.grind.ccw { animation-name: grind-settle-ccw; }
.slide-group { animation: slide-settle 1.25s ease-in-out forwards; }
.dust {
fill: rgba(160, 150, 130, 0.85);
opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards;
}
.dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; }
.dust.d3 { animation-delay: 1s; }
@keyframes chev {
0% { opacity: 0; }
35% { opacity: 0.9; }
100% { opacity: 0; }
}
/* The quarter-turn, then a grinding shudder as it settles. */
@keyframes grind-settle-cw {
0% { opacity: 0; transform: rotate(-90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(0.6deg); }
80% { transform: rotate(-0.8deg); }
87% { transform: rotate(0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes grind-settle-ccw {
0% { opacity: 0; transform: rotate(90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(-0.6deg); }
80% { transform: rotate(0.8deg); }
87% { transform: rotate(-0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes slide-settle {
0% { opacity: 0; transform: translate(0, 0); }
12% { opacity: 1; }
72% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
80% { transform: translate(calc(var(--dx) - 2px), var(--dy)); }
88% { transform: translate(calc(var(--dx) + 1px), var(--dy)); }
94% { transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
@keyframes dust-kick {
0% { opacity: 0; transform: translateY(0) scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shield"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`shield-dome-${uid}`}>
<stop offset="0" stop-color="#ffe9a3" stop-opacity="0" />
<stop offset="0.75" stop-color="#ffdf7a" stop-opacity="0.25" />
<stop offset="1" stop-color="#c9a72a" stop-opacity="0.9" />
</radialGradient>
<filter id={`shield-glow-${uid}`} x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<circle cx={c.x} cy={c.y} r="16" fill={`url(#shield-dome-${uid})`} class="dome" filter={`url(#shield-glow-${uid})`} />
<circle cx={c.x} cy={c.y} r="16" class="rim" />
<style>
.dome {
transform-box: fill-box;
transform-origin: center;
animation: dome 0.7s ease-out forwards;
}
.rim {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #fff3c4;
stroke-width: 2;
animation: rim-strike 0.7s ease-out forwards;
}
@keyframes dome {
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 rim-strike {
0% { opacity: 0; transform: scale(0.6); }
28% { opacity: 1; transform: scale(1.12); }
45% { opacity: 0.4; }
58% { opacity: 0.9; transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.18); }
}
</style>
@@ -0,0 +1,39 @@
<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="ring" />
<circle cx={c.x} cy={c.y} r="6" class="ring late" />
<g class="motes">
<circle cx={c.x - 8} cy={c.y - 8} r="1.5" />
<circle cx={c.x + 9} cy={c.y - 4} r="1.2" class="late" />
<circle cx={c.x - 3} cy={c.y + 9} r="1.3" class="later" />
</g>
<style>
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: ring 0.6s ease-out forwards;
}
.ring.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
.motes circle { fill: #e2c8ff; animation: mote 0.7s ease-out forwards; }
.motes .late { animation-delay: 0.1s; opacity: 0; }
.motes .later { animation-delay: 0.18s; opacity: 0; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes mote {
0% { opacity: 0; transform: translateY(0); }
30% { opacity: 1; }
100% { opacity: 0; transform: translateY(-12px); }
}
</style>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`slime-goo-${uid}`}>
<stop offset="0" stop-color="#c8e87a" stop-opacity="0.4" />
<stop offset="0.8" stop-color="#8cc83c" stop-opacity="0.65" />
<stop offset="1" stop-color="#7a9e2e" stop-opacity="0.9" />
</radialGradient>
</defs>
<!-- An irregular blob, squashing as it grips -->
<path
class="goo" style={`transform-origin: ${c.x}px ${c.y}px`}
fill={`url(#slime-goo-${uid})`}
d={`M ${c.x - 13} ${c.y + 2}
C ${c.x - 14} ${c.y - 7}, ${c.x - 6} ${c.y - 12}, ${c.x + 2} ${c.y - 10}
C ${c.x + 10} ${c.y - 13}, ${c.x + 15} ${c.y - 4}, ${c.x + 12} ${c.y + 4}
C ${c.x + 14} ${c.y + 10}, ${c.x + 4} ${c.y + 12}, ${c.x - 3} ${c.y + 10}
C ${c.x - 10} ${c.y + 12}, ${c.x - 12} ${c.y + 8}, ${c.x - 13} ${c.y + 2} z`}
/>
<!-- Elastic strands stretching toward the caught -->
<g class="strands">
<path d={`M ${c.x - 11} ${c.y - 6} Q ${c.x - 6} ${c.y - 2} ${c.x - 2} ${c.y}`} class="strand" />
<path d={`M ${c.x + 12} ${c.y - 3} Q ${c.x + 6} ${c.y - 1} ${c.x + 2} ${c.y + 1}`} class="strand late" />
<path d={`M ${c.x + 2} ${c.y + 10} Q ${c.x + 1} ${c.y + 5} ${c.x} ${c.y + 1}`} class="strand later" />
</g>
<circle cx={c.x + 4} cy={c.y - 5} r="2.5" class="bubble" />
<style>
.goo { animation: goo-grip 0.9s ease-out forwards; }
.strand {
fill: none;
stroke: #8fbf4d;
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 20 40;
animation: strand-snap 0.55s ease-in 0.15s forwards;
opacity: 0;
}
.strand.late { animation-delay: 0.28s; }
.strand.later { animation-delay: 0.4s; }
.bubble {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c8e87a;
stroke-width: 1.5;
animation: bubble-pop 0.6s ease-out 0.25s forwards;
opacity: 0;
}
@keyframes goo-grip {
0% { opacity: 0.95; transform: scale(1.3, 1.1) rotate(3deg); }
40% { transform: scale(0.92, 1.06) rotate(-2deg); }
70% { transform: scale(1.04, 0.96) rotate(1deg); }
100% { opacity: 0; transform: scale(1) rotate(0deg); }
}
@keyframes strand-snap {
0% { opacity: 0; stroke-dashoffset: 18; }
30% { opacity: 0.9; stroke-dashoffset: 6; }
75% { opacity: 0.85; stroke-dashoffset: 0; }
100% { opacity: 0; stroke-dashoffset: -4; }
}
@keyframes bubble-pop {
0% { opacity: 0; transform: scale(0.4); }
50% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(1.8); }
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"soul"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`soul-body-${uid}`} cy="0.35">
<stop offset="0" stop-color="#f7f4ff" stop-opacity="0.95" />
<stop offset="0.7" stop-color="#d9d0f0" stop-opacity="0.75" />
<stop offset="1" stop-color="#a096c8" stop-opacity="0.35" />
</radialGradient>
</defs>
<g class="soul">
<g class="sway" style={`transform-origin: ${c.x}px ${c.y}px`}>
<!-- Rounded head, wavy hem -->
<path
d={`M ${c.x - 8} ${c.y + 6} C ${c.x - 9} ${c.y - 6}, ${c.x - 5} ${c.y - 11}, ${c.x} ${c.y - 11}
C ${c.x + 5} ${c.y - 11}, ${c.x + 9} ${c.y - 6}, ${c.x + 8} ${c.y + 6}
q -2 -2.5 -4 0 q -2 2.5 -4 0 q -2 -2.5 -4 0 q -2 2.5 -4 0 z`}
fill={`url(#soul-body-${uid})`}
/>
<circle cx={c.x - 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
<circle cx={c.x + 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
</g>
</g>
<style>
.soul { animation: ascend 1.3s ease-out forwards; }
.sway { animation: sway 0.65s ease-in-out 2; }
@keyframes ascend {
0% { opacity: 0; transform: translateY(0); }
22% { opacity: 0.95; }
100% { opacity: 0; transform: translateY(-36px); }
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(6deg); }
}
</style>
@@ -0,0 +1,60 @@
<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="glint main" 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>
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
</g>
<circle cx={c.x} cy={c.y} r="3" class="core" />
<g class="motes">
<circle cx={c.x - 10} cy={c.y - 9} r="1.3" />
<circle cx={c.x + 11} cy={c.y - 5} r="1.1" class="late" />
<circle cx={c.x + 2} cy={c.y + 11} r="1.2" class="later" />
</g>
<style>
.glint.main path {
fill: #ffe084;
stroke: #c9a72a;
stroke-width: 1;
}
.glint.counter path { fill: #fff6d8; opacity: 0.9; }
.glint.main { animation: spin-snap 0.7s ease-out forwards; }
.glint.counter { animation: counter-spin 0.7s ease-out forwards; }
.core {
transform-box: fill-box;
transform-origin: center;
fill: #ffffff;
animation: core-snap 0.45s ease-out forwards;
}
.motes circle { fill: #ffe084; animation: mote-fly 0.6s ease-out 0.12s forwards; opacity: 0; }
.motes .late { animation-delay: 0.18s; }
.motes .later { animation-delay: 0.24s; }
@keyframes spin-snap {
0% { opacity: 0; transform: rotate(-20deg) scale(0.4); }
30% { opacity: 1; transform: rotate(15deg) scale(1.2); }
55% { transform: rotate(30deg) scale(1); }
100% { opacity: 0; transform: rotate(55deg) scale(0.9); }
}
@keyframes counter-spin {
0% { opacity: 0; transform: rotate(45deg) scale(0.4); }
30% { opacity: 0.95; transform: rotate(20deg) scale(1.15); }
100% { opacity: 0; transform: rotate(-25deg) scale(0.85); }
}
@keyframes core-snap {
0% { opacity: 0; transform: scale(0.3); }
30% { opacity: 1; transform: scale(2); }
100% { opacity: 0; transform: scale(0.6); }
}
@keyframes mote-fly {
0% { opacity: 0; transform: translateY(0) scale(1); }
30% { opacity: 0.95; }
100% { opacity: 0; transform: translateY(-8px) scale(0.6); }
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"splash"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`splash-pool-${uid}`}>
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.7" />
<stop offset="0.7" stop-color="#4fa3e3" stop-opacity="0.4" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y + 5} rx="16" ry="6.5" fill={`url(#splash-pool-${uid})`} class="pool" />
<circle cx={c.x} cy={c.y} r="7" class="ring" />
<!-- The crown: droplets thrown upward, falling back -->
<g class="crown">
<circle cx={c.x - 14} cy={c.y - 6} r="2.8" class="drop" />
<circle cx={c.x - 7} cy={c.y - 13} r="2.3" class="drop late" />
<circle cx={c.x + 1} cy={c.y - 16} r="2.5" class="drop" />
<circle cx={c.x + 8} cy={c.y - 12} r="2.2" class="drop later" />
<circle cx={c.x + 14} cy={c.y - 5} r="1.9" class="drop late" />
</g>
<style>
.pool {
transform-box: fill-box;
transform-origin: center;
animation: pool-spread 0.6s ease-out forwards;
}
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #7cc0ee;
stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards;
}
.drop { fill: #7cc0ee; animation: drop-arc 0.6s ease-in forwards; }
.drop.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; }
@keyframes pool-spread {
0% { opacity: 0.9; transform: scale(0.4); }
100% { opacity: 0; transform: scale(2.2); }
}
@keyframes ring-out {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(4); }
}
@keyframes drop-arc {
0% { opacity: 0.95; transform: translateY(0); }
45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); }
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<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));
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
</script>
<path {d} pathLength="100" class="trail wide" />
<path {d} pathLength="100" class="trail bright" />
<circle r="4" class="head" cx="0" cy="0">
<animateMotion dur="0.38s" fill="freeze" path={d} calcMode="spline"
keySplines="0.3 0 0.4 1" keyTimes="0;1" keyPoints="0;1" />
</circle>
<style>
.trail {
fill: none;
stroke-linecap: round;
stroke-dasharray: 100 100;
animation: draw-collapse 0.68s ease-out forwards;
}
.trail.wide { stroke: rgba(233, 225, 203, 0.3); stroke-width: 5; }
.trail.bright { stroke: rgba(255, 253, 240, 0.75); stroke-width: 2; }
.head {
fill: #fffdf0;
animation: head-life 0.6s ease-out forwards;
}
/* Draw from source to head, then the tail chases it into the destination. */
@keyframes draw-collapse {
0% { stroke-dashoffset: 100; opacity: 0.95; }
56% { stroke-dashoffset: 0; opacity: 0.95; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
@keyframes head-life {
0% { opacity: 0; }
12% { opacity: 1; }
60% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,43 @@
<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));
// Each tack: head disc + spike, at its own angle and beat.
const TACKS = [
{ x: -10, y: -4, rot: -18, cls: "" },
{ x: 5, y: -9, rot: 12, cls: "late" },
{ x: -1, y: 5, rot: -5, cls: "later" },
];
</script>
{#each TACKS as t (t.cls)}
<g transform={`translate(${c.x + t.x} ${c.y + t.y}) rotate(${t.rot})`}>
<g class={`tack ${t.cls}`}>
<circle cx="0" cy="3.8" r="3.9" class="head" />
<path d="M -2.1 1.5 L 0 -7.5 L 2.1 1.5 z" class="spike" />
</g>
</g>
{/each}
<style>
.tack .head {
fill: #8a2f24;
stroke: #5c1c14;
stroke-width: 0.8;
}
.tack .spike { fill: #b9b3a4; stroke: #6e6450; stroke-width: 0.5; }
.tack {
opacity: 0;
animation: tack-hop 0.55s ease-out forwards;
}
.tack.late { animation-delay: 0.1s; }
.tack.later { animation-delay: 0.2s; }
@keyframes tack-hop {
0% { opacity: 0; transform: translateY(4px); }
22% { opacity: 1; transform: translateY(-10px); }
50% { transform: translateY(-2px); }
72% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(-2px); }
}
</style>
@@ -0,0 +1,60 @@
<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>
<g class="bush" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<!-- Three springing branches, thorn spurs along each -->
<g class="branch b0" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q -8 -10 -13 -20`} class="stem" />
<path d={`M ${c.x - 6} ${c.y + 1} l -4 -1 M ${c.x - 10} ${c.y - 6} l -4 0`} class="spur" />
</g>
<g class="branch b1" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q 1 -13 -1 -24`} class="stem" />
<path d={`M ${c.x} ${c.y - 2} l 4 -2 M ${c.x - 1} ${c.y - 9} l -4 -2`} class="spur" />
</g>
<g class="branch b2" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q 9 -9 15 -18`} class="stem" />
<path d={`M ${c.x + 7} ${c.y + 2} l 4 -1 M ${c.x + 12} ${c.y - 4} l 4 1`} class="spur" />
</g>
</g>
<g class="leaves">
<ellipse cx={c.x - 12} cy={c.y - 10} rx="3" ry="1.6" class="leaf" transform={`rotate(-30 ${c.x - 12} ${c.y - 10})`} />
<ellipse cx={c.x + 13} cy={c.y - 8} rx="2.6" ry="1.4" class="leaf late" transform={`rotate(40 ${c.x + 13} ${c.y - 8})`} />
</g>
<style>
.stem {
fill: none;
stroke: #467832;
stroke-width: 2.5;
stroke-linecap: round;
}
.spur {
stroke: #2f5423;
stroke-width: 1.6;
stroke-linecap: round;
}
.branch { animation: spring 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) forwards; opacity: 0; }
.branch.b1 { animation-delay: 0.05s; }
.branch.b2 { animation-delay: 0.1s; }
.bush { animation: bush-fade 0.7s ease-out forwards; }
.leaf { fill: #7fae55; animation: leaf-fall 0.7s ease-in 0.15s forwards; opacity: 0; }
.leaf.late { animation-delay: 0.24s; }
@keyframes spring {
0% { opacity: 0; transform: scaleY(0.15); }
60% { opacity: 1; transform: scaleY(1.12); }
100% { opacity: 1; transform: scaleY(1); }
}
@keyframes bush-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes leaf-fall {
0% { opacity: 0; transform: translateY(0) rotate(0deg); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(10px) rotate(50deg); }
}
</style>
@@ -0,0 +1,66 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<defs>
<radialGradient id={`wb-core-${uid}`}>
<stop offset="0" stop-color="#eafaff" />
<stop offset="0.35" stop-color="#9fd9f7" />
<stop offset="0.7" stop-color="#3b8dd6" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0.2" />
</radialGradient>
<linearGradient id={`wb-tail-${uid}`} x1="1" x2="0">
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.9" />
<stop offset="0.4" stop-color="#4fa3e3" stop-opacity="0.55" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
</linearGradient>
<filter id={`wb-glow-${uid}`} x="-160%" y="-160%" width="420%" height="420%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g class="waterbolt">
<g>
<!-- Spray tail -->
<path
d="M -5 -9 C -22 -12, -44 -6, -70 -2 C -46 0, -50 8, -74 13 C -44 11, -22 8, -5 9 Z"
fill={`url(#wb-tail-${uid})`}
/>
<!-- Body and core -->
<circle r="11" fill="#3b8dd6" opacity="0.6" filter={`url(#wb-glow-${uid})`} />
<circle r="9" fill={`url(#wb-core-${uid})`} />
<ellipse cx="3" cy="-2.5" rx="4" ry="3" fill="#f2fcff" opacity="0.9" />
<!-- Trailing droplets -->
<g fill="#7cc0ee">
<circle cx="-24" cy="-10" r="2">
<animate attributeName="cy" values="-10; -15; -10" dur="0.18s" repeatCount="indefinite" />
</circle>
<circle cx="-40" cy="9" r="1.6">
<animate attributeName="cy" values="9; 14; 9" dur="0.22s" repeatCount="indefinite" />
</circle>
<circle cx="-56" cy="-4" r="1.3">
<animate attributeName="opacity" values="1; 0.2; 1" dur="0.16s" repeatCount="indefinite" />
</circle>
</g>
</g>
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</g>
<style>
.waterbolt {
opacity: 0;
animation: wb-fade 0.55s ease-in forwards;
}
@keyframes wb-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<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>
<g class="whiff" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x - 30} ${c.y - 9} q 20 -7 58 -4`} pathLength="100" class="streak a" />
<path d={`M ${c.x - 32} ${c.y + 1} q 24 4 62 0`} pathLength="100" class="streak b" />
<path d={`M ${c.x - 28} ${c.y + 10} q 18 7 56 5`} pathLength="100" class="streak c" />
</g>
<style>
.streak {
fill: none;
stroke: rgba(110, 125, 140, 0.9);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 38 100;
stroke-dashoffset: 38;
animation: sweep 0.5s ease-out forwards;
}
.streak.b { animation-delay: 0.06s; stroke-width: 3.6; }
.streak.c { animation-delay: 0.12s; stroke-width: 2.6; }
.whiff { animation: wobble 0.55s ease-in-out forwards; }
@keyframes sweep {
0% { stroke-dashoffset: 38; opacity: 0; }
18% { opacity: 0.95; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
@keyframes wobble {
0% { transform: rotate(0deg); }
40% { transform: rotate(-4deg); }
75% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
</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;
+2
View File
@@ -17,6 +17,8 @@ type FxShape =
| { 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;