From 1d015681d3a9928301ffb6195c41c7a975e7c217 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 11:36:49 -0400 Subject: [PATCH] SVG tokens render as vectors, not bitmaps of vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An svg referenced through an 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 : live vector at any zoom, verified crisp at 3x device scale. PNG art keeps the path untouched, and the token style family goes :global to reach the component's elements. Co-Authored-By: Claude Fable 5 --- packages/web/src/Board.svelte | 45 ++++++++++------------ packages/web/src/TokenArt.svelte | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 packages/web/src/TokenArt.svelte diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 29ad938..11e08b6 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -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} - {:else} @@ -298,12 +298,11 @@ {@const sx = Number(key.split(",")[0])} {@const sy = Number(key.split(",")[1])} {#if USE_TOKEN_ART && TERRAIN_ART[content.kind]} - {:else if content.kind === "stone"} @@ -328,11 +327,11 @@ {#each view.dimWarps as w, wi (wi)} {#each [w.a, w.b] as tok, i (i)} {#if USE_TOKEN_ART} - {:else} {#if USE_TOKEN_ART} {@const half = CELL * 0.3 * wizardScale(p.id)} - {}} > {#if USE_TOKEN_ART && CREATURE_ART[c.kind]} - - {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg - + cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`} + title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`} + /> + import { SvelteMap } from "svelte/reactivity"; + + // SVG art must be INLINED to stay vector: an svg referenced through an + // 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(); + + 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 = /]*)>([\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)); + } + + + + +{#if isSvg} + {#if entry && entry !== "pending"} + + {#if title}{title}{/if} + + {@html entry.inner} + + {/if} +{:else} + + {#if title}{title}{/if} + +{/if}