various fixes and performance improvements

This commit is contained in:
Brian Beck 2026-03-05 15:00:05 -08:00
parent cb28b66dad
commit 0c9ddb476a
62 changed files with 3109 additions and 1286 deletions

View file

@ -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 (

View file

@ -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();
};
}

View file

@ -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;

View file

@ -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);

View file

@ -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 016000 (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 016000 range, treat as a scale factor.
// Typical explosions have values like 20008000; 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);

View file

@ -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

View file

@ -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;

View file

@ -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>
);
}

View 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;
}

View 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>
);
}

View file

@ -21,7 +21,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector } 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

View file

@ -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}>

View file

@ -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 (c0c9 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 />

View file

@ -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,

View file

@ -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 (0x020x0e 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);
}
}

View file

@ -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;

View file

@ -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,

View file

@ -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;
}
`;

View file

@ -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;

View file

@ -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;
}

View file

@ -8,6 +8,9 @@ export type {
export {
engineStore,
demoEffectNow,
advanceEffectClock,
resetEffectClock,
useEngineSelector,
useEngineStoreApi,
useRuntimeObjectById,