270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|