fix(v2): full v1-style radar canvas + inventory icon composites

Radar — now pixel-accurate reproduction of v1:
- 300×300 canvas with dark circular background
- Semi-transparent dereth.png map overlay (heading-rotated)
- 4 range rings + crosshair lines
- Compass labels (N=red, E/S/W=gray) rotating with heading
- Facing direction indicator line
- Entity dots color-coded by type (Monster=red, Player=blue,
  NPC=green, Portal=purple, Corpse=orange, Container=yellow)
- Player dot: gold center with white border
- Heading-up rotation for all entity positions
- Click to select entity (white selection ring)
- Scroll to zoom (0.02-5.0 AC units range)
- Entity list with color dot, name, type, distance, compass direction
- Selected entity highlighted with blue left border

Inventory — v1-style icon composites + slot styling:
- 3-layer icon composite: underlay → base → overlay images
  using portal.dat offset formula + icon_overlay_id/IntValues
- Equipment slots: 3D beveled border + cyan glow when equipped
  (matching v1's outset border + #00ffff shadow)
- Pack item cells: purple gradient background (v1's #3d007a)
- Proper 36×36px icon rendering with pixelated scaling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 19:16:21 +02:00
parent e5c982d6f5
commit cf078b7765
6 changed files with 437 additions and 183 deletions

View file

@ -23,11 +23,26 @@ interface Item {
}
// Icon helper: convert raw icon ID to hex filename
function iconHex(raw: number): string {
if (!raw || raw <= 0) return '06000133';
return (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
function iconUrl(item: Item): string {
const raw = item.icon ?? item.Icon ?? 0;
if (raw === 0) return '/icons/06000133.png'; // fallback
const hex = (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
return `/icons/${hex}.png`;
return `/icons/${iconHex(item.icon ?? item.Icon ?? 0)}.png`;
}
function overlayUrl(item: Item): string | null {
const id = (item as any).icon_overlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103849'] && Number(iv['218103849']) > 100) return `/icons/${iconHex(Number(iv['218103849']))}.png`;
return null;
}
function underlayUrl(item: Item): string | null {
const id = (item as any).icon_underlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103850'] && Number(iv['218103850']) > 100) return `/icons/${iconHex(Number(iv['218103850']))}.png`;
return null;
}
function itemName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; }
@ -77,12 +92,16 @@ const SLOT_BG: Record<number, string> = {};
[2,4,134217728,268435456,536870912,1073741824].forEach(m => SLOT_BG[m] = '#1e3e3e');
[2097152,1048576,4194304,16777216,33554432,8388608].forEach(m => SLOT_BG[m] = '#142040');
function ItemIcon({ item, size = 38 }: { item: Item; size?: number }) {
function ItemIcon({ item, size = 36 }: { item: Item; size?: number }) {
const under = underlayUrl(item);
const over = overlayUrl(item);
const imgStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: size, height: size, border: 'none', background: 'transparent', imageRendering: 'pixelated' };
return (
<div title={itemTooltip(item)} style={{ width: size, height: size, position: 'relative', cursor: 'help' }}>
<img src={iconUrl(item)} alt={itemName(item)}
style={{ width: '100%', height: '100%', objectFit: 'contain', imageRendering: 'pixelated' }}
{under && <img src={under} alt="" style={{ ...imgStyle, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<img src={iconUrl(item)} alt={itemName(item)} style={{ ...imgStyle, zIndex: 2 }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{over && <img src={over} alt="" style={{ ...imgStyle, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
</div>
);
}
@ -154,12 +173,13 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div key={slot.key} title={item ? itemTooltip(item) : slot.name}
style={{
position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4,
width: 40, height: 40, background: item ? slot.bg : `${slot.bg}55`,
border: `1px solid ${item ? '#555' : '#2a2a2a'}`, borderRadius: 3,
width: 36, height: 36, background: item ? '#5a5a62' : '#3a3a42',
border: item ? '2px solid #00ffff' : '2px outset #6a6a72',
boxShadow: item ? '0 0 5px #00ffff, inset 0 0 5px rgba(0,255,255,0.2)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
}}>
{item ? <ItemIcon item={item} size={36} /> :
<span style={{ fontSize: '0.45rem', color: '#444', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>}
{item ? <ItemIcon item={item} size={32} /> :
<span style={{ fontSize: '0.42rem', color: '#555', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>}
</div>
);
})}
@ -171,9 +191,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4, alignContent: 'flex-start' }}>
{activeItems.map((item, i) => (
<div key={item.item_id ?? item.Id ?? i} title={itemTooltip(item)}
style={{ width: 40, height: 40, background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 2,
style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3d007a 0%, #1a0033 100%)',
border: '1px solid #4a148c',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', overflow: 'hidden' }}>
<ItemIcon item={item} size={36} />
<ItemIcon item={item} size={32} />
</div>
))}
</div>