mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 06:24:47 +00:00
initial demo support
This commit is contained in:
parent
0f2e103294
commit
359a036558
406 changed files with 10513 additions and 1158 deletions
|
|
@ -1,5 +1,18 @@
|
|||
import { useCallback, type ChangeEvent } from "react";
|
||||
import { useDemo } from "./DemoProvider";
|
||||
import {
|
||||
useDemoActions,
|
||||
useDemoCurrentTime,
|
||||
useDemoDuration,
|
||||
useDemoIsPlaying,
|
||||
useDemoRecording,
|
||||
useDemoSpeed,
|
||||
} from "./DemoProvider";
|
||||
import {
|
||||
buildSerializableDiagnosticsJson,
|
||||
buildSerializableDiagnosticsSnapshot,
|
||||
useEngineSelector,
|
||||
useEngineStoreApi,
|
||||
} from "../state";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
|
||||
|
||||
|
|
@ -9,18 +22,41 @@ function formatTime(seconds: number): string {
|
|||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | undefined): string {
|
||||
if (!Number.isFinite(value) || value == null) {
|
||||
return "n/a";
|
||||
}
|
||||
if (value < 1024) return `${Math.round(value)} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function DemoControls() {
|
||||
const {
|
||||
recording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setSpeed,
|
||||
} = useDemo();
|
||||
const recording = useDemoRecording();
|
||||
const isPlaying = useDemoIsPlaying();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const duration = useDemoDuration();
|
||||
const speed = useDemoSpeed();
|
||||
const { play, pause, seek, setSpeed } = useDemoActions();
|
||||
const engineStore = useEngineStoreApi();
|
||||
const webglContextLost = useEngineSelector(
|
||||
(state) => state.diagnostics.webglContextLost,
|
||||
);
|
||||
const rendererSampleCount = useEngineSelector(
|
||||
(state) => state.diagnostics.rendererSamples.length,
|
||||
);
|
||||
const latestRendererSample = useEngineSelector((state) => {
|
||||
const samples = state.diagnostics.rendererSamples;
|
||||
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||
});
|
||||
const playbackEventCount = useEngineSelector(
|
||||
(state) => state.diagnostics.playbackEvents.length,
|
||||
);
|
||||
const latestPlaybackEvent = useEngineSelector((state) => {
|
||||
const events = state.diagnostics.playbackEvents;
|
||||
return events.length > 0 ? events[events.length - 1] : null;
|
||||
});
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -36,6 +72,19 @@ export function DemoControls() {
|
|||
[setSpeed],
|
||||
);
|
||||
|
||||
const handleDumpDiagnostics = useCallback(() => {
|
||||
const state = engineStore.getState();
|
||||
const snapshot = buildSerializableDiagnosticsSnapshot(state);
|
||||
const json = buildSerializableDiagnosticsJson(state);
|
||||
console.log("[demo diagnostics dump]", snapshot);
|
||||
console.log("[demo diagnostics dump json]", json);
|
||||
}, [engineStore]);
|
||||
|
||||
const handleClearDiagnostics = useCallback(() => {
|
||||
engineStore.getState().clearPlaybackDiagnostics();
|
||||
console.info("[demo diagnostics] Cleared playback diagnostics");
|
||||
}, [engineStore]);
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +102,7 @@ export function DemoControls() {
|
|||
{isPlaying ? "\u275A\u275A" : "\u25B6"}
|
||||
</button>
|
||||
<span className="DemoControls-time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
{`${formatTime(currentTime)} / ${formatTime(duration)}`}
|
||||
</span>
|
||||
<input
|
||||
className="DemoControls-seek"
|
||||
|
|
@ -75,6 +124,54 @@ export function DemoControls() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
className="DemoDiagnosticsPanel"
|
||||
data-context-lost={webglContextLost ? "true" : undefined}
|
||||
>
|
||||
<div className="DemoDiagnosticsPanel-status">
|
||||
{webglContextLost ? "WebGL context: LOST" : "WebGL context: ok"}
|
||||
</div>
|
||||
<div className="DemoDiagnosticsPanel-metrics">
|
||||
{latestRendererSample ? (
|
||||
<>
|
||||
<span>
|
||||
geom {latestRendererSample.geometries} tex{" "}
|
||||
{latestRendererSample.textures} prog{" "}
|
||||
{latestRendererSample.programs}
|
||||
</span>
|
||||
<span>
|
||||
draw {latestRendererSample.renderCalls} tri{" "}
|
||||
{latestRendererSample.renderTriangles}
|
||||
</span>
|
||||
<span>
|
||||
scene {latestRendererSample.visibleSceneObjects}/
|
||||
{latestRendererSample.sceneObjects}
|
||||
</span>
|
||||
<span>heap {formatBytes(latestRendererSample.jsHeapUsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>No renderer samples yet</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="DemoDiagnosticsPanel-footer">
|
||||
<span>
|
||||
samples {rendererSampleCount} events {playbackEventCount}
|
||||
</span>
|
||||
{latestPlaybackEvent ? (
|
||||
<span title={latestPlaybackEvent.message}>
|
||||
last event: {latestPlaybackEvent.kind}
|
||||
</span>
|
||||
) : (
|
||||
<span>last event: none</span>
|
||||
)}
|
||||
<button type="button" onClick={handleDumpDiagnostics}>
|
||||
Dump
|
||||
</button>
|
||||
<button type="button" onClick={handleClearDiagnostics}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,6 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import type { DemoRecording } from "../demo/types";
|
||||
import { useEngineSelector } from "../state";
|
||||
|
||||
interface DemoContextValue {
|
||||
recording: DemoRecording | null;
|
||||
|
|
@ -20,123 +13,107 @@ interface DemoContextValue {
|
|||
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);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const playbackRef = useRef<PlaybackState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
speed: 1,
|
||||
pendingSeek: null,
|
||||
pendingPlayState: null,
|
||||
});
|
||||
export function useDemoRecording(): DemoRecording | null {
|
||||
return useEngineSelector((state) => state.playback.recording);
|
||||
}
|
||||
|
||||
const duration = recording?.duration ?? 0;
|
||||
export function useDemoIsPlaying(): boolean {
|
||||
return useEngineSelector((state) => state.playback.status === "playing");
|
||||
}
|
||||
|
||||
export function useDemoCurrentTime(): number {
|
||||
return useEngineSelector((state) => state.playback.timeMs / 1000);
|
||||
}
|
||||
|
||||
export function useDemoDuration(): number {
|
||||
return useEngineSelector((state) => state.playback.durationMs / 1000);
|
||||
}
|
||||
|
||||
export function useDemoSpeed(): number {
|
||||
return useEngineSelector((state) => state.playback.rate);
|
||||
}
|
||||
|
||||
export function useDemoActions() {
|
||||
const recording = useDemoRecording();
|
||||
const setDemoRecording = useEngineSelector((state) => state.setDemoRecording);
|
||||
const setPlaybackStatus = useEngineSelector(
|
||||
(state) => state.setPlaybackStatus,
|
||||
);
|
||||
const setPlaybackTime = useEngineSelector((state) => state.setPlaybackTime);
|
||||
const setPlaybackRate = useEngineSelector((state) => state.setPlaybackRate);
|
||||
|
||||
const setRecording = useCallback(
|
||||
(recording: DemoRecording | null) => {
|
||||
setDemoRecording(recording);
|
||||
},
|
||||
[setDemoRecording],
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
playbackRef.current.pendingPlayState = true;
|
||||
}, []);
|
||||
if (
|
||||
(recording?.isMetadataOnly || recording?.isPartial) &&
|
||||
!recording.streamingPlayback
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPlaybackStatus("playing");
|
||||
}, [recording, setPlaybackStatus]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
playbackRef.current.pendingPlayState = false;
|
||||
}, []);
|
||||
setPlaybackStatus("paused");
|
||||
}, [setPlaybackStatus]);
|
||||
|
||||
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,
|
||||
],
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
setPlaybackTime(time * 1000);
|
||||
},
|
||||
[setPlaybackTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<DemoContext.Provider value={context}>{children}</DemoContext.Provider>
|
||||
const setSpeed = useCallback(
|
||||
(speed: number) => {
|
||||
setPlaybackRate(speed);
|
||||
},
|
||||
[setPlaybackRate],
|
||||
);
|
||||
|
||||
return {
|
||||
setRecording,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDemo(): DemoContextValue {
|
||||
const recording = useDemoRecording();
|
||||
const isPlaying = useDemoIsPlaying();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const duration = useDemoDuration();
|
||||
const speed = useDemoSpeed();
|
||||
const actions = useDemoActions();
|
||||
|
||||
return {
|
||||
recording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
setRecording: actions.setRecording,
|
||||
play: actions.play,
|
||||
pause: actions.pause,
|
||||
seek: actions.seek,
|
||||
setSpeed: actions.setSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDemoOptional(): DemoContextValue {
|
||||
return useDemo();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,9 +211,17 @@ export const ForceFieldBare = memo(function ForceFieldBare({
|
|||
[datablock, numFrames],
|
||||
);
|
||||
|
||||
// Don't render if we have no textures
|
||||
// Render fallback mesh when textures are missing instead of disappearing.
|
||||
if (textureUrls.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<group position={position} quaternion={quaternion}>
|
||||
<ForceFieldFallback
|
||||
scale={scale}
|
||||
color={color}
|
||||
baseTranslucency={baseTranslucency}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, Suspense, useMemo } from "react";
|
||||
import { memo, Suspense, useMemo, useRef } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useGLTF, useTexture } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
|
||||
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
|
||||
import {
|
||||
|
|
@ -10,9 +11,10 @@ import {
|
|||
AdditiveBlending,
|
||||
Texture,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
} from "three";
|
||||
import { setupTexture } from "../textureUtils";
|
||||
import { useDebug } from "./SettingsProvider";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useIflTexture } from "./useIflTexture";
|
||||
|
|
@ -28,6 +30,10 @@ interface TextureProps {
|
|||
backGeometry?: BufferGeometry;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
/** DTS object visibility (0–1). Values < 1 enable alpha blending. */
|
||||
vis?: number;
|
||||
/** When true, material is created transparent for vis keyframe animation. */
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +55,7 @@ type MaterialResult =
|
|||
/**
|
||||
* Helper to apply volumetric fog and lighting multipliers to a material
|
||||
*/
|
||||
function applyShapeShaderModifications(
|
||||
export function applyShapeShaderModifications(
|
||||
mat: MeshBasicMaterial | MeshLambertMaterial,
|
||||
): void {
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
|
|
@ -61,24 +67,33 @@ function applyShapeShaderModifications(
|
|||
};
|
||||
}
|
||||
|
||||
function createMaterialFromFlags(
|
||||
export function createMaterialFromFlags(
|
||||
baseMaterial: MeshStandardMaterial,
|
||||
texture: Texture,
|
||||
flagNames: Set<string>,
|
||||
isOrganic: boolean,
|
||||
vis: number = 1,
|
||||
animated: boolean = false,
|
||||
): MaterialResult {
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
const isAdditive = flagNames.has("Additive");
|
||||
const isSelfIlluminating = flagNames.has("SelfIlluminating");
|
||||
// DTS per-object visibility: when vis < 1, the engine sets fadeSet=true which
|
||||
// forces the Translucent flag and renders with GL_SRC_ALPHA/GL_ONE_MINUS_SRC_ALPHA.
|
||||
// Animated vis also needs transparent materials so opacity can be updated per frame.
|
||||
const isFaded = vis < 1 || animated;
|
||||
|
||||
// SelfIlluminating materials are unlit (use MeshBasicMaterial)
|
||||
if (isSelfIlluminating) {
|
||||
const isBlended = isAdditive || isTranslucent || isFaded;
|
||||
const mat = new MeshBasicMaterial({
|
||||
map: texture,
|
||||
side: 2, // DoubleSide
|
||||
transparent: isAdditive,
|
||||
alphaTest: isAdditive ? 0 : 0.5,
|
||||
transparent: isBlended,
|
||||
depthWrite: !isBlended,
|
||||
alphaTest: 0,
|
||||
fog: true,
|
||||
...(isFaded && { opacity: vis }),
|
||||
...(isAdditive && { blending: AdditiveBlending }),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
|
|
@ -92,8 +107,11 @@ function createMaterialFromFlags(
|
|||
if (isOrganic || isTranslucent) {
|
||||
const baseProps = {
|
||||
map: texture,
|
||||
transparent: false,
|
||||
alphaTest: 0.5,
|
||||
// When vis < 1, switch from alpha cutout to alpha blend (matching the engine's
|
||||
// fadeSet behavior which forces GL_BLEND with no alpha test)
|
||||
transparent: isFaded,
|
||||
alphaTest: isFaded ? 0 : 0.5,
|
||||
...(isFaded && { opacity: vis, depthWrite: false }),
|
||||
reflectivity: 0,
|
||||
};
|
||||
const backMat = new MeshLambertMaterial({
|
||||
|
|
@ -119,6 +137,11 @@ function createMaterialFromFlags(
|
|||
map: texture,
|
||||
side: 2, // DoubleSide
|
||||
reflectivity: 0,
|
||||
...(isFaded && {
|
||||
transparent: true,
|
||||
opacity: vis,
|
||||
depthWrite: false,
|
||||
}),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
return mat;
|
||||
|
|
@ -143,6 +166,8 @@ const IflTexture = memo(function IflTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
||||
|
|
@ -152,8 +177,16 @@ const IflTexture = memo(function IflTexture({
|
|||
const isOrganic = shapeName && isOrganicShape(shapeName);
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
|
||||
[material, texture, flagNames, isOrganic],
|
||||
() =>
|
||||
createMaterialFromFlags(
|
||||
material,
|
||||
texture,
|
||||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
animated,
|
||||
),
|
||||
[material, texture, flagNames, isOrganic, vis, animated],
|
||||
);
|
||||
|
||||
// Two-pass rendering for organic/translucent materials
|
||||
|
|
@ -197,6 +230,8 @@ const StaticTexture = memo(function StaticTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
||||
|
|
@ -223,8 +258,16 @@ const StaticTexture = memo(function StaticTexture({
|
|||
});
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
|
||||
[material, texture, flagNames, isOrganic],
|
||||
() =>
|
||||
createMaterialFromFlags(
|
||||
material,
|
||||
texture,
|
||||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
animated,
|
||||
),
|
||||
[material, texture, flagNames, isOrganic, vis, animated],
|
||||
);
|
||||
|
||||
// Two-pass rendering for organic/translucent materials
|
||||
|
|
@ -268,6 +311,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const flagNames = new Set(material.userData.flag_names ?? []);
|
||||
const isIflMaterial = flagNames.has("IflMaterial");
|
||||
|
|
@ -283,6 +328,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry={backGeometry}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
);
|
||||
} else if (material.name) {
|
||||
|
|
@ -294,6 +341,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry={backGeometry}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
@ -328,6 +377,22 @@ export function DebugPlaceholder({
|
|||
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
|
||||
}
|
||||
|
||||
/** Shapes that don't have a .glb conversion and are rendered with built-in
|
||||
* Three.js geometry instead. These are editor-only markers in Tribes 2. */
|
||||
const HARDCODED_SHAPES = new Set(["octahedron.dts"]);
|
||||
|
||||
function HardcodedShape({ label }: { label?: string }) {
|
||||
const { debugMode } = useDebug();
|
||||
if (!debugMode) return null;
|
||||
return (
|
||||
<mesh>
|
||||
<icosahedronGeometry args={[1, 1]} />
|
||||
<meshBasicMaterial color="cyan" wireframe />
|
||||
{label ? <FloatingLabel color="cyan">{label}</FloatingLabel> : null}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
|
||||
* pattern used across shape-rendering components.
|
||||
|
|
@ -347,6 +412,10 @@ export function ShapeRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
|
||||
return <HardcodedShape label={`${object._id}: ${shapeName}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
|
|
@ -361,6 +430,76 @@ export function ShapeRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
/** Check if a GLB node has an auto-playing "Ambient" vis animation. */
|
||||
function hasAmbientVisAnimation(userData: any): boolean {
|
||||
return (
|
||||
userData != null &&
|
||||
(userData.vis_sequence ?? "").toLowerCase() === "ambient" &&
|
||||
Array.isArray(userData.vis_keyframes) &&
|
||||
userData.vis_keyframes.length > 1 &&
|
||||
(userData.vis_duration ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps child meshes and animates their material opacity using DTS vis keyframes.
|
||||
* Used for auto-playing "Ambient" sequences (glow pulses, light effects).
|
||||
*/
|
||||
function AnimatedVisGroup({
|
||||
keyframes,
|
||||
duration,
|
||||
cyclic,
|
||||
children,
|
||||
}: {
|
||||
keyframes: number[];
|
||||
duration: number;
|
||||
cyclic: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const { animationEnabled } = useSettings();
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!animationEnabled) {
|
||||
group.traverse((child) => {
|
||||
if ((child as any).isMesh) {
|
||||
const mat = (child as any).material;
|
||||
if (mat && !Array.isArray(mat)) {
|
||||
mat.opacity = keyframes[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() / 1000;
|
||||
const t = cyclic
|
||||
? (elapsed % duration) / duration
|
||||
: Math.min(elapsed / duration, 1);
|
||||
|
||||
const n = keyframes.length;
|
||||
const pos = t * n;
|
||||
const lo = Math.floor(pos) % n;
|
||||
const hi = (lo + 1) % n;
|
||||
const frac = pos - Math.floor(pos);
|
||||
const vis = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
||||
|
||||
group.traverse((child) => {
|
||||
if ((child as any).isMesh) {
|
||||
const mat = (child as any).material;
|
||||
if (mat && !Array.isArray(mat)) {
|
||||
mat.opacity = vis;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return <group ref={groupRef}>{children}</group>;
|
||||
}
|
||||
|
||||
export const ShapeModel = memo(function ShapeModel() {
|
||||
const { object, shapeName, isOrganic } = useShapeInfo();
|
||||
const { debugMode } = useDebug();
|
||||
|
|
@ -384,7 +523,12 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
([name, node]: [string, any]) =>
|
||||
node.material &&
|
||||
node.material.name !== "Unassigned" &&
|
||||
!node.name.match(/^Hulk/i),
|
||||
!node.name.match(/^Hulk/i) &&
|
||||
// DTS per-object visibility: skip invisible objects (engine threshold
|
||||
// is 0.01) unless they have an Ambient vis animation that will bring
|
||||
// them to life (e.g. glow effects that pulse from 0 to 1).
|
||||
((node.userData?.vis ?? 1) > 0.01 ||
|
||||
hasAmbientVisAnimation(node.userData)),
|
||||
)
|
||||
.map(([name, node]: [string, any]) => {
|
||||
let geometry = filterGeometryByVertexGroups(
|
||||
|
|
@ -459,7 +603,15 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
}
|
||||
}
|
||||
|
||||
return { node, geometry, backGeometry };
|
||||
const vis: number = node.userData?.vis ?? 1;
|
||||
const visAnim = hasAmbientVisAnimation(node.userData)
|
||||
? {
|
||||
keyframes: node.userData.vis_keyframes as number[],
|
||||
duration: node.userData.vis_duration as number,
|
||||
cyclic: !!node.userData.vis_cyclic,
|
||||
}
|
||||
: undefined;
|
||||
return { node, geometry, backGeometry, vis, visAnim };
|
||||
});
|
||||
}, [nodes, hullBoneIndices, isOrganic]);
|
||||
|
||||
|
|
@ -469,41 +621,61 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
|
||||
return (
|
||||
<group rotation={[0, Math.PI / 2, 0]}>
|
||||
{processedNodes.map(({ node, geometry, backGeometry }) => (
|
||||
<Suspense
|
||||
key={node.id}
|
||||
fallback={
|
||||
<mesh geometry={geometry}>
|
||||
<meshStandardMaterial color="gray" wireframe />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
{node.material ? (
|
||||
Array.isArray(node.material) ? (
|
||||
node.material.map((mat, index) => (
|
||||
<ShapeTexture
|
||||
key={index}
|
||||
material={mat as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
{processedNodes.map(({ node, geometry, backGeometry, vis, visAnim }) => {
|
||||
const animated = !!visAnim;
|
||||
const fallback = (
|
||||
<mesh geometry={geometry}>
|
||||
<meshStandardMaterial color="gray" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
const textures = node.material ? (
|
||||
Array.isArray(node.material) ? (
|
||||
node.material.map((mat, index) => (
|
||||
<ShapeTexture
|
||||
material={node.material as MeshStandardMaterial}
|
||||
key={index}
|
||||
material={mat as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Suspense>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<ShapeTexture
|
||||
material={node.material as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (visAnim) {
|
||||
return (
|
||||
<AnimatedVisGroup
|
||||
key={node.id}
|
||||
keyframes={visAnim.keyframes}
|
||||
duration={visAnim.duration}
|
||||
cyclic={visAnim.cyclic}
|
||||
>
|
||||
<Suspense fallback={fallback}>{textures}</Suspense>
|
||||
</AnimatedVisGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense key={node.id} fallback={fallback}>
|
||||
{textures}
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
{debugMode ? (
|
||||
<FloatingLabel>
|
||||
{object._id}: {shapeName}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { RefObject, useEffect, useState, useRef } from "react";
|
|||
import { Camera } from "three";
|
||||
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
||||
import { LoadDemoButton } from "./LoadDemoButton";
|
||||
import { useDemoRecording } from "./DemoProvider";
|
||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
||||
|
||||
export function InspectorControls({
|
||||
|
|
@ -45,6 +46,8 @@ export function InspectorControls({
|
|||
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } =
|
||||
useControls();
|
||||
const { debugMode, setDebugMode } = useDebug();
|
||||
const demoRecording = useDemoRecording();
|
||||
const isDemoLoaded = demoRecording != null;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -84,6 +87,7 @@ export function InspectorControls({
|
|||
value={missionName}
|
||||
missionType={missionType}
|
||||
onChange={onChangeMission}
|
||||
disabled={isDemoLoaded}
|
||||
/>
|
||||
<div ref={focusAreaRef}>
|
||||
<button
|
||||
|
|
@ -182,6 +186,7 @@ export function InspectorControls({
|
|||
max={120}
|
||||
step={5}
|
||||
value={fov}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) => setFov(parseInt(event.target.value))}
|
||||
/>
|
||||
<output htmlFor="fovInput">{fov}</output>
|
||||
|
|
@ -195,6 +200,7 @@ export function InspectorControls({
|
|||
max={5}
|
||||
step={0.05}
|
||||
value={speedMultiplier}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) =>
|
||||
setSpeedMultiplier(parseFloat(event.target.value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Group } from "three";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||
import { ShapeRenderer } from "./GenericShape";
|
||||
|
|
@ -6,6 +8,16 @@ import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
|||
import { useSimGroup } from "./SimGroup";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useDatablock } from "./useDatablock";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
|
||||
/** Handles TorqueScript's various truthy representations. */
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
const lower = value.toLowerCase();
|
||||
return lower !== "0" && lower !== "false" && lower !== "";
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
const TEAM_NAMES: Record<number, string> = {
|
||||
1: "Storm",
|
||||
|
|
@ -17,10 +29,23 @@ export function Item({ object }: { object: TorqueObject }) {
|
|||
const datablockName = getProperty(object, "dataBlock") ?? "";
|
||||
const datablock = useDatablock(datablockName);
|
||||
|
||||
const shouldRotate = isTruthy(
|
||||
getProperty(object, "rotate") ?? getProperty(datablock, "rotate")
|
||||
);
|
||||
|
||||
const position = useMemo(() => getPosition(object), [object]);
|
||||
const scale = useMemo(() => getScale(object), [object]);
|
||||
const q = useMemo(() => getRotation(object), [object]);
|
||||
|
||||
const { animationEnabled } = useSettings();
|
||||
const groupRef = useRef<Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (!groupRef.current || !shouldRotate || !animationEnabled) return;
|
||||
const t = performance.now() / 1000;
|
||||
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
|
||||
});
|
||||
|
||||
const shapeName = getProperty(datablock, "shapeFile");
|
||||
|
||||
if (!shapeName) {
|
||||
|
|
@ -34,7 +59,12 @@ export function Item({ object }: { object: TorqueObject }) {
|
|||
|
||||
return (
|
||||
<ShapeInfoProvider type="Item" object={object} shapeName={shapeName}>
|
||||
<group position={position} quaternion={q} scale={scale}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
{...(!shouldRotate && { quaternion: q })}
|
||||
scale={scale}
|
||||
>
|
||||
<ShapeRenderer loadingColor="pink">
|
||||
{label ? <FloatingLabel opacity={0.6}>{label}</FloatingLabel> : null}
|
||||
</ShapeRenderer>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useKeyboardControls } from "@react-three/drei";
|
||||
import { Controls } from "./ObserverControls";
|
||||
import { useDemoRecording } from "./DemoProvider";
|
||||
|
||||
export function KeyboardOverlay() {
|
||||
const recording = useDemoRecording();
|
||||
const forward = useKeyboardControls<Controls>((s) => s.forward);
|
||||
const backward = useKeyboardControls<Controls>((s) => s.backward);
|
||||
const left = useKeyboardControls<Controls>((s) => s.left);
|
||||
|
|
@ -13,6 +15,8 @@ export function KeyboardOverlay() {
|
|||
const lookLeft = useKeyboardControls<Controls>((s) => s.lookLeft);
|
||||
const lookRight = useKeyboardControls<Controls>((s) => s.lookRight);
|
||||
|
||||
if (recording) return null;
|
||||
|
||||
return (
|
||||
<div className="KeyboardOverlay">
|
||||
<div className="KeyboardOverlay-column">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { FiFilm } from "react-icons/fi";
|
||||
import { useDemo } from "./DemoProvider";
|
||||
import { parseDemoFile } from "../demo/parse";
|
||||
import { MdOndemandVideo } from "react-icons/md";
|
||||
import { useDemoActions, useDemoRecording } from "./DemoProvider";
|
||||
import { createDemoStreamingRecording } from "../demo/streaming";
|
||||
|
||||
export function LoadDemoButton() {
|
||||
const { setRecording, recording } = useDemo();
|
||||
const recording = useDemoRecording();
|
||||
const { setRecording } = useDemoActions();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parseTokenRef = useRef(0);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (recording) {
|
||||
// Unload the current recording.
|
||||
parseTokenRef.current += 1;
|
||||
setRecording(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -24,8 +27,14 @@ export function LoadDemoButton() {
|
|||
e.target.value = "";
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const demo = parseDemoFile(buffer);
|
||||
setRecording(demo);
|
||||
const parseToken = parseTokenRef.current + 1;
|
||||
parseTokenRef.current = parseToken;
|
||||
const recording = await createDemoStreamingRecording(buffer);
|
||||
if (parseTokenRef.current !== parseToken) {
|
||||
return;
|
||||
}
|
||||
// Metadata-first: mission/game-mode sync happens immediately.
|
||||
setRecording(recording);
|
||||
} catch (err) {
|
||||
console.error("Failed to load demo:", err);
|
||||
}
|
||||
|
|
@ -50,7 +59,7 @@ export function LoadDemoButton() {
|
|||
onClick={handleClick}
|
||||
data-active={recording ? "true" : undefined}
|
||||
>
|
||||
<FiFilm />
|
||||
<MdOndemandVideo className="DemoIcon" />
|
||||
<span className="ButtonLabel">
|
||||
{recording ? "Unload demo" : "Demo"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getSourceAndPath,
|
||||
} from "../manifest";
|
||||
import { MissionProvider } from "./MissionContext";
|
||||
import { engineStore } from "../state";
|
||||
|
||||
const loadScript = createScriptLoader();
|
||||
// Shared cache for parsed scripts - survives runtime restarts
|
||||
|
|
@ -72,6 +73,8 @@ function useExecutedMission(
|
|||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let isDisposed = false;
|
||||
let unsubscribeRuntimeEvents: (() => void) | null = null;
|
||||
|
||||
// Create progress tracker and update state on changes
|
||||
const progressTracker = createProgressTracker();
|
||||
|
|
@ -80,7 +83,7 @@ function useExecutedMission(
|
|||
};
|
||||
progressTracker.on("update", handleProgress);
|
||||
|
||||
const { runtime } = runServer({
|
||||
const { runtime, ready } = runServer({
|
||||
missionName,
|
||||
missionType,
|
||||
runtimeOptions: {
|
||||
|
|
@ -120,15 +123,45 @@ function useExecutedMission(
|
|||
"scripts/spdialog.cs",
|
||||
],
|
||||
},
|
||||
onMissionLoadDone: () => {
|
||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||
setState({ missionGroup, runtime, progress: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
void ready
|
||||
.then(() => {
|
||||
if (isDisposed || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Refresh the reactive runtime snapshot at mission-ready time.
|
||||
engineStore.getState().setRuntime(runtime);
|
||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||
setState({ missionGroup, runtime, progress: 1 });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Mission runtime failed to become ready:", err);
|
||||
});
|
||||
|
||||
// Subscribe as soon as the runtime exists so no mutation batches are missed
|
||||
// between mission init and React component mount.
|
||||
unsubscribeRuntimeEvents = runtime.subscribeRuntimeEvents((event) => {
|
||||
if (event.type !== "batch.flushed") {
|
||||
return;
|
||||
}
|
||||
engineStore.getState().applyRuntimeBatch(event.events, {
|
||||
tick: event.tick,
|
||||
});
|
||||
});
|
||||
// Seed store immediately; indexes are refreshed again when `ready` resolves
|
||||
// after server mission load reaches its ready state.
|
||||
engineStore.getState().setRuntime(runtime);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
progressTracker.off("update", handleProgress);
|
||||
controller.abort();
|
||||
unsubscribeRuntimeEvents?.();
|
||||
engineStore.getState().clearRuntime();
|
||||
runtime.destroy();
|
||||
};
|
||||
}, [missionName, missionType, parsedMission]);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export function MissionSelect({
|
|||
value,
|
||||
missionType,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
missionType: string;
|
||||
|
|
@ -164,6 +165,7 @@ export function MissionSelect({
|
|||
missionName: string;
|
||||
missionType: string | undefined;
|
||||
}) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -267,6 +269,7 @@ export function MissionSelect({
|
|||
<Combobox
|
||||
ref={inputRef}
|
||||
autoSelect
|
||||
disabled={disabled}
|
||||
placeholder={displayValue}
|
||||
className="MissionSelect-input"
|
||||
onFocus={() => {
|
||||
|
|
|
|||
53
src/components/PlayerHUD.module.css
Normal file
53
src/components/PlayerHUD.module.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PlayerHUD {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.ChatWindow {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.Bar {
|
||||
width: 160px;
|
||||
height: 14px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.HealthBar {
|
||||
composes: Bar;
|
||||
top: 60px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.EnergyBar {
|
||||
composes: Bar;
|
||||
top: 80px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.BarFill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
.HealthBar .BarFill {
|
||||
background: #2ecc40;
|
||||
}
|
||||
|
||||
.EnergyBar .BarFill {
|
||||
background: #0af;
|
||||
}
|
||||
185
src/components/PlayerHUD.tsx
Normal file
185
src/components/PlayerHUD.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useMemo } from "react";
|
||||
import { useDemoCurrentTime, useDemoRecording } from "./DemoProvider";
|
||||
import type { DemoEntity, DemoKeyframe, CameraModeFrame } from "../demo/types";
|
||||
import { useEngineSelector } from "../state";
|
||||
import styles from "./PlayerHUD.module.css";
|
||||
|
||||
/**
|
||||
* Binary search for the most recent keyframe at or before `time`.
|
||||
* Returns the keyframe's health/energy values (carried forward from last
|
||||
* known ghost update).
|
||||
*/
|
||||
function getStatusAtTime(
|
||||
keyframes: DemoKeyframe[],
|
||||
time: number,
|
||||
): { health: number; energy: number } {
|
||||
if (keyframes.length === 0) return { health: 1, energy: 1 };
|
||||
|
||||
let lo = 0;
|
||||
let hi = keyframes.length - 1;
|
||||
|
||||
if (time <= keyframes[0].time) {
|
||||
return {
|
||||
health: keyframes[0].health ?? 1,
|
||||
energy: keyframes[0].energy ?? 1,
|
||||
};
|
||||
}
|
||||
if (time >= keyframes[hi].time) {
|
||||
return {
|
||||
health: keyframes[hi].health ?? 1,
|
||||
energy: keyframes[hi].energy ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keyframes[mid].time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
|
||||
return {
|
||||
health: keyframes[lo].health ?? 1,
|
||||
energy: keyframes[lo].energy ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Binary search for the active CameraModeFrame at a given time. */
|
||||
function getCameraModeAtTime(
|
||||
frames: CameraModeFrame[],
|
||||
time: number,
|
||||
): CameraModeFrame | null {
|
||||
if (frames.length === 0) return null;
|
||||
if (time < frames[0].time) return null;
|
||||
if (time >= frames[frames.length - 1].time) return frames[frames.length - 1];
|
||||
|
||||
let lo = 0;
|
||||
let hi = frames.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (frames[mid].time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return frames[lo];
|
||||
}
|
||||
|
||||
function HealthBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
<div className={styles.HealthBar}>
|
||||
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnergyBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
<div className={styles.EnergyBar}>
|
||||
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWindow() {
|
||||
return <div className={styles.ChatWindow} />;
|
||||
}
|
||||
|
||||
function WeaponSlots() {
|
||||
return <div className={styles.WeaponSlots} />;
|
||||
}
|
||||
|
||||
function ToolBelt() {
|
||||
return <div className={styles.ToolBelt} />;
|
||||
}
|
||||
|
||||
function Reticle() {
|
||||
return <div className={styles.Reticle} />;
|
||||
}
|
||||
|
||||
function TeamStats() {
|
||||
return <div className={styles.TeamStats} />;
|
||||
}
|
||||
|
||||
function Compass() {
|
||||
return <div className={styles.Compass} />;
|
||||
}
|
||||
|
||||
export function PlayerHUD() {
|
||||
const recording = useDemoRecording();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const streamSnapshot = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot,
|
||||
);
|
||||
|
||||
// Build an entity lookup by ID for quick access.
|
||||
const entityMap = useMemo(() => {
|
||||
const map = new Map<string | number, DemoEntity>();
|
||||
if (!recording) return map;
|
||||
for (const entity of recording.entities) {
|
||||
map.set(entity.id, entity);
|
||||
}
|
||||
return map;
|
||||
}, [recording]);
|
||||
|
||||
if (!recording) return null;
|
||||
if (recording.isMetadataOnly || recording.isPartial) {
|
||||
const status = streamSnapshot?.status;
|
||||
if (!status) return null;
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<Compass />
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
<TeamStats />
|
||||
<Reticle />
|
||||
<ToolBelt />
|
||||
<WeaponSlots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which entity to show status for based on camera mode.
|
||||
const frame = getCameraModeAtTime(recording.cameraModes, currentTime);
|
||||
|
||||
// Resolve health and energy for the active player:
|
||||
// - First-person: health from ghost entity (DamageMask), energy from the
|
||||
// recording_player entity (CO readPacketData, higher precision).
|
||||
// - Third-person (orbit): both from the orbit target entity.
|
||||
let status = { health: 1, energy: 1 };
|
||||
if (frame?.mode === "first-person") {
|
||||
const ghostEntity = recording.controlPlayerGhostId
|
||||
? entityMap.get(recording.controlPlayerGhostId)
|
||||
: undefined;
|
||||
const recEntity = entityMap.get("recording_player");
|
||||
const ghostStatus = ghostEntity
|
||||
? getStatusAtTime(ghostEntity.keyframes, currentTime)
|
||||
: undefined;
|
||||
const recStatus = recEntity
|
||||
? getStatusAtTime(recEntity.keyframes, currentTime)
|
||||
: undefined;
|
||||
status = {
|
||||
health: ghostStatus?.health ?? 1,
|
||||
// Prefer CO energy (available every tick) over ghost energy (sparse).
|
||||
energy: recStatus?.energy ?? ghostStatus?.energy ?? 1,
|
||||
};
|
||||
} else if (frame?.mode === "third-person" && frame.orbitTargetId) {
|
||||
const entity = entityMap.get(frame.orbitTargetId);
|
||||
if (entity) {
|
||||
status = getStatusAtTime(entity.keyframes, currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<Compass />
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
<TeamStats />
|
||||
<Reticle />
|
||||
<ToolBelt />
|
||||
<WeaponSlots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { SimObject } from "./SimObject";
|
||||
import { useRuntimeChildIds, useRuntimeObjectById } from "../state";
|
||||
|
||||
export type SimGroupContextType = {
|
||||
object: TorqueObject;
|
||||
|
|
@ -16,7 +17,9 @@ export function useSimGroup() {
|
|||
}
|
||||
|
||||
export function SimGroup({ object }: { object: TorqueObject }) {
|
||||
const liveObject = useRuntimeObjectById(object._id) ?? object;
|
||||
const parent = useSimGroup();
|
||||
const childIds = useRuntimeChildIds(liveObject._id, liveObject._children ?? []);
|
||||
|
||||
const simGroup: SimGroupContextType = useMemo(() => {
|
||||
let team: number | null = null;
|
||||
|
|
@ -26,19 +29,19 @@ export function SimGroup({ object }: { object: TorqueObject }) {
|
|||
hasTeams = true;
|
||||
if (parent.team != null) {
|
||||
team = parent.team;
|
||||
} else if (object._name) {
|
||||
const match = object._name.match(/^team(\d+)$/i);
|
||||
} else if (liveObject._name) {
|
||||
const match = liveObject._name.match(/^team(\d+)$/i);
|
||||
if (match) {
|
||||
team = parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
} else if (object._name) {
|
||||
hasTeams = object._name.toLowerCase() === "teams";
|
||||
} else if (liveObject._name) {
|
||||
hasTeams = liveObject._name.toLowerCase() === "teams";
|
||||
}
|
||||
|
||||
return {
|
||||
// the current SimGroup's data
|
||||
object,
|
||||
object: liveObject,
|
||||
// the closest ancestor of this SimGroup
|
||||
parent,
|
||||
// whether this is, or is the descendant of, the "Teams" SimGroup
|
||||
|
|
@ -47,12 +50,12 @@ export function SimGroup({ object }: { object: TorqueObject }) {
|
|||
// or a descendant of one
|
||||
team,
|
||||
};
|
||||
}, [object, parent]);
|
||||
}, [liveObject, parent]);
|
||||
|
||||
return (
|
||||
<SimGroupContext.Provider value={simGroup}>
|
||||
{(object._children ?? []).map((child, i) => (
|
||||
<SimObject object={child} key={child._id} />
|
||||
{childIds.map((childId) => (
|
||||
<SimObject objectId={childId} key={childId} />
|
||||
))}
|
||||
</SimGroupContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Camera } from "./Camera";
|
|||
import { useSettings } from "./SettingsProvider";
|
||||
import { useMission } from "./MissionContext";
|
||||
import { getProperty } from "../mission";
|
||||
import { useEngineSelector, useRuntimeObjectById } from "../state";
|
||||
|
||||
const AudioEmitter = lazy(() =>
|
||||
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
|
||||
|
|
@ -51,27 +52,59 @@ const componentMap = {
|
|||
WayPoint,
|
||||
};
|
||||
|
||||
export function SimObject({ object }: { object: TorqueObject }) {
|
||||
/**
|
||||
* During demo playback, these mission-authored classes are rendered from demo
|
||||
* ghosts instead of the mission runtime scene tree.
|
||||
*/
|
||||
const demoGhostAuthoritativeClasses = new Set([
|
||||
"ForceFieldBare",
|
||||
"Item",
|
||||
"StaticShape",
|
||||
"Turret",
|
||||
]);
|
||||
|
||||
interface SimObjectProps {
|
||||
object?: TorqueObject;
|
||||
objectId?: number;
|
||||
}
|
||||
|
||||
export function SimObject({ object, objectId }: SimObjectProps) {
|
||||
const liveObject = useRuntimeObjectById(objectId ?? object?._id);
|
||||
const resolvedObject = liveObject ?? object;
|
||||
const { missionType } = useMission();
|
||||
const isDemoPlaybackActive = useEngineSelector(
|
||||
(state) => state.playback.recording != null,
|
||||
);
|
||||
|
||||
// FIXME: In theory we could make sure TorqueScript is calling `hide()`
|
||||
// based on the mission type already, which is built-in behavior, then just
|
||||
// make sure we respect the hidden/visible state here. For now do it this way.
|
||||
const shouldShowObject = useMemo(() => {
|
||||
if (!resolvedObject) {
|
||||
return false;
|
||||
}
|
||||
const missionTypesList = new Set(
|
||||
(getProperty(object, "missionTypesList") ?? "")
|
||||
(getProperty(resolvedObject, "missionTypesList") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/s+/)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (
|
||||
!missionTypesList.size || missionTypesList.has(missionType.toLowerCase())
|
||||
);
|
||||
}, [object, missionType]);
|
||||
}, [resolvedObject, missionType]);
|
||||
|
||||
const Component = componentMap[object._className];
|
||||
if (!resolvedObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = componentMap[resolvedObject._className];
|
||||
const isSuppressedByDemoAuthority =
|
||||
isDemoPlaybackActive &&
|
||||
demoGhostAuthoritativeClasses.has(resolvedObject._className);
|
||||
return shouldShowObject && Component ? (
|
||||
<Suspense>
|
||||
<Component object={object} />
|
||||
{!isSuppressedByDemoAuthority && <Component object={resolvedObject} />}
|
||||
</Suspense>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import type { TorqueObject } from "../torqueScript";
|
||||
import { useRuntime } from "./RuntimeProvider";
|
||||
import { useDatablockByName } from "../state";
|
||||
|
||||
/**
|
||||
* Look up a datablock by name from the runtime. Use with getProperty/getInt/getFloat.
|
||||
*
|
||||
* FIXME: This is not currently reactive! If new datablocks are defined, this
|
||||
* won't find them. We'd need to add an event/subscription system to the runtime
|
||||
* that fires when new datablocks are defined. Technically we should do the same
|
||||
* for the scene graph.
|
||||
*/
|
||||
/** Look up a datablock by name from runtime state (reactive). */
|
||||
export function useDatablock(
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
const runtime = useRuntime();
|
||||
if (!name) return undefined;
|
||||
return runtime.state.datablocks.get(name);
|
||||
return useDatablockByName(name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import type { TorqueObject } from "../torqueScript";
|
||||
import { useRuntime } from "./RuntimeProvider";
|
||||
import { useRuntimeObjectByName } from "../state";
|
||||
|
||||
/**
|
||||
* Look up a scene object by name from the runtime.
|
||||
*
|
||||
* FIXME: This is not currently reactive! If the object is created after
|
||||
* this hook runs, it won't be found. We'd need to add an event/subscription
|
||||
* system to the runtime that fires when objects are created.
|
||||
*/
|
||||
export function useSceneObject(name: string): TorqueObject | undefined {
|
||||
const runtime = useRuntime();
|
||||
return runtime.getObjectByName(name);
|
||||
return useRuntimeObjectByName(name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,14 @@
|
|||
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];
|
||||
}
|
||||
import type { DemoEntity } from "./types";
|
||||
|
||||
/**
|
||||
* Build a Three.js AnimationClip from a DemoEntity's keyframes.
|
||||
* Position and rotation values are converted from Torque to Three.js space.
|
||||
* Position is in Torque space [x,y,z] → converted to Three.js [y,z,x].
|
||||
* Rotation is already a Three.js quaternion [x,y,z,w].
|
||||
*/
|
||||
export function createEntityClip(entity: DemoEntity): AnimationClip {
|
||||
const { keyframes } = entity;
|
||||
|
|
@ -49,12 +22,12 @@ export function createEntityClip(entity: DemoEntity): AnimationClip {
|
|||
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;
|
||||
// Torque [x,y,z] → Three.js [y,z,x]
|
||||
positions[i * 3] = kf.position[1];
|
||||
positions[i * 3 + 1] = kf.position[2];
|
||||
positions[i * 3 + 2] = kf.position[0];
|
||||
|
||||
const [qx, qy, qz, qw] = torqueRotationToQuaternion(kf.rotation);
|
||||
const [qx, qy, qz, qw] = kf.rotation;
|
||||
quaternions[i * 4] = qx;
|
||||
quaternions[i * 4 + 1] = qy;
|
||||
quaternions[i * 4 + 2] = qz;
|
||||
|
|
@ -67,18 +40,4 @@ export function createEntityClip(entity: DemoEntity): AnimationClip {
|
|||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
1601
src/demo/parse.ts
1601
src/demo/parse.ts
File diff suppressed because it is too large
Load diff
83
src/demo/playerAnimation.ts
Normal file
83
src/demo/playerAnimation.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Movement animation selection logic replicating Torque's
|
||||
* Player::pickActionAnimation() (player.cc:2280).
|
||||
*/
|
||||
|
||||
/** Torque falling threshold: Z velocity below this = falling. */
|
||||
const FALLING_THRESHOLD = -10;
|
||||
|
||||
/** Minimum velocity dot product to count as intentional movement. */
|
||||
const MOVE_THRESHOLD = 0.1;
|
||||
|
||||
export interface MoveAnimationResult {
|
||||
/** GLB animation clip name (e.g. "Forward", "Back", "Side", "Fall", "Root"). */
|
||||
animation: string;
|
||||
/** 1 for forward playback, -1 for reversed (right strafe). */
|
||||
timeScale: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract body yaw (Torque rotationZ) from a Three.js quaternion produced by
|
||||
* `playerYawToQuaternion()`. That function builds a Y-axis rotation:
|
||||
* qy = sin(-rotZ / 2), qw = cos(-rotZ / 2)
|
||||
* So: rotZ = -2 * atan2(qy, qw)
|
||||
*/
|
||||
function quaternionToBodyYaw(q: [number, number, number, number]): number {
|
||||
return -2 * Math.atan2(q[1], q[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the movement animation for a player based on their velocity and
|
||||
* body orientation, matching Torque's pickActionAnimation().
|
||||
*
|
||||
* @param velocity Torque world-space velocity [x, y, z], or undefined for idle.
|
||||
* @param rotation Three.js quaternion from playerYawToQuaternion().
|
||||
*/
|
||||
export function pickMoveAnimation(
|
||||
velocity: [number, number, number] | undefined,
|
||||
rotation: [number, number, number, number],
|
||||
): MoveAnimationResult {
|
||||
if (!velocity) {
|
||||
return { animation: "Root", timeScale: 1 };
|
||||
}
|
||||
|
||||
const [vx, vy, vz] = velocity;
|
||||
|
||||
// Falling: Torque Z velocity below threshold.
|
||||
if (vz < FALLING_THRESHOLD) {
|
||||
return { animation: "Fall", timeScale: 1 };
|
||||
}
|
||||
|
||||
// Convert world velocity to player object space using body yaw.
|
||||
const yaw = quaternionToBodyYaw(rotation);
|
||||
const cosY = Math.cos(yaw);
|
||||
const sinY = Math.sin(yaw);
|
||||
|
||||
// Torque object space: localY = forward, localX = right.
|
||||
const localX = vx * cosY + vy * sinY;
|
||||
const localY = -vx * sinY + vy * cosY;
|
||||
|
||||
// Pick direction with largest dot product.
|
||||
const forwardDot = localY;
|
||||
const backDot = -localY;
|
||||
const leftDot = -localX;
|
||||
const rightDot = localX;
|
||||
|
||||
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
|
||||
|
||||
if (maxDot < MOVE_THRESHOLD) {
|
||||
return { animation: "Root", timeScale: 1 };
|
||||
}
|
||||
|
||||
if (maxDot === forwardDot) {
|
||||
return { animation: "Forward", timeScale: 1 };
|
||||
}
|
||||
if (maxDot === backDot) {
|
||||
return { animation: "Back", timeScale: 1 };
|
||||
}
|
||||
if (maxDot === leftDot) {
|
||||
return { animation: "Side", timeScale: 1 };
|
||||
}
|
||||
// Right strafe: same Side animation, reversed.
|
||||
return { animation: "Side", timeScale: -1 };
|
||||
}
|
||||
1630
src/demo/streaming.ts
Normal file
1630
src/demo/streaming.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,159 @@
|
|||
export interface DemoKeyframe {
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation: [number, number, number, number];
|
||||
/** Camera FOV in degrees (camera entity keyframes only). */
|
||||
fov?: number;
|
||||
/** Velocity in Torque world space [x, y, z]. */
|
||||
velocity?: [number, number, number];
|
||||
/** Normalized health (0 = dead, 1 = full). Derived from ghost damageLevel. */
|
||||
health?: number;
|
||||
/** Normalized energy (0 = empty, 1 = full). Derived from ghost energyPercent. */
|
||||
energy?: number;
|
||||
}
|
||||
|
||||
export interface DemoTracerVisual {
|
||||
kind: "tracer";
|
||||
/** Main tracer streak texture (e.g. "special/tracer00"). */
|
||||
texture: string;
|
||||
/** Edge-on cross section texture (e.g. "special/tracercross"). */
|
||||
crossTexture?: string;
|
||||
tracerLength: number;
|
||||
tracerWidth: number;
|
||||
crossViewAng: number;
|
||||
crossSize: number;
|
||||
renderCross: boolean;
|
||||
}
|
||||
|
||||
export interface DemoSpriteVisual {
|
||||
kind: "sprite";
|
||||
/** Sprite texture (e.g. "flarebase"). */
|
||||
texture: string;
|
||||
/** sRGB tint color from the datablock (e.g. flareColor). */
|
||||
color: { r: number; g: number; b: number };
|
||||
/** Billboard size in world units. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type DemoVisual = DemoTracerVisual | DemoSpriteVisual;
|
||||
|
||||
export interface DemoEntity {
|
||||
id: number | string;
|
||||
type: string;
|
||||
dataBlock?: string;
|
||||
visual?: DemoVisual;
|
||||
/** Projectile forward direction in Torque space [x, y, z]. */
|
||||
direction?: [number, number, number];
|
||||
/** Ghost index for streamed entities (debug/inspection). */
|
||||
ghostIndex?: number;
|
||||
/** Ghost class name for streamed entities (debug/inspection). */
|
||||
className?: string;
|
||||
/** Last seen datablock object id (debug/inspection). */
|
||||
dataBlockId?: number;
|
||||
/** Datablock-derived shape hint, if any (debug/inspection). */
|
||||
shapeHint?: string;
|
||||
/** Time (seconds) when this entity enters ghost scope. */
|
||||
spawnTime?: number;
|
||||
/** Time (seconds) when this entity leaves ghost scope. */
|
||||
despawnTime?: number;
|
||||
keyframes: DemoKeyframe[];
|
||||
/** Weapon shape file name for Player entities (e.g. "weapon_disc.dts"). */
|
||||
weaponShape?: string;
|
||||
/** Player name resolved from the target system string table. */
|
||||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
}
|
||||
|
||||
export interface CameraModeFrame {
|
||||
time: number;
|
||||
/** "first-person" = Player control object (camera at eye point).
|
||||
* "third-person" = Camera in OrbitObjectMode (orbiting a ghost).
|
||||
* "observer" = Camera in free/stationary/fly mode. */
|
||||
mode: "first-person" | "third-person" | "observer";
|
||||
/** Entity ID to hide in first-person (e.g. "player_5"). */
|
||||
controlEntityId?: string;
|
||||
/** Entity ID being orbited in third-person (e.g. "player_5"). */
|
||||
orbitTargetId?: string;
|
||||
}
|
||||
|
||||
export interface DemoRecording {
|
||||
duration: number;
|
||||
/** Mission name as it appears in the demo (e.g. "S5-WoodyMyrk"). */
|
||||
missionName: string | null;
|
||||
/** Game type display name from the demo (e.g. "Capture the Flag"). */
|
||||
gameType: string | null;
|
||||
/** True while playback uses deferred/streaming block parsing. */
|
||||
isMetadataOnly?: boolean;
|
||||
/** Legacy alias for `isMetadataOnly`. */
|
||||
isPartial?: boolean;
|
||||
/** Streaming parser session used for Move-tick-driven playback. */
|
||||
streamingPlayback?: DemoStreamingPlayback;
|
||||
entities: DemoEntity[];
|
||||
cameraModes: CameraModeFrame[];
|
||||
/** Ghost entity ID for the recording player (e.g. "player_5"). */
|
||||
controlPlayerGhostId?: string;
|
||||
}
|
||||
|
||||
export interface DemoStreamEntity {
|
||||
id: string;
|
||||
type: string;
|
||||
dataBlock?: string;
|
||||
visual?: DemoVisual;
|
||||
direction?: [number, number, number];
|
||||
weaponShape?: string;
|
||||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
ghostIndex?: number;
|
||||
className?: string;
|
||||
dataBlockId?: number;
|
||||
shapeHint?: string;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position?: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation?: [number, number, number, number];
|
||||
/** Velocity in Torque world space [x, y, z]. */
|
||||
velocity?: [number, number, number];
|
||||
health?: number;
|
||||
energy?: number;
|
||||
faceViewer?: boolean;
|
||||
}
|
||||
|
||||
export interface DemoStreamCamera {
|
||||
/** Timestamp in seconds for the current camera state. */
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation: [number, number, number, number];
|
||||
fov: number;
|
||||
mode: "first-person" | "third-person" | "observer";
|
||||
controlEntityId?: string;
|
||||
orbitTargetId?: string;
|
||||
/** Orbit distance used for third-person camera positioning. */
|
||||
orbitDistance?: number;
|
||||
/** Absolute control-object yaw in Torque radians (rotZ/rotationZ). */
|
||||
yaw?: number;
|
||||
/** Absolute control-object pitch in Torque radians (rotX/headX). */
|
||||
pitch?: number;
|
||||
}
|
||||
|
||||
export interface DemoStreamSnapshot {
|
||||
timeSec: number;
|
||||
exhausted: boolean;
|
||||
camera: DemoStreamCamera | null;
|
||||
entities: DemoStreamEntity[];
|
||||
controlPlayerGhostId?: string;
|
||||
status: { health: number; energy: number };
|
||||
}
|
||||
|
||||
export interface DemoStreamingPlayback {
|
||||
reset(): void;
|
||||
getSnapshot(): DemoStreamSnapshot;
|
||||
stepToTime(targetTimeSec: number, maxMoveTicks?: number): DemoStreamSnapshot;
|
||||
/** DTS shape names for weapon effects (explosions) that should be preloaded. */
|
||||
getEffectShapes(): string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,3 +143,18 @@ export function getMissionInfo(missionName: string) {
|
|||
export function getMissionList() {
|
||||
return Object.keys(manifest.missions);
|
||||
}
|
||||
|
||||
const missionsByNormalizedName = new Map(
|
||||
Object.keys(manifest.missions).map((key) => [key.toLowerCase(), key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a manifest mission key from a demo mission name like "S5-WoodyMyrk".
|
||||
* Returns null if no match is found.
|
||||
*/
|
||||
export function findMissionByDemoName(
|
||||
demoMissionName: string,
|
||||
): string | null {
|
||||
const normalized = demoMissionName.replace(/-/g, "_").toLowerCase();
|
||||
return missionsByNormalizedName.get(normalized) ?? null;
|
||||
}
|
||||
|
|
|
|||
342
src/state/diagnosticsSnapshot.ts
Normal file
342
src/state/diagnosticsSnapshot.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import type { EngineStoreState } from "./engineStore";
|
||||
import type { RuntimeEvent } from "../torqueScript";
|
||||
|
||||
interface BuildDiagnosticsSnapshotOptions {
|
||||
maxRuntimeEvents?: number;
|
||||
maxPlaybackEvents?: number;
|
||||
maxRendererSamples?: number;
|
||||
maxStreamEntities?: number;
|
||||
}
|
||||
|
||||
type JsonLike = unknown;
|
||||
|
||||
const defaultOptions: Required<BuildDiagnosticsSnapshotOptions> = {
|
||||
maxRuntimeEvents: 80,
|
||||
maxPlaybackEvents: 200,
|
||||
maxRendererSamples: 1200,
|
||||
maxStreamEntities: 40,
|
||||
};
|
||||
|
||||
function summarizeTorqueObject(value: any): JsonLike {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "TorqueObject",
|
||||
id: typeof value._id === "number" ? value._id : null,
|
||||
className: typeof value._className === "string" ? value._className : null,
|
||||
class: typeof value._class === "string" ? value._class : null,
|
||||
name: typeof value._name === "string" ? value._name : null,
|
||||
isDatablock: !!value._isDatablock,
|
||||
parentId:
|
||||
value._parent && typeof value._parent._id === "number"
|
||||
? value._parent._id
|
||||
: null,
|
||||
childCount: Array.isArray(value._children) ? value._children.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createJsonSafeValueSummarizer() {
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
function summarize(value: any, depth = 0): JsonLike {
|
||||
if (value == null) {
|
||||
return value as null | undefined;
|
||||
}
|
||||
const t = typeof value;
|
||||
if (t === "string" || t === "number" || t === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (t === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (t === "function") {
|
||||
return `[Function ${value.name || "anonymous"}]`;
|
||||
}
|
||||
if (t !== "object") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if ("_id" in value && "_className" in value) {
|
||||
return summarizeTorqueObject(value);
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (depth >= 2) {
|
||||
return {
|
||||
kind: "Array",
|
||||
length: value.length,
|
||||
};
|
||||
}
|
||||
const sample = value.slice(0, 8).map((entry) => summarize(entry, depth + 1));
|
||||
return {
|
||||
kind: "Array",
|
||||
length: value.length,
|
||||
sample,
|
||||
};
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (depth >= 2) {
|
||||
return {
|
||||
kind: value?.constructor?.name ?? "Object",
|
||||
};
|
||||
}
|
||||
|
||||
const keys = Object.keys(value).slice(0, 12);
|
||||
const summary: Record<string, JsonLike> = {};
|
||||
for (const key of keys) {
|
||||
try {
|
||||
summary[key] = summarize(value[key], depth + 1);
|
||||
} catch (error) {
|
||||
summary[key] = `[Unserializable: ${(error as Error).message}]`;
|
||||
}
|
||||
}
|
||||
if (Object.keys(value).length > keys.length) {
|
||||
summary.__truncatedKeys = Object.keys(value).length - keys.length;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
return summarize;
|
||||
}
|
||||
|
||||
function summarizeRuntimeEvent(
|
||||
event: RuntimeEvent,
|
||||
summarizeValue: (value: any, depth?: number) => JsonLike,
|
||||
): JsonLike {
|
||||
if (event.type === "object.created") {
|
||||
return {
|
||||
type: event.type,
|
||||
objectId: event.objectId,
|
||||
object: summarizeTorqueObject(event.object),
|
||||
};
|
||||
}
|
||||
if (event.type === "object.deleted") {
|
||||
return {
|
||||
type: event.type,
|
||||
objectId: event.objectId,
|
||||
object: summarizeTorqueObject(event.object),
|
||||
};
|
||||
}
|
||||
if (event.type === "field.changed") {
|
||||
return {
|
||||
type: event.type,
|
||||
objectId: event.objectId,
|
||||
field: event.field,
|
||||
value: summarizeValue(event.value),
|
||||
previousValue: summarizeValue(event.previousValue),
|
||||
object: summarizeTorqueObject(event.object),
|
||||
};
|
||||
}
|
||||
if (event.type === "method.called") {
|
||||
return {
|
||||
type: event.type,
|
||||
className: event.className,
|
||||
methodName: event.methodName,
|
||||
objectId: event.objectId ?? null,
|
||||
args: summarizeValue(event.args),
|
||||
};
|
||||
}
|
||||
if (event.type === "global.changed") {
|
||||
return {
|
||||
type: event.type,
|
||||
name: event.name,
|
||||
value: summarizeValue(event.value),
|
||||
previousValue: summarizeValue(event.previousValue),
|
||||
};
|
||||
}
|
||||
if (event.type === "batch.flushed") {
|
||||
const byType: Record<string, number> = {};
|
||||
for (const mutation of event.events) {
|
||||
byType[mutation.type] = (byType[mutation.type] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
type: event.type,
|
||||
tick: event.tick,
|
||||
eventCount: event.events.length,
|
||||
byType,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeStreamSnapshot(
|
||||
state: EngineStoreState,
|
||||
maxStreamEntities: number,
|
||||
): JsonLike {
|
||||
const snapshot = state.playback.streamSnapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entitiesByType: Record<string, number> = {};
|
||||
const visualsByKind: Record<string, number> = {};
|
||||
|
||||
for (const entity of snapshot.entities) {
|
||||
const type = entity.type || "Unknown";
|
||||
entitiesByType[type] = (entitiesByType[type] ?? 0) + 1;
|
||||
if (entity.visual?.kind) {
|
||||
visualsByKind[entity.visual.kind] = (visualsByKind[entity.visual.kind] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const entitySample = snapshot.entities.slice(0, maxStreamEntities).map((entity) => ({
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
dataBlock: entity.dataBlock ?? null,
|
||||
className: entity.className ?? null,
|
||||
ghostIndex: entity.ghostIndex ?? null,
|
||||
dataBlockId: entity.dataBlockId ?? null,
|
||||
shapeHint: entity.shapeHint ?? null,
|
||||
visualKind: entity.visual?.kind ?? null,
|
||||
hasPosition: !!entity.position,
|
||||
hasRotation: !!entity.rotation,
|
||||
}));
|
||||
|
||||
return {
|
||||
timeSec: snapshot.timeSec,
|
||||
exhausted: snapshot.exhausted,
|
||||
cameraMode: snapshot.camera?.mode ?? null,
|
||||
controlEntityId: snapshot.camera?.controlEntityId ?? null,
|
||||
orbitTargetId: snapshot.camera?.orbitTargetId ?? null,
|
||||
controlPlayerGhostId: snapshot.controlPlayerGhostId ?? null,
|
||||
entityCount: snapshot.entities.length,
|
||||
entitiesByType,
|
||||
visualsByKind,
|
||||
entitySample,
|
||||
status: snapshot.status,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRecording(state: EngineStoreState): JsonLike {
|
||||
const recording = state.playback.recording;
|
||||
if (!recording) return null;
|
||||
return {
|
||||
duration: recording.duration,
|
||||
missionName: recording.missionName,
|
||||
gameType: recording.gameType,
|
||||
isMetadataOnly: !!recording.isMetadataOnly,
|
||||
isPartial: !!recording.isPartial,
|
||||
hasStreamingPlayback: !!recording.streamingPlayback,
|
||||
entitiesCount: recording.entities.length,
|
||||
cameraModesCount: recording.cameraModes.length,
|
||||
controlPlayerGhostId: recording.controlPlayerGhostId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRuntime(state: EngineStoreState): JsonLike {
|
||||
const runtime = state.runtime.runtime;
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lastRuntimeTick: state.runtime.lastRuntimeTick,
|
||||
objectCount: runtime.state.objectsById.size,
|
||||
datablockCount: runtime.state.datablocks.size,
|
||||
globalCount: runtime.state.globals.size,
|
||||
activePackageCount: runtime.state.activePackages.length,
|
||||
executedScriptCount: runtime.state.executedScripts.size,
|
||||
failedScriptCount: runtime.state.failedScripts.size,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRendererTrend(samples: EngineStoreState["diagnostics"]["rendererSamples"]) {
|
||||
if (samples.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const first = samples[0];
|
||||
const last = samples[samples.length - 1];
|
||||
return {
|
||||
sampleCount: samples.length,
|
||||
durationSec: Number(((last.t - first.t) / 1000).toFixed(3)),
|
||||
geometriesDelta: last.geometries - first.geometries,
|
||||
texturesDelta: last.textures - first.textures,
|
||||
programsDelta: last.programs - first.programs,
|
||||
sceneObjectsDelta: last.sceneObjects - first.sceneObjects,
|
||||
visibleSceneObjectsDelta: last.visibleSceneObjects - first.visibleSceneObjects,
|
||||
renderCallsDelta: last.renderCalls - first.renderCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function countPlaybackEventsByKind(
|
||||
events: EngineStoreState["diagnostics"]["playbackEvents"],
|
||||
): Record<string, number> {
|
||||
const byKind: Record<string, number> = {};
|
||||
for (const event of events) {
|
||||
byKind[event.kind] = (byKind[event.kind] ?? 0) + 1;
|
||||
}
|
||||
return byKind;
|
||||
}
|
||||
|
||||
export function buildSerializableDiagnosticsSnapshot(
|
||||
state: EngineStoreState,
|
||||
options: BuildDiagnosticsSnapshotOptions = {},
|
||||
): JsonLike {
|
||||
const merged: Required<BuildDiagnosticsSnapshotOptions> = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
};
|
||||
|
||||
const summarizeValue = createJsonSafeValueSummarizer();
|
||||
|
||||
const runtimeEvents = state.diagnostics.recentEvents
|
||||
.slice(-merged.maxRuntimeEvents)
|
||||
.map((event) => summarizeRuntimeEvent(event, summarizeValue));
|
||||
|
||||
const playbackEvents = state.diagnostics.playbackEvents
|
||||
.slice(-merged.maxPlaybackEvents)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
meta: event.meta ? summarizeValue(event.meta) : undefined,
|
||||
}));
|
||||
|
||||
const rendererSamples = state.diagnostics.rendererSamples.slice(
|
||||
-merged.maxRendererSamples,
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
playback: {
|
||||
status: state.playback.status,
|
||||
timeMs: state.playback.timeMs,
|
||||
frameCursor: state.playback.frameCursor,
|
||||
rate: state.playback.rate,
|
||||
durationMs: state.playback.durationMs,
|
||||
recording: summarizeRecording(state),
|
||||
streamSnapshot: summarizeStreamSnapshot(state, merged.maxStreamEntities),
|
||||
},
|
||||
runtime: summarizeRuntime(state),
|
||||
diagnostics: {
|
||||
webglContextLost: state.diagnostics.webglContextLost,
|
||||
eventCounts: state.diagnostics.eventCounts,
|
||||
playbackEventCount: state.diagnostics.playbackEvents.length,
|
||||
rendererSampleCount: state.diagnostics.rendererSamples.length,
|
||||
runtimeEventCount: state.diagnostics.recentEvents.length,
|
||||
playbackEventsByKind: countPlaybackEventsByKind(
|
||||
state.diagnostics.playbackEvents,
|
||||
),
|
||||
rendererTrend: summarizeRendererTrend(rendererSamples),
|
||||
playbackEvents,
|
||||
rendererSamples,
|
||||
runtimeEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSerializableDiagnosticsJson(
|
||||
state: EngineStoreState,
|
||||
options: BuildDiagnosticsSnapshotOptions = {},
|
||||
): string {
|
||||
return JSON.stringify(buildSerializableDiagnosticsSnapshot(state, options), null, 2);
|
||||
}
|
||||
810
src/state/engineStore.ts
Normal file
810
src/state/engineStore.ts
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import type { DemoEntity, DemoRecording, DemoStreamSnapshot } from "../demo/types";
|
||||
import type {
|
||||
RuntimeEvent,
|
||||
RuntimeMutationEvent,
|
||||
TorqueObject,
|
||||
TorqueRuntime,
|
||||
} from "../torqueScript";
|
||||
|
||||
export type PlaybackStatus = "stopped" | "playing" | "paused";
|
||||
|
||||
export type PlaybackDiagnosticMetaValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export interface PlaybackDiagnosticEvent {
|
||||
t: number;
|
||||
kind: string;
|
||||
message?: string;
|
||||
playbackStatus: PlaybackStatus;
|
||||
playbackTimeMs: number;
|
||||
frameCursor: number;
|
||||
streamEntityCount: number;
|
||||
streamCameraMode: string | null;
|
||||
streamExhausted: boolean;
|
||||
meta?: Record<string, PlaybackDiagnosticMetaValue>;
|
||||
}
|
||||
|
||||
export interface RendererDiagnosticsSample {
|
||||
t: number;
|
||||
playbackStatus: PlaybackStatus;
|
||||
playbackTimeMs: number;
|
||||
frameCursor: number;
|
||||
streamEntityCount: number;
|
||||
streamCameraMode: string | null;
|
||||
streamExhausted: boolean;
|
||||
geometries: number;
|
||||
textures: number;
|
||||
programs: number;
|
||||
renderCalls: number;
|
||||
renderTriangles: number;
|
||||
renderPoints: number;
|
||||
renderLines: number;
|
||||
sceneObjects: number;
|
||||
visibleSceneObjects: number;
|
||||
jsHeapUsed?: number;
|
||||
jsHeapTotal?: number;
|
||||
jsHeapLimit?: number;
|
||||
}
|
||||
|
||||
export interface RecordPlaybackDiagnosticEventInput {
|
||||
kind: string;
|
||||
message?: string;
|
||||
meta?: Record<string, PlaybackDiagnosticMetaValue>;
|
||||
}
|
||||
|
||||
export interface AppendRendererSampleInput {
|
||||
t?: number;
|
||||
geometries: number;
|
||||
textures: number;
|
||||
programs: number;
|
||||
renderCalls: number;
|
||||
renderTriangles: number;
|
||||
renderPoints: number;
|
||||
renderLines: number;
|
||||
sceneObjects: number;
|
||||
visibleSceneObjects: number;
|
||||
jsHeapUsed?: number;
|
||||
jsHeapTotal?: number;
|
||||
jsHeapLimit?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSliceState {
|
||||
runtime: TorqueRuntime | null;
|
||||
objectVersionById: Record<number, number>;
|
||||
globalVersionByName: Record<string, number>;
|
||||
objectIdsByName: Record<string, number>;
|
||||
datablockIdsByName: Record<string, number>;
|
||||
lastRuntimeTick: number;
|
||||
}
|
||||
|
||||
export interface WorldSliceState {
|
||||
entitiesById: Record<string, DemoEntity>;
|
||||
players: string[];
|
||||
ghosts: string[];
|
||||
projectiles: string[];
|
||||
flags: string[];
|
||||
teams: Record<string, { score: number }>;
|
||||
scores: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PlaybackSliceState {
|
||||
recording: DemoRecording | null;
|
||||
status: PlaybackStatus;
|
||||
timeMs: number;
|
||||
rate: number;
|
||||
frameCursor: number;
|
||||
durationMs: number;
|
||||
streamSnapshot: DemoStreamSnapshot | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticsSliceState {
|
||||
eventCounts: Record<RuntimeEvent["type"], number>;
|
||||
recentEvents: RuntimeEvent[];
|
||||
maxRecentEvents: number;
|
||||
webglContextLost: boolean;
|
||||
playbackEvents: PlaybackDiagnosticEvent[];
|
||||
maxPlaybackEvents: number;
|
||||
rendererSamples: RendererDiagnosticsSample[];
|
||||
maxRendererSamples: number;
|
||||
}
|
||||
|
||||
export interface RuntimeTickInfo {
|
||||
tick?: number;
|
||||
}
|
||||
|
||||
export interface EngineStoreState {
|
||||
runtime: RuntimeSliceState;
|
||||
world: WorldSliceState;
|
||||
playback: PlaybackSliceState;
|
||||
diagnostics: DiagnosticsSliceState;
|
||||
setRuntime(runtime: TorqueRuntime): void;
|
||||
clearRuntime(): void;
|
||||
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo): void;
|
||||
setDemoRecording(recording: DemoRecording | null): void;
|
||||
setPlaybackTime(ms: number): void;
|
||||
setPlaybackStatus(status: PlaybackStatus): void;
|
||||
setPlaybackRate(rate: number): void;
|
||||
setPlaybackFrameCursor(frameCursor: number): void;
|
||||
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null): void;
|
||||
setWebglContextLost(lost: boolean): void;
|
||||
recordPlaybackDiagnosticEvent(
|
||||
input: RecordPlaybackDiagnosticEventInput,
|
||||
): void;
|
||||
appendRendererSample(input: AppendRendererSampleInput): void;
|
||||
clearPlaybackDiagnostics(): void;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeGlobalName(name: string): string {
|
||||
const normalized = normalizeName(name.trim());
|
||||
return normalized.startsWith("$") ? normalized.slice(1) : normalized;
|
||||
}
|
||||
|
||||
function keyFromEntityId(id: number | string): string {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function summarizeCallStack(skipFrames = 0): string | null {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return null;
|
||||
const lines = stack
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const callsiteLines = lines.slice(1 + skipFrames, 9 + skipFrames);
|
||||
return callsiteLines.length > 0 ? callsiteLines.join(" <= ") : null;
|
||||
}
|
||||
|
||||
function initialDiagnosticsCounts(): Record<RuntimeEvent["type"], number> {
|
||||
return {
|
||||
"object.created": 0,
|
||||
"object.deleted": 0,
|
||||
"field.changed": 0,
|
||||
"method.called": 0,
|
||||
"global.changed": 0,
|
||||
"batch.flushed": 0,
|
||||
};
|
||||
}
|
||||
|
||||
function projectWorldFromDemo(recording: DemoRecording | null): WorldSliceState {
|
||||
if (!recording) {
|
||||
return {
|
||||
entitiesById: {},
|
||||
players: [],
|
||||
ghosts: [],
|
||||
projectiles: [],
|
||||
flags: [],
|
||||
teams: {},
|
||||
scores: {},
|
||||
};
|
||||
}
|
||||
|
||||
const entitiesById: Record<string, DemoEntity> = {};
|
||||
const players: string[] = [];
|
||||
const ghosts: string[] = [];
|
||||
const projectiles: string[] = [];
|
||||
const flags: string[] = [];
|
||||
|
||||
for (const entity of recording.entities) {
|
||||
const entityId = keyFromEntityId(entity.id);
|
||||
entitiesById[entityId] = entity;
|
||||
|
||||
const type = entity.type.toLowerCase();
|
||||
if (type === "player") {
|
||||
players.push(entityId);
|
||||
if (entityId.startsWith("player_")) {
|
||||
ghosts.push(entityId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === "projectile") {
|
||||
projectiles.push(entityId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entity.dataBlock?.toLowerCase() === "flag" ||
|
||||
entity.dataBlock?.toLowerCase().includes("flag")
|
||||
) {
|
||||
flags.push(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entitiesById,
|
||||
players,
|
||||
ghosts,
|
||||
projectiles,
|
||||
flags,
|
||||
teams: {},
|
||||
scores: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeIndexes(runtime: TorqueRuntime): Pick<
|
||||
RuntimeSliceState,
|
||||
| "objectVersionById"
|
||||
| "globalVersionByName"
|
||||
| "objectIdsByName"
|
||||
| "datablockIdsByName"
|
||||
> {
|
||||
const objectVersionById: Record<number, number> = {};
|
||||
const globalVersionByName: Record<string, number> = {};
|
||||
const objectIdsByName: Record<string, number> = {};
|
||||
const datablockIdsByName: Record<string, number> = {};
|
||||
|
||||
for (const object of runtime.state.objectsById.values()) {
|
||||
objectVersionById[object._id] = 0;
|
||||
if (object._name) {
|
||||
objectIdsByName[normalizeName(object._name)] = object._id;
|
||||
if (object._isDatablock) {
|
||||
datablockIdsByName[normalizeName(object._name)] = object._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const globalName of runtime.state.globals.keys()) {
|
||||
globalVersionByName[normalizeGlobalName(globalName)] = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
objectVersionById,
|
||||
globalVersionByName,
|
||||
objectIdsByName,
|
||||
datablockIdsByName,
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: Omit<
|
||||
EngineStoreState,
|
||||
| "setRuntime"
|
||||
| "clearRuntime"
|
||||
| "applyRuntimeBatch"
|
||||
| "setDemoRecording"
|
||||
| "setPlaybackTime"
|
||||
| "setPlaybackStatus"
|
||||
| "setPlaybackRate"
|
||||
| "setPlaybackFrameCursor"
|
||||
| "setPlaybackStreamSnapshot"
|
||||
| "setWebglContextLost"
|
||||
| "recordPlaybackDiagnosticEvent"
|
||||
| "appendRendererSample"
|
||||
| "clearPlaybackDiagnostics"
|
||||
> = {
|
||||
runtime: {
|
||||
runtime: null,
|
||||
objectVersionById: {},
|
||||
globalVersionByName: {},
|
||||
objectIdsByName: {},
|
||||
datablockIdsByName: {},
|
||||
lastRuntimeTick: 0,
|
||||
},
|
||||
world: {
|
||||
entitiesById: {},
|
||||
players: [],
|
||||
ghosts: [],
|
||||
projectiles: [],
|
||||
flags: [],
|
||||
teams: {},
|
||||
scores: {},
|
||||
},
|
||||
playback: {
|
||||
recording: null,
|
||||
status: "stopped",
|
||||
timeMs: 0,
|
||||
rate: 1,
|
||||
frameCursor: 0,
|
||||
durationMs: 0,
|
||||
streamSnapshot: null,
|
||||
},
|
||||
diagnostics: {
|
||||
eventCounts: initialDiagnosticsCounts(),
|
||||
recentEvents: [],
|
||||
maxRecentEvents: 200,
|
||||
webglContextLost: false,
|
||||
playbackEvents: [],
|
||||
maxPlaybackEvents: 400,
|
||||
rendererSamples: [],
|
||||
maxRendererSamples: 2400,
|
||||
},
|
||||
};
|
||||
|
||||
export const engineStore = createStore<EngineStoreState>()(
|
||||
subscribeWithSelector((set) => ({
|
||||
...initialState,
|
||||
|
||||
setRuntime(runtime: TorqueRuntime) {
|
||||
const indexes = buildRuntimeIndexes(runtime);
|
||||
set((state) => ({
|
||||
...state,
|
||||
runtime: {
|
||||
runtime,
|
||||
objectVersionById: indexes.objectVersionById,
|
||||
globalVersionByName: indexes.globalVersionByName,
|
||||
objectIdsByName: indexes.objectIdsByName,
|
||||
datablockIdsByName: indexes.datablockIdsByName,
|
||||
lastRuntimeTick: 0,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearRuntime() {
|
||||
set((state) => ({
|
||||
...state,
|
||||
runtime: {
|
||||
runtime: null,
|
||||
objectVersionById: {},
|
||||
globalVersionByName: {},
|
||||
objectIdsByName: {},
|
||||
datablockIdsByName: {},
|
||||
lastRuntimeTick: 0,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo) {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const objectVersionById = { ...state.runtime.objectVersionById };
|
||||
const globalVersionByName = { ...state.runtime.globalVersionByName };
|
||||
const objectIdsByName = { ...state.runtime.objectIdsByName };
|
||||
const datablockIdsByName = { ...state.runtime.datablockIdsByName };
|
||||
const eventCounts = { ...state.diagnostics.eventCounts };
|
||||
const recentEvents = [...state.diagnostics.recentEvents];
|
||||
|
||||
const bumpVersion = (id: number | undefined | null) => {
|
||||
if (id == null) return;
|
||||
objectVersionById[id] = (objectVersionById[id] ?? 0) + 1;
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1;
|
||||
recentEvents.push(event);
|
||||
|
||||
if (event.type === "object.created") {
|
||||
const object = event.object;
|
||||
bumpVersion(event.objectId);
|
||||
if (object._name) {
|
||||
const key = normalizeName(object._name);
|
||||
objectIdsByName[key] = event.objectId;
|
||||
if (object._isDatablock) {
|
||||
datablockIdsByName[key] = event.objectId;
|
||||
}
|
||||
}
|
||||
bumpVersion(object._parent?._id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "object.deleted") {
|
||||
const object = event.object;
|
||||
delete objectVersionById[event.objectId];
|
||||
if (object?._name) {
|
||||
const key = normalizeName(object._name);
|
||||
delete objectIdsByName[key];
|
||||
if (object._isDatablock) {
|
||||
delete datablockIdsByName[key];
|
||||
}
|
||||
}
|
||||
bumpVersion(object?._parent?._id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "field.changed") {
|
||||
bumpVersion(event.objectId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "global.changed") {
|
||||
const globalName = normalizeGlobalName(event.name);
|
||||
globalVersionByName[globalName] =
|
||||
(globalVersionByName[globalName] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const tick =
|
||||
tickInfo?.tick ??
|
||||
(state.runtime.lastRuntimeTick > 0
|
||||
? state.runtime.lastRuntimeTick + 1
|
||||
: 1);
|
||||
const batchEvent: RuntimeEvent = {
|
||||
type: "batch.flushed",
|
||||
tick,
|
||||
events,
|
||||
};
|
||||
eventCounts["batch.flushed"] += 1;
|
||||
recentEvents.push(batchEvent);
|
||||
|
||||
const maxRecentEvents = state.diagnostics.maxRecentEvents;
|
||||
const boundedRecentEvents =
|
||||
recentEvents.length > maxRecentEvents
|
||||
? recentEvents.slice(recentEvents.length - maxRecentEvents)
|
||||
: recentEvents;
|
||||
|
||||
return {
|
||||
...state,
|
||||
runtime: {
|
||||
...state.runtime,
|
||||
objectVersionById,
|
||||
globalVersionByName,
|
||||
objectIdsByName,
|
||||
datablockIdsByName,
|
||||
lastRuntimeTick: tick,
|
||||
},
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
eventCounts,
|
||||
recentEvents: boundedRecentEvents,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setDemoRecording(recording: DemoRecording | null) {
|
||||
const durationMs = Math.max(0, (recording?.duration ?? 0) * 1000);
|
||||
const stack = summarizeCallStack(1);
|
||||
set((state) => {
|
||||
const snapshot = state.playback.streamSnapshot;
|
||||
const previousRecording = state.playback.recording;
|
||||
const event: PlaybackDiagnosticEvent = {
|
||||
t: Date.now(),
|
||||
kind: "recording.set",
|
||||
message: "setDemoRecording invoked",
|
||||
playbackStatus: state.playback.status,
|
||||
playbackTimeMs: state.playback.timeMs,
|
||||
frameCursor: state.playback.frameCursor,
|
||||
streamEntityCount: snapshot?.entities.length ?? 0,
|
||||
streamCameraMode: snapshot?.camera?.mode ?? null,
|
||||
streamExhausted: snapshot?.exhausted ?? false,
|
||||
meta: {
|
||||
previousMissionName: previousRecording?.missionName ?? null,
|
||||
nextMissionName: recording?.missionName ?? null,
|
||||
previousDurationSec: previousRecording
|
||||
? Number(previousRecording.duration.toFixed(3))
|
||||
: null,
|
||||
nextDurationSec: recording ? Number(recording.duration.toFixed(3)) : null,
|
||||
isNull: recording == null,
|
||||
isMetadataOnly: !!recording?.isMetadataOnly,
|
||||
isPartial: !!recording?.isPartial,
|
||||
hasStreamingPlayback: !!recording?.streamingPlayback,
|
||||
stack: stack ?? "unavailable",
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
world: projectWorldFromDemo(recording),
|
||||
playback: {
|
||||
recording,
|
||||
status: "stopped",
|
||||
timeMs: 0,
|
||||
rate: 1,
|
||||
frameCursor: 0,
|
||||
durationMs,
|
||||
streamSnapshot: null,
|
||||
},
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
webglContextLost: false,
|
||||
playbackEvents: [event],
|
||||
rendererSamples: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setPlaybackTime(ms: number) {
|
||||
set((state) => {
|
||||
const clamped = clamp(ms, 0, state.playback.durationMs);
|
||||
return {
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
timeMs: clamped,
|
||||
frameCursor: clamped,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setPlaybackStatus(status: PlaybackStatus) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
status,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setPlaybackRate(rate: number) {
|
||||
const nextRate = Number.isFinite(rate) ? clamp(rate, 0.01, 16) : 1;
|
||||
set((state) => ({
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
rate: nextRate,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setPlaybackFrameCursor(frameCursor: number) {
|
||||
const nextCursor = Number.isFinite(frameCursor) ? frameCursor : 0;
|
||||
set((state) => ({
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
frameCursor: nextCursor,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
streamSnapshot: snapshot,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setWebglContextLost(lost: boolean) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
webglContextLost: lost,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
recordPlaybackDiagnosticEvent(input: RecordPlaybackDiagnosticEventInput) {
|
||||
set((state) => {
|
||||
const snapshot = state.playback.streamSnapshot;
|
||||
const nextEvent: PlaybackDiagnosticEvent = {
|
||||
t: Date.now(),
|
||||
kind: input.kind,
|
||||
message: input.message,
|
||||
playbackStatus: state.playback.status,
|
||||
playbackTimeMs: state.playback.timeMs,
|
||||
frameCursor: state.playback.frameCursor,
|
||||
streamEntityCount: snapshot?.entities.length ?? 0,
|
||||
streamCameraMode: snapshot?.camera?.mode ?? null,
|
||||
streamExhausted: snapshot?.exhausted ?? false,
|
||||
meta: input.meta,
|
||||
};
|
||||
const playbackEvents = [
|
||||
...state.diagnostics.playbackEvents,
|
||||
nextEvent,
|
||||
];
|
||||
const maxPlaybackEvents = state.diagnostics.maxPlaybackEvents;
|
||||
const boundedPlaybackEvents =
|
||||
playbackEvents.length > maxPlaybackEvents
|
||||
? playbackEvents.slice(playbackEvents.length - maxPlaybackEvents)
|
||||
: playbackEvents;
|
||||
return {
|
||||
...state,
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
playbackEvents: boundedPlaybackEvents,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
appendRendererSample(input: AppendRendererSampleInput) {
|
||||
set((state) => {
|
||||
const snapshot = state.playback.streamSnapshot;
|
||||
const nextSample: RendererDiagnosticsSample = {
|
||||
t: input.t ?? Date.now(),
|
||||
playbackStatus: state.playback.status,
|
||||
playbackTimeMs: state.playback.timeMs,
|
||||
frameCursor: state.playback.frameCursor,
|
||||
streamEntityCount: snapshot?.entities.length ?? 0,
|
||||
streamCameraMode: snapshot?.camera?.mode ?? null,
|
||||
streamExhausted: snapshot?.exhausted ?? false,
|
||||
geometries: input.geometries,
|
||||
textures: input.textures,
|
||||
programs: input.programs,
|
||||
renderCalls: input.renderCalls,
|
||||
renderTriangles: input.renderTriangles,
|
||||
renderPoints: input.renderPoints,
|
||||
renderLines: input.renderLines,
|
||||
sceneObjects: input.sceneObjects,
|
||||
visibleSceneObjects: input.visibleSceneObjects,
|
||||
jsHeapUsed: input.jsHeapUsed,
|
||||
jsHeapTotal: input.jsHeapTotal,
|
||||
jsHeapLimit: input.jsHeapLimit,
|
||||
};
|
||||
const rendererSamples = [
|
||||
...state.diagnostics.rendererSamples,
|
||||
nextSample,
|
||||
];
|
||||
const maxRendererSamples = state.diagnostics.maxRendererSamples;
|
||||
const boundedRendererSamples =
|
||||
rendererSamples.length > maxRendererSamples
|
||||
? rendererSamples.slice(rendererSamples.length - maxRendererSamples)
|
||||
: rendererSamples;
|
||||
return {
|
||||
...state,
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
rendererSamples: boundedRendererSamples,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearPlaybackDiagnostics() {
|
||||
set((state) => ({
|
||||
...state,
|
||||
diagnostics: {
|
||||
...state.diagnostics,
|
||||
webglContextLost: false,
|
||||
playbackEvents: [],
|
||||
rendererSamples: [],
|
||||
},
|
||||
}));
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
export function useEngineStoreApi() {
|
||||
return engineStore;
|
||||
}
|
||||
|
||||
export function useEngineSelector<T>(
|
||||
selector: (state: EngineStoreState) => T,
|
||||
equality?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
return useStoreWithEqualityFn(engineStore, selector, equality);
|
||||
}
|
||||
|
||||
export function useRuntimeObjectById(
|
||||
id: number | undefined,
|
||||
): TorqueObject | undefined {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const version = useEngineSelector((state) =>
|
||||
id == null ? -1 : (state.runtime.objectVersionById[id] ?? -1),
|
||||
);
|
||||
|
||||
if (id == null || !runtime || version === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const object = runtime.state.objectsById.get(id);
|
||||
return object ? { ...object } : undefined;
|
||||
}
|
||||
|
||||
export function useRuntimeObjectField<T = any>(
|
||||
id: number | undefined,
|
||||
fieldName: string,
|
||||
): T | undefined {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const normalizedField = normalizeName(fieldName);
|
||||
useEngineSelector((state) =>
|
||||
id == null ? -1 : (state.runtime.objectVersionById[id] ?? -1),
|
||||
);
|
||||
|
||||
if (id == null || !runtime) {
|
||||
return undefined;
|
||||
}
|
||||
const object = runtime.state.objectsById.get(id);
|
||||
if (!object) {
|
||||
return undefined;
|
||||
}
|
||||
return object[normalizedField] as T;
|
||||
}
|
||||
|
||||
export function useRuntimeGlobal<T = any>(
|
||||
name: string | undefined,
|
||||
): T | undefined {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const normalizedName = name ? normalizeGlobalName(name) : "";
|
||||
useEngineSelector((state) =>
|
||||
normalizedName
|
||||
? (state.runtime.globalVersionByName[normalizedName] ?? -1)
|
||||
: -1,
|
||||
);
|
||||
|
||||
if (!runtime || !normalizedName) {
|
||||
return undefined;
|
||||
}
|
||||
return runtime.$g.get(normalizedName) as T;
|
||||
}
|
||||
|
||||
export function useRuntimeObjectByName(
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const normalizedName = name ? normalizeName(name) : "";
|
||||
const objectId = useEngineSelector((state) =>
|
||||
normalizedName ? state.runtime.objectIdsByName[normalizedName] : undefined,
|
||||
);
|
||||
const version = useEngineSelector((state) =>
|
||||
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
|
||||
);
|
||||
|
||||
if (!runtime || !normalizedName || objectId == null || version === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const object = runtime.state.objectsById.get(objectId);
|
||||
return object ? { ...object } : undefined;
|
||||
}
|
||||
|
||||
export function useDatablockByName(
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const normalizedName = name ? normalizeName(name) : "";
|
||||
const objectId = useEngineSelector((state) =>
|
||||
normalizedName
|
||||
? state.runtime.datablockIdsByName[normalizedName]
|
||||
: undefined,
|
||||
);
|
||||
const version = useEngineSelector((state) =>
|
||||
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
|
||||
);
|
||||
|
||||
if (!runtime || !normalizedName || objectId == null || version === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const object = runtime.state.objectsById.get(objectId);
|
||||
return object ? { ...object } : undefined;
|
||||
}
|
||||
|
||||
export function useRuntimeChildIds(
|
||||
parentId: number | undefined,
|
||||
fallbackChildren: readonly TorqueObject[] = [],
|
||||
): number[] {
|
||||
const runtime = useEngineSelector((state) => state.runtime.runtime);
|
||||
const version = useEngineSelector((state) =>
|
||||
parentId == null ? -1 : (state.runtime.objectVersionById[parentId] ?? -1),
|
||||
);
|
||||
|
||||
if (parentId == null) {
|
||||
return fallbackChildren.map((child) => child._id);
|
||||
}
|
||||
|
||||
if (!runtime || version === -1) {
|
||||
return fallbackChildren.map((child) => child._id);
|
||||
}
|
||||
|
||||
const parent = runtime.state.objectsById.get(parentId);
|
||||
if (!parent?._children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parent._children.map((child) => child._id);
|
||||
}
|
||||
|
||||
export function usePlaybackTimeSeconds(): number {
|
||||
return useEngineSelector((state) => state.playback.timeMs / 1000);
|
||||
}
|
||||
|
||||
export function useWorldEntity(
|
||||
entityId: number | string | undefined,
|
||||
): DemoEntity | undefined {
|
||||
const key = entityId == null ? "" : keyFromEntityId(entityId);
|
||||
return useEngineSelector((state) =>
|
||||
key ? state.world.entitiesById[key] : undefined,
|
||||
);
|
||||
}
|
||||
33
src/state/index.ts
Normal file
33
src/state/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export type {
|
||||
AppendRendererSampleInput,
|
||||
EngineStoreState,
|
||||
PlaybackDiagnosticEvent,
|
||||
PlaybackDiagnosticMetaValue,
|
||||
RecordPlaybackDiagnosticEventInput,
|
||||
PlaybackStatus,
|
||||
RendererDiagnosticsSample,
|
||||
RuntimeTickInfo,
|
||||
RuntimeSliceState,
|
||||
WorldSliceState,
|
||||
PlaybackSliceState,
|
||||
DiagnosticsSliceState,
|
||||
} from "./engineStore";
|
||||
|
||||
export {
|
||||
engineStore,
|
||||
useEngineSelector,
|
||||
useEngineStoreApi,
|
||||
useRuntimeObjectById,
|
||||
useRuntimeObjectByName,
|
||||
useRuntimeObjectField,
|
||||
useRuntimeGlobal,
|
||||
useDatablockByName,
|
||||
useRuntimeChildIds,
|
||||
usePlaybackTimeSeconds,
|
||||
useWorldEntity,
|
||||
} from "./engineStore";
|
||||
|
||||
export {
|
||||
buildSerializableDiagnosticsSnapshot,
|
||||
buildSerializableDiagnosticsJson,
|
||||
} from "./diagnosticsSnapshot";
|
||||
103
src/torqueScript/index.spec.ts
Normal file
103
src/torqueScript/index.spec.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runServer } from "./index";
|
||||
import type { TorqueRuntimeOptions } from "./types";
|
||||
|
||||
function createRuntimeOptions(
|
||||
files: Record<string, string>,
|
||||
): TorqueRuntimeOptions {
|
||||
const normalizedFiles = Object.fromEntries(
|
||||
Object.entries(files).map(([path, source]) => [
|
||||
path.replace(/\\/g, "/").toLowerCase(),
|
||||
source,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
loadScript: async (path: string) =>
|
||||
normalizedFiles[path.replace(/\\/g, "/").toLowerCase()] ?? null,
|
||||
fileSystem: {
|
||||
findFiles: (pattern: string) => {
|
||||
if (pattern.toLowerCase() === "scripts/*game.cs") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
isFile: (path: string) =>
|
||||
normalizedFiles[path.replace(/\\/g, "/").toLowerCase()] != null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runServer", () => {
|
||||
it("resolves when mission is running and notifies missionLoadDone callback", async () => {
|
||||
const runtimeOptions = createRuntimeOptions({
|
||||
"scripts/server.cs": `
|
||||
function DefaultGame::missionLoadDone(%game) {
|
||||
%game.ready = true;
|
||||
}
|
||||
|
||||
function CreateServer(%mission, %missionType) {
|
||||
new ScriptObject(Game) {
|
||||
class = "DefaultGame";
|
||||
};
|
||||
Game.missionLoadDone();
|
||||
$missionRunning = true;
|
||||
}
|
||||
`,
|
||||
"missions/TestMap.mis": "",
|
||||
});
|
||||
const onMissionLoadDone = vi.fn();
|
||||
|
||||
const { runtime, ready } = runServer({
|
||||
missionName: "TestMap",
|
||||
missionType: "CTF",
|
||||
runtimeOptions,
|
||||
onMissionLoadDone,
|
||||
});
|
||||
|
||||
await ready;
|
||||
|
||||
expect(onMissionLoadDone).toHaveBeenCalledTimes(1);
|
||||
expect(onMissionLoadDone.mock.calls[0][0]._name).toBe("Game");
|
||||
expect(runtime.$g.get("missionRunning")).toBe(true);
|
||||
runtime.destroy();
|
||||
});
|
||||
|
||||
it("does not resolve readiness from missionLoadDone alone", async () => {
|
||||
const runtimeOptions = createRuntimeOptions({
|
||||
"scripts/server.cs": `
|
||||
function DefaultGame::missionLoadDone(%game) {
|
||||
%game.ready = true;
|
||||
}
|
||||
|
||||
function CreateServer(%mission, %missionType) {
|
||||
new ScriptObject(Game) {
|
||||
class = "DefaultGame";
|
||||
};
|
||||
Game.missionLoadDone();
|
||||
}
|
||||
`,
|
||||
"missions/TestMap.mis": "",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const onMissionLoadDone = vi.fn();
|
||||
|
||||
const { runtime, ready } = runServer({
|
||||
missionName: "TestMap",
|
||||
missionType: "CTF",
|
||||
runtimeOptions: {
|
||||
...runtimeOptions,
|
||||
signal: controller.signal,
|
||||
},
|
||||
onMissionLoadDone,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(onMissionLoadDone).toHaveBeenCalledTimes(0);
|
||||
controller.abort();
|
||||
await ready;
|
||||
|
||||
expect(onMissionLoadDone).toHaveBeenCalledTimes(0);
|
||||
runtime.destroy();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,11 +9,21 @@ export type { Program } from "./ast";
|
|||
export { createBuiltins } from "./builtins";
|
||||
export { createProgressTracker, type ProgressTracker } from "./progress";
|
||||
export { createRuntime, createScriptCache } from "./runtime";
|
||||
export {
|
||||
DEFAULT_REACTIVE_FIELD_RULES,
|
||||
DEFAULT_REACTIVE_GLOBAL_NAMES,
|
||||
DEFAULT_REACTIVE_METHOD_RULES,
|
||||
} from "./reactivity";
|
||||
export { normalizePath } from "./utils";
|
||||
export type {
|
||||
BuiltinsContext,
|
||||
BuiltinsFactory,
|
||||
FileSystemHandler,
|
||||
ReactiveFieldRule,
|
||||
ReactiveMethodRule,
|
||||
RuntimeEvent,
|
||||
RuntimeEventListener,
|
||||
RuntimeMutationEvent,
|
||||
RuntimeState,
|
||||
ScriptCache,
|
||||
TorqueObject,
|
||||
|
|
@ -60,10 +70,114 @@ export interface RunServerOptions {
|
|||
export interface RunServerResult {
|
||||
/** The runtime instance - available immediately for cleanup */
|
||||
runtime: TorqueRuntime;
|
||||
/** Promise that resolves when the mission is fully loaded and CreateServer has run */
|
||||
/** Promise that resolves when the mission has reached mission-ready state */
|
||||
ready: Promise<void>;
|
||||
}
|
||||
|
||||
function isTruthyTorqueValue(value: any): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized !== "" && normalized !== "0" && normalized !== "false";
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function waitForMissionReady(
|
||||
runtime: TorqueRuntime,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onMissionLoadDone?: (game: TorqueObject) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { signal, onMissionLoadDone } = options;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let didNotifyMissionLoadDone = false;
|
||||
|
||||
const getGameObject = (): TorqueObject | undefined =>
|
||||
runtime.getObjectByName("Game");
|
||||
const isMissionRunning = (): boolean =>
|
||||
isTruthyTorqueValue(runtime.$g.get("missionRunning"));
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const notifyMissionLoadDone = (explicitGame?: TorqueObject) => {
|
||||
if (!onMissionLoadDone || didNotifyMissionLoadDone) {
|
||||
return;
|
||||
}
|
||||
const game = explicitGame ?? getGameObject();
|
||||
if (!game) {
|
||||
return;
|
||||
}
|
||||
didNotifyMissionLoadDone = true;
|
||||
onMissionLoadDone(game);
|
||||
};
|
||||
|
||||
const handleAbort = () => fail(createAbortError());
|
||||
const unsubscribeRuntimeEvents = runtime.subscribeRuntimeEvents((event) => {
|
||||
if (event.type === "global.changed" && event.name === "missionrunning") {
|
||||
if (isTruthyTorqueValue(event.value)) {
|
||||
notifyMissionLoadDone();
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type !== "batch.flushed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMissionRunning()) {
|
||||
notifyMissionLoadDone();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
function cleanup(): void {
|
||||
unsubscribeRuntimeEvents();
|
||||
signal?.removeEventListener("abort", handleAbort);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
fail(createAbortError());
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
}
|
||||
|
||||
if (isMissionRunning()) {
|
||||
notifyMissionLoadDone();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TorqueScript runtime and loads a mission.
|
||||
*
|
||||
|
|
@ -80,6 +194,7 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
|||
fileSystem,
|
||||
globals = {},
|
||||
preloadScripts = [],
|
||||
reactiveGlobalNames,
|
||||
} = runtimeOptions ?? {};
|
||||
|
||||
// server.cs has a loop that calls `findFirstFile("scripts/*Game.cs")` and
|
||||
|
|
@ -90,10 +205,14 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
|||
// sometimes map authors bundle a custom script that they don't exec() in the
|
||||
// .mis file, instead preferring to give it a "*Game.cs" name so it's loaded
|
||||
// automatically.
|
||||
const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
|
||||
const gameScripts = fileSystem?.findFiles("scripts/*Game.cs") ?? [];
|
||||
const mergedReactiveGlobalNames = reactiveGlobalNames
|
||||
? Array.from(new Set([...reactiveGlobalNames, "missionRunning"]))
|
||||
: undefined;
|
||||
|
||||
const runtime = createRuntime({
|
||||
...runtimeOptions,
|
||||
reactiveGlobalNames: mergedReactiveGlobalNames,
|
||||
globals: {
|
||||
...globals,
|
||||
"$Host::Map": missionName,
|
||||
|
|
@ -115,20 +234,13 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
|||
// Execute server.cs - it will exec() the game type and mission scripts
|
||||
serverScript.execute();
|
||||
|
||||
// Set up mission ready hook. It's unfortunate that we have to do it this
|
||||
// way, but there's no event system in TorqueScript. The problem is that
|
||||
// `CreateServer` will defer some actions using `schedule()`, so the
|
||||
// objects are created some arbitrary amount of time afterward, and we
|
||||
// don't actually know when they're ready. But, we can spy on the
|
||||
// `missionLoadDone` method using the runtime's `onMethodCalled` feature,
|
||||
// which we added specifically to solve this problem.
|
||||
if (onMissionLoadDone) {
|
||||
runtime.$.onMethodCalled(
|
||||
"DefaultGame",
|
||||
"missionLoadDone",
|
||||
onMissionLoadDone,
|
||||
);
|
||||
}
|
||||
// Tribes 2 mission readiness is script-driven (`Game.missionLoadDone()`
|
||||
// and then `$missionRunning = true` in loadMissionStage2), so we wait on
|
||||
// reactive global updates instead of hooking a specific method call.
|
||||
const missionReady = waitForMissionReady(runtime, {
|
||||
signal,
|
||||
onMissionLoadDone,
|
||||
});
|
||||
|
||||
// Run CreateServer to start the mission
|
||||
const createServerScript = await runtime.loadFromSource(
|
||||
|
|
@ -136,6 +248,7 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
|||
);
|
||||
signal?.throwIfAborted();
|
||||
createServerScript.execute();
|
||||
await missionReady;
|
||||
} catch (err) {
|
||||
// AbortError is expected when the caller cancels - don't propagate
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
|
|
|
|||
238
src/torqueScript/reactivity.ts
Normal file
238
src/torqueScript/reactivity.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import type { ReactiveFieldRule, ReactiveMethodRule } from "./types";
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeGlobalName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
return normalize(trimmed.startsWith("$") ? trimmed.slice(1) : trimmed);
|
||||
}
|
||||
|
||||
interface ClassRuleIndex {
|
||||
anyClassValues: Set<string>;
|
||||
valuesByClass: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
function getOrCreateSet(
|
||||
map: Map<string, Set<string>>,
|
||||
key: string,
|
||||
): Set<string> {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function addRuleValues(target: Set<string>, values: readonly string[]): void {
|
||||
for (const value of values) {
|
||||
target.add(normalize(value));
|
||||
}
|
||||
}
|
||||
|
||||
function buildFieldIndex(rules: readonly ReactiveFieldRule[]): ClassRuleIndex {
|
||||
const anyClassValues = new Set<string>();
|
||||
const valuesByClass = new Map<string, Set<string>>();
|
||||
|
||||
for (const rule of rules) {
|
||||
for (const className of rule.classNames) {
|
||||
const normalizedClass = normalize(className);
|
||||
if (normalizedClass === "*") {
|
||||
addRuleValues(anyClassValues, rule.fields);
|
||||
continue;
|
||||
}
|
||||
addRuleValues(getOrCreateSet(valuesByClass, normalizedClass), rule.fields);
|
||||
}
|
||||
}
|
||||
|
||||
return { anyClassValues, valuesByClass };
|
||||
}
|
||||
|
||||
function buildMethodIndex(
|
||||
rules: readonly ReactiveMethodRule[],
|
||||
): ClassRuleIndex {
|
||||
const anyClassValues = new Set<string>();
|
||||
const valuesByClass = new Map<string, Set<string>>();
|
||||
|
||||
for (const rule of rules) {
|
||||
for (const className of rule.classNames) {
|
||||
const normalizedClass = normalize(className);
|
||||
if (normalizedClass === "*") {
|
||||
addRuleValues(anyClassValues, rule.methods);
|
||||
continue;
|
||||
}
|
||||
addRuleValues(
|
||||
getOrCreateSet(valuesByClass, normalizedClass),
|
||||
rule.methods,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { anyClassValues, valuesByClass };
|
||||
}
|
||||
|
||||
function buildGlobalIndex(reactiveGlobalNames: readonly string[]): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const name of reactiveGlobalNames) {
|
||||
names.add(normalizeGlobalName(name));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function classRuleMatches(
|
||||
index: ClassRuleIndex,
|
||||
classChain: readonly string[],
|
||||
normalizedValue: string,
|
||||
): boolean {
|
||||
if (
|
||||
index.anyClassValues.has("*") ||
|
||||
index.anyClassValues.has(normalizedValue)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const className of classChain) {
|
||||
const values = index.valuesByClass.get(normalize(className));
|
||||
if (!values) {
|
||||
continue;
|
||||
}
|
||||
if (values.has("*") || values.has(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const DEFAULT_REACTIVE_FIELD_RULES: ReactiveFieldRule[] = [
|
||||
{
|
||||
classNames: ["SceneObject", "GameBase", "ShapeBase", "Item", "Player"],
|
||||
fields: [
|
||||
"position",
|
||||
"rotation",
|
||||
"scale",
|
||||
"transform",
|
||||
"hidden",
|
||||
"renderingdistance",
|
||||
"datablock",
|
||||
"shapename",
|
||||
"shapefile",
|
||||
"initialbarrel",
|
||||
"skin",
|
||||
"team",
|
||||
"health",
|
||||
"energy",
|
||||
"energylevel",
|
||||
"damagelevel",
|
||||
"damageflash",
|
||||
"damagepercent",
|
||||
"damagestate",
|
||||
"mountobject",
|
||||
"mountedimage",
|
||||
"targetposition",
|
||||
"targetrotation",
|
||||
"targetscale",
|
||||
"missiontypeslist",
|
||||
"renderenabled",
|
||||
"vis",
|
||||
"velocity",
|
||||
"name",
|
||||
],
|
||||
},
|
||||
{
|
||||
classNames: ["*"],
|
||||
fields: [
|
||||
"position",
|
||||
"rotation",
|
||||
"scale",
|
||||
"hidden",
|
||||
"shapefile",
|
||||
"datablock",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_REACTIVE_METHOD_RULES: ReactiveMethodRule[] = [
|
||||
{
|
||||
classNames: ["SceneObject", "GameBase", "ShapeBase", "SimObject"],
|
||||
methods: [
|
||||
"settransform",
|
||||
"setposition",
|
||||
"setrotation",
|
||||
"setscale",
|
||||
"sethidden",
|
||||
"setdatablock",
|
||||
"setshapename",
|
||||
"mountimage",
|
||||
"unmountimage",
|
||||
"mountobject",
|
||||
"unmountobject",
|
||||
"setdamagelevel",
|
||||
"setenergylevel",
|
||||
"schedule",
|
||||
"delete",
|
||||
"deleteallobjects",
|
||||
"add",
|
||||
"remove",
|
||||
],
|
||||
},
|
||||
{
|
||||
classNames: ["*"],
|
||||
methods: ["settransform", "setscale", "delete", "add", "remove"],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_REACTIVE_GLOBAL_NAMES = [
|
||||
"missionrunning",
|
||||
"loadingmission",
|
||||
] as const;
|
||||
|
||||
export function createReactiveFieldMatcher(
|
||||
rules: readonly ReactiveFieldRule[],
|
||||
): (classChain: readonly string[], fieldName: string) => boolean {
|
||||
const index = buildFieldIndex(rules);
|
||||
return (classChain, fieldName) =>
|
||||
classRuleMatches(index, classChain, normalize(fieldName));
|
||||
}
|
||||
|
||||
export function createReactiveMethodMatcher(
|
||||
rules: readonly ReactiveMethodRule[],
|
||||
): (classChain: readonly string[], methodName: string) => boolean {
|
||||
const index = buildMethodIndex(rules);
|
||||
return (classChain, methodName) =>
|
||||
classRuleMatches(index, classChain, normalize(methodName));
|
||||
}
|
||||
|
||||
export function createReactiveGlobalMatcher(
|
||||
reactiveGlobalNames: readonly string[],
|
||||
): (globalName: string) => boolean {
|
||||
const globalIndex = buildGlobalIndex(reactiveGlobalNames);
|
||||
return (globalName) => {
|
||||
const normalizedName = normalizeGlobalName(globalName);
|
||||
return globalIndex.has("*") || globalIndex.has(normalizedName);
|
||||
};
|
||||
}
|
||||
|
||||
export function isReactiveField(
|
||||
classChain: readonly string[],
|
||||
fieldName: string,
|
||||
rules: readonly ReactiveFieldRule[],
|
||||
): boolean {
|
||||
return createReactiveFieldMatcher(rules)(classChain, fieldName);
|
||||
}
|
||||
|
||||
export function isReactiveMethod(
|
||||
classChain: readonly string[],
|
||||
methodName: string,
|
||||
rules: readonly ReactiveMethodRule[],
|
||||
): boolean {
|
||||
return createReactiveMethodMatcher(rules)(classChain, methodName);
|
||||
}
|
||||
|
||||
export function isReactiveGlobal(
|
||||
globalName: string,
|
||||
reactiveGlobalNames: readonly string[],
|
||||
): boolean {
|
||||
return createReactiveGlobalMatcher(reactiveGlobalNames)(globalName);
|
||||
}
|
||||
|
|
@ -894,6 +894,22 @@ describe("TorqueScript Runtime", () => {
|
|||
expect(first._id).toBe(1027);
|
||||
expect(second._id).toBe(1028);
|
||||
});
|
||||
|
||||
it("keeps object IDs unique after many datablocks", () => {
|
||||
const { $, state } = run(``);
|
||||
|
||||
for (let i = 0; i < 1200; i += 1) {
|
||||
$.datablock("ItemData", `Db${i}`, null, {});
|
||||
}
|
||||
|
||||
const datablock = state.datablocks.get("Db1199");
|
||||
expect(datablock).toBeDefined();
|
||||
|
||||
const object = $.create("ScriptObject", "AfterDatablocks", {});
|
||||
expect(state.objectsById.get(datablock!._id)).toBe(datablock);
|
||||
expect(state.objectsById.get(object._id)).toBe(object);
|
||||
expect(object._id).toBeGreaterThan(datablock!._id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("object path resolution (nameToID)", () => {
|
||||
|
|
@ -2701,6 +2717,126 @@ describe("TorqueScript Runtime", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runtime reactivity events", () => {
|
||||
it("emits object lifecycle mutations and batched flush events", async () => {
|
||||
const runtime = createRuntime();
|
||||
const events: any[] = [];
|
||||
runtime.subscribeRuntimeEvents((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
runtime.$.create("ScriptObject", "LifecycleTest", {});
|
||||
runtime.$.deleteObject("LifecycleTest");
|
||||
await Promise.resolve();
|
||||
|
||||
const mutationEvents = events.filter((event) => event.type !== "batch.flushed");
|
||||
expect(mutationEvents.some((event) => event.type === "object.created")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mutationEvents.some((event) => event.type === "object.deleted")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const batchEvents = events.filter((event) => event.type === "batch.flushed");
|
||||
expect(batchEvents.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
batchEvents.some((batch) =>
|
||||
batch.events.some((event: any) => event.type === "object.created"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
batchEvents.some((batch) =>
|
||||
batch.events.some((event: any) => event.type === "object.deleted"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("emits field.changed for reactive fields and ignores untracked fields", async () => {
|
||||
const runtime = createRuntime();
|
||||
const events: any[] = [];
|
||||
runtime.subscribeRuntimeEvents((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const obj = runtime.$.create("SceneObject", "ReactiveFieldObject", {
|
||||
position: "0 0 0",
|
||||
customvalue: "before",
|
||||
});
|
||||
events.length = 0;
|
||||
|
||||
runtime.$.setProp(obj, "position", "10 20 30");
|
||||
runtime.$.setProp(obj, "customvalue", "after");
|
||||
await Promise.resolve();
|
||||
|
||||
const fieldEvents = events.filter((event) => event.type === "field.changed");
|
||||
expect(fieldEvents.length).toBe(1);
|
||||
expect(fieldEvents[0].field).toBe("position");
|
||||
expect(fieldEvents[0].value).toBe("10 20 30");
|
||||
});
|
||||
|
||||
it("emits method.called for tracked methods only", async () => {
|
||||
const runtime = createRuntime();
|
||||
const events: any[] = [];
|
||||
runtime.subscribeRuntimeEvents((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
runtime.$.registerMethod("SceneObject", "setTransform", (thisObj) => {
|
||||
thisObj.position = "1 2 3";
|
||||
});
|
||||
runtime.$.registerMethod("SceneObject", "doNothing", () => {});
|
||||
|
||||
const obj = runtime.$.create("SceneObject", "TrackedMethodObject", {});
|
||||
events.length = 0;
|
||||
|
||||
runtime.$.call(obj, "setTransform", "1 2 3");
|
||||
runtime.$.call(obj, "doNothing");
|
||||
await Promise.resolve();
|
||||
|
||||
const methodEvents = events.filter((event) => event.type === "method.called");
|
||||
expect(methodEvents.length).toBe(1);
|
||||
expect(methodEvents[0].className).toBe("sceneobject");
|
||||
expect(methodEvents[0].methodName).toBe("settransform");
|
||||
expect(methodEvents[0].objectId).toBe(obj._id);
|
||||
});
|
||||
|
||||
it("emits global.changed for tracked globals only", async () => {
|
||||
const runtime = createRuntime();
|
||||
const events: any[] = [];
|
||||
runtime.subscribeRuntimeEvents((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
runtime.$g.set("missionRunning", true);
|
||||
runtime.$g.set("customState", 123);
|
||||
await Promise.resolve();
|
||||
|
||||
const globalEvents = events.filter((event) => event.type === "global.changed");
|
||||
expect(globalEvents.length).toBe(1);
|
||||
expect(globalEvents[0].name).toBe("missionrunning");
|
||||
expect(globalEvents[0].value).toBe(true);
|
||||
});
|
||||
|
||||
it("supports overriding reactiveGlobalNames", async () => {
|
||||
const runtime = createRuntime({
|
||||
reactiveGlobalNames: ["customState"],
|
||||
});
|
||||
const events: any[] = [];
|
||||
runtime.subscribeRuntimeEvents((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
runtime.$g.set("missionRunning", true);
|
||||
runtime.$g.set("customState", 456);
|
||||
await Promise.resolve();
|
||||
|
||||
const globalEvents = events.filter((event) => event.type === "global.changed");
|
||||
expect(globalEvents.length).toBe(1);
|
||||
expect(globalEvents[0].name).toBe("customstate");
|
||||
expect(globalEvents[0].value).toBe(456);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFunction", () => {
|
||||
it("returns true for user-defined functions", () => {
|
||||
const { $ } = run(`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ import picomatch from "picomatch";
|
|||
import { generate } from "./codegen";
|
||||
import { parse, type Program } from "./index";
|
||||
import { createBuiltins as defaultCreateBuiltins } from "./builtins";
|
||||
import {
|
||||
createReactiveFieldMatcher,
|
||||
createReactiveGlobalMatcher,
|
||||
createReactiveMethodMatcher,
|
||||
DEFAULT_REACTIVE_FIELD_RULES,
|
||||
DEFAULT_REACTIVE_GLOBAL_NAMES,
|
||||
DEFAULT_REACTIVE_METHOD_RULES,
|
||||
} from "./reactivity";
|
||||
import { CaseInsensitiveMap, CaseInsensitiveSet, normalizePath } from "./utils";
|
||||
import type {
|
||||
BuiltinsContext,
|
||||
|
|
@ -14,6 +22,8 @@ import type {
|
|||
MethodStack,
|
||||
PackageState,
|
||||
RuntimeAPI,
|
||||
RuntimeEventListener,
|
||||
RuntimeMutationEvent,
|
||||
RuntimeState,
|
||||
ScriptCache,
|
||||
TorqueFunction,
|
||||
|
|
@ -59,6 +69,15 @@ function toName(value: any): string | null {
|
|||
export function createRuntime(
|
||||
options: TorqueRuntimeOptions = {},
|
||||
): TorqueRuntime {
|
||||
const reactiveFieldRules =
|
||||
options.reactiveFieldRules ?? DEFAULT_REACTIVE_FIELD_RULES;
|
||||
const reactiveMethodRules =
|
||||
options.reactiveMethodRules ?? DEFAULT_REACTIVE_METHOD_RULES;
|
||||
const reactiveGlobalNames =
|
||||
options.reactiveGlobalNames ?? DEFAULT_REACTIVE_GLOBAL_NAMES;
|
||||
const matchesReactiveField = createReactiveFieldMatcher(reactiveFieldRules);
|
||||
const matchesReactiveMethod = createReactiveMethodMatcher(reactiveMethodRules);
|
||||
const matchesReactiveGlobal = createReactiveGlobalMatcher(reactiveGlobalNames);
|
||||
const methods = new CaseInsensitiveMap<CaseInsensitiveMap<MethodStack>>();
|
||||
const functions = new CaseInsensitiveMap<FunctionStack>();
|
||||
const packages = new CaseInsensitiveMap<PackageState>();
|
||||
|
|
@ -80,6 +99,10 @@ export function createRuntime(
|
|||
>();
|
||||
// Namespace inheritance: className -> superClassName (for ScriptObject/ScriptGroup)
|
||||
const namespaceParents = new CaseInsensitiveMap<string>();
|
||||
const runtimeEventListeners = new Set<RuntimeEventListener>();
|
||||
const pendingRuntimeEvents: RuntimeMutationEvent[] = [];
|
||||
let flushScheduled = false;
|
||||
let runtimeTick = 0;
|
||||
|
||||
// Populate initial globals from options
|
||||
if (options.globals) {
|
||||
|
|
@ -154,6 +177,145 @@ export function createRuntime(
|
|||
return methods.get(className)?.get(methodName) ?? null;
|
||||
}
|
||||
|
||||
function getObjectClassChain(obj: TorqueObject | null | undefined): string[] {
|
||||
if (!obj) return [];
|
||||
const chain: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const className = obj.class || obj._className || obj._class;
|
||||
let currentClass = className ? normalize(String(className)) : "";
|
||||
|
||||
while (currentClass && !seen.has(currentClass)) {
|
||||
chain.push(currentClass);
|
||||
seen.add(currentClass);
|
||||
currentClass = namespaceParents.get(currentClass) ?? "";
|
||||
}
|
||||
|
||||
if (obj._superClass && !seen.has(obj._superClass)) {
|
||||
chain.push(obj._superClass);
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
function flushRuntimeEvents(): void {
|
||||
flushScheduled = false;
|
||||
if (pendingRuntimeEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
const events = pendingRuntimeEvents.splice(0, pendingRuntimeEvents.length);
|
||||
runtimeTick += 1;
|
||||
for (const listener of runtimeEventListeners) {
|
||||
listener({
|
||||
type: "batch.flushed",
|
||||
tick: runtimeTick,
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function emitRuntimeEvent(event: RuntimeMutationEvent): void {
|
||||
pendingRuntimeEvents.push(event);
|
||||
for (const listener of runtimeEventListeners) {
|
||||
listener(event);
|
||||
}
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true;
|
||||
queueMicrotask(flushRuntimeEvents);
|
||||
}
|
||||
}
|
||||
|
||||
function emitObjectCreated(obj: TorqueObject): void {
|
||||
emitRuntimeEvent({
|
||||
type: "object.created",
|
||||
objectId: obj._id,
|
||||
object: obj,
|
||||
});
|
||||
}
|
||||
|
||||
function emitObjectDeleted(obj: TorqueObject): void {
|
||||
emitRuntimeEvent({
|
||||
type: "object.deleted",
|
||||
objectId: obj._id,
|
||||
object: obj,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeEmitFieldChanged(
|
||||
obj: TorqueObject,
|
||||
fieldName: string,
|
||||
value: any,
|
||||
previousValue: any,
|
||||
): void {
|
||||
const normalizedField = normalize(fieldName);
|
||||
if (Object.is(value, previousValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!matchesReactiveField(getObjectClassChain(obj), normalizedField)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeEvent({
|
||||
type: "field.changed",
|
||||
objectId: obj._id,
|
||||
field: normalizedField,
|
||||
value,
|
||||
previousValue,
|
||||
object: obj,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeEmitMethodCalled(
|
||||
className: string,
|
||||
methodName: string,
|
||||
thisObj: TorqueObject,
|
||||
args: any[],
|
||||
): void {
|
||||
const classChain = getObjectClassChain(thisObj);
|
||||
if (
|
||||
!matchesReactiveMethod(
|
||||
classChain.length ? classChain : [className],
|
||||
methodName,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeEvent({
|
||||
type: "method.called",
|
||||
className: normalize(className),
|
||||
methodName: normalize(methodName),
|
||||
objectId: thisObj._id,
|
||||
args: [...args],
|
||||
});
|
||||
}
|
||||
|
||||
function maybeEmitGlobalChanged(
|
||||
name: string,
|
||||
value: any,
|
||||
previousValue: any,
|
||||
): void {
|
||||
const normalizedName = normalize(
|
||||
name.startsWith("$") ? name.slice(1) : name,
|
||||
);
|
||||
if (Object.is(value, previousValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesReactiveGlobal(normalizedName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitRuntimeEvent({
|
||||
type: "global.changed",
|
||||
name: normalizedName,
|
||||
value,
|
||||
previousValue,
|
||||
});
|
||||
}
|
||||
|
||||
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
let currentPackage: PackageState | null = null;
|
||||
let runtimeRef: TorqueRuntime | null = null;
|
||||
|
|
@ -291,6 +453,26 @@ export function createRuntime(
|
|||
}
|
||||
}
|
||||
|
||||
// Datablocks and dynamic objects share the same ID namespace.
|
||||
// Keep IDs globally unique so maps keyed by ID cannot be overwritten.
|
||||
function allocateObjectId(): number {
|
||||
while (objectsById.has(nextObjectId)) {
|
||||
nextObjectId += 1;
|
||||
}
|
||||
const id = nextObjectId;
|
||||
nextObjectId += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function allocateDatablockId(): number {
|
||||
while (objectsById.has(nextDatablockId)) {
|
||||
nextDatablockId += 1;
|
||||
}
|
||||
const id = nextDatablockId;
|
||||
nextDatablockId += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function createObject(
|
||||
className: string,
|
||||
instanceName: string | null,
|
||||
|
|
@ -298,7 +480,7 @@ export function createRuntime(
|
|||
children?: TorqueObject[],
|
||||
): TorqueObject {
|
||||
const normClass = normalize(className);
|
||||
const id = nextObjectId++;
|
||||
const id = allocateObjectId();
|
||||
|
||||
const obj: TorqueObject = {
|
||||
_class: normClass,
|
||||
|
|
@ -339,6 +521,8 @@ export function createRuntime(
|
|||
onAdd(obj);
|
||||
}
|
||||
|
||||
emitObjectCreated(obj);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +571,8 @@ export function createRuntime(
|
|||
}
|
||||
}
|
||||
|
||||
emitObjectDeleted(target);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +583,7 @@ export function createRuntime(
|
|||
props: Record<string, any>,
|
||||
): TorqueObject {
|
||||
const normClass = normalize(className);
|
||||
const id = nextDatablockId++;
|
||||
const id = allocateDatablockId();
|
||||
|
||||
const obj: TorqueObject = {
|
||||
_class: normClass,
|
||||
|
|
@ -432,6 +618,8 @@ export function createRuntime(
|
|||
datablocks.set(name, obj);
|
||||
}
|
||||
|
||||
emitObjectCreated(obj);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +647,10 @@ export function createRuntime(
|
|||
function setProp(obj: any, name: string, value: any): any {
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return value;
|
||||
resolved[normalize(name)] = value;
|
||||
const normalizedName = normalize(name);
|
||||
const previousValue = resolved[normalizedName];
|
||||
resolved[normalizedName] = value;
|
||||
maybeEmitFieldChanged(resolved, normalizedName, value, previousValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
@ -472,7 +663,10 @@ export function createRuntime(
|
|||
function setIndex(obj: any, index: any, value: any): any {
|
||||
const resolved = resolveObject(obj);
|
||||
if (resolved == null) return value;
|
||||
resolved[String(index)] = value;
|
||||
const key = String(index);
|
||||
const previousValue = resolved[key];
|
||||
resolved[key] = value;
|
||||
maybeEmitFieldChanged(resolved, key, value, previousValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
@ -481,6 +675,7 @@ export function createRuntime(
|
|||
if (resolved == null) return 0;
|
||||
const oldValue = toNum(resolved[key]);
|
||||
resolved[key] = oldValue + delta;
|
||||
maybeEmitFieldChanged(resolved, key, resolved[key], oldValue);
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
|
|
@ -536,6 +731,7 @@ export function createRuntime(
|
|||
thisObj: TorqueObject,
|
||||
args: any[],
|
||||
): void {
|
||||
maybeEmitMethodCalled(className, methodName, thisObj, args);
|
||||
const classHooks = methodHooks.get(className);
|
||||
if (classHooks) {
|
||||
const hooks = classHooks.get(methodName);
|
||||
|
|
@ -879,6 +1075,9 @@ export function createRuntime(
|
|||
|
||||
function createVariableStore(
|
||||
storage: CaseInsensitiveMap<any>,
|
||||
storeOptions?: {
|
||||
onSet?: (name: string, value: any, previousValue: any) => void;
|
||||
},
|
||||
): VariableStoreAPI {
|
||||
// TorqueScript array indexing: $foo[0] -> $foo0, $foo[0,1] -> $foo0_1
|
||||
function fullName(name: string, indices: any[]): string {
|
||||
|
|
@ -894,24 +1093,33 @@ export function createRuntime(
|
|||
throw new Error("set() requires at least a value argument");
|
||||
}
|
||||
if (args.length === 1) {
|
||||
const previousValue = storage.get(name);
|
||||
storage.set(name, args[0]);
|
||||
storeOptions?.onSet?.(name, args[0], previousValue);
|
||||
return args[0];
|
||||
}
|
||||
const value = args[args.length - 1];
|
||||
const indices = args.slice(0, -1);
|
||||
storage.set(fullName(name, indices), value);
|
||||
const key = fullName(name, indices);
|
||||
const previousValue = storage.get(key);
|
||||
storage.set(key, value);
|
||||
storeOptions?.onSet?.(key, value, previousValue);
|
||||
return value;
|
||||
},
|
||||
postInc(name: string, ...indices: any[]): number {
|
||||
const key = fullName(name, indices);
|
||||
const oldValue = toNum(storage.get(key));
|
||||
storage.set(key, oldValue + 1);
|
||||
const nextValue = oldValue + 1;
|
||||
storage.set(key, nextValue);
|
||||
storeOptions?.onSet?.(key, nextValue, oldValue);
|
||||
return oldValue;
|
||||
},
|
||||
postDec(name: string, ...indices: any[]): number {
|
||||
const key = fullName(name, indices);
|
||||
const oldValue = toNum(storage.get(key));
|
||||
storage.set(key, oldValue - 1);
|
||||
const nextValue = oldValue - 1;
|
||||
storage.set(key, nextValue);
|
||||
storeOptions?.onSet?.(key, nextValue, oldValue);
|
||||
return oldValue;
|
||||
},
|
||||
};
|
||||
|
|
@ -1018,7 +1226,9 @@ export function createRuntime(
|
|||
},
|
||||
};
|
||||
|
||||
const $g: GlobalsAPI = createVariableStore(globals);
|
||||
const $g: GlobalsAPI = createVariableStore(globals, {
|
||||
onSet: maybeEmitGlobalChanged,
|
||||
});
|
||||
|
||||
const state: RuntimeState = {
|
||||
methods,
|
||||
|
|
@ -1038,10 +1248,14 @@ export function createRuntime(
|
|||
};
|
||||
|
||||
function destroy(): void {
|
||||
if (pendingRuntimeEvents.length > 0) {
|
||||
flushRuntimeEvents();
|
||||
}
|
||||
for (const timeoutId of state.pendingTimeouts) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
state.pendingTimeouts.clear();
|
||||
runtimeEventListeners.clear();
|
||||
}
|
||||
|
||||
function getOrGenerateCode(ast: Program): string {
|
||||
|
|
@ -1248,6 +1462,12 @@ export function createRuntime(
|
|||
loadFromAST,
|
||||
call: (name: string, ...args: any[]) => $f.call(name, ...args),
|
||||
getObjectByName: (name: string) => objectsByName.get(name),
|
||||
subscribeRuntimeEvents(listener: RuntimeEventListener): () => void {
|
||||
runtimeEventListeners.add(listener);
|
||||
return () => {
|
||||
runtimeEventListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
return runtimeRef;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,68 @@ export interface TorqueObject {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ReactiveFieldRule {
|
||||
classNames: string[];
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface ReactiveMethodRule {
|
||||
classNames: string[];
|
||||
methods: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeObjectCreatedEvent {
|
||||
type: "object.created";
|
||||
objectId: number;
|
||||
object: TorqueObject;
|
||||
}
|
||||
|
||||
export interface RuntimeObjectDeletedEvent {
|
||||
type: "object.deleted";
|
||||
objectId: number;
|
||||
object?: TorqueObject;
|
||||
}
|
||||
|
||||
export interface RuntimeFieldChangedEvent {
|
||||
type: "field.changed";
|
||||
objectId: number;
|
||||
field: string;
|
||||
value: any;
|
||||
previousValue: any;
|
||||
object?: TorqueObject;
|
||||
}
|
||||
|
||||
export interface RuntimeMethodCalledEvent {
|
||||
type: "method.called";
|
||||
className: string;
|
||||
methodName: string;
|
||||
objectId?: number;
|
||||
args: any[];
|
||||
}
|
||||
|
||||
export interface RuntimeGlobalChangedEvent {
|
||||
type: "global.changed";
|
||||
name: string;
|
||||
value: any;
|
||||
previousValue: any;
|
||||
}
|
||||
|
||||
export type RuntimeMutationEvent =
|
||||
| RuntimeObjectCreatedEvent
|
||||
| RuntimeObjectDeletedEvent
|
||||
| RuntimeFieldChangedEvent
|
||||
| RuntimeMethodCalledEvent
|
||||
| RuntimeGlobalChangedEvent;
|
||||
|
||||
export interface RuntimeBatchFlushedEvent {
|
||||
type: "batch.flushed";
|
||||
tick: number;
|
||||
events: RuntimeMutationEvent[];
|
||||
}
|
||||
|
||||
export type RuntimeEvent = RuntimeMutationEvent | RuntimeBatchFlushedEvent;
|
||||
export type RuntimeEventListener = (event: RuntimeEvent) => void;
|
||||
|
||||
export type MethodStack = TorqueMethod[];
|
||||
export type FunctionStack = TorqueFunction[];
|
||||
|
||||
|
|
@ -61,6 +123,8 @@ export interface TorqueRuntime {
|
|||
call(name: string, ...args: any[]): any;
|
||||
/** Get an object by its name. Returns undefined if not found. */
|
||||
getObjectByName(name: string): TorqueObject | undefined;
|
||||
/** Subscribe to runtime reactivity events. */
|
||||
subscribeRuntimeEvents(listener: RuntimeEventListener): () => void;
|
||||
}
|
||||
|
||||
export type ScriptLoader = (path: string) => Promise<string | null>;
|
||||
|
|
@ -115,6 +179,15 @@ export interface TorqueRuntimeOptions {
|
|||
* Create with `createProgressTracker()`.
|
||||
*/
|
||||
progress?: ProgressTrackerInternal;
|
||||
/** Controls which field writes emit runtime reactivity events. */
|
||||
reactiveFieldRules?: ReactiveFieldRule[];
|
||||
/** Controls which method calls emit runtime reactivity events. */
|
||||
reactiveMethodRules?: ReactiveMethodRule[];
|
||||
/**
|
||||
* Controls which global variable writes emit runtime reactivity events.
|
||||
* Names may be specified with or without a leading `$`.
|
||||
*/
|
||||
reactiveGlobalNames?: string[];
|
||||
}
|
||||
|
||||
export interface LoadScriptOptions {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue