7 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
27 changed files with 1168 additions and 271 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. */
+9 -1
View File
@@ -21,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) {
@@ -949,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>
+28 -4
View File
@@ -1,22 +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>
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="absorb"
<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>
.absorb {
fill: #f6f0df;
.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(50deg); }
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>
+50 -13
View File
@@ -1,33 +1,70 @@
<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 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 pts: string[] = [`${a.x},${a.y}`];
const joints: { x: number; y: number }[] = [a];
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)}`);
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,
});
}
pts.push(`${b.x},${b.y}`);
return pts.join(" ");
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>
<polyline {points} class="bolt" />
<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 {
.bolt .wide {
fill: none;
stroke: #ffe94d;
stroke-width: 3;
stroke: #e6a812;
stroke-width: 5.5;
stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
animation: flicker 0.5s steps(2, jump-none) forwards;
opacity: 0.75;
}
@keyframes flicker {
.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; }
}
+48 -10
View File
@@ -1,26 +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>
<circle cx={c.x} cy={c.y} r="4" class="burst" />
<circle cx={c.x} cy={c.y} r="4" class="burst late" />
<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>
.burst {
.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: #ff8c1a;
stroke-width: 4;
animation: ring 0.5s ease-out 0.05s forwards;
stroke: #ffd27a;
stroke-width: 2.5;
animation: shockwave 0.45s ease-out 0.08s 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); }
.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>
+25 -9
View File
@@ -1,29 +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" />
<circle cx={c.x} cy={c.y} r="55" class="mid" />
<circle cx={c.x} cy={c.y} r="80" class="outer" />
<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>
.chaos circle {
.ring {
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; }
.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.6); }
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
100% { opacity: 0; transform: rotate(220deg) scale(1.4); }
}
</style>
+54 -10
View File
@@ -1,26 +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">
<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} />
{#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>
.claw line {
stroke: #b3372b;
stroke-width: 3;
stroke-linecap: round;
/* 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: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
0% { opacity: 1; transform: translate(2px, -3px); }
60% { opacity: 1; transform: translate(-2px, 2px); }
100% { opacity: 0; transform: translate(-3px, 3px); }
}
</style>
+45 -9
View File
@@ -1,7 +1,9 @@
<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);
@@ -10,18 +12,52 @@
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>
<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" />
<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>
.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); }
.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>
+88 -8
View File
@@ -1,22 +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>
<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}`} />
<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 {
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;
opacity: 0;
animation: fireball-fade 0.55s ease-in forwards;
}
@keyframes fireball-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
+50 -9
View File
@@ -1,26 +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" style={`transform-origin: ${c.x}px ${c.y}px`}>
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
<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 + 18 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
r="3" fill={COLORS[(deg / 45) % 4]}
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>
.fireworks { animation: boom 1.1s ease-out forwards; }
@keyframes boom {
0% { opacity: 0; transform: scale(0.1); }
.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(2.4) rotate(30deg); }
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>
+49 -7
View File
@@ -3,21 +3,63 @@
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
const DEBRIS = [40, 165, 285];
</script>
<circle cx={c.x} cy={c.y} r="10" class="hit" />
<!-- 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>
.hit {
.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.5;
animation: ring-small 0.45s ease-out forwards;
stroke-width: 3;
animation: ring-punch 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
}
@keyframes ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
.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>
+31 -9
View File
@@ -1,23 +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>
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="ooze" />
<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>
.ooze {
.splat {
transform-box: fill-box;
transform-origin: center;
fill: rgba(110, 160, 60, 0.55);
animation: wobble 0.7s ease-out forwards;
animation: splat-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); }
.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>
+49 -14
View File
@@ -1,29 +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>
<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" />
<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>
.pitfall {
.hole {
transform-box: fill-box;
transform-origin: center;
fill: rgba(30, 24, 16, 0.8);
animation: swallow 0.7s ease-in forwards;
animation: hole-open 0.75s ease-out 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); }
.faller {
fill: rgba(120, 100, 70, 0.85);
animation: fall-in 0.55s ease-in 0.06s forwards;
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
.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>
+76 -21
View File
@@ -2,6 +2,7 @@
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"}
@@ -9,46 +10,100 @@
{@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" />
<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" />
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>
.portal {
/* The threshold opens, light ripples along it, and it pinches shut. */
.curtain {
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;
animation: open-ripple-pinch 0.95s ease-in-out forwards;
}
.portal.glow {
.curtain.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;
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: rgba(155, 227, 224, 0.12);
fill: none;
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; }
.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;
}
@keyframes breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
.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>
+44 -6
View File
@@ -1,25 +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`} />
<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 {
fill: #ffe94d;
stroke: #b3372b;
stroke-width: 1.6;
}
.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>
+107 -25
View File
@@ -2,48 +2,130 @@
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"}
{@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`} />
<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}
{@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`} />
<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>
.spin, .slide {
.frame {
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 {
.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); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
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-ccw {
@keyframes grind-settle-ccw {
0% { opacity: 0; transform: rotate(90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
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-home {
@keyframes slide-settle {
0% { opacity: 0; transform: translate(0, 0); }
15% { opacity: 1; }
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
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>
+33 -7
View File
@@ -1,26 +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>
<circle cx={c.x} cy={c.y} r="16" class="shield" />
<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>
.shield {
.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: #c9a72a;
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
animation: pulse 0.7s ease-out forwards;
stroke: #fff3c4;
stroke-width: 2;
animation: rim-strike 0.7s ease-out forwards;
}
@keyframes pulse {
@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>
+18 -4
View File
@@ -1,15 +1,21 @@
<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" />
<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>
.shimmer {
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
@@ -17,9 +23,17 @@
stroke-width: 2.5;
animation: ring 0.6s ease-out forwards;
}
.shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
.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>
+60 -9
View File
@@ -2,22 +2,73 @@
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>
<circle cx={c.x} cy={c.y} r="12" class="slime" />
<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>
.slime {
.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: rgba(140, 200, 60, 0.5);
stroke: #7a9e2e;
stroke-width: 3;
animation: sink 0.9s ease-out forwards;
fill: none;
stroke: #c8e87a;
stroke-width: 1.5;
animation: bubble-pop 0.6s ease-out 0.25s forwards;
opacity: 0;
}
@keyframes sink {
0% { opacity: 0.9; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.7); }
@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>
+28 -10
View File
@@ -1,26 +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">
<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 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 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; }
.sway { animation: sway 0.65s ease-in-out 2; }
@keyframes ascend {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-34px); }
22% { opacity: 0.95; }
100% { opacity: 0; transform: translateY(-36px); }
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(6deg); }
}
</style>
+44 -10
View File
@@ -5,22 +5,56 @@
const c = $derived(center(fx.at));
</script>
<g class="sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
<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>
.sparkle path {
fill: #e8dfc6;
.glint.main path {
fill: #ffe084;
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); }
.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>
+43 -17
View File
@@ -1,33 +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>
<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" />
<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>
.splash {
.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: #4fa3e3;
stroke-width: 3.5;
animation: ring 0.55s ease-out forwards;
stroke: #7cc0ee;
stroke-width: 3.2;
animation: ring-out 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); }
.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 drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
@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>
+29 -9
View File
@@ -1,23 +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>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="streak" />
<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>
.streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
.trail {
fill: none;
stroke-linecap: round;
stroke-dasharray: 14 9;
animation: streak-fade 0.55s ease-out forwards;
stroke-dasharray: 100 100;
animation: draw-collapse 0.68s ease-out forwards;
}
@keyframes streak-fade {
0% { opacity: 0.9; stroke-dashoffset: 46; }
100% { opacity: 0; stroke-dashoffset: 0; }
.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>
+30 -15
View File
@@ -3,26 +3,41 @@
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>
<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>
{#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>
.tacks text {
font-size: 13px;
fill: #b3372b;
animation: hop 0.6s ease-out forwards;
.tack .head {
fill: #8a2f24;
stroke: #5c1c14;
stroke-width: 0.8;
}
.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); }
.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>
+48 -14
View File
@@ -5,22 +5,56 @@
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`} />
<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>
.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;
.stem {
fill: none;
stroke: #467832;
stroke-width: 2.5;
stroke-linecap: round;
}
@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); }
.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>
+52 -8
View File
@@ -1,22 +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>
<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}`} />
<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 {
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;
opacity: 0;
animation: wb-fade 0.55s ease-in forwards;
}
@keyframes wb-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
+26 -9
View File
@@ -5,17 +5,34 @@
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="8" class="whiff" />
<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>
.whiff {
transform-box: fill-box;
transform-origin: center;
fill: rgba(180, 180, 180, 0.4);
animation: drift 0.7s ease-out forwards;
.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;
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
.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>