387 lines
24 KiB
TypeScript
387 lines
24 KiB
TypeScript
import { Clone, useProgress } from "@react-three/drei";
|
|
import { Canvas, useFrame } from "@react-three/fiber";
|
|
import { Component, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
import { Group, 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 { arenaCameraPose, arenaSpawnYaw } from "./game/arenaCamera";
|
|
import { ArenaPlayerCamera } from "./scene/ArenaPlayerCamera";
|
|
import { ArenaActionBar } from "./ui/ArenaActionBar";
|
|
import { controlledArenaActor, arenaTargetActor } from "./game/arenaPlayer";
|
|
import { abilityById, abilityAtLevel } from "./game/abilityCatalog";
|
|
import type { ActionBindingLayer } from "./game/actionBindings";
|
|
import { useArenaInput } from "./game/useArenaInput";
|
|
import { beginMouseLookDrag, endMouseLookDrag, isDesktopCameraLookPointer } from "./game/inputManager";
|
|
import type { CharacterCombatAnimationEvent } from "./game/combatAnimation";
|
|
import { arenaStageById } from "./game/arenaStages";
|
|
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
|
|
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
|
|
import { ArenaCombatEffects, ArenaGroundTarget } from "./scene/ArenaCombatEffects";
|
|
import { ArenaPanels } from "./ui/ArenaPanels";
|
|
import { useArenaCombatView } from "./game/useArenaCombatView";
|
|
import { useGameStore } from "./game/store";
|
|
import { targetingRulesForAbility } from "./game/abilityRules";
|
|
import type { Vec3Tuple } from "./game/arenaTypes";
|
|
import { ArenaNavigationSurface } from "./scene/ArenaNavigationSurface";
|
|
|
|
const ArenaFallbackStage = lazy(() => import("./scene/ArenaFallbackStage"));
|
|
|
|
class ArenaStageErrorBoundary extends Component<{
|
|
readonly children: ReactNode;
|
|
readonly fallback: ReactNode;
|
|
readonly onError: (error: unknown) => void;
|
|
}, { readonly failed: boolean }> {
|
|
state = { failed: false };
|
|
|
|
static getDerivedStateFromError() {
|
|
return { failed: true };
|
|
}
|
|
|
|
componentDidCatch(error: unknown) {
|
|
this.props.onError(error);
|
|
}
|
|
|
|
render() {
|
|
return this.state.failed ? this.props.fallback : this.props.children;
|
|
}
|
|
}
|
|
|
|
function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onReady: () => void }) {
|
|
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
|
|
return (
|
|
<>
|
|
<ArenaNavigationSurface stageId={stage.id} />
|
|
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StageReady({ onReady }: { readonly onReady: () => void }) {
|
|
useEffect(onReady, [onReady]);
|
|
return null;
|
|
}
|
|
|
|
function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
|
|
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
|
|
useEffect(() => {
|
|
onReady();
|
|
}, [gltf.scene, onReady]);
|
|
return <Clone object={gltf.scene} />;
|
|
}
|
|
|
|
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, player }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; player: RefObject<Group | null> }) {
|
|
const other = useRef<Group>(null);
|
|
const group = local ? player : other;
|
|
const initial = useRef<[number, number, number]>([...actor.position]);
|
|
const target = useMemo(() => new Vector3(), []);
|
|
const movingRef = useRef(false);
|
|
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(null);
|
|
useFrame((_, delta) => {
|
|
const node = group.current;
|
|
if (!node) return;
|
|
target.set(...actor.position);
|
|
movingRef.current = node.position.distanceToSquared(target) > 0.0025;
|
|
node.position.lerp(target, Math.min(1, delta * 18));
|
|
if (Number.isFinite(actor.facing)) node.rotation.y = actor.facing;
|
|
animationEventRef.current = actor.combat?.playerAnimationEvent ?? null;
|
|
});
|
|
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} name={`arena-actor:${actor.id}`} position={initial.current} visible={actor.alive}
|
|
onPointerDown={(event) => { if (event.button === 0) useArenaStore.getState().intent({ type: "target", targetActorId: actor.id }); }}>
|
|
{(local && character) || actor.avatar ? (
|
|
<CharacterModel identity={local && character ? character : actor.avatar} equipment={local ? cosmeticEquipment : actor.visualEquipment} movingRef={movingRef} animationEventRef={animationEventRef} fallback={<ProceduralAvatar accent={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 ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, blocked, onStageReady, onStageError, groundTarget, groundPosition, controller }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; blocked: boolean; onStageReady: () => void; onStageError: (error: unknown) => void; groundTarget: string | null; groundPosition: RefObject<Vec3Tuple>; controller: boolean }) {
|
|
const snapshot = useArenaStore((state) => state.snapshot);
|
|
const player = useRef<Group>(null);
|
|
const environment = useRef<Group>(null);
|
|
const initialCamera = useMemo(() => arenaCameraPose(localActor?.position ?? stage.teamSpawns[0], arenaSpawnYaw(stage, localActor?.team ?? 0)).position, [stage]);
|
|
return (
|
|
<Canvas
|
|
shadows
|
|
dpr={[1, 1.35]}
|
|
camera={{ fov: 62, near: 0.05, far: 1200, position: initialCamera }}
|
|
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
|
|
>
|
|
<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>
|
|
<group ref={environment}>
|
|
{detailedAsset ? (
|
|
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
|
|
<Suspense fallback={<Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
|
|
</ArenaStageErrorBoundary>
|
|
) : <Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}
|
|
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
|
</group>
|
|
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} player={player} />)}
|
|
<ArenaPlayerCamera key={`${stage.id}:${localActor?.id}`} actor={localActor} stage={stage} player={player} environment={environment} movement={movement} blocked={blocked} />
|
|
{snapshot && <ArenaCombatEffects snapshot={snapshot} actorId={localActor?.id} />}
|
|
{groundTarget && localActor && <ArenaGroundTarget environment={environment} position={groundPosition} origin={localActor.position}
|
|
radius={abilityById(groundTarget)?.radius ?? 8} controller={controller} />}
|
|
</GameGltfLoaderLifecycle>
|
|
</Canvas>
|
|
);
|
|
}
|
|
|
|
function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: ArenaStageManifest; readonly ready: boolean; readonly fallbackReason: string | null }) {
|
|
const { active, progress } = useProgress();
|
|
if (ready && !active && !fallbackReason) return null;
|
|
const value = Math.max(3, Math.min(100, Math.round(progress)));
|
|
return (
|
|
<section className={`arena-asset-status${fallbackReason ? " arena-asset-status--fallback" : ""}`} role="status" aria-live="polite">
|
|
<strong>{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`}</strong>
|
|
{fallbackReason ? (
|
|
<small>The detailed arena asset could not be decoded, so a collision-matched fallback is active.</small>
|
|
) : (
|
|
<>
|
|
<span><i style={{ width: `${value}%` }} /></span>
|
|
<small>Streaming arena geometry · {value}%</small>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
|
const snapshot = useArenaStore((state) => state.snapshot);
|
|
const selected = snapshot?.actors.find((actor) => actor.id === snapshot.controlledActorId)?.targetActorId;
|
|
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" : ""} ${selected === actor.id ? "is-selected" : ""}`} 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>{Math.ceil(actor.health)} / {actor.maxHealth}</em>
|
|
{actor.combat?.activeCast && <small>{abilityById(actor.combat.activeCast.abilityId)?.name ?? "Casting"}</small>}
|
|
</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 startPractice = useArenaStore((state) => state.startPractice);
|
|
const session = useShellStore((state) => state.session);
|
|
const character = useShellStore(selectedCharacter);
|
|
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
|
|
const localActor = controlledArenaActor(snapshot, character);
|
|
const feedback = useArenaStore((state) => state.error);
|
|
const overlay = useGameStore((state) => state.overlay);
|
|
const companionOpen = useGameStore((state) => state.companionOpen);
|
|
const paused = overlay !== null || companionOpen;
|
|
const setPaused = (value: boolean | ((previous: boolean) => boolean)) => {
|
|
const next = typeof value === "function" ? value(paused) : value;
|
|
if (next) useGameStore.getState().openOverlay("pause"); else useGameStore.getState().closeOverlay();
|
|
};
|
|
const [groundTarget, setGroundTarget] = useState<string | null>(null);
|
|
const groundPosition = useRef<Vec3Tuple>([0, 0, 0]);
|
|
useArenaCombatView(character, localActor);
|
|
const [actionLayer, setActionLayer] = useState<ActionBindingLayer>("primary");
|
|
const [inputMode, setInputMode] = useState<"keyboard" | "gamepad">("keyboard");
|
|
const movementRef = useRef<[number, number]>([0, 0]);
|
|
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
|
|
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
|
|
const handleStageReady = useCallback(() => {
|
|
if (!stage) return;
|
|
setLoadedStageId(stage.id);
|
|
setStageFailure((current) => current?.stageId === stage.id ? null : current);
|
|
startPractice();
|
|
}, [stage, startPractice]);
|
|
const handleStageError = useCallback((error: unknown) => {
|
|
if (!stage) return;
|
|
setLoadedStageId(stage.id);
|
|
setStageFailure({ stageId: stage.id, reason: error instanceof Error ? error.message : "Arena asset failed to load" });
|
|
startPractice();
|
|
}, [stage, startPractice]);
|
|
|
|
useEffect(() => {
|
|
if (!stage || loadedStageId === stage.id) return;
|
|
const timeout = window.setTimeout(() => {
|
|
setLoadedStageId(stage.id);
|
|
setStageFailure({ stageId: stage.id, reason: "Arena asset loading timed out" });
|
|
startPractice();
|
|
}, 30_000);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [loadedStageId, stage, startPractice]);
|
|
|
|
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]);
|
|
|
|
const cast = useCallback((abilityId: string) => {
|
|
const state = useArenaStore.getState();
|
|
const player = controlledArenaActor(state.snapshot, character);
|
|
if (!player || useGameStore.getState().overlay || state.result) return;
|
|
const definition = abilityById(abilityId);
|
|
const ability = definition ? abilityAtLevel(definition, player.level ?? character?.level ?? 80) : null;
|
|
if (ability && targetingRulesForAbility(ability).shape === "ground" && (targetingRulesForAbility(ability).groundRange ?? 0) > 0) {
|
|
groundPosition.current = [player.position[0] + Math.sin(player.facing) * 8, player.position[1], player.position[2] + Math.cos(player.facing) * 8];
|
|
setGroundTarget(abilityId);
|
|
return;
|
|
}
|
|
let target = arenaTargetActor(state.snapshot, player.targetActorId) ?? undefined;
|
|
if (target && !target.alive && !ability?.effects.some((effect) => effect.kind === "resurrection")) target = undefined;
|
|
if (ability?.target === "self" || (ability?.target === "friendly" && target?.team !== player.team)) target = player;
|
|
if (ability?.target === "hostile" && (!target || target.team === player.team)) {
|
|
target = state.snapshot?.actors.filter((actor) => actor.alive && actor.team !== player.team).sort((a, b) => (
|
|
Math.hypot(a.position[0] - player.position[0], a.position[2] - player.position[2])
|
|
- Math.hypot(b.position[0] - player.position[0], b.position[2] - player.position[2])
|
|
))[0];
|
|
}
|
|
if (target && target.id !== player.id) state.intent({ type: "target", targetActorId: target.id });
|
|
state.intent({ type: "ability", abilityId, targetActorId: target?.id });
|
|
}, [character]);
|
|
const confirmGroundTarget = () => {
|
|
if (!groundTarget) return;
|
|
intent({ type: "ability", abilityId: groundTarget, groundPosition: groundPosition.current });
|
|
setGroundTarget(null);
|
|
};
|
|
useArenaInput({ character, blocked: paused || Boolean(result) || !localActor?.alive,
|
|
cast, togglePause: () => setPaused((value) => !value), setLayer: setActionLayer, setInputMode,
|
|
groundTargetActive: () => Boolean(groundTarget), confirmGroundTarget,
|
|
cancelGroundTarget: () => { setGroundTarget(null); intent({ type: "command", command: "cancel-cast" }); } });
|
|
|
|
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;
|
|
const detailedAsset = stageFailure?.stageId !== stage.id;
|
|
return (
|
|
<main className="arena-runtime">
|
|
<div className="arena-world" aria-label="Arena 3D view" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}
|
|
onPointerDown={(event) => {
|
|
if (event.button === 0 && groundTarget && !paused) { confirmGroundTarget(); return; }
|
|
if (paused || result || !isDesktopCameraLookPointer(event.pointerType, event.button)) return;
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
beginMouseLookDrag(event.clientX, event.clientY);
|
|
}}
|
|
onPointerUp={(event) => { if (event.button === 2) endMouseLookDrag(); }}
|
|
onLostPointerCapture={() => endMouseLookDrag()}
|
|
onContextMenu={(event) => event.preventDefault()}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} blocked={paused || Boolean(result)} onStageReady={handleStageReady} onStageError={handleStageError} groundTarget={groundTarget} groundPosition={groundPosition} controller={inputMode === "gamepad"} /></div>
|
|
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
|
|
<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} />
|
|
<div className="arena-combat-log" role="log" aria-label="Arena combat events">{(snapshot.combatEvents ?? []).filter((event) => snapshot.serverTime - event.occurredAt < 3000 && (event.sourceActorId === localActor?.id || event.targetActorId === localActor?.id) && ["damage", "healing", "interrupt"].includes(event.kind)).slice(-4).map((event) => <div key={event.id}>{abilityById(event.abilityId ?? "")?.name ?? event.kind}: {event.outcome === "miss" ? "Miss" : Math.round(event.effectiveAmount)} {event.absorbed > 0 ? `(${event.absorbed} absorbed)` : ""}</div>)}</div>
|
|
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
|
|
{character && <ArenaActionBar character={character} actor={localActor} layer={actionLayer} inputMode={inputMode}
|
|
blocked={paused || Boolean(result)} onCast={cast} feedback={feedback} />}
|
|
{groundTarget && <div className="arena-ground-hint" role="status">Place {abilityById(groundTarget)?.name} · Click or confirm to cast · Escape to cancel</div>}
|
|
{character && <ArenaPanels character={character} actor={localActor} snapshot={snapshot} stage={stage} cast={cast} />}
|
|
<nav className="arena-toolbar" aria-label="Arena character menus">
|
|
{(["spellbook", "inventory", "talents", "bindings", "keybinds", "map", "options"] as const).map((panel) => <button key={panel} type="button" onClick={() => useGameStore.getState().toggleOverlay(panel)}>{panel === "inventory" ? "Character" : panel}</button>)}
|
|
</nav>
|
|
{overlay === "pause" && !result && <section className="arena-result-panel" role="dialog" aria-label="Arena menu">
|
|
<h1>Arena menu</h1><p>Your character stays in the match while this menu is open.</p>
|
|
<button type="button" className="front-button" onClick={() => setPaused(false)}>Resume</button>
|
|
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("options")}>Options</button>
|
|
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("keybinds")}>Keybinds</button>
|
|
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("bindings")}>Assign abilities</button>
|
|
<button type="button" className="front-button" onClick={() => void exit()}>Leave arena</button>
|
|
</section>}
|
|
<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>
|
|
);
|
|
}
|