begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

View file

@ -41,11 +41,26 @@ export function AudioProvider({ children }: { children: ReactNode }) {
camera.add(listener);
}
listener.setMasterVolume(0.8);
setAudioContext({
audioLoader,
audioListener: listener,
});
// Resume the AudioContext on user interaction to satisfy browser autoplay
// policy. Without this, sounds won't play until the user clicks/taps.
const resumeOnGesture = () => {
const ctx = listener?.context;
if (!ctx || ctx.state !== "suspended") return;
ctx.resume().finally(() => {
document.removeEventListener("click", resumeOnGesture);
document.removeEventListener("keydown", resumeOnGesture);
});
};
document.addEventListener("click", resumeOnGesture);
document.addEventListener("keydown", resumeOnGesture);
// Suspend/resume the Web AudioContext when demo playback pauses/resumes.
// This freezes all playing sounds at their current position rather than
// stopping them, so they resume seamlessly.
@ -56,14 +71,17 @@ export function AudioProvider({ children }: { children: ReactNode }) {
if (!ctx) return;
if (status === "paused") {
ctx.suspend();
} else if (status === "playing" && ctx.state === "suspended") {
} else if (ctx.state === "suspended") {
ctx.resume();
}
},
);
return () => {
document.removeEventListener("click", resumeOnGesture);
document.removeEventListener("keydown", resumeOnGesture);
unsubscribe();
if (listener) camera.remove(listener);
};
}, [camera]);

View file

