mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-13 15:34:58 +00:00
improve audio support
This commit is contained in:
parent
d1acb6a5ce
commit
cb28b66dad
5587 changed files with 4538 additions and 2846 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
65
src/components/ChatSoundPlayer.tsx
Normal file
65
src/components/ChatSoundPlayer.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (c0–c9 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 (0–9) from the GuiChatHudProfile fontColors palette. */
|
||||
colorCode: number;
|
||||
}
|
||||
|
||||
export interface DemoChatMessage {
|
||||
timeSec: number;
|
||||
sender: string;
|
||||
text: string;
|
||||
kind: "chat" | "server";
|
||||
/**
|
||||
* Torque \c color index (0–9) 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 (0–17), 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 {
|
||||
|
|
|
|||
322
src/demo/weaponStateMachine.ts
Normal file
322
src/demo/weaponStateMachine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue