diff --git a/app/global.d.ts b/app/global.d.ts index ca14560a..6061bbfa 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -1,9 +1,11 @@ import type { getMissionList, getMissionInfo } from "@/src/manifest"; +import type { DemoRecording } from "@/src/demo/types"; declare global { interface Window { setMissionName?: (missionName: string) => void; getMissionList?: typeof getMissionList; getMissionInfo?: typeof getMissionInfo; + loadDemoRecording?: (recording: DemoRecording) => void; } } diff --git a/app/page.tsx b/app/page.tsx index c318c2ee..2c2fab4c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -29,6 +29,9 @@ import { ObserverCamera } from "@/src/components/ObserverCamera"; import { AudioProvider } from "@/src/components/AudioContext"; import { DebugElements } from "@/src/components/DebugElements"; 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 { createParser, parseAsBoolean, useQueryState } from "nuqs"; @@ -174,92 +177,141 @@ function MapInspector() { return (
- - -
- {showLoadingIndicator && ( -
-
-
-
-
-
- {Math.round(loadingProgress * 100)}% -
-
- )} - { - cameraRef.current = state.camera; - }} - > - - - - - - {isTouch === null ? null : isTouch ? ( - + + +
+ {showLoadingIndicator && ( +
+
+
+
- ) : ( - - )} - - - -
- {isTouch && ( - - )} - {isTouch === false && } - setMapInfoOpen(true)} - cameraRef={cameraRef} - isTouch={isTouch} - /> - {mapInfoOpen && ( - - setMapInfoOpen(false)} - missionName={missionName} - missionType={missionType ?? ""} +
+
+ {Math.round(loadingProgress * 100)}% +
+
+ )} + { + cameraRef.current = state.camera; + }} + > + + + + + + + + + + +
+ {isTouch && ( + - - )} - - + )} + {isTouch === false && } + setMapInfoOpen(true)} + cameraRef={cameraRef} + isTouch={isTouch} + /> + {mapInfoOpen && ( + + setMapInfoOpen(false)} + missionName={missionName} + missionType={missionType ?? ""} + /> + + )} + + + + +
); } +/** + * 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; + joystickZoneRef: React.RefObject; + lookJoystickStateRef: React.RefObject; + lookJoystickZoneRef: React.RefObject; +}) { + const { isPlaying } = useDemo(); + if (isPlaying) return null; + if (isTouch === null) return null; + if (isTouch) { + return ( + + ); + } + return ; +} + +/** 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() { return ( diff --git a/app/style.css b/app/style.css index 8a2adade..aae48cc7 100644 --- a/app/style.css +++ b/app/style.css @@ -187,6 +187,11 @@ input[type="range"] { 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 { margin: 0; } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..1d9923a0 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./docs/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/DemoControls.tsx b/src/components/DemoControls.tsx new file mode 100644 index 00000000..7220891d --- /dev/null +++ b/src/components/DemoControls.tsx @@ -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) => { + seek(parseFloat(e.target.value)); + }, + [seek], + ); + + const handleSpeedChange = useCallback( + (e: ChangeEvent) => { + setSpeed(parseFloat(e.target.value)); + }, + [setSpeed], + ); + + if (!recording) return null; + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + + {formatTime(currentTime)} / {formatTime(duration)} + + + +
+ ); +} diff --git a/src/components/DemoPlayback.tsx b/src/components/DemoPlayback.tsx new file mode 100644 index 00000000..3326132d --- /dev/null +++ b/src/components/DemoPlayback.tsx @@ -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(null); + const mixerRef = useRef(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(); + 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 ( + + {otherEntities.map((entity) => ( + + ))} + + ); +} + +/** + * 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 ( + + + + + + + ); +} + +function entityTypeColor(type: string): string { + switch (type.toLowerCase()) { + case "player": + return "#00ff88"; + case "vehicle": + return "#ff8800"; + case "projectile": + return "#ff0044"; + default: + return "#8888ff"; + } +} diff --git a/src/components/DemoProvider.tsx b/src/components/DemoProvider.tsx new file mode 100644 index 00000000..35c6d5e1 --- /dev/null +++ b/src/components/DemoProvider.tsx @@ -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; +} + +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(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(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [speed, setSpeed] = useState(1); + + const playbackRef = useRef({ + 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 ( + {children} + ); +} diff --git a/src/components/InspectorControls.tsx b/src/components/InspectorControls.tsx index c55f8eb8..dc68b277 100644 --- a/src/components/InspectorControls.tsx +++ b/src/components/InspectorControls.tsx @@ -8,6 +8,7 @@ import { MissionSelect } from "./MissionSelect"; import { RefObject, useEffect, useState, useRef } from "react"; import { Camera } from "three"; import { CopyCoordinatesButton } from "./CopyCoordinatesButton"; +import { LoadDemoButton } from "./LoadDemoButton"; import { FiInfo, FiSettings } from "react-icons/fi"; export function InspectorControls({ @@ -112,6 +113,7 @@ export function InspectorControls({ missionName={missionName} missionType={missionType} /> + + + ); +} diff --git a/src/demo/clips.ts b/src/demo/clips.ts new file mode 100644 index 00000000..e5c2fe2a --- /dev/null +++ b/src/demo/clips.ts @@ -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 { + const clips = new Map(); + for (const entity of recording.entities) { + const clip = createEntityClip(entity); + clips.set(String(entity.id), clip); + } + return clips; +} diff --git a/src/demo/parse.ts b/src/demo/parse.ts new file mode 100644 index 00000000..329e98aa --- /dev/null +++ b/src/demo/parse.ts @@ -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"); +} diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 00000000..2c0d7aeb --- /dev/null +++ b/src/demo/types.ts @@ -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[]; +}