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:
co-authored by
Claude Fable 5
parent
70d7b4babb
commit
6da350fd46
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user