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>
This commit is contained in:
Eric Wagoner
2026-08-17 10:30:48 -04:00
co-authored by Claude Fable 5
parent a88cae3a0e
commit bcaa06251a
11 changed files with 474 additions and 169 deletions
+45 -8
View File
@@ -5,6 +5,11 @@
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>
@@ -16,18 +21,50 @@
</defs>
<g class="claw">
<!-- Three tapered gashes: wide at entry, pointed at exit -->
<path d={`M ${c.x - 12} ${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 - 3} ${c.y - 16} q 4 12 9 26 q -1 1 -2 0 q -6 -13 -10 -25 z`} fill={`url(#claw-cut-${uid})`} />
<path d={`M ${c.x + 6} ${c.y - 14} q 4 12 10 26 q -1 1 -2 0 q -7 -13 -11 -25 z`} fill={`url(#claw-cut-${uid})`} />
{#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: 0; transform: translateY(-7px); }
18% { opacity: 1; }
70% { opacity: 0.9; }
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>
+33 -20
View File
@@ -12,39 +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>
<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="#b5a88e" stop-opacity="0.85" />
<stop offset="0" stop-color="#a5977a" stop-opacity="0.95" />
<stop offset="0.75" stop-color="#8d8266" stop-opacity="0.55" />
<stop offset="1" stop-color="#8d8266" stop-opacity="0" />
</radialGradient>
</defs>
<circle cx={c.x - 6} cy={c.y} r="6" fill={`url(#dust-soft-${uid})`} class="dust" />
<circle cx={c.x + 5} cy={c.y - 3} r="5" fill={`url(#dust-soft-${uid})`} class="dust late" />
<circle cx={c.x} cy={c.y + 4} r="5.5" fill={`url(#dust-soft-${uid})`} class="dust later" />
<g class="grit">
<circle cx={c.x - 4} cy={c.y - 5} r="1" />
<circle cx={c.x + 6} cy={c.y + 2} r="1.2" />
<circle cx={c.x + 1} cy={c.y - 2} r="0.8" />
</g>
<style>
.dust {
.puff {
transform-box: fill-box;
transform-origin: center;
animation: drift 0.75s ease-out forwards;
animation: blow-a 0.8s ease-out forwards;
}
.dust.late { animation-delay: 0.09s; }
.dust.later { animation-delay: 0.17s; }
.grit circle { fill: #6e6450; animation: grit-fall 0.6s ease-in forwards; }
@keyframes drift {
0% { opacity: 0.9; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-12px) scale(2); }
.puff.back { animation-name: blow-b; }
.puff.late { animation-delay: 0.08s; }
.puff.later { animation-delay: 0.16s; }
.grit circle { fill: #5c523e; 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: 0.9; transform: translateY(-3px); }
100% { opacity: 0; transform: translateY(7px); }
0% { opacity: 1; transform: translateY(-3px); }
100% { opacity: 0; transform: translateY(8px); }
}
</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>
+37 -21
View File
@@ -9,46 +9,62 @@
{@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}
<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; }
.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 breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
@keyframes veil-in {
0% { opacity: 0; transform: scale(1.25); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(0.45); }
}
</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>
+39 -13
View File
@@ -1,7 +1,6 @@
<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));
@@ -15,30 +14,57 @@
</radialGradient>
</defs>
<circle cx={c.x} cy={c.y} r="13" fill={`url(#slime-goo-${uid})`} class="goo" />
<!-- 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" />
<circle cx={c.x - 5} cy={c.y + 2} r="1.8" class="bubble late" />
<style>
.goo {
transform-box: fill-box;
transform-origin: center;
animation: goo-close 0.9s ease-out forwards;
.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.2s forwards;
animation: bubble-pop 0.6s ease-out 0.25s forwards;
opacity: 0;
}
.bubble.late { animation-delay: 0.4s; }
@keyframes goo-close {
0% { opacity: 0.95; transform: scale(1.35); }
60% { opacity: 0.85; transform: scale(0.9); }
100% { opacity: 0; transform: scale(1); }
@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); }
+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>
+24 -25
View File
@@ -3,42 +3,41 @@
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props();
const uid = $props.id();
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>
<defs>
<linearGradient id={`streak-trail-${uid}`}
x1={a.x} y1={a.y} x2={b.x} y2={b.y} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#e9e1cb" stop-opacity="0" />
<stop offset="0.7" stop-color="#e9e1cb" stop-opacity="0.6" />
<stop offset="1" stop-color="#fffdf0" stop-opacity="0.95" />
</linearGradient>
</defs>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={`url(#streak-trail-${uid})`} class="trail" />
<circle cx={b.x} cy={b.y} r="5" class="head" />
<path {d} pathLength="100" class="trail wide" />
<path {d} pathLength="100" class="trail bright" />
<circle r="4.5" class="head" cx="0" cy="0">
<animateMotion dur="0.26s" fill="freeze" path={d} calcMode="spline"
keySplines="0.3 0 0.4 1" keyTimes="0;1" keyPoints="0;1" />
</circle>
<style>
.trail {
stroke-width: 6;
fill: none;
stroke-linecap: round;
animation: trail-fade 0.55s ease-out forwards;
stroke-dasharray: 100 100;
animation: draw-collapse 0.55s ease-out forwards;
}
.trail.wide { stroke: rgba(233, 225, 203, 0.4); stroke-width: 7; }
.trail.bright { stroke: rgba(255, 253, 240, 0.9); stroke-width: 3; }
.head {
transform-box: fill-box;
transform-origin: center;
fill: rgba(255, 253, 240, 0.9);
animation: head-land 0.45s ease-out forwards;
fill: #fffdf0;
animation: head-life 0.5s ease-out forwards;
}
@keyframes trail-fade {
0% { opacity: 0.95; }
/* Draw from source to head, then the tail chases it into the destination. */
@keyframes draw-collapse {
0% { stroke-dashoffset: 100; opacity: 0.95; }
47% { 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; }
}
@keyframes head-land {
0% { opacity: 0; transform: scale(0.4); }
35% { opacity: 1; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.9); }
}
</style>
+31 -16
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>
</g>
{#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="2.5" r="2.6" class="head" />
<path d="M -1.4 1 L 0 -5 L 1.4 1 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(3px); }
25% { opacity: 1; transform: translateY(-6px); }
55% { transform: translateY(-1px); }
75% { transform: translateY(-4px); }
100% { opacity: 0; transform: translateY(-1px); }
}
</style>
+39 -15
View File
@@ -1,32 +1,56 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
const c = $derived(center(fx.at));
</script>
<path class="thorn" style={`transform-origin: ${c.x}px ${c.y}px`}
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.75);
stroke: #2f5423;
stroke-width: 1.5;
animation: snap 0.55s ease-out forwards;
.stem {
fill: none;
stroke: #467832;
stroke-width: 2.5;
stroke-linecap: round;
}
.leaf { fill: #7fae55; animation: leaf-fall 0.7s ease-in 0.1s forwards; opacity: 0; }
.leaf.late { animation-delay: 0.18s; }
@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); }
+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 - 20} ${c.y - 8} q 14 -5 40 -3`} pathLength="100" class="streak a" />
<path d={`M ${c.x - 22} ${c.y + 1} q 16 3 42 0`} pathLength="100" class="streak b" />
<path d={`M ${c.x - 18} ${c.y + 9} q 12 5 38 4`} 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(190, 200, 210, 0.85);
stroke-width: 2.4;
stroke-linecap: round;
stroke-dasharray: 30 100;
stroke-dashoffset: 30;
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; }
.streak.c { animation-delay: 0.12s; stroke-width: 2; }
.whiff { animation: wobble 0.55s ease-in-out forwards; }
@keyframes sweep {
0% { stroke-dashoffset: 30; opacity: 0; }
20% { opacity: 0.9; }
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>