From 6da350fd461084b310ef7eb55895aaa7f4133787 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 01:02:37 -0400 Subject: [PATCH] Flourishes second wave: reflections, warps, claws, and ongoing looks The reflected spell turns in the air and flies home wearing its own colors, striking its caster with the hit it meant for someone else. Warp travel glows green at both mouths. Punches land as a proper burst star; creature blows rake three red gashes; an absorbed spell is a little card swallowed into the absorber, spinning as it shrinks. And ongoing spells now wear their look between events: firewalls lick and glow, the invisible and misted breathe at a fraction of opacity, Medusa's victims gray to stone, and the webbed wear their webs. The pulses stop under reduced motion; the informative looks stay. Co-Authored-By: Claude Fable 5 --- packages/web/src/Board.svelte | 93 +++++++++++++++++++++++++++++++++++ packages/web/src/fx.ts | 51 +++++++++++++++++-- 2 files changed, 139 insertions(+), 5 deletions(-) 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 });