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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 01:02:37 -04:00
co-authored by Claude Fable 5
parent 70d7b4babb
commit 6da350fd46
2 changed files with 139 additions and 5 deletions
+93
View File
@@ -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")}
/>
<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>
@@ -631,6 +646,26 @@
{:else if fx.kind === "hit"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="10" class="fx-hit" />
{:else if fx.kind === "pow"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "claw"}
{@const c = center(fx.at)}
<g class="fx-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>
{:else if fx.kind === "absorb"}
{@const c = center(fx.at)}
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="fx-absorb"
style={`transform-origin: ${c.x}px ${c.y}px`} />
{:else if fx.kind === "warp-glow"}
{@const c = center(fx.at)}
<circle cx={c.x} cy={c.y} r="8" class="fx-warpglow" />
<circle cx={c.x} cy={c.y} r="8" class="fx-warpglow late" />
{:else if fx.kind === "edge-dust"}
{@const m = edgeMid(fx.cell, fx.side)}
<circle cx={m.x - 6} cy={m.y} r="4" class="fx-dust" />
@@ -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 {
+46 -5
View File
@@ -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 });