mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-06 22:10:42 +00:00
various fixes and performance improvements
This commit is contained in:
parent
cb28b66dad
commit
0c9ddb476a
62 changed files with 3109 additions and 1286 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { AudioListener, AudioLoader } from "three";
|
||||
import { engineStore } from "../state";
|
||||
|
||||
interface AudioContextType {
|
||||
audioLoader: AudioLoader | null;
|
||||
|
|
@ -44,6 +45,26 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
|||
audioLoader,
|
||||
audioListener: listener,
|
||||
});
|
||||
|
||||
// Suspend/resume the Web AudioContext when demo playback pauses/resumes.
|
||||
// This freezes all playing sounds at their current position rather than
|
||||
// stopping them, so they resume seamlessly.
|
||||
const unsubscribe = engineStore.subscribe(
|
||||
(state) => state.playback.status,
|
||||
(status) => {
|
||||
const ctx = listener?.context;
|
||||
if (!ctx) return;
|
||||
if (status === "paused") {
|
||||
ctx.suspend();
|
||||
} else if (status === "playing" && ctx.state === "suspended") {
|
||||
ctx.resume();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,10 +14,44 @@ import { audioToUrl } from "../loaders";
|
|||
import { useAudio } from "./AudioContext";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { engineStore } from "../state";
|
||||
|
||||
// Global audio buffer cache shared across all audio components.
|
||||
export const audioBufferCache = new Map<string, AudioBuffer>();
|
||||
|
||||
// ── Demo sound rate tracking ──
|
||||
// Track active demo sounds so their playbackRate can be updated when the
|
||||
// playback rate changes (e.g. slow-motion or fast-forward).
|
||||
// Maps each sound to its intrinsic pitch (1.0 for normal sounds, or the
|
||||
// voice pitch multiplier for chat sounds).
|
||||
const _activeDemoSounds = new Map<Audio<GainNode | PannerNode>, number>();
|
||||
|
||||
/** Register a sound for automatic playback rate tracking. */
|
||||
export function trackDemoSound(
|
||||
sound: Audio<GainNode | PannerNode>,
|
||||
basePitch = 1,
|
||||
): void {
|
||||
_activeDemoSounds.set(sound, basePitch);
|
||||
}
|
||||
|
||||
/** Unregister a tracked demo sound. */
|
||||
export function untrackDemoSound(sound: Audio<GainNode | PannerNode>): void {
|
||||
_activeDemoSounds.delete(sound);
|
||||
}
|
||||
|
||||
engineStore.subscribe(
|
||||
(state) => state.playback.rate,
|
||||
(rate) => {
|
||||
for (const [sound, basePitch] of _activeDemoSounds) {
|
||||
try {
|
||||
sound.setPlaybackRate(basePitch * rate);
|
||||
} catch {
|
||||
// Sound may have been disposed.
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResolvedAudioProfile {
|
||||
filename: string;
|
||||
is3D: boolean;
|
||||
|
|
@ -73,6 +107,7 @@ export function playOneShotSound(
|
|||
// File not in manifest — skip silently.
|
||||
return;
|
||||
}
|
||||
const rate = engineStore.getState().playback.rate;
|
||||
getCachedAudioBuffer(url, audioLoader, (buffer) => {
|
||||
try {
|
||||
if (resolved.is3D && parent) {
|
||||
|
|
@ -85,12 +120,15 @@ export function playOneShotSound(
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(rate);
|
||||
if (position) {
|
||||
sound.position.copy(position);
|
||||
}
|
||||
parent.add(sound);
|
||||
_activeDemoSounds.set(sound, 1);
|
||||
sound.play();
|
||||
sound.source!.onended = () => {
|
||||
_activeDemoSounds.delete(sound);
|
||||
sound.disconnect();
|
||||
parent.remove(sound);
|
||||
};
|
||||
|
|
@ -98,8 +136,11 @@ export function playOneShotSound(
|
|||
const sound = new Audio(audioListener);
|
||||
sound.setBuffer(buffer);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(rate);
|
||||
_activeDemoSounds.set(sound, 1);
|
||||
sound.play();
|
||||
sound.source!.onended = () => {
|
||||
_activeDemoSounds.delete(sound);
|
||||
sound.disconnect();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
|
|||
import { Audio } from "three";
|
||||
import { audioToUrl } from "../loaders";
|
||||
import { useAudio } from "./AudioContext";
|
||||
import { getCachedAudioBuffer } from "./AudioEmitter";
|
||||
import { getCachedAudioBuffer, trackDemoSound, untrackDemoSound } from "./AudioEmitter";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { useEngineSelector } from "../state";
|
||||
import { engineStore, useEngineSelector } from "../state";
|
||||
import type { DemoChatMessage } from "../demo/types";
|
||||
|
||||
/**
|
||||
|
|
@ -21,7 +21,12 @@ export function ChatSoundPlayer() {
|
|||
const timeSec = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.timeSec,
|
||||
);
|
||||
const playedCountRef = useRef(0);
|
||||
const playedSetRef = useRef(new WeakSet<DemoChatMessage>());
|
||||
// Track active voice chat sound per sender so a new voice bind from the
|
||||
// same player stops their previous one (matching Tribes 2 behavior).
|
||||
const activeBySenderRef = useRef(
|
||||
new Map<string, Audio<GainNode>>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -33,32 +38,51 @@ export function ChatSoundPlayer() {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
const startIdx = playedCountRef.current;
|
||||
for (let i = startIdx; i < messages.length; i++) {
|
||||
const msg: DemoChatMessage = messages[i];
|
||||
const played = playedSetRef.current;
|
||||
const activeBySender = activeBySenderRef.current;
|
||||
for (const msg of messages) {
|
||||
if (played.has(msg)) continue;
|
||||
played.add(msg);
|
||||
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;
|
||||
const rate = engineStore.getState().playback.rate;
|
||||
const sender = msg.sender;
|
||||
getCachedAudioBuffer(url, audioLoader, (buffer) => {
|
||||
// Stop the sender's previous voice chat sound.
|
||||
if (sender) {
|
||||
const prev = activeBySender.get(sender);
|
||||
if (prev) {
|
||||
try { prev.stop(); } catch {}
|
||||
untrackDemoSound(prev);
|
||||
prev.disconnect();
|
||||
activeBySender.delete(sender);
|
||||
}
|
||||
}
|
||||
const sound = new Audio(audioListener);
|
||||
sound.setBuffer(buffer);
|
||||
if (pitch !== 1) {
|
||||
sound.setPlaybackRate(pitch);
|
||||
sound.setPlaybackRate(pitch * rate);
|
||||
trackDemoSound(sound, pitch);
|
||||
if (sender) {
|
||||
activeBySender.set(sender, sound);
|
||||
}
|
||||
sound.play();
|
||||
// Clean up the source node once playback finishes.
|
||||
sound.source!.onended = () => {
|
||||
untrackDemoSound(sound);
|
||||
sound.disconnect();
|
||||
if (sender && activeBySender.get(sender) === sound) {
|
||||
activeBySender.delete(sender);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// File not in manifest — skip silently.
|
||||
}
|
||||
}
|
||||
playedCountRef.current = messages.length;
|
||||
}, [audioEnabled, audioLoader, audioListener, messages, timeSec]);
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { Component, Suspense } from "react";
|
||||
import { Component, memo, Suspense } from "react";
|
||||
import type { ErrorInfo, MutableRefObject, ReactNode } from "react";
|
||||
import { entityTypeColor } from "../demo/demoPlaybackUtils";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useDebug } from "./SettingsProvider";
|
||||
import { DemoPlayerModel } from "./DemoPlayerModel";
|
||||
import { DemoShapeModel, DemoWeaponModel } from "./DemoShapeModel";
|
||||
import { DemoShapeModel, DemoWeaponModel, DemoExplosionShape } from "./DemoShapeModel";
|
||||
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
|
||||
import { PlayerNameplate } from "./PlayerNameplate";
|
||||
import { FlagMarker } from "./FlagMarker";
|
||||
import { useEngineSelector } from "../state";
|
||||
import type { DemoEntity } from "../demo/types";
|
||||
import type { DemoEntity, DemoStreamingPlayback } from "../demo/types";
|
||||
|
||||
/**
|
||||
* Renders a non-camera demo entity.
|
||||
|
|
@ -16,12 +17,14 @@ import type { DemoEntity } from "../demo/types";
|
|||
* Player entities use DemoPlayerModel for skeletal animation; others use
|
||||
* DemoShapeModel.
|
||||
*/
|
||||
export function DemoEntityGroup({
|
||||
export const DemoEntityGroup = memo(function DemoEntityGroup({
|
||||
entity,
|
||||
timeRef,
|
||||
playback,
|
||||
}: {
|
||||
entity: DemoEntity;
|
||||
timeRef: MutableRefObject<number>;
|
||||
playback?: DemoStreamingPlayback;
|
||||
}) {
|
||||
const debug = useDebug();
|
||||
const debugMode = debug?.debugMode ?? false;
|
||||
|
|
@ -57,6 +60,7 @@ export function DemoEntityGroup({
|
|||
}
|
||||
|
||||
if (!entity.dataBlock) {
|
||||
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
|
||||
return (
|
||||
<group name={name}>
|
||||
<group name="model">
|
||||
|
|
@ -66,6 +70,11 @@ export function DemoEntityGroup({
|
|||
</mesh>
|
||||
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
|
||||
</group>
|
||||
{isFlag && (
|
||||
<Suspense fallback={null}>
|
||||
<FlagMarker entity={entity} timeRef={timeRef} />
|
||||
</Suspense>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,6 +89,7 @@ export function DemoEntityGroup({
|
|||
// Player entities use skeleton-preserving DemoPlayerModel for animation.
|
||||
if (entity.type === "Player") {
|
||||
const isControlPlayer = entity.id === controlPlayerGhostId;
|
||||
const hasFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
|
||||
return (
|
||||
<group name={name}>
|
||||
<group name="model">
|
||||
|
|
@ -93,11 +103,34 @@ export function DemoEntityGroup({
|
|||
<PlayerNameplate entity={entity} timeRef={timeRef} />
|
||||
</Suspense>
|
||||
)}
|
||||
{hasFlag && (
|
||||
<Suspense fallback={null}>
|
||||
<FlagMarker entity={entity} timeRef={timeRef} />
|
||||
</Suspense>
|
||||
)}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Explosion entities with DTS shapes use a specialized renderer
|
||||
// that handles faceViewer, size keyframes, and fade-out.
|
||||
if (entity.type === "Explosion" && entity.dataBlock && playback) {
|
||||
return (
|
||||
<group name={name}>
|
||||
<group name="model">
|
||||
<ShapeErrorBoundary fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<DemoExplosionShape entity={entity as any} playback={playback} />
|
||||
</Suspense>
|
||||
</ShapeErrorBoundary>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const isFlag = ((entity.targetRenderFlags ?? 0) & 0x2) !== 0;
|
||||
|
||||
return (
|
||||
<group name={name}>
|
||||
<group name="model">
|
||||
|
|
@ -119,9 +152,14 @@ export function DemoEntityGroup({
|
|||
</ShapeErrorBoundary>
|
||||
</group>
|
||||
)}
|
||||
{isFlag && (
|
||||
<Suspense fallback={null}>
|
||||
<FlagMarker entity={entity} timeRef={timeRef} />
|
||||
</Suspense>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) {
|
||||
const id = String(entity.id);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|||
import {
|
||||
AdditiveBlending,
|
||||
BoxGeometry,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
CanvasTexture,
|
||||
DataTexture,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
|
|
@ -15,6 +17,8 @@ import {
|
|||
RGBAFormat,
|
||||
ShaderMaterial,
|
||||
SphereGeometry,
|
||||
Sprite,
|
||||
SpriteMaterial,
|
||||
Texture,
|
||||
Uint16BufferAttribute,
|
||||
UnsignedByteType,
|
||||
|
|
@ -42,7 +46,10 @@ import {
|
|||
resolveAudioProfile,
|
||||
playOneShotSound,
|
||||
getCachedAudioBuffer,
|
||||
trackDemoSound,
|
||||
untrackDemoSound,
|
||||
} from "./AudioEmitter";
|
||||
import { demoEffectNow, engineStore } from "../state";
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
|
|
@ -92,10 +99,357 @@ const _debugOriginMat = new MeshBasicMaterial({ color: 0xff0000, wireframe: true
|
|||
const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3);
|
||||
const _debugParticleMat = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
|
||||
|
||||
// ── sRGB → linear conversion for shader attributes ──
|
||||
// ── Explosion wireframe sphere geometry (reusable) ──
|
||||
|
||||
function srgbToLinear(c: number): number {
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
const _explosionSphereGeo = new SphereGeometry(1, 12, 8);
|
||||
|
||||
interface ActiveExplosionSphere {
|
||||
entityId: string;
|
||||
mesh: Mesh;
|
||||
material: MeshBasicMaterial;
|
||||
label: Sprite;
|
||||
labelMaterial: SpriteMaterial;
|
||||
creationTime: number;
|
||||
lifetimeMS: number;
|
||||
targetRadius: number;
|
||||
}
|
||||
|
||||
/** Create a text label sprite for an explosion sphere. */
|
||||
function createExplosionLabel(text: string, color: number): { sprite: Sprite; material: SpriteMaterial } {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const fontSize = 32;
|
||||
ctx.font = `bold ${fontSize}px monospace`;
|
||||
const metrics = ctx.measureText(text);
|
||||
const padding = 8;
|
||||
canvas.width = Math.ceil(metrics.width) + padding * 2;
|
||||
canvas.height = fontSize + padding * 2;
|
||||
|
||||
// Redraw with correct canvas size.
|
||||
ctx.font = `bold ${fontSize}px monospace`;
|
||||
ctx.fillStyle = `#${color.toString(16).padStart(6, "0")}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, padding, canvas.height / 2);
|
||||
|
||||
const texture = new CanvasTexture(canvas);
|
||||
const material = new SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new Sprite(material);
|
||||
// Scale to be readable in world space (roughly 1 unit tall).
|
||||
const aspect = canvas.width / canvas.height;
|
||||
sprite.scale.set(aspect * 2, 2, 1);
|
||||
return { sprite, material };
|
||||
}
|
||||
|
||||
// ── Shockwave ring rendering ──
|
||||
|
||||
interface ShockwaveData {
|
||||
width: number;
|
||||
numSegments: number;
|
||||
velocity: number;
|
||||
height: number;
|
||||
verticalCurve: number;
|
||||
acceleration: number;
|
||||
texWrap: number;
|
||||
lifetimeMS: number;
|
||||
is2D: boolean;
|
||||
renderSquare: boolean;
|
||||
renderBottom: boolean;
|
||||
mapToTerrain: boolean;
|
||||
colors: { r: number; g: number; b: number; a: number }[];
|
||||
times: number[];
|
||||
textureName: string;
|
||||
mapToTexture: string;
|
||||
}
|
||||
|
||||
interface ActiveShockwave {
|
||||
entityId: string;
|
||||
mesh: Mesh;
|
||||
bottomMesh: Mesh | null;
|
||||
geometry: BufferGeometry;
|
||||
bottomGeometry: BufferGeometry | null;
|
||||
material: ShaderMaterial;
|
||||
creationTime: number;
|
||||
lifetimeMS: number;
|
||||
data: ShockwaveData;
|
||||
radius: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
/** Resolve a ShockwaveData datablock from an explosion's shockwave ref. */
|
||||
function resolveShockwaveData(
|
||||
shockwaveId: number,
|
||||
getDataBlockData: (id: number) => Record<string, unknown> | undefined,
|
||||
): ShockwaveData | null {
|
||||
const raw = getDataBlockData(shockwaveId);
|
||||
if (!raw) return null;
|
||||
|
||||
const colors = (raw.colors as ShockwaveData["colors"]) ?? [];
|
||||
const times = (raw.times as number[]) ?? [0, 0.5, 1, 1];
|
||||
|
||||
return {
|
||||
width: (raw.width as number) ?? 1,
|
||||
numSegments: Math.max((raw.numSegments as number) ?? 16, 4),
|
||||
velocity: (raw.velocity as number) ?? 0,
|
||||
height: (raw.height as number) ?? 0,
|
||||
verticalCurve: (raw.verticalCurve as number) ?? 0,
|
||||
acceleration: (raw.acceleration as number) ?? 0,
|
||||
texWrap: (raw.texWrap as number) ?? 1,
|
||||
lifetimeMS: (raw.lifetimeMS as number) ?? 500,
|
||||
is2D: !!raw.is2D,
|
||||
renderSquare: !!raw.renderSquare,
|
||||
renderBottom: !!raw.renderBottom,
|
||||
mapToTerrain: !!raw.mapToTerrain,
|
||||
colors,
|
||||
times,
|
||||
textureName: (raw.textureName as string) ?? "",
|
||||
mapToTexture: (raw.mapToTexture as string) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Interpolate RGBA color from shockwave keyframes at normalized time t. */
|
||||
function interpolateShockwaveColor(
|
||||
data: ShockwaveData,
|
||||
t: number,
|
||||
): [number, number, number, number] {
|
||||
const { colors, times } = data;
|
||||
if (colors.length === 0) return [1, 1, 1, 1];
|
||||
|
||||
// Find the active keyframe segment.
|
||||
let idx = 0;
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
if (t >= times[i]) idx = i;
|
||||
}
|
||||
const nextIdx = Math.min(idx + 1, colors.length - 1);
|
||||
|
||||
const t0 = times[idx] ?? 0;
|
||||
const t1 = times[nextIdx] ?? 1;
|
||||
const span = t1 - t0;
|
||||
const frac = span > 0 ? Math.min((t - t0) / span, 1) : 0;
|
||||
|
||||
const c0 = colors[idx] ?? colors[0];
|
||||
const c1 = colors[nextIdx] ?? colors[0];
|
||||
|
||||
return [
|
||||
c0.r + (c1.r - c0.r) * frac,
|
||||
c0.g + (c1.g - c0.g) * frac,
|
||||
c0.b + (c1.b - c0.b) * frac,
|
||||
c0.a + (c1.a - c0.a) * frac,
|
||||
];
|
||||
}
|
||||
|
||||
// Shockwave ring shader — additive blending, vertex colors with alpha.
|
||||
const shockwaveVertexShader = /* glsl */ `
|
||||
attribute vec4 vertexColor;
|
||||
attribute vec2 texCoord;
|
||||
varying vec4 vColor;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vColor = vertexColor;
|
||||
vUV = texCoord;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const shockwaveFragmentShader = /* glsl */ `
|
||||
uniform sampler2D uTexture;
|
||||
varying vec4 vColor;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vec4 tex = texture2D(uTexture, vUV);
|
||||
gl_FragColor = vec4(vColor.rgb * tex.rgb, vColor.a * tex.a);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Create ring geometry buffers for a shockwave with the given segment count.
|
||||
* Each segment is a quad (2 triangles) between inner and outer ring vertices.
|
||||
* Returns the geometry with position, texCoord, vertexColor attributes and
|
||||
* index buffer pre-allocated for numSegments quads.
|
||||
*/
|
||||
function createShockwaveGeometry(numSegments: number): BufferGeometry {
|
||||
// 2 vertices per segment (inner + outer) + 2 to close the loop.
|
||||
const numVerts = (numSegments + 1) * 2;
|
||||
const positions = new Float32Array(numVerts * 3);
|
||||
const texCoords = new Float32Array(numVerts * 2);
|
||||
const vertexColors = new Float32Array(numVerts * 4);
|
||||
|
||||
// 2 triangles per segment = 6 indices.
|
||||
const numIndices = numSegments * 6;
|
||||
const indices = new Uint16Array(numIndices);
|
||||
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
const base = i * 2;
|
||||
const j = i * 6;
|
||||
// Outer-inner-outer, inner-inner-outer (CCW winding).
|
||||
indices[j] = base;
|
||||
indices[j + 1] = base + 1;
|
||||
indices[j + 2] = base + 2;
|
||||
indices[j + 3] = base + 1;
|
||||
indices[j + 4] = base + 3;
|
||||
indices[j + 5] = base + 2;
|
||||
}
|
||||
|
||||
const geo = new BufferGeometry();
|
||||
const posAttr = new BufferAttribute(positions, 3);
|
||||
posAttr.setUsage(35048); // DynamicDrawUsage
|
||||
geo.setAttribute("position", posAttr);
|
||||
|
||||
const texAttr = new BufferAttribute(texCoords, 2);
|
||||
texAttr.setUsage(35048);
|
||||
geo.setAttribute("texCoord", texAttr);
|
||||
|
||||
const colorAttr = new BufferAttribute(vertexColors, 4);
|
||||
colorAttr.setUsage(35048);
|
||||
geo.setAttribute("vertexColor", colorAttr);
|
||||
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
|
||||
return geo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update shockwave ring vertex positions, UVs, and colors for the current
|
||||
* frame. Implements the V12 renderWave algorithm: an expanding annular ring
|
||||
* with optional height on the outer edge.
|
||||
*/
|
||||
function updateShockwaveGeometry(
|
||||
geo: BufferGeometry,
|
||||
sw: ShockwaveData,
|
||||
radius: number,
|
||||
color: [number, number, number, number],
|
||||
is2D: boolean,
|
||||
): void {
|
||||
const posArr = (geo.getAttribute("position") as BufferAttribute)
|
||||
.array as Float32Array;
|
||||
const texArr = (geo.getAttribute("texCoord") as BufferAttribute)
|
||||
.array as Float32Array;
|
||||
const colArr = (geo.getAttribute("vertexColor") as BufferAttribute)
|
||||
.array as Float32Array;
|
||||
|
||||
const innerRad = Math.max(radius - sw.width * 0.5, 0);
|
||||
const outerRad = radius + sw.width * 0.5;
|
||||
const numSegs = sw.numSegments;
|
||||
|
||||
// Pass colors as-is (gamma space) — ShaderMaterial has no automatic
|
||||
// output encoding, matching V12's direct gamma-space rendering.
|
||||
const lr = color[0];
|
||||
const lg = color[1];
|
||||
const lb = color[2];
|
||||
const la = color[3];
|
||||
|
||||
for (let i = 0; i <= numSegs; i++) {
|
||||
const angle = (i / numSegs) * Math.PI * 2;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
|
||||
// In Three.js space: ring lies in XZ plane, Y is up.
|
||||
const outerIdx = i * 2;
|
||||
const innerIdx = outerIdx + 1;
|
||||
|
||||
// Outer vertex — raised by height along Y.
|
||||
const opi = outerIdx * 3;
|
||||
posArr[opi] = cos * outerRad;
|
||||
posArr[opi + 1] = is2D ? 0 : sw.height;
|
||||
posArr[opi + 2] = sin * outerRad;
|
||||
|
||||
// Inner vertex — on ground plane.
|
||||
const ipi = innerIdx * 3;
|
||||
posArr[ipi] = cos * innerRad;
|
||||
posArr[ipi + 1] = 0;
|
||||
posArr[ipi + 2] = sin * innerRad;
|
||||
|
||||
// UV: U wraps around ring, V spans inner→outer.
|
||||
const u = (i / numSegs) * sw.texWrap;
|
||||
const oti = outerIdx * 2;
|
||||
texArr[oti] = u;
|
||||
texArr[oti + 1] = 0.05; // outer edge
|
||||
|
||||
const iti = innerIdx * 2;
|
||||
texArr[iti] = u;
|
||||
texArr[iti + 1] = 0.95; // inner edge
|
||||
|
||||
// Vertex colors (uniform across ring).
|
||||
const oci = outerIdx * 4;
|
||||
colArr[oci] = lr;
|
||||
colArr[oci + 1] = lg;
|
||||
colArr[oci + 2] = lb;
|
||||
colArr[oci + 3] = la;
|
||||
|
||||
const ici = innerIdx * 4;
|
||||
colArr[ici] = lr;
|
||||
colArr[ici + 1] = lg;
|
||||
colArr[ici + 2] = lb;
|
||||
colArr[ici + 3] = la;
|
||||
}
|
||||
|
||||
geo.getAttribute("position").needsUpdate = true;
|
||||
geo.getAttribute("texCoord").needsUpdate = true;
|
||||
geo.getAttribute("vertexColor").needsUpdate = true;
|
||||
geo.computeBoundingSphere();
|
||||
}
|
||||
|
||||
/** Create the ShaderMaterial for a shockwave ring. */
|
||||
function createShockwaveMaterial(texture: Texture): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
vertexShader: shockwaveVertexShader,
|
||||
fragmentShader: shockwaveFragmentShader,
|
||||
uniforms: { uTexture: { value: texture } },
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
side: DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
/** Map explosion dataBlock shape name to a debug wireframe color. */
|
||||
function getExplosionColor(dataBlock: string | undefined): number {
|
||||
if (!dataBlock) return 0xff00ff;
|
||||
const name = dataBlock.toLowerCase();
|
||||
if (name.includes("disc")) return 0x4488ff;
|
||||
if (name.includes("grenade")) return 0xff8800;
|
||||
if (name.includes("mortar")) return 0xff4400;
|
||||
if (name.includes("plasma")) return 0x44ff44;
|
||||
if (name.includes("laser")) return 0xff2222;
|
||||
if (name.includes("blaster")) return 0xffff00;
|
||||
if (name.includes("missile")) return 0xff6600;
|
||||
if (name.includes("bomb")) return 0xff0000;
|
||||
if (name.includes("mine")) return 0xff8844;
|
||||
if (name.includes("concussion")) return 0xffaa00;
|
||||
if (name.includes("shocklance")) return 0x8844ff;
|
||||
if (name.includes("chaingun") || name.includes("bullet")) return 0xcccccc;
|
||||
return 0xff00ff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract approximate radius from an ExplosionData datablock's `sizes` array.
|
||||
* Each entry is `{x, y, z}` with values in range 0–16000 (scale multiplier).
|
||||
* Falls back to `particleRadius` or a default of 5.
|
||||
*/
|
||||
function getExplosionRadius(
|
||||
expBlock: Record<string, unknown>,
|
||||
): number {
|
||||
const sizes = expBlock.sizes as Array<{ x: number; y: number; z: number }> | undefined;
|
||||
if (Array.isArray(sizes) && sizes.length > 0) {
|
||||
let maxVal = 0;
|
||||
for (const s of sizes) {
|
||||
maxVal = Math.max(maxVal, s.x, s.y, s.z);
|
||||
}
|
||||
if (maxVal > 0) {
|
||||
// Values are in 0–16000 range, treat as a scale factor.
|
||||
// Typical explosions have values like 2000–8000; map to reasonable world radii.
|
||||
return maxVal / 1000;
|
||||
}
|
||||
}
|
||||
const particleRadius = expBlock.particleRadius as number | undefined;
|
||||
if (typeof particleRadius === "number" && particleRadius > 0) {
|
||||
return particleRadius;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
// ── Geometry builder ──
|
||||
|
|
@ -129,6 +483,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
|
|||
const colors = new Float32Array(vertCount * 4);
|
||||
const sizes = new Float32Array(vertCount);
|
||||
const spins = new Float32Array(vertCount);
|
||||
const orientDirs = new Float32Array(vertCount * 3);
|
||||
|
||||
geo.setIndex(new Uint16BufferAttribute(indices, 1));
|
||||
geo.setAttribute("quadCorner", new Float32BufferAttribute(corners, 2));
|
||||
|
|
@ -136,6 +491,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
|
|||
geo.setAttribute("particleColor", new Float32BufferAttribute(colors, 4));
|
||||
geo.setAttribute("particleSize", new Float32BufferAttribute(sizes, 1));
|
||||
geo.setAttribute("particleSpin", new Float32BufferAttribute(spins, 1));
|
||||
geo.setAttribute("orientDir", new Float32BufferAttribute(orientDirs, 3));
|
||||
|
||||
geo.setDrawRange(0, 0);
|
||||
return geo;
|
||||
|
|
@ -144,6 +500,7 @@ function createParticleGeometry(maxParticles: number): BufferGeometry {
|
|||
function createParticleMaterial(
|
||||
texture: Texture,
|
||||
useInvAlpha: boolean,
|
||||
orientParticles = false,
|
||||
): ShaderMaterial {
|
||||
// Use the placeholder until the real texture's image data is ready.
|
||||
const ready = _texturesReady.has(texture);
|
||||
|
|
@ -153,6 +510,8 @@ function createParticleMaterial(
|
|||
uniforms: {
|
||||
particleTexture: { value: ready ? texture : _placeholderTexture },
|
||||
hasTexture: { value: true },
|
||||
debugOpacity: { value: 1.0 },
|
||||
uOrientParticles: { value: orientParticles },
|
||||
},
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
|
|
@ -178,6 +537,8 @@ interface ActiveEmitter {
|
|||
shaderChecked?: boolean;
|
||||
/** Entity ID this emitter follows (for projectile trails). */
|
||||
followEntityId?: string;
|
||||
/** Emission axis in Torque space (defaults to [0,0,1] = up). */
|
||||
emitAxis?: [number, number, number];
|
||||
/** Debug: origin marker mesh. */
|
||||
debugOriginMesh?: Mesh;
|
||||
/** Debug: particle marker meshes. */
|
||||
|
|
@ -268,13 +629,16 @@ function syncBuffers(active: ActiveEmitter): void {
|
|||
const colorAttr = geo.getAttribute("particleColor") as Float32BufferAttribute;
|
||||
const sizeAttr = geo.getAttribute("particleSize") as Float32BufferAttribute;
|
||||
const spinAttr = geo.getAttribute("particleSpin") as Float32BufferAttribute;
|
||||
const orientAttr = geo.getAttribute("orientDir") as Float32BufferAttribute;
|
||||
|
||||
const posArr = posAttr.array as Float32Array;
|
||||
const colArr = colorAttr.array as Float32Array;
|
||||
const sizeArr = sizeAttr.array as Float32Array;
|
||||
const spinArr = spinAttr.array as Float32Array;
|
||||
const orientArr = orientAttr.array as Float32Array;
|
||||
|
||||
const count = Math.min(particles.length, MAX_PARTICLES_PER_EMITTER);
|
||||
const useVelocity = active.emitter.data.orientOnVelocity;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = particles[i];
|
||||
|
|
@ -284,10 +648,19 @@ function syncBuffers(active: ActiveEmitter): void {
|
|||
const ty = p.pos[2];
|
||||
const tz = p.pos[0];
|
||||
|
||||
// Convert sRGB particle colors to linear for the shader.
|
||||
const lr = srgbToLinear(p.r);
|
||||
const lg = srgbToLinear(p.g);
|
||||
const lb = srgbToLinear(p.b);
|
||||
// Orient direction: use velocity or initial orientDir, swizzled.
|
||||
const dir = useVelocity ? p.vel : p.orientDir;
|
||||
const odx = dir[1];
|
||||
const ody = dir[2];
|
||||
const odz = dir[0];
|
||||
|
||||
// Pass particle colors as-is (sRGB / gamma space). ShaderMaterial does
|
||||
// not get automatic linear→sRGB output encoding, so linearizing here
|
||||
// would darken colors without compensation — matching V12's direct
|
||||
// gamma-space rendering.
|
||||
const lr = p.r;
|
||||
const lg = p.g;
|
||||
const lb = p.b;
|
||||
const la = p.a;
|
||||
|
||||
// Write the same values to all 4 vertices of the quad.
|
||||
|
|
@ -304,6 +677,11 @@ function syncBuffers(active: ActiveEmitter): void {
|
|||
colArr[ci + 2] = lb;
|
||||
colArr[ci + 3] = la;
|
||||
|
||||
const oi = vi * 3;
|
||||
orientArr[oi] = odx;
|
||||
orientArr[oi + 1] = ody;
|
||||
orientArr[oi + 2] = odz;
|
||||
|
||||
sizeArr[vi] = p.size;
|
||||
spinArr[vi] = p.currentSpin;
|
||||
}
|
||||
|
|
@ -320,6 +698,7 @@ function syncBuffers(active: ActiveEmitter): void {
|
|||
colorAttr.needsUpdate = true;
|
||||
sizeAttr.needsUpdate = true;
|
||||
spinAttr.needsUpdate = true;
|
||||
orientAttr.needsUpdate = true;
|
||||
|
||||
geo.setDrawRange(0, count * 6);
|
||||
}
|
||||
|
|
@ -349,12 +728,20 @@ export function DemoParticleEffects({
|
|||
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) => {
|
||||
/** Active wireframe explosion spheres. */
|
||||
const activeExplosionSpheresRef = useRef<ActiveExplosionSphere[]>([]);
|
||||
/** Active shockwave ring effects. */
|
||||
const activeShockwavesRef = useRef<ActiveShockwave[]>([]);
|
||||
useFrame((state, delta) => {
|
||||
const group = groupRef.current;
|
||||
const snapshot = snapshotRef.current;
|
||||
if (!group || !snapshot) return;
|
||||
|
||||
const dtMS = delta * 1000;
|
||||
const playbackState = engineStore.getState().playback;
|
||||
const isPlaying = playbackState.status === "playing";
|
||||
// Scale delta by playback rate; 0 when paused.
|
||||
const effectDelta = isPlaying ? delta * playbackState.rate : 0;
|
||||
const dtMS = effectDelta * 1000;
|
||||
const getDataBlockData = playback.getDataBlockData.bind(playback);
|
||||
|
||||
// Detect new explosion entities and create emitters.
|
||||
|
|
@ -390,6 +777,7 @@ export function DemoParticleEffects({
|
|||
const material = createParticleMaterial(
|
||||
texture,
|
||||
burst.data.particles.useInvAlpha,
|
||||
burst.data.orientParticles,
|
||||
);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.frustumCulled = false;
|
||||
|
|
@ -420,6 +808,7 @@ export function DemoParticleEffects({
|
|||
const material = createParticleMaterial(
|
||||
texture,
|
||||
emitterData.particles.useInvAlpha,
|
||||
emitterData.orientParticles,
|
||||
);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.frustumCulled = false;
|
||||
|
|
@ -436,6 +825,91 @@ export function DemoParticleEffects({
|
|||
hasBurst: false,
|
||||
});
|
||||
}
|
||||
|
||||
const expBlock = getDataBlockData(entity.explosionDataBlockId);
|
||||
|
||||
// Debug mode: show wireframe spheres and labels.
|
||||
if (debugMode) {
|
||||
const radius = expBlock ? getExplosionRadius(expBlock) : 5;
|
||||
const color = getExplosionColor(entity.dataBlock);
|
||||
const sphereMat = new MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sphereMesh = new Mesh(_explosionSphereGeo, sphereMat);
|
||||
sphereMesh.frustumCulled = false;
|
||||
sphereMesh.scale.setScalar(radius);
|
||||
sphereMesh.position.set(origin[1], origin[2], origin[0]);
|
||||
group.add(sphereMesh);
|
||||
|
||||
const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`;
|
||||
const { sprite: labelSprite, material: labelMat } = createExplosionLabel(labelText, color);
|
||||
labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]);
|
||||
labelSprite.frustumCulled = false;
|
||||
group.add(labelSprite);
|
||||
|
||||
activeExplosionSpheresRef.current.push({
|
||||
entityId: entity.id as string,
|
||||
mesh: sphereMesh,
|
||||
material: sphereMat,
|
||||
label: labelSprite,
|
||||
labelMaterial: labelMat,
|
||||
creationTime: demoEffectNow(),
|
||||
lifetimeMS: Math.max(resolved.lifetimeMS, 3000),
|
||||
targetRadius: radius,
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn shockwave ring if the explosion datablock references one.
|
||||
const shockwaveId = expBlock?.shockwave as number | null | undefined;
|
||||
if (typeof shockwaveId === "number") {
|
||||
const swData = resolveShockwaveData(shockwaveId, getDataBlockData);
|
||||
if (swData) {
|
||||
const texture = getParticleTexture(swData.textureName);
|
||||
const geo = createShockwaveGeometry(swData.numSegments);
|
||||
const mat = createShockwaveMaterial(texture);
|
||||
const mesh = new Mesh(geo, mat);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.position.set(origin[1], origin[2], origin[0]);
|
||||
group.add(mesh);
|
||||
|
||||
// Optional bottom face (renders the underside of the ring).
|
||||
let bottomMesh: Mesh | null = null;
|
||||
let bottomGeo: BufferGeometry | null = null;
|
||||
if (swData.renderBottom) {
|
||||
bottomGeo = createShockwaveGeometry(swData.numSegments);
|
||||
bottomMesh = new Mesh(bottomGeo, mat);
|
||||
bottomMesh.frustumCulled = false;
|
||||
bottomMesh.position.set(origin[1], origin[2], origin[0]);
|
||||
// Flip Y to render the underside.
|
||||
bottomMesh.scale.y = -1;
|
||||
group.add(bottomMesh);
|
||||
}
|
||||
|
||||
// Clamp denormalized velocity values (parser bug workaround).
|
||||
const initVelocity = Math.abs(swData.velocity) > 1e-10
|
||||
? swData.velocity
|
||||
: 0;
|
||||
|
||||
activeShockwavesRef.current.push({
|
||||
entityId: entity.id as string,
|
||||
mesh,
|
||||
bottomMesh,
|
||||
geometry: geo,
|
||||
bottomGeometry: bottomGeo,
|
||||
material: mat,
|
||||
creationTime: demoEffectNow(),
|
||||
lifetimeMS: swData.lifetimeMS,
|
||||
data: swData,
|
||||
radius: 0,
|
||||
velocity: initVelocity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Detect projectile entities with trail emitters (maintainEmitterId).
|
||||
|
|
@ -465,6 +939,7 @@ export function DemoParticleEffects({
|
|||
const material = createParticleMaterial(
|
||||
texture,
|
||||
emitterData.particles.useInvAlpha,
|
||||
emitterData.orientParticles,
|
||||
);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.frustumCulled = false;
|
||||
|
|
@ -508,7 +983,7 @@ export function DemoParticleEffects({
|
|||
entry.shaderChecked = true;
|
||||
}
|
||||
|
||||
// Update trail emitter origin to follow the projectile's position.
|
||||
// Update trail emitter origin and direction to follow the projectile.
|
||||
if (entry.followEntityId) {
|
||||
const tracked = snapshot.entities.find(
|
||||
(e) => e.id === entry.followEntityId,
|
||||
|
|
@ -518,11 +993,14 @@ export function DemoParticleEffects({
|
|||
entry.origin[1] = tracked.position[1];
|
||||
entry.origin[2] = tracked.position[2];
|
||||
}
|
||||
if (tracked?.direction) {
|
||||
entry.emitAxis = tracked.direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming emitters emit periodically.
|
||||
if (!entry.isBurst) {
|
||||
entry.emitter.emitPeriodic(entry.origin, dtMS);
|
||||
entry.emitter.emitPeriodic(entry.origin, dtMS, entry.emitAxis);
|
||||
}
|
||||
|
||||
// Advance physics and interpolation.
|
||||
|
|
@ -536,6 +1014,9 @@ export function DemoParticleEffects({
|
|||
entry.material.uniforms.particleTexture.value = entry.targetTexture;
|
||||
}
|
||||
|
||||
// Reduce particle opacity in debug mode for visibility.
|
||||
entry.material.uniforms.debugOpacity.value = debugMode ? 0.2 : 1.0;
|
||||
|
||||
// Sync GPU buffers.
|
||||
syncBuffers(entry);
|
||||
|
||||
|
|
@ -604,8 +1085,87 @@ export function DemoParticleEffects({
|
|||
}
|
||||
}
|
||||
|
||||
// ── Update explosion wireframe spheres ──
|
||||
const spheres = activeExplosionSpheresRef.current;
|
||||
const now = demoEffectNow();
|
||||
for (let i = spheres.length - 1; i >= 0; i--) {
|
||||
const sphere = spheres[i];
|
||||
const elapsed = now - sphere.creationTime;
|
||||
const frac = Math.min(elapsed / sphere.lifetimeMS, 1);
|
||||
|
||||
// Quick scale-up in first 10%, then hold.
|
||||
const scaleFrac = Math.min(frac / 0.1, 1);
|
||||
sphere.mesh.scale.setScalar(sphere.targetRadius * scaleFrac);
|
||||
|
||||
// Fade opacity over lifetime.
|
||||
sphere.material.opacity = 1 - frac;
|
||||
sphere.labelMaterial.opacity = 1 - frac;
|
||||
|
||||
// Remove when lifetime expires.
|
||||
if (frac >= 1) {
|
||||
group.remove(sphere.mesh);
|
||||
group.remove(sphere.label);
|
||||
sphere.material.dispose();
|
||||
sphere.labelMaterial.dispose();
|
||||
spheres.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update shockwave rings ──
|
||||
const shockwaves = activeShockwavesRef.current;
|
||||
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves[i];
|
||||
const elapsed = now - sw.creationTime;
|
||||
const t = Math.min(elapsed / sw.lifetimeMS, 1);
|
||||
const dtSec = effectDelta;
|
||||
|
||||
// V12 expansion physics: velocity += acceleration * dt; radius += velocity * dt
|
||||
sw.velocity += sw.data.acceleration * dtSec;
|
||||
sw.radius += sw.velocity * dtSec;
|
||||
|
||||
// Interpolate color from keyframes.
|
||||
const color = interpolateShockwaveColor(sw.data, t);
|
||||
|
||||
// Update ring geometry.
|
||||
updateShockwaveGeometry(
|
||||
sw.geometry,
|
||||
sw.data,
|
||||
sw.radius,
|
||||
color,
|
||||
sw.data.is2D,
|
||||
);
|
||||
|
||||
// Update bottom ring if present.
|
||||
if (sw.bottomGeometry) {
|
||||
updateShockwaveGeometry(
|
||||
sw.bottomGeometry,
|
||||
sw.data,
|
||||
sw.radius,
|
||||
color,
|
||||
sw.data.is2D,
|
||||
);
|
||||
}
|
||||
|
||||
// For is2D mode: billboard the ring to face the camera.
|
||||
if (sw.data.is2D) {
|
||||
sw.mesh.lookAt(state.camera.position);
|
||||
}
|
||||
|
||||
// Remove when lifetime expires.
|
||||
if (t >= 1) {
|
||||
group.remove(sw.mesh);
|
||||
if (sw.bottomMesh) group.remove(sw.bottomMesh);
|
||||
sw.geometry.dispose();
|
||||
sw.bottomGeometry?.dispose();
|
||||
sw.material.dispose();
|
||||
shockwaves.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio: explosion impact sounds ──
|
||||
if (audioEnabled && audioLoader && audioListener && groupRef.current) {
|
||||
// Only process new audio events while playing to avoid triggering
|
||||
// sounds during pause (existing sounds are frozen via AudioContext.suspend).
|
||||
if (isPlaying && audioEnabled && audioLoader && audioListener && groupRef.current) {
|
||||
for (const entity of snapshot.entities) {
|
||||
if (
|
||||
entity.type !== "Explosion" ||
|
||||
|
|
@ -684,6 +1244,7 @@ export function DemoParticleEffects({
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(playbackState.rate);
|
||||
sound.setLoop(true);
|
||||
sound.position.set(
|
||||
entity.position![1],
|
||||
|
|
@ -691,6 +1252,7 @@ export function DemoParticleEffects({
|
|||
entity.position![0],
|
||||
);
|
||||
group.add(sound);
|
||||
trackDemoSound(sound);
|
||||
sound.play();
|
||||
projSounds.set(entity.id, sound);
|
||||
});
|
||||
|
|
@ -702,6 +1264,7 @@ export function DemoParticleEffects({
|
|||
// Despawn: stop sounds for entities no longer present.
|
||||
for (const [entityId, sound] of projSounds) {
|
||||
if (!currentEntityIds.has(entityId)) {
|
||||
untrackDemoSound(sound);
|
||||
try { sound.stop(); } catch {}
|
||||
sound.disconnect();
|
||||
groupRef.current?.remove(sound);
|
||||
|
|
@ -768,10 +1331,32 @@ export function DemoParticleEffects({
|
|||
entry.material.dispose();
|
||||
}
|
||||
activeEmittersRef.current = [];
|
||||
// Clean up explosion spheres.
|
||||
for (const sphere of activeExplosionSpheresRef.current) {
|
||||
if (group) {
|
||||
group.remove(sphere.mesh);
|
||||
group.remove(sphere.label);
|
||||
}
|
||||
sphere.material.dispose();
|
||||
sphere.labelMaterial.dispose();
|
||||
}
|
||||
activeExplosionSpheresRef.current = [];
|
||||
// Clean up shockwave rings.
|
||||
for (const sw of activeShockwavesRef.current) {
|
||||
if (group) {
|
||||
group.remove(sw.mesh);
|
||||
if (sw.bottomMesh) group.remove(sw.bottomMesh);
|
||||
}
|
||||
sw.geometry.dispose();
|
||||
sw.bottomGeometry?.dispose();
|
||||
sw.material.dispose();
|
||||
}
|
||||
activeShockwavesRef.current = [];
|
||||
processedExplosionsRef.current.clear();
|
||||
trailEntitiesRef.current.clear();
|
||||
// Clean up projectile sounds.
|
||||
for (const [, sound] of projectileSoundsRef.current) {
|
||||
untrackDemoSound(sound);
|
||||
try { sound.stop(); } catch {}
|
||||
sound.disconnect();
|
||||
if (group) group.remove(sound);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { TickProvider } from "./TickProvider";
|
|||
import { DemoEntityGroup } from "./DemoEntities";
|
||||
import { DemoParticleEffects } from "./DemoParticleEffects";
|
||||
import { PlayerEyeOffset } from "./DemoPlayerModel";
|
||||
import { useEngineStoreApi } from "../state";
|
||||
import { useEngineStoreApi, advanceEffectClock } from "../state";
|
||||
import type {
|
||||
DemoEntity,
|
||||
DemoRecording,
|
||||
|
|
@ -67,14 +67,13 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
|
||||
const prevMap = entityMapRef.current;
|
||||
const nextMap = new Map<string, DemoEntity>();
|
||||
// Derive shouldRebuild from the entity loop itself instead of computing
|
||||
// an O(n) string signature every frame. Entity count change catches
|
||||
// add/remove; identity check catches per-entity changes.
|
||||
let shouldRebuild = snapshot.entities.length !== prevMap.size;
|
||||
let shouldRebuild = false;
|
||||
|
||||
for (const entity of snapshot.entities) {
|
||||
let renderEntity = prevMap.get(entity.id);
|
||||
if (
|
||||
|
||||
// Identity change → new component (unmount/remount)
|
||||
const needsNewIdentity =
|
||||
!renderEntity ||
|
||||
renderEntity.type !== entity.type ||
|
||||
renderEntity.dataBlock !== entity.dataBlock ||
|
||||
|
|
@ -82,8 +81,9 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
renderEntity.className !== entity.className ||
|
||||
renderEntity.ghostIndex !== entity.ghostIndex ||
|
||||
renderEntity.dataBlockId !== entity.dataBlockId ||
|
||||
renderEntity.shapeHint !== entity.shapeHint
|
||||
) {
|
||||
renderEntity.shapeHint !== entity.shapeHint;
|
||||
|
||||
if (needsNewIdentity) {
|
||||
renderEntity = buildStreamDemoEntity(
|
||||
entity.id,
|
||||
entity.type,
|
||||
|
|
@ -96,26 +96,57 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
entity.ghostIndex,
|
||||
entity.dataBlockId,
|
||||
entity.shapeHint,
|
||||
entity.explosionDataBlockId,
|
||||
entity.faceViewer,
|
||||
);
|
||||
renderEntity.playerName = entity.playerName;
|
||||
renderEntity.iffColor = entity.iffColor;
|
||||
renderEntity.targetRenderFlags = entity.targetRenderFlags;
|
||||
renderEntity.threads = entity.threads;
|
||||
renderEntity.weaponImageState = entity.weaponImageState;
|
||||
renderEntity.weaponImageStates = entity.weaponImageStates;
|
||||
renderEntity.headPitch = entity.headPitch;
|
||||
renderEntity.headYaw = entity.headYaw;
|
||||
renderEntity.direction = entity.direction;
|
||||
renderEntity.visual = entity.visual;
|
||||
renderEntity.explosionDataBlockId = entity.explosionDataBlockId;
|
||||
renderEntity.faceViewer = entity.faceViewer;
|
||||
renderEntity.spawnTime = snapshot.timeSec;
|
||||
shouldRebuild = true;
|
||||
} else if (
|
||||
renderEntity.playerName !== entity.playerName ||
|
||||
renderEntity.iffColor !== entity.iffColor ||
|
||||
renderEntity.targetRenderFlags !== entity.targetRenderFlags ||
|
||||
renderEntity.threads !== entity.threads ||
|
||||
renderEntity.weaponImageState !== entity.weaponImageState ||
|
||||
renderEntity.weaponImageStates !== entity.weaponImageStates ||
|
||||
renderEntity.headPitch !== entity.headPitch ||
|
||||
renderEntity.headYaw !== entity.headYaw ||
|
||||
renderEntity.direction !== entity.direction ||
|
||||
renderEntity.visual !== entity.visual
|
||||
) {
|
||||
// Render-affecting field changed → new object so React.memo sees
|
||||
// a different reference and re-renders this entity's component.
|
||||
renderEntity = {
|
||||
...renderEntity,
|
||||
playerName: entity.playerName,
|
||||
iffColor: entity.iffColor,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
threads: entity.threads,
|
||||
weaponImageState: entity.weaponImageState,
|
||||
weaponImageStates: entity.weaponImageStates,
|
||||
headPitch: entity.headPitch,
|
||||
headYaw: entity.headYaw,
|
||||
direction: entity.direction,
|
||||
visual: entity.visual,
|
||||
};
|
||||
shouldRebuild = true;
|
||||
}
|
||||
// else: no render-affecting changes, keep same object reference
|
||||
// so React.memo can skip re-rendering this entity.
|
||||
|
||||
renderEntity.playerName = entity.playerName;
|
||||
renderEntity.iffColor = entity.iffColor;
|
||||
renderEntity.dataBlock = entity.dataBlock;
|
||||
renderEntity.visual = entity.visual;
|
||||
renderEntity.direction = entity.direction;
|
||||
renderEntity.weaponShape = entity.weaponShape;
|
||||
renderEntity.className = entity.className;
|
||||
renderEntity.ghostIndex = entity.ghostIndex;
|
||||
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;
|
||||
|
||||
// Keyframe update (mutable — only used as fallback position for
|
||||
// retained explosion entities; useFrame reads from snapshot entities).
|
||||
if (renderEntity.keyframes.length === 0) {
|
||||
renderEntity.keyframes.push({
|
||||
time: snapshot.timeSec,
|
||||
|
|
@ -123,7 +154,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
rotation: entity.rotation ?? [0, 0, 0, 1],
|
||||
});
|
||||
}
|
||||
|
||||
const kf = renderEntity.keyframes[0];
|
||||
kf.time = snapshot.timeSec;
|
||||
if (entity.position) kf.position = entity.position;
|
||||
|
|
@ -138,6 +168,28 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
nextMap.set(entity.id, renderEntity);
|
||||
}
|
||||
|
||||
// Retain explosion entities with DTS shapes after they leave the snapshot.
|
||||
// These entities are ephemeral (~1 tick) but the visual effect lasts seconds.
|
||||
for (const [id, entity] of prevMap) {
|
||||
if (nextMap.has(id)) continue;
|
||||
if (
|
||||
entity.type === "Explosion" &&
|
||||
entity.dataBlock &&
|
||||
entity.spawnTime != null
|
||||
) {
|
||||
const age = snapshot.timeSec - entity.spawnTime;
|
||||
if (age < 5) {
|
||||
nextMap.set(id, entity);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Entity removed (or retention expired).
|
||||
shouldRebuild = true;
|
||||
}
|
||||
|
||||
// Detect new entities added.
|
||||
if (nextMap.size !== prevMap.size) shouldRebuild = true;
|
||||
|
||||
entityMapRef.current = nextMap;
|
||||
if (shouldRebuild) {
|
||||
setEntities(Array.from(nextMap.values()));
|
||||
|
|
@ -209,7 +261,10 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
playbackClockRef.current = requestedTimeSec;
|
||||
}
|
||||
|
||||
// Advance the shared effect clock so all effect timers (particles,
|
||||
// explosions, shockwaves, shape animations) respect pause and rate.
|
||||
if (isPlaying) {
|
||||
advanceEffectClock(delta, playback.rate);
|
||||
playbackClockRef.current += delta * playback.rate;
|
||||
}
|
||||
|
||||
|
|
@ -269,7 +324,12 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
renderCurrent.camera?.orbitTargetId !==
|
||||
publishedSnapshot.camera?.orbitTargetId ||
|
||||
renderCurrent.chatMessages.length !== publishedSnapshot.chatMessages.length ||
|
||||
renderCurrent.teamScores !== publishedSnapshot.teamScores;
|
||||
renderCurrent.teamScores.length !== publishedSnapshot.teamScores.length ||
|
||||
renderCurrent.teamScores.some(
|
||||
(ts, i) =>
|
||||
ts.score !== publishedSnapshot.teamScores[i]?.score ||
|
||||
ts.playerCount !== publishedSnapshot.teamScores[i]?.playerCount,
|
||||
);
|
||||
|
||||
if (shouldPublish) {
|
||||
publishedSnapshotRef.current = renderCurrent;
|
||||
|
|
@ -335,10 +395,23 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
|
||||
const currentEntities = getEntityMap(renderCurrent);
|
||||
const previousEntities = getEntityMap(renderPrev);
|
||||
const renderEntities = entityMapRef.current;
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
for (const child of root.children) {
|
||||
const entity = currentEntities.get(child.name);
|
||||
let entity = currentEntities.get(child.name);
|
||||
// Retained entities (e.g. explosion shapes kept alive past their
|
||||
// snapshot lifetime) won't be in the snapshot entity map. Fall back
|
||||
// to their last-known keyframe position from the render entity.
|
||||
if (!entity) {
|
||||
const renderEntity = renderEntities.get(child.name);
|
||||
if (renderEntity?.keyframes[0]?.position) {
|
||||
const kf = renderEntity.keyframes[0];
|
||||
child.visible = true;
|
||||
child.position.set(kf.position[1], kf.position[2], kf.position[0]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!entity?.position) {
|
||||
child.visible = false;
|
||||
continue;
|
||||
|
|
@ -448,7 +521,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
|
|||
<TickProvider>
|
||||
<group ref={rootRef}>
|
||||
{entities.map((entity) => (
|
||||
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} />
|
||||
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} playback={recording.streamingPlayback} />
|
||||
))}
|
||||
</group>
|
||||
<DemoParticleEffects
|
||||
|
|
|
|||
|
|
@ -33,12 +33,27 @@ import {
|
|||
resolveAudioProfile,
|
||||
playOneShotSound,
|
||||
getCachedAudioBuffer,
|
||||
trackDemoSound,
|
||||
untrackDemoSound,
|
||||
} from "./AudioEmitter";
|
||||
import { audioToUrl } from "../loaders";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { useEngineStoreApi, useEngineSelector } from "../state";
|
||||
import type { DemoEntity } from "../demo/types";
|
||||
|
||||
/**
|
||||
* Map weapon shape to the arm blend animation (armThread).
|
||||
* Only missile launcher and sniper rifle have custom arm poses; all others
|
||||
* use the default `lookde`.
|
||||
*/
|
||||
function getArmThread(weaponShape: string | undefined): string {
|
||||
if (!weaponShape) return "lookde";
|
||||
const lower = weaponShape.toLowerCase();
|
||||
if (lower.includes("missile")) return "lookms";
|
||||
if (lower.includes("sniper")) return "looksn";
|
||||
return "lookde";
|
||||
}
|
||||
|
||||
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
|
||||
function stopLoopingSound(
|
||||
soundRef: React.MutableRefObject<PositionalAudio | null>,
|
||||
|
|
@ -47,6 +62,7 @@ function stopLoopingSound(
|
|||
) {
|
||||
const sound = soundRef.current;
|
||||
if (!sound) return;
|
||||
untrackDemoSound(sound);
|
||||
try { sound.stop(); } catch {}
|
||||
sound.disconnect();
|
||||
parent?.remove(sound);
|
||||
|
|
@ -105,10 +121,12 @@ export function DemoPlayerModel({
|
|||
// 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 });
|
||||
}>({ head: null, headside: null });
|
||||
// Arm pose blend actions keyed by animation name (lookde, lookms, looksn).
|
||||
const armActionsRef = useRef(new Map<string, AnimationAction>());
|
||||
const activeArmRef = useRef<string | null>(null);
|
||||
const currentAnimRef = useRef({ name: "root", timeScale: 1 });
|
||||
const isDeadRef = useRef(false);
|
||||
|
||||
|
|
@ -126,20 +144,18 @@ export function DemoPlayerModel({
|
|||
// 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) {
|
||||
|
||||
// Head blend actions.
|
||||
const blendRefs: typeof blendActionsRef.current = { head: null, headside: null };
|
||||
for (const { key, names } of [
|
||||
{ key: "head" as const, names: ["head"] },
|
||||
{ key: "headside" as const, names: ["headside"] },
|
||||
]) {
|
||||
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);
|
||||
|
|
@ -152,13 +168,53 @@ export function DemoPlayerModel({
|
|||
}
|
||||
blendActionsRef.current = blendRefs;
|
||||
|
||||
// Arm pose blend actions: create one per available arm animation so we
|
||||
// can switch between them when the equipped weapon changes.
|
||||
// All arm clips use the lookde midpoint as the additive reference, so
|
||||
// switching from lookde to lookms captures the shoulder repositioning.
|
||||
const armActions = new Map<string, AnimationAction>();
|
||||
const lookdeClip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === "lookde",
|
||||
);
|
||||
const fps = 30;
|
||||
const lookdeRefFrame = lookdeClip
|
||||
? Math.round((lookdeClip.duration * fps) / 2)
|
||||
: 0;
|
||||
for (const armName of ["lookde", "lookms", "looksn"]) {
|
||||
const clip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === armName,
|
||||
);
|
||||
if (!clip) continue;
|
||||
const cloned = clip.clone();
|
||||
// Use lookde's midpoint as reference for all arm clips so that
|
||||
// lookms/looksn capture the absolute shoulder offset.
|
||||
const refClip = lookdeClip ?? clip;
|
||||
AnimationUtils.makeClipAdditive(cloned, lookdeRefFrame, refClip, fps);
|
||||
const action = mixer.clipAction(cloned);
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
action.timeScale = 0;
|
||||
action.weight = 0;
|
||||
action.play();
|
||||
armActions.set(armName, action);
|
||||
}
|
||||
armActionsRef.current = armActions;
|
||||
|
||||
// Start with default arm pose.
|
||||
const defaultArm = armActions.get("lookde");
|
||||
if (defaultArm) {
|
||||
defaultArm.weight = 1;
|
||||
activeArmRef.current = "lookde";
|
||||
}
|
||||
|
||||
// Force initial pose evaluation.
|
||||
mixer.update(0);
|
||||
|
||||
return () => {
|
||||
mixer.stopAllAction();
|
||||
animActionsRef.current = new Map();
|
||||
blendActionsRef.current = { look: null, head: null, headside: null };
|
||||
blendActionsRef.current = { head: null, headside: null };
|
||||
armActionsRef.current = new Map();
|
||||
activeArmRef.current = null;
|
||||
};
|
||||
}, [mixer, gltf.animations, shapeAliases]);
|
||||
|
||||
|
|
@ -252,8 +308,26 @@ export function DemoPlayerModel({
|
|||
}
|
||||
}
|
||||
|
||||
// Switch arm blend animation based on equipped weapon.
|
||||
const desiredArm = getArmThread(entity.weaponShape);
|
||||
if (desiredArm !== activeArmRef.current) {
|
||||
const armActions = armActionsRef.current;
|
||||
const prev = activeArmRef.current
|
||||
? armActions.get(activeArmRef.current)
|
||||
: null;
|
||||
const next = armActions.get(desiredArm);
|
||||
if (next) {
|
||||
if (prev) prev.weight = 0;
|
||||
next.weight = isDead ? 0 : 1;
|
||||
activeArmRef.current = desiredArm;
|
||||
}
|
||||
}
|
||||
|
||||
// Drive additive blend animations for aim/head articulation.
|
||||
const { look, head, headside } = blendActionsRef.current;
|
||||
const { head, headside } = blendActionsRef.current;
|
||||
const armAction = activeArmRef.current
|
||||
? armActionsRef.current.get(activeArmRef.current)
|
||||
: null;
|
||||
const blendWeight = isDead ? 0 : 1;
|
||||
|
||||
const headPitch = entity.headPitch ?? 0;
|
||||
|
|
@ -261,9 +335,9 @@ export function DemoPlayerModel({
|
|||
const pitchPos = (headPitch + 1) / 2;
|
||||
const yawPos = (headYaw + 1) / 2;
|
||||
|
||||
if (look) {
|
||||
look.time = pitchPos * look.getClip().duration;
|
||||
look.weight = blendWeight;
|
||||
if (armAction) {
|
||||
armAction.time = pitchPos * armAction.getClip().duration;
|
||||
armAction.weight = blendWeight;
|
||||
}
|
||||
if (head) {
|
||||
head.time = pitchPos * head.getClip().duration;
|
||||
|
|
@ -545,8 +619,10 @@ function AnimatedWeaponModel({
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(playback.rate);
|
||||
sound.setLoop(true);
|
||||
weaponClone.add(sound);
|
||||
trackDemoSound(sound);
|
||||
sound.play();
|
||||
loopingSoundRef.current = sound;
|
||||
loopingSoundStateRef.current = currentIdx;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
import { useMemo } from "react";
|
||||
import { Quaternion, Vector3 } from "three";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AnimationMixer,
|
||||
LoopOnce,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import type { Group, Material } from "three";
|
||||
import { demoEffectNow, engineStore } from "../state";
|
||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import {
|
||||
_r90,
|
||||
_r90inv,
|
||||
getPosedNodeTransform,
|
||||
processShapeScene,
|
||||
} from "../demo/demoPlaybackUtils";
|
||||
import {
|
||||
loadIflAtlas,
|
||||
getFrameIndexForTime,
|
||||
updateAtlasFrame,
|
||||
} from "./useIflTexture";
|
||||
import type { IflAtlas } from "./useIflTexture";
|
||||
import {
|
||||
ShapeRenderer,
|
||||
useStaticShape,
|
||||
} from "./GenericShape";
|
||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import type { DemoThreadState } from "../demo/types";
|
||||
import type { DemoThreadState, DemoStreamEntity } from "../demo/types";
|
||||
import type { DemoStreamingPlayback } from "../demo/types";
|
||||
|
||||
/** Renders a shape model for a demo entity using the existing shape pipeline. */
|
||||
export function DemoShapeModel({
|
||||
|
|
@ -43,13 +60,27 @@ export function DemoShapeModel({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map weapon shape to the arm blend animation (armThread).
|
||||
* Only missile launcher and sniper rifle have custom arm poses; all others
|
||||
* use the default `lookde`.
|
||||
*/
|
||||
function getArmThread(weaponShape: string | undefined): string {
|
||||
if (!weaponShape) return "lookde";
|
||||
const lower = weaponShape.toLowerCase();
|
||||
if (lower.includes("missile")) return "lookms";
|
||||
if (lower.includes("sniper")) return "looksn";
|
||||
return "lookde";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mounted weapon using the Torque engine's mount system.
|
||||
*
|
||||
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
|
||||
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
|
||||
* animation) pose. The mount transform is conjugated by ShapeRenderer's 90° Y
|
||||
* rotation: T_mount = R90 * M0 * MP^(-1) * R90^(-1).
|
||||
* animation) pose, with the weapon-specific arm animation applied additively.
|
||||
* The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
|
||||
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
|
||||
*/
|
||||
export function DemoWeaponModel({
|
||||
shapeName,
|
||||
|
|
@ -62,11 +93,13 @@ export function DemoWeaponModel({
|
|||
const weaponGltf = useStaticShape(shapeName);
|
||||
|
||||
const mountTransform = useMemo(() => {
|
||||
// Get Mount0 from the player's posed (Root animation) skeleton.
|
||||
// Get Mount0 from the player's posed skeleton with arm animation applied.
|
||||
const armThread = getArmThread(shapeName);
|
||||
const m0 = getPosedNodeTransform(
|
||||
playerGltf.scene,
|
||||
playerGltf.animations,
|
||||
"Mount0",
|
||||
[armThread],
|
||||
);
|
||||
if (!m0) return { position: undefined, quaternion: undefined };
|
||||
|
||||
|
|
@ -126,3 +159,322 @@ export function DemoWeaponModel({
|
|||
</ShapeInfoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explosion shape rendering ──
|
||||
//
|
||||
// Explosion DTS shapes are flat billboard planes with IFL-animated textures,
|
||||
// vis-keyframed opacity, and size keyframe interpolation. They use
|
||||
// useStaticShape (shared GLTF cache via drei's useGLTF) but render directly
|
||||
// rather than through ShapeRenderer, because:
|
||||
// - faceViewer billboarding needs to control the shape orientation
|
||||
// - ShapeModel's fixed 90° Y rotation conflicts with billboard orientation
|
||||
// - Explosion shapes need LoopOnce animation, not the deploy/ambient lifecycle
|
||||
|
||||
interface VisNode {
|
||||
mesh: any;
|
||||
keyframes: number[];
|
||||
duration: number;
|
||||
cyclic: boolean;
|
||||
}
|
||||
|
||||
interface IflInfo {
|
||||
mesh: any;
|
||||
iflPath: string;
|
||||
sequenceName?: string;
|
||||
duration?: number;
|
||||
cyclic?: boolean;
|
||||
toolBegin?: number;
|
||||
}
|
||||
|
||||
function extractSizeKeyframes(expBlock: Record<string, unknown>): {
|
||||
times: number[];
|
||||
sizes: [number, number, number][];
|
||||
} {
|
||||
const rawSizes = expBlock.sizes as
|
||||
| Array<{ x: number; y: number; z: number }>
|
||||
| undefined;
|
||||
const rawTimes = expBlock.times as number[] | undefined;
|
||||
|
||||
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
|
||||
return { times: [0, 1], sizes: [[1, 1, 1], [1, 1, 1]] };
|
||||
}
|
||||
|
||||
// sizes are packed as value*100 integers on the wire; divide by 100.
|
||||
const sizes: [number, number, number][] = rawSizes.map((s) => [
|
||||
s.x / 100,
|
||||
s.y / 100,
|
||||
s.z / 100,
|
||||
]);
|
||||
// times are written via writeFloat(8) and are already [0,1] floats.
|
||||
const times = Array.isArray(rawTimes)
|
||||
? rawTimes
|
||||
: sizes.map((_, i) => i / Math.max(sizes.length - 1, 1));
|
||||
|
||||
return { times, sizes };
|
||||
}
|
||||
|
||||
function interpolateSize(
|
||||
keyframes: { times: number[]; sizes: [number, number, number][] },
|
||||
t: number,
|
||||
): [number, number, number] {
|
||||
const { times, sizes } = keyframes;
|
||||
if (times.length === 0) return [1, 1, 1];
|
||||
if (t <= times[0]) return sizes[0];
|
||||
if (t >= times[times.length - 1]) return sizes[sizes.length - 1];
|
||||
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
if (t >= times[i] && t <= times[i + 1]) {
|
||||
const frac = (t - times[i]) / (times[i + 1] - times[i]);
|
||||
return [
|
||||
sizes[i][0] + (sizes[i + 1][0] - sizes[i][0]) * frac,
|
||||
sizes[i][1] + (sizes[i + 1][1] - sizes[i][1]) * frac,
|
||||
sizes[i][2] + (sizes[i + 1][2] - sizes[i][2]) * frac,
|
||||
];
|
||||
}
|
||||
}
|
||||
return sizes[sizes.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
|
||||
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
||||
*/
|
||||
export function DemoExplosionShape({
|
||||
entity,
|
||||
playback,
|
||||
}: {
|
||||
entity: DemoStreamEntity;
|
||||
playback: DemoStreamingPlayback;
|
||||
}) {
|
||||
const gltf = useStaticShape(entity.dataBlock!);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const startTimeRef = useRef(demoEffectNow());
|
||||
const randAngleRef = useRef(Math.random() * Math.PI * 2);
|
||||
const iflAtlasesRef = useRef<Array<{ atlas: IflAtlas; info: IflInfo }>>([]);
|
||||
|
||||
const expBlock = useMemo(() => {
|
||||
if (!entity.explosionDataBlockId) return undefined;
|
||||
return playback.getDataBlockData(entity.explosionDataBlockId);
|
||||
}, [entity.explosionDataBlockId, playback]);
|
||||
|
||||
const sizeKeyframes = useMemo(
|
||||
() => (expBlock ? extractSizeKeyframes(expBlock) : undefined),
|
||||
[expBlock],
|
||||
);
|
||||
|
||||
const baseScale = useMemo<[number, number, number]>(() => {
|
||||
const explosionScale = expBlock?.explosionScale as
|
||||
| { x: number; y: number; z: number }
|
||||
| undefined;
|
||||
return explosionScale
|
||||
? [explosionScale.x / 100, explosionScale.y / 100, explosionScale.z / 100]
|
||||
: [1, 1, 1];
|
||||
}, [expBlock]);
|
||||
|
||||
// lifetimeMS is packed as value >> 5 (ticks); recover with << 5 (× 32).
|
||||
const lifetimeTicks = (expBlock?.lifetimeMS as number) ?? 31;
|
||||
const lifetimeMS = lifetimeTicks * 32;
|
||||
const faceViewer = entity.faceViewer !== false;
|
||||
|
||||
// Clone scene, process materials, collect vis nodes and IFL info.
|
||||
const { scene, mixer, visNodes, iflInfos, materials } = useMemo(() => {
|
||||
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
||||
|
||||
// Collect IFL info BEFORE processShapeScene replaces materials.
|
||||
const iflInfos: IflInfo[] = [];
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh || !node.material) return;
|
||||
const mat = Array.isArray(node.material) ? node.material[0] : node.material;
|
||||
if (!mat?.userData) return;
|
||||
const flags = new Set<string>(mat.userData.flag_names ?? []);
|
||||
const rp: string | undefined = mat.userData.resource_path;
|
||||
if (flags.has("IflMaterial") && rp) {
|
||||
const ud = node.userData;
|
||||
iflInfos.push({
|
||||
mesh: node,
|
||||
iflPath: `textures/${rp}.ifl`,
|
||||
sequenceName: ud?.ifl_sequence
|
||||
? String(ud.ifl_sequence).toLowerCase()
|
||||
: undefined,
|
||||
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
|
||||
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
|
||||
toolBegin: ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
processShapeScene(scene);
|
||||
|
||||
// Collect vis-animated nodes keyed by sequence name.
|
||||
const visNodes: VisNode[] = [];
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh) return;
|
||||
const ud = node.userData;
|
||||
if (!ud) return;
|
||||
const kf = ud.vis_keyframes;
|
||||
const dur = ud.vis_duration;
|
||||
const seqName = (ud.vis_sequence ?? "").toLowerCase();
|
||||
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
|
||||
return;
|
||||
// Only include vis nodes tied to the "ambient" sequence.
|
||||
if (seqName === "ambient") {
|
||||
visNodes.push({ mesh: node, keyframes: kf, duration: dur, cyclic: !!ud.vis_cyclic });
|
||||
}
|
||||
});
|
||||
|
||||
// Activate vis nodes: make visible, ensure transparent material.
|
||||
for (const v of visNodes) {
|
||||
v.mesh.visible = true;
|
||||
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
||||
v.mesh.material.transparent = true;
|
||||
v.mesh.material.depthWrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also un-hide IFL meshes that don't have vis_sequence (always visible).
|
||||
for (const info of iflInfos) {
|
||||
if (!info.mesh.userData?.vis_sequence) {
|
||||
info.mesh.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up animation mixer with the ambient clip (LoopOnce).
|
||||
const clips = new Map<string, any>();
|
||||
for (const clip of gltf.animations) {
|
||||
clips.set(clip.name.toLowerCase(), clip);
|
||||
}
|
||||
const ambientClip = clips.get("ambient");
|
||||
let mixer: AnimationMixer | null = null;
|
||||
if (ambientClip) {
|
||||
mixer = new AnimationMixer(scene);
|
||||
const action = mixer.clipAction(ambientClip);
|
||||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
// playSpeed is packed as value*20 on the wire; divide by 20.
|
||||
const playSpeed = ((expBlock?.playSpeed as number) ?? 20) / 20;
|
||||
action.timeScale = playSpeed;
|
||||
action.play();
|
||||
}
|
||||
|
||||
// Collect all materials for fade-out.
|
||||
const materials: Material[] = [];
|
||||
scene.traverse((child: any) => {
|
||||
if (!child.isMesh) return;
|
||||
if (Array.isArray(child.material)) {
|
||||
materials.push(...child.material);
|
||||
} else if (child.material) {
|
||||
materials.push(child.material);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable frustum culling (explosion may scale beyond bounds).
|
||||
scene.traverse((child) => { child.frustumCulled = false; });
|
||||
|
||||
return { scene, mixer, visNodes, iflInfos, materials };
|
||||
}, [gltf, expBlock]);
|
||||
|
||||
// Load IFL texture atlases.
|
||||
useEffect(() => {
|
||||
iflAtlasesRef.current = [];
|
||||
for (const info of iflInfos) {
|
||||
loadIflAtlas(info.iflPath)
|
||||
.then((atlas) => {
|
||||
const mat = Array.isArray(info.mesh.material)
|
||||
? info.mesh.material[0]
|
||||
: info.mesh.material;
|
||||
if (mat) {
|
||||
mat.map = atlas.texture;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
iflAtlasesRef.current.push({ atlas, info });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [iflInfos]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const playbackState = engineStore.getState().playback;
|
||||
const effectDelta = playbackState.status === "playing"
|
||||
? delta * playbackState.rate : 0;
|
||||
|
||||
const elapsed = demoEffectNow() - startTimeRef.current;
|
||||
const t = Math.min(elapsed / lifetimeMS, 1);
|
||||
const elapsedSec = elapsed / 1000;
|
||||
|
||||
// Advance skeleton animation.
|
||||
if (mixer) {
|
||||
mixer.update(effectDelta);
|
||||
}
|
||||
|
||||
// Fade multiplier for the last 20% of lifetime.
|
||||
const fadeAlpha = t > 0.8 ? 1 - (t - 0.8) / 0.2 : 1;
|
||||
|
||||
// Drive vis opacity animation.
|
||||
for (const { mesh, keyframes, duration, cyclic } of visNodes) {
|
||||
const mat = mesh.material;
|
||||
if (!mat || Array.isArray(mat)) continue;
|
||||
const rawT = elapsedSec / duration;
|
||||
const vt = cyclic ? rawT % 1 : Math.min(rawT, 1);
|
||||
const n = keyframes.length;
|
||||
const pos = vt * n;
|
||||
const lo = Math.floor(pos) % n;
|
||||
const hi = (lo + 1) % n;
|
||||
const frac = pos - Math.floor(pos);
|
||||
const visOpacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
||||
mat.opacity = visOpacity * fadeAlpha;
|
||||
}
|
||||
|
||||
// Also fade non-vis materials.
|
||||
if (fadeAlpha < 1) {
|
||||
for (const mat of materials) {
|
||||
if ("opacity" in mat) {
|
||||
mat.transparent = true;
|
||||
(mat as any).opacity *= fadeAlpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance IFL texture atlases.
|
||||
for (const { atlas, info } of iflAtlasesRef.current) {
|
||||
let iflTime: number;
|
||||
if (info.sequenceName && info.duration) {
|
||||
const pos = info.cyclic
|
||||
? (elapsedSec / info.duration) % 1
|
||||
: Math.min(elapsedSec / info.duration, 1);
|
||||
iflTime = pos * info.duration + (info.toolBegin ?? 0);
|
||||
} else {
|
||||
iflTime = elapsedSec;
|
||||
}
|
||||
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, iflTime));
|
||||
}
|
||||
|
||||
// Size keyframe interpolation.
|
||||
if (sizeKeyframes) {
|
||||
const size = interpolateSize(sizeKeyframes, t);
|
||||
group.scale.set(
|
||||
size[0] * baseScale[0],
|
||||
size[1] * baseScale[1],
|
||||
size[2] * baseScale[2],
|
||||
);
|
||||
}
|
||||
|
||||
// faceViewer: billboard toward camera with random Z rotation.
|
||||
if (faceViewer) {
|
||||
group.lookAt(state.camera.position);
|
||||
group.rotateZ(randAngleRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{/* Flip 180° around Y so the face (GLB +Z normal) points toward the
|
||||
camera after the parent group's lookAt (which aims -Z at camera). */}
|
||||
<group rotation={[0, Math.PI, 0]}>
|
||||
<primitive object={scene} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
32
src/components/FlagMarker.module.css
Normal file
32
src/components/FlagMarker.module.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.Root {
|
||||
pointer-events: none;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
gap: 1px;
|
||||
}
|
||||
.Distance {
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.9),
|
||||
0 0 1px rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
image-rendering: pixelated;
|
||||
opacity: 0.5;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.8));
|
||||
mask-image: var(--flag-icon-url);
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-image: var(--flag-icon-url);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
}
|
||||
67
src/components/FlagMarker.tsx
Normal file
67
src/components/FlagMarker.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { Html } from "@react-three/drei";
|
||||
import { Group, Vector3 } from "three";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type { DemoEntity } from "../demo/types";
|
||||
import styles from "./FlagMarker.module.css";
|
||||
|
||||
const FLAG_ICON_HEIGHT = 1.5;
|
||||
|
||||
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
|
||||
|
||||
const _tmpVec = new Vector3();
|
||||
|
||||
/**
|
||||
* Floating flag icon above a flag entity, tinted by IFF color (green for
|
||||
* friendly, red for enemy — matching Tribes 2's sensor group color system).
|
||||
* Always visible regardless of distance.
|
||||
*/
|
||||
export function FlagMarker({
|
||||
entity,
|
||||
}: {
|
||||
entity: DemoEntity;
|
||||
timeRef: MutableRefObject<number>;
|
||||
}) {
|
||||
const markerRef = useRef<Group>(null);
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const distRef = useRef<HTMLSpanElement>(null);
|
||||
const { camera } = useThree();
|
||||
|
||||
useFrame(() => {
|
||||
// Tint imperatively — iffColor is mutated in-place by streaming playback.
|
||||
if (iconRef.current && entity.iffColor) {
|
||||
const { r, g, b } = entity.iffColor;
|
||||
iconRef.current.style.backgroundColor = `rgb(${r},${g},${b})`;
|
||||
}
|
||||
// Update distance label.
|
||||
if (distRef.current && markerRef.current) {
|
||||
markerRef.current.getWorldPosition(_tmpVec);
|
||||
const distance = camera.position.distanceTo(_tmpVec);
|
||||
distRef.current.textContent = distance.toFixed(1);
|
||||
}
|
||||
});
|
||||
|
||||
const initialColor = entity.iffColor
|
||||
? `rgb(${entity.iffColor.r},${entity.iffColor.g},${entity.iffColor.b})`
|
||||
: "rgb(200,200,200)";
|
||||
|
||||
return (
|
||||
<group ref={markerRef}>
|
||||
<Html position={[0, FLAG_ICON_HEIGHT, 0]} center>
|
||||
<div className={styles.Root}>
|
||||
<span ref={distRef} className={styles.Distance} />
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={styles.Icon}
|
||||
style={{
|
||||
backgroundColor: initialColor,
|
||||
"--flag-icon-url": `url(${FLAG_ICON_URL})`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
|||
import { setupTexture } from "../textureUtils";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
||||
import { useEngineSelector } from "../state";
|
||||
import { useEngineSelector, demoEffectNow, engineStore } from "../state";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import {
|
||||
useIflTexture,
|
||||
|
|
@ -39,6 +39,14 @@ import {
|
|||
} from "../demo/demoPlaybackUtils";
|
||||
import type { DemoThreadState } from "../demo/types";
|
||||
|
||||
/** Returns pausable time in seconds for demo mode, real time otherwise. */
|
||||
function shapeNowSec(): number {
|
||||
const status = engineStore.getState().playback.status;
|
||||
return status !== "stopped"
|
||||
? demoEffectNow() / 1000
|
||||
: performance.now() / 1000;
|
||||
}
|
||||
|
||||
/** Shared props for texture rendering components */
|
||||
interface TextureProps {
|
||||
material: MeshStandardMaterial;
|
||||
|
|
@ -687,7 +695,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
const vNodes = visNodesBySequence.get(seqLower);
|
||||
const thread: ThreadState = {
|
||||
sequence: seqLower,
|
||||
startTime: performance.now() / 1000,
|
||||
startTime: shapeNowSec(),
|
||||
};
|
||||
|
||||
if (clip && mixer) {
|
||||
|
|
@ -773,7 +781,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
for (const seqName of autoPlaySequences) {
|
||||
const vNodes = visNodesBySequence.get(seqName);
|
||||
if (vNodes) {
|
||||
const startTime = performance.now() / 1000;
|
||||
const startTime = shapeNowSec();
|
||||
for (const v of vNodes) prepareVisNode(v);
|
||||
const slot = seqName === "power" ? 0 : 1;
|
||||
threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime });
|
||||
|
|
@ -791,7 +799,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
threads.set(slot, {
|
||||
sequence: seqName,
|
||||
action,
|
||||
startTime: performance.now() / 1000,
|
||||
startTime: shapeNowSec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -893,6 +901,13 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
useFrame((_, delta) => {
|
||||
const threads = threadsRef.current;
|
||||
|
||||
// In demo mode, scale animation by playback rate; freeze when paused.
|
||||
const inDemo = demoThreadsRef.current != null;
|
||||
const playbackState = engineStore.getState().playback;
|
||||
const effectDelta = !inDemo ? delta
|
||||
: playbackState.status === "playing" ? delta * playbackState.rate
|
||||
: 0;
|
||||
|
||||
// React to demo thread state changes. The ghost ThreadMask data tells us
|
||||
// exactly which DTS sequences are playing/stopped on each of 4 thread slots.
|
||||
const currentDemoThreads = demoThreadsRef.current;
|
||||
|
|
@ -976,7 +991,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
|
||||
if (animationEnabled) {
|
||||
mixer.update(delta);
|
||||
mixer.update(effectDelta);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -993,7 +1008,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
continue;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() / 1000 - thread.startTime;
|
||||
const elapsed = shapeNowSec() - thread.startTime;
|
||||
const t = cyclic
|
||||
? (elapsed % duration) / duration
|
||||
: Math.min(elapsed / duration, 1);
|
||||
|
|
@ -1016,7 +1031,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
// with the desired frames (e.g. skipping a long "off" period).
|
||||
const iflAnimInfos = iflAnimInfosRef.current;
|
||||
if (iflAnimInfos.length > 0) {
|
||||
iflTimeRef.current += delta;
|
||||
iflTimeRef.current += effectDelta;
|
||||
for (const info of iflAnimInfos) {
|
||||
if (!animationEnabled) {
|
||||
updateAtlasFrame(info.atlas, 0);
|
||||
|
|
@ -1029,7 +1044,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
let iflTime = 0;
|
||||
for (const [, thread] of threads) {
|
||||
if (thread.sequence === info.sequenceName) {
|
||||
const elapsed = performance.now() / 1000 - thread.startTime;
|
||||
const elapsed = shapeNowSec() - thread.startTime;
|
||||
const dur = info.sequenceDuration;
|
||||
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
|
||||
const pos = info.cyclic
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import {
|
|||
type TouchMode,
|
||||
} from "./SettingsProvider";
|
||||
import { MissionSelect } from "./MissionSelect";
|
||||
import { RefObject, useEffect, useState, useRef } from "react";
|
||||
import { Camera } from "three";
|
||||
import { useEffect, useState, useRef, RefObject } from "react";
|
||||
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
||||
import { LoadDemoButton } from "./LoadDemoButton";
|
||||
import { useDemoRecording } from "./DemoProvider";
|
||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
||||
import { Camera } from "three";
|
||||
import styles from "./InspectorControls.module.css";
|
||||
|
||||
export function InspectorControls({
|
||||
|
|
@ -18,8 +18,8 @@ export function InspectorControls({
|
|||
missionType,
|
||||
onChangeMission,
|
||||
onOpenMapInfo,
|
||||
cameraRef,
|
||||
isTouch,
|
||||
cameraRef,
|
||||
}: {
|
||||
missionName: string;
|
||||
missionType: string;
|
||||
|
|
@ -31,8 +31,8 @@ export function InspectorControls({
|
|||
missionType: string;
|
||||
}) => void;
|
||||
onOpenMapInfo: () => void;
|
||||
cameraRef: RefObject<Camera | null>;
|
||||
isTouch: boolean | null;
|
||||
cameraRef: RefObject<Camera>;
|
||||
}) {
|
||||
const {
|
||||
fogEnabled,
|
||||
|
|
@ -117,9 +117,9 @@ export function InspectorControls({
|
|||
>
|
||||
<div className={styles.Group}>
|
||||
<CopyCoordinatesButton
|
||||
cameraRef={cameraRef}
|
||||
missionName={missionName}
|
||||
missionType={missionType}
|
||||
cameraRef={cameraRef}
|
||||
/>
|
||||
<LoadDemoButton />
|
||||
<button
|
||||
|
|
@ -181,35 +181,39 @@ export function InspectorControls({
|
|||
</div>
|
||||
</div>
|
||||
<div className={styles.Group}>
|
||||
<div className={styles.Field}>
|
||||
<label htmlFor="fovInput">FOV</label>
|
||||
<input
|
||||
id="fovInput"
|
||||
type="range"
|
||||
min={75}
|
||||
max={120}
|
||||
step={5}
|
||||
value={fov}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) => setFov(parseInt(event.target.value))}
|
||||
/>
|
||||
<output htmlFor="fovInput">{fov}</output>
|
||||
</div>
|
||||
<div className={styles.Field}>
|
||||
<label htmlFor="speedInput">Speed</label>
|
||||
<input
|
||||
id="speedInput"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.05}
|
||||
value={speedMultiplier}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) =>
|
||||
setSpeedMultiplier(parseFloat(event.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isDemoLoaded ? null : (
|
||||
<div className={styles.Field}>
|
||||
<label htmlFor="fovInput">FOV</label>
|
||||
<input
|
||||
id="fovInput"
|
||||
type="range"
|
||||
min={75}
|
||||
max={120}
|
||||
step={5}
|
||||
value={fov}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) => setFov(parseInt(event.target.value))}
|
||||
/>
|
||||
<output htmlFor="fovInput">{fov}</output>
|
||||
</div>
|
||||
)}
|
||||
{isDemoLoaded ? null : (
|
||||
<div className={styles.Field}>
|
||||
<label htmlFor="speedInput">Speed</label>
|
||||
<input
|
||||
id="speedInput"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.05}
|
||||
value={speedMultiplier}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) =>
|
||||
setSpeedMultiplier(parseFloat(event.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isTouch && (
|
||||
<div className={styles.Group}>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@ import type {
|
|||
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).
|
||||
|
|
@ -34,9 +31,7 @@ function Compass({ yaw }: { yaw: number | undefined }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health / Energy bars ──
|
||||
|
||||
function HealthBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
|
|
@ -45,7 +40,6 @@ function HealthBar({ value }: { value: number }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnergyBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
|
|
@ -54,20 +48,16 @@ function EnergyBar({ value }: { value: number }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reticle ──
|
||||
|
||||
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 normalizeWeaponName(shape: string | undefined): string {
|
||||
if (!shape) return "";
|
||||
return shape.replace(/\.dts$/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
function Reticle() {
|
||||
const weaponShape = useEngineSelector((state) => {
|
||||
const snap = state.playback.streamSnapshot;
|
||||
|
|
@ -98,9 +88,7 @@ function Reticle() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }> = {
|
||||
|
|
@ -124,7 +112,6 @@ const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
|
|||
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]) => [
|
||||
|
|
@ -132,12 +119,9 @@ const WEAPON_HUD_ICON_URLS = new Map(
|
|||
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,
|
||||
|
|
@ -171,7 +155,6 @@ function WeaponSlotIcon({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeaponHUD() {
|
||||
const weaponsHud = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.weaponsHud,
|
||||
|
|
@ -206,9 +189,7 @@ function WeaponHUD() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Team Scores (bottom-left) ──
|
||||
|
||||
/** Default team names from serverDefaults.cs. */
|
||||
const DEFAULT_TEAM_NAMES: Record<number, string> = {
|
||||
1: "Storm",
|
||||
|
|
@ -218,7 +199,6 @@ const DEFAULT_TEAM_NAMES: Record<number, string> = {
|
|||
5: "Blood Eagle",
|
||||
6: "Phoenix",
|
||||
};
|
||||
|
||||
function TeamScores() {
|
||||
const teamScores = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.teamScores,
|
||||
|
|
@ -227,7 +207,6 @@ function TeamScores() {
|
|||
(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) {
|
||||
|
|
@ -236,7 +215,6 @@ function TeamScores() {
|
|||
}
|
||||
return a.teamId - b.teamId;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.TeamScores}>
|
||||
{sorted.map((team: TeamScore) => {
|
||||
|
|
@ -262,9 +240,7 @@ function TeamScores() {
|
|||
</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,
|
||||
|
|
@ -278,11 +254,9 @@ const CHAT_COLOR_CLASSES: Record<number, string> = {
|
|||
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];
|
||||
|
|
@ -292,7 +266,6 @@ function chatColorClass(msg: DemoChatMessage): string {
|
|||
// 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,
|
||||
|
|
@ -340,9 +313,7 @@ function ChatWindow() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Backpack + Inventory HUD (bottom-right) ──
|
||||
|
||||
/** Maps $BackpackHudData indices to icon textures. */
|
||||
const BACKPACK_ICONS: Record<number, string> = {
|
||||
0: "gui/hud_new_packammo",
|
||||
|
|
@ -366,7 +337,6 @@ const BACKPACK_ICONS: Record<number, string> = {
|
|||
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",
|
||||
|
|
@ -375,7 +345,6 @@ const BACKPACK_ARMED_ICONS: Record<number, string> = {
|
|||
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]) => [
|
||||
|
|
@ -389,7 +358,6 @@ const BACKPACK_ARMED_ICON_URLS = new Map(
|
|||
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" },
|
||||
|
|
@ -397,14 +365,12 @@ const INVENTORY_SLOT_ICONS: Record<number, { icon: string; label: string }> = {
|
|||
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,
|
||||
|
|
@ -412,9 +378,7 @@ function PackAndInventoryHUD() {
|
|||
const inventoryHud = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.inventoryHud,
|
||||
);
|
||||
|
||||
const hasPack = backpackHud && backpackHud.packIndex >= 0;
|
||||
|
||||
// Resolve pack icon.
|
||||
let packIconUrl: string | undefined;
|
||||
if (hasPack) {
|
||||
|
|
@ -423,7 +387,6 @@ function PackAndInventoryHUD() {
|
|||
: undefined;
|
||||
packIconUrl = armedUrl ?? BACKPACK_ICON_URLS.get(backpackHud.packIndex);
|
||||
}
|
||||
|
||||
// Build count lookup from snapshot data.
|
||||
const countBySlot = new Map<number, number>();
|
||||
if (inventoryHud) {
|
||||
|
|
@ -431,14 +394,11 @@ function PackAndInventoryHUD() {
|
|||
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 && (
|
||||
|
|
@ -473,19 +433,15 @@ function PackAndInventoryHUD() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main HUD ──
|
||||
|
||||
export function PlayerHUD() {
|
||||
const recording = useDemoRecording();
|
||||
const streamSnapshot = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot,
|
||||
);
|
||||
|
||||
if (!recording) return null;
|
||||
const status = streamSnapshot?.status;
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export function getPosedNodeTransform(
|
|||
scene: Group,
|
||||
animations: AnimationClip[],
|
||||
nodeName: string,
|
||||
overrideClipNames?: string[],
|
||||
): { position: Vector3; quaternion: Quaternion } | null {
|
||||
const clone = scene.clone(true);
|
||||
|
||||
|
|
@ -158,6 +159,21 @@ export function getPosedNodeTransform(
|
|||
if (rootClip) {
|
||||
const mixer = new AnimationMixer(clone);
|
||||
mixer.clipAction(rootClip).play();
|
||||
// Play override clips (e.g. arm pose) which replace bone transforms
|
||||
// on the bones they animate, at clip midpoint (neutral pose).
|
||||
if (overrideClipNames) {
|
||||
for (const name of overrideClipNames) {
|
||||
const clip = animations.find(
|
||||
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (clip) {
|
||||
const action = mixer.clipAction(clip);
|
||||
action.time = clip.duration / 2;
|
||||
action.setEffectiveTimeScale(0);
|
||||
action.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
mixer.setTime(0);
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +414,8 @@ export function buildStreamDemoEntity(
|
|||
ghostIndex: number | undefined,
|
||||
dataBlockId: number | undefined,
|
||||
shapeHint: string | undefined,
|
||||
explosionDataBlockId?: number,
|
||||
faceViewer?: boolean,
|
||||
): DemoEntity {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -411,6 +429,8 @@ export function buildStreamDemoEntity(
|
|||
ghostIndex,
|
||||
dataBlockId,
|
||||
shapeHint,
|
||||
explosionDataBlockId,
|
||||
faceViewer,
|
||||
keyframes: [
|
||||
{
|
||||
time: 0,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { Matrix4, Quaternion } from "three";
|
||||
import { getTerrainHeightAt } from "../terrainHeight";
|
||||
import type {
|
||||
BackpackHudState,
|
||||
ChatSegment,
|
||||
DemoChatMessage,
|
||||
DemoThreadState,
|
||||
|
|
@ -96,6 +97,10 @@ interface MutableStreamEntity {
|
|||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. */
|
||||
headYaw?: number;
|
||||
/** Target render flags bitmask from the Target Manager. */
|
||||
targetRenderFlags?: number;
|
||||
/** True when FlagImage is mounted in slot 3 (player is carrying a flag). */
|
||||
carryingFlag?: boolean;
|
||||
/** Item physics simulation state (dropped weapons/items). */
|
||||
itemPhysics?: {
|
||||
velocity: [number, number, number];
|
||||
|
|
@ -153,6 +158,8 @@ interface StreamState {
|
|||
};
|
||||
/** Team scores aggregated from the PLAYERLIST demoValues section. */
|
||||
teamScores: TeamScore[];
|
||||
/** Live player roster keyed by clientId, updated by ServerMessage events. */
|
||||
playerRoster: Map<number, { name: string; teamId: number }>;
|
||||
}
|
||||
|
||||
const TICK_DURATION_MS = 32;
|
||||
|
|
@ -293,7 +300,10 @@ interface ParsedDemoValues {
|
|||
activeSlot: number;
|
||||
} | null;
|
||||
teamScores: TeamScore[];
|
||||
/** Initial player roster from PLAYERLIST section, keyed by clientId. */
|
||||
playerRoster: Map<number, { name: string; teamId: number }>;
|
||||
chatMessages: string[];
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -309,7 +319,9 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
backpackHud: null,
|
||||
inventoryHud: null,
|
||||
teamScores: [],
|
||||
playerRoster: new Map(),
|
||||
chatMessages: [],
|
||||
gravity: -20,
|
||||
};
|
||||
if (!demoValues.length) return result;
|
||||
|
||||
|
|
@ -330,7 +342,12 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
const playerCountByTeam = new Map<number, number>();
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const name = fields[0] ?? "";
|
||||
const clientId = parseInt(fields[2], 10);
|
||||
const teamId = parseInt(fields[4], 10);
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
result.playerRoster.set(clientId, { name, teamId });
|
||||
}
|
||||
if (!isNaN(teamId) && teamId > 0) {
|
||||
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
|
|
@ -458,7 +475,13 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
}
|
||||
}
|
||||
|
||||
// GRAVITY: 1 value — skip
|
||||
// GRAVITY: 1 value (the server's getGravity() value).
|
||||
if (idx < demoValues.length) {
|
||||
const g = parseFloat(next());
|
||||
if (Number.isFinite(g)) {
|
||||
result.gravity = g;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -983,6 +1006,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
targetId: number;
|
||||
name?: string;
|
||||
sensorGroup: number;
|
||||
targetData: number;
|
||||
}>;
|
||||
sensorGroupColors: Array<{
|
||||
group: number;
|
||||
|
|
@ -1002,6 +1026,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
private readonly netStrings = new Map<number, string>();
|
||||
private readonly targetNames = new Map<number, string>();
|
||||
private readonly targetTeams = new Map<number, number>();
|
||||
private readonly targetRenderFlags = new Map<number, number>();
|
||||
/** IFF color map: for the viewer's sensorGroup, map target sensorGroup → RGB. */
|
||||
private readonly sensorGroupColors = new Map<
|
||||
number,
|
||||
|
|
@ -1009,6 +1034,31 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
>();
|
||||
private state: StreamState;
|
||||
|
||||
// Generation counters for derived-array caching in buildSnapshot().
|
||||
private _teamScoresGen = 0;
|
||||
private _rosterGen = 0;
|
||||
private _weaponsHudGen = 0;
|
||||
private _inventoryHudGen = 0;
|
||||
|
||||
// Cached snapshot returned when no ticks advance between stepToTime() calls.
|
||||
private _cachedSnapshot: DemoStreamSnapshot | null = null;
|
||||
private _cachedSnapshotTick = -1;
|
||||
|
||||
// Cached derived arrays from the last buildSnapshot() call.
|
||||
private _snap: {
|
||||
teamScoresGen: number;
|
||||
rosterGen: number;
|
||||
teamScores: TeamScore[];
|
||||
weaponsHudGen: number;
|
||||
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
|
||||
inventoryHudGen: number;
|
||||
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
|
||||
backpackPackIndex: number;
|
||||
backpackActive: boolean;
|
||||
backpackText: string;
|
||||
backpackHud: BackpackHudState | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(parser: DemoParser) {
|
||||
this.parser = parser;
|
||||
this.registry = parser.getRegistry();
|
||||
|
|
@ -1055,6 +1105,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
backpackHud: { packIndex: -1, active: false, text: "" },
|
||||
inventoryHud: { slots: new Map(), activeSlot: -1 },
|
||||
teamScores: [],
|
||||
playerRoster: new Map(),
|
||||
};
|
||||
|
||||
this.reset();
|
||||
|
|
@ -1062,10 +1113,14 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
|
||||
reset(): void {
|
||||
this.parser.reset();
|
||||
this._cachedSnapshot = null;
|
||||
this._cachedSnapshotTick = -1;
|
||||
this._snap = null;
|
||||
|
||||
this.netStrings.clear();
|
||||
this.targetNames.clear();
|
||||
this.targetTeams.clear();
|
||||
this.targetRenderFlags.clear();
|
||||
this.sensorGroupColors.clear();
|
||||
this.state.entitiesById.clear();
|
||||
this.state.entityIdByGhostIndex.clear();
|
||||
|
|
@ -1081,6 +1136,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
);
|
||||
}
|
||||
this.targetTeams.set(entry.targetId, entry.sensorGroup);
|
||||
this.targetRenderFlags.set(entry.targetId, entry.targetData);
|
||||
}
|
||||
// Seed IFF color table from the initial block.
|
||||
for (const c of this.initialBlock.sensorGroupColors) {
|
||||
|
|
@ -1099,6 +1155,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
this.state.backpackHud = { packIndex: -1, active: false, text: "" };
|
||||
this.state.inventoryHud = { slots: new Map(), activeSlot: -1 };
|
||||
this.state.teamScores = [];
|
||||
this.state.playerRoster = new Map();
|
||||
this.state.moveTicks = 0;
|
||||
this.state.absoluteYaw = 0;
|
||||
this.state.absolutePitch = 0;
|
||||
|
|
@ -1227,6 +1284,9 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
evt.parsedData.funcName as string,
|
||||
);
|
||||
const args = evt.parsedData.args as string[];
|
||||
if (funcName === "ServerMessage") {
|
||||
this.handleServerMessage(args);
|
||||
}
|
||||
this.handleHudRemoteCommand(funcName, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -1248,6 +1308,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
this.state.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot;
|
||||
}
|
||||
this.state.teamScores = parsed.teamScores;
|
||||
this.state.playerRoster = new Map(parsed.playerRoster);
|
||||
// Seed chat messages at time 0 so they appear at start and fade naturally.
|
||||
// Raw lines from HudMessageVector contain Torque control chars: collapsed
|
||||
// color bytes (0x02–0x0e via collapseRemap), tagged string markup
|
||||
|
|
@ -1293,17 +1354,37 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
}
|
||||
|
||||
getSnapshot(): DemoStreamSnapshot {
|
||||
return this.buildSnapshot();
|
||||
if (this._cachedSnapshot && this._cachedSnapshotTick === this.state.moveTicks) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.state.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
|
||||
const shape = expBlock.dtsFileName as string | undefined;
|
||||
if (shape) shapes.add(shape);
|
||||
// Sub-explosions also have DTS shapes (e.g. mortar sub-explosions).
|
||||
const subExplosions = expBlock.subExplosions as (number | null)[] | undefined;
|
||||
if (Array.isArray(subExplosions)) {
|
||||
for (const subId of subExplosions) {
|
||||
if (subId == null) continue;
|
||||
const subBlock = this.getDataBlockData(subId);
|
||||
if (subBlock?.dtsFileName) {
|
||||
shapes.add(subBlock.dtsFileName as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const [, block] of this.initialBlock.dataBlocks) {
|
||||
const explosionId = block.data?.explosion as number | undefined;
|
||||
if (explosionId == null) continue;
|
||||
const expBlock = this.getDataBlockData(explosionId);
|
||||
const shape = expBlock?.dtsFileName as string | undefined;
|
||||
if (shape) shapes.add(shape);
|
||||
if (expBlock) collectShapesFromExplosion(expBlock);
|
||||
}
|
||||
return [...shapes];
|
||||
}
|
||||
|
|
@ -1317,10 +1398,13 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
: 0;
|
||||
const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS);
|
||||
|
||||
let didReset = false;
|
||||
if (targetTicks < this.state.moveTicks) {
|
||||
this.reset();
|
||||
didReset = true;
|
||||
}
|
||||
|
||||
const wasExhausted = this.state.exhausted;
|
||||
let movesProcessed = 0;
|
||||
while (
|
||||
!this.state.exhausted &&
|
||||
|
|
@ -1333,7 +1417,20 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
movesProcessed += 1;
|
||||
}
|
||||
|
||||
return this.buildSnapshot();
|
||||
if (
|
||||
movesProcessed === 0 &&
|
||||
!didReset &&
|
||||
wasExhausted === this.state.exhausted &&
|
||||
this._cachedSnapshot &&
|
||||
this._cachedSnapshotTick === this.state.moveTicks
|
||||
) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.state.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private stepOneMoveTick(): boolean {
|
||||
|
|
@ -1468,6 +1565,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
if (targetId != null && sensorGroup != null) {
|
||||
this.targetTeams.set(targetId, sensorGroup);
|
||||
}
|
||||
const renderFlags = evt.parsedData.renderFlags as number | undefined;
|
||||
if (targetId != null && renderFlags != null) {
|
||||
this.targetRenderFlags.set(targetId, renderFlags);
|
||||
// Propagate to any entity bound to this target so render flags
|
||||
// take effect immediately (e.g. clearing bit 0x2 when a player
|
||||
// drops a flag) rather than waiting for the next ghost update.
|
||||
for (const entity of this.state.entitiesById.values()) {
|
||||
if (entity.targetId === targetId) {
|
||||
entity.targetRenderFlags = renderFlags;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (eventName === "SetSensorGroupEvent" && evt.parsedData) {
|
||||
const sg = evt.parsedData.sensorGroup as number | undefined;
|
||||
if (sg != null) this.state.playerSensorGroup = sg;
|
||||
|
|
@ -1633,6 +1742,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
});
|
||||
}
|
||||
} else if (funcName === "ServerMessage" && args.length >= 2) {
|
||||
this.handleServerMessage(args);
|
||||
const rawTemplate = this.resolveNetString(args[1]);
|
||||
const serverColorCode = detectColorCode(rawTemplate);
|
||||
const rawText = this.formatRemoteArgs(args[1], args.slice(2));
|
||||
|
|
@ -1718,19 +1828,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
|
||||
// When a projectile entity is being removed (ghost delete, ghost index
|
||||
// reuse, or same-class index reuse), spawn an explosion at its last known
|
||||
// position if it hasn't already exploded. The Torque engine's KillGhost
|
||||
// mechanism silently drops pending ExplosionMask data when a ghost goes
|
||||
// out of scope, so explosion positions almost never arrive in the demo
|
||||
// stream. The original client compensated with client-side raycast
|
||||
// collision detection in processTick(); we approximate by triggering the
|
||||
// explosion when the ghost disappears.
|
||||
// position if it hasn't already exploded. Explosion positions usually
|
||||
// arrive via ExplosionMask in ghost updates (the server has a 13-tick /
|
||||
// 416ms DeleteWaitTicks window before KillGhost fires), but this fallback
|
||||
// catches cases where the explicit data was missed — e.g. network
|
||||
// congestion or the projectile going out of scope before the update.
|
||||
if (prevEntityId) {
|
||||
const prevEntity = this.state.entitiesById.get(prevEntityId);
|
||||
if (
|
||||
prevEntity &&
|
||||
prevEntity.type === "Projectile" &&
|
||||
!prevEntity.hasExploded &&
|
||||
prevEntity.explosionShape &&
|
||||
prevEntity.explosionDataBlockId != null &&
|
||||
prevEntity.position &&
|
||||
// Ghost is being deleted or its index is being reassigned to a new
|
||||
// ghost (either a different class or a fresh create of the same class).
|
||||
|
|
@ -1784,6 +1893,27 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
existingEntity.dataBlockId = undefined;
|
||||
existingEntity.shapeHint = undefined;
|
||||
existingEntity.visual = undefined;
|
||||
existingEntity.targetId = undefined;
|
||||
existingEntity.targetRenderFlags = undefined;
|
||||
existingEntity.carryingFlag = undefined;
|
||||
existingEntity.sensorGroup = undefined;
|
||||
existingEntity.playerName = undefined;
|
||||
existingEntity.weaponShape = undefined;
|
||||
existingEntity.weaponImageState = undefined;
|
||||
existingEntity.weaponImageStates = undefined;
|
||||
existingEntity.weaponImageStatesDbId = undefined;
|
||||
existingEntity.itemPhysics = undefined;
|
||||
existingEntity.threads = undefined;
|
||||
existingEntity.headPitch = undefined;
|
||||
existingEntity.headYaw = undefined;
|
||||
existingEntity.health = undefined;
|
||||
existingEntity.energy = undefined;
|
||||
existingEntity.maxEnergy = undefined;
|
||||
existingEntity.damageState = undefined;
|
||||
existingEntity.actionAnim = undefined;
|
||||
existingEntity.actionAtEnd = undefined;
|
||||
existingEntity.explosionDataBlockId = undefined;
|
||||
existingEntity.maintainEmitterId = undefined;
|
||||
entity = existingEntity;
|
||||
} else if (existingEntity) {
|
||||
entity = existingEntity;
|
||||
|
|
@ -1866,12 +1996,15 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
}
|
||||
| undefined {
|
||||
const projBlock = this.getDataBlockData(projDataBlockId);
|
||||
const explosionId = projBlock?.explosion as number | undefined;
|
||||
if (!projBlock) return undefined;
|
||||
const explosionId = projBlock.explosion as number | undefined;
|
||||
if (explosionId == null) return undefined;
|
||||
const expBlock = this.getDataBlockData(explosionId);
|
||||
if (!expBlock) return undefined;
|
||||
const shape = expBlock.dtsFileName as string | undefined;
|
||||
if (!shape) return undefined;
|
||||
// dtsFileName may be empty for particle-only explosions (e.g. grenades,
|
||||
// energy projectiles). Still return info so we can spawn the explosion
|
||||
// entity for position tracking and particle effects.
|
||||
const shape = (expBlock.dtsFileName as string | undefined) || undefined;
|
||||
// The parser's lifetimeMS field is actually in ticks (32ms each), not ms.
|
||||
const lifetimeTicks = (expBlock.lifetimeMS as number | undefined) ?? 31;
|
||||
return {
|
||||
|
|
@ -1915,14 +2048,15 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
entity.projectilePhysics = "linear";
|
||||
} else if (ballisticProjectileClassNames.has(entity.className)) {
|
||||
entity.projectilePhysics = "ballistic";
|
||||
entity.gravityMod = getNumberField(blockData, ["gravityMod"]) ?? 1.0;
|
||||
entity.gravityMod =
|
||||
getNumberField(blockData, ["gravityMod"]) ?? 1.0;
|
||||
} else if (seekerProjectileClassNames.has(entity.className)) {
|
||||
entity.projectilePhysics = "seeker";
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve explosion shape info for projectiles (once per entity).
|
||||
if (entity.type === "Projectile" && !entity.explosionShape) {
|
||||
if (entity.type === "Projectile" && entity.explosionDataBlockId == null) {
|
||||
const info = this.resolveExplosionInfo(dataBlockId);
|
||||
if (info) {
|
||||
entity.explosionShape = info.shape;
|
||||
|
|
@ -2000,6 +2134,24 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
entity.weaponImageState = undefined;
|
||||
entity.weaponImageStates = undefined;
|
||||
}
|
||||
|
||||
// Track FlagImage in slot 3 ($FlagSlot). The server mounts FlagImage
|
||||
// when a player picks up a flag and unmounts it on drop. buildSnapshot
|
||||
// uses carryingFlag to gate the flag icon on Players, which prevents
|
||||
// dead corpses (sharing the same targetId) from showing duplicate icons.
|
||||
const flagImage = images.find((img) => img.index === 3);
|
||||
if (flagImage) {
|
||||
const hasFlag = !!flagImage.dataBlockId && flagImage.dataBlockId > 0;
|
||||
entity.carryingFlag = hasFlag;
|
||||
if (entity.targetId != null && entity.targetId >= 0) {
|
||||
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
|
||||
const updated = hasFlag ? prev | 0x2 : prev & ~0x2;
|
||||
if (updated !== prev) {
|
||||
this.targetRenderFlags.set(entity.targetId, updated);
|
||||
entity.targetRenderFlags = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2093,8 +2245,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
// Item physics: simulate dropped items falling under gravity and bouncing.
|
||||
if (entity.type === "Item") {
|
||||
const atRest = data.atRest as boolean | undefined;
|
||||
if (atRest === true) {
|
||||
// Server says item is at rest — stop simulating.
|
||||
const warp = data.warp as boolean | undefined;
|
||||
if (atRest === true || warp === false) {
|
||||
// At rest, or position authoritatively set (e.g. flag returned to
|
||||
// base via setTransform → NoWarpMask) — stop simulating.
|
||||
entity.itemPhysics = undefined;
|
||||
} else if (atRest === false && isVec3Like(data.velocity)) {
|
||||
// Item is moving — initialize or update physics simulation.
|
||||
|
|
@ -2115,7 +2269,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
}
|
||||
}
|
||||
|
||||
// Compute simulatedVelocity for projectile physics.
|
||||
// Compute simulatedVelocity for projectile physics. Mirrors the engine's
|
||||
// unpackUpdate: velocity is only set on InitialUpdateMask or BounceMask,
|
||||
// so we only (re)initialize when this update actually transmits velocity
|
||||
// or direction data. Between updates, advanceProjectiles() accumulates
|
||||
// gravity on the existing simulatedVelocity.
|
||||
if (entity.projectilePhysics) {
|
||||
if (entity.projectilePhysics === "linear") {
|
||||
// Linear projectiles transmit direction + dryVelocity from datablock,
|
||||
|
|
@ -2147,14 +2305,18 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
vz += excessDir.z * excessVel;
|
||||
}
|
||||
entity.simulatedVelocity = [vx, vy, vz];
|
||||
} else if (entity.velocity) {
|
||||
// Ballistic and seeker: use the transmitted velocity directly.
|
||||
} else if (isVec3Like(data.velocity)) {
|
||||
// Ballistic/seeker: set velocity only when this ghost update transmits
|
||||
// it (initial create or BounceMask). The engine's unpackUpdate only
|
||||
// writes mCurrVelocity on these two mask bits.
|
||||
entity.simulatedVelocity = [
|
||||
entity.velocity[0],
|
||||
entity.velocity[1],
|
||||
entity.velocity[2],
|
||||
data.velocity.x,
|
||||
data.velocity.y,
|
||||
data.velocity.z,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (entity.projectilePhysics) {
|
||||
|
||||
// Fast-forward by currTick: the initial position is the firing point
|
||||
// and currTick tells us how many ticks have already elapsed.
|
||||
|
|
@ -2172,10 +2334,12 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
entity.position[2] += v[2] * dt;
|
||||
// For ballistic projectiles, also apply gravity during fast-forward.
|
||||
if (entity.projectilePhysics === "ballistic") {
|
||||
const g = 9.81 * (entity.gravityMod ?? 1);
|
||||
// GrenadeProjectile::computeNewState uses -9.81 * gravityMod
|
||||
// (globalGravity * 0.4905 * gravityMod, where 0.4905 = 9.81/20).
|
||||
const g = -9.81 * (entity.gravityMod ?? 1);
|
||||
// v.z changes linearly, position.z changes quadratically.
|
||||
entity.position[2] -= 0.5 * g * dt * dt;
|
||||
v[2] -= g * dt;
|
||||
entity.position[2] += 0.5 * g * dt * dt;
|
||||
v[2] += g * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2193,7 +2357,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
entity.type === "Projectile" &&
|
||||
!entity.hasExploded &&
|
||||
explodePos &&
|
||||
entity.explosionShape
|
||||
entity.explosionDataBlockId != null
|
||||
) {
|
||||
this.spawnExplosion(entity, [explodePos.x, explodePos.y, explodePos.z]);
|
||||
}
|
||||
|
|
@ -2252,6 +2416,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
this.state.playerSensorGroup = team;
|
||||
}
|
||||
}
|
||||
const renderFlags = this.targetRenderFlags.get(data.targetId);
|
||||
if (renderFlags != null) {
|
||||
entity.targetRenderFlags = renderFlags;
|
||||
}
|
||||
}
|
||||
|
||||
// SoundMask: ghost-level playAudio() calls (e.g. station activation).
|
||||
|
|
@ -2285,8 +2453,8 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
const p = entity.position;
|
||||
|
||||
if (entity.projectilePhysics === "ballistic") {
|
||||
const g = 9.81 * (entity.gravityMod ?? 1);
|
||||
v[2] -= g * dt;
|
||||
// GrenadeProjectile::computeNewState: -9.81 * gravityMod per tick.
|
||||
v[2] += -9.81 * (entity.gravityMod ?? 1) * dt;
|
||||
}
|
||||
|
||||
p[0] += v[0] * dt;
|
||||
|
|
@ -2346,8 +2514,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
position: [number, number, number],
|
||||
): void {
|
||||
entity.hasExploded = true;
|
||||
const fxId = `fx_${this.state.nextExplosionId++}`;
|
||||
const lifetimeTicks = entity.explosionLifetimeTicks ?? 31;
|
||||
|
||||
// Spawn the main explosion entity.
|
||||
const fxId = `fx_${this.state.nextExplosionId++}`;
|
||||
const fxEntity: MutableStreamEntity = {
|
||||
id: fxId,
|
||||
ghostIndex: -1,
|
||||
|
|
@ -2363,6 +2533,56 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
expiryTick: this.state.moveTicks + lifetimeTicks,
|
||||
};
|
||||
this.state.entitiesById.set(fxId, fxEntity);
|
||||
|
||||
// Spawn sub-explosion entities (e.g. MortarSubExplosion1/2/3 carry the
|
||||
// actual DTS shapes while the main MortarExplosion has none).
|
||||
if (entity.explosionDataBlockId != null) {
|
||||
const expBlock = this.getDataBlockData(entity.explosionDataBlockId);
|
||||
const subExplosions = expBlock?.subExplosions as
|
||||
| (number | null)[]
|
||||
| undefined;
|
||||
if (Array.isArray(subExplosions)) {
|
||||
for (const subId of subExplosions) {
|
||||
if (subId == null) continue;
|
||||
const subBlock = this.getDataBlockData(subId);
|
||||
if (!subBlock) continue;
|
||||
const subShape =
|
||||
(subBlock.dtsFileName as string | undefined) || undefined;
|
||||
if (!subShape) continue;
|
||||
|
||||
const subLifetimeTicks =
|
||||
(subBlock.lifetimeMS as number | undefined) ?? 31;
|
||||
const offset = (subBlock.offset as number | undefined) ?? 0;
|
||||
|
||||
// Randomize position offset in XY plane (Torque convention).
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const subPos: [number, number, number] = [
|
||||
position[0] + Math.cos(angle) * offset,
|
||||
position[1] + Math.sin(angle) * offset,
|
||||
position[2],
|
||||
];
|
||||
|
||||
const subFxId = `fx_${this.state.nextExplosionId++}`;
|
||||
const subFxEntity: MutableStreamEntity = {
|
||||
id: subFxId,
|
||||
ghostIndex: -1,
|
||||
className: "Explosion",
|
||||
spawnTick: this.state.moveTicks,
|
||||
type: "Explosion",
|
||||
dataBlock: subShape,
|
||||
explosionDataBlockId: subId,
|
||||
position: subPos,
|
||||
rotation: [0, 0, 0, 1],
|
||||
isExplosion: true,
|
||||
faceViewer:
|
||||
subBlock.faceViewer !== false && subBlock.faceViewer !== 0,
|
||||
expiryTick: this.state.moveTicks + subLifetimeTicks,
|
||||
};
|
||||
this.state.entitiesById.set(subFxId, subFxEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the projectile — the explosion takes over visually.
|
||||
entity.position = undefined;
|
||||
entity.simulatedVelocity = undefined;
|
||||
|
|
@ -2517,6 +2737,81 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
}
|
||||
}
|
||||
|
||||
/** Process ServerMessage events that update team scores and player roster. */
|
||||
private handleServerMessage(args: string[]): void {
|
||||
if (args.length < 2) return;
|
||||
const msgType = this.resolveNetString(args[0]);
|
||||
|
||||
if (msgType === "MsgTeamScoreIs" && args.length >= 4) {
|
||||
// args: [msgType, "", teamId, newScore]
|
||||
const teamId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const newScore = parseInt(this.resolveNetString(args[3]), 10);
|
||||
if (!isNaN(teamId) && !isNaN(newScore)) {
|
||||
const entry = this.state.teamScores.find((t) => t.teamId === teamId);
|
||||
if (entry) {
|
||||
entry.score = newScore;
|
||||
this._teamScoresGen++;
|
||||
}
|
||||
}
|
||||
} else if (msgType === "MsgCTFAddTeam" && args.length >= 6) {
|
||||
// args: [msgType, "", teamIdx, teamName, flagStatus, score]
|
||||
const teamIdx = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const teamName = stripTaggedStringMarkup(this.resolveNetString(args[3]));
|
||||
const score = parseInt(this.resolveNetString(args[5]), 10);
|
||||
if (!isNaN(teamIdx)) {
|
||||
const teamId = teamIdx + 1;
|
||||
const existing = this.state.teamScores.find(
|
||||
(t) => t.teamId === teamId,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = teamName;
|
||||
existing.score = isNaN(score) ? existing.score : score;
|
||||
this._teamScoresGen++;
|
||||
} else {
|
||||
this.state.teamScores.push({
|
||||
teamId,
|
||||
name: teamName,
|
||||
score: isNaN(score) ? 0 : score,
|
||||
playerCount: 0,
|
||||
});
|
||||
this._teamScoresGen++;
|
||||
}
|
||||
}
|
||||
} else if (msgType === "MsgClientJoin" && args.length >= 4) {
|
||||
// args: [msgType, "", clientId, name, ...]
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const name = stripTaggedStringMarkup(this.resolveNetString(args[3]));
|
||||
if (!isNaN(clientId)) {
|
||||
const existing = this.state.playerRoster.get(clientId);
|
||||
this.state.playerRoster.set(clientId, {
|
||||
name,
|
||||
teamId: existing?.teamId ?? 0,
|
||||
});
|
||||
this._rosterGen++;
|
||||
}
|
||||
} else if (msgType === "MsgClientDrop" && args.length >= 3) {
|
||||
// args: [msgType, "", clientId, ...]
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
if (!isNaN(clientId)) {
|
||||
this.state.playerRoster.delete(clientId);
|
||||
this._rosterGen++;
|
||||
}
|
||||
} else if (msgType === "MsgClientJoinTeam" && args.length >= 4) {
|
||||
// args: [msgType, "", clientId, teamId, ...]
|
||||
const clientId = parseInt(this.resolveNetString(args[2]), 10);
|
||||
const teamId = parseInt(this.resolveNetString(args[3]), 10);
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
const existing = this.state.playerRoster.get(clientId);
|
||||
if (existing) {
|
||||
existing.teamId = teamId;
|
||||
} else {
|
||||
this.state.playerRoster.set(clientId, { name: "", teamId });
|
||||
}
|
||||
this._rosterGen++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleHudRemoteCommand(funcName: string, args: string[]): void {
|
||||
// ── Weapons HUD ──
|
||||
if (funcName === "setWeaponsHudItem" && args.length >= 3) {
|
||||
|
|
@ -2529,6 +2824,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
} else {
|
||||
this.state.weaponsHud.slots.delete(slot);
|
||||
}
|
||||
this._weaponsHudGen++;
|
||||
}
|
||||
} else if (funcName === "setWeaponsHudAmmo" && args.length >= 2) {
|
||||
const slot = parseInt(args[0], 10);
|
||||
|
|
@ -2537,6 +2833,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
// Treat ammo updates as implicit inventory presence — the
|
||||
// initial setWeaponsHudItem may have been sent before recording.
|
||||
this.state.weaponsHud.slots.set(slot, isNaN(ammo) ? -1 : ammo);
|
||||
this._weaponsHudGen++;
|
||||
}
|
||||
} else if (funcName === "setWeaponsHudActive" && args.length >= 1) {
|
||||
const slot = parseInt(args[0], 10);
|
||||
|
|
@ -2547,9 +2844,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
this.state.weaponsHud.slots.set(slot, -1);
|
||||
}
|
||||
}
|
||||
this._weaponsHudGen++;
|
||||
} else if (funcName === "setWeaponsHudClearAll") {
|
||||
this.state.weaponsHud.slots.clear();
|
||||
this.state.weaponsHud.activeIndex = -1;
|
||||
this._weaponsHudGen++;
|
||||
|
||||
// ── Backpack HUD ──
|
||||
} else if (funcName === "setBackpackHudItem" && args.length >= 2) {
|
||||
|
|
@ -2594,16 +2893,19 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
} else {
|
||||
this.state.inventoryHud.slots.delete(slot);
|
||||
}
|
||||
this._inventoryHudGen++;
|
||||
}
|
||||
} else if (funcName === "setInventoryHudAmount" && args.length >= 2) {
|
||||
const slot = parseInt(args[0], 10);
|
||||
const amount = parseInt(args[1], 10);
|
||||
if (!isNaN(slot) && !isNaN(amount)) {
|
||||
this.state.inventoryHud.slots.set(slot, amount);
|
||||
this._inventoryHudGen++;
|
||||
}
|
||||
} else if (funcName === "setInventoryHudClearAll") {
|
||||
this.state.inventoryHud.slots.clear();
|
||||
this.state.inventoryHud.activeSlot = -1;
|
||||
this._inventoryHudGen++;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2613,6 +2915,20 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
if (!shouldRenderGhostEntity(entity)) {
|
||||
continue;
|
||||
}
|
||||
// Read the latest targetRenderFlags from the map (source of truth
|
||||
// from TargetInfoEvents and FlagImage slot tracking) rather than the
|
||||
// entity cache, which may be stale.
|
||||
let renderFlags =
|
||||
entity.targetId != null && entity.targetId >= 0
|
||||
? (this.targetRenderFlags.get(entity.targetId) ??
|
||||
entity.targetRenderFlags)
|
||||
: entity.targetRenderFlags;
|
||||
// For Players, only show the flag icon if this specific entity has
|
||||
// FlagImage mounted in slot 3. Dead corpses share the same targetId as
|
||||
// the alive player but don't have FlagImage, so this prevents duplicates.
|
||||
if (entity.type === "Player" && !entity.carryingFlag) {
|
||||
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
|
||||
}
|
||||
entities.push({
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
|
|
@ -2625,8 +2941,11 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
playerName: entity.playerName,
|
||||
targetRenderFlags: renderFlags,
|
||||
iffColor:
|
||||
entity.type === "Player" && entity.sensorGroup != null
|
||||
(entity.type === "Player" ||
|
||||
((renderFlags ?? 0) & 0x2) !== 0) &&
|
||||
entity.sensorGroup != null
|
||||
? this.resolveIffColor(entity.sensorGroup)
|
||||
: undefined,
|
||||
// Only clone position for entities whose position is mutated in-place
|
||||
|
|
@ -2658,6 +2977,80 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
}
|
||||
|
||||
const timeSec = this.state.moveTicks * (TICK_DURATION_MS / 1000);
|
||||
const prev = this._snap;
|
||||
|
||||
const chatMessages = this.state.chatMessages.filter(
|
||||
(m) => m.timeSec > timeSec - 15,
|
||||
);
|
||||
|
||||
const audioEvents = this.state.pendingAudioEvents.filter(
|
||||
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
|
||||
);
|
||||
|
||||
const weaponsHud =
|
||||
prev && prev.weaponsHudGen === this._weaponsHudGen
|
||||
? prev.weaponsHud
|
||||
: {
|
||||
slots: Array.from(this.state.weaponsHud.slots.entries()).map(
|
||||
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
|
||||
),
|
||||
activeIndex: this.state.weaponsHud.activeIndex,
|
||||
};
|
||||
|
||||
const inventoryHud =
|
||||
prev && prev.inventoryHudGen === this._inventoryHudGen
|
||||
? prev.inventoryHud
|
||||
: {
|
||||
slots: Array.from(this.state.inventoryHud.slots.entries()).map(
|
||||
([slot, count]): InventoryHudSlot => ({ slot, count }),
|
||||
),
|
||||
activeSlot: this.state.inventoryHud.activeSlot,
|
||||
};
|
||||
|
||||
const backpackHud =
|
||||
prev &&
|
||||
prev.backpackPackIndex === this.state.backpackHud.packIndex &&
|
||||
prev.backpackActive === this.state.backpackHud.active &&
|
||||
prev.backpackText === this.state.backpackHud.text
|
||||
? prev.backpackHud
|
||||
: this.state.backpackHud.packIndex >= 0
|
||||
? { ...this.state.backpackHud }
|
||||
: null;
|
||||
|
||||
let teamScores: TeamScore[];
|
||||
if (
|
||||
prev &&
|
||||
prev.teamScoresGen === this._teamScoresGen &&
|
||||
prev.rosterGen === this._rosterGen
|
||||
) {
|
||||
teamScores = prev.teamScores;
|
||||
} else {
|
||||
teamScores = this.state.teamScores.map((ts) => ({ ...ts }));
|
||||
const teamCounts = new Map<number, number>();
|
||||
for (const { teamId } of this.state.playerRoster.values()) {
|
||||
if (teamId > 0) {
|
||||
teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const ts of teamScores) {
|
||||
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
this._snap = {
|
||||
teamScoresGen: this._teamScoresGen,
|
||||
rosterGen: this._rosterGen,
|
||||
teamScores,
|
||||
weaponsHudGen: this._weaponsHudGen,
|
||||
weaponsHud,
|
||||
inventoryHudGen: this._inventoryHudGen,
|
||||
inventoryHud,
|
||||
backpackPackIndex: this.state.backpackHud.packIndex,
|
||||
backpackActive: this.state.backpackHud.active,
|
||||
backpackText: this.state.backpackHud.text,
|
||||
backpackHud,
|
||||
};
|
||||
|
||||
return {
|
||||
timeSec,
|
||||
exhausted: this.state.exhausted,
|
||||
|
|
@ -2666,29 +3059,12 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
controlPlayerGhostId: this.state.controlPlayerGhostId,
|
||||
playerSensorGroup: this.state.playerSensorGroup,
|
||||
status: this.state.lastStatus,
|
||||
chatMessages: this.state.chatMessages.filter(
|
||||
(m) => m.timeSec > timeSec - 15,
|
||||
),
|
||||
audioEvents: this.state.pendingAudioEvents.filter(
|
||||
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
|
||||
),
|
||||
weaponsHud: {
|
||||
slots: Array.from(this.state.weaponsHud.slots.entries()).map(
|
||||
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
|
||||
),
|
||||
activeIndex: this.state.weaponsHud.activeIndex,
|
||||
},
|
||||
backpackHud:
|
||||
this.state.backpackHud.packIndex >= 0
|
||||
? { ...this.state.backpackHud }
|
||||
: null,
|
||||
inventoryHud: {
|
||||
slots: Array.from(this.state.inventoryHud.slots.entries()).map(
|
||||
([slot, count]): InventoryHudSlot => ({ slot, count }),
|
||||
),
|
||||
activeSlot: this.state.inventoryHud.activeSlot,
|
||||
},
|
||||
teamScores: this.state.teamScores,
|
||||
chatMessages,
|
||||
audioEvents,
|
||||
weaponsHud,
|
||||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2787,6 +3163,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Torque drops trailing empty string args from commandToClient, so the
|
||||
// template may reference %N tokens beyond the supplied args (e.g. a
|
||||
// plural "s" that evaluates to ""). Replace any remaining placeholders.
|
||||
resolved = resolved.replace(/%\d+/g, "");
|
||||
return stripTaggedStringMarkup(resolved);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ export interface DemoEntity {
|
|||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
/** Target render flags bitmask from the Target Manager. */
|
||||
targetRenderFlags?: number;
|
||||
/** Weapon image condition flags from ghost ImageMask data. */
|
||||
weaponImageState?: WeaponImageState;
|
||||
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
|
||||
|
|
@ -125,6 +127,10 @@ export interface DemoEntity {
|
|||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
|
||||
headYaw?: number;
|
||||
/** Numeric ID of the ExplosionData datablock (for explosion shape rendering). */
|
||||
explosionDataBlockId?: number;
|
||||
/** Billboard toward camera (Torque's faceViewer). */
|
||||
faceViewer?: boolean;
|
||||
}
|
||||
|
||||
export interface DemoRecording {
|
||||
|
|
@ -147,6 +153,8 @@ export interface DemoStreamEntity {
|
|||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
/** Target render flags bitmask from the Target Manager. */
|
||||
targetRenderFlags?: number;
|
||||
ghostIndex?: number;
|
||||
className?: string;
|
||||
dataBlockId?: number;
|
||||
|
|
|
|||
|
|
@ -295,6 +295,17 @@ export class EmitterInstance {
|
|||
|
||||
if (this.particles.length < this.maxParticles) {
|
||||
this.addParticle(pos, axis);
|
||||
|
||||
// V12: when overrideAdvances is false, immediately age the newly
|
||||
// spawned particle by the remaining time in this frame. If that
|
||||
// exceeds its lifetime, kill it immediately (never rendered).
|
||||
if (!this.data.overrideAdvances && timeLeft > 0) {
|
||||
const p = this.particles[this.particles.length - 1];
|
||||
p.currentAge += timeLeft;
|
||||
if (p.currentAge >= p.totalLifetime) {
|
||||
this.particles.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute next emission time.
|
||||
|
|
@ -310,10 +321,10 @@ export class EmitterInstance {
|
|||
update(dtMS: number): void {
|
||||
this.emitterAge += dtMS;
|
||||
|
||||
// Check emitter lifetime.
|
||||
// Check emitter lifetime (V12 uses strictly greater).
|
||||
if (
|
||||
this.emitterLifetime > 0 &&
|
||||
this.emitterAge >= this.emitterLifetime
|
||||
this.emitterAge > this.emitterLifetime
|
||||
) {
|
||||
this.emitterDead = true;
|
||||
}
|
||||
|
|
@ -339,9 +350,9 @@ export class EmitterInstance {
|
|||
|
||||
// a = acc - vel*drag - wind*windCoeff + gravity*gravCoeff
|
||||
// We skip wind for now (no wind system yet).
|
||||
const ax = -p.vel[0] * drag;
|
||||
const ay = -p.vel[1] * drag;
|
||||
const az = -p.vel[2] * drag + GRAVITY_Z * gravCoeff;
|
||||
const ax = p.acc[0] - p.vel[0] * drag;
|
||||
const ay = p.acc[1] - p.vel[1] * drag;
|
||||
const az = p.acc[2] - p.vel[2] * drag + GRAVITY_Z * gravCoeff;
|
||||
|
||||
// Symplectic Euler: update vel first, then pos with new vel.
|
||||
p.vel[0] += ax * dt;
|
||||
|
|
@ -430,14 +441,13 @@ export class EmitterInstance {
|
|||
ejZ * speed,
|
||||
];
|
||||
|
||||
// V12: acc = vel * constantAcceleration (set once, never changes).
|
||||
// We fold this into the initial velocity for simplicity since the
|
||||
// constant acceleration just biases the starting velocity direction.
|
||||
// Actually, in V12 acc is a separate constant vector added each frame.
|
||||
// For faithfulness, we should track it. But since it's constant, we can
|
||||
// just apply it in the update loop as a per-particle property.
|
||||
// For now, bake it into the velocity since most Tribes 2 datablocks
|
||||
// have constantAcceleration = 0.
|
||||
// V12: acc = vel * constantAcceleration, set once at spawn, applied every frame.
|
||||
const ca = pData.constantAcceleration;
|
||||
const acc: [number, number, number] = [
|
||||
vel[0] * ca,
|
||||
vel[1] * ca,
|
||||
vel[2] * ca,
|
||||
];
|
||||
|
||||
// Particle lifetime with variance.
|
||||
let lifetime = pData.lifetimeMS;
|
||||
|
|
@ -456,6 +466,7 @@ export class EmitterInstance {
|
|||
this.particles.push({
|
||||
pos: spawnPos,
|
||||
vel,
|
||||
acc,
|
||||
orientDir: [ejX, ejY, ejZ],
|
||||
currentAge: 0,
|
||||
totalLifetime: lifetime,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ attribute vec4 particleColor;
|
|||
attribute float particleSize;
|
||||
attribute float particleSpin;
|
||||
attribute vec2 quadCorner; // (-0.5,-0.5) to (0.5,0.5)
|
||||
attribute vec3 orientDir;
|
||||
|
||||
uniform bool uOrientParticles;
|
||||
// cameraPosition is a built-in Three.js uniform.
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec4 vColor;
|
||||
|
|
@ -12,27 +16,47 @@ void main() {
|
|||
vUv = quadCorner + 0.5; // [0,1] range
|
||||
vColor = particleColor;
|
||||
|
||||
// Transform particle center to view space for billboarding.
|
||||
vec3 viewPos = (modelViewMatrix * vec4(position, 1.0)).xyz;
|
||||
if (uOrientParticles) {
|
||||
if (length(orientDir) < 0.0001) {
|
||||
// V12: don't render oriented particles with zero velocity.
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
// V12 oriented particle: quad aligned along direction, facing camera.
|
||||
vec3 worldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
vec3 dir = normalize(orientDir);
|
||||
vec3 dirFromCam = worldPos - cameraPosition;
|
||||
vec3 crossDir = normalize(cross(dirFromCam, dir));
|
||||
|
||||
// Apply spin rotation to quad corner.
|
||||
float c = cos(particleSpin);
|
||||
float s = sin(particleSpin);
|
||||
vec2 rotated = vec2(
|
||||
c * quadCorner.x - s * quadCorner.y,
|
||||
s * quadCorner.x + c * quadCorner.y
|
||||
);
|
||||
// V12 maps U along dir (velocity) — match by using quadCorner.x for dir.
|
||||
vec3 offset = dir * quadCorner.x + crossDir * quadCorner.y;
|
||||
worldPos += offset * particleSize;
|
||||
|
||||
// Offset in view space (camera-facing billboard).
|
||||
viewPos.xy += rotated * particleSize;
|
||||
gl_Position = projectionMatrix * viewMatrix * vec4(worldPos, 1.0);
|
||||
} else {
|
||||
// Standard camera-facing billboard.
|
||||
vec3 viewPos = (modelViewMatrix * vec4(position, 1.0)).xyz;
|
||||
|
||||
gl_Position = projectionMatrix * vec4(viewPos, 1.0);
|
||||
// Apply spin rotation to quad corner.
|
||||
float c = cos(particleSpin);
|
||||
float s = sin(particleSpin);
|
||||
vec2 rotated = vec2(
|
||||
c * quadCorner.x - s * quadCorner.y,
|
||||
s * quadCorner.x + c * quadCorner.y
|
||||
);
|
||||
|
||||
// Offset in view space (camera-facing billboard).
|
||||
viewPos.xy += rotated * particleSize;
|
||||
|
||||
gl_Position = projectionMatrix * vec4(viewPos, 1.0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const particleFragmentShader = /* glsl */ `
|
||||
uniform sampler2D particleTexture;
|
||||
uniform bool hasTexture;
|
||||
uniform float debugOpacity;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec4 vColor;
|
||||
|
|
@ -44,5 +68,6 @@ void main() {
|
|||
} else {
|
||||
gl_FragColor = vColor;
|
||||
}
|
||||
gl_FragColor.a *= debugOpacity;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface EmitterDataResolved {
|
|||
export interface Particle {
|
||||
pos: [number, number, number];
|
||||
vel: [number, number, number];
|
||||
/** V12: constant acceleration = vel * constantAcceleration, set once at spawn. */
|
||||
acc: [number, number, number];
|
||||
orientDir: [number, number, number];
|
||||
currentAge: number;
|
||||
totalLifetime: number;
|
||||
|
|
|
|||
|
|
@ -305,6 +305,49 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
})),
|
||||
);
|
||||
|
||||
// ── Rate-scaled effect clock ──
|
||||
//
|
||||
// A monotonic clock that advances by (frameDelta × playbackRate) each frame.
|
||||
// Components use demoEffectNow() instead of performance.now() so that effect
|
||||
// timers (explosions, particles, shockwaves, animation threads) automatically
|
||||
// pause when the demo is paused and speed up / slow down with the playback
|
||||
// rate. The main DemoPlaybackStreaming component calls advanceEffectClock()
|
||||
// once per frame.
|
||||
|
||||
let _effectClockMs = 0;
|
||||
|
||||
/**
|
||||
* Returns the current effect clock value in milliseconds.
|
||||
* Analogous to performance.now() but only advances when playing,
|
||||
* scaled by the playback rate.
|
||||
*/
|
||||
export function demoEffectNow(): number {
|
||||
return _effectClockMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the effect clock. Called once per frame from
|
||||
* DemoPlaybackStreaming before other useFrame callbacks run.
|
||||
*/
|
||||
export function advanceEffectClock(deltaSec: number, rate: number): void {
|
||||
_effectClockMs += deltaSec * rate * 1000;
|
||||
}
|
||||
|
||||
/** Reset the effect clock (call when demo recording changes or stops). */
|
||||
export function resetEffectClock(): void {
|
||||
_effectClockMs = 0;
|
||||
}
|
||||
|
||||
// Reset on stop.
|
||||
engineStore.subscribe(
|
||||
(state) => state.playback.status,
|
||||
(status) => {
|
||||
if (status === "stopped") {
|
||||
resetEffectClock();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function useEngineStoreApi() {
|
||||
return engineStore;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ export type {
|
|||
|
||||
export {
|
||||
engineStore,
|
||||
demoEffectNow,
|
||||
advanceEffectClock,
|
||||
resetEffectClock,
|
||||
useEngineSelector,
|
||||
useEngineStoreApi,
|
||||
useRuntimeObjectById,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue