188 lines
9.0 KiB
TypeScript
188 lines
9.0 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { buildBossGuideCatalog } from "../game/bossGuide";
|
||
import { BOSS_GUIDE_OVERRIDES } from "../game/bossGuideOverrides";
|
||
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "../game/dungeonRegistry";
|
||
import { useGameStore } from "../game/store";
|
||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||
import { ControllerButton } from "./ControllerButton";
|
||
|
||
function BossGuideDialog() {
|
||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||
const sessionRevision = useGameStore((state) => state.sessionRevision);
|
||
const bossTest = useGameStore((state) => state.bossTest);
|
||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||
const guideCatalog = useMemo(() => buildBossGuideCatalog(
|
||
DUNGEON_DEFINITIONS.map((definition) => (
|
||
definition.id === activeDungeonId ? dungeonDefinitionById(activeDungeonId) ?? definition : definition
|
||
)),
|
||
BOSS_GUIDE_OVERRIDES,
|
||
), [activeDungeonId, sessionRevision]);
|
||
const initialDungeon = guideCatalog.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? guideCatalog[0];
|
||
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
|
||
const selectedDungeon = guideCatalog.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
|
||
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
|
||
?? selectedDungeon?.bosses[0];
|
||
const close = () => openOverlay("pause");
|
||
|
||
const selectDungeon = (dungeonId: string) => {
|
||
const dungeon = guideCatalog.find((candidate) => candidate.id === dungeonId);
|
||
setSelectedDungeonId(dungeonId);
|
||
setSelectedBossId(dungeon?.bosses[0]?.id ?? "");
|
||
};
|
||
|
||
const actions = useMemo<MenuAction[]>(() => {
|
||
const dungeonActions = guideCatalog.map((dungeon, index): MenuAction => ({
|
||
id: `boss-guide-dungeon:${dungeon.id}`,
|
||
run: () => selectDungeon(dungeon.id),
|
||
neighbors: {
|
||
up: `boss-guide-dungeon:${guideCatalog[(index - 1 + guideCatalog.length) % guideCatalog.length].id}`,
|
||
down: `boss-guide-dungeon:${guideCatalog[(index + 1) % guideCatalog.length].id}`,
|
||
...(dungeon.id === selectedDungeon?.id && dungeon.bosses.length
|
||
? { right: `boss-guide-boss:${dungeon.bosses[0].id}` }
|
||
: {}),
|
||
},
|
||
}));
|
||
const bosses = selectedDungeon?.bosses ?? [];
|
||
const bossActions = bosses.map((boss, index): MenuAction => ({
|
||
id: `boss-guide-boss:${boss.id}`,
|
||
run: () => setSelectedBossId(boss.id),
|
||
neighbors: {
|
||
up: `boss-guide-boss:${bosses[(index - 1 + bosses.length) % bosses.length].id}`,
|
||
down: `boss-guide-boss:${bosses[(index + 1) % bosses.length].id}`,
|
||
left: `boss-guide-dungeon:${selectedDungeon!.id}`,
|
||
},
|
||
}));
|
||
return [...dungeonActions, ...bossActions];
|
||
}, [guideCatalog, selectedDungeon]);
|
||
const controller = useMenuController(actions, {
|
||
initialId: `boss-guide-dungeon:${selectedDungeon?.id ?? ""}`,
|
||
onBack: close,
|
||
});
|
||
|
||
return (
|
||
<div className="modal-backdrop game-panel-backdrop" role="presentation">
|
||
<section className="game-panel boss-guide-panel" role="dialog" aria-modal="true" aria-labelledby="boss-guide-title">
|
||
<header className="game-panel__header boss-guide-panel__header">
|
||
<div>
|
||
<p className="eyebrow">Encounter field manual</p>
|
||
<h2 id="boss-guide-title">Boss Guide</h2>
|
||
</div>
|
||
<p aria-live="polite">
|
||
<strong>{selectedDungeon?.bosses.length ?? 0}</strong>
|
||
<span>bosses in {selectedDungeon?.title ?? "this dungeon"}</span>
|
||
</p>
|
||
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
|
||
</header>
|
||
|
||
<div className="boss-guide-panel__layout">
|
||
<nav className="boss-guide-panel__dungeons" aria-label="Dungeons">
|
||
<h3>Dungeons</h3>
|
||
<div>
|
||
{guideCatalog.map((dungeon) => {
|
||
const controlId = `boss-guide-dungeon:${dungeon.id}`;
|
||
return (
|
||
<ControllerButton
|
||
key={dungeon.id}
|
||
controlId={controlId}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className={dungeon.id === selectedDungeon?.id ? "is-active" : ""}
|
||
aria-pressed={dungeon.id === selectedDungeon?.id}
|
||
onClick={() => selectDungeon(dungeon.id)}
|
||
>
|
||
<strong>{dungeon.title}</strong>
|
||
<small>{`${dungeon.bosses.length} ${dungeon.bosses.length === 1 ? "boss" : "bosses"}`}</small>
|
||
</ControllerButton>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
|
||
<nav className="boss-guide-panel__bosses" aria-label={`${selectedDungeon?.title ?? "Dungeon"} bosses`}>
|
||
<h3>Bosses</h3>
|
||
<div>
|
||
{selectedDungeon?.bosses.map((boss, index) => {
|
||
const controlId = `boss-guide-boss:${boss.id}`;
|
||
return (
|
||
<ControllerButton
|
||
key={boss.id}
|
||
controlId={controlId}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
type="button"
|
||
className={boss.id === selectedBoss?.id ? "is-active" : ""}
|
||
aria-pressed={boss.id === selectedBoss?.id}
|
||
onClick={() => setSelectedBossId(boss.id)}
|
||
>
|
||
<b>{index + 1}</b>
|
||
<span><strong>{boss.name}</strong><small>{`${boss.mechanics.length} special ${boss.mechanics.length === 1 ? "mechanic" : "mechanics"}`}</small></span>
|
||
</ControllerButton>
|
||
);
|
||
})}
|
||
{!selectedDungeon?.bosses.length ? (
|
||
<p className="boss-guide-panel__empty">No bosses are registered for this dungeon package.</p>
|
||
) : null}
|
||
</div>
|
||
</nav>
|
||
|
||
<main className="boss-guide-panel__details" aria-live="polite">
|
||
{selectedBoss ? (
|
||
<>
|
||
<header>
|
||
<p className="eyebrow">{selectedDungeon?.title}</p>
|
||
<h3>{selectedBoss.name}</h3>
|
||
{selectedBoss.title && selectedBoss.title !== selectedBoss.name ? <p>{selectedBoss.title}</p> : null}
|
||
</header>
|
||
{selectedBoss.mechanics.length ? (
|
||
<ol className="boss-guide-mechanics">
|
||
{selectedBoss.mechanics.map((mechanic, index) => (
|
||
<li key={mechanic.id} className={`boss-guide-mechanic boss-guide-mechanic--${mechanic.fidelity}`}>
|
||
<header>
|
||
<b>{index + 1}</b>
|
||
<h4>{mechanic.name}</h4>
|
||
<span title={mechanic.sourceDetail}>{mechanic.fidelityLabel}</span>
|
||
</header>
|
||
<dl>
|
||
<div><dt>Trigger</dt><dd>{mechanic.trigger}</dd></div>
|
||
<div><dt>Timing</dt><dd>{mechanic.timing}</dd></div>
|
||
<div><dt>Target / effect</dt><dd>{mechanic.targetAndEffect}</dd></div>
|
||
<div><dt>Warning area</dt><dd>{mechanic.telegraph}</dd></div>
|
||
</dl>
|
||
<div className="boss-guide-mechanic__response">
|
||
<p><strong>What to do</strong>{mechanic.counterplay}</p>
|
||
<p><strong>How to test it</strong>{mechanic.testInstruction}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<div className="boss-guide-panel__no-specials" role="status">
|
||
<strong>Basic attacks only</strong>
|
||
<p>This boss has no special mechanics in the current runtime definition. There is no timer, health trigger, or warning area to verify yet.</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="boss-guide-panel__no-specials" role="status">
|
||
<strong>No boss selected</strong>
|
||
<p>Select a dungeon with registered bosses to inspect its live mechanic definitions.</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
<footer className="game-panel__footer">
|
||
<span><kbd>↑↓</kbd> Browse</span><span><kbd>←→</kbd> Change column</span><span><kbd>A / Enter</kbd> Select</span><span><kbd>B / Esc</kbd> Pause menu</span>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function BossGuidePanel() {
|
||
const open = useGameStore((state) => state.overlay === "boss-guide");
|
||
return open ? <BossGuideDialog /> : null;
|
||
}
|