add shapes test page, particle effects

This commit is contained in:
Brian Beck 2026-03-02 22:57:58 -08:00
parent d9be5c1eba
commit d1acb6a5ce
269 changed files with 5777 additions and 2132 deletions

View file

@ -7,7 +7,7 @@ import { DemoPlayerModel } from "./DemoPlayerModel";
import { DemoShapeModel, DemoWeaponModel } from "./DemoShapeModel";
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
import { PlayerNameplate } from "./PlayerNameplate";
import { useEngineStoreApi } from "../state";
import { useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types";
/**
@ -23,9 +23,11 @@ export function DemoEntityGroup({
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
const name = String(entity.id);
if (entity.visual?.kind === "tracer") {
@ -77,9 +79,7 @@ export function DemoEntityGroup({
// Player entities use skeleton-preserving DemoPlayerModel for animation.
if (entity.type === "Player") {
const isControlPlayer =
entity.id ===
engineStore.getState().playback.recording?.controlPlayerGhostId;
const isControlPlayer = entity.id === controlPlayerGhostId;
return (
<group name={name}>
<group name="model">
@ -103,7 +103,7 @@ export function DemoEntityGroup({
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoShapeModel shapeName={entity.dataBlock} entityId={entity.id} />
<DemoShapeModel shapeName={entity.dataBlock} entityId={entity.id} threads={entity.threads} />
</Suspense>
</ShapeErrorBoundary>
</group>

View file

@ -0,0 +1,715 @@
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
BoxGeometry,
BufferGeometry,
DataTexture,
DoubleSide,
Float32BufferAttribute,
Group,
Mesh,
MeshBasicMaterial,
NormalBlending,
RGBAFormat,
ShaderMaterial,
SphereGeometry,
Texture,
TextureLoader,
Uint16BufferAttribute,
UnsignedByteType,
} from "three";
import { textureToUrl } from "../loaders";
import { setupEffectTexture } from "../demo/demoPlaybackUtils";
import {
EmitterInstance,
resolveEmitterData,
} from "../particles/ParticleSystem";
import {
particleVertexShader,
particleFragmentShader,
} from "../particles/shaders";
import type { EmitterDataResolved } from "../particles/types";
import type {
DemoStreamSnapshot,
DemoStreamingPlayback,
} from "../demo/types";
import { useDebug } from "./SettingsProvider";
// ── Constants ──
const MAX_PARTICLES_PER_EMITTER = 256;
const QUAD_CORNERS = new Float32Array([
-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5,
]);
// ── Texture cache ──
const _textureLoader = new TextureLoader();
const _textureCache = new Map<string, Texture>();
/** Set of textures whose image data has finished loading. */
const _texturesReady = new Set<Texture>();
/** 1×1 white placeholder so particles are visible before async textures load. */
const _placeholderTexture = new DataTexture(
new Uint8Array([255, 255, 255, 255]),
1,
1,
RGBAFormat,
UnsignedByteType,
);
_placeholderTexture.needsUpdate = true;
function getParticleTexture(textureName: string): Texture {
if (!textureName) return _placeholderTexture;
const cached = _textureCache.get(textureName);
if (cached) return cached;
try {
const url = textureToUrl(textureName);
const tex = _textureLoader.load(url, (t) => {
setupEffectTexture(t);
_texturesReady.add(t);
});
setupEffectTexture(tex);
_textureCache.set(textureName, tex);
return tex;
} catch {
return _placeholderTexture;
}
}
// ── Debug geometry (reusable) ──
const _debugOriginGeo = new SphereGeometry(1, 6, 6);
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 ──
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
// ── Geometry builder ──
function createParticleGeometry(maxParticles: number): BufferGeometry {
const geo = new BufferGeometry();
const vertCount = maxParticles * 4;
const indexCount = maxParticles * 6;
// Per-vertex quad corner offsets.
const corners = new Float32Array(vertCount * 2);
for (let i = 0; i < maxParticles; i++) {
corners.set(QUAD_CORNERS, i * 8);
}
// Index buffer.
const indices = new Uint16Array(indexCount);
for (let i = 0; i < maxParticles; i++) {
const vBase = i * 4;
const iBase = i * 6;
indices[iBase] = vBase;
indices[iBase + 1] = vBase + 1;
indices[iBase + 2] = vBase + 2;
indices[iBase + 3] = vBase;
indices[iBase + 4] = vBase + 2;
indices[iBase + 5] = vBase + 3;
}
// Per-particle attributes (4 verts share the same value).
const positions = new Float32Array(vertCount * 3);
const colors = new Float32Array(vertCount * 4);
const sizes = new Float32Array(vertCount);
const spins = new Float32Array(vertCount);
geo.setIndex(new Uint16BufferAttribute(indices, 1));
geo.setAttribute("quadCorner", new Float32BufferAttribute(corners, 2));
geo.setAttribute("position", new Float32BufferAttribute(positions, 3));
geo.setAttribute("particleColor", new Float32BufferAttribute(colors, 4));
geo.setAttribute("particleSize", new Float32BufferAttribute(sizes, 1));
geo.setAttribute("particleSpin", new Float32BufferAttribute(spins, 1));
geo.setDrawRange(0, 0);
return geo;
}
function createParticleMaterial(
texture: Texture,
useInvAlpha: boolean,
): ShaderMaterial {
// Use the placeholder until the real texture's image data is ready.
const ready = _texturesReady.has(texture);
return new ShaderMaterial({
vertexShader: particleVertexShader,
fragmentShader: particleFragmentShader,
uniforms: {
particleTexture: { value: ready ? texture : _placeholderTexture },
hasTexture: { value: true },
},
transparent: true,
depthWrite: false,
depthTest: true,
side: DoubleSide,
blending: useInvAlpha ? NormalBlending : AdditiveBlending,
});
}
// ── Per-emitter rendering state ──
interface ActiveEmitter {
emitter: EmitterInstance;
mesh: Mesh;
geometry: BufferGeometry;
material: ShaderMaterial;
/** The intended texture (may still be loading). */
targetTexture: Texture;
origin: [number, number, number];
isBurst: boolean;
hasBurst: boolean;
/** Entity ID this emitter follows (for projectile trails). */
followEntityId?: string;
/** Debug: origin marker mesh. */
debugOriginMesh?: Mesh;
/** Debug: particle marker meshes. */
debugParticleMeshes?: Mesh[];
}
/** Check if a ShaderMaterial compiled successfully. Must call after first render. */
function checkShaderCompilation(
renderer: import("three").WebGLRenderer,
material: ShaderMaterial,
label: string,
): void {
const props = renderer.properties.get(material) as { currentProgram?: { program: WebGLProgram } };
const program = props.currentProgram;
if (!program) return; // Not yet compiled.
const glProgram = program!.program;
const glContext = renderer.getContext();
if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) {
console.error(
`[ParticleFX] Shader LINK ERROR (${label}):`,
glContext.getProgramInfoLog(glProgram),
);
}
}
// ── Explosion resolution ──
interface ResolvedExplosion {
burstEmitters: Array<{ data: EmitterDataResolved; density: number }>;
streamingEmitters: EmitterDataResolved[];
lifetimeMS: number;
}
function resolveExplosion(
explosionDataBlockId: number,
getDataBlockData: (id: number) => Record<string, unknown> | undefined,
): ResolvedExplosion | null {
const expBlock = getDataBlockData(explosionDataBlockId);
if (!expBlock) {
console.log("[resolveExplosion] getDataBlockData returned undefined for id:", explosionDataBlockId);
return null;
}
// DEBUG: log the raw explosion datablock fields
console.log("[resolveExplosion] expBlock keys:", Object.keys(expBlock), "particleEmitter:", expBlock.particleEmitter, "emitters:", expBlock.emitters, "particleDensity:", expBlock.particleDensity);
const burstEmitters: ResolvedExplosion["burstEmitters"] = [];
const streamingEmitters: EmitterDataResolved[] = [];
// Burst emitter: particleEmitter + particleDensity.
const particleEmitterId = expBlock.particleEmitter as number | null;
if (typeof particleEmitterId === "number") {
const emitterRaw = getDataBlockData(particleEmitterId);
console.log("[resolveExplosion] burst emitter lookup — particleEmitterId:", particleEmitterId, "found:", !!emitterRaw);
if (emitterRaw) {
console.log("[resolveExplosion] burst emitter raw keys:", Object.keys(emitterRaw), "particles:", emitterRaw.particles);
const resolved = resolveEmitterData(emitterRaw, getDataBlockData);
if (resolved) {
const density = (expBlock.particleDensity as number) ?? 10;
console.log("[resolveExplosion] burst emitter RESOLVED — density:", density, "textureName:", resolved.particles.textureName, "particleLifetimeMS:", resolved.particles.lifetimeMS, "emitterLifetimeMS:", resolved.lifetimeMS);
burstEmitters.push({ data: resolved, density });
} else {
console.log("[resolveExplosion] resolveEmitterData returned null for burst emitter");
}
}
} else {
console.log("[resolveExplosion] no particleEmitter field (value:", expBlock.particleEmitter, ")");
}
// Streaming emitters: emitters[0..3].
const emitterRefs = expBlock.emitters as (number | null)[] | undefined;
if (Array.isArray(emitterRefs)) {
console.log("[resolveExplosion] emitters array:", emitterRefs);
for (const ref of emitterRefs) {
if (typeof ref !== "number") continue;
const emitterRaw = getDataBlockData(ref);
if (!emitterRaw) {
console.log("[resolveExplosion] streaming emitter ref", ref, "not found");
continue;
}
console.log("[resolveExplosion] streaming emitter raw keys:", Object.keys(emitterRaw), "particles:", emitterRaw.particles);
const resolved = resolveEmitterData(emitterRaw, getDataBlockData);
if (resolved) {
console.log("[resolveExplosion] streaming emitter RESOLVED — textureName:", resolved.particles.textureName, "particleLifetimeMS:", resolved.particles.lifetimeMS, "emitterLifetimeMS:", resolved.lifetimeMS, "ejectionPeriodMS:", resolved.ejectionPeriodMS);
streamingEmitters.push(resolved);
} else {
console.log("[resolveExplosion] resolveEmitterData returned null for streaming emitter ref:", ref);
}
}
} else {
console.log("[resolveExplosion] no emitters array on expBlock");
}
if (burstEmitters.length === 0 && streamingEmitters.length === 0) {
console.log("[resolveExplosion] no emitters resolved at all, returning null");
return null;
}
// lifetimeMS is in ticks (32ms each) in the demo parser.
const lifetimeTicks = (expBlock.lifetimeMS as number) ?? 31;
const lifetimeMS = lifetimeTicks * 32;
return { burstEmitters, streamingEmitters, lifetimeMS };
}
// ── Update GPU buffers from particle state ──
function syncBuffers(active: ActiveEmitter): void {
const particles = active.emitter.particles;
const geo = active.geometry;
const posAttr = geo.getAttribute("position") as Float32BufferAttribute;
const colorAttr = geo.getAttribute("particleColor") as Float32BufferAttribute;
const sizeAttr = geo.getAttribute("particleSize") as Float32BufferAttribute;
const spinAttr = geo.getAttribute("particleSpin") 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 count = Math.min(particles.length, MAX_PARTICLES_PER_EMITTER);
for (let i = 0; i < count; i++) {
const p = particles[i];
// Swizzle Torque [x,y,z] → Three.js [y,z,x].
const tx = p.pos[1];
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);
const la = p.a;
// Write the same values to all 4 vertices of the quad.
for (let v = 0; v < 4; v++) {
const vi = i * 4 + v;
const pi = vi * 3;
posArr[pi] = tx;
posArr[pi + 1] = ty;
posArr[pi + 2] = tz;
const ci = vi * 4;
colArr[ci] = lr;
colArr[ci + 1] = lg;
colArr[ci + 2] = lb;
colArr[ci + 3] = la;
sizeArr[vi] = p.size;
spinArr[vi] = p.currentSpin;
}
}
// Zero out unused vertices so they collapse to zero-area quads.
for (let i = count; i < MAX_PARTICLES_PER_EMITTER; i++) {
for (let v = 0; v < 4; v++) {
sizeArr[i * 4 + v] = 0;
}
}
posAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
sizeAttr.needsUpdate = true;
spinAttr.needsUpdate = true;
geo.setDrawRange(0, count * 6);
}
// ── Main component ──
export function DemoParticleEffects({
playback,
snapshotRef,
}: {
playback: DemoStreamingPlayback;
snapshotRef: React.RefObject<DemoStreamSnapshot | null>;
}) {
const { debugMode } = useDebug();
const gl = useThree((s) => s.gl);
const groupRef = useRef<Group>(null);
const activeEmittersRef = useRef<ActiveEmitter[]>([]);
/** Track which explosion entity IDs we've already processed. */
const processedExplosionsRef = useRef<Set<string>>(new Set());
/** Track which projectile entity IDs have trail emitters attached. */
const trailEntitiesRef = useRef<Set<string>>(new Set());
/** Throttle for periodic debug logs. */
const lastDebugLogRef = useRef(0);
useEffect(() => {
console.log("[ParticleFX] MOUNTED — playback:", !!playback, "snapshotRef:", !!snapshotRef);
}, [playback, snapshotRef]);
useFrame((_, delta) => {
const group = groupRef.current;
const snapshot = snapshotRef.current;
if (!group || !snapshot) {
// DEBUG: log when snapshot or group is missing
console.log("[ParticleFX] early return — group:", !!group, "snapshot:", !!snapshot);
return;
}
const dtMS = delta * 1000;
const getDataBlockData = playback.getDataBlockData.bind(playback);
// DEBUG: periodically log entity type counts (every 2 seconds).
const now = performance.now();
if (now - lastDebugLogRef.current > 2000) {
lastDebugLogRef.current = now;
const typeCounts: Record<string, number> = {};
let withMaintainEmitter = 0;
let withExplosionDataBlockId = 0;
for (const e of snapshot.entities) {
typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
if (e.maintainEmitterId) withMaintainEmitter++;
if (e.explosionDataBlockId) withExplosionDataBlockId++;
}
console.log(
"[ParticleFX] types:", typeCounts,
"| active emitters:", activeEmittersRef.current.length,
"| processedExplosions:", processedExplosionsRef.current.size,
"| trailEntities:", trailEntitiesRef.current.size,
"| withExplosionDataBlockId:", withExplosionDataBlockId,
"| withMaintainEmitter:", withMaintainEmitter,
);
}
// Detect new explosion entities and create emitters.
for (const entity of snapshot.entities) {
if (
entity.type !== "Explosion" ||
!entity.explosionDataBlockId ||
!entity.position
) {
// DEBUG: log entities that are type "Explosion" but fail the other checks
if (entity.type === "Explosion") {
console.log("[ParticleFX] Explosion entity SKIPPED — id:", entity.id, "explosionDataBlockId:", entity.explosionDataBlockId, "position:", entity.position);
}
continue;
}
if (processedExplosionsRef.current.has(entity.id)) continue;
processedExplosionsRef.current.add(entity.id);
// DEBUG: log new explosion entity being processed
console.log("[ParticleFX] NEW explosion entity:", entity.id, "dataBlockId:", entity.explosionDataBlockId, "pos:", entity.position);
const resolved = resolveExplosion(
entity.explosionDataBlockId,
getDataBlockData,
);
if (!resolved) {
console.log("[ParticleFX] resolveExplosion returned null for dataBlockId:", entity.explosionDataBlockId);
continue;
}
// DEBUG: log resolved explosion details
console.log("[ParticleFX] resolveExplosion OK — burstEmitters:", resolved.burstEmitters.length, "streamingEmitters:", resolved.streamingEmitters.length, "lifetimeMS:", resolved.lifetimeMS);
const origin: [number, number, number] = [...entity.position];
// Create burst emitters.
for (const burst of resolved.burstEmitters) {
const emitter = new EmitterInstance(
burst.data,
MAX_PARTICLES_PER_EMITTER,
);
emitter.emitBurst(origin, burst.density);
// DEBUG: log burst emitter creation
console.log("[ParticleFX] Created BURST emitter — particles after burst:", emitter.particles.length, "origin:", origin, "texture:", burst.data.particles.textureName, "particleLifetimeMS:", burst.data.particles.lifetimeMS, "keyframes:", burst.data.particles.keys.length, "key0:", burst.data.particles.keys[0]);
const texture = getParticleTexture(burst.data.particles.textureName);
console.log("[ParticleFX] burst texture loaded:", !!texture, "textureName:", burst.data.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
texture,
burst.data.particles.useInvAlpha,
);
const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false;
group.add(mesh);
activeEmittersRef.current.push({
emitter,
mesh,
geometry,
material,
targetTexture: texture,
origin,
isBurst: true,
hasBurst: true,
});
}
// Create streaming emitters (lifetime capped by explosion duration).
for (const emitterData of resolved.streamingEmitters) {
const emitter = new EmitterInstance(
emitterData,
MAX_PARTICLES_PER_EMITTER,
resolved.lifetimeMS,
);
// DEBUG: log streaming emitter creation
console.log("[ParticleFX] Created STREAMING emitter — emitterLifetimeMS:", emitterData.lifetimeMS, "ejectionPeriodMS:", emitterData.ejectionPeriodMS, "origin:", origin, "texture:", emitterData.particles.textureName, "particleLifetimeMS:", emitterData.particles.lifetimeMS);
const texture = getParticleTexture(emitterData.particles.textureName);
console.log("[ParticleFX] streaming texture loaded:", !!texture, "textureName:", emitterData.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
texture,
emitterData.particles.useInvAlpha,
);
const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false;
group.add(mesh);
activeEmittersRef.current.push({
emitter,
mesh,
geometry,
material,
targetTexture: texture,
origin,
isBurst: false,
hasBurst: false,
});
}
}
// Detect projectile entities with trail emitters (maintainEmitterId).
const currentEntityIds = new Set<string>();
for (const entity of snapshot.entities) {
currentEntityIds.add(entity.id);
if (!entity.maintainEmitterId || trailEntitiesRef.current.has(entity.id)) {
continue;
}
trailEntitiesRef.current.add(entity.id);
const emitterRaw = getDataBlockData(entity.maintainEmitterId);
if (!emitterRaw) continue;
const emitterData = resolveEmitterData(emitterRaw, getDataBlockData);
if (!emitterData) continue;
const origin: [number, number, number] = entity.position
? [...entity.position]
: [0, 0, 0];
const emitter = new EmitterInstance(emitterData, MAX_PARTICLES_PER_EMITTER);
console.log(
"[ParticleFX] Created TRAIL emitter for",
entity.type,
entity.id,
"— maintainEmitterId:",
entity.maintainEmitterId,
"texture:",
emitterData.particles.textureName,
);
const texture = getParticleTexture(emitterData.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
const material = createParticleMaterial(
texture,
emitterData.particles.useInvAlpha,
);
const mesh = new Mesh(geometry, material);
mesh.frustumCulled = false;
group.add(mesh);
activeEmittersRef.current.push({
emitter,
mesh,
geometry,
material,
targetTexture: texture,
origin,
isBurst: false,
hasBurst: false,
followEntityId: entity.id,
});
}
// Mark trail emitters as dead when their projectile disappears.
for (const entry of activeEmittersRef.current) {
if (entry.followEntityId && !currentEntityIds.has(entry.followEntityId)) {
entry.emitter.kill();
}
}
// Prune trail entity tracking set.
for (const id of trailEntitiesRef.current) {
if (!currentEntityIds.has(id)) {
trailEntitiesRef.current.delete(id);
}
}
// Update all active emitters.
const active = activeEmittersRef.current;
for (let i = active.length - 1; i >= 0; i--) {
const entry = active[i];
// One-time shader compilation check on first frame.
checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream");
// Update trail emitter origin to follow the projectile's position.
if (entry.followEntityId) {
const tracked = snapshot.entities.find(
(e) => e.id === entry.followEntityId,
);
if (tracked?.position) {
entry.origin[0] = tracked.position[0];
entry.origin[1] = tracked.position[1];
entry.origin[2] = tracked.position[2];
}
}
// Streaming emitters emit periodically.
if (!entry.isBurst) {
entry.emitter.emitPeriodic(entry.origin, dtMS);
}
// Advance physics and interpolation.
entry.emitter.update(dtMS);
// DEBUG: log particle state on first few frames of each emitter
if (entry.emitter.particles.length > 0 && Math.random() < 0.02) {
const p0 = entry.emitter.particles[0];
console.log("[ParticleFX] update — isBurst:", entry.isBurst, "particleCount:", entry.emitter.particles.length, "p0.pos:", p0.pos, "p0.size:", p0.size, "p0.a:", p0.a, "p0.age/lifetime:", p0.currentAge, "/", p0.totalLifetime, "drawRange:", entry.geometry.drawRange);
}
// Swap in the real texture once it finishes loading.
if (
_texturesReady.has(entry.targetTexture) &&
entry.material.uniforms.particleTexture.value !== entry.targetTexture
) {
entry.material.uniforms.particleTexture.value = entry.targetTexture;
}
// Sync GPU buffers.
syncBuffers(entry);
// Debug visualization: place markers at origin and particle positions.
if (debugMode) {
// Origin marker (red wireframe sphere).
if (!entry.debugOriginMesh) {
entry.debugOriginMesh = new Mesh(_debugOriginGeo, _debugOriginMat);
entry.debugOriginMesh.frustumCulled = false;
group.add(entry.debugOriginMesh);
}
// Swizzle origin to Three.js coordinates.
entry.debugOriginMesh.position.set(
entry.origin[1],
entry.origin[2],
entry.origin[0],
);
// Particle markers (green wireframe boxes) — show up to 8.
if (!entry.debugParticleMeshes) {
entry.debugParticleMeshes = [];
}
const maxDebugParticles = Math.min(entry.emitter.particles.length, 8);
// Add meshes if needed.
while (entry.debugParticleMeshes.length < maxDebugParticles) {
const m = new Mesh(_debugParticleGeo, _debugParticleMat);
m.frustumCulled = false;
group.add(m);
entry.debugParticleMeshes.push(m);
}
// Update positions or hide extras.
for (let j = 0; j < entry.debugParticleMeshes.length; j++) {
const dm = entry.debugParticleMeshes[j];
if (j < entry.emitter.particles.length) {
const p = entry.emitter.particles[j];
dm.position.set(p.pos[1], p.pos[2], p.pos[0]);
dm.visible = true;
} else {
dm.visible = false;
}
}
} else {
// Clean up debug meshes when debug mode is off.
if (entry.debugOriginMesh) {
group.remove(entry.debugOriginMesh);
entry.debugOriginMesh = undefined;
}
if (entry.debugParticleMeshes) {
for (const dm of entry.debugParticleMeshes) {
group.remove(dm);
}
entry.debugParticleMeshes = undefined;
}
}
// Remove dead emitters.
if (entry.emitter.isDead()) {
console.log("[ParticleFX] removing DEAD emitter — isBurst:", entry.isBurst, "origin:", entry.origin);
group.remove(entry.mesh);
entry.geometry.dispose();
entry.material.dispose();
if (entry.debugOriginMesh) group.remove(entry.debugOriginMesh);
if (entry.debugParticleMeshes) {
for (const dm of entry.debugParticleMeshes) group.remove(dm);
}
active.splice(i, 1);
}
}
// Prune processed set when it gets large.
if (processedExplosionsRef.current.size > 500) {
const currentIds = new Set(snapshot.entities.map((e) => e.id));
for (const id of processedExplosionsRef.current) {
if (!currentIds.has(id)) {
processedExplosionsRef.current.delete(id);
}
}
}
});
// Cleanup on unmount.
useEffect(() => {
return () => {
const group = groupRef.current;
for (const entry of activeEmittersRef.current) {
if (group) {
group.remove(entry.mesh);
if (entry.debugOriginMesh) group.remove(entry.debugOriginMesh);
if (entry.debugParticleMeshes) {
for (const dm of entry.debugParticleMeshes) group.remove(dm);
}
}
entry.geometry.dispose();
entry.material.dispose();
}
activeEmittersRef.current = [];
processedExplosionsRef.current.clear();
trailEntitiesRef.current.clear();
};
}, []);
return <group ref={groupRef} />;
}

View file

@ -30,9 +30,6 @@ function DemoPlaybackDiagnostics({ recording }: { recording: DemoRecording }) {
meta: {
missionName: recording.missionName ?? null,
gameType: recording.gameType ?? null,
isMetadataOnly: !!recording.isMetadataOnly,
isPartial: !!recording.isPartial,
hasStreamingPlayback: !!recording.streamingPlayback,
durationSec: Number(recording.duration.toFixed(3)),
},
});

