Every flourish gets its own file, and a workshop to watch them in

The effects move out of Board.svelte into src/fx-sprites/ — one
component per flourish, markup and animation CSS together, so editing
the fireball means opening Fireball.svelte and nothing else. A
registry (index.ts) maps kind to sprite; Board renders through it.
The event mapping stays in fx.ts.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 09:27:32 -04:00
co-authored by Claude Fable 5
parent 0fcbff4dc6
commit a59ecfa615
31 changed files with 893 additions and 420 deletions
+5
View File
@@ -6,6 +6,7 @@
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
@@ -886,6 +887,9 @@
}
</script>
{#if new URLSearchParams(location.search).has("fx")}
<FxGallery />
{:else}
<div class="table">
<header class="masthead">
<span class="mast-title">Wiz-War</span>
@@ -1663,6 +1667,7 @@
</div>
{/if}
</div>
{/if}
<style>
:global(html, body) {
+5 -420
View File
@@ -2,6 +2,7 @@
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48;
const WALL = 7;
@@ -51,28 +52,7 @@
effects?: import("./fx").BoardFx[] | null;
} = $props();
const center = (c: { x: number; y: number }) => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
/** A lightning bolt's jagged path between two squares. */
function boltPoints(from: { x: number; y: number }, to: { x: number; y: number }): string {
const a = center(from), b = center(to);
const segs = 6;
const pts: string[] = [`${a.x},${a.y}`];
for (let i = 1; i < segs; i++) {
const t = i / segs;
const nx = a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16;
const ny = a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16;
pts.push(`${nx.toFixed(1)},${ny.toFixed(1)}`);
}
pts.push(`${b.x},${b.y}`);
return pts.join(" ");
}
function edgeMid(cell: { x: number; y: number }, side: string): { x: number; y: number } {
const c = center(cell);
if (side === "N") return { x: c.x, y: cell.y * CELL };
if (side === "S") return { x: c.x, y: (cell.y + 1) * CELL };
if (side === "W") return { x: cell.x * CELL, y: c.y };
return { x: (cell.x + 1) * CELL, y: c.y };
}
const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including
@@ -607,145 +587,11 @@
{/if}
{/each}
{/if}
<!-- spell flourishes: short-lived, purely cosmetic -->
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
{#if fx.kind === "fireball" || fx.kind === "waterbolt"}
{@const a = center(fx.from)}
{@const b = center(fx.to)}
<circle r={fx.kind === "fireball" ? 7 : 6} class={`fx-${fx.kind}`} cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
{:else if fx.kind === "bolt"}
<polyline points={boltPoints(fx.from, fx.to)} class="fx-bolt" />
{:else if fx.kind === "burst"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="4" class="fx-burst" />
<circle cx={c.x} cy={c.y} r="4" class="fx-burst late" />
{:else if fx.kind === "splash"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="4" class="fx-splash" />
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="fx-droplet" />
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="fx-droplet late" />
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="fx-droplet later" />
{:else if fx.kind === "shimmer"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer" />
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer late" />
{:else if fx.kind === "shield"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="16" class="fx-shield" />
{:else if fx.kind === "sparkle"}
{@const c = center(fx.at)}
<g class="fx-sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
</g>
{:else if fx.kind === "whiff"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="8" class="fx-whiff" />
{:else if fx.kind === "hit"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="10" class="fx-hit" />
{:else if fx.kind === "pow"}
{@const c = center(fx.at)}
<g class="fx-pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
</g>
{:else if fx.kind === "claw"}
{@const c = center(fx.at)}
<g class="fx-claw">
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
</g>
{:else if fx.kind === "absorb"}
{@const c = center(fx.at)}
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="fx-absorb"
style={`transform-origin: ${c.x}px ${c.y}px`} />
{:else if fx.kind === "portal"}
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
<line {x1} {y1} {x2} {y2} class="fx-portal glow" />
<line {x1} {y1} {x2} {y2} class="fx-portal" />
{:else if fx.kind === "portal-cell"}
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="fx-portal-veil" />
{:else if fx.kind === "streak"}
{@const a = center(fx.from)}
{@const b = center(fx.to)}
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="fx-streak" />
{:else if fx.kind === "soul"}
{@const c = center(fx.at)}
<g class="fx-soul">
<circle cx={c.x} cy={c.y - 4} r="8" />
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
</g>
{:else if fx.kind === "fireworks"}
{@const c = center(fx.at)}
<g class="fx-fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
<circle
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
r="3" fill={["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"][(deg / 45) % 4]}
/>
{/each}
</g>
{:else if fx.kind === "chaos-swirl"}
{@const c = center(fx.at)}
<g class="fx-chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" />
<circle cx={c.x} cy={c.y} r="55" class="mid" />
<circle cx={c.x} cy={c.y} r="80" class="outer" />
</g>
{:else if fx.kind === "pit-fall"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="10" class="fx-pitfall" />
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="fx-dust" />
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="fx-dust late" />
{:else if fx.kind === "ooze-slip"}
{@const c = center(fx.at)}
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="fx-ooze" />
{:else if fx.kind === "tacks-ow"}
{@const c = center(fx.at)}
<g class="fx-tacks">
<text x={c.x - 10} y={c.y - 4}></text>
<text x={c.x + 4} y={c.y - 10} class="late"></text>
<text x={c.x - 2} y={c.y + 6} class="later"></text>
</g>
{:else if fx.kind === "thorn-snap"}
{@const c = center(fx.at)}
<path class="fx-thorn"
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
{:else if fx.kind === "slime-stuck"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="12" class="fx-slime" />
{:else if fx.kind === "dust-puff"}
{@const c = center(fx.at)}
<circle cx={c.x - 6} cy={c.y} r="4" class="fx-dust" />
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="fx-dust late" />
<circle cx={c.x} cy={c.y + 4} r="3.5" class="fx-dust later" />
{:else if fx.kind === "sector-spin"}
{@const ox = fx.origin.x * CELL}
{@const oy = fx.origin.y * CELL}
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class={`fx-sector-spin ${fx.clockwise ? "cw" : "ccw"}`}
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
{:else if fx.kind === "sector-slide"}
{@const fxp = fx.from.x * CELL}
{@const fyp = fx.from.y * CELL}
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class="fx-sector-slide"
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
{:else if fx.kind === "edge-dust"}
{@const m = edgeMid(fx.cell, fx.side)}
<circle cx={m.x - 6} cy={m.y} r="4" class="fx-dust" />
<circle cx={m.x + 5} cy={m.y - 3} r="3" class="fx-dust late" />
<circle cx={m.x} cy={m.y + 4} r="3.5" class="fx-dust later" />
{/if}
{@const Sprite = FX_SPRITES[fx.kind]}
<Sprite fx={fx as never} />
{/each}
</g>
</svg>
@@ -893,268 +739,7 @@
}
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
/* --- spell flourishes ---------------------------------------------- */
.fx-layer { pointer-events: none; }
.fx-fireball {
fill: #ff8c1a;
stroke: #ffd27a;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
animation: fx-fade 0.55s ease-in forwards;
}
.fx-waterbolt {
fill: #3b8dd6;
stroke: #b9e2ff;
stroke-width: 2;
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
animation: fx-fade 0.55s ease-in forwards;
}
.fx-bolt {
fill: none;
stroke: #ffe94d;
stroke-width: 3;
stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
animation: fx-flicker 0.5s steps(2, jump-none) forwards;
}
.fx-burst {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ff8c1a;
stroke-width: 4;
animation: fx-ring 0.5s ease-out 0.05s forwards;
opacity: 0;
}
.fx-burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
.fx-splash {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #4fa3e3;
stroke-width: 3.5;
animation: fx-ring 0.55s ease-out forwards;
}
.fx-droplet { fill: #7cc0ee; animation: fx-drop 0.6s ease-out forwards; }
.fx-droplet.late { animation-delay: 0.08s; }
.fx-droplet.later { animation-delay: 0.15s; }
.fx-shimmer {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: fx-ring 0.6s ease-out forwards;
}
.fx-shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
.fx-shield {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c9a72a;
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
animation: fx-shield-pulse 0.7s ease-out forwards;
}
.fx-sparkle path {
fill: #e8dfc6;
stroke: #c9a72a;
stroke-width: 1;
animation: fx-fade 0.8s ease-in forwards;
}
.fx-sparkle { animation: fx-spin 0.8s linear forwards; }
.fx-whiff {
transform-box: fill-box;
transform-origin: center;
fill: rgba(180, 180, 180, 0.4);
animation: fx-drift 0.7s ease-out forwards;
}
.fx-hit {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
animation: fx-ring-small 0.45s ease-out forwards;
}
.fx-pow path {
fill: #ffe94d;
stroke: #b3372b;
stroke-width: 1.6;
}
.fx-pow { animation: fx-pow-hit 0.5s ease-out forwards; }
.fx-claw line {
stroke: #b3372b;
stroke-width: 3;
stroke-linecap: round;
}
.fx-claw { animation: fx-claw-rake 0.55s ease-out forwards; }
.fx-absorb {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1.5;
animation: fx-swallow 0.65s ease-in forwards;
}
.fx-portal {
stroke: #9be3e0;
stroke-width: 3;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: fx-portal-shimmer 0.95s ease-in-out forwards;
}
.fx-portal.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-width: 9;
stroke-dasharray: none;
filter: blur(2px);
animation: fx-portal-breathe 0.95s ease-in-out forwards;
}
.fx-portal-veil {
fill: rgba(155, 227, 224, 0.12);
stroke: #9be3e0;
stroke-width: 2.5;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
animation: fx-portal-shimmer 0.95s ease-in-out forwards;
}
@keyframes fx-portal-shimmer {
0% { opacity: 0; stroke-dashoffset: 0; }
20% { opacity: 1; }
80% { opacity: 0.85; }
100% { opacity: 0; stroke-dashoffset: 34; }
}
@keyframes fx-portal-breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
}
.fx-streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 14 9;
animation: fx-streak-fade 0.55s ease-out forwards;
}
@keyframes fx-streak-fade {
0% { opacity: 0.9; stroke-dashoffset: 46; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
.fx-soul circle {
fill: rgba(233, 228, 245, 0.8);
stroke: rgba(160, 150, 200, 0.6);
stroke-width: 1;
}
.fx-soul { animation: fx-ascend 1.3s ease-out forwards; }
@keyframes fx-ascend {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-34px); }
}
.fx-fireworks { animation: fx-fireworks-boom 1.1s ease-out forwards; }
@keyframes fx-fireworks-boom {
0% { opacity: 0; transform: scale(0.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
}
.fx-chaos circle {
fill: none;
stroke: #b48ae0;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.fx-chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.fx-chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.fx-chaos { animation: fx-chaos-spin 1.4s ease-in-out forwards; }
@keyframes fx-chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
}
.fx-pitfall {
transform-box: fill-box;
transform-origin: center;
fill: rgba(30, 24, 16, 0.8);
animation: fx-swallow 0.7s ease-in forwards;
}
.fx-ooze {
transform-box: fill-box;
transform-origin: center;
fill: rgba(110, 160, 60, 0.55);
animation: fx-ooze-wobble 0.7s ease-out forwards;
}
@keyframes fx-ooze-wobble {
0% { opacity: 0.9; transform: scaleX(0.6); }
35% { transform: scaleX(1.25); }
65% { transform: scaleX(0.9); }
100% { opacity: 0; transform: scaleX(1.1); }
}
.fx-tacks text {
font-size: 13px;
fill: #b3372b;
animation: fx-hop 0.6s ease-out forwards;
}
.fx-tacks .late { animation-delay: 0.1s; opacity: 0; }
.fx-tacks .later { animation-delay: 0.2s; opacity: 0; }
@keyframes fx-hop {
0% { opacity: 0; transform: translateY(2px); }
30% { opacity: 1; transform: translateY(-5px); }
60% { transform: translateY(-1px); }
100% { opacity: 0; transform: translateY(-8px); }
}
.fx-thorn {
fill: rgba(70, 120, 50, 0.7);
stroke: #2f5423;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: fx-pow-hit 0.55s ease-out forwards;
}
.fx-slime {
transform-box: fill-box;
transform-origin: center;
fill: rgba(140, 200, 60, 0.5);
stroke: #7a9e2e;
stroke-width: 3;
animation: fx-slime-sink 0.9s ease-out forwards;
}
@keyframes fx-slime-sink {
0% { opacity: 0.9; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.7); }
}
.fx-sector-spin {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
animation: fx-grind-cw 1.2s ease-in-out forwards;
}
.fx-sector-spin.ccw { animation-name: fx-grind-ccw; }
@keyframes fx-grind-cw {
0% { opacity: 0; transform: rotate(-90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes fx-grind-ccw {
0% { opacity: 0; transform: rotate(90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
.fx-sector-slide {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
animation: fx-slide-home 1.2s ease-in-out forwards;
}
@keyframes fx-slide-home {
0% { opacity: 0; transform: translate(0, 0); }
15% { opacity: 1; }
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
/* --- persistent ambiance: ongoing spells wear their look ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
// The flourish workshop: every effect sprite on a loop, labeled, against a
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx } from "./fx";
let tick = $state(0);
$effect(() => {
const t = setInterval(() => (tick += 1), 2200);
return () => clearInterval(t);
});
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([
{ kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
{ kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
{ kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } },
{ kind: "burst", at: mid },
{ kind: "splash", at: mid },
{ kind: "shimmer", at: mid },
{ kind: "shield", at: mid },
{ kind: "sparkle", at: mid },
{ kind: "whiff", at: mid },
{ kind: "hit", at: mid },
{ kind: "pow", at: mid },
{ kind: "claw", at: mid },
{ kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid },
{ kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
{ kind: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
{ kind: "pit-fall", at: mid },
{ kind: "ooze-slip", at: mid },
{ kind: "tacks-ow", at: mid },
{ kind: "thorn-snap", at: mid },
{ kind: "slime-stuck", at: mid },
{ kind: "dust-puff", at: mid },
{ kind: "edge-dust", cell: mid, side: "S" },
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
<div class="gallery">
<h1>The Flourish Workshop</h1>
<p class="sub">
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
and it hot-reloads here.
</p>
<div class="grid">
{#each SAMPLES as fx (fx.kind)}
{@const Sprite = FX_SPRITES[fx.kind]}
<figure>
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cx) => cx) as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite fx={fx as never} /></g>
{/key}
</svg>
<figcaption>{fx.kind}</figcaption>
</figure>
{/each}
</div>
</div>
<style>
.gallery {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.5rem;
}
h1 {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 1.2rem;
color: #e9e1cb;
margin: 0 0 0.2rem;
}
.sub { color: #8d8672; margin: 0 0 1.2rem; }
.sub code { color: #c9a72a; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
figure {
margin: 0;
background: #efe8d4;
border-radius: 4px;
padding: 0.5rem;
}
svg { width: 100%; display: block; }
.cell {
fill: #efe8d4;
stroke: rgba(95, 74, 51, 0.25);
stroke-width: 1;
}
.fx-layer { pointer-events: none; }
figcaption {
font-family: "Courier Prime", monospace;
font-size: 0.75rem;
color: #43331f;
text-align: center;
padding-top: 0.3rem;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"absorb"> } = $props();
const c = $derived(center(fx.at));
</script>
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="absorb"
style={`transform-origin: ${c.x}px ${c.y}px`} />
<style>
.absorb {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1.5;
animation: swallow 0.65s ease-in forwards;
}
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"bolt"> } = $props();
/** A fresh jagged path every strike. */
const points = $derived.by(() => {
const a = center(fx.from), b = center(fx.to);
const segs = 6;
const pts: string[] = [`${a.x},${a.y}`];
for (let i = 1; i < segs; i++) {
const t = i / segs;
pts.push(`${(a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16).toFixed(1)},${(a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16).toFixed(1)}`);
}
pts.push(`${b.x},${b.y}`);
return pts.join(" ");
});
</script>
<polyline {points} class="bolt" />
<style>
.bolt {
fill: none;
stroke: #ffe94d;
stroke-width: 3;
stroke-linejoin: round;
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
animation: flicker 0.5s steps(2, jump-none) forwards;
}
@keyframes flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"burst"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="4" class="burst" />
<circle cx={c.x} cy={c.y} r="4" class="burst late" />
<style>
.burst {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ff8c1a;
stroke-width: 4;
animation: ring 0.5s ease-out 0.05s forwards;
opacity: 0;
}
.burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" />
<circle cx={c.x} cy={c.y} r="55" class="mid" />
<circle cx={c.x} cy={c.y} r="80" class="outer" />
</g>
<style>
.chaos circle {
fill: none;
stroke: #b48ae0;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
@keyframes chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"claw"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="claw">
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
</g>
<style>
.claw line {
stroke: #b3372b;
stroke-width: 3;
stroke-linecap: round;
}
.claw { animation: rake 0.55s ease-out forwards; }
@keyframes rake {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
}
</style>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, center } from "./geom";
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
const c = $derived.by(() => {
if (fx.kind === "dust-puff") return center(fx.at);
const mid = center(fx.cell);
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
});
</script>
<circle cx={c.x - 6} cy={c.y} r="4" class="dust" />
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="dust late" />
<circle cx={c.x} cy={c.y + 4} r="3.5" class="dust later" />
<style>
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
.dust.late { animation-delay: 0.09s; }
.dust.later { animation-delay: 0.17s; }
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<circle r="7" class="fireball" cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
<style>
.fireball {
fill: #ff8c1a;
stroke: #ffd27a;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
animation: fade 0.55s ease-in forwards;
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireworks"> } = $props();
const c = $derived(center(fx.at));
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
</script>
<g class="fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
<circle
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
r="3" fill={COLORS[(deg / 45) % 4]}
/>
{/each}
</g>
<style>
.fireworks { animation: boom 1.1s ease-out forwards; }
@keyframes boom {
0% { opacity: 0; transform: scale(0.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="10" class="hit" />
<style>
.hit {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
animation: ring-small 0.45s ease-out forwards;
}
@keyframes ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
const c = $derived(center(fx.at));
</script>
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="ooze" />
<style>
.ooze {
transform-box: fill-box;
transform-origin: center;
fill: rgba(110, 160, 60, 0.55);
animation: wobble 0.7s ease-out forwards;
}
@keyframes wobble {
0% { opacity: 0.9; transform: scaleX(0.6); }
35% { transform: scaleX(1.25); }
65% { transform: scaleX(0.9); }
100% { opacity: 0; transform: scaleX(1.1); }
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="10" class="pitfall" />
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="dust" />
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="dust late" />
<style>
.pitfall {
transform-box: fill-box;
transform-origin: center;
fill: rgba(30, 24, 16, 0.8);
animation: swallow 0.7s ease-in forwards;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
.dust.late { animation-delay: 0.09s; }
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL } from "./geom";
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
</script>
{#if fx.kind === "portal"}
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
<line {x1} {y1} {x2} {y2} class="portal glow" />
<line {x1} {y1} {x2} {y2} class="portal" />
{:else}
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil" />
{/if}
<style>
.portal {
stroke: #9be3e0;
stroke-width: 3;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: shimmer 0.95s ease-in-out forwards;
}
.portal.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-width: 9;
stroke-dasharray: none;
filter: blur(2px);
animation: breathe 0.95s ease-in-out forwards;
}
.veil {
fill: rgba(155, 227, 224, 0.12);
stroke: #9be3e0;
stroke-width: 2.5;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
animation: shimmer 0.95s ease-in-out forwards;
}
@keyframes shimmer {
0% { opacity: 0; stroke-dashoffset: 0; }
20% { opacity: 1; }
80% { opacity: 0.85; }
100% { opacity: 0; stroke-dashoffset: 34; }
}
@keyframes breathe {
0% { opacity: 0; }
30% { opacity: 0.8; }
100% { opacity: 0; }
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pow"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
</g>
<style>
.pow path {
fill: #ffe94d;
stroke: #b3372b;
stroke-width: 1.6;
}
.pow { animation: pow-hit 0.5s ease-out forwards; }
@keyframes pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, SECTOR } from "./geom";
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
</script>
{#if fx.kind === "sector-spin"}
{@const ox = fx.origin.x * CELL}
{@const oy = fx.origin.y * CELL}
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class={`spin ${fx.clockwise ? "cw" : "ccw"}`}
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
{:else}
{@const fxp = fx.from.x * CELL}
{@const fyp = fx.from.y * CELL}
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class="slide"
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
{/if}
<style>
.spin, .slide {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
}
.spin { animation: grind-cw 1.2s ease-in-out forwards; }
.spin.ccw { animation-name: grind-ccw; }
.slide { animation: slide-home 1.2s ease-in-out forwards; }
@keyframes grind-cw {
0% { opacity: 0; transform: rotate(-90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes grind-ccw {
0% { opacity: 0; transform: rotate(90deg); }
15% { opacity: 1; }
85% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; }
}
@keyframes slide-home {
0% { opacity: 0; transform: translate(0, 0); }
15% { opacity: 1; }
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shield"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="16" class="shield" />
<style>
.shield {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c9a72a;
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
animation: pulse 0.7s ease-out forwards;
}
@keyframes pulse {
0% { opacity: 0; transform: scale(0.6); }
30% { opacity: 1; transform: scale(1.1); }
60% { transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.15); }
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shimmer"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="6" class="shimmer" />
<circle cx={c.x} cy={c.y} r="6" class="shimmer late" />
<style>
.shimmer {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: ring 0.6s ease-out forwards;
}
.shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="12" class="slime" />
<style>
.slime {
transform-box: fill-box;
transform-origin: center;
fill: rgba(140, 200, 60, 0.5);
stroke: #7a9e2e;
stroke-width: 3;
animation: sink 0.9s ease-out forwards;
}
@keyframes sink {
0% { opacity: 0.9; transform: scale(1.3); }
100% { opacity: 0; transform: scale(0.7); }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"soul"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="soul">
<circle cx={c.x} cy={c.y - 4} r="8" />
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
</g>
<style>
.soul circle {
fill: rgba(233, 228, 245, 0.8);
stroke: rgba(160, 150, 200, 0.6);
stroke-width: 1;
}
.soul { animation: ascend 1.3s ease-out forwards; }
@keyframes ascend {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-34px); }
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"sparkle"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
</g>
<style>
.sparkle path {
fill: #e8dfc6;
stroke: #c9a72a;
stroke-width: 1;
animation: fade 0.8s ease-in forwards;
}
.sparkle { animation: spin 0.8s linear forwards; }
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
30% { opacity: 1; }
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"splash"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="4" class="splash" />
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="droplet" />
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="droplet late" />
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="droplet later" />
<style>
.splash {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #4fa3e3;
stroke-width: 3.5;
animation: ring 0.55s ease-out forwards;
}
.droplet { fill: #7cc0ee; animation: drop 0.6s ease-out forwards; }
.droplet.late { animation-delay: 0.08s; }
.droplet.later { animation-delay: 0.15s; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="streak" />
<style>
.streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 14 9;
animation: streak-fade 0.55s ease-out forwards;
}
@keyframes streak-fade {
0% { opacity: 0.9; stroke-dashoffset: 46; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
</style>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="tacks">
<text x={c.x - 10} y={c.y - 4}></text>
<text x={c.x + 4} y={c.y - 10} class="late"></text>
<text x={c.x - 2} y={c.y + 6} class="later"></text>
</g>
<style>
.tacks text {
font-size: 13px;
fill: #b3372b;
animation: hop 0.6s ease-out forwards;
}
.tacks .late { animation-delay: 0.1s; opacity: 0; }
.tacks .later { animation-delay: 0.2s; opacity: 0; }
@keyframes hop {
0% { opacity: 0; transform: translateY(2px); }
30% { opacity: 1; transform: translateY(-5px); }
60% { transform: translateY(-1px); }
100% { opacity: 0; transform: translateY(-8px); }
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
const c = $derived(center(fx.at));
</script>
<path class="thorn"
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
<style>
.thorn {
fill: rgba(70, 120, 50, 0.7);
stroke: #2f5423;
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: snap 0.55s ease-out forwards;
}
@keyframes snap {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
</script>
<circle r="6" class="waterbolt" cx="0" cy="0">
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
<style>
.waterbolt {
fill: #3b8dd6;
stroke: #b9e2ff;
stroke-width: 2;
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
animation: fade 0.55s ease-in forwards;
}
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"whiff"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="8" class="whiff" />
<style>
.whiff {
transform-box: fill-box;
transform-origin: center;
fill: rgba(180, 180, 180, 0.4);
animation: drift 0.7s ease-out forwards;
}
@keyframes drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
</style>
+7
View File
@@ -0,0 +1,7 @@
// Shared geometry for effect sprites. CELL must match Board.svelte's grid.
export const CELL = 48;
export const SECTOR = 5;
export const center = (c: { x: number; y: number }) => ({
x: c.x * CELL + CELL / 2,
y: c.y * CELL + CELL / 2,
});
+61
View File
@@ -0,0 +1,61 @@
// One sprite component per effect. To change an effect's look, edit its
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
// register it here.
import type { Component } from "svelte";
import type { BoardFx } from "../fx";
import Absorb from "./Absorb.svelte";
import Bolt from "./Bolt.svelte";
import Burst from "./Burst.svelte";
import ChaosSwirl from "./ChaosSwirl.svelte";
import Claw from "./Claw.svelte";
import DustPuff from "./DustPuff.svelte";
import Fireball from "./Fireball.svelte";
import Fireworks from "./Fireworks.svelte";
import Hit from "./Hit.svelte";
import OozeSlip from "./OozeSlip.svelte";
import PitFall from "./PitFall.svelte";
import Portal from "./Portal.svelte";
import Pow from "./Pow.svelte";
import SectorGrind from "./SectorGrind.svelte";
import Shield from "./Shield.svelte";
import Shimmer from "./Shimmer.svelte";
import SlimeStuck from "./SlimeStuck.svelte";
import Soul from "./Soul.svelte";
import Sparkle from "./Sparkle.svelte";
import Splash from "./Splash.svelte";
import Streak from "./Streak.svelte";
import TacksOw from "./TacksOw.svelte";
import ThornSnap from "./ThornSnap.svelte";
import Waterbolt from "./Waterbolt.svelte";
import Whiff from "./Whiff.svelte";
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
fireball: Fireball,
waterbolt: Waterbolt,
bolt: Bolt,
burst: Burst,
splash: Splash,
shimmer: Shimmer,
shield: Shield,
sparkle: Sparkle,
whiff: Whiff,
hit: Hit,
pow: Pow,
claw: Claw,
absorb: Absorb,
portal: Portal,
"portal-cell": Portal,
streak: Streak,
soul: Soul,
fireworks: Fireworks,
"chaos-swirl": ChaosSwirl,
"pit-fall": PitFall,
"ooze-slip": OozeSlip,
"tacks-ow": TacksOw,
"thorn-snap": ThornSnap,
"slime-stuck": SlimeStuck,
"dust-puff": DustPuff,
"edge-dust": DustPuff,
"sector-spin": SectorGrind,
"sector-slide": SectorGrind,
} as never;
+2
View File
@@ -17,6 +17,8 @@ type FxShape =
| { kind: "sector-slide"; from: Cell; to: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
export type BoardFx = FxShape & { id: number };
/** The narrow type of one effect kind (for sprite components). */
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
let nextId = 1;