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>
202 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
};
|