forked from phenom/healer-man
300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
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;
|
||
}
|