Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+128 -121
View File
@@ -1,6 +1,6 @@
import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
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";
@@ -8,10 +8,27 @@ 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 } from "./game/arenaCamera";
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;
@@ -37,7 +54,7 @@ function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onRea
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} floorOnly />
<ArenaNavigationSurface stageId={stage.id} />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
@@ -56,53 +73,6 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
return <Clone object={gltf.scene} />;
}
function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
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;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return (
<group>
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</mesh>
{!floorOnly && (
<>
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
{[
[centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
[centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
[stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
[stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
<meshStandardMaterial color="#26342d" roughness={0.88} />
</mesh>
))}
{stage.teamSpawns.map((spawn, index) => (
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
</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="#6a6f5e" 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]);
@@ -154,17 +124,21 @@ function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; ob
);
}
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);
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;
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;
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(
@@ -172,9 +146,10 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
[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 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>
@@ -186,26 +161,16 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
);
}
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => {
if (!actor) return;
target.set(...pose.target);
desired.set(...pose.position);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
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: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
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"]} />
@@ -213,14 +178,19 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
<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={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
<Suspense fallback={<Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
) : <ProceduralStage stage={stage} />}
) : <Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></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} />
</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>
);
@@ -246,13 +216,16 @@ function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: Ar
}
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" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<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>{actor.health}</em>
<em>{Math.ceil(actor.health)} / {actor.maxHealth}</em>
{actor.combat?.activeCast && <small>{abilityById(actor.combat.activeCast.abilityId)?.name ?? "Casting"}</small>}
</button>
))}
</div>
@@ -269,7 +242,20 @@ export default function ArenaRuntime() {
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 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);
@@ -308,38 +294,38 @@ export default function ArenaRuntime() {
});
}, [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 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);
@@ -351,19 +337,40 @@ export default function ArenaRuntime() {
const detailedAsset = stageFailure?.stageId !== stage.id;
return (
<main className="arena-runtime">
<div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
<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>}
<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>
))}
{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">