Files
healer-man/src/ui/TalentsPanel.tsx
T
2026-09-14 13:20:20 -04:00

453 lines
21 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 type { ClassId } from "../app/characterCatalog";
import { runtimeTalentAllocationFailure } from "../game/talentRuntimeCatalog";
import {
talentUiDescriptionForRank,
type TalentUiNode,
} from "../game/talentUiCatalog";
import type {
TalentAllocationFailure,
TalentRanks,
} from "../game/talentCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction, type MenuDirection } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export interface TalentTreeView {
id: string;
name: string;
description: string;
points: number;
color: string;
sigil: string;
iconUrl?: string;
backgroundUrl: string;
nodes: readonly TalentUiNode[];
}
export interface TalentsPanelProps {
readOnly?: boolean;
trees: readonly TalentTreeView[];
ranks: TalentRanks;
classId: ClassId;
playerLevel: number;
availablePoints: number;
abilityEssence?: number;
talentEssence?: number;
onInvest: (nodeId: string) => void;
onReset: () => void;
open?: boolean;
onClose?: () => void;
}
interface TalentNodeState {
rank: number;
failure: TalentAllocationFailure | null;
}
const nodeControlId = (nodeId: string) => `talent-node-${nodeId}`;
const tabControlId = (treeId: string) => `talent-tab-${treeId}`;
function TalentImage({ src, fallback }: { src?: string; fallback: string }) {
const [failed, setFailed] = useState(false);
return src && !failed
? <img src={src} alt="" onError={() => setFailed(true)} />
: <span className="talent-art-fallback" aria-hidden="true">{fallback}</span>;
}
function nearestNode(
nodes: readonly TalentUiNode[],
source: TalentUiNode,
direction: MenuDirection,
): TalentUiNode | null {
const vertical = direction === "up" || direction === "down";
const candidates = nodes.filter((candidate) => {
if (direction === "up") return candidate.row < source.row;
if (direction === "down") return candidate.row > source.row;
if (direction === "left") return candidate.column < source.column;
return candidate.column > source.column;
});
return candidates.sort((left, right) => {
const primaryLeft = vertical ? Math.abs(left.row - source.row) : Math.abs(left.column - source.column);
const primaryRight = vertical ? Math.abs(right.row - source.row) : Math.abs(right.column - source.column);
const crossLeft = vertical ? Math.abs(left.column - source.column) : Math.abs(left.row - source.row);
const crossRight = vertical ? Math.abs(right.column - source.column) : Math.abs(right.row - source.row);
return crossLeft * 8 + primaryLeft - (crossRight * 8 + primaryRight)
|| left.row - right.row
|| left.column - right.column;
})[0] ?? null;
}
function failureText(
failure: TalentAllocationFailure | null,
node: TalentUiNode,
tree: TalentTreeView,
prerequisite: TalentUiNode | null,
): string {
if (!failure) return "Ready to learn";
if (failure === "max-rank") return "Maximum rank learned";
if (failure === "no-points") return "Earn another talent point to continue";
if (failure === "tier-locked") return `${(node.row - 1) * 5} points in ${tree.name} are required`;
if (failure === "level-locked") return `Requires level ${node.requiredLevel ?? "a higher level"}`;
if (failure === "ability-essence") return "Not enough Ability Essence";
if (failure === "talent-essence") return "Not enough Talent Essence";
if (failure === "point-gate") {
return `${node.requiredClassPoints ?? 0} Ability Essence and ${node.requiredSpecPoints ?? 0} Talent Essence must be invested in ${tree.name}`;
}
if (failure === "prerequisite") return `${prerequisite?.name ?? "The prerequisite talent"} needs its required rank`;
if (failure === "wrong-class") return "This talent belongs to another class";
return "This talent is unavailable";
}
function TalentsDialog({
trees,
ranks,
classId,
playerLevel,
availablePoints,
abilityEssence,
talentEssence,
onInvest,
onReset,
onClose,
readOnly = false,
}: Omit<TalentsPanelProps, "open" | "onClose"> & { onClose: () => void }) {
const [activeTreeId, setActiveTreeId] = useState(() => trees[0]?.id ?? "");
const [detailNodeId, setDetailNodeId] = useState(() => trees[0]?.nodes[0]?.id ?? "");
const spent = trees.reduce((total, tree) => total + tree.points, 0);
const allNodes = useMemo(() => trees.flatMap((tree) => tree.nodes), [trees]);
const activeTree = trees.find((tree) => tree.id === activeTreeId) ?? trees[0] ?? null;
const activeNodes = activeTree?.nodes ?? [];
const isCoa = Boolean(trees.some((tree) => tree.nodes.some((node) => node.system === "coa")));
const nodeById = useMemo(
() => new Map(allNodes.map((node) => [node.id, node] as const)),
[allNodes],
);
const treeByNodeId = useMemo(() => new Map(
trees.flatMap((tree) => tree.nodes.map((node) => [node.id, tree] as const)),
), [trees]);
const nodeStateById = useMemo(() => new Map(allNodes.map((node) => {
const rank = Math.max(0, Math.min(node.maxRank, Math.trunc(ranks[node.id] ?? 0)));
const tree = treeByNodeId.get(node.id);
const rawFailure = runtimeTalentAllocationFailure(ranks, classId, node.id, playerLevel);
const failure: TalentAllocationFailure | null = rank >= node.maxRank
? "max-rank"
: node.system !== "coa" && tree && tree.points < (node.row - 1) * 5
? "tier-locked"
: node.prerequisites.some((required) => (ranks[required.talentId] ?? 0) < required.requiredRank)
? "prerequisite"
: rawFailure;
return [node.id, { rank, failure }] as const;
})), [allNodes, classId, nodeById, playerLevel, ranks, treeByNodeId]);
useEffect(() => {
if (!trees.some((tree) => tree.id === activeTreeId)) setActiveTreeId(trees[0]?.id ?? "");
}, [activeTreeId, trees]);
useEffect(() => {
if (!activeNodes.some((node) => node.id === detailNodeId)) setDetailNodeId(activeNodes[0]?.id ?? "");
}, [activeNodes, detailNodeId]);
const actions = useMemo<MenuAction[]>(() => {
const tabActions = trees.map((tree, index) => ({
id: tabControlId(tree.id),
run: () => setActiveTreeId(tree.id),
neighbors: {
left: tabControlId(trees[(index - 1 + trees.length) % trees.length]?.id ?? tree.id),
right: tabControlId(trees[(index + 1) % trees.length]?.id ?? tree.id),
down: tree.id === activeTreeId && activeNodes[0] ? nodeControlId(activeNodes[0].id) : tabControlId(activeTreeId),
},
}));
const nodeActions = activeNodes.map((node) => {
const neighbor = (direction: MenuDirection) => nearestNode(activeNodes, node, direction);
const up = neighbor("up");
const down = neighbor("down");
const left = neighbor("left");
const right = neighbor("right");
const state = nodeStateById.get(node.id);
return {
id: nodeControlId(node.id),
run: () => {
setDetailNodeId(node.id);
if (!readOnly && !state?.failure) onInvest(node.id);
},
neighbors: {
up: up ? nodeControlId(up.id) : tabControlId(activeTreeId),
down: down ? nodeControlId(down.id) : spent > 0 ? "talent-reset" : nodeControlId(node.id),
left: nodeControlId(left?.id ?? node.id),
right: nodeControlId(right?.id ?? node.id),
},
};
});
return [
...tabActions,
...nodeActions,
{ id: "talent-reset", run: onReset, enabled: !readOnly && spent > 0 },
];
}, [activeNodes, activeTreeId, nodeStateById, onInvest, onReset, spent, trees]);
const controller = useMenuController(actions, {
columns: 4,
initialId: activeNodes[0] ? nodeControlId(activeNodes[0].id) : undefined,
onBack: onClose,
});
useEffect(() => {
if (controller.selectedId.startsWith("talent-tab-")) {
const selectedTreeId = controller.selectedId.slice("talent-tab-".length);
if (trees.some((tree) => tree.id === selectedTreeId)) setActiveTreeId(selectedTreeId);
return;
}
if (controller.selectedId.startsWith("talent-node-")) {
const selectedNodeId = controller.selectedId.slice("talent-node-".length);
const selectedTree = treeByNodeId.get(selectedNodeId);
if (selectedTree) {
setActiveTreeId(selectedTree.id);
setDetailNodeId(selectedNodeId);
}
}
}, [controller.selectedId, treeByNodeId, trees]);
const detailNode = nodeById.get(detailNodeId) ?? activeNodes[0] ?? null;
const detailTree = detailNode ? treeByNodeId.get(detailNode.id) ?? activeTree : activeTree;
const detailState: TalentNodeState | null = detailNode ? nodeStateById.get(detailNode.id) ?? null : null;
const prerequisite = detailNode?.prerequisiteId ? nodeById.get(detailNode.prerequisiteId) ?? null : null;
const prerequisiteRank = prerequisite ? nodeStateById.get(prerequisite.id)?.rank ?? 0 : 0;
const prerequisiteRequiredRank = detailNode?.prerequisiteRank ?? prerequisite?.maxRank ?? 0;
const inspectNode = (treeId: string, nodeId: string) => {
setActiveTreeId(treeId);
setDetailNodeId(nodeId);
};
const activateNode = (treeId: string, node: TalentUiNode) => {
inspectNode(treeId, node.id);
if (!readOnly && !nodeStateById.get(node.id)?.failure) onInvest(node.id);
};
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className={`game-panel talents-panel ${isCoa ? "talents-panel--coa" : ""}`} role="dialog" aria-modal="true" aria-labelledby="talents-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">Level {playerLevel} specialization</p>
<h2 id="talents-title">Talents</h2>
</div>
<div className="talents-panel__points">
{isCoa ? (
<>
<strong>{abilityEssence ?? 0}</strong><span>Ability Essence</span>
<strong>{talentEssence ?? 0}</strong><span>Talent Essence</span>
</>
) : (
<><strong>{availablePoints}</strong><span>points available</span></>
)}
</div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
</header>
<p className="talents-panel__intro">
{isCoa
? `Live ${trees.length}-tab Conquest tree. Ability and Talent Essence unlock nodes according to Ascension's exact gates and prerequisites.`
: "Your first talent point arrives at level 10. Every five points spent in a tree unlocks its next row."}
</p>
<nav className="talents-panel__tabs" aria-label="Talent trees">
{trees.map((tree) => (
<ControllerButton
key={tree.id}
controlId={tabControlId(tree.id)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={`talent-tree-tab ${tree.id === activeTreeId ? "is-active" : ""}`}
style={{ "--talent-color": tree.color } as CSSProperties}
aria-pressed={tree.id === activeTreeId}
onClick={() => setActiveTreeId(tree.id)}
onPointerEnter={() => setActiveTreeId(tree.id)}
>
<span className="talent-tree-tab__icon"><TalentImage src={tree.iconUrl} fallback={tree.sigil} /></span>
<span><strong>{tree.name}</strong><small>{tree.points} points</small></span>
</ControllerButton>
))}
</nav>
<div className="talents-panel__workspace">
<div className="talents-panel__boards">
{trees.map((tree) => (
<article
key={tree.id}
className={`talent-tree-board ${tree.id === activeTreeId ? "is-active" : ""}`}
style={{
"--talent-color": tree.color,
"--talent-background": `url("${tree.backgroundUrl}")`,
} as CSSProperties}
onPointerEnter={() => setActiveTreeId(tree.id)}
>
<header className="talent-tree-board__header">
<span className="talent-tree-board__icon"><TalentImage src={tree.iconUrl} fallback={tree.sigil} /></span>
<span><strong>{tree.name}</strong><small>{tree.description}</small></span>
<b>{tree.points}</b>
</header>
<div className="talent-tree-board__canvas" aria-label={`${tree.name} talent tree`}>
<svg
className="talent-tree-board__connectors"
viewBox={`0 0 ${Math.max(4, ...tree.nodes.map((node) => node.column)) * 100} ${Math.max(11, ...tree.nodes.map((node) => node.row)) * 100}`}
preserveAspectRatio="none"
aria-hidden="true"
>
{tree.nodes.flatMap((node) => {
const required = node.prerequisiteId ? nodeById.get(node.prerequisiteId) : null;
if (!required) return [];
const earned = (nodeStateById.get(required.id)?.rank ?? 0) >= (node.prerequisiteRank ?? required.maxRank);
return [
<line
key={`${required.id}-${node.id}`}
className={earned ? "is-earned" : ""}
x1={(required.column - .5) * 100}
y1={(required.row - .5) * 100}
x2={(node.column - .5) * 100}
y2={(node.row - .5) * 100}
/>,
];
})}
</svg>
{tree.nodes.map((node) => {
const state = nodeStateById.get(node.id) ?? { rank: 0, failure: "unknown-talent" as const };
const learned = state.rank > 0;
const maximum = state.rank >= node.maxRank;
const locked = state.failure !== null
&& state.failure !== "no-points"
&& state.failure !== "ability-essence"
&& state.failure !== "talent-essence"
&& state.failure !== "max-rank";
const unavailable = state.failure === "no-points"
|| state.failure === "ability-essence"
|| state.failure === "talent-essence";
const label = `${node.name}, rank ${state.rank} of ${node.maxRank}`;
return (
<ControllerButton
key={node.id}
controlId={nodeControlId(node.id)}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={`talent-node ${learned ? "is-learned" : ""} ${maximum ? "is-max" : ""} ${locked ? "is-locked" : ""} ${unavailable ? "is-unavailable" : ""} ${detailNode?.id === node.id ? "is-inspected" : ""}`}
style={{ gridColumn: node.column, gridRow: node.row } as CSSProperties}
aria-label={label}
aria-disabled={readOnly || state.failure !== null}
aria-describedby={detailNode?.id === node.id ? "talent-node-details" : undefined}
title={`${label} — ${talentUiDescriptionForRank(node, state.rank || 1)}`}
onFocus={() => inspectNode(tree.id, node.id)}
onPointerEnter={() => inspectNode(tree.id, node.id)}
onClick={() => activateNode(tree.id, node)}
>
<TalentImage src={node.icon} fallback={node.name.slice(0, 2).toUpperCase()} />
<span className="talent-node__rank">{state.rank}/{node.maxRank}</span>
</ControllerButton>
);
})}
</div>
</article>
))}
{!trees.length && <p className="game-panel__empty">No talent trees are available for this class.</p>}
</div>
<aside id="talent-node-details" className="talent-details" aria-live="polite">
{detailNode && detailTree && detailState ? (
<>
<div className="talent-details__heading">
<span className="talent-details__icon"><TalentImage src={detailNode.icon} fallback={detailNode.name.slice(0, 2).toUpperCase()} /></span>
<span>
<small>{detailTree.name} · {detailNode.entryType ?? `Row ${detailNode.row}`}{detailNode.isPassive ? " · Passive" : ""}</small>
<h3>{detailNode.name}</h3>
</span>
</div>
<div className="talent-details__rank" aria-label={`Rank ${detailState.rank} of ${detailNode.maxRank}`}>
{Array.from({ length: detailNode.maxRank }, (_, index) => (
<i key={index} className={index < detailState.rank ? "is-filled" : ""} />
))}
<strong>{detailState.rank}/{detailNode.maxRank}</strong>
</div>
<small className="talent-details__effect-rank">
{detailState.rank > 0 ? `Rank ${detailState.rank} effect` : "Rank 1 effect"}
</small>
<p>{talentUiDescriptionForRank(detailNode, detailState.rank || 1)}</p>
{detailState.rank > 0 && detailState.rank < detailNode.maxRank && (
<>
<small className="talent-details__effect-rank">Next rank</small>
<p>{talentUiDescriptionForRank(detailNode, detailState.rank + 1)}</p>
</>
)}
{(detailNode.row > 1 || prerequisite || detailNode.system === "coa") && (
<div className="talent-details__requirements">
<small>Requirements</small>
{detailNode.system === "coa" ? (
<>
<span className={(abilityEssence ?? 0) >= (detailNode.abilityEssenceCost ?? 0) ? "is-met" : ""}>
{detailNode.abilityEssenceCost ?? 0} Ability Essence per rank
</span>
<span className={(talentEssence ?? 0) >= (detailNode.talentEssenceCost ?? 0) ? "is-met" : ""}>
{detailNode.talentEssenceCost ?? 0} Talent Essence per rank
</span>
{(detailNode.requiredLevel ?? 0) > 0 && (
<span className={playerLevel >= (detailNode.requiredLevel ?? 0) ? "is-met" : ""}>
Level {detailNode.requiredLevel}
</span>
)}
{((detailNode.requiredClassPoints ?? 0) > 0 || (detailNode.requiredSpecPoints ?? 0) > 0) && (
<span>
Gate: {detailNode.requiredClassPoints ?? 0} AE / {detailNode.requiredSpecPoints ?? 0} TE in {detailTree.name}
</span>
)}
</>
) : detailNode.row > 1 && (
<span className={detailTree.points >= (detailNode.row - 1) * 5 ? "is-met" : ""}>
{detailTree.points}/{(detailNode.row - 1) * 5} points in {detailTree.name}
</span>
)}
{prerequisite && (
<span className={prerequisiteRank >= prerequisiteRequiredRank ? "is-met" : ""}>
{prerequisite.name} {prerequisiteRank}/{prerequisiteRequiredRank}
</span>
)}
</div>
)}
<b className={`talent-details__status ${!detailState.failure ? "is-ready" : detailState.failure === "max-rank" ? "is-max" : ""}`}>
{failureText(detailState.failure, detailNode, detailTree, prerequisite)}
</b>
</>
) : (
<p>Select a talent to inspect its ranks and requirements.</p>
)}
</aside>
</div>
<div className="talents-panel__summary">
<span><strong>{spent}</strong> points spent</span>
<ControllerButton
controlId="talent-reset"
selectedId={controller.selectedId}
select={controller.select}
type="button"
className="button"
disabled={readOnly || spent <= 0}
onClick={onReset}
>Reset talents</ControllerButton>
</div>
<footer className="game-panel__footer talents-panel__footer">
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
</footer>
</section>
</div>
);
}
export function TalentsPanel(props: TalentsPanelProps) {
const storeOpen = useGameStore((state) => state.overlay === "talents");
const openOverlay = useGameStore((state) => state.openOverlay);
const close = props.onClose ?? (() => openOverlay("pause"));
if (!(props.open ?? storeOpen)) return null;
return <TalentsDialog {...props} onClose={close} />;
}