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 ( <> {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 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, player }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType; player: RefObject }) { const other = useRef(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(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 ( { if (event.button === 0) useArenaStore.getState().intent({ type: "target", targetActorId: actor.id }); }}> {(local && character) || actor.avatar ? ( } /> ) : ( )} ); } 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; movement: RefObject<[number, number]>; detailedAsset: boolean; blocked: boolean; onStageReady: () => void; onStageError: (error: unknown) => void; groundTarget: string | null; groundPosition: RefObject; controller: boolean }) { const snapshot = useArenaStore((state) => state.snapshot); const player = useRef(null); const environment = useRef(null); const initialCamera = useMemo(() => arenaCameraPose(localActor?.position ?? stage.teamSpawns[0], arenaSpawnYaw(stage, localActor?.team ?? 0)).position, [stage]); return ( 3D arena unavailableThis device could not start WebGL. Return to Arena Command and try another renderer.} > {detailedAsset ? ( }> ) : } {actors.map((actor) => )} {snapshot && } {groundTarget && localActor && } ); } 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 }) { const snapshot = useArenaStore((state) => state.snapshot); const selected = snapshot?.actors.find((actor) => actor.id === snapshot.controlledActorId)?.targetActorId; 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 = 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(null); const groundPosition = useRef([0, 0, 0]); useArenaCombatView(character, localActor); const [actionLayer, setActionLayer] = useState("primary"); const [inputMode, setInputMode] = useState<"keyboard" | "gamepad">("keyboard"); 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]); 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
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 (
{ 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()}>
{stage.name}{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}%
{(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) =>
{abilityById(event.abilityId ?? "")?.name ?? event.kind}: {event.outcome === "miss" ? "Miss" : Math.round(event.effectiveAmount)} {event.absorbed > 0 ? `(${event.absorbed} absorbed)` : ""}
)}
{snapshot.phase === "preparation" &&
Gates sealed{seconds}Prepare your opening
} {character && } {groundTarget &&
Place {abilityById(groundTarget)?.name} · Click or confirm to cast · Escape to cancel
} {character && } {overlay === "pause" && !result &&

Arena menu

Your character stays in the match while this menu is open.

} {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.

}
)}
); }