The real 1993 token art takes the board

All 45 tokens cropped from Eric's photos of the physical counter
sheets by connected-component segmentation (with cable/reflection
filtering, merged-blob rescue, and one hand-tuned crop for the red
treasure the charging cable was hugging). Terrain squares, monsters,
wizards, treasures, floor objects, and dimensional warps now render
the genuine hand-inked art: solid stone, thornbush, rosebush, killer
ooze, dustcloud, slime, tacks, pit, safe, skeleton, troll, wraith,
fire imp, democratic monster, shadow, alter ego, dagger, rock,
master key, magic stones and wands, the six wizard standees, and the
six color-keyed treasure chests. Wizards and creatures wear a thin
controller-color ring; art tokens cast a soft cardboard shadow;
terrain art passes clicks through to the floor while wizards and
creatures stay clickable. Every vector rendering remains in place as
automatic fallback (USE_TOKEN_ART flag plus per-kind mapping), per
Eric's request. Sprites live in packages/web/public/tokens at 144px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 00:26:06 -04:00
co-authored by Claude Fable 5
parent 9323399b60
commit bd8ca99dc0
46 changed files with 143 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+118 -2
View File
@@ -27,6 +27,36 @@
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
// Real token art, cropped from the owner's physical set (public/tokens/).
// Anything without an entry falls back to the vector rendering below.
const USE_TOKEN_ART = true;
const TERRAIN_ART: Record<string, string> = {
stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
tacks: "tacks", pit: "pit", safe: "safe",
};
const CREATURE_ART: Record<string, string> = {
skeleton: "skeleton", troll: "troll", wraith: "wraith",
"fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
shadow: "shadow", "alter-ego": "alter-ego",
};
function objectArt(cardId: string): string | null {
if (cardId === "dagger") return "dagger";
if (cardId === "large-rock") return "rock";
if (cardId === "master-key") return "master-key";
if (cardId.endsWith("stone")) return "magic-stone";
if (cardId.endsWith("-wand")) return "magic-wand";
return null;
}
function wizardArt(id: string): string {
const idx = view.players.findIndex((p) => p.id === id);
return `/tokens/wizard-${((idx % 6) + 6) % 6}.png`;
}
function treasureArt(owner: string): string {
const idx = view.players.findIndex((p) => p.id === owner);
return `/tokens/treasure-${((idx % 6) + 6) % 6}.png`;
}
function playerColor(id: string): string { function playerColor(id: string): string {
const idx = view.players.findIndex((p) => p.id === id); const idx = view.players.findIndex((p) => p.id === id);
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!; return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
@@ -124,19 +154,37 @@
{#if t.position} {#if t.position}
{@const tx = t.position.x * CELL + CELL / 2} {@const tx = t.position.x * CELL + CELL / 2}
{@const ty = t.position.y * CELL + CELL * 0.72} {@const ty = t.position.y * CELL + CELL * 0.72}
{#if USE_TOKEN_ART}
<image
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"
/>
{:else}
<g class="treasure-g"> <g class="treasure-g">
<circle cx={tx} cy={ty} r={8} class="treasure" fill={playerColor(t.owner)} /> <circle cx={tx} cy={ty} r={8} class="treasure" fill={playerColor(t.owner)} />
<circle cx={tx} cy={ty} r={4.5} class="treasure-swirl" /> <circle cx={tx} cy={ty} r={4.5} class="treasure-swirl" />
<circle cx={tx} cy={ty} r={1.6} class="treasure-core" /> <circle cx={tx} cy={ty} r={1.6} class="treasure-core" />
</g> </g>
{/if} {/if}
{/if}
{/each} {/each}
<!-- terrain: solid stone and thornbushes --> <!-- terrain: solid stone and thornbushes -->
{#each Object.entries(view.squareContents) as [key, content] (key)} {#each Object.entries(view.squareContents) as [key, content] (key)}
{@const sx = Number(key.split(",")[0])} {@const sx = Number(key.split(",")[0])}
{@const sy = Number(key.split(",")[1])} {@const sy = Number(key.split(",")[1])}
{#if content.kind === "stone"} {#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
<image
href={`/tokens/${TERRAIN_ART[content.kind]}.png`}
x={sx * CELL + 3} y={sy * CELL + 3}
width={CELL - 6} height={CELL - 6}
preserveAspectRatio="xMidYMid slice"
class="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" /> <rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
{:else if content.kind === "thornbush" || content.kind === "rosebush"} {:else if content.kind === "thornbush" || content.kind === "rosebush"}
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36} <circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36}
@@ -158,8 +206,17 @@
<!-- dimensional warp tokens --> <!-- dimensional warp tokens -->
{#each view.dimWarps as w, wi (wi)} {#each view.dimWarps as w, wi (wi)}
{#each [w.a, w.b] as tok, i (i)} {#each [w.a, w.b] as tok, i (i)}
{#if USE_TOKEN_ART}
<image
href="/tokens/dimensional-warp.png"
x={tok.x * CELL + CELL * 0.16} y={tok.y * CELL + CELL * 0.16}
width={CELL * 0.68} height={CELL * 0.68}
preserveAspectRatio="xMidYMid slice" class="token-art"
/>
{:else}
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3} <circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
class="dimwarp" /> class="dimwarp" />
{/if}
{/each} {/each}
{/each} {/each}
@@ -176,12 +233,25 @@
{@const gx = Number(key.split(",")[0])} {@const gx = Number(key.split(",")[0])}
{@const gy = Number(key.split(",")[1])} {@const gy = Number(key.split(",")[1])}
{#each objects as o, i (o.instanceId)} {#each objects as o, i (o.instanceId)}
{@const art = USE_TOKEN_ART ? objectArt(o.cardId) : null}
{#if art}
<image
href={`/tokens/${art}.png`}
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice"
class="token-art small"
>
<title>{o.cardId}</title>
</image>
{:else}
<rect <rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16} x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object" width={12} height={10} rx="2" class="ground-object"
> >
<title>{o.cardId}</title> <title>{o.cardId}</title>
</rect> </rect>
{/if}
{/each} {/each}
{/each} {/each}
@@ -259,10 +329,25 @@
onkeydown={() => {}} onkeydown={() => {}}
class="wizard" class="wizard"
> >
{#if USE_TOKEN_ART}
<image
href={wizardArt(p.id)}
x={cx - CELL * 0.3} y={cy - CELL * 0.3}
width={CELL * 0.6} height={CELL * 0.6}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
/>
<rect
x={cx - CELL * 0.3} y={cy - CELL * 0.3}
width={CELL * 0.6} height={CELL * 0.6}
class="wizard-ring" stroke={playerColor(p.id)}
/>
{:else}
<circle {cx} {cy} r={12} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" /> <circle {cx} {cy} r={12} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text> <text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{/if}
{#if p.carriedTreasureId} {#if p.carriedTreasureId}
<circle cx={cx + 9} cy={cy + 9} r={5} class="carried" /> <circle cx={cx + CELL * 0.26} cy={cy + CELL * 0.26} r={5} class="carried" />
{/if} {/if}
</g> </g>
{/each} {/each}
@@ -277,6 +362,25 @@
onclick={(ev) => { ev.stopPropagation(); onCreatureClick?.(c.id); }} onclick={(ev) => { ev.stopPropagation(); onCreatureClick?.(c.id); }}
onkeydown={() => {}} onkeydown={() => {}}
> >
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
<image
href={`/tokens/${CREATURE_ART[c.kind]}.png`}
x={ccx - CELL * 0.26} y={ccy - 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>
<rect
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
/>
{:else}
<rect <rect
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3" x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`} transform={`rotate(45 ${ccx} ${ccy})`}
@@ -287,6 +391,7 @@
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title> <title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect> </rect>
<text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text> <text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g> </g>
{/each} {/each}
@@ -349,6 +454,17 @@
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; } .trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
.trap-real { stroke: #d3352b; stroke-width: 2.5; } .trap-real { stroke: #d3352b; stroke-width: 2.5; }
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; } .dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
.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; }
.creature-ring, .wizard-ring {
fill: none;
stroke-width: 2.5;
pointer-events: none;
}
.creature-ring.selected { stroke-width: 4; stroke-dasharray: 5 3; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; } .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; } .illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.creature { cursor: pointer; } .creature { cursor: pointer; }