From 9c88ebf3e4274c9ac1e91070906cb4d8c499fca7 Mon Sep 17 00:00:00 2001
From: Michael O'Rourke
Date: Wed, 8 Apr 2026 14:12:54 -0600
Subject: [PATCH] feat: replace WebP item icons with ASCII art rendering across
all UI
Add ItemAsciiIcon component and itemAsciiArt subtype templates that
render weapons/armor as ASCII art via MonsterAsciiRenderer. Consumables
and spells fall back to WebP/emoji. Updated 9 components: ItemCard,
EquippedLoadout, ShopItemRow, LootReveal, MarketplaceRow, OrderRow,
MarketplaceItem, ConsumableQuickUse, CharacterCreation.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../src/components/ConsumableQuickUse.tsx | 22 +-
.../client/src/components/EquippedLoadout.tsx | 27 +-
.../client/src/components/ItemAsciiIcon.tsx | 102 ++++
packages/client/src/components/ItemCard.tsx | 66 +--
packages/client/src/components/LootReveal.tsx | 33 +-
.../client/src/components/MarketplaceRow.tsx | 26 +-
packages/client/src/components/OrderRow.tsx | 30 +-
.../client/src/components/ShopItemRow.tsx | 52 +-
.../components/pretext/game/itemAsciiArt.ts | 484 ++++++++++++++++++
.../client/src/pages/CharacterCreation.tsx | 9 +-
packages/client/src/pages/MarketplaceItem.tsx | 35 +-
11 files changed, 676 insertions(+), 210 deletions(-)
create mode 100644 packages/client/src/components/ItemAsciiIcon.tsx
create mode 100644 packages/client/src/components/pretext/game/itemAsciiArt.ts
diff --git a/packages/client/src/components/ConsumableQuickUse.tsx b/packages/client/src/components/ConsumableQuickUse.tsx
index 091bf5c4d..648756d34 100644
--- a/packages/client/src/components/ConsumableQuickUse.tsx
+++ b/packages/client/src/components/ConsumableQuickUse.tsx
@@ -1,11 +1,11 @@
-import { Box, Center, HStack, Image, Text, Tooltip, VStack, useDisclosure } from '@chakra-ui/react';
+import { Box, Center, HStack, Text, Tooltip, VStack, useDisclosure } from '@chakra-ui/react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCharacter } from '../contexts/CharacterContext';
import { useMap } from '../contexts/MapContext';
import { getStatSymbol, removeEmoji } from '../utils/helpers';
-import { getConsumableEmoji, getItemImage } from '../utils/itemImages';
+import { ItemAsciiIcon } from './ItemAsciiIcon';
import { getRarityColor } from '../utils/rarityHelpers';
import { type Consumable } from '../utils/types';
@@ -81,8 +81,6 @@ export const ConsumableQuickUse = (): JSX.Element | null => {
{usableConsumables.map(({ consumable, isEquipped }) => {
const name = removeEmoji(consumable.name);
- const imageSrc = getItemImage(name);
- const emoji = getConsumableEmoji(name);
const rarityColor = getRarityColor(consumable.rarity);
return (
@@ -101,16 +99,12 @@ export const ConsumableQuickUse = (): JSX.Element | null => {
h={TILE_SIZE}
w={TILE_SIZE}
>
- {imageSrc ? (
-
- ) : (
- {emoji}
- )}
+
{/* Quantity badge */}
- {imageSrc ? (
-
- ) : (
-
- {item.itemType === ItemType.Consumable
- ? getConsumableEmoji(removeEmoji(item.name))
- : getEmoji(item.name)}
-
- )}
+
;
+ /** Optional alt text */
+ alt?: string;
+};
+
+/** Cell size for ASCII rendering — smaller = more detail */
+const CELL_SIZE = 4;
+
+const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px', alt }: Props) => {
+ const cleanName = removeEmoji(name);
+ const isWeaponOrArmor = itemType === ItemType.Weapon || itemType === ItemType.Armor;
+
+ const template = useMemo(() => {
+ if (!isWeaponOrArmor) return null;
+ const type = itemType === ItemType.Weapon ? 'weapon' : 'armor';
+ return getItemAsciiTemplate(cleanName, type, rarity);
+ }, [cleanName, itemType, rarity, isWeaponOrArmor]);
+
+ const onFrame = useCallback(
+ (ctx: CanvasRenderingContext2D) => {
+ if (!template) return;
+ const { width, height } = ctx.canvas.getBoundingClientRect();
+ ctx.clearRect(0, 0, width, height);
+ renderMonster(ctx, template, 0, 0, width, height, {
+ cellSize: CELL_SIZE,
+ enable3D: false,
+ enableGlow: rarity >= 3,
+ enableBgFill: true,
+ });
+ },
+ [template, rarity],
+ );
+
+ const { canvasRef } = useCanvas({ onFrame, static: true });
+
+ // Weapons and armor get ASCII rendering
+ if (isWeaponOrArmor && template) {
+ return (
+
+
+ );
+ }
+
+ // Consumables and spells fall back to WebP image or emoji
+ const imageSrc = getItemImage(cleanName);
+ if (imageSrc) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {itemType === ItemType.Consumable
+ ? getConsumableEmoji(cleanName)
+ : getEmoji(name)}
+
+ );
+};
+
+export const ItemAsciiIcon = memo(ItemAsciiIconInner);
diff --git a/packages/client/src/components/ItemCard.tsx b/packages/client/src/components/ItemCard.tsx
index 5e0da182d..65a210d44 100644
--- a/packages/client/src/components/ItemCard.tsx
+++ b/packages/client/src/components/ItemCard.tsx
@@ -1,10 +1,10 @@
-import { Box, Center, HStack, Image, keyframes, Stack, Text, Tooltip, VStack } from '@chakra-ui/react';
+import { Box, Center, HStack, keyframes, Stack, Text, Tooltip, VStack } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useEffect, useMemo, useRef, useState } from 'react';
-import { getEmoji, removeEmoji } from '../utils/helpers';
-import { getConsumableEmoji, getItemImage } from '../utils/itemImages';
+import { removeEmoji } from '../utils/helpers';
import { getRarityAnimation, getRarityColor, getRarityGlow, getRarityName } from '../utils/rarityHelpers';
+import { ItemAsciiIcon } from './ItemAsciiIcon';
import {
type Armor,
type Consumable,
@@ -239,20 +239,12 @@ export const ItemCard: React.FC = ({
}
>
- {getItemImage(removeEmoji(name)) ? (
-
- ) : (
-
- {item.itemType === ItemType.Consumable
- ? getConsumableEmoji(removeEmoji(name))
- : getEmoji(name)}
-
- )}
+
@@ -301,20 +293,12 @@ export const ItemCardSmall: React.FC = ({
w="100%"
>
- {getItemImage(removeEmoji(item.name)) ? (
-
- ) : (
-
- {item.itemType === ItemType.Consumable
- ? getConsumableEmoji(removeEmoji(item.name))
- : getEmoji(item.name)}
-
- )}
+
@@ -341,20 +325,12 @@ export const ItemCardSmall: React.FC = ({
w="100%"
>
- {getItemImage(removeEmoji(item.name)) ? (
-
- ) : (
-
- {item.itemType === ItemType.Consumable
- ? getConsumableEmoji(removeEmoji(item.name))
- : getEmoji(item.name)}
-
- )}
+
diff --git a/packages/client/src/components/LootReveal.tsx b/packages/client/src/components/LootReveal.tsx
index d8f0fd714..ea44f6027 100644
--- a/packages/client/src/components/LootReveal.tsx
+++ b/packages/client/src/components/LootReveal.tsx
@@ -1,9 +1,9 @@
-import { Box, HStack, Image, keyframes, Text, VStack } from '@chakra-ui/react';
+import { Box, HStack, keyframes, Text, VStack } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { getEmoji, removeEmoji } from '../utils/helpers';
-import { getItemImage } from '../utils/itemImages';
+import { removeEmoji } from '../utils/helpers';
+import { ItemAsciiIcon } from './ItemAsciiIcon';
import {
type Armor,
type Consumable,
@@ -219,7 +219,6 @@ export const LootReveal: React.FC = ({ items, onItemClick }) =>
const rarity = item.rarity ?? 0;
const color = RARITY_COLORS[rarity as Rarity] ?? RARITY_COLORS[Rarity.Common];
const itemName = removeEmoji(item.name);
- const itemImage = getItemImage(itemName);
const isLegendary = rarity === Rarity.Legendary;
const isEpic = rarity === Rarity.Epic;
const isRare = rarity === Rarity.Rare;
@@ -290,26 +289,12 @@ export const LootReveal: React.FC = ({ items, onItemClick }) =>
textAlign="center"
w={{ base: '52px', sm: '60px' }}
>
- {itemImage ? (
-
- ) : (
-
- {getEmoji(item.name)}
-
- )}
+
{/* Sparkles for epic+ */}
{isEpic && (
diff --git a/packages/client/src/components/MarketplaceRow.tsx b/packages/client/src/components/MarketplaceRow.tsx
index 338da76c2..dcbc7844e 100644
--- a/packages/client/src/components/MarketplaceRow.tsx
+++ b/packages/client/src/components/MarketplaceRow.tsx
@@ -1,10 +1,8 @@
import {
- Avatar,
Box,
Button,
Flex,
HStack,
- Image,
Text,
Tooltip,
VStack,
@@ -20,8 +18,8 @@ import { useMUD } from '../contexts/MUDContext';
import { useTransaction } from '../hooks/useTransaction';
import { useOrders } from '../contexts/OrdersContext';
import { ITEM_PATH } from '../Routes';
-import { etherToFixedNumber, getEmoji, removeEmoji } from '../utils/helpers';
-import { getItemImage } from '../utils/itemImages';
+import { etherToFixedNumber, removeEmoji } from '../utils/helpers';
+import { ItemAsciiIcon } from './ItemAsciiIcon';
import {
type ArmorTemplate,
type ConsumableTemplate,
@@ -174,20 +172,14 @@ export const MarketplaceRow = ({
>
{/* Left: Item info */}
- {getItemImage(removeEmoji(name)) ? (
-
+
- ) : (
-
- {getEmoji(name)}
-
- )}
+
- {getItemImage(removeEmoji(item.name)) ? (
-
+
- ) : (
-
- {getEmoji(item.name)}
-
- )}
+
- {name && getItemImage(removeEmoji(name.toString())) ? (
-
+
- ) : (
-
- {name ? getEmoji(name.toString()) : ''}
-
- )}
+
- {name && getItemImage(removeEmoji(name.toString())) ? (
-
- ) : (
-
- {name ? getEmoji(name.toString()) : ''}
-
- )}
+
{item?.description || ''}
diff --git a/packages/client/src/components/pretext/game/itemAsciiArt.ts b/packages/client/src/components/pretext/game/itemAsciiArt.ts
new file mode 100644
index 000000000..7aa87cd65
--- /dev/null
+++ b/packages/client/src/components/pretext/game/itemAsciiArt.ts
@@ -0,0 +1,484 @@
+/**
+ * itemAsciiArt.ts — Subtype-based silhouette templates for item ASCII rendering.
+ *
+ * Each weapon/armor subtype gets a draw function that paints a recognizable
+ * silhouette onto a canvas. The MonsterAsciiRenderer converts these to ASCII
+ * art for inline UI display (item cards, loadouts, shop, etc.).
+ *
+ * Draw functions paint warm-toned shapes on a black background:
+ * (ctx, w, h) => void
+ *
+ * Rarity controls brightness: R0 is dim/monochrome, R4 is vivid with accents.
+ */
+
+import type { MonsterTemplate } from './monsterTemplates';
+
+// ── Weapon subtype classification (mirrors item-forge.mjs) ────────────
+
+export type WeaponSubtype = 'sword' | 'axe' | 'mace' | 'dagger' | 'bow' | 'staff' | 'wand' | 'spear';
+export type ArmorSubtype = 'cloth' | 'leather' | 'plate';
+export type ItemSubtype = WeaponSubtype | ArmorSubtype;
+
+export function classifyWeaponSubtype(name: string): WeaponSubtype {
+ const n = name.toLowerCase();
+ if (/bow/.test(n)) return 'bow';
+ if (/staff/.test(n)) return 'staff';
+ if (/wand|rod/.test(n)) return 'wand';
+ if (/axe|cleaver/.test(n)) return 'axe';
+ if (/hammer|maul|cudgel|mace/.test(n)) return 'mace';
+ if (/dagger|fang|shard|phasefang/.test(n)) return 'dagger';
+ if (/spear|lance/.test(n)) return 'spear';
+ return 'sword';
+}
+
+export function classifyArmorSubtype(name: string): ArmorSubtype {
+ const n = name.toLowerCase();
+ if (/cloth|robe|vestment|cowl|mantle|wraps|tattered|frostweave|mistcloak|ember/.test(n)) return 'cloth';
+ if (/leather|jerkin|vest|scout|ranger|stalker|hide|shroud|stormhide|galebound|phantom/.test(n)) return 'leather';
+ return 'plate';
+}
+
+// ── Rarity color palette ──────────────────────────────────────────────
+
+type RarityPalette = { base: string; accent: string; glow: string };
+
+const RARITY_PALETTES: RarityPalette[] = [
+ { base: '#6B6560', accent: '#8A8478', glow: 'none' }, // R0: dim gray
+ { base: '#8A7E6A', accent: '#A89A82', glow: 'none' }, // R1: warm gray
+ { base: '#7A9A6A', accent: '#9AB882', glow: 'none' }, // R2: green tint
+ { base: '#5A88B8', accent: '#7AA8D8', glow: '#5A88B820' }, // R3: blue
+ { base: '#9A6AB8', accent: '#BA8AD8', glow: '#9A6AB830' }, // R4: purple
+];
+
+function getPalette(rarity: number): RarityPalette {
+ return RARITY_PALETTES[Math.min(rarity, 4)];
+}
+
+// ── Draw helpers ──────────────────────────────────────────────────────
+
+function fillBg(ctx: CanvasRenderingContext2D, w: number, h: number) {
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(0, 0, w, h);
+}
+
+// ── Weapon draw functions ─────────────────────────────────────────────
+
+function drawSword(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2, cy = h / 2;
+
+ // Blade
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.08);
+ ctx.lineTo(cx + w * 0.08, h * 0.62);
+ ctx.lineTo(cx - w * 0.08, h * 0.62);
+ ctx.closePath();
+ ctx.fill();
+
+ // Fuller (center line)
+ ctx.fillStyle = pal.base;
+ ctx.fillRect(cx - w * 0.02, h * 0.15, w * 0.04, h * 0.42);
+
+ // Crossguard
+ ctx.fillStyle = pal.base;
+ ctx.fillRect(cx - w * 0.22, h * 0.60, w * 0.44, h * 0.06);
+
+ // Grip
+ ctx.fillStyle = '#4A3A28';
+ ctx.fillRect(cx - w * 0.05, h * 0.66, w * 0.10, h * 0.20);
+
+ // Pommel
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.90, w * 0.06, 0, Math.PI * 2);
+ ctx.fill();
+}
+
+function drawAxe(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Shaft
+ ctx.fillStyle = '#5A4A38';
+ ctx.fillRect(cx - w * 0.04, h * 0.15, w * 0.08, h * 0.75);
+
+ // Axe head
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx + w * 0.04, h * 0.12);
+ ctx.quadraticCurveTo(cx + w * 0.40, h * 0.20, cx + w * 0.38, h * 0.42);
+ ctx.lineTo(cx + w * 0.04, h * 0.42);
+ ctx.closePath();
+ ctx.fill();
+
+ // Edge highlight
+ ctx.strokeStyle = pal.base;
+ ctx.lineWidth = w * 0.02;
+ ctx.beginPath();
+ ctx.moveTo(cx + w * 0.38, h * 0.18);
+ ctx.quadraticCurveTo(cx + w * 0.42, h * 0.30, cx + w * 0.38, h * 0.42);
+ ctx.stroke();
+}
+
+function drawMace(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Handle
+ ctx.fillStyle = '#5A4A38';
+ ctx.fillRect(cx - w * 0.04, h * 0.45, w * 0.08, h * 0.48);
+
+ // Head
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.28, w * 0.22, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Flanges
+ ctx.fillStyle = pal.base;
+ for (let i = 0; i < 4; i++) {
+ const angle = (i * Math.PI) / 2 - Math.PI / 4;
+ ctx.beginPath();
+ ctx.moveTo(cx + Math.cos(angle) * w * 0.16, h * 0.28 + Math.sin(angle) * w * 0.16);
+ ctx.lineTo(cx + Math.cos(angle) * w * 0.30, h * 0.28 + Math.sin(angle) * w * 0.30);
+ ctx.lineTo(cx + Math.cos(angle + 0.3) * w * 0.16, h * 0.28 + Math.sin(angle + 0.3) * w * 0.16);
+ ctx.closePath();
+ ctx.fill();
+ }
+}
+
+function drawDagger(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Blade (shorter, curved)
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.12);
+ ctx.quadraticCurveTo(cx + w * 0.15, h * 0.30, cx + w * 0.06, h * 0.52);
+ ctx.lineTo(cx - w * 0.06, h * 0.52);
+ ctx.quadraticCurveTo(cx - w * 0.10, h * 0.30, cx, h * 0.12);
+ ctx.closePath();
+ ctx.fill();
+
+ // Guard
+ ctx.fillStyle = pal.base;
+ ctx.fillRect(cx - w * 0.16, h * 0.52, w * 0.32, h * 0.05);
+
+ // Handle
+ ctx.fillStyle = '#4A3A28';
+ ctx.fillRect(cx - w * 0.05, h * 0.57, w * 0.10, h * 0.28);
+
+ // Pommel
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.88, w * 0.05, 0, Math.PI * 2);
+ ctx.fill();
+}
+
+function drawBow(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Bow limb (curved)
+ ctx.strokeStyle = pal.accent;
+ ctx.lineWidth = w * 0.06;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.05, h * 0.10);
+ ctx.quadraticCurveTo(cx - w * 0.38, h * 0.50, cx - w * 0.05, h * 0.90);
+ ctx.stroke();
+
+ // String
+ ctx.strokeStyle = pal.base;
+ ctx.lineWidth = w * 0.02;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.05, h * 0.10);
+ ctx.lineTo(cx + w * 0.15, h * 0.50);
+ ctx.lineTo(cx - w * 0.05, h * 0.90);
+ ctx.stroke();
+
+ // Arrow
+ ctx.fillStyle = '#8A7A68';
+ ctx.fillRect(cx - w * 0.02, h * 0.18, w * 0.04, h * 0.64);
+ // Arrowhead
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.10);
+ ctx.lineTo(cx + w * 0.06, h * 0.20);
+ ctx.lineTo(cx - w * 0.06, h * 0.20);
+ ctx.closePath();
+ ctx.fill();
+}
+
+function drawStaff(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Shaft
+ ctx.fillStyle = '#5A4A38';
+ ctx.fillRect(cx - w * 0.04, h * 0.25, w * 0.08, h * 0.70);
+
+ // Crystal/orb
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.18, w * 0.15, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Inner glow
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.16, w * 0.07, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Staff head prongs
+ ctx.fillStyle = '#6A5A48';
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.12, h * 0.28);
+ ctx.quadraticCurveTo(cx - w * 0.18, h * 0.16, cx - w * 0.08, h * 0.08);
+ ctx.lineTo(cx - w * 0.04, h * 0.14);
+ ctx.lineTo(cx - w * 0.04, h * 0.28);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(cx + w * 0.12, h * 0.28);
+ ctx.quadraticCurveTo(cx + w * 0.18, h * 0.16, cx + w * 0.08, h * 0.08);
+ ctx.lineTo(cx + w * 0.04, h * 0.14);
+ ctx.lineTo(cx + w * 0.04, h * 0.28);
+ ctx.closePath();
+ ctx.fill();
+}
+
+function drawWand(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Rod
+ ctx.fillStyle = '#5A4A38';
+ ctx.save();
+ ctx.translate(cx, h * 0.50);
+ ctx.rotate(-0.15);
+ ctx.fillRect(-w * 0.03, -h * 0.32, w * 0.06, h * 0.64);
+ ctx.restore();
+
+ // Glowing tip
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.arc(cx - w * 0.05, h * 0.15, w * 0.10, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Inner spark
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.arc(cx - w * 0.05, h * 0.14, w * 0.04, 0, Math.PI * 2);
+ ctx.fill();
+}
+
+function drawSpear(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Shaft
+ ctx.fillStyle = '#5A4A38';
+ ctx.fillRect(cx - w * 0.03, h * 0.22, w * 0.06, h * 0.72);
+
+ // Spearhead
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.05);
+ ctx.lineTo(cx + w * 0.10, h * 0.25);
+ ctx.lineTo(cx - w * 0.10, h * 0.25);
+ ctx.closePath();
+ ctx.fill();
+}
+
+// ── Armor draw functions ──────────────────────────────────────────────
+
+function drawCloth(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Hood
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.arc(cx, h * 0.18, w * 0.18, Math.PI, 0);
+ ctx.lineTo(cx + w * 0.22, h * 0.28);
+ ctx.lineTo(cx - w * 0.22, h * 0.28);
+ ctx.closePath();
+ ctx.fill();
+
+ // Robe body
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.20, h * 0.28);
+ ctx.lineTo(cx - w * 0.35, h * 0.92);
+ ctx.lineTo(cx + w * 0.35, h * 0.92);
+ ctx.lineTo(cx + w * 0.20, h * 0.28);
+ ctx.closePath();
+ ctx.fill();
+
+ // Collar
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.10, h * 0.28);
+ ctx.lineTo(cx, h * 0.40);
+ ctx.lineTo(cx + w * 0.10, h * 0.28);
+ ctx.closePath();
+ ctx.fill();
+
+ // Belt
+ ctx.fillStyle = '#4A3A28';
+ ctx.fillRect(cx - w * 0.24, h * 0.55, w * 0.48, h * 0.04);
+}
+
+function drawLeather(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Vest body
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.28, h * 0.12);
+ ctx.lineTo(cx - w * 0.32, h * 0.80);
+ ctx.lineTo(cx + w * 0.32, h * 0.80);
+ ctx.lineTo(cx + w * 0.28, h * 0.12);
+ ctx.closePath();
+ ctx.fill();
+
+ // Shoulder pads
+ ctx.fillStyle = pal.base;
+ ctx.beginPath();
+ ctx.ellipse(cx - w * 0.24, h * 0.14, w * 0.12, h * 0.06, -0.2, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(cx + w * 0.24, h * 0.14, w * 0.12, h * 0.06, 0.2, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Center seam
+ ctx.strokeStyle = '#4A3A28';
+ ctx.lineWidth = w * 0.02;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.15);
+ ctx.lineTo(cx, h * 0.75);
+ ctx.stroke();
+
+ // Buckle straps
+ ctx.fillStyle = '#4A3A28';
+ ctx.fillRect(cx - w * 0.30, h * 0.30, w * 0.60, h * 0.03);
+ ctx.fillRect(cx - w * 0.30, h * 0.55, w * 0.60, h * 0.03);
+
+ // Buckles
+ ctx.fillStyle = pal.base;
+ ctx.fillRect(cx - w * 0.04, h * 0.28, w * 0.08, h * 0.07);
+ ctx.fillRect(cx - w * 0.04, h * 0.53, w * 0.08, h * 0.07);
+}
+
+function drawPlate(ctx: CanvasRenderingContext2D, w: number, h: number, pal: RarityPalette) {
+ fillBg(ctx, w, h);
+ const cx = w / 2;
+
+ // Breastplate body
+ ctx.fillStyle = pal.accent;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.32, h * 0.10);
+ ctx.quadraticCurveTo(cx - w * 0.36, h * 0.50, cx - w * 0.28, h * 0.82);
+ ctx.lineTo(cx + w * 0.28, h * 0.82);
+ ctx.quadraticCurveTo(cx + w * 0.36, h * 0.50, cx + w * 0.32, h * 0.10);
+ ctx.closePath();
+ ctx.fill();
+
+ // Chest ridge
+ ctx.strokeStyle = pal.base;
+ ctx.lineWidth = w * 0.03;
+ ctx.beginPath();
+ ctx.moveTo(cx, h * 0.14);
+ ctx.lineTo(cx, h * 0.50);
+ ctx.stroke();
+
+ // Plate segments
+ ctx.strokeStyle = '#4A3A28';
+ ctx.lineWidth = w * 0.015;
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.30, h * 0.35);
+ ctx.lineTo(cx + w * 0.30, h * 0.35);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx - w * 0.28, h * 0.58);
+ ctx.lineTo(cx + w * 0.28, h * 0.58);
+ ctx.stroke();
+
+ // Rivets
+ ctx.fillStyle = pal.base;
+ for (const oy of [0.18, 0.42, 0.68]) {
+ for (const ox of [-0.18, 0.18]) {
+ ctx.beginPath();
+ ctx.arc(cx + w * ox, h * oy, w * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+}
+
+// ── Template builder ──────────────────────────────────────────────────
+
+const WEAPON_DRAW: Record void> = {
+ sword: drawSword,
+ axe: drawAxe,
+ mace: drawMace,
+ dagger: drawDagger,
+ bow: drawBow,
+ staff: drawStaff,
+ wand: drawWand,
+ spear: drawSpear,
+};
+
+const ARMOR_DRAW: Record void> = {
+ cloth: drawCloth,
+ leather: drawLeather,
+ plate: drawPlate,
+};
+
+const templateCache = new Map();
+
+/**
+ * Build a MonsterTemplate for an item (weapon or armor).
+ * Caches by name so repeated renders reuse the same template.
+ */
+export function getItemAsciiTemplate(
+ name: string,
+ itemType: 'weapon' | 'armor',
+ rarity: number = 0,
+): MonsterTemplate {
+ const cacheKey = `${name}-${rarity}`;
+ const cached = templateCache.get(cacheKey);
+ if (cached) return cached;
+
+ const pal = getPalette(rarity);
+ const subtype = itemType === 'weapon'
+ ? classifyWeaponSubtype(name)
+ : classifyArmorSubtype(name);
+
+ const drawFn = itemType === 'weapon'
+ ? WEAPON_DRAW[subtype as WeaponSubtype]
+ : ARMOR_DRAW[subtype as ArmorSubtype];
+
+ const template: MonsterTemplate = {
+ id: `item-icon-${cacheKey}`,
+ name,
+ gridWidth: 1,
+ gridHeight: 1,
+ monsterClass: 0 as const,
+ level: 1,
+ dynamic: false,
+ renderOverrides: {
+ brightnessBoost: 1.6 + rarity * 0.2,
+ gamma: 0.45,
+ ambient: 0.80,
+ charDensityFloor: 0.10,
+ },
+ draw: (ctx, w, h) => drawFn(ctx, w, h, pal),
+ };
+
+ templateCache.set(cacheKey, template);
+ return template;
+}
diff --git a/packages/client/src/pages/CharacterCreation.tsx b/packages/client/src/pages/CharacterCreation.tsx
index d200afde3..298382f3e 100644
--- a/packages/client/src/pages/CharacterCreation.tsx
+++ b/packages/client/src/pages/CharacterCreation.tsx
@@ -3,7 +3,6 @@ import {
Button,
Center,
HStack,
- Image,
Input,
keyframes,
Spinner,
@@ -35,7 +34,7 @@ import {
import { GAME_BOARD_PATH, HOME_PATH } from '../Routes';
import { API_URL } from '../utils/constants';
import { debug } from '../utils/debug';
-import { getItemImage } from '../utils/itemImages';
+import { ItemAsciiIcon } from '../components/ItemAsciiIcon';
import {
type Armor,
PowerSource,
@@ -1035,7 +1034,6 @@ const CharacterCreationInner = (): JSX.Element => {
{availableStarterWeapons.map(weapon => {
const selected = selectedStarterWeaponId === BigInt(weapon.tokenId);
const recommended = isRecommended(weapon, dominantStat);
- const weaponImage = getItemImage(weapon.name);
return (
{
onClick={() => setSelectedStarterWeaponId(BigInt(weapon.tokenId))}
_hover={{ bg: '#2E2820' }}
>
- {weaponImage && }
+
{weapon.name}
@@ -1084,7 +1082,6 @@ const CharacterCreationInner = (): JSX.Element => {
{availableStarterArmors.map(armor => {
const selected = selectedStarterArmorId === BigInt(armor.tokenId);
- const armorImage = getItemImage(armor.name);
return (
{
onClick={() => setSelectedStarterArmorId(BigInt(armor.tokenId))}
_hover={{ bg: '#2E2820' }}
>
- {armorImage && }
+
{armor.name}
diff --git a/packages/client/src/pages/MarketplaceItem.tsx b/packages/client/src/pages/MarketplaceItem.tsx
index d5db748a8..045085d28 100644
--- a/packages/client/src/pages/MarketplaceItem.tsx
+++ b/packages/client/src/pages/MarketplaceItem.tsx
@@ -1,5 +1,4 @@
import {
- Avatar,
Badge,
Box,
Button,
@@ -7,7 +6,6 @@ import {
FormHelperText,
Heading,
HStack,
- Image,
Input,
InputGroup,
InputLeftAddon,
@@ -51,8 +49,8 @@ import { useOrders } from '../contexts/OrdersContext';
import { useToast } from '../hooks/useToast';
import { useTransaction } from '../hooks/useTransaction';
import { CHARACTER_CREATION_PATH, HOME_PATH, MARKETPLACE_PATH } from '../Routes';
-import { etherToFixedNumber, getEmoji, removeEmoji } from '../utils/helpers';
-import { getItemImage } from '../utils/itemImages';
+import { etherToFixedNumber, removeEmoji } from '../utils/helpers';
+import { ItemAsciiIcon } from '../components/ItemAsciiIcon';
import {
type ArmorTemplate,
ItemType,
@@ -460,7 +458,6 @@ export const MarketplaceItem = (): JSX.Element => {
}
const itemName = removeEmoji(selectedItem.name);
- const itemImage = getItemImage(itemName);
const itemRarity = selectedItem.rarity;
const rarityColor = itemRarity !== undefined ? RARITY_COLORS[itemRarity] : undefined;
const rarityName = itemRarity !== undefined ? RARITY_NAMES[itemRarity] : undefined;
@@ -551,28 +548,12 @@ export const MarketplaceItem = (): JSX.Element => {
justifyContent="center"
w={{ base: '140px', md: '160px' }}
>
- {itemImage ? (
-
- ) : (
-
-
- {getEmoji(selectedItem.name)}
-
-
- )}
+
{/* Item Info */}