mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-09 15:30:47 +00:00
add shapes test page, particle effects
This commit is contained in:
parent
d9be5c1eba
commit
d1acb6a5ce
269 changed files with 5777 additions and 2132 deletions
|
|
@ -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>
|
||||
|
|
|
|||
715
src/components/DemoParticleEffects.tsx
Normal file
715
src/components/DemoParticleEffects.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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)),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export function useRuntime(): TorqueRuntime {
|
|||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
|
|
|
|||
207
src/components/ShapeSelect.tsx
Normal file
207
src/components/ShapeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
472
src/particles/ParticleSystem.ts
Normal file
472
src/particles/ParticleSystem.ts
Normal 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
48
src/particles/shaders.ts
Normal 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
62
src/particles/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
69
src/terrainHeight.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
128
src/torqueScript/engineMethods.ts
Normal file
128
src/torqueScript/engineMethods.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
30
src/torqueScript/ignoreScripts.ts
Normal file
30
src/torqueScript/ignoreScripts.ts
Normal 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",
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@ export const DEFAULT_REACTIVE_METHOD_RULES: ReactiveMethodRule[] = [
|
|||
"deleteallobjects",
|
||||
"add",
|
||||
"remove",
|
||||
"playthread",
|
||||
"stopthread",
|
||||
"setthreaddir",
|
||||
"pausethread",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
94
src/torqueScript/shapeConstructor.ts
Normal file
94
src/torqueScript/shapeConstructor.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -301,7 +301,7 @@ export interface RuntimeAPI {
|
|||
className: string,
|
||||
methodName: string,
|
||||
callback: (thisObj: TorqueObject, ...args: any[]) => void,
|
||||
): void;
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export interface FunctionsAPI {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue