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 });