Compare commits
14
Commits
13ce8c442c
...
fx-polish
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca55f7d00b | ||
|
|
c6d73550df | ||
|
|
e713d69582 | ||
|
|
bcaa06251a | ||
|
|
a88cae3a0e | ||
|
|
308f1b6ff9 | ||
|
|
fe0318b99b | ||
|
|
a59ecfa615 | ||
|
|
0fcbff4dc6 | ||
|
|
a82b25b0e8 | ||
|
|
68b0da6301 | ||
|
|
6da350fd46 | ||
|
|
70d7b4babb | ||
|
|
bca34ae9e4 |
@@ -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
|
* The clockwork plays at a watchable pace: one command every beat, each
|
||||||
* broadcast as it lands, until the maze wants a human again.
|
* 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,
|
/** 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. */
|
* its own suffering when somebody else did. Null: nothing worth a word. */
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
import Replay from "./Replay.svelte";
|
import Replay from "./Replay.svelte";
|
||||||
|
import FxGallery from "./FxGallery.svelte";
|
||||||
|
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||||
import { local } from "./local.svelte";
|
import { local } from "./local.svelte";
|
||||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||||
import type { CardInstance, Side } from "@wizwar/engine";
|
import type { CardInstance, Side } from "@wizwar/engine";
|
||||||
@@ -19,6 +21,14 @@
|
|||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
/** The finished game whose victory fanfare has been dismissed. */
|
/** The finished game whose victory fanfare has been dismissed. */
|
||||||
let victorySeen = $state(false);
|
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). */
|
/** Quiet mode: skip the attack announcement modal (device preference). */
|
||||||
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
|
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
|
||||||
function setFanfare(announce: boolean) {
|
function setFanfare(announce: boolean) {
|
||||||
@@ -27,6 +37,22 @@
|
|||||||
}
|
}
|
||||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||||
let attackNoticeSeen = $state<string | null>(null);
|
let attackNoticeSeen = $state<string | null>(null);
|
||||||
|
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||||
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
|
function playFx(events: Parameters<typeof fxForEvents>[0]) {
|
||||||
|
if (!view) return;
|
||||||
|
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
boardFx = [...boardFx, fx];
|
||||||
|
setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind));
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
net.onFx = playFx;
|
||||||
|
local.onFx = playFx;
|
||||||
|
return () => { net.onFx = null; local.onFx = null; };
|
||||||
|
});
|
||||||
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
||||||
function dismissRolls() {
|
function dismissRolls() {
|
||||||
net.openingRolls = null;
|
net.openingRolls = null;
|
||||||
@@ -869,6 +895,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if new URLSearchParams(location.search).has("fx")}
|
||||||
|
<FxGallery />
|
||||||
|
{:else}
|
||||||
<div class="table">
|
<div class="table">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<span class="mast-title">Wiz-War</span>
|
<span class="mast-title">Wiz-War</span>
|
||||||
@@ -928,7 +957,7 @@
|
|||||||
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
|
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
|
||||||
{/if}
|
{/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="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
|
||||||
<div class="attack-notice victory-notice">
|
<div class="attack-notice victory-notice">
|
||||||
<div class="victory-trophy">🏆</div>
|
<div class="victory-trophy">🏆</div>
|
||||||
@@ -1251,6 +1280,7 @@
|
|||||||
onWarpClick={clickWarp}
|
onWarpClick={clickWarp}
|
||||||
markedCell={tradeFrom ?? pendingTeleport}
|
markedCell={tradeFrom ?? pendingTeleport}
|
||||||
markedCells={trapCells.length > 0 ? trapCells : null}
|
markedCells={trapCells.length > 0 ? trapCells : null}
|
||||||
|
effects={boardFx}
|
||||||
markedSector={pendingSectorOrigin}
|
markedSector={pendingSectorOrigin}
|
||||||
ghostSlots={relocateGhosts}
|
ghostSlots={relocateGhosts}
|
||||||
onGhostClick={clickGhostSlot}
|
onGhostClick={clickGhostSlot}
|
||||||
@@ -1645,6 +1675,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(html, body) {
|
:global(html, body) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
import type { Side } from "@wizwar/engine";
|
import type { Side } from "@wizwar/engine";
|
||||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||||
|
import { FX_SPRITES } from "./fx-sprites";
|
||||||
|
|
||||||
const CELL = 48;
|
const CELL = 48;
|
||||||
const WALL = 7;
|
const WALL = 7;
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
markedSector = null,
|
markedSector = null,
|
||||||
ghostSlots = null,
|
ghostSlots = null,
|
||||||
onGhostClick,
|
onGhostClick,
|
||||||
|
effects = null,
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
edgeSelectMode?: boolean;
|
edgeSelectMode?: boolean;
|
||||||
@@ -46,8 +48,12 @@
|
|||||||
* beyond the maze, since an empty slot has no floor of its own to click. */
|
* beyond the maze, since an empty slot has no floor of its own to click. */
|
||||||
ghostSlots?: { x: number; y: number }[] | null;
|
ghostSlots?: { x: number; y: number }[] | null;
|
||||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||||
|
/** Short-lived spell flourishes; purely cosmetic. */
|
||||||
|
effects?: import("./fx").BoardFx[] | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const SECTOR = 5;
|
const SECTOR = 5;
|
||||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||||
* negative coordinates (the maze renormalizes after the landing). The
|
* negative coordinates (the maze renormalizes after the landing). The
|
||||||
@@ -100,6 +106,12 @@
|
|||||||
if (cardId.endsWith("-wand")) return "magic-wand";
|
if (cardId.endsWith("-wand")) return "magic-wand";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
|
||||||
|
// stone-gray under Medusa's gaze.
|
||||||
|
function hasSpell(id: string, cardId: string): boolean {
|
||||||
|
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
|
||||||
|
}
|
||||||
|
|
||||||
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
||||||
function wizardScale(id: string): number {
|
function wizardScale(id: string): number {
|
||||||
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
||||||
@@ -478,12 +490,21 @@
|
|||||||
width={half * 2} height={half * 2}
|
width={half * 2} height={half * 2}
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
class="token-art hit"
|
class="token-art hit"
|
||||||
|
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
|
||||||
|
class:stone-gazed={hasSpell(p.id, "medusa")}
|
||||||
/>
|
/>
|
||||||
<rect
|
<rect
|
||||||
x={cx - half} y={cy - half}
|
x={cx - half} y={cy - half}
|
||||||
width={half * 2} height={half * 2}
|
width={half * 2} height={half * 2}
|
||||||
class="wizard-ring" stroke={playerColor(p.id)}
|
class="wizard-ring" stroke={playerColor(p.id)}
|
||||||
/>
|
/>
|
||||||
|
{#if hasSpell(p.id, "sticky-web")}
|
||||||
|
<g class="webbing">
|
||||||
|
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
|
||||||
|
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
|
||||||
|
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
|
||||||
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
|
||||||
@@ -566,6 +587,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||||
|
<g class="fx-layer" aria-hidden="true">
|
||||||
|
{#each effects ?? [] as fx (fx.id)}
|
||||||
|
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||||
|
<Sprite fx={fx as never} />
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -612,6 +640,11 @@
|
|||||||
fill: #d0342c;
|
fill: #d0342c;
|
||||||
stroke: #7c1a14;
|
stroke: #7c1a14;
|
||||||
stroke-width: 1.2;
|
stroke-width: 1.2;
|
||||||
|
animation: firewall-lick 1.1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes firewall-lick {
|
||||||
|
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
||||||
|
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
|
||||||
}
|
}
|
||||||
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||||
@@ -705,6 +738,69 @@
|
|||||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
||||||
|
|
||||||
|
.fx-layer { pointer-events: none; }
|
||||||
|
|
||||||
|
/* --- persistent ambiance: ongoing spells wear their look ------------ */
|
||||||
|
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
|
||||||
|
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
|
||||||
|
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
|
||||||
|
.webbing line {
|
||||||
|
stroke: rgba(220, 218, 205, 0.85);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
||||||
|
.fx-dust.late { animation-delay: 0.09s; }
|
||||||
|
.fx-dust.later { animation-delay: 0.17s; }
|
||||||
|
|
||||||
|
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||||
|
@keyframes fx-flicker {
|
||||||
|
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||||
|
60% { opacity: 1; } 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes fx-ring {
|
||||||
|
0% { opacity: 0.95; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(5); }
|
||||||
|
}
|
||||||
|
@keyframes fx-ring-small {
|
||||||
|
0% { opacity: 0.9; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(2.6); }
|
||||||
|
}
|
||||||
|
@keyframes fx-shield-pulse {
|
||||||
|
0% { opacity: 0; transform: scale(0.6); }
|
||||||
|
30% { opacity: 1; transform: scale(1.1); }
|
||||||
|
60% { transform: scale(0.95); }
|
||||||
|
100% { opacity: 0; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
@keyframes fx-drop {
|
||||||
|
0% { opacity: 0.9; transform: translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateY(-14px); }
|
||||||
|
}
|
||||||
|
@keyframes fx-drift {
|
||||||
|
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||||
|
}
|
||||||
|
@keyframes fx-pow-hit {
|
||||||
|
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||||
|
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||||
|
55% { transform: scale(1) rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
@keyframes fx-claw-rake {
|
||||||
|
0% { opacity: 0; transform: translateY(-6px); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateY(6px); }
|
||||||
|
}
|
||||||
|
@keyframes fx-swallow {
|
||||||
|
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||||
|
}
|
||||||
|
@keyframes fx-spin {
|
||||||
|
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||||
|
}
|
||||||
.warp-dest {
|
.warp-dest {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #2e7d32;
|
stroke: #2e7d32;
|
||||||
@@ -719,6 +815,8 @@
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||||
|
.fx-layer { display: none; }
|
||||||
|
.firewall, .token-art.ghosted { animation: none; }
|
||||||
}
|
}
|
||||||
.wizard { cursor: pointer; }
|
.wizard { cursor: pointer; }
|
||||||
.wizard-label {
|
.wizard-label {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
|
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,6 +21,21 @@
|
|||||||
);
|
);
|
||||||
const atEnd = $derived(idx >= steps.length - 1);
|
const atEnd = $derived(idx >= steps.length - 1);
|
||||||
|
|
||||||
|
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||||
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
|
$effect(() => {
|
||||||
|
const step = steps[Math.min(idx, steps.length - 1)];
|
||||||
|
if (!step) return;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||||||
|
timers.push(setTimeout(() => {
|
||||||
|
boardFx = [...boardFx, fx];
|
||||||
|
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||||||
|
}, delay));
|
||||||
|
}
|
||||||
|
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
@@ -47,7 +63,7 @@
|
|||||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="replay-board">
|
<div class="replay-board">
|
||||||
<Board view={step.view} />
|
<Board view={step.view} effects={boardFx} />
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-caption">
|
<div class="replay-caption">
|
||||||
<strong>{step.actor}</strong>
|
<strong>{step.actor}</strong>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"absorb"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`absorb-shine-${uid}`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#fffdf4" />
|
||||||
|
<stop offset="0.5" stop-color="#f6f0df" />
|
||||||
|
<stop offset="1" stop-color="#d8ccb0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx={c.x} cy={c.y} r="14" class="pull" />
|
||||||
|
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2"
|
||||||
|
fill={`url(#absorb-shine-${uid})`} class="card"
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
stroke: #43331f;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
animation: swallow 0.65s ease-in forwards;
|
||||||
|
}
|
||||||
|
.pull {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(180, 138, 224, 0.6);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 6;
|
||||||
|
animation: pull-in 0.6s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes swallow {
|
||||||
|
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: scale(0.05) rotate(140deg); }
|
||||||
|
}
|
||||||
|
@keyframes pull-in {
|
||||||
|
0% { opacity: 0.8; transform: scale(1.6) rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: scale(0.2) rotate(-90deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"bolt"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
/** A fresh jagged path every strike, plus two forks off mid-joints. */
|
||||||
|
const geom = $derived.by(() => {
|
||||||
|
const a = center(fx.from), b = center(fx.to);
|
||||||
|
const segs = 6;
|
||||||
|
const joints: { x: number; y: number }[] = [a];
|
||||||
|
for (let i = 1; i < segs; i++) {
|
||||||
|
const t = i / segs;
|
||||||
|
joints.push({
|
||||||
|
x: a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16,
|
||||||
|
y: a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
joints.push(b);
|
||||||
|
const points = joints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
||||||
|
const fork = (j: { x: number; y: number }) => {
|
||||||
|
const dx = (Math.random() - 0.5) * 26, dy = (Math.random() - 0.5) * 26;
|
||||||
|
return `M ${j.x} ${j.y} l ${dx / 2} ${dy / 2} l ${dx / 2 + (Math.random() - 0.5) * 8} ${dy / 2}`;
|
||||||
|
};
|
||||||
|
return { points, forks: [fork(joints[2]!), fork(joints[4]!)] };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<filter id={`bolt-glow-${uid}`} x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||||
|
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="bolt" filter={`url(#bolt-glow-${uid})`}>
|
||||||
|
<polyline points={geom.points} class="wide" />
|
||||||
|
{#each geom.forks as d, i (i)}
|
||||||
|
<path {d} class="fork" />
|
||||||
|
{/each}
|
||||||
|
<polyline points={geom.points} class="core" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bolt .wide {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6a812;
|
||||||
|
stroke-width: 5.5;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.bolt .fork {
|
||||||
|
fill: none;
|
||||||
|
stroke: #fff6b0;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.bolt .core {
|
||||||
|
fill: none;
|
||||||
|
stroke: #fffdf0;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.bolt .fork { stroke: #f5d54a; }
|
||||||
|
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
|
||||||
|
@keyframes bolt-flicker {
|
||||||
|
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||||
|
60% { opacity: 1; } 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"burst"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const EMBERS = [15, 80, 145, 210, 275, 340];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`burst-fire-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#fffde0" />
|
||||||
|
<stop offset="0.3" stop-color="#ffd34e" />
|
||||||
|
<stop offset="0.65" stop-color="#ff8c1a" stop-opacity="0.85" />
|
||||||
|
<stop offset="1" stop-color="#ef3b08" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx={c.x} cy={c.y} r="16" fill={`url(#burst-fire-${uid})`} class="bloom" />
|
||||||
|
<circle cx={c.x} cy={c.y} r="6" class="shockwave" />
|
||||||
|
{#each EMBERS as deg (deg)}
|
||||||
|
<circle
|
||||||
|
cx={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
|
||||||
|
cy={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
|
||||||
|
r={deg % 2 === 0 ? 2 : 1.5}
|
||||||
|
class="ember"
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bloom {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: bloom 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
.shockwave {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #ffd27a;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
animation: shockwave 0.45s ease-out 0.08s forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.ember {
|
||||||
|
fill: #ff9d19;
|
||||||
|
animation: ember-fly 0.55s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes bloom {
|
||||||
|
0% { opacity: 0; transform: scale(0.2); }
|
||||||
|
25% { opacity: 1; transform: scale(1.2); }
|
||||||
|
100% { opacity: 0; transform: scale(2.4); }
|
||||||
|
}
|
||||||
|
@keyframes shockwave {
|
||||||
|
0% { opacity: 0.9; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(6); }
|
||||||
|
}
|
||||||
|
@keyframes ember-fly {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(2.1) rotate(24deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const CARDS = [0, 72, 144, 216, 288];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<circle cx={c.x} cy={c.y} r="30" class="ring inner" />
|
||||||
|
<circle cx={c.x} cy={c.y} r="55" class="ring mid" />
|
||||||
|
<circle cx={c.x} cy={c.y} r="80" class="ring outer" />
|
||||||
|
{#each CARDS as deg (deg)}
|
||||||
|
<rect
|
||||||
|
x={c.x + 46 * Math.cos((deg * Math.PI) / 180) - 5}
|
||||||
|
y={c.y + 46 * Math.sin((deg * Math.PI) / 180) - 7}
|
||||||
|
width="10" height="14" rx="1.5"
|
||||||
|
class="slip"
|
||||||
|
transform={`rotate(${deg + 25} ${c.x + 46 * Math.cos((deg * Math.PI) / 180)} ${c.y + 46 * Math.sin((deg * Math.PI) / 180)})`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ring {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-dasharray: 30 22;
|
||||||
|
}
|
||||||
|
.ring.inner { stroke: #b48ae0; }
|
||||||
|
.ring.mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
|
||||||
|
.ring.outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
|
||||||
|
.slip {
|
||||||
|
fill: #f6f0df;
|
||||||
|
stroke: #43331f;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
|
||||||
|
@keyframes chaos-spin {
|
||||||
|
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: rotate(220deg) scale(1.4); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"claw"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const GASHES = [
|
||||||
|
{ x: -12, cls: "g0" },
|
||||||
|
{ x: -3, cls: "g1" },
|
||||||
|
{ x: 6, cls: "g2" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`claw-cut-${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e0604f" />
|
||||||
|
<stop offset="0.5" stop-color="#b3372b" />
|
||||||
|
<stop offset="1" stop-color="#6e1a12" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="claw">
|
||||||
|
{#each GASHES as g (g.cls)}
|
||||||
|
<g class={`gash ${g.cls}`} style={`transform-origin: ${c.x + g.x}px ${c.y - 15}px`}>
|
||||||
|
<path
|
||||||
|
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26 q -1 1 -2 0 q -7 -13 -11 -25 z`}
|
||||||
|
fill={`url(#claw-cut-${uid})`}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26`}
|
||||||
|
class="edge" pathLength="100"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Each slash reveals downward along its own length, in sequence,
|
||||||
|
while the whole strike rakes a little sideways. */
|
||||||
|
.gash { opacity: 0; animation: gash-cut 0.4s ease-out forwards; }
|
||||||
|
.gash.g1 { animation-delay: 0.06s; }
|
||||||
|
.gash.g2 { animation-delay: 0.12s; }
|
||||||
|
.edge {
|
||||||
|
fill: none;
|
||||||
|
stroke: #ffd9c4;
|
||||||
|
stroke-width: 1.4;
|
||||||
|
stroke-dasharray: 100 100;
|
||||||
|
stroke-dashoffset: 100;
|
||||||
|
animation: edge-draw 0.28s ease-out forwards;
|
||||||
|
}
|
||||||
|
.gash.g1 .edge { animation-delay: 0.06s; }
|
||||||
|
.gash.g2 .edge { animation-delay: 0.12s; }
|
||||||
|
.claw { animation: rake 0.55s ease-out forwards; }
|
||||||
|
@keyframes gash-cut {
|
||||||
|
0% { opacity: 0; transform: scaleY(0.1); }
|
||||||
|
30% { opacity: 1; transform: scaleY(1.05); }
|
||||||
|
100% { opacity: 0.9; transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
@keyframes edge-draw {
|
||||||
|
0% { stroke-dashoffset: 100; opacity: 1; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.9; }
|
||||||
|
100% { stroke-dashoffset: 0; opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes rake {
|
||||||
|
0% { opacity: 1; transform: translate(2px, -3px); }
|
||||||
|
60% { opacity: 1; transform: translate(-2px, 2px); }
|
||||||
|
100% { opacity: 0; transform: translate(-3px, 3px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { CELL, center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived.by(() => {
|
||||||
|
if (fx.kind === "dust-puff") return center(fx.at);
|
||||||
|
const mid = center(fx.cell);
|
||||||
|
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
|
||||||
|
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
|
||||||
|
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
|
||||||
|
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
|
||||||
|
});
|
||||||
|
/** Edge dust blows perpendicular to the wall; loose dust just rises. */
|
||||||
|
const away = $derived.by(() => {
|
||||||
|
if (fx.kind !== "edge-dust") return { x: 0, y: -12 };
|
||||||
|
return fx.side === "N" || fx.side === "S" ? { x: 11, y: -4 } : { x: 4, y: -11 };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||||
|
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
|
||||||
|
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
|
||||||
|
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
|
||||||
|
<g class="grit">
|
||||||
|
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
|
||||||
|
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
|
||||||
|
<circle cx={c.x + 1} cy={c.y - 2} r="0.9" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`dust-soft-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#96876a" stop-opacity="1" />
|
||||||
|
<stop offset="0.75" stop-color="#7d7358" stop-opacity="0.7" />
|
||||||
|
<stop offset="1" stop-color="#8d8266" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.puff {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: blow-a 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
.puff.back { animation-name: blow-b; }
|
||||||
|
.puff.late { animation-delay: 0.08s; }
|
||||||
|
.puff.later { animation-delay: 0.16s; }
|
||||||
|
.grit circle { fill: #4a4232; animation: grit-fall 0.6s ease-in forwards; }
|
||||||
|
@keyframes blow-a {
|
||||||
|
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
|
||||||
|
100% { opacity: 0; transform: translate(var(--ax), var(--ay)) scale(2); }
|
||||||
|
}
|
||||||
|
@keyframes blow-b {
|
||||||
|
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
|
||||||
|
100% { opacity: 0; transform: translate(var(--bx), var(--by)) scale(1.9); }
|
||||||
|
}
|
||||||
|
@keyframes grit-fall {
|
||||||
|
0% { opacity: 1; transform: translateY(-3px); }
|
||||||
|
100% { opacity: 0; transform: translateY(8px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"fireball"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
const a = $derived(center(fx.from));
|
||||||
|
const b = $derived(center(fx.to));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`fireball-core-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#fffde0" />
|
||||||
|
<stop offset="0.28" stop-color="#fff09a" />
|
||||||
|
<stop offset="0.62" stop-color="#ffad22" />
|
||||||
|
<stop offset="1" stop-color="#ef3b08" stop-opacity="0.15" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id={`fireball-tail-${uid}`} x1="1" x2="0">
|
||||||
|
<stop offset="0" stop-color="#fff2a1" stop-opacity="0.95" />
|
||||||
|
<stop offset="0.2" stop-color="#ff9d16" stop-opacity="0.86" />
|
||||||
|
<stop offset="0.62" stop-color="#ee3907" stop-opacity="0.42" />
|
||||||
|
<stop offset="1" stop-color="#581002" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id={`fireball-warp-${uid}`} x="-60%" y="-160%" width="230%" height="420%">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.022 0.12" numOctaves="3" seed="7" result="noise">
|
||||||
|
<animate
|
||||||
|
attributeName="baseFrequency"
|
||||||
|
values="0.022 0.12; 0.035 0.17; 0.018 0.1"
|
||||||
|
dur="0.22s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</feTurbulence>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="14" xChannelSelector="R" yChannelSelector="B" />
|
||||||
|
<feGaussianBlur stdDeviation="1.4" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id={`fireball-glow-${uid}`} x="-180%" y="-180%" width="460%" height="460%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="fireball">
|
||||||
|
<g>
|
||||||
|
<!-- Outer orange glow -->
|
||||||
|
<ellipse cx="-4" cy="0" rx="27" ry="16" fill="#ff5b0a" opacity="0.32" filter={`url(#fireball-glow-${uid})`} />
|
||||||
|
|
||||||
|
<!-- Turbulent outer flame -->
|
||||||
|
<path
|
||||||
|
d="M -8 -13 C -30 -18, -52 -8, -91 -3 C -60 1, -68 14, -98 20 C -54 17, -28 11, -7 12 Z"
|
||||||
|
fill={`url(#fireball-tail-${uid})`}
|
||||||
|
filter={`url(#fireball-warp-${uid})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Hot inner flame -->
|
||||||
|
<path
|
||||||
|
d="M -6 -8 C -24 -10, -42 -4, -64 1 C -39 2, -32 8, -10 8 Z"
|
||||||
|
fill="#ffd34e"
|
||||||
|
opacity="0.74"
|
||||||
|
filter={`url(#fireball-warp-${uid})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Fireball body -->
|
||||||
|
<circle r="15" fill="#ff5c09" opacity="0.72" filter={`url(#fireball-glow-${uid})`} />
|
||||||
|
<circle r="12" fill={`url(#fireball-core-${uid})`} />
|
||||||
|
|
||||||
|
<!-- White-hot highlight -->
|
||||||
|
<ellipse cx="4" cy="-3" rx="6" ry="5" fill="#fffde7" opacity="0.95" />
|
||||||
|
|
||||||
|
<!-- Sparks -->
|
||||||
|
<g fill="#ff9d19">
|
||||||
|
<circle cx="-28" cy="-17" r="2.2">
|
||||||
|
<animate attributeName="cy" values="-17; -23; -17" dur="0.16s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="-46" cy="12" r="1.6">
|
||||||
|
<animate attributeName="cy" values="12; 19; 12" dur="0.2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="-70" cy="-7" r="1.4">
|
||||||
|
<animate attributeName="opacity" values="1; 0.1; 1" dur="0.14s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fireball {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fireball-fade 0.55s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes fireball-fade {
|
||||||
|
0%, 70% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"fireworks"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
|
||||||
|
// Deliberately uneven: a real burst has no protractor.
|
||||||
|
const RAYS = [
|
||||||
|
{ deg: 8, len: 22, r: 2.8, cls: "" },
|
||||||
|
{ deg: 52, len: 17, r: 2.2, cls: "late" },
|
||||||
|
{ deg: 88, len: 24, r: 3, cls: "" },
|
||||||
|
{ deg: 141, len: 16, r: 2, cls: "later" },
|
||||||
|
{ deg: 176, len: 21, r: 2.6, cls: "late" },
|
||||||
|
{ deg: 224, len: 18, r: 2.3, cls: "" },
|
||||||
|
{ deg: 267, len: 23, r: 2.8, cls: "later" },
|
||||||
|
{ deg: 309, len: 15, r: 2, cls: "late" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g class="fireworks">
|
||||||
|
{#each RAYS as ray, i (ray.deg)}
|
||||||
|
{@const dx = Math.cos((ray.deg * Math.PI) / 180)}
|
||||||
|
{@const dy = Math.sin((ray.deg * Math.PI) / 180)}
|
||||||
|
<line
|
||||||
|
x1={c.x + 4 * dx} y1={c.y + 4 * dy}
|
||||||
|
x2={c.x + ray.len * dx} y2={c.y + ray.len * dy}
|
||||||
|
stroke={COLORS[i % 4]}
|
||||||
|
class={`trail ${ray.cls}`}
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={c.x + (ray.len + 1) * dx} cy={c.y + (ray.len + 1) * dy} r={ray.r}
|
||||||
|
fill={COLORS[i % 4]}
|
||||||
|
class={`spark ${ray.cls}`}
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<circle cx={c.x} cy={c.y} r="3.5" class="heart" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trail {
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
animation: trail-out 0.9s ease-out forwards;
|
||||||
|
}
|
||||||
|
.spark { animation: spark-out 1.05s ease-out forwards; }
|
||||||
|
.trail.late, .spark.late { animation-delay: 0.07s; }
|
||||||
|
.trail.later, .spark.later { animation-delay: 0.14s; }
|
||||||
|
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
|
||||||
|
@keyframes trail-out {
|
||||||
|
0% { opacity: 0; transform: scale(0.15); }
|
||||||
|
25% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: scale(1.5); }
|
||||||
|
}
|
||||||
|
@keyframes spark-out {
|
||||||
|
0% { opacity: 0; transform: scale(0.15); }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
80% { opacity: 0.9; transform: scale(1.55); }
|
||||||
|
100% { opacity: 0; transform: scale(1.6) translateY(4px); }
|
||||||
|
}
|
||||||
|
@keyframes heart-pop {
|
||||||
|
0% { opacity: 1; transform: scale(0.5); }
|
||||||
|
100% { opacity: 0; transform: scale(3); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"hit"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const DEBRIS = [40, 165, 285];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Contact flash: an instant of white -->
|
||||||
|
<path
|
||||||
|
d={`M ${c.x} ${c.y - 9} l 2.5 6 6.5 -2 -3.5 5.5 5.5 3.5 -6.5 1 1 6.5 -5.5 -4.5 -4.5 5 0 -7 -6.5 0.5 5 -4.5 -4 -5 6.5 1.5 z`}
|
||||||
|
class="flash" style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
<!-- Compressed impact ring: squashed, then out -->
|
||||||
|
<ellipse cx={c.x} cy={c.y} rx="9" ry="6" class="ring" />
|
||||||
|
{#each DEBRIS as deg, i (deg)}
|
||||||
|
<line
|
||||||
|
x1={c.x + 8 * Math.cos((deg * Math.PI) / 180)}
|
||||||
|
y1={c.y + 8 * Math.sin((deg * Math.PI) / 180)}
|
||||||
|
x2={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
|
||||||
|
y2={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
|
||||||
|
class={`debris d${i}`}
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flash {
|
||||||
|
fill: #fffdf0;
|
||||||
|
stroke: #c0392b;
|
||||||
|
stroke-width: 1;
|
||||||
|
animation: flash-snap 0.28s ease-out forwards;
|
||||||
|
}
|
||||||
|
.ring {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #c0392b;
|
||||||
|
stroke-width: 3;
|
||||||
|
animation: ring-punch 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
.debris {
|
||||||
|
stroke: #7c221a;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
opacity: 0;
|
||||||
|
animation: debris-fly 0.35s ease-out 0.05s forwards;
|
||||||
|
}
|
||||||
|
.debris.d1 { animation-delay: 0.08s; }
|
||||||
|
.debris.d2 { animation-delay: 0.11s; }
|
||||||
|
@keyframes flash-snap {
|
||||||
|
0% { opacity: 1; transform: scale(0.4); }
|
||||||
|
35% { opacity: 1; transform: scale(1.25); }
|
||||||
|
100% { opacity: 0; transform: scale(0.9); }
|
||||||
|
}
|
||||||
|
@keyframes ring-punch {
|
||||||
|
0% { opacity: 0.95; transform: scale(0.5, 0.35); }
|
||||||
|
45% { opacity: 0.9; transform: scale(1.6, 1.3); }
|
||||||
|
100% { opacity: 0; transform: scale(2.2, 1.9); }
|
||||||
|
}
|
||||||
|
@keyframes debris-fly {
|
||||||
|
0% { opacity: 0.9; transform: scale(0.8); }
|
||||||
|
100% { opacity: 0; transform: scale(1.7); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`ooze-splat-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#a5cc60" stop-opacity="0.8" />
|
||||||
|
<stop offset="0.7" stop-color="#6ea03c" stop-opacity="0.55" />
|
||||||
|
<stop offset="1" stop-color="#4a7024" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ellipse cx={c.x} cy={c.y + 8} rx="14" ry="5.5" fill={`url(#ooze-splat-${uid})`} class="splat" />
|
||||||
|
<g class="blobs">
|
||||||
|
<circle cx={c.x - 12} cy={c.y + 3} r="2.5" class="blob" />
|
||||||
|
<circle cx={c.x + 11} cy={c.y + 2} r="2" class="blob late" />
|
||||||
|
<circle cx={c.x + 3} cy={c.y - 3} r="1.7" class="blob later" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splat {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: splat-wobble 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
|
||||||
|
.blob.late { animation-delay: 0.06s; }
|
||||||
|
.blob.later { animation-delay: 0.12s; }
|
||||||
|
@keyframes splat-wobble {
|
||||||
|
0% { opacity: 0.95; transform: scale(0.5, 1); }
|
||||||
|
35% { transform: scale(1.3, 0.85); }
|
||||||
|
65% { transform: scale(0.9, 1.05); }
|
||||||
|
100% { opacity: 0; transform: scale(1.15, 0.95); }
|
||||||
|
}
|
||||||
|
@keyframes blob-fly {
|
||||||
|
0% { opacity: 0.9; transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-7px); }
|
||||||
|
100% { opacity: 0; transform: translateY(2px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`pit-hole-${uid}`} cy="0.42">
|
||||||
|
<stop offset="0" stop-color="#1a130a" />
|
||||||
|
<stop offset="0.55" stop-color="#3a2c1a" stop-opacity="0.9" />
|
||||||
|
<stop offset="0.85" stop-color="#5f4a33" stop-opacity="0.55" />
|
||||||
|
<stop offset="1" stop-color="#5f4a33" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ellipse cx={c.x} cy={c.y} rx="14" ry="11" fill={`url(#pit-hole-${uid})`} class="hole" />
|
||||||
|
<!-- The far lip catches the light: reads as depth, not a dot -->
|
||||||
|
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
|
||||||
|
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||||
|
<g class="rim-dust">
|
||||||
|
<circle cx={c.x - 11} cy={c.y - 6} r="3" class="dust" />
|
||||||
|
<circle cx={c.x + 10} cy={c.y - 7} r="2.5" class="dust late" />
|
||||||
|
<circle cx={c.x + 1} cy={c.y - 11} r="2" class="dust later" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hole {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: hole-open 0.75s ease-out forwards;
|
||||||
|
}
|
||||||
|
.faller {
|
||||||
|
fill: rgba(120, 100, 70, 0.85);
|
||||||
|
animation: fall-in 0.55s ease-in 0.06s forwards;
|
||||||
|
}
|
||||||
|
.lip {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(233, 225, 203, 0.7);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
animation: hole-open 0.75s ease-out forwards;
|
||||||
|
}
|
||||||
|
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
|
||||||
|
.dust.late { animation-delay: 0.24s; }
|
||||||
|
.dust.later { animation-delay: 0.32s; }
|
||||||
|
@keyframes hole-open {
|
||||||
|
0% { opacity: 0; transform: scale(0.3); }
|
||||||
|
25% { opacity: 1; transform: scale(1); }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes fall-in {
|
||||||
|
0% { opacity: 1; transform: scale(1) translateY(-6px); }
|
||||||
|
100% { opacity: 0; transform: scale(0.1) translateY(4px) rotate(60deg); }
|
||||||
|
}
|
||||||
|
@keyframes dust-drift {
|
||||||
|
0% { opacity: 0; transform: translateY(0) scale(1); }
|
||||||
|
30% { opacity: 0.8; }
|
||||||
|
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { CELL } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if fx.kind === "portal"}
|
||||||
|
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
|
||||||
|
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
|
||||||
|
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
|
||||||
|
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
|
||||||
|
<line {x1} {y1} {x2} {y2} class="curtain glow" />
|
||||||
|
<line {x1} {y1} {x2} {y2} class="curtain" pathLength="100" />
|
||||||
|
{:else}
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`portal-void-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#0d2430" stop-opacity="0.85" />
|
||||||
|
<stop offset="0.6" stop-color="#12414a" stop-opacity="0.5" />
|
||||||
|
<stop offset="1" stop-color="#9be3e0" stop-opacity="0.1" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x={fx.at.x * CELL + 5} y={fx.at.y * CELL + 5}
|
||||||
|
width={CELL - 10} height={CELL - 10} rx="8"
|
||||||
|
fill={`url(#portal-void-${uid})`} class="void"
|
||||||
|
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
|
||||||
|
{@const vx = fx.at.x * CELL + CELL / 2}
|
||||||
|
{@const vy = fx.at.y * CELL + CELL / 2}
|
||||||
|
<g class="swirl" style={`transform-origin: ${vx}px ${vy}px`}>
|
||||||
|
<path d={`M ${vx + 12} ${vy} A 12 12 0 0 1 ${vx - 6} ${vy + 10}`} class="arm" />
|
||||||
|
<path d={`M ${vx - 12} ${vy} A 12 12 0 0 1 ${vx + 6} ${vy - 10}`} class="arm" />
|
||||||
|
<path d={`M ${vx} ${vy + 7} A 7 7 0 0 1 ${vx - 6} ${vy - 4}`} class="arm faint" />
|
||||||
|
</g>
|
||||||
|
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
|
||||||
|
width={CELL - 6} height={CELL - 6} rx="6" class="veil outer"
|
||||||
|
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
|
||||||
|
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
|
||||||
|
width={CELL - 6} height={CELL - 6} rx="6" class="veil inner"
|
||||||
|
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The threshold opens, light ripples along it, and it pinches shut. */
|
||||||
|
.curtain {
|
||||||
|
stroke: #9be3e0;
|
||||||
|
stroke-dasharray: 6 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
|
||||||
|
animation: open-ripple-pinch 0.95s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
.curtain.glow {
|
||||||
|
stroke: rgba(180, 138, 224, 0.5);
|
||||||
|
stroke-dasharray: none;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: glow-swell 0.95s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes open-ripple-pinch {
|
||||||
|
0% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 0; }
|
||||||
|
22% { opacity: 1; stroke-width: 6; }
|
||||||
|
60% { stroke-width: 4.5; stroke-dashoffset: 22; }
|
||||||
|
88% { opacity: 0.9; stroke-width: 1.5; stroke-dashoffset: 34; }
|
||||||
|
100% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 38; }
|
||||||
|
}
|
||||||
|
@keyframes glow-swell {
|
||||||
|
0% { opacity: 0; stroke-width: 2; }
|
||||||
|
25% { opacity: 0.85; stroke-width: 14; }
|
||||||
|
70% { opacity: 0.6; stroke-width: 9; }
|
||||||
|
100% { opacity: 0; stroke-width: 2; }
|
||||||
|
}
|
||||||
|
/* Cell mouths: rings of energy collapse inward through the token. */
|
||||||
|
.veil {
|
||||||
|
fill: none;
|
||||||
|
stroke: #9be3e0;
|
||||||
|
stroke-dasharray: 7 5;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
|
||||||
|
}
|
||||||
|
.void { animation: void-open 0.9s ease-out forwards; }
|
||||||
|
.arm {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(155, 227, 224, 0.7);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.arm.faint { stroke: rgba(180, 138, 224, 0.55); }
|
||||||
|
.swirl { animation: swirl-turn 0.9s ease-in forwards; }
|
||||||
|
@keyframes swirl-turn {
|
||||||
|
0% { opacity: 0; transform: rotate(0deg) scale(1.1); }
|
||||||
|
25% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: rotate(160deg) scale(0.4); }
|
||||||
|
}
|
||||||
|
.veil.outer { stroke-width: 2.5; animation: veil-in 0.9s ease-in forwards; }
|
||||||
|
.veil.inner {
|
||||||
|
stroke: rgba(180, 138, 224, 0.8);
|
||||||
|
stroke-width: 2;
|
||||||
|
animation: veil-in 0.9s ease-in 0.2s forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@keyframes void-open {
|
||||||
|
0% { opacity: 0; transform: scale(0.4); }
|
||||||
|
30% { opacity: 1; transform: scale(1); }
|
||||||
|
75% { opacity: 0.9; transform: scale(0.92); }
|
||||||
|
100% { opacity: 0; transform: scale(0.5); }
|
||||||
|
}
|
||||||
|
@keyframes veil-in {
|
||||||
|
0% { opacity: 0; transform: scale(1.25); }
|
||||||
|
25% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: scale(0.45); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"pow"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
const DASHES = [20, 95, 160, 250, 320];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`pow-star-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#fffdf0" />
|
||||||
|
<stop offset="0.45" stop-color="#ffe94d" />
|
||||||
|
<stop offset="1" stop-color="#ffb02e" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<path
|
||||||
|
d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`}
|
||||||
|
fill={`url(#pow-star-${uid})`}
|
||||||
|
/>
|
||||||
|
<circle cx={c.x} cy={c.y} r="4" class="flash" />
|
||||||
|
</g>
|
||||||
|
{#each DASHES as deg (deg)}
|
||||||
|
<line
|
||||||
|
x1={c.x + 16 * Math.cos((deg * Math.PI) / 180)}
|
||||||
|
y1={c.y + 16 * Math.sin((deg * Math.PI) / 180)}
|
||||||
|
x2={c.x + 24 * Math.cos((deg * Math.PI) / 180)}
|
||||||
|
y2={c.y + 24 * Math.sin((deg * Math.PI) / 180)}
|
||||||
|
class="dash"
|
||||||
|
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pow path { stroke: #b3372b; stroke-width: 1.6; }
|
||||||
|
.pow .flash { fill: #ffffff; animation: flash 0.3s ease-out forwards; }
|
||||||
|
.pow { animation: pow-hit 0.5s ease-out forwards; }
|
||||||
|
.dash {
|
||||||
|
stroke: #b3372b;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
animation: dash-out 0.4s ease-out 0.06s forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@keyframes pow-hit {
|
||||||
|
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||||
|
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||||
|
55% { transform: scale(1) rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
@keyframes flash {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(3); }
|
||||||
|
}
|
||||||
|
@keyframes dash-out {
|
||||||
|
0% { opacity: 0; transform: scale(0.7); }
|
||||||
|
30% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { CELL, SECTOR } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
|
||||||
|
|
||||||
|
const S = SECTOR * CELL;
|
||||||
|
const origin = $derived(fx.kind === "sector-spin" ? fx.origin : fx.from);
|
||||||
|
const ox = $derived(origin.x * CELL);
|
||||||
|
const oy = $derived(origin.y * CELL);
|
||||||
|
const cx = $derived(ox + S / 2);
|
||||||
|
const cy = $derived(oy + S / 2);
|
||||||
|
/** Slide direction, unit-ish, for the chevron train. */
|
||||||
|
const dir = $derived.by(() => {
|
||||||
|
if (fx.kind !== "sector-slide") return { x: 0, y: 0 };
|
||||||
|
const dx = fx.to.x - fx.from.x, dy = fx.to.y - fx.from.y;
|
||||||
|
const m = Math.max(1, Math.hypot(dx, dy));
|
||||||
|
return { x: dx / m, y: dy / m };
|
||||||
|
});
|
||||||
|
const CORNERS = $derived([
|
||||||
|
{ x: ox + 6, y: oy + 6 }, { x: ox + S - 6, y: oy + 6 },
|
||||||
|
{ x: ox + 6, y: oy + S - 6 }, { x: ox + S - 6, y: oy + S - 6 },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if fx.kind === "sector-spin"}
|
||||||
|
<g class={`grind ${fx.clockwise ? "cw" : "ccw"}`} style={`transform-origin: ${cx}px ${cy}px`}>
|
||||||
|
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame" />
|
||||||
|
<!-- Curved motion arcs at the corners, pointing the way around -->
|
||||||
|
{#each [45, 135, 225, 315] as deg (deg)}
|
||||||
|
<path
|
||||||
|
d={`M ${cx + (S / 2 - 14) * Math.cos(((deg - 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg - 16) * Math.PI) / 180)}
|
||||||
|
A ${S / 2 - 14} ${S / 2 - 14} 0 0 ${fx.clockwise ? 1 : 0}
|
||||||
|
${cx + (S / 2 - 14) * Math.cos(((deg + 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg + 16) * Math.PI) / 180)}`}
|
||||||
|
class="arc"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
{:else}
|
||||||
|
<g class="slide-group" style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`}>
|
||||||
|
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame slide" />
|
||||||
|
<!-- Chevron train pointing along the journey -->
|
||||||
|
{#each [0.3, 0.5, 0.7] as t, i (t)}
|
||||||
|
<path
|
||||||
|
d={`M ${cx + dir.x * S * (t - 0.08) - dir.y * 9} ${cy + dir.y * S * (t - 0.08) - dir.x * 9}
|
||||||
|
L ${cx + dir.x * S * t} ${cy + dir.y * S * t}
|
||||||
|
L ${cx + dir.x * S * (t - 0.08) + dir.y * 9} ${cy + dir.y * S * (t - 0.08) + dir.x * 9}`}
|
||||||
|
class={`chevron c${i}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
<g class="corner-dust">
|
||||||
|
{#each CORNERS as p, i (i)}
|
||||||
|
<circle cx={p.x} cy={p.y} r="3.5" class={`dust d${i}`} />
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frame {
|
||||||
|
fill: rgba(233, 225, 203, 0.25);
|
||||||
|
stroke: #d3852b;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-dasharray: 12 7;
|
||||||
|
}
|
||||||
|
.arc {
|
||||||
|
fill: none;
|
||||||
|
stroke: #b3691f;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
fill: none;
|
||||||
|
stroke: #b3691f;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.chevron.c0 { animation: chev 0.5s ease-out 0.15s forwards; }
|
||||||
|
.chevron.c1 { animation: chev 0.5s ease-out 0.3s forwards; }
|
||||||
|
.chevron.c2 { animation: chev 0.5s ease-out 0.45s forwards; }
|
||||||
|
.grind { animation: grind-settle-cw 1.25s ease-in-out forwards; }
|
||||||
|
.grind.ccw { animation-name: grind-settle-ccw; }
|
||||||
|
.slide-group { animation: slide-settle 1.25s ease-in-out forwards; }
|
||||||
|
.dust {
|
||||||
|
fill: rgba(160, 150, 130, 0.85);
|
||||||
|
opacity: 0;
|
||||||
|
animation: dust-kick 0.6s ease-out 0.85s forwards;
|
||||||
|
}
|
||||||
|
.dust.d1 { animation-delay: 0.9s; }
|
||||||
|
.dust.d2 { animation-delay: 0.95s; }
|
||||||
|
.dust.d3 { animation-delay: 1s; }
|
||||||
|
@keyframes chev {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
35% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
/* The quarter-turn, then a grinding shudder as it settles. */
|
||||||
|
@keyframes grind-settle-cw {
|
||||||
|
0% { opacity: 0; transform: rotate(-90deg); }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
72% { opacity: 1; transform: rotate(0.6deg); }
|
||||||
|
80% { transform: rotate(-0.8deg); }
|
||||||
|
87% { transform: rotate(0.4deg); }
|
||||||
|
93% { transform: rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
@keyframes grind-settle-ccw {
|
||||||
|
0% { opacity: 0; transform: rotate(90deg); }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
72% { opacity: 1; transform: rotate(-0.6deg); }
|
||||||
|
80% { transform: rotate(0.8deg); }
|
||||||
|
87% { transform: rotate(-0.4deg); }
|
||||||
|
93% { transform: rotate(0deg); }
|
||||||
|
100% { opacity: 0; transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
@keyframes slide-settle {
|
||||||
|
0% { opacity: 0; transform: translate(0, 0); }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
72% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
|
||||||
|
80% { transform: translate(calc(var(--dx) - 2px), var(--dy)); }
|
||||||
|
88% { transform: translate(calc(var(--dx) + 1px), var(--dy)); }
|
||||||
|
94% { transform: translate(var(--dx), var(--dy)); }
|
||||||
|
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
|
||||||
|
}
|
||||||
|
@keyframes dust-kick {
|
||||||
|
0% { opacity: 0; transform: translateY(0) scale(0.7); }
|
||||||
|
30% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"shield"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`shield-dome-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#ffe9a3" stop-opacity="0" />
|
||||||
|
<stop offset="0.75" stop-color="#ffdf7a" stop-opacity="0.25" />
|
||||||
|
<stop offset="1" stop-color="#c9a72a" stop-opacity="0.9" />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id={`shield-glow-${uid}`} x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx={c.x} cy={c.y} r="16" fill={`url(#shield-dome-${uid})`} class="dome" filter={`url(#shield-glow-${uid})`} />
|
||||||
|
<circle cx={c.x} cy={c.y} r="16" class="rim" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dome {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: dome 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
.rim {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #fff3c4;
|
||||||
|
stroke-width: 2;
|
||||||
|
animation: rim-strike 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes dome {
|
||||||
|
0% { opacity: 0; transform: scale(0.6); }
|
||||||
|
30% { opacity: 1; transform: scale(1.1); }
|
||||||
|
60% { transform: scale(0.95); }
|
||||||
|
100% { opacity: 0; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
@keyframes rim-strike {
|
||||||
|
0% { opacity: 0; transform: scale(0.6); }
|
||||||
|
28% { opacity: 1; transform: scale(1.12); }
|
||||||
|
45% { opacity: 0.4; }
|
||||||
|
58% { opacity: 0.9; transform: scale(0.95); }
|
||||||
|
100% { opacity: 0; transform: scale(1.18); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"shimmer"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<circle cx={c.x} cy={c.y} r="6" class="ring" />
|
||||||
|
<circle cx={c.x} cy={c.y} r="6" class="ring late" />
|
||||||
|
<g class="motes">
|
||||||
|
<circle cx={c.x - 8} cy={c.y - 8} r="1.5" />
|
||||||
|
<circle cx={c.x + 9} cy={c.y - 4} r="1.2" class="late" />
|
||||||
|
<circle cx={c.x - 3} cy={c.y + 9} r="1.3" class="later" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ring {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #b48ae0;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
animation: ring 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
.ring.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
|
||||||
|
.motes circle { fill: #e2c8ff; animation: mote 0.7s ease-out forwards; }
|
||||||
|
.motes .late { animation-delay: 0.1s; opacity: 0; }
|
||||||
|
.motes .later { animation-delay: 0.18s; opacity: 0; }
|
||||||
|
@keyframes ring {
|
||||||
|
0% { opacity: 0.95; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(5); }
|
||||||
|
}
|
||||||
|
@keyframes mote {
|
||||||
|
0% { opacity: 0; transform: translateY(0); }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`slime-goo-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#c8e87a" stop-opacity="0.4" />
|
||||||
|
<stop offset="0.8" stop-color="#8cc83c" stop-opacity="0.65" />
|
||||||
|
<stop offset="1" stop-color="#7a9e2e" stop-opacity="0.9" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- An irregular blob, squashing as it grips -->
|
||||||
|
<path
|
||||||
|
class="goo" style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||||
|
fill={`url(#slime-goo-${uid})`}
|
||||||
|
d={`M ${c.x - 13} ${c.y + 2}
|
||||||
|
C ${c.x - 14} ${c.y - 7}, ${c.x - 6} ${c.y - 12}, ${c.x + 2} ${c.y - 10}
|
||||||
|
C ${c.x + 10} ${c.y - 13}, ${c.x + 15} ${c.y - 4}, ${c.x + 12} ${c.y + 4}
|
||||||
|
C ${c.x + 14} ${c.y + 10}, ${c.x + 4} ${c.y + 12}, ${c.x - 3} ${c.y + 10}
|
||||||
|
C ${c.x - 10} ${c.y + 12}, ${c.x - 12} ${c.y + 8}, ${c.x - 13} ${c.y + 2} z`}
|
||||||
|
/>
|
||||||
|
<!-- Elastic strands stretching toward the caught -->
|
||||||
|
<g class="strands">
|
||||||
|
<path d={`M ${c.x - 11} ${c.y - 6} Q ${c.x - 6} ${c.y - 2} ${c.x - 2} ${c.y}`} class="strand" />
|
||||||
|
<path d={`M ${c.x + 12} ${c.y - 3} Q ${c.x + 6} ${c.y - 1} ${c.x + 2} ${c.y + 1}`} class="strand late" />
|
||||||
|
<path d={`M ${c.x + 2} ${c.y + 10} Q ${c.x + 1} ${c.y + 5} ${c.x} ${c.y + 1}`} class="strand later" />
|
||||||
|
</g>
|
||||||
|
<circle cx={c.x + 4} cy={c.y - 5} r="2.5" class="bubble" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.goo { animation: goo-grip 0.9s ease-out forwards; }
|
||||||
|
.strand {
|
||||||
|
fill: none;
|
||||||
|
stroke: #8fbf4d;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 20 40;
|
||||||
|
animation: strand-snap 0.55s ease-in 0.15s forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.strand.late { animation-delay: 0.28s; }
|
||||||
|
.strand.later { animation-delay: 0.4s; }
|
||||||
|
.bubble {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #c8e87a;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
animation: bubble-pop 0.6s ease-out 0.25s forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@keyframes goo-grip {
|
||||||
|
0% { opacity: 0.95; transform: scale(1.3, 1.1) rotate(3deg); }
|
||||||
|
40% { transform: scale(0.92, 1.06) rotate(-2deg); }
|
||||||
|
70% { transform: scale(1.04, 0.96) rotate(1deg); }
|
||||||
|
100% { opacity: 0; transform: scale(1) rotate(0deg); }
|
||||||
|
}
|
||||||
|
@keyframes strand-snap {
|
||||||
|
0% { opacity: 0; stroke-dashoffset: 18; }
|
||||||
|
30% { opacity: 0.9; stroke-dashoffset: 6; }
|
||||||
|
75% { opacity: 0.85; stroke-dashoffset: 0; }
|
||||||
|
100% { opacity: 0; stroke-dashoffset: -4; }
|
||||||
|
}
|
||||||
|
@keyframes bubble-pop {
|
||||||
|
0% { opacity: 0; transform: scale(0.4); }
|
||||||
|
50% { opacity: 0.9; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.8); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"soul"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`soul-body-${uid}`} cy="0.35">
|
||||||
|
<stop offset="0" stop-color="#f7f4ff" stop-opacity="0.95" />
|
||||||
|
<stop offset="0.7" stop-color="#d9d0f0" stop-opacity="0.75" />
|
||||||
|
<stop offset="1" stop-color="#a096c8" stop-opacity="0.35" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="soul">
|
||||||
|
<g class="sway" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<!-- Rounded head, wavy hem -->
|
||||||
|
<path
|
||||||
|
d={`M ${c.x - 8} ${c.y + 6} C ${c.x - 9} ${c.y - 6}, ${c.x - 5} ${c.y - 11}, ${c.x} ${c.y - 11}
|
||||||
|
C ${c.x + 5} ${c.y - 11}, ${c.x + 9} ${c.y - 6}, ${c.x + 8} ${c.y + 6}
|
||||||
|
q -2 -2.5 -4 0 q -2 2.5 -4 0 q -2 -2.5 -4 0 q -2 2.5 -4 0 z`}
|
||||||
|
fill={`url(#soul-body-${uid})`}
|
||||||
|
/>
|
||||||
|
<circle cx={c.x - 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
|
||||||
|
<circle cx={c.x + 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.soul { animation: ascend 1.3s ease-out forwards; }
|
||||||
|
.sway { animation: sway 0.65s ease-in-out 2; }
|
||||||
|
@keyframes ascend {
|
||||||
|
0% { opacity: 0; transform: translateY(0); }
|
||||||
|
22% { opacity: 0.95; }
|
||||||
|
100% { opacity: 0; transform: translateY(-36px); }
|
||||||
|
}
|
||||||
|
@keyframes sway {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(6deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"sparkle"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g class="glint main" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
|
||||||
|
</g>
|
||||||
|
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
|
||||||
|
</g>
|
||||||
|
<circle cx={c.x} cy={c.y} r="3" class="core" />
|
||||||
|
<g class="motes">
|
||||||
|
<circle cx={c.x - 10} cy={c.y - 9} r="1.3" />
|
||||||
|
<circle cx={c.x + 11} cy={c.y - 5} r="1.1" class="late" />
|
||||||
|
<circle cx={c.x + 2} cy={c.y + 11} r="1.2" class="later" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.glint.main path {
|
||||||
|
fill: #ffe084;
|
||||||
|
stroke: #c9a72a;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.glint.counter path { fill: #fff6d8; opacity: 0.9; }
|
||||||
|
.glint.main { animation: spin-snap 0.7s ease-out forwards; }
|
||||||
|
.glint.counter { animation: counter-spin 0.7s ease-out forwards; }
|
||||||
|
.core {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: #ffffff;
|
||||||
|
animation: core-snap 0.45s ease-out forwards;
|
||||||
|
}
|
||||||
|
.motes circle { fill: #ffe084; animation: mote-fly 0.6s ease-out 0.12s forwards; opacity: 0; }
|
||||||
|
.motes .late { animation-delay: 0.18s; }
|
||||||
|
.motes .later { animation-delay: 0.24s; }
|
||||||
|
@keyframes spin-snap {
|
||||||
|
0% { opacity: 0; transform: rotate(-20deg) scale(0.4); }
|
||||||
|
30% { opacity: 1; transform: rotate(15deg) scale(1.2); }
|
||||||
|
55% { transform: rotate(30deg) scale(1); }
|
||||||
|
100% { opacity: 0; transform: rotate(55deg) scale(0.9); }
|
||||||
|
}
|
||||||
|
@keyframes counter-spin {
|
||||||
|
0% { opacity: 0; transform: rotate(45deg) scale(0.4); }
|
||||||
|
30% { opacity: 0.95; transform: rotate(20deg) scale(1.15); }
|
||||||
|
100% { opacity: 0; transform: rotate(-25deg) scale(0.85); }
|
||||||
|
}
|
||||||
|
@keyframes core-snap {
|
||||||
|
0% { opacity: 0; transform: scale(0.3); }
|
||||||
|
30% { opacity: 1; transform: scale(2); }
|
||||||
|
100% { opacity: 0; transform: scale(0.6); }
|
||||||
|
}
|
||||||
|
@keyframes mote-fly {
|
||||||
|
0% { opacity: 0; transform: translateY(0) scale(1); }
|
||||||
|
30% { opacity: 0.95; }
|
||||||
|
100% { opacity: 0; transform: translateY(-8px) scale(0.6); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"splash"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`splash-pool-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.7" />
|
||||||
|
<stop offset="0.7" stop-color="#4fa3e3" stop-opacity="0.4" />
|
||||||
|
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ellipse cx={c.x} cy={c.y + 5} rx="16" ry="6.5" fill={`url(#splash-pool-${uid})`} class="pool" />
|
||||||
|
<circle cx={c.x} cy={c.y} r="7" class="ring" />
|
||||||
|
<!-- The crown: droplets thrown upward, falling back -->
|
||||||
|
<g class="crown">
|
||||||
|
<circle cx={c.x - 14} cy={c.y - 6} r="2.8" class="drop" />
|
||||||
|
<circle cx={c.x - 7} cy={c.y - 13} r="2.3" class="drop late" />
|
||||||
|
<circle cx={c.x + 1} cy={c.y - 16} r="2.5" class="drop" />
|
||||||
|
<circle cx={c.x + 8} cy={c.y - 12} r="2.2" class="drop later" />
|
||||||
|
<circle cx={c.x + 14} cy={c.y - 5} r="1.9" class="drop late" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pool {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: pool-spread 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
.ring {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
fill: none;
|
||||||
|
stroke: #7cc0ee;
|
||||||
|
stroke-width: 3.2;
|
||||||
|
animation: ring-out 0.55s ease-out forwards;
|
||||||
|
}
|
||||||
|
.drop { fill: #7cc0ee; animation: drop-arc 0.6s ease-in forwards; }
|
||||||
|
.drop.late { animation-delay: 0.07s; }
|
||||||
|
.drop.later { animation-delay: 0.13s; }
|
||||||
|
@keyframes pool-spread {
|
||||||
|
0% { opacity: 0.9; transform: scale(0.4); }
|
||||||
|
100% { opacity: 0; transform: scale(2.2); }
|
||||||
|
}
|
||||||
|
@keyframes ring-out {
|
||||||
|
0% { opacity: 0.9; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(4); }
|
||||||
|
}
|
||||||
|
@keyframes drop-arc {
|
||||||
|
0% { opacity: 0.95; transform: translateY(0); }
|
||||||
|
45% { transform: translateY(-8px); }
|
||||||
|
100% { opacity: 0; transform: translateY(6px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"streak"> } = $props();
|
||||||
|
const a = $derived(center(fx.from));
|
||||||
|
const b = $derived(center(fx.to));
|
||||||
|
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<path {d} pathLength="100" class="trail wide" />
|
||||||
|
<path {d} pathLength="100" class="trail bright" />
|
||||||
|
<circle r="4" class="head" cx="0" cy="0">
|
||||||
|
<animateMotion dur="0.38s" fill="freeze" path={d} calcMode="spline"
|
||||||
|
keySplines="0.3 0 0.4 1" keyTimes="0;1" keyPoints="0;1" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trail {
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 100 100;
|
||||||
|
animation: draw-collapse 0.68s ease-out forwards;
|
||||||
|
}
|
||||||
|
.trail.wide { stroke: rgba(233, 225, 203, 0.3); stroke-width: 5; }
|
||||||
|
.trail.bright { stroke: rgba(255, 253, 240, 0.75); stroke-width: 2; }
|
||||||
|
.head {
|
||||||
|
fill: #fffdf0;
|
||||||
|
animation: head-life 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
/* Draw from source to head, then the tail chases it into the destination. */
|
||||||
|
@keyframes draw-collapse {
|
||||||
|
0% { stroke-dashoffset: 100; opacity: 0.95; }
|
||||||
|
56% { stroke-dashoffset: 0; opacity: 0.95; }
|
||||||
|
100% { stroke-dashoffset: -100; opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes head-life {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
60% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
// Each tack: head disc + spike, at its own angle and beat.
|
||||||
|
const TACKS = [
|
||||||
|
{ x: -10, y: -4, rot: -18, cls: "" },
|
||||||
|
{ x: 5, y: -9, rot: 12, cls: "late" },
|
||||||
|
{ x: -1, y: 5, rot: -5, cls: "later" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each TACKS as t (t.cls)}
|
||||||
|
<g transform={`translate(${c.x + t.x} ${c.y + t.y}) rotate(${t.rot})`}>
|
||||||
|
<g class={`tack ${t.cls}`}>
|
||||||
|
<circle cx="0" cy="3.8" r="3.9" class="head" />
|
||||||
|
<path d="M -2.1 1.5 L 0 -7.5 L 2.1 1.5 z" class="spike" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tack .head {
|
||||||
|
fill: #8a2f24;
|
||||||
|
stroke: #5c1c14;
|
||||||
|
stroke-width: 0.8;
|
||||||
|
}
|
||||||
|
.tack .spike { fill: #b9b3a4; stroke: #6e6450; stroke-width: 0.5; }
|
||||||
|
.tack {
|
||||||
|
opacity: 0;
|
||||||
|
animation: tack-hop 0.55s ease-out forwards;
|
||||||
|
}
|
||||||
|
.tack.late { animation-delay: 0.1s; }
|
||||||
|
.tack.later { animation-delay: 0.2s; }
|
||||||
|
@keyframes tack-hop {
|
||||||
|
0% { opacity: 0; transform: translateY(4px); }
|
||||||
|
22% { opacity: 1; transform: translateY(-10px); }
|
||||||
|
50% { transform: translateY(-2px); }
|
||||||
|
72% { transform: translateY(-7px); }
|
||||||
|
100% { opacity: 0; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g class="bush" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
|
||||||
|
<!-- Three springing branches, thorn spurs along each -->
|
||||||
|
<g class="branch b0" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
|
||||||
|
<path d={`M ${c.x} ${c.y + 10} q -8 -10 -13 -20`} class="stem" />
|
||||||
|
<path d={`M ${c.x - 6} ${c.y + 1} l -4 -1 M ${c.x - 10} ${c.y - 6} l -4 0`} class="spur" />
|
||||||
|
</g>
|
||||||
|
<g class="branch b1" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
|
||||||
|
<path d={`M ${c.x} ${c.y + 10} q 1 -13 -1 -24`} class="stem" />
|
||||||
|
<path d={`M ${c.x} ${c.y - 2} l 4 -2 M ${c.x - 1} ${c.y - 9} l -4 -2`} class="spur" />
|
||||||
|
</g>
|
||||||
|
<g class="branch b2" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
|
||||||
|
<path d={`M ${c.x} ${c.y + 10} q 9 -9 15 -18`} class="stem" />
|
||||||
|
<path d={`M ${c.x + 7} ${c.y + 2} l 4 -1 M ${c.x + 12} ${c.y - 4} l 4 1`} class="spur" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g class="leaves">
|
||||||
|
<ellipse cx={c.x - 12} cy={c.y - 10} rx="3" ry="1.6" class="leaf" transform={`rotate(-30 ${c.x - 12} ${c.y - 10})`} />
|
||||||
|
<ellipse cx={c.x + 13} cy={c.y - 8} rx="2.6" ry="1.4" class="leaf late" transform={`rotate(40 ${c.x + 13} ${c.y - 8})`} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stem {
|
||||||
|
fill: none;
|
||||||
|
stroke: #467832;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.spur {
|
||||||
|
stroke: #2f5423;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.branch { animation: spring 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) forwards; opacity: 0; }
|
||||||
|
.branch.b1 { animation-delay: 0.05s; }
|
||||||
|
.branch.b2 { animation-delay: 0.1s; }
|
||||||
|
.bush { animation: bush-fade 0.7s ease-out forwards; }
|
||||||
|
.leaf { fill: #7fae55; animation: leaf-fall 0.7s ease-in 0.15s forwards; opacity: 0; }
|
||||||
|
.leaf.late { animation-delay: 0.24s; }
|
||||||
|
@keyframes spring {
|
||||||
|
0% { opacity: 0; transform: scaleY(0.15); }
|
||||||
|
60% { opacity: 1; transform: scaleY(1.12); }
|
||||||
|
100% { opacity: 1; transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
@keyframes bush-fade {
|
||||||
|
0%, 70% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes leaf-fall {
|
||||||
|
0% { opacity: 0; transform: translateY(0) rotate(0deg); }
|
||||||
|
25% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: translateY(10px) rotate(50deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
|
||||||
|
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
const a = $derived(center(fx.from));
|
||||||
|
const b = $derived(center(fx.to));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={`wb-core-${uid}`}>
|
||||||
|
<stop offset="0" stop-color="#eafaff" />
|
||||||
|
<stop offset="0.35" stop-color="#9fd9f7" />
|
||||||
|
<stop offset="0.7" stop-color="#3b8dd6" />
|
||||||
|
<stop offset="1" stop-color="#155a9e" stop-opacity="0.2" />
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id={`wb-tail-${uid}`} x1="1" x2="0">
|
||||||
|
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.9" />
|
||||||
|
<stop offset="0.4" stop-color="#4fa3e3" stop-opacity="0.55" />
|
||||||
|
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id={`wb-glow-${uid}`} x="-160%" y="-160%" width="420%" height="420%">
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||||
|
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="waterbolt">
|
||||||
|
<g>
|
||||||
|
<!-- Spray tail -->
|
||||||
|
<path
|
||||||
|
d="M -5 -9 C -22 -12, -44 -6, -70 -2 C -46 0, -50 8, -74 13 C -44 11, -22 8, -5 9 Z"
|
||||||
|
fill={`url(#wb-tail-${uid})`}
|
||||||
|
/>
|
||||||
|
<!-- Body and core -->
|
||||||
|
<circle r="11" fill="#3b8dd6" opacity="0.6" filter={`url(#wb-glow-${uid})`} />
|
||||||
|
<circle r="9" fill={`url(#wb-core-${uid})`} />
|
||||||
|
<ellipse cx="3" cy="-2.5" rx="4" ry="3" fill="#f2fcff" opacity="0.9" />
|
||||||
|
<!-- Trailing droplets -->
|
||||||
|
<g fill="#7cc0ee">
|
||||||
|
<circle cx="-24" cy="-10" r="2">
|
||||||
|
<animate attributeName="cy" values="-10; -15; -10" dur="0.18s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="-40" cy="9" r="1.6">
|
||||||
|
<animate attributeName="cy" values="9; 14; 9" dur="0.22s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="-56" cy="-4" r="1.3">
|
||||||
|
<animate attributeName="opacity" values="1; 0.2; 1" dur="0.16s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.waterbolt {
|
||||||
|
opacity: 0;
|
||||||
|
animation: wb-fade 0.55s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes wb-fade {
|
||||||
|
0%, 70% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FxOf } from "../fx";
|
||||||
|
import { center } from "./geom";
|
||||||
|
let { fx }: { fx: FxOf<"whiff"> } = $props();
|
||||||
|
const c = $derived(center(fx.at));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g class="whiff" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||||
|
<path d={`M ${c.x - 30} ${c.y - 9} q 20 -7 58 -4`} pathLength="100" class="streak a" />
|
||||||
|
<path d={`M ${c.x - 32} ${c.y + 1} q 24 4 62 0`} pathLength="100" class="streak b" />
|
||||||
|
<path d={`M ${c.x - 28} ${c.y + 10} q 18 7 56 5`} pathLength="100" class="streak c" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.streak {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(110, 125, 140, 0.9);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 38 100;
|
||||||
|
stroke-dashoffset: 38;
|
||||||
|
animation: sweep 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
.streak.b { animation-delay: 0.06s; stroke-width: 3.6; }
|
||||||
|
.streak.c { animation-delay: 0.12s; stroke-width: 2.6; }
|
||||||
|
.whiff { animation: wobble 0.55s ease-in-out forwards; }
|
||||||
|
@keyframes sweep {
|
||||||
|
0% { stroke-dashoffset: 38; opacity: 0; }
|
||||||
|
18% { opacity: 0.95; }
|
||||||
|
100% { stroke-dashoffset: -100; opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes wobble {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
40% { transform: rotate(-4deg); }
|
||||||
|
75% { transform: rotate(2deg); }
|
||||||
|
100% { transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Whimsical flourishes: game events become short-lived board effects.
|
||||||
|
// Purely cosmetic — nothing here touches game state, and reduced-motion
|
||||||
|
// hides the whole layer.
|
||||||
|
|
||||||
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
|
type Cell = { x: number; y: number };
|
||||||
|
type Side = "N" | "E" | "S" | "W";
|
||||||
|
|
||||||
|
type FxShape =
|
||||||
|
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
|
||||||
|
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
||||||
|
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
||||||
|
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
|
||||||
|
| { kind: "portal"; cell: Cell; side: Side }
|
||||||
|
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
|
||||||
|
| { kind: "sector-slide"; from: Cell; to: Cell }
|
||||||
|
| { kind: "edge-dust"; cell: Cell; side: Side };
|
||||||
|
export type BoardFx = FxShape & { id: number };
|
||||||
|
/** The narrow type of one effect kind (for sprite components). */
|
||||||
|
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
/** How long each kind stays mounted (animation length + a little grace). */
|
||||||
|
export function fxTtl(kind: BoardFx["kind"]): number {
|
||||||
|
switch (kind) {
|
||||||
|
case "fireball": case "waterbolt": return 700;
|
||||||
|
case "bolt": case "streak": return 600;
|
||||||
|
case "portal": case "portal-cell":
|
||||||
|
case "soul": case "fireworks": case "chaos-swirl":
|
||||||
|
case "sector-spin": case "sector-slide": return 1500;
|
||||||
|
default: return 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTILES: Record<string, "fireball" | "bolt" | "waterbolt"> = {
|
||||||
|
fireball: "fireball",
|
||||||
|
"sudden-death": "fireball",
|
||||||
|
"blaster-wand": "fireball",
|
||||||
|
"lightning-blast": "bolt",
|
||||||
|
"power-drain": "bolt",
|
||||||
|
waterbolt: "waterbolt",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map one command's events to effects, each with a start delay in ms. */
|
||||||
|
export function fxForEvents(
|
||||||
|
events: GameEvent[], view: GameView,
|
||||||
|
): { fx: BoardFx; delay: number }[] {
|
||||||
|
const out: { fx: BoardFx; delay: number }[] = [];
|
||||||
|
const posOf = (playerId: string | null): Cell | null => {
|
||||||
|
if (!playerId) return null;
|
||||||
|
const p = view.players.find((p) => p.id === playerId);
|
||||||
|
return p ? { ...p.position } : null;
|
||||||
|
};
|
||||||
|
let beat = 0; // successive visuals from one command stagger slightly
|
||||||
|
const push = (fx: FxShape, extraDelay = 0) => {
|
||||||
|
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "spellCast": {
|
||||||
|
if (e.cardId === "chaos") {
|
||||||
|
push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const to = e.targetCell ?? posOf(e.target);
|
||||||
|
const projectile = PROJECTILES[e.cardId];
|
||||||
|
if (projectile && to) {
|
||||||
|
push({ kind: projectile, from: e.from, to });
|
||||||
|
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
|
||||||
|
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
|
||||||
|
beat++;
|
||||||
|
} else if (to && e.target) {
|
||||||
|
push({ kind: "sparkle", at: to });
|
||||||
|
beat++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "punched":
|
||||||
|
push({ kind: "pow", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "creatureAttacked": {
|
||||||
|
// The target may be a wizard or a fellow creature.
|
||||||
|
const at = posOf(typeof e.target === "string" ? e.target : null) ??
|
||||||
|
(() => {
|
||||||
|
const c = view.creatures.find((c) => c.id === e.target);
|
||||||
|
return c ? { ...c.position } : null;
|
||||||
|
})();
|
||||||
|
if (at) { push({ kind: "claw", at }); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "creatureTouched": {
|
||||||
|
const at = posOf(e.player);
|
||||||
|
if (at) { push({ kind: "claw", at }); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "attackAbsorbedIntoHand": {
|
||||||
|
const at = posOf(e.player);
|
||||||
|
if (at) { push({ kind: "absorb", at }); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "damaged": {
|
||||||
|
const at = posOf(e.player);
|
||||||
|
if (at) { push({ kind: "hit", at }); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "attackMissed": {
|
||||||
|
const at = posOf(e.defender);
|
||||||
|
if (at) { push({ kind: "whiff", at }); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "attackResolved": {
|
||||||
|
const back = posOf(e.attacker);
|
||||||
|
const stand = posOf(e.defender);
|
||||||
|
if ((e.redirected || e.reflectedDamage > 0) && back && stand) {
|
||||||
|
// The spell turns in the air and goes home.
|
||||||
|
const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt";
|
||||||
|
push({ kind, from: stand, to: back });
|
||||||
|
push({ kind: "hit", at: back }, 380);
|
||||||
|
beat++;
|
||||||
|
} else if (e.fullyStopped && stand) {
|
||||||
|
push({ kind: "shield", at: stand });
|
||||||
|
beat++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "teleported":
|
||||||
|
push({ kind: "shimmer", at: e.from });
|
||||||
|
push({ kind: "shimmer", at: e.to }, 200);
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "moved":
|
||||||
|
if (e.via === "warp") {
|
||||||
|
// The mouths are edges: shimmer the opening lines on both boards.
|
||||||
|
const w = view.board.warps.find((w) =>
|
||||||
|
w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction);
|
||||||
|
if (w) {
|
||||||
|
push({ kind: "portal", cell: w.from.cell, side: w.from.side });
|
||||||
|
push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150);
|
||||||
|
} else {
|
||||||
|
push({ kind: "portal-cell", at: e.from });
|
||||||
|
push({ kind: "portal-cell", at: e.to }, 150);
|
||||||
|
}
|
||||||
|
beat++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "warpStepped":
|
||||||
|
// Dimensional warp tokens fill their squares; the veil wraps them.
|
||||||
|
push({ kind: "portal-cell", at: e.from });
|
||||||
|
push({ kind: "portal-cell", at: e.to }, 150);
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "wallCreated":
|
||||||
|
case "wallDestroyed":
|
||||||
|
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "died": {
|
||||||
|
const at = posOf(e.player);
|
||||||
|
if (at) { push({ kind: "soul", at }, 250); beat++; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "gameWon": {
|
||||||
|
const home = view.players.find((p) => p.id === e.player)?.home;
|
||||||
|
if (home) {
|
||||||
|
push({ kind: "fireworks", at: home });
|
||||||
|
push({ kind: "fireworks", at: home }, 350);
|
||||||
|
push({ kind: "fireworks", at: home }, 700);
|
||||||
|
beat++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "knockedBack":
|
||||||
|
case "shoved":
|
||||||
|
push({ kind: "streak", from: e.from, to: e.to });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "objectDragged":
|
||||||
|
push({ kind: "streak", from: e.from, to: e.to });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "jumpedPit":
|
||||||
|
push({ kind: "streak", from: e.from, to: e.to });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "fellInPit":
|
||||||
|
push({ kind: "pit-fall", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "climbedFromPit": {
|
||||||
|
if (e.success) {
|
||||||
|
const at = posOf(e.player);
|
||||||
|
if (at) { push({ kind: "dust-puff", at }); beat++; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "slippedInOoze":
|
||||||
|
push({ kind: "ooze-slip", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "steppedOnTacks":
|
||||||
|
push({ kind: "tacks-ow", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "enteredThornbush":
|
||||||
|
push({ kind: "thorn-snap", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "stuckInSlime":
|
||||||
|
push({ kind: "slime-stuck", at: e.at });
|
||||||
|
beat++;
|
||||||
|
break;
|
||||||
|
case "sectorRotated": {
|
||||||
|
const origin = view.board.placements[e.sectorIndex]?.origin;
|
||||||
|
if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise });
|
||||||
|
// Everything after the grind points at pre-move ground; stop here.
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "sectorRelocated": {
|
||||||
|
// The event records pre-normalization origins; the view holds the
|
||||||
|
// truth. Shift the recorded start by the same correction.
|
||||||
|
const trueTo = view.board.placements[e.sectorIndex]?.origin;
|
||||||
|
if (trueTo) {
|
||||||
|
const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y;
|
||||||
|
push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ class LocalGame {
|
|||||||
/** The finished game as a reel, each step from its actor's own seat. */
|
/** The finished game as a reel, each step from its actor's own seat. */
|
||||||
/** The opening roll-off, shown once as the boards flip. */
|
/** The opening roll-off, shown once as the boards flip. */
|
||||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||||
|
/** Board flourishes: the app hooks in to animate command results. */
|
||||||
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||||
view = $derived(
|
view = $derived(
|
||||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||||
@@ -220,6 +222,7 @@ class LocalGame {
|
|||||||
this.log = [...this.log, `— ${result.error} —`];
|
this.log = [...this.log, `— ${result.error} —`];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.onFx?.(result.events);
|
||||||
this.gameState = result.state;
|
this.gameState = result.state;
|
||||||
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ class Net {
|
|||||||
/** A catch-up reel delivered by the server. */
|
/** A catch-up reel delivered by the server. */
|
||||||
/** The opening roll-off, shown once as the boards flip. */
|
/** The opening roll-off, shown once as the boards flip. */
|
||||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||||
|
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||||
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||||
private seen: Record<string, number> = loadSeen();
|
private seen: Record<string, number> = loadSeen();
|
||||||
/** Room whose live stream this connection has already shown once: states
|
/** Room whose live stream this connection has already shown once: states
|
||||||
@@ -342,6 +344,7 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "events": {
|
case "events": {
|
||||||
let talk = 0;
|
let talk = 0;
|
||||||
|
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||||
for (const e of msg.events as GameEvent[]) {
|
for (const e of msg.events as GameEvent[]) {
|
||||||
if (e.type === "tableTalk") talk++;
|
if (e.type === "tableTalk") talk++;
|
||||||
if (e.type === "gameStarted" && !msg.replayed) {
|
if (e.type === "gameStarted" && !msg.replayed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user