t2-mapper/app/page.tsx

179 lines
5.8 KiB
TypeScript
Raw Normal View History

2025-09-11 23:48:23 +00:00
"use client";
import { useState, useEffect, useCallback, Suspense, useRef } from "react";
import { Canvas, GLProps } from "@react-three/fiber";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap, Camera } from "three";
import { Mission } from "@/src/components/Mission";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ObserverControls } from "@/src/components/ObserverControls";
import { InspectorControls } from "@/src/components/InspectorControls";
import { SettingsProvider } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
2025-11-16 00:33:18 +00:00
import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements";
2025-11-26 22:37:49 +00:00
import { CamerasProvider } from "@/src/components/CamerasProvider";
import { getMissionList, getMissionInfo } from "@/src/manifest";
import { createParser, useQueryState } from "nuqs";
2025-11-14 06:55:58 +00:00
// Three.js has its own loaders for textures and models, but we need to load other
2025-11-14 06:55:58 +00:00
// stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient();
2025-09-11 23:48:23 +00:00
// Renderer settings to match Tribes 2's simple rendering pipeline.
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
const glSettings: GLProps = {
toneMapping: NoToneMapping,
outputColorSpace: SRGBColorSpace,
};
type CurrentMission = {
missionName: string;
missionType?: string;
};
2025-11-14 11:30:33 +00:00
const defaultMission: CurrentMission = {
missionName: "RiverDance",
missionType: "CTF",
};
const parseAsMissionWithType = createParser<CurrentMission>({
parse(query: string) {
2025-12-30 04:02:54 +00:00
const [missionName, missionType] = query.split("~");
let selectedMissionType = missionType;
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (!missionType || !availableMissionTypes.includes(missionType)) {
2025-12-30 04:02:54 +00:00
selectedMissionType = availableMissionTypes[0];
}
2025-12-30 04:02:54 +00:00
return { missionName, missionType: selectedMissionType };
},
serialize({ missionName, missionType }): string {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (availableMissionTypes.length === 1) {
return missionName;
}
return `${missionName}~${missionType}`;
},
eq(a, b) {
return a.missionName === b.missionName && a.missionType === b.missionType;
},
}).withDefault(defaultMission);
function MapInspector() {
const [currentMission, setCurrentMission] = useQueryState(
"mission",
parseAsMissionWithType,
2025-12-14 19:06:57 +00:00
);
const changeMission = useCallback(
(mission: CurrentMission) => {
window.location.hash = "";
setCurrentMission(mission);
},
[setCurrentMission],
2025-11-14 11:30:33 +00:00
);
2025-12-14 19:06:57 +00:00
const { missionName, missionType } = currentMission;
const [loadingProgress, setLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
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]);
2025-11-14 11:30:33 +00:00
useEffect(() => {
// For automation, like the t2-maps app!
window.setMissionName = (missionName: string) => {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
changeMission({
missionName,
missionType: availableMissionTypes[0],
});
};
window.getMissionList = getMissionList;
window.getMissionInfo = getMissionInfo;
return () => {
delete window.setMissionName;
delete window.getMissionList;
delete window.getMissionInfo;
};
}, [changeMission]);
2025-09-12 17:30:40 +00:00
const handleLoadingChange = useCallback(
(_loading: boolean, progress: number = 0) => {
setLoadingProgress(progress);
},
[],
);
2025-12-03 06:16:40 +00:00
const cameraRef = useRef<Camera | null>(null);
2025-09-11 23:48:23 +00:00
return (
<QueryClientProvider client={queryClient}>
<main>
<SettingsProvider>
2025-12-03 00:58:35 +00:00
<div id="canvasContainer">
{showLoadingIndicator && (
<div id="loadingIndicator" data-complete={!isLoading}>
<div className="LoadingSpinner" />
<div className="LoadingProgress">
<div
className="LoadingProgress-bar"
style={{ width: `${loadingProgress * 100}%` }}
/>
</div>
<div className="LoadingProgress-text">
{Math.round(loadingProgress * 100)}%
</div>
</div>
)}
2025-12-14 19:06:57 +00:00
<Canvas
frameloop="always"
gl={glSettings}
shadows={{ type: PCFShadowMap }}
onCreated={(state) => {
cameraRef.current = state.camera;
}}
2025-12-14 19:06:57 +00:00
>
2025-12-03 00:58:35 +00:00
<CamerasProvider>
<AudioProvider>
2025-12-03 06:16:40 +00:00
<Mission
2025-12-14 19:06:57 +00:00
key={`${missionName}~${missionType}`}
2025-12-03 06:16:40 +00:00
name={missionName}
2025-12-14 19:06:57 +00:00
missionType={missionType}
2025-12-03 06:16:40 +00:00
onLoadingChange={handleLoadingChange}
/>
2025-12-03 00:58:35 +00:00
<ObserverCamera />
<DebugElements />
<ObserverControls />
</AudioProvider>
</CamerasProvider>
</Canvas>
</div>
2025-11-14 06:55:58 +00:00
<InspectorControls
missionName={missionName}
2025-12-14 19:06:57 +00:00
missionType={missionType}
onChangeMission={changeMission}
cameraRef={cameraRef}
2025-09-12 17:30:40 +00:00
/>
</SettingsProvider>
</main>
</QueryClientProvider>
2025-09-11 23:48:23 +00:00
);
}
2025-11-15 20:55:08 +00:00
export default function HomePage() {
return (
<Suspense>
<MapInspector />
</Suspense>
);
}