fix(v2): inventory window — v1-style equipment grid + pack sidebar

Rebuilt InventoryWindow to match v1's three-panel layout:

Left column:
- Equipment grid: 6×7 slot grid (44px per slot) with color-coded
  backgrounds matching v1's EQUIP_SLOTS map (purple=jewelry,
  blue=armor, teal=clothing, dark blue=weapons). Items placed in
  correct slots using wielded_location bitmask matching.
- Pack contents: 40px item tiles in a wrapping grid, hover for
  full item tooltip (name, material, AL, damage, workmanship)

Right sidebar:
- Pack browser: main backpack + sub-packs (containers). Click to
  switch which pack's contents are shown. Item counts per pack.

Equipment slot layout matches v1 exactly: Row 1 (Neck, Head, Sigils),
Row 2 (Trinket, Upper Arm, Chest, Cloak), Row 3 (Bracelets, Lower
Arm, Abdomen, Upper Leg, Shirt), Row 4 (Rings, Hands, Lower Leg,
Pants), Row 5 (Feet), Row 6 (Shield, Weapon, Ammo).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:52:48 +02:00
parent 52e1bcd6b8
commit 863adb0c3c
3 changed files with 222 additions and 150 deletions

View file

@ -1,32 +1,46 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
interface Item {
Name: string;
ObjectClass?: number;
Icon?: number;
Value?: number;
Burden?: number;
ArmorLevel?: number;
MaxDamage?: number;
Workmanship?: number;
Material?: string;
ItemSet?: string;
Imbue?: string;
EquipSkill?: string;
Mastery?: string;
Tinks?: number;
WieldLevel?: number;
ContainerId?: number;
item_id?: number; Name: string; ObjectClass?: number; object_class?: number;
Icon?: number; Value?: number; Burden?: number; ArmorLevel?: number;
MaxDamage?: number; Workmanship?: number; Material?: string; ItemSet?: string;
Imbue?: string; EquipSkill?: string; Tinks?: number; ContainerId?: number;
container_id?: number; current_wielded_location?: number;
}
// Equipment slot positions matching v1's EQUIP_SLOTS
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 (Blue)', row: 1, col: 5 }, 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 },
1073741824: { name: 'Sigil (Red)', row: 1, col: 7 },
67108864: { name: 'Trinket', row: 2, col: 1 }, 2048: { name: 'Upper Arm', row: 2, col: 2 },
512: { name: 'Chest Armor', row: 2, col: 3 }, 134217728: { name: 'Cloak', row: 2, col: 7 },
65536: { name: 'Bracelet (L)', row: 3, col: 1 }, 4096: { name: 'Lower Arm', row: 3, col: 2 },
1024: { name: 'Abdomen', row: 3, col: 3 }, 8192: { name: 'Upper Leg', row: 3, col: 4 },
131072: { name: 'Bracelet (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: 'Lower 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: 'Two Handed', row: 6, col: 3 }, 8388608: { name: 'Ammo', row: 6, col: 7 },
};
const SLOT_COLOR: Record<number, string> = {};
[32768,67108864,65536,131072,262144,524288].forEach(m => SLOT_COLOR[m] = '#4a3060');
[1,512,2048,1024,4096,8192,16384,32,256].forEach(m => SLOT_COLOR[m] = '#2a3a5a');
[2,4,134217728,268435456,536870912,1073741824].forEach(m => SLOT_COLOR[m] = '#2a4a4a');
[2097152,1048576,4194304,16777216,33554432,8388608].forEach(m => SLOT_COLOR[m] = '#1a2a4a');
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [activePack, setActivePack] = useState<number>(0);
useEffect(() => {
setLoading(true);
@ -36,90 +50,147 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
.finally(() => setLoading(false));
}, [charName]);
const filtered = filter
? items.filter(i => i.Name?.toLowerCase().includes(filter.toLowerCase()))
: items;
// Build unique slot positions
const slotPositions = useMemo(() => {
const seen = new Set<string>();
const slots: Array<{ key: string; row: number; col: number; mask: number; name: string; color: 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, color: SLOT_COLOR[mask] ?? '#1a2a4a' });
}
});
return slots;
}, []);
// Separate equipped items (those with equipment-related fields) from pack contents
const equipped = filtered.filter(i =>
(i.ArmorLevel && i.ArmorLevel > 0) || (i.MaxDamage && i.MaxDamage > 0) ||
i.Imbue || i.ItemSet || (i.Tinks && i.Tinks > 0)
);
const other = filtered.filter(i => !equipped.includes(i));
// Categorize items
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 ?? 0); }
});
items.forEach(item => {
if (containerIds.has(item.item_id ?? 0)) return;
const wielded = item.current_wielded_location ?? 0;
if (wielded > 0) {
// Place in first matching slot
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.get(0) ?? [];
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={620} height={550}>
<div style={{ padding: '4px 8px', borderBottom: '1px solid #333' }}>
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter items..."
style={{ width: '100%', padding: '4px 8px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, outline: 'none', boxSizing: 'border-box' }}
/>
</div>
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.73rem' }}>
{loading ? (
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
) : items.length === 0 ? (
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
) : (
<>
{equipped.length > 0 && (
<>
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#88f', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
Equipment &amp; Notable Items ({equipped.length})
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={580} height={700}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left column: equipment grid + pack items */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Equipment grid */}
<div style={{ position: 'relative', height: 270, minHeight: 270, background: '#111', borderBottom: '1px solid #333' }}>
{slotPositions.map(slot => {
const item = equippedMap.get(slot.key);
return (
<div key={slot.key} title={item ? `${item.Name}${item.Material ? ` (${item.Material})` : ''}` : slot.name}
style={{
position: 'absolute',
left: (slot.col - 1) * 44 + 4,
top: (slot.row - 1) * 44 + 4,
width: 40, height: 40,
background: item ? slot.color : `${slot.color}44`,
border: `1px solid ${item ? '#666' : '#333'}`,
borderRadius: 3,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: item ? '0.58rem' : '0.5rem',
color: item ? '#ddd' : '#555',
overflow: 'hidden', textOverflow: 'ellipsis',
cursor: item ? 'help' : 'default',
padding: 1, textAlign: 'center', lineHeight: 1.1,
}}>
{item ? item.Name?.split(' ').slice(0, 2).join(' ') : slot.name}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem' }}>
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Item</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Imbue</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Wk</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Tink</th>
</tr>
</thead>
<tbody>
{equipped.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '2px 6px', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>{item.Name}</td>
<td style={{ padding: '2px 4px', color: '#888', fontSize: '0.68rem' }}>{item.Material || ''}</td>
<td style={{ padding: '2px 4px', color: '#9d9', fontSize: '0.68rem' }}>{item.ItemSet || ''}</td>
<td style={{ padding: '2px 4px', color: '#da8', fontSize: '0.68rem' }}>{item.Imbue || ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#f88' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#8af' }}>{item.Tinks && item.Tinks > 0 ? item.Tinks : ''}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{other.length > 0 && (
<>
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#888', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
Pack Contents ({other.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4 }}>
{other.map((item, i) => (
<div
key={i}
style={{ fontSize: '0.65rem', padding: '2px 6px', background: '#252525', borderRadius: 3, color: '#aaa', cursor: 'default' }}
title={`${item.Name}${item.Value ? ` (${item.Value} pyreal)` : ''}`}
>
{item.Name}
</div>
))}
</div>
</>
)}
</>
)}
);
})}
</div>
{/* Pack contents header */}
<div style={{ padding: '4px 8px', fontWeight: 600, fontSize: '0.7rem', color: '#888', borderBottom: '1px solid #333' }}>
Contents ({activeItems.length} items)
</div>
{/* Item grid */}
<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 ?? i}
title={`${item.Name}${item.Material ? ` [${item.Material}]` : ''}${item.ArmorLevel && item.ArmorLevel > 0 ? ` AL:${item.ArmorLevel}` : ''}${item.MaxDamage && item.MaxDamage > 0 ? ` Dmg:${item.MaxDamage}` : ''}${item.Workmanship ? ` WK:${item.Workmanship}` : ''}`}
style={{
width: 40, height: 40,
background: '#252525', border: '1px solid #333', borderRadius: 2,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.48rem', color: '#aaa', padding: 1,
textAlign: 'center', lineHeight: 1.1, cursor: 'help', overflow: 'hidden',
}}>
{item.Name?.split(' ').slice(0, 2).join('\n')}
</div>
))}
</div>
</div>
{/* Right sidebar: packs + burden */}
<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>
{/* Main backpack */}
<div style={{ padding: '3px 6px', cursor: 'pointer', background: activePack === 0 ? '#2a3a4a' : '',
borderBottom: '1px solid #222', color: '#ccc' }}
onClick={() => setActivePack(0)}>
🎒 Backpack
<span style={{ color: '#666', marginLeft: 4 }}>({(packItems.get(0) ?? []).length})</span>
</div>
{/* Sub-packs */}
{containers.map(c => {
const cid = c.item_id ?? 0;
const count = (packItems.get(cid) ?? []).length;
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?.split(' ')[0] ?? 'Pack'}
<span style={{ color: '#666', marginLeft: 4 }}>({count})</span>
</div>
);
})}
</div>
</div>
</DraggableWindow>
);