improve audio support

This commit is contained in:
Brian Beck 2026-03-04 12:15:24 -08:00
parent d1acb6a5ce
commit cb28b66dad
5587 changed files with 4538 additions and 2846 deletions

View file

@ -1,6 +1,13 @@
import { memo, useEffect, useRef } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { PositionalAudio, Vector3 } from "three";
import {
Audio,
AudioListener,
AudioLoader,
Object3D,
PositionalAudio,
Vector3,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
import { audioToUrl } from "../loaders";
@ -8,12 +15,103 @@ import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel";
// Global audio buffer cache
const audioBufferCache = new Map<string, AudioBuffer>();
// Global audio buffer cache shared across all audio components.
export const audioBufferCache = new Map<string, AudioBuffer>();
function getCachedAudioBuffer(
export interface ResolvedAudioProfile {
filename: string;
is3D: boolean;
isLooping: boolean;
refDist: number;
maxDist: number;
volume: number;
}
/**
* Resolve an AudioProfile datablock ID to its playback parameters by following
* the AudioProfile AudioDescription chain.
*/
export function resolveAudioProfile(
audioProfileId: number,
getDb: (id: number) => Record<string, unknown> | undefined,
): ResolvedAudioProfile | null {
const profileBlock = getDb(audioProfileId);
const rawFilename = profileBlock?.filename as string | undefined;
if (!rawFilename) return null;
const filename = rawFilename.endsWith(".wav")
? rawFilename
: `${rawFilename}.wav`;
const descId = profileBlock.description as number | null;
const descBlock = descId != null ? getDb(descId) : undefined;
const is3D = (descBlock?.is3D as boolean) ?? true;
const isLooping = (descBlock?.isLooping as boolean) ?? false;
const refDist = (descBlock?.referenceDistance as number) ?? 20;
const maxDist = (descBlock?.maxDistance as number) ?? 100;
const volume = (descBlock?.volume as number) ?? 1;
return { filename, is3D, isLooping, refDist, maxDist, volume };
}
/**
* Play a one-shot sound effect. For 3D sounds, creates a PositionalAudio
* attached to `parent` (at `position` if given, otherwise at the parent's
* origin); for 2D, creates a non-positional Audio. Self-cleans on end.
*/
export function playOneShotSound(
resolved: ResolvedAudioProfile,
audioListener: AudioListener,
audioLoader: AudioLoader,
position?: Vector3,
parent?: Object3D,
): void {
let url: string;
try {
url = audioToUrl(resolved.filename);
} catch {
// File not in manifest — skip silently.
return;
}
getCachedAudioBuffer(url, audioLoader, (buffer) => {
try {
if (resolved.is3D && parent) {
const sound = new PositionalAudio(audioListener);
sound.setBuffer(buffer);
// Torque uses inverse distance: gain = refDist / distance,
// hard cutoff at maxDistance. Web Audio's "inverse" model matches.
sound.setDistanceModel("inverse");
sound.setRefDistance(resolved.refDist);
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
if (position) {
sound.position.copy(position);
}
parent.add(sound);
sound.play();
sound.source!.onended = () => {
sound.disconnect();
parent.remove(sound);
};
} else {
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
sound.setVolume(resolved.volume);
sound.play();
sound.source!.onended = () => {
sound.disconnect();
};
}
} catch {
// Playback failure (e.g. suspended AudioContext) — skip silently.
}
});
}
export function getCachedAudioBuffer(
audioUrl: string,
audioLoader: any,
audioLoader: AudioLoader,
onLoad: (buffer: AudioBuffer) => void,
) {
if (audioBufferCache.has(audioUrl)) {
@ -27,7 +125,7 @@ function getCachedAudioBuffer(
},
undefined,
(err: any) => {
console.error("AudioEmitter: Audio load error", audioUrl, err);
console.error("Audio load error", audioUrl, err);
},
);
}
@ -52,46 +150,45 @@ export const AudioEmitter = memo(function AudioEmitter({
const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings();
const soundRef = useRef<PositionalAudio | null>(null);
const soundRef = useRef<Audio<GainNode | PannerNode> | null>(null);
const loopTimerRef = useRef<NodeJS.Timeout | null>(null);
const loopGapIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isLoadedRef = useRef(false);
const isInRangeRef = useRef(false);
const emitterPosRef = useRef(new Vector3(x, y, z));
// Create sound object on mount
const clearTimers = () => {
if (loopTimerRef.current) clearTimeout(loopTimerRef.current);
if (loopGapIntervalRef.current) clearTimeout(loopGapIntervalRef.current);
};
// Create sound object on mount.
useEffect(() => {
if (!audioLoader || !audioListener) return;
// Always use PositionalAudio for consistent interface
const sound = new PositionalAudio(audioListener);
sound.position.copy(emitterPosRef.current);
// Configure distance properties
let sound: Audio<GainNode | PannerNode>;
if (is3D) {
sound.setDistanceModel("exponential");
sound.setRefDistance(minDistance / 20);
sound.setMaxDistance(maxDistance / 25);
sound.setVolume(volume);
const positional = new PositionalAudio(audioListener);
positional.position.copy(emitterPosRef.current);
positional.setDistanceModel("inverse");
positional.setRefDistance(minDistance);
positional.setMaxDistance(maxDistance);
positional.setRolloffFactor(1);
positional.setVolume(volume);
sound = positional;
scene.add(sound);
} else {
// No attenuation: very large max distance
sound.setDistanceModel("linear");
sound.setRefDistance(1);
sound.setMaxDistance(2000000);
sound.setVolume(volume / 15);
sound = new Audio(audioListener);
sound.setVolume(volume);
}
soundRef.current = sound;
scene.add(sound);
return () => {
if (loopTimerRef.current) clearTimeout(loopTimerRef.current);
if (loopGapIntervalRef.current) clearTimeout(loopGapIntervalRef.current);
try {
sound.stop();
} catch (e) {}
clearTimers();
try { sound.stop(); } catch {}
sound.disconnect();
scene.remove(sound);
if (is3D) scene.remove(sound);
isLoadedRef.current = false;
isInRangeRef.current = false;
};
@ -105,8 +202,8 @@ export const AudioEmitter = memo(function AudioEmitter({
scene,
]);
// Setup looping logic (only called when audio loads)
const setupLooping = (sound: PositionalAudio) => {
// Setup looping logic (only called when audio loads).
const setupLooping = (sound: Audio<GainNode | PannerNode>) => {
if (minLoopGap > 0 || maxLoopGap > 0) {
const gapMin = Math.max(0, minLoopGap);
const gapMax = Math.max(gapMin, maxLoopGap);
@ -121,7 +218,7 @@ export const AudioEmitter = memo(function AudioEmitter({
try {
sound.play();
setupLooping(sound);
} catch (err) {}
} catch {}
}, gap);
} else {
loopGapIntervalRef.current = setTimeout(checkLoop, 100);
@ -133,69 +230,65 @@ export const AudioEmitter = memo(function AudioEmitter({
}
};
// Check proximity and load/unload audio
useFrame(() => {
const sound = soundRef.current;
if (!sound || !audioEnabled || !fileName) return;
const cameraPos = camera.position;
const emitterPos = emitterPosRef.current;
const distance = cameraPos.distanceTo(emitterPos);
const loadRadius = maxDistance; // Scale down by 10 like visualization
const wasInRange = isInRangeRef.current;
const isNowInRange = distance <= loadRadius;
// Entering range: load and play
if (isNowInRange && !wasInRange) {
isInRangeRef.current = true;
if (!isLoadedRef.current) {
const audioUrl = audioToUrl(fileName);
getCachedAudioBuffer(audioUrl, audioLoader, (audioBuffer) => {
if (!sound.buffer) {
sound.setBuffer(audioBuffer);
isLoadedRef.current = true;
try {
sound.play();
setupLooping(sound);
} catch (err) {}
}
});
} else {
// Already loaded, just play
try {
if (!sound.isPlaying) {
// Load and play audio. For 3D, gated by proximity; for 2D, plays immediately.
const loadAndPlay = (sound: Audio<GainNode | PannerNode>) => {
if (!isLoadedRef.current) {
const audioUrl = audioToUrl(fileName);
getCachedAudioBuffer(audioUrl, audioLoader, (audioBuffer) => {
if (!sound.buffer) {
sound.setBuffer(audioBuffer);
isLoadedRef.current = true;
try {
sound.play();
setupLooping(sound);
}
} catch (err) {}
}
}
// Leaving range: stop and clean up
else if (!isNowInRange && wasInRange) {
isInRangeRef.current = false;
if (loopTimerRef.current) clearTimeout(loopTimerRef.current);
if (loopGapIntervalRef.current) clearTimeout(loopGapIntervalRef.current);
} catch {}
}
});
} else {
try {
sound.stop();
} catch (err) {}
if (!sound.isPlaying) {
sound.play();
setupLooping(sound);
}
} catch {}
}
};
// 2D emitters: load and play on mount (no proximity gating).
useEffect(() => {
const sound = soundRef.current;
if (!sound || is3D || !audioEnabled || !fileName) return;
loadAndPlay(sound);
}, [audioEnabled, is3D, fileName, audioLoader, audioListener]);
// 3D emitters: check proximity and load/unload audio per frame.
useFrame(() => {
const sound = soundRef.current;
if (!sound || !is3D || !audioEnabled || !fileName) return;
const distance = camera.position.distanceTo(emitterPosRef.current);
const wasInRange = isInRangeRef.current;
const isNowInRange = distance <= maxDistance;
if (isNowInRange && !wasInRange) {
isInRangeRef.current = true;
loadAndPlay(sound);
} else if (!isNowInRange && wasInRange) {
isInRangeRef.current = false;
clearTimers();
try { sound.stop(); } catch {}
}
});
// Stop audio if disabled
// Stop audio if disabled.
useEffect(() => {
const sound = soundRef.current;
if (!sound) return;
if (!audioEnabled) {
if (loopTimerRef.current) clearTimeout(loopTimerRef.current);
if (loopGapIntervalRef.current) clearTimeout(loopGapIntervalRef.current);
try {
sound.stop();
} catch (err) {}
clearTimers();
try { sound.stop(); } catch {}
}
}, [audioEnabled]);

View file

@ -0,0 +1,65 @@
import { useEffect, useRef } from "react";
import { Audio } from "three";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { getCachedAudioBuffer } from "./AudioEmitter";
import { useSettings } from "./SettingsProvider";
import { useEngineSelector } from "../state";
import type { DemoChatMessage } from "../demo/types";
/**
* Plays non-positional sound effects for chat messages with ~w sound tags.
* Must be rendered inside the Canvas tree (within AudioProvider).
*/
export function ChatSoundPlayer() {
const { audioLoader, audioListener } = useAudio();
const settings = useSettings();
const audioEnabled = settings?.audioEnabled ?? false;
const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages,
);
const timeSec = useEngineSelector(
(state) => state.playback.streamSnapshot?.timeSec,
);
const playedCountRef = useRef(0);
useEffect(() => {
if (
!audioEnabled ||
!audioLoader ||
!audioListener ||
!messages?.length ||
timeSec == null
) {
return;
}
const startIdx = playedCountRef.current;
for (let i = startIdx; i < messages.length; i++) {
const msg: DemoChatMessage = messages[i];
if (!msg.soundPath) continue;
// Skip sounds that are too old (e.g. after seeking).
if (Math.abs(timeSec - msg.timeSec) > 2) continue;
try {
const url = audioToUrl(msg.soundPath);
const pitch = msg.soundPitch ?? 1;
getCachedAudioBuffer(url, audioLoader, (buffer) => {
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
if (pitch !== 1) {
sound.setPlaybackRate(pitch);
}
sound.play();
// Clean up the source node once playback finishes.
sound.source!.onended = () => {
sound.disconnect();
};
});
} catch {
// File not in manifest — skip silently.
}
}
playedCountRef.current = messages.length;
}, [audioEnabled, audioLoader, audioListener, messages, timeSec]);
return null;
}

View file

@ -56,56 +56,3 @@
color: #fff;
font-size: 12px;
}
.DiagnosticsPanel {
display: flex;
flex-direction: column;
gap: 3px;
margin-left: 8px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
min-width: 320px;
}
.DiagnosticsPanel[data-context-lost="true"] {
border-color: rgba(255, 90, 90, 0.8);
background: rgba(70, 0, 0, 0.45);
}
.DiagnosticsStatus {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.DiagnosticsMetrics {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
opacity: 0.92;
}
.DiagnosticsFooter {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
align-items: center;
font-size: 11px;
}
.DiagnosticsFooter button {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
padding: 1px 6px;
font-size: 11px;
cursor: pointer;
}
.DiagnosticsFooter button:hover {
background: rgba(0, 98, 179, 0.8);
}

View file

@ -1,4 +1,4 @@
import { useCallback, type ChangeEvent } from "react";
import { useCallback, useEffect, type ChangeEvent } from "react";
import {
useDemoActions,
useDemoCurrentTime,
@ -7,12 +7,6 @@ import {
useDemoRecording,
useDemoSpeed,
} from "./DemoProvider";
import {
buildSerializableDiagnosticsJson,
buildSerializableDiagnosticsSnapshot,
useEngineSelector,
useEngineStoreApi,
} from "../state";
import styles from "./DemoControls.module.css";
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
@ -23,16 +17,6 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`;
}
function formatBytes(value: number | undefined): string {
if (!Number.isFinite(value) || value == null) {
return "n/a";
}
if (value < 1024) return `${Math.round(value)} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
return `${(value / 1024 ** 3).toFixed(2)} GB`;
}
export function DemoControls() {
const recording = useDemoRecording();
const isPlaying = useDemoIsPlaying();
@ -40,24 +24,32 @@ export function DemoControls() {
const duration = useDemoDuration();
const speed = useDemoSpeed();
const { play, pause, seek, setSpeed } = useDemoActions();
const engineStore = useEngineStoreApi();
const webglContextLost = useEngineSelector(
(state) => state.diagnostics.webglContextLost,
);
const rendererSampleCount = useEngineSelector(
(state) => state.diagnostics.rendererSamples.length,
);
const latestRendererSample = useEngineSelector((state) => {
const samples = state.diagnostics.rendererSamples;
return samples.length > 0 ? samples[samples.length - 1] : null;
});
const playbackEventCount = useEngineSelector(
(state) => state.diagnostics.playbackEvents.length,
);
const latestPlaybackEvent = useEngineSelector((state) => {
const events = state.diagnostics.playbackEvents;
return events.length > 0 ? events[events.length - 1] : null;
});
// Spacebar toggles play/pause during demo playback.
useEffect(() => {
if (!recording) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code !== "Space") return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.tagName === "BUTTON" ||
target.isContentEditable
) {
return;
}
e.preventDefault();
if (isPlaying) {
pause();
} else {
play();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [recording, isPlaying, play, pause]);
const handleSeek = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@ -73,19 +65,6 @@ export function DemoControls() {
[setSpeed],
);
const handleDumpDiagnostics = useCallback(() => {
const state = engineStore.getState();
const snapshot = buildSerializableDiagnosticsSnapshot(state);
const json = buildSerializableDiagnosticsJson(state);
console.log("[demo diagnostics dump]", snapshot);
console.log("[demo diagnostics dump json]", json);
}, [engineStore]);
const handleClearDiagnostics = useCallback(() => {
engineStore.getState().clearPlaybackDiagnostics();
console.info("[demo diagnostics] Cleared playback diagnostics");
}, [engineStore]);
if (!recording) return null;
return (
@ -125,54 +104,6 @@ export function DemoControls() {
</option>
))}
</select>
<div
className={styles.DiagnosticsPanel}
data-context-lost={webglContextLost ? "true" : undefined}
>
<div className={styles.DiagnosticsStatus}>
{webglContextLost ? "WebGL context: LOST" : "WebGL context: ok"}
</div>
<div className={styles.DiagnosticsMetrics}>
{latestRendererSample ? (
<>
<span>
geom {latestRendererSample.geometries} tex{" "}
{latestRendererSample.textures} prog{" "}
{latestRendererSample.programs}
</span>
<span>
draw {latestRendererSample.renderCalls} tri{" "}
{latestRendererSample.renderTriangles}
</span>
<span>
scene {latestRendererSample.visibleSceneObjects}/
{latestRendererSample.sceneObjects}
</span>
<span>heap {formatBytes(latestRendererSample.jsHeapUsed)}</span>
</>
) : (
<span>No renderer samples yet</span>
)}
</div>
<div className={styles.DiagnosticsFooter}>
<span>
samples {rendererSampleCount} events {playbackEventCount}
</span>
{latestPlaybackEvent ? (
<span title={latestPlaybackEvent.message}>
last event: {latestPlaybackEvent.kind}
</span>
) : (
<span>last event: none</span>
)}
<button type="button" onClick={handleDumpDiagnostics}>
Dump
</button>
<button type="button" onClick={handleClearDiagnostics}>
Clear
</button>
</div>
</div>
</div>
);
}

View file

@ -11,15 +11,17 @@ import {
Mesh,
MeshBasicMaterial,
NormalBlending,
PositionalAudio,
RGBAFormat,
ShaderMaterial,
SphereGeometry,
Texture,
TextureLoader,
Uint16BufferAttribute,
UnsignedByteType,
Vector3,
} from "three";
import { textureToUrl } from "../loaders";
import { audioToUrl, textureToUrl } from "../loaders";
import { loadTexture } from "../textureUtils";
import { setupEffectTexture } from "../demo/demoPlaybackUtils";
import {
EmitterInstance,
@ -34,7 +36,13 @@ import type {
DemoStreamSnapshot,
DemoStreamingPlayback,
} from "../demo/types";
import { useDebug } from "./SettingsProvider";
import { useDebug, useSettings } from "./SettingsProvider";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
playOneShotSound,
getCachedAudioBuffer,
} from "./AudioEmitter";
// ── Constants ──
@ -45,7 +53,6 @@ const QUAD_CORNERS = new Float32Array([
// ── Texture cache ──
const _textureLoader = new TextureLoader();
const _textureCache = new Map<string, Texture>();
/** Set of textures whose image data has finished loading. */
const _texturesReady = new Set<Texture>();
@ -66,7 +73,7 @@ function getParticleTexture(textureName: string): Texture {
if (cached) return cached;
try {
const url = textureToUrl(textureName);
const tex = _textureLoader.load(url, (t) => {
const tex = loadTexture(url, (t) => {
setupEffectTexture(t);
_texturesReady.add(t);
});
@ -167,6 +174,8 @@ interface ActiveEmitter {
origin: [number, number, number];
isBurst: boolean;
hasBurst: boolean;
/** Whether shader compilation has been verified. */
shaderChecked?: boolean;
/** Entity ID this emitter follows (for projectile trails). */
followEntityId?: string;
/** Debug: origin marker mesh. */
@ -207,13 +216,7 @@ function resolveExplosion(
getDataBlockData: (id: number) => Record<string, unknown> | undefined,
): ResolvedExplosion | null {
const expBlock = getDataBlockData(explosionDataBlockId);
if (!expBlock) {
console.log("[resolveExplosion] getDataBlockData returned undefined for id:", explosionDataBlockId);
return null;
}
// DEBUG: log the raw explosion datablock fields
console.log("[resolveExplosion] expBlock keys:", Object.keys(expBlock), "particleEmitter:", expBlock.particleEmitter, "emitters:", expBlock.emitters, "particleDensity:", expBlock.particleDensity);
if (!expBlock) return null;
const burstEmitters: ResolvedExplosion["burstEmitters"] = [];
const streamingEmitters: EmitterDataResolved[] = [];
@ -222,48 +225,30 @@ function resolveExplosion(
const particleEmitterId = expBlock.particleEmitter as number | null;
if (typeof particleEmitterId === "number") {
const emitterRaw = getDataBlockData(particleEmitterId);
console.log("[resolveExplosion] burst emitter lookup — particleEmitterId:", particleEmitterId, "found:", !!emitterRaw);
if (emitterRaw) {
console.log("[resolveExplosion] burst emitter raw keys:", Object.keys(emitterRaw), "particles:", emitterRaw.particles);
const resolved = resolveEmitterData(emitterRaw, getDataBlockData);
if (resolved) {
const density = (expBlock.particleDensity as number) ?? 10;
console.log("[resolveExplosion] burst emitter RESOLVED — density:", density, "textureName:", resolved.particles.textureName, "particleLifetimeMS:", resolved.particles.lifetimeMS, "emitterLifetimeMS:", resolved.lifetimeMS);
burstEmitters.push({ data: resolved, density });
} else {
console.log("[resolveExplosion] resolveEmitterData returned null for burst emitter");
}
}
} else {
console.log("[resolveExplosion] no particleEmitter field (value:", expBlock.particleEmitter, ")");
}
// Streaming emitters: emitters[0..3].
const emitterRefs = expBlock.emitters as (number | null)[] | undefined;
if (Array.isArray(emitterRefs)) {
console.log("[resolveExplosion] emitters array:", emitterRefs);
for (const ref of emitterRefs) {
if (typeof ref !== "number") continue;
const emitterRaw = getDataBlockData(ref);
if (!emitterRaw) {
console.log("[resolveExplosion] streaming emitter ref", ref, "not found");
continue;
}
console.log("[resolveExplosion] streaming emitter raw keys:", Object.keys(emitterRaw), "particles:", emitterRaw.particles);
if (!emitterRaw) continue;
const resolved = resolveEmitterData(emitterRaw, getDataBlockData);
if (resolved) {
console.log("[resolveExplosion] streaming emitter RESOLVED — textureName:", resolved.particles.textureName, "particleLifetimeMS:", resolved.particles.lifetimeMS, "emitterLifetimeMS:", resolved.lifetimeMS, "ejectionPeriodMS:", resolved.ejectionPeriodMS);
streamingEmitters.push(resolved);
} else {
console.log("[resolveExplosion] resolveEmitterData returned null for streaming emitter ref:", ref);
}
}
} else {
console.log("[resolveExplosion] no emitters array on expBlock");
}
if (burstEmitters.length === 0 && streamingEmitters.length === 0) {
console.log("[resolveExplosion] no emitters resolved at all, returning null");
return null;
}
@ -341,6 +326,8 @@ function syncBuffers(active: ActiveEmitter): void {
// ── Main component ──
const MAX_PROJECTILE_SOUNDS = 20;
export function DemoParticleEffects({
playback,
snapshotRef,
@ -349,6 +336,8 @@ export function DemoParticleEffects({
snapshotRef: React.RefObject<DemoStreamSnapshot | null>;
}) {
const { debugMode } = useDebug();
const { audioEnabled } = useSettings();
const { audioLoader, audioListener } = useAudio();
const gl = useThree((s) => s.gl);
const groupRef = useRef<Group>(null);
const activeEmittersRef = useRef<ActiveEmitter[]>([]);
@ -356,47 +345,18 @@ export function DemoParticleEffects({
const processedExplosionsRef = useRef<Set<string>>(new Set());
/** Track which projectile entity IDs have trail emitters attached. */
const trailEntitiesRef = useRef<Set<string>>(new Set());
/** Throttle for periodic debug logs. */
const lastDebugLogRef = useRef(0);
useEffect(() => {
console.log("[ParticleFX] MOUNTED — playback:", !!playback, "snapshotRef:", !!snapshotRef);
}, [playback, snapshotRef]);
/** Active looping projectile sounds keyed by entity ID. */
const projectileSoundsRef = useRef<Map<string, PositionalAudio>>(new Map());
/** Track processed audio event keys to prevent replays on seek. */
const processedAudioEventsRef = useRef<Set<string>>(new Set());
useFrame((_, delta) => {
const group = groupRef.current;
const snapshot = snapshotRef.current;
if (!group || !snapshot) {
// DEBUG: log when snapshot or group is missing
console.log("[ParticleFX] early return — group:", !!group, "snapshot:", !!snapshot);
return;
}
if (!group || !snapshot) return;
const dtMS = delta * 1000;
const getDataBlockData = playback.getDataBlockData.bind(playback);
// DEBUG: periodically log entity type counts (every 2 seconds).
const now = performance.now();
if (now - lastDebugLogRef.current > 2000) {
lastDebugLogRef.current = now;
const typeCounts: Record<string, number> = {};
let withMaintainEmitter = 0;
let withExplosionDataBlockId = 0;
for (const e of snapshot.entities) {
typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
if (e.maintainEmitterId) withMaintainEmitter++;
if (e.explosionDataBlockId) withExplosionDataBlockId++;
}
console.log(
"[ParticleFX] types:", typeCounts,
"| active emitters:", activeEmittersRef.current.length,
"| processedExplosions:", processedExplosionsRef.current.size,
"| trailEntities:", trailEntitiesRef.current.size,
"| withExplosionDataBlockId:", withExplosionDataBlockId,
"| withMaintainEmitter:", withMaintainEmitter,
);
}
// Detect new explosion entities and create emitters.
for (const entity of snapshot.entities) {
if (
@ -404,29 +364,16 @@ export function DemoParticleEffects({
!entity.explosionDataBlockId ||
!entity.position
) {
// DEBUG: log entities that are type "Explosion" but fail the other checks
if (entity.type === "Explosion") {
console.log("[ParticleFX] Explosion entity SKIPPED — id:", entity.id, "explosionDataBlockId:", entity.explosionDataBlockId, "position:", entity.position);
}
continue;
}
if (processedExplosionsRef.current.has(entity.id)) continue;
processedExplosionsRef.current.add(entity.id);
// DEBUG: log new explosion entity being processed
console.log("[ParticleFX] NEW explosion entity:", entity.id, "dataBlockId:", entity.explosionDataBlockId, "pos:", entity.position);
const resolved = resolveExplosion(
entity.explosionDataBlockId,
getDataBlockData,
);
if (!resolved) {
console.log("[ParticleFX] resolveExplosion returned null for dataBlockId:", entity.explosionDataBlockId);
continue;
}
// DEBUG: log resolved explosion details
console.log("[ParticleFX] resolveExplosion OK — burstEmitters:", resolved.burstEmitters.length, "streamingEmitters:", resolved.streamingEmitters.length, "lifetimeMS:", resolved.lifetimeMS);
if (!resolved) continue;
const origin: [number, number, number] = [...entity.position];
@ -438,11 +385,7 @@ export function DemoParticleEffects({
);
emitter.emitBurst(origin, burst.density);
// DEBUG: log burst emitter creation
console.log("[ParticleFX] Created BURST emitter — particles after burst:", emitter.particles.length, "origin:", origin, "texture:", burst.data.particles.textureName, "particleLifetimeMS:", burst.data.particles.lifetimeMS, "keyframes:", burst.data.particles.keys.length, "key0:", burst.data.particles.keys[0]);
const texture = getParticleTexture(burst.data.particles.textureName);
console.log("[ParticleFX] burst texture loaded:", !!texture, "textureName:", burst.data.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
texture,
@ -472,11 +415,7 @@ export function DemoParticleEffects({
resolved.lifetimeMS,
);
// DEBUG: log streaming emitter creation
console.log("[ParticleFX] Created STREAMING emitter — emitterLifetimeMS:", emitterData.lifetimeMS, "ejectionPeriodMS:", emitterData.ejectionPeriodMS, "origin:", origin, "texture:", emitterData.particles.textureName, "particleLifetimeMS:", emitterData.particles.lifetimeMS);
const texture = getParticleTexture(emitterData.particles.textureName);
console.log("[ParticleFX] streaming texture loaded:", !!texture, "textureName:", emitterData.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
texture,
@ -521,16 +460,6 @@ export function DemoParticleEffects({
const emitter = new EmitterInstance(emitterData, MAX_PARTICLES_PER_EMITTER);
console.log(
"[ParticleFX] Created TRAIL emitter for",
entity.type,
entity.id,
"— maintainEmitterId:",
entity.maintainEmitterId,
"texture:",
emitterData.particles.textureName,
);
const texture = getParticleTexture(emitterData.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
@ -573,8 +502,11 @@ export function DemoParticleEffects({
for (let i = active.length - 1; i >= 0; i--) {
const entry = active[i];
// One-time shader compilation check on first frame.
checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream");
// One-time shader compilation check.
if (!entry.shaderChecked) {
checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream");
entry.shaderChecked = true;
}
// Update trail emitter origin to follow the projectile's position.
if (entry.followEntityId) {
@ -596,12 +528,6 @@ export function DemoParticleEffects({
// Advance physics and interpolation.
entry.emitter.update(dtMS);
// DEBUG: log particle state on first few frames of each emitter
if (entry.emitter.particles.length > 0 && Math.random() < 0.02) {
const p0 = entry.emitter.particles[0];
console.log("[ParticleFX] update — isBurst:", entry.isBurst, "particleCount:", entry.emitter.particles.length, "p0.pos:", p0.pos, "p0.size:", p0.size, "p0.a:", p0.a, "p0.age/lifetime:", p0.currentAge, "/", p0.totalLifetime, "drawRange:", entry.geometry.drawRange);
}
// Swap in the real texture once it finishes loading.
if (
_texturesReady.has(entry.targetTexture) &&
@ -667,7 +593,6 @@ export function DemoParticleEffects({
// Remove dead emitters.
if (entry.emitter.isDead()) {
console.log("[ParticleFX] removing DEAD emitter — isBurst:", entry.isBurst, "origin:", entry.origin);
group.remove(entry.mesh);
entry.geometry.dispose();
entry.material.dispose();
@ -679,6 +604,133 @@ export function DemoParticleEffects({
}
}
// ── Audio: explosion impact sounds ──
if (audioEnabled && audioLoader && audioListener && groupRef.current) {
for (const entity of snapshot.entities) {
if (
entity.type !== "Explosion" ||
!entity.explosionDataBlockId ||
!entity.position
) {
continue;
}
const soundKey = `snd:${entity.id}`;
if (processedAudioEventsRef.current.has(soundKey)) continue;
processedAudioEventsRef.current.add(soundKey);
const expBlock = getDataBlockData(entity.explosionDataBlockId);
if (!expBlock) continue;
const soundProfileId = expBlock.soundProfile as number | undefined;
if (typeof soundProfileId !== "number") continue;
const resolved = resolveAudioProfile(soundProfileId, getDataBlockData);
if (!resolved) continue;
const pos = new Vector3(
entity.position[1],
entity.position[2],
entity.position[0],
);
playOneShotSound(
resolved,
audioListener,
audioLoader,
pos,
groupRef.current,
);
}
// ── Audio: projectile in-flight sounds ──
const projSounds = projectileSoundsRef.current;
for (const entity of snapshot.entities) {
if (entity.type !== "Projectile" || !entity.dataBlockId || !entity.position) {
continue;
}
if (projSounds.has(entity.id)) {
// Update position of existing sound.
const sound = projSounds.get(entity.id)!;
sound.position.set(
entity.position[1],
entity.position[2],
entity.position[0],
);
continue;
}
// Cap active projectile sounds.
if (projSounds.size >= MAX_PROJECTILE_SOUNDS) continue;
const projBlock = getDataBlockData(entity.dataBlockId);
if (!projBlock) continue;
const soundId = projBlock.sound as number | undefined;
if (typeof soundId !== "number") continue;
const resolved = resolveAudioProfile(soundId, getDataBlockData);
if (!resolved || !resolved.isLooping || !resolved.is3D) continue;
try {
const url = audioToUrl(resolved.filename);
getCachedAudioBuffer(url, audioLoader, (buffer) => {
// Entity may have despawned by the time the buffer loads.
if (!currentEntityIds.has(entity.id)) return;
if (projSounds.has(entity.id)) return;
const group = groupRef.current;
if (!group) return;
const sound = new PositionalAudio(audioListener);
sound.setBuffer(buffer);
sound.setDistanceModel("inverse");
sound.setRefDistance(resolved.refDist);
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setLoop(true);
sound.position.set(
entity.position![1],
entity.position![2],
entity.position![0],
);
group.add(sound);
sound.play();
projSounds.set(entity.id, sound);
});
} catch {
// File not in manifest.
}
}
// Despawn: stop sounds for entities no longer present.
for (const [entityId, sound] of projSounds) {
if (!currentEntityIds.has(entityId)) {
try { sound.stop(); } catch {}
sound.disconnect();
groupRef.current?.remove(sound);
projSounds.delete(entityId);
}
}
// ── Audio: event-based sounds (Sim3DAudioEvent / Sim2DAudioEvent) ──
for (const evt of snapshot.audioEvents) {
const evtKey = `${evt.timeSec}:${evt.profileId}:${evt.position?.x ?? ""}`;
if (processedAudioEventsRef.current.has(evtKey)) continue;
processedAudioEventsRef.current.add(evtKey);
const resolved = resolveAudioProfile(evt.profileId, getDataBlockData);
if (!resolved) continue;
const pos = evt.position
? new Vector3(evt.position.y, evt.position.z, evt.position.x)
: undefined;
playOneShotSound(
resolved,
audioListener,
audioLoader,
pos,
groupRef.current,
);
}
}
// Prune processed set when it gets large.
if (processedExplosionsRef.current.size > 500) {
const currentIds = new Set(snapshot.entities.map((e) => e.id));
@ -688,6 +740,16 @@ export function DemoParticleEffects({
}
}
}
// Prune processed audio events set: keep only entries for current entities
// and recent event keys.
if (processedAudioEventsRef.current.size > 500) {
const currentIds = new Set(snapshot.entities.map((e) => e.id));
for (const key of processedAudioEventsRef.current) {
// Keep explosion sound keys (prefixed "snd:") if entity is still present.
if (key.startsWith("snd:") && currentIds.has(key.slice(4))) continue;
processedAudioEventsRef.current.delete(key);
}
}
});
// Cleanup on unmount.
@ -708,6 +770,14 @@ export function DemoParticleEffects({
activeEmittersRef.current = [];
processedExplosionsRef.current.clear();
trailEntitiesRef.current.clear();
// Clean up projectile sounds.
for (const [, sound] of projectileSoundsRef.current) {
try { sound.stop(); } catch {}
sound.disconnect();
if (group) group.remove(sound);
}
projectileSoundsRef.current.clear();
processedAudioEventsRef.current.clear();
};
}, []);

View file

@ -1,278 +1,9 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import { useDemoRecording } from "./DemoProvider";
import {
collectSceneObjectCounts,
nextLifecycleInstanceId,
} from "../demo/demoPlaybackUtils";
import { StreamingDemoPlayback } from "./DemoPlaybackStreaming";
import { useEngineStoreApi } from "../state";
import type { DemoRecording } from "../demo/types";
let demoPlaybackMountCount = 0;
let demoPlaybackUnmountCount = 0;
function DemoPlaybackDiagnostics({ recording }: { recording: DemoRecording }) {
const { gl, scene } = useThree();
const engineStore = useEngineStoreApi();
const previousSampleRef = useRef<{
geometries: number;
textures: number;
programs: number;
sceneObjects: number;
visibleSceneObjects: number;
} | null>(null);
const lastSpikeEventMsRef = useRef(0);
useEffect(() => {
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "recording.loaded",
meta: {
missionName: recording.missionName ?? null,
gameType: recording.gameType ?? null,
durationSec: Number(recording.duration.toFixed(3)),
},
});
}, [engineStore]);
useEffect(() => {
const canvas = gl.domElement;
if (!canvas) return;
const getIsContextLost = () => {
try {
const context = gl.getContext();
if (
context &&
typeof (context as { isContextLost?: () => boolean }).isContextLost ===
"function"
) {
return !!(
context as {
isContextLost: () => boolean;
}
).isContextLost();
}
} catch {
// no-op
}
return undefined;
};
const handleContextLost = (event: Event) => {
event.preventDefault();
const store = engineStore.getState();
store.setWebglContextLost(true);
store.recordPlaybackDiagnosticEvent({
kind: "webgl.context.lost",
message: "Renderer emitted webglcontextlost",
meta: {
contextLost: getIsContextLost(),
},
});
console.error("[demo diagnostics] WebGL context lost");
};
const handleContextRestored = () => {
const store = engineStore.getState();
store.setWebglContextLost(false);
store.recordPlaybackDiagnosticEvent({
kind: "webgl.context.restored",
message: "Renderer emitted webglcontextrestored",
meta: {
contextLost: getIsContextLost(),
},
});
console.warn("[demo diagnostics] WebGL context restored");
};
const handleContextCreationError = (event: Event) => {
const contextEvent = event as Event & { statusMessage?: string };
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "webgl.context.creation_error",
message: contextEvent.statusMessage ?? "Context creation error",
meta: {
contextLost: getIsContextLost(),
},
});
console.error(
"[demo diagnostics] WebGL context creation error",
contextEvent.statusMessage ?? "",
);
};
canvas.addEventListener("webglcontextlost", handleContextLost, false);
canvas.addEventListener("webglcontextrestored", handleContextRestored, false);
canvas.addEventListener(
"webglcontextcreationerror",
handleContextCreationError,
false,
);
return () => {
canvas.removeEventListener("webglcontextlost", handleContextLost, false);
canvas.removeEventListener(
"webglcontextrestored",
handleContextRestored,
false,
);
canvas.removeEventListener(
"webglcontextcreationerror",
handleContextCreationError,
false,
);
};
}, [engineStore, gl]);
useEffect(() => {
const collectSample = () => {
const { sceneObjects, visibleSceneObjects } = collectSceneObjectCounts(scene);
const programs = Array.isArray((gl.info as any).programs)
? (gl.info as any).programs.length
: 0;
const perfMemory = (performance as any).memory as
| {
usedJSHeapSize?: number;
totalJSHeapSize?: number;
jsHeapSizeLimit?: number;
}
| undefined;
const nextSample = {
t: Date.now(),
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
programs,
renderCalls: gl.info.render.calls,
renderTriangles: gl.info.render.triangles,
renderPoints: gl.info.render.points,
renderLines: gl.info.render.lines,
sceneObjects,
visibleSceneObjects,
jsHeapUsed: perfMemory?.usedJSHeapSize,
jsHeapTotal: perfMemory?.totalJSHeapSize,
jsHeapLimit: perfMemory?.jsHeapSizeLimit,
};
engineStore.getState().appendRendererSample(nextSample);
const previous = previousSampleRef.current;
previousSampleRef.current = {
geometries: nextSample.geometries,
textures: nextSample.textures,
programs: nextSample.programs,
sceneObjects: nextSample.sceneObjects,
visibleSceneObjects: nextSample.visibleSceneObjects,
};
if (!previous) {
return;
}
const now = nextSample.t;
const geometryDelta = nextSample.geometries - previous.geometries;
const textureDelta = nextSample.textures - previous.textures;
const programDelta = nextSample.programs - previous.programs;
const sceneObjectDelta = nextSample.sceneObjects - previous.sceneObjects;
if (
now - lastSpikeEventMsRef.current >= 5000 &&
(geometryDelta >= 200 ||
textureDelta >= 100 ||
programDelta >= 20 ||
sceneObjectDelta >= 400)
) {
lastSpikeEventMsRef.current = now;
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "renderer.resource.spike",
message: "Detected large one-second renderer resource increase",
meta: {
geometryDelta,
textureDelta,
programDelta,
sceneObjectDelta,
geometries: nextSample.geometries,
textures: nextSample.textures,
programs: nextSample.programs,
sceneObjects: nextSample.sceneObjects,
},
});
}
};
collectSample();
const intervalId = window.setInterval(collectSample, 1000);
return () => {
window.clearInterval(intervalId);
};
}, [engineStore, gl, scene]);
return null;
}
export function DemoPlayback() {
const engineStore = useEngineStoreApi();
const recording = useDemoRecording();
const instanceIdRef = useRef<string | null>(null);
if (!instanceIdRef.current) {
instanceIdRef.current = nextLifecycleInstanceId("DemoPlayback");
}
useEffect(() => {
demoPlaybackMountCount += 1;
const mountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "DemoPlayback mounted",
meta: {
component: "DemoPlayback",
phase: "mount",
instanceId: instanceIdRef.current,
mountCount: demoPlaybackMountCount,
unmountCount: demoPlaybackUnmountCount,
recordingMissionName: recording?.missionName ?? null,
recordingDurationSec: recording
? Number(recording.duration.toFixed(3))
: null,
ts: mountedAt,
},
});
console.info("[demo diagnostics] DemoPlayback mounted", {
instanceId: instanceIdRef.current,
mountCount: demoPlaybackMountCount,
unmountCount: demoPlaybackUnmountCount,
recordingMissionName: recording?.missionName ?? null,
mountedAt,
});
return () => {
demoPlaybackUnmountCount += 1;
const unmountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "DemoPlayback unmounted",
meta: {
component: "DemoPlayback",
phase: "unmount",
instanceId: instanceIdRef.current,
mountCount: demoPlaybackMountCount,
unmountCount: demoPlaybackUnmountCount,
recordingMissionName: recording?.missionName ?? null,
ts: unmountedAt,
},
});
console.info("[demo diagnostics] DemoPlayback unmounted", {
instanceId: instanceIdRef.current,
mountCount: demoPlaybackMountCount,
unmountCount: demoPlaybackUnmountCount,
recordingMissionName: recording?.missionName ?? null,
unmountedAt,
});
};
}, [engineStore]);
if (!recording) return null;
return (
<>
<DemoPlaybackDiagnostics recording={recording} />
<StreamingDemoPlayback recording={recording} />
</>
);
return <StreamingDemoPlayback recording={recording} />;
}

View file

@ -9,7 +9,6 @@ import {
import {
buildStreamDemoEntity,
DEFAULT_EYE_HEIGHT,
nextLifecycleInstanceId,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../demo/demoPlaybackUtils";
@ -47,15 +46,8 @@ const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
let streamingDemoPlaybackMountCount = 0;
let streamingDemoPlaybackUnmountCount = 0;
export function StreamingDemoPlayback({ recording }: { recording: DemoRecording }) {
const engineStore = useEngineStoreApi();
const instanceIdRef = useRef<string | null>(null);
if (!instanceIdRef.current) {
instanceIdRef.current = nextLifecycleInstanceId("StreamingDemoPlayback");
}
const rootRef = useRef<Group>(null);
const timeRef = useRef(0);
const playbackClockRef = useRef(0);
@ -66,62 +58,9 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const publishedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, DemoEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const lastEntityRebuildEventMsRef = useRef(0);
const exhaustedEventLoggedRef = useRef(false);
const [entities, setEntities] = useState<DemoEntity[]>([]);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
useEffect(() => {
streamingDemoPlaybackMountCount += 1;
const mountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback mounted",
meta: {
component: "StreamingDemoPlayback",
phase: "mount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
recordingDurationSec: Number(recording.duration.toFixed(3)),
ts: mountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback mounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
mountedAt,
});
return () => {
streamingDemoPlaybackUnmountCount += 1;
const unmountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback unmounted",
meta: {
component: "StreamingDemoPlayback",
phase: "unmount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
ts: unmountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback unmounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
unmountedAt,
});
};
}, [engineStore]);
const syncRenderableEntities = useCallback((snapshot: DemoStreamSnapshot) => {
if (snapshot === lastSyncedSnapshotRef.current) return;
lastSyncedSnapshotRef.current = snapshot;
@ -172,6 +111,10 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
renderEntity.dataBlockId = entity.dataBlockId;
renderEntity.shapeHint = entity.shapeHint;
renderEntity.threads = entity.threads;
renderEntity.weaponImageState = entity.weaponImageState;
renderEntity.weaponImageStates = entity.weaponImageStates;
renderEntity.headPitch = entity.headPitch;
renderEntity.headYaw = entity.headYaw;
if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({
@ -198,19 +141,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
entityMapRef.current = nextMap;
if (shouldRebuild) {
setEntities(Array.from(nextMap.values()));
const now = Date.now();
if (now - lastEntityRebuildEventMsRef.current >= 500) {
lastEntityRebuildEventMsRef.current = now;
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "stream.entities.rebuild",
message: "Renderable demo entity list was rebuilt",
meta: {
previousEntityCount: prevMap.size,
nextEntityCount: nextMap.size,
snapshotTimeSec: Number(snapshot.timeSec.toFixed(3)),
},
});
}
}
let nextFirstPersonShape: string | null = null;
@ -223,7 +153,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
setFirstPersonShape((prev) =>
prev === nextFirstPersonShape ? prev : nextFirstPersonShape,
);
}, [engineStore]);
}, []);
useEffect(() => {
streamRef.current = recording.streamingPlayback ?? null;
@ -234,8 +164,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
playbackClockRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
exhaustedEventLoggedRef.current = false;
const stream = streamRef.current;
if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null);
@ -339,7 +267,9 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
renderCurrent.camera?.controlEntityId !==
publishedSnapshot.camera?.controlEntityId ||
renderCurrent.camera?.orbitTargetId !==
publishedSnapshot.camera?.orbitTargetId;
publishedSnapshot.camera?.orbitTargetId ||
renderCurrent.chatMessages.length !== publishedSnapshot.chatMessages.length ||
renderCurrent.teamScores !== publishedSnapshot.teamScores;
if (shouldPublish) {
publishedSnapshotRef.current = renderCurrent;
@ -505,20 +435,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
if (isPlaying && snapshot.exhausted) {
if (!exhaustedEventLoggedRef.current) {
exhaustedEventLoggedRef.current = true;
storeState.recordPlaybackDiagnosticEvent({
kind: "stream.exhausted",
message: "Streaming playback reached end-of-stream while playing",
meta: {
streamTimeSec: Number(snapshot.timeSec.toFixed(3)),
requestedPlaybackSec: Number(playbackClockRef.current.toFixed(3)),
},
});
}
storeState.setPlaybackStatus("paused");
} else if (!snapshot.exhausted) {
exhaustedEventLoggedRef.current = false;
}
const timeMs = playbackClockRef.current * 1000;

View file

@ -2,14 +2,18 @@ import { Suspense, useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
AdditiveAnimationBlendMode,
AnimationMixer,
AnimationUtils,
FrontSide,
Group,
LoopOnce,
LoopRepeat,
Object3D,
PositionalAudio,
Vector3,
} from "three";
import type { AnimationAction } from "three";
import type { AnimationAction, AnimationClip } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
@ -19,12 +23,37 @@ import {
processShapeScene,
} from "../demo/demoPlaybackUtils";
import { pickMoveAnimation } from "../demo/playerAnimation";
import { WeaponImageStateMachine } from "../demo/weaponStateMachine";
import type { WeaponAnimState } from "../demo/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape } from "./GenericShape";
import { ShapeErrorBoundary } from "./DemoEntities";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
playOneShotSound,
getCachedAudioBuffer,
} from "./AudioEmitter";
import { audioToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider";
import { useEngineStoreApi, useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types";
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
function stopLoopingSound(
soundRef: React.MutableRefObject<PositionalAudio | null>,
stateRef: React.MutableRefObject<number>,
parent?: Object3D,
) {
const sound = soundRef.current;
if (!sound) return;
try { sound.stop(); } catch {}
sound.disconnect();
parent?.remove(sound);
soundRef.current = null;
stateRef.current = -1;
}
/**
* Renders a player model with skeleton-preserving animation.
*
@ -50,9 +79,19 @@ export function DemoPlayerModel({
});
// Clone scene preserving skeleton bindings, create mixer, find Mount0 bone.
const { clonedScene, mixer, mount0 } = useMemo(() => {
const { clonedScene, mixer, mount0, iflInitializers } = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
processShapeScene(scene);
const iflInits = processShapeScene(scene);
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
scene.traverse((n: any) => {
if (n.isMesh && n.material) {
const mats = Array.isArray(n.material) ? n.material : [n.material];
for (const m of mats) m.side = FrontSide;
}
});
const mix = new AnimationMixer(scene);
let m0: Object3D | null = null;
@ -60,11 +99,16 @@ export function DemoPlayerModel({
if (!m0 && n.name === "Mount0") m0 = n;
});
return { clonedScene: scene, mixer: mix, mount0: m0 };
return { clonedScene: scene, mixer: mix, mount0: m0, iflInitializers: iflInits };
}, [gltf]);
// Build case-insensitive clip lookup with alias support.
const animActionsRef = useRef(new Map<string, AnimationAction>());
const blendActionsRef = useRef<{
look: AnimationAction | null;
head: AnimationAction | null;
headside: AnimationAction | null;
}>({ look: null, head: null, headside: null });
const currentAnimRef = useRef({ name: "root", timeScale: 1 });
const isDeadRef = useRef(false);
@ -79,15 +123,57 @@ export function DemoPlayerModel({
}
currentAnimRef.current = { name: "root", timeScale: 1 };
// Set up additive blend animations for aim/head articulation.
// These clips must be cloned before makeClipAdditive (which mutates in
// place) since multiple player entities share the same GLTF cache.
const blendNames: Array<{ key: keyof typeof blendActionsRef.current; names: string[] }> = [
{ key: "look", names: ["lookde", "look"] },
{ key: "head", names: ["head"] },
{ key: "headside", names: ["headside"] },
];
const blendRefs: typeof blendActionsRef.current = { look: null, head: null, headside: null };
for (const { key, names } of blendNames) {
const clip = gltf.animations.find((c) =>
names.includes(c.name.toLowerCase()),
);
if (!clip) continue;
const cloned = clip.clone();
// Reference frame at clip midpoint = neutral pose. The second arg is a
// frame index (not time), so convert via fps.
const fps = 30;
const neutralFrame = Math.round((clip.duration * fps) / 2);
AnimationUtils.makeClipAdditive(cloned, neutralFrame, clip, fps);
const action = mixer.clipAction(cloned);
action.blendMode = AdditiveAnimationBlendMode;
action.timeScale = 0;
action.weight = 1;
action.play();
blendRefs[key] = action;
}
blendActionsRef.current = blendRefs;
// Force initial pose evaluation.
mixer.update(0);
return () => {
mixer.stopAllAction();
animActionsRef.current = new Map();
blendActionsRef.current = { look: null, head: null, headside: null };
};
}, [mixer, gltf.animations, shapeAliases]);
// Initialize IFL materials: load atlas textures and set up onBeforeRender
// callbacks that animate texture offsets based on the current playback time.
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const { mesh, initialize } of iflInitializers) {
initialize(mesh, () => timeRef.current)
.then((dispose) => cleanups.push(dispose))
.catch(() => {});
}
return () => cleanups.forEach((fn) => fn());
}, [iflInitializers]);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
const playback = engineStore.getState().playback;
@ -166,6 +252,28 @@ export function DemoPlayerModel({
}
}
// Drive additive blend animations for aim/head articulation.
const { look, head, headside } = blendActionsRef.current;
const blendWeight = isDead ? 0 : 1;
const headPitch = entity.headPitch ?? 0;
const headYaw = entity.headYaw ?? 0;
const pitchPos = (headPitch + 1) / 2;
const yawPos = (headYaw + 1) / 2;
if (look) {
look.time = pitchPos * look.getClip().duration;
look.weight = blendWeight;
}
if (head) {
head.time = pitchPos * head.getClip().duration;
head.weight = blendWeight;
}
if (headside) {
headside.time = yawPos * headside.getClip().duration;
headside.weight = blendWeight;
}
// Advance or evaluate the body animation mixer.
if (isPlaying) {
mixer.update(delta * playback.rate);
@ -182,9 +290,10 @@ export function DemoPlayerModel({
{entity.weaponShape && mount0 && (
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<AnimatedWeaponMount
weaponShape={entity.weaponShape}
<AnimatedWeaponModel
entity={entity}
mount0={mount0}
timeRef={timeRef}
/>
</Suspense>
</ShapeErrorBoundary>
@ -194,46 +303,375 @@ export function DemoPlayerModel({
}
/**
* Imperatively attaches a weapon model to the animated Mount0 bone.
* Computes the Mountpoint inverse offset so the weapon's grip aligns with
* the player's hand. The weapon follows the animated skeleton automatically.
* 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.
*/
export function AnimatedWeaponMount({
weaponShape,
function buildSeqIndexToName(
scene: Group,
animations: AnimationClip[],
): string[] {
const raw = scene.userData?.dts_sequence_names;
if (typeof raw === "string") {
try {
const names: string[] = JSON.parse(raw);
return names.map((n) => n.toLowerCase());
} catch { /* fall through */ }
}
return animations.map((a) => a.name.toLowerCase());
}
/**
* Attaches an animated weapon model to the player's Mount0 bone.
* Drives a weapon-specific AnimationMixer using the WeaponImageStateMachine
* to play fire, reload, spin, and other weapon animations based on the
* server-replicated condition flags.
*
* Reads `entity.weaponImageState` and `entity.weaponImageStates` directly
* from the entity inside useFrame, since these fields are mutated per-tick
* without triggering React re-renders.
*/
function AnimatedWeaponModel({
entity,
mount0,
timeRef,
}: {
weaponShape: string;
entity: DemoEntity;
mount0: Object3D;
timeRef: MutableRefObject<number>;
}) {
const weaponGltf = useStaticShape(weaponShape);
const engineStore = useEngineStoreApi();
const weaponGltf = useStaticShape(entity.weaponShape!);
// Clone weapon with skeleton bindings, create dedicated mixer.
const { weaponClone, weaponMixer, seqIndexToName, visNodesBySequence, weaponIflInitializers } =
useMemo(() => {
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
const iflInits = processShapeScene(clone);
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
if (mp) {
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
clone.position.copy(invPos);
clone.quaternion.copy(invQuat);
}
// Collect vis-animated meshes grouped by controlling sequence name.
// E.g. the disc launcher's Disc mesh has vis_sequence="discSpin" and is
// hidden by default (vis=0). When "discSpin" plays, the mesh becomes
// visible; when a different sequence plays, it hides again.
const visBySeq = new Map<string, Object3D[]>();
clone.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
const seqName = (ud?.vis_sequence ?? "").toLowerCase();
if (!seqName) return;
let list = visBySeq.get(seqName);
if (!list) {
list = [];
visBySeq.set(seqName, list);
}
list.push(node);
});
const mix = new AnimationMixer(clone);
const seq = buildSeqIndexToName(
weaponGltf.scene as Group,
weaponGltf.animations,
);
return {
weaponClone: clone,
weaponMixer: mix,
seqIndexToName: seq,
visNodesBySequence: visBySeq,
weaponIflInitializers: iflInits,
};
}, [weaponGltf]);
// Build case-insensitive action map for weapon animations.
const weaponActionsRef = useRef(new Map<string, AnimationAction>());
const spinActionRef = useRef<AnimationAction | null>(null);
useEffect(() => {
const weaponClone = weaponGltf.scene.clone(true);
processShapeScene(weaponClone);
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
if (mp) {
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
weaponClone.position.copy(invPos);
weaponClone.quaternion.copy(invQuat);
const actions = new Map<string, AnimationAction>();
for (const clip of weaponGltf.animations) {
actions.set(clip.name.toLowerCase(), weaponMixer.clipAction(clip));
}
weaponActionsRef.current = actions;
// Set up the spin thread: a looping "spin" animation with variable timeScale.
const spinAction = actions.get("spin");
if (spinAction) {
spinAction.setLoop(LoopRepeat, Infinity);
spinAction.timeScale = 0;
spinAction.play();
}
spinActionRef.current = spinAction ?? null;
// Force initial pose.
weaponMixer.update(0);
return () => {
weaponMixer.stopAllAction();
weaponActionsRef.current = new Map();
spinActionRef.current = null;
stopLoopingSound(loopingSoundRef, loopingSoundStateRef);
};
}, [weaponMixer, weaponGltf.animations]);
// Initialize IFL materials on the weapon model.
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const { mesh, initialize } of weaponIflInitializers) {
initialize(mesh, () => timeRef.current)
.then((dispose) => cleanups.push(dispose))
.catch(() => {});
}
return () => cleanups.forEach((fn) => fn());
}, [weaponIflInitializers]);
// Audio context for weapon sounds.
const { audioLoader, audioListener } = useAudio();
const settings = useSettings();
const audioEnabled = settings?.audioEnabled ?? false;
// Weapon state machine, lazily initialized on first tick with data.
const stateMachineRef = useRef<WeaponImageStateMachine | null>(null);
const currentWeaponAnimRef = useRef<string | null>(null);
const lastWeaponStatesRef = useRef(entity.weaponImageStates);
// Track active looping weapon sound (e.g. chaingun fire).
const loopingSoundRef = useRef<PositionalAudio | null>(null);
const loopingSoundStateRef = useRef<number>(-1);
// Imperatively attach/detach weapon clone to Mount0.
useEffect(() => {
mount0.add(weaponClone);
return () => {
mount0.remove(weaponClone);
};
}, [weaponGltf, mount0]);
}, [weaponClone, mount0]);
// Per-frame: tick state machine and drive weapon animation mixer.
useFrame((_, delta) => {
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
const actions = weaponActionsRef.current;
// Read weapon state directly from entity (mutated per-tick, not via props).
const imageState = entity.weaponImageState;
const imageStates = entity.weaponImageStates;
// Lazily create or recreate the state machine when the datablock states
// become available or change (e.g. weapon switch within same shape).
if (imageStates !== lastWeaponStatesRef.current) {
lastWeaponStatesRef.current = imageStates;
if (imageStates && imageStates.length > 0) {
stateMachineRef.current = new WeaponImageStateMachine(
imageStates,
seqIndexToName,
);
} else {
stateMachineRef.current = null;
}
currentWeaponAnimRef.current = null;
stopLoopingSound(loopingSoundRef, loopingSoundStateRef, weaponClone);
}
// Initialize state machine if we have states but haven't created it yet.
if (!stateMachineRef.current && imageStates && imageStates.length > 0) {
stateMachineRef.current = new WeaponImageStateMachine(
imageStates,
seqIndexToName,
);
}
const sm = stateMachineRef.current;
if (sm && imageState && isPlaying) {
const effectiveDelta = delta * playback.rate;
const animState = sm.tick(effectiveDelta, imageState);
applyWeaponAnim(
animState,
actions,
currentWeaponAnimRef,
visNodesBySequence,
);
// Stop active looping sound when the state changes.
if (
loopingSoundRef.current &&
animState.stateIndex !== loopingSoundStateRef.current
) {
stopLoopingSound(loopingSoundRef, loopingSoundStateRef, weaponClone);
}
// Play weapon state-entry sounds as positional audio on transitions.
// The engine plays a sound for every state entered during a transition
// chain, so there may be multiple sounds per tick.
if (
audioEnabled &&
audioLoader &&
audioListener &&
animState.soundDataBlockIds.length > 0
) {
const getDb = playback.recording?.streamingPlayback.getDataBlockData
.bind(playback.recording.streamingPlayback);
if (getDb) {
for (const soundDbId of animState.soundDataBlockIds) {
const resolved = resolveAudioProfile(soundDbId, getDb);
if (!resolved) continue;
if (resolved.isLooping) {
// Looping sounds (e.g. chaingun fire) persist while in this
// state and stop on transition to a different state.
if (!loopingSoundRef.current) {
try {
const url = audioToUrl(resolved.filename);
getCachedAudioBuffer(url, audioLoader, (buffer) => {
// Guard: state may have changed by the time buffer loads.
if (loopingSoundRef.current) return;
// Read live state index (not the closure-captured one).
const currentIdx = sm.stateIndex;
const sound = new PositionalAudio(audioListener);
sound.setBuffer(buffer);
sound.setDistanceModel("inverse");
sound.setRefDistance(resolved.refDist);
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setLoop(true);
weaponClone.add(sound);
sound.play();
loopingSoundRef.current = sound;
loopingSoundStateRef.current = currentIdx;
});
} catch {}
}
} else {
playOneShotSound(
resolved,
audioListener,
audioLoader,
undefined,
weaponClone,
);
}
}
}
}
// Drive the spin thread (e.g. chaingun barrel rotation).
if (spinActionRef.current) {
spinActionRef.current.timeScale = animState.spinTimeScale;
}
}
// Advance the weapon mixer.
if (isPlaying) {
weaponMixer.update(delta * playback.rate);
} else {
weaponMixer.update(0);
}
});
return null;
}
/**
* Applies the weapon state machine output to the weapon's AnimationMixer.
* Handles crossfading between sequences, configuring loop/timeScale, and
* toggling DTS vis-node visibility (e.g. disc launcher's disc mesh).
*/
function applyWeaponAnim(
animState: WeaponAnimState,
actions: Map<string, AnimationAction>,
currentAnimRef: MutableRefObject<string | null>,
visNodesBySequence: Map<string, Object3D[]>,
): void {
const targetName = animState.sequenceName;
const currentName = currentAnimRef.current;
if (targetName === currentName && !animState.transitioned) {
return;
}
// Toggle vis-node visibility when the active sequence changes.
// Meshes with vis_sequence are hidden by default (processShapeScene sets
// visible=false for vis<0.01). They become visible only when their
// controlling sequence is the active one. E.g. the disc launcher's Disc
// mesh has vis_sequence="discspin" and appears only during the discSpin
// (Ready) state.
if (targetName !== currentName) {
// Hide vis nodes from the previous sequence.
if (currentName) {
const prevVis = visNodesBySequence.get(currentName);
if (prevVis) {
for (const node of prevVis) node.visible = false;
}
}
// Show vis nodes for the new sequence.
if (targetName) {
const nextVis = visNodesBySequence.get(targetName);
if (nextVis) {
for (const node of nextVis) node.visible = true;
}
}
}
if (!targetName) {
// No sequence for this state — stop current animation.
if (currentName) {
const prev = actions.get(currentName);
if (prev) prev.fadeOut(ANIM_TRANSITION_TIME);
currentAnimRef.current = null;
}
return;
}
const action = actions.get(targetName);
if (!action) return;
// On state transition, restart the animation.
if (animState.transitioned || targetName !== currentName) {
const prevAction = currentName ? actions.get(currentName) : null;
// Fire/reload animations play once; others loop.
if (animState.isFiring || animState.timeoutValue > 0) {
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
} else {
action.setLoop(LoopRepeat, Infinity);
action.clampWhenFinished = false;
}
// Scale animation to fit the state timeout if requested.
if (animState.scaleAnimation && animState.timeoutValue > 0) {
const clipDuration = action.getClip().duration;
action.timeScale = clipDuration > 0
? clipDuration / animState.timeoutValue
: 1;
} else {
action.timeScale = animState.reverse ? -1 : 1;
}
if (prevAction && prevAction !== action) {
prevAction.fadeOut(ANIM_TRANSITION_TIME);
action.reset().fadeIn(ANIM_TRANSITION_TIME).play();
} else {
action.reset().play();
}
currentAnimRef.current = targetName;
}
}
/**
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton

View file

@ -2,19 +2,6 @@ import { useCallback, type ReactNode } from "react";
import type { DemoRecording } from "../demo/types";
import { useEngineSelector } from "../state";
interface DemoContextValue {
recording: DemoRecording | null;
setRecording: (recording: DemoRecording | null) => void;
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
play: () => void;
pause: () => void;
seek: (time: number) => void;
setSpeed: (speed: number) => void;
}
export function DemoProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@ -86,29 +73,3 @@ export function useDemoActions() {
setSpeed,
};
}
export function useDemo(): DemoContextValue {
const recording = useDemoRecording();
const isPlaying = useDemoIsPlaying();
const currentTime = useDemoCurrentTime();
const duration = useDemoDuration();
const speed = useDemoSpeed();
const actions = useDemoActions();
return {
recording,
isPlaying,
currentTime,
duration,
speed,
setRecording: actions.setRecording,
play: actions.play,
pause: actions.pause,
seek: actions.seek,
setSpeed: actions.setSpeed,
};
}
export function useDemoOptional(): DemoContextValue {
return useDemo();
}

View file

@ -86,7 +86,7 @@ export function applyShapeShaderModifications(
export function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture,
texture: Texture | null,
flagNames: Set<string>,
isOrganic: boolean,
vis: number = 1,
@ -666,7 +666,7 @@ export const ShapeModel = memo(function ShapeModel({
if (v.mesh.material?.isMeshStandardMaterial) {
const mat = v.mesh.material as MeshStandardMaterial;
const result = replaceWithShapeMaterial(mat, v.mesh.userData?.vis ?? 0);
v.mesh.material = Array.isArray(result) ? result[1] : result;
v.mesh.material = result.material;
}
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.transparent = true;
@ -898,10 +898,12 @@ export const ShapeModel = memo(function ShapeModel({
const currentDemoThreads = demoThreadsRef.current;
const prevDemoThreads = prevDemoThreadsRef.current;
if (currentDemoThreads !== prevDemoThreads) {
prevDemoThreadsRef.current = currentDemoThreads;
const playThread = handlePlayThreadRef.current;
const stopThread = handleStopThreadRef.current;
// Don't consume thread data until handlers are ready — leave
// prevDemoThreadsRef unchanged so the change is re-detected next frame.
if (playThread && stopThread) {
prevDemoThreadsRef.current = currentDemoThreads;
// Use sparse arrays instead of Maps — thread indices are 0-3.
const currentBySlot: Array<DemoThreadState | undefined> = [];
if (currentDemoThreads) {
@ -921,6 +923,28 @@ export const ShapeModel = memo(function ShapeModel({
|| 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;
if (onlyAtEndChanged) {
const thread = threads.get(slot);
if (thread?.action) {
const clip = thread.action.getClip();
thread.action.time = t.forward ? clip.duration : 0;
thread.action.setLoop(LoopOnce, 1);
thread.action.clampWhenFinished = true;
thread.action.paused = true;
}
continue;
}
const seqName = seqIndexToName[t.sequence];
if (!seqName) continue;
if (t.state === 0) {

View file

@ -6,48 +6,233 @@
bottom: 0;
z-index: 1;
pointer-events: none;
padding-bottom: 48px;
}
.ChatWindow {
/* ── Top-right cluster: compass + bars ── */
.TopRight {
position: absolute;
top: 60px;
left: 4px;
top: 56px;
right: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
}
.Bar {
width: 160px;
height: 14px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
position: absolute;
.Compass {
position: relative;
width: 64px;
height: 64px;
flex-shrink: 0;
}
.HealthBar {
composes: Bar;
top: 60px;
right: 32px;
}
.EnergyBar {
composes: Bar;
top: 80px;
right: 32px;
}
.BarFill {
.CompassRing {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
image-rendering: auto;
}
.CompassNSEW {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.Bars {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 10px;
}
.BarTrack {
width: 120px;
height: 10px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
overflow: hidden;
}
.BarFillHealth {
height: 100%;
background: #2ecc40;
transition: width 0.15s ease-out;
}
.HealthBar .BarFill {
background: #2ecc40;
.BarFillEnergy {
height: 100%;
background: #0af;
transition: width 0.15s ease-out;
}
.EnergyBar .BarFill {
background: #0af;
/* ── Weapon HUD (right side vertical list) ── */
.WeaponHUD {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 2px;
}
.WeaponSeparator {
height: 6px;
}
/* ── Chat Window (top-left) ── */
.ChatWindow {
position: absolute;
top: 56px;
left: 0;
max-width: 420px;
background: rgba(0, 50, 60, 0.65);
padding: 4px 8px;
font-size: 12px;
line-height: 1.3;
}
.ChatMessage {
padding: 1px 0;
transition: opacity 0.3s ease-out;
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
color: rgb(44, 172, 181);
}
/* T2 GuiChatHudProfile fontColors palette (\c0\c9). */
.ChatColor0 { color: rgb(44, 172, 181); }
.ChatColor1 { color: rgb(4, 235, 105); }
.ChatColor2 { color: rgb(219, 200, 128); }
.ChatColor3 { color: rgb(77, 253, 95); }
.ChatColor4 { color: rgb(40, 231, 240); }
.ChatColor5 { color: rgb(200, 200, 50); }
.ChatColor6 { color: rgb(200, 200, 200); }
.ChatColor7 { color: rgb(220, 220, 20); }
.ChatColor8 { color: rgb(150, 150, 250); }
.ChatColor9 { color: rgb(60, 220, 150); }
/* ── Team Scores (bottom-left) ── */
.TeamScores {
position: absolute;
bottom: 130px;
left: 0;
font-family: monospace;
font-size: 12px;
}
.TeamRow {
display: flex;
gap: 6px;
padding: 2px 8px;
background: rgba(0, 50, 60, 0.65);
}
.TeamRow + .TeamRow {
border-top: 1px solid rgba(128, 255, 200, 0.15);
}
.TeamNameFriendly {
color: #2ecc40;
min-width: 60px;
}
.TeamNameEnemy {
color: #e44;
min-width: 60px;
}
.TeamScore {
color: #fff;
min-width: 24px;
text-align: right;
font-weight: bold;
}
.TeamCount {
color: #9ba;
min-width: 24px;
text-align: right;
}
/* ── Pack + Inventory HUD (bottom-right) ── */
.PackInventoryHUD {
position: absolute;
bottom: 100px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.PackInvItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 50, 60, 0.65);
border: 1px solid rgba(128, 255, 200, 0.15);
padding: 4px;
gap: 1px;
}
.PackInvItemActive {
border-color: rgba(128, 255, 200, 0.5);
box-shadow: 0 0 6px rgba(128, 255, 200, 0.3);
}
.PackInvItemDim {
opacity: 0.5;
}
.PackInvIcon {
display: block;
image-rendering: pixelated;
}
.PackInvCount {
font-family: monospace;
font-size: 11px;
color: #bfe;
min-width: 12px;
text-align: center;
}
.PackInvInfinity {
display: block;
image-rendering: pixelated;
opacity: 0.8;
}
/* ── Reticle (center) ── */
.Reticle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.ReticleImage {
width: 64px;
height: 64px;
opacity: 0.85;
image-rendering: pixelated;
}
.ReticleDot {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(46, 204, 64, 0.7);
box-shadow: 0 0 4px rgba(46, 204, 64, 0.5);
}

View file

@ -1,12 +1,47 @@
import { useDemoRecording } from "./DemoProvider";
import { useEngineSelector } from "../state";
import { textureToUrl } from "../loaders";
import type {
ChatSegment,
DemoChatMessage,
DemoStreamEntity,
TeamScore,
WeaponsHudSlot,
} from "../demo/types";
import styles from "./PlayerHUD.module.css";
// ── Compass ──
const COMPASS_URL = textureToUrl("gui/hud_new_compass");
const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
function Compass({ yaw }: { yaw: number | undefined }) {
if (yaw == null) return null;
// The ring notch is the fixed heading indicator (always "forward" at top).
// The NSEW letters rotate to show world cardinal directions relative to
// the player's heading. Positive Torque yaw = turning right (clockwise
// from above), so N moves counter-clockwise on the display.
const deg = (yaw * 180) / Math.PI;
return (
<div className={styles.Compass}>
<img src={COMPASS_URL} alt="" className={styles.CompassRing} />
<img
src={NSEW_URL}
alt=""
className={styles.CompassNSEW}
style={{ transform: `rotate(${-deg}deg)` }}
/>
</div>
);
}
// ── Health / Energy bars ──
function HealthBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100));
return (
<div className={styles.HealthBar}>
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
<div className={styles.BarTrack}>
<div className={styles.BarFillHealth} style={{ width: `${pct}%` }} />
</div>
);
}
@ -14,36 +49,433 @@ function HealthBar({ value }: { value: number }) {
function EnergyBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100));
return (
<div className={styles.EnergyBar}>
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
<div className={styles.BarTrack}>
<div className={styles.BarFillEnergy} style={{ width: `${pct}%` }} />
</div>
);
}
function ChatWindow() {
return <div className={styles.ChatWindow} />;
}
// ── Reticle ──
function WeaponSlots() {
return <div className={styles.WeaponSlots} />;
}
const RETICLE_TEXTURES: Record<string, string> = {
weapon_sniper: "gui/hud_ret_sniper",
weapon_shocklance: "gui/hud_ret_shocklance",
weapon_targeting: "gui/hud_ret_targlaser",
};
function ToolBelt() {
return <div className={styles.ToolBelt} />;
function normalizeWeaponName(shape: string | undefined): string {
if (!shape) return "";
return shape.replace(/\.dts$/i, "").toLowerCase();
}
function Reticle() {
return <div className={styles.Reticle} />;
const weaponShape = useEngineSelector((state) => {
const snap = state.playback.streamSnapshot;
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)
?.weaponShape;
});
if (weaponShape === undefined) return null;
const weapon = normalizeWeaponName(weaponShape);
const textureName = RETICLE_TEXTURES[weapon];
if (textureName) {
return (
<div className={styles.Reticle}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={textureToUrl(textureName)}
alt=""
className={styles.ReticleImage}
/>
</div>
);
}
return (
<div className={styles.Reticle}>
<div className={styles.ReticleDot} />
</div>
);
}
function TeamStats() {
return <div className={styles.TeamStats} />;
// ── Weapon HUD (right side weapon list) ──
/** Maps $WeaponsHudData indices to simple icon textures (no baked background)
* and labels. Mortar uses hud_new_ because no simple variant exists. */
const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
0: { icon: "gui/hud_blaster", label: "Blaster" },
1: { icon: "gui/hud_plasma", label: "Plasma" },
2: { icon: "gui/hud_chaingun", label: "Chaingun" },
3: { icon: "gui/hud_disc", label: "Spinfusor" },
4: { icon: "gui/hud_grenlaunch", label: "GL" },
5: { icon: "gui/hud_sniper", label: "Laser Rifle" },
6: { icon: "gui/hud_elfgun", label: "ELF Gun" },
7: { icon: "gui/hud_new_mortar", label: "Mortar" },
8: { icon: "gui/hud_missiles", label: "Missile" },
9: { icon: "gui/hud_targetlaser", label: "Targeting" },
10: { icon: "gui/hud_shocklance", label: "Shocklance" },
// TR2 variants reuse the same icons.
11: { icon: "gui/hud_disc", label: "Spinfusor" },
12: { icon: "gui/hud_grenlaunch", label: "GL" },
13: { icon: "gui/hud_chaingun", label: "Chaingun" },
14: { icon: "gui/hud_targetlaser", label: "Targeting" },
15: { icon: "gui/hud_targetlaser", label: "Targeting" },
16: { icon: "gui/hud_shocklance", label: "Shocklance" },
17: { icon: "gui/hud_new_mortar", label: "Mortar" },
};
// Precompute URLs so we don't call textureToUrl on every render.
const WEAPON_HUD_ICON_URLS = new Map(
Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [
Number(idx),
textureToUrl(w.icon),
]),
);
/** Targeting laser HUD indices (standard + TR2 variants). */
const TARGETING_LASER_INDICES = new Set([9, 14, 15]);
const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity");
function WeaponSlotIcon({
slot,
isSelected,
}: {
slot: WeaponsHudSlot;
isSelected: boolean;
}) {
const info = WEAPON_HUD_SLOTS[slot.index];
if (!info) return null;
const isInfinite = slot.ammo < 0;
return (
<div
className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={WEAPON_HUD_ICON_URLS.get(slot.index)!}
alt={info.label}
className={styles.PackInvIcon}
/>
{isInfinite ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={INFINITY_ICON_URL}
alt="\u221E"
className={styles.PackInvInfinity}
/>
) : (
<span className={styles.PackInvCount}>{slot.ammo}</span>
)}
</div>
);
}
function Compass() {
return <div className={styles.Compass} />;
function WeaponHUD() {
const weaponsHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.weaponsHud,
);
if (!weaponsHud || !weaponsHud.slots.length) return null;
const weapons: WeaponsHudSlot[] = [];
const targeting: WeaponsHudSlot[] = [];
for (const slot of weaponsHud.slots) {
if (TARGETING_LASER_INDICES.has(slot.index)) {
targeting.push(slot);
} else {
weapons.push(slot);
}
}
return (
<div className={styles.WeaponHUD}>
{weapons.map((slot) => (
<WeaponSlotIcon
key={slot.index}
slot={slot}
isSelected={slot.index === weaponsHud.activeIndex}
/>
))}
{targeting.length > 0 && <div className={styles.WeaponSeparator} />}
{targeting.map((slot) => (
<WeaponSlotIcon
key={slot.index}
slot={slot}
isSelected={slot.index === weaponsHud.activeIndex}
/>
))}
</div>
);
}
// ── Team Scores (bottom-left) ──
/** Default team names from serverDefaults.cs. */
const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm",
2: "Inferno",
3: "Starwolf",
4: "Diamond Sword",
5: "Blood Eagle",
6: "Phoenix",
};
function TeamScores() {
const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores,
);
const playerSensorGroup = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerSensorGroup,
);
if (!teamScores?.length) return null;
// Sort: friendly team first (if known), then by teamId.
const sorted = [...teamScores].sort((a, b) => {
if (playerSensorGroup) {
if (a.teamId === playerSensorGroup) return -1;
if (b.teamId === playerSensorGroup) return 1;
}
return a.teamId - b.teamId;
});
return (
<div className={styles.TeamScores}>
{sorted.map((team: TeamScore) => {
const isFriendly =
playerSensorGroup > 0 && team.teamId === playerSensorGroup;
const name =
team.name ||
(DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`);
return (
<div key={team.teamId} className={styles.TeamRow}>
<span
className={
isFriendly ? styles.TeamNameFriendly : styles.TeamNameEnemy
}
>
{name}
</span>
<span className={styles.TeamScore}>{team.score}</span>
<span className={styles.TeamCount}>({team.playerCount})</span>
</div>
);
})}
</div>
);
}
// ── Chat Window (top-left) ──
/** Map a colorCode to a CSS module class name (c0c9 GuiChatHudProfile). */
const CHAT_COLOR_CLASSES: Record<number, string> = {
0: styles.ChatColor0,
1: styles.ChatColor1,
2: styles.ChatColor2,
3: styles.ChatColor3,
4: styles.ChatColor4,
5: styles.ChatColor5,
6: styles.ChatColor6,
7: styles.ChatColor7,
8: styles.ChatColor8,
9: styles.ChatColor9,
};
function segmentColorClass(colorCode: number): string {
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
}
function chatColorClass(msg: DemoChatMessage): string {
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
return CHAT_COLOR_CLASSES[msg.colorCode];
}
// Fallback: default to \c0 (teal). Messages with detected codes (like \c2
// for flag events) will match above; \c0 kill messages may lose their null
// byte color code, so the correct default for server messages is c0.
return CHAT_COLOR_CLASSES[0];
}
function ChatWindow() {
const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages,
);
const timeSec = useEngineSelector(
(state) => state.playback.streamSnapshot?.timeSec,
);
if (!messages || !messages.length || timeSec == null) return null;
const fadeStart = 6;
const fadeDuration = 1.5;
const cutoff = timeSec - (fadeStart + fadeDuration);
const visible = messages.filter(
(m: DemoChatMessage) => m.timeSec > cutoff && m.text.trim() !== "",
);
if (!visible.length) return null;
return (
<div className={styles.ChatWindow}>
{visible.map((msg: DemoChatMessage, i: number) => {
const age = timeSec - msg.timeSec;
const opacity =
age <= fadeStart
? 1
: Math.max(0, 1 - (age - fadeStart) / fadeDuration);
return (
<div
key={`${msg.timeSec}-${i}`}
className={styles.ChatMessage}
style={{ opacity }}
>
{msg.segments ? (
msg.segments.map((seg: ChatSegment, j: number) => (
<span key={j} className={segmentColorClass(seg.colorCode)}>
{seg.text}
</span>
))
) : (
<span className={chatColorClass(msg)}>
{msg.sender ? `${msg.sender}: ` : ""}
{msg.text}
</span>
)}
</div>
);
})}
</div>
);
}
// ── Backpack + Inventory HUD (bottom-right) ──
/** Maps $BackpackHudData indices to icon textures. */
const BACKPACK_ICONS: Record<number, string> = {
0: "gui/hud_new_packammo",
1: "gui/hud_new_packcloak",
2: "gui/hud_new_packenergy",
3: "gui/hud_new_packrepair",
4: "gui/hud_new_packsatchel",
5: "gui/hud_new_packshield",
6: "gui/hud_new_packinventory",
7: "gui/hud_new_packmotionsens",
8: "gui/hud_new_packradar",
9: "gui/hud_new_packturretout",
10: "gui/hud_new_packturretin",
11: "gui/hud_new_packsensjam",
12: "gui/hud_new_packturret",
13: "gui/hud_new_packturret",
14: "gui/hud_new_packturret",
15: "gui/hud_new_packturret",
16: "gui/hud_new_packturret",
17: "gui/hud_new_packturret",
18: "gui/hud_satchel_unarmed",
19: "gui/hud_new_packenergy",
};
/** Pack indices that have an armed/activated icon variant. */
const BACKPACK_ARMED_ICONS: Record<number, string> = {
1: "gui/hud_new_packcloak_armed",
3: "gui/hud_new_packrepair_armed",
4: "gui/hud_satchel_armed",
5: "gui/hud_new_packshield_armed",
11: "gui/hud_new_packsensjam_armed",
};
// Precompute URLs.
const BACKPACK_ICON_URLS = new Map(
Object.entries(BACKPACK_ICONS).map(([idx, tex]) => [
Number(idx),
textureToUrl(tex),
]),
);
const BACKPACK_ARMED_ICON_URLS = new Map(
Object.entries(BACKPACK_ARMED_ICONS).map(([idx, tex]) => [
Number(idx),
textureToUrl(tex),
]),
);
/** Simple icons per inventory display slot (no baked-in background). */
const INVENTORY_SLOT_ICONS: Record<number, { icon: string; label: string }> = {
0: { icon: "gui/hud_handgren", label: "Grenade" },
1: { icon: "gui/hud_mine", label: "Mine" },
2: { icon: "gui/hud_beacon", label: "Beacon" },
3: { icon: "gui/hud_medpack", label: "Repair Kit" },
};
const INVENTORY_ICON_URLS = new Map(
Object.entries(INVENTORY_SLOT_ICONS).map(([slot, info]) => [
Number(slot),
textureToUrl(info.icon),
]),
);
function PackAndInventoryHUD() {
const backpackHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.backpackHud,
);
const inventoryHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.inventoryHud,
);
const hasPack = backpackHud && backpackHud.packIndex >= 0;
// Resolve pack icon.
let packIconUrl: string | undefined;
if (hasPack) {
const armedUrl = backpackHud.active
? BACKPACK_ARMED_ICON_URLS.get(backpackHud.packIndex)
: undefined;
packIconUrl = armedUrl ?? BACKPACK_ICON_URLS.get(backpackHud.packIndex);
}
// Build count lookup from snapshot data.
const countBySlot = new Map<number, number>();
if (inventoryHud) {
for (const s of inventoryHud.slots) {
countBySlot.set(s.slot, s.count);
}
}
// Always show all inventory slot types, defaulting to 0.
const allSlotIds = Object.keys(INVENTORY_SLOT_ICONS)
.map(Number)
.sort((a, b) => a - b);
if (!hasPack && !countBySlot.size) return null;
return (
<div className={styles.PackInventoryHUD}>
{packIconUrl && (
<div
className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={packIconUrl} alt="" className={styles.PackInvIcon} />
<span className={styles.PackInvCount}>
{backpackHud!.text || "\u00A0"}
</span>
</div>
)}
{allSlotIds.map((slotId) => {
const info = INVENTORY_SLOT_ICONS[slotId];
const iconUrl = INVENTORY_ICON_URLS.get(slotId);
if (!info || !iconUrl) return null;
return (
<div key={slotId} className={styles.PackInvItem}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconUrl}
alt={info.label}
className={styles.PackInvIcon}
/>
<span className={styles.PackInvCount}>
{countBySlot.get(slotId) ?? 0}
</span>
</div>
);
})}
</div>
);
}
// ── Main HUD ──
export function PlayerHUD() {
const recording = useDemoRecording();
const streamSnapshot = useEngineSelector(
@ -57,13 +489,17 @@ export function PlayerHUD() {
return (
<div className={styles.PlayerHUD}>
<ChatWindow />
<Compass />
<HealthBar value={status.health} />
<EnergyBar value={status.energy} />
<TeamStats />
<div className={styles.TopRight}>
<div className={styles.Bars}>
<HealthBar value={status.health} />
<EnergyBar value={status.energy} />
</div>
<Compass yaw={streamSnapshot?.camera?.yaw} />
</div>
<WeaponHUD />
<PackAndInventoryHUD />
<TeamScores />
<Reticle />
<ToolBelt />
<WeaponSlots />
</div>
);
}

View file

@ -7,9 +7,9 @@ import {
NearestFilter,
SRGBColorSpace,
Texture,
TextureLoader,
} from "three";
import { iflTextureToUrl, loadImageFrameList } from "../loaders";
import { loadTextureAsync } from "../textureUtils";
import { useTick, TICK_RATE } from "./TickProvider";
import { useSettings } from "./SettingsProvider";
@ -32,16 +32,8 @@ export interface IflAtlas {
// Module-level cache for atlas textures, shared across all components.
const atlasCache = new Map<string, IflAtlas>();
const _textureLoader = new TextureLoader();
function loadTextureAsync(url: string): Promise<Texture> {
return new Promise((resolve, reject) => {
_textureLoader.load(url, resolve, undefined, reject);
});
}
function createAtlas(textures: Texture[]): IflAtlas {
const firstImage = textures[0].image as HTMLImageElement;
const firstImage = textures[0].image as HTMLImageElement | ImageBitmap;
const frameWidth = firstImage.width;
const frameHeight = firstImage.height;
const frameCount = textures.length;

View file

@ -9,11 +9,11 @@ import {
NoColorSpace,
Object3D,
Quaternion,
TextureLoader,
Vector3,
} from "three";
import type {
BufferGeometry,
Material,
MeshStandardMaterial,
Texture,
} from "three";
@ -21,13 +21,17 @@ import {
createMaterialFromFlags,
applyShapeShaderModifications,
} from "../components/GenericShape";
import {
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "../components/useIflTexture";
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
import { setupTexture } from "../textureUtils";
import { loadTexture, setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type {
DemoEntity,
DemoKeyframe,
DemoStreamSnapshot,
} from "./types";
/** Fallback eye height when the player model isn't loaded or has no Cam node. */
@ -38,7 +42,6 @@ export const ANIM_TRANSITION_TIME = 0.25;
export const STREAM_TICK_MS = 32;
export const STREAM_TICK_SEC = STREAM_TICK_MS / 1000;
export const CAMERA_COLLISION_RADIUS = 0.05;
// ── Temp vectors / quaternions (module-level to avoid per-frame alloc) ──
@ -54,15 +57,6 @@ export const _r90 = new Quaternion().setFromAxisAngle(
);
export const _r90inv = _r90.clone().invert();
// ── Lifecycle tracking ──
let lifecycleInstanceIdSeed = 0;
export function nextLifecycleInstanceId(prefix: string): string {
lifecycleInstanceIdSeed += 1;
return `${prefix}-${lifecycleInstanceIdSeed}`;
}
// ── Pure functions ──
/**
@ -235,7 +229,11 @@ export function smoothVertexNormals(geometry: BufferGeometry): void {
normAttr.needsUpdate = true;
}
const _textureLoader = new TextureLoader();
export interface ShapeMaterialResult {
material: Material;
/** For IFL materials: loads atlas, configures texture, sets up animation. */
initialize?: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
}
/**
* Replace a PBR MeshStandardMaterial with a diffuse-only Lambert/Basic material
@ -243,7 +241,10 @@ const _textureLoader = new TextureLoader();
* from URLs (GLB files don't embed texture data; they store a resource_path in
* material userData instead).
*/
export function replaceWithShapeMaterial(mat: MeshStandardMaterial, vis: number) {
export function replaceWithShapeMaterial(
mat: MeshStandardMaterial,
vis: number,
): ShapeMaterialResult {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
@ -255,30 +256,75 @@ export function replaceWithShapeMaterial(mat: MeshStandardMaterial, vis: number)
reflectivity: 0,
});
applyShapeShaderModifications(fallback);
return fallback;
return { material: fallback };
}
// Load texture asynchronously via Three.js TextureLoader. The returned
// IFL materials need async atlas loading — create with null map to avoid
// "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;
return {
material,
initialize: (mesh, getTime) =>
initializeIflMaterial(material, resourcePath, mesh, getTime),
};
}
// Load texture via ImageBitmapLoader (decodes off main thread). The returned
// Texture is empty initially and gets populated when the image arrives;
// Three.js re-renders automatically once loaded.
const url = textureToUrl(resourcePath);
const texture = _textureLoader.load(url);
const texture = loadTexture(url);
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.
if (Array.isArray(result)) {
return result[1];
}
return result;
const material = Array.isArray(result) ? result[1] : result;
return { material };
}
export interface IflInitializer {
mesh: Object3D;
initialize: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
}
async function initializeIflMaterial(
material: Material,
resourcePath: string,
mesh: Object3D,
getTime: () => number,
): Promise<() => void> {
const iflPath = `textures/${resourcePath}.ifl`;
const atlas = await loadIflAtlas(iflPath);
(material as any).map = atlas.texture;
material.needsUpdate = true;
let disposed = false;
const prevOnBeforeRender = mesh.onBeforeRender;
mesh.onBeforeRender = function (this: any, ...args: any[]) {
prevOnBeforeRender?.apply(this, args);
if (disposed) return;
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
};
return () => {
disposed = true;
mesh.onBeforeRender = prevOnBeforeRender ?? (() => {});
};
}
/**
* Post-process a cloned shape scene: hide collision/hull geometry, smooth
* normals, and replace PBR materials with diffuse-only Lambert materials.
* Returns IFL initializers for any IFL materials found.
*/
export function processShapeScene(scene: Object3D): void {
export function processShapeScene(scene: Object3D): IflInitializer[] {
const iflInitializers: IflInitializer[] = [];
// Find skeleton for hull bone filtering.
let skeleton: any = null;
scene.traverse((n: any) => {
@ -291,16 +337,20 @@ export function processShapeScene(scene: Object3D): void {
scene.traverse((node: any) => {
if (!node.isMesh) return;
// Hide unwanted nodes: hull geometry, unassigned materials, invisible objects.
if (
node.name.match(/^Hulk/i) ||
node.material?.name === "Unassigned" ||
(node.userData?.vis ?? 1) < 0.01
) {
// Hide unwanted nodes: hull geometry, unassigned materials.
if (node.name.match(/^Hulk/i) || node.material?.name === "Unassigned") {
node.visible = false;
return;
}
// Hide vis-animated meshes (default vis < 0.01) but DON'T skip material
// replacement — they need correct textures for when they become visible
// (e.g. disc launcher's Disc mesh toggles visibility via state machine).
const hasVisSequence = !!node.userData?.vis_sequence;
if ((node.userData?.vis ?? 1) < 0.01) {
node.visible = false;
}
// Filter hull-influenced triangles and smooth normals.
if (node.geometry) {
let geometry = filterGeometryByVertexGroups(
@ -313,47 +363,27 @@ export function processShapeScene(scene: Object3D): void {
}
// Replace PBR materials with diffuse-only Lambert materials.
const vis: number = node.userData?.vis ?? 1;
// For vis-animated meshes, use vis=1 so the material is fully opaque —
// their visibility is toggled via node.visible, not material opacity.
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
if (Array.isArray(node.material)) {
node.material = node.material.map((m: MeshStandardMaterial) =>
replaceWithShapeMaterial(m, vis),
);
node.material = node.material.map((m: MeshStandardMaterial) => {
const result = replaceWithShapeMaterial(m, vis);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
return result.material;
});
} else if (node.material) {
node.material = replaceWithShapeMaterial(node.material, vis);
const result = replaceWithShapeMaterial(node.material, vis);
if (result.initialize) {
iflInitializers.push({ mesh: node, initialize: result.initialize });
}
node.material = result.material;
}
});
}
export function collectSceneObjectCounts(scene: Object3D): {
sceneObjects: number;
visibleSceneObjects: number;
} {
let sceneObjects = 0;
let visibleSceneObjects = 0;
scene.traverse((node) => {
sceneObjects += 1;
if (node.visible) {
visibleSceneObjects += 1;
}
});
return { sceneObjects, visibleSceneObjects };
}
export function streamSnapshotSignature(snapshot: DemoStreamSnapshot): string {
const parts: string[] = [];
for (const entity of snapshot.entities) {
const visualPart =
entity.visual?.kind === "tracer"
? `tracer:${entity.visual.texture}:${entity.visual.crossTexture ?? ""}:${entity.visual.tracerLength}:${entity.visual.tracerWidth}:${entity.visual.crossViewAng}:${entity.visual.crossSize}:${entity.visual.renderCross ? 1 : 0}`
: entity.visual?.kind === "sprite"
? `sprite:${entity.visual.texture}:${entity.visual.color.r}:${entity.visual.color.g}:${entity.visual.color.b}:${entity.visual.size}`
: "";
parts.push(
`${entity.id}|${entity.type}|${entity.dataBlock ?? ""}|${entity.weaponShape ?? ""}|${entity.playerName ?? ""}|${entity.className ?? ""}|${entity.ghostIndex ?? ""}|${entity.dataBlockId ?? ""}|${entity.shapeHint ?? ""}|${entity.faceViewer ? "fv" : ""}|${visualPart}`,
);
}
parts.sort();
return parts.join(";");
return iflInitializers;
}
export function buildStreamDemoEntity(
@ -391,15 +421,6 @@ export function buildStreamDemoEntity(
};
}
export function hasAncestorNamed(object: Object3D | null, name: string): boolean {
let node: Object3D | null = object;
while (node) {
if (node.name === name) return true;
node = node.parent;
}
return false;
}
export function entityTypeColor(type: string): string {
switch (type.toLowerCase()) {
case "player":

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,41 @@ export interface DemoThreadState {
atEnd: boolean;
}
export interface WeaponImageState {
dataBlockId: number;
triggerDown: boolean;
ammo: boolean;
loaded: boolean;
target: boolean;
wet: boolean;
fireCount: number;
}
export interface WeaponImageDataBlockState {
name: string;
transitionOnLoaded: number;
transitionOnNotLoaded: number;
transitionOnAmmo: number;
transitionOnNoAmmo: number;
transitionOnTarget: number;
transitionOnNoTarget: number;
transitionOnWet: number;
transitionOnNotWet: number;
transitionOnTriggerUp: number;
transitionOnTriggerDown: number;
transitionOnTimeout: number;
timeoutValue?: number;
waitForTimeout: boolean;
fire: boolean;
sequence?: number;
spin: number;
direction: boolean;
scaleAnimation: boolean;
loaded: number;
/** AudioProfile datablock ID for the state's entry sound, or -1 if none. */
soundDataBlockId: number;
}
export interface DemoKeyframe {
time: number;
/** Position in Torque space [x, y, z]. */
@ -82,6 +117,14 @@ export interface DemoEntity {
playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: 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;
}
export interface DemoRecording {
@ -126,6 +169,14 @@ export interface DemoStreamEntity {
explosionDataBlockId?: number;
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
maintainEmitterId?: 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;
}
export interface DemoStreamCamera {
@ -147,13 +198,96 @@ export interface DemoStreamCamera {
pitch?: number;
}
/** A colored text segment from inline \c color switching. */
export interface ChatSegment {
text: string;
/** Torque \c color index (09) from the GuiChatHudProfile fontColors palette. */
colorCode: number;
}
export interface DemoChatMessage {
timeSec: number;
sender: string;
text: string;
kind: "chat" | "server";
/**
* Torque \c color index (09) from the GuiChatHudProfile fontColors palette.
* 0=default/death, 1=join/drop, 2=gameplay/flags, 3=team chat, 4=global chat,
* 6=player name, 7=tribe tag, 8=smurf name, 9=bot name.
*/
colorCode?: number;
/** Colored text segments for inline color switching in rendered text. */
segments?: ChatSegment[];
/** Audio file path from ~w tag (e.g. "fx/misc/flag_taken.wav"). */
soundPath?: string;
/** Pitch multiplier for voice chat (default 1.0). */
soundPitch?: number;
}
export interface WeaponsHudSlot {
/** HUD slot index (017), matching the $WeaponsHudData table. */
index: number;
/** Ammo count, or -1 for infinite (energy weapons). */
ammo: number;
}
export interface TeamScore {
teamId: number;
name: string;
score: number;
playerCount: number;
}
export interface BackpackHudState {
/** Index into the $BackpackHudData table, or -1 if no pack. */
packIndex: number;
/** Whether the pack is currently activated/armed. */
active: boolean;
/** Optional text overlay (e.g. sensor pack counts). */
text: string;
}
export interface InventoryHudSlot {
/** Display slot (0=grenade, 1=mine, 2=beacon, 3=repairkit). */
slot: number;
/** Item count. */
count: number;
}
export interface PendingAudioEvent {
profileId: number;
position?: { x: number; y: number; z: number };
timeSec: number;
}
export interface DemoStreamSnapshot {
timeSec: number;
exhausted: boolean;
camera: DemoStreamCamera | null;
entities: DemoStreamEntity[];
controlPlayerGhostId?: string;
/** Recording player's sensor group (team number). */
playerSensorGroup: number;
status: { health: number; energy: number };
chatMessages: DemoChatMessage[];
/** One-shot audio events from Sim3DAudioEvent / Sim2DAudioEvent. */
audioEvents: PendingAudioEvent[];
/** Weapons HUD state from inventory RemoteCommandEvents. */
weaponsHud: {
/** Weapon slots present in the player's inventory, in HUD index order. */
slots: WeaponsHudSlot[];
/** Currently active (selected) HUD slot index, or -1 if none. */
activeIndex: number;
};
/** Backpack/pack HUD state from RemoteCommandEvents. */
backpackHud: BackpackHudState | null;
/** Inventory HUD state (grenades, mines, beacons, repair kits). */
inventoryHud: {
slots: InventoryHudSlot[];
activeSlot: number;
};
/** Team scores aggregated from the PLAYERLIST demoValues section. */
teamScores: TeamScore[];
}
export interface DemoStreamingPlayback {

View file

@ -0,0 +1,322 @@
import type {
WeaponImageDataBlockState,
WeaponImageState,
} from "./types";
/** Transition index sentinel: -1 means "no transition defined". */
const NO_TRANSITION = -1;
/** Max transitions per tick to prevent infinite loops from misconfigured datablocks. */
const MAX_TRANSITIONS_PER_TICK = 32;
/** Torque SpinState enum values from ShapeBaseImageData (shapeBase.h). */
const SPIN_STOP = 1; // NoSpin
const SPIN_UP = 2; // SpinUp
const SPIN_DOWN = 3; // SpinDown
const SPIN_FULL = 4; // FullSpin
export interface WeaponAnimState {
/** Name of the current animation sequence to play (lowercase), or null. */
sequenceName: string | null;
/** Whether the current state is a fire state. */
isFiring: boolean;
/** Spin thread timeScale (0 = stopped, 1 = full speed). */
spinTimeScale: number;
/** Whether the animation should play in reverse. */
reverse: boolean;
/** Whether the animation timeScale should be scaled to the timeout. */
scaleAnimation: boolean;
/** The timeout value of the current state (for timeScale calculation). */
timeoutValue: number;
/** True when a state transition occurred this tick. */
transitioned: boolean;
/** AudioProfile datablock IDs for sounds that should play this tick.
* In the engine, every state entry triggers its stateSound; a single tick
* can chain through multiple states, so multiple sounds may fire. */
soundDataBlockIds: number[];
/** Index of the current state in the state machine. */
stateIndex: number;
}
/**
* Client-side weapon image state machine replicating the Torque C++ logic from
* `ShapeBase::updateImageState` / `ShapeBase::setImageState`. The server sends
* only condition flags (trigger, ammo, loaded, wet, target) and a fireCount;
* the client runs its own copy of the state machine to determine which
* animation to play.
*/
export class WeaponImageStateMachine {
private states: WeaponImageDataBlockState[];
private seqIndexToName: string[];
private currentStateIndex = 0;
private delayTime = 0;
private lastFireCount = -1;
private spinTimeScale = 0;
constructor(
states: WeaponImageDataBlockState[],
seqIndexToName: string[],
) {
this.states = states;
this.seqIndexToName = seqIndexToName;
if (states.length > 0) {
this.delayTime = states[0].timeoutValue ?? 0;
}
}
get stateIndex(): number {
return this.currentStateIndex;
}
reset(): void {
this.currentStateIndex = 0;
this.delayTime = this.states.length > 0
? (this.states[0].timeoutValue ?? 0)
: 0;
this.lastFireCount = -1;
}
/**
* Advance the state machine by `dt` seconds using the given condition flags.
* Returns the animation state to apply this frame.
*/
tick(dt: number, flags: WeaponImageState): WeaponAnimState {
if (this.states.length === 0) {
return {
sequenceName: null,
isFiring: false,
spinTimeScale: 0,
reverse: false,
scaleAnimation: false,
timeoutValue: 0,
transitioned: false,
soundDataBlockIds: [],
stateIndex: -1,
};
}
// Detect fire count changes — forces a resync to the Fire state.
// The server increments fireCount each time it fires; if our state machine
// has diverged, this brings us back in sync.
const fireCountChanged =
this.lastFireCount >= 0 && flags.fireCount !== this.lastFireCount;
this.lastFireCount = flags.fireCount;
const soundDataBlockIds: number[] = [];
if (fireCountChanged) {
const fireIdx = this.states.findIndex((s) => s.fire);
if (fireIdx >= 0 && fireIdx !== this.currentStateIndex) {
this.currentStateIndex = fireIdx;
this.delayTime = this.states[fireIdx].timeoutValue ?? 0;
// Fire count resync is a state entry — play its sound.
const fireSound = this.states[fireIdx].soundDataBlockId;
if (fireSound >= 0) soundDataBlockIds.push(fireSound);
}
}
this.delayTime -= dt;
let transitioned = fireCountChanged;
// Per-tick transition evaluation (C++ updateImageState): check conditions
// and timeout when delayTime <= 0 or waitForTimeout is false.
let nextState = this.evaluateTickTransitions(flags);
// Process transitions. Self-transitions just reset delayTime (matching
// the C++ setImageState early return path). Different-state transitions
// run full entry logic including recursive entry transitions.
let transitionsThisTick = 0;
while (nextState >= 0 && transitionsThisTick < MAX_TRANSITIONS_PER_TICK) {
transitionsThisTick++;
transitioned = true;
if (nextState === this.currentStateIndex) {
// Self-transition (C++ setImageState self-transition path):
// reset delayTime only; skip entry transitions and spin handling.
this.delayTime = this.states[nextState].timeoutValue ?? 0;
break;
}
// Transition to a different state (C++ setImageState normal path).
const lastSpin = this.states[this.currentStateIndex].spin;
const lastDelay = this.delayTime;
this.currentStateIndex = nextState;
const newTimeout = this.states[nextState].timeoutValue ?? 0;
this.delayTime = newTimeout;
// Every state entry plays its sound (C++ setImageState).
const entrySound = this.states[nextState].soundDataBlockId;
if (entrySound >= 0) soundDataBlockIds.push(entrySound);
// Spin handling on state entry (C++ setImageState spin switch).
const newSpin = this.states[nextState].spin;
switch (newSpin) {
case SPIN_STOP:
this.spinTimeScale = 0;
break;
case SPIN_FULL:
this.spinTimeScale = 1;
break;
case SPIN_UP:
// Partial ramp reversal from SpinDown: adjust delayTime so the ramp
// starts from the current barrel speed.
if (lastSpin === SPIN_DOWN && newTimeout > 0) {
this.delayTime *= 1 - lastDelay / newTimeout;
}
break;
case SPIN_DOWN:
// Partial ramp reversal from SpinUp.
if (lastSpin === SPIN_UP && newTimeout > 0) {
this.delayTime *= 1 - lastDelay / newTimeout;
}
break;
// SPIN_IGNORE (0): preserve spinTimeScale.
}
// Entry transitions: check conditions immediately (no waitForTimeout,
// no timeout). Matches C++ setImageState's recursive condition checks.
nextState = this.evaluateEntryTransitions(flags);
}
// Per-tick spin update (C++ updateImageState spin switch).
// In C++, FullSpin/NoSpin/IgnoreSpin are no-ops here (set on state entry
// in setImageState). But our fireCount resync path bypasses the transition
// loop, so we must handle FullSpin and NoSpin per-tick as a fallback.
const state = this.states[this.currentStateIndex];
const timeout = state.timeoutValue ?? 0;
switch (state.spin) {
case SPIN_STOP:
this.spinTimeScale = 0;
break;
case SPIN_UP:
this.spinTimeScale = timeout > 0
? Math.max(0, 1 - this.delayTime / timeout)
: 1;
break;
case SPIN_FULL:
this.spinTimeScale = 1;
break;
case SPIN_DOWN:
this.spinTimeScale = timeout > 0
? Math.max(0, this.delayTime / timeout)
: 0;
break;
// SPIN_IGNORE (0): leave spinTimeScale unchanged.
}
return {
sequenceName: this.resolveSequenceName(state),
isFiring: state.fire,
spinTimeScale: this.spinTimeScale,
reverse: !state.direction,
scaleAnimation: state.scaleAnimation,
timeoutValue: state.timeoutValue ?? 0,
transitioned,
soundDataBlockIds,
stateIndex: this.currentStateIndex,
};
}
/**
* Per-tick transition evaluation (C++ updateImageState).
* Respects waitForTimeout: only evaluates when delayTime has elapsed
* or the state doesn't require waiting. Includes timeout transition.
*
* V12 engine priority order: loaded, ammo, target, wet, trigger, timeout.
*/
private evaluateTickTransitions(flags: WeaponImageState): number {
const state = this.states[this.currentStateIndex];
const timedOut = this.delayTime <= 0;
const canTransition = timedOut || !state.waitForTimeout;
if (!canTransition) return -1;
const cond = this.evaluateConditions(state, flags);
if (cond !== -1) return cond;
// timeout (only when delayTime has elapsed)
if (timedOut) {
const timeoutTarget = state.transitionOnTimeout;
if (timeoutTarget !== NO_TRANSITION) {
return timeoutTarget;
}
}
return -1;
}
/**
* Entry transition evaluation (C++ setImageState).
* Fires immediately on state entry ignores waitForTimeout and does NOT
* check timeout transition.
*/
private evaluateEntryTransitions(flags: WeaponImageState): number {
const state = this.states[this.currentStateIndex];
return this.evaluateConditions(state, flags);
}
/**
* Evaluate condition-based transitions in V12 priority order:
* loaded, ammo, target, wet, trigger.
*
* Matches C++ updateImageState: no self-transition guard. If a condition
* resolves to the current state, setImageState handles it as a
* self-transition (just resets delayTime).
*/
private evaluateConditions(
state: WeaponImageDataBlockState,
flags: WeaponImageState,
): number {
// loaded
const loadedTarget = flags.loaded
? state.transitionOnLoaded
: state.transitionOnNotLoaded;
if (loadedTarget !== NO_TRANSITION) {
return loadedTarget;
}
// ammo
const ammoTarget = flags.ammo
? state.transitionOnAmmo
: state.transitionOnNoAmmo;
if (ammoTarget !== NO_TRANSITION) {
return ammoTarget;
}
// target
const targetTarget = flags.target
? state.transitionOnTarget
: state.transitionOnNoTarget;
if (targetTarget !== NO_TRANSITION) {
return targetTarget;
}
// wet
const wetTarget = flags.wet
? state.transitionOnWet
: state.transitionOnNotWet;
if (wetTarget !== NO_TRANSITION) {
return wetTarget;
}
// trigger
const triggerTarget = flags.triggerDown
? state.transitionOnTriggerDown
: state.transitionOnTriggerUp;
if (triggerTarget !== NO_TRANSITION) {
return triggerTarget;
}
return -1;
}
/** Resolve a state's sequence index to a clip name via the GLB metadata. */
private resolveSequenceName(
state: WeaponImageDataBlockState,
): string | null {
if (state.sequence == null || state.sequence < 0) return null;
const name = this.seqIndexToName[state.sequence];
return name ?? null;
}
}

View file

@ -70,7 +70,8 @@ export function textureToUrl(name: string) {
}
export function audioToUrl(fileName: string) {
return getUrlForPath(`audio/${fileName}`);
const url = getUrlForPath(`audio/${fileName}`);
return url.replace(/\.wav$/i, ".ogg");
}
export async function loadDetailMapList(name: string) {

View file

@ -1,337 +0,0 @@
import type { EngineStoreState } from "./engineStore";
import type { RuntimeEvent } from "../torqueScript";
interface BuildDiagnosticsSnapshotOptions {
maxRuntimeEvents?: number;
maxPlaybackEvents?: number;
maxRendererSamples?: number;
maxStreamEntities?: number;
}
type JsonLike = unknown;
const defaultOptions: Required<BuildDiagnosticsSnapshotOptions> = {
maxRuntimeEvents: 80,
maxPlaybackEvents: 200,
maxRendererSamples: 1200,
maxStreamEntities: 40,
};
function summarizeTorqueObject(value: any): JsonLike {
if (!value || typeof value !== "object") {
return null;
}
return {
kind: "TorqueObject",
id: typeof value._id === "number" ? value._id : null,
className: typeof value._className === "string" ? value._className : null,
class: typeof value._class === "string" ? value._class : null,
name: typeof value._name === "string" ? value._name : null,
isDatablock: !!value._isDatablock,
parentId:
value._parent && typeof value._parent._id === "number"
? value._parent._id
: null,
childCount: Array.isArray(value._children) ? value._children.length : 0,
};
}
function createJsonSafeValueSummarizer() {
const seen = new WeakSet<object>();
function summarize(value: any, depth = 0): JsonLike {
if (value == null) {
return value as null | undefined;
}
const t = typeof value;
if (t === "string" || t === "number" || t === "boolean") {
return value;
}
if (t === "bigint") {
return value.toString();
}
if (t === "function") {
return `[Function ${value.name || "anonymous"}]`;
}
if (t !== "object") {
return String(value);
}
if ("_id" in value && "_className" in value) {
return summarizeTorqueObject(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
if (depth >= 2) {
return {
kind: "Array",
length: value.length,
};
}
const sample = value.slice(0, 8).map((entry) => summarize(entry, depth + 1));
return {
kind: "Array",
length: value.length,
sample,
};
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (depth >= 2) {
return {
kind: value?.constructor?.name ?? "Object",
};
}
const keys = Object.keys(value).slice(0, 12);
const summary: Record<string, JsonLike> = {};
for (const key of keys) {
try {
summary[key] = summarize(value[key], depth + 1);
} catch (error) {
summary[key] = `[Unserializable: ${(error as Error).message}]`;
}
}
if (Object.keys(value).length > keys.length) {
summary.__truncatedKeys = Object.keys(value).length - keys.length;
}
return summary;
}
return summarize;
}
function summarizeRuntimeEvent(
event: RuntimeEvent,
summarizeValue: (value: any, depth?: number) => JsonLike,
): JsonLike {
if (event.type === "object.created") {
return {
type: event.type,
objectId: event.objectId,
object: summarizeTorqueObject(event.object),
};
}
if (event.type === "object.deleted") {
return {
type: event.type,
objectId: event.objectId,
object: summarizeTorqueObject(event.object),
};
}
if (event.type === "field.changed") {
return {
type: event.type,
objectId: event.objectId,
field: event.field,
value: summarizeValue(event.value),
previousValue: summarizeValue(event.previousValue),
object: summarizeTorqueObject(event.object),
};
}
if (event.type === "method.called") {
return {
type: event.type,
className: event.className,
methodName: event.methodName,
objectId: event.objectId ?? null,
args: summarizeValue(event.args),
};
}
if (event.type === "global.changed") {
return {
type: event.type,
name: event.name,
value: summarizeValue(event.value),
previousValue: summarizeValue(event.previousValue),
};
}
if (event.type === "batch.flushed") {
const byType: Record<string, number> = {};
for (const mutation of event.events) {
byType[mutation.type] = (byType[mutation.type] ?? 0) + 1;
}
return {
type: event.type,
tick: event.tick,
eventCount: event.events.length,
byType,
};
}
return {
type: "unknown",
};
}
function summarizeStreamSnapshot(
state: EngineStoreState,
maxStreamEntities: number,
): JsonLike {
const snapshot = state.playback.streamSnapshot;
if (!snapshot) {
return null;
}
const entitiesByType: Record<string, number> = {};
const visualsByKind: Record<string, number> = {};
for (const entity of snapshot.entities) {
const type = entity.type || "Unknown";
entitiesByType[type] = (entitiesByType[type] ?? 0) + 1;
if (entity.visual?.kind) {
visualsByKind[entity.visual.kind] = (visualsByKind[entity.visual.kind] ?? 0) + 1;
}
}
const entitySample = snapshot.entities.slice(0, maxStreamEntities).map((entity) => ({
id: entity.id,
type: entity.type,
dataBlock: entity.dataBlock ?? null,
className: entity.className ?? null,
ghostIndex: entity.ghostIndex ?? null,
dataBlockId: entity.dataBlockId ?? null,
shapeHint: entity.shapeHint ?? null,
visualKind: entity.visual?.kind ?? null,
hasPosition: !!entity.position,
hasRotation: !!entity.rotation,
}));
return {
timeSec: snapshot.timeSec,
exhausted: snapshot.exhausted,
cameraMode: snapshot.camera?.mode ?? null,
controlEntityId: snapshot.camera?.controlEntityId ?? null,
orbitTargetId: snapshot.camera?.orbitTargetId ?? null,
controlPlayerGhostId: snapshot.controlPlayerGhostId ?? null,
entityCount: snapshot.entities.length,
entitiesByType,
visualsByKind,
entitySample,
status: snapshot.status,
};
}
function summarizeRecording(state: EngineStoreState): JsonLike {
const recording = state.playback.recording;
if (!recording) return null;
return {
duration: recording.duration,
missionName: recording.missionName,
gameType: recording.gameType,
hasStreamingPlayback: !!recording.streamingPlayback,
};
}
function summarizeRuntime(state: EngineStoreState): JsonLike {
const runtime = state.runtime.runtime;
if (!runtime) {
return null;
}
return {
lastRuntimeTick: state.runtime.lastRuntimeTick,
objectCount: runtime.state.objectsById.size,
datablockCount: runtime.state.datablocks.size,
globalCount: runtime.state.globals.size,
activePackageCount: runtime.state.activePackages.length,
executedScriptCount: runtime.state.executedScripts.size,
failedScriptCount: runtime.state.failedScripts.size,
};
}
function summarizeRendererTrend(samples: EngineStoreState["diagnostics"]["rendererSamples"]) {
if (samples.length < 2) {
return null;
}
const first = samples[0];
const last = samples[samples.length - 1];
return {
sampleCount: samples.length,
durationSec: Number(((last.t - first.t) / 1000).toFixed(3)),
geometriesDelta: last.geometries - first.geometries,
texturesDelta: last.textures - first.textures,
programsDelta: last.programs - first.programs,
sceneObjectsDelta: last.sceneObjects - first.sceneObjects,
visibleSceneObjectsDelta: last.visibleSceneObjects - first.visibleSceneObjects,
renderCallsDelta: last.renderCalls - first.renderCalls,
};
}
function countPlaybackEventsByKind(
events: EngineStoreState["diagnostics"]["playbackEvents"],
): Record<string, number> {
const byKind: Record<string, number> = {};
for (const event of events) {
byKind[event.kind] = (byKind[event.kind] ?? 0) + 1;
}
return byKind;
}
export function buildSerializableDiagnosticsSnapshot(
state: EngineStoreState,
options: BuildDiagnosticsSnapshotOptions = {},
): JsonLike {
const merged: Required<BuildDiagnosticsSnapshotOptions> = {
...defaultOptions,
...options,
};
const summarizeValue = createJsonSafeValueSummarizer();
const runtimeEvents = state.diagnostics.recentEvents
.slice(-merged.maxRuntimeEvents)
.map((event) => summarizeRuntimeEvent(event, summarizeValue));
const playbackEvents = state.diagnostics.playbackEvents
.slice(-merged.maxPlaybackEvents)
.map((event) => ({
...event,
meta: event.meta ? summarizeValue(event.meta) : undefined,
}));
const rendererSamples = state.diagnostics.rendererSamples.slice(
-merged.maxRendererSamples,
);
return {
generatedAt: new Date().toISOString(),
playback: {
status: state.playback.status,
timeMs: state.playback.timeMs,
frameCursor: state.playback.frameCursor,
rate: state.playback.rate,
durationMs: state.playback.durationMs,
recording: summarizeRecording(state),
streamSnapshot: summarizeStreamSnapshot(state, merged.maxStreamEntities),
},
runtime: summarizeRuntime(state),
diagnostics: {
webglContextLost: state.diagnostics.webglContextLost,
eventCounts: state.diagnostics.eventCounts,
playbackEventCount: state.diagnostics.playbackEvents.length,
rendererSampleCount: state.diagnostics.rendererSamples.length,
runtimeEventCount: state.diagnostics.recentEvents.length,
playbackEventsByKind: countPlaybackEventsByKind(
state.diagnostics.playbackEvents,
),
rendererTrend: summarizeRendererTrend(rendererSamples),
playbackEvents,
rendererSamples,
runtimeEvents,
},
};
}
export function buildSerializableDiagnosticsJson(
state: EngineStoreState,
options: BuildDiagnosticsSnapshotOptions = {},
): string {
return JSON.stringify(buildSerializableDiagnosticsSnapshot(state, options), null, 2);
}

View file

@ -1,9 +1,8 @@
import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type { DemoEntity, DemoRecording, DemoStreamSnapshot } from "../demo/types";
import type { DemoRecording, DemoStreamSnapshot } from "../demo/types";
import type {
RuntimeEvent,
RuntimeMutationEvent,
TorqueObject,
TorqueRuntime,
@ -15,70 +14,6 @@ import {
export type PlaybackStatus = "stopped" | "playing" | "paused";
export type PlaybackDiagnosticMetaValue =
| string
| number
| boolean
| null
| undefined;
export interface PlaybackDiagnosticEvent {
t: number;
kind: string;
message?: string;
playbackStatus: PlaybackStatus;
playbackTimeMs: number;
frameCursor: number;
streamEntityCount: number;
streamCameraMode: string | null;
streamExhausted: boolean;
meta?: Record<string, PlaybackDiagnosticMetaValue>;
}
export interface RendererDiagnosticsSample {
t: number;
playbackStatus: PlaybackStatus;
playbackTimeMs: number;
frameCursor: number;
streamEntityCount: number;
streamCameraMode: string | null;
streamExhausted: boolean;
geometries: number;
textures: number;
programs: number;
renderCalls: number;
renderTriangles: number;
renderPoints: number;
renderLines: number;
sceneObjects: number;
visibleSceneObjects: number;
jsHeapUsed?: number;
jsHeapTotal?: number;
jsHeapLimit?: number;
}
export interface RecordPlaybackDiagnosticEventInput {
kind: string;
message?: string;
meta?: Record<string, PlaybackDiagnosticMetaValue>;
}
export interface AppendRendererSampleInput {
t?: number;
geometries: number;
textures: number;
programs: number;
renderCalls: number;
renderTriangles: number;
renderPoints: number;
renderLines: number;
sceneObjects: number;
visibleSceneObjects: number;
jsHeapUsed?: number;
jsHeapTotal?: number;
jsHeapLimit?: number;
}
export interface RuntimeSliceState {
runtime: TorqueRuntime | null;
sequenceAliases: SequenceAliasMap;
@ -89,46 +24,22 @@ export interface RuntimeSliceState {
lastRuntimeTick: number;
}
export interface WorldSliceState {
entitiesById: Record<string, DemoEntity>;
players: string[];
ghosts: string[];
projectiles: string[];
flags: string[];
teams: Record<string, { score: number }>;
scores: Record<string, number>;
}
export interface PlaybackSliceState {
recording: DemoRecording | null;
status: PlaybackStatus;
timeMs: number;
rate: number;
frameCursor: number;
durationMs: number;
streamSnapshot: DemoStreamSnapshot | null;
}
export interface DiagnosticsSliceState {
eventCounts: Record<RuntimeEvent["type"], number>;
recentEvents: RuntimeEvent[];
maxRecentEvents: number;
webglContextLost: boolean;
playbackEvents: PlaybackDiagnosticEvent[];
maxPlaybackEvents: number;
rendererSamples: RendererDiagnosticsSample[];
maxRendererSamples: number;
}
export interface RuntimeTickInfo {
tick?: number;
}
export interface EngineStoreState {
runtime: RuntimeSliceState;
world: WorldSliceState;
playback: PlaybackSliceState;
diagnostics: DiagnosticsSliceState;
setRuntime(runtime: TorqueRuntime): void;
clearRuntime(): void;
applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo): void;
@ -136,14 +47,7 @@ export interface EngineStoreState {
setPlaybackTime(ms: number): void;
setPlaybackStatus(status: PlaybackStatus): void;
setPlaybackRate(rate: number): void;
setPlaybackFrameCursor(frameCursor: number): void;
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null): void;
setWebglContextLost(lost: boolean): void;
recordPlaybackDiagnosticEvent(
input: RecordPlaybackDiagnosticEventInput,
): void;
appendRendererSample(input: AppendRendererSampleInput): void;
clearPlaybackDiagnostics(): void;
}
function normalizeName(name: string): string {
@ -155,48 +59,12 @@ function normalizeGlobalName(name: string): string {
return normalized.startsWith("$") ? normalized.slice(1) : normalized;
}
function keyFromEntityId(id: number | string): string {
return String(id);
}
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
function summarizeCallStack(skipFrames = 0): string | null {
const stack = new Error().stack;
if (!stack) return null;
const lines = stack
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const callsiteLines = lines.slice(1 + skipFrames, 9 + skipFrames);
return callsiteLines.length > 0 ? callsiteLines.join(" <= ") : null;
}
function initialDiagnosticsCounts(): Record<RuntimeEvent["type"], number> {
return {
"object.created": 0,
"object.deleted": 0,
"field.changed": 0,
"method.called": 0,
"global.changed": 0,
"batch.flushed": 0,
};
}
const emptyWorld: WorldSliceState = {
entitiesById: {},
players: [],
ghosts: [],
projectiles: [],
flags: [],
teams: {},
scores: {},
};
function buildRuntimeIndexes(runtime: TorqueRuntime): Pick<
RuntimeSliceState,
| "objectVersionById"
@ -240,12 +108,7 @@ const initialState: Omit<
| "setPlaybackTime"
| "setPlaybackStatus"
| "setPlaybackRate"
| "setPlaybackFrameCursor"
| "setPlaybackStreamSnapshot"
| "setWebglContextLost"
| "recordPlaybackDiagnosticEvent"
| "appendRendererSample"
| "clearPlaybackDiagnostics"
> = {
runtime: {
runtime: null,
@ -256,34 +119,14 @@ const initialState: Omit<
datablockIdsByName: {},
lastRuntimeTick: 0,
},
world: {
entitiesById: {},
players: [],
ghosts: [],
projectiles: [],
flags: [],
teams: {},
scores: {},
},
playback: {
recording: null,
status: "stopped",
timeMs: 0,
rate: 1,
frameCursor: 0,
durationMs: 0,
streamSnapshot: null,
},
diagnostics: {
eventCounts: initialDiagnosticsCounts(),
recentEvents: [],
maxRecentEvents: 200,
webglContextLost: false,
playbackEvents: [],
maxPlaybackEvents: 400,
rendererSamples: [],
maxRendererSamples: 2400,
},
};
export const engineStore = createStore<EngineStoreState>()(
@ -332,8 +175,6 @@ export const engineStore = createStore<EngineStoreState>()(
const globalVersionByName = { ...state.runtime.globalVersionByName };
const objectIdsByName = { ...state.runtime.objectIdsByName };
const datablockIdsByName = { ...state.runtime.datablockIdsByName };
const eventCounts = { ...state.diagnostics.eventCounts };
const recentEvents = [...state.diagnostics.recentEvents];
const bumpVersion = (id: number | undefined | null) => {
if (id == null) return;
@ -341,9 +182,6 @@ export const engineStore = createStore<EngineStoreState>()(
};
for (const event of events) {
eventCounts[event.type] = (eventCounts[event.type] ?? 0) + 1;
recentEvents.push(event);
if (event.type === "object.created") {
const object = event.object;
bumpVersion(event.objectId);
@ -390,19 +228,6 @@ export const engineStore = createStore<EngineStoreState>()(
(state.runtime.lastRuntimeTick > 0
? state.runtime.lastRuntimeTick + 1
: 1);
const batchEvent: RuntimeEvent = {
type: "batch.flushed",
tick,
events,
};
eventCounts["batch.flushed"] += 1;
recentEvents.push(batchEvent);
const maxRecentEvents = state.diagnostics.maxRecentEvents;
const boundedRecentEvents =
recentEvents.length > maxRecentEvents
? recentEvents.slice(recentEvents.length - maxRecentEvents)
: recentEvents;
return {
...state,
@ -414,62 +239,23 @@ export const engineStore = createStore<EngineStoreState>()(
datablockIdsByName,
lastRuntimeTick: tick,
},
diagnostics: {
...state.diagnostics,
eventCounts,
recentEvents: boundedRecentEvents,
},
};
});
},
setDemoRecording(recording: DemoRecording | null) {
const durationMs = Math.max(0, (recording?.duration ?? 0) * 1000);
const stack = summarizeCallStack(1);
set((state) => {
const snapshot = state.playback.streamSnapshot;
const previousRecording = state.playback.recording;
const event: PlaybackDiagnosticEvent = {
t: Date.now(),
kind: "recording.set",
message: "setDemoRecording invoked",
playbackStatus: state.playback.status,
playbackTimeMs: state.playback.timeMs,
frameCursor: state.playback.frameCursor,
streamEntityCount: snapshot?.entities.length ?? 0,
streamCameraMode: snapshot?.camera?.mode ?? null,
streamExhausted: snapshot?.exhausted ?? false,
meta: {
previousMissionName: previousRecording?.missionName ?? null,
nextMissionName: recording?.missionName ?? null,
previousDurationSec: previousRecording
? Number(previousRecording.duration.toFixed(3))
: null,
nextDurationSec: recording ? Number(recording.duration.toFixed(3)) : null,
isNull: recording == null,
stack: stack ?? "unavailable",
},
};
return {
...state,
world: emptyWorld,
playback: {
recording,
status: "stopped",
timeMs: 0,
rate: 1,
frameCursor: 0,
durationMs,
streamSnapshot: null,
},
diagnostics: {
...state.diagnostics,
webglContextLost: false,
playbackEvents: [event],
rendererSamples: [],
},
};
});
set((state) => ({
...state,
playback: {
recording,
status: "stopped",
timeMs: 0,
rate: 1,
durationMs,
streamSnapshot: null,
},
}));
},
setPlaybackTime(ms: number) {
@ -480,7 +266,6 @@ export const engineStore = createStore<EngineStoreState>()(
playback: {
...state.playback,
timeMs: clamped,
frameCursor: clamped,
},
};
});
@ -507,17 +292,6 @@ export const engineStore = createStore<EngineStoreState>()(
}));
},
setPlaybackFrameCursor(frameCursor: number) {
const nextCursor = Number.isFinite(frameCursor) ? frameCursor : 0;
set((state) => ({
...state,
playback: {
...state.playback,
frameCursor: nextCursor,
},
}));
},
setPlaybackStreamSnapshot(snapshot: DemoStreamSnapshot | null) {
set((state) => ({
...state,
@ -528,104 +302,6 @@ export const engineStore = createStore<EngineStoreState>()(
}));
},
setWebglContextLost(lost: boolean) {
set((state) => ({
...state,
diagnostics: {
...state.diagnostics,
webglContextLost: lost,
},
}));
},
recordPlaybackDiagnosticEvent(input: RecordPlaybackDiagnosticEventInput) {
set((state) => {
const snapshot = state.playback.streamSnapshot;
const nextEvent: PlaybackDiagnosticEvent = {
t: Date.now(),
kind: input.kind,
message: input.message,
playbackStatus: state.playback.status,
playbackTimeMs: state.playback.timeMs,
frameCursor: state.playback.frameCursor,
streamEntityCount: snapshot?.entities.length ?? 0,
streamCameraMode: snapshot?.camera?.mode ?? null,
streamExhausted: snapshot?.exhausted ?? false,
meta: input.meta,
};
const playbackEvents = [
...state.diagnostics.playbackEvents,
nextEvent,
];
const maxPlaybackEvents = state.diagnostics.maxPlaybackEvents;
const boundedPlaybackEvents =
playbackEvents.length > maxPlaybackEvents
? playbackEvents.slice(playbackEvents.length - maxPlaybackEvents)
: playbackEvents;
return {
...state,
diagnostics: {
...state.diagnostics,
playbackEvents: boundedPlaybackEvents,
},
};
});
},
appendRendererSample(input: AppendRendererSampleInput) {
set((state) => {
const snapshot = state.playback.streamSnapshot;
const nextSample: RendererDiagnosticsSample = {
t: input.t ?? Date.now(),
playbackStatus: state.playback.status,
playbackTimeMs: state.playback.timeMs,
frameCursor: state.playback.frameCursor,
streamEntityCount: snapshot?.entities.length ?? 0,
streamCameraMode: snapshot?.camera?.mode ?? null,
streamExhausted: snapshot?.exhausted ?? false,
geometries: input.geometries,
textures: input.textures,
programs: input.programs,
renderCalls: input.renderCalls,
renderTriangles: input.renderTriangles,
renderPoints: input.renderPoints,
renderLines: input.renderLines,
sceneObjects: input.sceneObjects,
visibleSceneObjects: input.visibleSceneObjects,
jsHeapUsed: input.jsHeapUsed,
jsHeapTotal: input.jsHeapTotal,
jsHeapLimit: input.jsHeapLimit,
};
const rendererSamples = [
...state.diagnostics.rendererSamples,
nextSample,
];
const maxRendererSamples = state.diagnostics.maxRendererSamples;
const boundedRendererSamples =
rendererSamples.length > maxRendererSamples
? rendererSamples.slice(rendererSamples.length - maxRendererSamples)
: rendererSamples;
return {
...state,
diagnostics: {
...state.diagnostics,
rendererSamples: boundedRendererSamples,
},
};
});
},
clearPlaybackDiagnostics() {
set((state) => ({
...state,
diagnostics: {
...state.diagnostics,
webglContextLost: false,
playbackEvents: [],
rendererSamples: [],
},
}));
},
})),
);
@ -757,15 +433,3 @@ export function useRuntimeChildIds(
return parent._children.map((child) => child._id);
}
export function usePlaybackTimeSeconds(): number {
return useEngineSelector((state) => state.playback.timeMs / 1000);
}
export function useWorldEntity(
entityId: number | string | undefined,
): DemoEntity | undefined {
const key = entityId == null ? "" : keyFromEntityId(entityId);
return useEngineSelector((state) =>
key ? state.world.entitiesById[key] : undefined,
);
}

View file

@ -1,16 +1,9 @@
export type {
AppendRendererSampleInput,
EngineStoreState,
PlaybackDiagnosticEvent,
PlaybackDiagnosticMetaValue,
RecordPlaybackDiagnosticEventInput,
PlaybackStatus,
RendererDiagnosticsSample,
RuntimeTickInfo,
RuntimeSliceState,
WorldSliceState,
PlaybackSliceState,
DiagnosticsSliceState,
} from "./engineStore";
export {
@ -23,11 +16,4 @@ export {
useRuntimeGlobal,
useDatablockByName,
useRuntimeChildIds,
usePlaybackTimeSeconds,
useWorldEntity,
} from "./engineStore";
export {
buildSerializableDiagnosticsSnapshot,
buildSerializableDiagnosticsJson,
} from "./diagnosticsSnapshot";

View file

@ -3,6 +3,7 @@
*/
import {
DataTexture,
ImageBitmapLoader,
LinearFilter,
LinearMipmapLinearFilter,
NoColorSpace,
@ -13,6 +14,71 @@ import {
UnsignedByteType,
} from "three";
const _bitmapLoader = new ImageBitmapLoader();
const _textureCache = new Map<string, Texture>();
/**
* Load a texture using ImageBitmapLoader, which decodes images off the main
* thread to avoid jank from synchronous image decodes during texSubImage2D.
* Returns a cached Texture if the same URL was loaded before, otherwise creates
* an initially-empty Texture that gets populated when the image loads.
*/
export function loadTexture(
url: string,
onLoad?: (texture: Texture) => void,
): Texture {
const cached = _textureCache.get(url);
if (cached) {
// Already loaded (or in flight) — fire callback if image is ready.
if (onLoad && cached.image) onLoad(cached);
return cached;
}
const texture = new Texture();
// ImageBitmap doesn't support UNPACK_FLIP_Y_WEBGL, so flipY must be false.
// This matches our codebase where all textures use flipY = false.
texture.flipY = false;
_textureCache.set(url, texture);
_bitmapLoader.load(url, (bitmap) => {
texture.image = bitmap;
texture.needsUpdate = true;
onLoad?.(texture);
});
return texture;
}
/** Promise-based variant of loadTexture. */
export function loadTextureAsync(url: string): Promise<Texture> {
const cached = _textureCache.get(url);
if (cached) {
return cached.image
? Promise.resolve(cached)
: new Promise((resolve) => {
// In flight — poll until populated (bitmapLoader doesn't expose
// a way to attach multiple callbacks to the same request).
const check = () => {
if (cached.image) resolve(cached);
else setTimeout(check, 16);
};
check();
});
}
return new Promise((resolve, reject) => {
const texture = new Texture();
texture.flipY = false;
_textureCache.set(url, texture);
_bitmapLoader.load(
url,
(bitmap) => {
texture.image = bitmap;
texture.needsUpdate = true;
resolve(texture);
},
undefined,
reject,
);
});
}
export interface TextureSetupOptions {
/** Texture repeat values [x, y]. Default: [1, 1] */
repeat?: [number, number];