SVG tokens render as vectors, not bitmaps of vectors

An svg referenced through an <image> element is rasterized once at
its layout size in user units — about 29 pixels for a wizard — and
scales as a blurry bitmap thereafter, defeating the entire point of
vector art. TokenArt.svelte now fetches each file once, namespaces
its ids (45 tokens' worth of filter and pattern defs share one
document), and inlines it as a nested <svg>: live vector at any
zoom, verified crisp at 3x device scale. PNG art keeps the <image>
path untouched, and the token style family goes :global to reach the
component's elements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 11:36:49 -04:00
co-authored by Claude Fable 5
parent c62c3b4f05
commit 1d015681d3
2 changed files with 84 additions and 26 deletions
+19 -26
View File
@@ -3,6 +3,7 @@
import type { Side } from "@wizwar/engine";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { tokenArt } from "./art";
import TokenArt from "./TokenArt.svelte";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48;
@@ -275,12 +276,11 @@
{@const tx = t.position.x * CELL + CELL / 2 + (group.length > 1 ? (ti - (group.length - 1) / 2) * CELL * 0.3 : 0)}
{@const ty = t.position.y * CELL + CELL * 0.72}
{#if USE_TOKEN_ART}
<image
<TokenArt
href={treasureArt(t.owner)}
x={tx - CELL * 0.26} y={ty - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art small"
cls="token-art small"
/>
{:else}
<g class="treasure-g">
@@ -298,12 +298,11 @@
{@const sx = Number(key.split(",")[0])}
{@const sy = Number(key.split(",")[1])}
{#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
<image
<TokenArt
href={tokenArt(TERRAIN_ART[content.kind]!, "terrain")}
x={sx * CELL + 3} y={sy * CELL + 3}
width={CELL - 6} height={CELL - 6}
preserveAspectRatio="xMidYMid slice"
class="token-art"
cls="token-art"
/>
{:else if content.kind === "stone"}
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
@@ -328,11 +327,11 @@
{#each view.dimWarps as w, wi (wi)}
{#each [w.a, w.b] as tok, i (i)}
{#if USE_TOKEN_ART}
<image
<TokenArt
href={tokenArt("dimensional-warp", "terrain")}
x={tok.x * CELL + CELL * 0.04} y={tok.y * CELL + CELL * 0.04}
width={CELL * 0.44} height={CELL * 0.44}
preserveAspectRatio="xMidYMid slice" class="token-art"
cls="token-art"
/>
{:else}
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
@@ -508,14 +507,11 @@
>
{#if USE_TOKEN_ART}
{@const half = CELL * 0.3 * wizardScale(p.id)}
<image
<TokenArt
href={wizardArt(p.id)}
x={-half} y={-half}
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")}
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`}
/>
<rect
x={-half} y={-half}
@@ -556,16 +552,13 @@
onkeydown={() => {}}
>
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
<image
<TokenArt
href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
class:selected-art={c.id === selectedCreatureId}
>
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</image>
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
/>
<rect
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
@@ -680,11 +673,11 @@
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
.token-art {
:global(.token-art) {
filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
pointer-events: none;
}
.token-art.hit { pointer-events: auto; cursor: pointer; }
:global(.token-art.hit) { pointer-events: auto; cursor: pointer; }
.creature-ring, .wizard-ring {
fill: none;
stroke-width: 2.5;
@@ -766,9 +759,9 @@
.mover.snap { transition: none; }
/* --- 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); }
: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;
@@ -840,7 +833,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; }
.firewall, :global(.token-art.ghosted) { animation: none; }
.mover { transition: none; }
}
.wizard { cursor: pointer; }
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts" module>
import { SvelteMap } from "svelte/reactivity";
// SVG art must be INLINED to stay vector: an svg referenced through an
// <image> element is rasterized once at its layout size in user units and
// scales as a bitmap — blurry the moment the board zooms. Fetched files
// are cached per URL, with their ids namespaced so 45 tokens' worth of
// filter/pattern defs cannot collide in one document.
type Entry = { vb: string; inner: string };
const cache = new SvelteMap<string, Entry | "pending">();
function load(href: string): void {
if (cache.has(href)) return;
cache.set(href, "pending");
fetch(href)
.then((r) => r.text())
.then((text) => {
const ns = href.replace(/[^a-zA-Z0-9]/g, "");
const t = text
.replace(/id="([^"]+)"/g, (_, id) => `id="${id}-${ns}"`)
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${id}-${ns})`)
.replace(/href="#([^"]+)"/g, (_, id) => `href="#${id}-${ns}"`);
const m = /<svg([^>]*)>([\s\S]*)<\/svg>/i.exec(t);
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
cache.set(href, { vb, inner: m?.[2] ?? "" });
})
.catch(() => cache.delete(href));
}
</script>
<script lang="ts">
let {
href, x, y, width, height, cls = "", title = null,
}: {
href: string;
x: number;
y: number;
width: number;
height: number;
cls?: string;
title?: string | null;
} = $props();
const isSvg = $derived(href.endsWith(".svg"));
$effect(() => {
if (isSvg) load(href);
});
const entry = $derived(isSvg ? cache.get(href) : undefined);
</script>
{#if isSvg}
{#if entry && entry !== "pending"}
<svg {x} {y} {width} {height} viewBox={entry.vb}
preserveAspectRatio="xMidYMid slice" class={cls}>
{#if title}<title>{title}</title>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags — our own art files -->
{@html entry.inner}
</svg>
{/if}
{:else}
<image {href} {x} {y} {width} {height}
preserveAspectRatio="xMidYMid slice" class={cls}>
{#if title}<title>{title}</title>{/if}
</image>
{/if}