From 27a6bf059ec2b5ea09521d5485ebe97b7946fb96 Mon Sep 17 00:00:00 2001
From: Michael O'Rourke
Date: Wed, 8 Apr 2026 15:10:30 -0600
Subject: [PATCH] fix: rarity color tinting for item icons via canvas multiply
blend
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
White-on-black ink illustrations get multiply-blended with rarity color:
Common→dim gray, Uncommon→green, Rare→blue, Epic→purple, Legendary→gold.
Each tier is instantly recognizable at any size. Epic+ items get glow
drop-shadows and breathing animations.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../client/src/components/ItemAsciiIcon.tsx | 190 ++++++++++++------
1 file changed, 134 insertions(+), 56 deletions(-)
diff --git a/packages/client/src/components/ItemAsciiIcon.tsx b/packages/client/src/components/ItemAsciiIcon.tsx
index b427cae4a..8c24496b4 100644
--- a/packages/client/src/components/ItemAsciiIcon.tsx
+++ b/packages/client/src/components/ItemAsciiIcon.tsx
@@ -1,95 +1,173 @@
/**
- * ItemAsciiIcon — Displays item art with rarity-driven visual effects.
+ * ItemAsciiIcon — Displays item art tinted by rarity color.
*
- * 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.
+ * Loads the WebP illustration (white ink on black) and applies a canvas
+ * multiply blend with the rarity color so white→rarity color, black→black.
+ * Common items render dim gray, Legendaries glow gold. Each tier is
+ * instantly recognizable.
*
- * Fallback chain: WebP image → emoji.
+ * Fallback: emoji for items without art.
*/
-import { memo } from 'react';
-import { Box, Image, Text } from '@chakra-ui/react';
+import { memo, useEffect, useRef, useState } from 'react';
+import { Box, Text } from '@chakra-ui/react';
import { getEmoji, removeEmoji } from '../utils/helpers';
import { getConsumableEmoji, getItemImage } from '../utils/itemImages';
-import { getRarityColor } from '../utils/rarityHelpers';
+import { getRarityAnimation, 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-5) */
rarity?: number;
- /** CSS size string for the icon container */
size?: string | Record;
- /** Optional alt text */
alt?: string;
};
// ---------------------------------------------------------------------------
-// Rarity visual effects — each tier should "feel" distinct at a glance
+// Rarity tint colors — tuned for white-on-black multiply blend
+// Brighter/more saturated than border colors so they read at small sizes
// ---------------------------------------------------------------------------
-
-/** 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)',
+const RARITY_TINT: Record = {
+ [Rarity.Worn]: '#706866', // Dim warm gray
+ [Rarity.Common]: '#B0A890', // Parchment
+ [Rarity.Uncommon]: '#5CAA6A', // Bright forest green
+ [Rarity.Rare]: '#5A90D0', // Bright steel blue
+ [Rarity.Epic]: '#9A6AD8', // Bright purple
+ [Rarity.Legendary]: '#E8A030', // Bright gold
};
-/** 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;
- }
-};
+// ---------------------------------------------------------------------------
+// Image cache
+// ---------------------------------------------------------------------------
+const imageCache = new Map();
-const getImageFilter = (rarity: number): string => {
- const base = RARITY_FILTERS[rarity] ?? RARITY_FILTERS[Rarity.Common];
- const glow = getRarityDropShadow(rarity);
- return glow ? `${base} ${glow}` : base;
-};
+function loadImage(src: string): Promise {
+ const cached = imageCache.get(src);
+ if (cached) return Promise.resolve(cached);
+
+ return new Promise((resolve, reject) => {
+ const img = new window.Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => { imageCache.set(src, img); resolve(img); };
+ img.onerror = reject;
+ img.src = src;
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Tinted canvas renderer
+// ---------------------------------------------------------------------------
+function renderTinted(
+ canvas: HTMLCanvasElement,
+ img: HTMLImageElement,
+ tintColor: string,
+) {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ const w = Math.floor(rect.width);
+ const h = Math.floor(rect.height);
+ if (w === 0 || h === 0) return;
+
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ // Draw original white-on-black image
+ ctx.drawImage(img, 0, 0, w, h);
+
+ // Multiply blend: white * color = color, black * color = black
+ ctx.globalCompositeOperation = 'multiply';
+ ctx.fillStyle = tintColor;
+ ctx.fillRect(0, 0, w, h);
+
+ ctx.globalCompositeOperation = 'source-over';
+}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
+const TintedItemCanvas = memo(({
+ imageSrc,
+ rarity,
+ size,
+}: {
+ imageSrc: string;
+ rarity: number;
+ size: string | Record;
+}) => {
+ const canvasRef = useRef(null);
+ const [loaded, setLoaded] = useState(!!imageCache.get(imageSrc));
+ const tintColor = RARITY_TINT[rarity] ?? RARITY_TINT[Rarity.Common];
+ const rarityAnimation = getRarityAnimation(rarity);
+ const rarityBorderColor = getRarityColor(rarity);
+
+ useEffect(() => {
+ loadImage(imageSrc)
+ .then(img => {
+ setLoaded(true);
+ if (canvasRef.current) {
+ renderTinted(canvasRef.current, img, tintColor);
+ }
+ })
+ .catch(() => {});
+ }, [imageSrc, tintColor]);
+
+ // Re-render when canvas mounts after image is already cached
+ useEffect(() => {
+ if (!loaded || !canvasRef.current) return;
+ const img = imageCache.get(imageSrc);
+ if (img) renderTinted(canvasRef.current, img, tintColor);
+ }, [loaded, imageSrc, tintColor]);
+
+ // Rarity glow filter for epic+ items
+ const glowFilter = rarity >= Rarity.Epic
+ ? `drop-shadow(0 0 ${rarity >= Rarity.Legendary ? '6px' : '4px'} ${rarityBorderColor}80)`
+ : rarity >= Rarity.Rare
+ ? `drop-shadow(0 0 3px ${rarityBorderColor}50)`
+ : undefined;
+
+ return (
+
+
+
+ );
+});
+
+TintedItemCanvas.displayName = 'TintedItemCanvas';
-const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px', alt }: Props) => {
+const ItemAsciiIconInner = ({ name, itemType, rarity = 0, size = '40px' }: Props) => {
const cleanName = removeEmoji(name);
const imageSrc = getItemImage(cleanName);
if (imageSrc) {
return (
-
-
-
+
);
}
- // Emoji fallback for items without art
return (
{itemType === ItemType.Consumable