This commit is contained in:
Brian Beck 2026-02-20 15:48:15 -08:00
parent 3c8cce685d
commit 0f2e103294
12 changed files with 776 additions and 80 deletions

2
app/global.d.ts vendored
View file

@ -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;
}
}

View file

@ -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 (
<QueryClientProvider client={queryClient}>
<main>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
>
<KeyboardControls map={KEYBOARD_CONTROLS}>
<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>
)}
<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}
<DemoProvider>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
>
<KeyboardControls map={KEYBOARD_CONTROLS}>
<div id="canvasContainer">
{showLoadingIndicator && (
<div id="loadingIndicator" data-complete={!isLoading}>
<div className="LoadingSpinner" />
<div className="LoadingProgress">
<div
className="LoadingProgress-bar"
style={{ width: `${loadingProgress * 100}%` }}
/>
) : (
<ObserverControls />
)}
</AudioProvider>
</CamerasProvider>
</Canvas>
</div>
{isTouch && (
<TouchJoystick
joystickState={joystickStateRef}
joystickZone={joystickZoneRef}
lookJoystickState={lookJoystickStateRef}
lookJoystickZone={lookJoystickZoneRef}
/>
)}
{isTouch === false && <KeyboardOverlay />}
<InspectorControls
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 ?? ""}
</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 />
<DemoPlayback />
<DemoAwareControls
isTouch={isTouch}
joystickStateRef={joystickStateRef}
joystickZoneRef={joystickZoneRef}
lookJoystickStateRef={lookJoystickStateRef}
lookJoystickZoneRef={lookJoystickZoneRef}
/>
</AudioProvider>
</CamerasProvider>
</Canvas>
</div>
{isTouch && (
<TouchJoystick
joystickState={joystickStateRef}
joystickZone={joystickZoneRef}
lookJoystickState={lookJoystickStateRef}
lookJoystickZone={lookJoystickZoneRef}
/>
</Suspense>
)}
</KeyboardControls>
</SettingsProvider>
)}
{isTouch === false && <KeyboardOverlay />}
<InspectorControls
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>
</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() {
return (
<Suspense>

View file

@ -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;
}

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View 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>
);
}

View 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";
}
}

View 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>
);
}

View file

@ -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}
/>
<LoadDemoButton />
<button
type="button"
className="IconButton LabelledButton MapInfoButton"

View 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
View 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
View 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
View 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[];
}