176 lines
13 KiB
TypeScript
176 lines
13 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { classById, raceById, type RomClassId } from "../app/characterCatalog";
|
|
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
|
import type { CharacterProfile } from "../app/types";
|
|
import {
|
|
ROM_ELITE_SKILL_LEVELS,
|
|
ROM_SECONDARY_UNLOCK_LEVEL,
|
|
romSecondaryClassesForRace,
|
|
unlockedRomEliteLevels,
|
|
} from "../app/romDualClass";
|
|
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 { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
|
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
|
|
import {
|
|
specializationById,
|
|
specializationsForClass,
|
|
} from "../game/specializationCatalog";
|
|
|
|
export function ClassHallScreen() {
|
|
const character = useShellStore(selectedCharacter);
|
|
if (!character) return null;
|
|
return character.categoryId === "rom"
|
|
? <RomClassHall character={character} />
|
|
: <SpecializationClassHall character={character} />;
|
|
}
|
|
|
|
function SpecializationClassHall({ character }: { readonly character: CharacterProfile }) {
|
|
const notice = useShellStore((state) => state.notice);
|
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
|
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
|
const changeSpecialization = useShellStore((state) => state.changeSelectedSpecialization);
|
|
const [pendingSpecializationId, setPendingSpecializationId] = useState<string | null>(null);
|
|
const options = useMemo(() => specializationsForClass(character.classId), [character.classId]);
|
|
const current = specializationById(character.specializationId);
|
|
const forced = current === null;
|
|
const goBack = forced ? returnToCharacters : returnToMainMenu;
|
|
const apply = (resetTalents: boolean) => {
|
|
if (!pendingSpecializationId || !changeSpecialization(pendingSpecializationId, resetTalents)) return;
|
|
setPendingSpecializationId(null);
|
|
if (forced) returnToMainMenu();
|
|
};
|
|
const actions = useMemo<MenuAction[]>(() => pendingSpecializationId ? [
|
|
{ id: "specialization-cancel", run: () => setPendingSpecializationId(null) },
|
|
{ id: "specialization-keep", run: () => apply(false) },
|
|
{ id: "specialization-reset", run: () => apply(true) },
|
|
] : [
|
|
...options.map((definition) => ({ id: `specialization-${definition.id}`, run: () => setPendingSpecializationId(definition.id) })),
|
|
{ id: "class-hall-back", run: goBack },
|
|
], [goBack, options, pendingSpecializationId]);
|
|
const controller = useMenuController(actions, {
|
|
initialId: current ? `specialization-${current.id}` : options[0] ? `specialization-${options[0].id}` : "class-hall-back",
|
|
onBack: pendingSpecializationId ? () => setPendingSpecializationId(null) : goBack,
|
|
});
|
|
const race = raceById(character.raceId);
|
|
const characterClass = classById(character.classId);
|
|
const pending = specializationById(pendingSpecializationId);
|
|
|
|
return (
|
|
<DualDisplayFrame
|
|
contextLabel="Specialization details"
|
|
top={
|
|
<FrontSurface className="class-hall-screen" ariaLabel="Class Hall specialization selection">
|
|
<header className="class-hall-header">
|
|
<BrandMark compact />
|
|
<div><span>{characterClass.name} / Class Hall</span><strong>{forced ? "Choose your specialization to continue." : "Change your combat path."}</strong></div>
|
|
<ControllerButton controlId="class-hall-back" selectedId={controller.selectedId} select={controller.select} type="button" onClick={goBack}>{forced ? "Roster" : "Back"}</ControllerButton>
|
|
</header>
|
|
<aside className="class-hall-character">
|
|
<CharacterPreview character={character} />
|
|
<div><p className="front-kicker">{race.name} / Level {character.level}</p><h1>{character.name}</h1><strong style={{ color: characterClass.color }}>{characterClass.name}</strong>{current && <small>{current.displayName} / {PARTY_ROLE_LABELS[current.role]}</small>}</div>
|
|
</aside>
|
|
<main className="class-hall-content">
|
|
<section className="secondary-class-choice">
|
|
<p className="front-kicker">Specialization and heirloom profile</p>
|
|
<h2>{forced ? "A specialization is required" : `Current: ${current.displayName}`}</h2>
|
|
<p>Your saved specialization locks your activity role and tunes every heirloom stat and weapon slot. Talent points do not change specialization automatically.</p>
|
|
<div>{options.map((definition) => (
|
|
<ControllerButton key={definition.id} controlId={`specialization-${definition.id}`} selectedId={controller.selectedId} select={controller.select} className={`front-button secondary-class-option ${current?.id === definition.id ? "is-active" : ""}`} type="button" onClick={() => setPendingSpecializationId(definition.id)}>
|
|
<b style={{ color: characterClass.color }}>{characterClass.sigil}</b>
|
|
<span><strong>{definition.displayName}</strong><small>{PARTY_ROLE_LABELS[definition.role]} / {definition.gearArchetype.replaceAll("-", " ")} / {definition.weaponLayout.replaceAll("-", " ")}</small></span>
|
|
</ControllerButton>
|
|
))}</div>
|
|
</section>
|
|
{notice && <p className="front-notice" role="status">{notice}</p>}
|
|
</main>
|
|
{pending && (
|
|
<div className="front-modal-backdrop"><section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="specialization-confirm-title">
|
|
<p className="front-kicker">Specialization change</p>
|
|
<h2 id="specialization-confirm-title">Become {pending.displayName}?</h2>
|
|
<p>Heirloom stats and your allowed activity role change immediately. Choose whether to preserve the current talent build and action bindings.</p>
|
|
<div>
|
|
<ControllerButton controlId="specialization-cancel" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setPendingSpecializationId(null)}>Cancel</ControllerButton>
|
|
<ControllerButton controlId="specialization-keep" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => apply(false)}>Keep talents</ControllerButton>
|
|
<ControllerButton controlId="specialization-reset" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => apply(true)}>Reset talents</ControllerButton>
|
|
</div>
|
|
</section></div>
|
|
)}
|
|
<ControllerLegend back />
|
|
</FrontSurface>
|
|
}
|
|
bottom={<LazyLocalCompanionPanel />}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RomClassHall({ character }: { readonly character: CharacterProfile }) {
|
|
const notice = useShellStore((state) => state.notice);
|
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
|
const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass);
|
|
const swapClasses = useShellStore((state) => state.swapSelectedRomClasses);
|
|
const [pendingClassId, setPendingClassId] = useState<RomClassId | null>(null);
|
|
const options = useMemo(
|
|
() => character ? romSecondaryClassesForRace(character.raceId, character.classId) : [],
|
|
[character],
|
|
);
|
|
const actions = useMemo<MenuAction[]>(() => pendingClassId ? [
|
|
{ id: "secondary-cancel", run: () => setPendingClassId(null) },
|
|
{ id: "secondary-confirm", run: () => { if (chooseSecondary(pendingClassId)) setPendingClassId(null); } },
|
|
] : [
|
|
...options.map((classId) => ({ id: `secondary-${classId}`, run: () => setPendingClassId(classId) })),
|
|
...(character?.secondaryClassId ? [{ id: "swap-classes", run: swapClasses }] : []),
|
|
{ id: "class-hall-back", run: returnToMainMenu },
|
|
], [character?.secondaryClassId, chooseSecondary, options, pendingClassId, returnToMainMenu, swapClasses]);
|
|
const controller = useMenuController(actions, {
|
|
initialId: character?.secondaryClassId ? "swap-classes" : options[0] ? `secondary-${options[0]}` : "class-hall-back",
|
|
onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu,
|
|
});
|
|
|
|
const race = raceById(character.raceId);
|
|
const primary = classById(character.classId);
|
|
const secondary = character.secondaryClassId ? classById(character.secondaryClassId) : null;
|
|
const locked = character.level < ROM_SECONDARY_UNLOCK_LEVEL;
|
|
const eliteLevels = unlockedRomEliteLevels(character.level);
|
|
|
|
return (
|
|
<DualDisplayFrame
|
|
contextLabel="Class Hall details"
|
|
top={
|
|
<FrontSurface className="class-hall-screen" ariaLabel="Runes of Magic Class Hall">
|
|
<header className="class-hall-header">
|
|
<BrandMark compact />
|
|
<div><span>Runes of Magic / Class Hall</span><strong>Bind two paths. Master both directions.</strong></div>
|
|
<ControllerButton controlId="class-hall-back" selectedId={controller.selectedId} select={controller.select} type="button" onClick={returnToMainMenu}>Back</ControllerButton>
|
|
</header>
|
|
<aside className="class-hall-character">
|
|
<CharacterPreview character={character} />
|
|
<div><p className="front-kicker">{race.name} / Level {character.level}</p><h1>{character.name}</h1><strong style={{ color: primary.color }}>{primary.name}</strong>{secondary && <small>Secondary: {secondary.name}</small>}</div>
|
|
</aside>
|
|
<main className="class-hall-content">
|
|
{locked ? (
|
|
<section className="class-hall-locked"><span>10</span><h2>The second path opens at level 10</h2><p>Your level and experience are shared. Return after reaching level {ROM_SECONDARY_UNLOCK_LEVEL}.</p></section>
|
|
) : secondary ? (
|
|
<>
|
|
<section className="class-orientation-card"><p className="front-kicker">Active ordered pair</p><h2>{primary.name} <span>/</span> {secondary.name}</h2><p>The primary contributes full base stats and determines your role and resource. {secondary.name} contributes 10% of its level-scaled base stats and its general skills.</p><ControllerButton controlId="swap-classes" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={swapClasses}>Swap to {secondary.name} / {primary.name}</ControllerButton></section>
|
|
<section className="elite-milestones"><p className="front-kicker">Automatic elite skills</p><h2>{eliteLevels.length} / 10 unlocked</h2><div>{ROM_ELITE_SKILL_LEVELS.map((level) => <span key={level} className={eliteLevels.includes(level) ? "is-unlocked" : ""}>Lv {level}</span>)}</div><small>Only the active ordering's elite set appears in combat. Swapping restores that orientation's action bar.</small></section>
|
|
</>
|
|
) : (
|
|
<section className="secondary-class-choice"><p className="front-kicker">Permanent secondary class</p><h2>Choose {character.name}'s second path</h2><p>This choice cannot be replaced. You can swap which of the pair is primary whenever you return here outside a run.</p><div>{options.map((classId) => { const definition = classById(classId); return <ControllerButton key={classId} controlId={`secondary-${classId}`} selectedId={controller.selectedId} select={controller.select} className="front-button secondary-class-option" type="button" onClick={() => setPendingClassId(classId)}><b style={{ color: definition.color }}>{definition.sigil}</b><span><strong>{definition.name}</strong><small>{definition.role} / {definition.resourceSummary}</small></span></ControllerButton>; })}</div></section>
|
|
)}
|
|
{notice && <p className="front-notice" role="status">{notice}</p>}
|
|
</main>
|
|
{pendingClassId && (
|
|
<div className="front-modal-backdrop"><section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="secondary-confirm-title"><p className="front-kicker">Irreversible choice</p><h2 id="secondary-confirm-title">Bind {classById(pendingClassId).name} to {primary.name}?</h2><p>The pair is permanent. Its direction may be swapped here later, but the partner class cannot be replaced.</p><div><ControllerButton controlId="secondary-cancel" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setPendingClassId(null)}>Go back</ControllerButton><ControllerButton controlId="secondary-confirm" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => { if (chooseSecondary(pendingClassId)) setPendingClassId(null); }}>Confirm permanent pair</ControllerButton></div></section></div>
|
|
)}
|
|
<ControllerLegend back />
|
|
</FrontSurface>
|
|
}
|
|
bottom={<LazyLocalCompanionPanel />}
|
|
/>
|
|
);
|
|
}
|