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:
co-authored by
Claude Fable 5
parent
c62c3b4f05
commit
1d015681d3
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user