mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-22 22:00:59 +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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue