From 06dd20197ed9191ac68c328a6b76d9a5c53de8f3 Mon Sep 17 00:00:00 2001
From: Michael O'Rourke
Date: Wed, 8 Apr 2026 14:58:00 -0600
Subject: [PATCH] fix: show WebP item art directly with rarity CSS effects
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The MonsterAsciiRenderer filter was destroying the beautiful ink-on-black
illustrations — flattening all items into identical gray blobs with no
rarity differentiation and invisible thumbnails at small sizes.
Now shows WebP images directly with rarity-driven CSS filter hierarchy:
- Worn/Common: dimmed, desaturated (feels worn)
- Uncommon: subtle green glow
- Rare: blue glow, full brightness
- Epic: purple glow, enhanced saturation
- Legendary: golden triple-layer glow, boosted brightness
The art is already in the game's ASCII ink style — no filter needed.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../client/src/components/ItemAsciiIcon.tsx | 196 +++++-------------
1 file changed, 55 insertions(+), 141 deletions(-)
diff --git a/packages/client/src/components/ItemAsciiIcon.tsx b/packages/client/src/components/ItemAsciiIcon.tsx
index 93e254176..b427cae4a 100644
--- a/packages/client/src/components/ItemAsciiIcon.tsx
+++ b/packages/client/src/components/ItemAsciiIcon.tsx
@@ -1,28 +1,27 @@
/**
- * ItemAsciiIcon — Renders item images as ASCII art via MonsterAsciiRenderer.
+ * ItemAsciiIcon — Displays item art with rarity-driven visual effects.
*
- * Primary path: loads the item's WebP image, draws it to an offscreen canvas,
- * and feeds that through renderMonster for consistent ASCII styling.
+ * Shows the WebP illustration directly (already in the game's ink-on-black
+ * art style) with CSS filter/glow effects per rarity tier to create clear
+ * visual hierarchy. Common items look dim and worn; legendaries glow.
*
- * Fallback chain: WebP image → silhouette template → emoji.
+ * Fallback chain: WebP image → emoji.
*/
-import { useCallback, useEffect, useMemo, useState, memo } from 'react';
-import { Box, Text } from '@chakra-ui/react';
+import { memo } from 'react';
+import { Box, Image, Text } from '@chakra-ui/react';
-import { useCanvas } from './pretext/hooks/useCanvas';
-import { renderMonster } from './pretext/game/MonsterAsciiRenderer';
-import { getItemAsciiTemplate } from './pretext/game/itemAsciiArt';
import { getEmoji, removeEmoji } from '../utils/helpers';
import { getConsumableEmoji, getItemImage } from '../utils/itemImages';
-import { ItemType } from '../utils/types';
+import { getRarityColor } from '../utils/rarityHelpers';
+import { ItemType, Rarity } from '../utils/types';
type Props = {
/** Item name (may include emoji prefix) */
name: string;
/** ItemType enum value */
itemType: ItemType;
- /** Item rarity (0-4) */
+ /** Item rarity (0-5) */
rarity?: number;
/** CSS size string for the icon container */
size?: string | Record;
@@ -30,152 +29,67 @@ type Props = {
alt?: string;
};
-/** Cell size for ASCII rendering — smaller = more detail */
-const CELL_SIZE = 4;
-
// ---------------------------------------------------------------------------
-// Image cache — load each WebP once, reuse forever
+// Rarity visual effects — each tier should "feel" distinct at a glance
// ---------------------------------------------------------------------------
-const imageCache = new Map();
-
-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; }
+/** CSS filter per rarity — dims commons, enhances legendaries */
+const RARITY_FILTERS: Record = {
+ [Rarity.Worn]: 'brightness(0.5) saturate(0.3)',
+ [Rarity.Common]: 'brightness(0.7) saturate(0.5)',
+ [Rarity.Uncommon]: 'brightness(0.9) saturate(0.8)',
+ [Rarity.Rare]: 'brightness(1.0) saturate(1.0)',
+ [Rarity.Epic]: 'brightness(1.1) saturate(1.2)',
+ [Rarity.Legendary]: 'brightness(1.2) saturate(1.3)',
+};
- 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]);
+/** Drop-shadow glow per rarity — subtle to dramatic */
+const getRarityDropShadow = (rarity: number): string | undefined => {
+ const color = getRarityColor(rarity);
+ switch (rarity) {
+ case Rarity.Uncommon:
+ return `drop-shadow(0 0 3px ${color}40)`;
+ case Rarity.Rare:
+ return `drop-shadow(0 0 4px ${color}50) drop-shadow(0 0 8px ${color}25)`;
+ case Rarity.Epic:
+ return `drop-shadow(0 0 5px ${color}60) drop-shadow(0 0 12px ${color}30)`;
+ case Rarity.Legendary:
+ return `drop-shadow(0 0 6px ${color}70) drop-shadow(0 0 14px ${color}40) drop-shadow(0 0 24px ${color}20)`;
+ default:
+ return undefined;
+ }
+};
- return img;
-}
+const getImageFilter = (rarity: number): string => {
+ const base = RARITY_FILTERS[rarity] ?? RARITY_FILTERS[Rarity.Common];
+ const glow = getRarityDropShadow(rarity);
+ return glow ? `${base} ${glow}` : base;
+};
// ---------------------------------------------------------------------------
-// Inner canvas component — mounts fresh each time template identity changes,
-// so useCanvas's static single-render fires with the correct data.
+// Component
// ---------------------------------------------------------------------------
-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) => {
- 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 });
-
- return (
-
-
- );
-});
-
-AsciiCanvas.displayName = 'AsciiCanvas';
-// ---------------------------------------------------------------------------
-// Main component
-// ---------------------------------------------------------------------------
-const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px' }: Props) => {
+const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px', alt }: 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 (
-
- );
- }
- // Fallback: silhouette ASCII for weapons/armor without WebP
- if (silhouetteTemplate) {
+ if (imageSrc) {
return (
-
+
+
+
);
}
- // Final fallback: emoji
+ // Emoji fallback for items without art
return (
{itemType === ItemType.Consumable