MosswartOverlord/frontend/src/components/windows/InventoryWindow.tsx
Erik e5c982d6f5 fix(v2): radar data flow + inventory icons
Radar:
- nearby_objects WebSocket messages now tracked in useLiveData state
- Passed through MapLayout → WindowRenderer → RadarWindow
- Objects list updates live as radar data streams in

Inventory:
- Items now render actual game icons via /icons/{hexId}.png
  using the portal.dat offset formula (iconRaw + 0x06000000)
- Hover tooltip shows: name, material, AL, damage, workmanship,
  tinks, set, imbue (multi-line)
- Equipment grid slots show item icons instead of text names
- Pack item grid shows item icons with proper tooltips
- Fallback icon (06000133.png) on load error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:06:14 +02:00

202 lines
9.8 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
interface Item {
item_id?: number; Id?: number; Name?: string; name?: string;
ObjectClass?: number; object_class?: number;
Icon?: number; icon?: number;
Value?: number; value?: number; Burden?: number; burden?: number;
ArmorLevel?: number; armor_level?: number;
MaxDamage?: number; max_damage?: number;
Workmanship?: number; workmanship?: number;
Material?: string; material?: string; material_name?: string;
ItemSet?: string; item_set?: string;
Imbue?: string; imbue?: string;
EquipSkill?: string; equip_skill?: string;
Tinks?: number; tinks?: number;
ContainerId?: number; container_id?: number;
current_wielded_location?: number; CurrentWieldedLocation?: number;
IntValues?: Record<string, number>;
}
// Icon helper: convert raw icon ID to hex filename
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`;
}
function itemName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; }
function val(v: number | undefined | null, sentinel = -1): number | undefined {
return (v !== undefined && v !== null && v !== sentinel) ? v : undefined;
}
// Build tooltip text
function itemTooltip(item: Item): string {
const parts = [itemName(item)];
const mat = item.material ?? item.material_name ?? item.Material;
if (mat) parts.push(`Material: ${mat}`);
const al = val(item.armor_level ?? item.ArmorLevel);
if (al) parts.push(`AL: ${al}`);
const dmg = val(item.max_damage ?? item.MaxDamage);
if (dmg) parts.push(`Damage: ${dmg}`);
const wk = val(item.workmanship ?? item.Workmanship);
if (wk) parts.push(`Work: ${wk}`);
const tk = val(item.tinks ?? item.Tinks);
if (tk) parts.push(`Tinks: ${tk}`);
const set = item.item_set ?? item.ItemSet;
if (set) parts.push(`Set: ${set}`);
const imb = item.imbue ?? item.Imbue;
if (imb) parts.push(`Imbue: ${imb}`);
return parts.join('\n');
}
// Equipment slot map matching v1
const EQUIP_SLOTS: Record<number, { name: string; row: number; col: number }> = {
32768:{name:'Neck',row:1,col:1},1:{name:'Head',row:1,col:3},
268435456:{name:'Sigil',row:1,col:5},536870912:{name:'Sigil',row:1,col:6},1073741824:{name:'Sigil',row:1,col:7},
67108864:{name:'Trinket',row:2,col:1},2048:{name:'U.Arm',row:2,col:2},
512:{name:'Chest',row:2,col:3},134217728:{name:'Cloak',row:2,col:7},
65536:{name:'Brace L',row:3,col:1},4096:{name:'L.Arm',row:3,col:2},
1024:{name:'Abdomen',row:3,col:3},8192:{name:'U.Leg',row:3,col:4},
131072:{name:'Brace R',row:3,col:5},2:{name:'Shirt',row:3,col:7},
262144:{name:'Ring L',row:4,col:1},32:{name:'Hands',row:4,col:2},
16384:{name:'L.Leg',row:4,col:4},524288:{name:'Ring R',row:4,col:5},4:{name:'Pants',row:4,col:7},
256:{name:'Feet',row:5,col:4},
2097152:{name:'Shield',row:6,col:1},1048576:{name:'Melee',row:6,col:3},
4194304:{name:'Missile',row:6,col:3},16777216:{name:'Held',row:6,col:3},
33554432:{name:'2H',row:6,col:3},8388608:{name:'Ammo',row:6,col:7},
};
const SLOT_BG: Record<number, string> = {};
[32768,67108864,65536,131072,262144,524288].forEach(m => SLOT_BG[m] = '#3a2050');
[1,512,2048,1024,4096,8192,16384,32,256].forEach(m => SLOT_BG[m] = '#1e2e4e');
[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 }) {
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' }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
</div>
);
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number>(0);
useEffect(() => {
setLoading(true);
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=1000`)
.then(d => setItems(d.items ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, [charName]);
const slotPositions = useMemo(() => {
const seen = new Set<string>();
const slots: Array<{ key: string; row: number; col: number; mask: number; name: string; bg: string }> = [];
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
const k = `${def.row}-${def.col}`;
if (!seen.has(k)) { seen.add(k); const mask = parseInt(maskStr); slots.push({ key: k, ...def, mask, bg: SLOT_BG[mask] ?? '#142040' }); }
});
return slots;
}, []);
const { equippedMap, containers, packItems } = useMemo(() => {
const equippedMap = new Map<string, Item>();
const containers: Item[] = [];
const containerIds = new Set<number>();
const packItems = new Map<number, Item[]>();
items.forEach(item => { const oc = item.ObjectClass ?? item.object_class ?? 0; if (oc === 10) { containers.push(item); containerIds.add(item.item_id ?? item.Id ?? 0); } });
items.forEach(item => {
if (containerIds.has(item.item_id ?? item.Id ?? 0)) return;
const wielded = item.current_wielded_location ?? item.CurrentWieldedLocation ?? (item.IntValues?.['10'] ? Number(item.IntValues['10']) : 0);
if (wielded > 0) {
for (const [maskStr, def] of Object.entries(EQUIP_SLOTS)) {
const slotMask = parseInt(maskStr);
if ((wielded & slotMask) === slotMask) { const key = `${def.row}-${def.col}`; if (!equippedMap.has(key)) { equippedMap.set(key, item); break; } }
}
} else {
const cid = item.container_id ?? item.ContainerId ?? 0;
if (!packItems.has(cid)) packItems.set(cid, []);
packItems.get(cid)!.push(item);
}
});
return { equippedMap, containers, packItems };
}, [items]);
const activeItems = packItems.get(activePack) ?? [...packItems.values()].flat().slice(0, 200);
if (loading) {
return <DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={580} height={700}>
<div style={{ padding: 20, color: '#666' }}>Loading inventory...</div>
</DraggableWindow>;
}
return (
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={580} height={700}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left: equipment + pack items */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Equipment grid */}
<div style={{ position: 'relative', height: 270, minHeight: 270, background: '#0d0d0d', borderBottom: '1px solid #333' }}>
{slotPositions.map(slot => {
const item = equippedMap.get(slot.key);
return (
<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,
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>}
</div>
);
})}
</div>
{/* Pack contents */}
<div style={{ padding: '4px 8px', fontWeight: 600, fontSize: '0.7rem', color: '#888', borderBottom: '1px solid #333' }}>
Contents ({activeItems.length})
</div>
<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,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', overflow: 'hidden' }}>
<ItemIcon item={item} size={36} />
</div>
))}
</div>
</div>
{/* Right: packs */}
<div style={{ width: 100, borderLeft: '1px solid #333', display: 'flex', flexDirection: 'column', fontSize: '0.65rem' }}>
<div style={{ padding: '4px 6px', fontWeight: 600, color: '#888', borderBottom: '1px solid #333' }}>Packs</div>
<div style={{ padding: '3px 6px', cursor: 'pointer', background: activePack === 0 ? '#2a3a4a' : '',
borderBottom: '1px solid #222', color: '#ccc' }} onClick={() => setActivePack(0)}>
🎒 Backpack
</div>
{containers.map(c => {
const cid = c.item_id ?? c.Id ?? 0;
return (
<div key={cid} style={{ padding: '3px 6px', cursor: 'pointer',
background: activePack === cid ? '#2a3a4a' : '', borderBottom: '1px solid #222', color: '#aaa' }}
onClick={() => setActivePack(cid)}>
📦 {(c.name ?? c.Name ?? 'Pack').split(' ')[0]}
</div>
);
})}
</div>
</div>
</DraggableWindow>
);
};