View file

@ -4,34 +4,48 @@ import { useGLTF } from "@react-three/drei";
import {
Group,
Quaternion,
Raycaster,
Vector3,
} from "three";
import {
buildStreamDemoEntity,
CAMERA_COLLISION_RADIUS,
DEFAULT_EYE_HEIGHT,
hasAncestorNamed,
nextLifecycleInstanceId,
streamSnapshotSignature,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../demo/demoPlaybackUtils";
import { shapeToUrl } from "../loaders";
import { TickProvider } from "./TickProvider";
import { DemoEntityGroup } from "./DemoEntities";
import { DemoParticleEffects } from "./DemoParticleEffects";
import { PlayerEyeOffset } from "./DemoPlayerModel";
import { useEngineStoreApi } from "../state";
import type { DemoEntity, DemoRecording, DemoStreamSnapshot } from "../demo/types";
import type {
DemoEntity,
DemoRecording,
DemoStreamEntity,
DemoStreamSnapshot,
} from "../demo/types";
type EntityById = Map<string, DemoStreamEntity>;
/** Cache entity-by-id Maps per snapshot so they're built once, not every frame. */
const _snapshotEntityCache = new WeakMap<DemoStreamSnapshot, EntityById>();
function getEntityMap(snapshot: DemoStreamSnapshot): EntityById {
let map = _snapshotEntityCache.get(snapshot);
if (!map) {
map = new Map(snapshot.entities.map((e) => [e.id, e]));
_snapshotEntityCache.set(snapshot, map);
}
return map;
}
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
const _billboardFlip = new Quaternion(0, 1, 0, 0); // 180° around Y
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
const _hitNormal = new Vector3();
const _orbitRaycaster = new Raycaster();
let streamingDemoPlaybackMountCount = 0;
let streamingDemoPlaybackUnmountCount = 0;
@ -50,8 +64,8 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef(recording.streamingPlayback ?? null);
const publishedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const entitySignatureRef = useRef("");
const entityMapRef = useRef<Map<string, DemoEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const lastEntityRebuildEventMsRef = useRef(0);
const exhaustedEventLoggedRef = useRef(false);
const [entities, setEntities] = useState<DemoEntity[]>([]);
@ -109,13 +123,18 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}, [engineStore]);
const syncRenderableEntities = useCallback((snapshot: DemoStreamSnapshot) => {
const previousEntityCount = entityMapRef.current.size;
const nextSignature = streamSnapshotSignature(snapshot);
const shouldRebuild = entitySignatureRef.current !== nextSignature;
if (snapshot === lastSyncedSnapshotRef.current) return;
lastSyncedSnapshotRef.current = snapshot;
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;
for (const entity of snapshot.entities) {
let renderEntity = entityMapRef.current.get(entity.id);
let renderEntity = prevMap.get(entity.id);
if (
!renderEntity ||
renderEntity.type !== entity.type ||
@ -139,6 +158,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
entity.dataBlockId,
entity.shapeHint,
);
shouldRebuild = true;
}
renderEntity.playerName = entity.playerName;
@ -151,6 +171,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
renderEntity.ghostIndex = entity.ghostIndex;
renderEntity.dataBlockId = entity.dataBlockId;
renderEntity.shapeHint = entity.shapeHint;
renderEntity.threads = entity.threads;
if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({
@ -176,7 +197,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
entityMapRef.current = nextMap;
if (shouldRebuild) {
entitySignatureRef.current = nextSignature;
setEntities(Array.from(nextMap.values()));
const now = Date.now();
if (now - lastEntityRebuildEventMsRef.current >= 500) {
@ -185,7 +205,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
kind: "stream.entities.rebuild",
message: "Renderable demo entity list was rebuilt",
meta: {
previousEntityCount,
previousEntityCount: prevMap.size,
nextEntityCount: nextMap.size,
snapshotTimeSec: Number(snapshot.timeSec.toFixed(3)),
},
@ -208,7 +228,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
useEffect(() => {
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
entitySignatureRef.current = "";
lastSyncedSnapshotRef.current = null;
publishedSnapshotRef.current = null;
timeRef.current = 0;
playbackClockRef.current = 0;
@ -383,8 +403,8 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
}
const currentEntities = new Map(renderCurrent.entities.map((e) => [e.id, e]));
const previousEntities = new Map(renderPrev.entities.map((e) => [e.id, e]));
const currentEntities = getEntityMap(renderCurrent);
const previousEntities = getEntityMap(renderPrev);
const root = rootRef.current;
if (root) {
for (const child of root.children) {
@ -412,7 +432,7 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
}
if (entity.faceViewer) {
child.quaternion.copy(state.camera.quaternion);
child.quaternion.copy(state.camera.quaternion).multiply(_billboardFlip);
} else if (entity.visual?.kind === "tracer") {
child.quaternion.identity();
} else if (entity.rotation) {
@ -466,31 +486,6 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
_orbitCandidate.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance);
// Mirror Camera::validateEyePoint: cast 2.5x desired distance toward
// the candidate and pull in if an obstacle blocks the orbit.
_orbitRaycaster.near = 0.001;
_orbitRaycaster.far = orbitDistance * 2.5;
_orbitRaycaster.camera = state.camera;
_orbitRaycaster.set(_orbitTarget, _orbitDir);
const hits = _orbitRaycaster.intersectObjects(state.scene.children, true);
for (const hit of hits) {
if (hit.distance <= 0.0001) continue;
if (hasAncestorNamed(hit.object, currentCamera.orbitTargetId)) continue;
if (!hit.face) break;
_hitNormal.copy(hit.face.normal).transformDirection(hit.object.matrixWorld);
const dot = -_orbitDir.dot(_hitNormal);
if (dot > 0.01) {
let colDist = hit.distance - CAMERA_COLLISION_RADIUS / dot;
if (colDist > orbitDistance) colDist = orbitDistance;
if (colDist < 0) colDist = 0;
_orbitCandidate
.copy(_orbitTarget)
.addScaledVector(_orbitDir, colDist);
}
break;
}
state.camera.position.copy(_orbitCandidate);
state.camera.lookAt(_orbitTarget);
}
@ -539,6 +534,10 @@ export function StreamingDemoPlayback({ recording }: { recording: DemoRecording
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} />
))}
</group>
<DemoParticleEffects
playback={recording.streamingPlayback}
snapshotRef={currentTickSnapshotRef}
/>
{firstPersonShape && (
<Suspense fallback={null}>
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} />

View file

@ -19,9 +19,10 @@ import {
processShapeScene,
} from "../demo/demoPlaybackUtils";
import { pickMoveAnimation } from "../demo/playerAnimation";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape } from "./GenericShape";
import { ShapeErrorBoundary } from "./DemoEntities";
import { useEngineStoreApi } from "../state";
import { useEngineStoreApi, useEngineSelector } from "../state";
import type { DemoEntity } from "../demo/types";
/**
@ -41,6 +42,12 @@ export function DemoPlayerModel({
}) {
const engineStore = useEngineStoreApi();
const gltf = useStaticShape(entity.dataBlock!);
const shapeAliases = useEngineSelector((state) => {
const shapeName = entity.dataBlock?.toLowerCase();
return shapeName
? state.runtime.sequenceAliases.get(shapeName)
: undefined;
});
// Clone scene preserving skeleton bindings, create mixer, find Mount0 bone.
const { clonedScene, mixer, mount0 } = useMemo(() => {
@ -56,25 +63,21 @@ export function DemoPlayerModel({
return { clonedScene: scene, mixer: mix, mount0: m0 };
}, [gltf]);
// Build case-insensitive clip lookup and start with Root animation.
// Build case-insensitive clip lookup with alias support.
const animActionsRef = useRef(new Map<string, AnimationAction>());
const currentAnimRef = useRef({ name: "Root", timeScale: 1 });
const currentAnimRef = useRef({ name: "root", timeScale: 1 });
const isDeadRef = useRef(false);
useEffect(() => {
const actions = new Map<string, AnimationAction>();
for (const clip of gltf.animations) {
const action = mixer.clipAction(clip);
actions.set(clip.name.toLowerCase(), action);
}
const actions = getAliasedActions(gltf.animations, mixer, shapeAliases);
animActionsRef.current = actions;
// Start with Root (idle) animation.
// Start with root (idle) animation.
const rootAction = actions.get("root");
if (rootAction) {
rootAction.play();
}
currentAnimRef.current = { name: "Root", timeScale: 1 };
currentAnimRef.current = { name: "root", timeScale: 1 };
// Force initial pose evaluation.
mixer.update(0);
@ -83,7 +86,7 @@ export function DemoPlayerModel({
mixer.stopAllAction();
animActionsRef.current = new Map();
};
}, [mixer, gltf.animations]);
}, [mixer, gltf.animations, shapeAliases]);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
@ -101,7 +104,7 @@ export function DemoPlayerModel({
isDeadRef.current = true;
const deathClips = [...actions.keys()].filter((k) =>
k.startsWith("die"),
k.startsWith("death"),
);
if (deathClips.length > 0) {
const pick = deathClips[Math.floor(Math.random() * deathClips.length)];
@ -129,7 +132,7 @@ export function DemoPlayerModel({
deathAction.clampWhenFinished = false;
}
// Reset to root so movement selection picks up on next iteration.
currentAnimRef.current = { name: "Root", timeScale: 1 };
currentAnimRef.current = { name: "root", timeScale: 1 };
const rootAction = actions.get("root");
if (rootAction) rootAction.reset().play();
}

View file

@ -56,12 +56,7 @@ export function useDemoActions() {
);
const play = useCallback(() => {
if (
(recording?.isMetadataOnly || recording?.isPartial) &&
!recording.streamingPlayback
) {
return;
}
if (!recording) return;
setPlaybackStatus("playing");
}, [recording, setPlaybackStatus]);

View file

@ -11,14 +11,17 @@ import {
} from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
import type { DemoThreadState } from "../demo/types";
/** Renders a shape model for a demo entity using the existing shape pipeline. */
export function DemoShapeModel({
shapeName,
entityId,
threads,
}: {
shapeName: string;
entityId: number | string;
threads?: DemoThreadState[];
}) {
const torqueObject = useMemo<TorqueObject>(
() => ({
@ -35,7 +38,7 @@ export function DemoShapeModel({
shapeName={shapeName}
type="StaticShape"
>
<ShapeRenderer loadingColor="#00ff88" />
<ShapeRenderer loadingColor="#00ff88" demoThreads={threads} />
</ShapeInfoProvider>
);
}

View file

@ -1,26 +1,43 @@
import { memo, Suspense, useMemo, useRef } from "react";
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
import {
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
AnimationMixer,
AnimationClip,
LoopOnce,
LoopRepeat,
Texture,
BufferGeometry,
Group,
} from "three";
import type { AnimationAction } from "three";
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 { FloatingLabel } from "./FloatingLabel";
import { useIflTexture } from "./useIflTexture";
import {
useIflTexture,
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
import {
processShapeScene,
replaceWithShapeMaterial,
} from "../demo/demoPlaybackUtils";
import type { DemoThreadState } from "../demo/types";
/** Shared props for texture rendering components */
interface TextureProps {
@ -83,8 +100,10 @@ export function createMaterialFromFlags(
// Animated vis also needs transparent materials so opacity can be updated per frame.
const isFaded = vis < 1 || animated;
// SelfIlluminating materials are unlit (use MeshBasicMaterial)
if (isSelfIlluminating) {
// SelfIlluminating or Additive materials are unlit (use MeshBasicMaterial).
// Additive materials without SelfIlluminating (e.g. explosion shells) must
// also be unlit, otherwise they render black with no scene lighting.
if (isSelfIlluminating || isAdditive) {
const isBlended = isAdditive || isTranslucent || isFaded;
const mat = new MeshBasicMaterial({
map: texture,
@ -399,9 +418,11 @@ function HardcodedShape({ label }: { label?: string }) {
*/
export function ShapeRenderer({
loadingColor = "yellow",
demoThreads,
children,
}: {
loadingColor?: string;
demoThreads?: DemoThreadState[];
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
@ -423,259 +444,594 @@ export function ShapeRenderer({
}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModel />
<ShapeModelLoader demoThreads={demoThreads} />
{children}
</Suspense>
</ErrorBoundary>
);
}
/** Check if a GLB node has an auto-playing "Ambient" vis animation. */
function hasAmbientVisAnimation(userData: any): boolean {
return (
userData != null &&
(userData.vis_sequence ?? "").toLowerCase() === "ambient" &&
Array.isArray(userData.vis_keyframes) &&
userData.vis_keyframes.length > 1 &&
(userData.vis_duration ?? 0) > 0
);
}
/**
* Wraps child meshes and animates their material opacity using DTS vis keyframes.
* Used for auto-playing "Ambient" sequences (glow pulses, light effects).
*/
function AnimatedVisGroup({
keyframes,
duration,
cyclic,
children,
}: {
/** Vis node info collected from the scene for vis opacity animation. */
interface VisNode {
mesh: any;
keyframes: number[];
duration: number;
cyclic: boolean;
children: React.ReactNode;
}) {
const groupRef = useRef<Group>(null);
const { animationEnabled } = useSettings();
useFrame(() => {
const group = groupRef.current;
if (!group) return;
if (!animationEnabled) {
group.traverse((child) => {
if ((child as any).isMesh) {
const mat = (child as any).material;
if (mat && !Array.isArray(mat)) {
mat.opacity = keyframes[0];
}
}
});
return;
}
const elapsed = performance.now() / 1000;
const t = cyclic
? (elapsed % duration) / duration
: Math.min(elapsed / duration, 1);
const n = keyframes.length;
const pos = t * n;
const lo = Math.floor(pos) % n;
const hi = (lo + 1) % n;
const frac = pos - Math.floor(pos);
const vis = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
group.traverse((child) => {
if ((child as any).isMesh) {
const mat = (child as any).material;
if (mat && !Array.isArray(mat)) {
mat.opacity = vis;
}
}
});
});
return <group ref={groupRef}>{children}</group>;
}
export const ShapeModel = memo(function ShapeModel() {
const { object, shapeName, isOrganic } = useShapeInfo();
/** Active animation thread state, keyed by thread slot number. */
interface ThreadState {
sequence: string;
action?: AnimationAction;
visNodes?: VisNode[];
startTime: number;
}
// Thread slot constants matching power.cs globals
const DEPLOY_THREAD = 3;
/**
* Unified shape renderer. Clones the full scene graph (preserving skeleton
* bindings), applies Tribes 2 materials via processShapeScene, and drives
* animation threads either through TorqueScript (for deployable shapes with
* a runtime) or directly (ambient/power vis sequences).
*/
export const ShapeModel = memo(function ShapeModel({
gltf,
demoThreads,
}: {
gltf: ReturnType<typeof useStaticShape>;
demoThreads?: DemoThreadState[];
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
const { nodes } = useStaticShape(shapeName);
const { animationEnabled } = useSettings();
const runtime = useEngineSelector((state) => state.runtime.runtime);
const hullBoneIndices = useMemo(() => {
const skeletonsFound = Object.values(nodes).filter(
(node: any) => node.skeleton,
);
const {
clonedScene,
mixer,
clipsByName,
visNodesBySequence,
iflMeshes,
} = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
if (skeletonsFound.length > 0) {
const skeleton = (skeletonsFound[0] as any).skeleton;
return getHullBoneIndices(skeleton);
// Detect IFL materials BEFORE processShapeScene replaces them, since the
// replacement materials lose the original userData (flag_names, resource_path).
const iflInfos: Array<{
mesh: any;
iflPath: string;
hasVisSequence: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
iflToolBegin?: number;
}> = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
// ifl_sequence is set by the addon when ifl_matters links this IFL to
// a controlling sequence. vis_sequence is a separate system (opacity
// animation) and must NOT be used as a fallback — the two are independent.
const iflSeq = ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined;
const iflDur = ud?.ifl_duration
? Number(ud.ifl_duration)
: undefined;
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
const iflToolBegin = ud?.ifl_tool_begin != null
? Number(ud.ifl_tool_begin)
: undefined;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!(ud?.vis_sequence),
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
iflToolBegin,
});
}
});
processShapeScene(scene);
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
// sequence is activated by playThread.
for (const { mesh, hasVisSequence } of iflInfos) {
if (!hasVisSequence) {
mesh.visible = true;
}
}
return new Set<number>();
}, [nodes]);
const processedNodes = useMemo(() => {
return Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
node.material &&
node.material.name !== "Unassigned" &&
!node.name.match(/^Hulk/i) &&
// DTS per-object visibility: skip invisible objects (engine threshold
// is 0.01) unless they have an Ambient vis animation that will bring
// them to life (e.g. glow effects that pulse from 0 to 1).
((node.userData?.vis ?? 1) > 0.01 ||
hasAmbientVisAnimation(node.userData)),
)
.map(([name, node]: [string, any]) => {
let geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices,
);
let backGeometry = null;
// Collect ALL vis-animated nodes, grouped by sequence name.
const visBySeq = new Map<string, VisNode[]>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
if (!ud) return;
const kf = ud.vis_keyframes;
const dur = ud.vis_duration;
const seqName = (ud.vis_sequence ?? "").toLowerCase();
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
return;
// Compute smooth vertex normals for ALL shapes to match Tribes 2's lighting
if (geometry) {
geometry = geometry.clone();
let list = visBySeq.get(seqName);
if (!list) {
list = [];
visBySeq.set(seqName, list);
}
list.push({
mesh: node,
keyframes: kf,
duration: dur,
cyclic: !!ud.vis_cyclic,
});
});
// First compute face normals
geometry.computeVertexNormals();
// Build clips by name (case-insensitive)
const clips = new Map<string, AnimationClip>();
for (const clip of gltf.animations) {
clips.set(clip.name.toLowerCase(), clip);
}
// Then smooth normals across vertices at the same position
// This handles split vertices (for UV seams) that computeVertexNormals misses
const posAttr = geometry.attributes.position;
const normAttr = geometry.attributes.normal;
const positions = posAttr.array as Float32Array;
const normals = normAttr.array as Float32Array;
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
// Build a map of position -> list of vertex indices at that position
const positionMap = new Map<string, number[]>();
for (let i = 0; i < posAttr.count; i++) {
// Round to avoid floating point precision issues
const key = `${positions[i * 3].toFixed(4)},${positions[i * 3 + 1].toFixed(4)},${positions[i * 3 + 2].toFixed(4)}`;
if (!positionMap.has(key)) {
positionMap.set(key, []);
}
positionMap.get(key)!.push(i);
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf]);
const threadsRef = useRef(new Map<number, ThreadState>());
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());
interface IflAnimInfo {
atlas: IflAtlas;
sequenceName?: string;
/** Controlling sequence duration in seconds. */
sequenceDuration?: number;
cyclic?: boolean;
/** Torque `toolBegin`: offset into IFL timeline (seconds). */
toolBegin?: number;
}
const iflAnimInfosRef = useRef<IflAnimInfo[]>([]);
const iflTimeRef = useRef(0);
const animationEnabledRef = useRef(animationEnabled);
animationEnabledRef.current = animationEnabled;
// Stable ref for the deploy-end callback so useFrame can advance the
// lifecycle when animation is toggled off mid-deploy.
const onDeployEndRef = useRef<((slot: number) => void) | null>(null);
// Refs for demo thread-driven animation (exposed from the main animation effect).
const demoThreadsRef = useRef(demoThreads);
demoThreadsRef.current = demoThreads;
const handlePlayThreadRef = useRef<((slot: number, seq: string) => void) | null>(null);
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
const prevDemoThreadsRef = useRef<DemoThreadState[] | undefined>(undefined);
// Load IFL texture atlases imperatively (processShapeScene can't resolve
// .ifl paths since they require async loading of the frame list).
useEffect(() => {
iflAnimInfosRef.current = [];
iflMeshAtlasRef.current.clear();
for (const info of iflMeshes) {
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;
}
iflAnimInfosRef.current.push({
atlas,
sequenceName: info.iflSequence,
sequenceDuration: info.iflDuration,
cyclic: info.iflCyclic,
toolBegin: info.iflToolBegin,
});
iflMeshAtlasRef.current.set(info.mesh, atlas);
})
.catch(() => {});
}
}, [iflMeshes]);
// Average normals for vertices at the same position
for (const indices of positionMap.values()) {
if (indices.length > 1) {
// Sum all normals at this position
let nx = 0,
ny = 0,
nz = 0;
for (const idx of indices) {
nx += normals[idx * 3];
ny += normals[idx * 3 + 1];
nz += normals[idx * 3 + 2];
}
// Normalize the sum
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
nx /= len;
ny /= len;
nz /= len;
}
// Apply averaged normal to all vertices at this position
for (const idx of indices) {
normals[idx * 3] = nx;
normals[idx * 3 + 1] = ny;
normals[idx * 3 + 2] = nz;
}
}
}
normAttr.needsUpdate = true;
// Animation setup. Shared helpers (handlePlayThread, handleStopThread) are
// used by both mission rendering and demo playback. The lifecycle that
// decides WHEN to call them differs: mission mode auto-plays deploy and
// subscribes to TorqueScript; demo mode does nothing on mount and lets
// the useFrame handler drive everything from ghost thread data.
useEffect(() => {
const threads = threadsRef.current;
// For organic shapes, also create back geometry with flipped normals
if (isOrganic) {
backGeometry = geometry.clone();
const backNormAttr = backGeometry.attributes.normal;
const backNormals = backNormAttr.array;
for (let i = 0; i < backNormals.length; i++) {
backNormals[i] = -backNormals[i];
}
backNormAttr.needsUpdate = true;
function prepareVisNode(v: VisNode) {
v.mesh.visible = true;
if (v.mesh.material?.isMeshStandardMaterial) {
const mat = v.mesh.material as MeshStandardMaterial;
const result = replaceWithShapeMaterial(mat, v.mesh.userData?.vis ?? 0);
v.mesh.material = Array.isArray(result) ? result[1] : result;
}
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.transparent = true;
v.mesh.material.depthWrite = false;
}
const atlas = iflMeshAtlasRef.current.get(v.mesh);
if (atlas && v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.map = atlas.texture;
v.mesh.material.needsUpdate = true;
}
}
function handlePlayThread(slot: number, sequenceName: string) {
const seqLower = sequenceName.toLowerCase();
handleStopThread(slot);
const clip = clipsByName.get(seqLower);
const vNodes = visNodesBySequence.get(seqLower);
const thread: ThreadState = {
sequence: seqLower,
startTime: performance.now() / 1000,
};
if (clip && mixer) {
const action = mixer.clipAction(clip);
if (seqLower === "deploy") {
action.setLoop(LoopOnce, 1);
action.clampWhenFinished = true;
} else {
action.setLoop(LoopRepeat, Infinity);
}
action.reset().play();
thread.action = action;
// When animations are disabled, snap deploy to its end pose.
if (!animationEnabledRef.current && seqLower === "deploy") {
action.time = clip.duration;
mixer.update(0);
// In mission mode, onDeployEndRef advances the lifecycle.
// In demo mode it's null — the ghost data drives what's next.
if (onDeployEndRef.current) {
queueMicrotask(() => onDeployEndRef.current?.(slot));
}
}
}
const vis: number = node.userData?.vis ?? 1;
const visAnim = hasAmbientVisAnimation(node.userData)
? {
keyframes: node.userData.vis_keyframes as number[],
duration: node.userData.vis_duration as number,
cyclic: !!node.userData.vis_cyclic,
if (vNodes) {
for (const v of vNodes) prepareVisNode(v);
thread.visNodes = vNodes;
}
threads.set(slot, thread);
}
function handleStopThread(slot: number) {
const thread = threads.get(slot);
if (!thread) return;
if (thread.action) thread.action.stop();
if (thread.visNodes) {
for (const v of thread.visNodes) {
v.mesh.visible = false;
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.opacity = v.keyframes[0];
}
}
}
threads.delete(slot);
}
handlePlayThreadRef.current = handlePlayThread;
handleStopThreadRef.current = handleStopThread;
// ── Demo playback: all animation driven by ghost thread data ──
// No deploy lifecycle, no auto-play, no TorqueScript. The useFrame
// handler reads demoThreads and calls handlePlayThread/handleStopThread.
if (demoThreadsRef.current != null) {
return () => {
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}
// ── Mission rendering: deploy lifecycle + TorqueScript ──
const hasDeployClip = clipsByName.has("deploy");
const useTorqueDeploy = !!(runtime && hasDeployClip && object.datablock);
function fireOnEndSequence(slot: number) {
if (!runtime) return;
const dbName = object.datablock;
if (!dbName) return;
const datablock = runtime.getObjectByName(String(dbName));
if (datablock) {
runtime.$.call(datablock, "onEndSequence", object, slot);
}
}
onDeployEndRef.current = useTorqueDeploy
? fireOnEndSequence
: () => startPostDeployThreads();
function startPostDeployThreads() {
const autoPlaySequences = ["ambient", "power"];
for (const seqName of autoPlaySequences) {
const vNodes = visNodesBySequence.get(seqName);
if (vNodes) {
const startTime = performance.now() / 1000;
for (const v of vNodes) prepareVisNode(v);
const slot = seqName === "power" ? 0 : 1;
threads.set(slot, { sequence: seqName, visNodes: vNodes, startTime });
}
const clip = clipsByName.get(seqName);
if (clip && mixer) {
const action = mixer.clipAction(clip);
action.setLoop(LoopRepeat, Infinity);
action.reset().play();
const slot = seqName === "power" ? 0 : 1;
const existing = threads.get(slot);
if (existing) {
existing.action = action;
} else {
threads.set(slot, {
sequence: seqName,
action,
startTime: performance.now() / 1000,
});
}
}
}
}
const unsubs: (() => void)[] = [];
const onFinished = mixer
? (e: { action: AnimationAction }) => {
for (const [slot, thread] of threads) {
if (thread.action === e.action) {
if (useTorqueDeploy) {
fireOnEndSequence(slot);
} else {
startPostDeployThreads();
}
break;
}
: undefined;
return { node, geometry, backGeometry, vis, visAnim };
});
}, [nodes, hullBoneIndices, isOrganic]);
}
}
: null;
// Disable shadows for organic shapes to avoid artifacts with alpha-tested materials
// Shadow maps don't properly handle alpha transparency, causing checkerboard patterns
const enableShadows = !isOrganic;
if (onFinished && mixer) {
mixer.addEventListener("finished", onFinished);
}
if (runtime) {
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"playThread",
(thisObj, slot, sequence) => {
if (thisObj._id !== object._id) return;
handlePlayThread(Number(slot), String(sequence));
},
),
);
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"stopThread",
(thisObj, slot) => {
if (thisObj._id !== object._id) return;
handleStopThread(Number(slot));
},
),
);
unsubs.push(
runtime.$.onMethodCalled(
"ShapeBase",
"pauseThread",
(thisObj, slot) => {
if (thisObj._id !== object._id) return;
const thread = threads.get(Number(slot));
if (thread?.action) {
thread.action.paused = true;
}
},
),
);
}
if (useTorqueDeploy) {
runtime.$.call(object, "deploy");
} else if (hasDeployClip) {
handlePlayThread(DEPLOY_THREAD, "deploy");
} else {
startPostDeployThreads();
}
return () => {
if (onFinished && mixer) {
mixer.removeEventListener("finished", onFinished);
}
unsubs.forEach((fn) => fn());
onDeployEndRef.current = null;
handlePlayThreadRef.current = null;
handleStopThreadRef.current = null;
for (const slot of [...threads.keys()]) handleStopThread(slot);
};
}, [mixer, clipsByName, visNodesBySequence, object, runtime]);
// Build DTS sequence index → animation name lookup. If the glTF has the
// dts_sequence_names extra (set by the addon), use it for an exact mapping
// from ghost ThreadMask indices to animation names. Otherwise fall back to
// positional indexing (which only works if no sequences were filtered).
const seqIndexToName = useMemo(() => {
const raw = gltf.scene.userData?.dts_sequence_names;
if (typeof raw === "string") {
try {
const names: string[] = JSON.parse(raw);
return names.map((n) => n.toLowerCase());
} catch {}
}
return gltf.animations.map((a) => a.name.toLowerCase());
}, [gltf]);
useFrame((_, delta) => {
const threads = threadsRef.current;
// React to demo thread state changes. The ghost ThreadMask data tells us
// exactly which DTS sequences are playing/stopped on each of 4 thread slots.
const currentDemoThreads = demoThreadsRef.current;
const prevDemoThreads = prevDemoThreadsRef.current;
if (currentDemoThreads !== prevDemoThreads) {
prevDemoThreadsRef.current = currentDemoThreads;
const playThread = handlePlayThreadRef.current;
const stopThread = handleStopThreadRef.current;
if (playThread && stopThread) {
// Use sparse arrays instead of Maps — thread indices are 0-3.
const currentBySlot: Array<DemoThreadState | undefined> = [];
if (currentDemoThreads) {
for (const t of currentDemoThreads) currentBySlot[t.index] = t;
}
const prevBySlot: Array<DemoThreadState | undefined> = [];
if (prevDemoThreads) {
for (const t of prevDemoThreads) prevBySlot[t.index] = t;
}
const maxSlot = Math.max(currentBySlot.length, prevBySlot.length);
for (let slot = 0; slot < maxSlot; slot++) {
const t = currentBySlot[slot];
const prev = prevBySlot[slot];
if (t) {
const changed = !prev
|| prev.sequence !== t.sequence
|| prev.state !== t.state
|| prev.atEnd !== t.atEnd;
if (!changed) continue;
const seqName = seqIndexToName[t.sequence];
if (!seqName) continue;
if (t.state === 0) {
playThread(slot, seqName);
} else {
stopThread(slot);
}
} else if (prev) {
// Thread disappeared — stop it.
stopThread(slot);
}
}
}
}
if (mixer) {
// If animation is disabled and deploy is still mid-animation,
// snap to the fully-deployed pose and fire onEndSequence.
if (!animationEnabled) {
const deployThread = threads.get(DEPLOY_THREAD);
if (deployThread?.action) {
const clip = deployThread.action.getClip();
if (deployThread.action.time < clip.duration - 0.001) {
deployThread.action.time = clip.duration;
mixer.update(0);
onDeployEndRef.current?.(DEPLOY_THREAD);
}
}
}
if (animationEnabled) {
mixer.update(delta);
}
}
// Drive vis opacity animations for active threads.
for (const [, thread] of threads) {
if (!thread.visNodes) continue;
for (const { mesh, keyframes, duration, cyclic } of thread.visNodes) {
const mat = mesh.material;
if (!mat || Array.isArray(mat)) continue;
if (!animationEnabled) {
mat.opacity = keyframes[0];
continue;
}
const elapsed = performance.now() / 1000 - thread.startTime;
const t = cyclic
? (elapsed % duration) / duration
: Math.min(elapsed / duration, 1);
const n = keyframes.length;
const pos = t * n;
const lo = Math.floor(pos) % n;
const hi = (lo + 1) % n;
const frac = pos - Math.floor(pos);
mat.opacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
}
}
// Advance IFL texture atlases.
// Matches Torque's animateIfls():
// time = th->pos * th->sequence->duration + th->sequence->toolBegin
// where pos is [0,1) cyclic or [0,1] clamped, then frame is looked up in
// cumulative iflFrameOffTimes (seconds, at 1/30s per IFL tick).
// toolBegin offsets into the IFL timeline so the sequence window aligns
// with the desired frames (e.g. skipping a long "off" period).
const iflAnimInfos = iflAnimInfosRef.current;
if (iflAnimInfos.length > 0) {
iflTimeRef.current += delta;
for (const info of iflAnimInfos) {
if (!animationEnabled) {
updateAtlasFrame(info.atlas, 0);
continue;
}
if (info.sequenceName && info.sequenceDuration) {
// Sequence-driven IFL: find the thread playing this sequence and
// compute time = pos * duration + toolBegin (matching the engine).
let iflTime = 0;
for (const [, thread] of threads) {
if (thread.sequence === info.sequenceName) {
const elapsed = performance.now() / 1000 - thread.startTime;
const dur = info.sequenceDuration;
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
const pos = info.cyclic
? (elapsed / dur) % 1
: Math.min(elapsed / dur, 1);
iflTime = pos * dur + (info.toolBegin ?? 0);
break;
}
}
updateAtlasFrame(info.atlas, getFrameIndexForTime(info.atlas, iflTime));
} else {
// No controlling sequence: use accumulated real time.
// (In the engine, these would stay at frame 0, but cycling is more
// useful for display purposes.)
updateAtlasFrame(
info.atlas,
getFrameIndexForTime(info.atlas, iflTimeRef.current),
);
}
}
}
});
return (
<group rotation={[0, Math.PI / 2, 0]}>
{processedNodes.map(({ node, geometry, backGeometry, vis, visAnim }) => {
const animated = !!visAnim;
const fallback = (
<mesh geometry={geometry}>
<meshStandardMaterial color="gray" wireframe />
</mesh>
);
const textures = node.material ? (
Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
vis={vis}
animated={animated}
/>
))
) : (
<ShapeTexture
material={node.material as MeshStandardMaterial}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={enableShadows}
receiveShadow={enableShadows}
vis={vis}
animated={animated}
/>
)
) : null;
if (visAnim) {
return (
<AnimatedVisGroup
key={node.id}
keyframes={visAnim.keyframes}
duration={visAnim.duration}
cyclic={visAnim.cyclic}
>
<Suspense fallback={fallback}>{textures}</Suspense>
</AnimatedVisGroup>
);
}
return (
<Suspense key={node.id} fallback={fallback}>
{textures}
</Suspense>
);
})}
<primitive object={clonedScene} />
{debugMode ? (
<FloatingLabel>
{object._id}: {shapeName}
@ -684,3 +1040,9 @@ export const ShapeModel = memo(function ShapeModel() {
</group>
);
});
function ShapeModelLoader({ demoThreads }: { demoThreads?: DemoThreadState[] }) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
return <ShapeModel gltf={gltf} demoThreads={demoThreads} />;
}

View file

@ -22,6 +22,7 @@ import {
} from "../manifest";
import { MissionProvider } from "./MissionContext";
import { engineStore } from "../state";
import { ignoreScripts } from "../torqueScript/ignoreScripts";
const loadScript = createScriptLoader();
// Shared cache for parsed scripts - survives runtime restarts
@ -92,36 +93,7 @@ function useExecutedMission(
cache: scriptCache,
signal: controller.signal,
progress: progressTracker,
ignoreScripts: [
"scripts/admin.cs",
// `ignoreScripts` supports globs, but out of an abundance of caution
// we don't want to do `ai*.cs` in case there's some non-AI related
// word like "air" in a script name.
"scripts/ai.cs",
"scripts/aiBotProfiles.cs",
"scripts/aiBountyGame.cs",
"scripts/aiChat.cs",
"scripts/aiCnH.cs",
"scripts/aiCTF.cs",
"scripts/aiDeathMatch.cs",
"scripts/aiDebug.cs",
"scripts/aiDefaultTasks.cs",
"scripts/aiDnD.cs",
"scripts/aiHumanTasks.cs",
"scripts/aiHunters.cs",
"scripts/aiInventory.cs",
"scripts/aiObjectiveBuilder.cs",
"scripts/aiObjectives.cs",
"scripts/aiRabbit.cs",
"scripts/aiSiege.cs",
"scripts/aiTDM.cs",
"scripts/aiTeamHunters.cs",
"scripts/deathMessages.cs",
"scripts/graphBuild.cs",
"scripts/navGraph.cs",
"scripts/serverTasks.cs",
"scripts/spdialog.cs",
],
ignoreScripts,
},
});

View file

@ -1,67 +1,7 @@
import { useMemo } from "react";
import { useDemoCurrentTime, useDemoRecording } from "./DemoProvider";
import type { DemoEntity, DemoKeyframe, CameraModeFrame } from "../demo/types";
import { useDemoRecording } from "./DemoProvider";
import { useEngineSelector } from "../state";
import styles from "./PlayerHUD.module.css";
/**
* Binary search for the most recent keyframe at or before `time`.
* Returns the keyframe's health/energy values (carried forward from last
* known ghost update).
*/
function getStatusAtTime(
keyframes: DemoKeyframe[],
time: number,
): { health: number; energy: number } {
if (keyframes.length === 0) return { health: 1, energy: 1 };
let lo = 0;
let hi = keyframes.length - 1;
if (time <= keyframes[0].time) {
return {
health: keyframes[0].health ?? 1,
energy: keyframes[0].energy ?? 1,
};
}
if (time >= keyframes[hi].time) {
return {
health: keyframes[hi].health ?? 1,
energy: keyframes[hi].energy ?? 1,
};
}
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (keyframes[mid].time <= time) lo = mid;
else hi = mid;
}
return {
health: keyframes[lo].health ?? 1,
energy: keyframes[lo].energy ?? 1,
};
}
/** Binary search for the active CameraModeFrame at a given time. */
function getCameraModeAtTime(
frames: CameraModeFrame[],
time: number,
): CameraModeFrame | null {
if (frames.length === 0) return null;
if (time < frames[0].time) return null;
if (time >= frames[frames.length - 1].time) return frames[frames.length - 1];
let lo = 0;
let hi = frames.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (frames[mid].time <= time) lo = mid;
else hi = mid;
}
return frames[lo];
}
function HealthBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100));
return (
@ -106,69 +46,13 @@ function Compass() {
export function PlayerHUD() {
const recording = useDemoRecording();
const currentTime = useDemoCurrentTime();
const streamSnapshot = useEngineSelector(
(state) => state.playback.streamSnapshot,
);
// Build an entity lookup by ID for quick access.
const entityMap = useMemo(() => {
const map = new Map<string | number, DemoEntity>();
if (!recording) return map;
for (const entity of recording.entities) {
map.set(entity.id, entity);
}
return map;
}, [recording]);
if (!recording) return null;
if (recording.isMetadataOnly || recording.isPartial) {
const status = streamSnapshot?.status;
if (!status) return null;
return (
<div className={styles.PlayerHUD}>
<ChatWindow />
<Compass />
<HealthBar value={status.health} />
<EnergyBar value={status.energy} />
<TeamStats />
<Reticle />
<ToolBelt />
<WeaponSlots />
</div>
);
}
// Determine which entity to show status for based on camera mode.
const frame = getCameraModeAtTime(recording.cameraModes, currentTime);
// Resolve health and energy for the active player:
// - First-person: health from ghost entity (DamageMask), energy from the
// recording_player entity (CO readPacketData, higher precision).
// - Third-person (orbit): both from the orbit target entity.
let status = { health: 1, energy: 1 };
if (frame?.mode === "first-person") {
const ghostEntity = recording.controlPlayerGhostId
? entityMap.get(recording.controlPlayerGhostId)
: undefined;
const recEntity = entityMap.get("recording_player");
const ghostStatus = ghostEntity
? getStatusAtTime(ghostEntity.keyframes, currentTime)
: undefined;
const recStatus = recEntity
? getStatusAtTime(recEntity.keyframes, currentTime)
: undefined;
status = {
health: ghostStatus?.health ?? 1,
// Prefer CO energy (available every tick) over ghost energy (sparse).
energy: recStatus?.energy ?? ghostStatus?.energy ?? 1,
};
} else if (frame?.mode === "third-person" && frame.orbitTargetId) {
const entity = entityMap.get(frame.orbitTargetId);
if (entity) {
status = getStatusAtTime(entity.keyframes, currentTime);
}
}
const status = streamSnapshot?.status;
if (!status) return null;
return (
<div className={styles.PlayerHUD}>

View file

@ -24,3 +24,4 @@ export function useRuntime(): TorqueRuntime {
}
return runtime;
}

View file

@ -0,0 +1,207 @@
import {
startTransition,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Combobox,
ComboboxItem,
ComboboxList,
ComboboxPopover,
ComboboxProvider,
ComboboxGroup,
ComboboxGroupLabel,
useComboboxStore,
} from "@ariakit/react";
import { matchSorter } from "match-sorter";
import { getResourceList, getSourceAndPath } from "../manifest";
import orderBy from "lodash.orderby";
import styles from "./MissionSelect.module.css";
interface ShapeItem {
/** Manifest resource key (lowercased path like "shapes/beacon.glb"). */
resourceKey: string;
/** Display name (e.g. "beacon.dts"). */
displayName: string;
/** The .dts shape name to pass to shapeToUrl (e.g. "beacon.dts"). */
shapeName: string;
/** Source vl2 archive. */
sourcePath: string;
/** Group label for the combobox. */
groupName: string;
}
const sourceGroupNames: Record<string, string> = {
"shapes.vl2": "Shapes",
"TR2final105-client.vl2": "Team Rabbit 2",
};
const allShapes: ShapeItem[] = getResourceList()
.filter((key) => key.startsWith("shapes/") && key.endsWith(".dts"))
.map((resourceKey) => {
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
const fileName = actualPath.split("/").pop() ?? actualPath;
const groupName = sourceGroupNames[sourcePath] ?? (sourcePath || "Loose");
return {
resourceKey,
displayName: fileName,
shapeName: fileName,
sourcePath,
groupName,
};
});
const shapesByName = new Map(allShapes.map((s) => [s.shapeName, s]));
function groupShapes(shapes: ShapeItem[]) {
const groupMap = new Map<string, ShapeItem[]>();
for (const shape of shapes) {
const group = groupMap.get(shape.groupName) ?? [];
group.push(shape);
groupMap.set(shape.groupName, group);
}
groupMap.forEach((groupShapes, groupName) => {
groupMap.set(
groupName,
orderBy(groupShapes, [(s) => s.displayName.toLowerCase()], ["asc"]),
);
});
return orderBy(
Array.from(groupMap.entries()),
[
([groupName]) => (groupName === "Shapes" ? 0 : 1),
([groupName]) => groupName.toLowerCase(),
],
["asc", "asc"],
);
}
const defaultGroups = groupShapes(allShapes);
const isMac =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/.test(navigator.platform);
export function ShapeSelect({
value,
onChange,
}: {
value: string;
onChange: (shapeName: string) => void;
}) {
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const combobox = useComboboxStore({
placement: "bottom-start",
resetValueOnHide: true,
selectedValue: value,
setSelectedValue: (newValue) => {
if (newValue) {
onChange(newValue);
inputRef.current?.blur();
}
},
setValue: (value) => {
startTransition(() => setSearchValue(value));
},
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
combobox.show();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [combobox]);
const selectedShape = shapesByName.get(value);
const filteredResults = useMemo(() => {
if (!searchValue)
return { type: "grouped" as const, groups: defaultGroups };
const matches = matchSorter(allShapes, searchValue, {
keys: ["displayName", "groupName"],
});
return { type: "flat" as const, shapes: matches };
}, [searchValue]);
const displayValue = selectedShape?.displayName ?? value;
const noResults =
filteredResults.type === "flat"
? filteredResults.shapes.length === 0
: filteredResults.groups.length === 0;
const renderItem = (shape: ShapeItem) => {
return (
<ComboboxItem
key={shape.shapeName}
value={shape.shapeName}
className={styles.Item}
focusOnHover
>
<span className={styles.ItemName}>{shape.displayName}</span>
</ComboboxItem>
);
};
return (
<ComboboxProvider store={combobox}>
<div className={styles.InputWrapper}>
<Combobox
ref={inputRef}
autoSelect
placeholder={displayValue}
className={styles.Input}
onFocus={() => {
try {
document.exitPointerLock();
} catch {}
combobox.show();
}}
onKeyDown={(e) => {
if (e.key === "Escape" && !combobox.getState().open) {
inputRef.current?.blur();
}
}}
/>
<div className={styles.SelectedValue}>
<span className={styles.SelectedName}>{displayValue}</span>
</div>
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
</div>
<ComboboxPopover
portal
gutter={4}
autoFocusOnHide={false}
className={styles.Popover}
>
<ComboboxList className={styles.List}>
{filteredResults.type === "flat"
? filteredResults.shapes.map(renderItem)
: filteredResults.groups.map(([groupName, shapes]) => (
<ComboboxGroup key={groupName} className={styles.Group}>
<ComboboxGroupLabel className={styles.GroupLabel}>
{groupName}
</ComboboxGroupLabel>
{shapes.map(renderItem)}
</ComboboxGroup>
))}
{noResults && (
<div className={styles.NoResults}>No shapes found</div>
)}
</ComboboxList>
</ComboboxPopover>
</ComboboxProvider>
);
}

View file

@ -1,4 +1,4 @@
import { memo, useMemo, useRef, useState } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useQuery } from "@tanstack/react-query";
import {
@ -23,6 +23,10 @@ import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { TerrainTile } from "./TerrainTile";
import { useSceneObject } from "./useSceneObject";
import {
createTerrainHeightSampler,
setTerrainHeightSampler,
} from "../terrainHeight";
const DEFAULT_SQUARE_SIZE = 8;
const DEFAULT_VISIBLE_DISTANCE = 600;
@ -572,6 +576,15 @@ export const TerrainBlock = memo(function TerrainBlock({
return geometry;
}, [squareSize, terrain]);
// Register terrain height sampler for item physics simulation.
useEffect(() => {
if (!terrain) return;
setTerrainHeightSampler(
createTerrainHeightSampler(terrain.heightMap, squareSize),
);
return () => setTerrainHeightSampler(null);
}, [terrain, squareSize]);
// Get sun direction for lightmap generation
const sun = useSceneObject("Sun");
const sunDirection = useMemo(() => {

View file

@ -7,20 +7,24 @@ import {
NearestFilter,
SRGBColorSpace,
Texture,
TextureLoader,
} from "three";
import { iflTextureToUrl, loadImageFrameList } from "../loaders";
import { useTick } from "./TickProvider";
import { useTick, TICK_RATE } from "./TickProvider";
import { useSettings } from "./SettingsProvider";
interface IflAtlas {
/** One IFL tick in seconds (Torque converts at 1/30s per tick). */
export const IFL_TICK_SECONDS = 1 / 30;
export interface IflAtlas {
texture: CanvasTexture;
columns: number;
rows: number;
frameCount: number;
/** Tick at which each frame starts (cumulative). */
frameStartTicks: number[];
/** Total ticks for one complete animation cycle. */
totalTicks: number;
/** Cumulative end time (seconds) for each frame. */
frameOffsetSeconds: number[];
/** Total IFL cycle duration in seconds. */
totalDurationSeconds: number;
/** Last rendered frame index, to avoid redundant offset updates. */
lastFrame: number;
}
@ -28,6 +32,14 @@ interface IflAtlas {
// Module-level cache for atlas textures, shared across all components.
const atlasCache = new Map<string, IflAtlas>();
const _textureLoader = new TextureLoader();
function loadTextureAsync(url: string): Promise<Texture> {
return new Promise((resolve, reject) => {
_textureLoader.load(url, resolve, undefined, reject);
});
}
function createAtlas(textures: Texture[]): IflAtlas {
const firstImage = textures[0].image as HTMLImageElement;
const frameWidth = firstImage.width;
@ -67,8 +79,8 @@ function createAtlas(textures: Texture[]): IflAtlas {
columns,
rows,
frameCount,
frameStartTicks: [],
totalTicks: 0,
frameOffsetSeconds: [],
totalDurationSeconds: 0,
lastFrame: -1,
};
}
@ -77,16 +89,15 @@ function computeTiming(
atlas: IflAtlas,
frames: { name: string; frameCount: number }[],
) {
let totalTicks = 0;
atlas.frameStartTicks = frames.map((frame) => {
const start = totalTicks;
totalTicks += frame.frameCount;
return start;
let cumulativeSeconds = 0;
atlas.frameOffsetSeconds = frames.map((frame) => {
cumulativeSeconds += frame.frameCount * IFL_TICK_SECONDS;
return cumulativeSeconds;
});
atlas.totalTicks = totalTicks;
atlas.totalDurationSeconds = cumulativeSeconds;
}
function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
export function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
if (frameIndex === atlas.lastFrame) return;
atlas.lastFrame = frameIndex;
@ -96,19 +107,42 @@ function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
atlas.texture.offset.set(col / atlas.columns, row / atlas.rows);
}
function getFrameIndexForTick(atlas: IflAtlas, tick: number): number {
if (atlas.totalTicks === 0) return 0;
const cycleTick = tick % atlas.totalTicks;
const { frameStartTicks } = atlas;
// Binary search would be faster for many frames, but linear is fine for typical IFLs.
for (let i = frameStartTicks.length - 1; i >= 0; i--) {
if (cycleTick >= frameStartTicks[i]) {
return i;
}
/**
* Find the frame index for a given time in seconds. Matches Torque's
* `animateIfls()` lookup using cumulative `iflFrameOffTimes`.
*/
export function getFrameIndexForTime(
atlas: IflAtlas,
seconds: number,
): number {
const dur = atlas.totalDurationSeconds;
if (dur <= 0) return 0;
let t = seconds;
if (t > dur) t -= dur * Math.floor(t / dur);
for (let i = 0; i < atlas.frameOffsetSeconds.length; i++) {
if (t <= atlas.frameOffsetSeconds[i]) return i;
}
return 0;
return atlas.frameOffsetSeconds.length - 1;
}
/**
* Imperatively load an IFL atlas (all frames). Returns a cached atlas if the
* same IFL has been loaded before. The returned atlas can be animated
* per-frame with `updateAtlasFrame` + `getFrameIndexForTime`.
*/
export async function loadIflAtlas(iflPath: string): Promise<IflAtlas> {
const cached = atlasCache.get(iflPath);
if (cached) return cached;
const frames = await loadImageFrameList(iflPath);
const urls = frames.map((f) => iflTextureToUrl(f.name, iflPath));
const textures = await Promise.all(urls.map(loadTextureAsync));
const atlas = createAtlas(textures);
computeTiming(atlas, frames);
atlasCache.set(iflPath, atlas);
return atlas;
}
/**
@ -141,7 +175,8 @@ export function useIflTexture(iflPath: string): Texture {
}, [iflPath, textures, frames]);
useTick((tick) => {
const frameIndex = animationEnabled ? getFrameIndexForTick(atlas, tick) : 0;
const time = tick / TICK_RATE;
const frameIndex = animationEnabled ? getFrameIndexForTime(atlas, time) : 0;
updateAtlasFrame(atlas, frameIndex);
});

View file

@ -10,7 +10,7 @@ const FALLING_THRESHOLD = -10;
const MOVE_THRESHOLD = 0.1;
export interface MoveAnimationResult {
/** GLB animation clip name (e.g. "Forward", "Back", "Side", "Fall", "Root"). */
/** Engine alias name (e.g. "root", "run", "back", "side", "fall"). */
animation: string;
/** 1 for forward playback, -1 for reversed (right strafe). */
timeScale: number;
@ -38,14 +38,14 @@ export function pickMoveAnimation(
rotation: [number, number, number, number],
): MoveAnimationResult {
if (!velocity) {
return { animation: "Root", timeScale: 1 };
return { animation: "root", timeScale: 1 };
}
const [vx, vy, vz] = velocity;
// Falling: Torque Z velocity below threshold.
if (vz < FALLING_THRESHOLD) {
return { animation: "Fall", timeScale: 1 };
return { animation: "fall", timeScale: 1 };
}
// Convert world velocity to player object space using body yaw.
@ -66,18 +66,18 @@ export function pickMoveAnimation(
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
if (maxDot < MOVE_THRESHOLD) {
return { animation: "Root", timeScale: 1 };
return { animation: "root", timeScale: 1 };
}
if (maxDot === forwardDot) {
return { animation: "Forward", timeScale: 1 };
return { animation: "run", timeScale: 1 };
}
if (maxDot === backDot) {
return { animation: "Back", timeScale: 1 };
return { animation: "back", timeScale: 1 };
}
if (maxDot === leftDot) {
return { animation: "Side", timeScale: 1 };
return { animation: "side", timeScale: 1 };
}
// Right strafe: same Side animation, reversed.
return { animation: "Side", timeScale: -1 };
return { animation: "side", timeScale: -1 };
}

View file

@ -5,7 +5,9 @@ import {
DemoParser,
} from "t2-demo-parser";
import { Matrix4, Quaternion } from "three";
import { getTerrainHeightAt } from "../terrainHeight";
import type {
DemoThreadState,
DemoVisual,
DemoRecording,
DemoStreamCamera,
@ -69,8 +71,24 @@ interface MutableStreamEntity {
expiryTick?: number;
/** Billboard toward camera (Torque's faceViewer). */
faceViewer?: boolean;
/** Numeric ID of the ExplosionData datablock (for particle effect resolution). */
explosionDataBlockId?: number;
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
maintainEmitterId?: number;
/** Whether we've already tried to resolve maintainEmitter (debug flag). */
maintainEmitterChecked?: boolean;
/** Target's sensor group (team number). */
sensorGroup?: number;
/** DTS animation thread states from ghost ThreadMask data. */
threads?: DemoThreadState[];
/** Item physics simulation state (dropped weapons/items). */
itemPhysics?: {
velocity: [number, number, number];
atRest: boolean;
elasticity: number;
friction: number;
gravityMod: number;
};
}
interface StreamState {
@ -791,6 +809,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
if (block.type === BlockTypeMove) {
this.state.moveTicks += 1;
this.advanceProjectiles();
this.advanceItems();
this.removeExpiredExplosions();
this.updateCameraAndHud();
return true;
@ -965,6 +984,33 @@ class StreamingPlayback implements DemoStreamingPlayback {
const ghostIndex = ghost.index;
const prevEntityId = this.state.entityIdByGhostIndex.get(ghostIndex);
// 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.
if (prevEntityId) {
const prevEntity = this.state.entitiesById.get(prevEntityId);
if (
prevEntity &&
prevEntity.type === "Projectile" &&
!prevEntity.hasExploded &&
prevEntity.explosionShape &&
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).
(ghost.type === "delete" || ghost.type === "create")
) {
this.spawnExplosion(
prevEntity,
[...prevEntity.position] as [number, number, number],
);
}
}
if (ghost.type === "delete") {
if (prevEntityId) {
this.state.entitiesById.delete(prevEntityId);
@ -983,8 +1029,32 @@ class StreamingPlayback implements DemoStreamingPlayback {
this.state.entitiesById.delete(prevEntityId);
}
let entity = this.state.entitiesById.get(entityId);
if (!entity) {
let entity: MutableStreamEntity;
const existingEntity = this.state.entitiesById.get(entityId);
if (existingEntity && ghost.type === "create") {
// Same-class ghost index reuse: reset the entity for the new ghost
// to avoid stale fields (hasExploded, explosionShape, etc.) from the
// previous occupant leaking into the new one.
existingEntity.spawnTick = this.state.moveTicks;
existingEntity.rotation = [0, 0, 0, 1];
existingEntity.hasExploded = undefined;
existingEntity.explosionShape = undefined;
existingEntity.explosionLifetimeTicks = undefined;
existingEntity.faceViewer = undefined;
existingEntity.simulatedVelocity = undefined;
existingEntity.projectilePhysics = undefined;
existingEntity.gravityMod = undefined;
existingEntity.direction = undefined;
existingEntity.velocity = undefined;
existingEntity.position = undefined;
existingEntity.dataBlock = undefined;
existingEntity.dataBlockId = undefined;
existingEntity.shapeHint = undefined;
existingEntity.visual = undefined;
entity = existingEntity;
} else if (existingEntity) {
entity = existingEntity;
} else {
entity = {
id: entityId,
ghostIndex,
@ -1042,7 +1112,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
return undefined;
}
private getDataBlockData(
getDataBlockData(
dataBlockId: number,
): Record<string, unknown> | undefined {
const initialBlock = this.initialBlock.dataBlocks.get(dataBlockId);
@ -1060,20 +1130,35 @@ class StreamingPlayback implements DemoStreamingPlayback {
shape: string;
faceViewer: boolean;
lifetimeTicks: number;
explosionDataBlockId: number;
} | undefined {
const projBlock = this.getDataBlockData(projDataBlockId);
const explosionId = projBlock?.explosion as number | undefined;
if (explosionId == null) return undefined;
// The demo parser's field names don't match the V12 engine. The parser's
// `maintainSound` field is actually the engine's `explosion` DataBlockRef.
// (Parser reads bits correctly but assigns wrong names to ProjectileData fields.)
const explosionId = projBlock?.maintainSound as number | undefined;
if (explosionId == null) {
console.log("[streaming] resolveExplosionInfo — no explosion field on projBlock id:", projDataBlockId);
return undefined;
}
const expBlock = this.getDataBlockData(explosionId);
if (!expBlock) return undefined;
if (!expBlock) {
console.log("[streaming] resolveExplosionInfo — expBlock not found for explosionId:", explosionId);
return undefined;
}
const shape = expBlock.dtsFileName as string | undefined;
if (!shape) return undefined;
if (!shape) {
console.log("[streaming] resolveExplosionInfo — no dtsFileName on expBlock, explosionId:", explosionId, "keys:", Object.keys(expBlock));
return undefined;
}
// The parser's lifetimeMS field is actually in ticks (32ms each), not ms.
const lifetimeTicks = (expBlock.lifetimeMS as number | undefined) ?? 31;
console.log("[streaming] resolveExplosionInfo OK — projDataBlockId:", projDataBlockId, "explosionId:", explosionId, "shape:", shape, "lifetimeTicks:", lifetimeTicks);
return {
shape,
faceViewer: expBlock.faceViewer !== false && expBlock.faceViewer !== 0,
lifetimeTicks,
explosionDataBlockId: explosionId,
};
}
@ -1121,6 +1206,24 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.explosionShape = info.shape;
entity.faceViewer = info.faceViewer;
entity.explosionLifetimeTicks = info.lifetimeTicks;
entity.explosionDataBlockId = info.explosionDataBlockId;
}
}
// Resolve trail particle emitter for projectiles (once per entity).
if (
entity.type === "Projectile" &&
entity.maintainEmitterId == null
) {
// The demo parser's `activateEmitter` is actually the engine's
// `baseEmitter` — the primary trail emitter for projectiles.
const trailEmitterId = blockData?.activateEmitter as number | null;
if (typeof trailEmitterId === "number" && trailEmitterId > 0) {
entity.maintainEmitterId = trailEmitterId;
console.log("[streaming] baseEmitter resolved for", entity.className, entity.id, "— emitterId:", trailEmitterId);
} else if (!entity.maintainEmitterChecked) {
console.log("[streaming] baseEmitter NOT found on", entity.className, entity.id, "— blockData keys:", blockData ? Object.keys(blockData) : "NO blockData", "activateEmitter:", blockData?.activateEmitter);
entity.maintainEmitterChecked = true;
}
}
}
@ -1138,6 +1241,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.weaponShape = weaponShape;
}
}
} else if (weaponImage && !weaponImage.dataBlockId) {
// Server explicitly unmounted the weapon (dataBlockId = 0), e.g. on
// player death. Clear the weapon so it stops rendering.
entity.weaponShape = undefined;
}
}
}
@ -1215,6 +1322,31 @@ 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.
entity.itemPhysics = undefined;
} else if (atRest === false && isVec3Like(data.velocity)) {
// Item is moving — initialize or update physics simulation.
const blockData =
entity.dataBlockId != null
? this.getDataBlockData(entity.dataBlockId)
: undefined;
entity.itemPhysics = {
velocity: [data.velocity.x, data.velocity.y, data.velocity.z],
atRest: false,
elasticity: getNumberField(blockData, ["elasticity"]) ?? 0.2,
friction: getNumberField(blockData, ["friction"]) ?? 0.6,
gravityMod: getNumberField(blockData, ["gravityMod"]) ?? 1.0,
};
} else if (position && !isVec3Like(data.velocity)) {
// Server snapped position without velocity — stop simulating.
entity.itemPhysics = undefined;
}
}
// Compute simulatedVelocity for projectile physics.
if (entity.projectilePhysics) {
if (entity.projectilePhysics === "linear") {
@ -1295,26 +1427,7 @@ class StreamingPlayback implements DemoStreamingPlayback {
explodePos &&
entity.explosionShape
) {
entity.hasExploded = true;
const fxId = `fx_${this.state.nextExplosionId++}`;
const lifetimeTicks = entity.explosionLifetimeTicks ?? 31;
const fxEntity: MutableStreamEntity = {
id: fxId,
ghostIndex: -1,
className: "Explosion",
spawnTick: this.state.moveTicks,
type: "Explosion",
dataBlock: entity.explosionShape,
position: [explodePos.x, explodePos.y, explodePos.z],
rotation: [0, 0, 0, 1],
isExplosion: true,
faceViewer: entity.faceViewer !== false,
expiryTick: this.state.moveTicks + lifetimeTicks,
};
this.state.entitiesById.set(fxId, fxEntity);
// Stop the projectile — the explosion takes over visually.
entity.position = undefined;
entity.simulatedVelocity = undefined;
this.spawnExplosion(entity, [explodePos.x, explodePos.y, explodePos.z]);
}
if (typeof data.damageLevel === "number") {
@ -1329,6 +1442,10 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.actionAtEnd = !!data.actionAtEnd;
}
if (Array.isArray(data.threads)) {
entity.threads = data.threads as DemoThreadState[];
}
if (typeof data.energy === "number") {
entity.energy = clamp(data.energy, 0, 1);
}
@ -1376,6 +1493,78 @@ class StreamingPlayback implements DemoStreamingPlayback {
}
}
private advanceItems(): void {
const dt = TICK_DURATION_MS / 1000; // 0.032
for (const entity of this.state.entitiesById.values()) {
const phys = entity.itemPhysics;
if (!phys || phys.atRest || !entity.position) continue;
const v = phys.velocity;
const p = entity.position;
// Gravity: Tribes 2 uses -20 m/s² (Torque Z-up).
v[2] += -20 * phys.gravityMod * dt;
// Move
p[0] += v[0] * dt;
p[1] += v[1] * dt;
p[2] += v[2] * dt;
// Terrain collision (flat normal approximation: [0, 0, 1])
const groundZ = getTerrainHeightAt(p[0], p[1]);
if (groundZ != null && p[2] < groundZ) {
p[2] = groundZ;
const bd = Math.abs(v[2]); // normal impact speed
v[2] = bd * phys.elasticity; // reflect with restitution
// Friction: reduce horizontal speed proportional to impact
const friction = bd * phys.friction;
const hSpeed = Math.sqrt(v[0] * v[0] + v[1] * v[1]);
if (hSpeed > 0) {
const scale = Math.max(0, 1 - friction / hSpeed);
v[0] *= scale;
v[1] *= scale;
}
// At-rest check
const speed = Math.sqrt(
v[0] * v[0] + v[1] * v[1] + v[2] * v[2],
);
if (speed < 0.15) {
v[0] = v[1] = v[2] = 0;
phys.atRest = true;
}
}
}
}
/** Create a synthetic explosion entity from a projectile. */
private spawnExplosion(
entity: MutableStreamEntity,
position: [number, number, number],
): void {
entity.hasExploded = true;
const fxId = `fx_${this.state.nextExplosionId++}`;
const lifetimeTicks = entity.explosionLifetimeTicks ?? 31;
// DEBUG: log explosion spawn
console.log("[streaming] spawnExplosion — fxId:", fxId, "explosionDataBlockId:", entity.explosionDataBlockId, "explosionShape:", entity.explosionShape, "pos:", position, "lifetimeTicks:", lifetimeTicks, "moveTicks:", this.state.moveTicks);
const fxEntity: MutableStreamEntity = {
id: fxId,
ghostIndex: -1,
className: "Explosion",
spawnTick: this.state.moveTicks,
type: "Explosion",
dataBlock: entity.explosionShape,
explosionDataBlockId: entity.explosionDataBlockId,
position,
rotation: [0, 0, 0, 1],
isExplosion: true,
faceViewer: entity.faceViewer !== false,
expiryTick: this.state.moveTicks + lifetimeTicks,
};
this.state.entitiesById.set(fxId, fxEntity);
// Stop the projectile — the explosion takes over visually.
entity.position = undefined;
entity.simulatedVelocity = undefined;
}
private removeExpiredExplosions(): void {
for (const [id, entity] of this.state.entitiesById) {
if (
@ -1543,16 +1732,17 @@ class StreamingPlayback implements DemoStreamingPlayback {
entity.type === "Player" && entity.sensorGroup != null
? this.resolveIffColor(entity.sensorGroup)
: undefined,
// Clone mutable arrays so each snapshot is an immutable record of
// tick-time state. advanceProjectiles() mutates entity.position
// in-place, which would otherwise corrupt previous snapshots and
// break inter-tick interpolation in the renderer.
position: entity.position
? ([...entity.position] as [number, number, number])
: undefined,
rotation: entity.rotation
? ([...entity.rotation] as [number, number, number, number])
: undefined,
// Only clone position for entities whose position is mutated in-place
// by advanceProjectiles() or advanceItems(). Other entities get new
// arrays from applyGhostData(), so the old reference stays valid.
position:
entity.position &&
(entity.simulatedVelocity ||
(entity.itemPhysics && !entity.itemPhysics.atRest))
? ([...entity.position] as [number, number, number])
: entity.position,
// Rotation is always replaced (never mutated in-place), so no clone.
rotation: entity.rotation,
velocity: entity.velocity,
health: entity.health,
energy: entity.energy,
@ -1560,6 +1750,9 @@ class StreamingPlayback implements DemoStreamingPlayback {
actionAtEnd: entity.actionAtEnd,
damageState: entity.damageState,
faceViewer: entity.faceViewer,
threads: entity.threads,
explosionDataBlockId: entity.explosionDataBlockId,
maintainEmitterId: entity.maintainEmitterId,
});
}
@ -1657,10 +1850,6 @@ export async function createDemoStreamingRecording(
duration: header.demoLengthMs / 1000,
missionName: infoMissionName ?? initialBlock.missionName ?? null,
gameType,
entities: [],
cameraModes: [],
isMetadataOnly: true,
isPartial: true,
streamingPlayback: new StreamingPlayback(parser),
};
}

View file

@ -1,3 +1,11 @@
export interface DemoThreadState {
index: number;
sequence: number;
state: number;
forward: boolean;
atEnd: boolean;
}
export interface DemoKeyframe {
time: number;
/** Position in Torque space [x, y, z]. */
@ -66,6 +74,8 @@ export interface DemoEntity {
/** Time (seconds) when this entity leaves ghost scope. */
despawnTime?: number;
keyframes: DemoKeyframe[];
/** DTS animation thread states from ghost ThreadMask data. */
threads?: DemoThreadState[];
/** Weapon shape file name for Player entities (e.g. "weapon_disc.dts"). */
weaponShape?: string;
/** Player name resolved from the target system string table. */
@ -74,34 +84,14 @@ export interface DemoEntity {
iffColor?: { r: number; g: number; b: number };
}
export interface CameraModeFrame {
time: number;
/** "first-person" = Player control object (camera at eye point).
* "third-person" = Camera in OrbitObjectMode (orbiting a ghost).
* "observer" = Camera in free/stationary/fly mode. */
mode: "first-person" | "third-person" | "observer";
/** Entity ID to hide in first-person (e.g. "player_5"). */
controlEntityId?: string;
/** Entity ID being orbited in third-person (e.g. "player_5"). */
orbitTargetId?: string;
}
export interface DemoRecording {
duration: number;
/** Mission name as it appears in the demo (e.g. "S5-WoodyMyrk"). */
missionName: string | null;
/** Game type display name from the demo (e.g. "Capture the Flag"). */
gameType: string | null;
/** True while playback uses deferred/streaming block parsing. */
isMetadataOnly?: boolean;
/** Legacy alias for `isMetadataOnly`. */
isPartial?: boolean;
/** Streaming parser session used for Move-tick-driven playback. */
streamingPlayback?: DemoStreamingPlayback;
entities: DemoEntity[];
cameraModes: CameraModeFrame[];
/** Ghost entity ID for the recording player (e.g. "player_5"). */
controlPlayerGhostId?: string;
streamingPlayback: DemoStreamingPlayback;
}
export interface DemoStreamEntity {
@ -130,6 +120,12 @@ export interface DemoStreamEntity {
actionAtEnd?: boolean;
damageState?: number;
faceViewer?: boolean;
/** DTS animation thread states from ghost ThreadMask data. */
threads?: DemoThreadState[];
/** Numeric ID of the ExplosionData datablock (for particle effect resolution). */
explosionDataBlockId?: number;
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
maintainEmitterId?: number;
}
export interface DemoStreamCamera {
@ -166,4 +162,6 @@ export interface DemoStreamingPlayback {
stepToTime(targetTimeSec: number, maxMoveTicks?: number): DemoStreamSnapshot;
/** DTS shape names for weapon effects (explosions) that should be preloaded. */
getEffectShapes(): string[];
/** Resolve a datablock by its numeric ID. */
getDataBlockData(id: number): Record<string, unknown> | undefined;
}

View file

@ -0,0 +1,472 @@
import type {
EmitterDataResolved,
Particle,
ParticleDataResolved,
ParticleKey,
} from "./types";
const DEG_TO_RAD = Math.PI / 180;
const GRAVITY_Z = -9.81;
/** Converts (degrees/sec * ms) to radians. */
const SPIN_FACTOR = Math.PI / (180 * 1000);
// V12 bit-packing scaling constants. The demo parser reads raw bit-packed
// integers/floats without applying the scaling the V12 client does on read.
// See particleEngine.cc lines 205-257, 471-550.
const VELOCITY_SCALE = 1 / 100; // ejectionVelocity, velocityVariance, ejectionOffset
const SPIN_RANDOM_OFFSET = -1000; // spinRandomMin, spinRandomMax
const MAX_PARTICLE_SIZE = 50; // sizes[] packed as size/MaxParticleSize
const LIFETIME_SHIFT = 5; // lifetimeMS packed as ms >> 5; unpack with << 5
const DRAG_SCALE = 5; // dragCoefficient packed as drag/5
const GRAVITY_SCALE = 10; // gravityCoefficient packed as gravity/10
// ── Datablock resolution ──
function getNumber(
raw: Record<string, unknown>,
key: string,
def: number,
): number {
const v = raw[key];
return typeof v === "number" && Number.isFinite(v) ? v : def;
}
function getBool(
raw: Record<string, unknown>,
key: string,
def: boolean,
): boolean {
const v = raw[key];
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0;
return def;
}
export function resolveParticleData(
raw: Record<string, unknown>,
): ParticleDataResolved {
// The demo parser packs keyframes into a `keys` array of {r,g,b,a,size,time}.
const rawKeys = raw.keys as
| Array<{
r?: number;
g?: number;
b?: number;
a?: number;
size?: number;
time?: number;
}>
| undefined;
const keys: ParticleKey[] = [];
if (Array.isArray(rawKeys) && rawKeys.length > 0) {
for (let i = 0; i < rawKeys.length && i < 4; i++) {
const k = rawKeys[i];
keys.push({
r: k.r ?? 1,
g: k.g ?? 1,
b: k.b ?? 1,
a: k.a ?? 1,
// V12 packs size as size/MaxParticleSize; parser returns [0,1].
size: (k.size ?? (1 / MAX_PARTICLE_SIZE)) * MAX_PARTICLE_SIZE,
time: i === 0 ? 0 : k.time ?? 1,
});
}
}
// Ensure at least two keyframes for interpolation.
if (keys.length === 0) {
keys.push({ r: 1, g: 1, b: 1, a: 1, size: 1, time: 0 });
}
if (keys.length < 2) {
keys.push({ ...keys[0], time: 1 });
}
// Resolve texture name. The parser stores `textures` as string[].
let textureName = "";
if (typeof raw.textureName === "string" && raw.textureName) {
textureName = raw.textureName;
} else {
const names = raw.textures as string[] | undefined;
if (Array.isArray(names) && names.length > 0 && names[0]) {
textureName = names[0];
}
}
return {
dragCoefficient: getNumber(raw, "dragCoefficient", 0) * DRAG_SCALE,
windCoefficient: getNumber(raw, "windCoefficient", 1),
gravityCoefficient: getNumber(raw, "gravityCoefficient", 0) * GRAVITY_SCALE,
inheritedVelFactor: getNumber(raw, "inheritedVelFactor", 0),
constantAcceleration: getNumber(raw, "constantAcceleration", 0),
lifetimeMS: getNumber(raw, "lifetimeMS", 31) << LIFETIME_SHIFT,
lifetimeVarianceMS: getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT,
spinSpeed: getNumber(raw, "spinSpeed", 0),
// V12 packs spinRandom as value+1000; parser returns raw integer.
spinRandomMin: getNumber(raw, "spinRandomMin", 1000) + SPIN_RANDOM_OFFSET,
spinRandomMax: getNumber(raw, "spinRandomMax", 1000) + SPIN_RANDOM_OFFSET,
useInvAlpha: getBool(raw, "useInvAlpha", false),
keys,
textureName,
};
}
export function resolveEmitterData(
raw: Record<string, unknown>,
getDataBlockData: (id: number) => Record<string, unknown> | undefined,
): EmitterDataResolved | null {
// The demo parser stores `particles` as (number | null)[] — an array of
// datablock ref IDs. Resolve the first valid one.
let particleRaw: Record<string, unknown> | undefined;
const particleRefs = raw.particles as (number | null)[] | undefined;
if (Array.isArray(particleRefs)) {
for (const ref of particleRefs) {
if (typeof ref === "number") {
particleRaw = getDataBlockData(ref);
if (particleRaw) break;
}
}
}
if (!particleRaw) return null;
return {
ejectionPeriodMS: getNumber(raw, "ejectionPeriodMS", 100),
periodVarianceMS: getNumber(raw, "periodVarianceMS", 0),
// V12 packs velocity/offset as value*100; parser returns raw integer.
ejectionVelocity: getNumber(raw, "ejectionVelocity", 200) * VELOCITY_SCALE,
velocityVariance: getNumber(raw, "velocityVariance", 100) * VELOCITY_SCALE,
ejectionOffset: getNumber(raw, "ejectionOffset", 0) * VELOCITY_SCALE,
thetaMin: getNumber(raw, "thetaMin", 0),
thetaMax: getNumber(raw, "thetaMax", 90),
phiReferenceVel: getNumber(raw, "phiReferenceVel", 0),
phiVariance: getNumber(raw, "phiVariance", 360),
overrideAdvances: getBool(raw, "overrideAdvances", false),
orientParticles: getBool(raw, "orientParticles", false),
orientOnVelocity: getBool(raw, "orientOnVelocity", true),
lifetimeMS: getNumber(raw, "lifetimeMS", 0) << LIFETIME_SHIFT,
lifetimeVarianceMS: getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT,
particles: resolveParticleData(particleRaw),
};
}
// ── Emitter instance (owns particles, runs simulation) ──
function randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function randomVariance(base: number, variance: number): number {
return base + (Math.random() * 2 - 1) * variance;
}
/**
* Compute perpendicular axis (`axisx`) for theta rotation, matching V12's
* `emitParticles` which uses: if |axis.z| < 0.9 cross with (0,0,1) else (0,1,0).
*/
function computeAxisX(
ax: number,
ay: number,
az: number,
): [number, number, number] {
let cx: number, cy: number, cz: number;
if (Math.abs(az) < 0.9) {
// cross(axis, (0,0,1))
cx = ay;
cy = -ax;
cz = 0;
} else {
// cross(axis, (0,1,0))
cx = -az;
cy = 0;
cz = ax;
}
const len = Math.sqrt(cx * cx + cy * cy + cz * cz);
if (len < 1e-8) return [1, 0, 0];
return [cx / len, cy / len, cz / len];
}
/**
* Rotate vector `v` around arbitrary axis `a` (unit vector) by `angle` radians.
* Uses Rodrigues' rotation formula.
*/
function rotateAroundAxis(
vx: number,
vy: number,
vz: number,
ax: number,
ay: number,
az: number,
angle: number,
): [number, number, number] {
const c = Math.cos(angle);
const s = Math.sin(angle);
const dot = vx * ax + vy * ay + vz * az;
// cross(a, v)
const crossX = ay * vz - az * vy;
const crossY = az * vx - ax * vz;
const crossZ = ax * vy - ay * vx;
return [
vx * c + crossX * s + ax * dot * (1 - c),
vy * c + crossY * s + ay * dot * (1 - c),
vz * c + crossZ * s + az * dot * (1 - c),
];
}
function interpolateKeys(
keys: ParticleKey[],
t: number,
): { r: number; g: number; b: number; a: number; size: number } {
for (let i = 1; i < keys.length; i++) {
if (keys[i].time >= t) {
const prev = keys[i - 1];
const curr = keys[i];
const span = curr.time - prev.time;
const f = span > 0 ? (t - prev.time) / span : 0;
return {
r: prev.r + (curr.r - prev.r) * f,
g: prev.g + (curr.g - prev.g) * f,
b: prev.b + (curr.b - prev.b) * f,
a: prev.a + (curr.a - prev.a) * f,
size: prev.size + (curr.size - prev.size) * f,
};
}
}
const last = keys[keys.length - 1];
return { r: last.r, g: last.g, b: last.b, a: last.a, size: last.size };
}
export class EmitterInstance {
readonly data: EmitterDataResolved;
readonly particles: Particle[] = [];
readonly maxParticles: number;
private internalClock = 0;
private nextParticleTime = 0;
private emitterAge = 0;
private emitterLifetime: number;
private emitterDead = false;
constructor(
data: EmitterDataResolved,
maxParticles = 256,
overrideLifetimeMS?: number,
) {
this.data = data;
this.maxParticles = maxParticles;
let lifetime = overrideLifetimeMS ?? data.lifetimeMS;
if (!overrideLifetimeMS && data.lifetimeVarianceMS > 0) {
lifetime += Math.round(randomVariance(0, data.lifetimeVarianceMS));
}
this.emitterLifetime = lifetime;
}
/**
* Burst-emit a fixed number of particles (for explosion particleDensity).
* The axis defaults to straight up in Torque space (0,0,1).
*/
emitBurst(
pos: [number, number, number],
count: number,
axis: [number, number, number] = [0, 0, 1],
): void {
for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) {
this.addParticle(pos, axis);
}
}
/**
* Periodic emission over a time delta. Faithful to V12's emitParticles timing.
*/
emitPeriodic(
pos: [number, number, number],
dtMS: number,
axis: [number, number, number] = [0, 0, 1],
): void {
if (this.emitterDead) return;
let timeLeft = dtMS;
while (timeLeft > 0) {
if (this.nextParticleTime > 0) {
const step = Math.min(timeLeft, this.nextParticleTime);
this.nextParticleTime -= step;
timeLeft -= step;
this.internalClock += step;
continue;
}
if (this.particles.length < this.maxParticles) {
this.addParticle(pos, axis);
}
// Compute next emission time.
let period = this.data.ejectionPeriodMS;
if (this.data.periodVarianceMS > 0) {
period += Math.round(randomVariance(0, this.data.periodVarianceMS));
}
this.nextParticleTime = Math.max(1, period);
}
}
/** Advance all live particles by dtMS. */
update(dtMS: number): void {
this.emitterAge += dtMS;
// Check emitter lifetime.
if (
this.emitterLifetime > 0 &&
this.emitterAge >= this.emitterLifetime
) {
this.emitterDead = true;
}
const dt = dtMS / 1000;
const pData = this.data.particles;
// Age particles, remove dead, update physics + interpolation.
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.currentAge += dtMS;
if (p.currentAge >= p.totalLifetime) {
// Remove dead particle (swap with last for O(1) removal).
this.particles[i] = this.particles[this.particles.length - 1];
this.particles.pop();
continue;
}
// Physics integration (V12 updateSingleParticle).
const drag = pData.dragCoefficient;
const gravCoeff = pData.gravityCoefficient;
// 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;
// Symplectic Euler: update vel first, then pos with new vel.
p.vel[0] += ax * dt;
p.vel[1] += ay * dt;
p.vel[2] += az * dt;
p.pos[0] += p.vel[0] * dt;
p.pos[1] += p.vel[1] * dt;
p.pos[2] += p.vel[2] * dt;
// Color/size keyframe interpolation.
const normalizedAge = p.currentAge / p.totalLifetime;
const interp = interpolateKeys(pData.keys, normalizedAge);
p.r = interp.r;
p.g = interp.g;
p.b = interp.b;
p.a = interp.a;
p.size = interp.size;
p.currentSpin = p.spinSpeed * p.currentAge * SPIN_FACTOR;
}
}
isDead(): boolean {
return this.emitterDead && this.particles.length === 0;
}
/** Immediately stop emitting new particles. Existing particles live out their lifetime. */
kill(): void {
this.emitterDead = true;
}
private addParticle(
pos: [number, number, number],
axis: [number, number, number],
): void {
const d = this.data;
const pData = d.particles;
// Compute ejection direction from theta/phi (V12 addParticle).
let ejX = axis[0];
let ejY = axis[1];
let ejZ = axis[2];
const axisx = computeAxisX(ejX, ejY, ejZ);
// Theta: angle off main axis.
const theta =
(d.thetaMin + Math.random() * (d.thetaMax - d.thetaMin)) * DEG_TO_RAD;
// Phi: rotation around main axis.
const phiRef = (this.internalClock / 1000) * d.phiReferenceVel;
const phi = (phiRef + Math.random() * d.phiVariance) * DEG_TO_RAD;
// Rotate axis by theta around axisx, then by phi around original axis.
[ejX, ejY, ejZ] = rotateAroundAxis(
ejX, ejY, ejZ,
axisx[0], axisx[1], axisx[2],
theta,
);
[ejX, ejY, ejZ] = rotateAroundAxis(
ejX, ejY, ejZ,
axis[0], axis[1], axis[2],
phi,
);
// Normalize ejection direction.
const ejLen = Math.sqrt(ejX * ejX + ejY * ejY + ejZ * ejZ);
if (ejLen > 1e-8) {
ejX /= ejLen;
ejY /= ejLen;
ejZ /= ejLen;
}
// Velocity with variance.
const speed = randomVariance(d.ejectionVelocity, d.velocityVariance);
const spawnPos: [number, number, number] = [
pos[0] + ejX * d.ejectionOffset,
pos[1] + ejY * d.ejectionOffset,
pos[2] + ejZ * d.ejectionOffset,
];
const vel: [number, number, number] = [
ejX * speed,
ejY * speed,
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.
// Particle lifetime with variance.
let lifetime = pData.lifetimeMS;
if (pData.lifetimeVarianceMS > 0) {
lifetime += Math.round(randomVariance(0, pData.lifetimeVarianceMS));
}
lifetime = Math.max(1, lifetime);
// Spin speed.
const spin =
pData.spinSpeed + randomRange(pData.spinRandomMin, pData.spinRandomMax);
// Initial color/size from first keyframe.
const k0 = pData.keys[0];
this.particles.push({
pos: spawnPos,
vel,
orientDir: [ejX, ejY, ejZ],
currentAge: 0,
totalLifetime: lifetime,
dataIndex: 0,
spinSpeed: spin,
currentSpin: 0,
r: k0.r,
g: k0.g,
b: k0.b,
a: k0.a,
size: k0.size,
});
}
}

48
src/particles/shaders.ts Normal file
View file

@ -0,0 +1,48 @@
export const particleVertexShader = /* glsl */ `
// 'position' is auto-declared by Three.js for ShaderMaterial.
attribute vec4 particleColor;
attribute float particleSize;
attribute float particleSpin;
attribute vec2 quadCorner; // (-0.5,-0.5) to (0.5,0.5)
varying vec2 vUv;
varying vec4 vColor;
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;
// 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;
varying vec2 vUv;
varying vec4 vColor;
void main() {
if (hasTexture) {
vec4 texColor = texture2D(particleTexture, vUv);
gl_FragColor = texColor * vColor;
} else {
gl_FragColor = vColor;
}
}
`;

62
src/particles/types.ts Normal file
View file

@ -0,0 +1,62 @@
/** Resolved particle data from a ParticleData datablock. */
export interface ParticleDataResolved {
dragCoefficient: number;
windCoefficient: number;
gravityCoefficient: number;
inheritedVelFactor: number;
constantAcceleration: number;
lifetimeMS: number;
lifetimeVarianceMS: number;
spinSpeed: number;
spinRandomMin: number;
spinRandomMax: number;
useInvAlpha: boolean;
/** 1-4 keyframes with normalized time (0-1). */
keys: ParticleKey[];
textureName: string;
}
export interface ParticleKey {
r: number;
g: number;
b: number;
a: number;
size: number;
time: number;
}
/** Resolved emitter data from a ParticleEmitterData datablock. */
export interface EmitterDataResolved {
ejectionPeriodMS: number;
periodVarianceMS: number;
ejectionVelocity: number;
velocityVariance: number;
ejectionOffset: number;
thetaMin: number;
thetaMax: number;
phiReferenceVel: number;
phiVariance: number;
overrideAdvances: boolean;
orientParticles: boolean;
orientOnVelocity: boolean;
lifetimeMS: number;
lifetimeVarianceMS: number;
particles: ParticleDataResolved;
}
/** Live particle instance during simulation. */
export interface Particle {
pos: [number, number, number];
vel: [number, number, number];
orientDir: [number, number, number];
currentAge: number;
totalLifetime: number;
dataIndex: number;
spinSpeed: number;
currentSpin: number;
r: number;
g: number;
b: number;
a: number;
size: number;
}

View file

@ -226,12 +226,7 @@ function summarizeRecording(state: EngineStoreState): JsonLike {
duration: recording.duration,
missionName: recording.missionName,
gameType: recording.gameType,
isMetadataOnly: !!recording.isMetadataOnly,
isPartial: !!recording.isPartial,
hasStreamingPlayback: !!recording.streamingPlayback,
entitiesCount: recording.entities.length,
cameraModesCount: recording.cameraModes.length,
controlPlayerGhostId: recording.controlPlayerGhostId ?? null,
};
}

View file

@ -8,6 +8,10 @@ import type {
TorqueObject,
TorqueRuntime,
} from "../torqueScript";
import {
buildSequenceAliasMap,
type SequenceAliasMap,
} from "../torqueScript/shapeConstructor";
export type PlaybackStatus = "stopped" | "playing" | "paused";
@ -77,6 +81,7 @@ export interface AppendRendererSampleInput {
export interface RuntimeSliceState {
runtime: TorqueRuntime | null;
sequenceAliases: SequenceAliasMap;
objectVersionById: Record<number, number>;
globalVersionByName: Record<string, number>;
objectIdsByName: Record<string, number>;
@ -182,60 +187,15 @@ function initialDiagnosticsCounts(): Record<RuntimeEvent["type"], number> {
};
}
function projectWorldFromDemo(recording: DemoRecording | null): WorldSliceState {
if (!recording) {
return {
entitiesById: {},
players: [],
ghosts: [],
projectiles: [],
flags: [],
teams: {},
scores: {},
};
}
const entitiesById: Record<string, DemoEntity> = {};
const players: string[] = [];
const ghosts: string[] = [];
const projectiles: string[] = [];
const flags: string[] = [];
for (const entity of recording.entities) {
const entityId = keyFromEntityId(entity.id);
entitiesById[entityId] = entity;
const type = entity.type.toLowerCase();
if (type === "player") {
players.push(entityId);
if (entityId.startsWith("player_")) {
ghosts.push(entityId);
}
continue;
}
if (type === "projectile") {
projectiles.push(entityId);
continue;
}
if (
entity.dataBlock?.toLowerCase() === "flag" ||
entity.dataBlock?.toLowerCase().includes("flag")
) {
flags.push(entityId);
}
}
return {
entitiesById,
players,
ghosts,
projectiles,
flags,
teams: {},
scores: {},
};
}
const emptyWorld: WorldSliceState = {
entitiesById: {},
players: [],
ghosts: [],
projectiles: [],
flags: [],
teams: {},
scores: {},
};
function buildRuntimeIndexes(runtime: TorqueRuntime): Pick<
RuntimeSliceState,
@ -289,6 +249,7 @@ const initialState: Omit<
> = {
runtime: {
runtime: null,
sequenceAliases: new Map(),
objectVersionById: {},
globalVersionByName: {},
objectIdsByName: {},
@ -331,10 +292,12 @@ export const engineStore = createStore<EngineStoreState>()(
setRuntime(runtime: TorqueRuntime) {
const indexes = buildRuntimeIndexes(runtime);
const sequenceAliases = buildSequenceAliasMap(runtime);
set((state) => ({
...state,
runtime: {
runtime,
sequenceAliases,
objectVersionById: indexes.objectVersionById,
globalVersionByName: indexes.globalVersionByName,
objectIdsByName: indexes.objectIdsByName,
@ -349,6 +312,7 @@ export const engineStore = createStore<EngineStoreState>()(
...state,
runtime: {
runtime: null,
sequenceAliases: new Map(),
objectVersionById: {},
globalVersionByName: {},
objectIdsByName: {},
@ -483,15 +447,12 @@ export const engineStore = createStore<EngineStoreState>()(
: null,
nextDurationSec: recording ? Number(recording.duration.toFixed(3)) : null,
isNull: recording == null,
isMetadataOnly: !!recording?.isMetadataOnly,
isPartial: !!recording?.isPartial,
hasStreamingPlayback: !!recording?.streamingPlayback,
stack: stack ?? "unavailable",
},
};
return {
...state,
world: projectWorldFromDemo(recording),
world: emptyWorld,
playback: {
recording,
status: "stopped",

69
src/terrainHeight.ts Normal file
View file

@ -0,0 +1,69 @@
/**
* Module-level terrain height sampler that bridges React (TerrainBlock) and
* non-React (streaming.ts) code. TerrainBlock registers a sampler once the
* heightmap is loaded; item physics queries it during simulation.
*/
const TERRAIN_SIZE = 256;
const HALF_SIZE = TERRAIN_SIZE / 2; // 128
const HEIGHT_SCALE = 2048;
export type HeightFn = (torqueX: number, torqueY: number) => number;
let sampler: HeightFn | null = null;
/** Called by TerrainBlock when heightmap is loaded. Pass null on unmount. */
export function setTerrainHeightSampler(fn: HeightFn | null): void {
sampler = fn;
}
/** Returns terrain Z at Torque (x, y) or null if no terrain is loaded. */
export function getTerrainHeightAt(
torqueX: number,
torqueY: number,
): number | null {
return sampler ? sampler(torqueX, torqueY) : null;
}
/**
* Build a height sampler closure from raw heightmap data and terrain params.
* Uses bilinear interpolation and clamps to terrain bounds.
*
* Coordinate mapping (derived from terrain geometry rotations):
* - Torque X heightmap row
* - Torque Y heightmap col
*/
export function createTerrainHeightSampler(
heightMap: Uint16Array,
squareSize: number,
): HeightFn {
return (torqueX: number, torqueY: number): number => {
// Convert Torque world coords to fractional heightmap coords.
// The terrain origin is at (-squareSize * 128, -squareSize * 128, 0),
// so grid center (128, 128) corresponds to Torque (0, 0).
const col = torqueY / squareSize + HALF_SIZE;
const row = torqueX / squareSize + HALF_SIZE;
// Clamp to valid range
const clampedCol = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
const clampedRow = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
const col0 = Math.floor(clampedCol);
const row0 = Math.floor(clampedRow);
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
const fx = clampedCol - col0;
const fy = clampedRow - row0;
// Bilinear interpolation
const h00 = heightMap[row0 * TERRAIN_SIZE + col0];
const h10 = heightMap[row0 * TERRAIN_SIZE + col1];
const h01 = heightMap[row1 * TERRAIN_SIZE + col0];
const h11 = heightMap[row1 * TERRAIN_SIZE + col1];
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return ((h0 * (1 - fy) + h1 * fy) / 65535) * HEIGHT_SCALE;
};
}

View file

@ -0,0 +1,128 @@
import type { TorqueRuntime } from "./types";
/**
* Register C++ engine method stubs that TorqueScript code expects to exist.
* These are methods that would normally be implemented in the Torque C++ engine
* (on classes like ShapeBase, GameBase, SimObject, SimGroup) and called by
* game scripts (power.cs, staticShape.cs, station.cs, deployables.cs, etc.).
*/
export function registerEngineStubs(runtime: TorqueRuntime): void {
const reg = runtime.$.registerMethod.bind(runtime.$);
// ---- Animation thread methods (ShapeBase) ----
reg("ShapeBase", "playThread", (this_, slot, sequence) => {
if (!this_._threads) this_._threads = {};
this_._threads[Number(slot)] = {
sequence: String(sequence),
playing: true,
direction: true, // forward
};
});
reg("ShapeBase", "stopThread", (this_, slot) => {
if (this_._threads) {
delete this_._threads[Number(slot)];
}
});
reg("ShapeBase", "setThreadDir", (this_, slot, forward) => {
if (!this_._threads) this_._threads = {};
const s = Number(slot);
if (this_._threads[s]) {
this_._threads[s].direction = !!Number(forward);
} else {
this_._threads[s] = {
sequence: "",
playing: false,
direction: !!Number(forward),
};
}
});
reg("ShapeBase", "pauseThread", (this_, slot) => {
if (this_._threads?.[Number(slot)]) {
this_._threads[Number(slot)].playing = false;
}
});
// ---- Audio (no-op) ----
reg("ShapeBase", "playAudio", () => {});
reg("ShapeBase", "stopAudio", () => {});
// ---- Object hierarchy (SimObject / SimGroup) ----
reg("SimObject", "getDatablock", (this_) => {
const dbName = this_.datablock;
if (!dbName) return "";
return runtime.getObjectByName(String(dbName)) ?? "";
});
reg("SimObject", "getGroup", (this_) => {
return this_._parent ?? "";
});
reg("SimObject", "getName", (this_) => {
return this_._name ?? "";
});
reg("SimObject", "getType", () => {
// Return a bitmask; scripts use this with $TypeMasks checks.
// GameBaseObjectType = 0x4000 covers StaticShape/Turret/etc.
return 0x4000;
});
reg("SimGroup", "getCount", (this_) => {
return this_._children ? this_._children.length : 0;
});
reg("SimGroup", "getObject", (this_, index) => {
const children = this_._children;
if (!children) return "";
return children[Number(index)] ?? "";
});
// ---- Power / energy stubs (GameBase) ----
reg("GameBase", "isEnabled", () => true);
reg("GameBase", "isDisabled", () => false);
reg("GameBase", "setPoweredState", () => {});
reg("GameBase", "setRechargeRate", () => {});
reg("GameBase", "getRechargeRate", () => 0);
reg("GameBase", "setEnergyLevel", () => {});
reg("GameBase", "getEnergyLevel", () => 0);
// ---- Damage / repair stubs (ShapeBase) ----
reg("ShapeBase", "getDamageLevel", () => 0);
reg("ShapeBase", "setDamageLevel", () => {});
reg("ShapeBase", "getRepairRate", () => 0);
reg("ShapeBase", "setRepairRate", () => {});
reg("ShapeBase", "getDamagePercent", () => 0);
// ---- Client / control stubs (GameBase) ----
reg("GameBase", "getControllingClient", () => 0);
// ---- Object method: schedule ----
// %obj.schedule(delay, "methodName", args...) calls the method on %obj
// after a delay. Distinct from the global schedule() function in builtins.
reg("SimObject", "schedule", (this_, delay, methodName, ...args) => {
const ms = Number(delay) || 0;
const timeoutId = setTimeout(() => {
runtime.state.pendingTimeouts.delete(timeoutId);
try {
runtime.$.call(this_, String(methodName), ...args);
} catch (err) {
console.error(
`schedule: error calling ${methodName} on ${this_._id}:`,
err,
);
}
}, ms);
runtime.state.pendingTimeouts.add(timeoutId);
return timeoutId;
});
}

View file

@ -0,0 +1,30 @@
export const ignoreScripts = [
"scripts/admin.cs",
// `ignoreScripts` supports globs, but out of an abundance of caution
// we don't want to do `ai*.cs` in case there's some non-AI related
// word like "air" in a script name.
"scripts/ai.cs",
"scripts/aiBotProfiles.cs",
"scripts/aiBountyGame.cs",
"scripts/aiChat.cs",
"scripts/aiCnH.cs",
"scripts/aiCTF.cs",
"scripts/aiDeathMatch.cs",
"scripts/aiDebug.cs",
"scripts/aiDefaultTasks.cs",
"scripts/aiDnD.cs",
"scripts/aiHumanTasks.cs",
"scripts/aiHunters.cs",
"scripts/aiInventory.cs",
"scripts/aiObjectiveBuilder.cs",
"scripts/aiObjectives.cs",
"scripts/aiRabbit.cs",
"scripts/aiSiege.cs",
"scripts/aiTDM.cs",
"scripts/aiTeamHunters.cs",
"scripts/deathMessages.cs",
"scripts/graphBuild.cs",
"scripts/navGraph.cs",
"scripts/serverTasks.cs",
"scripts/spdialog.cs",
];

View file

@ -2,6 +2,7 @@ import TorqueScript from "@/generated/TorqueScript.cjs";
import { generate, type GeneratorOptions } from "./codegen";
import type { Program } from "./ast";
import { createRuntime } from "./runtime";
import { registerEngineStubs } from "./engineMethods";
import { TorqueObject, TorqueRuntime, TorqueRuntimeOptions } from "./types";
export { generate, type GeneratorOptions } from "./codegen";
@ -221,6 +222,8 @@ export function runServer(options: RunServerOptions): RunServerResult {
preloadScripts: [...preloadScripts, ...gameScripts],
});
registerEngineStubs(runtime);
const ready = (async function createServer() {
try {
// Load all required scripts

View file

@ -175,6 +175,10 @@ export const DEFAULT_REACTIVE_METHOD_RULES: ReactiveMethodRule[] = [
"deleteallobjects",
"add",
"remove",
"playthread",
"stopthread",
"setthreaddir",
"pausethread",
],
},
{

View file

@ -1185,7 +1185,7 @@ export function createRuntime(
className: string,
methodName: string,
callback: (thisObj: TorqueObject, ...args: any[]) => void,
): void {
): () => void {
let classMethods = methodHooks.get(className);
if (!classMethods) {
classMethods = new CaseInsensitiveMap();
@ -1197,6 +1197,10 @@ export function createRuntime(
classMethods.set(methodName, hooks);
}
hooks.push(callback);
return () => {
const idx = hooks!.indexOf(callback);
if (idx !== -1) hooks!.splice(idx, 1);
};
},
};

View file

@ -0,0 +1,94 @@
import type { AnimationAction, AnimationClip, AnimationMixer } from "three";
import type { TorqueRuntime } from "./types";
/**
* Outer key: shape filename (lowercase, e.g. "light_male.dts").
* Inner map: alias (lowercase) -> GLB clip name (lowercase).
*/
export type SequenceAliasMap = Map<string, Map<string, string>>;
/**
* Build sequence alias maps from TSShapeConstructor datablocks already
* registered in the runtime. Each datablock has `baseshape` and
* `sequence0``sequence127` properties like:
*
* sequence1 = "light_male_forward.dsq run"
*
* The GLB clip name is derived by stripping the DTS model prefix and .dsq
* extension from the DSQ filename, matching the Blender addon's
* `dsq_name_from_filename` behavior.
*/
export function buildSequenceAliasMap(runtime: TorqueRuntime): SequenceAliasMap {
const result: SequenceAliasMap = new Map();
for (const obj of runtime.state.datablocks.values()) {
if (obj._class !== "tsshapeconstructor") continue;
const baseShape = obj.baseshape;
if (typeof baseShape !== "string") continue;
const shapeKey = baseShape.toLowerCase();
// Derive prefix: "light_male.dts" -> "light_male_"
const stem = shapeKey.replace(/\.dts$/i, "");
const prefix = stem + "_";
const aliases = new Map<string, string>();
for (let i = 0; i <= 127; i++) {
const value = obj[`sequence${i}`];
if (typeof value !== "string") continue;
// Format: "filename.dsq alias"
const spaceIdx = value.indexOf(" ");
if (spaceIdx === -1) continue;
const dsqFile = value.slice(0, spaceIdx).toLowerCase();
const alias = value.slice(spaceIdx + 1).trim().toLowerCase();
if (!alias) continue;
// Strip prefix and .dsq to get the GLB clip name.
// Only process DSQs matching the model prefix (others won't be in the GLB).
if (!dsqFile.startsWith(prefix) || !dsqFile.endsWith(".dsq")) continue;
const clipName = dsqFile.slice(prefix.length, -4);
if (clipName) {
aliases.set(alias, clipName);
}
}
if (aliases.size > 0) {
result.set(shapeKey, aliases);
}
}
return result;
}
/**
* Build a case-insensitive action map from GLB clips, augmented with
* TSShapeConstructor aliases. Both the original clip name and the alias
* resolve to the same AnimationAction.
*/
export function getAliasedActions(
clips: AnimationClip[],
mixer: AnimationMixer,
aliases: Map<string, string> | undefined,
): Map<string, AnimationAction> {
const actions = new Map<string, AnimationAction>();
for (const clip of clips) {
const action = mixer.clipAction(clip);
actions.set(clip.name.toLowerCase(), action);
}
if (aliases) {
for (const [alias, clipName] of aliases) {
const action = actions.get(clipName);
if (action && !actions.has(alias)) {
actions.set(alias, action);
}
}
}
return actions;
}

View file

@ -301,7 +301,7 @@ export interface RuntimeAPI {
className: string,
methodName: string,
callback: (thisObj: TorqueObject, ...args: any[]) => void,
): void;
): () => void;
}
export interface FunctionsAPI {