@ -8,8 +8,6 @@ import {
PositionalAudio,
Vector3,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
@ -19,30 +17,49 @@ import { engineStore } from "../state";
// Global audio buffer cache shared across all audio components.
export const audioBufferCache = new Map<string, AudioBuffer>();
// ── Demo sound rate tracking ──
// Track active demo sounds so their playbackRate can be updated when the
// playback rate changes (e.g. slow-motion or fast-forward).
// Maps each sound to its intrinsic pitch (1.0 for normal sounds, or the
// voice pitch multiplier for chat sounds).
const _activeDemoSounds = new Map<Audio<GainNode | PannerNode>, number>();
// Track active sounds so their playbackRate can be updated when the playback
// rate changes (e.g. slow-motion or fast-forward). Maps each sound to its
// intrinsic pitch (1.0 for normal, or the voice pitch multiplier for chat).
const _activeSounds = new Map<Audio<GainNode | PannerNode>, number>();
/** Register a sound for automatic playback rate tracking. */
export function trackDemoSound(
/** Register a sound for automatic playback rate tracking during streaming. */
export function trackSound(
sound: Audio<GainNode | PannerNode>,
basePitch = 1,
): void {
_activeDemoSounds.set(sound, basePitch);
_activeSounds.set(sound, basePitch);
}
/** Unregister a tracked demo sound. */
export function untrackDemoSound(sound: Audio<GainNode | PannerNode>): void {
_activeDemoSounds.delete(sound);
/** Unregister a tracked sound. */
export function untrackSound(sound: Audio<GainNode | PannerNode>): void {
_activeSounds.delete(sound);
}
/**
* Generation counter incremented on each stopAllTrackedSounds() call.
* Async sound callbacks check this to avoid playing after teardown.
*/
let _soundGeneration = 0;
/** Current sound generation — capture before async work, check on completion. */
export function getSoundGeneration(): number {
return _soundGeneration;
}
/** Stop and unregister all tracked sounds. Called on recording change. */
export function stopAllTrackedSounds(): void {
_soundGeneration++;
for (const [sound] of _activeSounds) {
try { sound.stop(); } catch { /* already stopped */ }
try { sound.disconnect(); } catch { /* already disconnected */ }
}
_activeSounds.clear();
}
engineStore.subscribe(
(state) => state.playback.rate,
(rate) => {
for (const [sound, basePitch] of _activeDemoSounds) {
for (const [sound, basePitch] of _activeSounds) {
try {
sound.setPlaybackRate(basePitch * rate);
} catch {
@ -108,7 +125,9 @@ export function playOneShotSound(
return;
}
const rate = engineStore.getState().playback.rate;
const gen = _soundGeneration;
getCachedAudioBuffer(url, audioLoader, (buffer) => {
if (gen !== _soundGeneration) return;
try {
if (resolved.is3D && parent) {
const sound = new PositionalAudio(audioListener);
@ -125,11 +144,11 @@ export function playOneShotSound(
sound.position.copy(position);
}
parent.add(sound);
_activeDemoSounds.set(sound, 1);
_activeSounds.set(sound, 1);
sound.play();
sound.source!.onended = () => {
_activeDemoSounds.delete(sound);
sound.disconnect();
_activeSounds.delete(sound);
try { sound.disconnect(); } catch { /* already disconnected */ }
parent.remove(sound);
};
} else {
@ -137,11 +156,11 @@ export function playOneShotSound(
sound.setBuffer(buffer);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(rate);
_activeDemoSounds.set(sound, 1);
_activeSounds.set(sound, 1);
sound.play();
sound.source!.onended = () => {
_activeDemoSounds.delete(sound);
sound.disconnect();
_activeSounds.delete(sound);
try { sound.disconnect(); } catch { /* already disconnected */ }
};
}
} catch {
@ -173,40 +192,62 @@ export function getCachedAudioBuffer(
}
export const AudioEmitter = memo(function AudioEmitter({
object,
entity,
}: {
object: TorqueObject;
entity: {
audioFileName?: string;
audioVolume?: number;
audioMinDistance?: number;
audioMaxDistance?: number;
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
audioIs3D?: boolean;
audioIsLooping?: boolean;
position?: [number, number, number];
};
}) {
const { debugMode } = useDebug();
const fileName = getProperty(object, "fileName") ?? "";
const volume = getFloat(object, "volume") ?? 1;
const minDistance = getFloat(object, "minDistance") ?? 1;
const maxDistance = getFloat(object, "maxDistance") ?? 1;
const minLoopGap = getFloat(object, "minLoopGap") ?? 0;
const maxLoopGap = getFloat(object, "maxLoopGap") ?? 0;
const is3D = getInt(object, "is3D") ?? 0;
const fileName = entity.audioFileName ?? "";
const volume = entity.audioVolume ?? 1;
const minDistance = entity.audioMinDistance ?? 1;
const maxDistance = entity.audioMaxDistance ?? 1;
const minLoopGap = entity.audioMinLoopGap ?? 0;
const maxLoopGap = entity.audioMaxLoopGap ?? 0;
const is3D = (entity.audioIs3D ?? true) ? 1 : 0;
const isLooping = entity.audioIsLooping ?? true;
const [x, y, z] = getPosition(object);
const [x, y, z] = entity.position ?? [0, 0, 0];
const { scene, camera } = useThree();
const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings();
const soundRef = useRef<Audio<GainNode | PannerNode> | null>(null);
const loopTimerRef = useRef<NodeJS.Timeout | null>(null);
const loopGapIntervalRef = useRef<NodeJS.Timeout | null>(null);
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loopGapIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLoadedRef = useRef(false);
const isInRangeRef = useRef(false);
const emitterPosRef = useRef(new Vector3(x, y, z));
// Generation counter: incremented when the sound object is recreated so
// that stale setTimeout callbacks from a previous sound are discarded.
const generationRef = useRef(0);
const clearTimers = () => {
if (loopTimerRef.current) clearTimeout(loopTimerRef.current);
if (loopGapIntervalRef.current) clearTimeout(loopGapIntervalRef.current);
if (loopTimerRef.current != null) {
clearTimeout(loopTimerRef.current);
loopTimerRef.current = null;
}
if (loopGapIntervalRef.current != null) {
clearTimeout(loopGapIntervalRef.current);
loopGapIntervalRef.current = null;
}
};
// Create sound object on mount.
useEffect(() => {
if (!audioLoader || !audioListener) return;
generationRef.current++;
let sound: Audio<GainNode | PannerNode>;
if (is3D) {
const positional = new PositionalAudio(audioListener);
@ -227,9 +268,10 @@ export const AudioEmitter = memo(function AudioEmitter({
return () => {
clearTimers();
try { sound.stop(); } catch {}
sound.disconnect();
try { sound.stop(); } catch { /* already stopped */ }
try { sound.disconnect(); } catch { /* already disconnected */ }
if (is3D) scene.remove(sound);
soundRef.current = null;
isLoadedRef.current = false;
isInRangeRef.current = false;
};
@ -243,8 +285,10 @@ export const AudioEmitter = memo(function AudioEmitter({
scene,
]);
// Setup looping logic (only called when audio loads).
const setupLooping = (sound: Audio<GainNode | PannerNode>) => {
// Setup looping logic (only called from effects/timers, never during render).
const setupLooping = (sound: Audio<GainNode | PannerNode>, gen: number) => {
if (!isLooping) return;
if (minLoopGap > 0 || maxLoopGap > 0) {
const gapMin = Math.max(0, minLoopGap);
const gapMax = Math.max(gapMin, maxLoopGap);
@ -254,12 +298,15 @@ export const AudioEmitter = memo(function AudioEmitter({
sound.loop = false;
const checkLoop = () => {
// Discard callbacks from a previous sound generation.
if (gen !== generationRef.current) return;
if (sound.isPlaying === false) {
loopTimerRef.current = setTimeout(() => {
if (gen !== generationRef.current) return;
try {
sound.play();
setupLooping(sound);
} catch {}
setupLooping(sound, gen);
} catch { /* expected */ }
}, gap);
} else {
loopGapIntervalRef.current = setTimeout(checkLoop, 100);
@ -273,25 +320,33 @@ export const AudioEmitter = memo(function AudioEmitter({
// Load and play audio. For 3D, gated by proximity; for 2D, plays immediately.
const loadAndPlay = (sound: Audio<GainNode | PannerNode>) => {
if (!audioLoader) return;
const gen = generationRef.current;
if (!isLoadedRef.current) {
const audioUrl = audioToUrl(fileName);
let audioUrl: string;
try {
audioUrl = audioToUrl(fileName);
} catch {
return;
}
getCachedAudioBuffer(audioUrl, audioLoader, (audioBuffer) => {
if (gen !== generationRef.current) return;
if (!sound.buffer) {
sound.setBuffer(audioBuffer);
isLoadedRef.current = true;
try {
sound.play();
setupLooping(sound);
} catch {}
setupLooping(sound, gen);
} catch { /* expected */ }
}
});
} else {
try {
if (!sound.isPlaying) {
sound.play();
setupLooping(sound);
setupLooping(sound, gen);
}
} catch {}
} catch { /* expected */ }
}
};
@ -318,18 +373,19 @@ export const AudioEmitter = memo(function AudioEmitter({
} else if (!isNowInRange && wasInRange) {
isInRangeRef.current = false;
clearTimers();
try { sound.stop(); } catch {}
try { sound.stop(); } catch { /* expected */ }
}
});
// Stop audio if disabled.
// Stop audio if disabled; reset range state so re-enabling triggers playback.
useEffect(() => {
const sound = soundRef.current;
if (!sound) return;
if (!audioEnabled) {
clearTimers();
try { sound.stop(); } catch {}
try { sound.stop(); } catch { /* expected */ }
isInRangeRef.current = false;
}
}, [audioEnabled]);

View file

@ -1,26 +1,37 @@
import { useEffect, useId, useMemo } from "react";
import { Quaternion, Vector3 } from "three";
import { useCameras } from "./CamerasProvider";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation } from "../mission";
import { Vector3 } from "three";
import type { CameraEntity } from "../state/gameEntityTypes";
export function Camera({ object }: { object: TorqueObject }) {
export function Camera({ entity }: { entity: CameraEntity }) {
const { registerCamera, unregisterCamera } = useCameras();
const id = useId();
const dataBlock = getProperty(object, "dataBlock");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const dataBlock = entity.cameraDataBlock;
const position = useMemo(
() =>
entity.position
? new Vector3(...entity.position)
: new Vector3(),
[entity.position],
);
const rotation = useMemo(
() =>
entity.rotation
? new Quaternion(...entity.rotation)
: new Quaternion(),
[entity.rotation],
);
useEffect(() => {
if (dataBlock === "Observer") {
const camera = { id, position: new Vector3(...position), rotation: q };
const camera = { id, position, rotation };
registerCamera(camera);
return () => {
unregisterCamera(camera);
};
}
}, [id, dataBlock, registerCamera, unregisterCamera, position, q]);
}, [id, dataBlock, registerCamera, unregisterCamera, position, rotation]);
// Maps can define preset observer camera locations. You should be able to jump
// to an observer camera position and then fly around from that starting point

View file

@ -53,7 +53,7 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
const unregisterCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => {
const { [camera.id]: removedCamera, ...remainingCameras } = prevCameraMap;
const { [camera.id]: _removedCamera, ...remainingCameras } = prevCameraMap;
return remainingCameras;
});
}, []);

View file

@ -2,10 +2,10 @@ import { useEffect, useRef } from "react";
import { Audio } from "three";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { getCachedAudioBuffer, trackDemoSound, untrackDemoSound } from "./AudioEmitter";
import { getCachedAudioBuffer, getSoundGeneration, trackSound, untrackSound } from "./AudioEmitter";
import { useSettings } from "./SettingsProvider";
import { engineStore, useEngineSelector } from "../state";
import type { DemoChatMessage } from "../demo/types";
import type { ChatMessage } from "../stream/types";
/**
* Plays non-positional sound effects for chat messages with ~w sound tags.
@ -21,7 +21,7 @@ export function ChatSoundPlayer() {
const timeSec = useEngineSelector(
(state) => state.playback.streamSnapshot?.timeSec,
);
const playedSetRef = useRef(new WeakSet<DemoChatMessage>());
const playedSetRef = useRef(new WeakSet<ChatMessage>());
// Track active voice chat sound per sender so a new voice bind from the
// same player stops their previous one (matching Tribes 2 behavior).
const activeBySenderRef = useRef(
@ -51,29 +51,31 @@ export function ChatSoundPlayer() {
const pitch = msg.soundPitch ?? 1;
const rate = engineStore.getState().playback.rate;
const sender = msg.sender;
const gen = getSoundGeneration();
getCachedAudioBuffer(url, audioLoader, (buffer) => {
if (gen !== getSoundGeneration()) return;
// Stop the sender's previous voice chat sound.
if (sender) {
const prev = activeBySender.get(sender);
if (prev) {
try { prev.stop(); } catch {}
untrackDemoSound(prev);
prev.disconnect();
try { prev.stop(); } catch { /* already stopped */ }
untrackSound(prev);
try { prev.disconnect(); } catch { /* already disconnected */ }
activeBySender.delete(sender);
}
}
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
sound.setPlaybackRate(pitch * rate);
trackDemoSound(sound, pitch);
trackSound(sound, pitch);
if (sender) {
activeBySender.set(sender, sound);
}
sound.play();
// Clean up the source node once playback finishes.
sound.source!.onended = () => {
untrackDemoSound(sound);
sound.disconnect();
untrackSound(sound);
try { sound.disconnect(); } catch { /* already disconnected */ }
if (sender && activeBySender.get(sender) === sound) {
activeBySender.delete(sender);
}

View file

@ -15,8 +15,7 @@ import {
Group,
} from "three";
import { loadDetailMapList, textureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
import type { SceneSky } from "../scene/types";
import { useDebug, useSettings } from "./SettingsProvider";
const noop = () => {};
@ -460,43 +459,29 @@ function useDetailMapList(name: string | undefined) {
}
export interface CloudLayersProps {
object: TorqueObject;
scene: SceneSky;
}
/**
* CloudLayers component renders multiple cloud layers as domed meshes.
* Matches the Tribes 2 cloud rendering system.
*
* Reads from TorqueObject:
* - materialList: DML file containing cloud textures at indices 7+
* - cloudSpeed1/2/3: Speed for each cloud layer
* - cloudHeightPer0/1/2: Height percentage for each layer
* - windVelocity: Wind direction for cloud movement
*/
export function CloudLayers({ object }: CloudLayersProps) {
const materialList = getProperty(object, "materialList");
export function CloudLayers({ scene }: CloudLayersProps) {
const materialList = scene.materialList || undefined;
const { data: detailMapList } = useDetailMapList(materialList);
// From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95
const visibleDistance = getFloat(object, "visibleDistance") ?? 500;
const visibleDistance = scene.visibleDistance > 0 ? scene.visibleDistance : 500;
const radius = visibleDistance * 0.95;
const cloudSpeeds = useMemo(
() => [
getFloat(object, "cloudSpeed1") ?? 0.0001,
getFloat(object, "cloudSpeed2") ?? 0.0002,
getFloat(object, "cloudSpeed3") ?? 0.0003,
],
[object],
() => scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
[scene.cloudLayers],
);
const cloudHeights = useMemo(
() => [
getFloat(object, "cloudHeightPer1") ?? 0.35,
getFloat(object, "cloudHeightPer2") ?? 0.25,
getFloat(object, "cloudHeightPer3") ?? 0.2,
],
[object],
() => scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
[scene.cloudLayers],
);
// Wind direction from windVelocity
@ -504,16 +489,13 @@ export function CloudLayers({ object }: CloudLayersProps) {
// Our cloud geometry has UV U along world X, UV V along world Z.
// Rotate 90 degrees clockwise to match Torque's coordinate system.
const windDirection = useMemo(() => {
const windVelocity = getProperty(object, "windVelocity");
if (windVelocity) {
const [x, y] = windVelocity.split(" ").map((s: string) => parseFloat(s));
if (x !== 0 || y !== 0) {
// Rotate 90 degrees clockwise: (x, y) -> (y, -x)
return new Vector2(y, -x).normalize();
}
const { x, y } = scene.windVelocity;
if (x !== 0 || y !== 0) {
// Rotate 90 degrees clockwise: (x, y) -> (y, -x)
return new Vector2(y, -x).normalize();
}
return new Vector2(1, 0);
}, [object]);
}, [scene.windVelocity]);
// Extract cloud layer configurations from DML (indices 7+)
const layers = useMemo<CloudLayerConfig[]>(() => {

View file

@ -1,204 +0,0 @@
import { Component, memo, Suspense } from "react";
import type { ErrorInfo, MutableRefObject, ReactNode } from "react";
import { entityTypeColor } from "../demo/demoPlaybackUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { DemoPlayerModel } from "./DemoPlayerModel";
import { DemoShapeModel, DemoWeaponModel, DemoExplosionShape } from "./DemoShapeModel";
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker";
import { useEngineSelector } from "../state";
import type { DemoEntity, DemoStreamingPlayback } from "../demo/types";
/**
* Renders a non-camera demo entity.
* The group name must match the entity ID so the AnimationMixer can target it.
* Player entities use DemoPlayerModel for skeletal animation; others use
* DemoShapeModel.
*/
export const DemoEntityGroup = memo(function DemoEntityGroup({
entity,
timeRef,
playback,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
playback?: DemoStreamingPlayback;
}) {
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
const name = String(entity.id);
if (entity.visual?.kind === "tracer") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "tracer" }}>
<Suspense fallback={null}>
<DemoTracerProjectile entity={entity} visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (entity.visual?.kind === "sprite") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "sprite" }}>
<Suspense fallback={null}>
<DemoSpriteProjectile visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (!entity.dataBlock) {
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return (
<group name={name}>
<group name="model">
<mesh>
<sphereGeometry args={[0.3, 6, 4]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
{isFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group>
);
}
const fallback = (
<mesh>
<sphereGeometry args={[0.5, 8, 6]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
);
// Player entities use skeleton-preserving DemoPlayerModel for animation.
if (entity.type === "Player") {
const isControlPlayer = entity.id === controlPlayerGhostId;
const hasFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoPlayerModel entity={entity} timeRef={timeRef} />
</Suspense>
</ShapeErrorBoundary>
{!isControlPlayer && (
<Suspense fallback={null}>
<PlayerNameplate entity={entity} timeRef={timeRef} />
</Suspense>
)}
{hasFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group>
</group>
);
}
// Explosion entities with DTS shapes use a specialized renderer
// that handles faceViewer, size keyframes, and fade-out.
if (entity.type === "Explosion" && entity.dataBlock && playback) {
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<DemoExplosionShape entity={entity as any} playback={playback} />
</Suspense>
</ShapeErrorBoundary>
</group>
</group>
);
}
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoShapeModel shapeName={entity.dataBlock} entityId={entity.id} threads={entity.threads} />
</Suspense>
</ShapeErrorBoundary>
</group>
{entity.weaponShape && (
<group name="weapon">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<DemoWeaponModel
shapeName={entity.weaponShape}
playerShapeName={entity.dataBlock}
/>
</Suspense>
</ShapeErrorBoundary>
</group>
)}
{isFlag && (
<Suspense fallback={null}>
<FlagMarker entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group>
);
});
export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) {
const id = String(entity.id);
const bits: string[] = [];
bits.push(`${id} (${entity.type})`);
if (entity.className) bits.push(`class ${entity.className}`);
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
bits.push(
entity.shapeHint
? `shapeHint ${entity.shapeHint}`
: "shapeHint <none resolved>",
);
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
}
/** Error boundary that renders a fallback when shape loading fails. */
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn(
"[demo] Shape load failed:",
error.message,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

View file

@ -1,9 +1,9 @@
import { useDemoRecording } from "./DemoProvider";
import { StreamingDemoPlayback } from "./DemoPlaybackStreaming";
import { useRecording } from "./RecordingProvider";
import { DemoPlaybackController } from "./DemoPlaybackController";
export function DemoPlayback() {
const recording = useDemoRecording();
const recording = useRecording();
if (!recording) return null;
return <StreamingDemoPlayback recording={recording} />;
return <DemoPlaybackController recording={recording} />;
}

View file

@ -2,34 +2,74 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import {
Group,
Quaternion,
Vector3,
} from "three";
import {
buildStreamDemoEntity,
DEFAULT_EYE_HEIGHT,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../demo/demoPlaybackUtils";
} from "../stream/playbackUtils";
import { shapeToUrl } from "../loaders";
import { TickProvider } from "./TickProvider";
import { DemoEntityGroup } from "./DemoEntities";
import { DemoParticleEffects } from "./DemoParticleEffects";
import { PlayerEyeOffset } from "./DemoPlayerModel";
import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter";
import { useEngineStoreApi, advanceEffectClock } from "../state";
import { gameEntityStore } from "../state/gameEntityStore";
import {
streamPlaybackStore,
resetStreamPlayback,
} from "../state/streamPlaybackStore";
import { streamEntityToGameEntity } from "../stream/entityBridge";
import type {
DemoEntity,
DemoRecording,
DemoStreamEntity,
DemoStreamSnapshot,
} from "../demo/types";
StreamRecording,
StreamEntity,
StreamSnapshot,
} from "../stream/types";
import type { GameEntity } from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes";
type EntityById = Map<string, DemoStreamEntity>;
type EntityById = Map<string, StreamEntity>;
/** Safely access a field that exists only on some GameEntity variants. */
function getField(entity: GameEntity, field: string): string | undefined {
return (entity as unknown as Record<string, unknown>)[field] as string | undefined;
}
/** Mutate render-affecting fields on an entity in-place from stream data.
* Components read these fields imperatively in useFrame no React
* re-render is needed. This is the key to avoiding Suspense starvation. */
function mutateRenderFields(
renderEntity: GameEntity,
stream: StreamEntity,
): void {
switch (renderEntity.renderType) {
case "Player": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.weaponShape = stream.weaponShape;
e.weaponImageState = stream.weaponImageState;
e.weaponImageStates = stream.weaponImageStates;
e.playerName = stream.playerName;
e.iffColor = stream.iffColor;
e.headPitch = stream.headPitch;
e.headYaw = stream.headYaw;
e.targetRenderFlags = stream.targetRenderFlags;
break;
}
case "Shape": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
break;
}
}
}
/** Cache entity-by-id Maps per snapshot so they're built once, not every frame. */
const _snapshotEntityCache = new WeakMap<DemoStreamSnapshot, EntityById>();
function getEntityMap(snapshot: DemoStreamSnapshot): EntityById {
const _snapshotEntityCache = new WeakMap<StreamSnapshot, EntityById>();
function getEntityMap(snapshot: StreamSnapshot): EntityById {
let map = _snapshotEntityCache.get(snapshot);
if (!map) {
map = new Map(snapshot.entities.map((e) => [e.id, e]));
@ -38,6 +78,16 @@ function getEntityMap(snapshot: DemoStreamSnapshot): EntityById {
return map;
}
/** Push the current entity map to the game entity store.
* Only triggers a version bump (and subscriber notifications) when the
* entity set changed (adds/removes). Render-field updates are mutated
* in-place on existing entity objects and read imperatively in useFrame. */
function pushEntitiesToStore(entityMap: Map<string, GameEntity>): void {
gameEntityStore
.getState()
.setAllStreamEntities(Array.from(entityMap.values()));
}
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
@ -46,115 +96,70 @@ const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
export function StreamingDemoPlayback({ recording }: { recording: DemoRecording }) {
export function DemoPlaybackController({ recording }: { recording: StreamRecording }) {
const engineStore = useEngineStoreApi();
const rootRef = useRef<Group>(null);
const timeRef = useRef(0);
const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef(recording.streamingPlayback ?? null);
const publishedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, DemoEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const [entities, setEntities] = useState<DemoEntity[]>([]);
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
const syncRenderableEntities = useCallback((snapshot: DemoStreamSnapshot) => {
const syncRenderableEntities = useCallback((snapshot: StreamSnapshot) => {
if (snapshot === lastSyncedSnapshotRef.current) return;
lastSyncedSnapshotRef.current = snapshot;
const prevMap = entityMapRef.current;
const nextMap = new Map<string, DemoEntity>();
const nextMap = new Map<string, GameEntity>();
let shouldRebuild = false;
for (const entity of snapshot.entities) {
let renderEntity = prevMap.get(entity.id);
// Identity change → new component (unmount/remount)
// Identity change -> new component (unmount/remount).
// Compare fields that, when changed, require a full entity rebuild.
const needsNewIdentity =
!renderEntity ||
renderEntity.type !== entity.type ||
renderEntity.dataBlock !== entity.dataBlock ||
renderEntity.weaponShape !== entity.weaponShape ||
renderEntity.className !== entity.className ||
renderEntity.className !== (entity.className ?? entity.type) ||
renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint;
renderEntity.shapeHint !== entity.shapeHint ||
getField(renderEntity, "shapeName") !== entity.dataBlock ||
// weaponShape changes only force rebuild for non-Player shapes
// (turrets, vehicles). Players handle weapon changes internally
// via PlayerModel's Mount0 bone, and rebuilding on weapon change
// would lose animation state (death animations, etc.).
(renderEntity.renderType !== "Player" &&
getField(renderEntity, "weaponShape") !== entity.weaponShape);
if (needsNewIdentity) {
renderEntity = buildStreamDemoEntity(
entity.id,
entity.type,
entity.dataBlock,
entity.visual,
entity.direction,
entity.weaponShape,
entity.playerName,
entity.className,
entity.ghostIndex,
entity.dataBlockId,
entity.shapeHint,
entity.explosionDataBlockId,
entity.faceViewer,
);
renderEntity.playerName = entity.playerName;
renderEntity.iffColor = entity.iffColor;
renderEntity.targetRenderFlags = entity.targetRenderFlags;
renderEntity.threads = entity.threads;
renderEntity.weaponImageState = entity.weaponImageState;
renderEntity.weaponImageStates = entity.weaponImageStates;
renderEntity.headPitch = entity.headPitch;
renderEntity.headYaw = entity.headYaw;
renderEntity.direction = entity.direction;
renderEntity.visual = entity.visual;
renderEntity.explosionDataBlockId = entity.explosionDataBlockId;
renderEntity.faceViewer = entity.faceViewer;
renderEntity.spawnTime = snapshot.timeSec;
shouldRebuild = true;
} else if (
renderEntity.playerName !== entity.playerName ||
renderEntity.iffColor !== entity.iffColor ||
renderEntity.targetRenderFlags !== entity.targetRenderFlags ||
renderEntity.threads !== entity.threads ||
renderEntity.weaponImageState !== entity.weaponImageState ||
renderEntity.weaponImageStates !== entity.weaponImageStates ||
renderEntity.headPitch !== entity.headPitch ||
renderEntity.headYaw !== entity.headYaw ||
renderEntity.direction !== entity.direction ||
renderEntity.visual !== entity.visual
) {
// Render-affecting field changed → new object so React.memo sees
// a different reference and re-renders this entity's component.
renderEntity = {
...renderEntity,
playerName: entity.playerName,
iffColor: entity.iffColor,
targetRenderFlags: entity.targetRenderFlags,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
direction: entity.direction,
visual: entity.visual,
};
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
shouldRebuild = true;
} else {
// Mutate render fields in-place on the existing entity object.
// Components read these imperatively in useFrame — no React
// re-render needed. This avoids store churn that starves Suspense.
mutateRenderFields(renderEntity, entity);
}
// else: no render-affecting changes, keep same object reference
// so React.memo can skip re-rendering this entity.
// Keyframe update (mutable — only used as fallback position for
// retained explosion entities; useFrame reads from snapshot entities).
if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({
nextMap.set(entity.id, renderEntity);
// Keyframe update (mutable -- used for fallback position for
// retained explosion entities and per-frame reads in useFrame).
// Scene entities and None don't have keyframes.
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None") continue;
const keyframes = renderEntity.keyframes!;
if (keyframes.length === 0) {
keyframes.push({
time: snapshot.timeSec,
position: entity.position ?? [0, 0, 0],
rotation: entity.rotation ?? [0, 0, 0, 1],
});
}
const kf = renderEntity.keyframes[0];
const kf = keyframes[0];
kf.time = snapshot.timeSec;
if (entity.position) kf.position = entity.position;
if (entity.rotation) kf.rotation = entity.rotation;
@ -164,8 +169,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
kf.actionAnim = entity.actionAnim;
kf.actionAtEnd = entity.actionAtEnd;
kf.damageState = entity.damageState;
nextMap.set(entity.id, renderEntity);
}
// Retain explosion entities with DTS shapes after they leave the snapshot.
@ -173,8 +176,8 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
for (const [id, entity] of prevMap) {
if (nextMap.has(id)) continue;
if (
entity.type === "Explosion" &&
entity.dataBlock &&
entity.renderType === "Explosion" &&
entity.shapeName &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
@ -192,14 +195,15 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
entityMapRef.current = nextMap;
if (shouldRebuild) {
setEntities(Array.from(nextMap.values()));
pushEntitiesToStore(nextMap);
}
let nextFirstPersonShape: string | null = null;
if (snapshot.camera?.mode === "first-person" && snapshot.camera.controlEntityId) {
const entity = nextMap.get(snapshot.camera.controlEntityId);
if (entity?.dataBlock) {
nextFirstPersonShape = entity.dataBlock;
const sn = entity ? getField(entity, "shapeName") : undefined;
if (sn) {
nextFirstPersonShape = sn;
}
}
setFirstPersonShape((prev) =>
@ -208,15 +212,24 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}, []);
useEffect(() => {
// Stop any lingering sounds from the previous recording before setting
// up the new one. One-shot sounds and looping projectile sounds survive
// across recording changes because ParticleEffects doesn't unmount.
stopAllTrackedSounds();
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
lastSyncedSnapshotRef.current = null;
publishedSnapshotRef.current = null;
timeRef.current = 0;
resetStreamPlayback();
playbackClockRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
const stream = streamRef.current;
streamPlaybackStore.setState({ playback: stream });
gameEntityStore.getState().beginStreaming();
if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null);
return;
@ -224,21 +237,26 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
stream.reset();
// Preload weapon effect shapes (explosions) so they're cached before
// the first projectile detonates otherwise the GLB fetch latency
// the first projectile detonates -- otherwise the GLB fetch latency
// causes the short-lived explosion entity to expire before it renders.
for (const shape of stream.getEffectShapes()) {
useGLTF.preload(shapeToUrl(shape));
}
const snapshot = stream.getSnapshot();
timeRef.current = snapshot.timeSec;
streamPlaybackStore.setState({ time: snapshot.timeSec });
playbackClockRef.current = snapshot.timeSec;
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
syncRenderableEntities(snapshot);
engineStore.getState().setPlaybackStreamSnapshot(snapshot);
publishedSnapshotRef.current = snapshot;
return () => {
stopAllTrackedSounds();
gameEntityStore.getState().endStreaming();
resetStreamPlayback();
engineStore.getState().setPlaybackStreamSnapshot(null);
};
}, [recording, engineStore, syncRenderableEntities]);
@ -254,7 +272,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const externalSeekWhilePaused =
!isPlaying && Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
const externalSeekWhilePlaying =
isPlaying && Math.abs(requestedTimeSec - timeRef.current) > 0.05;
isPlaying && Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
if (isSeeking) {
// Sync stream cursor to UI/programmatic seek.
@ -305,33 +323,19 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
Math.min(1, (playbackClockRef.current - tickStartTime) / STREAM_TICK_SEC),
);
timeRef.current = playbackClockRef.current;
streamPlaybackStore.setState({ time: playbackClockRef.current });
if (snapshot.exhausted && isPlaying) {
playbackClockRef.current = Math.min(playbackClockRef.current, snapshot.timeSec);
}
syncRenderableEntities(renderCurrent);
const publishedSnapshot = publishedSnapshotRef.current;
const shouldPublish =
!publishedSnapshot ||
renderCurrent.timeSec !== publishedSnapshot.timeSec ||
renderCurrent.exhausted !== publishedSnapshot.exhausted ||
renderCurrent.status.health !== publishedSnapshot.status.health ||
renderCurrent.status.energy !== publishedSnapshot.status.energy ||
renderCurrent.camera?.mode !== publishedSnapshot.camera?.mode ||
renderCurrent.camera?.controlEntityId !==
publishedSnapshot.camera?.controlEntityId ||
renderCurrent.camera?.orbitTargetId !==
publishedSnapshot.camera?.orbitTargetId ||
renderCurrent.chatMessages.length !== publishedSnapshot.chatMessages.length ||
renderCurrent.teamScores.length !== publishedSnapshot.teamScores.length ||
renderCurrent.teamScores.some(
(ts, i) =>
ts.score !== publishedSnapshot.teamScores[i]?.score ||
ts.playerCount !== publishedSnapshot.teamScores[i]?.playerCount,
);
// Publish the entity map for imperative reads by components in useFrame.
// This is a plain object assignment — no React re-renders triggered.
streamPlaybackStore.getState().entities = entityMapRef.current;
if (shouldPublish) {
// Publish snapshot when it changed.
if (renderCurrent !== publishedSnapshotRef.current) {
publishedSnapshotRef.current = renderCurrent;
storeState.setPlaybackStreamSnapshot(renderCurrent);
}
@ -346,7 +350,14 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
? renderPrev.camera
: null;
if (currentCamera) {
// When freeFlyCamera is active, skip stream camera positioning so
// ObserverControls drives the camera instead.
const freeFly = streamPlaybackStore.getState().freeFlyCamera;
// In live mode, LiveObserver owns camera rotation (client-side prediction).
// DemoPlaybackController still handles position, FOV, and entity interpolation.
const isLive = recording.source === "live";
if (currentCamera && !freeFly) {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
@ -359,20 +370,25 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const iz = pz + (cz - pz) * interpT;
state.camera.position.set(iy, iz, ix);
_interpQuatA.set(...previousCamera.rotation);
_interpQuatB.set(...currentCamera.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
state.camera.quaternion.copy(_interpQuatA);
if (!isLive) {
_interpQuatA.set(...previousCamera.rotation);
_interpQuatB.set(...currentCamera.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
state.camera.quaternion.copy(_interpQuatA);
}
} else {
state.camera.position.set(
currentCamera.position[1],
currentCamera.position[2],
currentCamera.position[0],
);
state.camera.quaternion.set(...currentCamera.rotation);
if (!isLive) {
state.camera.quaternion.set(...currentCamera.rotation);
}
}
if (
!isLive &&
Number.isFinite(currentCamera.fov) &&
"isPerspectiveCamera" in state.camera &&
(state.camera as any).isPerspectiveCamera
@ -393,20 +409,28 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
}
// Imperative position interpolation via the shared entity root.
const currentEntities = getEntityMap(renderCurrent);
const previousEntities = getEntityMap(renderPrev);
const renderEntities = entityMapRef.current;
const root = rootRef.current;
const root = streamPlaybackStore.getState().root;
if (root) {
for (const child of root.children) {
let entity = currentEntities.get(child.name);
// Scene infrastructure (terrain, interiors, sky, etc.) handles its
// own positioning — skip interpolation and visibility management.
const renderEntity = renderEntities.get(child.name);
if (renderEntity && isSceneEntity(renderEntity)) {
continue;
}
const entity = currentEntities.get(child.name);
// Retained entities (e.g. explosion shapes kept alive past their
// snapshot lifetime) won't be in the snapshot entity map. Fall back
// to their last-known keyframe position from the render entity.
if (!entity) {
const renderEntity = renderEntities.get(child.name);
if (renderEntity?.keyframes[0]?.position) {
const kf = renderEntity.keyframes[0];
const kfs = renderEntity && "keyframes" in renderEntity ? renderEntity.keyframes : undefined;
if (kfs?.[0]?.position) {
const kf = kfs[0];
child.visible = true;
child.position.set(kf.position[1], kf.position[2], kf.position[0]);
continue;
@ -452,7 +476,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
const mode = currentCamera?.mode;
if (mode === "third-person" && root && currentCamera?.orbitTargetId) {
if (!freeFly && !isLive && mode === "third-person" && root && currentCamera?.orbitTargetId) {
const targetGroup = root.children.find(
(child) => child.name === currentCamera.orbitTargetId,
);
@ -495,7 +519,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
}
if (mode === "first-person" && root && currentCamera?.controlEntityId) {
if (!freeFly && mode === "first-person" && root && currentCamera?.controlEntityId) {
const playerGroup = root.children.find(
(child) => child.name === currentCamera.controlEntityId,
);
@ -518,13 +542,8 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
});
return (
<TickProvider>
<group ref={rootRef}>
{entities.map((entity) => (
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} playback={recording.streamingPlayback} />
))}
</group>
<DemoParticleEffects
<>
<ParticleEffects
playback={recording.streamingPlayback}
snapshotRef={currentTickSnapshotRef}
/>
@ -533,6 +552,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} />
</Suspense>
)}
</TickProvider>
</>
);
}

View file

@ -1,13 +1,13 @@
import { useCallback, useEffect, type ChangeEvent } from "react";
import {
useDemoActions,
useDemoCurrentTime,
useDemoDuration,
useDemoIsPlaying,
useDemoRecording,
useDemoSpeed,
} from "./DemoProvider";
import styles from "./DemoControls.module.css";
usePlaybackActions,
useCurrentTime,
useDuration,
useIsPlaying,
useRecording,
useSpeed,
} from "./RecordingProvider";
import styles from "./DemoPlaybackControls.module.css";
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
@ -17,13 +17,13 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function DemoControls() {
const recording = useDemoRecording();
const isPlaying = useDemoIsPlaying();
const currentTime = useDemoCurrentTime();
const duration = useDemoDuration();
const speed = useDemoSpeed();
const { play, pause, seek, setSpeed } = useDemoActions();
export function DemoPlaybackControls() {
const recording = useRecording();
const isPlaying = useIsPlaying();
const currentTime = useCurrentTime();
const duration = useDuration();
const speed = useSpeed();
const { play, pause, seek, setSpeed } = usePlaybackActions();
// Spacebar toggles play/pause during demo playback.
useEffect(() => {
@ -65,7 +65,7 @@ export function DemoControls() {
[setSpeed],
);
if (!recording) return null;
if (!recording || !Number.isFinite(recording.duration)) return null;
return (
<div

View file

@ -0,0 +1,54 @@
/* Shared button base for dialog actions (server browser, map info, etc.). */
.DialogButton {
background: linear-gradient(
to bottom,
rgba(48, 164, 151, 0.8),
rgba(31, 150, 136, 0.8) 33%,
rgba(33, 131, 119, 0.8) 67%,
rgba(4, 101, 100, 0.8)
);
border: 1px solid rgba(56, 124, 116, 0.8);
border-top-color: rgba(87, 183, 185, 0.8);
border-radius: 4px;
box-shadow: inset 0 0 4px rgba(2, 128, 142, 0.5);
color: rgb(153, 255, 241);
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.4);
font-size: 14px;
font-weight: 500;
padding: 4px 18px;
cursor: pointer;
font-family: inherit;
}
.DialogButton:hover:not(:disabled) {
color: rgb(177, 255, 245);
border: 1px solid rgba(64, 145, 136, 0.9);
border-top-color: rgba(90, 198, 194, 0.9);
box-shadow:
inset 0 0 4px rgba(2, 128, 142, 0.5),
0 0 5px rgba(62, 255, 191, 0.5);
}
.DialogButton:active:not(:disabled) {
transform: translate(0, 1px);
}
.DialogButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Secondary/ghost variant for less prominent actions (Close, Cancel). */
.Secondary {
composes: DialogButton;
background: transparent;
border: 1px solid rgba(56, 124, 116, 0.8);
box-shadow: none;
color: rgba(162, 226, 207, 0.8);
text-shadow: none;
}
.Secondary:hover:not(:disabled) {
color: rgba(169, 255, 229, 0.8);
border: 1px solid rgba(63, 144, 135, 0.9);
}

View file

@ -0,0 +1,263 @@
import { lazy, memo, Suspense, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import type { Group } from "three";
import type {
GameEntity,
ShapeEntity as ShapeEntityType,
ForceFieldBareEntity as ForceFieldBareEntityType,
PlayerEntity as PlayerEntityType,
ExplosionEntity as ExplosionEntityType,
TracerEntity as TracerEntityType,
SpriteEntity as SpriteEntityType,
AudioEmitterEntity as AudioEmitterEntityType,
} from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
import { useSettings } from "./SettingsProvider";
import { Camera } from "./Camera";
import { WayPoint } from "./WayPoint";
import { TerrainBlock } from "./TerrainBlock";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
import type { TorqueObject } from "../torqueScript";
// Lazy-loaded heavy renderers
const PlayerModel = lazy(() =>
import("./PlayerModel").then((mod) => ({ default: mod.PlayerModel })),
);
const ExplosionShape = lazy(() =>
import("./ShapeModel").then((mod) => ({
default: mod.ExplosionShape,
})),
);
const TracerProjectile = lazy(() =>
import("./Projectiles").then((mod) => ({
default: mod.TracerProjectile,
})),
);
const SpriteProjectile = lazy(() =>
import("./Projectiles").then((mod) => ({
default: mod.SpriteProjectile,
})),
);
const ForceFieldBareRenderer = lazy(() =>
import("./ForceFieldBare").then((mod) => ({
default: mod.ForceFieldBare,
})),
);
const AudioEmitter = lazy(() =>
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
);
const WaterBlock = lazy(() =>
import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
);
const TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
};
/**
* Renders a GameEntity by dispatching to the appropriate renderer based
* on renderType. Does NOT handle positioning the caller is responsible
* for placing the entity group in world space (either declaratively for
* mission mode or imperatively for streaming interpolation).
*/
export const EntityRenderer = memo(function EntityRenderer({
entity,
}: {
entity: GameEntity;
}) {
switch (entity.renderType) {
case "Shape":
return <ShapeEntity entity={entity} />;
case "ForceFieldBare":
return <ForceFieldBareEntity entity={entity} />;
case "Player":
return <PlayerEntity entity={entity} />;
case "Explosion":
return <ExplosionEntity entity={entity} />;
case "Tracer":
return <TracerEntity entity={entity} />;
case "Sprite":
return <SpriteEntity entity={entity} />;
case "AudioEmitter":
return <AudioEntity entity={entity} />;
case "Camera":
return <Camera entity={entity} />;
case "WayPoint":
return <WayPoint entity={entity} />;
case "TerrainBlock":
return <TerrainBlock scene={entity.terrainData} />;
case "InteriorInstance":
return <InteriorInstance scene={entity.interiorData} />;
case "Sky":
return <Sky scene={entity.skyData} />;
case "Sun":
// Sun lighting is handled by SceneLighting (rendered outside EntityScene)
return null;
case "WaterBlock":
return (
<Suspense fallback={null}>
<WaterBlock scene={entity.waterData} />
</Suspense>
);
case "MissionArea":
return null;
case "None":
return null;
}
});
// ── Shape Entity ──
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
const { animationEnabled } = useSettings();
const groupRef = useRef<Group>(null);
// Y-axis spinning for Items with rotate=true
useFrame(() => {
if (!groupRef.current || !entity.rotate || !animationEnabled) return;
const t = performance.now() / 1000;
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
});
if (!entity.shapeName) return null;
const torqueObject = entity.runtimeObject as TorqueObject | undefined;
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
// Flag label for flag Items
const isFlag = entity.dataBlock?.toLowerCase() === "flag";
const teamName =
entity.teamId && entity.teamId > 0 ? TEAM_NAMES[entity.teamId] : null;
const flagLabel = isFlag && teamName ? `${teamName} Flag` : null;
const loadingColor =
entity.shapeType === "Item"
? "pink"
: entity.threads
? "#00ff88"
: "yellow";
return (
<ShapeInfoProvider
object={torqueObject}
shapeName={entity.shapeName}
type={shapeType}
>
<group ref={entity.rotate ? groupRef : undefined}>
<ShapeRenderer loadingColor={loadingColor} streamEntity={torqueObject ? undefined : entity}>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
) : null}
</ShapeRenderer>
{entity.barrelShapeName && (
<ShapeInfoProvider
object={torqueObject}
shapeName={entity.barrelShapeName}
type="Turret"
>
<group position={[0, 1.5, 0]}>
<ShapeRenderer />
</group>
</ShapeInfoProvider>
)}
</group>
</ShapeInfoProvider>
);
}
// ── Force Field Entity ──
function ForceFieldBareEntity({ entity }: { entity: ForceFieldBareEntityType }) {
if (!entity.forceFieldData) return null;
return (
<Suspense fallback={null}>
<ForceFieldBareRenderer
data={entity.forceFieldData}
scale={entity.forceFieldData.dimensions}
/>
</Suspense>
);
}
// ── Player Entity ──
function PlayerEntity({ entity }: { entity: PlayerEntityType }) {
if (!entity.shapeName) return null;
return (
<Suspense fallback={null}>
<PlayerModel entity={entity} />
</Suspense>
);
}
// ── Explosion Entity ──
function ExplosionEntity({ entity }: { entity: ExplosionEntityType }) {
const playback = streamPlaybackStore.getState().playback;
// ExplosionShape still expects a StreamEntity-shaped object.
// Adapt minimally until that component is also refactored.
const streamEntity = {
id: entity.id,
type: "Explosion" as const,
dataBlock: entity.shapeName,
position: entity.position,
rotation: entity.rotation,
faceViewer: entity.faceViewer,
explosionDataBlockId: entity.explosionDataBlockId,
};
if (!entity.shapeName || !playback) return null;
return (
<Suspense fallback={null}>
<ExplosionShape entity={streamEntity as any} playback={playback} />
</Suspense>
);
}
// ── Tracer Entity ──
function TracerEntity({ entity }: { entity: TracerEntityType }) {
return (
<Suspense fallback={null}>
<TracerProjectile entity={entity} visual={entity.visual} />
</Suspense>
);
}
// ── Sprite Entity ──
function SpriteEntity({ entity }: { entity: SpriteEntityType }) {
return (
<Suspense fallback={null}>
<SpriteProjectile visual={entity.visual} />
</Suspense>
);
}
// ── Audio Entity ──
function AudioEntity({ entity }: { entity: AudioEmitterEntityType }) {
const { audioEnabled } = useSettings();
if (!entity.audioFileName || !audioEnabled) return null;
return (
<Suspense fallback={null}>
<AudioEmitter entity={entity} />
</Suspense>
);
}

View file

@ -0,0 +1,293 @@
import { lazy, memo, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { Quaternion } from "three";
import type { Group } from "three";
import { useFrame } from "@react-three/fiber";
import { useAllGameEntities } from "../state";
import type { GameEntity, PositionedEntity, PlayerEntity } from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { EntityRenderer } from "./EntityRenderer";
import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker";
import { FloatingLabel } from "./FloatingLabel";
import { entityTypeColor } from "../stream/playbackUtils";
import { useDebug } from "./SettingsProvider";
import { useEngineSelector } from "../state";
const WeaponModel = lazy(() =>
import("./ShapeModel").then((mod) => ({
default: mod.WeaponModel,
})),
);
/**
* The ONE rendering component tree for all game entities.
* Reads from the game entity store (active layer: mission or stream entities).
* Data sources (mission .mis, demo .rec, live server) are controllers that
* populate the store this component doesn't know or care which is active.
*/
export function EntityScene({ missionType }: { missionType?: string }) {
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const rootRef = useCallback((node: Group | null) => {
streamPlaybackStore.setState({ root: node });
}, []);
return (
<group ref={rootRef}>
<EntityLayer missionType={missionType} debugMode={debugMode} />
</group>
);
}
/** Renders all game entities. Uses an ID-stable selector so the component
* only re-renders when entities are added or removed, not when their
* fields change. Entity references are cached so that once an entity
* renders and loads resources via Suspense, it keeps its reference stable. */
const EntityLayer = memo(function EntityLayer({
missionType,
debugMode,
}: {
missionType?: string;
debugMode: boolean;
}) {
const entities = useAllGameEntities();
// Cache entity references by ID so that in-place field mutations
// (threads, colors, weapon shape) don't cause React to see a new
// object and remount Suspense boundaries. The cache IS updated when
// the store provides a genuinely new object reference (identity
// rebuild: armor change, datablock change, etc.).
const cacheRef = useRef(new Map<string, GameEntity>());
const cache = cacheRef.current;
const currentIds = new Set<string>();
for (const entity of entities) {
currentIds.add(entity.id);
cache.set(entity.id, entity);
}
// Remove entities no longer in the set
for (const id of cache.keys()) {
if (!currentIds.has(id)) {
cache.delete(id);
}
}
const filtered = useMemo(() => {
const result: GameEntity[] = [];
const lowerType = missionType?.toLowerCase();
for (const entity of cache.values()) {
if (lowerType && entity.missionTypesList) {
const types = new Set(
entity.missionTypesList
.toLowerCase()
.split(/\s+/)
.filter(Boolean),
);
if (types.size > 0 && !types.has(lowerType)) continue;
}
result.push(entity);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entities, missionType]);
return (
<>
{filtered.map((entity) => (
<EntityWrapper key={entity.id} entity={entity} debugMode={debugMode} />
))}
</>
);
});
const EntityWrapper = memo(function EntityWrapper({
entity,
debugMode,
}: {
entity: GameEntity;
debugMode: boolean;
}) {
// Scene infrastructure handles its own positioning — render directly.
// The named group allows the interpolation loop to identify and skip them.
if (isSceneEntity(entity)) {
return (
<group name={entity.id}>
<EntityRenderer entity={entity} />
</group>
);
}
if (entity.renderType === "None") return null;
// From here, entity is a PositionedEntity
return <PositionedEntityWrapper entity={entity} debugMode={debugMode} />;
});
/** Renders the player nameplate, subscribing to controlPlayerGhostId
* internally so that PositionedEntityWrapper doesn't need to. This keeps
* engine store mutations from triggering synchronous selector evaluations
* on every positioned entity (which was starving Suspense retries for
* shape GLB loading). */
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
if (entity.id === controlPlayerGhostId) return null;
return <PlayerNameplate entity={entity} />;
}
/** Imperatively tracks targetRenderFlags bit 0x2 on a game entity and
* mounts/unmounts FlagMarker when the flag state changes. Entity field
* mutations don't trigger React re-renders (ID-only equality), so this
* uses useFrame to poll the mutable field. */
function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
const flagRef = useRef(false);
const [isFlag, setIsFlag] = useState(() => {
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
return ((flags ?? 0) & 0x2) !== 0;
});
flagRef.current = isFlag;
useFrame(() => {
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined;
const nowFlag = ((flags ?? 0) & 0x2) !== 0;
if (nowFlag !== flagRef.current) {
flagRef.current = nowFlag;
setIsFlag(nowFlag);
}
});
if (!isFlag) return null;
return (
<Suspense fallback={null}>
<FlagMarker entity={entity} />
</Suspense>
);
}
function PositionedEntityWrapper({
entity,
debugMode,
}: {
entity: PositionedEntity;
debugMode: boolean;
}) {
const position = entity.position;
const scale = entity.scale;
const quaternion = useMemo(() => {
if (!entity.rotation) return undefined;
return new Quaternion(...entity.rotation);
}, [entity.rotation]);
const isPlayer = entity.renderType === "Player";
// Entities without a resolved shape get a wireframe placeholder.
if (entity.renderType === "Shape" && !entity.shapeName) {
return (
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}>
<mesh>
<sphereGeometry args={[0.3, 6, 4]} />
<meshBasicMaterial
color={entityTypeColor(entity.className)}
wireframe
/>
</mesh>
{debugMode && <MissingShapeLabel entity={entity} />}
<FlagMarkerSlot entity={entity} />
</group>
);
}
const fallback =
entity.renderType === "Explosion" ? null : (
<mesh>
<sphereGeometry args={[0.5, 8, 6]} />
<meshBasicMaterial
color={entityTypeColor(entity.className)}
wireframe
/>
</mesh>
);
const shapeName = "shapeName" in entity ? entity.shapeName : undefined;
const weaponShape = "weaponShape" in entity ? entity.weaponShape : undefined;
return (
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<EntityRenderer entity={entity} />
</Suspense>
</ShapeErrorBoundary>
{isPlayer && (
<Suspense fallback={null}>
<PlayerNameplateIfVisible entity={entity as PlayerEntity} />
</Suspense>
)}
<FlagMarkerSlot entity={entity} />
{debugMode && !shapeName && entity.renderType !== "Shape" && (
<MissingShapeLabel entity={entity} />
)}
</group>
{weaponShape && shapeName && !isPlayer && (
<group name="weapon">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<WeaponModel
shapeName={weaponShape}
playerShapeName={shapeName}
/>
</Suspense>
</ShapeErrorBoundary>
</group>
)}
</group>
);
}
function MissingShapeLabel({ entity }: { entity: GameEntity }) {
const bits: string[] = [];
bits.push(`${entity.id} (${entity.className})`);
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
bits.push(
entity.shapeHint
? `shapeHint ${entity.shapeHint}`
: "shapeHint <none resolved>",
);
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
}
/** Error boundary that renders a fallback when shape loading fails. */
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn(
"[entity] Shape load failed:",
error.message,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

View file

@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { useQueryState, parseAsString } from "nuqs";
type Features = {
live: boolean;
};
const defaultFeatures: Features = {
live: false,
};
const FeaturesContext = createContext<Features>(defaultFeatures);
export function useFeatures(): Features {
return useContext(FeaturesContext);
}
/** Reads `?features=live,demo,...` once on mount and provides feature flags. */
export function FeaturesProvider({ children }: { children: ReactNode }) {
const [featuresParam] = useQueryState("features", parseAsString);
const [features] = useState<Features>(() => {
const tokens = new Set(
(featuresParam ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
return {
live: tokens.has("live"),
};
});
return (
<FeaturesContext.Provider value={features}>
{children}
</FeaturesContext.Provider>
);
}

View file

@ -1,10 +1,12 @@
import { useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Vector3 } from "three";
import { textureToUrl } from "../loaders";
import type { DemoEntity } from "../demo/types";
interface FlagEntity {
id: string;
iffColor?: { r: number; g: number; b: number };
}
import styles from "./FlagMarker.module.css";
const FLAG_ICON_HEIGHT = 1.5;
@ -18,12 +20,7 @@ const _tmpVec = new Vector3();
* friendly, red for enemy matching Tribes 2's sensor group color system).
* Always visible regardless of distance.
*/
export function FlagMarker({
entity,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
export function FlagMarker({ entity }: { entity: FlagEntity }) {
const markerRef = useRef<Group>(null);
const iconRef = useRef<HTMLDivElement>(null);
const distRef = useRef<HTMLSpanElement>(null);

View file

@ -19,6 +19,7 @@ import { useFrame } from "@react-three/fiber";
import { Color } from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
import type { SceneSky } from "../scene/types";
/** Maximum number of fog volumes supported (matches Torque) */
export const MAX_FOG_VOLUMES = 3;
@ -182,6 +183,34 @@ export function parseFogState(
};
}
/** Build FogState directly from a typed SceneSky (no string parsing). */
export function fogStateFromScene(sky: SceneSky): FogState {
const fogDistance = sky.fogDistance;
const visibleDistance = sky.visibleDistance > 0 ? sky.visibleDistance : 1000;
const { r, g, b } = sky.fogColor;
const fogColor = new Color().setRGB(r, g, b).convertSRGBToLinear();
const fogVolumes: FogVolume[] = [];
for (const vol of sky.fogVolumes) {
if (vol.visibleDistance <= 0 || vol.maxHeight <= vol.minHeight) continue;
fogVolumes.push({
visibleDistance: vol.visibleDistance,
minHeight: vol.minHeight,
maxHeight: vol.maxHeight,
percentage: 1.0,
});
}
const fogLine = fogVolumes.reduce(
(max, vol) => Math.max(max, vol.maxHeight),
0,
);
const enabled = visibleDistance > fogDistance;
return { fogDistance, visibleDistance, fogColor, fogVolumes, fogLine, enabled };
}
/**
* Create initial fog uniforms structure.
*/

View file

@ -1,4 +1,4 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { Suspense, useEffect, useMemo, useRef } from "react";
import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import {
@ -8,56 +8,23 @@ import {
DoubleSide,
NoColorSpace,
RepeatWrapping,
Texture,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import type { Texture } from "three";
import type { ForceFieldData } from "../state/gameEntityTypes";
import { textureToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider";
import { useDatablock } from "./useDatablock";
import {
createForceFieldMaterial,
OPACITY_FACTOR,
} from "../forceFieldMaterial";
/**
* 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];
}
function setupForceFieldTexture(texture: Texture) {
texture.wrapS = texture.wrapT = RepeatWrapping;
// NoColorSpace - values pass through directly to display without conversion,
// matching how WaterBlock handles textures in custom ShaderMaterial.
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;
@ -73,90 +40,16 @@ function useCornerBoxGeometry(scale: [number, number, number]) {
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(() => {
return createForceFieldMaterial({
textures,
scale,
umapping,
vmapping,
color,
baseTranslucency,
});
}, [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;
});
// renderOrder ensures force fields render after water (which uses default 0).
// Water writes depth, force fields don't - so depth testing gives correct
// per-pixel occlusion (underwater force fields are hidden, above-water visible).
return <mesh geometry={geometry} material={material} renderOrder={1} />;
}
function ForceFieldFallback({
scale,
color,
baseTranslucency,
}: ForceFieldGeometryProps) {
}: {
scale: [number, number, number];
color: [number, number, number];
baseTranslucency: number;
}) {
const geometry = useCornerBoxGeometry(scale);
// Use color directly - no gamma correction needed to match main shader
const fallbackColor = useMemo(
() => new Color(color[0], color[1], color[2]),
[color],
@ -171,82 +64,101 @@ function ForceFieldFallback({
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
fog={false} // Standard fog doesn't work with additive blending
fog={false}
/>
</mesh>
);
}
export const ForceFieldBare = memo(function ForceFieldBare({
object,
function ForceFieldMesh({
scale,
data,
}: {
object: TorqueObject;
scale: [number, number, number];
data: ForceFieldData;
}) {
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 { animationEnabled } = useSettings();
const geometry = useCornerBoxGeometry(scale);
const textureUrls = useMemo(
() => getTextureUrls(datablock, numFrames),
[datablock, numFrames],
() => data.textures.map((t) => textureToUrl(t)),
[data.textures],
);
const textures = useTexture(textureUrls, (textures) => {
textures.forEach((tex) => setupForceFieldTexture(tex));
});
const material = useMemo(() => {
return createForceFieldMaterial({
textures,
scale,
umapping: data.umapping,
vmapping: data.vmapping,
color: data.color,
baseTranslucency: data.baseTranslucency,
});
}, [textures, scale, data]);
useEffect(() => {
return () => material.dispose();
}, [material]);
const elapsedRef = useRef(0);
useFrame((_, delta) => {
if (!animationEnabled) {
elapsedRef.current = 0;
material.uniforms.currentFrame.value = 0;
material.uniforms.vScroll.value = 0;
return;
}
elapsedRef.current += delta;
material.uniforms.currentFrame.value =
Math.floor(elapsedRef.current * data.framesPerSec) % data.numFrames;
material.uniforms.vScroll.value = elapsedRef.current * data.scrollSpeed;
});
return <mesh geometry={geometry} material={material} renderOrder={1} />;
}
/**
* Renders a ForceFieldBare from pre-resolved ForceFieldData.
* Used by the unified EntityRenderer does NOT read from TorqueObject/datablock.
*/
export function ForceFieldBare({
data,
scale,
}: {
data: ForceFieldData;
scale: [number, number, number];
}) {
const textureUrls = useMemo(
() => data.textures.map((t) => textureToUrl(t)),
[data.textures],
);
// Render fallback mesh when textures are missing instead of disappearing.
if (textureUrls.length === 0) {
return (
<group position={position} quaternion={quaternion}>
<ForceFieldFallback
scale={scale}
color={color}
baseTranslucency={baseTranslucency}
/>
</group>
<ForceFieldFallback
scale={scale}
color={data.color}
baseTranslucency={data.baseTranslucency}
/>
);
}
return (
<group position={position} quaternion={quaternion}>
<Suspense
fallback={
<ForceFieldFallback
scale={scale}
color={color}
baseTranslucency={baseTranslucency}
/>
}
>
<ForceFieldMesh
<Suspense
fallback={
<ForceFieldFallback
scale={scale}
color={color}
baseTranslucency={baseTranslucency}
textureUrls={textureUrls}
numFrames={numFrames}
framesPerSec={framesPerSec}
scrollSpeed={scrollSpeed}
umapping={umapping}
vmapping={vmapping}
color={data.color}
baseTranslucency={data.baseTranslucency}
/>
</Suspense>
</group>
}
>
<ForceFieldMesh scale={scale} data={data} />
</Suspense>
);
});
}

View file

@ -21,7 +21,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector, demoEffectNow, engineStore } from "../state";
import { useEngineSelector, effectNow, engineStore } from "../state";
import { FloatingLabel } from "./FloatingLabel";
import {
useIflTexture,
@ -36,15 +36,13 @@ import { injectShapeLighting } from "../shapeMaterial";
import {
processShapeScene,
replaceWithShapeMaterial,
} from "../demo/demoPlaybackUtils";
import type { DemoThreadState } from "../demo/types";
} from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types";
/** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number {
const status = engineStore.getState().playback.status;
return status !== "stopped"
? demoEffectNow() / 1000
: performance.now() / 1000;
const { recording } = engineStore.getState().playback;
return recording != null ? effectNow() / 1000 : performance.now() / 1000;
}
/** Shared props for texture rendering components */
@ -250,6 +248,8 @@ const IflTexture = memo(function IflTexture({
);
});
const EMPTY_FLAG_NAMES = new Set<string>();
const StaticTexture = memo(function StaticTexture({
material,
shapeName,
@ -261,7 +261,13 @@ const StaticTexture = memo(function StaticTexture({
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = new Set<string>(material.userData.flag_names ?? []);
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
const url = useMemo(() => {
if (!resourcePath) {
@ -424,40 +430,41 @@ function HardcodedShape({ label }: { label?: string }) {
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
* pattern used across shape-rendering components.
*/
export function ShapeRenderer({
export const ShapeRenderer = memo(function ShapeRenderer({
loadingColor = "yellow",
demoThreads,
streamEntity,
children,
}: {
loadingColor?: string;
demoThreads?: DemoThreadState[];
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
if (!shapeName) {
return (
<DebugPlaceholder color="orange" label={`${object._id}: <missing>`} />
<DebugPlaceholder color="orange" label={`${object?._id}: <missing>`} />
);
}
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
return <HardcodedShape label={`${object._id}: ${shapeName}`} />;
return <HardcodedShape label={`${object?._id}: ${shapeName}`} />;
}
return (
<ErrorBoundary
fallback={
<DebugPlaceholder color="red" label={`${object._id}: ${shapeName}`} />
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModelLoader demoThreads={demoThreads} />
<ShapeModelLoader streamEntity={streamEntity} />
{children}
</Suspense>
</ErrorBoundary>
);
}
});
/** Vis node info collected from the scene for vis opacity animation. */
interface VisNode {
@ -475,9 +482,6 @@ interface ThreadState {
startTime: number;
}
// Thread slot constants matching power.cs globals
const DEPLOY_THREAD = 3;
/**
* Unified shape renderer. Clones the full scene graph (preserving skeleton
* bindings), applies Tribes 2 materials via processShapeScene, and drives
@ -486,124 +490,123 @@ const DEPLOY_THREAD = 3;
*/
export const ShapeModel = memo(function ShapeModel({
gltf,
demoThreads,
streamEntity,
}: {
gltf: ReturnType<typeof useStaticShape>;
demoThreads?: DemoThreadState[];
/** Stable entity reference whose `.threads` field is mutated in-place. */
streamEntity?: { threads?: StreamThreadState[] };
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const runtime = useEngineSelector((state) => state.runtime.runtime);
const {
clonedScene,
mixer,
clipsByName,
visNodesBySequence,
iflMeshes,
} = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
const { clonedScene, mixer, clipsByName, visNodesBySequence, iflMeshes } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
// Detect IFL materials BEFORE processShapeScene replaces them, since the
// replacement materials lose the original userData (flag_names, resource_path).
const iflInfos: Array<{
mesh: any;
iflPath: string;
hasVisSequence: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
iflToolBegin?: number;
}> = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
// ifl_sequence is set by the addon when ifl_matters links this IFL to
// a controlling sequence. vis_sequence is a separate system (opacity
// animation) and must NOT be used as a fallback — the two are independent.
const iflSeq = ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined;
const iflDur = ud?.ifl_duration
? Number(ud.ifl_duration)
: undefined;
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
const iflToolBegin = ud?.ifl_tool_begin != null
? Number(ud.ifl_tool_begin)
: undefined;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!(ud?.vis_sequence),
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
iflToolBegin,
});
}
});
processShapeScene(scene);
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
// sequence is activated by playThread.
for (const { mesh, hasVisSequence } of iflInfos) {
if (!hasVisSequence) {
mesh.visible = true;
}
}
// Collect ALL vis-animated nodes, grouped by sequence name.
const visBySeq = new Map<string, VisNode[]>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
if (!ud) return;
const kf = ud.vis_keyframes;
const dur = ud.vis_duration;
const seqName = (ud.vis_sequence ?? "").toLowerCase();
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
return;
let list = visBySeq.get(seqName);
if (!list) {
list = [];
visBySeq.set(seqName, list);
}
list.push({
mesh: node,
keyframes: kf,
duration: dur,
cyclic: !!ud.vis_cyclic,
// Detect IFL materials BEFORE processShapeScene replaces them, since the
// replacement materials lose the original userData (flag_names, resource_path).
const iflInfos: Array<{
mesh: any;
iflPath: string;
hasVisSequence: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
iflToolBegin?: number;
}> = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
// ifl_sequence is set by the addon when ifl_matters links this IFL to
// a controlling sequence. vis_sequence is a separate system (opacity
// animation) and must NOT be used as a fallback — the two are independent.
const iflSeq = ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined;
const iflDur = ud?.ifl_duration ? Number(ud.ifl_duration) : undefined;
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
const iflToolBegin =
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!ud?.vis_sequence,
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
iflToolBegin,
});
}
});
});
// Build clips by name (case-insensitive)
const clips = new Map<string, AnimationClip>();
for (const clip of gltf.animations) {
clips.set(clip.name.toLowerCase(), clip);
}
processShapeScene(scene, shapeName ?? undefined);
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
// sequence is activated by playThread.
for (const { mesh, hasVisSequence } of iflInfos) {
if (!hasVisSequence) {
mesh.visible = true;
}
}
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf]);
// Collect ALL vis-animated nodes, grouped by sequence name.
const visBySeq = new Map<string, VisNode[]>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
if (!ud) return;
const kf = ud.vis_keyframes;
const dur = ud.vis_duration;
const seqName = (ud.vis_sequence ?? "").toLowerCase();
if (
!seqName ||
!Array.isArray(kf) ||
kf.length <= 1 ||
!dur ||
dur <= 0
)
return;
let list = visBySeq.get(seqName);
if (!list) {
list = [];
visBySeq.set(seqName, list);
}
list.push({
mesh: node,
keyframes: kf,
duration: dur,
cyclic: !!ud.vis_cyclic,
});
});
// Build clips by name (case-insensitive)
const clips = new Map<string, AnimationClip>();
for (const clip of gltf.animations) {
clips.set(clip.name.toLowerCase(), clip);
}
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf]);
const threadsRef = useRef(new Map<number, ThreadState>());
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());
@ -622,16 +625,16 @@ export const ShapeModel = memo(function ShapeModel({
const animationEnabledRef = useRef(animationEnabled);
animationEnabledRef.current = animationEnabled;
// Stable ref for the deploy-end callback so useFrame can advance the
// lifecycle when animation is toggled off mid-deploy.
const onDeployEndRef = useRef<((slot: number) => void) | null>(null);
// Refs for demo thread-driven animation (exposed from the main animation effect).
const demoThreadsRef = useRef(demoThreads);
demoThreadsRef.current = demoThreads;
const handlePlayThreadRef = useRef<((slot: number, seq: string) => void) | null>(null);
// Stream entity reference for imperative thread reads in useFrame.
// The entity is mutated in-place, so reading streamEntity?.threads
// always returns the latest value without requiring React re-renders.
const streamEntityRef = useRef(streamEntity);
streamEntityRef.current = streamEntity;
const handlePlayThreadRef = useRef<
((slot: number, seq: string) => void) | null
>(null);
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
const prevDemoThreadsRef = useRef<DemoThreadState[] | undefined>(undefined);
const prevDemoThreadsRef = useRef<StreamThreadState[] | undefined>(undefined);
// Load IFL texture atlases imperatively (processShapeScene can't resolve
// .ifl paths since they require async loading of the frame list).
@ -648,26 +651,56 @@ export const ShapeModel = memo(function ShapeModel({
mat.map = atlas.texture;
mat.needsUpdate = true;
}
iflAnimInfosRef.current.push({
const iflInfo = {
atlas,
sequenceName: info.iflSequence,
sequenceDuration: info.iflDuration,
cyclic: info.iflCyclic,
toolBegin: info.iflToolBegin,
});
};
iflAnimInfosRef.current.push(iflInfo);
iflMeshAtlasRef.current.set(info.mesh, atlas);
})
.catch(() => {});
.catch((err) => {
console.warn(
`[ShapeModel] Failed to load IFL atlas for ${info.iflPath}:`,
err,
);
});
}
}, [iflMeshes]);
// Animation setup. Shared helpers (handlePlayThread, handleStopThread) are
// used by both mission rendering and demo playback. The lifecycle that
// decides WHEN to call them differs: mission mode auto-plays deploy and
// subscribes to TorqueScript; demo mode does nothing on mount and lets
// the useFrame handler drive everything from ghost thread data.
// DTS cyclic flags by sequence name. Cyclic sequences loop; non-cyclic
// play once and clamp. Falls back to assuming cyclic if data is absent.
const seqCyclicByName = useMemo(() => {
const map = new Map<string, boolean>();
const rawNames = gltf.scene.userData?.dts_sequence_names;
const rawCyclic = gltf.scene.userData?.dts_sequence_cyclic;
if (typeof rawNames === "string" && typeof rawCyclic === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const cyclic: boolean[] = JSON.parse(rawCyclic);
for (let i = 0; i < names.length; i++) {
map.set(names[i].toLowerCase(), cyclic[i] ?? true);
}
} catch {
/* expected */
}
}
return map;
}, [gltf]);
// Animation setup.
//
// Mission mode (streamEntity absent): auto-play default looping sequences
// (power, ambient) so static shapes look alive. TorqueScript playThread/
// stopThread/pauseThread events can override if scripts are loaded.
//
// Demo/live mode (streamEntity present): no auto-play. The useFrame
// handler reads ghost ThreadMask data and drives everything.
useEffect(() => {
const threads = threadsRef.current;
const isMissionMode = streamEntityRef.current == null;
function prepareVisNode(v: VisNode) {
v.mesh.visible = true;
@ -700,25 +733,15 @@ export const ShapeModel = memo(function ShapeModel({
if (clip && mixer) {
const action = mixer.clipAction(clip);
if (seqLower === "deploy") {
const cyclic = seqCyclicByName.get(seqLower) ?? true;
if (cyclic) {
action.setLoop(LoopRepeat, Infinity);
} else {
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
} else {
action.setLoop(LoopRepeat, Infinity);
}
action.reset().play();
thread.action = action;
// When animations are disabled, snap deploy to its end pose.
if (!animationEnabledRef.current && seqLower === "deploy") {
action.time = clip.duration;
mixer.update(0);
// In mission mode, onDeployEndRef advances the lifecycle.
// In demo mode it's null — the ghost data drives what's next.
if (onDeployEndRef.current) {
queueMicrotask(() => onDeployEndRef.current?.(slot));
}
}
}
if (vNodes) {
@ -747,113 +770,44 @@ export const ShapeModel = memo(function ShapeModel({
handlePlayThreadRef.current = handlePlayThread;
handleStopThreadRef.current = handleStopThread;
// ── Demo playback: all animation driven by ghost thread data ──
// No deploy lifecycle, no auto-play, no TorqueScript. The useFrame
// handler reads demoThreads and calls handlePlayThread/handleStopThread.
if (demoThreadsRef.current != null) {
// ── Demo/live mode: no auto-play, useFrame drives from ghost data ──
if (!isMissionMode) {
return () => {
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}
// ── Mission rendering: deploy lifecycle + TorqueScript ──
const hasDeployClip = clipsByName.has("deploy");
const useTorqueDeploy = !!(runtime && hasDeployClip && object.datablock);
function fireOnEndSequence(slot: number) {
if (!runtime) return;
const dbName = object.datablock;
if (!dbName) return;
const datablock = runtime.getObjectByName(String(dbName));
if (datablock) {
runtime.$.call(datablock, "onEndSequence", object, slot);
}
}
onDeployEndRef.current = useTorqueDeploy
? fireOnEndSequence
: () => startPostDeployThreads();
function startPostDeployThreads() {
const autoPlaySequences = ["ambient", "power"];
for (const seqName of autoPlaySequences) {
const vNodes = visNodesBySequence.get(seqName);
if (vNodes) {
const startTime = shapeNowSec();
for (const v of vNodes) prepareVisNode(v);
const slot = seqName === "power" ? 0 : 1;
threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime });
}
const clip = clipsByName.get(seqName);
if (clip && mixer) {
const action = mixer.clipAction(clip);
action.setLoop(LoopRepeat, Infinity);
action.reset().play();
const slot = seqName === "power" ? 0 : 1;
const existing = threads.get(slot);
if (existing) {
existing.action = action;
} else {
threads.set(slot, {
sequence: seqName,
action,
startTime: shapeNowSec(),
});
}
}
}
}
// ── Mission mode ──
const unsubs: (() => void)[] = [];
const onFinished = mixer
? (e: { action: AnimationAction }) => {
for (const [slot, thread] of threads) {
if (thread.action === e.action) {
if (useTorqueDeploy) {
fireOnEndSequence(slot);
} else {
startPostDeployThreads();
}
break;
}
}
}
: null;
if (onFinished && mixer) {
mixer.addEventListener("finished", onFinished);
}
// Subscribe to TorqueScript playThread/stopThread/pauseThread so
// scripts can control animations at runtime.
if (runtime) {
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"playThread",
(thisObj, slot, sequence) => {
if (thisObj._id !== object._id) return;
if (thisObj._id !== object?._id) return;
handlePlayThread(Number(slot), String(sequence));
},
),
);
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"stopThread",
(thisObj, slot) => {
if (thisObj._id !== object._id) return;
handleStopThread(Number(slot));
},
),
runtime.$.onMethodCalled("ShapeBase", "stopThread", (thisObj, slot) => {
if (thisObj._id !== object?._id) return;
handleStopThread(Number(slot));
}),
);
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"pauseThread",
(thisObj, slot) => {
if (thisObj._id !== object._id) return;
if (thisObj._id !== object?._id) return;
const thread = threads.get(Number(slot));
if (thread?.action) {
thread.action.paused = true;
@ -863,25 +817,33 @@ export const ShapeModel = memo(function ShapeModel({
);
}
if (useTorqueDeploy) {
runtime.$.call(object, "deploy");
} else if (hasDeployClip) {
handlePlayThread(DEPLOY_THREAD, "deploy");
} else {
startPostDeployThreads();
// Start default looping sequences immediately. Thread slots match
// power.cs globals: $PowerThread=0, $AmbientThread=1.
const defaults: Array<[number, string]> = [
[0, "power"],
[1, "ambient"],
];
for (const [slot, seqName] of defaults) {
if (clipsByName.has(seqName) || visNodesBySequence.has(seqName)) {
handlePlayThread(slot, seqName);
}
}
return () => {
if (onFinished && mixer) {
mixer.removeEventListener("finished", onFinished);
}
unsubs.forEach((fn) => fn());
onDeployEndRef.current = null;
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
prevDemoThreadsRef.current = undefined;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}, [mixer, clipsByName, visNodesBySequence, object, runtime]);
}, [
mixer,
clipsByName,
visNodesBySequence,
seqCyclicByName,
object,
runtime,
]);
// Build DTS sequence index → animation name lookup. If the glTF has the
// dts_sequence_names extra (set by the addon), use it for an exact mapping
@ -893,7 +855,9 @@ export const ShapeModel = memo(function ShapeModel({
try {
const names: string[] = JSON.parse(raw);
return names.map((n) => n.toLowerCase());
} catch {}
} catch {
/* expected */
}
}
return gltf.animations.map((a) => a.name.toLowerCase());
}, [gltf]);
@ -901,16 +865,20 @@ export const ShapeModel = memo(function ShapeModel({
useFrame((_, delta) => {
const threads = threadsRef.current;
// In demo mode, scale animation by playback rate; freeze when paused.
const inDemo = demoThreadsRef.current != null;
// In demo/live mode, scale animation by playback rate; freeze when paused.
// Check streamEntity existence (not .threads) so shapes without thread
// data (e.g. Items) also freeze correctly when paused.
const inDemo = streamEntityRef.current != null;
const playbackState = engineStore.getState().playback;
const effectDelta = !inDemo ? delta
: playbackState.status === "playing" ? delta * playbackState.rate
: 0;
const effectDelta = !inDemo
? delta
: playbackState.status === "playing"
? delta * playbackState.rate
: 0;
// React to demo thread state changes. The ghost ThreadMask data tells us
// exactly which DTS sequences are playing/stopped on each of 4 thread slots.
const currentDemoThreads = demoThreadsRef.current;
const currentDemoThreads = streamEntityRef.current?.threads;
const prevDemoThreads = prevDemoThreadsRef.current;
if (currentDemoThreads !== prevDemoThreads) {
const playThread = handlePlayThreadRef.current;
@ -920,11 +888,11 @@ export const ShapeModel = memo(function ShapeModel({
if (playThread && stopThread) {
prevDemoThreadsRef.current = currentDemoThreads;
// Use sparse arrays instead of Maps — thread indices are 0-3.
const currentBySlot: Array<DemoThreadState | undefined> = [];
const currentBySlot: Array<StreamThreadState | undefined> = [];
if (currentDemoThreads) {
for (const t of currentDemoThreads) currentBySlot[t.index] = t;
}
const prevBySlot: Array<DemoThreadState | undefined> = [];
const prevBySlot: Array<StreamThreadState | undefined> = [];
if (prevDemoThreads) {
for (const t of prevDemoThreads) prevBySlot[t.index] = t;
}
@ -933,21 +901,24 @@ export const ShapeModel = memo(function ShapeModel({
const t = currentBySlot[slot];
const prev = prevBySlot[slot];
if (t) {
const changed = !prev
|| prev.sequence !== t.sequence
|| prev.state !== t.state
|| prev.atEnd !== t.atEnd;
const changed =
!prev ||
prev.sequence !== t.sequence ||
prev.state !== t.state ||
prev.atEnd !== t.atEnd;
if (!changed) continue;
// When only atEnd changed (false→true) on a playing thread with
// the same sequence, the animation has finished on the server.
// Don't restart it — snap to the end pose so one-shot animations
// like "deploy" stay clamped instead of collapsing back.
const onlyAtEndChanged = prev
&& prev.sequence === t.sequence
&& prev.state === t.state
&& t.state === 0
&& !prev.atEnd && t.atEnd;
const onlyAtEndChanged =
prev &&
prev.sequence === t.sequence &&
prev.state === t.state &&
t.state === 0 &&
!prev.atEnd &&
t.atEnd;
if (onlyAtEndChanged) {
const thread = threads.get(slot);
if (thread?.action) {
@ -975,24 +946,8 @@ export const ShapeModel = memo(function ShapeModel({
}
}
if (mixer) {
// If animation is disabled and deploy is still mid-animation,
// snap to the fully-deployed pose and fire onEndSequence.
if (!animationEnabled) {
const deployThread = threads.get(DEPLOY_THREAD);
if (deployThread?.action) {
const clip = deployThread.action.getClip();
if (deployThread.action.time < clip.duration - 0.001) {
deployThread.action.time = clip.duration;
mixer.update(0);
onDeployEndRef.current?.(DEPLOY_THREAD);
}
}
}
if (animationEnabled) {
mixer.update(effectDelta);
}
if (mixer && animationEnabled) {
mixer.update(effectDelta);
}
// Drive vis opacity animations for active threads.
@ -1054,7 +1009,10 @@ export const ShapeModel = memo(function ShapeModel({
break;
}
}
updateAtlasFrame(info.atlas, getFrameIndexForTime(info.atlas, iflTime));
updateAtlasFrame(
info.atlas,
getFrameIndexForTime(info.atlas, iflTime),
);
} else {
// No controlling sequence: use accumulated real time.
// (In the engine, these would stay at frame 0, but cycling is more
@ -1073,15 +1031,19 @@ export const ShapeModel = memo(function ShapeModel({
<primitive object={clonedScene} />
{debugMode ? (
<FloatingLabel>
{object._id}: {shapeName}
{object?._id}: {shapeName}
</FloatingLabel>
) : null}
</group>
);
});
function ShapeModelLoader({ demoThreads }: { demoThreads?: DemoThreadState[] }) {
function ShapeModelLoader({
streamEntity,
}: {
streamEntity?: { threads?: StreamThreadState[] };
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
return <ShapeModel gltf={gltf} demoThreads={demoThreads} />;
return <ShapeModel gltf={gltf} streamEntity={streamEntity} />;
}

View file

@ -13,40 +13,35 @@
justify-content: center;
gap: 20px;
}
.Dropdown {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.Group {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.CheckboxField,
.LabelledButton {
display: flex;
align-items: center;
gap: 6px;
}
.Field {
display: flex;
align-items: center;
gap: 6px;
}
.IconButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
margin: 0 0 0 -12px;
font-size: 15px;
@ -65,52 +60,42 @@
background 0.2s,
border-color 0.2s;
}
.IconButton svg {
pointer-events: none;
}
@media (hover: hover) {
.IconButton:hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
}
.IconButton:active,
.IconButton[aria-expanded="true"] {
background: rgba(0, 98, 179, 0.7);
border-color: rgba(255, 255, 255, 0.3);
transform: translate(0, 1px);
}
.IconButton[data-active="true"] {
background: rgba(0, 117, 213, 0.9);
border-color: rgba(255, 255, 255, 0.4);
}
.ButtonLabel {
font-size: 12px;
}
.Toggle {
composes: IconButton;
margin: 0;
}
.MapInfoButton {
composes: IconButton;
composes: LabelledButton;
}
.MissionSelectWrapper {
}
@media (max-width: 1279px) {
.Dropdown[data-open="false"] {
display: none;
}
.Dropdown {
position: absolute;
top: calc(100% + 2px);
@ -128,47 +113,38 @@
padding: 12px;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
}
.Group {
flex-wrap: wrap;
gap: 12px 20px;
}
.LabelledButton {
width: auto;
padding: 0 10px;
}
}
@media (max-width: 639px) {
.Controls {
right: 0;
border-radius: 0;
}
.MissionSelectWrapper {
flex: 1 1 0;
min-width: 0;
}
.MissionSelectWrapper input {
width: 100%;
}
.Toggle {
flex: 0 0 auto;
}
}
@media (min-width: 1280px) {
.Toggle {
display: none;
}
.LabelledButton .ButtonLabel {
display: none;
}
.MapInfoButton {
display: none;
}

View file

@ -8,16 +8,18 @@ import { MissionSelect } from "./MissionSelect";
import { useEffect, useState, useRef, RefObject } from "react";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { LoadDemoButton } from "./LoadDemoButton";
import { useDemoRecording } from "./DemoProvider";
import { JoinServerButton } from "./JoinServerButton";
import { useRecording } from "./RecordingProvider";
import { useLiveConnectionOptional } from "./LiveConnection";
import { FiInfo, FiSettings } from "react-icons/fi";
import { Camera } from "three";
import styles from "./InspectorControls.module.css";
export function InspectorControls({
missionName,
missionType,
onChangeMission,
onOpenMapInfo,
onOpenServerBrowser,
isTouch,
cameraRef,
}: {
@ -31,6 +33,7 @@ export function InspectorControls({
missionType: string;
}) => void;
onOpenMapInfo: () => void;
onOpenServerBrowser?: () => void;
isTouch: boolean | null;
cameraRef: RefObject<Camera>;
}) {
@ -47,20 +50,23 @@ export function InspectorControls({
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } =
useControls();
const { debugMode, setDebugMode } = useDebug();
const demoRecording = useDemoRecording();
const isDemoLoaded = demoRecording != null;
const demoRecording = useRecording();
const live = useLiveConnectionOptional();
const isLive = live?.adapter != null;
const isStreaming = demoRecording != null || isLive;
// Hide FOV/speed controls during .rec playback (faithfully replaying),
// but show them in .mis browsing and live observer mode.
const hideViewControls = isStreaming && !isLive;
const [settingsOpen, setSettingsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const focusAreaRef = useRef<HTMLDivElement>(null);
// Focus the panel when it opens.
useEffect(() => {
if (settingsOpen) {
dropdownRef.current?.focus();
}
}, [settingsOpen]);
const handleDropdownBlur = (e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && focusAreaRef.current?.contains(relatedTarget)) {
@ -68,7 +74,6 @@ export function InspectorControls({
}
setSettingsOpen(false);
};
// Close on Escape and return focus to the gear button.
const handlePanelKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@ -76,7 +81,6 @@ export function InspectorControls({
buttonRef.current?.focus();
}
};
return (
<div
id="controls"
@ -90,7 +94,7 @@ export function InspectorControls({
value={missionName}
missionType={missionType}
onChange={onChangeMission}
disabled={isDemoLoaded}
disabled={isStreaming}
/>
</div>
<div ref={focusAreaRef}>
@ -122,6 +126,9 @@ export function InspectorControls({
cameraRef={cameraRef}
/>
<LoadDemoButton />
{onOpenServerBrowser && (
<JoinServerButton onOpenServerBrowser={onOpenServerBrowser} />
)}
<button
type="button"
className={styles.MapInfoButton}
@ -181,7 +188,7 @@ export function InspectorControls({
</div>
</div>
<div className={styles.Group}>
{isDemoLoaded ? null : (
{hideViewControls ? null : (
<div className={styles.Field}>
<label htmlFor="fovInput">FOV</label>
<input
@ -191,13 +198,12 @@ export function InspectorControls({
max={120}
step={5}
value={fov}
disabled={isDemoLoaded}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="fovInput">{fov}</output>
</div>
)}
{isDemoLoaded ? null : (
{hideViewControls ? null : (
<div className={styles.Field}>
<label htmlFor="speedInput">Speed</label>
<input
@ -207,7 +213,6 @@ export function InspectorControls({
max={5}
step={0.05}
value={speedMultiplier}
disabled={isDemoLoaded}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}

View file

@ -11,8 +11,12 @@ import {
} from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { textureToUrl, interiorToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import type { SceneInteriorInstance } from "../scene/types";
import {
torqueToThree,
torqueScaleToThree,
matrixFToQuaternion,
} from "../scene/coordinates";
import { setupTexture } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
@ -41,18 +45,15 @@ function InteriorTexture({
const debugMode = debugContext?.debugMode ?? false;
const url = textureToUrl(materialName);
const texture = useTexture(url, (texture) => setupTexture(texture));
// Check for self-illuminating flag in material userData
// Note: The io_dif Blender add-on needs to be updated to export material flags
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);
const isSelfIlluminating = flagNames.has("SelfIlluminating");
// Check for SurfaceOutsideVisible flag (surfaces that receive scene ambient light)
const surfaceFlagNames = new Set<string>(
material?.userData?.surface_flag_names ?? [],
);
const isSurfaceOutsideVisible = surfaceFlagNames.has("SurfaceOutsideVisible");
// Inject volumetric fog and lighting multipliers into materials
// NOTE: This hook must be called unconditionally (before any early returns)
const onBeforeCompile = useCallback(
@ -64,11 +65,9 @@ function InteriorTexture({
},
[isSurfaceOutsideVisible],
);
// Refs for forcing shader recompilation
const basicMaterialRef = useRef<MeshBasicMaterial>(null);
const lambertMaterialRef = useRef<MeshLambertMaterial>(null);
// Force shader recompilation when debugMode changes
// r3f doesn't sync defines prop changes, so we update the material directly
useEffect(() => {
@ -81,12 +80,9 @@ function InteriorTexture({
mat.needsUpdate = true;
}
}, [debugMode]);
const defines = { DEBUG_MODE: debugMode ? 1 : 0 };
// Key for shader structure changes (surfaceOutsideVisible affects lighting model)
const materialKey = `${isSurfaceOutsideVisible}`;
// Self-illuminating materials are fullbright (unlit), no lightmap
if (isSelfIlluminating) {
return (
@ -100,7 +96,6 @@ function InteriorTexture({
/>
);
}
// MeshLambertMaterial for diffuse-only lighting (matches Tribes 2's GL pipeline)
// Shader modifications in onBeforeCompile:
// - Outside surfaces (SurfaceOutsideVisible): scene lighting + additive lightmap
@ -186,11 +181,11 @@ function InteriorMesh({ node }: { node: Mesh }) {
}
export const InteriorModel = memo(function InteriorModel({
object,
interiorFile,
ghostIndex,
}: {
object: TorqueObject;
interiorFile: string;
ghostIndex?: number;
}) {
const { nodes } = useInterior(interiorFile);
const debugContext = useDebug();
@ -205,7 +200,7 @@ export const InteriorModel = memo(function InteriorModel({
))}
{debugMode ? (
<FloatingLabel>
{object._id}: {interiorFile}
{ghostIndex}: {interiorFile}
</FloatingLabel>
) : null}
</group>
@ -235,24 +230,40 @@ function DebugInteriorPlaceholder({ label }: { label?: string }) {
}
export const InteriorInstance = memo(function InteriorInstance({
object,
scene,
}: {
object: TorqueObject;
scene: SceneInteriorInstance;
}) {
const interiorFile = getProperty(object, "interiorFile");
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const position = useMemo(
() => torqueToThree(scene.transform.position),
[scene.transform.position],
);
const q = useMemo(
() => matrixFToQuaternion(scene.transform),
[scene.transform],
);
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
return (
<group position={position} quaternion={q} scale={scale}>
<ErrorBoundary
fallback={
<DebugInteriorPlaceholder label={`${object._id}: ${interiorFile}`} />
<DebugInteriorPlaceholder
label={`${scene.ghostIndex}: ${scene.interiorFile}`}
/>
}
onError={(error) => {
console.warn(
`[interior] Failed to load ${scene.interiorFile}:`,
error.message,
);
}}
>
<Suspense fallback={<InteriorPlaceholder color="orange" />}>
<InteriorModel object={object} interiorFile={interiorFile} />
<InteriorModel
interiorFile={scene.interiorFile}
ghostIndex={scene.ghostIndex}
/>
</Suspense>
</ErrorBoundary>
</group>

View file

@ -1,74 +0,0 @@
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";
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",
2: "Inferno",
};
export function Item({ object }: { object: TorqueObject }) {
const simGroup = useSimGroup();
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) {
console.error(`<Item> missing shape for datablock: ${datablockName}`);
}
const isFlag = datablockName?.toLowerCase() === "flag";
const team = simGroup?.team ?? null;
const teamName = team && team > 0 ? TEAM_NAMES[team] : null;
const label = isFlag && teamName ? `${teamName} Flag` : null;
return (
<ShapeInfoProvider type="Item" object={object} shapeName={shapeName}>
<group
ref={groupRef}
position={position}
{...(!shouldRotate && { quaternion: q })}
scale={scale}
>
<ShapeRenderer loadingColor="pink">
{label ? <FloatingLabel opacity={0.6}>{label}</FloatingLabel> : null}
</ShapeRenderer>
</group>
</ShapeInfoProvider>
);
}

View file

@ -0,0 +1,29 @@
.Root {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
padding: 0 5px;
}
/* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */
.TextLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
}
/* Ping label is always visible regardless of breakpoint. */
.PingLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
display: flex !important;
margin-right: 2px;
}
.LiveIcon {
font-size: 15px;
}
.Pulsing {
animation: blink 1.2s ease-out infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}

View file

@ -0,0 +1,53 @@
import { BsFillLightningChargeFill } from "react-icons/bs";
import { useLiveConnectionOptional } from "./LiveConnection";
import styles from "./JoinServerButton.module.css";
function formatPing(ms: number): string {
return ms >= 1000 ? ms.toLocaleString() + "ms" : ms + "ms";
}
export function JoinServerButton({
onOpenServerBrowser,
}: {
onOpenServerBrowser: () => void;
}) {
const live = useLiveConnectionOptional();
if (!live) return null;
const isLive = live.gameStatus === "connected";
const isConnecting =
live.gameStatus === "connecting" ||
live.gameStatus === "challenging" ||
live.gameStatus === "authenticating";
return (
<button
type="button"
className={styles.Root}
aria-label={isLive ? "Disconnect" : "Join server"}
title={isLive ? "Disconnect" : "Join server"}
onClick={() => {
if (isLive) {
live.disconnectServer();
} else {
onOpenServerBrowser();
}
}}
data-active={isLive ? "true" : undefined}
>
<BsFillLightningChargeFill
className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`}
/>
{!isLive && (
<span className={styles.TextLabel}>
{isConnecting ? "Connecting..." : "Connect"}
</span>
)}
{isLive && (
<span className={styles.PingLabel}>
{live.ping != null ? formatPing(live.ping) : "Live"}
</span>
)}
</button>
);
}

View file

@ -1,10 +1,10 @@
import { useKeyboardControls } from "@react-three/drei";
import { Controls } from "./ObserverControls";
import { useDemoRecording } from "./DemoProvider";
import { useRecording } from "./RecordingProvider";
import styles from "./KeyboardOverlay.module.css";
export function KeyboardOverlay() {
const recording = useDemoRecording();
const recording = useRecording();
const forward = useKeyboardControls<Controls>((s) => s.forward);
const backward = useKeyboardControls<Controls>((s) => s.backward);
const left = useKeyboardControls<Controls>((s) => s.left);
@ -16,7 +16,9 @@ export function KeyboardOverlay() {
const lookLeft = useKeyboardControls<Controls>((s) => s.lookLeft);
const lookRight = useKeyboardControls<Controls>((s) => s.lookRight);
if (recording) return null;
// Show when no recording (map browsing) or during live mode.
// Hidden during demo playback (recording with finite duration).
if (recording && recording.source !== "live") return null;
return (
<div className={styles.Root}>

View file

@ -0,0 +1,266 @@
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
} from "react";
import { RelayClient } from "../stream/relayClient";
import { LiveStreamAdapter } from "../stream/liveStreaming";
import type {
ClientMove,
ServerInfo,
ConnectionStatus,
} from "../../relay/types";
interface LiveConnectionState {
relayConnected: boolean;
gameStatus: ConnectionStatus | null;
gameStatusMessage?: string;
/** Map name from the server being joined (from GameInfoResponse or status). */
mapName?: string;
/** Effective RTT to the game server (relay↔T2 + browser↔relay). */
ping: number | null;
/** Browser↔relay WebSocket RTT in ms. */
wsPing: number | null;
servers: ServerInfo[];
serversLoading: boolean;
adapter: LiveStreamAdapter | null;
/** True once the first ghost entity arrives (game is rendering). */
liveReady: boolean;
}
interface LiveConnectionActions {
connectRelay: (url?: string) => void;
disconnectRelay: () => void;
listServers: () => void;
joinServer: (address: string) => void;
disconnectServer: () => void;
sendMove: (move: ClientMove) => void;
sendCommand: (command: string, ...args: string[]) => void;
}
const LiveConnectionContext = createContext<
(LiveConnectionState & LiveConnectionActions) | null
>(null);
export function useLiveConnection() {
const ctx = useContext(LiveConnectionContext);
if (!ctx) {
throw new Error("useLiveConnection must be used within LiveConnectionProvider");
}
return ctx;
}
export function useLiveConnectionOptional() {
return useContext(LiveConnectionContext);
}
const DEFAULT_RELAY_URL =
process.env.NEXT_PUBLIC_RELAY_URL || "ws://localhost:8765";
export function LiveConnectionProvider({
children,
}: {
children: React.ReactNode;
}) {
const relayRef = useRef<RelayClient | null>(null);
const adapterRef = useRef<LiveStreamAdapter | null>(null);
// Queue of actions to run once the relay WebSocket opens.
const pendingRef = useRef<Array<() => void>>([]);
const listInFlightRef = useRef(false);
const [relayConnected, setRelayConnected] = useState(false);
const [gameStatus, setGameStatus] = useState<ConnectionStatus | null>(null);
const [gameStatusMessage, setGameStatusMessage] = useState<
string | undefined
>();
const [mapName, setMapName] = useState<string | undefined>();
const [servers, setServers] = useState<ServerInfo[]>([]);
const [serversLoading, setServersLoading] = useState(false);
const [adapter, setAdapter] = useState<LiveStreamAdapter | null>(null);
const [liveReady, setLiveReady] = useState(false);
const [relayPing, setRelayPing] = useState<number | null>(null);
const [wsPing, setWsPing] = useState<number | null>(null);
const connectRelay = useCallback((url: string = DEFAULT_RELAY_URL) => {
if (relayRef.current) {
relayRef.current.close();
relayRef.current = null;
}
const relay = new RelayClient(url, {
onOpen() {
setRelayConnected(true);
// Flush any queued actions (e.g. listServers called before open).
for (const fn of pendingRef.current) fn();
pendingRef.current = [];
},
onStatus(status, message, _connectSequence, statusMapName) {
console.log(
`[relay] game status: ${status}${message ? `${message}` : ""}${statusMapName ? ` map=${statusMapName}` : ""}`,
);
setGameStatus(status);
setGameStatusMessage(message);
if (statusMapName) {
setMapName(statusMapName);
}
},
onServerList(list) {
setServers(list);
setServersLoading(false);
listInFlightRef.current = false;
},
onGamePacket(data) {
if (!adapterRef.current) {
console.warn("[relay] received game packet but no adapter is active");
}
adapterRef.current?.feedPacket(data);
},
onPing(ms) {
setRelayPing(ms);
},
onWsPing(ms) {
setWsPing(ms);
},
onError(message) {
console.error("Relay error:", message);
setServersLoading(false);
listInFlightRef.current = false;
},
onClose() {
// Only update state if this is still the active relay.
if (relayRef.current === relay) {
relayRef.current = null;
setRelayConnected(false);
setGameStatus(null);
setMapName(undefined);
setRelayPing(null);
setWsPing(null);
setAdapter(null);
setLiveReady(false);
adapterRef.current = null;
pendingRef.current = [];
listInFlightRef.current = false;
}
},
});
relay.connect();
relayRef.current = relay;
}, []);
const disconnectRelay = useCallback(() => {
relayRef.current?.close();
relayRef.current = null;
adapterRef.current = null;
pendingRef.current = [];
setRelayConnected(false);
setGameStatus(null);
setMapName(undefined);
setAdapter(null);
setLiveReady(false);
}, []);
const listServers = useCallback(() => {
if (listInFlightRef.current) return;
listInFlightRef.current = true;
const doList = () => {
relayRef.current?.sendWsPing();
relayRef.current?.listServers();
};
setServersLoading(true);
if (relayRef.current?.connected) {
doList();
} else {
// Connect first, then list once the socket opens.
pendingRef.current.push(doList);
if (!relayRef.current) {
connectRelay();
}
}
}, [connectRelay]);
const joinServer = useCallback((address: string) => {
if (!relayRef.current) return;
// Set mapName from the cached server list immediately so the browser
// can start loading the mission before the relay even connects to the
// game server.
const cachedServer = servers.find((s) => s.address === address);
if (cachedServer?.mapName) {
setMapName(cachedServer.mapName);
}
const newAdapter = new LiveStreamAdapter(relayRef.current);
newAdapter.onReady = () => setLiveReady(true);
adapterRef.current = newAdapter;
setLiveReady(false);
setGameStatus(null);
setAdapter(newAdapter);
relayRef.current.joinServer(address);
}, [servers]);
const disconnectServer = useCallback(() => {
relayRef.current?.disconnectServer();
adapterRef.current?.reset();
adapterRef.current = null;
setAdapter(null);
setLiveReady(false);
setGameStatus(null);
setMapName(undefined);
setRelayPing(null);
}, []);
const sendMove = useCallback((move: ClientMove) => {
relayRef.current?.sendMove(move);
}, []);
const sendCommand = useCallback((command: string, ...args: string[]) => {
relayRef.current?.sendCommand(command, args);
}, []);
// Clean up on unmount.
useEffect(() => {
return () => {
relayRef.current?.close();
};
}, []);
// Effective RTT = relay↔T2 RTT + browser↔relay RTT.
const ping =
relayPing != null && wsPing != null
? relayPing + wsPing
: relayPing ?? null;
const value: LiveConnectionState & LiveConnectionActions = {
relayConnected,
gameStatus,
gameStatusMessage,
mapName,
ping,
wsPing,
servers,
serversLoading,
adapter,
liveReady,
connectRelay,
disconnectRelay,
listServers,
joinServer,
disconnectServer,
sendMove,
sendCommand,
};
return (
<LiveConnectionContext.Provider value={value}>
{children}
</LiveConnectionContext.Provider>
);
}

View file

@ -0,0 +1,328 @@
import { useRef, useEffect } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Vector3 } from "three";
import { useKeyboardControls } from "@react-three/drei";
import { useLiveConnection } from "./LiveConnection";
import { useEngineStoreApi } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { Controls, MOUSE_SENSITIVITY, ARROW_LOOK_SPEED } from "./ObserverControls";
import { useControls } from "./SettingsProvider";
import { useTick, TICK_RATE } from "./TickProvider";
import {
yawPitchToQuaternion,
MAX_PITCH,
} from "../stream/streamHelpers";
import type { StreamRecording, StreamCamera } from "../stream/types";
import type { LiveStreamAdapter } from "../stream/liveStreaming";
const TICK_INTERVAL = 1 / TICK_RATE;
// Scratch objects to avoid per-frame allocations.
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
/** Predicted camera rotation state for client-side prediction. */
interface PredictionState {
/** Absolute predicted yaw (Torque radians). */
yaw: number;
/** Absolute predicted pitch (Torque radians). */
pitch: number;
/** Previous tick's yaw, for inter-tick interpolation. */
prevYaw: number;
/** Previous tick's pitch, for inter-tick interpolation. */
prevPitch: number;
/** Whether prediction has been initialized from a server snapshot. */
initialized: boolean;
/** Last server camera snapshot we synced from (identity check for new data). */
lastSyncedCamera: StreamCamera | null;
}
/**
* Bridges the LiveStreamAdapter into the playback pipeline.
* Sends Move structs to the relay and applies client-side rotation prediction
* so camera look feels responsive at frame rate, matching how the real
* Tribes 2 client works (predict locally, correct from server).
*/
export function LiveObserver() {
const { adapter, gameStatus, sendMove } = useLiveConnection();
const store = useEngineStoreApi();
const { speedMultiplier } = useControls();
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
const { gl } = useThree();
const [, getKeys] = useKeyboardControls<Controls>();
// Accumulated rotation deltas since last move was sent. Mouse events and
// arrow keys both add to these; consumed at the tick rate (32ms).
const deltaYawRef = useRef(0);
const deltaPitchRef = useRef(0);
// Client-side prediction state.
const predRef = useRef<PredictionState>({
yaw: 0,
pitch: 0,
prevYaw: 0,
prevPitch: 0,
initialized: false,
lastSyncedCamera: null,
});
// Sub-tick accumulator for interpolation (0..TICK_INTERVAL).
const tickAccRef = useRef(0);
// Wire adapter to engine store.
useEffect(() => {
if (adapter && (gameStatus === "connected" || gameStatus === "authenticating")) {
if (activeAdapterRef.current === adapter) return;
console.log("[LiveObserver] wiring adapter to engine store");
const liveRecording: StreamRecording = {
source: "live",
duration: Infinity,
missionName: null,
gameType: null,
streamingPlayback: adapter,
};
store.getState().setRecording(liveRecording);
store.getState().setPlaybackStatus("playing");
activeAdapterRef.current = adapter;
// Reset prediction when connecting to a new server.
predRef.current.initialized = false;
predRef.current.lastSyncedCamera = null;
} else if (!adapter && activeAdapterRef.current) {
store.getState().setRecording(null);
activeAdapterRef.current = null;
predRef.current.initialized = false;
}
}, [adapter, gameStatus, store]);
// Accumulate mouse deltas when pointer is locked or dragging.
useEffect(() => {
let dragging = false;
const handleMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement) {
// Match Three.js PointerLockControls default (0.002).
deltaYawRef.current += e.movementX * 0.002;
deltaPitchRef.current += e.movementY * 0.002;
} else if (dragging) {
deltaYawRef.current += e.movementX * MOUSE_SENSITIVITY;
deltaPitchRef.current += e.movementY * MOUSE_SENSITIVITY;
}
};
const handleMouseDown = (e: MouseEvent) => {
if (!document.pointerLockElement && e.target === gl.domElement) {
dragging = true;
}
};
const handleMouseUp = () => {
dragging = false;
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [gl.domElement]);
// Left-click when pointer-locked: enter follow mode (from fly) or cycle
// to next player (in follow). Capture phase intercepts before ObserverControls.
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!document.pointerLockElement || !activeAdapterRef.current) return;
e.stopImmediatePropagation();
activeAdapterRef.current.cycleObserveNext();
};
document.addEventListener("click", handleClick, { capture: true });
return () => {
document.removeEventListener("click", handleClick, { capture: true });
};
}, [gl.domElement]);
// 'O' toggles between follow and free-fly observer modes on the server.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.code !== "KeyO" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (!activeAdapterRef.current) return;
activeAdapterRef.current.toggleObserverMode();
console.log(`[LiveObserver] observer mode: ${activeAdapterRef.current.observerMode}`);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
// Accumulate arrow-key rotation each render frame (frame-rate independent).
useFrame((_, delta) => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const { lookUp, lookDown, lookLeft, lookRight } = getKeys();
if (lookRight) deltaYawRef.current += ARROW_LOOK_SPEED * delta;
if (lookLeft) deltaYawRef.current -= ARROW_LOOK_SPEED * delta;
if (lookDown) deltaPitchRef.current += ARROW_LOOK_SPEED * delta;
if (lookUp) deltaPitchRef.current -= ARROW_LOOK_SPEED * delta;
});
// Send moves at the Torque tick rate (32Hz) and apply rotation prediction.
useTick(() => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const { forward, backward, left, right, up, down } = getKeys();
// Torque Camera axes: x = strafe (+ right), y = forward/back, z = up/down.
let mx = 0;
let my = 0;
let mz = 0;
if (forward) my += 1;
if (backward) my -= 1;
if (left) mx -= 1;
if (right) mx += 1;
if (up) mz += 1;
if (down) mz -= 1;
// Consume accumulated rotation deltas.
const yaw = deltaYawRef.current;
const pitch = deltaPitchRef.current;
deltaYawRef.current = 0;
deltaPitchRef.current = 0;
// Apply prediction: save previous tick state, then advance.
const pred = predRef.current;
pred.prevYaw = pred.yaw;
pred.prevPitch = pred.pitch;
pred.yaw += yaw;
pred.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, pred.pitch + pitch));
// Reset sub-tick accumulator for interpolation.
tickAccRef.current = 0;
// Scale movement axes by speed multiplier. Values > 1 still clamp to
// [-1, 1] server-side, but < 1 lets the user move slower.
const speed = Math.min(1, speedMultiplier);
sendMove({
x: mx * speed,
y: my * speed,
z: mz * speed,
yaw,
pitch,
roll: 0,
trigger: [false, false, false, false, false, false],
freeLook: false,
});
});
// Override camera rotation with predicted values at frame rate.
// Priority 1 ensures this runs AFTER DemoPlaybackController (priority 0),
// which handles position from server snapshots.
useFrame((state, delta) => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const pred = predRef.current;
// Sync prediction base from each new server snapshot. The server's
// yaw/pitch is authoritative; we layer any pending (unconsumed) mouse
// deltas on top so the camera feels responsive between server updates.
const snapshot = activeAdapterRef.current.getSnapshot();
const serverCam = snapshot?.camera;
if (
serverCam &&
serverCam !== pred.lastSyncedCamera &&
typeof serverCam.yaw === "number" &&
typeof serverCam.pitch === "number"
) {
// Pending deltas not yet consumed by useTick — replay on top of server.
const pendingYaw = deltaYawRef.current;
const pendingPitch = deltaPitchRef.current;
pred.prevYaw = pred.initialized ? pred.yaw : serverCam.yaw;
pred.prevPitch = pred.initialized ? pred.pitch : serverCam.pitch;
pred.yaw = serverCam.yaw + pendingYaw;
pred.pitch = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, serverCam.pitch + pendingPitch),
);
pred.lastSyncedCamera = serverCam;
pred.initialized = true;
}
if (!pred.initialized) return;
// Advance sub-tick accumulator for interpolation.
tickAccRef.current += delta;
const t = Math.min(1, tickAccRef.current / TICK_INTERVAL);
// Interpolate between previous and current tick prediction, then add
// pending (unconsumed) mouse/arrow deltas so rotation responds at frame
// rate rather than waiting for the next useTick to consume them.
const interpYaw = pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current;
const interpPitch = Math.max(
-MAX_PITCH,
Math.min(
MAX_PITCH,
pred.prevPitch + (pred.pitch - pred.prevPitch) * t + deltaPitchRef.current,
),
);
// Convert predicted rotation to Three.js quaternion and apply.
const [qx, qy, qz, qw] = yawPitchToQuaternion(interpYaw, interpPitch);
// For third-person (orbit) mode, recompute orbit position from predicted
// angles so the orbit responds at frame rate too.
if (serverCam?.mode === "third-person" && serverCam.orbitTargetId) {
const root = streamPlaybackStore.getState().root;
const targetGroup = root?.children.find(
(child) => child.name === serverCam.orbitTargetId,
);
if (targetGroup) {
_orbitTarget.copy(targetGroup.position);
const entities = streamPlaybackStore.getState().entities;
const orbitEntity = entities.get(serverCam.orbitTargetId);
if (orbitEntity?.renderType === "Player") {
_orbitTarget.y += 1.0;
}
const sx = Math.sin(interpPitch);
const cx = Math.cos(interpPitch);
const sz = Math.sin(interpYaw);
const cz = Math.cos(interpYaw);
_orbitDir.set(-cx, -sz * sx, -cz * sx);
if (_orbitDir.lengthSq() > 1e-8) {
_orbitDir.normalize();
const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4);
state.camera.position.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance);
state.camera.lookAt(_orbitTarget);
}
}
} else {
// Observer fly or first-person: override rotation only (position comes
// from DemoPlaybackController's server snapshot interpolation).
state.camera.quaternion.set(qx, qy, qz, qw);
}
});
// Clean up on unmount.
useEffect(() => {
return () => {
if (activeAdapterRef.current) {
store.getState().setRecording(null);
activeAdapterRef.current = null;
}
};
}, [store]);
return null;
}

View file

@ -1,24 +1,25 @@
import { useCallback, useRef } from "react";
import { MdOndemandVideo } from "react-icons/md";
import { useDemoActions, useDemoRecording } from "./DemoProvider";
import { createDemoStreamingRecording } from "../demo/streaming";
import { usePlaybackActions, useRecording } from "./RecordingProvider";
import { createDemoStreamingRecording } from "../stream/demoStreaming";
import styles from "./LoadDemoButton.module.css";
export function LoadDemoButton() {
const recording = useDemoRecording();
const { setRecording } = useDemoActions();
const recording = useRecording();
const isDemoLoaded = recording?.source === "demo";
const { setRecording } = usePlaybackActions();
const inputRef = useRef<HTMLInputElement>(null);
const parseTokenRef = useRef(0);
const handleClick = useCallback(() => {
if (recording) {
if (isDemoLoaded) {
// Unload the current recording.
parseTokenRef.current += 1;
setRecording(null);
return;
}
inputRef.current?.click();
}, [recording, setRecording]);
}, [isDemoLoaded, setRecording]);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -55,14 +56,15 @@ export function LoadDemoButton() {
<button
type="button"
className={styles.Root}
aria-label={recording ? "Unload demo" : "Load demo (.rec)"}
title={recording ? "Unload demo" : "Load demo (.rec)"}
aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
onClick={handleClick}
data-active={recording ? "true" : undefined}
data-active={isDemoLoaded ? "true" : undefined}
disabled={recording != null && !isDemoLoaded}
>
<MdOndemandVideo className={styles.DemoIcon} />
<span className={styles.ButtonLabel}>
{recording ? "Unload demo" : "Demo"}
{isDemoLoaded ? "Unload demo" : "Demo"}
</span>
</button>
</>

View file

@ -174,28 +174,7 @@
}
.CloseButton {
padding: 4px 18px;
background: linear-gradient(
to bottom,
rgba(41, 172, 156, 0.7),
rgba(0, 80, 65, 0.7)
);
border: 1px solid rgba(41, 97, 84, 0.6);
border-top-color: rgba(101, 185, 176, 0.5);
border-radius: 3px;
box-shadow:
inset 0 1px 0 rgba(120, 220, 195, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.3),
0 2px 4px rgba(0, 0, 0, 0.4);
color: rgba(154, 239, 225, 0.9);
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.5);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.CloseButton:active {
transform: translate(0, 1px);
composes: DialogButton from "./DialogButton.module.css";
}
.Hint {

View file

@ -50,7 +50,7 @@ function getBitmapUrl(
try {
const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`);
return getUrlForPath(key);
} catch {}
} catch { /* expected */ }
}
// Fall back to Load_<MissionName>.png convention (multiplayer missions)
try {
@ -58,7 +58,7 @@ function getBitmapUrl(
`textures/gui/Load_${missionName}`,
);
return getUrlForPath(key);
} catch {}
} catch { /* expected */ }
return null;
}
@ -179,7 +179,7 @@ export function MapInfoDialog({
dialogRef.current?.focus();
try {
document.exitPointerLock();
} catch {}
} catch { /* expected */ }
}
}, [open]);

View file

@ -3,7 +3,6 @@ import picomatch from "picomatch";
import { loadMission } from "../loaders";
import { type ParsedMission } from "../mission";
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
import { SimObject } from "./SimObject";
import { memo, useEffect, useMemo, useState } from "react";
import { RuntimeProvider } from "./RuntimeProvider";
import {
@ -11,7 +10,6 @@ import {
createScriptCache,
FileSystemHandler,
runServer,
TorqueObject,
TorqueRuntime,
} from "../torqueScript";
import {
@ -21,8 +19,9 @@ import {
getSourceAndPath,
} from "../manifest";
import { MissionProvider } from "./MissionContext";
import { engineStore } from "../state";
import { engineStore, gameEntityStore } from "../state";
import { ignoreScripts } from "../torqueScript/ignoreScripts";
import { walkMissionTree } from "../stream/missionEntityBridge";
const loadScript = createScriptLoader();
// Shared cache for parsed scripts - survives runtime restarts
@ -52,7 +51,7 @@ function useParsedMission(name: string) {
}
interface ExecutedMissionState {
missionGroup: TorqueObject | undefined;
ready: boolean;
runtime: TorqueRuntime | undefined;
progress: number;
}
@ -63,7 +62,7 @@ function useExecutedMission(
parsedMission: ParsedMission | undefined,
): ExecutedMissionState {
const [state, setState] = useState<ExecutedMissionState>({
missionGroup: undefined,
ready: false,
runtime: undefined,
progress: 0,
});
@ -105,7 +104,11 @@ function useExecutedMission(
// Refresh the reactive runtime snapshot at mission-ready time.
engineStore.getState().setRuntime(runtime);
const missionGroup = runtime.getObjectByName("MissionGroup");
setState({ missionGroup, runtime, progress: 1 });
if (missionGroup) {
const gameEntities = walkMissionTree(missionGroup, runtime);
gameEntityStore.getState().setAllEntities(gameEntities);
}
setState({ ready: true, runtime, progress: 1 });
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") {
@ -134,6 +137,7 @@ function useExecutedMission(
controller.abort();
unsubscribeRuntimeEvents?.();
engineStore.getState().clearRuntime();
gameEntityStore.getState().clearEntities();
runtime.destroy();
};
}, [missionName, missionType, parsedMission]);
@ -154,20 +158,19 @@ export const Mission = memo(function Mission({
}: MissionProps) {
const { data: parsedMission } = useParsedMission(name);
const { missionGroup, runtime, progress } = useExecutedMission(
const { ready, runtime, progress } = useExecutedMission(
name,
missionType,
parsedMission,
);
const isLoading = !parsedMission || !missionGroup || !runtime;
const isLoading = !parsedMission || !ready || !runtime;
const missionContext = useMemo(
() => ({
metadata: parsedMission,
missionType,
missionGroup,
}),
[parsedMission, missionType, missionGroup],
[parsedMission, missionType],
);
useEffect(() => {
@ -180,9 +183,7 @@ export const Mission = memo(function Mission({
return (
<MissionProvider value={missionContext}>
<RuntimeProvider runtime={runtime}>
<SimObject object={missionGroup} />
</RuntimeProvider>
<RuntimeProvider runtime={runtime} />
</MissionProvider>
);
});

View file

@ -1,11 +1,9 @@
import { createContext, useContext } from "react";
import { ParsedMission } from "../mission";
import { TorqueObject } from "../torqueScript";
export type MissionContextType = {
metadata: ParsedMission;
missionType: string;
missionGroup: TorqueObject;
};
const MissionContext = createContext<MissionContextType | null>(null);

View file

@ -274,7 +274,7 @@ export function MissionSelect({
onFocus={() => {
try {
document.exitPointerLock();
} catch {}
} catch { /* expected */ }
combobox.show();
}}
onKeyDown={(e) => {

View file

@ -5,6 +5,7 @@ import { useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
import { useControls } from "./SettingsProvider";
import { useCameras } from "./CamerasProvider";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
export enum Controls {
forward = "forward",
@ -29,13 +30,15 @@ export enum Controls {
}
const BASE_SPEED = 80;
const LOOK_SPEED = 1; // radians/sec
const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 0.5;
const DRAG_SENSITIVITY = 0.003;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
/** Shared mouse/look sensitivity used across all modes (.mis, .rec, live). */
export const MOUSE_SENSITIVITY = 0.003;
export const ARROW_LOOK_SPEED = 1; // radians/sec
function CameraMovement() {
const { speedMultiplier, setSpeedMultiplier } = useControls();
const [subscribe, getKeys] = useKeyboardControls<Controls>();
@ -90,8 +93,8 @@ function CameraMovement() {
didDrag = true;
euler.setFromQuaternion(camera.quaternion, "YXZ");
euler.y -= e.movementX * DRAG_SENSITIVITY;
euler.x -= e.movementY * DRAG_SENSITIVITY;
euler.y -= e.movementX * MOUSE_SENSITIVITY;
euler.x -= e.movementY * MOUSE_SENSITIVITY;
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
camera.quaternion.setFromEuler(euler);
};
@ -176,6 +179,11 @@ function CameraMovement() {
}, [gl.domElement, setSpeedMultiplier]);
useFrame((state, delta) => {
// When streaming is active and not in free-fly mode, the stream
// (DemoPlaybackController) drives the camera — skip our movement.
const spState = streamPlaybackStore.getState();
if (spState.playback && !spState.freeFlyCamera) return;
const {
forward,
backward,
@ -192,10 +200,10 @@ function CameraMovement() {
// Arrow keys: rotate camera look direction
if (lookUp || lookDown || lookLeft || lookRight) {
lookEuler.current.setFromQuaternion(camera.quaternion, "YXZ");
if (lookLeft) lookEuler.current.y += LOOK_SPEED * delta;
if (lookRight) lookEuler.current.y -= LOOK_SPEED * delta;
if (lookUp) lookEuler.current.x += LOOK_SPEED * delta;
if (lookDown) lookEuler.current.x -= LOOK_SPEED * delta;
if (lookLeft) lookEuler.current.y += ARROW_LOOK_SPEED * delta;
if (lookRight) lookEuler.current.y -= ARROW_LOOK_SPEED * delta;
if (lookUp) lookEuler.current.x += ARROW_LOOK_SPEED * delta;
if (lookDown) lookEuler.current.x -= ARROW_LOOK_SPEED * delta;
lookEuler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, lookEuler.current.x),

View file

@ -26,7 +26,7 @@ import {
} from "three";
import { audioToUrl, textureToUrl } from "../loaders";
import { loadTexture } from "../textureUtils";
import { setupEffectTexture } from "../demo/demoPlaybackUtils";
import { setupEffectTexture } from "../stream/playbackUtils";
import {
EmitterInstance,
resolveEmitterData,
@ -37,19 +37,20 @@ import {
} from "../particles/shaders";
import type { EmitterDataResolved } from "../particles/types";
import type {
DemoStreamSnapshot,
DemoStreamingPlayback,
} from "../demo/types";
StreamSnapshot,
StreamingPlayback,
} from "../stream/types";
import { useDebug, useSettings } from "./SettingsProvider";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
playOneShotSound,
getCachedAudioBuffer,
trackDemoSound,
untrackDemoSound,
getSoundGeneration,
trackSound,
untrackSound,
} from "./AudioEmitter";
import { demoEffectNow, engineStore } from "../state";
import { effectNow, engineStore } from "../state";
// ── Constants ──
@ -707,12 +708,12 @@ function syncBuffers(active: ActiveEmitter): void {
const MAX_PROJECTILE_SOUNDS = 20;
export function DemoParticleEffects({
export function ParticleEffects({
playback,
snapshotRef,
}: {
playback: DemoStreamingPlayback;
snapshotRef: React.RefObject<DemoStreamSnapshot | null>;
playback: StreamingPlayback;
snapshotRef: React.RefObject<StreamSnapshot | null>;
}) {
const { debugMode } = useDebug();
const { audioEnabled } = useSettings();
@ -857,7 +858,7 @@ export function DemoParticleEffects({
material: sphereMat,
label: labelSprite,
labelMaterial: labelMat,
creationTime: demoEffectNow(),
creationTime: effectNow(),
lifetimeMS: Math.max(resolved.lifetimeMS, 3000),
targetRadius: radius,
});
@ -901,7 +902,7 @@ export function DemoParticleEffects({
geometry: geo,
bottomGeometry: bottomGeo,
material: mat,
creationTime: demoEffectNow(),
creationTime: effectNow(),
lifetimeMS: swData.lifetimeMS,
data: swData,
radius: 0,
@ -1087,7 +1088,7 @@ export function DemoParticleEffects({
// ── Update explosion wireframe spheres ──
const spheres = activeExplosionSpheresRef.current;
const now = demoEffectNow();
const now = effectNow();
for (let i = spheres.length - 1; i >= 0; i--) {
const sphere = spheres[i];
const elapsed = now - sphere.creationTime;
@ -1230,8 +1231,10 @@ export function DemoParticleEffects({
try {
const url = audioToUrl(resolved.filename);
const gen = getSoundGeneration();
getCachedAudioBuffer(url, audioLoader, (buffer) => {
// Entity may have despawned by the time the buffer loads.
// Recording may have been unloaded by the time the buffer loads.
if (gen !== getSoundGeneration()) return;
if (!currentEntityIds.has(entity.id)) return;
if (projSounds.has(entity.id)) return;
const group = groupRef.current;
@ -1252,7 +1255,7 @@ export function DemoParticleEffects({
entity.position![0],
);
group.add(sound);
trackDemoSound(sound);
trackSound(sound);
sound.play();
projSounds.set(entity.id, sound);
});
@ -1264,9 +1267,9 @@ export function DemoParticleEffects({
// Despawn: stop sounds for entities no longer present.
for (const [entityId, sound] of projSounds) {
if (!currentEntityIds.has(entityId)) {
untrackDemoSound(sound);
try { sound.stop(); } catch {}
sound.disconnect();
untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ }
try { sound.disconnect(); } catch { /* already disconnected */ }
groupRef.current?.remove(sound);
projSounds.delete(entityId);
}
@ -1356,9 +1359,9 @@ export function DemoParticleEffects({
trailEntitiesRef.current.clear();
// Clean up projectile sounds.
for (const [, sound] of projectileSoundsRef.current) {
untrackDemoSound(sound);
try { sound.stop(); } catch {}
sound.disconnect();
untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ }
try { sound.disconnect(); } catch { /* already disconnected */ }
if (group) group.remove(sound);
}
projectileSoundsRef.current.clear();

View file

@ -1,13 +1,13 @@
import { useDemoRecording } from "./DemoProvider";
import { useRecording } from "./RecordingProvider";
import { useEngineSelector } from "../state";
import { textureToUrl } from "../loaders";
import type {
ChatSegment,
DemoChatMessage,
DemoStreamEntity,
ChatMessage,
StreamEntity,
TeamScore,
WeaponsHudSlot,
} from "../demo/types";
} from "../stream/types";
import styles from "./PlayerHUD.module.css";
// ── Compass ──
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
@ -64,7 +64,7 @@ function Reticle() {
if (!snap || snap.camera?.mode !== "first-person") return undefined;
const ctrl = snap.controlPlayerGhostId;
if (!ctrl) return undefined;
return snap.entities.find((e: DemoStreamEntity) => e.id === ctrl)
return snap.entities.find((e: StreamEntity) => e.id === ctrl)
?.weaponShape;
});
if (weaponShape === undefined) return null;
@ -257,7 +257,7 @@ const CHAT_COLOR_CLASSES: Record<number, string> = {
function segmentColorClass(colorCode: number): string {
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
}
function chatColorClass(msg: DemoChatMessage): string {
function chatColorClass(msg: ChatMessage): string {
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
return CHAT_COLOR_CLASSES[msg.colorCode];
}
@ -278,12 +278,12 @@ function ChatWindow() {
const fadeDuration = 1.5;
const cutoff = timeSec - (fadeStart + fadeDuration);
const visible = messages.filter(
(m: DemoChatMessage) => m.timeSec > cutoff && m.text.trim() !== "",
(m: ChatMessage) => m.timeSec > cutoff && m.text.trim() !== "",
);
if (!visible.length) return null;
return (
<div className={styles.ChatWindow}>
{visible.map((msg: DemoChatMessage, i: number) => {
{visible.map((msg: ChatMessage, i: number) => {
const age = timeSec - msg.timeSec;
const opacity =
age <= fadeStart
@ -435,7 +435,7 @@ function PackAndInventoryHUD() {
}
// ── Main HUD ──
export function PlayerHUD() {
const recording = useDemoRecording();
const recording = useRecording();
const streamSnapshot = useEngineSelector(
(state) => state.playback.streamSnapshot,
);

View file

@ -1,4 +1,4 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
@ -21,25 +21,27 @@ import {
getKeyframeAtTime,
getPosedNodeTransform,
processShapeScene,
} from "../demo/demoPlaybackUtils";
import { pickMoveAnimation } from "../demo/playerAnimation";
import { WeaponImageStateMachine } from "../demo/weaponStateMachine";
import type { WeaponAnimState } from "../demo/weaponStateMachine";
} from "../stream/playbackUtils";
import { pickMoveAnimation } from "../stream/playerAnimation";
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
import type { WeaponAnimState } from "../stream/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape } from "./GenericShape";
import { ShapeErrorBoundary } from "./DemoEntities";
import { ShapeErrorBoundary } from "./EntityScene";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
playOneShotSound,
getCachedAudioBuffer,
trackDemoSound,
untrackDemoSound,
getSoundGeneration,
trackSound,
untrackSound,
} from "./AudioEmitter";
import { audioToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider";
import { useEngineStoreApi, useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
/**
* Map weapon shape to the arm blend animation (armThread).
@ -54,6 +56,72 @@ function getArmThread(weaponShape: string | undefined): string {
return "lookde";
}
/** Number of table actions in the engine's ActionAnimationList. */
const NUM_TABLE_ACTION_ANIMS = 7;
/** Table action names in engine order (indices 0-6). */
const TABLE_ACTION_NAMES = ["root", "run", "back", "side", "fall", "jump", "land"];
interface ActionAnimEntry {
/** GLB clip name (lowercase, e.g. "diehead"). */
clipName: string;
/** Engine alias (lowercase, e.g. "death1"). */
alias: string;
}
/**
* Build the engine's action index -> animation entry mapping from a
* TSShapeConstructor's sequence entries (e.g. `"heavy_male_root.dsq root"`).
*
* The engine builds its action list as:
* 1. Table actions (0-6): found by searching for aliased names (root, run, etc.)
* 2. Non-table actions (7+): remaining sequences in TSShapeConstructor order.
*/
function buildActionAnimMap(
sequences: string[],
shapePrefix: string,
): Map<number, ActionAnimEntry> {
const result = new Map<number, ActionAnimEntry>();
// Parse each sequence entry into { clipName, alias }.
const parsed: Array<{ clipName: string; alias: string }> = [];
for (const entry of sequences) {
const spaceIdx = entry.indexOf(" ");
if (spaceIdx === -1) continue;
const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
const alias = entry.slice(spaceIdx + 1).trim().toLowerCase();
if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq"))
continue;
const clipName = dsqFile.slice(shapePrefix.length, -4);
if (clipName) parsed.push({ clipName, alias });
}
// Find which parsed entries are table actions (by alias name).
const tableEntryIndices = new Set<number>();
for (let i = 0; i < TABLE_ACTION_NAMES.length; i++) {
const name = TABLE_ACTION_NAMES[i];
for (let pi = 0; pi < parsed.length; pi++) {
if (parsed[pi].alias === name) {
tableEntryIndices.add(pi);
result.set(i, parsed[pi]);
break;
}
}
}
// Non-table actions: remaining entries in TSShapeConstructor order.
let actionIdx = NUM_TABLE_ACTION_ANIMS;
for (let pi = 0; pi < parsed.length; pi++) {
if (!tableEntryIndices.has(pi)) {
result.set(actionIdx, parsed[pi]);
actionIdx++;
}
}
return result;
}
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
function stopLoopingSound(
soundRef: React.MutableRefObject<PositionalAudio | null>,
@ -62,9 +130,9 @@ function stopLoopingSound(
) {
const sound = soundRef.current;
if (!sound) return;
untrackDemoSound(sound);
try { sound.stop(); } catch {}
sound.disconnect();
untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ }
try { sound.disconnect(); } catch { /* already disconnected */ }
parent?.remove(sound);
soundRef.current = null;
stateRef.current = -1;
@ -78,19 +146,14 @@ function stopLoopingSound(
* (Root, Forward, Back, Side, Fall) selected from the keyframe velocity data.
* Weapon is attached to the animated Mount0 bone.
*/
export function DemoPlayerModel({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const engineStore = useEngineStoreApi();
const gltf = useStaticShape(entity.dataBlock!);
const shapeName = entity.shapeName ?? entity.dataBlock;
const gltf = useStaticShape(shapeName!);
const shapeAliases = useEngineSelector((state) => {
const shapeName = entity.dataBlock?.toLowerCase();
return shapeName
? state.runtime.sequenceAliases.get(shapeName)
const sn = shapeName?.toLowerCase();
return sn
? state.runtime.sequenceAliases.get(sn)
: undefined;
});
@ -129,6 +192,21 @@ export function DemoPlayerModel({
const activeArmRef = useRef<string | null>(null);
const currentAnimRef = useRef({ name: "root", timeScale: 1 });
const isDeadRef = useRef(false);
// Action animation (taunts, celebrations, etc.) tracking.
const actionAnimRef = useRef<number | undefined>(undefined);
// Build action index -> animation clip name mapping from TSShapeConstructor.
const actionAnimMap = useMemo(() => {
const playback = engineStore.getState().playback;
const sp = playback.recording?.streamingPlayback;
const sn = shapeName?.toLowerCase();
if (!sp || !sn) return new Map<number, ActionAnimEntry>();
const sequences = sp.getShapeConstructorSequences(sn);
if (!sequences) return new Map<number, ActionAnimEntry>();
// Derive prefix: "heavy_male.dts" -> "heavy_male_"
const stem = sn.replace(/\.dts$/i, "");
return buildActionAnimMap(sequences, stem + "_");
}, [engineStore, shapeName]);
useEffect(() => {
const actions = getAliasedActions(gltf.animations, mixer, shapeAliases);
@ -223,49 +301,64 @@ export function DemoPlayerModel({
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const { mesh, initialize } of iflInitializers) {
initialize(mesh, () => timeRef.current)
initialize(mesh, () => streamPlaybackStore.getState().time)
.then((dispose) => cleanups.push(dispose))
.catch(() => {});
}
return () => cleanups.forEach((fn) => fn());
}, [iflInitializers]);
// Track weaponShape changes. The entity is mutated in-place by the
// streaming layer (no React re-render), so we sync it in useFrame.
const weaponShapeRef = useRef(entity.weaponShape);
const [currentWeaponShape, setCurrentWeaponShape] = useState(
entity.weaponShape,
);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
if (entity.weaponShape !== weaponShapeRef.current) {
weaponShapeRef.current = entity.weaponShape;
setCurrentWeaponShape(entity.weaponShape);
}
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
const time = timeRef.current;
const time = streamPlaybackStore.getState().time;
// Resolve velocity at current playback time.
const kf = getKeyframeAtTime(entity.keyframes, time);
const kf = getKeyframeAtTime(entity.keyframes ?? [], time);
const isDead = kf?.damageState != null && kf.damageState >= 1;
const actions = animActionsRef.current;
// Alive→Dead transition: play a random death animation.
// Alive->Dead transition: play the server-specified death animation.
if (isDead && !isDeadRef.current) {
isDeadRef.current = true;
const deathClips = [...actions.keys()].filter((k) =>
k.startsWith("death"),
);
if (deathClips.length > 0) {
const pick = deathClips[Math.floor(Math.random() * deathClips.length)];
const prevAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
// The server sends the death animation as an actionAnim index.
const deathEntry = kf.actionAnim != null
? actionAnimMap.get(kf.actionAnim)
: undefined;
if (deathEntry) {
const deathAction = actions.get(deathEntry.clipName);
if (deathAction) {
const prevAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
const deathAction = actions.get(pick)!;
deathAction.setLoop(LoopOnce, 1);
deathAction.clampWhenFinished = true;
deathAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
currentAnimRef.current = { name: pick, timeScale: 1 };
deathAction.setLoop(LoopOnce, 1);
deathAction.clampWhenFinished = true;
deathAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
currentAnimRef.current = { name: deathEntry.clipName, timeScale: 1 };
actionAnimRef.current = kf.actionAnim;
}
}
}
// DeadAlive transition: stop death animation, let movement resume.
// Dead->Alive transition: stop death animation, let movement resume.
if (!isDead && isDeadRef.current) {
isDeadRef.current = false;
actionAnimRef.current = undefined;
const deathAction = actions.get(currentAnimRef.current.name.toLowerCase());
if (deathAction) {
@ -279,8 +372,61 @@ export function DemoPlayerModel({
if (rootAction) rootAction.reset().play();
}
// Movement animation selection (skip while dead).
if (!isDeadRef.current) {
// Action animation (taunts, celebrations, etc.).
// Non-table actions (index >= 7) override movement animation.
const actionAnim = kf?.actionAnim;
const prevActionAnim = actionAnimRef.current;
if (!isDeadRef.current && actionAnim !== prevActionAnim) {
actionAnimRef.current = actionAnim;
const isNonTableAction = actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS;
const wasNonTableAction = prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS;
if (isNonTableAction) {
// Start or change action animation.
const entry = actionAnimMap.get(actionAnim);
if (entry) {
const actionAction = actions.get(entry.clipName);
if (actionAction) {
const prevAction = actions.get(currentAnimRef.current.name.toLowerCase());
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
actionAction.setLoop(LoopOnce, 1);
actionAction.clampWhenFinished = true;
actionAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
currentAnimRef.current = { name: entry.clipName, timeScale: 1 };
}
}
} else if (wasNonTableAction) {
// Action ended -- stop the action clip and resume movement.
const prevEntry = actionAnimMap.get(prevActionAnim);
if (prevEntry) {
const prevAction = actions.get(prevEntry.clipName);
if (prevAction) {
prevAction.fadeOut(ANIM_TRANSITION_TIME);
prevAction.setLoop(LoopRepeat, Infinity);
prevAction.clampWhenFinished = false;
}
}
currentAnimRef.current = { name: "root", timeScale: 1 };
const rootAction = actions.get("root");
if (rootAction) rootAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
}
}
// If atEnd, clamp the action animation at its final frame.
if (actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS && kf?.actionAtEnd) {
const entry = actionAnimMap.get(actionAnim);
if (entry) {
const actionAction = actions.get(entry.clipName);
if (actionAction) {
actionAction.paused = true;
}
}
}
// Movement animation selection (skip while dead or playing action anim).
const playingActionAnim = actionAnimRef.current != null &&
actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS;
if (!isDeadRef.current && !playingActionAnim) {
const anim = pickMoveAnimation(
kf?.velocity,
kf?.rotation ?? [0, 0, 0, 1],
@ -361,13 +507,13 @@ export function DemoPlayerModel({
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
</group>
{entity.weaponShape && mount0 && (
<ShapeErrorBoundary fallback={null}>
{currentWeaponShape && mount0 && (
<ShapeErrorBoundary key={currentWeaponShape} fallback={null}>
<Suspense fallback={null}>
<AnimatedWeaponModel
entity={entity}
weaponShape={currentWeaponShape}
mount0={mount0}
timeRef={timeRef}
/>
</Suspense>
</ShapeErrorBoundary>
@ -377,7 +523,7 @@ export function DemoPlayerModel({
}
/**
* Build a DTS sequence-index name lookup from GLB metadata.
* Build a DTS sequence-index -> name lookup from GLB metadata.
* Weapon GLBs include `dts_sequence_names` in scene extras, providing the
* original DTS sequence ordering that datablock state indices reference.
*/
@ -407,15 +553,15 @@ function buildSeqIndexToName(
*/
function AnimatedWeaponModel({
entity,
weaponShape,
mount0,
timeRef,
}: {
entity: DemoEntity;
entity: PlayerEntity;
weaponShape: string;
mount0: Object3D;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const weaponGltf = useStaticShape(entity.weaponShape!);
const weaponGltf = useStaticShape(weaponShape);
// Clone weapon with skeleton bindings, create dedicated mixer.
const { weaponClone, weaponMixer, seqIndexToName, visNodesBySequence, weaponIflInitializers } =
@ -501,7 +647,7 @@ function AnimatedWeaponModel({
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const { mesh, initialize } of weaponIflInitializers) {
initialize(mesh, () => timeRef.current)
initialize(mesh, () => streamPlaybackStore.getState().time)
.then((dispose) => cleanups.push(dispose))
.catch(() => {});
}
@ -607,8 +753,10 @@ function AnimatedWeaponModel({
if (!loopingSoundRef.current) {
try {
const url = audioToUrl(resolved.filename);
const gen = getSoundGeneration();
getCachedAudioBuffer(url, audioLoader, (buffer) => {
// Guard: state may have changed by the time buffer loads.
if (gen !== getSoundGeneration()) return;
if (loopingSoundRef.current) return;
// Read live state index (not the closure-captured one).
const currentIdx = sm.stateIndex;
@ -622,12 +770,12 @@ function AnimatedWeaponModel({
sound.setPlaybackRate(playback.rate);
sound.setLoop(true);
weaponClone.add(sound);
trackDemoSound(sound);
trackSound(sound);
sound.play();
loopingSoundRef.current = sound;
loopingSoundStateRef.current = currentIdx;
});
} catch {}
} catch { /* expected */ }
}
} else {
playOneShotSound(
@ -702,7 +850,7 @@ function applyWeaponAnim(
}
if (!targetName) {
// No sequence for this state stop current animation.
// No sequence for this state -- stop current animation.
if (currentName) {
const prev = actions.get(currentName);
if (prev) prev.fadeOut(ANIM_TRANSITION_TIME);
@ -768,8 +916,8 @@ export function PlayerEyeOffset({
const eye = getPosedNodeTransform(gltf.scene, gltf.animations, "Eye");
if (eye) {
// Convert from GLB space to entity space via ShapeRenderer's R90:
// R90 maps GLB (x,y,z) entity (z, y, -x).
// This gives ~(0.169, 2.122, 0.0) 17cm forward and 2.12m up.
// R90 maps GLB (x,y,z) -> entity (z, y, -x).
// This gives ~(0.169, 2.122, 0.0) -- 17cm forward and 2.12m up.
eyeOffsetRef.current.set(eye.position.z, eye.position.y, -eye.position.x);
} else {
eyeOffsetRef.current.set(0, DEFAULT_EYE_HEIGHT, 0);

View file

@ -1,12 +1,12 @@
import { useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Box3, Object3D, Vector3 } from "three";
import { getKeyframeAtTime } from "../demo/demoPlaybackUtils";
import { getKeyframeAtTime } from "../stream/playbackUtils";
import { textureToUrl } from "../loaders";
import { useStaticShape } from "./GenericShape";
import type { DemoEntity } from "../demo/types";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
import styles from "./PlayerNameplate.module.css";
/** Max distance at which nameplates are visible. */
@ -27,14 +27,8 @@ const _tmpVec = new Vector3();
* Floating nameplate above a player model showing the entity name and a health
* bar. Fades out with distance.
*/
export function PlayerNameplate({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const gltf = useStaticShape(entity.dataBlock!);
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
const { camera } = useThree();
const groupRef = useRef<Object3D>(null);
const iffContainerRef = useRef<HTMLDivElement>(null);
@ -46,9 +40,11 @@ export function PlayerNameplate({
const displayName = useMemo(() => {
if (entity.playerName) return entity.playerName;
if (typeof entity.id === "string") {
return entity.id.replace(/^player_/, "Player ");
const m = entity.id.match(/\d+/);
if (m) return `<Player #${m[0]}>`;
return entity.id;
}
return `Player ${entity.id}`;
return "<Player>";
}, [entity.id, entity.playerName]);
// Derive IFF height from the shape's bounding box.
@ -58,9 +54,10 @@ export function PlayerNameplate({
}, [gltf.scene]);
// Check whether this entity has any health data at all.
const keyframes = entity.keyframes ?? [];
const hasHealthData = useMemo(
() => entity.keyframes.some((kf) => kf.health != null),
[entity.keyframes],
() => keyframes.some((kf) => kf.health != null),
[keyframes],
);
useFrame(() => {
@ -87,7 +84,7 @@ export function PlayerNameplate({
if (!shouldBeVisible) return;
// Hide nameplate when player is dead.
const kf = getKeyframeAtTime(entity.keyframes, timeRef.current);
const kf = getKeyframeAtTime(keyframes, streamPlaybackStore.getState().time);
const health = kf?.health ?? 1;
if (kf?.damageState != null && kf.damageState >= 1) {
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";
@ -116,7 +113,7 @@ export function PlayerNameplate({
entity.iffColor.r > entity.iffColor.g
? IFF_ENEMY_URL
: IFF_FRIENDLY_URL;
if (iffImgRef.current.src !== url) {
if (iffImgRef.current.getAttribute("src") !== url) {
iffImgRef.current.src = url;
}
}

View file

@ -14,9 +14,9 @@ import {
setupEffectTexture,
torqueVecToThree,
setQuaternionFromDir,
} from "../demo/demoPlaybackUtils";
} from "../stream/playbackUtils";
import { textureToUrl } from "../loaders";
import type { DemoEntity, DemoTracerVisual, DemoSpriteVisual } from "../demo/types";
import type { TracerVisual, SpriteVisual } from "../stream/types";
const _tracerDir = new Vector3();
const _tracerDirFromCam = new Vector3();
@ -26,7 +26,7 @@ const _tracerEnd = new Vector3();
const _tracerWorldPos = new Vector3();
const _upY = new Vector3(0, 1, 0);
export function DemoSpriteProjectile({ visual }: { visual: DemoSpriteVisual }) {
export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
const url = textureToUrl(visual.texture);
const texture = useTexture(url, (tex) => {
const t = Array.isArray(tex) ? tex[0] : tex;
@ -55,12 +55,12 @@ export function DemoSpriteProjectile({ visual }: { visual: DemoSpriteVisual }) {
);
}
export function DemoTracerProjectile({
export function TracerProjectile({
entity,
visual,
}: {
entity: DemoEntity;
visual: DemoTracerVisual;
entity: { keyframes?: Array<{ position?: [number, number, number]; velocity?: [number, number, number] }>; direction?: [number, number, number] };
visual: TracerVisual;
}) {
const tracerRef = useRef<Mesh>(null);
const tracerPosRef = useRef<BufferAttribute>(null);
@ -88,7 +88,7 @@ export function DemoTracerProjectile({
const posAttr = tracerPosRef.current;
if (!tracerMesh || !posAttr) return;
const kf = entity.keyframes[0];
const kf = entity.keyframes?.[0];
const pos = kf?.position;
const direction = entity.direction ?? kf?.velocity;
if (!pos || !direction) {

View file

@ -1,45 +1,45 @@
import { useCallback, type ReactNode } from "react";
import type { DemoRecording } from "../demo/types";
import type { StreamRecording } from "../stream/types";
import { useEngineSelector } from "../state";
export function DemoProvider({ children }: { children: ReactNode }) {
export function RecordingProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
export function useDemoRecording(): DemoRecording | null {
export function useRecording(): StreamRecording | null {
return useEngineSelector((state) => state.playback.recording);
}
export function useDemoIsPlaying(): boolean {
export function useIsPlaying(): boolean {
return useEngineSelector((state) => state.playback.status === "playing");
}
export function useDemoCurrentTime(): number {
export function useCurrentTime(): number {
return useEngineSelector((state) => state.playback.timeMs / 1000);
}
export function useDemoDuration(): number {
export function useDuration(): number {
return useEngineSelector((state) => state.playback.durationMs / 1000);
}
export function useDemoSpeed(): number {
export function useSpeed(): number {
return useEngineSelector((state) => state.playback.rate);
}
export function useDemoActions() {
const recording = useDemoRecording();
const setDemoRecording = useEngineSelector((state) => state.setDemoRecording);
export function usePlaybackActions() {
const recording = useRecording();
const setRecording = useEngineSelector((state) => state.setRecording);
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);
const setRec = useCallback(
(recording: StreamRecording | null) => {
setRecording(recording);
},
[setDemoRecording],
[setRecording],
);
const play = useCallback(() => {
@ -66,7 +66,7 @@ export function useDemoActions() {
);
return {
setRecording,
setRecording: setRec,
play,
pause,
seek,

View file

@ -1,18 +1,18 @@
import { createContext, ReactNode, useContext } from "react";
import type { TorqueRuntime } from "../torqueScript";
import { TickProvider } from "./TickProvider";
const RuntimeContext = createContext<TorqueRuntime | null>(null);
interface RuntimeProviderProps {
export interface RuntimeProviderProps {
runtime: TorqueRuntime;
children: ReactNode;
children?: ReactNode;
}
export function RuntimeProvider({ runtime, children }: RuntimeProviderProps) {
return (
<RuntimeContext.Provider value={runtime}>
<TickProvider>{children}</TickProvider>
{children}
</RuntimeContext.Provider>
);
}

View file

@ -0,0 +1,81 @@
import { useEffect, useMemo } from "react";
import { Color, Vector3 } from "three";
import { useSceneSun } from "../state/gameEntityStore";
import { torqueToThree } from "../scene/coordinates";
import { updateGlobalSunUniforms } from "../globalSunUniforms";
/**
* Renders scene-global lights (directional sun + ambient) derived from the
* Sun entity in the game entity store. Rendered outside EntityScene so that
* lights are siblings of the scene graph root rather than buried inside a
* group works around r3f reconciliation issues where lights added inside
* dynamically-populated groups sometimes fail to illuminate existing meshes.
*/
export function SceneLighting() {
const sunData = useSceneSun();
if (!sunData) {
// Fallback lighting when no Sun entity exists yet
return <ambientLight color="#888888" intensity={1.0} />;
}
return <SunLighting sunData={sunData} />;
}
function SunLighting({ sunData }: { sunData: NonNullable<ReturnType<typeof useSceneSun>> }) {
const direction = useMemo(() => {
const [x, y, z] = torqueToThree(sunData.direction);
const len = Math.sqrt(x * x + y * y + z * z);
return new Vector3(x / len, y / len, z / len);
}, [sunData.direction]);
const lightPosition = useMemo(() => {
const distance = 5000;
return new Vector3(
-direction.x * distance,
-direction.y * distance,
-direction.z * distance,
);
}, [direction]);
const color = useMemo(
() => new Color(sunData.color.r, sunData.color.g, sunData.color.b),
[sunData.color],
);
const ambient = useMemo(
() => new Color(sunData.ambient.r, sunData.ambient.g, sunData.ambient.b),
[sunData.ambient],
);
const sunLightPointsDown = direction.y < 0;
useEffect(() => {
updateGlobalSunUniforms(sunLightPointsDown);
}, [sunLightPointsDown]);
const shadowCameraSize = 4096;
return (
<>
<directionalLight
position={lightPosition}
color={color}
intensity={1.0}
castShadow
shadow-mapSize-width={8192}
shadow-mapSize-height={8192}
shadow-camera-left={-shadowCameraSize}
shadow-camera-right={shadowCameraSize}
shadow-camera-top={shadowCameraSize}
shadow-camera-bottom={-shadowCameraSize}
shadow-camera-near={100}
shadow-camera-far={12000}
shadow-bias={-0.00001}
shadow-normalBias={0.4}
shadow-radius={2}
/>
<ambientLight color={ambient} intensity={1.0} />
</>
);
}

View file

@ -0,0 +1,178 @@
.Dialog {
position: relative;
width: 860px;
height: 560px;
max-width: calc(100dvw - 40px);
max-height: calc(100dvh - 40px);
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto 1fr auto;
background: rgba(20, 37, 38, 0.8);
border: 1px solid rgba(65, 131, 139, 0.6);
border-radius: 4px;
box-shadow:
0 0 50px rgba(0, 0, 0, 0.4),
inset 0 0 60px rgba(1, 7, 13, 0.6);
color: #b0d5c9;
font-size: 14px;
line-height: 1.5;
overflow: hidden;
outline: none;
user-select: text;
-webkit-touch-callout: default;
}
.Overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px 10px;
border-bottom: 1px solid rgba(0, 190, 220, 0.25);
}
.Title {
font-size: 18px;
font-weight: 500;
color: #7dffff;
margin: 0;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
flex: 1;
}
.RefreshButton {
composes: DialogButton from "./DialogButton.module.css";
padding: 3px 14px;
font-size: 12px;
}
.ServerCount {
font-size: 12px;
color: rgba(201, 220, 216, 0.4);
}
.TableWrapper {
overflow-y: auto;
min-height: 0;
}
.Table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.Table th {
position: sticky;
top: 0;
background: rgba(10, 25, 26, 0.95);
padding: 6px 12px;
text-align: left;
cursor: pointer;
user-select: none;
border-bottom: 1px solid rgba(0, 190, 220, 0.2);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(125, 255, 255, 0.6);
}
.Table th:hover {
color: #7dffff;
}
.Table th:nth-child(2),
.Table td:nth-child(2),
.Table th:nth-child(3),
.Table td:nth-child(3) {
text-align: right;
}
.Table td {
padding: 3px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.Table tbody tr {
cursor: pointer;
}
.Table tbody tr:hover {
background: rgba(65, 131, 139, 0.12);
}
.Selected {
background: rgba(93, 255, 225, 0.9) !important;
color: #1e2828;
}
.PasswordIcon {
color: rgba(255, 200, 60, 0.6);
margin-right: 4px;
font-size: 11px;
}
.Empty {
text-align: center;
color: rgba(201, 220, 216, 0.3);
padding: 32px 12px !important;
font-style: italic;
}
.Footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
border-top: 1px solid rgba(0, 190, 220, 0.25);
background: rgba(2, 20, 21, 0.7);
flex-shrink: 0;
}
.JoinButton {
composes: DialogButton from "./DialogButton.module.css";
}
.CloseButton {
composes: Secondary from "./DialogButton.module.css";
}
.Hint {
font-size: 12px;
color: rgba(201, 220, 216, 0.3);
margin-left: auto;
}
@media (max-width: 719px) {
.Dialog {
width: 100%;
height: 100%;
max-width: 100dvw;
max-height: 100dvh;
border-radius: 0;
}
.Hint {
display: none;
}
.Table td {
max-width: 200px;
}
}

View file

@ -0,0 +1,194 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { ServerInfo } from "../../relay/types";
import styles from "./ServerBrowser.module.css";
export function ServerBrowser({
open,
onClose,
servers,
loading,
onRefresh,
onJoin,
wsPing,
}: {
open: boolean;
onClose: () => void;
servers: ServerInfo[];
loading: boolean;
onRefresh: () => void;
onJoin: (address: string) => void;
/** Browser↔relay RTT to add to server pings for effective latency. */
wsPing?: number | null;
}) {
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const dialogRef = useRef<HTMLDivElement>(null);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
const didAutoRefreshRef = useRef(false);
useEffect(() => {
if (open) {
dialogRef.current?.focus();
try {
document.exitPointerLock();
} catch {
/* expected */
}
} else {
didAutoRefreshRef.current = false;
}
}, [open]);
// Refresh on open if no servers cached
useEffect(() => {
if (open && servers.length === 0 && !didAutoRefreshRef.current) {
didAutoRefreshRef.current = true;
onRefreshRef.current();
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Block keyboard events from reaching Three.js while open
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [open, onClose]);
const handleSort = useCallback(
(key: keyof ServerInfo) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
},
[sortKey],
);
const sorted = useMemo(() => {
return [...servers].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
}, [servers, sortDir, sortKey]);
const handleJoin = useCallback(() => {
if (selectedAddress) {
onJoin(selectedAddress);
onClose();
}
}, [selectedAddress, onJoin, onClose]);
if (!open) return null;
return (
<div className={styles.Overlay} onClick={onClose}>
<div
className={styles.Dialog}
ref={dialogRef}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.Header}>
<h2 className={styles.Title}>Server Browser</h2>
<span className={styles.ServerCount}>
{servers.length} server{servers.length !== 1 ? "s" : ""}
</span>
<button
className={styles.RefreshButton}
onClick={onRefresh}
disabled={loading}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
<div className={styles.TableWrapper}>
<table className={styles.Table}>
<thead>
<tr>
<th onClick={() => handleSort("name")}>Server Name</th>
<th onClick={() => handleSort("playerCount")}>Players</th>
<th onClick={() => handleSort("ping")}>Ping</th>
<th onClick={() => handleSort("mapName")}>Map</th>
<th onClick={() => handleSort("gameType")}>Type</th>
<th onClick={() => handleSort("mod")}>Mod</th>
</tr>
</thead>
<tbody>
{sorted.map((server) => (
<tr
key={server.address}
className={
selectedAddress === server.address
? styles.Selected
: undefined
}
onClick={() => setSelectedAddress(server.address)}
onDoubleClick={() => {
setSelectedAddress(server.address);
onJoin(server.address);
onClose();
}}
>
<td>
{server.passwordRequired && (
<span className={styles.PasswordIcon}>&#x1F512;</span>
)}
{server.name}
</td>
<td>
{server.playerCount}/{server.maxPlayers}
</td>
<td>
{wsPing != null
? (server.ping + wsPing).toLocaleString()
: "\u2014"}
</td>
<td>{server.mapName}</td>
<td>{server.gameType}</td>
<td>{server.mod}</td>
</tr>
))}
{sorted.length === 0 && !loading && (
<tr>
<td colSpan={6} className={styles.Empty}>
No servers found
</td>
</tr>
)}
{loading && sorted.length === 0 && (
<tr>
<td colSpan={6} className={styles.Empty}>
Querying master server...
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.Footer}>
<button
onClick={handleJoin}
disabled={!selectedAddress}
className={styles.JoinButton}
>
Join
</button>
<button onClick={onClose} className={styles.CloseButton}>
Cancel
</button>
<span className={styles.Hint}>Double-click a server to join</span>
</div>
</div>
</div>
);
}

View file

@ -19,7 +19,7 @@ export function isOrganicShape(shapeName: string): boolean {
}
interface ShapeInfoContextValue {
object: TorqueObject;
object?: TorqueObject;
shapeName: string;
type: StaticShapeType;
isOrganic: boolean;
@ -41,7 +41,7 @@ export function ShapeInfoProvider({
shapeName,
type,
}: {
object: TorqueObject;
object?: TorqueObject;
children: ReactNode;
shapeName: string;
type: StaticShapeType;

View file

@ -7,14 +7,14 @@ import {
Vector3,
} from "three";
import type { Group, Material } from "three";
import { demoEffectNow, engineStore } from "../state";
import { effectNow, engineStore } from "../state";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
_r90,
_r90inv,
getPosedNodeTransform,
processShapeScene,
} from "../demo/demoPlaybackUtils";
} from "../stream/playbackUtils";
import {
loadIflAtlas,
getFrameIndexForTime,
@ -27,38 +27,8 @@ import {
} from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
import type { DemoThreadState, DemoStreamEntity } from "../demo/types";
import type { DemoStreamingPlayback } from "../demo/types";
/** Renders a shape model for a demo entity using the existing shape pipeline. */
export function DemoShapeModel({
shapeName,
entityId,
threads,
}: {
shapeName: string;
entityId: number | string;
threads?: DemoThreadState[];
}) {
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "player",
_className: "Player",
_id: typeof entityId === "number" ? entityId : 0,
}),
[entityId],
);
return (
<ShapeInfoProvider
object={torqueObject}
shapeName={shapeName}
type="StaticShape"
>
<ShapeRenderer loadingColor="#00ff88" demoThreads={threads} />
</ShapeInfoProvider>
);
}
import type { StreamEntity } from "../stream/types";
import type { StreamingPlayback } from "../stream/types";
/**
* Map weapon shape to the arm blend animation (armThread).
@ -82,7 +52,7 @@ function getArmThread(weaponShape: string | undefined): string {
* The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
*/
export function DemoWeaponModel({
export function WeaponModel({
shapeName,
playerShapeName,
}: {
@ -92,6 +62,7 @@ export function DemoWeaponModel({
const playerGltf = useStaticShape(playerShapeName);
const weaponGltf = useStaticShape(shapeName);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed skeleton with arm animation applied.
const armThread = getArmThread(shapeName);
@ -239,16 +210,17 @@ function interpolateSize(
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
*/
export function DemoExplosionShape({
export function ExplosionShape({
entity,
playback,
}: {
entity: DemoStreamEntity;
playback: DemoStreamingPlayback;
entity: StreamEntity;
playback: StreamingPlayback;
}) {
const gltf = useStaticShape(entity.dataBlock!);
const groupRef = useRef<Group>(null);
const startTimeRef = useRef(demoEffectNow());
const startTimeRef = useRef(effectNow());
// eslint-disable-next-line react-hooks/purity
const randAngleRef = useRef(Math.random() * Math.PI * 2);
const iflAtlasesRef = useRef<Array<{ atlas: IflAtlas; info: IflInfo }>>([]);
@ -303,7 +275,7 @@ export function DemoExplosionShape({
}
});
processShapeScene(scene);
processShapeScene(scene, entity.dataBlock);
// Collect vis-animated nodes keyed by sequence name.
const visNodes: VisNode[] = [];
@ -400,7 +372,7 @@ export function DemoExplosionShape({
const effectDelta = playbackState.status === "playing"
? delta * playbackState.rate : 0;
const elapsed = demoEffectNow() - startTimeRef.current;
const elapsed = effectNow() - startTimeRef.current;
const t = Math.min(elapsed / lifetimeMS, 1);
const elapsedSec = elapsed / 1000;

View file

@ -166,7 +166,7 @@ export function ShapeSelect({
onFocus={() => {
try {
document.exitPointerLock();
} catch {}
} catch { /* expected */ }
combobox.show();
}}
onKeyDown={(e) => {

View file

@ -1,62 +0,0 @@
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;
parent: SimGroupContextType;
hasTeams: boolean;
team: null | number;
};
const SimGroupContext = createContext<SimGroupContextType | null>(null);
export function useSimGroup() {
return useContext(SimGroupContext);
}
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;
let hasTeams = false;
if (parent && parent.hasTeams) {
hasTeams = true;
if (parent.team != null) {
team = parent.team;
} else if (liveObject._name) {
const match = liveObject._name.match(/^team(\d+)$/i);
if (match) {
team = parseInt(match[1], 10);
}
}
} else if (liveObject._name) {
hasTeams = liveObject._name.toLowerCase() === "teams";
}
return {
// the current SimGroup's data
object: liveObject,
// the closest ancestor of this SimGroup
parent,
// whether this is, or is the descendant of, the "Teams" SimGroup
hasTeams,
// what team this is for, when this is either a "Team<N>" SimGroup itself,
// or a descendant of one
team,
};
}, [liveObject, parent]);
return (
<SimGroupContext.Provider value={simGroup}>
{childIds.map((childId) => (
<SimObject objectId={childId} key={childId} />
))}
</SimGroupContext.Provider>
);
}

View file

@ -1,110 +0,0 @@
import { lazy, Suspense, useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { TerrainBlock } from "./TerrainBlock";
import { SimGroup } from "./SimGroup";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
import { Sun } from "./Sun";
import { TSStatic } from "./TSStatic";
import { StaticShape } from "./StaticShape";
import { Item } from "./Item";
import { Turret } from "./Turret";
import { WayPoint } from "./WayPoint";
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 })),
);
function ConditionalAudioEmitter(props) {
const { audioEnabled } = useSettings();
return audioEnabled ? <AudioEmitter {...props} /> : null;
}
// Not every map will have force fields.
const ForceFieldBare = lazy(() =>
import("./ForceFieldBare").then((mod) => ({ default: mod.ForceFieldBare })),
);
// Not every map will have water.
const WaterBlock = lazy(() =>
import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
);
const componentMap = {
AudioEmitter: ConditionalAudioEmitter,
Camera,
ForceFieldBare,
InteriorInstance,
Item,
SimGroup,
Sky,
StaticShape,
Sun,
TerrainBlock,
TSStatic,
Turret,
WaterBlock,
WayPoint,
};
/**
* 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(resolvedObject, "missionTypesList") ?? "")
.toLowerCase()
.split(/\s+/)
.filter(Boolean),
);
return (
!missionTypesList.size || missionTypesList.has(missionType.toLowerCase())
);
}, [resolvedObject, missionType]);
if (!resolvedObject) {
return null;
}
const Component = componentMap[resolvedObject._className];
const isSuppressedByDemoAuthority =
isDemoPlaybackActive &&
demoGhostAuthoritativeClasses.has(resolvedObject._className);
return shouldShowObject && Component ? (
<Suspense>
{!isSuppressedByDemoAuthority && <Component object={resolvedObject} />}
</Suspense>
) : null;
}

View file

@ -3,12 +3,11 @@ import { useQuery } from "@tanstack/react-query";
import { useThree, useFrame } from "@react-three/fiber";
import { useCubeTexture } from "@react-three/drei";
import { Color, Fog } from "three";
import type { TorqueObject } from "../torqueScript";
import { getInt, getProperty } from "../mission";
import type { SceneSky } from "../scene/types";
import { useSettings } from "./SettingsProvider";
import { loadDetailMapList, textureToUrl } from "../loaders";
import { CloudLayers } from "./CloudLayers";
import { parseFogState, type FogState } from "./FogProvider";
import { fogStateFromScene, type FogState } from "./FogProvider";
import { installCustomFogShader } from "../fogShader";
import {
globalFogUniforms,
@ -20,18 +19,13 @@ import {
// Track if fog shader has been installed (idempotent installation)
let fogShaderInstalled = false;
/**
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
* Returns [sRGB Color, linear Color] or undefined if no color string.
*/
function parseColorString(
colorString: string | undefined,
): [Color, Color] | undefined {
if (!colorString) return undefined;
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
import type { Color3 } from "../scene/types";
/** Convert a Color3 to [sRGB Color, linear Color]. */
function color3ToThree(c: Color3): [Color, Color] {
return [
new Color().setRGB(r, g, b),
new Color().setRGB(r, g, b).convertSRGBToLinear(),
new Color().setRGB(c.r, c.g, c.b),
new Color().setRGB(c.r, c.g, c.b).convertSRGBToLinear(),
];
}
@ -600,29 +594,29 @@ function DynamicFog({
return null;
}
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled, highQualityFog } = useSettings();
export function Sky({ scene }: { scene: SceneSky }) {
const { fogEnabled } = useSettings();
// Skybox textures
const materialList = getProperty(object, "materialList");
const materialList = scene.materialList || undefined;
const skySolidColor = useMemo(
() => parseColorString(getProperty(object, "SkySolidColor")),
[object],
() => color3ToThree(scene.skySolidColor),
[scene.skySolidColor],
);
const useSkyTextures = getInt(object, "useSkyTextures") ?? 1;
const useSkyTextures = scene.useSkyTextures;
// Parse full fog state from Sky object using FogProvider's parser
// Parse full fog state from typed scene sky
const fogState = useMemo(
() => parseFogState(object, highQualityFog),
[object, highQualityFog],
() => fogStateFromScene(scene),
[scene],
);
// Get sRGB fog color for background
const fogColor = useMemo(
() => parseColorString(getProperty(object, "fogColor")),
[object],
() => color3ToThree(scene.fogColor),
[scene.fogColor],
);
const skyColor = skySolidColor || fogColor;
@ -635,32 +629,32 @@ export function Sky({ object }: { object: TorqueObject }) {
// Set scene background color directly using useThree
// This ensures the gap between fogged terrain and skybox blends correctly
const { scene, gl } = useThree();
const { scene: threeScene, gl } = useThree();
useEffect(() => {
if (hasFogParams) {
// Use effective fog color for background (matches terrain fog)
const bgColor = effectiveFogColor.clone();
scene.background = bgColor;
threeScene.background = bgColor;
// Also set the renderer clear color as a fallback
gl.setClearColor(bgColor);
} else if (skyColor) {
const bgColor = skyColor[0].clone();
scene.background = bgColor;
threeScene.background = bgColor;
gl.setClearColor(bgColor);
} else {
scene.background = null;
threeScene.background = null;
}
return () => {
scene.background = null;
threeScene.background = null;
};
}, [scene, gl, hasFogParams, effectiveFogColor, skyColor]);
}, [threeScene, gl, hasFogParams, effectiveFogColor, skyColor]);
// Get linear sky solid color for the solid color sky shader
const linearSkySolidColor = skySolidColor?.[1];
return (
<>
{materialList && useSkyTextures ? (
{materialList && useSkyTextures && materialList.length > 0 ? (
<Suspense fallback={null}>
{/* Key forces remount when mission changes to clear texture caches */}
<SkyBox
@ -680,7 +674,7 @@ export function Sky({ object }: { object: TorqueObject }) {
) : null}
{/* Cloud layers render independently of skybox textures */}
<Suspense>
<CloudLayers object={object} />
<CloudLayers scene={scene} />
</Suspense>
{/* Always render DynamicFog when mission has fog params.
Pass fogEnabled to control visibility - this avoids shader recompilation

View file

@ -1,31 +0,0 @@
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useDatablock } from "./useDatablock";
export function StaticShape({ object }: { object: TorqueObject }) {
const datablockName = getProperty(object, "dataBlock") ?? "";
const datablock = useDatablock(datablockName);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const shapeName = getProperty(datablock, "shapeFile");
if (!shapeName) {
console.error(
`<StaticShape> missing shape for datablock: ${datablockName}`,
);
}
return (
<ShapeInfoProvider type="StaticShape" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer />
</group>
</ShapeInfoProvider>
);
}

View file

@ -1,26 +1,17 @@
import { useEffect, useMemo } from "react";
import { Color, Vector3 } from "three";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
import type { SceneSun } from "../scene/types";
import { torqueToThree } from "../scene/coordinates";
import { updateGlobalSunUniforms } from "../globalSunUniforms";
export function Sun({ object }: { object: TorqueObject }) {
// Parse sun direction - points FROM sun TO scene
// Torque uses Z-up, Three.js uses Y-up
export function Sun({ scene }: { scene: SceneSun }) {
// Sun direction - points FROM sun TO scene
// Convert Torque (X-right, Y-forward, Z-up) to Three.js (X-right, Y-up, Z-backward)
const direction = useMemo(() => {
const directionStr =
getProperty(object, "direction") ?? "0.57735 0.57735 -0.57735";
const [tx, ty, tz] = directionStr
.split(" ")
.map((s: string) => parseFloat(s));
// Convert Torque (X, Y, Z) to Three.js:
// Swap Y/Z for coordinate system: (tx, ty, tz) -> (tx, tz, ty)
const x = tx;
const y = tz;
const z = ty;
const [x, y, z] = torqueToThree(scene.direction);
const len = Math.sqrt(x * x + y * y + z * z);
return new Vector3(x / len, y / len, z / len);
}, [object]);
}, [scene.direction]);
// Position light far away, opposite to direction (light shines FROM position)
const lightPosition = useMemo(() => {
@ -32,17 +23,15 @@ export function Sun({ object }: { object: TorqueObject }) {
);
}, [direction]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color") ?? "0.7 0.7 0.7 1";
const [r, g, b] = colorStr.split(" ").map((s: string) => parseFloat(s));
return new Color(r, g, b);
}, [object]);
const color = useMemo(
() => new Color(scene.color.r, scene.color.g, scene.color.b),
[scene.color],
);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient") ?? "0.5 0.5 0.5 1";
const [r, g, b] = ambientStr.split(" ").map((s: string) => parseFloat(s));
return new Color(r, g, b);
}, [object]);
const ambient = useMemo(
() => new Color(scene.ambient.r, scene.ambient.g, scene.ambient.b),
[scene.ambient],
);
// Torque lighting check (terrLighting.cc): if light direction points up,
// terrain surfaces with upward normals receive only ambient light.

View file

@ -1,22 +1,30 @@
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import type { SceneTSStatic } from "../scene/types";
import {
torqueToThree,
torqueScaleToThree,
matrixFToQuaternion,
} from "../scene/coordinates";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: TorqueObject }) {
const shapeName = getProperty(object, "shapeName");
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
if (!shapeName) {
console.error("<TSStatic> missing shapeName for object", object);
export function TSStatic({ scene }: { scene: SceneTSStatic }) {
const position = useMemo(
() => torqueToThree(scene.transform.position),
[scene.transform.position],
);
const q = useMemo(
() => matrixFToQuaternion(scene.transform),
[scene.transform],
);
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
if (!scene.shapeName) {
console.error(
"<TSStatic> missing shapeName for ghostIndex",
scene.ghostIndex,
);
}
return (
<ShapeInfoProvider type="TSStatic" object={object} shapeName={shapeName}>
<ShapeInfoProvider type="TSStatic" shapeName={scene.shapeName}>
<group position={position} quaternion={q} scale={scale}>
<ShapeRenderer />
</group>

View file

@ -16,24 +16,22 @@ import {
UnsignedByteType,
Vector3,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getProperty } from "../mission";
import type { SceneTerrainBlock } from "../scene/types";
import { torqueToThree } from "../scene/coordinates";
import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { TerrainTile } from "./TerrainTile";
import { useSceneObject } from "./useSceneObject";
import {
createTerrainHeightSampler,
setTerrainHeightSampler,
} from "../terrainHeight";
const DEFAULT_SQUARE_SIZE = 8;
const DEFAULT_VISIBLE_DISTANCE = 600;
const TERRAIN_SIZE = 256;
const LIGHTMAP_SIZE = 512; // Match Tribes 2's 512x512 lightmap
const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
/**
* Create terrain geometry with Torque-style alternating diagonal triangulation.
*
@ -55,15 +53,12 @@ function createTerrainGeometry(size: number, segments: number): BufferGeometry {
const positions = new Float32Array(vertexCount * 3);
const normals = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
// Pre-allocate index buffer: segments² squares × 2 triangles × 3 indices
// Use Uint32Array since vertex count (257² = 66049) exceeds Uint16 max (65535)
const indexCount = segments * segments * 6;
const indices = new Uint32Array(indexCount);
let indexOffset = 0;
const segmentSize = size / segments;
// Create vertices in X-Y plane (same as PlaneGeometry)
// PlaneGeometry goes from top-left to bottom-right with UVs 0→1
// X: -size/2 to +size/2 (left to right)
@ -71,23 +66,19 @@ function createTerrainGeometry(size: number, segments: number): BufferGeometry {
for (let row = 0; row <= segments; row++) {
for (let col = 0; col <= segments; col++) {
const idx = row * (segments + 1) + col;
// Position in X-Y plane (Z=0), centered at origin
positions[idx * 3] = col * segmentSize - size / 2; // X: -size/2 to +size/2
positions[idx * 3 + 1] = size / 2 - row * segmentSize; // Y: +size/2 to -size/2
positions[idx * 3 + 2] = 0; // Z: 0 (will be displaced after rotation)
// Default normal pointing +Z (out of plane, will become +Y after rotation)
normals[idx * 3] = 0;
normals[idx * 3 + 1] = 0;
normals[idx * 3 + 2] = 1;
// UV coordinates (0 to 1), matching PlaneGeometry
uvs[idx * 2] = col / segments;
uvs[idx * 2 + 1] = 1 - row / segments; // Flip V so row 0 is at V=1
}
}
// Create triangle indices with Torque-style alternating diagonals
// Using CCW winding for front face (Three.js default)
for (let row = 0; row < segments; row++) {
@ -100,10 +91,8 @@ function createTerrainGeometry(size: number, segments: number): BufferGeometry {
const b = a + 1;
const c = (row + 1) * (segments + 1) + col;
const d = c + 1;
// Torque's split decision: ((x ^ y) & 1) == 0 means Split45
const split45 = ((col ^ row) & 1) === 0;
if (split45) {
// Split45: diagonal from a to d (top-left to bottom-right in screen space)
// Triangle 1: a, c, d (CCW from front)
@ -127,21 +116,17 @@ function createTerrainGeometry(size: number, segments: number): BufferGeometry {
}
}
}
geometry.setIndex(new BufferAttribute(indices, 1));
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
// Apply same rotations as the original PlaneGeometry approach:
// rotateX(-90°) puts the X-Y plane into X-Z (horizontal), with +Y becoming up
// rotateY(-90°) rotates around Y axis to match terrain coordinate system
geometry.rotateX(-Math.PI / 2);
geometry.rotateY(-Math.PI / 2);
return geometry;
}
/**
* Displace terrain vertices on CPU and compute smooth normals from heightmap gradients.
*
@ -163,54 +148,44 @@ function displaceTerrainAndComputeNormals(
const uvs = uvAttr.array as Float32Array;
const normals = normalAttr.array as Float32Array;
const vertexCount = posAttr.count;
// Helper to get height at heightmap coordinates with clamping (integer coords)
const getHeightInt = (col: number, row: number): number => {
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
return (heightMap[row * TERRAIN_SIZE + col] / 65535) * HEIGHT_SCALE;
};
// Helper to get bilinearly interpolated height (matches GPU texture sampling)
const getHeight = (col: number, row: number): number => {
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
const col0 = Math.floor(col);
const row0 = Math.floor(row);
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
const fx = col - col0;
const fy = row - row0;
const h00 = (heightMap[row0 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
const h10 = (heightMap[row0 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
const h01 = (heightMap[row1 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
const h11 = (heightMap[row1 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
// Bilinear interpolation
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return h0 * (1 - fy) + h1 * fy;
};
// Process each vertex
for (let i = 0; i < vertexCount; i++) {
const u = uvs[i * 2];
const v = uvs[i * 2 + 1];
// Map UV to heightmap coordinates - must match Torque's terrain sampling.
// Torque formula: floor(worldPos / squareSize) & BlockMask
// UV 0→1 maps to world 0→2048, squareSize=8, so: floor(UV * 256) & 255
// This wraps at edges for seamless terrain tiling.
const col = Math.floor(u * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
const row = Math.floor(v * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
// Use direct integer sampling to match GPU nearest-neighbor filtering
const height = getHeightInt(col, row);
positions[i * 3 + 1] = height;
// Compute normal using central differences on heightmap with smooth interpolation.
// Use fractional coordinates for gradient sampling to get smooth normals.
const colF = u * (TERRAIN_SIZE - 1);
@ -219,11 +194,9 @@ function displaceTerrainAndComputeNormals(
const hR = getHeight(colF + 1, rowF); // right
const hD = getHeight(colF, rowF + 1); // down (increasing row)
const hU = getHeight(colF, rowF - 1); // up (decreasing row)
// Gradients in heightmap space (col increases = +U, row increases = +V)
const dCol = (hR - hL) / 2; // height change per column
const dRow = (hD - hU) / 2; // height change per row
// Now map heightmap gradients to world-space normal
// After rotateX(-PI/2) and rotateY(-PI/2):
// - U direction (col) maps to world +Z
@ -234,7 +207,6 @@ function displaceTerrainAndComputeNormals(
let nx = dRow;
let ny = squareSize;
let nz = dCol;
// Normalize
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
@ -246,16 +218,13 @@ function displaceTerrainAndComputeNormals(
ny = 1;
nz = 0;
}
normals[i * 3] = nx;
normals[i * 3 + 1] = ny;
normals[i * 3 + 2] = nz;
}
posAttr.needsUpdate = true;
normalAttr.needsUpdate = true;
}
/**
* Ray-march through heightmap to determine if a point is in shadow.
* Uses the same coordinate system as the terrain geometry.
@ -284,52 +253,42 @@ function rayMarchShadow(
const stepCol = lightDir.z / squareSize;
const stepRow = lightDir.x / squareSize;
const stepHeight = lightDir.y;
// Normalize to step ~0.5 heightmap units per iteration for good sampling
const horizontalLen = Math.sqrt(stepCol * stepCol + stepRow * stepRow);
if (horizontalLen < 0.0001) {
// Light is nearly vertical - no self-shadowing possible
return 1.0;
}
const stepScale = 0.5 / horizontalLen;
const dCol = stepCol * stepScale;
const dRow = stepRow * stepScale;
const dHeight = stepHeight * stepScale;
let col = startCol;
let row = startRow;
let height = startHeight + 0.1; // Small offset to avoid self-intersection
// March until we exit terrain bounds or confirm we're lit
const maxSteps = TERRAIN_SIZE * 3; // Enough to cross terrain diagonally
for (let i = 0; i < maxSteps; i++) {
col += dCol;
row += dRow;
height += dHeight;
// Check if ray exited terrain bounds horizontally
if (col < 0 || col >= TERRAIN_SIZE || row < 0 || row >= TERRAIN_SIZE) {
return 1.0; // Exited terrain, not in shadow
}
// Check if ray is above max terrain height
if (height > HEIGHT_SCALE) {
return 1.0; // Above all terrain, not in shadow
}
// Sample terrain height at current position
const terrainHeight = getHeight(col, row);
// If ray is below terrain surface, we're in shadow
if (height < terrainHeight) {
return 0.0;
}
}
return 1.0; // Reached max steps, assume not in shadow
}
/**
* Generate a terrain lightmap texture with smooth normals and ray-traced shadows.
*
@ -360,39 +319,31 @@ function generateTerrainLightmap(
// Clamp to valid range (don't wrap for shadow rays)
const clampedCol = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
const clampedRow = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
const col0 = Math.floor(clampedCol);
const row0 = Math.floor(clampedRow);
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
const fx = clampedCol - col0;
const fy = clampedRow - row0;
const h00 = heightMap[row0 * TERRAIN_SIZE + col0] / 65535;
const h10 = heightMap[row0 * TERRAIN_SIZE + col1] / 65535;
const h01 = heightMap[row1 * TERRAIN_SIZE + col0] / 65535;
const h11 = heightMap[row1 * TERRAIN_SIZE + col1] / 65535;
// Bilinear interpolation
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return (h0 * (1 - fy) + h1 * fy) * HEIGHT_SCALE;
};
// Light direction (negate sun direction since it points FROM sun)
const lightDir = new Vector3(
-sunDirection.x,
-sunDirection.y,
-sunDirection.z,
).normalize();
const lightmapData = new Uint8Array(LIGHTMAP_SIZE * LIGHTMAP_SIZE);
// Epsilon for gradient sampling (in heightmap units)
// Use 0.5 to sample across a reasonable distance for smooth gradients
const eps = 0.5;
// Generate lightmap by computing normal and shadow at each pixel
for (let lRow = 0; lRow < LIGHTMAP_SIZE; lRow++) {
for (let lCol = 0; lCol < LIGHTMAP_SIZE; lCol++) {
@ -401,28 +352,22 @@ function generateTerrainLightmap(
// With 2 lightmap pixels per terrain square: pos = lCol/2 + 0.25
const col = lCol / 2 + 0.25;
const row = lRow / 2 + 0.25;
// Get height at this position for shadow ray starting point
const surfaceHeight = getInterpolatedHeight(col, row);
// Compute gradient using central differences on interpolated heights
const hL = getInterpolatedHeight(col - eps, row);
const hR = getInterpolatedHeight(col + eps, row);
const hU = getInterpolatedHeight(col, row - eps);
const hD = getInterpolatedHeight(col, row + eps);
// Gradient in heightmap units
const dCol = (hR - hL) / (2 * eps);
const dRow = (hD - hU) / (2 * eps);
// Convert to world-space normal - must match displaceTerrainAndComputeNormals
// After geometry rotations: U (col) → +Z, V (row) → +X
const nx = -dRow;
const ny = squareSize;
const nz = -dCol;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
// Compute NdotL
const NdotL = Math.max(
0,
@ -430,7 +375,6 @@ function generateTerrainLightmap(
(ny / len) * lightDir.y +
(nz / len) * lightDir.z,
);
// Ray-march to determine shadow (only if surface faces the light)
let shadow = 1.0;
if (NdotL > 0) {
@ -443,14 +387,12 @@ function generateTerrainLightmap(
getInterpolatedHeight,
);
}
// Store NdotL * shadow in lightmap
lightmapData[lRow * LIGHTMAP_SIZE + lCol] = Math.floor(
NdotL * shadow * 255,
);
}
}
const texture = new DataTexture(
lightmapData,
LIGHTMAP_SIZE,
@ -465,10 +407,8 @@ function generateTerrainLightmap(
texture.magFilter = LinearFilter;
texture.minFilter = LinearFilter;
texture.needsUpdate = true;
return texture;
}
/**
* Load a .ter file, used for terrain heightmap and texture info.
*/
@ -478,39 +418,32 @@ function useTerrain(terrainFile: string) {
queryFn: () => loadTerrain(terrainFile),
});
}
/**
* Get visibleDistance from the Sky object, used to determine how far terrain
* tiles should render. This matches Tribes 2's terrain tiling behavior.
* Get visibleDistance from the Sky scene object, used to determine how far
* terrain tiles should render. This matches Tribes 2's terrain tiling behavior.
*/
function useVisibleDistance(): number {
const sky = useSceneObject("Sky");
const sky = useSceneSky();
if (!sky) return DEFAULT_VISIBLE_DISTANCE;
const highVisibleDistance = getFloat(sky, "high_visibleDistance");
if (highVisibleDistance != null && highVisibleDistance > 0) {
return highVisibleDistance;
}
return getFloat(sky, "visibleDistance") ?? DEFAULT_VISIBLE_DISTANCE;
return sky.visibleDistance > 0
? sky.visibleDistance
: DEFAULT_VISIBLE_DISTANCE;
}
interface TileAssignment {
tileX: number;
tileZ: number;
}
/**
* Create a visibility mask texture from emptySquares data.
*/
function createVisibilityMask(emptySquares: number[]): DataTexture {
const maskData = new Uint8Array(TERRAIN_SIZE * TERRAIN_SIZE);
maskData.fill(255); // Start with everything visible
for (const squareId of emptySquares) {
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
const rowOffset = y * TERRAIN_SIZE;
for (let i = 0; i < count; i++) {
const index = rowOffset + x + i;
if (index < maskData.length) {
@ -518,7 +451,6 @@ function createVisibilityMask(emptySquares: number[]): DataTexture {
}
}
}
const texture = new DataTexture(
maskData,
TERRAIN_SIZE,
@ -531,22 +463,19 @@ function createVisibilityMask(emptySquares: number[]): DataTexture {
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.needsUpdate = true;
return texture;
}
export const TerrainBlock = memo(function TerrainBlock({
object,
scene,
}: {
object: TorqueObject;
scene: SceneTerrainBlock;
}) {
const terrainFile = getProperty(object, "terrainFile");
const squareSize = getInt(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
const detailTexture = getProperty(object, "detailTexture");
const terrainFile = scene.terrFileName;
const squareSize = scene.squareSize || DEFAULT_SQUARE_SIZE;
const detailTexture = scene.detailTextureName || undefined;
const blockSize = squareSize * 256;
const visibleDistance = useVisibleDistance();
const camera = useThree((state) => state.camera);
// Torque ignores the mission's terrain position and always uses a fixed formula:
// setPosition(Point3F(-squareSize * (BlockSize >> 1), -squareSize * (BlockSize >> 1), 0));
// where BlockSize = 256. See tribes2-engine/terrain/terrData.cc:679
@ -554,28 +483,21 @@ export const TerrainBlock = memo(function TerrainBlock({
const offset = -squareSize * (TERRAIN_SIZE / 2);
return { x: offset, z: offset };
}, [squareSize]);
const emptySquares = useMemo(() => {
const value = getProperty(object, "emptySquares");
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
}, [object]);
const emptySquares = useMemo(
() => scene.emptySquareRuns ?? [],
[scene.emptySquareRuns],
);
const { data: terrain } = useTerrain(terrainFile);
// Shared geometry for all tiles - with smooth normals computed from heightmap
// Uses Torque-style alternating diagonal triangulation for accurate terrain
const sharedGeometry = useMemo(() => {
if (!terrain) return null;
const size = squareSize * 256;
const geometry = createTerrainGeometry(size, TERRAIN_SIZE);
// Displace vertices on CPU and compute smooth normals
displaceTerrainAndComputeNormals(geometry, terrain.heightMap, squareSize);
return geometry;
}, [squareSize, terrain]);
// Register terrain height sampler for item physics simulation.
useEffect(() => {
if (!terrain) return;
@ -584,30 +506,19 @@ export const TerrainBlock = memo(function TerrainBlock({
);
return () => setTerrainHeightSampler(null);
}, [terrain, squareSize]);
// Get sun direction for lightmap generation
const sun = useSceneObject("Sun");
const sun = useSceneSun();
const sunDirection = useMemo(() => {
if (!sun) return new Vector3(0.57735, -0.57735, 0.57735); // Default diagonal
const directionStr =
getProperty(sun, "direction") ?? "0.57735 0.57735 -0.57735";
const [tx, ty, tz] = directionStr
.split(" ")
.map((s: string) => parseFloat(s));
// Convert Torque (X, Y, Z) to Three.js: swap Y/Z
const x = tx;
const y = tz;
const z = ty;
const [x, y, z] = torqueToThree(sun.direction);
const len = Math.sqrt(x * x + y * y + z * z);
return new Vector3(x / len, y / len, z / len);
}, [sun]);
// Generate terrain lightmap for smooth per-pixel lighting
const terrainLightmap = useMemo(() => {
if (!terrain) return null;
return generateTerrainLightmap(terrain.heightMap, sunDirection, squareSize);
}, [terrain, sunDirection, squareSize]);
// Shared displacement map from heightmap - created once for all tiles
const sharedDisplacementMap = useMemo(() => {
if (!terrain) return null;
@ -626,53 +537,43 @@ export const TerrainBlock = memo(function TerrainBlock({
texture.needsUpdate = true;
return texture;
}, [terrain]);
// Visibility mask for primary tile (0,0) - may have empty squares
const primaryVisibilityMask = useMemo(
() => createVisibilityMask(emptySquares),
[emptySquares],
);
// Visibility mask for pooled tiles - all visible (no empty squares)
// This is a stable reference shared by all pooled tiles
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
// Shared alpha textures from terrain alphaMaps - created once for all tiles
const sharedAlphaTextures = useMemo(() => {
if (!terrain) return null;
return terrain.alphaMaps.map((data) => setupMask(data));
}, [terrain]);
// Calculate the maximum number of tiles that can be visible at once.
const poolSize = useMemo(() => {
const extent = Math.ceil(visibleDistance / blockSize);
const gridSize = 2 * extent + 1;
return gridSize * gridSize - 1; // -1 because primary tile is separate
}, [visibleDistance, blockSize]);
// Create stable pool indices for React keys
const poolIndices = useMemo(
() => Array.from({ length: poolSize }, (_, i) => i),
[poolSize],
);
// Track which tile coordinate each pool slot is assigned to
const [tileAssignments, setTileAssignments] = useState<
(TileAssignment | null)[]
>(() => Array(poolSize).fill(null));
// Track previous tile bounds to avoid unnecessary state updates
const prevBoundsRef = useRef({ xStart: 0, xEnd: 0, zStart: 0, zEnd: 0 });
useFrame(() => {
const relativeCamX = camera.position.x - basePosition.x;
const relativeCamZ = camera.position.z - basePosition.z;
const xStart = Math.floor((relativeCamX - visibleDistance) / blockSize);
const xEnd = Math.ceil((relativeCamX + visibleDistance) / blockSize);
const zStart = Math.floor((relativeCamZ - visibleDistance) / blockSize);
const zEnd = Math.ceil((relativeCamZ + visibleDistance) / blockSize);
// Early exit if bounds haven't changed
const prev = prevBoundsRef.current;
if (
@ -687,7 +588,6 @@ export const TerrainBlock = memo(function TerrainBlock({
prev.xEnd = xEnd;
prev.zStart = zStart;
prev.zEnd = zEnd;
// Build new assignments array
const newAssignments: (TileAssignment | null)[] = [];
for (let x = xStart; x < xEnd; x++) {
@ -699,10 +599,8 @@ export const TerrainBlock = memo(function TerrainBlock({
while (newAssignments.length < poolSize) {
newAssignments.push(null);
}
setTileAssignments(newAssignments);
});
if (
!terrain ||
!sharedGeometry ||
@ -711,7 +609,6 @@ export const TerrainBlock = memo(function TerrainBlock({
) {
return null;
}
return (
<>
{/* Primary tile at (0,0) with emptySquares applied */}

View file

@ -42,7 +42,7 @@ interface TerrainTileProps {
visible?: boolean;
}
function BlendedTerrainTextures({
const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
@ -136,9 +136,9 @@ function BlendedTerrainTextures({
onBeforeCompile={onBeforeCompile}
/>
);
}
});
function TerrainMaterial({
const TerrainMaterial = memo(function TerrainMaterial({
displacementMap,
visibilityMask,
textureNames,
@ -170,7 +170,7 @@ function TerrainMaterial({
/>
</Suspense>
);
}
});
export const TerrainTile = memo(function TerrainTile({
tileX,

View file

@ -4,20 +4,16 @@ import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import { useDatablock } from "./useDatablock";
export function Turret({ object }: { object: TorqueObject }) {
const datablockName = getProperty(object, "dataBlock") ?? "";
const barrelDatablockName = getProperty(object, "initialBarrel");
const datablock = useDatablock(datablockName);
const barrelDatablock = useDatablock(barrelDatablockName);
const position = useMemo(() => getPosition(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const shapeName = getProperty(datablock, "shapeFile");
const barrelShapeName = getProperty(barrelDatablock, "shapeFile");
if (!shapeName) {
console.error(`<Turret> missing shape for datablock: ${datablockName}`);
}
@ -28,7 +24,6 @@ export function Turret({ object }: { object: TorqueObject }) {
`<Turret> missing shape for barrel datablock: ${barrelDatablockName}`,
);
}
return (
<ShapeInfoProvider type="Turret" object={object} shapeName={shapeName}>
<group position={position} quaternion={q} scale={scale}>

View file

@ -3,14 +3,8 @@ import { Box, useTexture } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
import { textureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import {
getFloat,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import type { SceneWaterBlock } from "../scene/types";
import { torqueToThree, torqueScaleToThree, matrixFToQuaternion } from "../scene";
import { setupTexture } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useDebug, useSettings } from "./SettingsProvider";
@ -93,26 +87,19 @@ export function WaterMaterial({
* - Renders 9 reps (3x3 grid) centered on camera's rep
*/
export const WaterBlock = memo(function WaterBlock({
object,
scene,
}: {
object: TorqueObject;
scene: SceneWaterBlock;
}) {
const { debugMode } = useDebug();
const q = useMemo(() => getRotation(object), [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => matrixFToQuaternion(scene.transform), [scene.transform]);
const position = useMemo(() => torqueToThree(scene.transform.position), [scene.transform]);
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
const [scaleX, scaleY, scaleZ] = scale;
const camera = useThree((state) => state.camera);
const hasCameraPositionChanged = usePositionTracker();
// Water surface height (top of water volume)
// TODO: Use this for terrain intersection masking (reject water blocks where
// terrain height > surfaceZ + waveMagnitude/2). Requires TerrainProvider.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const surfaceZ = position[1] + scaleY;
// Wave magnitude affects terrain masking (Torque adds half to surface height)
const waveMagnitude = getFloat(object, "waveMagnitude") ?? 1.0;
const waveMagnitude = scene.waveMagnitude;
// Convert world position to terrain space and snap to grid.
// Matches Torque's UpdateFluidRegion() and fluid::SetInfo():
@ -196,11 +183,10 @@ export const WaterBlock = memo(function WaterBlock({
});
});
const surfaceTexture =
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
const envMapTexture = getProperty(object, "envMapTexture");
const opacity = getFloat(object, "surfaceOpacity") ?? 0.75;
const envMapIntensity = getFloat(object, "envMapIntensity") ?? 1.0;
const surfaceTexture = scene.surfaceName || "liquidTiles/BlueWater";
const envMapTexture = scene.envMapName || undefined;
const opacity = scene.surfaceOpacity;
const envMapIntensity = scene.envMapIntensity;
// Create subdivided plane geometry for the water surface
// Tessellation matches Tribes 2 engine (5x5 vertices per block)

View file

@ -1,15 +1,10 @@
import { useMemo } from "react";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty } from "../mission";
import type { WayPointEntity } from "../state/gameEntityTypes";
import { FloatingLabel } from "./FloatingLabel";
export function WayPoint({ object }: { object: TorqueObject }) {
const position = useMemo(() => getPosition(object), [object]);
const label = getProperty(object, "name");
return label ? (
<FloatingLabel position={position} opacity={0.6}>
{label}
export function WayPoint({ entity }: { entity: WayPointEntity }) {
return entity.label ? (
<FloatingLabel position={entity.position} opacity={0.6}>
{entity.label}
</FloatingLabel>
) : null;
}