diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte
index 345cfc2..858c3a3 100644
--- a/packages/web/src/Board.svelte
+++ b/packages/web/src/Board.svelte
@@ -126,6 +126,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;
@@ -504,12 +510,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")}
/>
+ {#if hasSpell(p.id, "sticky-web")}
+
+
+
+
+
+ {/if}
{:else}
{p.id[0]?.toUpperCase()}
@@ -631,6 +646,26 @@
{:else if fx.kind === "hit"}
{@const c = center(fx.at)}
+ {:else if fx.kind === "pow"}
+ {@const c = center(fx.at)}
+
+
+
+ {:else if fx.kind === "claw"}
+ {@const c = center(fx.at)}
+
+
+
+
+
+ {:else if fx.kind === "absorb"}
+ {@const c = center(fx.at)}
+
+ {:else if fx.kind === "warp-glow"}
+ {@const c = center(fx.at)}
+
+
{:else if fx.kind === "edge-dust"}
{@const m = edgeMid(fx.cell, fx.side)}
@@ -685,6 +720,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; }
@@ -863,6 +903,43 @@
stroke-width: 3.5;
animation: fx-ring-small 0.45s ease-out forwards;
}
+ .fx-pow path {
+ fill: #ffe94d;
+ stroke: #b3372b;
+ stroke-width: 1.6;
+ }
+ .fx-pow { animation: fx-pow-hit 0.5s ease-out forwards; }
+ .fx-claw line {
+ stroke: #b3372b;
+ stroke-width: 3;
+ stroke-linecap: round;
+ }
+ .fx-claw { animation: fx-claw-rake 0.55s ease-out forwards; }
+ .fx-absorb {
+ fill: #f6f0df;
+ stroke: #43331f;
+ stroke-width: 1.5;
+ animation: fx-swallow 0.65s ease-in forwards;
+ }
+ .fx-warpglow {
+ transform-box: fill-box;
+ transform-origin: center;
+ fill: none;
+ stroke: #2e7d32;
+ stroke-width: 3;
+ filter: drop-shadow(0 0 5px rgba(46, 125, 50, 0.8));
+ animation: fx-ring 0.55s ease-out forwards;
+ }
+ .fx-warpglow.late { stroke: #7ac47e; animation-delay: 0.15s; opacity: 0; }
+ /* --- 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; }
@@ -894,6 +971,21 @@
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; }
@@ -914,6 +1006,7 @@
@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 {
diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts
index b9045cb..69863d8 100644
--- a/packages/web/src/fx.ts
+++ b/packages/web/src/fx.ts
@@ -9,7 +9,8 @@ type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt"; from: Cell; to: Cell }
- | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"; at: Cell }
+ | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
+ | "pow" | "claw" | "absorb" | "warp-glow"; at: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
export type BoardFx = FxShape & { id: number };
@@ -65,9 +66,29 @@ export function fxForEvents(
break;
}
case "punched":
- push({ kind: "hit", at: e.at });
+ 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++; }
@@ -79,9 +100,17 @@ export function fxForEvents(
break;
}
case "attackResolved": {
- if (e.fullyStopped) {
- const at = posOf(e.defender);
- if (at) { push({ kind: "shield", at }); beat++; }
+ 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;
}
@@ -90,6 +119,18 @@ export function fxForEvents(
push({ kind: "shimmer", at: e.to }, 200);
beat++;
break;
+ case "moved":
+ if (e.via === "warp") {
+ push({ kind: "warp-glow", at: e.from });
+ push({ kind: "warp-glow", at: e.to }, 150);
+ beat++;
+ }
+ break;
+ case "warpStepped":
+ push({ kind: "warp-glow", at: e.from });
+ push({ kind: "warp-glow", at: e.to }, 150);
+ beat++;
+ break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });