add shapes test page, particle effects

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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