Files
healer-man/src/ui/EquipmentPanel.tsx
T

519 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}