initial demo support

This commit is contained in:
Brian Beck 2026-02-28 17:58:09 -08:00
parent 0f2e103294
commit 359a036558
406 changed files with 10513 additions and 1158 deletions

View file

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

View file

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

View file

@ -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 (

View file

@ -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 (01). 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}

View file

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

View file

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

View file

@ -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">

View file

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

View file

@ -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]);

View file

@ -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={() => {

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

View file

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

View file

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

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

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

View file

@ -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") {

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

View file

@ -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(`

View file

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

View file

@ -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 {