add TorqueScript transpiler and runtime

This commit is contained in:
Brian Beck 2025-11-30 11:44:47 -08:00
parent c8391a1056
commit 7d10fb7dee
49 changed files with 12324 additions and 2075 deletions

View file

@ -1,7 +1,8 @@
import { memo, useEffect, useRef } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { PositionalAudio, Vector3 } from "three";
import { ConsoleObject, getPosition, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
@ -35,24 +36,16 @@ function getCachedAudioBuffer(
export const AudioEmitter = memo(function AudioEmitter({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const { debugMode } = useDebug();
const fileName = getProperty(object, "fileName")?.value ?? "";
const volume = parseFloat(getProperty(object, "volume")?.value ?? "1");
const minDistance = parseFloat(
getProperty(object, "minDistance")?.value ?? "1",
);
const maxDistance = parseFloat(
getProperty(object, "maxDistance")?.value ?? "1",
);
const minLoopGap = parseFloat(
getProperty(object, "minLoopGap")?.value ?? "0",
);
const maxLoopGap = parseFloat(
getProperty(object, "maxLoopGap")?.value ?? "0",
);
const is3D = parseInt(getProperty(object, "is3D")?.value ?? "0");
const fileName = getProperty(object, "fileName") ?? "";
const volume = getProperty(object, "volume") ?? 1;
const minDistance = getProperty(object, "minDistance") ?? 1;
const maxDistance = getProperty(object, "maxDistance") ?? 1;
const minLoopGap = getProperty(object, "minLoopGap") ?? 0;
const maxLoopGap = getProperty(object, "maxLoopGap") ?? 0;
const is3D = getProperty(object, "is3D") ?? 0;
const [x, y, z] = getPosition(object);
const { scene, camera } = useThree();

View file

@ -1,20 +1,14 @@
import { useEffect, useId, useMemo, useRef } from "react";
import { PerspectiveCamera } from "@react-three/drei";
import { useEffect, useId, useMemo } from "react";
import { useCameras } from "./CamerasProvider";
import { useSettings } from "./SettingsProvider";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
} from "../mission";
import { Quaternion, Vector3 } from "three";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation } from "../mission";
import { Vector3 } from "three";
export function Camera({ object }: { object: ConsoleObject }) {
export function Camera({ object }: { object: TorqueObject }) {
const { registerCamera, unregisterCamera } = useCameras();
const id = useId();
const dataBlock = getProperty(object, "dataBlock").value;
const dataBlock = getProperty(object, "dataBlock");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -6,7 +6,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";

View file

@ -72,8 +72,6 @@ export function DebugPlaceholder({ color }: { color: string }) {
return debugMode ? <ShapePlaceholder color={color} /> : null;
}
export type StaticShapeType = "StaticShape" | "TSStatic" | "Item" | "Turret";
export const ShapeModel = memo(function ShapeModel() {
const { shapeName } = useShapeInfo();
const { debugMode } = useDebug();

View file

@ -3,13 +3,8 @@ import { ErrorBoundary } from "react-error-boundary";
import { Mesh } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
@ -93,9 +88,9 @@ function DebugInteriorPlaceholder() {
export const InteriorInstance = memo(function InteriorInstance({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const interiorFile = getProperty(object, "interiorFile").value;
const interiorFile = getProperty(object, "interiorFile");
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useSimGroup } from "./SimGroup";
@ -61,9 +56,9 @@ const TEAM_NAMES = {
2: "Inferno",
};
export function Item({ object }: { object: ConsoleObject }) {
export function Item({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
const dataBlock = getProperty(object, "dataBlock").value;
const dataBlock = getProperty(object, "dataBlock") ?? "";
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);

View file

@ -1,21 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { loadMission } from "../loaders";
import {
executeMission,
type ParsedMission,
type ExecutedMission,
} from "../mission";
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { renderObject } from "./renderObject";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
function useMission(name: string) {
const loadScript = createScriptLoader();
function useParsedMission(name: string) {
return useQuery({
queryKey: ["mission", name],
queryKey: ["parsedMission", name],
queryFn: () => loadMission(name),
});
}
export const Mission = memo(function Mission({ name }: { name: string }) {
const { data: mission } = useMission(name);
function useExecutedMission(parsedMission: ParsedMission | undefined) {
const [executedMission, setExecutedMission] = useState<
ExecutedMission | undefined
>();
if (!mission) {
useEffect(() => {
if (!parsedMission) {
setExecutedMission(undefined);
return;
}
// Clear previous mission immediately to avoid rendering with destroyed runtime
setExecutedMission(undefined);
let cancelled = false;
let result: ExecutedMission | undefined;
async function run() {
try {
const executed = await executeMission(parsedMission, { loadScript });
if (cancelled) {
executed.runtime.destroy();
} else {
result = executed;
setExecutedMission(executed);
}
} catch (error) {
if (!cancelled) {
console.error("Failed to execute mission:", error);
}
}
}
run();
return () => {
cancelled = true;
result?.runtime.destroy();
};
}, [parsedMission]);
return executedMission;
}
export const Mission = memo(function Mission({ name }: { name: string }) {
const { data: parsedMission } = useParsedMission(name);
const executedMission = useExecutedMission(parsedMission);
if (!executedMission) {
return null;
}
return mission.objects.map((object, i) => renderObject(object, i));
return executedMission.objects.map((object, i) => renderObject(object, i));
});

View file

@ -1,9 +1,9 @@
import { createContext, useContext, useMemo } from "react";
import { ConsoleObject } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { renderObject } from "./renderObject";
export type SimGroupContextType = {
object: ConsoleObject;
object: TorqueObject;
parent: SimGroupContextType;
hasTeams: boolean;
team: null | number;
@ -15,7 +15,7 @@ export function useSimGroup() {
return useContext(SimGroupContext);
}
export function SimGroup({ object }: { object: ConsoleObject }) {
export function SimGroup({ object }: { object: TorqueObject }) {
const parent = useSimGroup();
const simGroup: SimGroupContextType = useMemo(() => {
@ -26,12 +26,14 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
hasTeams = true;
if (parent.team != null) {
team = parent.team;
} else if (object.instanceName) {
const match = object.instanceName.match(/^team(\d+)$/i);
team = parseInt(match[1], 10);
} else if (object._name) {
const match = object._name.match(/^team(\d+)$/i);
if (match) {
team = parseInt(match[1], 10);
}
}
} else if (object.instanceName) {
hasTeams = object.instanceName.toLowerCase() === "teams";
} else if (object._name) {
hasTeams = object._name.toLowerCase() === "teams";
}
return {
@ -49,7 +51,7 @@ export function SimGroup({ object }: { object: ConsoleObject }) {
return (
<SimGroupContext.Provider value={simGroup}>
{object.children.map((child, i) => renderObject(child, i))}
{(object._children ?? []).map((child, i) => renderObject(child, i))}
</SimGroupContext.Provider>
);
}

View file

@ -2,7 +2,8 @@ import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, Euler } from "three";
import { ConsoleObject, getProperty, getRotation } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
import { useThree } from "@react-three/fiber";
@ -139,27 +140,27 @@ export function SkyBox({
);
}
export function Sky({ object }: { object: ConsoleObject }) {
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
const materialList = getProperty(object, "materialList")?.value;
const materialList = getProperty(object, "materialList");
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = useMemo(() => {
const distanceString = getProperty(object, "fogDistance")?.value;
if (distanceString) {
return parseFloat(distanceString);
}
return getProperty(object, "fogDistance");
}, [object]);
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor")?.value;
const colorString = getProperty(object, "fogColor");
if (colorString) {
// `colorString` might specify an alpha value, but three.js doesn't
// support opacity on fog or scene backgrounds, so ignore it.
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = colorString
.split(" ")
.map((s: string) => parseFloat(s));
return [
new Color().setRGB(r, g, b),
new Color().setRGB(r, g, b).convertSRGBToLinear(),

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
@ -20,6 +15,8 @@ const dataBlockToShapeName = {
GeneratorLarge: "station_generator_large.dts",
InteriorFlagStand: "int_flagstand.dts",
LightMaleHuman_Dead: "light_male_dead.dts",
MediumMaleHuman_Dead: "medium_male_dead.dts",
HeavyMaleHuman_Dead: "heavy_male_dead.dts",
LogoProjector: "teamlogo_projector.dts",
SensorLargePulse: "sensor_pulse_large.dts",
SensorMediumPulse: "sensor_pulse_medium.dts",
@ -44,8 +41,8 @@ function getDataBlockShape(dataBlock: string) {
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
}
export function StaticShape({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
export function StaticShape({ object }: { object: TorqueObject }) {
const dataBlock = getProperty(object, "dataBlock") ?? "";
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -1,25 +1,29 @@
import { useMemo } from "react";
import { Color } from "three";
import { ConsoleObject, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
export function Sun({ object }: { object: ConsoleObject }) {
export function Sun({ object }: { object: TorqueObject }) {
const direction = useMemo(() => {
const directionStr = getProperty(object, "direction")?.value ?? "0 0 -1";
const [x, y, z] = directionStr.split(" ").map((s) => parseFloat(s));
const directionStr = getProperty(object, "direction") ?? "0 0 -1";
// Note: This is a space-separated string, so we split and parse each component.
const [x, y, z] = directionStr.split(" ").map((s: string) => parseFloat(s));
// Scale the direction vector to position the light far from the scene
const scale = 5000;
return [x * scale, y * scale, z * scale] as [number, number, number];
}, [object]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color")?.value ?? "1 1 1 1";
const [r, g, b] = colorStr.split(" ").map((s) => parseFloat(s));
const colorStr = getProperty(object, "color") ?? "1 1 1 1";
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = colorStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient")?.value ?? "0.5 0.5 0.5 1";
const [r, g, b] = ambientStr.split(" ").map((s) => parseFloat(s));
const ambientStr = getProperty(object, "ambient") ?? "0.5 0.5 0.5 1";
// Note: This is a space-separated string, so we split and parse each component.
const [r, g, b] = ambientStr.split(" ").map((s: string) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);

View file

@ -1,17 +1,12 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: ConsoleObject }) {
const shapeName = getProperty(object, "shapeName").value;
export function TSStatic({ object }: { object: TorqueObject }) {
const shapeName = getProperty(object, "shapeName");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -15,13 +15,8 @@ import {
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import {
setupColor,
setupMask,
@ -204,28 +199,19 @@ function TerrainMaterial({
export const TerrainBlock = memo(function TerrainBlock({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const terrainFile: string = getProperty(object, "terrainFile").value;
const terrainFile = getProperty(object, "terrainFile");
const squareSize = useMemo(() => {
const squareSizeString: string | undefined = getProperty(
object,
"squareSize",
)?.value;
return squareSizeString
? parseInt(squareSizeString, 10)
: DEFAULT_SQUARE_SIZE;
return getProperty(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
}, [object]);
const emptySquares: number[] = useMemo(() => {
const emptySquaresString: string | undefined = getProperty(
object,
"emptySquares",
)?.value;
return emptySquaresString
? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
const emptySquaresValue = getProperty(object, "emptySquares");
// Note: This is a space-separated string, so we split and parse each component.
return emptySquaresValue
? emptySquaresValue.split(" ").map((s: string) => parseInt(s, 10))
: [];
}, [object]);

View file

@ -1,12 +1,7 @@
import { Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { DebugPlaceholder, ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
@ -34,9 +29,9 @@ function getDataBlockShape(dataBlock: string) {
return _caseInsensitiveLookup[dataBlock.toLowerCase()];
}
export function Turret({ object }: { object: ConsoleObject }) {
const dataBlock = getProperty(object, "dataBlock").value;
const initialBarrel = getProperty(object, "initialBarrel")?.value;
export function Turret({ object }: { object: TorqueObject }) {
const dataBlock = getProperty(object, "dataBlock") ?? "";
const initialBarrel = getProperty(object, "initialBarrel");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);

View file

@ -2,13 +2,8 @@ import { memo, Suspense, useEffect, useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { BoxGeometry, DoubleSide } from "three";
import { textureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
export function WaterMaterial({
@ -35,14 +30,14 @@ export function WaterMaterial({
export const WaterBlock = memo(function WaterBlock({
object,
}: {
object: ConsoleObject;
object: TorqueObject;
}) {
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const surfaceTexture =
getProperty(object, "surfaceTexture")?.value ?? "liquidTiles/BlueWater";
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
const geometry = useMemo(() => {
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);

View file

@ -1,12 +1,13 @@
import { useMemo } from "react";
import { ConsoleObject, getPosition, getProperty } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import { FloatingLabel } from "./FloatingLabel";
import { useSimGroup } from "./SimGroup";
export function WayPoint({ object }: { object: ConsoleObject }) {
export function WayPoint({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
const position = useMemo(() => getPosition(object), [object]);
const label = getProperty(object, "name")?.value;
const label = getProperty(object, "name");
return label ? (
<FloatingLabel position={position} opacity={0.6}>

View file

@ -1,4 +1,4 @@
import { ConsoleObject } from "../mission";
import type { TorqueObject } from "../torqueScript";
import { TerrainBlock } from "./TerrainBlock";
import { WaterBlock } from "./WaterBlock";
import { SimGroup } from "./SimGroup";
@ -29,7 +29,7 @@ const componentMap = {
WayPoint,
};
export function renderObject(object: ConsoleObject, key: string | number) {
const Component = componentMap[object.className];
export function renderObject(object: TorqueObject, key: string | number) {
const Component = componentMap[object._className];
return Component ? <Component key={key} object={object} /> : null;
}