diff --git a/src/components/ForceFieldBare.tsx b/src/components/ForceFieldBare.tsx
new file mode 100644
index 00000000..a0e4a40c
--- /dev/null
+++ b/src/components/ForceFieldBare.tsx
@@ -0,0 +1,316 @@
+import { memo, Suspense, useEffect, useMemo, useRef } from "react";
+import { useTexture } from "@react-three/drei";
+import { useFrame } from "@react-three/fiber";
+import {
+ AdditiveBlending,
+ BoxGeometry,
+ Color,
+ DoubleSide,
+ NoColorSpace,
+ RepeatWrapping,
+ ShaderMaterial,
+ Texture,
+ Vector2,
+} from "three";
+import type { TorqueObject } from "../torqueScript";
+import { getPosition, getProperty, getRotation, getScale } from "../mission";
+import { textureToUrl } from "../loaders";
+import { useSettings } from "./SettingsProvider";
+import { useDatablock } from "./useDatablock";
+
+/**
+ * Get texture URLs from datablock.
+ * Datablock defines textures as texture[0], texture[1], etc. which become
+ * properties texture0, texture1, etc. (TorqueScript array indexing flattens to suffix)
+ */
+function getTextureUrls(
+ datablock: TorqueObject | undefined,
+ numFrames: number,
+): string[] {
+ const textures: string[] = [];
+ for (let i = 0; i < numFrames; i++) {
+ // TorqueScript array indexing: texture[0] -> texture0
+ const texturePath = getProperty(datablock, `texture${i}`);
+ if (texturePath) {
+ textures.push(textureToUrl(texturePath));
+ }
+ }
+ return textures;
+}
+
+function parseColor(colorStr: string): [number, number, number] {
+ const parts = colorStr.split(" ").map((s) => parseFloat(s));
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
+}
+
+// Vertex shader
+const vertexShader = `
+varying vec2 vUv;
+
+void main() {
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`;
+
+// Fragment shader - handles frame animation, UV scrolling, and color tinting
+// NOTE: Shader supports up to 5 texture frames (hardcoded samplers)
+const fragmentShader = `
+uniform sampler2D frame0;
+uniform sampler2D frame1;
+uniform sampler2D frame2;
+uniform sampler2D frame3;
+uniform sampler2D frame4;
+uniform int currentFrame;
+uniform float vScroll;
+uniform vec2 uvScale;
+uniform vec3 tintColor;
+uniform float opacity;
+
+varying vec2 vUv;
+
+// FIXME: This gamma correction may not be accurate. Tribes 2 had no gamma correction;
+// Three.js applies gamma on output, so we pre-darken to compensate. The result is
+// close but not quite right - the force field is still slightly more opaque than in T2.
+vec3 srgbToLinear(vec3 srgb) {
+ return pow(srgb, vec3(2.2));
+}
+
+void main() {
+ // Scale and scroll UVs
+ vec2 scrolledUv = vec2(vUv.x * uvScale.x, vUv.y * uvScale.y + vScroll);
+
+ // Sample the current frame
+ vec4 texColor;
+ if (currentFrame == 0) {
+ texColor = texture2D(frame0, scrolledUv);
+ } else if (currentFrame == 1) {
+ texColor = texture2D(frame1, scrolledUv);
+ } else if (currentFrame == 2) {
+ texColor = texture2D(frame2, scrolledUv);
+ } else if (currentFrame == 3) {
+ texColor = texture2D(frame3, scrolledUv);
+ } else {
+ texColor = texture2D(frame4, scrolledUv);
+ }
+
+ // Apply color tint with constant opacity (like Tribes 2's GL_MODULATE)
+ vec3 finalColor = texColor.rgb * tintColor;
+
+ // Pre-darken to counteract renderer's sRGB gamma encoding
+ // This makes additive blending behave like Tribes 2's non-gamma-corrected output
+ finalColor = srgbToLinear(finalColor);
+
+ // FIXME: Halving opacity is a rough approximation to compensate for front+back faces
+ // both contributing (BoxGeometry with DoubleSide causes additive stacking that Tribes 2's
+ // thin quads didn't have). This doesn't account for viewing angles where more faces are visible.
+ gl_FragColor = vec4(finalColor, opacity * 0.5);
+}
+`;
+
+function setupForceFieldTexture(texture: Texture) {
+ texture.wrapS = texture.wrapT = RepeatWrapping;
+ // FIXME: Using NoColorSpace to treat textures as raw linear values like Tribes 2 did,
+ // but the interaction with the renderer's sRGB output and shader gamma correction
+ // may not be fully correct. The force field appears close but not identical to T2.
+ texture.colorSpace = NoColorSpace;
+ texture.flipY = false;
+ texture.needsUpdate = true;
+}
+
+/**
+ * Creates a box geometry with origin at corner (like Torque) instead of center.
+ * Handles disposal automatically.
+ */
+function useCornerBoxGeometry(scale: [number, number, number]) {
+ const geometry = useMemo(() => {
+ const [x, y, z] = scale;
+ const geom = new BoxGeometry(x, y, z);
+ geom.translate(x / 2, y / 2, z / 2);
+ return geom;
+ }, [scale]);
+
+ useEffect(() => {
+ return () => geometry.dispose();
+ }, [geometry]);
+
+ return geometry;
+}
+
+interface ForceFieldGeometryProps {
+ scale: [number, number, number];
+ color: [number, number, number];
+ baseTranslucency: number;
+}
+
+interface ForceFieldMeshProps extends ForceFieldGeometryProps {
+ textureUrls: string[];
+ numFrames: number;
+ framesPerSec: number;
+ scrollSpeed: number;
+ umapping: number;
+ vmapping: number;
+}
+
+function ForceFieldMesh({
+ scale,
+ color,
+ baseTranslucency,
+ textureUrls,
+ numFrames,
+ framesPerSec,
+ scrollSpeed,
+ umapping,
+ vmapping,
+}: ForceFieldMeshProps) {
+ const { animationEnabled } = useSettings();
+ const geometry = useCornerBoxGeometry(scale);
+ const textures = useTexture(textureUrls, (textures) => {
+ textures.forEach((tex) => setupForceFieldTexture(tex));
+ });
+
+ // Create shader material once (uniforms updated in useFrame)
+ const material = useMemo(() => {
+ // UV scale based on the two largest dimensions (force fields are thin planes)
+ const dims = [...scale].sort((a, b) => b - a);
+ const uvScale = new Vector2(dims[0] * umapping, dims[1] * vmapping);
+
+ // Use first texture as fallback for unused slots
+ const fallbackTex = textures[0];
+ return new ShaderMaterial({
+ uniforms: {
+ frame0: { value: textures[0] ?? fallbackTex },
+ frame1: { value: textures[1] ?? fallbackTex },
+ frame2: { value: textures[2] ?? fallbackTex },
+ frame3: { value: textures[3] ?? fallbackTex },
+ frame4: { value: textures[4] ?? fallbackTex },
+ currentFrame: { value: 0 },
+ vScroll: { value: 0 },
+ uvScale: { value: uvScale },
+ tintColor: { value: new Color(...color) },
+ opacity: { value: baseTranslucency },
+ },
+ vertexShader,
+ fragmentShader,
+ transparent: true,
+ blending: AdditiveBlending,
+ side: DoubleSide,
+ depthWrite: false,
+ });
+ }, [textures, scale, umapping, vmapping, color, baseTranslucency]);
+
+ useEffect(() => {
+ return () => material.dispose();
+ }, [material]);
+
+ // Animation state
+ const elapsedRef = useRef(0);
+
+ // Animate frame and scroll
+ useFrame((_, delta) => {
+ if (!animationEnabled) {
+ elapsedRef.current = 0;
+ material.uniforms.currentFrame.value = 0;
+ material.uniforms.vScroll.value = 0;
+ return;
+ }
+
+ elapsedRef.current += delta;
+
+ // Frame animation
+ material.uniforms.currentFrame.value =
+ Math.floor(elapsedRef.current * framesPerSec) % numFrames;
+
+ // UV scrolling
+ material.uniforms.vScroll.value = elapsedRef.current * scrollSpeed;
+ });
+
+ return ;
+}
+
+function ForceFieldFallback({
+ scale,
+ color,
+ baseTranslucency,
+}: ForceFieldGeometryProps) {
+ const geometry = useCornerBoxGeometry(scale);
+
+ return (
+
+
+
+ );
+}
+
+export const ForceFieldBare = memo(function ForceFieldBare({
+ object,
+}: {
+ object: TorqueObject;
+}) {
+ const position = useMemo(() => getPosition(object), [object]);
+ const quaternion = useMemo(() => getRotation(object), [object]);
+ const scale = useMemo(() => getScale(object), [object]);
+
+ // Look up the datablock - rendering properties like color, translucency, etc.
+ // are stored on the datablock, not the instance (see forceFieldBare.cc)
+ const datablock = useDatablock(getProperty(object, "dataBlock"));
+
+ // All rendering properties come from the datablock
+ const colorStr = getProperty(datablock, "color");
+ const color = useMemo(
+ () =>
+ colorStr ? parseColor(colorStr) : ([1, 1, 1] as [number, number, number]),
+ [colorStr],
+ );
+
+ const baseTranslucency =
+ parseFloat(getProperty(datablock, "baseTranslucency")) || 1;
+ const numFrames = parseInt(getProperty(datablock, "numFrames"), 10) || 1;
+ const framesPerSec = parseFloat(getProperty(datablock, "framesPerSec")) || 1;
+ const scrollSpeed = parseFloat(getProperty(datablock, "scrollSpeed")) || 0;
+ const umapping = parseFloat(getProperty(datablock, "umapping")) || 1;
+ const vmapping = parseFloat(getProperty(datablock, "vmapping")) || 1;
+
+ const textureUrls = useMemo(
+ () => getTextureUrls(datablock, numFrames),
+ [datablock, numFrames],
+ );
+
+ // Don't render if we have no textures
+ if (textureUrls.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ }
+ >
+
+
+
+ );
+});
diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx
index ee8727a8..b78d9511 100644
--- a/src/components/Mission.tsx
+++ b/src/components/Mission.tsx
@@ -5,12 +5,13 @@ import { type ParsedMission } from "../mission";
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { renderObject } from "./renderObject";
import { memo, useEffect, useState } from "react";
-import { TickProvider } from "./TickProvider";
+import { RuntimeProvider } from "./RuntimeProvider";
import {
createScriptCache,
FileSystemHandler,
runServer,
TorqueObject,
+ TorqueRuntime,
} from "../torqueScript";
import {
getResourceKey,
@@ -46,11 +47,19 @@ function useParsedMission(name: string) {
});
}
+interface ExecutedMissionState {
+ missionGroup: TorqueObject | undefined;
+ runtime: TorqueRuntime | undefined;
+}
+
function useExecutedMission(
missionName: string,
parsedMission: ParsedMission | undefined,
-) {
- const [missionGroup, setMissionGroup] = useState();
+): ExecutedMissionState {
+ const [state, setState] = useState({
+ missionGroup: undefined,
+ runtime: undefined,
+ });
useEffect(() => {
if (!parsedMission) {
@@ -83,7 +92,7 @@ function useExecutedMission(
},
onMissionLoadDone: () => {
const missionGroup = runtime.getObjectByName("MissionGroup");
- setMissionGroup(missionGroup);
+ setState({ missionGroup, runtime });
},
});
@@ -93,7 +102,7 @@ function useExecutedMission(
};
}, [missionName, parsedMission]);
- return missionGroup;
+ return state;
}
interface MissionProps {
@@ -106,8 +115,8 @@ export const Mission = memo(function Mission({
onLoadingChange,
}: MissionProps) {
const { data: parsedMission } = useParsedMission(name);
- const missionGroup = useExecutedMission(name, parsedMission);
- const isLoading = !missionGroup;
+ const { missionGroup, runtime } = useExecutedMission(name, parsedMission);
+ const isLoading = !missionGroup || !runtime;
useEffect(() => {
onLoadingChange?.(isLoading);
@@ -117,5 +126,9 @@ export const Mission = memo(function Mission({
return null;
}
- return {renderObject(missionGroup)};
+ return (
+
+ {renderObject(missionGroup)}
+
+ );
});
diff --git a/src/components/RuntimeProvider.tsx b/src/components/RuntimeProvider.tsx
new file mode 100644
index 00000000..994915aa
--- /dev/null
+++ b/src/components/RuntimeProvider.tsx
@@ -0,0 +1,26 @@
+import { createContext, ReactNode, useContext } from "react";
+import type { TorqueRuntime } from "../torqueScript";
+import { TickProvider } from "./TickProvider";
+
+const RuntimeContext = createContext(null);
+
+interface RuntimeProviderProps {
+ runtime: TorqueRuntime;
+ children: ReactNode;
+}
+
+export function RuntimeProvider({ runtime, children }: RuntimeProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRuntime(): TorqueRuntime {
+ const runtime = useContext(RuntimeContext);
+ if (!runtime) {
+ throw new Error("useRuntime must be used within a RuntimeProvider");
+ }
+ return runtime;
+}
diff --git a/src/components/TickProvider.tsx b/src/components/TickProvider.tsx
index 8a440b30..322aad9b 100644
--- a/src/components/TickProvider.tsx
+++ b/src/components/TickProvider.tsx
@@ -9,19 +9,24 @@ import {
} from "react";
import { useFrame } from "@react-three/fiber";
+/** Ticks per second, matching the Torque engine tick rate. */
export const TICK_RATE = 32;
const TICK_INTERVAL = 1 / TICK_RATE;
-type TickCallback = (tick: number) => void;
+export type TickCallback = (tick: number) => void;
-type TickContextValue = {
+interface TickContextValue {
subscribe: (callback: TickCallback) => () => void;
getTick: () => number;
-};
+}
const TickContext = createContext(null);
-export function TickProvider({ children }: { children: ReactNode }) {
+interface TickProviderProps {
+ children: ReactNode;
+}
+
+export function TickProvider({ children }: TickProviderProps) {
const callbacksRef = useRef | undefined>(undefined);
const accumulatorRef = useRef(0);
const tickRef = useRef(0);
diff --git a/src/components/renderObject.tsx b/src/components/renderObject.tsx
index 358f8fdb..e4b1978a 100644
--- a/src/components/renderObject.tsx
+++ b/src/components/renderObject.tsx
@@ -12,10 +12,12 @@ import { Turret } from "./Turret";
import { AudioEmitter } from "./AudioEmitter";
import { WayPoint } from "./WayPoint";
import { Camera } from "./Camera";
+import { ForceFieldBare } from "./ForceFieldBare";
const componentMap = {
AudioEmitter,
Camera,
+ ForceFieldBare,
InteriorInstance,
Item,
SimGroup,
diff --git a/src/components/useDatablock.ts b/src/components/useDatablock.ts
new file mode 100644
index 00000000..5493e52a
--- /dev/null
+++ b/src/components/useDatablock.ts
@@ -0,0 +1,18 @@
+import type { TorqueObject } from "../torqueScript";
+import { useRuntime } from "./RuntimeProvider";
+
+/**
+ * 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.
+ */
+export function useDatablock(
+ name: string | undefined,
+): TorqueObject | undefined {
+ const runtime = useRuntime();
+ if (!name) return undefined;
+ return runtime.state.datablocks.get(name);
+}
diff --git a/src/components/useIflTexture.ts b/src/components/useIflTexture.ts
index 8e507e88..fcb351d9 100644
--- a/src/components/useIflTexture.ts
+++ b/src/components/useIflTexture.ts
@@ -8,7 +8,7 @@ import {
SRGBColorSpace,
Texture,
} from "three";
-import { loadImageFrameList, textureFrameToUrl } from "../loaders";
+import { iflTextureToUrl, loadImageFrameList } from "../loaders";
import { useTick } from "./TickProvider";
import { useSettings } from "./SettingsProvider";
@@ -119,8 +119,8 @@ export function useIflTexture(iflPath: string): Texture {
});
const textureUrls = useMemo(
- () => frames.map((frame) => textureFrameToUrl(frame.name)),
- [frames],
+ () => frames.map((frame) => iflTextureToUrl(frame.name, iflPath)),
+ [frames, iflPath],
);
const textures = useTexture(textureUrls);
diff --git a/src/loaders.ts b/src/loaders.ts
index 8c57399f..e9da5248 100644
--- a/src/loaders.ts
+++ b/src/loaders.ts
@@ -6,6 +6,7 @@ import {
getStandardTextureResourceKey,
} from "./manifest";
import { parseMissionScript } from "./mission";
+import { normalizePath } from "./stringUtils";
import { parseTerrainBuffer } from "./terrain";
export const BASE_URL = "/t2-mapper";
@@ -50,10 +51,14 @@ export function terrainTextureToUrl(name: string) {
return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL);
}
-export function textureFrameToUrl(fileName: string) {
- const resourceKey = getStandardTextureResourceKey(
- `textures/skins/${fileName}`,
- );
+export function iflTextureToUrl(name: string, iflPath: string) {
+ // Paths inside IFL files are relative to the IFL file, so we need to prepend
+ // the IFL's dir (if any) to the parsed paths.
+ const pathParts = normalizePath(iflPath).split("/");
+ const iflDir =
+ pathParts.length > 1 ? pathParts.slice(0, -1).join("/") + "/" : "";
+ const finalPath = `${iflDir}${name}`;
+ const resourceKey = getStandardTextureResourceKey(finalPath);
return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL);
}
diff --git a/src/mission.ts b/src/mission.ts
index aa45c6b6..f23ff868 100644
--- a/src/mission.ts
+++ b/src/mission.ts
@@ -191,7 +191,8 @@ export function* iterObjects(
}
}
-export function getProperty(obj: TorqueObject, name: string): any {
+export function getProperty(obj: TorqueObject | undefined, name: string): any {
+ if (!obj) return undefined;
return obj[name.toLowerCase()];
}