Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59ecfa615 | ||
|
|
0fcbff4dc6 | ||
|
|
a82b25b0e8 | ||
|
|
68b0da6301 | ||
|
|
6da350fd46 | ||
|
|
70d7b4babb | ||
|
|
bca34ae9e4 |
@@ -6,6 +6,8 @@
|
||||
import Card from "./Card.svelte";
|
||||
import Help from "./Help.svelte";
|
||||
import Replay from "./Replay.svelte";
|
||||
import FxGallery from "./FxGallery.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
@@ -27,6 +29,22 @@
|
||||
}
|
||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||
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);
|
||||
function dismissRolls() {
|
||||
net.openingRolls = null;
|
||||
@@ -869,6 +887,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if new URLSearchParams(location.search).has("fx")}
|
||||
<FxGallery />
|
||||
{:else}
|
||||
<div class="table">
|
||||
<header class="masthead">
|
||||
<span class="mast-title">Wiz-War</span>
|
||||
@@ -1251,6 +1272,7 @@
|
||||
onWarpClick={clickWarp}
|
||||
markedCell={tradeFrom ?? pendingTeleport}
|
||||
markedCells={trapCells.length > 0 ? trapCells : null}
|
||||
effects={boardFx}
|
||||
markedSector={pendingSectorOrigin}
|
||||
ghostSlots={relocateGhosts}
|
||||
onGhostClick={clickGhostSlot}
|
||||
@@ -1645,6 +1667,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(html, body) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
@@ -22,6 +23,7 @@
|
||||
markedSector = null,
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -46,8 +48,12 @@
|
||||
* beyond the maze, since an empty slot has no floor of its own to click. */
|
||||
ghostSlots?: { x: number; y: number }[] | null;
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: import("./fx").BoardFx[] | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
* negative coordinates (the maze renormalizes after the landing). The
|
||||
@@ -100,6 +106,12 @@
|
||||
if (cardId.endsWith("-wand")) return "magic-wand";
|
||||
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.
|
||||
function wizardScale(id: string): number {
|
||||
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}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art hit"
|
||||
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
|
||||
class:stone-gazed={hasSpell(p.id, "medusa")}
|
||||
/>
|
||||
<rect
|
||||
x={cx - half} y={cy - half}
|
||||
width={half * 2} height={half * 2}
|
||||
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}
|
||||
<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>
|
||||
@@ -566,6 +587,13 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
@@ -612,6 +640,11 @@
|
||||
fill: #d0342c;
|
||||
stroke: #7c1a14;
|
||||
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; }
|
||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||
@@ -705,6 +738,69 @@
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.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 {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
@@ -719,6 +815,8 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
.firewall, .token-art.ghosted { animation: none; }
|
||||
}
|
||||
.wizard { cursor: pointer; }
|
||||
.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">
|
||||
import Board from "./Board.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -20,6 +21,21 @@
|
||||
);
|
||||
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(() => {
|
||||
if (!playing) return;
|
||||
const t = setInterval(() => {
|
||||
@@ -47,7 +63,7 @@
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<Board view={step.view} />
|
||||
<Board view={step.view} effects={boardFx} />
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"absorb"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="absorb"
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||
|
||||
<style>
|
||||
.absorb {
|
||||
fill: #f6f0df;
|
||||
stroke: #43331f;
|
||||
stroke-width: 1.5;
|
||||
animation: swallow 0.65s ease-in forwards;
|
||||
}
|
||||
@keyframes swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"bolt"> } = $props();
|
||||
/** A fresh jagged path every strike. */
|
||||
const points = $derived.by(() => {
|
||||
const a = center(fx.from), b = center(fx.to);
|
||||
const segs = 6;
|
||||
const pts: string[] = [`${a.x},${a.y}`];
|
||||
for (let i = 1; i < segs; i++) {
|
||||
const t = i / segs;
|
||||
pts.push(`${(a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16).toFixed(1)},${(a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16).toFixed(1)}`);
|
||||
}
|
||||
pts.push(`${b.x},${b.y}`);
|
||||
return pts.join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<polyline {points} class="bolt" />
|
||||
|
||||
<style>
|
||||
.bolt {
|
||||
fill: none;
|
||||
stroke: #ffe94d;
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
|
||||
animation: flicker 0.5s steps(2, jump-none) forwards;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"burst"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="4" class="burst" />
|
||||
<circle cx={c.x} cy={c.y} r="4" class="burst late" />
|
||||
|
||||
<style>
|
||||
.burst {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #ff8c1a;
|
||||
stroke-width: 4;
|
||||
animation: ring 0.5s ease-out 0.05s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<circle cx={c.x} cy={c.y} r="30" />
|
||||
<circle cx={c.x} cy={c.y} r="55" class="mid" />
|
||||
<circle cx={c.x} cy={c.y} r="80" class="outer" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.chaos circle {
|
||||
fill: none;
|
||||
stroke: #b48ae0;
|
||||
stroke-width: 4;
|
||||
stroke-dasharray: 30 22;
|
||||
}
|
||||
.chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
|
||||
.chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
|
||||
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
|
||||
@keyframes chaos-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"claw"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="claw">
|
||||
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
|
||||
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
|
||||
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.claw line {
|
||||
stroke: #b3372b;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.claw { animation: rake 0.55s ease-out forwards; }
|
||||
@keyframes rake {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL, center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
|
||||
const c = $derived.by(() => {
|
||||
if (fx.kind === "dust-puff") return center(fx.at);
|
||||
const mid = center(fx.cell);
|
||||
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
|
||||
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
|
||||
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
|
||||
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
|
||||
});
|
||||
</script>
|
||||
|
||||
<circle cx={c.x - 6} cy={c.y} r="4" class="dust" />
|
||||
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="dust late" />
|
||||
<circle cx={c.x} cy={c.y + 4} r="3.5" class="dust later" />
|
||||
|
||||
<style>
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
|
||||
.dust.late { animation-delay: 0.09s; }
|
||||
.dust.later { animation-delay: 0.17s; }
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"fireball"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<circle r="7" class="fireball" cx="0" cy="0">
|
||||
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||
</circle>
|
||||
|
||||
<style>
|
||||
.fireball {
|
||||
fill: #ff8c1a;
|
||||
stroke: #ffd27a;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
|
||||
animation: fade 0.55s ease-in forwards;
|
||||
}
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"fireworks"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
|
||||
</script>
|
||||
|
||||
<g class="fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
|
||||
<circle
|
||||
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
|
||||
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
|
||||
r="3" fill={COLORS[(deg / 45) % 4]}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.fireworks { animation: boom 1.1s ease-out forwards; }
|
||||
@keyframes boom {
|
||||
0% { opacity: 0; transform: scale(0.1); }
|
||||
25% { opacity: 1; }
|
||||
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"hit"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="10" class="hit" />
|
||||
|
||||
<style>
|
||||
.hit {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c0392b;
|
||||
stroke-width: 3.5;
|
||||
animation: ring-small 0.45s ease-out forwards;
|
||||
}
|
||||
@keyframes ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="ooze" />
|
||||
|
||||
<style>
|
||||
.ooze {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(110, 160, 60, 0.55);
|
||||
animation: wobble 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes wobble {
|
||||
0% { opacity: 0.9; transform: scaleX(0.6); }
|
||||
35% { transform: scaleX(1.25); }
|
||||
65% { transform: scaleX(0.9); }
|
||||
100% { opacity: 0; transform: scaleX(1.1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="10" class="pitfall" />
|
||||
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="dust" />
|
||||
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="dust late" />
|
||||
|
||||
<style>
|
||||
.pitfall {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(30, 24, 16, 0.8);
|
||||
animation: swallow 0.7s ease-in forwards;
|
||||
}
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: drift 0.7s ease-out forwards; }
|
||||
.dust.late { animation-delay: 0.09s; }
|
||||
@keyframes swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL } from "./geom";
|
||||
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
|
||||
</script>
|
||||
|
||||
{#if fx.kind === "portal"}
|
||||
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
|
||||
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
|
||||
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
|
||||
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
|
||||
<line {x1} {y1} {x2} {y2} class="portal glow" />
|
||||
<line {x1} {y1} {x2} {y2} class="portal" />
|
||||
{:else}
|
||||
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
|
||||
width={CELL - 6} height={CELL - 6} rx="6" class="veil" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.portal {
|
||||
stroke: #9be3e0;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 6 5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
|
||||
animation: shimmer 0.95s ease-in-out forwards;
|
||||
}
|
||||
.portal.glow {
|
||||
stroke: rgba(180, 138, 224, 0.5);
|
||||
stroke-width: 9;
|
||||
stroke-dasharray: none;
|
||||
filter: blur(2px);
|
||||
animation: breathe 0.95s ease-in-out forwards;
|
||||
}
|
||||
.veil {
|
||||
fill: rgba(155, 227, 224, 0.12);
|
||||
stroke: #9be3e0;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 7 5;
|
||||
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
|
||||
animation: shimmer 0.95s ease-in-out forwards;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { opacity: 0; stroke-dashoffset: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 0.85; }
|
||||
100% { opacity: 0; stroke-dashoffset: 34; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0% { opacity: 0; }
|
||||
30% { opacity: 0.8; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"pow"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.pow path {
|
||||
fill: #ffe94d;
|
||||
stroke: #b3372b;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.pow { animation: pow-hit 0.5s ease-out forwards; }
|
||||
@keyframes pow-hit {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { CELL, SECTOR } from "./geom";
|
||||
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
|
||||
</script>
|
||||
|
||||
{#if fx.kind === "sector-spin"}
|
||||
{@const ox = fx.origin.x * CELL}
|
||||
{@const oy = fx.origin.y * CELL}
|
||||
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||
class={`spin ${fx.clockwise ? "cw" : "ccw"}`}
|
||||
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
|
||||
{:else}
|
||||
{@const fxp = fx.from.x * CELL}
|
||||
{@const fyp = fx.from.y * CELL}
|
||||
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||
class="slide"
|
||||
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spin, .slide {
|
||||
fill: rgba(233, 225, 203, 0.25);
|
||||
stroke: #d3852b;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 12 7;
|
||||
}
|
||||
.spin { animation: grind-cw 1.2s ease-in-out forwards; }
|
||||
.spin.ccw { animation-name: grind-ccw; }
|
||||
.slide { animation: slide-home 1.2s ease-in-out forwards; }
|
||||
@keyframes grind-cw {
|
||||
0% { opacity: 0; transform: rotate(-90deg); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: rotate(0deg); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes grind-ccw {
|
||||
0% { opacity: 0; transform: rotate(90deg); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: rotate(0deg); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes slide-home {
|
||||
0% { opacity: 0; transform: translate(0, 0); }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
|
||||
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"shield"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="16" class="shield" />
|
||||
|
||||
<style>
|
||||
.shield {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 3.5;
|
||||
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
|
||||
animation: pulse 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"shimmer"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="6" class="shimmer" />
|
||||
<circle cx={c.x} cy={c.y} r="6" class="shimmer late" />
|
||||
|
||||
<style>
|
||||
.shimmer {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #b48ae0;
|
||||
stroke-width: 2.5;
|
||||
animation: ring 0.6s ease-out forwards;
|
||||
}
|
||||
.shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="12" class="slime" />
|
||||
|
||||
<style>
|
||||
.slime {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(140, 200, 60, 0.5);
|
||||
stroke: #7a9e2e;
|
||||
stroke-width: 3;
|
||||
animation: sink 0.9s ease-out forwards;
|
||||
}
|
||||
@keyframes sink {
|
||||
0% { opacity: 0.9; transform: scale(1.3); }
|
||||
100% { opacity: 0; transform: scale(0.7); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"soul"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="soul">
|
||||
<circle cx={c.x} cy={c.y - 4} r="8" />
|
||||
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
|
||||
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.soul circle {
|
||||
fill: rgba(233, 228, 245, 0.8);
|
||||
stroke: rgba(160, 150, 200, 0.6);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.soul { animation: ascend 1.3s ease-out forwards; }
|
||||
@keyframes ascend {
|
||||
0% { opacity: 0; transform: translateY(0); }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-34px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"sparkle"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.sparkle path {
|
||||
fill: #e8dfc6;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 1;
|
||||
animation: fade 0.8s ease-in forwards;
|
||||
}
|
||||
.sparkle { animation: spin 0.8s linear forwards; }
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"splash"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="4" class="splash" />
|
||||
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="droplet" />
|
||||
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="droplet late" />
|
||||
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="droplet later" />
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #4fa3e3;
|
||||
stroke-width: 3.5;
|
||||
animation: ring 0.55s ease-out forwards;
|
||||
}
|
||||
.droplet { fill: #7cc0ee; animation: drop 0.6s ease-out forwards; }
|
||||
.droplet.late { animation-delay: 0.08s; }
|
||||
.droplet.later { animation-delay: 0.15s; }
|
||||
@keyframes ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"streak"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="streak" />
|
||||
|
||||
<style>
|
||||
.streak {
|
||||
stroke: rgba(233, 225, 203, 0.85);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 14 9;
|
||||
animation: streak-fade 0.55s ease-out forwards;
|
||||
}
|
||||
@keyframes streak-fade {
|
||||
0% { opacity: 0.9; stroke-dashoffset: 46; }
|
||||
100% { opacity: 0; stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<g class="tacks">
|
||||
<text x={c.x - 10} y={c.y - 4}>✱</text>
|
||||
<text x={c.x + 4} y={c.y - 10} class="late">✱</text>
|
||||
<text x={c.x - 2} y={c.y + 6} class="later">✱</text>
|
||||
</g>
|
||||
|
||||
<style>
|
||||
.tacks text {
|
||||
font-size: 13px;
|
||||
fill: #b3372b;
|
||||
animation: hop 0.6s ease-out forwards;
|
||||
}
|
||||
.tacks .late { animation-delay: 0.1s; opacity: 0; }
|
||||
.tacks .later { animation-delay: 0.2s; opacity: 0; }
|
||||
@keyframes hop {
|
||||
0% { opacity: 0; transform: translateY(2px); }
|
||||
30% { opacity: 1; transform: translateY(-5px); }
|
||||
60% { transform: translateY(-1px); }
|
||||
100% { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<path class="thorn"
|
||||
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
|
||||
|
||||
<style>
|
||||
.thorn {
|
||||
fill: rgba(70, 120, 50, 0.7);
|
||||
stroke: #2f5423;
|
||||
stroke-width: 1.5;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: snap 0.55s ease-out forwards;
|
||||
}
|
||||
@keyframes snap {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
|
||||
const a = $derived(center(fx.from));
|
||||
const b = $derived(center(fx.to));
|
||||
</script>
|
||||
|
||||
<circle r="6" class="waterbolt" cx="0" cy="0">
|
||||
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||
</circle>
|
||||
|
||||
<style>
|
||||
.waterbolt {
|
||||
fill: #3b8dd6;
|
||||
stroke: #b9e2ff;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
|
||||
animation: fade 0.55s ease-in forwards;
|
||||
}
|
||||
@keyframes fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { FxOf } from "../fx";
|
||||
import { center } from "./geom";
|
||||
let { fx }: { fx: FxOf<"whiff"> } = $props();
|
||||
const c = $derived(center(fx.at));
|
||||
</script>
|
||||
|
||||
<circle cx={c.x} cy={c.y} r="8" class="whiff" />
|
||||
|
||||
<style>
|
||||
.whiff {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(180, 180, 180, 0.4);
|
||||
animation: drift 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
</style>
|
||||
@@ -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 opening roll-off, shown once as the boards flip. */
|
||||
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);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
@@ -220,6 +222,7 @@ class LocalGame {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
return;
|
||||
}
|
||||
this.onFx?.(result.events);
|
||||
this.gameState = result.state;
|
||||
this.commands = [...this.commands, { playerId: this.viewerId, command }];
|
||||
const now = Date.now();
|
||||
|
||||
@@ -253,6 +253,8 @@ class Net {
|
||||
/** A catch-up reel delivered by the server. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
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);
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
@@ -342,6 +344,7 @@ class Net {
|
||||
break;
|
||||
case "events": {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") talk++;
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
|
||||
Reference in New Issue
Block a user