Wall of Fire burns like it means it
The pulsing red rectangle becomes a standing fire: a glowing gradient bar seated on the wall line, five tongues of flame licking on staggered beats, and embers drifting off the top. Pure CSS on a handful of shapes - no turbulence filters, which stay budgeted for the one-shot Fireball, since firewalls burn indefinitely. Vertical walls flame just as well as horizontal ones; reduced motion stills the fire but keeps it visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1f03fee81
commit
ce556b5508
@@ -5,6 +5,7 @@
|
|||||||
import type { BoardFx } from "./fx";
|
import type { BoardFx } from "./fx";
|
||||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
|
import FirewallEdge from "./FirewallEdge.svelte";
|
||||||
import TokenArt from "./TokenArt.svelte";
|
import TokenArt from "./TokenArt.svelte";
|
||||||
import { FX_SPRITES } from "./fx-sprites";
|
import { FX_SPRITES } from "./fx-sprites";
|
||||||
|
|
||||||
@@ -382,23 +383,27 @@
|
|||||||
|
|
||||||
<!-- walls & doors & firewalls -->
|
<!-- walls & doors & firewalls -->
|
||||||
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
||||||
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
|
{#if e.state === "firewall"}
|
||||||
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} />
|
||||||
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
|
||||||
: e.lock === "held" ? "held open by a standing wizard"
|
|
||||||
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
|
||||||
{#if e.kind === "V"}
|
|
||||||
<rect
|
|
||||||
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
|
||||||
width={WALL} height={CELL + WALL}
|
|
||||||
class={cls}
|
|
||||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
|
||||||
{:else}
|
{:else}
|
||||||
<rect
|
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : "wall"}
|
||||||
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
||||||
width={CELL + WALL} height={WALL}
|
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
||||||
class={cls}
|
: e.lock === "held" ? "held open by a standing wizard"
|
||||||
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
||||||
|
{#if e.kind === "V"}
|
||||||
|
<rect
|
||||||
|
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
||||||
|
width={WALL} height={CELL + WALL}
|
||||||
|
class={cls}
|
||||||
|
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||||
|
{:else}
|
||||||
|
<rect
|
||||||
|
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
||||||
|
width={CELL + WALL} height={WALL}
|
||||||
|
class={cls}
|
||||||
|
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -707,16 +712,6 @@
|
|||||||
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
||||||
/* Held open: a wizard stands propping it. */
|
/* Held open: a wizard stands propping it. */
|
||||||
.door.held { fill: #d4bd8e; stroke: #2e7d32; }
|
.door.held { fill: #d4bd8e; stroke: #2e7d32; }
|
||||||
.firewall {
|
|
||||||
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; }
|
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||||
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
|
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
|
||||||
@@ -874,7 +869,7 @@
|
|||||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||||
.sight-line, .sight-mouth { animation: none; }
|
.sight-line, .sight-mouth { animation: none; }
|
||||||
.fx-layer { display: none; }
|
.fx-layer { display: none; }
|
||||||
.firewall, :global(.token-art.ghosted) { animation: none; }
|
:global(.token-art.ghosted) { animation: none; }
|
||||||
.mover { transition: none; }
|
.mover { transition: none; }
|
||||||
}
|
}
|
||||||
.wizard { cursor: pointer; }
|
.wizard { cursor: pointer; }
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A standing WALL OF FIRE: board furniture, not a flourish — it burns for
|
||||||
|
// as long as the spell holds, so the animation is pure CSS on a handful
|
||||||
|
// of shapes (no turbulence filters; those are budgeted for one-shot fx).
|
||||||
|
let { x, y, kind }: { x: number; y: number; kind: "V" | "H" } = $props();
|
||||||
|
const uid = $props.id();
|
||||||
|
const CELL = 48;
|
||||||
|
const WALL = 7;
|
||||||
|
const cx = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + CELL / 2);
|
||||||
|
const cy = $derived(kind === "V" ? y * CELL + CELL / 2 : (y + 1) * CELL);
|
||||||
|
// Tongues sit along the edge's axis; every flame rises screen-up.
|
||||||
|
const TONGUES = [
|
||||||
|
{ t: -0.38, h: 11, d: 0.0 },
|
||||||
|
{ t: -0.19, h: 15, d: 0.45 },
|
||||||
|
{ t: 0.0, h: 18, d: 0.15 },
|
||||||
|
{ t: 0.21, h: 13, d: 0.6 },
|
||||||
|
{ t: 0.4, h: 16, d: 0.3 },
|
||||||
|
];
|
||||||
|
const EMBERS = [
|
||||||
|
{ t: -0.28, d: 0.0 },
|
||||||
|
{ t: 0.08, d: 0.9 },
|
||||||
|
{ t: 0.33, d: 1.7 },
|
||||||
|
];
|
||||||
|
const px = (t: number) => (kind === "V" ? 0 : t * CELL);
|
||||||
|
const py = (t: number) => (kind === "V" ? t * CELL : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<g transform={`translate(${cx} ${cy})`} class="fw" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fw-bar-${uid}`} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7c1a14" />
|
||||||
|
<stop offset="0.5" stop-color="#ef3b08" />
|
||||||
|
<stop offset="1" stop-color="#ffad22" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`fw-tongue-${uid}`} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0" stop-color="#ef3b08" />
|
||||||
|
<stop offset="0.55" stop-color="#ff9d16" />
|
||||||
|
<stop offset="1" stop-color="#fff09a" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- the burning bar itself, seated on the wall line -->
|
||||||
|
{#if kind === "V"}
|
||||||
|
<rect x={-WALL / 2} y={-CELL / 2 - 2} width={WALL} height={CELL + 4} rx="3"
|
||||||
|
fill={`url(#fw-bar-${uid})`} class="fw-bar" />
|
||||||
|
{:else}
|
||||||
|
<rect x={-CELL / 2 - 2} y={-WALL / 2} width={CELL + 4} height={WALL} rx="3"
|
||||||
|
fill={`url(#fw-bar-${uid})`} class="fw-bar" />
|
||||||
|
{/if}
|
||||||
|
<!-- tongues of flame, each licking on its own beat -->
|
||||||
|
{#each TONGUES as f (f.t)}
|
||||||
|
<path
|
||||||
|
d={`M ${px(f.t) - 3.4} ${py(f.t) + 1}
|
||||||
|
C ${px(f.t) - 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) - 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t)} ${py(f.t) - f.h}
|
||||||
|
C ${px(f.t) + 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t) + 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) + 3.4} ${py(f.t) + 1} Z`}
|
||||||
|
fill={`url(#fw-tongue-${uid})`}
|
||||||
|
class="fw-tongue"
|
||||||
|
style={`transform-origin: ${px(f.t)}px ${py(f.t) + 1}px; animation-delay: ${f.d}s`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<!-- embers drifting off the top -->
|
||||||
|
{#each EMBERS as em (em.t)}
|
||||||
|
<circle cx={px(em.t)} cy={py(em.t) - 6} r="1.4" class="fw-ember"
|
||||||
|
style={`animation-delay: ${em.d}s`} />
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fw { pointer-events: none; }
|
||||||
|
.fw-bar {
|
||||||
|
stroke: #7c1a14;
|
||||||
|
stroke-width: 1;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(255, 140, 30, 0.7));
|
||||||
|
animation: fw-glow 1.3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.fw-tongue {
|
||||||
|
animation: fw-lick 0.9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.fw-ember {
|
||||||
|
fill: #ffd166;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fw-rise 2.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes fw-glow {
|
||||||
|
from { filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
||||||
|
to { filter: drop-shadow(0 0 6px rgba(255, 150, 40, 0.95)); }
|
||||||
|
}
|
||||||
|
@keyframes fw-lick {
|
||||||
|
from { transform: scale(0.95, 0.72); opacity: 0.82; }
|
||||||
|
to { transform: scale(1.04, 1.12); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fw-rise {
|
||||||
|
0% { opacity: 0; transform: translateY(0); }
|
||||||
|
15% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: translateY(-16px); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fw-bar, .fw-tongue, .fw-ember { animation: none; }
|
||||||
|
.fw-ember { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user