Files
healer-man/src/ui/SpellbookPanel.tsx
T
2026-08-14 15:56:39 -04:00

270 lines
11 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 { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export interface SpellbookAbilityView {
id: string;
name: string;
description: string;
level: number;
color: string;
sigil: string;
iconUrl?: string;
school?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
known: boolean;
talentLocked?: boolean;
passive?: boolean;
rank?: number;
rankCount?: number;
ranks?: readonly {
rank: number;
level: number;
spellId: number;
description: string;
}[];
}
export interface SpellbookPanelProps {
abilities: readonly SpellbookAbilityView[];
playerLevel: number;
onActivate: (abilityId: string) => void;
open?: boolean;
onClose?: () => void;
}
function isUnlocked(ability: SpellbookAbilityView, playerLevel: number): boolean {
return ability.known && ability.level <= playerLevel;
}
export function spellbookAbilitiesForDisplay(
abilities: readonly SpellbookAbilityView[],
playerLevel: number,
unlockedOnly: boolean,
): SpellbookAbilityView[] {
return abilities
.filter((ability) => !unlockedOnly || isUnlocked(ability, playerLevel))
.sort((left, right) => Number(Boolean(left.passive)) - Number(Boolean(right.passive))
|| left.level - right.level
|| left.name.localeCompare(right.name));
}
function AbilityIcon({ ability }: { ability: SpellbookAbilityView }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [ability.iconUrl]);
return (
<span className="spellbook-ability__icon" style={{ "--ability-color": ability.color } as CSSProperties} aria-hidden="true">
{ability.iconUrl && !failed
? <img src={ability.iconUrl} alt="" onError={() => setFailed(true)} />
: <b>{ability.sigil}</b>}
</span>
);
}
interface SpellbookDialogProps extends Required<Omit<SpellbookPanelProps, "open">> {
unlockedOnly: boolean;
onUnlockedOnlyChange: (unlockedOnly: boolean) => void;
}
function SpellbookDialog({
abilities,
playerLevel,
onActivate,
onClose,
unlockedOnly,
onUnlockedOnlyChange,
}: SpellbookDialogProps) {
const orderedAbilities = useMemo(
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
[abilities, playerLevel],
);
const visibleAbilities = useMemo(
() => unlockedOnly
? orderedAbilities.filter((ability) => isUnlocked(ability, playerLevel))
: orderedAbilities,
[orderedAbilities, playerLevel, unlockedOnly],
);
const available = useMemo(
() => orderedAbilities.filter((ability) => isUnlocked(ability, playerLevel)),
[orderedAbilities, playerLevel],
);
const future = abilities.filter((ability) => !ability.known || ability.level > playerLevel);
const [inspectedId, setInspectedId] = useState(available[0]?.id ?? orderedAbilities[0]?.id ?? "");
const actions = useMemo<MenuAction[]>(() => visibleAbilities.map((ability) => ({
id: `spell-${ability.id}`,
run: () => {
setInspectedId(ability.id);
if (!ability.passive && ability.known && ability.level <= playerLevel) onActivate(ability.id);
},
})), [visibleAbilities, onActivate, playerLevel]);
const controller = useMenuController(actions, { columns: 2, onBack: onClose });
const controllerAbility = controller.selectedId.startsWith("spell-")
? controller.selectedId.slice("spell-".length)
: "";
const inspected = visibleAbilities.find((ability) => ability.id === (controllerAbility || inspectedId))
?? visibleAbilities[0]
?? null;
const inspectedLocked = Boolean(inspected && (!inspected.known || inspected.level > playerLevel));
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel spellbook-panel" role="dialog" aria-modal="true" aria-labelledby="spellbook-title">
<header className="game-panel__header">
<div>
<p className="eyebrow">Class abilities · Level {playerLevel}</p>
<h2 id="spellbook-title">Spellbook</h2>
</div>
<div className="spellbook-panel__progress" aria-label={`${available.length} unlocked abilities and ${future.length} locked abilities`}>
<strong>{available.length}</strong>
<span>Unlocked</span>
<i aria-hidden="true" />
<strong>{future.length}</strong>
<span>Locked</span>
</div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
</header>
<div className="spellbook-panel__toolbar">
<button
type="button"
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
aria-pressed={unlockedOnly}
onClick={() => onUnlockedOnlyChange(!unlockedOnly)}
>
<span aria-hidden="true" />
Unlocked only
</button>
</div>
<div className="spellbook-panel__layout">
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
{visibleAbilities.map((ability) => {
const locked = !ability.known || ability.level > playerLevel;
const lockLabel = ability.talentLocked && !ability.known
? "Learn in talents"
: `Unlocks at level ${ability.level}`;
return (
<ControllerButton
key={ability.id}
controlId={`spell-${ability.id}`}
selectedId={controller.selectedId}
select={(id) => {
controller.select(id);
setInspectedId(ability.id);
}}
type="button"
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""}`}
aria-disabled={locked}
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked`}
onFocus={() => setInspectedId(ability.id)}
onClick={() => {
setInspectedId(ability.id);
if (!locked && !ability.passive) onActivate(ability.id);
}}
>
<AbilityIcon ability={ability} />
<span>
<strong>{ability.name}</strong>
<small>
{locked
? lockLabel
: ability.passive
? "Passive"
: ability.rank
? `Rank ${ability.rank}${ability.rankCount ? ` of ${ability.rankCount}` : ""}`
: ability.school ?? "Class ability"}
</small>
</span>
{locked
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
</ControllerButton>
);
})}
{!visibleAbilities.length && (
<p className="game-panel__empty">
{unlockedOnly
? "No abilities have been unlocked yet."
: "No abilities are available for this character."}
</p>
)}
</div>
<aside className="spellbook-panel__details" aria-live="polite">
{inspected ? (
<>
<AbilityIcon ability={inspected} />
<p className="eyebrow">{inspected.school ?? "Class ability"}</p>
<h3>{inspected.name}</h3>
<p>{inspected.description}</p>
<dl>
<div><dt>Status</dt><dd>{inspectedLocked
? inspected.talentLocked && !inspected.known
? "Learn in talents"
: `Unlocks at level ${inspected.level}`
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
{inspected.castSeconds !== undefined && <div><dt>Cast</dt><dd>{inspected.castSeconds ? `${inspected.castSeconds} sec` : "Instant"}</dd></div>}
{inspected.range !== undefined && <div><dt>Range</dt><dd>{inspected.range} m</dd></div>}
</dl>
{inspected.ranks && inspected.ranks.length > 1 && (
<div className="spellbook-panel__ranks">
<h4>All live ranks</h4>
<ol>
{inspected.ranks.map((rank) => (
<li key={rank.spellId} className={rank.rank === inspected.rank ? "is-current" : ""}>
<strong>Rank {rank.rank}</strong>
<span>Level {rank.level}</span>
<small>Spell {rank.spellId}</small>
<p>{rank.description}</p>
</li>
))}
</ol>
</div>
)}
<button
type="button"
className="button button--primary"
disabled={inspectedLocked || inspected.passive}
onClick={() => onActivate(inspected.id)}
>{inspectedLocked
? inspected.talentLocked && !inspected.known
? "Learn in talents"
: `Unlocks at level ${inspected.level}`
: inspected.passive ? "Passive ability" : "Use ability"}</button>
</>
) : <p>Select an ability to inspect it.</p>}
</aside>
</div>
<footer className="game-panel__footer"><span><kbd>A</kbd> Inspect / use</span><span><kbd>B</kbd> Pause menu</span></footer>
</section>
</div>
);
}
export function SpellbookPanel({ abilities, playerLevel, onActivate, open, onClose }: SpellbookPanelProps) {
const storeOpen = useGameStore((state) => state.overlay === "spellbook");
const openOverlay = useGameStore((state) => state.openOverlay);
const close = onClose ?? (() => openOverlay("pause"));
const [unlockedOnly, setUnlockedOnly] = useState(false);
if (!(open ?? storeOpen)) return null;
return (
<SpellbookDialog
abilities={abilities}
playerLevel={playerLevel}
onActivate={onActivate}
onClose={close}
unlockedOnly={unlockedOnly}
onUnlockedOnlyChange={setUnlockedOnly}
/>
);
}