Files
healer-man/src/ui/DungeonSelectScreen.tsx
T
2026-09-09 11:21:33 -04:00

427 lines
20 KiB
TypeScript

import { useMemo, useState } from "react";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { classById, raceById } from "../app/characterCatalog";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import {
DUNGEON_CATALOG,
DUNGEON_CATEGORIES,
dungeonsByCategory,
type DungeonCategoryId,
type DungeonId,
} from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import {
partyRolesForClass,
PARTY_ROLE_LABELS,
type PartyRole,
} from "../game/partyStore";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
installDungeonShellRuntime();
const ROLE_DETAILS: Readonly<Record<PartyRole, { readonly sigil: string; readonly summary: string }>> = {
tank: {
sigil: "T",
summary: "Lead from the front with a healer and three damage dealers at your side.",
},
healer: {
sigil: "+",
summary: "Keep the group alive while one tank and three damage dealers handle the fight.",
},
damage: {
sigil: "D",
summary: "Focus down enemies with a tank, a dedicated healer, and two other damage dealers.",
},
};
interface PendingDungeonDownload {
readonly dungeonId: DungeonId;
readonly role: PartyRole | null;
}
export function DungeonSelectScreen() {
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const enterDungeon = useShellStore((state) => state.enterDungeon);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const character = activeCharacter ?? rosterCharacter;
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
const [rolePickerOpen, setRolePickerOpen] = useState(false);
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
const [downloadError, setDownloadError] = useState("");
const [downloading, setDownloading] = useState(false);
const categoryDungeons = dungeonsByCategory(selectedCategory);
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
?? firstCategoryDungeon
?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
: [];
const selectDungeon = (dungeonId: DungeonId) => {
setSelectedDungeonId(dungeonId);
setRolePickerOpen(false);
setDownloadError("");
const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === dungeonId);
if (dungeon?.available && !dungeonContentIsUsable(dungeonId)) {
setPendingDownload({ dungeonId, role: null });
}
};
const selectCategory = (category: DungeonCategoryId) => {
const dungeons = dungeonsByCategory(category);
const nextDungeon = dungeons.find((dungeon) => dungeon.available) ?? dungeons[0];
setSelectedCategory(category);
setSelectedDungeonId(nextDungeon?.id ?? "");
setRolePickerOpen(false);
};
const continueAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(selectedDungeon.id, role);
return;
}
if (!await updateOnlineMember(session, character, role)) return;
if (onlineGroup.leaderAccountId !== session.ownerId) {
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
return;
}
await startOnlineActivity(session, {
type: "dungeon",
contentId: selectedDungeon.id,
partySize: 5,
});
};
const requestRolePicker = () => {
if (!selectedDungeon?.available) return;
if (!dungeonContentIsUsable(selectedDungeon.id)) {
setDownloadError("");
setPendingDownload({ dungeonId: selectedDungeon.id, role: null });
return;
}
setRolePickerOpen(true);
};
const enterAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!dungeonContentIsUsable(selectedDungeon.id)) {
setDownloadError("");
setPendingDownload({ dungeonId: selectedDungeon.id, role });
return;
}
await continueAsRole(role);
};
const confirmDownload = async () => {
if (!pendingDownload || downloading) return;
setDownloading(true);
setDownloadError("");
try {
await installContentPack(`dungeon:${pendingDownload.dungeonId}`);
if (!dungeonContentIsUsable(pendingDownload.dungeonId)) {
throw new Error("The downloaded expedition could not be verified on this device.");
}
const role = pendingDownload.role;
setPendingDownload(null);
if (role) await continueAsRole(role);
else setRolePickerOpen(true);
} catch (error) {
setDownloadError(error instanceof Error ? error.message : "The expedition download failed.");
} finally {
setDownloading(false);
}
};
const actions = useMemo<MenuAction[]>(() => pendingDownload ? [
{ id: "dungeon-download-cancel", run: () => setPendingDownload(null), enabled: !downloading },
{ id: "dungeon-download-confirm", run: () => void confirmDownload(), enabled: !downloading },
] : [
...DUNGEON_CATEGORIES.map((category, index) => ({
id: `dungeon-category-${category.id}`,
run: () => selectCategory(category.id),
neighbors: {
left: `dungeon-category-${DUNGEON_CATEGORIES[(index + DUNGEON_CATEGORIES.length - 1) % DUNGEON_CATEGORIES.length].id}`,
right: `dungeon-category-${DUNGEON_CATEGORIES[(index + 1) % DUNGEON_CATEGORIES.length].id}`,
down: firstCategoryDungeon ? `dungeon-${firstCategoryDungeon.id}` : "dungeon-back",
},
})),
...categoryDungeons.map((dungeon, index) => ({
id: `dungeon-${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
enabled: true,
neighbors: index === 0 ? { up: `dungeon-category-${selectedCategory}` } : undefined,
})),
...(rolePickerOpen
? availableRoles.map((role) => ({
id: `dungeon-role-${role}`,
run: () => enterAsRole(role),
}))
: [{
id: "choose-dungeon-role",
run: requestRolePicker,
enabled: Boolean(selectedDungeon?.available),
}]),
...(rolePickerOpen ? [{
id: "dungeon-role-back",
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload
? () => { if (!downloading) setPendingDownload(null); }
: rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu,
});
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
return (
<DualDisplayFrame
contextLabel="Dungeon details"
top={
<FrontSurface className="dungeon-select-screen" ariaLabel="Dungeon selection">
<header className="dungeon-select-header">
<BrandMark compact />
<div>
<span>Main menu / Dungeons</span>
<strong>Choose an expedition</strong>
</div>
{character && race && characterClass && (
<p>
<b>{character.name}</b>
<small>Level {character.level} {race.name} {characterClass.name}</small>
</p>
)}
</header>
<aside className="dungeon-select-list" aria-label="Available dungeons">
<header>
<p className="front-kicker">Dungeon browser</p>
<h1>Dungeons</h1>
<small>{categoryDungeons.filter((dungeon) => dungeon.available).length} available</small>
</header>
<nav className="dungeon-category-tabs" aria-label="Dungeon categories">
{DUNGEON_CATEGORIES.map((category) => {
const count = dungeonsByCategory(category.id).length;
return (
<ControllerButton
key={category.id}
controlId={`dungeon-category-${category.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-category-tab ${category.id === selectedCategory ? "is-active" : ""}`}
type="button"
aria-pressed={category.id === selectedCategory}
title={category.fullName}
onClick={() => selectCategory(category.id)}
>
<strong>{category.label}</strong>
<small>{count} {count === 1 ? "dungeon" : "dungeons"}</small>
</ControllerButton>
);
})}
</nav>
<div className="dungeon-select-list__items">
{categoryDungeons.map((dungeon, index) => (
<ControllerButton
key={dungeon.id}
controlId={`dungeon-${dungeon.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`}
type="button"
aria-pressed={dungeon.id === selectedDungeon?.id}
onClick={() => selectDungeon(dungeon.id)}
>
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
<em>{dungeon.available
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em>
</ControllerButton>
))}
</div>
<ControllerButton
controlId="dungeon-back"
selectedId={controller.selectedId}
select={controller.select}
className="front-button dungeon-select-back"
type="button"
onClick={returnToMainMenu}
>
Back to main menu
</ControllerButton>
</aside>
<main className="dungeon-select-detail" aria-live="polite">
{selectedDungeon ? (
<>
<div className="dungeon-select-map">
{selectedDefinition ? (
<DungeonMapGraphic
dungeonId={selectedDefinition.id}
position={selectedDefinition.entrance.footPosition}
yaw={selectedDefinition.entrance.yaw}
/>
) : (
<div className="dungeon-select-map__placeholder" aria-hidden="true">HM</div>
)}
</div>
<section className="dungeon-select-copy">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2>
<p>{selectedDungeon.summary}</p>
{!canStart && selectedDungeon.availabilityReason && (
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
)}
{selectedDungeon.available && !selectedPackInstalled && (
<p className="front-notice" role="status">Select this expedition to download its required files.</p>
)}
<OnlineGroupPanel />
{rolePickerOpen && characterClass ? (
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
<header>
<p className="front-kicker">Party role</p>
<h3 id="dungeon-role-title">How do you want to play?</h3>
<small>{characterClass.name} can enter as {availableRoles.map((role) => PARTY_ROLE_LABELS[role]).join(" or ")}.</small>
</header>
<div className="dungeon-role-picker__options">
{availableRoles.map((role) => (
<ControllerButton
key={role}
controlId={`dungeon-role-${role}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-role-option dungeon-role-option--${role}`}
type="button"
onClick={() => enterAsRole(role)}
>
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
<span>
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? `Ready as ${PARTY_ROLE_LABELS[role]}` : PARTY_ROLE_LABELS[role]}</strong>
<small>{ROLE_DETAILS[role].summary}</small>
</span>
</ControllerButton>
))}
</div>
<ControllerButton
controlId="dungeon-role-back"
selectedId={controller.selectedId}
select={controller.select}
className="front-button dungeon-role-picker__back"
type="button"
onClick={() => setRolePickerOpen(false)}
>
Back to dungeon details
</ControllerButton>
</section>
) : (
<>
<dl>
<div><dt>Status</dt><dd>{canStart ? "Available offline" : selectedDungeon.available ? "Download required" : "Coming soon"}</dd></div>
<div><dt>Category</dt><dd>{DUNGEON_CATEGORIES.find((category) => category.id === selectedCategory)?.fullName}</dd></div>
<div><dt>Location</dt><dd>{selectedDungeon.location}</dd></div>
<div><dt>Theme</dt><dd>{selectedDungeon.theme}</dd></div>
<div><dt>Map</dt><dd>{selectedDungeon.mapId}</dd></div>
<div><dt>Starting point</dt><dd>{selectedDungeon.entranceName}</dd></div>
</dl>
<ControllerButton
controlId="choose-dungeon-role"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary dungeon-select-enter"
type="button"
disabled={!selectedDungeon.available}
onClick={requestRolePicker}
>
<span>{canStart ? "Choose role" : selectedDungeon.available ? "Download and choose role" : "Unavailable"}</span>
<small>{canStart ? "Select your role before entering" : selectedDungeon.available ? "Required files will be verified before play" : "This expedition is not ready yet"}</small>
</ControllerButton>
</>
)}
{notice && <p className="front-notice" role="status">{notice}</p>}
</section>
</>
) : (
<section className="dungeon-select-empty">
<p className="front-kicker">No expeditions</p>
<h2>No dungeons are available yet.</h2>
</section>
)}
</main>
<ControllerLegend back />
{pendingDownload && (
<div className="front-modal-backdrop">
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dungeon-download-title">
<p className="front-kicker">Download required</p>
<h2 id="dungeon-download-title">
Download {DUNGEON_CATALOG.find((dungeon) => dungeon.id === pendingDownload.dungeonId)?.title ?? "this expedition"}?
</h2>
<p>The map, creatures, and shared dependencies are not available on this device. Download and verify them now, then continue?</p>
{downloading && (
<p role="status">
Downloading and verifying files
{contentState.totalBytes > 0 ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "..."}
</p>
)}
{downloadError && <p className="front-notice" role="alert">{downloadError}</p>}
<div>
<ControllerButton
controlId="dungeon-download-cancel"
selectedId={controller.selectedId}
select={controller.select}
className="front-button"
type="button"
disabled={downloading}
onClick={() => setPendingDownload(null)}
>
Not now
</ControllerButton>
<ControllerButton
controlId="dungeon-download-confirm"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary"
type="button"
disabled={downloading}
onClick={() => void confirmDownload()}
>
{downloading ? "Downloading..." : downloadError ? "Try again" : "Download and continue"}
</ControllerButton>
</div>
</section>
</div>
)}
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
/>
);
}