Files
healer-man/src/ui/BossGuidePanel.tsx
T
2026-09-20 13:40:23 -04:00

188 lines
9.0 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 { 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;
}