2026-03-12 16:25:04 -07:00
|
|
|
"use client";
|
|
|
|
|
import {
|
|
|
|
|
useState,
|
|
|
|
|
useEffect,
|
|
|
|
|
useCallback,
|
|
|
|
|
startTransition,
|
|
|
|
|
Suspense,
|
|
|
|
|
useRef,
|
|
|
|
|
lazy,
|
|
|
|
|
Activity,
|
2026-03-13 23:00:08 -07:00
|
|
|
ReactNode,
|
|
|
|
|
// ViewTransition,
|
2026-03-12 16:25:04 -07:00
|
|
|
} from "react";
|
|
|
|
|
import { Camera } from "three";
|
|
|
|
|
import { InspectorControls } from "@/src/components/InspectorControls";
|
|
|
|
|
import { MissionSelect } from "@/src/components/MissionSelect";
|
|
|
|
|
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
|
2026-03-13 11:08:11 -07:00
|
|
|
import { useSettings } from "@/src/components/SettingsProvider";
|
2026-03-12 16:25:04 -07:00
|
|
|
import { ObserverCamera } from "@/src/components/ObserverCamera";
|
|
|
|
|
import { AudioProvider } from "@/src/components/AudioContext";
|
|
|
|
|
import { CamerasProvider } from "@/src/components/CamerasProvider";
|
2026-03-13 11:08:11 -07:00
|
|
|
import { InputConsumer } from "./InputConsumer";
|
2026-03-12 16:25:04 -07:00
|
|
|
import {
|
|
|
|
|
RecordingProvider,
|
|
|
|
|
useRecording,
|
|
|
|
|
} from "@/src/components/RecordingProvider";
|
|
|
|
|
import { EntityScene } from "@/src/components/EntityScene";
|
|
|
|
|
import { TickProvider } from "@/src/components/TickProvider";
|
|
|
|
|
import { SceneLighting } from "@/src/components/SceneLighting";
|
|
|
|
|
import { useFeatures } from "@/src/components/FeaturesProvider";
|
|
|
|
|
import {
|
|
|
|
|
liveConnectionStore,
|
|
|
|
|
useLiveSelector,
|
|
|
|
|
} from "@/src/state/liveConnectionStore";
|
|
|
|
|
import { usePublicWindowAPI } from "@/src/components/usePublicWindowAPI";
|
|
|
|
|
import {
|
|
|
|
|
CurrentMission,
|
|
|
|
|
useMissionQueryState,
|
|
|
|
|
} from "@/src/components/useQueryParams";
|
|
|
|
|
import { ThreeCanvas, InvalidateFunction } from "@/src/components/ThreeCanvas";
|
2026-03-13 11:08:11 -07:00
|
|
|
import { InputProducers, InputProvider } from "./InputHandlers";
|
2026-03-12 16:25:04 -07:00
|
|
|
import { VisualInput } from "./VisualInput";
|
|
|
|
|
import { LoadingIndicator } from "./LoadingIndicator";
|
|
|
|
|
import { AudioEnabled } from "./AudioEnabled";
|
|
|
|
|
import { DebugEnabled } from "./DebugEnabled";
|
|
|
|
|
import { engineStore } from "../state/engineStore";
|
|
|
|
|
import {
|
|
|
|
|
gameEntityStore,
|
|
|
|
|
useDataSource,
|
|
|
|
|
useMissionName,
|
|
|
|
|
useMissionType,
|
|
|
|
|
} from "../state/gameEntityStore";
|
|
|
|
|
import { getMissionInfo } from "../manifest";
|
|
|
|
|
import {
|
|
|
|
|
LuPanelLeftClose,
|
|
|
|
|
LuPanelLeftOpen,
|
|
|
|
|
LuPanelTopClose,
|
|
|
|
|
LuPanelTopOpen,
|
|
|
|
|
} from "react-icons/lu";
|
|
|
|
|
import styles from "./MapInspector.module.css";
|
2026-03-12 17:31:10 -07:00
|
|
|
import { useTouchDevice } from "./useTouchDevice";
|
2026-03-12 16:25:04 -07:00
|
|
|
|
2026-03-13 23:00:08 -07:00
|
|
|
function ViewTransition({ children }: { children: ReactNode }) {
|
|
|
|
|
return children;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
function createLazy(
|
|
|
|
|
name: string,
|
|
|
|
|
loader: () => Promise<{
|
|
|
|
|
[name]: React.ComponentType<any>;
|
|
|
|
|
}>,
|
|
|
|
|
) {
|
|
|
|
|
return lazy(() => loader().then((mod) => ({ default: mod[name] })));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const StreamingController = createLazy(
|
|
|
|
|
"StreamingController",
|
|
|
|
|
() => import("@/src/components/StreamingController"),
|
|
|
|
|
);
|
|
|
|
|
const DemoPlaybackControls = createLazy(
|
|
|
|
|
"DemoPlaybackControls",
|
|
|
|
|
() => import("@/src/components/DemoPlaybackControls"),
|
|
|
|
|
);
|
|
|
|
|
const DebugElements = createLazy(
|
|
|
|
|
"DebugElements",
|
|
|
|
|
() => import("@/src/components/DebugElements"),
|
|
|
|
|
);
|
|
|
|
|
const Mission = createLazy("Mission", () => import("@/src/components/Mission"));
|
|
|
|
|
const ChatSoundPlayer = createLazy(
|
|
|
|
|
"ChatSoundPlayer",
|
|
|
|
|
() => import("@/src/components/ChatSoundPlayer"),
|
|
|
|
|
);
|
|
|
|
|
const PlayerHUD = createLazy(
|
|
|
|
|
"PlayerHUD",
|
|
|
|
|
() => import("@/src/components/PlayerHUD"),
|
|
|
|
|
);
|
|
|
|
|
const MapInfoDialog = createLazy(
|
|
|
|
|
"MapInfoDialog",
|
|
|
|
|
() => import("@/src/components/MapInfoDialog"),
|
|
|
|
|
);
|
|
|
|
|
const ServerBrowser = createLazy(
|
|
|
|
|
"ServerBrowser",
|
|
|
|
|
() => import("@/src/components/ServerBrowser"),
|
|
|
|
|
);
|
2026-03-14 17:12:37 -07:00
|
|
|
const ScoreScreen = createLazy(
|
|
|
|
|
"ScoreScreen",
|
|
|
|
|
() => import("@/src/components/ScoreScreen"),
|
|
|
|
|
);
|
2026-03-12 16:25:04 -07:00
|
|
|
|
|
|
|
|
export function MapInspector() {
|
|
|
|
|
const [currentMission, setCurrentMission] = useMissionQueryState();
|
|
|
|
|
const features = useFeatures();
|
2026-03-13 11:08:11 -07:00
|
|
|
const { clearFogEnabledOverride, sidebarOpen, setSidebarOpen } =
|
|
|
|
|
useSettings();
|
2026-03-12 16:25:04 -07:00
|
|
|
const { missionName, missionType } = currentMission;
|
|
|
|
|
const [mapInfoOpen, setMapInfoOpen] = useState(false);
|
|
|
|
|
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
|
2026-03-14 17:12:37 -07:00
|
|
|
const [scoreScreenOpen, setScoreScreenOpen] = useState(false);
|
2026-03-12 16:25:04 -07:00
|
|
|
const [choosingMap, setChoosingMap] = useState(false);
|
|
|
|
|
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
|
|
|
|
|
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
|
2026-03-12 17:31:10 -07:00
|
|
|
const isTouch = useTouchDevice();
|
2026-03-12 16:25:04 -07:00
|
|
|
|
|
|
|
|
const changeMission = useCallback(
|
|
|
|
|
(mission: CurrentMission) => {
|
|
|
|
|
window.location.hash = "";
|
|
|
|
|
clearFogEnabledOverride();
|
|
|
|
|
setChoosingMap(false);
|
|
|
|
|
// Disconnect from any live server, unload any active recording, and
|
|
|
|
|
// clear stream state before loading the new mission in map mode.
|
|
|
|
|
const liveState = liveConnectionStore.getState();
|
|
|
|
|
liveState.disconnectServer();
|
|
|
|
|
liveState.disconnectRelay();
|
|
|
|
|
engineStore.getState().setRecording(null);
|
|
|
|
|
gameEntityStore.getState().endStreaming();
|
|
|
|
|
setCurrentMission(mission);
|
2026-03-12 17:31:10 -07:00
|
|
|
if (isTouch) {
|
|
|
|
|
setSidebarOpen(false);
|
|
|
|
|
}
|
2026-03-12 16:25:04 -07:00
|
|
|
},
|
2026-03-13 11:08:11 -07:00
|
|
|
[clearFogEnabledOverride, setCurrentMission, isTouch, setSidebarOpen],
|
2026-03-12 16:25:04 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
usePublicWindowAPI({ onChangeMission: changeMission });
|
|
|
|
|
|
|
|
|
|
const recording = useRecording();
|
|
|
|
|
const dataSource = useDataSource();
|
|
|
|
|
const hasStreamData = dataSource === "demo" || dataSource === "live";
|
|
|
|
|
// Sync the mission query param when streaming data provides a mission name.
|
|
|
|
|
const streamMissionName = useMissionName();
|
|
|
|
|
const streamMissionType = useMissionType();
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!hasStreamData || !streamMissionName) return;
|
|
|
|
|
try {
|
|
|
|
|
const info = getMissionInfo(streamMissionName);
|
|
|
|
|
const matchedType =
|
|
|
|
|
streamMissionType && info.missionTypes.includes(streamMissionType)
|
|
|
|
|
? streamMissionType
|
|
|
|
|
: undefined;
|
|
|
|
|
setCurrentMission({
|
|
|
|
|
missionName: streamMissionName,
|
|
|
|
|
missionType: matchedType,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Mission not in manifest — remove the query param.
|
|
|
|
|
setCurrentMission(null);
|
|
|
|
|
}
|
|
|
|
|
}, [hasStreamData, streamMissionName, streamMissionType, setCurrentMission]);
|
|
|
|
|
|
|
|
|
|
// Cancel "choosing map" when a new recording loads.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (recording) {
|
|
|
|
|
setChoosingMap(false);
|
|
|
|
|
}
|
|
|
|
|
}, [recording]);
|
|
|
|
|
|
2026-03-12 17:31:10 -07:00
|
|
|
// Close the sidebar when a live server connection is established.
|
|
|
|
|
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (gameStatus === "connected" && isTouch) {
|
|
|
|
|
setSidebarOpen(false);
|
|
|
|
|
}
|
2026-03-13 11:08:11 -07:00
|
|
|
}, [gameStatus, isTouch, setSidebarOpen]);
|
2026-03-12 17:31:10 -07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (recording && isTouch) {
|
|
|
|
|
setSidebarOpen(false);
|
|
|
|
|
}
|
2026-03-13 11:08:11 -07:00
|
|
|
}, [isTouch, recording, setSidebarOpen]);
|
2026-03-12 17:31:10 -07:00
|
|
|
|
2026-03-12 16:25:04 -07:00
|
|
|
const loadingProgress = missionLoadingProgress;
|
|
|
|
|
const isLoading = loadingProgress < 1;
|
|
|
|
|
|
|
|
|
|
// Keep the loading indicator visible briefly after reaching 100%
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
setShowLoadingIndicator(true);
|
|
|
|
|
} else {
|
|
|
|
|
const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}, [isLoading]);
|
|
|
|
|
|
|
|
|
|
const handleLoadingChange = useCallback(
|
|
|
|
|
(_loading: boolean, progress: number = 0) => {
|
|
|
|
|
setMissionLoadingProgress(progress);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const cameraRef = useRef<Camera | null>(null);
|
|
|
|
|
const invalidateRef = useRef<InvalidateFunction | null>(null);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<main className={styles.Frame}>
|
|
|
|
|
<RecordingProvider>
|
2026-03-13 11:08:11 -07:00
|
|
|
<header
|
|
|
|
|
className={styles.Toolbar}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-03-12 16:25:04 -07:00
|
|
|
>
|
2026-03-13 11:08:11 -07:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.ToggleSidebarButton}
|
|
|
|
|
data-orientation="top"
|
|
|
|
|
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
|
|
|
|
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
startTransition(() => setSidebarOpen((open) => !open));
|
|
|
|
|
}}
|
2026-03-12 16:25:04 -07:00
|
|
|
>
|
2026-03-13 11:08:11 -07:00
|
|
|
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.ToggleSidebarButton}
|
|
|
|
|
data-orientation="left"
|
|
|
|
|
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
|
|
|
|
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
startTransition(() => setSidebarOpen((open) => !open));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
|
|
|
|
|
</button>
|
|
|
|
|
<Activity mode={hasStreamData && !choosingMap ? "visible" : "hidden"}>
|
|
|
|
|
<StreamingMissionInfo />
|
|
|
|
|
</Activity>
|
|
|
|
|
<Activity mode={!hasStreamData || choosingMap ? "visible" : "hidden"}>
|
|
|
|
|
<MissionSelect
|
|
|
|
|
value={choosingMap ? "" : missionName}
|
|
|
|
|
missionType={choosingMap ? "" : missionType}
|
|
|
|
|
onChange={changeMission}
|
|
|
|
|
autoFocus={choosingMap}
|
|
|
|
|
/>
|
|
|
|
|
{choosingMap && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.CancelButton}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setChoosingMap(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</Activity>
|
|
|
|
|
</header>
|
|
|
|
|
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
|
|
|
|
|
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
|
|
|
|
|
<ViewTransition>
|
|
|
|
|
<div
|
|
|
|
|
className={styles.Sidebar}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
data-open={sidebarOpen}
|
2026-03-12 16:25:04 -07:00
|
|
|
>
|
2026-03-13 11:08:11 -07:00
|
|
|
<InspectorControls
|
|
|
|
|
missionName={missionName}
|
|
|
|
|
missionType={missionType}
|
|
|
|
|
onOpenMapInfo={() => setMapInfoOpen(true)}
|
2026-03-14 17:12:37 -07:00
|
|
|
onOpenScoreScreen={
|
|
|
|
|
hasStreamData ? () => setScoreScreenOpen(true) : undefined
|
|
|
|
|
}
|
2026-03-13 11:08:11 -07:00
|
|
|
onOpenServerBrowser={
|
|
|
|
|
features.live ? () => setServerBrowserOpen(true) : undefined
|
|
|
|
|
}
|
|
|
|
|
onChooseMap={
|
|
|
|
|
hasStreamData
|
|
|
|
|
? () => {
|
|
|
|
|
setChoosingMap(true);
|
|
|
|
|
}
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
onCancelChoosingMap={() => {
|
|
|
|
|
setChoosingMap(false);
|
|
|
|
|
}}
|
|
|
|
|
choosingMap={choosingMap}
|
|
|
|
|
cameraRef={cameraRef}
|
|
|
|
|
invalidateRef={invalidateRef}
|
2026-03-12 16:25:04 -07:00
|
|
|
/>
|
2026-03-13 11:08:11 -07:00
|
|
|
</div>
|
|
|
|
|
</ViewTransition>
|
|
|
|
|
</Activity>
|
|
|
|
|
<InputProvider>
|
|
|
|
|
<div className={styles.Content}>
|
|
|
|
|
<div className={styles.ThreeView}>
|
|
|
|
|
<ThreeCanvas
|
2026-03-14 17:12:37 -07:00
|
|
|
dpr={
|
|
|
|
|
mapInfoOpen || serverBrowserOpen || scoreScreenOpen
|
|
|
|
|
? 0.25
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
2026-03-13 11:08:11 -07:00
|
|
|
onCreated={(state) => {
|
|
|
|
|
cameraRef.current = state.camera;
|
|
|
|
|
invalidateRef.current = state.invalidate;
|
|
|
|
|
}}
|
2026-03-12 16:25:04 -07:00
|
|
|
>
|
2026-03-13 11:08:11 -07:00
|
|
|
<TickProvider>
|
|
|
|
|
<CamerasProvider>
|
|
|
|
|
<InputProducers />
|
|
|
|
|
<AudioProvider>
|
|
|
|
|
<SceneLighting />
|
|
|
|
|
<Suspense>
|
|
|
|
|
<EntityScene />
|
|
|
|
|
</Suspense>
|
|
|
|
|
<ObserverCamera />
|
|
|
|
|
<AudioEnabled>
|
|
|
|
|
<ChatSoundPlayer />
|
|
|
|
|
</AudioEnabled>
|
|
|
|
|
<DebugEnabled>
|
|
|
|
|
<DebugElements />
|
|
|
|
|
</DebugEnabled>
|
|
|
|
|
{recording ? (
|
2026-03-12 16:25:04 -07:00
|
|
|
<Suspense>
|
2026-03-13 11:08:11 -07:00
|
|
|
<StreamingController recording={recording} />
|
2026-03-12 16:25:04 -07:00
|
|
|
</Suspense>
|
2026-03-13 11:08:11 -07:00
|
|
|
) : null}
|
|
|
|
|
{!hasStreamData ? (
|
|
|
|
|
<Suspense>
|
|
|
|
|
<Mission
|
|
|
|
|
key={`${missionName}~${missionType}`}
|
|
|
|
|
name={missionName}
|
|
|
|
|
missionType={missionType}
|
|
|
|
|
onLoadingChange={handleLoadingChange}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
) : null}
|
|
|
|
|
<InputConsumer />
|
|
|
|
|
</AudioProvider>
|
|
|
|
|
</CamerasProvider>
|
|
|
|
|
</TickProvider>
|
|
|
|
|
</ThreeCanvas>
|
2026-03-12 16:25:04 -07:00
|
|
|
</div>
|
2026-03-14 17:12:37 -07:00
|
|
|
{hasStreamData && !scoreScreenOpen ? (
|
2026-03-12 16:25:04 -07:00
|
|
|
<Suspense>
|
2026-03-13 11:08:11 -07:00
|
|
|
<PlayerHUD />
|
2026-03-12 16:25:04 -07:00
|
|
|
</Suspense>
|
|
|
|
|
) : null}
|
2026-03-13 11:08:11 -07:00
|
|
|
<VisualInput />
|
|
|
|
|
{showLoadingIndicator && (
|
|
|
|
|
<LoadingIndicator
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
progress={loadingProgress}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</InputProvider>
|
|
|
|
|
<footer
|
|
|
|
|
className={styles.PlayerBar}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{recording?.source === "demo" ? (
|
|
|
|
|
<Suspense>
|
|
|
|
|
<DemoPlaybackControls />
|
|
|
|
|
</Suspense>
|
2026-03-12 16:25:04 -07:00
|
|
|
) : null}
|
2026-03-13 11:08:11 -07:00
|
|
|
</footer>
|
|
|
|
|
{mapInfoOpen ? (
|
|
|
|
|
<ViewTransition>
|
|
|
|
|
<Suspense>
|
|
|
|
|
<MapInfoDialog
|
|
|
|
|
onClose={() => setMapInfoOpen(false)}
|
|
|
|
|
missionName={missionName}
|
|
|
|
|
missionType={missionType ?? ""}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
</ViewTransition>
|
|
|
|
|
) : null}
|
|
|
|
|
{serverBrowserOpen ? (
|
|
|
|
|
<ViewTransition>
|
|
|
|
|
<Suspense>
|
|
|
|
|
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
|
|
|
|
|
</Suspense>
|
|
|
|
|
</ViewTransition>
|
|
|
|
|
) : null}
|
2026-03-14 17:12:37 -07:00
|
|
|
{scoreScreenOpen ? (
|
|
|
|
|
<ViewTransition>
|
|
|
|
|
<Suspense>
|
|
|
|
|
<ScoreScreen onClose={() => setScoreScreenOpen(false)} />
|
|
|
|
|
</Suspense>
|
|
|
|
|
</ViewTransition>
|
|
|
|
|
) : null}
|
2026-03-12 16:25:04 -07:00
|
|
|
</RecordingProvider>
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|