mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-16 00:44:59 +00:00
v2
This commit is contained in:
parent
3c8cce685d
commit
0f2e103294
12 changed files with 776 additions and 80 deletions
2
app/global.d.ts
vendored
2
app/global.d.ts
vendored
|
|
@ -1,9 +1,11 @@
|
||||||
import type { getMissionList, getMissionInfo } from "@/src/manifest";
|
import type { getMissionList, getMissionInfo } from "@/src/manifest";
|
||||||
|
import type { DemoRecording } from "@/src/demo/types";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
setMissionName?: (missionName: string) => void;
|
setMissionName?: (missionName: string) => void;
|
||||||
getMissionList?: typeof getMissionList;
|
getMissionList?: typeof getMissionList;
|
||||||
getMissionInfo?: typeof getMissionInfo;
|
getMissionInfo?: typeof getMissionInfo;
|
||||||
|
loadDemoRecording?: (recording: DemoRecording) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
210
app/page.tsx
210
app/page.tsx
|
|
@ -29,6 +29,9 @@ import { ObserverCamera } from "@/src/components/ObserverCamera";
|
||||||
import { AudioProvider } from "@/src/components/AudioContext";
|
import { AudioProvider } from "@/src/components/AudioContext";
|
||||||
import { DebugElements } from "@/src/components/DebugElements";
|
import { DebugElements } from "@/src/components/DebugElements";
|
||||||
import { CamerasProvider } from "@/src/components/CamerasProvider";
|
import { CamerasProvider } from "@/src/components/CamerasProvider";
|
||||||
|
import { DemoProvider, useDemo } from "@/src/components/DemoProvider";
|
||||||
|
import { DemoPlayback } from "@/src/components/DemoPlayback";
|
||||||
|
import { DemoControls } from "@/src/components/DemoControls";
|
||||||
import { getMissionList, getMissionInfo } from "@/src/manifest";
|
import { getMissionList, getMissionInfo } from "@/src/manifest";
|
||||||
import { createParser, parseAsBoolean, useQueryState } from "nuqs";
|
import { createParser, parseAsBoolean, useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
|
@ -174,92 +177,141 @@ function MapInspector() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<main>
|
<main>
|
||||||
<SettingsProvider
|
<DemoProvider>
|
||||||
fogEnabledOverride={fogEnabledOverride}
|
<SettingsProvider
|
||||||
onClearFogEnabledOverride={clearFogEnabledOverride}
|
fogEnabledOverride={fogEnabledOverride}
|
||||||
>
|
onClearFogEnabledOverride={clearFogEnabledOverride}
|
||||||
<KeyboardControls map={KEYBOARD_CONTROLS}>
|
>
|
||||||
<div id="canvasContainer">
|
<KeyboardControls map={KEYBOARD_CONTROLS}>
|
||||||
{showLoadingIndicator && (
|
<div id="canvasContainer">
|
||||||
<div id="loadingIndicator" data-complete={!isLoading}>
|
{showLoadingIndicator && (
|
||||||
<div className="LoadingSpinner" />
|
<div id="loadingIndicator" data-complete={!isLoading}>
|
||||||
<div className="LoadingProgress">
|
<div className="LoadingSpinner" />
|
||||||
<div
|
<div className="LoadingProgress">
|
||||||
className="LoadingProgress-bar"
|
<div
|
||||||
style={{ width: `${loadingProgress * 100}%` }}
|
className="LoadingProgress-bar"
|
||||||
/>
|
style={{ width: `${loadingProgress * 100}%` }}
|
||||||
</div>
|
|
||||||
<div className="LoadingProgress-text">
|
|
||||||
{Math.round(loadingProgress * 100)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Canvas
|
|
||||||
frameloop="always"
|
|
||||||
gl={glSettings}
|
|
||||||
shadows={{ type: PCFShadowMap }}
|
|
||||||
onCreated={(state) => {
|
|
||||||
cameraRef.current = state.camera;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CamerasProvider>
|
|
||||||
<AudioProvider>
|
|
||||||
<Mission
|
|
||||||
key={`${missionName}~${missionType}`}
|
|
||||||
name={missionName}
|
|
||||||
missionType={missionType}
|
|
||||||
onLoadingChange={handleLoadingChange}
|
|
||||||
/>
|
|
||||||
<ObserverCamera />
|
|
||||||
<DebugElements />
|
|
||||||
{isTouch === null ? null : isTouch ? (
|
|
||||||
<TouchCameraMovement
|
|
||||||
joystickState={joystickStateRef}
|
|
||||||
joystickZone={joystickZoneRef}
|
|
||||||
lookJoystickState={lookJoystickStateRef}
|
|
||||||
lookJoystickZone={lookJoystickZoneRef}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<ObserverControls />
|
<div className="LoadingProgress-text">
|
||||||
)}
|
{Math.round(loadingProgress * 100)}%
|
||||||
</AudioProvider>
|
</div>
|
||||||
</CamerasProvider>
|
</div>
|
||||||
</Canvas>
|
)}
|
||||||
</div>
|
<Canvas
|
||||||
{isTouch && (
|
frameloop="always"
|
||||||
<TouchJoystick
|
gl={glSettings}
|
||||||
joystickState={joystickStateRef}
|
shadows={{ type: PCFShadowMap }}
|
||||||
joystickZone={joystickZoneRef}
|
onCreated={(state) => {
|
||||||
lookJoystickState={lookJoystickStateRef}
|
cameraRef.current = state.camera;
|
||||||
lookJoystickZone={lookJoystickZoneRef}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<CamerasProvider>
|
||||||
{isTouch === false && <KeyboardOverlay />}
|
<AudioProvider>
|
||||||
<InspectorControls
|
<Mission
|
||||||
missionName={missionName}
|
key={`${missionName}~${missionType}`}
|
||||||
missionType={missionType}
|
name={missionName}
|
||||||
onChangeMission={changeMission}
|
missionType={missionType}
|
||||||
onOpenMapInfo={() => setMapInfoOpen(true)}
|
onLoadingChange={handleLoadingChange}
|
||||||
cameraRef={cameraRef}
|
/>
|
||||||
isTouch={isTouch}
|
<ObserverCamera />
|
||||||
/>
|
<DebugElements />
|
||||||
{mapInfoOpen && (
|
<DemoPlayback />
|
||||||
<Suspense fallback={null}>
|
<DemoAwareControls
|
||||||
<MapInfoDialog
|
isTouch={isTouch}
|
||||||
open={mapInfoOpen}
|
joystickStateRef={joystickStateRef}
|
||||||
onClose={() => setMapInfoOpen(false)}
|
joystickZoneRef={joystickZoneRef}
|
||||||
missionName={missionName}
|
lookJoystickStateRef={lookJoystickStateRef}
|
||||||
missionType={missionType ?? ""}
|
lookJoystickZoneRef={lookJoystickZoneRef}
|
||||||
|
/>
|
||||||
|
</AudioProvider>
|
||||||
|
</CamerasProvider>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
{isTouch && (
|
||||||
|
<TouchJoystick
|
||||||
|
joystickState={joystickStateRef}
|
||||||
|
joystickZone={joystickZoneRef}
|
||||||
|
lookJoystickState={lookJoystickStateRef}
|
||||||
|
lookJoystickZone={lookJoystickZoneRef}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
)}
|
||||||
)}
|
{isTouch === false && <KeyboardOverlay />}
|
||||||
</KeyboardControls>
|
<InspectorControls
|
||||||
</SettingsProvider>
|
missionName={missionName}
|
||||||
|
missionType={missionType}
|
||||||
|
onChangeMission={changeMission}
|
||||||
|
onOpenMapInfo={() => setMapInfoOpen(true)}
|
||||||
|
cameraRef={cameraRef}
|
||||||
|
isTouch={isTouch}
|
||||||
|
/>
|
||||||
|
{mapInfoOpen && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MapInfoDialog
|
||||||
|
open={mapInfoOpen}
|
||||||
|
onClose={() => setMapInfoOpen(false)}
|
||||||
|
missionName={missionName}
|
||||||
|
missionType={missionType ?? ""}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
<DemoControls />
|
||||||
|
<DemoWindowAPI />
|
||||||
|
</KeyboardControls>
|
||||||
|
</SettingsProvider>
|
||||||
|
</DemoProvider>
|
||||||
</main>
|
</main>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables observer/touch controls when a demo is playing so they don't
|
||||||
|
* fight the animated camera.
|
||||||
|
*/
|
||||||
|
function DemoAwareControls({
|
||||||
|
isTouch,
|
||||||
|
joystickStateRef,
|
||||||
|
joystickZoneRef,
|
||||||
|
lookJoystickStateRef,
|
||||||
|
lookJoystickZoneRef,
|
||||||
|
}: {
|
||||||
|
isTouch: boolean | null;
|
||||||
|
joystickStateRef: React.RefObject<JoystickState>;
|
||||||
|
joystickZoneRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
lookJoystickStateRef: React.RefObject<JoystickState>;
|
||||||
|
lookJoystickZoneRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}) {
|
||||||
|
const { isPlaying } = useDemo();
|
||||||
|
if (isPlaying) return null;
|
||||||
|
if (isTouch === null) return null;
|
||||||
|
if (isTouch) {
|
||||||
|
return (
|
||||||
|
<TouchCameraMovement
|
||||||
|
joystickState={joystickStateRef}
|
||||||
|
joystickZone={joystickZoneRef}
|
||||||
|
lookJoystickState={lookJoystickStateRef}
|
||||||
|
lookJoystickZone={lookJoystickZoneRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ObserverControls />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposes `window.loadDemoRecording` for automation/testing. */
|
||||||
|
function DemoWindowAPI() {
|
||||||
|
const { setRecording } = useDemo();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.loadDemoRecording = setRecording;
|
||||||
|
return () => {
|
||||||
|
delete window.loadDemoRecording;
|
||||||
|
};
|
||||||
|
}, [setRecording]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,11 @@ input[type="range"] {
|
||||||
transform: translate(0, 1px);
|
transform: translate(0, 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.IconButton[data-active="true"] {
|
||||||
|
background: rgba(0, 117, 213, 0.9);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.Controls-toggle {
|
.Controls-toggle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./docs/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
80
src/components/DemoControls.tsx
Normal file
80
src/components/DemoControls.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useCallback, type ChangeEvent } from "react";
|
||||||
|
import { useDemo } from "./DemoProvider";
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoControls() {
|
||||||
|
const {
|
||||||
|
recording,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
speed,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setSpeed,
|
||||||
|
} = useDemo();
|
||||||
|
|
||||||
|
const handleSeek = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
seek(parseFloat(e.target.value));
|
||||||
|
},
|
||||||
|
[seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSpeedChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSpeed(parseFloat(e.target.value));
|
||||||
|
},
|
||||||
|
[setSpeed],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recording) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="DemoControls"
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="DemoControls-playPause"
|
||||||
|
onClick={isPlaying ? pause : play}
|
||||||
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{isPlaying ? "\u275A\u275A" : "\u25B6"}
|
||||||
|
</button>
|
||||||
|
<span className="DemoControls-time">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="DemoControls-seek"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration}
|
||||||
|
step={0.01}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSeek}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="DemoControls-speed"
|
||||||
|
value={speed}
|
||||||
|
onChange={handleSpeedChange}
|
||||||
|
>
|
||||||
|
{SPEED_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}x
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/components/DemoPlayback.tsx
Normal file
243
src/components/DemoPlayback.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import {
|
||||||
|
AnimationClip,
|
||||||
|
AnimationMixer,
|
||||||
|
Group,
|
||||||
|
LoopOnce,
|
||||||
|
Quaternion,
|
||||||
|
Vector3,
|
||||||
|
} from "three";
|
||||||
|
import { useDemo } from "./DemoProvider";
|
||||||
|
import { createEntityClip } from "../demo/clips";
|
||||||
|
import type { DemoEntity } from "../demo/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate camera position and rotation from keyframes at the given time.
|
||||||
|
* Uses linear interpolation for position and slerp for rotation.
|
||||||
|
*/
|
||||||
|
function interpolateCameraAtTime(
|
||||||
|
entity: DemoEntity,
|
||||||
|
time: number,
|
||||||
|
outPosition: Vector3,
|
||||||
|
outQuaternion: Quaternion,
|
||||||
|
) {
|
||||||
|
const { keyframes } = entity;
|
||||||
|
if (keyframes.length === 0) return;
|
||||||
|
|
||||||
|
// Clamp to range
|
||||||
|
if (time <= keyframes[0].time) {
|
||||||
|
const kf = keyframes[0];
|
||||||
|
outPosition.set(kf.position[1], kf.position[2], kf.position[0]);
|
||||||
|
setQuaternionFromTorque(kf.rotation, outQuaternion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (time >= keyframes[keyframes.length - 1].time) {
|
||||||
|
const kf = keyframes[keyframes.length - 1];
|
||||||
|
outPosition.set(kf.position[1], kf.position[2], kf.position[0]);
|
||||||
|
setQuaternionFromTorque(kf.rotation, outQuaternion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search for the bracketing keyframes.
|
||||||
|
let lo = 0;
|
||||||
|
let hi = keyframes.length - 1;
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (keyframes[mid].time <= time) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kfA = keyframes[lo];
|
||||||
|
const kfB = keyframes[hi];
|
||||||
|
const t = (time - kfA.time) / (kfB.time - kfA.time);
|
||||||
|
|
||||||
|
// Lerp position (in Three.js space).
|
||||||
|
outPosition.set(kfA.position[1], kfA.position[2], kfA.position[0]);
|
||||||
|
_tmpVec.set(kfB.position[1], kfB.position[2], kfB.position[0]);
|
||||||
|
outPosition.lerp(_tmpVec, t);
|
||||||
|
|
||||||
|
// Slerp rotation.
|
||||||
|
setQuaternionFromTorque(kfA.rotation, outQuaternion);
|
||||||
|
setQuaternionFromTorque(kfB.rotation, _tmpQuat);
|
||||||
|
outQuaternion.slerp(_tmpQuat, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _tmpVec = new Vector3();
|
||||||
|
const _tmpQuat = new Quaternion();
|
||||||
|
const _tmpAxis = new Vector3();
|
||||||
|
|
||||||
|
function setQuaternionFromTorque(
|
||||||
|
rot: [number, number, number, number],
|
||||||
|
out: Quaternion,
|
||||||
|
) {
|
||||||
|
const [ax, ay, az, angleDegrees] = rot;
|
||||||
|
_tmpAxis.set(ay, az, ax).normalize();
|
||||||
|
const angleRadians = -angleDegrees * (Math.PI / 180);
|
||||||
|
out.setFromAxisAngle(_tmpAxis, angleRadians);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R3F component that plays back a demo recording using Three.js AnimationMixer.
|
||||||
|
*
|
||||||
|
* Camera entities are interpolated manually each frame (not via the mixer)
|
||||||
|
* since the camera isn't part of the replay root group.
|
||||||
|
*
|
||||||
|
* All other entities get animated via AnimationMixer with clips targeting
|
||||||
|
* named child groups.
|
||||||
|
*/
|
||||||
|
export function DemoPlayback() {
|
||||||
|
const { recording, playbackRef } = useDemo();
|
||||||
|
const { camera } = useThree();
|
||||||
|
const rootRef = useRef<Group>(null);
|
||||||
|
const mixerRef = useRef<AnimationMixer | null>(null);
|
||||||
|
const timeRef = useRef(0);
|
||||||
|
const lastSyncRef = useRef(0);
|
||||||
|
|
||||||
|
// Identify the camera entity and non-camera entities.
|
||||||
|
const { cameraEntity, otherEntities } = useMemo(() => {
|
||||||
|
if (!recording) return { cameraEntity: null, otherEntities: [] };
|
||||||
|
const cam = recording.entities.find((e) => e.type === "Camera") ?? null;
|
||||||
|
const others = recording.entities.filter((e) => e.type !== "Camera");
|
||||||
|
return { cameraEntity: cam, otherEntities: others };
|
||||||
|
}, [recording]);
|
||||||
|
|
||||||
|
// Create clips for non-camera entities.
|
||||||
|
const entityClips = useMemo(() => {
|
||||||
|
const map = new Map<string, AnimationClip>();
|
||||||
|
for (const entity of otherEntities) {
|
||||||
|
map.set(String(entity.id), createEntityClip(entity));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [otherEntities]);
|
||||||
|
|
||||||
|
// Set up the mixer and actions when recording/clips change.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root || entityClips.size === 0) {
|
||||||
|
mixerRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mixer = new AnimationMixer(root);
|
||||||
|
mixerRef.current = mixer;
|
||||||
|
|
||||||
|
for (const [, clip] of entityClips) {
|
||||||
|
const action = mixer.clipAction(clip);
|
||||||
|
action.setLoop(LoopOnce, 1);
|
||||||
|
action.clampWhenFinished = true;
|
||||||
|
action.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start paused — useFrame will unpause based on playback state.
|
||||||
|
mixer.timeScale = 0;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mixer.stopAllAction();
|
||||||
|
mixerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [entityClips]);
|
||||||
|
|
||||||
|
// Drive playback each frame.
|
||||||
|
useFrame((_state, delta) => {
|
||||||
|
const pb = playbackRef.current;
|
||||||
|
const mixer = mixerRef.current;
|
||||||
|
|
||||||
|
// Handle pending seek.
|
||||||
|
if (pb.pendingSeek != null) {
|
||||||
|
timeRef.current = pb.pendingSeek;
|
||||||
|
if (mixer) {
|
||||||
|
mixer.setTime(pb.pendingSeek);
|
||||||
|
}
|
||||||
|
pb.pendingSeek = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pending play/pause state change.
|
||||||
|
if (pb.pendingPlayState != null) {
|
||||||
|
pb.isPlaying = pb.pendingPlayState;
|
||||||
|
pb.pendingPlayState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time if playing.
|
||||||
|
if (pb.isPlaying && recording) {
|
||||||
|
const advance = delta * pb.speed;
|
||||||
|
timeRef.current += advance;
|
||||||
|
|
||||||
|
// Clamp to duration; stop at end.
|
||||||
|
if (timeRef.current >= recording.duration) {
|
||||||
|
timeRef.current = recording.duration;
|
||||||
|
pb.isPlaying = false;
|
||||||
|
(pb as any).updateCurrentTime?.(timeRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mixer) {
|
||||||
|
mixer.timeScale = 1;
|
||||||
|
mixer.update(advance);
|
||||||
|
}
|
||||||
|
} else if (mixer) {
|
||||||
|
mixer.timeScale = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolate camera.
|
||||||
|
if (cameraEntity && cameraEntity.keyframes.length > 0) {
|
||||||
|
interpolateCameraAtTime(
|
||||||
|
cameraEntity,
|
||||||
|
timeRef.current,
|
||||||
|
camera.position,
|
||||||
|
camera.quaternion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle syncing current time to React state (~10 Hz).
|
||||||
|
const now = performance.now();
|
||||||
|
if (pb.isPlaying && now - lastSyncRef.current > 100) {
|
||||||
|
lastSyncRef.current = now;
|
||||||
|
pb.currentTime = timeRef.current;
|
||||||
|
(pb as any).updateCurrentTime?.(timeRef.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recording) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={rootRef}>
|
||||||
|
{otherEntities.map((entity) => (
|
||||||
|
<DemoEntityGroup key={entity.id} entity={entity} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a placeholder for a non-camera demo entity.
|
||||||
|
* The group name must match the entity ID so the AnimationMixer can target it.
|
||||||
|
*/
|
||||||
|
function DemoEntityGroup({ entity }: { entity: DemoEntity }) {
|
||||||
|
const name = String(entity.id);
|
||||||
|
const color = entityTypeColor(entity.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group name={name}>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.5, 8, 6]} />
|
||||||
|
<meshBasicMaterial color={color} wireframe />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityTypeColor(type: string): string {
|
||||||
|
switch (type.toLowerCase()) {
|
||||||
|
case "player":
|
||||||
|
return "#00ff88";
|
||||||
|
case "vehicle":
|
||||||
|
return "#ff8800";
|
||||||
|
case "projectile":
|
||||||
|
return "#ff0044";
|
||||||
|
default:
|
||||||
|
return "#8888ff";
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/components/DemoProvider.tsx
Normal file
142
src/components/DemoProvider.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type { DemoRecording } from "../demo/types";
|
||||||
|
|
||||||
|
interface DemoContextValue {
|
||||||
|
recording: DemoRecording | null;
|
||||||
|
setRecording: (recording: DemoRecording | null) => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
speed: number;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
setSpeed: (speed: number) => void;
|
||||||
|
/** Ref used by the scene component to sync playback time back to context. */
|
||||||
|
playbackRef: React.RefObject<PlaybackState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
speed: number;
|
||||||
|
/** Set by the provider when seeking; cleared by the scene component. */
|
||||||
|
pendingSeek: number | null;
|
||||||
|
/** Set by the provider when play/pause changes; cleared by the scene. */
|
||||||
|
pendingPlayState: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DemoContext = createContext<DemoContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useDemo() {
|
||||||
|
const context = useContext(DemoContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useDemo must be used within DemoProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoOptional() {
|
||||||
|
return useContext(DemoContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [recording, setRecording] = useState<DemoRecording | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
|
||||||
|
const playbackRef = useRef<PlaybackState>({
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
speed: 1,
|
||||||
|
pendingSeek: null,
|
||||||
|
pendingPlayState: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = recording?.duration ?? 0;
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
playbackRef.current.pendingPlayState = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
playbackRef.current.pendingPlayState = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
playbackRef.current.pendingSeek = time;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetSpeed = useCallback((newSpeed: number) => {
|
||||||
|
setSpeed(newSpeed);
|
||||||
|
playbackRef.current.speed = newSpeed;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetRecording = useCallback((rec: DemoRecording | null) => {
|
||||||
|
setRecording(rec);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setSpeed(1);
|
||||||
|
playbackRef.current.isPlaying = false;
|
||||||
|
playbackRef.current.currentTime = 0;
|
||||||
|
playbackRef.current.speed = 1;
|
||||||
|
playbackRef.current.pendingSeek = null;
|
||||||
|
playbackRef.current.pendingPlayState = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by DemoPlayback on each frame to sync the current time back
|
||||||
|
* to React state (throttled by the scene component).
|
||||||
|
*/
|
||||||
|
const updateCurrentTime = useCallback((time: number) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attach the updater to the ref so the scene component can call it
|
||||||
|
// without needing it as a dependency.
|
||||||
|
(playbackRef.current as any).updateCurrentTime = updateCurrentTime;
|
||||||
|
|
||||||
|
const context: DemoContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
recording,
|
||||||
|
setRecording: handleSetRecording,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
speed,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setSpeed: handleSetSpeed,
|
||||||
|
playbackRef,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
recording,
|
||||||
|
handleSetRecording,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
speed,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
handleSetSpeed,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DemoContext.Provider value={context}>{children}</DemoContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { MissionSelect } from "./MissionSelect";
|
||||||
import { RefObject, useEffect, useState, useRef } from "react";
|
import { RefObject, useEffect, useState, useRef } from "react";
|
||||||
import { Camera } from "three";
|
import { Camera } from "three";
|
||||||
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
||||||
|
import { LoadDemoButton } from "./LoadDemoButton";
|
||||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
import { FiInfo, FiSettings } from "react-icons/fi";
|
||||||
|
|
||||||
export function InspectorControls({
|
export function InspectorControls({
|
||||||
|
|
@ -112,6 +113,7 @@ export function InspectorControls({
|
||||||
missionName={missionName}
|
missionName={missionName}
|
||||||
missionType={missionType}
|
missionType={missionType}
|
||||||
/>
|
/>
|
||||||
|
<LoadDemoButton />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="IconButton LabelledButton MapInfoButton"
|
className="IconButton LabelledButton MapInfoButton"
|
||||||
|
|
|
||||||
60
src/components/LoadDemoButton.tsx
Normal file
60
src/components/LoadDemoButton.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { FiFilm } from "react-icons/fi";
|
||||||
|
import { useDemo } from "./DemoProvider";
|
||||||
|
import { parseDemoFile } from "../demo/parse";
|
||||||
|
|
||||||
|
export function LoadDemoButton() {
|
||||||
|
const { setRecording, recording } = useDemo();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (recording) {
|
||||||
|
// Unload the current recording.
|
||||||
|
setRecording(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.current?.click();
|
||||||
|
}, [recording, setRecording]);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
// Reset the input so the same file can be re-selected.
|
||||||
|
e.target.value = "";
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const demo = parseDemoFile(buffer);
|
||||||
|
setRecording(demo);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load demo:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setRecording],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".rec"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="IconButton LabelledButton"
|
||||||
|
aria-label={recording ? "Unload demo" : "Load demo (.rec)"}
|
||||||
|
title={recording ? "Unload demo" : "Load demo (.rec)"}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-active={recording ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<FiFilm />
|
||||||
|
<span className="ButtonLabel">
|
||||||
|
{recording ? "Unload demo" : "Demo"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/demo/clips.ts
Normal file
84
src/demo/clips.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import {
|
||||||
|
AnimationClip,
|
||||||
|
Quaternion,
|
||||||
|
QuaternionKeyframeTrack,
|
||||||
|
Vector3,
|
||||||
|
VectorKeyframeTrack,
|
||||||
|
} from "three";
|
||||||
|
import type { DemoEntity, DemoRecording } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Torque position `[x, y, z]` to Three.js `[y, z, x]`.
|
||||||
|
* Matches `getPosition` in `src/mission.ts`.
|
||||||
|
*/
|
||||||
|
function torquePositionToThree(
|
||||||
|
pos: [number, number, number],
|
||||||
|
): [number, number, number] {
|
||||||
|
return [pos[1], pos[2], pos[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Torque axis-angle rotation `[ax, ay, az, angleDegrees]` to a
|
||||||
|
* Three.js quaternion `[x, y, z, w]`.
|
||||||
|
* Matches `getRotation` in `src/mission.ts`: axis is reordered `(ay, az, ax)`
|
||||||
|
* and the angle is negated.
|
||||||
|
*/
|
||||||
|
function torqueRotationToQuaternion(
|
||||||
|
rot: [number, number, number, number],
|
||||||
|
): [number, number, number, number] {
|
||||||
|
const [ax, ay, az, angleDegrees] = rot;
|
||||||
|
const axis = new Vector3(ay, az, ax).normalize();
|
||||||
|
const angleRadians = -angleDegrees * (Math.PI / 180);
|
||||||
|
const q = new Quaternion().setFromAxisAngle(axis, angleRadians);
|
||||||
|
return [q.x, q.y, q.z, q.w];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Three.js AnimationClip from a DemoEntity's keyframes.
|
||||||
|
* Position and rotation values are converted from Torque to Three.js space.
|
||||||
|
*/
|
||||||
|
export function createEntityClip(entity: DemoEntity): AnimationClip {
|
||||||
|
const { keyframes } = entity;
|
||||||
|
const name = String(entity.id);
|
||||||
|
|
||||||
|
const times = new Float32Array(keyframes.length);
|
||||||
|
const positions = new Float32Array(keyframes.length * 3);
|
||||||
|
const quaternions = new Float32Array(keyframes.length * 4);
|
||||||
|
|
||||||
|
for (let i = 0; i < keyframes.length; i++) {
|
||||||
|
const kf = keyframes[i];
|
||||||
|
times[i] = kf.time;
|
||||||
|
|
||||||
|
const [px, py, pz] = torquePositionToThree(kf.position);
|
||||||
|
positions[i * 3] = px;
|
||||||
|
positions[i * 3 + 1] = py;
|
||||||
|
positions[i * 3 + 2] = pz;
|
||||||
|
|
||||||
|
const [qx, qy, qz, qw] = torqueRotationToQuaternion(kf.rotation);
|
||||||
|
quaternions[i * 4] = qx;
|
||||||
|
quaternions[i * 4 + 1] = qy;
|
||||||
|
quaternions[i * 4 + 2] = qz;
|
||||||
|
quaternions[i * 4 + 3] = qw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = [
|
||||||
|
new VectorKeyframeTrack(`${name}.position`, times, positions),
|
||||||
|
new QuaternionKeyframeTrack(`${name}.quaternion`, times, quaternions),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new AnimationClip(name, -1, tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert all entities in a recording to AnimationClips, keyed by entity ID.
|
||||||
|
*/
|
||||||
|
export function createDemoClips(
|
||||||
|
recording: DemoRecording,
|
||||||
|
): Map<string, AnimationClip> {
|
||||||
|
const clips = new Map<string, AnimationClip>();
|
||||||
|
for (const entity of recording.entities) {
|
||||||
|
const clip = createEntityClip(entity);
|
||||||
|
clips.set(String(entity.id), clip);
|
||||||
|
}
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
9
src/demo/parse.ts
Normal file
9
src/demo/parse.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { DemoRecording } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Tribes 2 .rec demo file into a DemoRecording.
|
||||||
|
* Not yet implemented — will be filled in when the parser is ready.
|
||||||
|
*/
|
||||||
|
export function parseDemoFile(_data: ArrayBuffer): DemoRecording {
|
||||||
|
throw new Error("Demo file parsing is not yet implemented");
|
||||||
|
}
|
||||||
17
src/demo/types.ts
Normal file
17
src/demo/types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export interface DemoKeyframe {
|
||||||
|
time: number;
|
||||||
|
position: [number, number, number];
|
||||||
|
rotation: [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoEntity {
|
||||||
|
id: number | string;
|
||||||
|
type: string;
|
||||||
|
dataBlock?: string;
|
||||||
|
keyframes: DemoKeyframe[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoRecording {
|
||||||
|
duration: number;
|
||||||
|
entities: DemoEntity[];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue