mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-13 17:30:56 +00:00
begin live server support
This commit is contained in:
parent
0c9ddb476a
commit
e4ae265184
368 changed files with 17756 additions and 7738 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
54
src/components/DialogButton.module.css
Normal file
54
src/components/DialogButton.module.css
Normal 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);
|
||||
}
|
||||
263
src/components/EntityRenderer.tsx
Normal file
263
src/components/EntityRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
src/components/EntityScene.tsx
Normal file
293
src/components/EntityScene.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/components/FeaturesProvider.tsx
Normal file
40
src/components/FeaturesProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
29
src/components/JoinServerButton.module.css
Normal file
29
src/components/JoinServerButton.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/components/JoinServerButton.tsx
Normal file
53
src/components/JoinServerButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
266
src/components/LiveConnection.tsx
Normal file
266
src/components/LiveConnection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
src/components/LiveObserver.tsx
Normal file
328
src/components/LiveObserver.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export function MissionSelect({
|
|||
onFocus={() => {
|
||||
try {
|
||||
document.exitPointerLock();
|
||||
} catch {}
|
||||
} catch { /* expected */ }
|
||||
combobox.show();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dead→Alive 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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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,
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
81
src/components/SceneLighting.tsx
Normal file
81
src/components/SceneLighting.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
src/components/ServerBrowser.module.css
Normal file
178
src/components/ServerBrowser.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
194
src/components/ServerBrowser.tsx
Normal file
194
src/components/ServerBrowser.tsx
Normal 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}>🔒</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ export function ShapeSelect({
|
|||
onFocus={() => {
|
||||
try {
|
||||
document.exitPointerLock();
|
||||
} catch {}
|
||||
} catch { /* expected */ }
|
||||
combobox.show();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
112
src/file.vert
112
src/file.vert
|
|
@ -1,112 +0,0 @@
|
|||
#ifdef USE_FOG
|
||||
// Check runtime fog enabled uniform - allows toggling without shader recompilation
|
||||
if (fogEnabled) {
|
||||
// Fog disabled at runtime, skip all fog calculations
|
||||
} else {
|
||||
float dist = vFogDepth;
|
||||
|
||||
// Discard fragments at or beyond visible distance - matches Torque's behavior
|
||||
// where objects beyond visibleDistance are not rendered at all.
|
||||
// This prevents fully-fogged geometry from showing as silhouettes against
|
||||
// the sky's fog-to-sky gradient.
|
||||
if (dist >= fogFar) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// Step 1: Calculate distance-based haze (quadratic falloff)
|
||||
// Since we discard at fogFar, haze never reaches 1.0 here
|
||||
float haze = 0.0;
|
||||
if (dist > fogNear) {
|
||||
float fogScale = 1.0 / (fogFar - fogNear);
|
||||
float distFactor = (dist - fogNear) * fogScale - 1.0;
|
||||
haze = 1.0 - distFactor * distFactor;
|
||||
}
|
||||
|
||||
// Step 2: Calculate fog volume contributions
|
||||
// Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false)
|
||||
// All fog uses the global fogColor - see Tribes2_Fog_System.md for details
|
||||
float volumeFog = 0.0;
|
||||
|
||||
#ifdef USE_VOLUMETRIC_FOG
|
||||
{
|
||||
#ifdef USE_FOG_WORLD_POSITION
|
||||
float fragmentHeight = vFogWorldPosition.y;
|
||||
#else
|
||||
float fragmentHeight = cameraHeight;
|
||||
#endif
|
||||
|
||||
float deltaY = fragmentHeight - cameraHeight;
|
||||
float absDeltaY = abs(deltaY);
|
||||
|
||||
// Determine if we're going up (positive) or down (negative)
|
||||
if (absDeltaY > 0.01) {
|
||||
// Non-horizontal ray: ray-march through fog volumes
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int offset = i * 4;
|
||||
float volVisDist = allFogVolumes[offset + 0];
|
||||
float volMinH = allFogVolumes[offset + 1];
|
||||
float volMaxH = allFogVolumes[offset + 2];
|
||||
float volPct = allFogVolumes[offset + 3];
|
||||
|
||||
// Skip inactive volumes (visibleDistance = 0)
|
||||
if (volVisDist <= 0.0) continue;
|
||||
|
||||
// Calculate fog factor for this volume
|
||||
// From Torque: factor = (1 / (volumeVisDist * visFactor)) * percentage
|
||||
// where visFactor is smVisibleDistanceMod (a user quality pref, default 1.0)
|
||||
// Since we don't have quality settings, we use visFactor = 1.0
|
||||
float factor = (1.0 / volVisDist) * volPct;
|
||||
|
||||
// Find ray intersection with this volume's height range
|
||||
float rayMinY = min(cameraHeight, fragmentHeight);
|
||||
float rayMaxY = max(cameraHeight, fragmentHeight);
|
||||
|
||||
// Check if ray intersects volume height range
|
||||
if (rayMinY < volMaxH && rayMaxY > volMinH) {
|
||||
float intersectMin = max(rayMinY, volMinH);
|
||||
float intersectMax = min(rayMaxY, volMaxH);
|
||||
float intersectHeight = intersectMax - intersectMin;
|
||||
|
||||
// Calculate distance traveled through this volume using similar triangles:
|
||||
// subDist / dist = intersectHeight / absDeltaY
|
||||
float subDist = dist * (intersectHeight / absDeltaY);
|
||||
|
||||
// Accumulate fog: fog += subDist * factor
|
||||
volumeFog += subDist * factor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Near-horizontal ray: if camera is inside a volume, apply full fog for that volume
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int offset = i * 4;
|
||||
float volVisDist = allFogVolumes[offset + 0];
|
||||
float volMinH = allFogVolumes[offset + 1];
|
||||
float volMaxH = allFogVolumes[offset + 2];
|
||||
float volPct = allFogVolumes[offset + 3];
|
||||
|
||||
if (volVisDist <= 0.0) continue;
|
||||
|
||||
// If camera is inside this volume, apply fog for full distance
|
||||
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
|
||||
float factor = (1.0 / volVisDist) * volPct;
|
||||
volumeFog += dist * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Step 3: Combine haze and volume fog
|
||||
// Torque's clamping: if (bandPct + hazePct > 1) hazePct = 1 - bandPct
|
||||
// This gives fog volumes priority over haze
|
||||
float volPct = min(volumeFog, 1.0);
|
||||
float hazePct = haze;
|
||||
if (volPct + hazePct > 1.0) {
|
||||
hazePct = 1.0 - volPct;
|
||||
}
|
||||
float fogFactor = hazePct + volPct;
|
||||
|
||||
// Apply fog using global fogColor (per-volume colors not used in Tribes 2)
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
|
||||
}
|
||||
#endif
|
||||
|
|
@ -143,18 +143,3 @@ export function getMissionInfo(missionName: string) {
|
|||
export function getMissionList() {
|
||||
return Object.keys(manifest.missions);
|
||||
}
|
||||
|
||||
const missionsByNormalizedName = new Map(
|
||||
Object.keys(manifest.missions).map((key) => [key.toLowerCase(), key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a manifest mission key from a demo mission name like "S5-WoodyMyrk".
|
||||
* Returns null if no match is found.
|
||||
*/
|
||||
export function findMissionByDemoName(
|
||||
demoMissionName: string,
|
||||
): string | null {
|
||||
const normalized = demoMissionName.replace(/-/g, "_").toLowerCase();
|
||||
return missionsByNormalizedName.get(normalized) ?? null;
|
||||
}
|
||||
|
|
|
|||
211
src/scene/coordinates.spec.ts
Normal file
211
src/scene/coordinates.spec.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Quaternion, Vector3 } from "three";
|
||||
import {
|
||||
torqueToThree,
|
||||
torqueScaleToThree,
|
||||
matrixFToQuaternion,
|
||||
torqueAxisAngleToQuaternion,
|
||||
} from "./coordinates";
|
||||
import type { MatrixF } from "./types";
|
||||
import { IDENTITY_MATRIX } from "./types";
|
||||
|
||||
describe("torqueToThree", () => {
|
||||
it("swizzles Torque (X,Y,Z) to Three.js (Y,Z,X)", () => {
|
||||
expect(torqueToThree({ x: 1, y: 2, z: 3 })).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it("handles zero vector", () => {
|
||||
expect(torqueToThree({ x: 0, y: 0, z: 0 })).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("torqueScaleToThree", () => {
|
||||
it("applies same swizzle as position", () => {
|
||||
expect(torqueScaleToThree({ x: 10, y: 20, z: 30 })).toEqual([20, 30, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrixFToQuaternion", () => {
|
||||
it("returns identity quaternion for identity matrix", () => {
|
||||
const q = matrixFToQuaternion(IDENTITY_MATRIX);
|
||||
expect(q.x).toBeCloseTo(0, 5);
|
||||
expect(q.y).toBeCloseTo(0, 5);
|
||||
expect(q.z).toBeCloseTo(0, 5);
|
||||
expect(q.w).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("handles 90° rotation around Torque Z-axis (up)", () => {
|
||||
// Torque Z-up → Three.js Y-up
|
||||
// 90° around Torque Z maps to -90° around Three.js Y
|
||||
// (negative due to conjugation for row-vector → column-vector convention)
|
||||
const angleRad = Math.PI / 2;
|
||||
const c = Math.cos(angleRad);
|
||||
const s = Math.sin(angleRad);
|
||||
|
||||
const elements = [
|
||||
c, s, 0, 0,
|
||||
-s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
|
||||
const q = matrixFToQuaternion(m);
|
||||
|
||||
const expected = new Quaternion().setFromAxisAngle(
|
||||
new Vector3(0, 1, 0),
|
||||
-angleRad,
|
||||
);
|
||||
|
||||
expect(q.x).toBeCloseTo(expected.x, 4);
|
||||
expect(q.y).toBeCloseTo(expected.y, 4);
|
||||
expect(q.z).toBeCloseTo(expected.z, 4);
|
||||
expect(q.w).toBeCloseTo(expected.w, 4);
|
||||
});
|
||||
|
||||
it("handles 90° rotation around Torque X-axis (right)", () => {
|
||||
// Torque X → Three.js Z, angle negated
|
||||
const angleRad = Math.PI / 2;
|
||||
const c = Math.cos(angleRad);
|
||||
const s = Math.sin(angleRad);
|
||||
|
||||
const elements = [
|
||||
1, 0, 0, 0,
|
||||
0, c, s, 0,
|
||||
0, -s, c, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
|
||||
const q = matrixFToQuaternion(m);
|
||||
|
||||
const expected = new Quaternion().setFromAxisAngle(
|
||||
new Vector3(0, 0, 1),
|
||||
-angleRad,
|
||||
);
|
||||
|
||||
expect(q.x).toBeCloseTo(expected.x, 4);
|
||||
expect(q.y).toBeCloseTo(expected.y, 4);
|
||||
expect(q.z).toBeCloseTo(expected.z, 4);
|
||||
expect(q.w).toBeCloseTo(expected.w, 4);
|
||||
});
|
||||
|
||||
it("handles 90° rotation around Torque Y-axis (forward)", () => {
|
||||
// Torque Y → Three.js X, angle negated
|
||||
const angleRad = Math.PI / 2;
|
||||
const c = Math.cos(angleRad);
|
||||
const s = Math.sin(angleRad);
|
||||
|
||||
const elements = [
|
||||
c, 0, -s, 0,
|
||||
0, 1, 0, 0,
|
||||
s, 0, c, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
|
||||
const q = matrixFToQuaternion(m);
|
||||
|
||||
const expected = new Quaternion().setFromAxisAngle(
|
||||
new Vector3(1, 0, 0),
|
||||
-angleRad,
|
||||
);
|
||||
|
||||
expect(q.x).toBeCloseTo(expected.x, 4);
|
||||
expect(q.y).toBeCloseTo(expected.y, 4);
|
||||
expect(q.z).toBeCloseTo(expected.z, 4);
|
||||
expect(q.w).toBeCloseTo(expected.w, 4);
|
||||
});
|
||||
|
||||
it("produces unit quaternion from valid rotation matrix", () => {
|
||||
// Arbitrary rotation: 45° around Torque (1,1,0) normalized
|
||||
const len = Math.sqrt(2);
|
||||
const nx = 1 / len, ny = 1 / len, nz = 0;
|
||||
const angle = Math.PI / 4;
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
const t = 1 - c;
|
||||
|
||||
const elements = new Array(16).fill(0);
|
||||
elements[0] = t * nx * nx + c;
|
||||
elements[1] = t * nx * ny + s * nz;
|
||||
elements[2] = t * nx * nz - s * ny;
|
||||
elements[4] = t * nx * ny - s * nz;
|
||||
elements[5] = t * ny * ny + c;
|
||||
elements[6] = t * ny * nz + s * nx;
|
||||
elements[8] = t * nx * nz + s * ny;
|
||||
elements[9] = t * ny * nz - s * nx;
|
||||
elements[10] = t * nz * nz + c;
|
||||
elements[15] = 1;
|
||||
|
||||
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
|
||||
const q = matrixFToQuaternion(m);
|
||||
const qLen = Math.sqrt(q.x ** 2 + q.y ** 2 + q.z ** 2 + q.w ** 2);
|
||||
expect(qLen).toBeCloseTo(1, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("torqueAxisAngleToQuaternion", () => {
|
||||
it("returns identity for zero angle", () => {
|
||||
const q = torqueAxisAngleToQuaternion(1, 0, 0, 0);
|
||||
expect(q.x).toBeCloseTo(0, 5);
|
||||
expect(q.y).toBeCloseTo(0, 5);
|
||||
expect(q.z).toBeCloseTo(0, 5);
|
||||
expect(q.w).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("returns identity for zero-length axis", () => {
|
||||
const q = torqueAxisAngleToQuaternion(0, 0, 0, 90);
|
||||
expect(q.w).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("90° around Torque Z maps to Three.js Y rotation", () => {
|
||||
// Torque Z-axis = (0,0,1), swizzled to Three.js = (0,1,0)
|
||||
const q = torqueAxisAngleToQuaternion(0, 0, 1, 90);
|
||||
|
||||
// Negative angle because of coordinate system handedness flip
|
||||
const expected = new Quaternion().setFromAxisAngle(
|
||||
new Vector3(0, 1, 0),
|
||||
-90 * (Math.PI / 180),
|
||||
);
|
||||
|
||||
expect(q.x).toBeCloseTo(expected.x, 4);
|
||||
expect(q.y).toBeCloseTo(expected.y, 4);
|
||||
expect(q.z).toBeCloseTo(expected.z, 4);
|
||||
expect(q.w).toBeCloseTo(expected.w, 4);
|
||||
});
|
||||
|
||||
it("produces unit quaternion", () => {
|
||||
const q = torqueAxisAngleToQuaternion(1, 2, 3, 45);
|
||||
const len = Math.sqrt(q.x ** 2 + q.y ** 2 + q.z ** 2 + q.w ** 2);
|
||||
expect(len).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("agrees with matrixFToQuaternion for same rotation", () => {
|
||||
// 60° around Torque Z-axis
|
||||
// Both functions should produce the same quaternion.
|
||||
const ax = 0, ay = 0, az = 1, angleDeg = 60;
|
||||
const angleRad = angleDeg * (Math.PI / 180);
|
||||
|
||||
const c = Math.cos(angleRad);
|
||||
const s = Math.sin(angleRad);
|
||||
const elements = [
|
||||
c, s, 0, 0,
|
||||
-s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } };
|
||||
|
||||
const qFromMatrix = matrixFToQuaternion(m);
|
||||
const qFromAxisAngle = torqueAxisAngleToQuaternion(ax, ay, az, angleDeg);
|
||||
|
||||
const dot =
|
||||
qFromMatrix.x * qFromAxisAngle.x +
|
||||
qFromMatrix.y * qFromAxisAngle.y +
|
||||
qFromMatrix.z * qFromAxisAngle.z +
|
||||
qFromMatrix.w * qFromAxisAngle.w;
|
||||
|
||||
expect(Math.abs(dot)).toBeCloseTo(1, 4);
|
||||
});
|
||||
});
|
||||
113
src/scene/coordinates.ts
Normal file
113
src/scene/coordinates.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Matrix4, Quaternion } from "three";
|
||||
import type { MatrixF, Vec3 } from "./types";
|
||||
|
||||
/**
|
||||
* Convert a Torque Vec3 (X-right, Y-forward, Z-up) to Three.js (X-right, Y-up, Z-backward).
|
||||
* Swizzle: three.x = torque.y, three.y = torque.z, three.z = torque.x
|
||||
*
|
||||
* Note: this is the same swizzle used by getPosition() in mission.ts.
|
||||
*/
|
||||
export function torqueToThree(v: Vec3): [number, number, number] {
|
||||
return [v.y, v.z, v.x];
|
||||
}
|
||||
|
||||
/** Convert a Torque scale Vec3 to Three.js axis order. */
|
||||
export function torqueScaleToThree(v: Vec3): [number, number, number] {
|
||||
return [v.y, v.z, v.x];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Torque MatrixF to a Three.js Quaternion.
|
||||
*
|
||||
* Torque MatrixF layout (row-major, idx = row*4 + col):
|
||||
* [0] [1] [2] [3] m00 m01 m02 tx
|
||||
* [4] [5] [6] [7] = m10 m11 m12 ty
|
||||
* [8] [9] [10] [11] m20 m21 m22 tz
|
||||
* [12] [13] [14] [15] 0 0 0 1
|
||||
*
|
||||
* Three.js Matrix4 is column-major, with elements in the same index layout
|
||||
* but interpreted differently. We extract the 3×3 rotation, apply the
|
||||
* Torque→Three.js coordinate transform, then decompose to quaternion.
|
||||
*/
|
||||
export function matrixFToQuaternion(m: MatrixF): Quaternion {
|
||||
const e = m.elements;
|
||||
|
||||
// Extract the Torque 3×3 rotation columns (row-major storage)
|
||||
// Column 0: e[0], e[1], e[2]
|
||||
// Column 1: e[4], e[5], e[6]
|
||||
// Column 2: e[8], e[9], e[10]
|
||||
|
||||
// Apply Torque→Three.js coordinate transform to the rotation matrix.
|
||||
// Torque (X,Y,Z) → Three.js (Y,Z,X) means:
|
||||
// Three.js row i, col j = Torque row swizzle[i], col swizzle[j]
|
||||
// where swizzle maps Three axis → Torque axis: X→Y(1), Y→Z(2), Z→X(0)
|
||||
//
|
||||
// Build a Three.js column-major Matrix4 from the transformed rotation.
|
||||
const mat4 = new Matrix4();
|
||||
// Three.js Matrix4.elements is column-major:
|
||||
// [m11, m21, m31, m41, m12, m22, m32, m42, m13, m23, m33, m43, m14, m24, m34, m44]
|
||||
const t = mat4.elements;
|
||||
|
||||
// Torque col 0 (X-axis) maps to Three.js col 2 (Z-axis after swizzle)
|
||||
// Torque col 1 (Y-axis) maps to Three.js col 0 (X-axis after swizzle)
|
||||
// Torque col 2 (Z-axis) maps to Three.js col 1 (Y-axis after swizzle)
|
||||
//
|
||||
// Within each column, rows are also swizzled:
|
||||
// Torque row 0 (X) → Three.js row 2 (Z)
|
||||
// Torque row 1 (Y) → Three.js row 0 (X)
|
||||
// Torque row 2 (Z) → Three.js row 1 (Y)
|
||||
|
||||
// Three.js column 0 ← Torque column 1 (Y→X), rows swizzled
|
||||
t[0] = e[5]; // T_Y_Y → Three X_X
|
||||
t[1] = e[6]; // T_Z_Y → Three Y_X
|
||||
t[2] = e[4]; // T_X_Y → Three Z_X
|
||||
t[3] = 0;
|
||||
|
||||
// Three.js column 1 ← Torque column 2 (Z→Y), rows swizzled
|
||||
t[4] = e[9]; // T_Y_Z → Three X_Y
|
||||
t[5] = e[10]; // T_Z_Z → Three Y_Y
|
||||
t[6] = e[8]; // T_X_Z → Three Z_Y
|
||||
t[7] = 0;
|
||||
|
||||
// Three.js column 2 ← Torque column 0 (X→Z), rows swizzled
|
||||
t[8] = e[1]; // T_Y_X → Three X_Z
|
||||
t[9] = e[2]; // T_Z_X → Three Y_Z
|
||||
t[10] = e[0]; // T_X_X → Three Z_Z
|
||||
t[11] = 0;
|
||||
|
||||
// Translation column (not used for quaternion, but set for completeness)
|
||||
t[12] = 0;
|
||||
t[13] = 0;
|
||||
t[14] = 0;
|
||||
t[15] = 1;
|
||||
|
||||
const q = new Quaternion();
|
||||
q.setFromRotationMatrix(mat4);
|
||||
// Torque uses row-vector convention (v * M), Three.js uses column-vector (M * v).
|
||||
// The extracted rotation is the transpose, so conjugate to invert.
|
||||
q.conjugate();
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Torque axis-angle rotation string ("ax ay az angleDeg") to a Quaternion.
|
||||
* This is the format used in .mis files. The axis is in Torque coordinates.
|
||||
*/
|
||||
export function torqueAxisAngleToQuaternion(
|
||||
ax: number,
|
||||
ay: number,
|
||||
az: number,
|
||||
angleDeg: number,
|
||||
): Quaternion {
|
||||
// Swizzle axis: Torque (X,Y,Z) → Three.js (Y,Z,X)
|
||||
const threeAx = ay;
|
||||
const threeAy = az;
|
||||
const threeAz = ax;
|
||||
const len = Math.sqrt(threeAx * threeAx + threeAy * threeAy + threeAz * threeAz);
|
||||
if (len < 1e-8) return new Quaternion();
|
||||
const angleRad = -angleDeg * (Math.PI / 180);
|
||||
return new Quaternion().setFromAxisAngle(
|
||||
{ x: threeAx / len, y: threeAy / len, z: threeAz / len } as any,
|
||||
angleRad,
|
||||
);
|
||||
}
|
||||
171
src/scene/crossValidation.spec.ts
Normal file
171
src/scene/crossValidation.spec.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Cross-validation: misToScene and ghostToScene should produce equivalent
|
||||
* scene objects for the same logical data. This catches drift between the
|
||||
* two adapter paths.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { interiorFromMis, tsStaticFromMis, skyFromMis } from "./misToScene";
|
||||
import {
|
||||
interiorFromGhost,
|
||||
tsStaticFromGhost,
|
||||
skyFromGhost,
|
||||
} from "./ghostToScene";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
|
||||
function makeObj(
|
||||
className: string,
|
||||
props: Record<string, string>,
|
||||
id = 42,
|
||||
): TorqueObject {
|
||||
const obj: TorqueObject = {
|
||||
_class: className.toLowerCase(),
|
||||
_className: className,
|
||||
_name: "",
|
||||
_id: id,
|
||||
_children: [],
|
||||
};
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
obj[k.toLowerCase()] = v;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
describe("misToScene ↔ ghostToScene cross-validation", () => {
|
||||
it("InteriorInstance: identity rotation produces same transform", () => {
|
||||
const misResult = interiorFromMis(
|
||||
makeObj("InteriorInstance", {
|
||||
interiorFile: "building.dif",
|
||||
position: "100 200 300",
|
||||
rotation: "1 0 0 0",
|
||||
scale: "1 1 1",
|
||||
showTerrainInside: "0",
|
||||
}),
|
||||
);
|
||||
|
||||
const ghostResult = interiorFromGhost(42, {
|
||||
interiorFile: "building.dif",
|
||||
transform: {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 200, 300, 1],
|
||||
position: { x: 100, y: 200, z: 300 },
|
||||
},
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
showTerrainInside: false,
|
||||
skinBase: "",
|
||||
alarmState: false,
|
||||
});
|
||||
|
||||
expect(misResult.interiorFile).toBe(ghostResult.interiorFile);
|
||||
expect(misResult.scale).toEqual(ghostResult.scale);
|
||||
expect(misResult.showTerrainInside).toBe(ghostResult.showTerrainInside);
|
||||
|
||||
// Transform position
|
||||
expect(misResult.transform.position.x).toBeCloseTo(
|
||||
ghostResult.transform.position.x,
|
||||
);
|
||||
expect(misResult.transform.position.y).toBeCloseTo(
|
||||
ghostResult.transform.position.y,
|
||||
);
|
||||
expect(misResult.transform.position.z).toBeCloseTo(
|
||||
ghostResult.transform.position.z,
|
||||
);
|
||||
|
||||
// Rotation elements (identity case)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
expect(misResult.transform.elements[i]).toBeCloseTo(
|
||||
ghostResult.transform.elements[i],
|
||||
4,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("InteriorInstance: 90° Z rotation matches", () => {
|
||||
const misResult = interiorFromMis(
|
||||
makeObj("InteriorInstance", {
|
||||
interiorFile: "building.dif",
|
||||
position: "0 0 0",
|
||||
rotation: "0 0 1 90",
|
||||
}),
|
||||
);
|
||||
|
||||
// Build the same matrix manually: 90° around Z
|
||||
const c = Math.cos(Math.PI / 2);
|
||||
const s = Math.sin(Math.PI / 2);
|
||||
const elements = [
|
||||
c, s, 0, 0,
|
||||
-s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
const ghostResult = interiorFromGhost(42, {
|
||||
interiorFile: "building.dif",
|
||||
transform: { elements, position: { x: 0, y: 0, z: 0 } },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
});
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
expect(misResult.transform.elements[i]).toBeCloseTo(
|
||||
ghostResult.transform.elements[i],
|
||||
4,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("TSStatic: position and scale match", () => {
|
||||
const misResult = tsStaticFromMis(
|
||||
makeObj("TSStatic", {
|
||||
shapeName: "tree.dts",
|
||||
position: "50 60 70",
|
||||
rotation: "1 0 0 0",
|
||||
scale: "2 3 4",
|
||||
}),
|
||||
);
|
||||
|
||||
const ghostResult = tsStaticFromGhost(42, {
|
||||
shapeName: "tree.dts",
|
||||
transform: {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 70, 1],
|
||||
position: { x: 50, y: 60, z: 70 },
|
||||
},
|
||||
scale: { x: 2, y: 3, z: 4 },
|
||||
});
|
||||
|
||||
expect(misResult.shapeName).toBe(ghostResult.shapeName);
|
||||
expect(misResult.scale).toEqual(ghostResult.scale);
|
||||
expect(misResult.transform.position).toEqual(ghostResult.transform.position);
|
||||
});
|
||||
|
||||
it("Sky: fog and cloud data match", () => {
|
||||
const misResult = skyFromMis(
|
||||
makeObj("Sky", {
|
||||
materialList: "sky_ice.dml",
|
||||
fogColor: "0.5 0.6 0.7",
|
||||
visibleDistance: "2000",
|
||||
fogDistance: "500",
|
||||
SkySolidColor: "0.1 0.2 0.3",
|
||||
useSkyTextures: "1",
|
||||
windVelocity: "1 0 0",
|
||||
}),
|
||||
);
|
||||
|
||||
const ghostResult = skyFromGhost(42, {
|
||||
materialList: "sky_ice.dml",
|
||||
fogColor: { r: 0.5, g: 0.6, b: 0.7 },
|
||||
visibleDistance: 2000,
|
||||
fogDistance: 500,
|
||||
skySolidColor: { r: 0.1, g: 0.2, b: 0.3 },
|
||||
useSkyTextures: true,
|
||||
fogVolumes: [],
|
||||
cloudLayers: [],
|
||||
windVelocity: { x: 1, y: 0, z: 0 },
|
||||
});
|
||||
|
||||
expect(misResult.materialList).toBe(ghostResult.materialList);
|
||||
expect(misResult.fogColor).toEqual(ghostResult.fogColor);
|
||||
expect(misResult.visibleDistance).toBe(ghostResult.visibleDistance);
|
||||
expect(misResult.fogDistance).toBe(ghostResult.fogDistance);
|
||||
expect(misResult.skySolidColor).toEqual(ghostResult.skySolidColor);
|
||||
expect(misResult.useSkyTextures).toBe(ghostResult.useSkyTextures);
|
||||
expect(misResult.windVelocity).toEqual(ghostResult.windVelocity);
|
||||
});
|
||||
});
|
||||
159
src/scene/ghostToScene.spec.ts
Normal file
159
src/scene/ghostToScene.spec.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
terrainFromGhost,
|
||||
interiorFromGhost,
|
||||
skyFromGhost,
|
||||
sunFromGhost,
|
||||
missionAreaFromGhost,
|
||||
waterBlockFromGhost,
|
||||
ghostToSceneObject,
|
||||
} from "./ghostToScene";
|
||||
|
||||
describe("terrainFromGhost", () => {
|
||||
it("extracts terrain fields", () => {
|
||||
const result = terrainFromGhost(5, {
|
||||
terrFileName: "ice.ter",
|
||||
detailTextureName: "details/detail1.png",
|
||||
squareSize: 8,
|
||||
emptySquareRuns: [0, 10, 256, 5],
|
||||
});
|
||||
expect(result.className).toBe("TerrainBlock");
|
||||
expect(result.ghostIndex).toBe(5);
|
||||
expect(result.terrFileName).toBe("ice.ter");
|
||||
expect(result.squareSize).toBe(8);
|
||||
expect(result.emptySquareRuns).toEqual([0, 10, 256, 5]);
|
||||
});
|
||||
|
||||
it("uses defaults for missing fields", () => {
|
||||
const result = terrainFromGhost(0, {});
|
||||
expect(result.terrFileName).toBe("");
|
||||
expect(result.squareSize).toBe(8);
|
||||
expect(result.emptySquareRuns).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interiorFromGhost", () => {
|
||||
it("extracts transform and scale", () => {
|
||||
const transform = {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 200, 300, 1],
|
||||
position: { x: 100, y: 200, z: 300 },
|
||||
};
|
||||
const result = interiorFromGhost(10, {
|
||||
interiorFile: "building.dif",
|
||||
transform,
|
||||
scale: { x: 2, y: 3, z: 4 },
|
||||
showTerrainInside: true,
|
||||
skinBase: "base",
|
||||
alarmState: false,
|
||||
});
|
||||
expect(result.interiorFile).toBe("building.dif");
|
||||
expect(result.transform).toBe(transform);
|
||||
expect(result.scale).toEqual({ x: 2, y: 3, z: 4 });
|
||||
expect(result.showTerrainInside).toBe(true);
|
||||
});
|
||||
|
||||
it("uses identity transform for missing data", () => {
|
||||
const result = interiorFromGhost(0, {});
|
||||
expect(result.transform.elements).toEqual([
|
||||
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
||||
]);
|
||||
expect(result.scale).toEqual({ x: 1, y: 1, z: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("skyFromGhost", () => {
|
||||
it("extracts fog volumes and cloud layers", () => {
|
||||
const result = skyFromGhost(1, {
|
||||
materialList: "sky_ice.dml",
|
||||
fogColor: { r: 0.5, g: 0.5, b: 0.5 },
|
||||
visibleDistance: 2000,
|
||||
fogDistance: 500,
|
||||
skySolidColor: { r: 0.1, g: 0.2, b: 0.3 },
|
||||
useSkyTextures: true,
|
||||
fogVolumes: [
|
||||
{ visibleDistance: 500, minHeight: 0, maxHeight: 300, color: { r: 0.5, g: 0.5, b: 0.5 } },
|
||||
],
|
||||
cloudLayers: [
|
||||
{ texture: "cloud1.png", heightPercent: 0.35, speed: 0.001 },
|
||||
],
|
||||
windVelocity: { x: 1, y: 0, z: 0 },
|
||||
});
|
||||
expect(result.fogVolumes).toHaveLength(1);
|
||||
expect(result.fogVolumes[0].visibleDistance).toBe(500);
|
||||
expect(result.cloudLayers).toHaveLength(1);
|
||||
expect(result.cloudLayers[0].texture).toBe("cloud1.png");
|
||||
expect(result.visibleDistance).toBe(2000);
|
||||
});
|
||||
|
||||
it("defaults to empty arrays for missing volumes/layers", () => {
|
||||
const result = skyFromGhost(1, {});
|
||||
expect(result.fogVolumes).toEqual([]);
|
||||
expect(result.cloudLayers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sunFromGhost", () => {
|
||||
it("extracts direction and colors", () => {
|
||||
const result = sunFromGhost(2, {
|
||||
direction: { x: 0.57735, y: 0.57735, z: -0.57735 },
|
||||
color: { r: 0.8, g: 0.8, b: 0.7, a: 1.0 },
|
||||
ambient: { r: 0.3, g: 0.3, b: 0.4, a: 1.0 },
|
||||
textures: ["sun.png"],
|
||||
});
|
||||
expect(result.direction.x).toBeCloseTo(0.57735);
|
||||
expect(result.color.r).toBe(0.8);
|
||||
expect(result.textures).toEqual(["sun.png"]);
|
||||
});
|
||||
|
||||
it("uses defaults for missing data", () => {
|
||||
const result = sunFromGhost(0, {});
|
||||
expect(result.direction).toEqual({ x: 0.57735, y: 0.57735, z: -0.57735 });
|
||||
expect(result.color).toEqual({ r: 0.7, g: 0.7, b: 0.7, a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("missionAreaFromGhost", () => {
|
||||
it("extracts area and flight ceiling", () => {
|
||||
const result = missionAreaFromGhost(3, {
|
||||
area: { x: -1024, y: -1024, w: 2048, h: 2048 },
|
||||
flightCeiling: 5000,
|
||||
flightCeilingRange: 100,
|
||||
});
|
||||
expect(result.area).toEqual({ x: -1024, y: -1024, w: 2048, h: 2048 });
|
||||
expect(result.flightCeiling).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waterBlockFromGhost", () => {
|
||||
it("extracts surface textures", () => {
|
||||
const result = waterBlockFromGhost(4, {
|
||||
surfaceName: "water.png",
|
||||
envMapName: "envmap.png",
|
||||
scale: { x: 512, y: 512, z: 10 },
|
||||
});
|
||||
expect(result.surfaceName).toBe("water.png");
|
||||
expect(result.envMapName).toBe("envmap.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ghostToSceneObject", () => {
|
||||
it("dispatches by className", () => {
|
||||
const terrain = ghostToSceneObject("TerrainBlock", 1, {
|
||||
terrFileName: "test.ter",
|
||||
});
|
||||
expect(terrain?.className).toBe("TerrainBlock");
|
||||
|
||||
const interior = ghostToSceneObject("InteriorInstance", 2, {
|
||||
interiorFile: "test.dif",
|
||||
});
|
||||
expect(interior?.className).toBe("InteriorInstance");
|
||||
|
||||
const sky = ghostToSceneObject("Sky", 3, {});
|
||||
expect(sky?.className).toBe("Sky");
|
||||
});
|
||||
|
||||
it("returns null for non-scene classes", () => {
|
||||
expect(ghostToSceneObject("Player", 1, {})).toBeNull();
|
||||
expect(ghostToSceneObject("Vehicle", 2, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
231
src/scene/ghostToScene.ts
Normal file
231
src/scene/ghostToScene.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import type {
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
SceneTSStatic,
|
||||
SceneSky,
|
||||
SceneSun,
|
||||
SceneMissionArea,
|
||||
SceneWaterBlock,
|
||||
SceneObject,
|
||||
MatrixF,
|
||||
Vec3,
|
||||
Color3,
|
||||
Color4,
|
||||
} from "./types";
|
||||
|
||||
type GhostData = Record<string, unknown>;
|
||||
|
||||
function vec3(v: unknown, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
|
||||
if (v && typeof v === "object" && "x" in v) return v as Vec3;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function color3(v: unknown, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
|
||||
if (v && typeof v === "object" && "r" in v) return v as Color3;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function color4(
|
||||
v: unknown,
|
||||
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
||||
): Color4 {
|
||||
if (v && typeof v === "object" && "r" in v) return v as Color4;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function matrixF(v: unknown): MatrixF {
|
||||
if (
|
||||
v &&
|
||||
typeof v === "object" &&
|
||||
"elements" in v &&
|
||||
Array.isArray((v as any).elements)
|
||||
) {
|
||||
return v as MatrixF;
|
||||
}
|
||||
// readAffineTransform() returns {position, rotation} — convert to MatrixF.
|
||||
if (
|
||||
v &&
|
||||
typeof v === "object" &&
|
||||
"position" in v &&
|
||||
"rotation" in v
|
||||
) {
|
||||
const { position: pos, rotation: q } = v as {
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { x: number; y: number; z: number; w: number };
|
||||
};
|
||||
// Quaternion to column-major 4×4 matrix (idx = row + col*4).
|
||||
const xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z;
|
||||
const xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z;
|
||||
const wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z;
|
||||
return {
|
||||
elements: [
|
||||
1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
|
||||
2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
|
||||
2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
|
||||
pos.x, pos.y, pos.z, 1,
|
||||
],
|
||||
position: { x: pos.x, y: pos.y, z: pos.z },
|
||||
};
|
||||
}
|
||||
return {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function terrainFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneTerrainBlock {
|
||||
return {
|
||||
className: "TerrainBlock",
|
||||
ghostIndex,
|
||||
terrFileName: (data.terrFileName as string) ?? "",
|
||||
detailTextureName: (data.detailTextureName as string) ?? "",
|
||||
squareSize: (data.squareSize as number) ?? 8,
|
||||
emptySquareRuns: data.emptySquareRuns as number[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function interiorFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneInteriorInstance {
|
||||
return {
|
||||
className: "InteriorInstance",
|
||||
ghostIndex,
|
||||
interiorFile: (data.interiorFile as string) ?? "",
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
showTerrainInside: (data.showTerrainInside as boolean) ?? false,
|
||||
skinBase: (data.skinBase as string) ?? "",
|
||||
alarmState: (data.alarmState as boolean) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function tsStaticFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneTSStatic {
|
||||
return {
|
||||
className: "TSStatic",
|
||||
ghostIndex,
|
||||
shapeName: (data.shapeName as string) ?? "",
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
|
||||
const fogVolumes = Array.isArray(data.fogVolumes)
|
||||
? (data.fogVolumes as Array<{
|
||||
visibleDistance?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
color?: Color3;
|
||||
}>).map((v) => ({
|
||||
visibleDistance: v.visibleDistance ?? 0,
|
||||
minHeight: v.minHeight ?? 0,
|
||||
maxHeight: v.maxHeight ?? 0,
|
||||
color: color3(v.color),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const cloudLayers = Array.isArray(data.cloudLayers)
|
||||
? (data.cloudLayers as Array<{
|
||||
texture?: string;
|
||||
heightPercent?: number;
|
||||
speed?: number;
|
||||
}>).map((c) => ({
|
||||
texture: c.texture ?? "",
|
||||
heightPercent: c.heightPercent ?? 0,
|
||||
speed: c.speed ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
className: "Sky",
|
||||
ghostIndex,
|
||||
materialList: (data.materialList as string) ?? "",
|
||||
fogColor: color3(data.fogColor),
|
||||
visibleDistance: (data.visibleDistance as number) ?? 1000,
|
||||
fogDistance: (data.fogDistance as number) ?? 0,
|
||||
skySolidColor: color3(data.skySolidColor),
|
||||
useSkyTextures: (data.useSkyTextures as boolean) ?? true,
|
||||
fogVolumes,
|
||||
cloudLayers,
|
||||
windVelocity: vec3(data.windVelocity),
|
||||
};
|
||||
}
|
||||
|
||||
export function sunFromGhost(ghostIndex: number, data: GhostData): SceneSun {
|
||||
return {
|
||||
className: "Sun",
|
||||
ghostIndex,
|
||||
direction: vec3(data.direction, { x: 0.57735, y: 0.57735, z: -0.57735 }),
|
||||
color: color4(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
|
||||
ambient: color4(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
|
||||
textures: Array.isArray(data.textures)
|
||||
? (data.textures as string[])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function missionAreaFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneMissionArea {
|
||||
const area = data.area as
|
||||
| { x: number; y: number; w: number; h: number }
|
||||
| undefined;
|
||||
return {
|
||||
className: "MissionArea",
|
||||
ghostIndex,
|
||||
area: area ?? { x: -512, y: -512, w: 1024, h: 1024 },
|
||||
flightCeiling: (data.flightCeiling as number) ?? 2000,
|
||||
flightCeilingRange: (data.flightCeilingRange as number) ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
export function waterBlockFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneWaterBlock {
|
||||
return {
|
||||
className: "WaterBlock",
|
||||
ghostIndex,
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
surfaceName: (data.surfaceName as string) ?? "",
|
||||
envMapName: (data.envMapName as string) ?? "",
|
||||
surfaceOpacity: (data.surfaceOpacity as number) ?? 0.75,
|
||||
waveMagnitude: (data.waveMagnitude as number) ?? 1.0,
|
||||
envMapIntensity: (data.envMapIntensity as number) ?? 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a ghost update to a typed scene object, or null if not a scene type. */
|
||||
export function ghostToSceneObject(
|
||||
className: string,
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
): SceneObject | null {
|
||||
switch (className) {
|
||||
case "TerrainBlock":
|
||||
return terrainFromGhost(ghostIndex, data);
|
||||
case "InteriorInstance":
|
||||
return interiorFromGhost(ghostIndex, data);
|
||||
case "TSStatic":
|
||||
return tsStaticFromGhost(ghostIndex, data);
|
||||
case "Sky":
|
||||
return skyFromGhost(ghostIndex, data);
|
||||
case "Sun":
|
||||
return sunFromGhost(ghostIndex, data);
|
||||
case "MissionArea":
|
||||
return missionAreaFromGhost(ghostIndex, data);
|
||||
case "WaterBlock":
|
||||
return waterBlockFromGhost(ghostIndex, data);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
25
src/scene/index.ts
Normal file
25
src/scene/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export type {
|
||||
Vec3,
|
||||
Color3,
|
||||
Color4,
|
||||
MatrixF,
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
SceneTSStatic,
|
||||
SceneSky,
|
||||
SceneSkyFogVolume,
|
||||
SceneSkyCloudLayer,
|
||||
SceneSun,
|
||||
SceneMissionArea,
|
||||
SceneWaterBlock,
|
||||
SceneObject,
|
||||
} from "./types";
|
||||
export { IDENTITY_MATRIX } from "./types";
|
||||
export { ghostToSceneObject } from "./ghostToScene";
|
||||
export { misToSceneObject } from "./misToScene";
|
||||
export {
|
||||
torqueToThree,
|
||||
torqueScaleToThree,
|
||||
matrixFToQuaternion,
|
||||
torqueAxisAngleToQuaternion,
|
||||
} from "./coordinates";
|
||||
248
src/scene/misToScene.spec.ts
Normal file
248
src/scene/misToScene.spec.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
terrainFromMis,
|
||||
interiorFromMis,
|
||||
skyFromMis,
|
||||
sunFromMis,
|
||||
missionAreaFromMis,
|
||||
waterBlockFromMis,
|
||||
misToSceneObject,
|
||||
} from "./misToScene";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
|
||||
function makeObj(
|
||||
className: string,
|
||||
props: Record<string, string>,
|
||||
id = 0,
|
||||
): TorqueObject {
|
||||
const obj: TorqueObject = {
|
||||
_class: className.toLowerCase(),
|
||||
_className: className,
|
||||
_name: "",
|
||||
_id: id,
|
||||
_children: [],
|
||||
};
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
obj[k.toLowerCase()] = v;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
describe("terrainFromMis", () => {
|
||||
it("extracts terrain properties", () => {
|
||||
const obj = makeObj("TerrainBlock", {
|
||||
terrainFile: "ice.ter",
|
||||
detailTexture: "details/detail1.png",
|
||||
squareSize: "8",
|
||||
});
|
||||
const result = terrainFromMis(obj);
|
||||
expect(result.className).toBe("TerrainBlock");
|
||||
expect(result.terrFileName).toBe("ice.ter");
|
||||
expect(result.detailTextureName).toBe("details/detail1.png");
|
||||
expect(result.squareSize).toBe(8);
|
||||
});
|
||||
|
||||
it("uses defaults for missing properties", () => {
|
||||
const result = terrainFromMis(makeObj("TerrainBlock", {}));
|
||||
expect(result.terrFileName).toBe("");
|
||||
expect(result.squareSize).toBe(8);
|
||||
});
|
||||
|
||||
it("parses emptySquares", () => {
|
||||
const obj = makeObj("TerrainBlock", {
|
||||
terrainFile: "ice.ter",
|
||||
emptySquares: "0 10 256 5",
|
||||
});
|
||||
const result = terrainFromMis(obj);
|
||||
expect(result.emptySquareRuns).toEqual([0, 10, 256, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interiorFromMis", () => {
|
||||
it("builds transform from position and rotation", () => {
|
||||
const obj = makeObj("InteriorInstance", {
|
||||
interiorFile: "building.dif",
|
||||
position: "100 200 300",
|
||||
rotation: "0 0 1 90",
|
||||
scale: "2 3 4",
|
||||
});
|
||||
const result = interiorFromMis(obj);
|
||||
expect(result.className).toBe("InteriorInstance");
|
||||
expect(result.interiorFile).toBe("building.dif");
|
||||
|
||||
// Position should be at elements[12..14]
|
||||
const e = result.transform.elements;
|
||||
expect(e[12]).toBeCloseTo(100);
|
||||
expect(e[13]).toBeCloseTo(200);
|
||||
expect(e[14]).toBeCloseTo(300);
|
||||
|
||||
// Position object should match
|
||||
expect(result.transform.position).toEqual({ x: 100, y: 200, z: 300 });
|
||||
|
||||
// Scale
|
||||
expect(result.scale).toEqual({ x: 2, y: 3, z: 4 });
|
||||
});
|
||||
|
||||
it("handles identity rotation (0 0 1 0)", () => {
|
||||
const obj = makeObj("InteriorInstance", {
|
||||
interiorFile: "test.dif",
|
||||
position: "10 20 30",
|
||||
rotation: "1 0 0 0",
|
||||
});
|
||||
const result = interiorFromMis(obj);
|
||||
const e = result.transform.elements;
|
||||
// Should be identity rotation
|
||||
expect(e[0]).toBeCloseTo(1);
|
||||
expect(e[5]).toBeCloseTo(1);
|
||||
expect(e[10]).toBeCloseTo(1);
|
||||
// Off-diagonals should be 0
|
||||
expect(e[1]).toBeCloseTo(0);
|
||||
expect(e[2]).toBeCloseTo(0);
|
||||
expect(e[4]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("90° rotation around Z produces correct rotation matrix", () => {
|
||||
const obj = makeObj("InteriorInstance", {
|
||||
interiorFile: "test.dif",
|
||||
position: "0 0 0",
|
||||
rotation: "0 0 1 90",
|
||||
});
|
||||
const result = interiorFromMis(obj);
|
||||
const e = result.transform.elements;
|
||||
|
||||
// Rotation around Z by 90°:
|
||||
// cos(90°) = 0, sin(90°) = 1
|
||||
// Row-major, idx = row + col*4:
|
||||
// [0]=cos [4]=-sin [8]=0
|
||||
// [1]=sin [5]=cos [9]=0
|
||||
// [2]=0 [6]=0 [10]=1
|
||||
expect(e[0]).toBeCloseTo(0, 4);
|
||||
expect(e[1]).toBeCloseTo(1, 4);
|
||||
expect(e[4]).toBeCloseTo(-1, 4);
|
||||
expect(e[5]).toBeCloseTo(0, 4);
|
||||
expect(e[10]).toBeCloseTo(1, 4);
|
||||
});
|
||||
|
||||
it("180° rotation is self-consistent", () => {
|
||||
const obj = makeObj("InteriorInstance", {
|
||||
interiorFile: "test.dif",
|
||||
position: "0 0 0",
|
||||
rotation: "0 0 1 180",
|
||||
});
|
||||
const result = interiorFromMis(obj);
|
||||
const e = result.transform.elements;
|
||||
// cos(180°) = -1, sin(180°) = 0
|
||||
expect(e[0]).toBeCloseTo(-1, 4);
|
||||
expect(e[5]).toBeCloseTo(-1, 4);
|
||||
expect(e[10]).toBeCloseTo(1, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skyFromMis", () => {
|
||||
it("parses fog volumes", () => {
|
||||
const obj = makeObj("Sky", {
|
||||
materialList: "sky_ice.dml",
|
||||
fogVolume1: "500 0 300",
|
||||
fogVolume2: "0 0 0",
|
||||
fogVolume3: "1000 100 500",
|
||||
visibleDistance: "2000",
|
||||
fogDistance: "500",
|
||||
});
|
||||
const result = skyFromMis(obj);
|
||||
expect(result.className).toBe("Sky");
|
||||
// fogVolume2 is all zeros, should be filtered out
|
||||
expect(result.fogVolumes).toHaveLength(2);
|
||||
expect(result.fogVolumes[0].visibleDistance).toBe(500);
|
||||
expect(result.fogVolumes[0].maxHeight).toBe(300);
|
||||
expect(result.fogVolumes[1].visibleDistance).toBe(1000);
|
||||
});
|
||||
|
||||
it("parses cloud layers", () => {
|
||||
const obj = makeObj("Sky", {
|
||||
cloudText1: "cloud1.png",
|
||||
cloudText2: "cloud2.png",
|
||||
cloudText3: "",
|
||||
"cloudheightper0": "0.35",
|
||||
"cloudheightper1": "0.25",
|
||||
cloudSpeed1: "0.001",
|
||||
});
|
||||
const result = skyFromMis(obj);
|
||||
expect(result.cloudLayers).toHaveLength(3);
|
||||
expect(result.cloudLayers[0].texture).toBe("cloud1.png");
|
||||
expect(result.cloudLayers[0].heightPercent).toBeCloseTo(0.35);
|
||||
expect(result.cloudLayers[0].speed).toBeCloseTo(0.001);
|
||||
});
|
||||
|
||||
it("parses fog and sky colors", () => {
|
||||
const obj = makeObj("Sky", {
|
||||
fogColor: "0.5 0.6 0.7",
|
||||
SkySolidColor: "0.1 0.2 0.3",
|
||||
});
|
||||
const result = skyFromMis(obj);
|
||||
expect(result.fogColor).toEqual({ r: 0.5, g: 0.6, b: 0.7 });
|
||||
expect(result.skySolidColor).toEqual({ r: 0.1, g: 0.2, b: 0.3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sunFromMis", () => {
|
||||
it("parses direction and colors", () => {
|
||||
const obj = makeObj("Sun", {
|
||||
direction: "0.57735 0.57735 -0.57735",
|
||||
color: "0.8 0.8 0.7 1.0",
|
||||
ambient: "0.3 0.3 0.4 1.0",
|
||||
});
|
||||
const result = sunFromMis(obj);
|
||||
expect(result.direction.x).toBeCloseTo(0.57735);
|
||||
expect(result.color).toEqual({ r: 0.8, g: 0.8, b: 0.7, a: 1.0 });
|
||||
expect(result.ambient).toEqual({ r: 0.3, g: 0.3, b: 0.4, a: 1.0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("missionAreaFromMis", () => {
|
||||
it("parses area string", () => {
|
||||
const obj = makeObj("MissionArea", {
|
||||
area: "-1024 -1024 2048 2048",
|
||||
flightCeiling: "5000",
|
||||
flightCeilingRange: "100",
|
||||
});
|
||||
const result = missionAreaFromMis(obj);
|
||||
expect(result.area).toEqual({ x: -1024, y: -1024, w: 2048, h: 2048 });
|
||||
expect(result.flightCeiling).toBe(5000);
|
||||
expect(result.flightCeilingRange).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waterBlockFromMis", () => {
|
||||
it("extracts surface and env map", () => {
|
||||
const obj = makeObj("WaterBlock", {
|
||||
position: "0 0 50",
|
||||
rotation: "1 0 0 0",
|
||||
scale: "512 512 10",
|
||||
surfaceTexture: "water.png",
|
||||
envMapTexture: "envmap.png",
|
||||
});
|
||||
const result = waterBlockFromMis(obj);
|
||||
expect(result.surfaceName).toBe("water.png");
|
||||
expect(result.envMapName).toBe("envmap.png");
|
||||
expect(result.scale).toEqual({ x: 512, y: 512, z: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("misToSceneObject", () => {
|
||||
it("dispatches to correct converter by className", () => {
|
||||
const terrain = misToSceneObject(
|
||||
makeObj("TerrainBlock", { terrainFile: "test.ter" }),
|
||||
);
|
||||
expect(terrain?.className).toBe("TerrainBlock");
|
||||
|
||||
const interior = misToSceneObject(
|
||||
makeObj("InteriorInstance", { interiorFile: "test.dif" }),
|
||||
);
|
||||
expect(interior?.className).toBe("InteriorInstance");
|
||||
});
|
||||
|
||||
it("returns null for unknown className", () => {
|
||||
expect(misToSceneObject(makeObj("Player", {}))).toBeNull();
|
||||
expect(misToSceneObject(makeObj("SimGroup", {}))).toBeNull();
|
||||
});
|
||||
});
|
||||
288
src/scene/misToScene.ts
Normal file
288
src/scene/misToScene.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* Convert .mis TorqueObject data (string properties) into typed scene objects.
|
||||
* This is the adapter layer that makes .mis data look like ghost parsedData.
|
||||
*/
|
||||
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import type {
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
SceneTSStatic,
|
||||
SceneSky,
|
||||
SceneSun,
|
||||
SceneMissionArea,
|
||||
SceneWaterBlock,
|
||||
SceneObject,
|
||||
MatrixF,
|
||||
Vec3,
|
||||
Color3,
|
||||
Color4,
|
||||
SceneSkyFogVolume,
|
||||
SceneSkyCloudLayer,
|
||||
} from "./types";
|
||||
|
||||
// ── String parsing helpers ──
|
||||
|
||||
function prop(obj: TorqueObject, name: string): string | undefined {
|
||||
return obj[name.toLowerCase()];
|
||||
}
|
||||
|
||||
function propFloat(obj: TorqueObject, name: string): number | undefined {
|
||||
const v = prop(obj, name);
|
||||
if (v == null) return undefined;
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function propInt(obj: TorqueObject, name: string): number | undefined {
|
||||
const v = prop(obj, name);
|
||||
if (v == null) return undefined;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseVec3(s: string | undefined, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
x: parts[0] ?? fallback.x,
|
||||
y: parts[1] ?? fallback.y,
|
||||
z: parts[2] ?? fallback.z,
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor3(s: string | undefined, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor4(
|
||||
s: string | undefined,
|
||||
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
||||
): Color4 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
a: parts[3] ?? fallback.a,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MatrixF from .mis position ("x y z") and rotation ("ax ay az angleDeg").
|
||||
* Torque stores rotation as axis-angle in degrees.
|
||||
*/
|
||||
function buildMatrixF(
|
||||
positionStr: string | undefined,
|
||||
rotationStr: string | undefined,
|
||||
): MatrixF {
|
||||
const pos = parseVec3(positionStr);
|
||||
const rotParts = (rotationStr ?? "1 0 0 0").split(" ").map(Number);
|
||||
const ax = rotParts[0] ?? 1;
|
||||
const ay = rotParts[1] ?? 0;
|
||||
const az = rotParts[2] ?? 0;
|
||||
const angleDeg = rotParts[3] ?? 0;
|
||||
const angleRad = angleDeg * (Math.PI / 180);
|
||||
|
||||
// Normalize axis
|
||||
const len = Math.sqrt(ax * ax + ay * ay + az * az);
|
||||
let nx = 0, ny = 0, nz = 1;
|
||||
if (len > 1e-8) {
|
||||
nx = ax / len;
|
||||
ny = ay / len;
|
||||
nz = az / len;
|
||||
}
|
||||
|
||||
// Axis-angle to rotation matrix (Rodrigues)
|
||||
const c = Math.cos(angleRad);
|
||||
const s = Math.sin(angleRad);
|
||||
const t = 1 - c;
|
||||
|
||||
// Row-major MatrixF: idx(row, col) = row + col * 4
|
||||
const elements = new Array<number>(16).fill(0);
|
||||
elements[0] = t * nx * nx + c;
|
||||
elements[1] = t * nx * ny + s * nz;
|
||||
elements[2] = t * nx * nz - s * ny;
|
||||
elements[4] = t * nx * ny - s * nz;
|
||||
elements[5] = t * ny * ny + c;
|
||||
elements[6] = t * ny * nz + s * nx;
|
||||
elements[8] = t * nx * nz + s * ny;
|
||||
elements[9] = t * ny * nz - s * nx;
|
||||
elements[10] = t * nz * nz + c;
|
||||
elements[12] = pos.x;
|
||||
elements[13] = pos.y;
|
||||
elements[14] = pos.z;
|
||||
elements[15] = 1;
|
||||
|
||||
return { elements, position: pos };
|
||||
}
|
||||
|
||||
function parseEmptySquares(s: string | undefined): number[] | undefined {
|
||||
if (!s) return undefined;
|
||||
const runs = s.split(/\s+/).map(Number).filter(Number.isFinite);
|
||||
return runs.length > 0 ? runs : undefined;
|
||||
}
|
||||
|
||||
function parseFogVolume(s: string | undefined): SceneSkyFogVolume | null {
|
||||
if (!s) return null;
|
||||
const parts = s.split(/\s+/).map(Number);
|
||||
const visDist = parts[0] ?? 0;
|
||||
const minH = parts[1] ?? 0;
|
||||
const maxH = parts[2] ?? 0;
|
||||
if (visDist === 0 && minH === 0 && maxH === 0) return null;
|
||||
return {
|
||||
visibleDistance: visDist,
|
||||
minHeight: minH,
|
||||
maxHeight: maxH,
|
||||
color: { r: 0.5, g: 0.5, b: 0.5 }, // fogVolumeColor is cosmetic only in T2
|
||||
};
|
||||
}
|
||||
|
||||
// ── Conversion functions ──
|
||||
|
||||
export function terrainFromMis(obj: TorqueObject): SceneTerrainBlock {
|
||||
return {
|
||||
className: "TerrainBlock",
|
||||
ghostIndex: obj._id,
|
||||
terrFileName: prop(obj, "terrainFile") ?? "",
|
||||
detailTextureName: prop(obj, "detailTexture") ?? "",
|
||||
squareSize: propInt(obj, "squareSize") ?? 8,
|
||||
emptySquareRuns: parseEmptySquares(prop(obj, "emptySquares")),
|
||||
};
|
||||
}
|
||||
|
||||
export function interiorFromMis(obj: TorqueObject): SceneInteriorInstance {
|
||||
return {
|
||||
className: "InteriorInstance",
|
||||
ghostIndex: obj._id,
|
||||
interiorFile: prop(obj, "interiorFile") ?? "",
|
||||
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
|
||||
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
|
||||
showTerrainInside: prop(obj, "showTerrainInside") === "1",
|
||||
skinBase: prop(obj, "skinBase") ?? "",
|
||||
alarmState: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function tsStaticFromMis(obj: TorqueObject): SceneTSStatic {
|
||||
return {
|
||||
className: "TSStatic",
|
||||
ghostIndex: obj._id,
|
||||
shapeName: prop(obj, "shapeName") ?? "",
|
||||
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
|
||||
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function skyFromMis(obj: TorqueObject): SceneSky {
|
||||
const fogVolumes: SceneSkyFogVolume[] = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const vol = parseFogVolume(prop(obj, `fogVolume${i}`));
|
||||
if (vol) fogVolumes.push(vol);
|
||||
}
|
||||
|
||||
const cloudLayers: SceneSkyCloudLayer[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const texture = prop(obj, `cloudText${i + 1}`) ?? "";
|
||||
const heightPercent = propFloat(obj, `cloudHeightPer[${i}]`) ?? propFloat(obj, `cloudheightper${i}`) ?? [0.35, 0.25, 0.2][i];
|
||||
const speed = propFloat(obj, `cloudSpeed${i + 1}`) ?? [0.0001, 0.0002, 0.0003][i];
|
||||
cloudLayers.push({ texture, heightPercent, speed });
|
||||
}
|
||||
|
||||
return {
|
||||
className: "Sky",
|
||||
ghostIndex: obj._id,
|
||||
materialList: prop(obj, "materialList") ?? "",
|
||||
fogColor: parseColor3(prop(obj, "fogColor")),
|
||||
visibleDistance: propFloat(obj, "visibleDistance") ?? 1000,
|
||||
fogDistance: propFloat(obj, "fogDistance") ?? 0,
|
||||
skySolidColor: parseColor3(prop(obj, "SkySolidColor")),
|
||||
useSkyTextures: (propInt(obj, "useSkyTextures") ?? 1) !== 0,
|
||||
fogVolumes,
|
||||
cloudLayers,
|
||||
windVelocity: parseVec3(prop(obj, "windVelocity")),
|
||||
};
|
||||
}
|
||||
|
||||
export function sunFromMis(obj: TorqueObject): SceneSun {
|
||||
return {
|
||||
className: "Sun",
|
||||
ghostIndex: obj._id,
|
||||
direction: parseVec3(prop(obj, "direction"), {
|
||||
x: 0.57735,
|
||||
y: 0.57735,
|
||||
z: -0.57735,
|
||||
}),
|
||||
color: parseColor4(prop(obj, "color"), { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
|
||||
ambient: parseColor4(prop(obj, "ambient"), {
|
||||
r: 0.5,
|
||||
g: 0.5,
|
||||
b: 0.5,
|
||||
a: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function missionAreaFromMis(obj: TorqueObject): SceneMissionArea {
|
||||
const areaStr = prop(obj, "area");
|
||||
let area = { x: -512, y: -512, w: 1024, h: 1024 };
|
||||
if (areaStr) {
|
||||
const parts = areaStr.split(/\s+/).map(Number);
|
||||
area = {
|
||||
x: parts[0] ?? area.x,
|
||||
y: parts[1] ?? area.y,
|
||||
w: parts[2] ?? area.w,
|
||||
h: parts[3] ?? area.h,
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: "MissionArea",
|
||||
ghostIndex: obj._id,
|
||||
area,
|
||||
flightCeiling: propFloat(obj, "flightCeiling") ?? 2000,
|
||||
flightCeilingRange: propFloat(obj, "flightCeilingRange") ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
export function waterBlockFromMis(obj: TorqueObject): SceneWaterBlock {
|
||||
return {
|
||||
className: "WaterBlock",
|
||||
ghostIndex: obj._id,
|
||||
transform: buildMatrixF(prop(obj, "position"), prop(obj, "rotation")),
|
||||
scale: parseVec3(prop(obj, "scale"), { x: 1, y: 1, z: 1 }),
|
||||
surfaceName: prop(obj, "surfaceTexture") ?? "",
|
||||
envMapName: prop(obj, "envMapTexture") ?? "",
|
||||
surfaceOpacity: propFloat(obj, "surfaceOpacity") ?? 0.75,
|
||||
waveMagnitude: propFloat(obj, "waveMagnitude") ?? 1.0,
|
||||
envMapIntensity: propFloat(obj, "envMapIntensity") ?? 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a .mis TorqueObject to a typed scene object based on className. */
|
||||
export function misToSceneObject(obj: TorqueObject): SceneObject | null {
|
||||
switch (obj._className) {
|
||||
case "TerrainBlock":
|
||||
return terrainFromMis(obj);
|
||||
case "InteriorInstance":
|
||||
return interiorFromMis(obj);
|
||||
case "TSStatic":
|
||||
return tsStaticFromMis(obj);
|
||||
case "Sky":
|
||||
return skyFromMis(obj);
|
||||
case "Sun":
|
||||
return sunFromMis(obj);
|
||||
case "MissionArea":
|
||||
return missionAreaFromMis(obj);
|
||||
case "WaterBlock":
|
||||
return waterBlockFromMis(obj);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
131
src/scene/types.ts
Normal file
131
src/scene/types.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/** 3D vector in Torque coordinate space (X-right, Y-forward, Z-up). */
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface Color3 {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface Color4 {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-major 4×4 transform matrix as used by Torque's MatrixF.
|
||||
* Index formula: idx(row, col) = row + col * 4.
|
||||
* Position is at elements[12], elements[13], elements[14].
|
||||
*/
|
||||
export interface MatrixF {
|
||||
elements: number[];
|
||||
position: Vec3;
|
||||
}
|
||||
|
||||
/** Identity MatrixF. */
|
||||
export const IDENTITY_MATRIX: MatrixF = {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
};
|
||||
|
||||
// ── Mission scene object types ──
|
||||
// These match the ghost parsedData structures from t2-demo-parser.
|
||||
|
||||
export interface SceneTerrainBlock {
|
||||
className: "TerrainBlock";
|
||||
ghostIndex: number;
|
||||
terrFileName: string;
|
||||
detailTextureName: string;
|
||||
squareSize: number;
|
||||
emptySquareRuns?: number[];
|
||||
}
|
||||
|
||||
export interface SceneInteriorInstance {
|
||||
className: "InteriorInstance";
|
||||
ghostIndex: number;
|
||||
interiorFile: string;
|
||||
transform: MatrixF;
|
||||
scale: Vec3;
|
||||
showTerrainInside: boolean;
|
||||
skinBase: string;
|
||||
alarmState: boolean;
|
||||
}
|
||||
|
||||
export interface SceneTSStatic {
|
||||
className: "TSStatic";
|
||||
ghostIndex: number;
|
||||
shapeName: string;
|
||||
transform: MatrixF;
|
||||
scale: Vec3;
|
||||
}
|
||||
|
||||
export interface SceneSkyFogVolume {
|
||||
visibleDistance: number;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
color: Color3;
|
||||
}
|
||||
|
||||
export interface SceneSkyCloudLayer {
|
||||
texture: string;
|
||||
heightPercent: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface SceneSky {
|
||||
className: "Sky";
|
||||
ghostIndex: number;
|
||||
materialList: string;
|
||||
fogColor: Color3;
|
||||
visibleDistance: number;
|
||||
fogDistance: number;
|
||||
skySolidColor: Color3;
|
||||
useSkyTextures: boolean;
|
||||
fogVolumes: SceneSkyFogVolume[];
|
||||
cloudLayers: SceneSkyCloudLayer[];
|
||||
windVelocity: Vec3;
|
||||
}
|
||||
|
||||
export interface SceneSun {
|
||||
className: "Sun";
|
||||
ghostIndex: number;
|
||||
direction: Vec3;
|
||||
color: Color4;
|
||||
ambient: Color4;
|
||||
textures?: string[];
|
||||
}
|
||||
|
||||
export interface SceneMissionArea {
|
||||
className: "MissionArea";
|
||||
ghostIndex: number;
|
||||
area: { x: number; y: number; w: number; h: number };
|
||||
flightCeiling: number;
|
||||
flightCeilingRange: number;
|
||||
}
|
||||
|
||||
export interface SceneWaterBlock {
|
||||
className: "WaterBlock";
|
||||
ghostIndex: number;
|
||||
transform: MatrixF;
|
||||
scale: Vec3;
|
||||
surfaceName: string;
|
||||
envMapName: string;
|
||||
surfaceOpacity: number;
|
||||
waveMagnitude: number;
|
||||
envMapIntensity: number;
|
||||
}
|
||||
|
||||
export type SceneObject =
|
||||
| SceneTerrainBlock
|
||||
| SceneInteriorInstance
|
||||
| SceneTSStatic
|
||||
| SceneSky
|
||||
| SceneSun
|
||||
| SceneMissionArea
|
||||
| SceneWaterBlock;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import type { DemoRecording, DemoStreamSnapshot } from "../demo/types";
|
||||
import type { StreamRecording, StreamSnapshot } from "../stream/types";
|
||||
import type {
|
||||
RuntimeMutationEvent,
|
||||
TorqueObject,
|
||||
|
|
@ -25,12 +25,12 @@ export interface RuntimeSliceState {
|
|||
}
|
||||
|
||||
export interface PlaybackSliceState {
|
||||
recording: DemoRecording | null;
|
||||
recording: StreamRecording | null;
|
||||
status: PlaybackStatus;
|
||||
timeMs: number;
|
||||
rate: number;
|
||||
durationMs: number;
|
||||
streamSnapshot: DemoStreamSnapshot | null;
|
||||
streamSnapshot: StreamSnapshot | null;
|
||||
}
|
||||
|
||||
export interface RuntimeTickInfo {
|
||||
|
|
@ -43,11 +43,11 @@ export interface EngineStoreState {
|
|||
setRuntime(runtime: TorqueRuntime): void;
|
||||
clearRuntime(): void;
|
||||
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo): void;
|
||||
setDemoRecording(recording: DemoRecording | null): void;
|
||||
setRecording(recording: StreamRecording | null): void;
|
||||
setPlaybackTime(ms: number): void;
|
||||
setPlaybackStatus(status: PlaybackStatus): void;
|
||||
setPlaybackRate(rate: number): void;
|
||||
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null): void;
|
||||
setPlaybackStreamSnapshot(snapshot: StreamSnapshot | null): void;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
|
|
@ -104,7 +104,7 @@ const initialState: Omit<
|
|||
| "setRuntime"
|
||||
| "clearRuntime"
|
||||
| "applyRuntimeBatch"
|
||||
| "setDemoRecording"
|
||||
| "setRecording"
|
||||
| "setPlaybackTime"
|
||||
| "setPlaybackStatus"
|
||||
| "setPlaybackRate"
|
||||
|
|
@ -243,7 +243,7 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
});
|
||||
},
|
||||
|
||||
setDemoRecording(recording: DemoRecording | null) {
|
||||
setRecording(recording: StreamRecording | null) {
|
||||
const durationMs = Math.max(0, (recording?.duration ?? 0) * 1000);
|
||||
set((state) => ({
|
||||
...state,
|
||||
|
|
@ -292,7 +292,7 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
}));
|
||||
},
|
||||
|
||||
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null) {
|
||||
setPlaybackStreamSnapshot(snapshot: StreamSnapshot | null) {
|
||||
set((state) => ({
|
||||
...state,
|
||||
playback: {
|
||||
|
|
@ -308,10 +308,10 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
// ── Rate-scaled effect clock ──
|
||||
//
|
||||
// A monotonic clock that advances by (frameDelta × playbackRate) each frame.
|
||||
// Components use demoEffectNow() instead of performance.now() so that effect
|
||||
// Components use effectNow() instead of performance.now() so that effect
|
||||
// timers (explosions, particles, shockwaves, animation threads) automatically
|
||||
// pause when the demo is paused and speed up / slow down with the playback
|
||||
// rate. The main DemoPlaybackStreaming component calls advanceEffectClock()
|
||||
// rate. The DemoPlaybackController component calls advanceEffectClock()
|
||||
// once per frame.
|
||||
|
||||
let _effectClockMs = 0;
|
||||
|
|
@ -321,13 +321,13 @@ let _effectClockMs = 0;
|
|||
* Analogous to performance.now() but only advances when playing,
|
||||
* scaled by the playback rate.
|
||||
*/
|
||||
export function demoEffectNow(): number {
|
||||
export function effectNow(): number {
|
||||
return _effectClockMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the effect clock. Called once per frame from
|
||||
* DemoPlaybackStreaming before other useFrame callbacks run.
|
||||
* DemoPlaybackController before other useFrame callbacks run.
|
||||
*/
|
||||
export function advanceEffectClock(deltaSec: number, rate: number): void {
|
||||
_effectClockMs += deltaSec * rate * 1000;
|
||||
|
|
|
|||
298
src/state/gameEntityStore.ts
Normal file
298
src/state/gameEntityStore.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import type { GameEntity, RenderType } from "./gameEntityTypes";
|
||||
|
||||
export interface GameEntityState {
|
||||
/**
|
||||
* Mission-authored entities (from .mis TorqueObject tree).
|
||||
* Persists while the mission is loaded. Overridden by streamEntities
|
||||
* when streaming is active.
|
||||
*/
|
||||
missionEntities: Map<string, GameEntity>;
|
||||
/**
|
||||
* Stream entities from demo playback or live server connection.
|
||||
* When non-empty, these are rendered instead of missionEntities.
|
||||
*/
|
||||
streamEntities: Map<string, GameEntity>;
|
||||
/** True when a demo/live source is actively driving entity state. */
|
||||
isStreaming: boolean;
|
||||
/** Monotonically increasing version counter, bumped on any mutation. */
|
||||
version: number;
|
||||
|
||||
// ── Mission entity mutations ──
|
||||
setEntity(entity: GameEntity): void;
|
||||
deleteEntity(id: string): void;
|
||||
setEntities(entities: GameEntity[]): void;
|
||||
setAllEntities(entities: GameEntity[]): void;
|
||||
clearEntities(): void;
|
||||
|
||||
// ── Stream entity mutations ──
|
||||
/** Begin streaming mode. Stream entities will be rendered instead of mission entities. */
|
||||
beginStreaming(): void;
|
||||
/** End streaming mode and clear stream entities. Mission entities become active again. */
|
||||
endStreaming(): void;
|
||||
setStreamEntity(entity: GameEntity): void;
|
||||
deleteStreamEntity(id: string): void;
|
||||
setStreamEntities(entities: GameEntity[]): void;
|
||||
setAllStreamEntities(entities: GameEntity[]): void;
|
||||
clearStreamEntities(): void;
|
||||
}
|
||||
|
||||
export const gameEntityStore = createStore<GameEntityState>()((set) => ({
|
||||
missionEntities: new Map(),
|
||||
streamEntities: new Map(),
|
||||
isStreaming: false,
|
||||
version: 0,
|
||||
|
||||
// ── Mission entity mutations ──
|
||||
|
||||
setEntity(entity: GameEntity) {
|
||||
set((state) => {
|
||||
const next = new Map(state.missionEntities);
|
||||
next.set(entity.id, entity);
|
||||
return { missionEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
deleteEntity(id: string) {
|
||||
set((state) => {
|
||||
if (!state.missionEntities.has(id)) return state;
|
||||
const next = new Map(state.missionEntities);
|
||||
next.delete(id);
|
||||
return { missionEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
setEntities(entities: GameEntity[]) {
|
||||
set((state) => {
|
||||
const next = new Map(state.missionEntities);
|
||||
for (const entity of entities) {
|
||||
next.set(entity.id, entity);
|
||||
}
|
||||
return { missionEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
setAllEntities(entities: GameEntity[]) {
|
||||
set(() => {
|
||||
const next = new Map<string, GameEntity>();
|
||||
for (const entity of entities) {
|
||||
next.set(entity.id, entity);
|
||||
}
|
||||
return { missionEntities: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearEntities() {
|
||||
set((state) => {
|
||||
if (state.missionEntities.size === 0) return state;
|
||||
return { missionEntities: new Map(), version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
// ── Stream entity mutations ──
|
||||
|
||||
beginStreaming() {
|
||||
set((state) => {
|
||||
if (state.isStreaming) return state;
|
||||
return { isStreaming: true, streamEntities: new Map(), version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
endStreaming() {
|
||||
set((state) => {
|
||||
if (!state.isStreaming) return state;
|
||||
return { isStreaming: false, streamEntities: new Map(), version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
setStreamEntity(entity: GameEntity) {
|
||||
set((state) => {
|
||||
const next = new Map(state.streamEntities);
|
||||
next.set(entity.id, entity);
|
||||
return { streamEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
deleteStreamEntity(id: string) {
|
||||
set((state) => {
|
||||
if (!state.streamEntities.has(id)) return state;
|
||||
const next = new Map(state.streamEntities);
|
||||
next.delete(id);
|
||||
return { streamEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
setStreamEntities(entities: GameEntity[]) {
|
||||
set((state) => {
|
||||
const next = new Map(state.streamEntities);
|
||||
for (const entity of entities) {
|
||||
next.set(entity.id, entity);
|
||||
}
|
||||
return { streamEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
setAllStreamEntities(entities: GameEntity[]) {
|
||||
set((state) => {
|
||||
const prev = state.streamEntities;
|
||||
const next = new Map<string, GameEntity>();
|
||||
for (const entity of entities) {
|
||||
next.set(entity.id, entity);
|
||||
}
|
||||
// Only update (and bump version) when the entity set changed
|
||||
// (adds/removes). Render-field-only updates (threads, colors, etc.)
|
||||
// are applied via mutateStreamEntities below instead. This prevents
|
||||
// frequent Zustand set() calls from starving React Suspense.
|
||||
if (next.size === prev.size && [...next.keys()].every((id) => prev.has(id))) {
|
||||
return state; // same set — no store update at all
|
||||
}
|
||||
return { streamEntities: next, version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
clearStreamEntities() {
|
||||
set((state) => {
|
||||
if (state.streamEntities.size === 0) return state;
|
||||
return { streamEntities: new Map(), version: state.version + 1 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Selectors ──
|
||||
|
||||
function selectActiveEntities(state: GameEntityState) {
|
||||
return state.isStreaming ? state.streamEntities : state.missionEntities;
|
||||
}
|
||||
|
||||
function selectVersion(state: GameEntityState) {
|
||||
return state.version;
|
||||
}
|
||||
|
||||
function useGameEntitiesInternal() {
|
||||
useStoreWithEqualityFn(gameEntityStore, selectVersion);
|
||||
return useStoreWithEqualityFn(gameEntityStore, selectActiveEntities);
|
||||
}
|
||||
|
||||
/** Hook that returns the active game entities. Re-renders on any entity change. */
|
||||
export function useGameEntities(): Map<string, GameEntity> {
|
||||
return useGameEntitiesInternal();
|
||||
}
|
||||
|
||||
// ── All-entity selector ──
|
||||
|
||||
function selectAllEntities(state: GameEntityState): GameEntity[] {
|
||||
const entities = state.isStreaming
|
||||
? state.streamEntities
|
||||
: state.missionEntities;
|
||||
const result: GameEntity[] = [];
|
||||
for (const entity of entities.values()) {
|
||||
if (entity.renderType !== "None") {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare entity sets by reference identity. Field-only mutations
|
||||
* (threads, colors, position) reuse the same object so this won't trigger.
|
||||
* Identity rebuilds (new datablock, shape change) create new objects,
|
||||
* which correctly triggers a re-render to pick up the new entity. */
|
||||
function entitySetEqual(a: GameEntity[], b: GameEntity[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns all renderable entities (renderType !== "None").
|
||||
* Re-renders when entities are added, removed, or identity-rebuilt
|
||||
* (new datablock/shape → new object reference). Does NOT re-render
|
||||
* for in-place field mutations (threads, colors, position).
|
||||
*/
|
||||
export function useAllGameEntities(): GameEntity[] {
|
||||
return useStoreWithEqualityFn(
|
||||
gameEntityStore,
|
||||
selectAllEntities,
|
||||
entitySetEqual,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook returning entities filtered by render type. */
|
||||
export function useGameEntitiesByRenderType(renderType: RenderType): GameEntity[] {
|
||||
const entities = useGameEntitiesInternal();
|
||||
const result: GameEntity[] = [];
|
||||
for (const entity of entities.values()) {
|
||||
if (entity.renderType === renderType) result.push(entity);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Hook returning entities filtered by class name. */
|
||||
export function useGameEntitiesByClass(className: string): GameEntity[] {
|
||||
const entities = useGameEntitiesInternal();
|
||||
const result: GameEntity[] = [];
|
||||
for (const entity of entities.values()) {
|
||||
if (entity.className === className) result.push(entity);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Hook returning a single entity by id, or undefined. */
|
||||
export function useGameEntity(id: string): GameEntity | undefined {
|
||||
const entities = useGameEntitiesInternal();
|
||||
return entities.get(id);
|
||||
}
|
||||
|
||||
// ── Scene infrastructure queries ──
|
||||
|
||||
import type {
|
||||
SceneSky,
|
||||
SceneSun,
|
||||
SceneMissionArea,
|
||||
} from "../scene/types";
|
||||
|
||||
// Scene infrastructure selectors use Object.is equality (default) on the
|
||||
// extracted data object — these are set once and referentially stable, so
|
||||
// the hooks won't re-render when unrelated (dynamic) entities update.
|
||||
|
||||
function selectSkyData(state: GameEntityState): SceneSky | null {
|
||||
const entities = state.isStreaming ? state.streamEntities : state.missionEntities;
|
||||
for (const e of entities.values()) {
|
||||
if (e.renderType === "Sky") return e.skyData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectSunData(state: GameEntityState): SceneSun | null {
|
||||
const entities = state.isStreaming ? state.streamEntities : state.missionEntities;
|
||||
for (const e of entities.values()) {
|
||||
if (e.renderType === "Sun") return e.sunData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectMissionAreaData(state: GameEntityState): SceneMissionArea | null {
|
||||
const entities = state.isStreaming ? state.streamEntities : state.missionEntities;
|
||||
for (const e of entities.values()) {
|
||||
if (e.renderType === "MissionArea") return e.missionAreaData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Hook returning the Sky data, or null if no sky entity exists. */
|
||||
export function useSceneSky(): SceneSky | null {
|
||||
return useStoreWithEqualityFn(gameEntityStore, selectSkyData);
|
||||
}
|
||||
|
||||
/** Hook returning the Sun data, or null if no sun entity exists. */
|
||||
export function useSceneSun(): SceneSun | null {
|
||||
return useStoreWithEqualityFn(gameEntityStore, selectSunData);
|
||||
}
|
||||
|
||||
/** Hook returning the MissionArea data, or null if none exists. */
|
||||
export function useSceneMissionArea(): SceneMissionArea | null {
|
||||
return useStoreWithEqualityFn(gameEntityStore, selectMissionAreaData);
|
||||
}
|
||||
241
src/state/gameEntityTypes.ts
Normal file
241
src/state/gameEntityTypes.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import type {
|
||||
Keyframe,
|
||||
ThreadState,
|
||||
TracerVisual,
|
||||
SpriteVisual,
|
||||
WeaponImageState,
|
||||
WeaponImageDataBlockState,
|
||||
} from "../stream/types";
|
||||
import type {
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
SceneSky,
|
||||
SceneSun,
|
||||
SceneWaterBlock,
|
||||
SceneMissionArea,
|
||||
} from "../scene/types";
|
||||
|
||||
/**
|
||||
* Determines which renderer handles this entity. Uses engine class names
|
||||
* where there's a 1:1 mapping; functional names for cross-class renderers.
|
||||
*/
|
||||
export type RenderType =
|
||||
// Scene infrastructure (handle own positioning)
|
||||
| "TerrainBlock"
|
||||
| "InteriorInstance"
|
||||
| "Sky"
|
||||
| "Sun"
|
||||
| "WaterBlock"
|
||||
| "MissionArea"
|
||||
// Gameplay entities (positioned by interpolation loop)
|
||||
| "Shape"
|
||||
| "Player"
|
||||
| "ForceFieldBare"
|
||||
| "Explosion"
|
||||
| "Tracer"
|
||||
| "Sprite"
|
||||
| "AudioEmitter"
|
||||
| "Camera"
|
||||
| "WayPoint"
|
||||
| "None";
|
||||
|
||||
// ── Common base ──
|
||||
|
||||
interface EntityBase {
|
||||
id: string;
|
||||
className: string;
|
||||
renderType: RenderType;
|
||||
ghostIndex?: number;
|
||||
dataBlockId?: number;
|
||||
shapeHint?: string;
|
||||
spawnTime?: number;
|
||||
runtimeObject?: unknown;
|
||||
missionTypesList?: string;
|
||||
}
|
||||
|
||||
// ── Scene infrastructure entities ──
|
||||
|
||||
export interface TerrainBlockEntity extends EntityBase {
|
||||
renderType: "TerrainBlock";
|
||||
terrainData: SceneTerrainBlock;
|
||||
}
|
||||
|
||||
export interface InteriorInstanceEntity extends EntityBase {
|
||||
renderType: "InteriorInstance";
|
||||
interiorData: SceneInteriorInstance;
|
||||
}
|
||||
|
||||
export interface SkyEntity extends EntityBase {
|
||||
renderType: "Sky";
|
||||
skyData: SceneSky;
|
||||
}
|
||||
|
||||
export interface SunEntity extends EntityBase {
|
||||
renderType: "Sun";
|
||||
sunData: SceneSun;
|
||||
}
|
||||
|
||||
export interface WaterBlockEntity extends EntityBase {
|
||||
renderType: "WaterBlock";
|
||||
waterData: SceneWaterBlock;
|
||||
}
|
||||
|
||||
export interface MissionAreaEntity extends EntityBase {
|
||||
renderType: "MissionArea";
|
||||
missionAreaData: SceneMissionArea;
|
||||
}
|
||||
|
||||
export type SceneEntity =
|
||||
| TerrainBlockEntity
|
||||
| InteriorInstanceEntity
|
||||
| SkyEntity
|
||||
| SunEntity
|
||||
| WaterBlockEntity
|
||||
| MissionAreaEntity;
|
||||
|
||||
export function isSceneEntity(entity: GameEntity): entity is SceneEntity {
|
||||
switch (entity.renderType) {
|
||||
case "TerrainBlock":
|
||||
case "InteriorInstance":
|
||||
case "Sky":
|
||||
case "Sun":
|
||||
case "WaterBlock":
|
||||
case "MissionArea":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Positioned entity base ──
|
||||
|
||||
interface PositionedBase extends EntityBase {
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
velocity?: [number, number, number];
|
||||
keyframes?: Keyframe[];
|
||||
}
|
||||
|
||||
// ── Gameplay entities ──
|
||||
|
||||
/** Generic DTS-rendered entity (StaticShape, Turret, Item, TSStatic, etc.). */
|
||||
export interface ShapeEntity extends PositionedBase {
|
||||
renderType: "Shape";
|
||||
shapeName?: string;
|
||||
shapeType?: string;
|
||||
dataBlock?: string;
|
||||
threads?: ThreadState[];
|
||||
rotate?: boolean;
|
||||
teamId?: number;
|
||||
barrelShapeName?: string;
|
||||
targetRenderFlags?: number;
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
weaponShape?: string;
|
||||
}
|
||||
|
||||
export interface PlayerEntity extends PositionedBase {
|
||||
renderType: "Player";
|
||||
shapeName?: string;
|
||||
dataBlock?: string;
|
||||
weaponShape?: string;
|
||||
playerName?: string;
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
threads?: ThreadState[];
|
||||
weaponImageState?: WeaponImageState;
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
headPitch?: number;
|
||||
headYaw?: number;
|
||||
health?: number;
|
||||
energy?: number;
|
||||
actionAnim?: number;
|
||||
actionAtEnd?: boolean;
|
||||
damageState?: number;
|
||||
targetRenderFlags?: number;
|
||||
}
|
||||
|
||||
export interface ForceFieldBareEntity extends PositionedBase {
|
||||
renderType: "ForceFieldBare";
|
||||
forceFieldData?: ForceFieldData;
|
||||
}
|
||||
|
||||
export interface ExplosionEntity extends PositionedBase {
|
||||
renderType: "Explosion";
|
||||
shapeName?: string;
|
||||
dataBlock?: string;
|
||||
explosionDataBlockId?: number;
|
||||
faceViewer?: boolean;
|
||||
}
|
||||
|
||||
export interface TracerEntity extends PositionedBase {
|
||||
renderType: "Tracer";
|
||||
visual: TracerVisual;
|
||||
dataBlock?: string;
|
||||
direction?: [number, number, number];
|
||||
}
|
||||
|
||||
export interface SpriteEntity extends PositionedBase {
|
||||
renderType: "Sprite";
|
||||
visual: SpriteVisual;
|
||||
}
|
||||
|
||||
export interface AudioEmitterEntity extends PositionedBase {
|
||||
renderType: "AudioEmitter";
|
||||
audioFileName?: string;
|
||||
audioVolume?: number;
|
||||
audioIs3D?: boolean;
|
||||
audioIsLooping?: boolean;
|
||||
audioMinDistance?: number;
|
||||
audioMaxDistance?: number;
|
||||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
}
|
||||
|
||||
export interface CameraEntity extends PositionedBase {
|
||||
renderType: "Camera";
|
||||
cameraDataBlock?: string;
|
||||
}
|
||||
|
||||
export interface WayPointEntity extends PositionedBase {
|
||||
renderType: "WayPoint";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface NoneEntity extends EntityBase {
|
||||
renderType: "None";
|
||||
}
|
||||
|
||||
export type PositionedEntity = Exclude<GameEntity, SceneEntity | NoneEntity>;
|
||||
|
||||
// ── Union type ──
|
||||
|
||||
export type GameEntity =
|
||||
| TerrainBlockEntity
|
||||
| InteriorInstanceEntity
|
||||
| SkyEntity
|
||||
| SunEntity
|
||||
| WaterBlockEntity
|
||||
| MissionAreaEntity
|
||||
| ShapeEntity
|
||||
| PlayerEntity
|
||||
| ForceFieldBareEntity
|
||||
| ExplosionEntity
|
||||
| TracerEntity
|
||||
| SpriteEntity
|
||||
| AudioEmitterEntity
|
||||
| CameraEntity
|
||||
| WayPointEntity
|
||||
| NoneEntity;
|
||||
|
||||
export interface ForceFieldData {
|
||||
textures: string[];
|
||||
color: [number, number, number];
|
||||
baseTranslucency: number;
|
||||
numFrames: number;
|
||||
framesPerSec: number;
|
||||
scrollSpeed: number;
|
||||
umapping: number;
|
||||
vmapping: number;
|
||||
/** Box dimensions in Three.js space [x, y, z]. NOT a transform scale. */
|
||||
dimensions: [number, number, number];
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ export type {
|
|||
|
||||
export {
|
||||
engineStore,
|
||||
demoEffectNow,
|
||||
effectNow,
|
||||
advanceEffectClock,
|
||||
resetEffectClock,
|
||||
useEngineSelector,
|
||||
|
|
@ -20,3 +20,43 @@ export {
|
|||
useDatablockByName,
|
||||
useRuntimeChildIds,
|
||||
} from "./engineStore";
|
||||
|
||||
export type {
|
||||
GameEntity,
|
||||
PositionedEntity,
|
||||
SceneEntity,
|
||||
RenderType,
|
||||
ForceFieldData,
|
||||
ShapeEntity,
|
||||
PlayerEntity,
|
||||
ForceFieldBareEntity,
|
||||
ExplosionEntity,
|
||||
TracerEntity,
|
||||
SpriteEntity,
|
||||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
NoneEntity,
|
||||
TerrainBlockEntity,
|
||||
InteriorInstanceEntity,
|
||||
SkyEntity,
|
||||
SunEntity,
|
||||
WaterBlockEntity,
|
||||
MissionAreaEntity,
|
||||
} from "./gameEntityTypes";
|
||||
|
||||
export { isSceneEntity } from "./gameEntityTypes";
|
||||
|
||||
export type { GameEntityState } from "./gameEntityStore";
|
||||
|
||||
export {
|
||||
gameEntityStore,
|
||||
useGameEntities,
|
||||
useAllGameEntities,
|
||||
useGameEntitiesByRenderType,
|
||||
useGameEntitiesByClass,
|
||||
useGameEntity,
|
||||
useSceneSky,
|
||||
useSceneSun,
|
||||
useSceneMissionArea,
|
||||
} from "./gameEntityStore";
|
||||
|
|
|
|||
44
src/state/streamPlaybackStore.ts
Normal file
44
src/state/streamPlaybackStore.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import type { Group } from "three";
|
||||
import type { StreamingPlayback } from "../stream/types";
|
||||
import type { GameEntity } from "./gameEntityTypes";
|
||||
|
||||
/**
|
||||
* Store for mutable streaming playback state that needs to be shared between
|
||||
* the playback controller (writer) and entity rendering components (readers).
|
||||
*
|
||||
* These values are updated every frame and read imperatively in useFrame
|
||||
* callbacks — they intentionally bypass React's render cycle. Use
|
||||
* `streamPlaybackStore.getState()` to read current values.
|
||||
*/
|
||||
export interface StreamPlaybackState {
|
||||
/** Current playback time in seconds, updated every frame. */
|
||||
time: number;
|
||||
/** The active streaming playback source (demo or live). */
|
||||
playback: StreamingPlayback | null;
|
||||
/** The Three.js group node containing all entity children. */
|
||||
root: Group | null;
|
||||
/**
|
||||
* When true, ObserverControls drives the camera instead of the stream.
|
||||
* Toggled by 'O' key during live observation.
|
||||
*/
|
||||
freeFlyCamera: boolean;
|
||||
/** Live entity map, updated every frame. Components read from this in
|
||||
* useFrame to get the latest render fields (threads, weapons, etc.)
|
||||
* without triggering React re-renders. */
|
||||
entities: Map<string, GameEntity>;
|
||||
}
|
||||
|
||||
export const streamPlaybackStore = createStore<StreamPlaybackState>()(() => ({
|
||||
time: 0,
|
||||
playback: null,
|
||||
root: null,
|
||||
freeFlyCamera: false,
|
||||
entities: new Map(),
|
||||
}));
|
||||
|
||||
/** Reset all streaming playback state. Called when streaming ends. */
|
||||
export function resetStreamPlayback(): void {
|
||||
streamPlaybackStore.setState({ time: 0, playback: null, freeFlyCamera: false });
|
||||
// root is managed by the React ref callback in EntityScene — don't clear it
|
||||
}
|
||||
1868
src/stream/StreamEngine.ts
Normal file
1868
src/stream/StreamEngine.ts
Normal file
File diff suppressed because it is too large
Load diff
948
src/stream/demoStreaming.ts
Normal file
948
src/stream/demoStreaming.ts
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
import {
|
||||
BlockTypeInfo,
|
||||
BlockTypeMove,
|
||||
BlockTypePacket,
|
||||
DemoParser,
|
||||
} from "t2-demo-parser";
|
||||
import { ghostToSceneObject } from "../scene";
|
||||
import {
|
||||
toEntityType,
|
||||
toEntityId,
|
||||
TICK_DURATION_MS,
|
||||
} from "./entityClassification";
|
||||
import {
|
||||
clamp,
|
||||
MAX_PITCH,
|
||||
isValidPosition,
|
||||
stripTaggedStringMarkup,
|
||||
detectControlObjectType,
|
||||
parseColorSegments,
|
||||
backpackBitmapToIndex,
|
||||
} from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
import type {
|
||||
StreamRecording,
|
||||
StreamSnapshot,
|
||||
TeamScore,
|
||||
WeaponsHudSlot,
|
||||
InventoryHudSlot,
|
||||
BackpackHudState,
|
||||
} from "./types";
|
||||
import { StreamEngine, type MutableEntity } from "./StreamEngine";
|
||||
|
||||
function extractMissionInfo(demoValues: string[]): {
|
||||
missionName: string | null;
|
||||
gameType: string | null;
|
||||
} {
|
||||
let missionName: string | null = null;
|
||||
let gameType: string | null = null;
|
||||
|
||||
for (let i = 0; i < demoValues.length; i++) {
|
||||
if (demoValues[i] !== "readplayerinfo") continue;
|
||||
const value = demoValues[i + 1];
|
||||
if (!value) continue;
|
||||
|
||||
if (value.startsWith("2\t")) {
|
||||
const fields = value.split("\t");
|
||||
if (fields[4]) {
|
||||
missionName = fields[4];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("3\t")) {
|
||||
const fields = value.split("\t");
|
||||
if (fields[2]) {
|
||||
gameType = fields[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { missionName, gameType };
|
||||
}
|
||||
|
||||
interface ParsedDemoValues {
|
||||
weaponsHud: { slots: Map<number, number>; activeIndex: number } | null;
|
||||
backpackHud: { packIndex: number; active: boolean; text: string } | null;
|
||||
inventoryHud: {
|
||||
slots: Map<number, number>;
|
||||
activeSlot: number;
|
||||
} | null;
|
||||
teamScores: TeamScore[];
|
||||
playerRoster: Map<number, { name: string; teamId: number }>;
|
||||
chatMessages: string[];
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the $DemoValue[] array to extract initial HUD state.
|
||||
*
|
||||
* Sections are written sequentially by saveDemoSettings/getState in
|
||||
* recordings.cs: MISC, PLAYERLIST, RETICLE, BACKPACK, WEAPON, INVENTORY,
|
||||
* SCORE, CLOCK, CHAT, GRAVITY.
|
||||
*/
|
||||
function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
||||
const result: ParsedDemoValues = {
|
||||
weaponsHud: null,
|
||||
backpackHud: null,
|
||||
inventoryHud: null,
|
||||
teamScores: [],
|
||||
playerRoster: new Map(),
|
||||
chatMessages: [],
|
||||
gravity: -20,
|
||||
};
|
||||
if (!demoValues.length) return result;
|
||||
|
||||
let idx = 0;
|
||||
const next = () => {
|
||||
const v = demoValues[idx++];
|
||||
return v === "<BLANK>" ? "" : (v ?? "");
|
||||
};
|
||||
|
||||
// MISC: 1 value
|
||||
next();
|
||||
|
||||
// PLAYERLIST: count + count entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const playerCount = parseInt(next(), 10) || 0;
|
||||
const playerCountByTeam = new Map<number, number>();
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const name = fields[0] ?? "";
|
||||
const clientId = parseInt(fields[2], 10);
|
||||
const teamId = parseInt(fields[4], 10);
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
result.playerRoster.set(clientId, { name, teamId });
|
||||
}
|
||||
if (!isNaN(teamId) && teamId > 0) {
|
||||
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// RETICLE: 1 value
|
||||
if (idx >= demoValues.length) return result;
|
||||
next();
|
||||
|
||||
// BACKPACK: 1 value (bitmap TAB visible TAB text TAB textVisible TAB pack)
|
||||
if (idx >= demoValues.length) return result;
|
||||
{
|
||||
const backpackVal = next();
|
||||
const fields = backpackVal.split("\t");
|
||||
const bitmap = fields[0] ?? "";
|
||||
const visible = fields[1] === "1" || fields[1] === "true";
|
||||
const text = fields[2] ?? "";
|
||||
const pack = fields[4] === "1" || fields[4] === "true";
|
||||
if (visible && bitmap) {
|
||||
const packIndex = backpackBitmapToIndex(bitmap);
|
||||
result.backpackHud = { packIndex, active: pack, text };
|
||||
}
|
||||
}
|
||||
|
||||
// WEAPON: header + count bitmap entries + slotCount slot entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const weaponHeader = next().split("\t");
|
||||
const weaponCount = parseInt(weaponHeader[4], 10) || 0;
|
||||
const weaponSlotCount = parseInt(weaponHeader[5], 10) || 0;
|
||||
const weaponActive = parseInt(weaponHeader[6], 10);
|
||||
|
||||
for (let i = 0; i < weaponCount; i++) next();
|
||||
|
||||
const slots = new Map<number, number>();
|
||||
for (let i = 0; i < weaponSlotCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const slotId = parseInt(fields[0], 10);
|
||||
const ammo = parseInt(fields[1], 10);
|
||||
if (!isNaN(slotId)) {
|
||||
slots.set(slotId, isNaN(ammo) ? -1 : ammo);
|
||||
}
|
||||
}
|
||||
result.weaponsHud = {
|
||||
slots,
|
||||
activeIndex: isNaN(weaponActive) ? -1 : weaponActive,
|
||||
};
|
||||
|
||||
// INVENTORY: header + count bitmap entries + slotCount slot entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const invHeader = next().split("\t");
|
||||
const invCount = parseInt(invHeader[4], 10) || 0;
|
||||
const invSlotCount = parseInt(invHeader[5], 10) || 0;
|
||||
const invActive = parseInt(invHeader[6], 10);
|
||||
for (let i = 0; i < invCount; i++) next();
|
||||
{
|
||||
const invSlots = new Map<number, number>();
|
||||
for (let i = 0; i < invSlotCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const slotId = parseInt(fields[0], 10);
|
||||
const count = parseInt(fields[1], 10);
|
||||
if (!isNaN(slotId) && !isNaN(count) && count > 0) {
|
||||
invSlots.set(slotId, count);
|
||||
}
|
||||
}
|
||||
if (invSlots.size > 0) {
|
||||
result.inventoryHud = {
|
||||
slots: invSlots,
|
||||
activeSlot: isNaN(invActive) ? -1 : invActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// SCORE: header (visible TAB gameType TAB objCount) + objCount entries.
|
||||
if (idx >= demoValues.length) return result;
|
||||
const scoreHeader = next().split("\t");
|
||||
const gameType = scoreHeader[1] ?? "";
|
||||
const objCount = parseInt(scoreHeader[2], 10) || 0;
|
||||
const scoreObjs: string[] = [];
|
||||
for (let i = 0; i < objCount; i++) scoreObjs.push(next());
|
||||
|
||||
if (gameType === "CTFGame" && objCount >= 8) {
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const base = t * 4;
|
||||
const teamId = t + 1;
|
||||
result.teamScores.push({
|
||||
teamId,
|
||||
name: scoreObjs[base] ?? "",
|
||||
score: parseInt(scoreObjs[base + 1], 10) || 0,
|
||||
playerCount: playerCountByTeam.get(teamId) ?? 0,
|
||||
});
|
||||
}
|
||||
} else if (gameType === "TR2Game" && objCount >= 4) {
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const base = t * 2;
|
||||
const teamId = t + 1;
|
||||
result.teamScores.push({
|
||||
teamId,
|
||||
name: scoreObjs[base + 1] ?? "",
|
||||
score: parseInt(scoreObjs[base], 10) || 0,
|
||||
playerCount: playerCountByTeam.get(teamId) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CLOCK: 1 value
|
||||
if (idx >= demoValues.length) return result;
|
||||
next();
|
||||
|
||||
// CHAT: always 10 entries
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (idx >= demoValues.length) break;
|
||||
const line = next();
|
||||
if (line) {
|
||||
result.chatMessages.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// GRAVITY: 1 value
|
||||
if (idx < demoValues.length) {
|
||||
const g = parseFloat(next());
|
||||
if (Number.isFinite(g)) {
|
||||
result.gravity = g;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class StreamingPlayback extends StreamEngine {
|
||||
private readonly parser: DemoParser;
|
||||
private readonly initialBlock: {
|
||||
dataBlocks: Map<
|
||||
number,
|
||||
{ className: string; data: Record<string, unknown> }
|
||||
>;
|
||||
initialGhosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
controlObjectGhostIndex: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
targetEntries: Array<{
|
||||
targetId: number;
|
||||
name?: string;
|
||||
sensorGroup: number;
|
||||
targetData: number;
|
||||
}>;
|
||||
sensorGroupColors: Array<{
|
||||
group: number;
|
||||
targetGroup: number;
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}>;
|
||||
taggedStrings: Map<number, string>;
|
||||
initialEvents: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
demoValues: string[];
|
||||
};
|
||||
// Demo-specific: move delta tracking for V12-style camera rotation
|
||||
private moveTicks = 0;
|
||||
private absoluteYaw = 0;
|
||||
private absolutePitch = 0;
|
||||
private lastAbsYaw = 0;
|
||||
private lastAbsPitch = 0;
|
||||
private exhausted = false;
|
||||
|
||||
// Generation counters for derived-array caching in buildSnapshot().
|
||||
private _teamScoresGen = 0;
|
||||
private _rosterGen = 0;
|
||||
private _weaponsHudGen = 0;
|
||||
private _inventoryHudGen = 0;
|
||||
|
||||
// Cached snapshot
|
||||
private _cachedSnapshot: StreamSnapshot | null = null;
|
||||
private _cachedSnapshotTick = -1;
|
||||
|
||||
// Cached derived arrays
|
||||
private _snap: {
|
||||
teamScoresGen: number;
|
||||
rosterGen: number;
|
||||
teamScores: TeamScore[];
|
||||
weaponsHudGen: number;
|
||||
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
|
||||
inventoryHudGen: number;
|
||||
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
|
||||
backpackPackIndex: number;
|
||||
backpackActive: boolean;
|
||||
backpackText: string;
|
||||
backpackHud: BackpackHudState | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(parser: DemoParser) {
|
||||
super();
|
||||
this.parser = parser;
|
||||
this.registry = parser.getRegistry();
|
||||
this.ghostTracker = parser.getGhostTracker();
|
||||
const initial = parser.initialBlock;
|
||||
this.initialBlock = {
|
||||
dataBlocks: initial.dataBlocks,
|
||||
initialGhosts: initial.initialGhosts,
|
||||
controlObjectGhostIndex: initial.controlObjectGhostIndex,
|
||||
controlObjectData: initial.controlObjectData,
|
||||
targetEntries: initial.targetEntries,
|
||||
sensorGroupColors: initial.sensorGroupColors,
|
||||
taggedStrings: initial.taggedStrings,
|
||||
initialEvents: initial.initialEvents,
|
||||
demoValues: initial.demoValues,
|
||||
};
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(dataBlockId: number): Record<string, unknown> | undefined {
|
||||
const initialBlock = this.initialBlock.dataBlocks.get(dataBlockId);
|
||||
if (initialBlock?.data) {
|
||||
return initialBlock.data;
|
||||
}
|
||||
const packetParser = this.parser.getPacketParser() as unknown as {
|
||||
dataBlockDataMap?: Map<number, Record<string, unknown>>;
|
||||
};
|
||||
return packetParser.dataBlockDataMap?.get(dataBlockId);
|
||||
}
|
||||
|
||||
private _shapeConstructorCache: Map<string, string[]> | null = null;
|
||||
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined {
|
||||
if (!this._shapeConstructorCache) {
|
||||
this._shapeConstructorCache = new Map();
|
||||
for (const [, db] of this.initialBlock.dataBlocks) {
|
||||
if (db.className !== "TSShapeConstructor" || !db.data) continue;
|
||||
const shape = db.data.shape as string | undefined;
|
||||
const seqs = db.data.sequences as string[] | undefined;
|
||||
if (shape && seqs) {
|
||||
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._shapeConstructorCache.get(shapeName.toLowerCase());
|
||||
}
|
||||
|
||||
protected getTimeSec(): number {
|
||||
return this.moveTicks * (TICK_DURATION_MS / 1000);
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(
|
||||
_data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number } {
|
||||
const hasMoves = !this.isPiloting && this.lastControlType === "player";
|
||||
const yaw = hasMoves ? this.absoluteYaw : this.lastAbsYaw;
|
||||
const pitch = hasMoves ? this.absolutePitch : this.lastAbsPitch;
|
||||
|
||||
if (hasMoves) {
|
||||
this.lastAbsYaw = yaw;
|
||||
this.lastAbsPitch = pitch;
|
||||
}
|
||||
|
||||
return { yaw, pitch };
|
||||
}
|
||||
|
||||
protected getControlPlayerHeadPitch(_pitch: number): number {
|
||||
return clamp(this.absolutePitch / MAX_PITCH, -1, 1);
|
||||
}
|
||||
|
||||
// ── Generation counter hooks ──
|
||||
|
||||
protected onTeamScoresChanged(): void {
|
||||
this._teamScoresGen++;
|
||||
}
|
||||
|
||||
protected onRosterChanged(): void {
|
||||
this._rosterGen++;
|
||||
}
|
||||
|
||||
protected onWeaponsHudChanged(): void {
|
||||
this._weaponsHudGen++;
|
||||
}
|
||||
|
||||
protected onInventoryHudChanged(): void {
|
||||
this._inventoryHudGen++;
|
||||
}
|
||||
|
||||
// ── StreamingPlayback interface ──
|
||||
|
||||
reset(): void {
|
||||
this.parser.reset();
|
||||
// parser.reset() creates a fresh GhostTracker internally — refresh our
|
||||
// reference so resolveGhostClassName doesn't use the stale one.
|
||||
this.ghostTracker = this.parser.getGhostTracker();
|
||||
this._cachedSnapshot = null;
|
||||
this._cachedSnapshotTick = -1;
|
||||
this._snap = null;
|
||||
|
||||
this.resetSharedState();
|
||||
|
||||
// Seed net strings from initial block
|
||||
for (const [id, value] of this.initialBlock.taggedStrings) {
|
||||
this.netStrings.set(id, value);
|
||||
}
|
||||
for (const entry of this.initialBlock.targetEntries) {
|
||||
if (entry.name) {
|
||||
this.targetNames.set(
|
||||
entry.targetId,
|
||||
stripTaggedStringMarkup(entry.name),
|
||||
);
|
||||
}
|
||||
this.targetTeams.set(entry.targetId, entry.sensorGroup);
|
||||
this.targetRenderFlags.set(entry.targetId, entry.targetData);
|
||||
}
|
||||
// Seed IFF color table from the initial block.
|
||||
for (const c of this.initialBlock.sensorGroupColors) {
|
||||
let map = this.sensorGroupColors.get(c.group);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this.sensorGroupColors.set(c.group, map);
|
||||
}
|
||||
map.set(c.targetGroup, { r: c.r, g: c.g, b: c.b });
|
||||
}
|
||||
|
||||
// Demo-specific state
|
||||
this.moveTicks = 0;
|
||||
this.absoluteYaw = 0;
|
||||
this.absolutePitch = 0;
|
||||
this.lastAbsYaw = 0;
|
||||
this.lastAbsPitch = 0;
|
||||
this.lastControlType =
|
||||
detectControlObjectType(this.initialBlock.controlObjectData) ?? "player";
|
||||
this.isPiloting =
|
||||
this.lastControlType === "player"
|
||||
? !!(
|
||||
this.initialBlock.controlObjectData?.pilot ||
|
||||
this.initialBlock.controlObjectData?.controlObjectGhost != null
|
||||
)
|
||||
: false;
|
||||
this.lastCameraMode =
|
||||
this.lastControlType === "camera" &&
|
||||
typeof this.initialBlock.controlObjectData?.cameraMode === "number"
|
||||
? this.initialBlock.controlObjectData.cameraMode
|
||||
: undefined;
|
||||
this.lastOrbitGhostIndex =
|
||||
this.lastControlType === "camera" &&
|
||||
typeof this.initialBlock.controlObjectData?.orbitObjectGhostIndex ===
|
||||
"number"
|
||||
? this.initialBlock.controlObjectData.orbitObjectGhostIndex
|
||||
: undefined;
|
||||
if (this.lastControlType === "camera") {
|
||||
const minOrbit = this.initialBlock.controlObjectData?.minOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
const maxOrbit = this.initialBlock.controlObjectData?.maxOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
const curOrbit = this.initialBlock.controlObjectData?.curOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
if (
|
||||
typeof minOrbit === "number" &&
|
||||
typeof maxOrbit === "number" &&
|
||||
Number.isFinite(minOrbit) &&
|
||||
Number.isFinite(maxOrbit)
|
||||
) {
|
||||
this.lastOrbitDistance = Math.max(0, maxOrbit - minOrbit);
|
||||
} else if (typeof curOrbit === "number" && Number.isFinite(curOrbit)) {
|
||||
this.lastOrbitDistance = Math.max(0, curOrbit);
|
||||
} else {
|
||||
this.lastOrbitDistance = undefined;
|
||||
}
|
||||
} else {
|
||||
this.lastOrbitDistance = undefined;
|
||||
}
|
||||
const initialAbsRot = this.getAbsoluteRotation(
|
||||
this.initialBlock.controlObjectData,
|
||||
);
|
||||
if (initialAbsRot) {
|
||||
this.absoluteYaw = initialAbsRot.yaw;
|
||||
this.absolutePitch = initialAbsRot.pitch;
|
||||
this.lastAbsYaw = initialAbsRot.yaw;
|
||||
this.lastAbsPitch = initialAbsRot.pitch;
|
||||
}
|
||||
this.exhausted = false;
|
||||
this.latestFov = 100;
|
||||
this.latestControl = {
|
||||
ghostIndex: this.initialBlock.controlObjectGhostIndex,
|
||||
data: this.initialBlock.controlObjectData,
|
||||
position: isValidPosition(
|
||||
this.initialBlock.controlObjectData?.position as Vec3,
|
||||
)
|
||||
? (this.initialBlock.controlObjectData?.position as Vec3)
|
||||
: undefined,
|
||||
};
|
||||
this.controlPlayerGhostId =
|
||||
this.lastControlType === "player" &&
|
||||
this.initialBlock.controlObjectGhostIndex >= 0
|
||||
? toEntityId("Player", this.initialBlock.controlObjectGhostIndex)
|
||||
: undefined;
|
||||
|
||||
for (const ghost of this.initialBlock.initialGhosts) {
|
||||
if (ghost.type !== "create" || ghost.classId == null) continue;
|
||||
const className = this.registry.getGhostParser(ghost.classId)?.name;
|
||||
if (!className) {
|
||||
throw new Error(
|
||||
`No ghost parser for classId ${ghost.classId} (ghost index ${ghost.index})`,
|
||||
);
|
||||
}
|
||||
const id = toEntityId(className, ghost.index);
|
||||
const entity: MutableEntity = {
|
||||
id,
|
||||
ghostIndex: ghost.index,
|
||||
className,
|
||||
spawnTick: 0,
|
||||
type: toEntityType(className),
|
||||
rotation: [0, 0, 0, 1],
|
||||
};
|
||||
this.applyGhostData(entity, ghost.parsedData);
|
||||
if (ghost.parsedData) {
|
||||
const sceneObj = ghostToSceneObject(
|
||||
className,
|
||||
ghost.index,
|
||||
ghost.parsedData as Record<string, unknown>,
|
||||
);
|
||||
if (sceneObj) entity.sceneData = sceneObj;
|
||||
}
|
||||
this.entities.set(id, entity);
|
||||
this.entityIdByGhostIndex.set(ghost.index, id);
|
||||
}
|
||||
|
||||
// Derive playerSensorGroup from the control player entity
|
||||
if (
|
||||
this.playerSensorGroup === 0 &&
|
||||
this.lastControlType === "player" &&
|
||||
this.latestControl.ghostIndex >= 0
|
||||
) {
|
||||
const ctrlId = this.entityIdByGhostIndex.get(
|
||||
this.latestControl.ghostIndex,
|
||||
);
|
||||
const ctrlEntity = ctrlId ? this.entities.get(ctrlId) : undefined;
|
||||
if (ctrlEntity?.sensorGroup != null && ctrlEntity.sensorGroup > 0) {
|
||||
this.playerSensorGroup = ctrlEntity.sensorGroup;
|
||||
}
|
||||
}
|
||||
|
||||
// Process initial events
|
||||
for (const evt of this.initialBlock.initialEvents) {
|
||||
const eventName = this.registry.getEventParser(evt.classId)?.name;
|
||||
if (eventName === "SetSensorGroupEvent" && evt.parsedData) {
|
||||
const sg = evt.parsedData.sensorGroup as number | undefined;
|
||||
if (sg != null) this.playerSensorGroup = sg;
|
||||
} else if (eventName === "RemoteCommandEvent" && evt.parsedData) {
|
||||
const funcName = this.resolveNetString(
|
||||
evt.parsedData.funcName as string,
|
||||
);
|
||||
const args = evt.parsedData.args as string[];
|
||||
if (funcName === "ServerMessage") {
|
||||
this.handleServerMessage(args);
|
||||
}
|
||||
this.handleHudRemoteCommand(funcName, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed HUD state from demoValues
|
||||
const parsed = parseDemoValues(this.initialBlock.demoValues);
|
||||
if (parsed.weaponsHud) {
|
||||
this.weaponsHud.slots = parsed.weaponsHud.slots;
|
||||
this.weaponsHud.activeIndex = parsed.weaponsHud.activeIndex;
|
||||
}
|
||||
if (parsed.backpackHud) {
|
||||
this.backpackHud.packIndex = parsed.backpackHud.packIndex;
|
||||
this.backpackHud.active = parsed.backpackHud.active;
|
||||
this.backpackHud.text = parsed.backpackHud.text;
|
||||
}
|
||||
if (parsed.inventoryHud) {
|
||||
this.inventoryHud.slots = parsed.inventoryHud.slots;
|
||||
this.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot;
|
||||
}
|
||||
this.teamScores = parsed.teamScores;
|
||||
this.playerRoster = new Map(parsed.playerRoster);
|
||||
// Seed chat messages from demoValues
|
||||
for (const rawLine of parsed.chatMessages) {
|
||||
const segments = parseColorSegments(rawLine);
|
||||
if (!segments.length) continue;
|
||||
const fullText = segments.map((s) => s.text).join("");
|
||||
if (!fullText.trim()) continue;
|
||||
const primaryColor = segments[0].colorCode;
|
||||
const hasChatColor = segments.some(
|
||||
(s) => s.colorCode === 3 || s.colorCode === 4,
|
||||
);
|
||||
const isPlayerChat = hasChatColor && fullText.includes(": ");
|
||||
if (isPlayerChat) {
|
||||
const colonIdx = fullText.indexOf(": ");
|
||||
this.chatMessages.push({
|
||||
timeSec: 0,
|
||||
sender: fullText.slice(0, colonIdx),
|
||||
text: fullText.slice(colonIdx + 2),
|
||||
kind: "chat",
|
||||
colorCode: primaryColor,
|
||||
segments,
|
||||
});
|
||||
} else {
|
||||
this.chatMessages.push({
|
||||
timeSec: 0,
|
||||
sender: "",
|
||||
text: fullText,
|
||||
kind: "server",
|
||||
colorCode: primaryColor,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCameraAndHud();
|
||||
}
|
||||
|
||||
getSnapshot(): StreamSnapshot {
|
||||
if (
|
||||
this._cachedSnapshot &&
|
||||
this._cachedSnapshotTick === this.moveTicks
|
||||
) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
|
||||
const shape = expBlock.dtsFileName as string | undefined;
|
||||
if (shape) shapes.add(shape);
|
||||
const subExplosions = expBlock.subExplosions as
|
||||
| (number | null)[]
|
||||
| undefined;
|
||||
if (Array.isArray(subExplosions)) {
|
||||
for (const subId of subExplosions) {
|
||||
if (subId == null) continue;
|
||||
const subBlock = this.getDataBlockData(subId);
|
||||
if (subBlock?.dtsFileName) {
|
||||
shapes.add(subBlock.dtsFileName as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const [, block] of this.initialBlock.dataBlocks) {
|
||||
const explosionId = block.data?.explosion as number | undefined;
|
||||
if (explosionId == null) continue;
|
||||
const expBlock = this.getDataBlockData(explosionId);
|
||||
if (expBlock) collectShapesFromExplosion(expBlock);
|
||||
}
|
||||
return [...shapes];
|
||||
}
|
||||
|
||||
stepToTime(
|
||||
targetTimeSec: number,
|
||||
maxMoveTicks = Number.POSITIVE_INFINITY,
|
||||
): StreamSnapshot {
|
||||
const safeTargetSec = Number.isFinite(targetTimeSec)
|
||||
? Math.max(0, targetTimeSec)
|
||||
: 0;
|
||||
const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS);
|
||||
|
||||
let didReset = false;
|
||||
if (targetTicks < this.moveTicks) {
|
||||
this.reset();
|
||||
didReset = true;
|
||||
}
|
||||
|
||||
const wasExhausted = this.exhausted;
|
||||
let movesProcessed = 0;
|
||||
while (
|
||||
!this.exhausted &&
|
||||
this.moveTicks < targetTicks &&
|
||||
movesProcessed < maxMoveTicks
|
||||
) {
|
||||
if (!this.stepOneMoveTick()) {
|
||||
break;
|
||||
}
|
||||
movesProcessed += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
movesProcessed === 0 &&
|
||||
!didReset &&
|
||||
wasExhausted === this.exhausted &&
|
||||
this._cachedSnapshot &&
|
||||
this._cachedSnapshotTick === this.moveTicks
|
||||
) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ── Demo block processing ──
|
||||
|
||||
private stepOneMoveTick(): boolean {
|
||||
while (true) {
|
||||
const block = this.parser.nextBlock();
|
||||
if (!block) {
|
||||
this.exhausted = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.handleBlock(block);
|
||||
|
||||
if (block.type === BlockTypeMove) {
|
||||
this.moveTicks += 1;
|
||||
this.tickCount = this.moveTicks;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
this.removeExpiredExplosions();
|
||||
this.updateCameraAndHud();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlock(block: { type: number; parsed?: unknown }): void {
|
||||
if (block.type === BlockTypePacket && this.isPacketData(block.parsed)) {
|
||||
const packet = block.parsed;
|
||||
|
||||
// Process control object
|
||||
this.processControlObject(packet.gameState);
|
||||
|
||||
// Apply ghost rotation to absolute tracking. This must happen before
|
||||
// the next move delta so that our tracking stays calibrated to V12.
|
||||
const controlData = packet.gameState.controlObjectData;
|
||||
if (controlData) {
|
||||
const absRot = this.getAbsoluteRotation(controlData);
|
||||
if (absRot) {
|
||||
this.absoluteYaw = absRot.yaw;
|
||||
this.absolutePitch = absRot.pitch;
|
||||
this.lastAbsYaw = absRot.yaw;
|
||||
this.lastAbsPitch = absRot.pitch;
|
||||
}
|
||||
}
|
||||
|
||||
for (const evt of packet.events) {
|
||||
const eventName = this.registry.getEventParser(evt.classId)?.name;
|
||||
this.processEvent(evt, eventName);
|
||||
}
|
||||
|
||||
for (const ghost of packet.ghosts) {
|
||||
this.processGhostUpdate(ghost);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === BlockTypeInfo && this.isInfoData(block.parsed)) {
|
||||
if (Number.isFinite(block.parsed.value2)) {
|
||||
this.latestFov = block.parsed.value2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === BlockTypeMove && this.isMoveData(block.parsed)) {
|
||||
// Replicate V12 Player::updateMove(): apply delta then wrap/clamp.
|
||||
this.absoluteYaw += block.parsed.yaw ?? 0;
|
||||
const TWO_PI = Math.PI * 2;
|
||||
this.absoluteYaw =
|
||||
((this.absoluteYaw % TWO_PI) + TWO_PI) % TWO_PI;
|
||||
this.absolutePitch = clamp(
|
||||
this.absolutePitch + (block.parsed.pitch ?? 0),
|
||||
-MAX_PITCH,
|
||||
MAX_PITCH,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build snapshot (with generation-counter caching) ──
|
||||
|
||||
private buildSnapshot(): StreamSnapshot {
|
||||
const entities = this.buildEntityList();
|
||||
const timeSec = this.getTimeSec();
|
||||
const prev = this._snap;
|
||||
|
||||
const { chatMessages, audioEvents } =
|
||||
this.buildTimeFilteredEvents(timeSec);
|
||||
|
||||
const weaponsHud =
|
||||
prev && prev.weaponsHudGen === this._weaponsHudGen
|
||||
? prev.weaponsHud
|
||||
: {
|
||||
slots: Array.from(this.weaponsHud.slots.entries()).map(
|
||||
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
|
||||
),
|
||||
activeIndex: this.weaponsHud.activeIndex,
|
||||
};
|
||||
|
||||
const inventoryHud =
|
||||
prev && prev.inventoryHudGen === this._inventoryHudGen
|
||||
? prev.inventoryHud
|
||||
: {
|
||||
slots: Array.from(this.inventoryHud.slots.entries()).map(
|
||||
([slot, count]): InventoryHudSlot => ({ slot, count }),
|
||||
),
|
||||
activeSlot: this.inventoryHud.activeSlot,
|
||||
};
|
||||
|
||||
const backpackHud =
|
||||
prev &&
|
||||
prev.backpackPackIndex === this.backpackHud.packIndex &&
|
||||
prev.backpackActive === this.backpackHud.active &&
|
||||
prev.backpackText === this.backpackHud.text
|
||||
? prev.backpackHud
|
||||
: this.backpackHud.packIndex >= 0
|
||||
? { ...this.backpackHud }
|
||||
: null;
|
||||
|
||||
let teamScores: TeamScore[];
|
||||
if (
|
||||
prev &&
|
||||
prev.teamScoresGen === this._teamScoresGen &&
|
||||
prev.rosterGen === this._rosterGen
|
||||
) {
|
||||
teamScores = prev.teamScores;
|
||||
} else {
|
||||
teamScores = this.teamScores.map((ts) => ({ ...ts }));
|
||||
const teamCounts = new Map<number, number>();
|
||||
for (const { teamId } of this.playerRoster.values()) {
|
||||
if (teamId > 0) {
|
||||
teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const ts of teamScores) {
|
||||
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
this._snap = {
|
||||
teamScoresGen: this._teamScoresGen,
|
||||
rosterGen: this._rosterGen,
|
||||
teamScores,
|
||||
weaponsHudGen: this._weaponsHudGen,
|
||||
weaponsHud,
|
||||
inventoryHudGen: this._inventoryHudGen,
|
||||
inventoryHud,
|
||||
backpackPackIndex: this.backpackHud.packIndex,
|
||||
backpackActive: this.backpackHud.active,
|
||||
backpackText: this.backpackHud.text,
|
||||
backpackHud,
|
||||
};
|
||||
|
||||
return {
|
||||
timeSec,
|
||||
exhausted: this.exhausted,
|
||||
camera: this.camera,
|
||||
entities,
|
||||
controlPlayerGhostId: this.controlPlayerGhostId,
|
||||
playerSensorGroup: this.playerSensorGroup,
|
||||
status: this.lastStatus,
|
||||
chatMessages,
|
||||
audioEvents,
|
||||
weaponsHud,
|
||||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Type guards ──
|
||||
|
||||
private isPacketData(parsed: unknown): parsed is {
|
||||
gameState: {
|
||||
controlObjectGhostIndex?: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
compressionPoint?: Vec3;
|
||||
cameraFov?: number;
|
||||
};
|
||||
events: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
ghosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
} {
|
||||
return (
|
||||
!!parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"gameState" in parsed &&
|
||||
"events" in parsed &&
|
||||
"ghosts" in parsed
|
||||
);
|
||||
}
|
||||
|
||||
private isMoveData(
|
||||
parsed: unknown,
|
||||
): parsed is { yaw?: number; pitch?: number } {
|
||||
return !!parsed && typeof parsed === "object" && "yaw" in parsed;
|
||||
}
|
||||
|
||||
private isInfoData(parsed: unknown): parsed is { value2: number } {
|
||||
return (
|
||||
!!parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"value2" in parsed &&
|
||||
typeof (parsed as { value2?: unknown }).value2 === "number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDemoStreamingRecording(
|
||||
data: ArrayBuffer,
|
||||
): Promise<StreamRecording> {
|
||||
const parser = new DemoParser(new Uint8Array(data));
|
||||
const { header, initialBlock } = await parser.load();
|
||||
const { missionName: infoMissionName, gameType } = extractMissionInfo(
|
||||
initialBlock.demoValues,
|
||||
);
|
||||
|
||||
return {
|
||||
source: "demo",
|
||||
duration: header.demoLengthMs / 1000,
|
||||
missionName: infoMissionName ?? initialBlock.missionName ?? null,
|
||||
gameType,
|
||||
streamingPlayback: new StreamingPlayback(parser),
|
||||
};
|
||||
}
|
||||
185
src/stream/entityBridge.ts
Normal file
185
src/stream/entityBridge.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import type { StreamEntity } from "./types";
|
||||
import type {
|
||||
GameEntity,
|
||||
ShapeEntity,
|
||||
PlayerEntity,
|
||||
ForceFieldBareEntity,
|
||||
ExplosionEntity,
|
||||
TracerEntity,
|
||||
SpriteEntity,
|
||||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import type { SceneTSStatic } from "../scene/types";
|
||||
|
||||
/** Common fields extracted from a StreamEntity for positioned game entities. */
|
||||
function positionedBase(entity: StreamEntity, spawnTime?: number) {
|
||||
return {
|
||||
id: entity.id,
|
||||
className: entity.className ?? entity.type,
|
||||
ghostIndex: entity.ghostIndex,
|
||||
dataBlockId: entity.dataBlockId,
|
||||
shapeHint: entity.shapeHint,
|
||||
spawnTime,
|
||||
position: entity.position,
|
||||
rotation: entity.rotation,
|
||||
velocity: entity.velocity,
|
||||
keyframes: [
|
||||
{
|
||||
time: spawnTime ?? 0,
|
||||
position: entity.position ?? [0, 0, 0] as [number, number, number],
|
||||
rotation: entity.rotation ?? [0, 0, 0, 1] as [number, number, number, number],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a StreamEntity to a GameEntity for the entity store. */
|
||||
export function streamEntityToGameEntity(
|
||||
entity: StreamEntity,
|
||||
spawnTime?: number,
|
||||
): GameEntity {
|
||||
// Scene infrastructure — routed from sceneData
|
||||
if (entity.sceneData) {
|
||||
const base = {
|
||||
id: entity.id,
|
||||
className: entity.className ?? entity.type,
|
||||
ghostIndex: entity.ghostIndex,
|
||||
dataBlockId: entity.dataBlockId,
|
||||
shapeHint: entity.shapeHint,
|
||||
spawnTime,
|
||||
};
|
||||
switch (entity.sceneData.className) {
|
||||
case "TerrainBlock":
|
||||
return { ...base, renderType: "TerrainBlock", terrainData: entity.sceneData };
|
||||
case "InteriorInstance":
|
||||
return { ...base, renderType: "InteriorInstance", interiorData: entity.sceneData };
|
||||
case "Sky":
|
||||
return { ...base, renderType: "Sky", skyData: entity.sceneData };
|
||||
case "Sun":
|
||||
return { ...base, renderType: "Sun", sunData: entity.sceneData };
|
||||
case "WaterBlock":
|
||||
return { ...base, renderType: "WaterBlock", waterData: entity.sceneData };
|
||||
case "MissionArea":
|
||||
return { ...base, renderType: "MissionArea", missionAreaData: entity.sceneData };
|
||||
case "TSStatic":
|
||||
// TSStatic is rendered as a shape — extract shapeName from scene data.
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Shape",
|
||||
shapeName: (entity.sceneData as SceneTSStatic).shapeName,
|
||||
shapeType: "TSStatic",
|
||||
dataBlock: entity.dataBlock,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
}
|
||||
|
||||
// Projectile visuals
|
||||
if (entity.visual?.kind === "tracer") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Tracer",
|
||||
visual: entity.visual,
|
||||
dataBlock: entity.dataBlock,
|
||||
direction: entity.direction,
|
||||
} satisfies TracerEntity;
|
||||
}
|
||||
if (entity.visual?.kind === "sprite") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Sprite",
|
||||
visual: entity.visual,
|
||||
} satisfies SpriteEntity;
|
||||
}
|
||||
|
||||
// Player
|
||||
if (entity.type === "Player") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Player",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
playerName: entity.playerName,
|
||||
iffColor: entity.iffColor,
|
||||
threads: entity.threads,
|
||||
weaponImageState: entity.weaponImageState,
|
||||
weaponImageStates: entity.weaponImageStates,
|
||||
headPitch: entity.headPitch,
|
||||
headYaw: entity.headYaw,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
} satisfies PlayerEntity;
|
||||
}
|
||||
|
||||
// Explosion
|
||||
if (entity.type === "Explosion") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Explosion",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
explosionDataBlockId: entity.explosionDataBlockId,
|
||||
faceViewer: entity.faceViewer,
|
||||
} satisfies ExplosionEntity;
|
||||
}
|
||||
|
||||
// Force field
|
||||
if (entity.className === "ForceFieldBare") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "ForceFieldBare",
|
||||
} satisfies ForceFieldBareEntity;
|
||||
}
|
||||
|
||||
// Audio emitter
|
||||
if (entity.className === "AudioEmitter") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "AudioEmitter",
|
||||
audioFileName: entity.audioFileName,
|
||||
audioVolume: entity.audioVolume,
|
||||
audioIs3D: entity.audioIs3D,
|
||||
audioIsLooping: entity.audioIsLooping ?? true,
|
||||
audioMinDistance: entity.audioMinDistance,
|
||||
audioMaxDistance: entity.audioMaxDistance,
|
||||
audioMinLoopGap: entity.audioMinLoopGap,
|
||||
audioMaxLoopGap: entity.audioMaxLoopGap,
|
||||
} satisfies AudioEmitterEntity;
|
||||
}
|
||||
|
||||
// WayPoint
|
||||
if (entity.className === "WayPoint") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "WayPoint",
|
||||
label: entity.label,
|
||||
} satisfies WayPointEntity;
|
||||
}
|
||||
|
||||
// Camera
|
||||
if (entity.className === "Camera") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Camera",
|
||||
} satisfies CameraEntity;
|
||||
}
|
||||
|
||||
// Default: generic DTS shape
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Shape",
|
||||
shapeName: entity.dataBlock,
|
||||
shapeType:
|
||||
entity.className === "Turret"
|
||||
? "Turret"
|
||||
: entity.className === "Item"
|
||||
? "Item"
|
||||
: "StaticShape",
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
threads: entity.threads,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
iffColor: entity.iffColor,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
69
src/stream/entityClassification.ts
Normal file
69
src/stream/entityClassification.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/** Class names for vehicle ghosts. */
|
||||
export const vehicleClassNames = new Set([
|
||||
"FlyingVehicle",
|
||||
"HoverVehicle",
|
||||
"WheeledVehicle",
|
||||
]);
|
||||
|
||||
/** All projectile class names. */
|
||||
export const projectileClassNames = new Set([
|
||||
"BombProjectile",
|
||||
"EnergyProjectile",
|
||||
"FlareProjectile",
|
||||
"GrenadeProjectile",
|
||||
"LinearFlareProjectile",
|
||||
"LinearProjectile",
|
||||
"Projectile",
|
||||
"SeekerProjectile",
|
||||
"TracerProjectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes with linear (constant-velocity) physics. */
|
||||
export const linearProjectileClassNames = new Set([
|
||||
"LinearProjectile",
|
||||
"TracerProjectile",
|
||||
"LinearFlareProjectile",
|
||||
"Projectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes with ballistic (gravity-affected) physics. */
|
||||
export const ballisticProjectileClassNames = new Set([
|
||||
"GrenadeProjectile",
|
||||
"EnergyProjectile",
|
||||
"FlareProjectile",
|
||||
"BombProjectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes that use seeking (homing) physics. */
|
||||
export const seekerProjectileClassNames = new Set(["SeekerProjectile"]);
|
||||
|
||||
/** Deployable/placed object class names. */
|
||||
export const deployableClassNames = new Set([
|
||||
"StaticShape",
|
||||
"ScopeAlwaysShape",
|
||||
"Turret",
|
||||
"BeaconObject",
|
||||
"ForceFieldBare",
|
||||
]);
|
||||
|
||||
/** Map a ghost class name to a high-level entity type string. */
|
||||
export function toEntityType(className: string): string {
|
||||
if (className === "Player") return "Player";
|
||||
if (vehicleClassNames.has(className)) return "Vehicle";
|
||||
if (className === "Item") return "Item";
|
||||
if (projectileClassNames.has(className)) return "Projectile";
|
||||
if (deployableClassNames.has(className)) return "Deployable";
|
||||
return "Ghost";
|
||||
}
|
||||
|
||||
/** Generate a stable entity ID from ghost class name and index. */
|
||||
export function toEntityId(className: string, ghostIndex: number): string {
|
||||
return `${className}_${ghostIndex}`;
|
||||
}
|
||||
|
||||
/** Tribes 2 default IFF colors (sRGB 0-255). */
|
||||
export const IFF_GREEN = Object.freeze({ r: 0, g: 255, b: 0 });
|
||||
export const IFF_RED = Object.freeze({ r: 255, g: 0, b: 0 });
|
||||
|
||||
/** Torque engine tick duration in milliseconds. */
|
||||
export const TICK_DURATION_MS = 32;
|
||||
555
src/stream/liveStreaming.ts
Normal file
555
src/stream/liveStreaming.ts
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
import {
|
||||
createLiveParser,
|
||||
type PacketParser,
|
||||
} from "t2-demo-parser";
|
||||
import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
import type { StreamSnapshot } from "./types";
|
||||
import { StreamEngine } from "./StreamEngine";
|
||||
import type { RelayClient } from "./relayClient";
|
||||
|
||||
// ── Player list entry ──
|
||||
|
||||
export interface PlayerListEntry {
|
||||
targetId: number;
|
||||
name: string;
|
||||
sensorGroup: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts live game packets from a relay connection into the
|
||||
* StreamingPlayback interface used by the existing rendering pipeline.
|
||||
*/
|
||||
export class LiveStreamAdapter extends StreamEngine {
|
||||
private packetParser: PacketParser;
|
||||
relay: RelayClient;
|
||||
|
||||
private currentTimeSec = 0;
|
||||
private connectSynced = false;
|
||||
private _snapshot: StreamSnapshot | null = null;
|
||||
private _snapshotTick = -1;
|
||||
private _ready = false;
|
||||
/** Class names for datablocks, tracked from SimDataBlockEvents. */
|
||||
private dataBlockClassNames = new Map<number, string>();
|
||||
|
||||
/** Called once when the first ghost entity is created. */
|
||||
onReady?: () => void;
|
||||
|
||||
constructor(relay: RelayClient) {
|
||||
super();
|
||||
this.relay = relay;
|
||||
const { registry, ghostTracker, packetParser } = createLiveParser();
|
||||
this.packetParser = packetParser;
|
||||
this.ghostTracker = ghostTracker;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(id: number): Record<string, unknown> | undefined {
|
||||
return this.packetParser.getDataBlockDataMap()?.get(id);
|
||||
}
|
||||
|
||||
private _shapeConstructorCache: Map<string, string[]> | null = null;
|
||||
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined {
|
||||
// Rebuild cache each call since datablocks arrive incrementally.
|
||||
this._shapeConstructorCache = new Map();
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
if (!dbMap) return undefined;
|
||||
for (const [, block] of dbMap) {
|
||||
const shape = block.shape as string | undefined;
|
||||
const seqs = block.sequences as string[] | undefined;
|
||||
if (shape && seqs) {
|
||||
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
|
||||
}
|
||||
}
|
||||
return this._shapeConstructorCache.get(shapeName.toLowerCase());
|
||||
}
|
||||
|
||||
protected getTimeSec(): number {
|
||||
return this.currentTimeSec;
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number } {
|
||||
const absRot = this.getAbsoluteRotation(data);
|
||||
return absRot ?? { yaw: 0, pitch: 0 };
|
||||
}
|
||||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
if (!dbMap) return [];
|
||||
for (const [, block] of dbMap) {
|
||||
const explosionId = block.explosion as number | undefined;
|
||||
if (explosionId == null) continue;
|
||||
const expBlock = dbMap.get(explosionId);
|
||||
if (expBlock?.dtsFileName) {
|
||||
shapes.add(expBlock.dtsFileName as string);
|
||||
}
|
||||
}
|
||||
return [...shapes];
|
||||
}
|
||||
|
||||
// ── StreamingPlayback interface ──
|
||||
|
||||
reset(): void {
|
||||
this.resetSharedState();
|
||||
this.ghostTracker.clear?.();
|
||||
this.currentTimeSec = 0;
|
||||
this._snapshot = null;
|
||||
this._snapshotTick = -1;
|
||||
this.dataBlockClassNames.clear();
|
||||
this.observerMode = "fly";
|
||||
}
|
||||
|
||||
getSnapshot(): StreamSnapshot {
|
||||
if (this._snapshot && this._snapshotTick === this.tickCount) {
|
||||
return this._snapshot;
|
||||
}
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
stepToTime(
|
||||
targetTimeSec: number,
|
||||
_maxMoveTicks?: number,
|
||||
): StreamSnapshot {
|
||||
this.currentTimeSec = targetTimeSec;
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
// ── Live-specific: connect sequence sync ──
|
||||
|
||||
private syncConnectSequence(data: Uint8Array): void {
|
||||
if (this.connectSynced || data.length < 1) return;
|
||||
this.connectSynced = true;
|
||||
const connectSeqBit = (data[0] >> 1) & 1;
|
||||
// The browser parser is a passive observer — it never sends packets
|
||||
// (the relay handles all outgoing UDP traffic). Set lastSendSeq very
|
||||
// high so the parser's ack validation (lastSendSeq < highestAck →
|
||||
// reject) never fires. Without this, the parser rejects any packet
|
||||
// where the server acks relay-sent sequences (e.g. auth events).
|
||||
this.packetParser.setConnectionProtocolState({
|
||||
lastSeqRecvdAtSend: new Array(32).fill(0),
|
||||
lastSeqRecvd: 0,
|
||||
highestAckedSeq: 0,
|
||||
lastSendSeq: 0x1fffffff,
|
||||
ackMask: 0,
|
||||
connectSequence: connectSeqBit,
|
||||
lastRecvAckAck: 0,
|
||||
connectionEstablished: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Live-specific: feed raw packet ──
|
||||
|
||||
feedPacket(data: Uint8Array): void {
|
||||
this.syncConnectSequence(data);
|
||||
this.processPacket(data);
|
||||
}
|
||||
|
||||
// ── Live-specific: auth event detection ──
|
||||
|
||||
/**
|
||||
* Handle RemoteCommandEvents that require relay-side responses:
|
||||
* auth events, mission phase acknowledgments, etc.
|
||||
*/
|
||||
private handleRelayCommands(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "RemoteCommandEvent") return;
|
||||
const rawFuncName = parsedData.funcName as string;
|
||||
if (!rawFuncName) return;
|
||||
const funcName = this.resolveNetString(rawFuncName);
|
||||
|
||||
// T2csri auth events → forward to relay for crypto processing.
|
||||
const authCommands = [
|
||||
"t2csri_pokeClient",
|
||||
"t2csri_getChallengeChunk",
|
||||
"t2csri_decryptChallenge",
|
||||
];
|
||||
if (authCommands.includes(funcName)) {
|
||||
const rawArgs = (parsedData.args as string[]) ?? [];
|
||||
const args = rawArgs
|
||||
.map((a) => this.resolveNetString(a))
|
||||
.filter((a) => a !== "");
|
||||
console.log(`[live] auth event: ${funcName}`, args);
|
||||
this.relay.sendAuthEvent(funcName, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mission download phase acknowledgments — the server won't proceed
|
||||
// to ghosting until the client responds to each phase.
|
||||
const rawArgs = (parsedData.args as string[]) ?? [];
|
||||
const resolvedArgs = rawArgs.map((a) => this.resolveNetString(a));
|
||||
if (funcName === "MissionStartPhase1") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 1, seq=${seq}`);
|
||||
this.relay.sendCommand("MissionStartPhase1Done", [seq]);
|
||||
} else if (funcName === "MissionStartPhase2") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 2 (datablocks), seq=${seq}`);
|
||||
this.relay.sendCommand("MissionStartPhase2Done", [seq]);
|
||||
} else if (funcName === "MissionStartPhase3") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 3 (ghosting), seq=${seq}`);
|
||||
// Send an empty favorites list then acknowledge phase 3.
|
||||
this.relay.sendCommand("setClientFav", [""]);
|
||||
this.relay.sendCommand("MissionStartPhase3Done", [seq]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Respond to CRCChallengeEvent — required for Phase 2 to begin. */
|
||||
private handleCRCChallenge(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "CRCChallengeEvent") return;
|
||||
const seed = parsedData.crcValue as number;
|
||||
const field1 = parsedData.field1 as number;
|
||||
const field2 = parsedData.field2 as number;
|
||||
// field1 bit 0 = includeTextures (from $Host::CRCTextures)
|
||||
const includeTextures = (field1 & 1) !== 0;
|
||||
console.log(
|
||||
`[live] CRC challenge: seed=0x${(seed >>> 0).toString(16)} ` +
|
||||
`f1=0x${(field1 >>> 0).toString(16)} f2=0x${(field2 >>> 0).toString(16)} ` +
|
||||
`includeTextures=${includeTextures}`,
|
||||
);
|
||||
|
||||
// Collect datablocks for relay-side CRC computation over game files.
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
const datablocks: { objectId: number; className: string; shapeName: string }[] = [];
|
||||
if (dbMap) {
|
||||
for (const [id, block] of dbMap) {
|
||||
const className = this.dataBlockClassNames.get(id);
|
||||
if (!className) continue;
|
||||
const shapeName = resolveShapeName(className, block as Record<string, unknown>);
|
||||
datablocks.push({
|
||||
objectId: id,
|
||||
className,
|
||||
shapeName: shapeName ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`[live] CRC: sending ${datablocks.length} datablocks for computation`);
|
||||
this.relay.sendCRCCompute(seed, field2, datablocks, includeTextures);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to GhostingMessageEvent type 0 (GhostAlwaysDone).
|
||||
* The server sends this after activateGhosting(); the client must respond
|
||||
* with type 1 so the server sets mGhosting=true and begins sending ghosts.
|
||||
*/
|
||||
private handleGhostingMessage(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "GhostingMessageEvent") return;
|
||||
const message = parsedData.message as number;
|
||||
const sequence = parsedData.sequence as number;
|
||||
const ghostCount = parsedData.ghostCount as number;
|
||||
console.log(
|
||||
`[live] GhostingMessageEvent: message=${message} sequence=${sequence} ghostCount=${ghostCount}`,
|
||||
);
|
||||
if (message === 0) {
|
||||
// GhostAlwaysDone → send type 1 acknowledgment
|
||||
console.log(`[live] Sending ghost ack (type 1) for sequence ${sequence}`);
|
||||
this.relay.sendGhostAck(sequence, ghostCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side observer camera mode. In "fly" mode, trigger 0 (fire) would
|
||||
* make the server assign a team — so we must NEVER send fire in fly mode.
|
||||
* Jump (trigger 2) transitions between modes.
|
||||
*/
|
||||
observerMode: "fly" | "follow" = "fly";
|
||||
|
||||
/** Enter follow mode (from fly) or cycle to next player (in follow). */
|
||||
cycleObserveNext(): void {
|
||||
if (this.observerMode === "fly") {
|
||||
// Jump trigger enters observerFollow from observerFly
|
||||
console.log("[live] observer: fly → follow (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "follow";
|
||||
} else {
|
||||
// Fire trigger cycles to next player in observerFollow
|
||||
console.log("[live] observer: cycle next (fire trigger)");
|
||||
this.sendTrigger(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle between follow and free-fly observer modes. */
|
||||
toggleObserverMode(): void {
|
||||
if (this.observerMode === "fly") {
|
||||
// Jump trigger enters observerFollow from observerFly
|
||||
console.log("[live] observer: fly → follow (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "follow";
|
||||
} else {
|
||||
// Jump trigger returns to observerFly from observerFollow
|
||||
console.log("[live] observer: follow → fly (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "fly";
|
||||
}
|
||||
}
|
||||
|
||||
private sendTrigger(index: number): void {
|
||||
const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] =
|
||||
[false, false, false, false, false, false];
|
||||
trigger[index] = true;
|
||||
this.relay.sendMove({
|
||||
x: 0, y: 0, z: 0,
|
||||
yaw: 0, pitch: 0, roll: 0,
|
||||
trigger,
|
||||
freeLook: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the player list (for observer cycling UI). */
|
||||
getPlayerList(): PlayerListEntry[] {
|
||||
const entries: PlayerListEntry[] = [];
|
||||
for (const [targetId, name] of this.targetNames) {
|
||||
const sg = this.targetTeams.get(targetId) ?? 0;
|
||||
entries.push({ targetId, name, sensorGroup: sg });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Packet processing ──
|
||||
|
||||
private processPacket(data: Uint8Array): void {
|
||||
try {
|
||||
const rejectedBefore = this.packetParser.protocolRejected;
|
||||
const noDispatchBefore = this.packetParser.protocolNoDispatch;
|
||||
const parsed = this.packetParser.parsePacket(data);
|
||||
const wasRejected = this.packetParser.protocolRejected > rejectedBefore;
|
||||
const wasNoDispatch = this.packetParser.protocolNoDispatch > noDispatchBefore;
|
||||
|
||||
if (wasRejected || wasNoDispatch) {
|
||||
console.warn(
|
||||
`[live] packet #${this.tickCount} ${wasRejected ? "REJECTED" : "no-dispatch"}: ${data.length} bytes` +
|
||||
` (total rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch})`,
|
||||
);
|
||||
}
|
||||
|
||||
const isEarlyPacket = this.tickCount < 20;
|
||||
const isMilestonePacket = this.tickCount % 100 === 0;
|
||||
const shouldLog = isEarlyPacket || isMilestonePacket;
|
||||
|
||||
if (shouldLog) {
|
||||
console.log(
|
||||
`[live] packet #${this.tickCount}: ${parsed.events.length} events, ${parsed.ghosts.length} ghosts, ${data.length} bytes` +
|
||||
(parsed.gameState.controlObjectGhostIndex !== undefined
|
||||
? `, control=${parsed.gameState.controlObjectGhostIndex}`
|
||||
: "") +
|
||||
(parsed.gameState.cameraFov !== undefined
|
||||
? `, fov=${parsed.gameState.cameraFov}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Control object state
|
||||
this.processControlObject(parsed.gameState);
|
||||
|
||||
// Events
|
||||
for (const event of parsed.events) {
|
||||
if (event.parsedData) {
|
||||
this.handleRelayCommands(event.parsedData);
|
||||
this.handleCRCChallenge(event.parsedData);
|
||||
this.handleGhostingMessage(event.parsedData);
|
||||
const type = event.parsedData.type as string;
|
||||
|
||||
// Log events in early packets
|
||||
if (isEarlyPacket) {
|
||||
if (type !== "NetStringEvent") {
|
||||
console.log(
|
||||
`[live] event: ${type}`,
|
||||
type === "RemoteCommandEvent"
|
||||
? { funcName: this.resolveNetString(event.parsedData.funcName as string ?? "") }
|
||||
: type === "SimDataBlockEvent"
|
||||
? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track SimDataBlockEvent class names for CRC computation.
|
||||
if (type === "SimDataBlockEvent") {
|
||||
const dbId = event.parsedData.objectId as number | undefined;
|
||||
const dbClassName = event.parsedData.dataBlockClassName as string | undefined;
|
||||
if (dbId != null && dbClassName) {
|
||||
this.dataBlockClassNames.set(dbId, dbClassName);
|
||||
}
|
||||
if (shouldLog) {
|
||||
const dbData = event.parsedData.dataBlockData as Record<string, unknown> | undefined;
|
||||
const shapeName = resolveShapeName(dbClassName ?? "", dbData);
|
||||
console.log(
|
||||
`[live] datablock: id=${dbId} class=${dbClassName ?? "?"}` +
|
||||
(shapeName ? ` shape=${shapeName}` : ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eventName = this.registry.getEventParser(event.classId)?.name;
|
||||
this.processEvent(event, eventName);
|
||||
|
||||
// Log net strings in early packets
|
||||
if (isEarlyPacket && type === "NetStringEvent") {
|
||||
const id = event.parsedData.id as number;
|
||||
const value = event.parsedData.value as string;
|
||||
if (id != null && typeof value === "string") {
|
||||
console.log(`[live] netString #${id} = "${value.length > 60 ? value.slice(0, 60) + "…" : value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log target info
|
||||
if (type === "TargetInfoEvent") {
|
||||
const targetId = event.parsedData.targetId as number | undefined;
|
||||
const nameTag = event.parsedData.nameTag as number | undefined;
|
||||
if (targetId != null && nameTag != null) {
|
||||
const resolved = this.netStrings.get(nameTag);
|
||||
if (resolved) {
|
||||
const name = stripTaggedStringMarkup(resolved);
|
||||
console.log(`[live] target #${targetId}: "${name}" team=${event.parsedData.sensorGroup ?? "?"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log sensor group changes
|
||||
if (type === "SetSensorGroupEvent") {
|
||||
const sg = event.parsedData.sensorGroup as number | undefined;
|
||||
if (sg != null) {
|
||||
console.log(`[live] sensor group changed: → ${sg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log sensor group colors
|
||||
if (type === "SensorGroupColorEvent") {
|
||||
const sg = event.parsedData.sensorGroup as number;
|
||||
const colors = event.parsedData.colors as Array<unknown> | undefined;
|
||||
if (colors) {
|
||||
console.log(
|
||||
`[live] sensor group colors: group=${sg}, ${colors.length} entries`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ghosts
|
||||
for (const ghost of parsed.ghosts) {
|
||||
if (ghost.type === "create") {
|
||||
const pos = ghost.parsedData?.position as Vec3 | undefined;
|
||||
const hasPos = pos && typeof pos.x === "number" && typeof pos.y === "number" && typeof pos.z === "number";
|
||||
const className = this.resolveGhostClassName(ghost.index, ghost.classId);
|
||||
console.log(
|
||||
`[live] ghost create: #${ghost.index} ${className ?? "?"}` +
|
||||
(hasPos ? ` at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})` : "") +
|
||||
` (${this.entities.size + 1} entities total)`,
|
||||
);
|
||||
if (!this._ready) {
|
||||
this._ready = true;
|
||||
this.onReady?.();
|
||||
}
|
||||
} else if (ghost.type === "delete") {
|
||||
const prevEntityId = this.entityIdByGhostIndex.get(ghost.index);
|
||||
const prevEntity = prevEntityId ? this.entities.get(prevEntityId) : undefined;
|
||||
if (this.tickCount < 50 || this.tickCount % 200 === 0) {
|
||||
console.log(
|
||||
`[live] ghost delete: #${ghost.index} ${prevEntity?.className ?? "?"}` +
|
||||
` (${this.entities.size - 1} entities remaining)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.processGhostUpdate(ghost);
|
||||
}
|
||||
|
||||
this.tickCount++;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
|
||||
// Periodic status at milestones
|
||||
if (isMilestonePacket && this.tickCount > 1) {
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
console.log(
|
||||
`[live] status @ tick ${this.tickCount}: ${this.entities.size} entities, ` +
|
||||
`${dbMap?.size ?? 0} datablocks, ` +
|
||||
`rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Entity count milestones
|
||||
const entityCount = this.entities.size;
|
||||
if (
|
||||
this.tickCount === 1 ||
|
||||
(entityCount > 0 && entityCount % 25 === 0 && this.tickCount < 100)
|
||||
) {
|
||||
const types = new Map<string, number>();
|
||||
for (const e of this.entities.values()) {
|
||||
types.set(e.type, (types.get(e.type) ?? 0) + 1);
|
||||
}
|
||||
const summary = [...types.entries()]
|
||||
.map(([t, c]) => `${t}=${c}`)
|
||||
.join(" ");
|
||||
console.log(
|
||||
`[live] entity count: ${entityCount} (${summary})`,
|
||||
);
|
||||
}
|
||||
|
||||
this.updateCameraAndHud();
|
||||
|
||||
// Log camera position for early packets
|
||||
if (this.tickCount <= 5 && this.camera) {
|
||||
const [cx, cy, cz] = this.camera.position;
|
||||
console.log(
|
||||
`[live] camera: mode=${this.camera.mode} pos=(${cx.toFixed(1)}, ${cy.toFixed(1)}, ${cz.toFixed(1)}) fov=${this.camera.fov}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorContext = {
|
||||
tickCount: this.tickCount,
|
||||
entityCount: this.entities.size,
|
||||
dataLength: data.length,
|
||||
controlGhost: this.latestControl.ghostIndex,
|
||||
connectSynced: this.connectSynced,
|
||||
};
|
||||
console.error("Failed to process live packet:", e, errorContext);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build snapshot ──
|
||||
|
||||
private buildSnapshot(): StreamSnapshot {
|
||||
const entities = this.buildEntityList();
|
||||
const timeSec = this.currentTimeSec;
|
||||
const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec);
|
||||
const { weaponsHud, inventoryHud, backpackHud, teamScores } =
|
||||
this.buildHudState();
|
||||
|
||||
// Default observer camera if none exists
|
||||
if (!this.camera) {
|
||||
this.camera = {
|
||||
time: timeSec,
|
||||
position: [0, 0, 200],
|
||||
rotation: [0, 0, 0, 1],
|
||||
fov: 90,
|
||||
mode: "observer",
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: StreamSnapshot = {
|
||||
timeSec,
|
||||
exhausted: false,
|
||||
camera: this.camera,
|
||||
entities,
|
||||
controlPlayerGhostId: this.controlPlayerGhostId,
|
||||
playerSensorGroup: this.playerSensorGroup,
|
||||
status: this.lastStatus,
|
||||
chatMessages,
|
||||
audioEvents,
|
||||
weaponsHud,
|
||||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
};
|
||||
|
||||
this._snapshot = snapshot;
|
||||
this._snapshotTick = this.tickCount;
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
297
src/stream/missionEntityBridge.ts
Normal file
297
src/stream/missionEntityBridge.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import type { TorqueObject, TorqueRuntime } from "../torqueScript";
|
||||
import type {
|
||||
GameEntity,
|
||||
ShapeEntity,
|
||||
ForceFieldBareEntity,
|
||||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import { getPosition, getProperty, getScale } from "../mission";
|
||||
import {
|
||||
terrainFromMis,
|
||||
interiorFromMis,
|
||||
skyFromMis,
|
||||
sunFromMis,
|
||||
missionAreaFromMis,
|
||||
waterBlockFromMis,
|
||||
} from "../scene/misToScene";
|
||||
|
||||
/** Resolve a named datablock from the runtime. */
|
||||
function resolveDatablock(
|
||||
runtime: TorqueRuntime,
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
if (!name) return undefined;
|
||||
return runtime.state.datablocks.get(name);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function parseColor3(colorStr: string): [number, number, number] {
|
||||
const parts = colorStr.split(" ").map((s) => parseFloat(s));
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
||||
function parseRotationToQuat(
|
||||
rotationStr: string,
|
||||
): [number, number, number, number] {
|
||||
const [ax, ay, az, angleDeg] = rotationStr.split(" ").map(parseFloat);
|
||||
// Convert Torque axis-angle to Three.js quaternion (with coordinate swap
|
||||
// and angle negation matching getRotation() in mission.ts).
|
||||
const halfRad = (-(angleDeg || 0) * Math.PI) / 360;
|
||||
const s = Math.sin(halfRad);
|
||||
const c = Math.cos(halfRad);
|
||||
const len = Math.sqrt(
|
||||
(ay || 0) * (ay || 0) + (az || 0) * (az || 0) + (ax || 0) * (ax || 0),
|
||||
);
|
||||
if (len < 1e-8) return [0, 0, 0, 1];
|
||||
// Three.js quaternion [x, y, z, w] with Torque→Three axis swap (x→y, y→z, z→x)
|
||||
return [
|
||||
((ay || 0) / len) * s,
|
||||
((az || 0) / len) * s,
|
||||
((ax || 0) / len) * s,
|
||||
c,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GameEntity from a mission TorqueObject. Returns null if the
|
||||
* object's className is not a renderable entity type.
|
||||
*/
|
||||
export function buildGameEntityFromMission(
|
||||
object: TorqueObject,
|
||||
runtime: TorqueRuntime,
|
||||
teamId?: number,
|
||||
): GameEntity | null {
|
||||
const className = object._className;
|
||||
const id = `mission_${object._id}`;
|
||||
const position = getPosition(object);
|
||||
const scale = getScale(object);
|
||||
const rotStr = object.rotation ?? "1 0 0 0";
|
||||
const rotation = parseRotationToQuat(rotStr);
|
||||
const datablockName = getProperty(object, "dataBlock") ?? "";
|
||||
const datablock = resolveDatablock(runtime, datablockName);
|
||||
const missionTypesList = getProperty(object, "missionTypesList");
|
||||
|
||||
const base = {
|
||||
id,
|
||||
className,
|
||||
runtimeObject: object,
|
||||
missionTypesList,
|
||||
};
|
||||
const posBase = { ...base, position, rotation, scale };
|
||||
|
||||
switch (className) {
|
||||
// Scene infrastructure
|
||||
case "TerrainBlock":
|
||||
return { ...base, renderType: "TerrainBlock", terrainData: terrainFromMis(object) };
|
||||
case "InteriorInstance":
|
||||
return { ...base, renderType: "InteriorInstance", interiorData: interiorFromMis(object) };
|
||||
case "Sky":
|
||||
return { ...base, renderType: "Sky", skyData: skyFromMis(object) };
|
||||
case "Sun":
|
||||
return { ...base, renderType: "Sun", sunData: sunFromMis(object) };
|
||||
case "WaterBlock":
|
||||
return { ...base, renderType: "WaterBlock", waterData: waterBlockFromMis(object) };
|
||||
case "MissionArea":
|
||||
return { ...base, renderType: "MissionArea", missionAreaData: missionAreaFromMis(object) };
|
||||
|
||||
// Shapes
|
||||
case "StaticShape":
|
||||
case "Item":
|
||||
case "Turret":
|
||||
case "TSStatic":
|
||||
return buildShapeEntity(posBase, object, datablock, runtime, className, teamId, datablockName);
|
||||
|
||||
// Force field
|
||||
case "ForceFieldBare":
|
||||
return buildForceFieldEntity(posBase, object, datablock, scale);
|
||||
|
||||
// Audio
|
||||
case "AudioEmitter":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "AudioEmitter",
|
||||
audioFileName: getProperty(object, "fileName") ?? undefined,
|
||||
audioVolume: parseFloat(getProperty(object, "volume")) || 1,
|
||||
audioIs3D: (getProperty(object, "is3D") ?? "0") !== "0",
|
||||
audioIsLooping: (getProperty(object, "isLooping") ?? "0") !== "0",
|
||||
audioMinDistance: parseFloat(getProperty(object, "minDistance")) || 1,
|
||||
audioMaxDistance: parseFloat(getProperty(object, "maxDistance")) || 1,
|
||||
audioMinLoopGap: parseFloat(getProperty(object, "minLoopGap")) || 0,
|
||||
audioMaxLoopGap: parseFloat(getProperty(object, "maxLoopGap")) || 0,
|
||||
} satisfies AudioEmitterEntity;
|
||||
|
||||
case "Camera":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "Camera",
|
||||
cameraDataBlock: datablockName || undefined,
|
||||
} satisfies CameraEntity;
|
||||
|
||||
case "WayPoint":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "WayPoint",
|
||||
label: getProperty(object, "name") || undefined,
|
||||
} satisfies WayPointEntity;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildShapeEntity(
|
||||
posBase: {
|
||||
id: string;
|
||||
className: string;
|
||||
runtimeObject: unknown;
|
||||
missionTypesList?: string;
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
},
|
||||
object: TorqueObject,
|
||||
datablock: TorqueObject | undefined,
|
||||
runtime: TorqueRuntime,
|
||||
className: string,
|
||||
teamId: number | undefined,
|
||||
datablockName: string,
|
||||
): ShapeEntity {
|
||||
const shapeName = className === "TSStatic"
|
||||
? getProperty(object, "shapeName")
|
||||
: getProperty(datablock, "shapeFile");
|
||||
const shapeType =
|
||||
className === "Turret" ? "Turret"
|
||||
: className === "Item" ? "Item"
|
||||
: className === "TSStatic" ? "TSStatic"
|
||||
: "StaticShape";
|
||||
|
||||
const entity: ShapeEntity = {
|
||||
...posBase,
|
||||
renderType: "Shape",
|
||||
shapeName,
|
||||
shapeType,
|
||||
dataBlock: datablockName || undefined,
|
||||
teamId,
|
||||
};
|
||||
|
||||
if (className === "Item") {
|
||||
entity.rotate = isTruthy(
|
||||
getProperty(object, "rotate") ?? getProperty(datablock, "rotate"),
|
||||
);
|
||||
}
|
||||
|
||||
if (className === "Turret") {
|
||||
const barrelName = getProperty(object, "initialBarrel");
|
||||
if (barrelName) {
|
||||
const barrelDb = resolveDatablock(runtime, barrelName);
|
||||
entity.barrelShapeName = getProperty(barrelDb, "shapeFile");
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
function buildForceFieldEntity(
|
||||
posBase: {
|
||||
id: string;
|
||||
className: string;
|
||||
runtimeObject: unknown;
|
||||
missionTypesList?: string;
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
},
|
||||
object: TorqueObject,
|
||||
datablock: TorqueObject | undefined,
|
||||
rawScale: [number, number, number] | undefined,
|
||||
): ForceFieldBareEntity {
|
||||
const colorStr = getProperty(datablock, "color");
|
||||
const color = colorStr
|
||||
? parseColor3(colorStr)
|
||||
: ([1, 1, 1] as [number, number, number]);
|
||||
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 textures: string[] = [];
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
const texturePath = getProperty(datablock, `texture${i}`);
|
||||
if (texturePath) {
|
||||
textures.push(texturePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ForceFieldBare uses "scale" as box dimensions, not as a transform scale.
|
||||
const dimensions = rawScale ?? [1, 1, 1];
|
||||
|
||||
return {
|
||||
...posBase,
|
||||
scale: undefined, // Don't apply scale as a group transform
|
||||
renderType: "ForceFieldBare",
|
||||
forceFieldData: {
|
||||
textures,
|
||||
color,
|
||||
baseTranslucency,
|
||||
numFrames,
|
||||
framesPerSec,
|
||||
scrollSpeed,
|
||||
umapping,
|
||||
vmapping,
|
||||
dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a TorqueObject tree and extract all GameEntities.
|
||||
* Respects team assignment from SimGroup hierarchy.
|
||||
*/
|
||||
export function walkMissionTree(
|
||||
root: TorqueObject,
|
||||
runtime: TorqueRuntime,
|
||||
teamId?: number,
|
||||
): GameEntity[] {
|
||||
const entities: GameEntity[] = [];
|
||||
|
||||
// Determine team from SimGroup hierarchy
|
||||
let currentTeam = teamId;
|
||||
if (root._className === "SimGroup") {
|
||||
if (root._name?.toLowerCase() === "teams") {
|
||||
currentTeam = undefined;
|
||||
} else if (currentTeam === undefined && root._name) {
|
||||
const match = root._name.match(/^team(\d+)$/i);
|
||||
if (match) {
|
||||
currentTeam = parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to build entity for this object
|
||||
const entity = buildGameEntityFromMission(root, runtime, currentTeam);
|
||||
if (entity) {
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
if (root._children) {
|
||||
for (const child of root._children) {
|
||||
entities.push(...walkMissionTree(child, runtime, currentTeam));
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
createMaterialFromFlags,
|
||||
applyShapeShaderModifications,
|
||||
} from "../components/GenericShape";
|
||||
import { isOrganicShape } from "../components/ShapeInfoProvider";
|
||||
import {
|
||||
loadIflAtlas,
|
||||
getFrameIndexForTime,
|
||||
|
|
@ -29,10 +30,7 @@ import {
|
|||
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
|
||||
import { loadTexture, setupTexture } from "../textureUtils";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type {
|
||||
DemoEntity,
|
||||
DemoKeyframe,
|
||||
} from "./types";
|
||||
import type { Keyframe } from "./types";
|
||||
|
||||
/** Fallback eye height when the player model isn't loaded or has no Cam node. */
|
||||
export const DEFAULT_EYE_HEIGHT = 2.1;
|
||||
|
|
@ -124,9 +122,9 @@ export function setQuaternionFromDir(dir: Vector3, out: Quaternion): void {
|
|||
|
||||
/** Binary search for the keyframe at or before the given time. */
|
||||
export function getKeyframeAtTime(
|
||||
keyframes: DemoKeyframe[],
|
||||
keyframes: Keyframe[],
|
||||
time: number,
|
||||
): DemoKeyframe | null {
|
||||
): Keyframe | null {
|
||||
if (keyframes.length === 0) return null;
|
||||
if (time <= keyframes[0].time) return keyframes[0];
|
||||
if (time >= keyframes[keyframes.length - 1].time)
|
||||
|
|
@ -247,6 +245,8 @@ export function smoothVertexNormals(geometry: BufferGeometry): void {
|
|||
|
||||
export interface ShapeMaterialResult {
|
||||
material: Material;
|
||||
/** Back-face material for organic/translucent two-pass rendering. */
|
||||
backMaterial?: Material;
|
||||
/** For IFL materials: loads atlas, configures texture, sets up animation. */
|
||||
initialize?: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
|
||||
}
|
||||
|
|
@ -260,6 +260,7 @@ export interface ShapeMaterialResult {
|
|||
export function replaceWithShapeMaterial(
|
||||
mat: MeshStandardMaterial,
|
||||
vis: number,
|
||||
isOrganic = false,
|
||||
): ShapeMaterialResult {
|
||||
const resourcePath: string | undefined = mat.userData?.resource_path;
|
||||
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
|
||||
|
|
@ -279,12 +280,22 @@ export function replaceWithShapeMaterial(
|
|||
// "Resource not found" warnings from textureToUrl, and return an initializer
|
||||
// that loads the atlas and sets up per-frame animation.
|
||||
if (flagNames.has("IflMaterial")) {
|
||||
const result = createMaterialFromFlags(mat, null, flagNames, false, vis);
|
||||
const material = Array.isArray(result) ? result[1] : result;
|
||||
const result = createMaterialFromFlags(
|
||||
mat, null, flagNames, isOrganic, vis,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
const material = result[1];
|
||||
return {
|
||||
material,
|
||||
backMaterial: result[0],
|
||||
initialize: (mesh, getTime) =>
|
||||
initializeIflMaterial(material, resourcePath, mesh, getTime),
|
||||
};
|
||||
}
|
||||
return {
|
||||
material,
|
||||
material: result,
|
||||
initialize: (mesh, getTime) =>
|
||||
initializeIflMaterial(material, resourcePath, mesh, getTime),
|
||||
initializeIflMaterial(result, resourcePath, mesh, getTime),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -293,13 +304,20 @@ export function replaceWithShapeMaterial(
|
|||
// Three.js re-renders automatically once loaded.
|
||||
const url = textureToUrl(resourcePath);
|
||||
const texture = loadTexture(url);
|
||||
setupTexture(texture);
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
if (isOrganic || isTranslucent) {
|
||||
setupTexture(texture, { disableMipmaps: true });
|
||||
} else {
|
||||
setupTexture(texture);
|
||||
}
|
||||
|
||||
const result = createMaterialFromFlags(mat, texture, flagNames, false, vis);
|
||||
// createMaterialFromFlags may return a [back, front] pair for translucent
|
||||
// materials. Use the front material since we can't split meshes imperatively.
|
||||
const material = Array.isArray(result) ? result[1] : result;
|
||||
return { material };
|
||||
const result = createMaterialFromFlags(
|
||||
mat, texture, flagNames, isOrganic, vis,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
return { material: result[1], backMaterial: result[0] };
|
||||
}
|
||||
return { material: result };
|
||||
}
|
||||
|
||||
export interface IflInitializer {
|
||||
|
|
@ -338,8 +356,12 @@ async function initializeIflMaterial(
|
|||
* normals, and replace PBR materials with diffuse-only Lambert materials.
|
||||
* Returns IFL initializers for any IFL materials found.
|
||||
*/
|
||||
export function processShapeScene(scene: Object3D): IflInitializer[] {
|
||||
export function processShapeScene(
|
||||
scene: Object3D,
|
||||
shapeName?: string,
|
||||
): IflInitializer[] {
|
||||
const iflInitializers: IflInitializer[] = [];
|
||||
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
|
||||
|
||||
// Find skeleton for hull bone filtering.
|
||||
let skeleton: any = null;
|
||||
|
|
@ -350,6 +372,9 @@ export function processShapeScene(scene: Object3D): IflInitializer[] {
|
|||
? getHullBoneIndices(skeleton)
|
||||
: new Set<number>();
|
||||
|
||||
// Collect back-face meshes to add after traversal (can't modify during traverse).
|
||||
const backFaceMeshes: Array<{ parent: Object3D; mesh: any }> = [];
|
||||
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh) return;
|
||||
|
||||
|
|
@ -384,61 +409,37 @@ export function processShapeScene(scene: Object3D): IflInitializer[] {
|
|||
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
|
||||
if (Array.isArray(node.material)) {
|
||||
node.material = node.material.map((m: MeshStandardMaterial) => {
|
||||
const result = replaceWithShapeMaterial(m, vis);
|
||||
const result = replaceWithShapeMaterial(m, vis, isOrganic);
|
||||
if (result.initialize) {
|
||||
iflInitializers.push({ mesh: node, initialize: result.initialize });
|
||||
}
|
||||
if (result.backMaterial && node.parent) {
|
||||
const backMesh = node.clone();
|
||||
backMesh.material = result.backMaterial;
|
||||
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
|
||||
}
|
||||
return result.material;
|
||||
});
|
||||
} else if (node.material) {
|
||||
const result = replaceWithShapeMaterial(node.material, vis);
|
||||
const result = replaceWithShapeMaterial(node.material, vis, isOrganic);
|
||||
if (result.initialize) {
|
||||
iflInitializers.push({ mesh: node, initialize: result.initialize });
|
||||
}
|
||||
node.material = result.material;
|
||||
if (result.backMaterial && node.parent) {
|
||||
const backMesh = node.clone();
|
||||
backMesh.material = result.backMaterial;
|
||||
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return iflInitializers;
|
||||
}
|
||||
// Add back-face meshes for two-pass organic/translucent rendering.
|
||||
for (const { parent, mesh } of backFaceMeshes) {
|
||||
parent.add(mesh);
|
||||
}
|
||||
|
||||
export function buildStreamDemoEntity(
|
||||
id: string,
|
||||
type: string,
|
||||
dataBlock: string | undefined,
|
||||
visual: DemoEntity["visual"] | undefined,
|
||||
direction: DemoEntity["direction"] | undefined,
|
||||
weaponShape: string | undefined,
|
||||
playerName: string | undefined,
|
||||
className: string | undefined,
|
||||
ghostIndex: number | undefined,
|
||||
dataBlockId: number | undefined,
|
||||
shapeHint: string | undefined,
|
||||
explosionDataBlockId?: number,
|
||||
faceViewer?: boolean,
|
||||
): DemoEntity {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
dataBlock,
|
||||
visual,
|
||||
direction,
|
||||
weaponShape,
|
||||
playerName,
|
||||
className,
|
||||
ghostIndex,
|
||||
dataBlockId,
|
||||
shapeHint,
|
||||
explosionDataBlockId,
|
||||
faceViewer,
|
||||
keyframes: [
|
||||
{
|
||||
time: 0,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
},
|
||||
],
|
||||
};
|
||||
return iflInitializers;
|
||||
}
|
||||
|
||||
export function entityTypeColor(type: string): string {
|
||||
|
|
@ -2,20 +2,16 @@
|
|||
* Movement animation selection logic replicating Torque's
|
||||
* Player::pickActionAnimation() (player.cc:2280).
|
||||
*/
|
||||
|
||||
/** Torque falling threshold: Z velocity below this = falling. */
|
||||
const FALLING_THRESHOLD = -10;
|
||||
|
||||
/** Minimum velocity dot product to count as intentional movement. */
|
||||
const MOVE_THRESHOLD = 0.1;
|
||||
|
||||
export interface MoveAnimationResult {
|
||||
/** Engine alias name (e.g. "root", "run", "back", "side", "fall"). */
|
||||
animation: string;
|
||||
/** 1 for forward playback, -1 for reversed (right strafe). */
|
||||
timeScale: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract body yaw (Torque rotationZ) from a Three.js quaternion produced by
|
||||
* `playerYawToQuaternion()`. That function builds a Y-axis rotation:
|
||||
|
|
@ -25,7 +21,6 @@ export interface MoveAnimationResult {
|
|||
function quaternionToBodyYaw(q: [number, number, number, number]): number {
|
||||
return -2 * Math.atan2(q[1], q[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the movement animation for a player based on their velocity and
|
||||
* body orientation, matching Torque's pickActionAnimation().
|
||||
|
|
@ -40,35 +35,27 @@ export function pickMoveAnimation(
|
|||
if (!velocity) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
const [vx, vy, vz] = velocity;
|
||||
|
||||
// Falling: Torque Z velocity below threshold.
|
||||
if (vz < FALLING_THRESHOLD) {
|
||||
return { animation: "fall", timeScale: 1 };
|
||||
}
|
||||
|
||||
// Convert world velocity to player object space using body yaw.
|
||||
const yaw = quaternionToBodyYaw(rotation);
|
||||
const cosY = Math.cos(yaw);
|
||||
const sinY = Math.sin(yaw);
|
||||
|
||||
// Torque object space: localY = forward, localX = right.
|
||||
const localX = vx * cosY + vy * sinY;
|
||||
const localY = -vx * sinY + vy * cosY;
|
||||
|
||||
// Pick direction with largest dot product.
|
||||
const forwardDot = localY;
|
||||
const backDot = -localY;
|
||||
const leftDot = -localX;
|
||||
const rightDot = localX;
|
||||
|
||||
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
|
||||
|
||||
if (maxDot < MOVE_THRESHOLD) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
if (maxDot === forwardDot) {
|
||||
return { animation: "run", timeScale: 1 };
|
||||
}
|
||||
197
src/stream/relayClient.ts
Normal file
197
src/stream/relayClient.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type {
|
||||
ClientMessage,
|
||||
ClientMove,
|
||||
ServerMessage,
|
||||
ServerInfo,
|
||||
ConnectionStatus,
|
||||
} from "../../relay/types";
|
||||
|
||||
export type RelayEventHandler = {
|
||||
onOpen?: () => void;
|
||||
onStatus?: (status: ConnectionStatus, message?: string, connectSequence?: number, mapName?: string) => void;
|
||||
onServerList?: (servers: ServerInfo[]) => void;
|
||||
onGamePacket?: (data: Uint8Array) => void;
|
||||
/** Relay↔T2 server RTT. */
|
||||
onPing?: (ms: number) => void;
|
||||
/** Browser↔relay WebSocket RTT. */
|
||||
onWsPing?: (ms: number) => void;
|
||||
onError?: (message: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSocket client that connects to the relay server.
|
||||
* Handles JSON control messages and binary game packet forwarding.
|
||||
*/
|
||||
export class RelayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: RelayEventHandler;
|
||||
private url: string;
|
||||
private _connected = false;
|
||||
private wsPingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private smoothedWsPing = 0;
|
||||
|
||||
constructor(url: string, handlers: RelayEventHandler) {
|
||||
this.url = url;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[relay] WebSocket connected to", this.url);
|
||||
this._connected = true;
|
||||
this.startWsPing();
|
||||
this.handlers.onOpen?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// Binary message — game packet from server
|
||||
this.handlers.onGamePacket?.(new Uint8Array(event.data));
|
||||
} else {
|
||||
// JSON control message
|
||||
try {
|
||||
const message: ServerMessage = JSON.parse(event.data as string);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse relay message:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[relay] WebSocket disconnected");
|
||||
this._connected = false;
|
||||
this.stopWsPing();
|
||||
this.handlers.onClose?.();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
console.error("[relay] WebSocket error");
|
||||
this.handlers.onError?.("WebSocket connection error");
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(message: ServerMessage): void {
|
||||
switch (message.type) {
|
||||
case "serverList":
|
||||
this.handlers.onServerList?.(message.servers);
|
||||
break;
|
||||
case "status":
|
||||
this.handlers.onStatus?.(message.status, message.message, message.connectSequence, message.mapName);
|
||||
break;
|
||||
case "ping":
|
||||
this.handlers.onPing?.(message.ms);
|
||||
break;
|
||||
case "wsPong": {
|
||||
const rtt = Date.now() - message.ts;
|
||||
this.smoothedWsPing =
|
||||
this.smoothedWsPing === 0
|
||||
? rtt
|
||||
: this.smoothedWsPing * 0.5 + rtt * 0.5;
|
||||
this.handlers.onWsPing?.(Math.round(this.smoothedWsPing));
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
this.handlers.onError?.(message.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request the server list from the master server. */
|
||||
listServers(): void {
|
||||
this.send({ type: "listServers" });
|
||||
}
|
||||
|
||||
/** Send a WebSocket ping to measure browser↔relay RTT. */
|
||||
sendWsPing(): void {
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
}
|
||||
|
||||
/** Join a specific game server. */
|
||||
joinServer(address: string): void {
|
||||
console.log("[relay] Joining server:", address);
|
||||
this.send({ type: "joinServer", address });
|
||||
}
|
||||
|
||||
/** Disconnect from the current game server. */
|
||||
disconnectServer(): void {
|
||||
this.send({ type: "disconnect" });
|
||||
}
|
||||
|
||||
/** Forward a T2csri auth event to the relay. */
|
||||
sendAuthEvent(command: string, args: string[]): void {
|
||||
this.send({ type: "sendCommand", command, args });
|
||||
}
|
||||
|
||||
/** Send a commandToServer through the relay. */
|
||||
sendCommand(command: string, args: string[]): void {
|
||||
this.send({ type: "sendCommand", command, args });
|
||||
}
|
||||
|
||||
/** Send a CRC challenge response through the relay (legacy echo). */
|
||||
sendCRCResponse(crcValue: number, field1: number, field2: number): void {
|
||||
this.send({ type: "sendCRCResponse", crcValue, field1, field2 });
|
||||
}
|
||||
|
||||
/** Send datablock info for relay-side CRC computation over game files. */
|
||||
sendCRCCompute(
|
||||
seed: number,
|
||||
field2: number,
|
||||
datablocks: { objectId: number; className: string; shapeName: string }[],
|
||||
includeTextures: boolean,
|
||||
): void {
|
||||
this.send({ type: "sendCRCCompute", seed, field2, includeTextures, datablocks });
|
||||
}
|
||||
|
||||
/** Send a GhostAlwaysDone acknowledgment through the relay. */
|
||||
sendGhostAck(sequence: number, ghostCount: number): void {
|
||||
this.send({ type: "sendGhostAck", sequence, ghostCount });
|
||||
}
|
||||
|
||||
/** Send a move struct to the relay for forwarding to the game server. */
|
||||
sendMove(move: ClientMove): void {
|
||||
this.send({ type: "sendMove", move });
|
||||
}
|
||||
|
||||
/** Close the WebSocket connection entirely. */
|
||||
close(): void {
|
||||
this.stopWsPing();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
private startWsPing(): void {
|
||||
this.smoothedWsPing = 0;
|
||||
// Send immediately so we have a measurement before the server list arrives.
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
this.wsPingInterval = setInterval(() => {
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
}, 7000);
|
||||
}
|
||||
|
||||
private stopWsPing(): void {
|
||||
if (this.wsPingInterval != null) {
|
||||
clearInterval(this.wsPingInterval);
|
||||
this.wsPingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: ClientMessage): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn("[relay] send dropped (ws not open):", message.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
src/stream/streamHelpers.ts
Normal file
468
src/stream/streamHelpers.ts
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import { Matrix4, Quaternion } from "three";
|
||||
import type {
|
||||
StreamVisual,
|
||||
WeaponImageDataBlockState,
|
||||
ChatSegment,
|
||||
} from "./types";
|
||||
import { projectileClassNames } from "./entityClassification";
|
||||
|
||||
export type Vec3 = { x: number; y: number; z: number };
|
||||
|
||||
// ── Math helpers ──
|
||||
|
||||
const _rotMat = new Matrix4();
|
||||
const _rotQuat = new Quaternion();
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const MAX_PITCH = Math.PI * 0.494;
|
||||
export const CameraMode_OrbitObject = 3;
|
||||
|
||||
/**
|
||||
* Build a Three.js quaternion from Torque observer yaw/pitch angles.
|
||||
* Uses a shared Matrix4/Quaternion to avoid per-frame allocations.
|
||||
*/
|
||||
export function yawPitchToQuaternion(
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
): [number, number, number, number] {
|
||||
const sx = Math.sin(pitch);
|
||||
const cx = Math.cos(pitch);
|
||||
const sz = Math.sin(yaw);
|
||||
const cz = Math.cos(yaw);
|
||||
|
||||
_rotMat.set(
|
||||
-sz, cz * sx, -cz * cx, 0,
|
||||
0, cx, sx, 0,
|
||||
cz, sz * sx, -sz * cx, 0,
|
||||
0, 0, 0, 1,
|
||||
);
|
||||
|
||||
_rotQuat.setFromRotationMatrix(_rotMat);
|
||||
return [_rotQuat.x, _rotQuat.y, _rotQuat.z, _rotQuat.w];
|
||||
}
|
||||
|
||||
/** Player body rotation: yaw only, around Three.js Y axis. */
|
||||
export function playerYawToQuaternion(
|
||||
rotZ: number,
|
||||
): [number, number, number, number] {
|
||||
const halfAngle = -rotZ / 2;
|
||||
return [0, Math.sin(halfAngle), 0, Math.cos(halfAngle)];
|
||||
}
|
||||
|
||||
/** Convert a Torque quaternion (x-right, y-forward, z-up) to Three.js. */
|
||||
export function torqueQuatToThreeJS(q: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}): [number, number, number, number] | null {
|
||||
if (
|
||||
!Number.isFinite(q.x) ||
|
||||
!Number.isFinite(q.y) ||
|
||||
!Number.isFinite(q.z) ||
|
||||
!Number.isFinite(q.w)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Axis swizzle (x,y,z)->(y,z,x) and inverted rotation direction.
|
||||
const x = -q.y;
|
||||
const y = -q.z;
|
||||
const z = -q.x;
|
||||
const w = q.w;
|
||||
|
||||
const lenSq = x * x + y * y + z * z + w * w;
|
||||
if (lenSq <= 1e-12) return null;
|
||||
|
||||
const invLen = 1 / Math.sqrt(lenSq);
|
||||
return [x * invLen, y * invLen, z * invLen, w * invLen];
|
||||
}
|
||||
|
||||
// ── Position / type guards ──
|
||||
|
||||
export function isValidPosition(
|
||||
pos: { x: number; y: number; z: number } | undefined | null,
|
||||
): pos is { x: number; y: number; z: number } {
|
||||
return (
|
||||
pos != null &&
|
||||
Number.isFinite(pos.x) &&
|
||||
Number.isFinite(pos.y) &&
|
||||
Number.isFinite(pos.z)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVec3Like(
|
||||
value: unknown,
|
||||
): value is { x: number; y: number; z: number } {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { x?: unknown }).x === "number" &&
|
||||
typeof (value as { y?: unknown }).y === "number" &&
|
||||
typeof (value as { z?: unknown }).z === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isQuatLike(value: unknown): value is {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
} {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { x?: unknown }).x === "number" &&
|
||||
typeof (value as { y?: unknown }).y === "number" &&
|
||||
typeof (value as { z?: unknown }).z === "number" &&
|
||||
typeof (value as { w?: unknown }).w === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── DataBlock field accessors ──
|
||||
|
||||
/**
|
||||
* Resolve the DTS shape path from a datablock's parsed data.
|
||||
* Accepts either a ghost className (e.g. "LinearProjectile") or a datablock
|
||||
* className (e.g. "LinearProjectileData") to determine which field holds the
|
||||
* shape path.
|
||||
*/
|
||||
export function resolveShapeName(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
let value: unknown;
|
||||
if (
|
||||
projectileClassNames.has(className) ||
|
||||
className.endsWith("ProjectileData")
|
||||
) {
|
||||
value = data.projectileShapeName;
|
||||
} else if (className === "DebrisData") {
|
||||
value = data.shapeFileName;
|
||||
} else {
|
||||
value = data.shapeName;
|
||||
}
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function getNumberField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): number | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStringField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBooleanField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): boolean | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Visual resolution ──
|
||||
|
||||
export function resolveTracerVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const texture =
|
||||
getStringField(data, ["tracerTex0", "textureName0", "texture0"]) ?? "";
|
||||
const hasTracerHints =
|
||||
className === "TracerProjectile" ||
|
||||
(texture.length > 0 && getNumberField(data, ["tracerLength"]) != null);
|
||||
if (!hasTracerHints || !texture) return undefined;
|
||||
|
||||
const crossTexture = getStringField(data, [
|
||||
"tracerTex1",
|
||||
"textureName1",
|
||||
"texture1",
|
||||
]);
|
||||
|
||||
const tracerLength = getNumberField(data, ["tracerLength"]) ?? 10;
|
||||
const canonicalTracerWidth = getNumberField(data, ["tracerWidth"]);
|
||||
const aliasTracerWidth = getNumberField(data, ["tracerAlpha"]);
|
||||
const tracerWidth =
|
||||
canonicalTracerWidth != null &&
|
||||
(getNumberField(data, ["crossViewAng"]) != null ||
|
||||
canonicalTracerWidth <= 0.7)
|
||||
? canonicalTracerWidth
|
||||
: (aliasTracerWidth ?? canonicalTracerWidth ?? 0.5);
|
||||
const crossViewAng =
|
||||
getNumberField(data, ["crossViewAng", "crossViewFraction"]) ??
|
||||
(typeof data.tracerWidth === "number" && data.tracerWidth > 0.7
|
||||
? data.tracerWidth
|
||||
: 0.98);
|
||||
const crossSize =
|
||||
getNumberField(data, ["crossSize", "muzzleVelocity"]) ?? 0.45;
|
||||
const renderCross =
|
||||
getBooleanField(data, ["renderCross", "proximityRadius"]) ?? true;
|
||||
|
||||
return {
|
||||
kind: "tracer",
|
||||
texture,
|
||||
crossTexture,
|
||||
tracerLength,
|
||||
tracerWidth,
|
||||
crossViewAng,
|
||||
crossSize,
|
||||
renderCross,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSpriteVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
if (className === "LinearFlareProjectile") {
|
||||
const texture = getStringField(data, ["smokeTexture", "flareTexture"]);
|
||||
if (!texture) return undefined;
|
||||
const color = data.flareColor as
|
||||
| { r: number; g: number; b: number }
|
||||
| undefined;
|
||||
const size = getNumberField(data, ["size"]) ?? 0.5;
|
||||
return {
|
||||
kind: "sprite",
|
||||
texture,
|
||||
color: color
|
||||
? { r: color.r, g: color.g, b: color.b }
|
||||
: { r: 1, g: 1, b: 1 },
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
if (className === "FlareProjectile") {
|
||||
const texture = getStringField(data, ["flareTexture"]);
|
||||
if (!texture) return undefined;
|
||||
const size = getNumberField(data, ["size"]) ?? 4.0;
|
||||
return {
|
||||
kind: "sprite",
|
||||
texture,
|
||||
color: { r: 1, g: 0.9, b: 0.5 },
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Weapon image state parsing ──
|
||||
|
||||
/**
|
||||
* Parse weapon image state machine from a ShapeBaseImageData datablock.
|
||||
*
|
||||
* CRITICAL: The parser's field names for transitions are MISALIGNED with
|
||||
* the actual engine packing order. See demoStreaming.ts for details on the
|
||||
* remap table.
|
||||
*/
|
||||
export function parseWeaponImageStates(
|
||||
blockData: Record<string, unknown>,
|
||||
): WeaponImageDataBlockState[] | undefined {
|
||||
const rawStates = blockData.states as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
if (!Array.isArray(rawStates) || rawStates.length === 0) return undefined;
|
||||
|
||||
return rawStates.map((s) => {
|
||||
const remap = (v: unknown): number => {
|
||||
const n = v as number;
|
||||
if (n == null) return -1;
|
||||
return n - 1;
|
||||
};
|
||||
|
||||
return {
|
||||
name: (s.name as string) ?? "",
|
||||
transitionOnNotLoaded: remap(s.transitionOnAmmo),
|
||||
transitionOnLoaded: remap(s.transitionOnNoAmmo),
|
||||
transitionOnNoAmmo: remap(s.transitionOnTarget),
|
||||
transitionOnAmmo: remap(s.transitionOnNoTarget),
|
||||
transitionOnNoTarget: remap(s.transitionOnWet),
|
||||
transitionOnTarget: remap(s.transitionOnNotWet),
|
||||
transitionOnNotWet: remap(s.transitionOnTriggerUp),
|
||||
transitionOnWet: remap(s.transitionOnTriggerDown),
|
||||
transitionOnTriggerUp: remap(s.transitionOnTimeout),
|
||||
transitionOnTriggerDown: remap(s.transitionGeneric0In),
|
||||
transitionOnTimeout: remap(s.transitionGeneric0Out),
|
||||
timeoutValue: s.timeoutValue as number | undefined,
|
||||
waitForTimeout: (s.waitForTimeout as boolean) ?? false,
|
||||
fire: (s.fire as boolean) ?? false,
|
||||
sequence: s.sequence as number | undefined,
|
||||
spin: (s.spin as number) ?? 0,
|
||||
direction: (s.direction as boolean) ?? true,
|
||||
scaleAnimation: (s.scaleAnimation as boolean) ?? false,
|
||||
loaded: (s.loaded as number) ?? 0,
|
||||
soundDataBlockId: (s.sound as number) ?? -1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Chat / text helpers ──
|
||||
|
||||
/** Strip non-printable Torque tagged string markup from a string. */
|
||||
export function stripTaggedStringMarkup(s: string): string {
|
||||
let stripped = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s.charCodeAt(i) >= 0x20) stripped += s[i];
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-to-fontColors-index remap table from the Torque V12 renderer (dgl.cc).
|
||||
*
|
||||
* TorqueScript `\cN` escapes are encoded via `collapseRemap` in scan.l,
|
||||
* producing byte values that skip \t (0x9), \n (0xa), and \r (0xd).
|
||||
*/
|
||||
const BYTE_TO_COLOR_INDEX: Record<number, number> = {
|
||||
0x2: 0, 0x3: 1, 0x4: 2, 0x5: 3, 0x6: 4,
|
||||
0x7: 5, 0x8: 6, 0xb: 7, 0xc: 8, 0xe: 9,
|
||||
};
|
||||
|
||||
const BYTE_COLOR_RESET = 0x0f;
|
||||
const BYTE_COLOR_PUSH = 0x10;
|
||||
const BYTE_COLOR_POP = 0x11;
|
||||
|
||||
/**
|
||||
* Extract the leading Torque \c color index (0–9) from a tagged string.
|
||||
*/
|
||||
export function detectColorCode(s: string): number | undefined {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
const colorIndex = BYTE_TO_COLOR_INDEX[code];
|
||||
if (colorIndex !== undefined) return colorIndex;
|
||||
if (code >= 0x20) return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Parse a raw Torque HudMessageVector line into colored segments. */
|
||||
export function parseColorSegments(raw: string): ChatSegment[] {
|
||||
const segments: ChatSegment[] = [];
|
||||
let currentColor = 0;
|
||||
let currentText = "";
|
||||
let inTaggedString = false;
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const code = raw.charCodeAt(i);
|
||||
|
||||
if (code === BYTE_COLOR_PUSH) {
|
||||
inTaggedString = true;
|
||||
continue;
|
||||
}
|
||||
if (code === BYTE_COLOR_POP) {
|
||||
inTaggedString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTaggedString) {
|
||||
if (code >= 0x20) currentText += raw[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const colorIndex = BYTE_TO_COLOR_INDEX[code];
|
||||
if (colorIndex !== undefined) {
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
currentText = "";
|
||||
}
|
||||
currentColor = colorIndex;
|
||||
} else if (code === BYTE_COLOR_RESET) {
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
currentText = "";
|
||||
}
|
||||
currentColor = 0;
|
||||
} else if (code >= 0x20) {
|
||||
currentText += raw[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Extract an embedded `~w<path>` sound tag from a message string. */
|
||||
export function extractWavTag(
|
||||
text: string,
|
||||
): { text: string; wavPath: string | null } {
|
||||
const idx = text.indexOf("~w");
|
||||
if (idx === -1) return { text, wavPath: null };
|
||||
return {
|
||||
text: text.substring(0, idx),
|
||||
wavPath: text.substring(idx + 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Control object detection ──
|
||||
|
||||
export type ControlObjectType = "camera" | "player";
|
||||
|
||||
export function detectControlObjectType(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): ControlObjectType | null {
|
||||
if (!data) return null;
|
||||
if (typeof data.cameraMode === "number") return "camera";
|
||||
if (typeof data.rotationZ === "number") return "player";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Backpack HUD ──
|
||||
|
||||
const BACKPACK_BITMAP_TO_INDEX = new Map<string, number>([
|
||||
["gui/hud_new_packammo", 0],
|
||||
["gui/hud_new_packcloak", 1],
|
||||
["gui/hud_new_packenergy", 2],
|
||||
["gui/hud_new_packrepair", 3],
|
||||
["gui/hud_new_packsatchel", 4],
|
||||
["gui/hud_new_packshield", 5],
|
||||
["gui/hud_new_packinventory", 6],
|
||||
["gui/hud_new_packmotionsens", 7],
|
||||
["gui/hud_new_packradar", 8],
|
||||
["gui/hud_new_packturretout", 9],
|
||||
["gui/hud_new_packturretin", 10],
|
||||
["gui/hud_new_packsensjam", 11],
|
||||
["gui/hud_new_packturret", 12],
|
||||
["gui/hud_satchel_unarmed", 18],
|
||||
]);
|
||||
|
||||
export function backpackBitmapToIndex(bitmap: string): number {
|
||||
const lower = bitmap.toLowerCase();
|
||||
for (const [key, val] of BACKPACK_BITMAP_TO_INDEX) {
|
||||
if (key === lower) return val;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
export interface DemoThreadState {
|
||||
import type { SceneObject } from "../scene/types";
|
||||
|
||||
/** DTS animation thread state from ghost ThreadMask data. */
|
||||
export interface ThreadState {
|
||||
index: number;
|
||||
sequence: number;
|
||||
state: number;
|
||||
|
|
@ -41,7 +44,7 @@ export interface WeaponImageDataBlockState {
|
|||
soundDataBlockId: number;
|
||||
}
|
||||
|
||||
export interface DemoKeyframe {
|
||||
export interface Keyframe {
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position: [number, number, number];
|
||||
|
|
@ -64,7 +67,7 @@ export interface DemoKeyframe {
|
|||
actionAtEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface DemoTracerVisual {
|
||||
export interface TracerVisual {
|
||||
kind: "tracer";
|
||||
/** Main tracer streak texture (e.g. "special/tracer00"). */
|
||||
texture: string;
|
||||
|
|
@ -77,7 +80,7 @@ export interface DemoTracerVisual {
|
|||
renderCross: boolean;
|
||||
}
|
||||
|
||||
export interface DemoSpriteVisual {
|
||||
export interface SpriteVisual {
|
||||
kind: "sprite";
|
||||
/** Sprite texture (e.g. "flarebase"). */
|
||||
texture: string;
|
||||
|
|
@ -87,67 +90,13 @@ export interface DemoSpriteVisual {
|
|||
size: number;
|
||||
}
|
||||
|
||||
export type DemoVisual = DemoTracerVisual | DemoSpriteVisual;
|
||||
export type StreamVisual = TracerVisual | SpriteVisual;
|
||||
|
||||
export interface DemoEntity {
|
||||
id: number | string;
|
||||
type: string;
|
||||
dataBlock?: string;
|
||||
visual?: DemoVisual;
|
||||
/** Projectile forward direction in Torque space [x, y, z]. */
|
||||
direction?: [number, number, number];
|
||||
/** Ghost index for streamed entities (debug/inspection). */
|
||||
ghostIndex?: number;
|
||||
/** Ghost class name for streamed entities (debug/inspection). */
|
||||
className?: string;
|
||||
/** Last seen datablock object id (debug/inspection). */
|
||||
dataBlockId?: number;
|
||||
/** Datablock-derived shape hint, if any (debug/inspection). */
|
||||
shapeHint?: string;
|
||||
/** Time (seconds) when this entity enters ghost scope. */
|
||||
spawnTime?: number;
|
||||
/** Time (seconds) when this entity leaves ghost scope. */
|
||||
despawnTime?: number;
|
||||
keyframes: DemoKeyframe[];
|
||||
/** DTS animation thread states from ghost ThreadMask data. */
|
||||
threads?: DemoThreadState[];
|
||||
/** Weapon shape file name for Player entities (e.g. "weapon_disc.dts"). */
|
||||
weaponShape?: string;
|
||||
/** Player name resolved from the target system string table. */
|
||||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
/** Target render flags bitmask from the Target Manager. */
|
||||
targetRenderFlags?: number;
|
||||
/** Weapon image condition flags from ghost ImageMask data. */
|
||||
weaponImageState?: WeaponImageState;
|
||||
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
/** Head pitch for blend animations, normalized [-1,1]. -1 = max down, 1 = max up. */
|
||||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
|
||||
headYaw?: number;
|
||||
/** Numeric ID of the ExplosionData datablock (for explosion shape rendering). */
|
||||
explosionDataBlockId?: number;
|
||||
/** Billboard toward camera (Torque's faceViewer). */
|
||||
faceViewer?: boolean;
|
||||
}
|
||||
|
||||
export interface DemoRecording {
|
||||
duration: number;
|
||||
/** Mission name as it appears in the demo (e.g. "S5-WoodyMyrk"). */
|
||||
missionName: string | null;
|
||||
/** Game type display name from the demo (e.g. "Capture the Flag"). */
|
||||
gameType: string | null;
|
||||
/** Streaming parser session used for Move-tick-driven playback. */
|
||||
streamingPlayback: DemoStreamingPlayback;
|
||||
}
|
||||
|
||||
export interface DemoStreamEntity {
|
||||
export interface StreamEntity {
|
||||
id: string;
|
||||
type: string;
|
||||
dataBlock?: string;
|
||||
visual?: DemoVisual;
|
||||
visual?: StreamVisual;
|
||||
direction?: [number, number, number];
|
||||
weaponShape?: string;
|
||||
playerName?: string;
|
||||
|
|
@ -172,7 +121,7 @@ export interface DemoStreamEntity {
|
|||
damageState?: number;
|
||||
faceViewer?: boolean;
|
||||
/** DTS animation thread states from ghost ThreadMask data. */
|
||||
threads?: DemoThreadState[];
|
||||
threads?: ThreadState[];
|
||||
/** Numeric ID of the ExplosionData datablock (for particle effect resolution). */
|
||||
explosionDataBlockId?: number;
|
||||
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
|
||||
|
|
@ -185,9 +134,22 @@ export interface DemoStreamEntity {
|
|||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
|
||||
headYaw?: number;
|
||||
/** WayPoint display label. */
|
||||
label?: string;
|
||||
// AudioEmitter ghost fields
|
||||
audioFileName?: string;
|
||||
audioVolume?: number;
|
||||
audioIs3D?: boolean;
|
||||
audioIsLooping?: boolean;
|
||||
audioMinDistance?: number;
|
||||
audioMaxDistance?: number;
|
||||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
/** Scene infrastructure data (terrain, interior, sky, etc.). */
|
||||
sceneData?: SceneObject;
|
||||
}
|
||||
|
||||
export interface DemoStreamCamera {
|
||||
export interface StreamCamera {
|
||||
/** Timestamp in seconds for the current camera state. */
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
|
|
@ -213,7 +175,7 @@ export interface ChatSegment {
|
|||
colorCode: number;
|
||||
}
|
||||
|
||||
export interface DemoChatMessage {
|
||||
export interface ChatMessage {
|
||||
timeSec: number;
|
||||
sender: string;
|
||||
text: string;
|
||||
|
|
@ -268,16 +230,16 @@ export interface PendingAudioEvent {
|
|||
timeSec: number;
|
||||
}
|
||||
|
||||
export interface DemoStreamSnapshot {
|
||||
export interface StreamSnapshot {
|
||||
timeSec: number;
|
||||
exhausted: boolean;
|
||||
camera: DemoStreamCamera | null;
|
||||
entities: DemoStreamEntity[];
|
||||
camera: StreamCamera | null;
|
||||
entities: StreamEntity[];
|
||||
controlPlayerGhostId?: string;
|
||||
/** Recording player's sensor group (team number). */
|
||||
playerSensorGroup: number;
|
||||
status: { health: number; energy: number };
|
||||
chatMessages: DemoChatMessage[];
|
||||
chatMessages: ChatMessage[];
|
||||
/** One-shot audio events from Sim3DAudioEvent / Sim2DAudioEvent. */
|
||||
audioEvents: PendingAudioEvent[];
|
||||
/** Weapons HUD state from inventory RemoteCommandEvents. */
|
||||
|
|
@ -298,12 +260,29 @@ export interface DemoStreamSnapshot {
|
|||
teamScores: TeamScore[];
|
||||
}
|
||||
|
||||
export interface DemoStreamingPlayback {
|
||||
export interface StreamingPlayback {
|
||||
reset(): void;
|
||||
getSnapshot(): DemoStreamSnapshot;
|
||||
stepToTime(targetTimeSec: number, maxMoveTicks?: number): DemoStreamSnapshot;
|
||||
getSnapshot(): StreamSnapshot;
|
||||
stepToTime(targetTimeSec: number, maxMoveTicks?: number): StreamSnapshot;
|
||||
/** DTS shape names for weapon effects (explosions) that should be preloaded. */
|
||||
getEffectShapes(): string[];
|
||||
/** Resolve a datablock by its numeric ID. */
|
||||
getDataBlockData(id: number): Record<string, unknown> | undefined;
|
||||
/**
|
||||
* Get TSShapeConstructor sequence entries for a shape (e.g. "heavy_male.dts").
|
||||
* Returns the raw sequence strings like `"heavy_male_root.dsq root"`.
|
||||
*/
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined;
|
||||
}
|
||||
|
||||
export interface StreamRecording {
|
||||
/** "demo" for .rec file playback, "live" for live server observation. */
|
||||
source: "demo" | "live";
|
||||
duration: number;
|
||||
/** Mission name (e.g. "S5-WoodyMyrk"). */
|
||||
missionName: string | null;
|
||||
/** Game type display name (e.g. "Capture the Flag"). */
|
||||
gameType: string | null;
|
||||
/** Streaming parser session for tick-driven playback. */
|
||||
streamingPlayback: StreamingPlayback;
|
||||
}
|
||||
|
|
@ -30,8 +30,8 @@ export function getTerrainHeightAt(
|
|||
* Uses bilinear interpolation and clamps to terrain bounds.
|
||||
*
|
||||
* Coordinate mapping (derived from terrain geometry rotations):
|
||||
* - Torque X → heightmap row
|
||||
* - Torque Y → heightmap col
|
||||
* - Torque X → heightmap col
|
||||
* - Torque Y → heightmap row
|
||||
*/
|
||||
export function createTerrainHeightSampler(
|
||||
heightMap: Uint16Array,
|
||||
|
|
@ -41,8 +41,11 @@ export function createTerrainHeightSampler(
|
|||
// Convert Torque world coords to fractional heightmap coords.
|
||||
// The terrain origin is at (-squareSize * 128, -squareSize * 128, 0),
|
||||
// so grid center (128, 128) corresponds to Torque (0, 0).
|
||||
const col = torqueY / squareSize + HALF_SIZE;
|
||||
const row = torqueX / squareSize + HALF_SIZE;
|
||||
// Row/col mapping must match the terrain geometry displacement:
|
||||
// after UV flip and geometry rotations, row corresponds to Torque Y
|
||||
// and col corresponds to Torque X.
|
||||
const col = torqueX / squareSize + HALF_SIZE;
|
||||
const row = torqueY / squareSize + HALF_SIZE;
|
||||
|
||||
// Clamp to valid range
|
||||
const clampedCol = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { TorqueRuntime } from "./types";
|
|||
*/
|
||||
export type SequenceAliasMap = Map<string, Map<string, string>>;
|
||||
|
||||
|
||||
/**
|
||||
* Build sequence alias maps from TSShapeConstructor datablocks already
|
||||
* registered in the runtime. Each datablock has `baseshape` and
|
||||
|
|
@ -64,6 +65,7 @@ export function buildSequenceAliasMap(runtime: TorqueRuntime): SequenceAliasMap
|
|||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a case-insensitive action map from GLB clips, augmented with
|
||||
* TSShapeConstructor aliases. Both the original clip name and the alias
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue