Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -0,0 +1,158 @@
|
||||
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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,11 @@ const GAME_MODES = [
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "roguelike-rpg",
|
||||
id: "arenas",
|
||||
number: "03",
|
||||
name: "Roguelike RPG",
|
||||
description: "A new mode is taking shape.",
|
||||
available: false,
|
||||
name: "Arenas",
|
||||
description: "Rated 2v2 and 3v3 combat against players or rating-matched opponents.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "trials",
|
||||
@@ -69,6 +69,7 @@ export function MainMenuScreen() {
|
||||
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);
|
||||
@@ -89,6 +90,11 @@ export function MainMenuScreen() {
|
||||
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: () => setContentManagerOpen(true),
|
||||
@@ -101,7 +107,7 @@ export function MainMenuScreen() {
|
||||
},
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -197,6 +203,8 @@ export function MainMenuScreen() {
|
||||
type="button"
|
||||
onClick={mode.id === "manastorms"
|
||||
? openManastorms
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? () => setContentManagerOpen(true)
|
||||
: openDungeons}
|
||||
|
||||
Reference in new issue
Block a user