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 ( {ability.iconUrl && !failed ? setFailed(true)} /> : {ability.sigil}} ); } interface SpellbookDialogProps extends Required> { 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(() => 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 ( Class abilities · Level {playerLevel} Spellbook {available.length} Unlocked {future.length} Locked × onUnlockedOnlyChange(!unlockedOnly)} > Unlocked only {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 ( { 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); }} > {ability.name} {locked ? lockLabel : ability.passive ? "Passive" : ability.rank ? `Rank ${ability.rank}${ability.rankCount ? ` of ${ability.rankCount}` : ""}` : ability.school ?? "Class ability"} {locked ? {ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`} : {ability.passive ? "Passive" : "Known"}} ); })} {!visibleAbilities.length && ( {unlockedOnly ? "No abilities have been unlocked yet." : "No abilities are available for this character."} )} ); } 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 ( ); }
Class abilities · Level {playerLevel}
{unlockedOnly ? "No abilities have been unlocked yet." : "No abilities are available for this character."}