fix(v2): inventory — multi-slot armor + vertical fill bars + burden fix

1. Equipment slots: armor (object_class=2) now renders in ALL matching
   slots via bitmask, not just the first. E.g. a chest piece covering
   upper arm + chest + abdomen appears in all 3 slots. Non-armor items
   still use first-match. Matches v1's exact logic.

2. Pack fill bars: changed from horizontal-below to vertical-right of
   each pack icon. 4px wide bar with fill from bottom, color-coded:
   green <70%, orange 70-90%, red >90%.

3. Burden: removed garbled percentage (was dividing by 10). Now shows
   "Burden" label with total burden in tooltip. Bar shows 50% as
   placeholder until character_stats provides encumbrance_capacity.

4. PackIcon component: reusable for main backpack + sub-packs, shows
   game icon + vertical fill bar + green active glow + gold ▶ arrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 21:31:58 +02:00
parent a38c7f3e69
commit 2b1c06a5e0
4 changed files with 194 additions and 149 deletions

View file

@ -109,6 +109,26 @@ function ItemTooltip({ item, x, y }: { item: Item; x: number; y: number }) {
);
}
function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
iconSrc: string; isActive: boolean; fillPct: number; label: string; onClick: () => void;
}) {
const fillColor = fillPct > 90 ? '#b7432c' : fillPct > 70 ? '#d8a431' : '#00ff00';
return (
<div onClick={onClick} title={label}
style={{ display: 'flex', alignItems: 'stretch', gap: 2, cursor: 'pointer', flexShrink: 0, marginTop: 2, position: 'relative' }}>
{isActive && <span style={{ position: 'absolute', left: -11, top: 10, color: gold, fontSize: 10 }}></span>}
<div style={{ width: 30, height: 30, border: isActive ? '1px solid #00ff00' : '1px solid transparent', boxShadow: isActive ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img src={iconSrc} alt="" style={{ width: 26, height: 26, objectFit: 'contain', imageRendering: 'pixelated' }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06001080.png'; }} />
</div>
{/* Vertical fill bar to the right */}
<div style={{ width: 4, background: '#111', border: '1px solid #333', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${fillPct}%`, background: fillColor, transition: 'height 0.3s' }} />
</div>
</div>
);
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
@ -150,9 +170,34 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
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; } }
const oc = item.ObjectClass ?? item.object_class ?? 0;
const isArmor = oc === 2;
if (isArmor) {
// Armor: render in ALL matching slots (multi-slot like chest covers upper arm + chest + abdomen)
Object.entries(EQUIP_SLOTS).forEach(([maskStr, def]) => {
const slotMask = parseInt(maskStr);
if ((wielded & slotMask) === slotMask) {
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) equippedMap.set(key, item);
}
});
} else {
// Non-armor: exact mask match first, then first bit overlap
let placed = false;
if (EQUIP_SLOTS[wielded]) {
const def = EQUIP_SLOTS[wielded];
const key = `${def.row}-${def.col}`;
if (!equippedMap.has(key)) { equippedMap.set(key, item); placed = true; }
}
if (!placed) {
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;
@ -166,8 +211,11 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const mainItems = packItems.get(0) ?? [...packItems.values()].flat().slice(0, 200);
const activeItems = activePack !== null ? (packItems.get(activePack) ?? []) : mainItems;
// Burden (simplified — needs character_stats data for full calc)
// Burden calculation — sum all item burdens
const totalBurden = items.reduce((s, i) => s + (i.burden ?? i.Burden ?? 0), 0);
// Typical encumbrance capacity is ~15000-30000 burden units
// Without character_stats we estimate; the bar is just visual
const burdenDisplay = totalBurden.toLocaleString();
if (loading) {
return <DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={572} height={720}>
@ -226,42 +274,39 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
{/* SIDEBAR: Burden + Packs */}
<div style={{ width: 38, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '4px 2px', gap: 2, borderLeft: `1px solid ${gold}`, borderRight: `1px solid ${gold}` }}>
{/* Burden bar */}
<div style={{ textAlign: 'center', fontSize: 9, color: '#ccc', whiteSpace: 'nowrap', marginBottom: 2 }}>
{Math.round(totalBurden / 10)}%
{/* Burden label */}
<div style={{ textAlign: 'center', fontSize: 8, color: '#ccc', whiteSpace: 'nowrap', marginBottom: 2 }}>
Burden
</div>
<div style={{ width: 14, height: 40, background: '#111', border: '1px solid #555', position: 'relative', overflow: 'hidden', marginBottom: 4, flexShrink: 0 }} title={`Burden: ${totalBurden.toLocaleString()}`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${Math.min(100, totalBurden / 200)}%`, background: '#2e8b57', transition: 'height 0.3s' }} />
<div style={{ width: 14, height: 40, background: '#111', border: '1px solid #555', position: 'relative', overflow: 'hidden', marginBottom: 4, flexShrink: 0 }}
title={`Burden: ${burdenDisplay}`}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%', background: '#2e8b57', transition: 'height 0.3s' }} />
</div>
{/* Pack list with icons */}
{/* Main backpack */}
<div onClick={() => setActivePack(null)}
style={{ width: 32, height: 32, position: 'relative', cursor: 'pointer', border: activePack === null ? '1px solid #00ff00' : '1px solid transparent', boxShadow: activePack === null ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{activePack === null && <span style={{ position: 'absolute', left: -11, top: 10, color: gold, fontSize: 10 }}></span>}
<img src="/icons/0600127E.png" alt="Backpack" style={{ width: 28, height: 28, objectFit: 'contain', imageRendering: 'pixelated' }} onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{/* Fill indicator */}
<div style={{ position: 'absolute', bottom: -6, left: -1, width: 36, height: 4, background: '#000', border: '1px solid #333' }}>
<div style={{ height: '100%', width: `${Math.min(100, (mainItems.length / 102) * 100)}%`, background: '#00ff00' }} />
</div>
</div>
<PackIcon
iconSrc="/icons/0600127E.png"
isActive={activePack === null}
fillPct={Math.min(100, (mainItems.length / 102) * 100)}
label={`Backpack (${mainItems.length}/102)`}
onClick={() => setActivePack(null)}
/>
{/* Sub-packs */}
{containers.map(c => {
const cid = c.item_id ?? c.Id ?? 0;
const pItems = packItems.get(cid) ?? [];
const capacity = c.items_capacity ?? c.ItemsCapacity ?? 24;
const isActive = activePack === cid;
return (
<div key={cid} onClick={() => setActivePack(cid)}
style={{ width: 32, height: 32, position: 'relative', cursor: 'pointer', border: isActive ? '1px solid #00ff00' : '1px solid transparent', boxShadow: isActive ? '0 0 4px #00ff00' : 'none', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 6 }}>
{isActive && <span style={{ position: 'absolute', left: -11, top: 10, color: gold, fontSize: 10 }}></span>}
<img src={`/icons/${iconHex(c.icon ?? c.Icon ?? 0)}.png`} alt={getName(c)} style={{ width: 28, height: 28, objectFit: 'contain', imageRendering: 'pixelated' }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06001080.png'; }} />
<div style={{ position: 'absolute', bottom: -6, left: -1, width: 36, height: 4, background: '#000', border: '1px solid #333' }}>
<div style={{ height: '100%', width: `${Math.min(100, (pItems.length / capacity) * 100)}%`, background: '#00ff00' }} />
</div>
</div>
<PackIcon
key={cid}
iconSrc={`/icons/${iconHex(c.icon ?? c.Icon ?? 0)}.png`}
isActive={activePack === cid}
fillPct={Math.min(100, (pItems.length / capacity) * 100)}
label={`${getName(c)} (${pItems.length}/${capacity})`}
onClick={() => setActivePack(cid)}
/>
);
})}
</div>