t2-mapper/src/components/MapInspector.tsx

411 lines
14 KiB
TypeScript
Raw Normal View History

"use client";
import {
useState,
useEffect,
useCallback,
startTransition,
Suspense,
useRef,
lazy,
Activity,
ViewTransition,
} from "react";
import { Camera } from "three";
import { InspectorControls } from "@/src/components/InspectorControls";
import { MissionSelect } from "@/src/components/MissionSelect";
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
import { SettingsProvider } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { CamerasProvider } from "@/src/components/CamerasProvider";
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,
useFogQueryState,
useMissionQueryState,
} from "@/src/components/useQueryParams";
import { ThreeCanvas, InvalidateFunction } from "@/src/components/ThreeCanvas";
import { InputHandlers, InputProvider } from "./InputHandlers";
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";
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 LiveObserver = createLazy(
"LiveObserver",
() => import("@/src/components/LiveObserver"),
);
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"),
);
export function MapInspector() {
const [currentMission, setCurrentMission] = useMissionQueryState();
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
const clearFogEnabledOverride = useCallback(() => {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const features = useFeatures();
const { missionName, missionType } = currentMission;
const [mapInfoOpen, setMapInfoOpen] = useState(false);
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
2026-03-12 17:31:10 -07:00
const [sidebarOpen, setSidebarOpen] = useState(false);
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();
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 17:31:10 -07:00
[clearFogEnabledOverride, setCurrentMission, isTouch],
);
usePublicWindowAPI({ onChangeMission: changeMission });
const recording = useRecording();
const dataSource = useDataSource();
const hasStreamData = dataSource === "demo" || dataSource === "live";
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
// 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);
}
}, [gameStatus, isTouch]);
useEffect(() => {
if (recording && isTouch) {
setSidebarOpen(false);
}
}, [isTouch, recording]);
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>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
>
<header
className={styles.Toolbar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<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));
}}
>
{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}
>
<InspectorControls
missionName={missionName}
missionType={missionType}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenServerBrowser={
features.live ? () => setServerBrowserOpen(true) : undefined
}
onChooseMap={
hasStreamData
? () => {
setChoosingMap(true);
}
: undefined
}
onCancelChoosingMap={() => {
setChoosingMap(false);
}}
choosingMap={choosingMap}
cameraRef={cameraRef}
invalidateRef={invalidateRef}
/>
</div>
</ViewTransition>
</Activity>
<InputProvider>
<div className={styles.Content}>
<div className={styles.ThreeView}>
<ThreeCanvas
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
onCreated={(state) => {
cameraRef.current = state.camera;
invalidateRef.current = state.invalidate;
}}
>
<TickProvider>
<CamerasProvider>
<InputHandlers />
<AudioProvider>
<SceneLighting />
<Suspense>
<EntityScene />
</Suspense>
<ObserverCamera />
<AudioEnabled>
<ChatSoundPlayer />
</AudioEnabled>
<DebugEnabled>
<DebugElements />
</DebugEnabled>
{recording ? (
<Suspense>
<StreamingController recording={recording} />
</Suspense>
) : null}
{!hasStreamData ? (
<Suspense>
<Mission
key={`${missionName}~${missionType}`}
name={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
/>
</Suspense>
) : null}
{hasLiveAdapter ? (
<Suspense>
<LiveObserver />
</Suspense>
) : null}
</AudioProvider>
</CamerasProvider>
</TickProvider>
</ThreeCanvas>
</div>
{hasStreamData ? (
<Suspense>
<PlayerHUD />
</Suspense>
) : null}
<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>
) : null}
</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}
</SettingsProvider>
</RecordingProvider>
</main>
);
}