forked from phenom/healer-man
159 lines
9.7 KiB
TypeScript
159 lines
9.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||
import { useArenaStore } from "../game/arenaStore";
|
||
import { arenaAiProfile } from "../game/arenaRules";
|
||
import { ARENA_STAGES } from "../game/arenaStages";
|
||
import { romGearProfile, specializationById, specializationsForClass } from "../game/specializationCatalog";
|
||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||
import { ControllerLegend, FrontSurface } from "./FrontSurface";
|
||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||
|
||
const MODES = [
|
||
{ id: "offline-practice", name: "Offline Practice", detail: "Choose any arena and bot rating. Results never affect rating." },
|
||
{ id: "rated-ai", name: "Rated AI", detail: "Fill both teams immediately with rating-matched opponents." },
|
||
{ id: "ranked-pvp", name: "Ranked PvP", detail: "Match humans first; all open slots lock to bots after 30 seconds." },
|
||
] as const;
|
||
|
||
export function ArenaSelectScreen() {
|
||
const character = useShellStore(selectedCharacter);
|
||
const session = useShellStore((state) => state.session);
|
||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||
const arena = useArenaStore();
|
||
const [loadoutName, setLoadoutName] = useState("Arena Loadout");
|
||
const specs = useMemo(() => {
|
||
if (!character) return [];
|
||
if (character.categoryId === "rom") return [romGearProfile(character.classId, "healer"), romGearProfile(character.classId, "damage")];
|
||
return specializationsForClass(character.classId).filter((spec) => spec.role !== "tank");
|
||
}, [character]);
|
||
const fallbackSpec = specs.find((spec) => spec.id === character?.specializationId) ?? specs[0] ?? null;
|
||
const [specializationId, setSpecializationId] = useState(fallbackSpec?.id ?? "");
|
||
const profile = arenaAiProfile(arena.botDifficulty);
|
||
const randomStage = arena.stageId === "random";
|
||
const selectedStage = ARENA_STAGES.find((stage) => stage.id === arena.stageId) ?? ARENA_STAGES[0];
|
||
const selectedLoadout = arena.remote?.loadouts.find((loadout) => loadout.slot === arena.selectedLoadoutSlot) ?? null;
|
||
const rating = arena.remote?.ratings[arena.bracket];
|
||
|
||
useEffect(() => {
|
||
if (character) void arena.load(session, character);
|
||
}, [character?.id, session?.accessToken]);
|
||
|
||
useEffect(() => {
|
||
if (!fallbackSpec) return;
|
||
setSpecializationId(fallbackSpec.id);
|
||
setLoadoutName(`${fallbackSpec.displayName} PvP`);
|
||
}, [fallbackSpec?.id]);
|
||
|
||
if (!character || !selectedStage) return null;
|
||
const online = Boolean(session?.accessToken);
|
||
const rated = arena.mode !== "offline-practice";
|
||
const nativeRuleset = character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
|
||
const stageHeading = rated ? "Random Rated Arena" : randomStage ? "Random Arena" : selectedStage.name;
|
||
const queueLabel = arena.queue.status === "queued"
|
||
? "Leave queue"
|
||
: arena.queue.status === "ready-check"
|
||
? "Ready"
|
||
: arena.loading ? "Working..." : arena.mode === "offline-practice" ? "Start practice" : "Enter rated queue";
|
||
|
||
const primaryAction = async () => {
|
||
if (arena.queue.status === "queued") {
|
||
await arena.leave(session);
|
||
return;
|
||
}
|
||
if (arena.queue.status === "ready-check" && session) {
|
||
await arena.ready(session);
|
||
return;
|
||
}
|
||
await arena.queueMatch(session, character);
|
||
};
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
contextLabel="Arena queue details"
|
||
top={(
|
||
<FrontSurface className="arena-select-screen" ariaLabel="Arena selection">
|
||
<header className="arena-select-header">
|
||
<div><span className="brand-sigil">+</span><strong>Arena Command</strong></div>
|
||
<p><small>{character.name}</small><b>{arena.bracket} / {rating ? `${rating.rating} ${rating.title}` : "Practice"}</b></p>
|
||
<button className="front-button" type="button" onClick={returnToMainMenu}>Back</button>
|
||
</header>
|
||
|
||
<aside className="arena-select-sidebar">
|
||
<p className="front-kicker">Format</p>
|
||
<div className="arena-segmented">
|
||
{(["2v2", "3v3"] as const).map((bracket) => (
|
||
<button key={bracket} type="button" className={arena.bracket === bracket ? "is-active" : ""} onClick={() => arena.configure({ bracket })}>{bracket}</button>
|
||
))}
|
||
</div>
|
||
<p className="front-kicker">Queue</p>
|
||
<div className="arena-mode-list">
|
||
{MODES.map((mode) => (
|
||
<button key={mode.id} type="button" className={arena.mode === mode.id ? "is-active" : ""} onClick={() => arena.configure({ mode: mode.id, ...(mode.id === "offline-practice" ? {} : { ruleset: nativeRuleset }) })}>
|
||
<strong>{mode.name}</strong><small>{mode.detail}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="front-kicker">Ruleset pool</p>
|
||
<div className="arena-segmented">
|
||
{(["wow", "coa", "rom"] as const).map((ruleset) => (
|
||
<button key={ruleset} type="button" disabled={rated && ruleset !== nativeRuleset} className={arena.ruleset === ruleset ? "is-active" : ""} onClick={() => arena.configure({ ruleset })}>{ruleset.toUpperCase()}</button>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="arena-select-main">
|
||
<section className="arena-stage-browser">
|
||
<header><div><p className="front-kicker">Eight battlegrounds</p><h1>{stageHeading}</h1></div><span className="arena-status arena-status--validated">validated</span></header>
|
||
<div className="arena-stage-grid">
|
||
<button type="button" disabled={rated} className={rated || randomStage ? "is-active" : ""} onClick={() => arena.configure({ stageId: "random" })}>
|
||
<b>00</b><span><strong>Random Arena</strong><small>Validated eight-map pool</small></span>
|
||
</button>
|
||
{ARENA_STAGES.map((stage, index) => (
|
||
<button key={stage.id} type="button" disabled={rated} className={!rated && !randomStage && stage.id === selectedStage.id ? "is-active" : ""} onClick={() => arena.configure({ stageId: stage.id })}>
|
||
<b>{String(index + 1).padStart(2, "0")}</b><span><strong>{stage.name}</strong><small>{stage.sourceMap}</small></span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="arena-stage-note">{rated ? "Rated matches choose uniformly from the validated pool and avoid your immediately previous arena when possible." : randomStage ? "Practice will select one of the eight validated arenas when the match starts." : selectedStage.validation.notes[0]}</p>
|
||
</section>
|
||
|
||
<section className="arena-configuration">
|
||
{arena.mode === "offline-practice" ? (
|
||
<>
|
||
<header><p className="front-kicker">Opponent calibration</p><h2>{arena.botDifficulty} / {profile.title}</h2></header>
|
||
<input aria-label="Bot rating" type="range" min="0" max="2500" step="25" value={arena.botDifficulty} onChange={(event) => arena.configure({ botDifficulty: Number(event.target.value) })} />
|
||
<dl>
|
||
<div><dt>Reaction</dt><dd>{profile.reactionMinSeconds.toFixed(2)}–{profile.reactionMaxSeconds.toFixed(2)}s</dd></div>
|
||
<div><dt>Healer kicks</dt><dd>{Math.round(profile.healerInterruptChance * 100)}%</dd></div>
|
||
<div><dt>Minimum error</dt><dd>{Math.round(profile.minimumErrorChance * 100)}%</dd></div>
|
||
</dl>
|
||
</>
|
||
) : (
|
||
<>
|
||
<header><p className="front-kicker">Level 80 PvP profile</p><h2>{selectedLoadout?.name ?? "Automatic first loadout"}</h2></header>
|
||
{!online && <p className="front-notice">Sign in to use rated queues. Practice remains fully offline.</p>}
|
||
{online && (
|
||
<div className="arena-loadout-editor">
|
||
<label>Name<input value={loadoutName} maxLength={32} onChange={(event) => setLoadoutName(event.target.value)} /></label>
|
||
<label>Specialization<select value={specializationId} onChange={(event) => setSpecializationId(event.target.value)}>{specs.map((spec) => <option key={spec.id} value={spec.id}>{spec.displayName} / {spec.role}</option>)}</select></label>
|
||
<label>Slot<select value={arena.selectedLoadoutSlot} onChange={(event) => arena.configure({ selectedLoadoutSlot: Number(event.target.value) })}>{Array.from({ length: 10 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label>
|
||
<button type="button" onClick={() => void arena.saveLoadout(session!, character, { slot: arena.selectedLoadoutSlot, name: loadoutName, specializationId })}>Save loadout</button>
|
||
</div>
|
||
)}
|
||
<p>Gear and prestige are replaced by versioned arena templates. Cosmetics remain visible.</p>
|
||
</>
|
||
)}
|
||
<button className="front-button front-button--primary arena-queue-button" disabled={arena.loading || (rated && !online)} type="button" onClick={() => void primaryAction()}>
|
||
<strong>{queueLabel}</strong>
|
||
<small>{arena.queue.status === "queued" ? `Searching within ±${arena.queue.searchRange ?? 100}` : rated ? "15s ready check / 10s sealed preparation" : `${randomStage ? "Random arena" : selectedStage.name} / no rating changes`}</small>
|
||
</button>
|
||
{arena.error && <p className="front-notice" role="alert">{arena.error}</p>}
|
||
</section>
|
||
</main>
|
||
<ControllerLegend back />
|
||
</FrontSurface>
|
||
)}
|
||
bottom={<LazyLocalCompanionPanel />}
|
||
/>
|
||
);
|
||
}
|