519 lines
24 KiB
TypeScript
519 lines
24 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||
import { classById, type ClassId } from "../app/characterCatalog";
|
||
import { useShellStore } from "../app/shellStore";
|
||
import {
|
||
EQUIPMENT_SLOTS,
|
||
PLAYER_EQUIPMENT_OWNER_ID,
|
||
classCanEquipItem,
|
||
equippedOwnerId,
|
||
equipmentSlotForItem,
|
||
type EquipmentSlot,
|
||
} from "../game/equipment";
|
||
import { useCombatStore } from "../game/combatStore";
|
||
import {
|
||
itemStatSummary,
|
||
weaponDps,
|
||
type ItemStats,
|
||
} from "../game/itemStats";
|
||
import type { InventoryItem } from "../game/lootTypes";
|
||
import { usePartyStore } from "../game/partyStore";
|
||
import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats";
|
||
import { useGameStore } from "../game/store";
|
||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||
import { ControllerButton } from "./ControllerButton";
|
||
import {
|
||
compareEquipmentUpgrade,
|
||
resolvedAverageItemLevel,
|
||
resolvedEquipmentLoadout,
|
||
type HeirloomContext,
|
||
} from "../game/heirloomEquipment";
|
||
import { gearProfileFor } from "../game/specializationCatalog";
|
||
import type { PartyRole } from "../game/partyRoles";
|
||
|
||
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
|
||
head: "Head",
|
||
neck: "Neck",
|
||
shoulder: "Shoulders",
|
||
back: "Back",
|
||
chest: "Chest",
|
||
wrist: "Wrists",
|
||
hands: "Hands",
|
||
waist: "Waist",
|
||
legs: "Legs",
|
||
feet: "Feet",
|
||
finger: "Finger",
|
||
trinket: "Trinket",
|
||
"main-hand": "Main hand",
|
||
"off-hand": "Off hand",
|
||
ranged: "Ranged",
|
||
});
|
||
|
||
interface EquipmentOwner {
|
||
readonly id: string;
|
||
readonly name: string;
|
||
readonly detail: string;
|
||
readonly classId: ClassId;
|
||
readonly secondaryClassId?: ClassId | null;
|
||
readonly specializationId: string | null;
|
||
readonly role: PartyRole;
|
||
readonly level: number;
|
||
readonly ownerKind: "player" | "party";
|
||
}
|
||
|
||
type StatPage = "attributes" | "offense" | "defense";
|
||
const STAT_PAGES: readonly StatPage[] = ["attributes", "offense", "defense"];
|
||
|
||
const ITEM_STAT_LABELS: readonly [keyof ItemStats, string][] = [
|
||
["strength", "Strength"],
|
||
["agility", "Agility"],
|
||
["stamina", "Stamina"],
|
||
["intellect", "Intellect"],
|
||
["spirit", "Spirit"],
|
||
["attackPower", "Attack Power"],
|
||
["rangedAttackPower", "Ranged Attack Power"],
|
||
["spellPower", "Spell Power"],
|
||
["criticalStrikeRating", "Critical Strike Rating"],
|
||
["hitRating", "Hit Rating"],
|
||
["hasteRating", "Haste Rating"],
|
||
["expertiseRating", "Expertise Rating"],
|
||
["armorPenetrationRating", "Armor Penetration Rating"],
|
||
["defenseRating", "Defense Rating"],
|
||
["dodgeRating", "Dodge Rating"],
|
||
["parryRating", "Parry Rating"],
|
||
["blockRating", "Block Rating"],
|
||
["blockValue", "Block Value"],
|
||
["resilienceRating", "Resilience Rating"],
|
||
["spellPenetration", "Spell Penetration"],
|
||
["mp5", "Mana per 5 sec"],
|
||
["healthPer5", "Health per 5 sec"],
|
||
["arcaneResistance", "Arcane Resistance"],
|
||
["fireResistance", "Fire Resistance"],
|
||
["frostResistance", "Frost Resistance"],
|
||
["natureResistance", "Nature Resistance"],
|
||
["shadowResistance", "Shadow Resistance"],
|
||
["health", "Health"],
|
||
["mana", "Mana"],
|
||
];
|
||
|
||
function displayNumber(value: number, digits = 0): string {
|
||
return value.toLocaleString(undefined, {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
});
|
||
}
|
||
|
||
function itemStatRows(item: InventoryItem): readonly { label: string; value: string; beneficial?: boolean }[] {
|
||
const rows: { label: string; value: string; beneficial?: boolean }[] = [];
|
||
const weapon = item.stats.meleeWeapon ?? item.stats.rangedWeapon;
|
||
if (weapon) {
|
||
rows.push({
|
||
label: `${weapon.damageSchool[0].toUpperCase()}${weapon.damageSchool.slice(1)} damage`,
|
||
value: `${displayNumber(weapon.damageMin, weapon.damageMin % 1 ? 1 : 0)}–${displayNumber(weapon.damageMax, weapon.damageMax % 1 ? 1 : 0)}`,
|
||
});
|
||
rows.push({ label: "Speed", value: (weapon.speedMs / 1_000).toFixed(2) });
|
||
rows.push({ label: "Damage per second", value: weaponDps(weapon).toFixed(2) });
|
||
}
|
||
if (item.stats.armor > 0) rows.push({ label: "Armor", value: displayNumber(item.stats.armor) });
|
||
for (const [key, label] of ITEM_STAT_LABELS) {
|
||
if (key === "armor" || key === "meleeWeapon" || key === "rangedWeapon") continue;
|
||
const value = item.stats[key];
|
||
if (typeof value === "number" && value > 0) {
|
||
rows.push({
|
||
label,
|
||
value: `+${displayNumber(value, value % 1 ? 2 : 0)}`,
|
||
beneficial: true,
|
||
});
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function characterRows(stats: CharacterStats, page: StatPage): readonly { label: string; value: string }[] {
|
||
if (page === "attributes") {
|
||
return [
|
||
{ label: "Strength", value: displayNumber(stats.strength) },
|
||
{ label: "Agility", value: displayNumber(stats.agility) },
|
||
{ label: "Stamina", value: displayNumber(stats.stamina) },
|
||
{ label: "Intellect", value: displayNumber(stats.intellect) },
|
||
{ label: "Spirit", value: displayNumber(stats.spirit) },
|
||
{ label: "Health", value: displayNumber(stats.health) },
|
||
...(stats.mana ? [{ label: "Mana", value: displayNumber(stats.mana) }] : []),
|
||
{ label: "Health / 5 sec", value: displayNumber(stats.healthPer5, 1) },
|
||
...(stats.mana ? [{ label: "Mana / 5 sec", value: displayNumber(stats.manaPer5, 1) }] : []),
|
||
];
|
||
}
|
||
if (page === "offense") {
|
||
return [
|
||
{ label: "Attack Power", value: displayNumber(stats.attackPower) },
|
||
{ label: "Ranged Attack Power", value: displayNumber(stats.rangedAttackPower) },
|
||
{ label: "Spell Power", value: displayNumber(stats.spellPower) },
|
||
{ label: "Melee Crit", value: `${stats.meleeCritPercent.toFixed(2)}%` },
|
||
{ label: "Ranged Crit", value: `${stats.rangedCritPercent.toFixed(2)}%` },
|
||
{ label: "Spell Crit", value: `${stats.spellCritPercent.toFixed(2)}%` },
|
||
{ label: "Melee Hit", value: `${stats.meleeHitPercent.toFixed(2)}%` },
|
||
{ label: "Ranged Hit", value: `${stats.rangedHitPercent.toFixed(2)}%` },
|
||
{ label: "Spell Hit", value: `${stats.spellHitPercent.toFixed(2)}%` },
|
||
{ label: "Melee Haste", value: `${stats.meleeHastePercent.toFixed(2)}%` },
|
||
{ label: "Ranged Haste", value: `${stats.rangedHastePercent.toFixed(2)}%` },
|
||
{ label: "Spell Haste", value: `${stats.spellHastePercent.toFixed(2)}%` },
|
||
{ label: "Expertise", value: stats.expertise.toFixed(2) },
|
||
{ label: "Armor Penetration", value: `${stats.armorPenetrationPercent.toFixed(2)}%` },
|
||
{ label: "Spell Penetration", value: displayNumber(stats.spellPenetration) },
|
||
];
|
||
}
|
||
return [
|
||
{ label: "Armor", value: `${displayNumber(stats.armor)} · ${stats.armorReductionPercent.toFixed(2)}%` },
|
||
{ label: "Defense", value: stats.defenseSkill.toFixed(2) },
|
||
{ label: "Dodge", value: `${stats.dodgePercent.toFixed(2)}%` },
|
||
{ label: "Parry", value: `${stats.parryPercent.toFixed(2)}%` },
|
||
{ label: "Block", value: `${stats.blockPercent.toFixed(2)}%` },
|
||
{ label: "Block Value", value: displayNumber(stats.blockValue) },
|
||
{ label: "Resilience damage reduction", value: `${stats.resiliencePercent.toFixed(2)}%` },
|
||
{ label: "Arcane Resistance", value: displayNumber(stats.arcaneResistance) },
|
||
{ label: "Fire Resistance", value: displayNumber(stats.fireResistance) },
|
||
{ label: "Frost Resistance", value: displayNumber(stats.frostResistance) },
|
||
{ label: "Nature Resistance", value: displayNumber(stats.natureResistance) },
|
||
{ label: "Shadow Resistance", value: displayNumber(stats.shadowResistance) },
|
||
];
|
||
}
|
||
|
||
function itemType(item: InventoryItem): string {
|
||
return [
|
||
item.armorType,
|
||
item.weaponType,
|
||
SLOT_LABELS[equipmentSlotForItem(item) ?? "trinket"],
|
||
].filter(Boolean).join(" · ");
|
||
}
|
||
|
||
function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
|
||
const character = useShellStore((state) => state.activeCharacter);
|
||
const inventory = useCombatStore((state) => state.inventory);
|
||
const equipment = useCombatStore((state) => state.equipment);
|
||
const playerClassId = useCombatStore((state) => state.classId);
|
||
const playerSecondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||
const playerLevel = useCombatStore((state) => state.level);
|
||
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
|
||
const playerGearStats = useCombatStore((state) => state.gearStats);
|
||
const playerSpecializationId = useCombatStore((state) => state.specializationId);
|
||
const playerGearRole = useCombatStore((state) => state.gearRole);
|
||
const feedback = useCombatStore((state) => state.feedback);
|
||
const members = usePartyStore((state) => state.members);
|
||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||
const close = () => openOverlay("pause");
|
||
const owners = useMemo<readonly EquipmentOwner[]>(() => [
|
||
{
|
||
id: PLAYER_EQUIPMENT_OWNER_ID,
|
||
name: character?.name ?? "You",
|
||
detail: "Player",
|
||
classId: playerClassId,
|
||
secondaryClassId: playerSecondaryClassId,
|
||
specializationId: playerSpecializationId,
|
||
role: playerGearRole,
|
||
level: playerLevel,
|
||
ownerKind: "player" as const,
|
||
},
|
||
...members.map((member) => ({
|
||
id: member.id,
|
||
name: member.name,
|
||
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
|
||
classId: member.classId,
|
||
secondaryClassId: member.secondaryClassId,
|
||
specializationId: member.specializationId,
|
||
role: member.role,
|
||
level: member.level,
|
||
ownerKind: "party" as const,
|
||
})),
|
||
], [character?.name, members, playerClassId, playerSecondaryClassId, playerGearRole, playerLevel, playerSpecializationId]);
|
||
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
|
||
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
|
||
const [statPage, setStatPage] = useState<StatPage>("attributes");
|
||
|
||
useEffect(() => {
|
||
if (!owners.some((owner) => owner.id === ownerId)) setOwnerId(PLAYER_EQUIPMENT_OWNER_ID);
|
||
}, [ownerId, owners]);
|
||
useEffect(() => {
|
||
if (!inventory.some((item) => item.instanceId === inspectedId)) {
|
||
setInspectedId(inventory.at(-1)?.instanceId ?? "");
|
||
}
|
||
}, [inspectedId, inventory]);
|
||
|
||
const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0];
|
||
const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null;
|
||
const ownerById = useMemo(() => new Map(owners.map((candidate) => [candidate.id, candidate])), [owners]);
|
||
const heirloomContext: HeirloomContext = {
|
||
ownerId: owner.id,
|
||
ownerKind: owner.ownerKind,
|
||
classId: owner.classId,
|
||
secondaryClassId: owner.secondaryClassId,
|
||
specializationId: owner.specializationId,
|
||
role: owner.role,
|
||
level: owner.level,
|
||
};
|
||
const loadout = useMemo(
|
||
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
|
||
[equipment, inventory, owner.classId, owner.secondaryClassId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
|
||
);
|
||
const earnedLoadout = equipment[owner.id] ?? {};
|
||
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
|
||
right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt
|
||
)), [inventory]);
|
||
|
||
const equip = (item: InventoryItem) => {
|
||
setInspectedId(item.instanceId);
|
||
if (readOnly) return;
|
||
useCombatStore.getState().equipItem(item.instanceId, owner.id);
|
||
};
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
...owners.map((candidate) => ({
|
||
id: `equipment-owner-${candidate.id}`,
|
||
run: () => setOwnerId(candidate.id),
|
||
})),
|
||
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
|
||
id: `equipment-slot-${slot}`,
|
||
run: () => { if (!readOnly) useCombatStore.getState().unequipItem(owner.id, slot); },
|
||
}] : []),
|
||
...STAT_PAGES.map((page) => ({
|
||
id: `equipment-stat-${page}`,
|
||
run: () => setStatPage(page),
|
||
})),
|
||
...sortedInventory.map((item) => ({
|
||
id: `equipment-item-${item.instanceId}`,
|
||
run: () => equip(item),
|
||
})),
|
||
{ id: "equipment-back", run: close },
|
||
], [earnedLoadout, owner.id, owners, sortedInventory]);
|
||
const controller = useMenuController(actions, {
|
||
columns: 2,
|
||
initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`,
|
||
onBack: close,
|
||
});
|
||
|
||
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
|
||
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
|
||
const compatible = inspected ? classCanEquipItem(owner.classId, inspected, owner) : false;
|
||
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
|
||
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
|
||
const inspectedComparison = inspected && compatible
|
||
? compareEquipmentUpgrade(inspected, loadout, gearProfile, heirloomContext)
|
||
: null;
|
||
const selectedMember = members.find((member) => member.id === owner.id) ?? null;
|
||
const ownerLevel = selectedMember?.level ?? playerLevel;
|
||
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
|
||
const ownerGearStats = selectedMember?.gearStats ?? playerGearStats;
|
||
const ownerRaceId = selectedMember?.raceId ?? character?.raceId ?? "human";
|
||
const ownerStats = deriveCharacterStats(owner.classId, ownerLevel, ownerGearStats, ownerRaceId);
|
||
const visibleCharacterRows = characterRows(ownerStats, statPage);
|
||
const inspectedStatRows = inspected ? itemStatRows(inspected) : [];
|
||
|
||
return (
|
||
<div className="modal-backdrop game-panel-backdrop" role="presentation">
|
||
<section className="game-panel equipment-panel" role="dialog" aria-modal="true" aria-labelledby="equipment-title">
|
||
<header className="game-panel__header">
|
||
<div>
|
||
<p className="eyebrow">Shared expedition inventory</p>
|
||
<h2 id="equipment-title">Inventory & Equipment</h2>
|
||
</div>
|
||
<div className="equipment-panel__summary">
|
||
<strong>{inventory.length}</strong><span>Items</span>
|
||
<i aria-hidden="true" />
|
||
<strong>{gearLevel}</strong><span>Average item level</span>
|
||
</div>
|
||
<ControllerButton
|
||
controlId="equipment-back"
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className="icon-button"
|
||
onClick={close}
|
||
aria-label="Return to pause menu"
|
||
>×</ControllerButton>
|
||
</header>
|
||
|
||
<nav className="equipment-panel__owners" aria-label="Equip a party member">
|
||
{owners.map((candidate) => {
|
||
const definition = classById(candidate.classId);
|
||
return (
|
||
<ControllerButton
|
||
key={candidate.id}
|
||
controlId={`equipment-owner-${candidate.id}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className={`equipment-owner ${candidate.id === owner.id ? "is-active" : ""}`}
|
||
style={{ "--equipment-class-color": definition.color } as CSSProperties}
|
||
aria-pressed={candidate.id === owner.id}
|
||
onClick={() => setOwnerId(candidate.id)}
|
||
>
|
||
<b>{definition.sigil}</b>
|
||
<span><strong>{candidate.name}</strong><small>{candidate.detail} · {definition.name}</small></span>
|
||
<em>{resolvedAverageItemLevel(equipment, inventory, {
|
||
ownerId: candidate.id,
|
||
ownerKind: candidate.ownerKind,
|
||
classId: candidate.classId,
|
||
secondaryClassId: candidate.secondaryClassId,
|
||
specializationId: candidate.specializationId,
|
||
role: candidate.role,
|
||
level: candidate.level,
|
||
})}</em>
|
||
</ControllerButton>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className="equipment-panel__body">
|
||
<section className="equipment-loadout" aria-label={`${owner.name} equipped items`}>
|
||
<header><span><small>Equipping</small><strong>{owner.name}</strong></span><b>{classById(owner.classId).name}</b></header>
|
||
<div className="equipment-stat-tabs" role="tablist" aria-label={`${owner.name} stat categories`}>
|
||
{STAT_PAGES.map((page) => (
|
||
<ControllerButton
|
||
key={page}
|
||
controlId={`equipment-stat-${page}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={statPage === page}
|
||
className={statPage === page ? "is-active" : ""}
|
||
onClick={() => setStatPage(page)}
|
||
>{page}</ControllerButton>
|
||
))}
|
||
</div>
|
||
<dl className="equipment-character-stats" aria-label={`${owner.name} ${statPage} stats`}>
|
||
{visibleCharacterRows.map((row) => (
|
||
<div key={row.label}><dt>{row.label}</dt><dd>{row.value}</dd></div>
|
||
))}
|
||
</dl>
|
||
<div className="equipment-character-vitals">
|
||
<span>Combat health <strong>{displayNumber(ownerMaxHealth)}</strong></span>
|
||
<span>Gear stamina <strong>+{displayNumber(ownerGearStats.stamina)}</strong></span>
|
||
</div>
|
||
<div className="equipment-loadout__slots">
|
||
{EQUIPMENT_SLOTS.map((slot) => {
|
||
const item = loadout[slot] ?? null;
|
||
const earned = Boolean(earnedLoadout[slot]);
|
||
return item ? (
|
||
<ControllerButton
|
||
key={slot}
|
||
controlId={`equipment-slot-${slot}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className={`equipment-slot equipment-quality--${item.quality}`}
|
||
onFocus={() => setInspectedId(item.instanceId)}
|
||
disabled={readOnly || !earned}
|
||
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
|
||
>
|
||
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
|
||
<em>{earned ? "Unequip" : `Heirloom ${item.itemLevel}`}</em>
|
||
</ControllerButton>
|
||
) : (
|
||
<div key={slot} className="equipment-slot is-empty">
|
||
<span><small>{SLOT_LABELS[slot]}</small><strong>Empty</strong></span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="equipment-inventory" aria-label="Collected loot">
|
||
<header><span><small>Boss drops</small><strong>Shared inventory</strong></span><b>{inventory.length} owned</b></header>
|
||
<div className="equipment-inventory__list">
|
||
{sortedInventory.map((item) => {
|
||
const assignedId = equippedOwnerId(equipment, item.instanceId);
|
||
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
|
||
const canEquip = classCanEquipItem(owner.classId, item, owner);
|
||
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
|
||
return (
|
||
<ControllerButton
|
||
key={item.instanceId}
|
||
controlId={`equipment-item-${item.instanceId}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className={`equipment-item equipment-quality--${item.quality} ${item.instanceId === inspected?.instanceId ? "is-inspected" : ""} ${!canEquip ? "is-incompatible" : ""}`}
|
||
aria-label={`${item.name}, level ${item.itemLevel}, ${itemStatSummary(item.stats)}, ${canEquip ? `equip on ${owner.name}` : `${owner.name} cannot equip`}`}
|
||
onFocus={() => setInspectedId(item.instanceId)}
|
||
onClick={() => equip(item)}
|
||
>
|
||
<b>{item.name.slice(0, 2).toUpperCase()}</b>
|
||
<span>
|
||
<strong>{item.name}</strong>
|
||
<small>Level {item.itemLevel} · {itemType(item)}</small>
|
||
<small className="equipment-item__stats">{itemStatSummary(item.stats)}</small>
|
||
</span>
|
||
<em>{comparison?.isUpgrade ? `Upgrade +${displayNumber(comparison.scoreDelta, 1)}` : assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
|
||
</ControllerButton>
|
||
);
|
||
})}
|
||
{!inventory.length && (
|
||
<p className="game-panel__empty">
|
||
No loot collected yet. Boss drops are added here automatically when the boss is defeated.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="equipment-details" aria-live="polite">
|
||
{inspected ? (
|
||
<>
|
||
<span className={`equipment-details__icon equipment-quality--${inspected.quality}`}>
|
||
{inspected.name.slice(0, 2).toUpperCase()}
|
||
</span>
|
||
<p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p>
|
||
<h3>{inspected.name}</h3>
|
||
<p>{itemType(inspected)}</p>
|
||
{inspectedComparison && (
|
||
<p className={inspectedComparison.isUpgrade ? "is-beneficial" : undefined}>
|
||
{inspectedComparison.isUpgrade ? "Upgrade" : "Not an upgrade"} · {inspectedComparison.scoreDelta >= 0 ? "+" : ""}{displayNumber(inspectedComparison.scoreDelta, 2)} specialization score
|
||
</p>
|
||
)}
|
||
<dl className="equipment-details__stats">
|
||
{inspectedStatRows.map((row) => (
|
||
<div key={row.label}>
|
||
<dt>{row.label}</dt>
|
||
<dd className={row.beneficial ? "is-beneficial" : undefined}>{row.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
{inspectedComparison && Object.keys(inspectedComparison.usefulStatDeltas).length > 0 && (
|
||
<dl className="equipment-details__stats">
|
||
{Object.entries(inspectedComparison.usefulStatDeltas).map(([key, value]) => (
|
||
<div key={key}><dt>{key.replace(/([A-Z])/g, " $1")}</dt><dd className={(value ?? 0) > 0 ? "is-beneficial" : undefined}>{(value ?? 0) > 0 ? "+" : ""}{value}</dd></div>
|
||
))}
|
||
</dl>
|
||
)}
|
||
<dl className="equipment-details__provenance">
|
||
<div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div>
|
||
<div><dt>Original level</dt><dd>{inspected.sourceItemLevel}</dd></div>
|
||
<div><dt>Currently</dt><dd>{inspectedOwner ? `Equipped by ${inspectedOwner.name}` : "In inventory"}</dd></div>
|
||
<div><dt>{owner.name}</dt><dd>{compatible ? "Can equip" : "Cannot equip"}</dd></div>
|
||
</dl>
|
||
<button
|
||
type="button"
|
||
className="button button--primary"
|
||
disabled={readOnly || !compatible}
|
||
onClick={() => equip(inspected)}
|
||
>{inspectedOwnerId === owner.id ? "Re-equip item" : `Equip on ${owner.name}`}</button>
|
||
</>
|
||
) : (
|
||
<p>Select collected loot to inspect and equip it.</p>
|
||
)}
|
||
{feedback && (feedback.kind === "success" || feedback.kind === "error") ? (
|
||
<p className={`equipment-details__feedback is-${feedback.kind}`} role="status">{feedback.message}</p>
|
||
) : null}
|
||
</aside>
|
||
</div>
|
||
|
||
<footer className="game-panel__footer">
|
||
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Equip / unequip</span><span><kbd>B</kbd> Pause menu</span>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function EquipmentPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
|
||
const open = useGameStore((state) => state.overlay === "inventory");
|
||
return open ? <EquipmentDialog readOnly={readOnly} /> : null;
|
||
}
|