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
@@ -14,6 +14,8 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
|
||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
||||
const ArenaSelectScreen = lazy(() => import("./ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
|
||||
const ArenaRuntime = lazy(() => import("./ArenaRuntime"));
|
||||
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||
|
||||
function ShellTransition() {
|
||||
@@ -72,6 +74,8 @@ export default function App() {
|
||||
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>;
|
||||
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>;
|
||||
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Clone } from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import { FrontSide, Group, Mesh, Vector3 } from "three";
|
||||
import { selectedCharacter, useShellStore } from "./app/shellStore";
|
||||
import { CharacterModel } from "./avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
|
||||
import { classById } from "./app/characterCatalog";
|
||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
|
||||
import { useArenaStore } from "./game/arenaStore";
|
||||
import { arenaStageById } from "./game/arenaStages";
|
||||
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
|
||||
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
|
||||
|
||||
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
|
||||
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
|
||||
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
|
||||
}
|
||||
|
||||
function LoadedStage({ url }: { url: string }) {
|
||||
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
|
||||
useEffect(() => {
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) {
|
||||
material.side = FrontSide;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}, [gltf.scene]);
|
||||
return <Clone object={gltf.scene} />;
|
||||
}
|
||||
|
||||
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
|
||||
const width = stage.bounds.max[0] - stage.bounds.min[0];
|
||||
const depth = stage.bounds.max[2] - stage.bounds.min[2];
|
||||
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
|
||||
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
|
||||
<boxGeometry args={[width, 0.5, depth]} />
|
||||
<meshStandardMaterial color="#252b27" roughness={0.9} />
|
||||
</mesh>
|
||||
{stage.lineOfSight.map((object) => (
|
||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color="#4e5146" roughness={0.78} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
|
||||
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
|
||||
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
|
||||
const fire = states.get("fire");
|
||||
const pipes = states.get("pipes");
|
||||
const elevators = states.get("elevators");
|
||||
return (
|
||||
<group>
|
||||
{stage.lineOfSight.filter((object) => object.dynamicObjectId).map((object) => {
|
||||
const state = states.get(object.dynamicObjectId!);
|
||||
const definition = definitions.get(object.dynamicObjectId!);
|
||||
if (!state || !definition) return null;
|
||||
const waterfall = definition.kind === "waterfall";
|
||||
if (waterfall && state.state === "open") return null;
|
||||
const raised = state.state === "raised" || state.state === "active";
|
||||
const movingOffset = definition.kind === "moving-pillars" && !raised ? object.size[1] * 0.8 : 0;
|
||||
const color = waterfall ? (state.state === "warning" ? "#9fcaf0" : "#4aa9ef") : "#9a7447";
|
||||
return (
|
||||
<mesh key={object.id} position={[object.position[0], object.position[1] - movingOffset, object.position[2]]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color={color} emissive={waterfall ? color : "#000000"} emissiveIntensity={waterfall ? 0.35 : 0} transparent={waterfall} opacity={waterfall ? 0.62 : 0.9} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{fire?.state === "active" && [-31, 31].map((z) => (
|
||||
<mesh key={z} position={[0, stage.bounds.min[1] + 0.6, z]}>
|
||||
<boxGeometry args={[70, 1.2, 4]} />
|
||||
<meshStandardMaterial color="#ff7a21" emissive="#ff3b0b" emissiveIntensity={1.8} transparent opacity={0.72} />
|
||||
</mesh>
|
||||
))}
|
||||
{pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => (
|
||||
<mesh key={index} position={[spawn[0], spawn[1] + 1.3, spawn[2] + (index === 0 ? 8 : -8)]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[pipes.state === "active" ? 2.2 : 1.1, 0.4, pipes.state === "active" ? 10 : 4, 12]} />
|
||||
<meshStandardMaterial color={pipes.state === "active" ? "#73c8ff" : "#d9e9f6"} emissive="#4b9dcc" emissiveIntensity={0.8} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
{elevators && stage.teamSpawns.map((spawn, index) => {
|
||||
const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0;
|
||||
return (
|
||||
<mesh key={index} position={[spawn[0], spawn[1] - 0.5 - descent * 7, spawn[2]]} receiveShadow>
|
||||
<cylinderGeometry args={[4.5, 4.5, 1, 24]} />
|
||||
<meshStandardMaterial color="#6b5944" metalness={0.45} roughness={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
|
||||
const group = useRef<Group>(null);
|
||||
useFrame((_, delta) => {
|
||||
const node = group.current;
|
||||
if (!node) return;
|
||||
if (local) {
|
||||
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
|
||||
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
|
||||
}
|
||||
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
|
||||
node.rotation.y = actor.facing;
|
||||
});
|
||||
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
|
||||
const cosmeticEquipment = useMemo(
|
||||
() => character ? equipmentItemsForOwner(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID) : [],
|
||||
[character],
|
||||
);
|
||||
return (
|
||||
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
|
||||
{local && character ? (
|
||||
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
|
||||
) : (
|
||||
<group>
|
||||
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
|
||||
<mesh position={[0, 1.85, 0]} castShadow><sphereGeometry args={[0.34, 14, 10]} /><meshStandardMaterial color={actor.role === "healer" ? "#e8df8b" : color} /></mesh>
|
||||
</group>
|
||||
)}
|
||||
<pointLight position={[0, 1.2, 0]} color={color} intensity={0.75} distance={3} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
|
||||
const target = useMemo(() => new Vector3(), []);
|
||||
const desired = useMemo(() => new Vector3(), []);
|
||||
useFrame(({ camera }, delta) => {
|
||||
if (!actor) return;
|
||||
const spawn = stage.teamSpawns[actor.team];
|
||||
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
|
||||
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
|
||||
const directionX = centerX - spawn[0];
|
||||
const directionZ = centerZ - spawn[2];
|
||||
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
|
||||
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
|
||||
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
|
||||
camera.position.lerp(desired, Math.min(1, delta * 5));
|
||||
camera.lookAt(target);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
|
||||
<color attach="background" args={["#111815"]} />
|
||||
<fog attach="fog" args={["#111815", 55, 240]} />
|
||||
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
|
||||
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
|
||||
<GameGltfLoaderLifecycle>
|
||||
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
|
||||
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
||||
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
|
||||
<ArenaCamera actor={localActor} stage={stage} />
|
||||
</GameGltfLoaderLifecycle>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
||||
return (
|
||||
<div className={`arena-team-frames arena-team-frames--${team}`}>
|
||||
{actors.filter((actor) => actor.team === team).map((actor) => (
|
||||
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
|
||||
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
|
||||
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
|
||||
<em>{actor.health}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArenaRuntime() {
|
||||
const snapshot = useArenaStore((state) => state.snapshot);
|
||||
const result = useArenaStore((state) => state.result);
|
||||
const intent = useArenaStore((state) => state.intent);
|
||||
const leave = useArenaStore((state) => state.leave);
|
||||
const load = useArenaStore((state) => state.load);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
|
||||
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
|
||||
const movementRef = useRef<[number, number]>([0, 0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot || !character) return;
|
||||
if (!session?.accessToken) {
|
||||
useArenaStore.setState({ error: "Offline Practice ended when the game reloaded. Start a new practice match.", queue: { status: "idle" } });
|
||||
useShellStore.setState({ phase: "arenas" });
|
||||
return;
|
||||
}
|
||||
void load(session, character).then(() => {
|
||||
if (useArenaStore.getState().queue.status !== "matched") useShellStore.setState({ phase: "arenas" });
|
||||
});
|
||||
}, [character, load, session, snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const pressed = new Set<string>();
|
||||
const movement = () => {
|
||||
const next: [number, number] = [
|
||||
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
|
||||
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
|
||||
];
|
||||
movementRef.current = next;
|
||||
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
|
||||
};
|
||||
const down = (event: KeyboardEvent) => {
|
||||
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
|
||||
pressed.add(event.code);
|
||||
movement();
|
||||
}
|
||||
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
|
||||
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
|
||||
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
|
||||
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
|
||||
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
|
||||
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
|
||||
};
|
||||
const up = (event: KeyboardEvent) => {
|
||||
if (pressed.delete(event.code)) movement();
|
||||
};
|
||||
window.addEventListener("keydown", down);
|
||||
window.addEventListener("keyup", up);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down);
|
||||
window.removeEventListener("keyup", up);
|
||||
};
|
||||
}, [intent]);
|
||||
|
||||
const exit = async () => {
|
||||
await leave(session);
|
||||
useShellStore.setState({ phase: "arenas" });
|
||||
};
|
||||
|
||||
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
|
||||
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
|
||||
return (
|
||||
<main className="arena-runtime">
|
||||
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
|
||||
<header className="arena-match-header">
|
||||
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
|
||||
</header>
|
||||
<TeamFrames actors={snapshot.actors} team={0} />
|
||||
<TeamFrames actors={snapshot.actors} team={1} />
|
||||
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
|
||||
<nav className="arena-action-bar" aria-label="Arena abilities">
|
||||
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
|
||||
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
|
||||
{result && (
|
||||
<section className="arena-result-panel" role="dialog" aria-modal="true">
|
||||
<p className="front-kicker">Match complete</p>
|
||||
<h1>{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}</h1>
|
||||
{result.ratingUpdates.map((update) => <p key={`${update.characterId}-${update.bracket}`}>{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}</p>)}
|
||||
{!result.ratingUpdates.length && <p>Practice result — rating unchanged.</p>}
|
||||
<button type="button" className="front-button front-button--primary" onClick={() => void exit()}>Return to Arena Command</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PlayerSession } from "./types";
|
||||
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
|
||||
import type { ArenaLoadout, ArenaQueueRequest, ArenaQueueState, ArenaStageManifest } from "../game/arenaTypes";
|
||||
|
||||
export interface ArenaStateResponse {
|
||||
characterId: string | null;
|
||||
loadouts: ArenaLoadout[];
|
||||
ratings: Record<"2v2" | "3v3", { bracket: "2v2" | "3v3"; rating: number; title: string; updatedAt: number | null } | null>;
|
||||
queue: ArenaQueueState;
|
||||
stages: ArenaStageManifest[];
|
||||
}
|
||||
|
||||
function token(session: PlayerSession): string {
|
||||
if (session.kind !== "account" || !session.accessToken) throw new Error("Rated arenas require an online account.");
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
export function fetchArenaState(session: PlayerSession, characterId: string): Promise<ArenaStateResponse> {
|
||||
return requestOnlineJson(`/api/arena/state?characterId=${encodeURIComponent(characterId)}`, {}, token(session));
|
||||
}
|
||||
|
||||
export function putArenaLoadout(
|
||||
session: PlayerSession,
|
||||
characterId: string,
|
||||
slot: number,
|
||||
loadout: Omit<ArenaLoadout, "slot" | "characterId" | "level" | "version" | "selected" | "updatedAt"> & { selected?: boolean },
|
||||
): Promise<ArenaLoadout> {
|
||||
return requestOnlineJson(`/api/arena/loadouts/${slot}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...loadout, characterId }),
|
||||
}, token(session));
|
||||
}
|
||||
|
||||
export function deleteArenaLoadout(session: PlayerSession, characterId: string, slot: number) {
|
||||
return requestOnlineJson<{ ok: true }>(`/api/arena/loadouts/${slot}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ characterId }),
|
||||
}, token(session));
|
||||
}
|
||||
|
||||
export function enterArenaQueue(session: PlayerSession, request: ArenaQueueRequest): Promise<ArenaQueueState> {
|
||||
return requestOnlineJson("/api/arena/queue", { method: "POST", body: JSON.stringify(request) }, token(session));
|
||||
}
|
||||
|
||||
export function leaveArenaQueue(session: PlayerSession): Promise<ArenaQueueState> {
|
||||
return requestOnlineJson("/api/arena/queue", { method: "DELETE" }, token(session));
|
||||
}
|
||||
|
||||
export function acceptArenaReadyCheck(session: PlayerSession): Promise<ArenaQueueState> {
|
||||
return requestOnlineJson("/api/arena/ready", { method: "POST" }, token(session));
|
||||
}
|
||||
|
||||
interface ArenaSocketTicket { ticket: string; expiresAt: number; path: string }
|
||||
|
||||
export async function openArenaSocket(
|
||||
session: PlayerSession,
|
||||
onMessage: (message: unknown) => void,
|
||||
onClose: () => void,
|
||||
): Promise<WebSocket> {
|
||||
const response = await requestOnlineJson<ArenaSocketTicket>("/api/arena/socket-ticket", { method: "POST" }, token(session));
|
||||
const origin = onlineApiOrigin();
|
||||
const base = origin || window.location.origin;
|
||||
const url = new URL(response.path, base);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.searchParams.set("ticket", response.ticket);
|
||||
const socket = new WebSocket(url);
|
||||
socket.addEventListener("message", (event) => {
|
||||
try { onMessage(JSON.parse(String(event.data))); } catch { /* Ignore malformed server messages. */ }
|
||||
});
|
||||
socket.addEventListener("close", onClose);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("open", () => resolve(), { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("Could not connect to the arena server.")), { once: true });
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ let pendingCharacters: CharacterProfile[] | null = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveQueue = Promise.resolve();
|
||||
|
||||
function apiOrigin(): string {
|
||||
export function onlineApiOrigin(): string {
|
||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
if (configured) return configured;
|
||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
||||
@@ -53,7 +53,7 @@ export async function requestOnlineJson<T>(pathname: string, init: RequestInit =
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${apiOrigin()}${pathname}`, { ...init, headers });
|
||||
response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers });
|
||||
} catch {
|
||||
throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface ShellState {
|
||||
openMainMenu: () => boolean;
|
||||
openDungeons: () => boolean;
|
||||
openManastorms: () => boolean;
|
||||
openArenas: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||
@@ -333,6 +334,15 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openArenas: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "arenas", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!isGameMasterSession(session)) {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import type { ManastormProgress } from "../game/manastormProgress";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
|
||||
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
|
||||
|
||||
interface PracticeOptions {
|
||||
character: CharacterProfile;
|
||||
role: ArenaRole;
|
||||
bracket: "2v2" | "3v3";
|
||||
ruleset: "wow" | "coa" | "rom";
|
||||
stage: ArenaStageManifest;
|
||||
difficulty: number;
|
||||
onSnapshot: (snapshot: ArenaAuthoritativeSnapshot) => void;
|
||||
onResult: (result: ArenaResult) => void;
|
||||
}
|
||||
|
||||
function teamRoles(teamSize: number, playerRole?: ArenaRole): ArenaRole[] {
|
||||
const roles: ArenaRole[] = playerRole ? [playerRole] : [];
|
||||
if (!roles.includes("healer")) roles.push("healer");
|
||||
while (roles.length < teamSize) roles.push("damage");
|
||||
return roles;
|
||||
}
|
||||
|
||||
export function createArenaPractice(options: PracticeOptions) {
|
||||
const id = `practice-${Date.now().toString(36)}`;
|
||||
const teamSize = options.bracket === "2v2" ? 2 : 3;
|
||||
const participants = [
|
||||
...teamRoles(teamSize, options.role).map((role, index) => ({
|
||||
actorId: index === 0 ? "practice-player" : `practice-ally-${index}`,
|
||||
accountId: null,
|
||||
characterId: index === 0 ? options.character.id : null,
|
||||
name: index === 0 ? options.character.name : role === "healer" ? "Training Medic" : `Training Ally ${index}`,
|
||||
team: 0 as const,
|
||||
role,
|
||||
isBot: index !== 0,
|
||||
rating: options.difficulty,
|
||||
})),
|
||||
...teamRoles(teamSize).map((role, index) => ({
|
||||
actorId: `practice-enemy-${index}`,
|
||||
accountId: null,
|
||||
characterId: null,
|
||||
name: role === "healer" ? "Opponent Medic" : `Opponent ${index + 1}`,
|
||||
team: 1 as const,
|
||||
role,
|
||||
isBot: true,
|
||||
rating: options.difficulty,
|
||||
})),
|
||||
];
|
||||
const simulation = new ArenaSimulation({
|
||||
id,
|
||||
stage: options.stage,
|
||||
bracket: options.bracket,
|
||||
mode: "practice",
|
||||
ruleset: options.ruleset,
|
||||
participants,
|
||||
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
|
||||
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
|
||||
});
|
||||
simulation.start();
|
||||
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
|
||||
return {
|
||||
intent(intent: ArenaCombatIntent) {
|
||||
return simulation.receiveIntent("practice-player", intent);
|
||||
},
|
||||
stop() {
|
||||
simulation.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
arenaAiProfile,
|
||||
arenaDampening,
|
||||
arenaDrDuration,
|
||||
arenaRatingBand,
|
||||
calculateArenaRating,
|
||||
clampArenaRating,
|
||||
} from "./arenaRules";
|
||||
|
||||
describe("arena rating and difficulty", () => {
|
||||
it("clamps ratings and applies the requested Elo bands", () => {
|
||||
expect(clampArenaRating(-5)).toBe(0);
|
||||
expect(clampArenaRating(2600)).toBe(2500);
|
||||
expect(arenaRatingBand(0)).toMatchObject({ title: "Recruit", k: 64 });
|
||||
expect(arenaRatingBand(2400)).toMatchObject({ title: "Gladiator", k: 20 });
|
||||
expect(calculateArenaRating(0, 0, 1)).toMatchObject({ delta: 32, after: 32 });
|
||||
expect(calculateArenaRating(0, 0, 0)).toMatchObject({ delta: 0, after: 0 });
|
||||
expect(calculateArenaRating(1250, 1250, 0.5)).toMatchObject({ delta: 0, after: 1250 });
|
||||
});
|
||||
|
||||
it("continuously improves bots without removing the error floor", () => {
|
||||
const recruit = arenaAiProfile(0);
|
||||
const rival = arenaAiProfile(1600);
|
||||
const gladiator = arenaAiProfile(2500);
|
||||
expect(rival.reactionMinSeconds).toBeLessThan(recruit.reactionMinSeconds);
|
||||
expect(gladiator.healerInterruptChance).toBe(0.85);
|
||||
expect(gladiator.minimumErrorChance).toBe(0.08);
|
||||
});
|
||||
|
||||
it("starts dampening at ten percent and reaches zero healing at nine minutes", () => {
|
||||
expect(arenaDampening(0)).toBeCloseTo(0.1);
|
||||
expect(arenaDampening(30_000)).toBeCloseTo(0.15);
|
||||
expect(arenaDampening(540_000)).toBe(1);
|
||||
});
|
||||
|
||||
it("uses full, half, quarter, immune DR and resets after fifteen seconds", () => {
|
||||
expect(arenaDrDuration(8_000, 0, 0).durationMs).toBe(8_000);
|
||||
expect(arenaDrDuration(8_000, 1, 0).durationMs).toBe(4_000);
|
||||
expect(arenaDrDuration(8_000, 2, 0).durationMs).toBe(2_000);
|
||||
expect(arenaDrDuration(8_000, 3, 0).durationMs).toBe(0);
|
||||
expect(arenaDrDuration(8_000, 3, 15_000).durationMs).toBe(8_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
export type ArenaBracket = "2v2" | "3v3";
|
||||
export type ArenaMode = "practice" | "rated-ai" | "ranked-pvp";
|
||||
export type ArenaRuleset = "wow" | "coa" | "rom";
|
||||
export type ArenaRole = "healer" | "damage";
|
||||
|
||||
export interface ArenaAiProfile {
|
||||
readonly rating: number;
|
||||
readonly title: string;
|
||||
readonly reactionMinSeconds: number;
|
||||
readonly reactionMaxSeconds: number;
|
||||
readonly healerInterruptChance: number;
|
||||
readonly coordinatedCcChance: number;
|
||||
readonly correctDefensiveChance: number;
|
||||
readonly minimumErrorChance: number;
|
||||
}
|
||||
|
||||
export const ARENA_BRACKETS: Readonly<Record<ArenaBracket, { readonly size: 2 | 3; readonly healers: 1; readonly damage: 1 | 2 }>> = Object.freeze({
|
||||
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
|
||||
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
|
||||
});
|
||||
|
||||
export const ARENA_RATING_BANDS = Object.freeze([
|
||||
{ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 },
|
||||
{ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 },
|
||||
{ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 },
|
||||
{ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 },
|
||||
{ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 },
|
||||
{ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 },
|
||||
{ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 },
|
||||
] as const);
|
||||
|
||||
export function clampArenaRating(value: number): number {
|
||||
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
|
||||
}
|
||||
|
||||
export function arenaRatingBand(rating: number): typeof ARENA_RATING_BANDS[number] {
|
||||
const normalized = clampArenaRating(rating);
|
||||
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max) ?? ARENA_RATING_BANDS[0];
|
||||
}
|
||||
|
||||
function lerp(left: number, right: number, amount: number): number {
|
||||
return left + (right - left) * Math.max(0, Math.min(1, amount));
|
||||
}
|
||||
|
||||
export function arenaAiProfile(rating: number): ArenaAiProfile {
|
||||
const normalized = clampArenaRating(rating);
|
||||
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
|
||||
const current = ARENA_RATING_BANDS[Math.max(0, index)];
|
||||
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
|
||||
const progress = current === next ? 0 : (normalized - current.min) / Math.max(1, current.max - current.min + 1);
|
||||
return {
|
||||
rating: normalized,
|
||||
title: current.title,
|
||||
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
|
||||
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
|
||||
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
|
||||
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
|
||||
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
|
||||
minimumErrorChance: lerp(current.error, next.error, progress),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateArenaRating(rating: number, opponentRating: number, score: 0 | 0.5 | 1) {
|
||||
const before = clampArenaRating(rating);
|
||||
if (score === 0.5) return { before, expected: 0.5, delta: 0, after: before };
|
||||
const expected = 1 / (1 + 10 ** ((clampArenaRating(opponentRating) - before) / 400));
|
||||
const rawDelta = Math.round(arenaRatingBand(before).k * (score - expected));
|
||||
const after = clampArenaRating(before + rawDelta);
|
||||
return { before, expected, delta: after - before, after };
|
||||
}
|
||||
|
||||
export function arenaDampening(elapsedMs: number): number {
|
||||
return Math.min(1, 0.1 + Math.floor(Math.max(0, elapsedMs) / 30_000) * 0.05);
|
||||
}
|
||||
|
||||
export function arenaDrDuration(baseDurationMs: number, priorApplications: number, elapsedSinceEndMs = Infinity) {
|
||||
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
|
||||
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
|
||||
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import catalog from "../../shared/arena-stages.json";
|
||||
import type { ArenaStageManifest } from "./arenaTypes";
|
||||
|
||||
function normalizeStage(stage: Omit<ArenaStageManifest, "assets">): ArenaStageManifest {
|
||||
return {
|
||||
...stage,
|
||||
assets: [
|
||||
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
|
||||
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
|
||||
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const ARENA_STAGES = Object.freeze(
|
||||
catalog.stages.map((stage) => normalizeStage(stage as unknown as Omit<ArenaStageManifest, "assets">)),
|
||||
);
|
||||
|
||||
export const VALIDATED_ARENA_STAGES = Object.freeze(
|
||||
ARENA_STAGES.filter((stage) => Object.values(stage.validation).slice(1, 6).every(Boolean)),
|
||||
);
|
||||
|
||||
export function arenaStageById(stageId: string) {
|
||||
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
acceptArenaReadyCheck,
|
||||
deleteArenaLoadout as deleteRemoteLoadout,
|
||||
enterArenaQueue,
|
||||
fetchArenaState,
|
||||
leaveArenaQueue,
|
||||
openArenaSocket,
|
||||
putArenaLoadout as putRemoteLoadout,
|
||||
type ArenaStateResponse,
|
||||
} from "../app/arenaClient";
|
||||
import type { CharacterProfile, PlayerSession } from "../app/types";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { arenaAiProfile } from "./arenaRules";
|
||||
import { ARENA_STAGES } from "./arenaStages";
|
||||
import { createArenaPractice } from "./arenaPractice";
|
||||
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
|
||||
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
|
||||
|
||||
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
|
||||
|
||||
interface ArenaStoreState {
|
||||
mode: ArenaModeSelection;
|
||||
bracket: "2v2" | "3v3";
|
||||
ruleset: "wow" | "coa" | "rom";
|
||||
stageId: string;
|
||||
botDifficulty: number;
|
||||
selectedLoadoutSlot: number;
|
||||
remote: ArenaStateResponse | null;
|
||||
queue: ArenaQueueState;
|
||||
snapshot: ArenaAuthoritativeSnapshot | null;
|
||||
result: ArenaResult | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
socket: WebSocket | null;
|
||||
practice: ReturnType<typeof createArenaPractice> | null;
|
||||
sequence: number;
|
||||
configure: (patch: Partial<Pick<ArenaStoreState, "mode" | "bracket" | "ruleset" | "stageId" | "botDifficulty" | "selectedLoadoutSlot">>) => void;
|
||||
load: (session: PlayerSession | null, character: CharacterProfile) => Promise<void>;
|
||||
saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise<boolean>;
|
||||
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
|
||||
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
|
||||
ready: (session: PlayerSession) => Promise<void>;
|
||||
leave: (session: PlayerSession | null) => Promise<void>;
|
||||
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
|
||||
resetResult: () => void;
|
||||
}
|
||||
|
||||
function messageFor(error: unknown) {
|
||||
return error instanceof Error ? error.message : "The arena request could not be completed.";
|
||||
}
|
||||
|
||||
function rulesetFor(character: CharacterProfile): "wow" | "coa" | "rom" {
|
||||
return character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
|
||||
}
|
||||
|
||||
function defaultSpec(character: CharacterProfile, preferredRole: "healer" | "damage" = "healer") {
|
||||
if (character.categoryId === "rom") return romGearProfile(character.classId, preferredRole);
|
||||
const selected = specializationById(character.specializationId);
|
||||
if (selected && selected.role !== "tank") return selected;
|
||||
return specializationsForClass(character.classId).find((spec) => spec.role === preferredRole)
|
||||
?? specializationsForClass(character.classId).find((spec) => spec.role !== "tank")
|
||||
?? null;
|
||||
}
|
||||
|
||||
function handleSocketMessage(raw: unknown) {
|
||||
const message = raw as { type?: string; snapshot?: ArenaAuthoritativeSnapshot; result?: ArenaResult; deadline?: number; matchId?: string; message?: string };
|
||||
if (message.type === "snapshot" && message.snapshot) useArenaStore.setState({ snapshot: message.snapshot });
|
||||
if (message.type === "ready-check") useArenaStore.setState((current) => ({ queue: { ...current.queue, status: "ready-check", readyDeadline: message.deadline, matchId: message.matchId } }));
|
||||
if (message.type === "match-start" && message.snapshot) {
|
||||
useArenaStore.setState({ snapshot: message.snapshot, queue: { status: "matched", matchId: message.matchId }, loading: false });
|
||||
useShellStore.setState({ phase: "arena-game" });
|
||||
}
|
||||
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
|
||||
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
|
||||
}
|
||||
|
||||
async function connectArenaSocket(session: PlayerSession) {
|
||||
const existing = useArenaStore.getState().socket;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return existing;
|
||||
const socket = await openArenaSocket(session, handleSocketMessage, () => {
|
||||
const current = useArenaStore.getState();
|
||||
const error = current.queue.status === "matched"
|
||||
? "Arena connection closed; your character is now controlled by a bot."
|
||||
: current.queue.status !== "idle" ? "Live arena updates disconnected. Reopen Arenas to reconnect." : current.error;
|
||||
useArenaStore.setState({ socket: null, error });
|
||||
});
|
||||
useArenaStore.setState({ socket });
|
||||
return socket;
|
||||
}
|
||||
|
||||
export const useArenaStore = create<ArenaStoreState>((set, get) => ({
|
||||
mode: "offline-practice",
|
||||
bracket: "2v2",
|
||||
ruleset: "wow",
|
||||
stageId: "warnorken-arena",
|
||||
botDifficulty: 1000,
|
||||
selectedLoadoutSlot: 1,
|
||||
remote: null,
|
||||
queue: { status: "idle" },
|
||||
snapshot: null,
|
||||
result: null,
|
||||
loading: false,
|
||||
error: "",
|
||||
socket: null,
|
||||
practice: null,
|
||||
sequence: 0,
|
||||
|
||||
configure: (patch) => set(patch),
|
||||
|
||||
load: async (session, character) => {
|
||||
get().practice?.stop();
|
||||
get().socket?.close();
|
||||
const ruleset = rulesetFor(character);
|
||||
set({ ruleset, loading: Boolean(session?.accessToken), error: "", snapshot: null, result: null, practice: null, socket: null });
|
||||
if (!session?.accessToken) {
|
||||
set({ remote: null, queue: { status: "idle" }, loading: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const remote = await fetchArenaState(session, character.id);
|
||||
set({ remote, queue: remote.queue, loading: false });
|
||||
await connectArenaSocket(session);
|
||||
} catch (error) {
|
||||
set({ loading: false, error: messageFor(error) });
|
||||
}
|
||||
},
|
||||
|
||||
saveLoadout: async (session, character, input) => {
|
||||
const spec = character.categoryId === "rom"
|
||||
? (["healer", "damage"] as const).map((role) => romGearProfile(character.classId, role)).find((entry) => entry.id === input.specializationId)
|
||||
: specializationById(input.specializationId);
|
||||
if (!spec || spec.classId !== character.classId || spec.role === "tank") {
|
||||
set({ error: "Rated loadouts require a healer or damage specialization from this character's class." });
|
||||
return false;
|
||||
}
|
||||
set({ loading: true, error: "" });
|
||||
try {
|
||||
await putRemoteLoadout(session, character.id, input.slot, {
|
||||
name: input.name,
|
||||
classId: character.classId,
|
||||
specializationId: spec.id,
|
||||
role: spec.role,
|
||||
ruleset: rulesetFor(character),
|
||||
talents: character.talentRanks,
|
||||
actions: Object.fromEntries(Object.entries(character.actionBindings).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
|
||||
});
|
||||
const remote = await fetchArenaState(session, character.id);
|
||||
set({ remote, selectedLoadoutSlot: input.slot, loading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: messageFor(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteLoadout: async (session, characterId, slot) => {
|
||||
set({ loading: true, error: "" });
|
||||
try {
|
||||
await deleteRemoteLoadout(session, characterId, slot);
|
||||
const remote = await fetchArenaState(session, characterId);
|
||||
set({ remote, selectedLoadoutSlot: remote.loadouts[0]?.slot ?? 1, loading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: messageFor(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
queueMatch: async (session, character) => {
|
||||
const state = get();
|
||||
const validatedStages = ARENA_STAGES.filter((entry) => entry.validation.status === "validated");
|
||||
const stage = state.stageId === "random"
|
||||
? validatedStages[Math.floor(Math.random() * validatedStages.length)]
|
||||
: ARENA_STAGES.find((entry) => entry.id === state.stageId) ?? ARENA_STAGES[0];
|
||||
const fallback = defaultSpec(character);
|
||||
if (!stage || !fallback) {
|
||||
set({ error: "This character has no arena-compatible specialization." });
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "offline-practice") {
|
||||
const practice = createArenaPractice({
|
||||
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
|
||||
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
|
||||
onSnapshot: (snapshot) => set({ snapshot }),
|
||||
onResult: (result) => set({ result }),
|
||||
});
|
||||
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
|
||||
useShellStore.setState({ phase: "arena-game" });
|
||||
return true;
|
||||
}
|
||||
if (!session?.accessToken) {
|
||||
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
|
||||
return false;
|
||||
}
|
||||
let loadout = state.remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
|
||||
if (!loadout) {
|
||||
const saved = await get().saveLoadout(session, character, { slot: state.selectedLoadoutSlot, name: `${fallback.displayName} PvP`, specializationId: fallback.id });
|
||||
if (!saved) return false;
|
||||
loadout = get().remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
|
||||
}
|
||||
if (!loadout) return false;
|
||||
set({ loading: true, error: "", result: null });
|
||||
try {
|
||||
const socket = await connectArenaSocket(session);
|
||||
const queue = await enterArenaQueue(session, {
|
||||
characterId: character.id, loadoutSlot: loadout.slot, bracket: state.bracket,
|
||||
mode: state.mode, ruleset: state.ruleset,
|
||||
botDifficulty: state.botDifficulty,
|
||||
});
|
||||
set({ socket, queue, loading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: messageFor(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
ready: async (session) => {
|
||||
try { set({ queue: await acceptArenaReadyCheck(session), error: "" }); }
|
||||
catch (error) { set({ error: messageFor(error) }); }
|
||||
},
|
||||
|
||||
leave: async (session) => {
|
||||
get().practice?.stop();
|
||||
get().socket?.close();
|
||||
if (session?.accessToken && get().queue.status !== "idle") await leaveArenaQueue(session).catch(() => undefined);
|
||||
set({ queue: { status: "idle" }, snapshot: null, result: null, practice: null, socket: null, error: "" });
|
||||
},
|
||||
|
||||
intent: (intent) => {
|
||||
const sequence = get().sequence + 1;
|
||||
const message = { ...intent, sequence } as ArenaCombatIntent;
|
||||
set({ sequence });
|
||||
get().practice?.intent(message);
|
||||
const socket = get().socket;
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
},
|
||||
|
||||
resetResult: () => set({ result: null }),
|
||||
}));
|
||||
|
||||
export function currentArenaAiTitle() {
|
||||
return arenaAiProfile(useArenaStore.getState().botDifficulty).title;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
|
||||
|
||||
export type Vec3Tuple = readonly [number, number, number];
|
||||
export type ArenaRole = "healer" | "damage";
|
||||
export type ArenaPhase = "queue" | "ready-check" | "preparation" | "active" | "finished" | "void";
|
||||
|
||||
export interface ArenaStageAsset {
|
||||
id: "visual" | "collision" | "navigation";
|
||||
url: string;
|
||||
checksum?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
export interface ArenaStageValidation {
|
||||
status: "source-ready" | "repair-required" | "validated";
|
||||
traversal: boolean;
|
||||
collision: boolean;
|
||||
spawnSafety: boolean;
|
||||
lineOfSight: boolean;
|
||||
performance: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ArenaLineOfSightObject {
|
||||
id: string;
|
||||
shape: "box" | "cylinder";
|
||||
position: Vec3Tuple;
|
||||
size: Vec3Tuple;
|
||||
dynamicObjectId?: string;
|
||||
}
|
||||
|
||||
export interface ArenaDynamicObjectDefinition {
|
||||
id: string;
|
||||
kind: "pipe-knockback" | "waterfall" | "starting-elevators" | "fire-cycle" | "moving-pillars";
|
||||
initialDelayMs?: number;
|
||||
initialDelayRangeMs?: readonly [number, number];
|
||||
runtimeStartMs?: number;
|
||||
warningMs: number;
|
||||
activeMs: number;
|
||||
cycleMs: number;
|
||||
}
|
||||
|
||||
export interface ArenaStageManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceMap: string;
|
||||
mapId: number;
|
||||
assetBaseUrl: string;
|
||||
assets: readonly ArenaStageAsset[];
|
||||
bounds: { min: Vec3Tuple; max: Vec3Tuple };
|
||||
teamSpawns: readonly [Vec3Tuple, Vec3Tuple];
|
||||
preparationGates: readonly [Vec3Tuple, Vec3Tuple];
|
||||
spectatorPositions: readonly [Vec3Tuple, Vec3Tuple];
|
||||
shadowSight: readonly [Vec3Tuple, Vec3Tuple];
|
||||
lineOfSight: readonly ArenaLineOfSightObject[];
|
||||
dynamicObjects: readonly ArenaDynamicObjectDefinition[];
|
||||
validation: ArenaStageValidation;
|
||||
}
|
||||
|
||||
export interface ArenaLoadout {
|
||||
slot: number;
|
||||
name: string;
|
||||
characterId: string;
|
||||
classId: string;
|
||||
specializationId: string;
|
||||
role: ArenaRole;
|
||||
ruleset: ArenaRuleset;
|
||||
level: 80;
|
||||
talents: Record<string, number>;
|
||||
actions: Record<string, string>;
|
||||
version: number;
|
||||
selected: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ArenaQueueRequest {
|
||||
characterId: string;
|
||||
loadoutSlot: number;
|
||||
bracket: ArenaBracket;
|
||||
mode: ArenaMode;
|
||||
ruleset: ArenaRuleset;
|
||||
botDifficulty?: number;
|
||||
groupMembers?: readonly { accountId: string; characterId: string; loadoutSlot: number }[];
|
||||
}
|
||||
|
||||
export interface ArenaQueueState {
|
||||
status: "idle" | "queued" | "ready-check" | "matched";
|
||||
ticketId?: string;
|
||||
joinedAt?: number;
|
||||
searchRange?: number;
|
||||
readyDeadline?: number;
|
||||
matchId?: string;
|
||||
lockedLoadout?: ArenaLoadout;
|
||||
}
|
||||
|
||||
export interface ArenaCombatIntent {
|
||||
sequence: number;
|
||||
type: "movement" | "facing" | "target" | "ability";
|
||||
movement?: { x: number; z: number };
|
||||
facing?: number;
|
||||
targetActorId?: string;
|
||||
abilityId?: string;
|
||||
clientTick?: number;
|
||||
}
|
||||
|
||||
export interface ArenaActorSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
team: 0 | 1;
|
||||
role: ArenaRole;
|
||||
isBot: boolean;
|
||||
disconnected: boolean;
|
||||
alive: boolean;
|
||||
position: Vec3Tuple;
|
||||
facing: number;
|
||||
health: number;
|
||||
maxHealth: number;
|
||||
resource: number;
|
||||
maxResource: number;
|
||||
targetActorId: string | null;
|
||||
cooldowns: Record<string, number>;
|
||||
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
|
||||
}
|
||||
|
||||
export interface ArenaDynamicObjectSnapshot {
|
||||
id: string;
|
||||
state: "closed" | "warning" | "active" | "open" | "raised" | "moving" | "lowered";
|
||||
progress: number;
|
||||
collisionEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ArenaAuthoritativeSnapshot {
|
||||
matchId: string;
|
||||
tick: number;
|
||||
serverTime: number;
|
||||
phase: ArenaPhase;
|
||||
phaseEndsAt: number | null;
|
||||
stageId: string;
|
||||
bracket: ArenaBracket;
|
||||
mode: ArenaMode;
|
||||
ruleset: ArenaRuleset;
|
||||
dampening: number;
|
||||
actors: readonly ArenaActorSnapshot[];
|
||||
dynamicObjects: readonly ArenaDynamicObjectSnapshot[];
|
||||
acknowledgedSequence?: number;
|
||||
controlledActorId?: string;
|
||||
}
|
||||
|
||||
export interface ArenaRatingUpdate {
|
||||
characterId: string;
|
||||
bracket: ArenaBracket;
|
||||
before: number;
|
||||
delta: number;
|
||||
after: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ArenaResult {
|
||||
matchId: string;
|
||||
winnerTeam: 0 | 1 | null;
|
||||
draw: boolean;
|
||||
reason: "elimination" | "simultaneous-elimination" | "void";
|
||||
ratingUpdates: readonly ArenaRatingUpdate[];
|
||||
}
|
||||
+352
@@ -3094,3 +3094,355 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
@media (max-height: 560px) and (min-width: 601px) {
|
||||
.gm-review-card { top: auto; bottom: max(62px, calc(env(safe-area-inset-bottom) + 50px)); }
|
||||
}
|
||||
|
||||
/* Rated arenas */
|
||||
.arena-select-screen {
|
||||
display: grid;
|
||||
grid-template: 4.4rem 1fr 2.6rem / 17.5rem 1fr;
|
||||
min-height: 100%;
|
||||
color: #eef5e8;
|
||||
}
|
||||
|
||||
.arena-select-header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .65rem 1rem;
|
||||
border-bottom: 1px solid rgba(205, 223, 186, .18);
|
||||
background: rgba(6, 15, 12, .86);
|
||||
}
|
||||
|
||||
.arena-select-header > div,
|
||||
.arena-select-header > p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .7rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.arena-select-header p {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: .1rem;
|
||||
}
|
||||
|
||||
.arena-select-sidebar {
|
||||
padding: .9rem;
|
||||
overflow: auto;
|
||||
border-right: 1px solid rgba(205, 223, 186, .16);
|
||||
background: rgba(8, 20, 16, .88);
|
||||
}
|
||||
|
||||
.arena-segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: .25rem;
|
||||
margin: .35rem 0 1rem;
|
||||
}
|
||||
|
||||
.arena-segmented:first-of-type {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.arena-segmented button,
|
||||
.arena-mode-list button,
|
||||
.arena-stage-grid button,
|
||||
.arena-loadout-editor button,
|
||||
.arena-loadout-editor input,
|
||||
.arena-loadout-editor select {
|
||||
border: 1px solid rgba(198, 219, 177, .2);
|
||||
color: inherit;
|
||||
background: rgba(20, 38, 30, .9);
|
||||
}
|
||||
|
||||
.arena-segmented button {
|
||||
padding: .55rem .3rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arena-segmented button.is-active,
|
||||
.arena-mode-list button.is-active,
|
||||
.arena-stage-grid button.is-active {
|
||||
border-color: #d8b962;
|
||||
background: linear-gradient(135deg, rgba(127, 102, 38, .65), rgba(29, 57, 42, .9));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 232, 154, .16);
|
||||
}
|
||||
|
||||
.arena-mode-list {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
margin: .35rem 0 1rem;
|
||||
}
|
||||
|
||||
.arena-mode-list button {
|
||||
display: grid;
|
||||
gap: .18rem;
|
||||
padding: .6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.arena-mode-list small,
|
||||
.arena-stage-grid small {
|
||||
color: #aeb9ab;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.arena-select-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(15rem, .65fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
background: radial-gradient(circle at 25% 20%, rgba(54, 88, 59, .28), transparent 45%);
|
||||
}
|
||||
|
||||
.arena-stage-browser,
|
||||
.arena-configuration {
|
||||
min-width: 0;
|
||||
padding: .9rem;
|
||||
border: 1px solid rgba(205, 223, 186, .16);
|
||||
background: rgba(8, 18, 14, .76);
|
||||
}
|
||||
|
||||
.arena-stage-browser > header,
|
||||
.arena-configuration > header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: .6rem;
|
||||
}
|
||||
|
||||
.arena-stage-browser h1,
|
||||
.arena-configuration h2 {
|
||||
margin: .12rem 0 .65rem;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.arena-status {
|
||||
padding: .28rem .45rem;
|
||||
border: 1px solid currentColor;
|
||||
color: #d8b962;
|
||||
font-size: .65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arena-status--validated { color: #88d49a; }
|
||||
.arena-status--repair-required { color: #dc8d73; }
|
||||
|
||||
.arena-stage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.arena-stage-grid button {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .55rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.arena-stage-grid button > b {
|
||||
color: #d8b962;
|
||||
font: 700 1rem Georgia, serif;
|
||||
}
|
||||
|
||||
.arena-stage-grid button > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.arena-stage-note {
|
||||
margin: .7rem 0 0;
|
||||
color: #c7cec2;
|
||||
font-size: .74rem;
|
||||
}
|
||||
|
||||
.arena-configuration {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .7rem;
|
||||
}
|
||||
|
||||
.arena-configuration input[type="range"] { width: 100%; accent-color: #d8b962; }
|
||||
|
||||
.arena-configuration dl {
|
||||
display: grid;
|
||||
gap: .3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.arena-configuration dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(205, 223, 186, .12);
|
||||
}
|
||||
|
||||
.arena-loadout-editor,
|
||||
.arena-loadout-editor label {
|
||||
display: grid;
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
.arena-loadout-editor {
|
||||
gap: .55rem;
|
||||
}
|
||||
|
||||
.arena-loadout-editor label {
|
||||
color: #aeb9ab;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arena-loadout-editor input,
|
||||
.arena-loadout-editor select,
|
||||
.arena-loadout-editor button {
|
||||
padding: .48rem;
|
||||
}
|
||||
|
||||
.arena-queue-button {
|
||||
display: grid;
|
||||
gap: .15rem;
|
||||
margin-top: auto;
|
||||
padding: .8rem;
|
||||
}
|
||||
|
||||
.arena-runtime {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: #f2f5ee;
|
||||
background: #07100d;
|
||||
}
|
||||
|
||||
.arena-world,
|
||||
.arena-world > div { position: absolute; inset: 0; }
|
||||
|
||||
.arena-match-header {
|
||||
position: absolute;
|
||||
top: .7rem;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
gap: .7rem;
|
||||
align-items: center;
|
||||
padding: .45rem .7rem;
|
||||
border: 1px solid rgba(225, 236, 214, .22);
|
||||
background: rgba(5, 12, 9, .82);
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.arena-match-header span,
|
||||
.arena-match-header em { color: #bac5b7; font-size: .72rem; font-style: normal; }
|
||||
|
||||
.arena-team-frames {
|
||||
position: absolute;
|
||||
top: 4.2rem;
|
||||
display: grid;
|
||||
width: min(16rem, 27vw);
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
.arena-team-frames--0 { left: .7rem; }
|
||||
.arena-team-frames--1 { right: .7rem; }
|
||||
|
||||
.arena-team-frames button {
|
||||
display: grid;
|
||||
grid-template: auto .28rem / 1fr auto;
|
||||
gap: .22rem .6rem;
|
||||
padding: .42rem;
|
||||
border: 1px solid rgba(215, 230, 204, .2);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: rgba(5, 12, 9, .82);
|
||||
}
|
||||
|
||||
.arena-team-frames--1 button { text-align: right; }
|
||||
.arena-team-frames button.is-dead { opacity: .38; filter: grayscale(1); }
|
||||
.arena-team-frames button > span { display: grid; }
|
||||
.arena-team-frames button small { color: #acb8aa; }
|
||||
.arena-team-frames button i { grid-column: 1 / -1; overflow: hidden; background: #241b1b; }
|
||||
.arena-team-frames button i b { display: block; height: 100%; background: #54a55d; transition: width .1s linear; }
|
||||
.arena-team-frames button em { font-style: normal; }
|
||||
|
||||
.arena-countdown {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 13rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(220, 190, 99, .5);
|
||||
background: rgba(6, 13, 10, .88);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.arena-countdown strong { color: #e1c46f; font: 700 3rem Georgia, serif; }
|
||||
|
||||
.arena-action-bar {
|
||||
position: absolute;
|
||||
bottom: .8rem;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
gap: .3rem;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.arena-action-bar button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 4.2rem;
|
||||
min-height: 3.6rem;
|
||||
border: 1px solid rgba(221, 232, 210, .25);
|
||||
color: inherit;
|
||||
background: rgba(5, 12, 9, .88);
|
||||
}
|
||||
|
||||
.arena-action-bar button b { color: #dfc16b; }
|
||||
.arena-action-bar button span { font-size: .65rem; text-transform: capitalize; }
|
||||
|
||||
.arena-exit {
|
||||
position: absolute;
|
||||
right: .7rem;
|
||||
bottom: .8rem;
|
||||
padding: .5rem;
|
||||
border: 1px solid rgba(221, 232, 210, .2);
|
||||
color: #d2d9cd;
|
||||
background: rgba(5, 12, 9, .8);
|
||||
}
|
||||
|
||||
.arena-result-panel {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: min(27rem, 85vw);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #c4a95d;
|
||||
text-align: center;
|
||||
background: rgba(7, 15, 11, .96);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 1rem 5rem rgba(0, 0, 0, .55);
|
||||
}
|
||||
|
||||
.arena-result-panel h1 { margin: .2rem 0 .7rem; color: #e0c26c; font: 700 2.5rem Georgia, serif; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.arena-select-screen { grid-template: auto auto 1fr auto / 1fr; }
|
||||
.arena-select-header { grid-column: 1; }
|
||||
.arena-select-sidebar { border-right: 0; border-bottom: 1px solid rgba(205, 223, 186, .16); }
|
||||
.arena-select-main { grid-template-columns: 1fr; }
|
||||
.arena-team-frames { width: 42vw; }
|
||||
.arena-match-header { left: .55rem; gap: .35rem; transform: none; }
|
||||
.arena-match-header span { display: none; }
|
||||
.arena-action-bar { bottom: .55rem; gap: .2rem; }
|
||||
.arena-action-bar button { width: 3rem; min-height: 3.25rem; }
|
||||
.arena-exit { inset: .55rem .55rem auto auto; max-width: 5.4rem; font-size: .65rem; }
|
||||
}
|
||||
@@ -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