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