Files
healer-man/src/ui/PartyManagementPanel.tsx
T
2026-08-22 15:53:43 -04:00

300 lines
13 KiB
TypeScript
Raw Permalink 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 { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import { classById, raceById } from "../app/characterCatalog";
import {
learnedPartyTalentSelections,
upcomingPartyTalentSelections,
type PartyTalentSelection,
} from "../game/partyTalentUi";
import {
PARTY_ROLE_LABELS,
usePartyStore,
type PartyCommand,
type PartyMember,
} from "../game/partyStore";
import {
loadTalentUiCatalog,
talentUiTreeById,
type TalentUiCatalog,
} from "../game/talentUiCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { PartyCommandBar } from "./PartyFrames";
const COMMAND_SUMMARY: Readonly<Record<PartyCommand, string>> = {
attack: "The party will advance toward the next boss and clear every enemy along the route.",
defend: "The party will stay with you and fight only enemies that engage the group.",
stop: "The party will hold its current position until you issue another order.",
};
const STATUS_LABEL: Readonly<Record<PartyMember["status"], string>> = {
ready: "Ready",
advancing: "Advancing",
guarding: "Guarding",
healing: "Healing",
attacking: "Attacking",
holding: "Holding",
down: "Down",
};
function percent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
return Math.max(0, Math.min(100, current / maximum * 100));
}
function currentTalentRanks(selections: readonly PartyTalentSelection[]): readonly PartyTalentSelection[] {
const latestByNode = new Map<string, PartyTalentSelection>();
for (const selection of selections) latestByNode.set(selection.nodeId, selection);
return [...latestByNode.values()].reverse();
}
function TalentSelectionCard({ selection, upcoming = false }: {
selection: PartyTalentSelection;
upcoming?: boolean;
}) {
return (
<article className={`party-talent-card ${upcoming ? "party-talent-card--upcoming" : ""}`}>
<span className="party-talent-card__icon" aria-hidden="true">
<img src={selection.icon} alt="" />
</span>
<span className="party-talent-card__body">
<span className="party-talent-card__heading">
<strong>{selection.name}</strong>
<b>{upcoming ? `Level ${selection.level}` : selection.treeName}</b>
</span>
<small>Rank {selection.rank} / {selection.maxRank}</small>
<p>{selection.description}</p>
</span>
</article>
);
}
function PartyManagementDialog() {
const members = usePartyStore((state) => state.members);
const command = usePartyStore((state) => state.command);
const openOverlay = useGameStore((state) => state.openOverlay);
const [inspectedMemberId, setInspectedMemberId] = useState(() => members[0]?.id ?? "");
const [talentCatalog, setTalentCatalog] = useState<TalentUiCatalog | null>(null);
const [talentCatalogErrorClassId, setTalentCatalogErrorClassId] = useState<string | null>(null);
const close = useCallback(() => openOverlay("pause"), [openOverlay]);
useEffect(() => {
if (!members.some((member) => member.id === inspectedMemberId)) {
setInspectedMemberId(members[0]?.id ?? "");
}
}, [inspectedMemberId, members]);
const inspectedMember = members.find((member) => member.id === inspectedMemberId) ?? members[0] ?? null;
useEffect(() => {
const classId = inspectedMember?.classId;
if (!classId || classId.startsWith("rom-")) {
setTalentCatalog(null);
setTalentCatalogErrorClassId(null);
return;
}
let active = true;
setTalentCatalogErrorClassId(null);
void loadTalentUiCatalog(classId).then((catalog) => {
if (active) setTalentCatalog(catalog);
}).catch(() => {
if (!active) return;
setTalentCatalog(null);
setTalentCatalogErrorClassId(classId);
});
return () => { active = false; };
}, [inspectedMember?.classId]);
const activeTalentCatalog = inspectedMember && talentCatalog?.classId === inspectedMember.classId
? talentCatalog
: null;
const learned = useMemo(() => inspectedMember && activeTalentCatalog
? learnedPartyTalentSelections(
activeTalentCatalog,
inspectedMember.classId,
inspectedMember.specialization,
inspectedMember.level,
)
: [], [activeTalentCatalog, inspectedMember]);
const currentTalents = useMemo(() => currentTalentRanks(learned), [learned]);
const upcomingTalents = useMemo(() => inspectedMember && activeTalentCatalog
? upcomingPartyTalentSelections(
activeTalentCatalog,
inspectedMember.classId,
inspectedMember.specialization,
inspectedMember.level,
3,
)
: [], [activeTalentCatalog, inspectedMember]);
const actions = useMemo<MenuAction[]>(() => [
...members.map((member) => ({
id: `party-member-${member.id}`,
run: () => setInspectedMemberId(member.id),
})),
...(["attack", "defend", "stop"] as const).map((nextCommand) => ({
id: `party-command-${nextCommand}`,
run: () => usePartyStore.getState().setCommand(nextCommand),
})),
{ id: "party-command-recall", run: () => usePartyStore.getState().recallParty() },
{ id: "party-management-back", run: close },
], [close, members]);
const controller = useMenuController(actions, {
initialId: members[0] ? `party-member-${members[0].id}` : "party-command-defend",
onBack: close,
});
const memberClass = inspectedMember ? classById(inspectedMember.classId) : null;
const memberRace = inspectedMember ? raceById(inspectedMember.raceId) : null;
const primaryTree = inspectedMember && activeTalentCatalog
? talentUiTreeById(activeTalentCatalog, inspectedMember.talentTreeId)
: null;
const primaryPoints = inspectedMember
? learned.filter((selection) => selection.treeId === inspectedMember.talentTreeId).length
: 0;
const healthPercent = inspectedMember ? percent(inspectedMember.health, inspectedMember.maxHealth) : 0;
const labelledBy = inspectedMember ? `party-member-tab-${inspectedMember.id}` : "party-management-title";
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel party-management-panel" role="dialog" aria-modal="true" aria-labelledby="party-management-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">Expedition roster and tactics</p>
<h2 id="party-management-title">Party Management</h2>
</div>
<ControllerButton
controlId="party-management-back"
selectedId={controller.selectedId}
select={controller.select}
type="button"
className="icon-button"
onClick={close}
aria-label="Return to pause menu"
>×</ControllerButton>
</header>
<div className="party-management__layout">
<nav className="party-management__roster" role="tablist" aria-label="Party members">
<header><strong>Roster</strong><span>{members.length} allies</span></header>
{members.map((member) => {
const definition = classById(member.classId);
const race = raceById(member.raceId);
const selected = member.id === inspectedMember?.id;
return (
<ControllerButton
key={member.id}
controlId={`party-member-${member.id}`}
selectedId={controller.selectedId}
select={controller.select}
id={`party-member-tab-${member.id}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls="party-member-details"
className={`party-management__member ${selected ? "is-active" : ""} ${member.health <= 0 ? "is-down" : ""}`}
style={{ "--party-class-color": definition.color } as CSSProperties}
onClick={() => setInspectedMemberId(member.id)}
>
<span className="party-management__member-sigil">{race.sigil}</span>
<span>
<strong>{member.name}</strong>
<small>Level {member.level} · {member.gender === "male" ? "Male" : "Female"} {race.name}</small>
<em>{PARTY_ROLE_LABELS[member.role]} · {STATUS_LABEL[member.status]}</em>
</span>
</ControllerButton>
);
})}
{!members.length && <p className="game-panel__empty">No party members are available.</p>}
</nav>
<section
id="party-member-details"
className="party-management__details"
role="tabpanel"
aria-labelledby={labelledBy}
>
{inspectedMember && memberClass && memberRace ? (
<>
<div className="party-management__profile" style={{ "--party-class-color": memberClass.color } as CSSProperties}>
<span className="party-management__portrait" aria-hidden="true">{memberRace.sigil}</span>
<span>
<small>Level {inspectedMember.level} · {inspectedMember.gender === "male" ? "Male" : "Female"} {memberRace.name} · Matches your level</small>
<h3>{inspectedMember.name}</h3>
<p>{inspectedMember.specialization} {memberClass.name} · {PARTY_ROLE_LABELS[inspectedMember.role]}</p>
</span>
<span className="party-management__health">
<span><b>{STATUS_LABEL[inspectedMember.status]}</b><small>{Math.ceil(inspectedMember.health)} / {inspectedMember.maxHealth}</small></span>
<i role="progressbar" aria-label={`${inspectedMember.name} health`} aria-valuemin={0} aria-valuemax={inspectedMember.maxHealth} aria-valuenow={Math.max(0, inspectedMember.health)}>
<b style={{ width: `${healthPercent}%` }} />
</i>
</span>
</div>
<section className="party-management__orders" aria-labelledby="party-orders-title">
<span>
<small>Current group order</small>
<strong id="party-orders-title">{command[0].toUpperCase() + command.slice(1)}</strong>
<p>{COMMAND_SUMMARY[command]}</p>
</span>
<PartyCommandBar
className="party-command-bar--management"
selectedControlId={controller.selectedId}
onControlSelect={controller.select}
/>
</section>
<div className="party-management__talents">
<section className="party-management__talent-column">
<header>
<span><small>Current build</small><strong>{primaryTree?.name ?? inspectedMember.specialization}</strong></span>
<b>{primaryPoints} primary · {learned.length} total</b>
</header>
<div className="party-management__talent-list">
{currentTalents.map((selection) => <TalentSelectionCard key={selection.nodeId} selection={selection} />)}
{!activeTalentCatalog && (
<p className="party-management__empty-talents">
{talentCatalogErrorClassId === inspectedMember.classId
? "Talent details are temporarily unavailable."
: "Preparing this class's talent catalog…"}
</p>
)}
{activeTalentCatalog && !currentTalents.length && (
<p className="party-management__empty-talents">
Talent training begins at level 10. This build will advance automatically with you.
</p>
)}
</div>
</section>
<section className="party-management__talent-column party-management__talent-column--next">
<header>
<span><small>Automatic progression</small><strong>Next talent choices</strong></span>
<b>{upcomingTalents.length ? `Through level ${upcomingTalents.at(-1)?.level}` : "Complete"}</b>
</header>
<div className="party-management__talent-list">
{upcomingTalents.map((selection) => (
<TalentSelectionCard key={`${selection.level}-${selection.nodeId}`} selection={selection} upcoming />
))}
{activeTalentCatalog && !upcomingTalents.length && (
<p className="party-management__empty-talents">This party member has completed the level 80 talent plan.</p>
)}
</div>
</section>
</div>
</>
) : <p className="game-panel__empty">Choose a party member to inspect their build.</p>}
</section>
</div>
<footer className="game-panel__footer">
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Select / order</span><span><kbd>B</kbd> Pause menu</span>
</footer>
</section>
</div>
);
}
export function PartyManagementPanel() {
const open = useGameStore((state) => state.overlay === "party");
return open ? <PartyManagementDialog /> : null;
}