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 ? ( - {name} - ) : ( - {emoji} - )} + {/* Quantity badge */}
- {imageSrc ? ( - {removeEmoji(item.name)} - ) : ( - - {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 ( + + } + style={{ width: '100%', height: '100%' }} + /> + + ); + } + + // Consumables and spells fall back to WebP image or emoji + const imageSrc = getItemImage(cleanName); + if (imageSrc) { + return ( + {alt + ); + } + + 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)) ? ( - {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)) ? ( - {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)) ? ( - {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 ? ( - {itemName} - ) : ( - - {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)) ? ( - {removeEmoji(name)} + - ) : ( - - {getEmoji(name)} - - )} + - {getItemImage(removeEmoji(item.name)) ? ( - {removeEmoji(item.name)} + - ) : ( - - {getEmoji(item.name)} - - )} + - {name && getItemImage(removeEmoji(name.toString())) ? ( - {removeEmoji(name.toString())} + - ) : ( - - {name ? getEmoji(name.toString()) : ''} - - )} + - {name && getItemImage(removeEmoji(name.toString())) ? ( - {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}} + {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}} + {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 ? ( - {itemName} - ) : ( - - - {getEmoji(selectedItem.name)} - - - )} + {/* Item Info */}