diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte
index 858c3a3..fe210f2 100644
--- a/packages/web/src/Board.svelte
+++ b/packages/web/src/Board.svelte
@@ -666,6 +666,74 @@
{@const c = center(fx.at)}
+ {:else if fx.kind === "streak"}
+ {@const a = center(fx.from)}
+ {@const b = center(fx.to)}
+
+ {:else if fx.kind === "soul"}
+ {@const c = center(fx.at)}
+
+
+
+
+
+ {:else if fx.kind === "fireworks"}
+ {@const c = center(fx.at)}
+
+ {#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
+
+ {/each}
+
+ {:else if fx.kind === "chaos-swirl"}
+ {@const c = center(fx.at)}
+
+
+
+
+
+ {:else if fx.kind === "pit-fall"}
+ {@const c = center(fx.at)}
+
+
+
+ {:else if fx.kind === "ooze-slip"}
+ {@const c = center(fx.at)}
+
+ {:else if fx.kind === "tacks-ow"}
+ {@const c = center(fx.at)}
+
+ ✱
+ ✱
+ ✱
+
+ {:else if fx.kind === "thorn-snap"}
+ {@const c = center(fx.at)}
+
+ {:else if fx.kind === "slime-stuck"}
+ {@const c = center(fx.at)}
+
+ {:else if fx.kind === "dust-puff"}
+ {@const c = center(fx.at)}
+
+
+
+ {:else if fx.kind === "sector-spin"}
+ {@const ox = fx.origin.x * CELL}
+ {@const oy = fx.origin.y * CELL}
+
+ {:else if fx.kind === "sector-slide"}
+ {@const fxp = fx.from.x * CELL}
+ {@const fyp = fx.from.y * CELL}
+
{:else if fx.kind === "edge-dust"}
{@const m = edgeMid(fx.cell, fx.side)}
@@ -931,6 +999,133 @@
animation: fx-ring 0.55s ease-out forwards;
}
.fx-warpglow.late { stroke: #7ac47e; animation-delay: 0.15s; opacity: 0; }
+ .fx-streak {
+ stroke: rgba(233, 225, 203, 0.85);
+ stroke-width: 5;
+ stroke-linecap: round;
+ stroke-dasharray: 14 9;
+ animation: fx-streak-fade 0.55s ease-out forwards;
+ }
+ @keyframes fx-streak-fade {
+ 0% { opacity: 0.9; stroke-dashoffset: 46; }
+ 100% { opacity: 0; stroke-dashoffset: 0; }
+ }
+ .fx-soul circle {
+ fill: rgba(233, 228, 245, 0.8);
+ stroke: rgba(160, 150, 200, 0.6);
+ stroke-width: 1;
+ }
+ .fx-soul { animation: fx-ascend 1.3s ease-out forwards; }
+ @keyframes fx-ascend {
+ 0% { opacity: 0; transform: translateY(0); }
+ 25% { opacity: 0.9; }
+ 100% { opacity: 0; transform: translateY(-34px); }
+ }
+ .fx-fireworks { animation: fx-fireworks-boom 1.1s ease-out forwards; }
+ @keyframes fx-fireworks-boom {
+ 0% { opacity: 0; transform: scale(0.1); }
+ 25% { opacity: 1; }
+ 100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
+ }
+ .fx-chaos circle {
+ fill: none;
+ stroke: #b48ae0;
+ stroke-width: 4;
+ stroke-dasharray: 30 22;
+ }
+ .fx-chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
+ .fx-chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
+ .fx-chaos { animation: fx-chaos-spin 1.4s ease-in-out forwards; }
+ @keyframes fx-chaos-spin {
+ 0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
+ 20% { opacity: 1; }
+ 100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
+ }
+ .fx-pitfall {
+ transform-box: fill-box;
+ transform-origin: center;
+ fill: rgba(30, 24, 16, 0.8);
+ animation: fx-swallow 0.7s ease-in forwards;
+ }
+ .fx-ooze {
+ transform-box: fill-box;
+ transform-origin: center;
+ fill: rgba(110, 160, 60, 0.55);
+ animation: fx-ooze-wobble 0.7s ease-out forwards;
+ }
+ @keyframes fx-ooze-wobble {
+ 0% { opacity: 0.9; transform: scaleX(0.6); }
+ 35% { transform: scaleX(1.25); }
+ 65% { transform: scaleX(0.9); }
+ 100% { opacity: 0; transform: scaleX(1.1); }
+ }
+ .fx-tacks text {
+ font-size: 13px;
+ fill: #b3372b;
+ animation: fx-hop 0.6s ease-out forwards;
+ }
+ .fx-tacks .late { animation-delay: 0.1s; opacity: 0; }
+ .fx-tacks .later { animation-delay: 0.2s; opacity: 0; }
+ @keyframes fx-hop {
+ 0% { opacity: 0; transform: translateY(2px); }
+ 30% { opacity: 1; transform: translateY(-5px); }
+ 60% { transform: translateY(-1px); }
+ 100% { opacity: 0; transform: translateY(-8px); }
+ }
+ .fx-thorn {
+ fill: rgba(70, 120, 50, 0.7);
+ stroke: #2f5423;
+ stroke-width: 1.5;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: fx-pow-hit 0.55s ease-out forwards;
+ }
+ .fx-slime {
+ transform-box: fill-box;
+ transform-origin: center;
+ fill: rgba(140, 200, 60, 0.5);
+ stroke: #7a9e2e;
+ stroke-width: 3;
+ animation: fx-slime-sink 0.9s ease-out forwards;
+ }
+ @keyframes fx-slime-sink {
+ 0% { opacity: 0.9; transform: scale(1.3); }
+ 100% { opacity: 0; transform: scale(0.7); }
+ }
+ .fx-sector-spin {
+ fill: rgba(233, 225, 203, 0.25);
+ stroke: #d3852b;
+ stroke-width: 3;
+ stroke-dasharray: 12 7;
+ animation: fx-grind-cw 1.2s ease-in-out forwards;
+ }
+ .fx-sector-spin.ccw { animation-name: fx-grind-ccw; }
+ @keyframes fx-grind-cw {
+ 0% { opacity: 0; transform: rotate(-90deg); }
+ 15% { opacity: 1; }
+ 85% { opacity: 1; transform: rotate(0deg); }
+ 100% { opacity: 0; }
+ }
+ @keyframes fx-grind-ccw {
+ 0% { opacity: 0; transform: rotate(90deg); }
+ 15% { opacity: 1; }
+ 85% { opacity: 1; transform: rotate(0deg); }
+ 100% { opacity: 0; }
+ }
+ .fx-sector-slide {
+ fill: rgba(233, 225, 203, 0.25);
+ stroke: #d3852b;
+ stroke-width: 3;
+ stroke-dasharray: 12 7;
+ animation: fx-slide-home 1.2s ease-in-out forwards;
+ }
+ @keyframes fx-slide-home {
+ 0% { opacity: 0; transform: translate(0, 0); }
+ 15% { opacity: 1; }
+ 85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
+ 100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
+ }
+
/* --- persistent ambiance: ongoing spells wear their look ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts
index 69863d8..2c09ae5 100644
--- a/packages/web/src/fx.ts
+++ b/packages/web/src/fx.ts
@@ -8,9 +8,12 @@ type Cell = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
type FxShape =
- | { kind: "fireball" | "bolt" | "waterbolt"; from: Cell; to: Cell }
+ | { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
- | "pow" | "claw" | "absorb" | "warp-glow"; at: Cell }
+ | "pow" | "claw" | "absorb" | "warp-glow" | "soul" | "fireworks" | "chaos-swirl"
+ | "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
+ | { 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 };
@@ -20,7 +23,9 @@ let nextId = 1;
export function fxTtl(kind: BoardFx["kind"]): number {
switch (kind) {
case "fireball": case "waterbolt": return 700;
- case "bolt": return 600;
+ case "bolt": case "streak": return 600;
+ case "soul": case "fireworks": case "chaos-swirl":
+ case "sector-spin": case "sector-slide": return 1500;
default: return 900;
}
}
@@ -52,6 +57,11 @@ export function fxForEvents(
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) {
@@ -136,10 +146,77 @@ export function fxForEvents(
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
- case "sectorRotated":
- case "sectorRelocated":
- // The whole maze moved; any effect would point at stale ground.
+ 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;
}