From 577acaeacfbc26a32c7e9eb9a15c7240f226f019 Mon Sep 17 00:00:00 2001 From: Michael O'Rourke Date: Wed, 8 Apr 2026 14:27:45 -0600 Subject: [PATCH] fix: use WebP source images for ASCII item icons instead of silhouettes The flat single-color silhouette draw functions produced muddy, indistinct ASCII art. Now loads the actual WebP item illustrations (which have rich detail, shading, and color on black backgrounds) and feeds them through MonsterAsciiRenderer for dramatically better output quality matching the creature ASCII aesthetic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/components/ItemAsciiIcon.tsx | 158 ++++++++++++++---- 1 file changed, 122 insertions(+), 36 deletions(-) diff --git a/packages/client/src/components/ItemAsciiIcon.tsx b/packages/client/src/components/ItemAsciiIcon.tsx index b0424b713..93e254176 100644 --- a/packages/client/src/components/ItemAsciiIcon.tsx +++ b/packages/client/src/components/ItemAsciiIcon.tsx @@ -1,14 +1,14 @@ /** - * ItemAsciiIcon — Renders weapon/armor items as ASCII art on a small canvas. + * ItemAsciiIcon — Renders item images as ASCII art via MonsterAsciiRenderer. * - * Uses subtype-based silhouette templates (itemAsciiArt.ts) processed through - * the MonsterAsciiRenderer for a consistent ASCII look across the UI. + * Primary path: loads the item's WebP image, draws it to an offscreen canvas, + * and feeds that through renderMonster for consistent ASCII styling. * - * Falls back to WebP image for consumables/spells (no ASCII template). + * Fallback chain: WebP image → silhouette template → emoji. */ -import { useCallback, useMemo, memo } from 'react'; -import { Box, Image, Text } from '@chakra-ui/react'; +import { useCallback, useEffect, useMemo, useState, memo } from 'react'; +import { Box, Text } from '@chakra-ui/react'; import { useCanvas } from './pretext/hooks/useCanvas'; import { renderMonster } from './pretext/game/MonsterAsciiRenderer'; @@ -33,19 +33,61 @@ type Props = { /** 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; +// --------------------------------------------------------------------------- +// Image cache — load each WebP once, reuse forever +// --------------------------------------------------------------------------- +const imageCache = new Map(); - const template = useMemo(() => { - if (!isWeaponOrArmor) return null; - const type = itemType === ItemType.Weapon ? 'weapon' : 'armor'; - return getItemAsciiTemplate(cleanName, type, rarity); - }, [cleanName, itemType, rarity, isWeaponOrArmor]); +function useItemImage(src: string | undefined) { + const [img, setImg] = useState(() => + src ? imageCache.get(src) ?? null : null, + ); + + useEffect(() => { + if (!src) return; + const cached = imageCache.get(src); + if (cached) { setImg(cached); return; } + + let cancelled = false; + const el = new window.Image(); + el.crossOrigin = 'anonymous'; + el.onload = () => { + imageCache.set(src, el); + if (!cancelled) setImg(el); + }; + el.onerror = () => {}; // fallback handled below + el.src = src; + return () => { cancelled = true; }; + }, [src]); + + return img; +} + +// --------------------------------------------------------------------------- +// Inner canvas component — mounts fresh each time template identity changes, +// so useCanvas's static single-render fires with the correct data. +// --------------------------------------------------------------------------- +const AsciiCanvas = memo(({ templateId, draw, rarity, size, renderOverrides }: { + templateId: string; + draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void; + rarity: number; + size: string | Record; + renderOverrides?: Record; +}) => { + const template = useMemo(() => ({ + id: templateId, + name: templateId, + gridWidth: 1, + gridHeight: 1, + monsterClass: 0, + level: 1, + dynamic: false, + renderOverrides, + draw, + }), [templateId, draw, renderOverrides]); 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, { @@ -60,36 +102,80 @@ const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px', alt }: const { canvasRef } = useCanvas({ onFrame, static: true }); - // Weapons and armor get ASCII rendering - if (isWeaponOrArmor && template) { + return ( + + } + style={{ width: '100%', height: '100%' }} + /> + + ); +}); + +AsciiCanvas.displayName = 'AsciiCanvas'; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px' }: Props) => { + const cleanName = removeEmoji(name); + const imageSrc = getItemImage(cleanName); + const loadedImage = useItemImage(imageSrc); + const isWeaponOrArmor = itemType === ItemType.Weapon || itemType === ItemType.Armor; + + // Image-based draw function (primary path) + const imageDraw = useCallback( + (ctx: CanvasRenderingContext2D, w: number, h: number) => { + if (!loadedImage) return; + ctx.drawImage(loadedImage, 0, 0, w, h); + }, + [loadedImage], + ); + + // Silhouette fallback template (only when no image exists) + const silhouetteTemplate = useMemo(() => { + if (imageSrc || !isWeaponOrArmor) return null; + const type = itemType === ItemType.Weapon ? 'weapon' : 'armor'; + return getItemAsciiTemplate(cleanName, type, rarity); + }, [imageSrc, isWeaponOrArmor, cleanName, itemType, rarity]); + + // Render overrides tuned for photographic WebP sources + const imageOverrides = useMemo(() => ({ + brightnessBoost: 1.6, + gamma: 0.5, + ambient: 0.65, + charDensityFloor: 0.06, + }), []); + + // Primary: image-based ASCII rendering + if (loadedImage) { return ( - - } - style={{ width: '100%', height: '100%' }} - /> - + ); } - // Consumables and spells fall back to WebP image or emoji - const imageSrc = getItemImage(cleanName); - if (imageSrc) { + // Fallback: silhouette ASCII for weapons/armor without WebP + if (silhouetteTemplate) { return ( - {alt ); } + // Final fallback: emoji return ( {itemType === ItemType.Consumable