mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-24 06:39:29 +00:00
begin live server support
This commit is contained in:
parent
0c9ddb476a
commit
e4ae265184
368 changed files with 17756 additions and 7738 deletions
452
src/components/ShapeModel.tsx
Normal file
452
src/components/ShapeModel.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
AnimationMixer,
|
||||
LoopOnce,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import type { Group, Material } from "three";
|
||||
import { effectNow, engineStore } from "../state";
|
||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import {
|
||||
_r90,
|
||||
_r90inv,
|
||||
getPosedNodeTransform,
|
||||
processShapeScene,
|
||||
} from "../stream/playbackUtils";
|
||||
import {
|
||||
loadIflAtlas,
|
||||
getFrameIndexForTime,
|
||||
updateAtlasFrame,
|
||||
} from "./useIflTexture";
|
||||
import type { IflAtlas } from "./useIflTexture";
|
||||
import {
|
||||
ShapeRenderer,
|
||||
useStaticShape,
|
||||
} from "./GenericShape";
|
||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import type { StreamEntity } from "../stream/types";
|
||||
import type { StreamingPlayback } from "../stream/types";
|
||||
|
||||
/**
|
||||
* Map weapon shape to the arm blend animation (armThread).
|
||||
* Only missile launcher and sniper rifle have custom arm poses; all others
|
||||
* use the default `lookde`.
|
||||
*/
|
||||
function getArmThread(weaponShape: string | undefined): string {
|
||||
if (!weaponShape) return "lookde";
|
||||
const lower = weaponShape.toLowerCase();
|
||||
if (lower.includes("missile")) return "lookms";
|
||||
if (lower.includes("sniper")) return "looksn";
|
||||
return "lookde";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mounted weapon using the Torque engine's mount system.
|
||||
*
|
||||
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
|
||||
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
|
||||
* animation) pose, with the weapon-specific arm animation applied additively.
|
||||
* The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
|
||||
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
|
||||
*/
|
||||
export function WeaponModel({
|
||||
shapeName,
|
||||
playerShapeName,
|
||||
}: {
|
||||
shapeName: string;
|
||||
playerShapeName: string;
|
||||
}) {
|
||||
const playerGltf = useStaticShape(playerShapeName);
|
||||
const weaponGltf = useStaticShape(shapeName);
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const mountTransform = useMemo(() => {
|
||||
// Get Mount0 from the player's posed skeleton with arm animation applied.
|
||||
const armThread = getArmThread(shapeName);
|
||||
const m0 = getPosedNodeTransform(
|
||||
playerGltf.scene,
|
||||
playerGltf.animations,
|
||||
"Mount0",
|
||||
[armThread],
|
||||
);
|
||||
if (!m0) return { position: undefined, quaternion: undefined };
|
||||
|
||||
// Get Mountpoint from weapon (may not be animated).
|
||||
const mp = getPosedNodeTransform(
|
||||
weaponGltf.scene,
|
||||
weaponGltf.animations,
|
||||
"Mountpoint",
|
||||
);
|
||||
|
||||
// Compute T_mount = R90 * M0 * MP^(-1) * R90^(-1)
|
||||
// This conjugates the GLB-space mount transform by ShapeRenderer's 90° Y
|
||||
// rotation so the weapon is correctly oriented in entity space.
|
||||
let combinedPos: Vector3;
|
||||
let combinedQuat: Quaternion;
|
||||
|
||||
if (mp) {
|
||||
// MP^(-1)
|
||||
const mpInvQuat = mp.quaternion.clone().invert();
|
||||
const mpInvPos = mp.position.clone().negate().applyQuaternion(mpInvQuat);
|
||||
|
||||
// M0 * MP^(-1)
|
||||
combinedQuat = m0.quaternion.clone().multiply(mpInvQuat);
|
||||
combinedPos = mpInvPos
|
||||
.clone()
|
||||
.applyQuaternion(m0.quaternion)
|
||||
.add(m0.position);
|
||||
} else {
|
||||
combinedPos = m0.position.clone();
|
||||
combinedQuat = m0.quaternion.clone();
|
||||
}
|
||||
|
||||
// R90 * combined * R90^(-1)
|
||||
const mountPos = combinedPos.applyQuaternion(_r90);
|
||||
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
|
||||
|
||||
return { position: mountPos, quaternion: mountQuat };
|
||||
}, [playerGltf, weaponGltf]);
|
||||
|
||||
const torqueObject = useMemo<TorqueObject>(
|
||||
() => ({
|
||||
_class: "weapon",
|
||||
_className: "Weapon",
|
||||
_id: 0,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ShapeInfoProvider object={torqueObject} shapeName={shapeName} type="Item">
|
||||
<group
|
||||
position={mountTransform.position}
|
||||
quaternion={mountTransform.quaternion}
|
||||
>
|
||||
<ShapeRenderer loadingColor="#4488ff" />
|
||||
</group>
|
||||
</ShapeInfoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explosion shape rendering ──
|
||||
//
|
||||
// Explosion DTS shapes are flat billboard planes with IFL-animated textures,
|
||||
// vis-keyframed opacity, and size keyframe interpolation. They use
|
||||
// useStaticShape (shared GLTF cache via drei's useGLTF) but render directly
|
||||
// rather than through ShapeRenderer, because:
|
||||
// - faceViewer billboarding needs to control the shape orientation
|
||||
// - ShapeModel's fixed 90° Y rotation conflicts with billboard orientation
|
||||
// - Explosion shapes need LoopOnce animation, not the deploy/ambient lifecycle
|
||||
|
||||
interface VisNode {
|
||||
mesh: any;
|
||||
keyframes: number[];
|
||||
duration: number;
|
||||
cyclic: boolean;
|
||||
}
|
||||
|
||||
interface IflInfo {
|
||||
mesh: any;
|
||||
iflPath: string;
|
||||
sequenceName?: string;
|
||||
duration?: number;
|
||||
cyclic?: boolean;
|
||||
toolBegin?: number;
|
||||
}
|
||||
|
||||
function extractSizeKeyframes(expBlock: Record<string, unknown>): {
|
||||
times: number[];
|
||||
sizes: [number, number, number][];
|
||||
} {
|
||||
const rawSizes = expBlock.sizes as
|
||||
| Array<{ x: number; y: number; z: number }>
|
||||
| undefined;
|
||||
const rawTimes = expBlock.times as number[] | undefined;
|
||||
|
||||
if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
|
||||
return { times: [0, 1], sizes: [[1, 1, 1], [1, 1, 1]] };
|
||||
}
|
||||
|
||||
// sizes are packed as value*100 integers on the wire; divide by 100.
|
||||
const sizes: [number, number, number][] = rawSizes.map((s) => [
|
||||
s.x / 100,
|
||||
s.y / 100,
|
||||
s.z / 100,
|
||||
]);
|
||||
// times are written via writeFloat(8) and are already [0,1] floats.
|
||||
const times = Array.isArray(rawTimes)
|
||||
? rawTimes
|
||||
: sizes.map((_, i) => i / Math.max(sizes.length - 1, 1));
|
||||
|
||||
return { times, sizes };
|
||||
}
|
||||
|
||||
function interpolateSize(
|
||||
keyframes: { times: number[]; sizes: [number, number, number][] },
|
||||
t: number,
|
||||
): [number, number, number] {
|
||||
const { times, sizes } = keyframes;
|
||||
if (times.length === 0) return [1, 1, 1];
|
||||
if (t <= times[0]) return sizes[0];
|
||||
if (t >= times[times.length - 1]) return sizes[sizes.length - 1];
|
||||
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
if (t >= times[i] && t <= times[i + 1]) {
|
||||
const frac = (t - times[i]) / (times[i + 1] - times[i]);
|
||||
return [
|
||||
sizes[i][0] + (sizes[i + 1][0] - sizes[i][0]) * frac,
|
||||
sizes[i][1] + (sizes[i + 1][1] - sizes[i][1]) * frac,
|
||||
sizes[i][2] + (sizes[i + 1][2] - sizes[i][2]) * frac,
|
||||
];
|
||||
}
|
||||
}
|
||||
return sizes[sizes.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
|
||||
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
|
||||
*/
|
||||
export function ExplosionShape({
|
||||
entity,
|
||||
playback,
|
||||
}: {
|
||||
entity: StreamEntity;
|
||||
playback: StreamingPlayback;
|
||||
}) {
|
||||
const gltf = useStaticShape(entity.dataBlock!);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const startTimeRef = useRef(effectNow());
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const randAngleRef = useRef(Math.random() * Math.PI * 2);
|
||||
const iflAtlasesRef = useRef<Array<{ atlas: IflAtlas; info: IflInfo }>>([]);
|
||||
|
||||
const expBlock = useMemo(() => {
|
||||
if (!entity.explosionDataBlockId) return undefined;
|
||||
return playback.getDataBlockData(entity.explosionDataBlockId);
|
||||
}, [entity.explosionDataBlockId, playback]);
|
||||
|
||||
const sizeKeyframes = useMemo(
|
||||
() => (expBlock ? extractSizeKeyframes(expBlock) : undefined),
|
||||
[expBlock],
|
||||
);
|
||||
|
||||
const baseScale = useMemo<[number, number, number]>(() => {
|
||||
const explosionScale = expBlock?.explosionScale as
|
||||
| { x: number; y: number; z: number }
|
||||
| undefined;
|
||||
return explosionScale
|
||||
? [explosionScale.x / 100, explosionScale.y / 100, explosionScale.z / 100]
|
||||
: [1, 1, 1];
|
||||
}, [expBlock]);
|
||||
|
||||
// lifetimeMS is packed as value >> 5 (ticks); recover with << 5 (× 32).
|
||||
const lifetimeTicks = (expBlock?.lifetimeMS as number) ?? 31;
|
||||
const lifetimeMS = lifetimeTicks * 32;
|
||||
const faceViewer = entity.faceViewer !== false;
|
||||
|
||||
// Clone scene, process materials, collect vis nodes and IFL info.
|
||||
const { scene, mixer, visNodes, iflInfos, materials } = useMemo(() => {
|
||||
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
||||
|
||||
// Collect IFL info BEFORE processShapeScene replaces materials.
|
||||
const iflInfos: IflInfo[] = [];
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh || !node.material) return;
|
||||
const mat = Array.isArray(node.material) ? node.material[0] : node.material;
|
||||
if (!mat?.userData) return;
|
||||
const flags = new Set<string>(mat.userData.flag_names ?? []);
|
||||
const rp: string | undefined = mat.userData.resource_path;
|
||||
if (flags.has("IflMaterial") && rp) {
|
||||
const ud = node.userData;
|
||||
iflInfos.push({
|
||||
mesh: node,
|
||||
iflPath: `textures/${rp}.ifl`,
|
||||
sequenceName: ud?.ifl_sequence
|
||||
? String(ud.ifl_sequence).toLowerCase()
|
||||
: undefined,
|
||||
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
|
||||
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
|
||||
toolBegin: ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
processShapeScene(scene, entity.dataBlock);
|
||||
|
||||
// Collect vis-animated nodes keyed by sequence name.
|
||||
const visNodes: VisNode[] = [];
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh) return;
|
||||
const ud = node.userData;
|
||||
if (!ud) return;
|
||||
const kf = ud.vis_keyframes;
|
||||
const dur = ud.vis_duration;
|
||||
const seqName = (ud.vis_sequence ?? "").toLowerCase();
|
||||
if (!seqName || !Array.isArray(kf) || kf.length <= 1 || !dur || dur <= 0)
|
||||
return;
|
||||
// Only include vis nodes tied to the "ambient" sequence.
|
||||
if (seqName === "ambient") {
|
||||
visNodes.push({ mesh: node, keyframes: kf, duration: dur, cyclic: !!ud.vis_cyclic });
|
||||
}
|
||||
});
|
||||
|
||||
// Activate vis nodes: make visible, ensure transparent material.
|
||||
for (const v of visNodes) {
|
||||
v.mesh.visible = true;
|
||||
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
||||
v.mesh.material.transparent = true;
|
||||
v.mesh.material.depthWrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also un-hide IFL meshes that don't have vis_sequence (always visible).
|
||||
for (const info of iflInfos) {
|
||||
if (!info.mesh.userData?.vis_sequence) {
|
||||
info.mesh.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up animation mixer with the ambient clip (LoopOnce).
|
||||
const clips = new Map<string, any>();
|
||||
for (const clip of gltf.animations) {
|
||||
clips.set(clip.name.toLowerCase(), clip);
|
||||
}
|
||||
const ambientClip = clips.get("ambient");
|
||||
let mixer: AnimationMixer | null = null;
|
||||
if (ambientClip) {
|
||||
mixer = new AnimationMixer(scene);
|
||||
const action = mixer.clipAction(ambientClip);
|
||||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
// playSpeed is packed as value*20 on the wire; divide by 20.
|
||||
const playSpeed = ((expBlock?.playSpeed as number) ?? 20) / 20;
|
||||
action.timeScale = playSpeed;
|
||||
action.play();
|
||||
}
|
||||
|
||||
// Collect all materials for fade-out.
|
||||
const materials: Material[] = [];
|
||||
scene.traverse((child: any) => {
|
||||
if (!child.isMesh) return;
|
||||
if (Array.isArray(child.material)) {
|
||||
materials.push(...child.material);
|
||||
} else if (child.material) {
|
||||
materials.push(child.material);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable frustum culling (explosion may scale beyond bounds).
|
||||
scene.traverse((child) => { child.frustumCulled = false; });
|
||||
|
||||
return { scene, mixer, visNodes, iflInfos, materials };
|
||||
}, [gltf, expBlock]);
|
||||
|
||||
// Load IFL texture atlases.
|
||||
useEffect(() => {
|
||||
iflAtlasesRef.current = [];
|
||||
for (const info of iflInfos) {
|
||||
loadIflAtlas(info.iflPath)
|
||||
.then((atlas) => {
|
||||
const mat = Array.isArray(info.mesh.material)
|
||||
? info.mesh.material[0]
|
||||
: info.mesh.material;
|
||||
if (mat) {
|
||||
mat.map = atlas.texture;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
iflAtlasesRef.current.push({ atlas, info });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [iflInfos]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const playbackState = engineStore.getState().playback;
|
||||
const effectDelta = playbackState.status === "playing"
|
||||
? delta * playbackState.rate : 0;
|
||||
|
||||
const elapsed = effectNow() - startTimeRef.current;
|
||||
const t = Math.min(elapsed / lifetimeMS, 1);
|
||||
const elapsedSec = elapsed / 1000;
|
||||
|
||||
// Advance skeleton animation.
|
||||
if (mixer) {
|
||||
mixer.update(effectDelta);
|
||||
}
|
||||
|
||||
// Fade multiplier for the last 20% of lifetime.
|
||||
const fadeAlpha = t > 0.8 ? 1 - (t - 0.8) / 0.2 : 1;
|
||||
|
||||
// Drive vis opacity animation.
|
||||
for (const { mesh, keyframes, duration, cyclic } of visNodes) {
|
||||
const mat = mesh.material;
|
||||
if (!mat || Array.isArray(mat)) continue;
|
||||
const rawT = elapsedSec / duration;
|
||||
const vt = cyclic ? rawT % 1 : Math.min(rawT, 1);
|
||||
const n = keyframes.length;
|
||||
const pos = vt * n;
|
||||
const lo = Math.floor(pos) % n;
|
||||
const hi = (lo + 1) % n;
|
||||
const frac = pos - Math.floor(pos);
|
||||
const visOpacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
||||
mat.opacity = visOpacity * fadeAlpha;
|
||||
}
|
||||
|
||||
// Also fade non-vis materials.
|
||||
if (fadeAlpha < 1) {
|
||||
for (const mat of materials) {
|
||||
if ("opacity" in mat) {
|
||||
mat.transparent = true;
|
||||
(mat as any).opacity *= fadeAlpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance IFL texture atlases.
|
||||
for (const { atlas, info } of iflAtlasesRef.current) {
|
||||
let iflTime: number;
|
||||
if (info.sequenceName && info.duration) {
|
||||
const pos = info.cyclic
|
||||
? (elapsedSec / info.duration) % 1
|
||||
: Math.min(elapsedSec / info.duration, 1);
|
||||
iflTime = pos * info.duration + (info.toolBegin ?? 0);
|
||||
} else {
|
||||
iflTime = elapsedSec;
|
||||
}
|
||||
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, iflTime));
|
||||
}
|
||||
|
||||
// Size keyframe interpolation.
|
||||
if (sizeKeyframes) {
|
||||
const size = interpolateSize(sizeKeyframes, t);
|
||||
group.scale.set(
|
||||
size[0] * baseScale[0],
|
||||
size[1] * baseScale[1],
|
||||
size[2] * baseScale[2],
|
||||
);
|
||||
}
|
||||
|
||||
// faceViewer: billboard toward camera with random Z rotation.
|
||||
if (faceViewer) {
|
||||
group.lookAt(state.camera.position);
|
||||
group.rotateZ(randAngleRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{/* Flip 180° around Y so the face (GLB +Z normal) points toward the
|
||||
camera after the parent group's lookAt (which aims -Z at camera). */}
|
||||
<group rotation={[0, Math.PI, 0]}>
|
||||
<primitive object={scene} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue