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 { 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 } from "./game/arenaCamera"; import { arenaStageById } from "./game/arenaStages"; import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes"; import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF"; 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 ( <> {visual ? : } ); } 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 ; } 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 ( {!floorOnly && ( <> {[ [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) => ( ))} {stage.teamSpawns.map((spawn, index) => ( ))} {stage.lineOfSight.map((object) => ( {object.shape === "cylinder" ? : } ))} )} ); } 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 ( {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 ( {object.shape === "cylinder" ? : } ); })} {fire?.state === "active" && [-31, 31].map((z) => ( ))} {pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => ( ))} {elevators && stage.teamSpawns.map((spawn, index) => { const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0; return ( ); })} ); } function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) { const group = useRef(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 ( {local && character ? ( } /> ) : ( )} ); } 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; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) { return ( 3D arena unavailableThis device could not start WebGL. Return to Arena Command and try another renderer.} > {detailedAsset ? ( }> ) : } {actors.map((actor) => )} ); } 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 (
{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`} {fallbackReason ? ( The detailed arena asset could not be decoded, so a collision-matched fallback is active. ) : ( <> Streaming arena geometry · {value}% )}
); } function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) { return (
{actors.filter((actor) => actor.team === team).map((actor) => ( ))}
); } 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 = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null; const movementRef = useRef<[number, number]>([0, 0]); const [loadedStageId, setLoadedStageId] = useState(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]); useEffect(() => { const pressed = new Set(); 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
HMEntering arenaWaiting for the authoritative match snapshot...
; const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null; const detailedAsset = stageFailure?.stageId !== stage.id; return (
{stage.name}{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}%
{snapshot.phase === "preparation" &&
Gates sealed{seconds}Prepare your opening
} {result && (

Match complete

{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}

{result.ratingUpdates.map((update) =>

{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}

)} {!result.ratingUpdates.length &&

Practice result — rating unchanged.

}
)}
); }