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"];
// 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 {
const idx = view.players.findIndex((p) => p.id === id);
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
@@ -124,19 +154,37 @@
{#if t.position}
{@const tx = t.position.x * CELL + CELL / 2}
{@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">
<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={1.6} class="treasure-core" />
</g>
{/if}
{/if}
{/each}
<!-- terrain: solid stone and thornbushes -->
{#each Object.entries(view.squareContents) as [key, content] (key)}
{@const sx = Number(key.split(",")[0])}
{@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" />
{:else if content.kind === "thornbush" || content.kind === "rosebush"}
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36}
@@ -158,8 +206,17 @@
<!-- dimensional warp tokens -->
{#each view.dimWarps as w, wi (wi)}
{#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}
class="dimwarp" />
{/if}
{/each}
{/each}
@@ -176,12 +233,25 @@
{@const gx = Number(key.split(",")[0])}
{@const gy = Number(key.split(",")[1])}
{#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
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object"
>
<title>{o.cardId}</title>
</rect>
{/if}
{/each}
{/each}
@@ -259,10 +329,25 @@
onkeydown={() => {}}
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" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{/if}
{#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}
</g>
{/each}
@@ -277,6 +362,25 @@
onclick={(ev) => { ev.stopPropagation(); onCreatureClick?.(c.id); }}
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
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`}
@@ -287,6 +391,7 @@
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</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>
{/if}
</g>
{/each}
@@ -349,6 +454,17 @@
.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 {
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; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.creature { cursor: pointer; }