Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 122 additions & 36 deletions packages/client/src/components/ItemAsciiIcon.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, HTMLImageElement>();

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<HTMLImageElement | null>(() =>
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<string, string>;
renderOverrides?: Record<string, number>;
}) => {
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, {
Expand All @@ -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 (
<Box boxSize={size} flexShrink={0} position="relative">
<canvas
ref={canvasRef as React.LegacyRef<HTMLCanvasElement>}
style={{ width: '100%', height: '100%' }}
/>
</Box>
);
});

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 (
<Box
boxSize={size}
flexShrink={0}
position="relative"
>
<canvas
ref={canvasRef as React.LegacyRef<HTMLCanvasElement>}
style={{ width: '100%', height: '100%' }}
/>
</Box>
<AsciiCanvas
key={`img-${cleanName}`}
templateId={`item-img-${cleanName}`}
draw={imageDraw}
rarity={rarity}
size={size}
renderOverrides={imageOverrides}
/>
);
}

// 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 (
<Image
src={imageSrc}
alt={alt ?? cleanName}
boxSize={size}
objectFit="contain"
flexShrink={0}
<AsciiCanvas
key={`sil-${cleanName}`}
templateId={silhouetteTemplate.id}
draw={silhouetteTemplate.draw}
rarity={rarity}
size={size}
renderOverrides={silhouetteTemplate.renderOverrides}
/>
);
}

// Final fallback: emoji
return (
<Text fontSize="xl" lineHeight={1}>
{itemType === ItemType.Consumable
Expand Down
Loading