The board's furniture and the reel's fists, painted as cels

The last of the inked look. Walls carry stone joints and highlights,
doors their grain, hinges, and a brass lock plate whose keyhole turns
to a cross when jammed; battle scars fracture and branch with the
damage; illusion walls drift as tapered wisps with breathing glints;
the wall of fire burns in flat three-tone flames on a stepped flicker;
warp mouths and destinations are painted curls. Through a wizard's
eyes the punch is a three-tone hand with an ink contour and a
hand-cut POW, the same on every platform where the emoji glyph was
not, and the incoming fist no longer reverses late in its travel. The
inked stand-ins for missing conjuration files match. Goldens
re-blessed: every board frame, and the web's third ring.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 12:42:02 -04:00
co-authored by Claude Fable 5.1
parent 8dfd0e0c29
commit 34b621b5cb
22 changed files with 305 additions and 189 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+9 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
@@ -2103,7 +2104,14 @@
{view.stack.sourceName.toUpperCase()} — {view.stack.params?.damage ?? 0} damage inbound unless you answer it
</div>
{:else if view.stack.defenderId === view.you}
<div class="attack-fist">👊 a bare-knuckled punch</div>
<div class="attack-fist">
<svg viewBox="-55 -50 110 100" width="30" height="28" style="vertical-align: middle; margin-right: 4px" aria-hidden="true">
{#each FISTS.in as cel}
<path d={cel.d} fill={cel.fill ?? "none"} fill-rule="evenodd" stroke={cel.stroke ?? "#2b2218"} stroke-width={cel.width ?? 2.5} stroke-linejoin="round" stroke-linecap="round" />
{/each}
</svg>
a bare-knuckled punch
</div>
{:else if view.stack.counters.length > 0}
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
{/if}
+26 -22
View File
@@ -6,6 +6,7 @@
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { edgeScar, edgeWisps, webThreads, warpCurls } from "./boardArt";
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
@@ -464,11 +465,13 @@
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"}
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{:else}
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{/if}
{/each}
@@ -478,9 +481,9 @@
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
{#if kind === "V"}
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
{:else}
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
{/if}
{/each}
@@ -523,10 +526,7 @@
</g>
{#if standing}
<!-- show where this opening leads -->
<circle
cx={w.to.cell.x * CELL + CELL / 2} cy={w.to.cell.y * CELL + CELL / 2}
r={CELL * 0.42} class="warp-dest"
/>
<path d={warpCurls(w.to.cell.x*CELL+CELL/2, w.to.cell.y*CELL+CELL/2, CELL*.42)} class="warp-dest" />
{/if}
{/each}
@@ -592,9 +592,7 @@
/>
{#if hasSpell(p.id, "sticky-web")}
<g class="webbing">
<line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
<line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
<line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
<path d={webThreads(half)} />
</g>
{/if}
{:else}
@@ -796,7 +794,7 @@
}
.creature-ring.selected { stroke-width: 4; stroke-dasharray: 5 3; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.illusion { fill: #7a6f9a; stroke: none; opacity: 0.7; pointer-events: none; }
.creature { cursor: pointer; }
.creature-body { fill: #3b3428; stroke-width: 2.5; }
.creature-body.selected { fill: #6b5a34; }
@@ -914,7 +912,7 @@
fill: rgba(90, 44, 110, 0.05);
stroke: #7a4a8a;
stroke-width: 2;
stroke-dasharray: 8 6;
stroke-dasharray: 15 5 3 5;
pointer-events: none;
animation: fear-breathe 2.4s ease-in-out infinite;
}
@@ -929,16 +927,19 @@
:global(.token-art.ghosted) { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes -global-ghost-breathe { 50% { opacity: 0.22; } }
:global(.token-art.stone-gazed) { filter: grayscale(0.9) brightness(0.8); }
.webbing line {
stroke: rgba(220, 218, 205, 0.85);
stroke-width: 1.6;
.webbing { pointer-events: none; }
.webbing path {
fill: none;
stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25;
stroke-linecap: round;
pointer-events: none;
}
.warp-dest {
fill: none;
stroke: #2e7d32;
stroke-width: 2.5;
stroke-dasharray: 7 4;
stroke-linecap: round;
pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite;
}
@@ -947,7 +948,8 @@
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.marked-cell, .marked-sector, .ghost-slot, .warp-dest, .fear-aura { animation: none; }
.marked-cell, .marked-sector, .ghost-slot, .warp-dest, .fear-aura,
.spot-cell, .sight-corner, .sight-pierced { animation: none; }
.fx-layer { display: none; }
:global(.token-art.ghosted) { animation: none; }
.mover { transition: none; }
@@ -963,10 +965,12 @@
filter: drop-shadow(0.5px 1px 1px rgba(0, 0, 0, 0.55));
pointer-events: none;
}
.crack-shadow { fill: none; stroke: #6e5a3d; stroke-width: 2.8; stroke-linejoin: bevel; stroke-linecap: round; pointer-events: none; }
.crack {
fill: none;
stroke: #efe8d4;
stroke-width: 2;
stroke-dasharray: 3 5;
stroke-width: 1.4;
stroke-linejoin: bevel;
stroke-linecap: round;
pointer-events: none;
}
+28
View File
@@ -48,7 +48,35 @@
><title>{tip}</title></rect>
{/if}
<!-- Pigment and hardware sit over the original wall/door hit target. -->
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
{#if state === "wall"}
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
<path d="M -14 -2.5 l .5 2 -.8 2.5 M 1 -2.5 l -.5 2 .8 2.5 M 15 -2.5 l -.8 2 .5 2.5" class="stone-joints" />
{:else}
<path d="M -23 -1.6 Q -14 -.8 -7 -1.6 M 7 1.5 Q 15 .8 23 1.6" class="wood-grain" />
{#if lock !== "removed"}
<path d="M -16 -2.4 L -13 -2.4 -13 2.4 -16 2.4 Z M 13 -2.4 L 16 -2.4 16 2.4 13 2.4 Z" class="ironwork" />
<ellipse rx="3.2" ry="2.9" class="lock-plate" />
{#if lock === "jammed"}
<path d="M -1.3 -1.4 L 1.4 1.4 M 1.3 -1.4 L -1.4 1.4" class="jam-mark" />
{:else}
<path d="M -.75 .1 a 1 1 0 1 1 1.5 0 L 1 1.8 -1 1.8 Z" class="keyhole" />
{/if}
{/if}
{/if}
</g>
<style>
.edge-detail { pointer-events: none; }
.stone-light { fill: none; stroke: #fff7df; stroke-width: 0.7; }
.stone-joints { fill: none; stroke: #b7a786; stroke-width: 0.65; }
.wood-grain { fill: none; stroke: #5c3c20; stroke-width: 0.6; }
.ironwork { fill: #43331f; }
.lock-plate { fill: #c4a260; stroke: #43331f; stroke-width: 0.6; }
.keyhole { fill: #2b2218; }
.jam-mark { fill: none; stroke: #2b2218; stroke-width: 1; stroke-linecap: round; }
.wall {
fill: #f2ecd8;
stroke: #2b2218;
+23 -37
View File
@@ -1,7 +1,7 @@
<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).
// of flat cel shapes, with a short held-pose flicker.
import { holdToPeek } from "./peek";
let { x, y, kind, onpeek }: {
x: number; y: number; kind: "V" | "H";
@@ -10,7 +10,6 @@
} = $props();
const TIP = "a wall of fire — passing through burns for 4";
const { press, release } = holdToPeek(() => onpeek?.(TIP));
const uid = $props.id();
const CELL = 48;
const WALL = 7;
const cx = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + CELL / 2);
@@ -34,69 +33,56 @@
<g transform={`translate(${cx} ${cy})`} class="fw">
<title>{TIP}</title>
<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"
fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{:else}
<rect x={-CELL / 2 - 2} y={-WALL / 2} width={CELL + 4} height={WALL} rx="3"
fill={`url(#fw-bar-${uid})`} class="fw-bar"
fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if}
<!-- tongues of flame, each licking on its own beat -->
<!-- Drawn flame poses share the original bases and hit area. -->
{#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`}
/>
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
<path d={`M -3.5 1 Q -6 ${-f.h*.35} -1.7 ${-f.h*.61} Q 1 ${-f.h*.81} -.5 ${-f.h} Q 5 ${-f.h*.82} 2.5 ${-f.h*.47} Q 6 ${-f.h*.6} 5 ${-f.h*.75} Q 9 ${-f.h*.27} 3.4 1 Z`}
fill="#ef681e" stroke="#922e1a" stroke-width="0.65" stroke-linejoin="round" />
<path d={`M -1.8 1 Q -3 ${-f.h*.25} 1 ${-f.h*.58} Q .1 ${-f.h*.2} 3 ${-f.h*.34} Q 4 ${-f.h*.12} 1.8 1 Z`} fill="#ffd34e" />
<path d={`M -.5 1 Q -1 ${-f.h*.12} 1.2 ${-f.h*.28} L 1.4 1 Z`} fill="#fff09a" />
</g>
</g>
{/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`} />
<path d={`M ${px(em.t)} ${py(em.t)-9} q -3 3 -.5 5 q 3 -1 .5 -5 Z`} class="fw-ember"
style={`animation-delay: ${-em.d}s`} />
{/each}
</g>
<style>
.fw-tongue, .fw-ember { pointer-events: none; }
.fw-art, .fw-ember { 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;
animation: fw-coals 1.3s steps(2, end) infinite alternate;
}
.fw-tongue {
animation: fw-lick 0.9s ease-in-out infinite alternate;
transform-origin: 0px 1px;
animation: fw-lick 0.9s steps(3, end) 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-coals {
from { fill: #bd3b1b; }
to { fill: #d64b1c; }
}
@keyframes fw-lick {
from { transform: scale(0.95, 0.72); opacity: 0.82; }
to { transform: scale(1.04, 1.12); opacity: 1; }
from { transform: scale(0.95, 0.82) rotate(-4deg); opacity: 1; }
to { transform: scale(1.04, 1.08) rotate(3deg); opacity: 1; }
}
@keyframes fw-rise {
0% { opacity: 0; transform: translateY(0); }
+18 -8
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { edgeWisps } from "./boardArt";
import { glint } from "./fx-sprites/geom";
// An UNTESTED illusion wall: the wall renders solid underneath (it is a
// wall to you until your eyes rule) — this is the tell-tale shimmer laid
// over it, and the tap target that offers the belief test.
@@ -13,7 +15,12 @@
const y2 = $derived(kind === "V" ? (y + 1) * CELL - 2 : (y + 1) * CELL);
</script>
<line {x1} {y1} {x2} {y2} class="illusion-shimmer" />
<g class="illusion-art" aria-hidden="true">
<path d={edgeWisps(x1,y1,x2,y2)} class="illusion-shimmer" />
{#each [0.23, 0.78] as t, i (t)}
<path d={glint(x1+(x2-x1)*t,y1+(y2-y1)*t,i===0?2.6:1.8)} class="illusion-glint" style={`animation-delay: ${-i*.7}s`} />
{/each}
</g>
{#if onclick}
<line {x1} {y1} {x2} {y2} class="illusion-hit"
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
@@ -23,20 +30,23 @@
<style>
.illusion-shimmer {
stroke: #cfc3f0;
stroke-width: 3;
stroke-dasharray: 4 5;
fill: #cfc3f0;
stroke: #9d8cc1;
stroke-width: 0.4;
stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s linear infinite;
animation: shimmer-drift 1.4s ease-in-out infinite;
}
.illusion-art { pointer-events: none; }
.illusion-glint { fill: #efe6fa; animation: glint-breathe 1.4s ease-in-out infinite; }
@keyframes glint-breathe { 0%,100% { opacity: 0.9; } 50% { opacity: 0.25; } }
.illusion-hit { stroke: transparent; stroke-width: 14; cursor: pointer; }
@keyframes shimmer-drift {
0% { stroke-dashoffset: 0; opacity: 0.9; }
0% { opacity: 0.9; }
50% { opacity: 0.4; }
100% { stroke-dashoffset: -18; opacity: 0.9; }
100% { opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.illusion-shimmer { animation: none; opacity: 0.7; }
.illusion-shimmer, .illusion-glint { animation: none; opacity: 0.7; }
}
</style>
+7 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
// The sight line an attack traveled — straight when direct, leg by leg
// through both warp mouths (with pulsing rings) when the maze wrapped.
import { warpCurls } from "./boardArt";
import type { SightTrace } from "@wizwar/engine";
let { from, to, trace }: {
from: { x: number; y: number };
@@ -20,8 +21,8 @@
{@const exit = { x: trace.exit.x * CELL, y: trace.exit.y * CELL }}
<line x1={A.x} y1={A.y} x2={entry.x} y2={entry.y} class="sight-line" />
<line x1={exit.x} y1={exit.y} x2={B.x} y2={B.y} class="sight-line" />
<circle cx={entry.x} cy={entry.y} r={7} class="sight-mouth" />
<circle cx={exit.x} cy={exit.y} r={7} class="sight-mouth" />
<path d={warpCurls(entry.x,entry.y,7)} class="sight-mouth" />
<path d={warpCurls(exit.x,exit.y,7)} class="sight-mouth" />
{/if}
</g>
@@ -31,18 +32,19 @@
stroke: #c9a72a;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 7 6;
stroke-dasharray: 10 4 2 4;
opacity: 0.85;
animation: sight-march 0.8s linear infinite;
}
.sight-mouth {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
stroke-width: 1.8;
stroke-linecap: round;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-march {
to { stroke-dashoffset: -13; }
to { stroke-dashoffset: -20; }
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
+31
View File
@@ -0,0 +1,31 @@
/** Painted board details. Coordinates remain in the board's 48px cell space. */
const along = (x1: number, y1: number, x2: number, y2: number, t: number, offset = 0) => {
const dx = x2 - x1, dy = y2 - y1, length = Math.hypot(dx, dy) || 1;
return `${x1 + dx * t - dy / length * offset} ${y1 + dy * t + dx / length * offset}`;
};
/** Three separate tapered curls, close enough to the wall to leave cells clear. */
export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return [0.06, 0.38, 0.71].map((t, i) => {
const sign = i === 1 ? -1 : 1;
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`;
}).join(' ');
};
/** An irregular fracture with branches that become longer as damage increases. */
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`;
};
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
export const webThreads = (r: number) => {
const p = (x: number, y: number) => `${x*r} ${y*r}`;
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`;
};
/** Open painted curls around a warp's precise center. */
export const warpCurls = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`;
+16 -94
View File
@@ -4,8 +4,9 @@
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, smoothstep, surgeIntensity, type FpFx } from "./fx3d";
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d";
import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors";
@@ -589,40 +590,10 @@
if (s.glow) ctx.globalCompositeOperation = "source-over";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
// STICKY WAND's webs cling over the caught body: spokes from a hub
// and two sagging rings, each strand shadowed so the silk reads
// and three sagging rings, each strand shadowed so the silk reads
// against busy token art. Only when the walls show the sprite.
if (s.webbed && s.hit && seen) {
const w = s.right - s.left, h = s.bottom - s.top;
const hubX = s.left + w * 0.5, hubY = s.top + h * 0.45;
const rimU: [number, number][] = [
[0.02, 0.06], [0.5, 0.0], [0.98, 0.08], [1.0, 0.5],
[0.96, 0.94], [0.5, 1.0], [0.04, 0.92], [0.0, 0.5],
];
const rim: [number, number][] = rimU.map(([u, v]) => [s.left + u * w, s.top + v * h]);
const web = () => {
ctx.beginPath();
for (const [rx, ry] of rim) {
ctx.moveTo(hubX, hubY);
ctx.lineTo(rx, ry);
}
for (const t of [0.45, 0.75]) {
for (let i = 0; i <= rim.length; i++) {
const [rx, ry] = rim[i % rim.length]!;
const px = hubX + (rx - hubX) * t, py = hubY + (ry - hubY) * t + h * 0.02;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
}
ctx.stroke();
};
ctx.save();
ctx.strokeStyle = "rgba(25,25,30,0.45)";
ctx.lineWidth = 2.5;
web();
ctx.strokeStyle = "rgba(245,245,240,0.8)";
ctx.lineWidth = 1;
web();
ctx.restore();
paintWeb(ctx, s.left, s.top, s.right - s.left, s.bottom - s.top);
}
}
@@ -742,60 +713,19 @@
if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
const ease = smoothstep;
const out = f.swing === "out";
// Travel: out 0→0.42 lunge, 0.42→1 retract; in 0→0.45 approach, then hold and fade.
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : Math.min(1, ease(p / 0.45));
const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * 0.56, ty = H * 0.5;
const fx0 = sx + (tx - sx) * a, fy0 = sy + (ty - sy) * a;
const size = out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a);
const glyph = out ? "\u{1F91C}" : "\u{1F44A}";
const pose = punchPose(p, f.swing === "out", W, H);
ctx.save();
ctx.globalAlpha = !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1;
ctx.translate(fx0, fy0);
ctx.rotate(out ? -0.6 : 0.15);
ctx.font = `${Math.round(size)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.55)";
ctx.shadowBlur = 12;
ctx.shadowOffsetY = 6;
ctx.fillText(glyph, 0, 0);
ctx.globalAlpha = pose.alpha;
ctx.translate(pose.x, pose.y);
ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing);
ctx.restore();
// POW: pops at the landing, holds, and shrinks away.
const land = out ? 0.36 : 0.4;
if (p >= land) {
const q = (p - land) / (1 - land);
const pop = q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
if (pop > 0.01) {
const R = H * 0.19 * pop;
const cx = out ? tx - W * 0.02 : W * 0.5, cy = out ? ty - H * 0.16 : H * 0.34;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.14);
ctx.beginPath();
for (let i = 0; i < 24; i++) {
const r = i % 2 === 0 ? R : R * 0.68;
const t = (i / 24) * Math.PI * 2;
ctx.lineTo(Math.cos(t) * r * 1.35, Math.sin(t) * r);
}
ctx.closePath();
ctx.fillStyle = "#f2d24a";
ctx.strokeStyle = "#2b2218";
ctx.lineWidth = Math.max(2, R * 0.08);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#c03020";
ctx.strokeStyle = "#2b2218";
ctx.lineWidth = Math.max(1.5, R * 0.06);
ctx.font = `600 ${Math.round(R * 0.95)}px Oswald, "Archivo Narrow", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.strokeText("POW!", 0, R * 0.04);
ctx.fillText("POW!", 0, R * 0.04);
ctx.restore();
}
if (pose.radius > H * 0.19 * 0.01) {
ctx.save();
ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14);
paintPow(ctx, pose.radius);
ctx.restore();
}
}
@@ -846,15 +776,7 @@
* the stroke at `alpha`, the fill fainter. */
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
rgb: string, alpha: number, fill: number) {
ctx.save();
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
}
/** Paint the hover cue for whatever stands under the crosshair. */
+8 -22
View File
@@ -4,6 +4,7 @@
// camera shakes for the raycaster to draw. What happens ACROSS the room
// is watched; what happens to YOU is felt.
import { fallbackCels, paintCels } from "./paintedFx";
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
import { castRay, edgeMid, warpMotion } from "./raycast";
@@ -74,7 +75,7 @@ export function doorOpenness(p: number): number {
}
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
* transparent ground, drawn as a billboard mid-air. A procedural glow
* transparent ground, drawn as a billboard mid-air. An inked silhouette
* stands in wherever a file is missing. */
export const FX_ART = [
"fireball", "bolt", "waterbolt", "spark",
@@ -305,8 +306,7 @@ export function fpFxForEvents(
return out;
}
/** Procedural stand-ins for the conjuration sprites: a soft glow in each
* art's palette, replaced on screen the moment a painted file loads. */
/** Inked stand-ins in each original palette, replaced when the PNG loads. */
const FX_COLORS: Record<string, [string, string]> = {
fireball: ["#ffe27a", "#e0431a"],
bolt: ["#ffffff", "#7ab0ff"],
@@ -328,25 +328,11 @@ export function fxFallback(name: string): HTMLCanvasElement {
t.height = 64;
const c = t.getContext("2d")!;
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
if (name === "rubble") {
// A mound of broken stone, opaque — this one is debris, not light.
for (let i = 0; i < 9; i++) {
const jx = 12 + ((i * 37) % 40);
const jy = 40 + ((i * 23) % 18);
const r = 6 + ((i * 13) % 8);
c.fillStyle = i % 2 ? core : rim;
c.beginPath();
c.arc(jx, jy, r, 0, Math.PI * 2);
c.fill();
}
} else {
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, core);
g.addColorStop(0.55, rim);
g.addColorStop(1, "rgba(0,0,0,0)");
c.fillStyle = g;
c.fillRect(0, 0, 64, 64);
}
c.save();
c.translate(1, 1);
c.scale(0.62, 0.62);
paintCels(c, fallbackCels(name, core, rim));
c.restore();
baked.set(name, t);
}
return t;
+139
View File
@@ -0,0 +1,139 @@
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
const INK = "#2b2218";
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
/** Poses modeled on the original right-facing and oncoming fist emoji:
* folded fingers face the viewer; the thumb stays outside their curl. */
export const FISTS: Record<"out" | "in", Cel[]> = {
out: [
// Wrist at left, back of hand above, bent index at the right edge.
{ d: "M-48-28 Q-29-24-12-25 L24-24 Q41-24 45-15 Q48-7 48 7 L48 20 Q48 28 37 26 L33 24 Q39 31 33 36 Q29 40 17 33 L-6 24 Q-15 21-20 12 L-29 3 Q-36-1-48-2 Q-54-7-48-28Z", fill: ochre, width: 2.6 },
{ d: "M-46-24 Q-26-20-10-21 L23-20 Q35-21 40-14 L40-9 Q20-14 8-7 Q-4 3-12 8 L-23 0 Q-34-7-47-7Z", fill: light, stroke: "none" },
// The small recess between the folded index and the thumb.
{ d: "M20 0 Q25 1 29-4 Q28 2 31 10 L27 12 Q22 7 20 0Z", fill: shade, width: 1.6 },
{ d: "M32-8 Q32 5 39 16 Q42 20 45 18 L45 22 Q42 26 37 23 L27 14 Q24 6 32-8Z", fill: light, stroke: "none" },
{ d: "M31-7 Q27 3 29 9 Q32 18 39 23", width: 1.7 },
// The thumb crosses below the curled index, its tip pointing forward.
{ d: "M-6 9 Q3 17 16 22 L29 26 Q35 27 34 32 Q33 36 27 34 L10 27 -6 21 Q-14 17-18 10Z", fill: ochre, stroke: "none" },
{ d: "M-18 12 Q-10 22-2 24 L17 33 Q29 40 33 36 Q37 31 32 27 Q30 32 25 29 L7 22 Q-7 18-18 12Z", fill: shade, stroke: "none" },
{ d: "M20 0 Q22 6 27 11 L33 17", width: 1.8 },
],
in: [
// Thumb is behind the index finger, showing at the lower right.
{ d: "M26-9 Q40-6 43 10 Q45 22 35 34 Q30 42 16 40 L4 39 Q-3 38-3 32 Q-3 26 7 27 L23 23Z", fill: ochre, width: 2.6 },
{ d: "M36 7 Q42 17 33 29 Q26 36 9 34 L0 34 Q2 39 10 39 L22 40 Q36 39 42 22 Q45 11 36 7Z", fill: shade, stroke: "none" },
{ d: "M27 23 Q20 29 7 29 L3 32 Q20 33 30 27Z", fill: light, stroke: "none" },
// Four fingers folded toward the palm: no exposed nails or reverse bends.
{ d: "M-43-6 Q-43-18-33-18 Q-30-26-18-24 Q-11-37-1-33 Q6-27 12-30 Q26-36 32-25 Q39-13 38 7 L36 21 Q33 30 22 27 L16 25 Q9 33-3 29 Q-13 33-21 27 Q-31 31-35 23 Q-43 26-44 14Z", fill: ochre, width: 2.6 },
{ d: "M-39-9 Q-37-15-32-13 L-31 4 -33 18 -38 18Z M-27-17 Q-22-23-17-19 L-12-5 -13 20 Q-18 24-22 20 L-26 5Z M-9-24 Q-4-32 3-25 L9-11 10 18 Q6 25-1 21 L-6 6Z M16-25 Q23-30 28-21 Q32-10 32 7 L30 19 Q24 24 20 18 L17 0Z", fill: light, stroke: "none" },
// Narrow cel shadows describe the tucked fingertips and finger overlap.
{ d: "M-41 7 L-39 20 Q-36 23-32 21 L-30 18 Q-32 27-37 24 Q-43 25-43 13Z M-30-5 Q-29 11-27 19 Q-23 28-16 25 L-13 23 Q-16 31-23 28 Q-30 27-30 16Z M-12-12 Q-11 7-7 22 Q-3 29 7 25 L10 22 Q9 31-3 29 Q-10 29-11 19Z M12-15 Q16-1 15 17 Q14 26 23 27 Q35 29 36 20 L34 14 Q31 24 23 22 Q18 22 18 13Z", fill: shade, stroke: "none" },
{ d: "M-30-5 Q-30 8-27 19 M-12-12 Q-12 5-8 22 M12-15 Q15 0 14 18 Q14 24 18 25", width: 1.65 },
],
};
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */
export const POW: Cel[] = [
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
{ d: "M -82 30 L -76 -31 -51 -31 Q -30 -30 -34 -10 Q -37 5 -62 3 L -65 31 Z M -62 -16 L -64 -8 Q -46 -6 -47 -14 Q -47 -18 -62 -16 Z", fill: "#c03020", width: 2.4 },
{ d: "M -19 -31 Q 4 -34 7 -15 L 5 13 Q 3 32 -19 31 Q -40 28 -38 8 L -34 -15 Q -33 -28 -19 -31 Z M -20 -16 Q -24 -15 -25 -8 L -27 13 Q -26 20 -21 18 Q -10 19 -9 10 L -8 -9 Q -7 -18 -20 -16 Z", fill: "#c03020", width: 2.4 },
{ d: "M 10 -29 L 24 -29 22 7 31 -12 41 -11 43 6 52 -28 64 -27 48 32 34 30 31 11 22 30 9 29 Z M 72 -31 L 87 -29 77 11 68 9 Z M 66 18 L 79 20 76 32 64 30 Z", fill: "#c03020", width: 2.4 },
];
// Cache immutable path objects; the animation loop only paints them.
const paths = new Map<string, Path2D>();
export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
ctx.save();
ctx.lineJoin = "round"; ctx.lineCap = "round";
for (const cel of cels) {
let path = paths.get(cel.d);
if (!path) { path = new Path2D(cel.d); paths.set(cel.d, path); }
ctx.lineWidth = cel.width ?? 2.5;
if (cel.fill) { ctx.fillStyle = cel.fill; ctx.fill(path, "evenodd"); }
if (cel.stroke !== "none") { ctx.strokeStyle = cel.stroke ?? INK; ctx.stroke(path); }
}
ctx.restore();
}
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore();
}
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore();
}
/** Same endpoints and landing beats as the original animation. */
export function punchPose(p: number, out: boolean, W: number, H: number) {
const ease = (v: number) => v * v * (3 - 2 * v);
// Clamp before easing: otherwise the incoming fist reverses past p=.45.
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45));
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55;
const tx = W * .56, ty = H * .5;
const land = out ? .36 : .4, q = (p - land) / (1 - land);
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3);
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a),
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1,
rotation: out ? -.6 : .15, radius: H * .19 * pop,
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 };
}
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
const hub = [left + w * .5, top + h * .45];
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]);
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; };
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); }
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) {
const a = at(i,t), b = at(i+1,t);
ctx.moveTo(a[0]!,a[1]!);
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
}
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke();
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore();
}
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) {
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill();
ctx.beginPath();
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]];
for (const [start,end] of segments) for (let i=0; i<=20; i++) {
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5));
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k;
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke(); ctx.restore();
}
/** Small, legible loading/error sprites using the original fallback palettes. */
export function fallbackCels(name: string, core: string, rim: string): Cel[] {
const outer: Record<string, string> = {
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
bolt: "M62 4 L23 51 44 52 31 96 81 39 57 42 78 4Z",
waterbolt: "M50 4 C45 27 17 44 18 66 C19 104 84 106 84 65 Q85 44 50 4Z",
spark: "M50 3 L60 37 88 18 71 44 98 53 65 60 80 91 55 71 42 99 38 65 6 78 28 53 2 39 38 36Z",
burst: "M50 2 L60 27 81 9 77 37 100 34 80 54 99 79 69 72 66 100 48 80 22 97 24 72 1 72 22 50 3 23 33 29Z",
splash: "M5 73 Q19 53 8 30 Q34 31 33 58 Q42 51 40 12 Q64 15 58 53 Q72 37 83 29 Q89 48 73 67 Q89 60 98 77 Q61 102 5 73Z",
hit: "M9 31 L35 35 46 3 59 35 92 16 75 48 100 63 72 68 75 97 49 77 23 98 27 68 2 62 23 49Z",
shimmer: "M42 11 C4 17 5 76 44 81 C69 85 87 70 77 52 C72 40 54 42 55 56 C59 48 66 52 65 58 C63 72 32 68 29 49 C24 32 35 20 42 11Z M70 6 L76 21 92 25 77 30 72 45 66 30 52 25 66 21Z",
shield: "M50 4 Q65 19 88 19 L84 56 Q78 82 50 96 Q20 80 16 56 L12 19 Q35 19 50 4Z",
rubble: "M2 96 L4 84 18 75 30 79 34 68 48 62 64 70 67 80 80 74 93 81 98 98Z",
};
const inside: Record<string,string> = {
fireball:"M33 76 Q24 63 45 40 Q44 57 54 55 L66 34 Q84 65 62 80 Q44 90 33 76Z",
bolt:"M61 17 L36 47 52 47 43 75 69 46 49 50Z",
waterbolt:"M47 25 Q27 49 28 65 Q29 81 43 84 Q30 62 47 25Z",
spark:"M50 30 L56 46 73 52 56 58 48 76 42 58 24 51 43 45Z",
burst:"M49 29 L59 45 78 46 64 61 64 79 47 69 30 80 31 60 17 47 38 45Z",
splash:"M20 74 Q41 66 43 43 Q54 66 73 69 L87 77 Q54 92 20 74Z",
hit:"M47 30 L55 47 72 44 63 59 65 76 48 65 35 77 38 59 23 51 42 48Z",
shimmer:"M24 32 Q14 64 46 73 Q65 77 73 62 Q63 72 44 66 Q25 58 24 32Z",
shield:"M50 17 Q63 27 77 29 L73 55 Q69 72 50 83 Q30 70 27 53 L23 29 Q40 27 50 17Z",
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
};
const kind = outer[name] ? name : "spark";
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}];
}