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:
parent
a38c7f3e69
commit
2b1c06a5e0
4 changed files with 194 additions and 149 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue