Files
healer-man/src/ui/MainMenuScreen.tsx
T
2026-09-14 13:20:20 -04:00

295 lines
14 KiB
TypeScript

import { lazy, Suspense, useMemo, useState } from "react";
import { classById, FACTIONS, raceById } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const GAME_MODES = [
{
id: "dungeons",
number: "01",
name: "Dungeons",
description: "Choose an expedition and lead your party into the depths.",
available: true,
},
...(SHELVED_MODES_ENABLED ? [{
id: "manastorms" as const,
number: "02",
name: "Manastorms",
description: "Chain random boss encounters as the storm grows stronger and more rewarding.",
available: true,
}, {
id: "arenas" as const,
number: "03",
name: "Arenas",
description: "Rated 2v2 and 3v3 combat against players or rating-matched opponents.",
available: true,
}] : []),
{
id: "trials",
number: "04",
name: "Trials",
description: "A new mode is taking shape.",
available: false,
},
{
id: "downloads",
number: "05",
name: "Downloads",
description: "Install, update, or remove adventures that remain playable offline.",
available: true,
},
{
id: "options",
number: "06",
name: "Options",
description: "Main-menu settings are coming soon.",
available: false,
},
{
id: "player-profile",
number: "07",
name: "Player Profile",
description: "Profile details are coming soon.",
available: false,
},
] as const;
export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseShelvedGmTools(session);
const actions = useMemo<MenuAction[]>(() => [
{
id: "mode-dungeons",
run: openDungeons,
neighbors: { right: "mode-manastorms", down: "back-to-roster" },
},
...(SHELVED_MODES_ENABLED ? [{
id: "mode-manastorms",
run: openManastorms,
neighbors: { left: "mode-dungeons", down: "back-to-roster" },
}, {
id: "mode-arenas",
run: openArenas,
neighbors: { left: "mode-manastorms", down: "back-to-roster" },
}] : []),
{
id: "mode-downloads",
run: onOpenDownloads,
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
},
{
id: "back-to-roster",
run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
},
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
});
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
return (
<DualDisplayFrame
contextLabel="Main menu details"
top={
<FrontSurface className="main-menu-screen" ariaLabel="Healer Man main menu">
<header className="main-menu-header">
<BrandMark compact />
<div>
<span>Adventurer selected</span>
<strong>Choose your next journey</strong>
</div>
<ControllerButton
controlId="back-to-roster"
selectedId={controller.selectedId}
select={controller.select}
type="button"
onClick={returnToCharacters}
>
Change character
</ControllerButton>
</header>
<aside className="main-menu-character" aria-label="Selected character">
<div className="main-menu-character__preview">
<CharacterPreview character={character} />
</div>
{character && race && characterClass && (
<div className="main-menu-character__summary">
<p className="front-kicker">Ready for adventure</p>
<h1>{character.name}</h1>
<strong style={{ color: characterClass.color }}>
Level {character.level} {race.name} {characterClass.name}
</strong>
<small>{FACTIONS[race.faction].name} / {character.gender}</small>
{isRom && <small>RoM / {character.secondaryClassId ? `${characterClass.name} + ${classById(character.secondaryClassId).name}` : "Secondary class not chosen"}</small>}
</div>
)}
</aside>
<main className="main-menu-modes">
<header>
<p className="front-kicker">Main menu</p>
<h2>Ways to play</h2>
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
</ControllerButton>
{character && <>
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
</ControllerButton>
</>}
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
<b aria-hidden="true">!</b>
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Manage Manastorm rules, boss readiness, and encounter placement.</small></span>
<em>Authorized</em>
</ControllerButton>
)}
{character && (
<ControllerButton
controlId="mode-class-hall"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--class-hall"
type="button"
onClick={openClassHall}
>
<b aria-hidden="true">CH</b>
<span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span>
<em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
</ControllerButton>
)}
{GAME_MODES.map((mode) => mode.available ? (
<ControllerButton
key={mode.id}
controlId={`mode-${mode.id}`}
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available"
type="button"
onClick={mode.id === "manastorms"
? openManastorms
: mode.id === "arenas"
? openArenas
: mode.id === "downloads"
? onOpenDownloads
: openDungeons}
>
<b aria-hidden="true">{mode.number}</b>
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
<em>Available</em>
</ControllerButton>
) : (
<button
key={mode.id}
className="front-button main-menu-mode main-menu-mode--coming-soon"
type="button"
disabled
aria-label={`${mode.name}, coming soon`}
>
<b aria-hidden="true">{mode.number}</b>
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
<em>Coming soon</em>
</button>
))}
</nav>
{notice && <p className="front-notice main-menu-notice" role="status" aria-live="polite">{notice}</p>}
</main>
<ControllerLegend back />
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
/>
);
}