mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 14:21:01 +00:00
1107 lines
37 KiB
TypeScript
1107 lines
37 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import type { MutableRefObject } from "react";
|
|
import { useFrame } from "@react-three/fiber";
|
|
import {
|
|
AdditiveAnimationBlendMode,
|
|
AnimationMixer,
|
|
AnimationUtils,
|
|
FrontSide,
|
|
Group,
|
|
LoopOnce,
|
|
LoopRepeat,
|
|
Object3D,
|
|
PositionalAudio,
|
|
Vector3,
|
|
} from "three";
|
|
import type { AnimationAction, AnimationClip } from "three";
|
|
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
|
import {
|
|
ANIM_TRANSITION_TIME,
|
|
DEFAULT_EYE_HEIGHT,
|
|
disposeClonedScene,
|
|
getKeyframeAtTime,
|
|
getPosedNodeTransform,
|
|
processShapeScene,
|
|
} from "../stream/playbackUtils";
|
|
import { pickMoveAnimation } from "../stream/playerAnimation";
|
|
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
|
|
import type { WeaponAnimState } from "../stream/weaponStateMachine";
|
|
import { getAliasedActions } from "../torqueScript/shapeConstructor";
|
|
import { useStaticShape, ShapePlaceholder } from "./GenericShape";
|
|
import { useAnisotropy } from "./useAnisotropy";
|
|
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
|
|
import { DebugSuspense } from "./DebugSuspense";
|
|
import { useAudio } from "./AudioContext";
|
|
import {
|
|
resolveAudioProfile,
|
|
playOneShotSound,
|
|
getCachedAudioBuffer,
|
|
getSoundGeneration,
|
|
trackSound,
|
|
untrackSound,
|
|
} from "./AudioEmitter";
|
|
import { audioToUrl } from "../loaders";
|
|
import { useSettings } from "./SettingsProvider";
|
|
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
|
|
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
|
import type { PlayerEntity } from "../state/gameEntityTypes";
|
|
|
|
/**
|
|
* 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";
|
|
}
|
|
|
|
/** Number of table actions in the engine's ActionAnimationList (Tribes2.exe build 25034). */
|
|
const NUM_TABLE_ACTION_ANIMS = 8;
|
|
|
|
/** Table action names in engine order (indices 0-7). */
|
|
const TABLE_ACTION_NAMES = [
|
|
"root",
|
|
"run",
|
|
"back",
|
|
"side",
|
|
"fall",
|
|
"jet",
|
|
"jump",
|
|
"land",
|
|
];
|
|
|
|
interface ActionAnimEntry {
|
|
/** GLB clip name (lowercase, e.g. "diehead"). */
|
|
clipName: string;
|
|
/** Engine alias (lowercase, e.g. "death1"). */
|
|
alias: string;
|
|
}
|
|
|
|
/**
|
|
* Build the engine's action index -> animation entry mapping from a
|
|
* TSShapeConstructor's sequence entries (e.g. `"heavy_male_root.dsq root"`).
|
|
*
|
|
* The engine builds its action list as:
|
|
* 1. Table actions (0-7): found by searching for aliased names (root, run, etc.)
|
|
* 2. Non-table actions (8+): remaining sequences in TSShapeConstructor order.
|
|
*/
|
|
function buildActionAnimMap(
|
|
sequences: string[],
|
|
shapePrefix: string,
|
|
): Map<number, ActionAnimEntry> {
|
|
const result = new Map<number, ActionAnimEntry>();
|
|
|
|
// Parse each sequence entry into { clipName, alias }.
|
|
const parsed: Array<{ clipName: string; alias: string }> = [];
|
|
for (const entry of sequences) {
|
|
const spaceIdx = entry.indexOf(" ");
|
|
if (spaceIdx === -1) continue;
|
|
const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
|
|
const alias = entry
|
|
.slice(spaceIdx + 1)
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq"))
|
|
continue;
|
|
const clipName = dsqFile.slice(shapePrefix.length, -4);
|
|
if (clipName) parsed.push({ clipName, alias });
|
|
}
|
|
|
|
// Find which parsed entries are table actions (by alias name).
|
|
const tableEntryIndices = new Set<number>();
|
|
for (let i = 0; i < TABLE_ACTION_NAMES.length; i++) {
|
|
const name = TABLE_ACTION_NAMES[i];
|
|
for (let pi = 0; pi < parsed.length; pi++) {
|
|
if (parsed[pi].alias === name) {
|
|
tableEntryIndices.add(pi);
|
|
result.set(i, parsed[pi]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-table actions: remaining entries in TSShapeConstructor order.
|
|
let actionIdx = NUM_TABLE_ACTION_ANIMS;
|
|
for (let pi = 0; pi < parsed.length; pi++) {
|
|
if (!tableEntryIndices.has(pi)) {
|
|
result.set(actionIdx, parsed[pi]);
|
|
actionIdx++;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Stop, disconnect, and remove a looping PositionalAudio from its parent. */
|
|
function stopLoopingSound(
|
|
soundRef: React.MutableRefObject<PositionalAudio | null>,
|
|
stateRef: React.MutableRefObject<number>,
|
|
parent?: Object3D,
|
|
) {
|
|
const sound = soundRef.current;
|
|
if (!sound) return;
|
|
untrackSound(sound);
|
|
try {
|
|
sound.stop();
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
try {
|
|
sound.disconnect();
|
|
} catch {
|
|
/* already disconnected */
|
|
}
|
|
parent?.remove(sound);
|
|
soundRef.current = null;
|
|
stateRef.current = -1;
|
|
}
|
|
|
|
/**
|
|
* Renders a player model with skeleton-preserving animation.
|
|
*
|
|
* Uses SkeletonUtils.clone to deep-clone the GLTF scene with skeleton bindings
|
|
* intact, then drives a per-entity AnimationMixer to play movement animations
|
|
* (Root, Forward, Back, Side, Fall) selected from the keyframe velocity data.
|
|
* Weapon is attached to the animated Mount0 bone.
|
|
*/
|
|
export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|
const engineStore = useEngineStoreApi();
|
|
const shapeName = entity.shapeName ?? entity.dataBlock;
|
|
const gltf = useStaticShape(shapeName!);
|
|
const shapeAliases = useEngineSelector((state) => {
|
|
const sn = shapeName?.toLowerCase();
|
|
return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
|
|
});
|
|
const anisotropy = useAnisotropy();
|
|
|
|
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
|
|
const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
|
|
useMemo(() => {
|
|
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
|
const iflInits = processShapeScene(scene, undefined, { anisotropy });
|
|
|
|
// Use front-face-only rendering so the camera can see out from inside the
|
|
// model in first-person (backface culling hides interior faces).
|
|
scene.traverse((n: any) => {
|
|
if (n.isMesh && n.material) {
|
|
const mats = Array.isArray(n.material) ? n.material : [n.material];
|
|
for (const m of mats) m.side = FrontSide;
|
|
}
|
|
});
|
|
|
|
const mix = new AnimationMixer(scene);
|
|
|
|
let m0: Object3D | null = null;
|
|
let m1: Object3D | null = null;
|
|
let m2: Object3D | null = null;
|
|
scene.traverse((n) => {
|
|
if (!m0 && n.name === "Mount0") m0 = n;
|
|
if (!m1 && n.name === "Mount1") m1 = n;
|
|
if (!m2 && n.name === "Mount2") m2 = n;
|
|
});
|
|
|
|
return {
|
|
clonedScene: scene,
|
|
mixer: mix,
|
|
mount0: m0,
|
|
mount1: m1,
|
|
mount2: m2,
|
|
iflInitializers: iflInits,
|
|
};
|
|
}, [gltf, anisotropy]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
disposeClonedScene(clonedScene);
|
|
mixer.uncacheRoot(clonedScene);
|
|
};
|
|
}, [clonedScene, mixer]);
|
|
|
|
// Build case-insensitive clip lookup with alias support.
|
|
const animActionsRef = useRef(new Map<string, AnimationAction>());
|
|
const blendActionsRef = useRef<{
|
|
head: AnimationAction | null;
|
|
headside: AnimationAction | null;
|
|
}>({ head: null, headside: null });
|
|
// Arm pose blend actions keyed by animation name (lookde, lookms, looksn).
|
|
const armActionsRef = useRef(new Map<string, AnimationAction>());
|
|
const activeArmRef = useRef<string | null>(null);
|
|
const currentAnimRef = useRef({ name: "root", timeScale: 1 });
|
|
const isDeadRef = useRef(false);
|
|
// Action animation (taunts, celebrations, etc.) tracking.
|
|
const actionAnimRef = useRef<number | undefined>(undefined);
|
|
|
|
// Build action index -> animation clip name mapping from TSShapeConstructor.
|
|
const actionAnimMap = useMemo(() => {
|
|
const playback = engineStore.getState().playback;
|
|
const sp = playback.recording?.streamingPlayback;
|
|
const sn = shapeName?.toLowerCase();
|
|
if (!sp || !sn) return new Map<number, ActionAnimEntry>();
|
|
const sequences = sp.getShapeConstructorSequences(sn);
|
|
if (!sequences) return new Map<number, ActionAnimEntry>();
|
|
// Derive prefix: "heavy_male.dts" -> "heavy_male_"
|
|
const stem = sn.replace(/\.dts$/i, "");
|
|
return buildActionAnimMap(sequences, stem + "_");
|
|
}, [engineStore, shapeName]);
|
|
|
|
useEffect(() => {
|
|
const actions = getAliasedActions(gltf.animations, mixer, shapeAliases);
|
|
animActionsRef.current = actions;
|
|
|
|
// Start with root (idle) animation.
|
|
const rootAction = actions.get("root");
|
|
if (rootAction) {
|
|
rootAction.play();
|
|
}
|
|
currentAnimRef.current = { name: "root", timeScale: 1 };
|
|
|
|
// Set up additive blend animations for aim/head articulation.
|
|
// These clips must be cloned before makeClipAdditive (which mutates in
|
|
// place) since multiple player entities share the same GLTF cache.
|
|
|
|
// Head blend actions.
|
|
const blendRefs: typeof blendActionsRef.current = {
|
|
head: null,
|
|
headside: null,
|
|
};
|
|
for (const { key, names } of [
|
|
{ key: "head" as const, names: ["head"] },
|
|
{ key: "headside" as const, names: ["headside"] },
|
|
]) {
|
|
const clip = gltf.animations.find((c) =>
|
|
names.includes(c.name.toLowerCase()),
|
|
);
|
|
if (!clip) continue;
|
|
const cloned = clip.clone();
|
|
const fps = 30;
|
|
const neutralFrame = Math.round((clip.duration * fps) / 2);
|
|
AnimationUtils.makeClipAdditive(cloned, neutralFrame, clip, fps);
|
|
const action = mixer.clipAction(cloned);
|
|
action.blendMode = AdditiveAnimationBlendMode;
|
|
action.timeScale = 0;
|
|
action.weight = 1;
|
|
action.play();
|
|
blendRefs[key] = action;
|
|
}
|
|
blendActionsRef.current = blendRefs;
|
|
|
|
// Arm pose blend actions: create one per available arm animation so we
|
|
// can switch between them when the equipped weapon changes.
|
|
// All arm clips use the lookde midpoint as the additive reference, so
|
|
// switching from lookde to lookms captures the shoulder repositioning.
|
|
const armActions = new Map<string, AnimationAction>();
|
|
const lookdeClip = gltf.animations.find(
|
|
(c) => c.name.toLowerCase() === "lookde",
|
|
);
|
|
const fps = 30;
|
|
const lookdeRefFrame = lookdeClip
|
|
? Math.round((lookdeClip.duration * fps) / 2)
|
|
: 0;
|
|
for (const armName of ["lookde", "lookms", "looksn"]) {
|
|
const clip = gltf.animations.find(
|
|
(c) => c.name.toLowerCase() === armName,
|
|
);
|
|
if (!clip) continue;
|
|
const cloned = clip.clone();
|
|
// Use lookde's midpoint as reference for all arm clips so that
|
|
// lookms/looksn capture the absolute shoulder offset.
|
|
const refClip = lookdeClip ?? clip;
|
|
AnimationUtils.makeClipAdditive(cloned, lookdeRefFrame, refClip, fps);
|
|
const action = mixer.clipAction(cloned);
|
|
action.blendMode = AdditiveAnimationBlendMode;
|
|
action.timeScale = 0;
|
|
action.weight = 0;
|
|
action.play();
|
|
armActions.set(armName, action);
|
|
}
|
|
armActionsRef.current = armActions;
|
|
|
|
// Start with default arm pose.
|
|
const defaultArm = armActions.get("lookde");
|
|
if (defaultArm) {
|
|
defaultArm.weight = 1;
|
|
activeArmRef.current = "lookde";
|
|
}
|
|
|
|
// Force initial pose evaluation.
|
|
mixer.update(0);
|
|
|
|
return () => {
|
|
mixer.stopAllAction();
|
|
animActionsRef.current = new Map();
|
|
blendActionsRef.current = { head: null, headside: null };
|
|
armActionsRef.current = new Map();
|
|
activeArmRef.current = null;
|
|
};
|
|
}, [mixer, gltf.animations, shapeAliases]);
|
|
|
|
// Initialize IFL materials: load atlas textures and set up onBeforeRender
|
|
// callbacks that animate texture offsets based on the current playback time.
|
|
useEffect(() => {
|
|
const cleanups: (() => void)[] = [];
|
|
for (const { mesh, initialize } of iflInitializers) {
|
|
initialize(mesh, () => streamPlaybackStore.getState().time)
|
|
.then((dispose) => cleanups.push(dispose))
|
|
.catch(() => {});
|
|
}
|
|
return () => cleanups.forEach((fn) => fn());
|
|
}, [iflInitializers]);
|
|
|
|
// Track weaponShape changes. The entity is mutated in-place by the
|
|
// streaming layer (no React re-render), so we sync it in useFrame.
|
|
const weaponShapeRef = useRef(entity.weaponShape);
|
|
const [currentWeaponShape, setCurrentWeaponShape] = useState(
|
|
entity.weaponShape,
|
|
);
|
|
const packShapeRef = useRef(entity.packShape);
|
|
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
|
|
const flagShapeRef = useRef(entity.flagShape);
|
|
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
|
|
|
|
// Per-frame animation selection and mixer update.
|
|
useFrame((_, delta) => {
|
|
if (entity.weaponShape !== weaponShapeRef.current) {
|
|
weaponShapeRef.current = entity.weaponShape;
|
|
setCurrentWeaponShape(entity.weaponShape);
|
|
}
|
|
if (entity.packShape !== packShapeRef.current) {
|
|
packShapeRef.current = entity.packShape;
|
|
setCurrentPackShape(entity.packShape);
|
|
}
|
|
if (entity.flagShape !== flagShapeRef.current) {
|
|
flagShapeRef.current = entity.flagShape;
|
|
setCurrentFlagShape(entity.flagShape);
|
|
}
|
|
const playback = engineStore.getState().playback;
|
|
const isPlaying = playback.status === "playing";
|
|
const time = streamPlaybackStore.getState().time;
|
|
|
|
// Resolve velocity at current playback time.
|
|
const kf = getKeyframeAtTime(entity.keyframes ?? [], time);
|
|
const isDead = kf?.damageState != null && kf.damageState >= 1;
|
|
const actions = animActionsRef.current;
|
|
|
|
// Alive->Dead transition: play the server-specified death animation.
|
|
if (isDead && !isDeadRef.current) {
|
|
isDeadRef.current = true;
|
|
|
|
// The server sends the death animation as an actionAnim index.
|
|
const deathEntry =
|
|
kf.actionAnim != null ? actionAnimMap.get(kf.actionAnim) : undefined;
|
|
if (deathEntry) {
|
|
const deathAction = actions.get(deathEntry.clipName);
|
|
if (deathAction) {
|
|
const prevAction = actions.get(
|
|
currentAnimRef.current.name.toLowerCase(),
|
|
);
|
|
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
|
|
|
deathAction.setLoop(LoopOnce, 1);
|
|
deathAction.clampWhenFinished = true;
|
|
deathAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
|
currentAnimRef.current = { name: deathEntry.clipName, timeScale: 1 };
|
|
actionAnimRef.current = kf.actionAnim;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dead->Alive transition: stop death animation, let movement resume.
|
|
if (!isDead && isDeadRef.current) {
|
|
isDeadRef.current = false;
|
|
actionAnimRef.current = undefined;
|
|
|
|
const deathAction = actions.get(
|
|
currentAnimRef.current.name.toLowerCase(),
|
|
);
|
|
if (deathAction) {
|
|
deathAction.stop();
|
|
deathAction.setLoop(LoopRepeat, Infinity);
|
|
deathAction.clampWhenFinished = false;
|
|
}
|
|
// Reset to root so movement selection picks up on next iteration.
|
|
currentAnimRef.current = { name: "root", timeScale: 1 };
|
|
const rootAction = actions.get("root");
|
|
if (rootAction) rootAction.reset().play();
|
|
}
|
|
|
|
// Action animation (taunts, celebrations, etc.).
|
|
// Non-table actions (index >= 7) override movement animation.
|
|
const actionAnim = kf?.actionAnim;
|
|
const prevActionAnim = actionAnimRef.current;
|
|
if (!isDeadRef.current && actionAnim !== prevActionAnim) {
|
|
actionAnimRef.current = actionAnim;
|
|
const isNonTableAction =
|
|
actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS;
|
|
const wasNonTableAction =
|
|
prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS;
|
|
|
|
if (isNonTableAction) {
|
|
// Start or change action animation.
|
|
const entry = actionAnimMap.get(actionAnim);
|
|
if (entry) {
|
|
const actionAction = actions.get(entry.clipName);
|
|
if (actionAction) {
|
|
const prevAction = actions.get(
|
|
currentAnimRef.current.name.toLowerCase(),
|
|
);
|
|
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
|
actionAction.setLoop(LoopOnce, 1);
|
|
actionAction.clampWhenFinished = true;
|
|
actionAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
|
currentAnimRef.current = { name: entry.clipName, timeScale: 1 };
|
|
}
|
|
}
|
|
} else if (wasNonTableAction) {
|
|
// Action ended -- stop the action clip and resume movement.
|
|
const prevEntry = actionAnimMap.get(prevActionAnim);
|
|
if (prevEntry) {
|
|
const prevAction = actions.get(prevEntry.clipName);
|
|
if (prevAction) {
|
|
prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
|
prevAction.setLoop(LoopRepeat, Infinity);
|
|
prevAction.clampWhenFinished = false;
|
|
}
|
|
}
|
|
currentAnimRef.current = { name: "root", timeScale: 1 };
|
|
const rootAction = actions.get("root");
|
|
if (rootAction) rootAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
|
}
|
|
}
|
|
|
|
// If atEnd, clamp the action animation at its final frame.
|
|
if (
|
|
actionAnim != null &&
|
|
actionAnim >= NUM_TABLE_ACTION_ANIMS &&
|
|
kf?.actionAtEnd
|
|
) {
|
|
const entry = actionAnimMap.get(actionAnim);
|
|
if (entry) {
|
|
const actionAction = actions.get(entry.clipName);
|
|
if (actionAction) {
|
|
actionAction.paused = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Movement animation selection (skip while dead or playing action anim).
|
|
const playingActionAnim =
|
|
actionAnimRef.current != null &&
|
|
actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS;
|
|
if (!isDeadRef.current && !playingActionAnim) {
|
|
const anim = pickMoveAnimation(
|
|
kf?.velocity,
|
|
kf?.rotation ?? [0, 0, 0, 1],
|
|
entity.falling,
|
|
entity.jetting,
|
|
);
|
|
|
|
const prev = currentAnimRef.current;
|
|
if (anim.animation !== prev.name || anim.timeScale !== prev.timeScale) {
|
|
const prevAction = actions.get(prev.name.toLowerCase());
|
|
const nextAction = actions.get(anim.animation.toLowerCase());
|
|
|
|
if (nextAction) {
|
|
if (isPlaying && prevAction && prevAction !== nextAction) {
|
|
prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
|
nextAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
|
} else {
|
|
if (prevAction && prevAction !== nextAction) prevAction.stop();
|
|
nextAction.reset().play();
|
|
}
|
|
nextAction.timeScale = anim.timeScale;
|
|
currentAnimRef.current = {
|
|
name: anim.animation,
|
|
timeScale: anim.timeScale,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Switch arm blend animation based on equipped weapon.
|
|
const desiredArm = getArmThread(entity.weaponShape);
|
|
if (desiredArm !== activeArmRef.current) {
|
|
const armActions = armActionsRef.current;
|
|
const prev = activeArmRef.current
|
|
? armActions.get(activeArmRef.current)
|
|
: null;
|
|
const next = armActions.get(desiredArm);
|
|
if (next) {
|
|
if (prev) prev.weight = 0;
|
|
next.weight = isDead ? 0 : 1;
|
|
activeArmRef.current = desiredArm;
|
|
}
|
|
}
|
|
|
|
// Drive additive blend animations for aim/head articulation.
|
|
const { head, headside } = blendActionsRef.current;
|
|
const armAction = activeArmRef.current
|
|
? armActionsRef.current.get(activeArmRef.current)
|
|
: null;
|
|
const blendWeight = isDead ? 0 : 1;
|
|
|
|
const headPitch = entity.headPitch ?? 0;
|
|
const headYaw = entity.headYaw ?? 0;
|
|
const pitchPos = (headPitch + 1) / 2;
|
|
const yawPos = (headYaw + 1) / 2;
|
|
|
|
if (armAction) {
|
|
armAction.time = pitchPos * armAction.getClip().duration;
|
|
armAction.weight = blendWeight;
|
|
}
|
|
if (head) {
|
|
head.time = pitchPos * head.getClip().duration;
|
|
head.weight = blendWeight;
|
|
}
|
|
if (headside) {
|
|
headside.time = yawPos * headside.getClip().duration;
|
|
headside.weight = blendWeight;
|
|
}
|
|
|
|
// Advance or evaluate the body animation mixer.
|
|
if (isPlaying) {
|
|
mixer.update(delta * playback.rate);
|
|
} else {
|
|
mixer.update(0);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<group rotation={[0, Math.PI / 2, 0]}>
|
|
<primitive object={clonedScene} />
|
|
</group>
|
|
{currentWeaponShape && mount0 && (
|
|
<ShapeErrorBoundary
|
|
key={currentWeaponShape}
|
|
fallback={<ShapePlaceholder color="red" label={currentWeaponShape} />}
|
|
>
|
|
<DebugSuspense
|
|
name={`Weapon:${entity.id}/${currentWeaponShape}`}
|
|
fallback={
|
|
<ShapePlaceholder color="cyan" label={currentWeaponShape} />
|
|
}
|
|
>
|
|
<WeaponModel
|
|
entity={entity}
|
|
weaponShape={currentWeaponShape}
|
|
mount0={mount0}
|
|
/>
|
|
</DebugSuspense>
|
|
</ShapeErrorBoundary>
|
|
)}
|
|
{currentPackShape && mount1 && (
|
|
<ShapeErrorBoundary
|
|
key={currentPackShape}
|
|
fallback={<ShapePlaceholder color="red" label={currentPackShape} />}
|
|
>
|
|
<DebugSuspense
|
|
name={`Pack:${entity.id}/${currentPackShape}`}
|
|
fallback={
|
|
<ShapePlaceholder color="cyan" label={currentPackShape} />
|
|
}
|
|
>
|
|
<PackModel packShape={currentPackShape} mountBone={mount1} />
|
|
</DebugSuspense>
|
|
</ShapeErrorBoundary>
|
|
)}
|
|
{currentFlagShape && mount2 && (
|
|
<ShapeErrorBoundary
|
|
key={currentFlagShape}
|
|
fallback={<ShapePlaceholder color="red" label={currentFlagShape} />}
|
|
>
|
|
<DebugSuspense
|
|
name={`Flag:${entity.id}/${currentFlagShape}`}
|
|
fallback={
|
|
<ShapePlaceholder color="cyan" label={currentFlagShape} />
|
|
}
|
|
>
|
|
<PackModel packShape={currentFlagShape} mountBone={mount2} />
|
|
</DebugSuspense>
|
|
</ShapeErrorBoundary>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a DTS sequence-index -> name lookup from GLB metadata.
|
|
* Weapon GLBs include `dts_sequence_names` in scene extras, providing the
|
|
* original DTS sequence ordering that datablock state indices reference.
|
|
*/
|
|
function buildSeqIndexToName(
|
|
scene: Group,
|
|
animations: AnimationClip[],
|
|
): string[] {
|
|
const raw = scene.userData?.dts_sequence_names;
|
|
if (typeof raw === "string") {
|
|
try {
|
|
const names: string[] = JSON.parse(raw);
|
|
return names.map((n) => n.toLowerCase());
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return animations.map((a) => a.name.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Attaches an animated weapon model to the player's Mount0 bone.
|
|
* Drives a weapon-specific AnimationMixer using the WeaponImageStateMachine
|
|
* to play fire, reload, spin, and other weapon animations based on the
|
|
* server-replicated condition flags.
|
|
*
|
|
* Reads `entity.weaponImageState` and `entity.weaponImageStates` directly
|
|
* from the entity inside useFrame, since these fields are mutated per-tick
|
|
* without triggering React re-renders.
|
|
*/
|
|
function WeaponModel({
|
|
entity,
|
|
weaponShape,
|
|
mount0,
|
|
}: {
|
|
entity: PlayerEntity;
|
|
weaponShape: string;
|
|
mount0: Object3D;
|
|
}) {
|
|
const engineStore = useEngineStoreApi();
|
|
const weaponGltf = useStaticShape(weaponShape);
|
|
const anisotropy = useAnisotropy();
|
|
|
|
// Clone weapon with skeleton bindings, create dedicated mixer.
|
|
const {
|
|
weaponClone,
|
|
weaponMixer,
|
|
seqIndexToName,
|
|
visNodesBySequence,
|
|
weaponIflInitializers,
|
|
} = useMemo(() => {
|
|
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
|
|
const iflInits = processShapeScene(clone, undefined, { anisotropy });
|
|
|
|
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
|
|
const mp = getPosedNodeTransform(
|
|
weaponGltf.scene,
|
|
weaponGltf.animations,
|
|
"Mountpoint",
|
|
);
|
|
if (mp) {
|
|
const invQuat = mp.quaternion.clone().invert();
|
|
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
|
|
clone.position.copy(invPos);
|
|
clone.quaternion.copy(invQuat);
|
|
}
|
|
|
|
// Collect vis-animated meshes grouped by controlling sequence name.
|
|
// E.g. the disc launcher's Disc mesh has vis_sequence="discSpin" and is
|
|
// hidden by default (vis=0). When "discSpin" plays, the mesh becomes
|
|
// visible; when a different sequence plays, it hides again.
|
|
const visBySeq = new Map<string, Object3D[]>();
|
|
clone.traverse((node: any) => {
|
|
if (!node.isMesh) return;
|
|
const ud = node.userData;
|
|
const seqName = (ud?.vis_sequence ?? "").toLowerCase();
|
|
if (!seqName) return;
|
|
let list = visBySeq.get(seqName);
|
|
if (!list) {
|
|
list = [];
|
|
visBySeq.set(seqName, list);
|
|
}
|
|
list.push(node);
|
|
});
|
|
|
|
const mix = new AnimationMixer(clone);
|
|
const seq = buildSeqIndexToName(
|
|
weaponGltf.scene as Group,
|
|
weaponGltf.animations,
|
|
);
|
|
return {
|
|
weaponClone: clone,
|
|
weaponMixer: mix,
|
|
seqIndexToName: seq,
|
|
visNodesBySequence: visBySeq,
|
|
weaponIflInitializers: iflInits,
|
|
};
|
|
}, [weaponGltf, anisotropy]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
disposeClonedScene(weaponClone);
|
|
weaponMixer.uncacheRoot(weaponClone);
|
|
};
|
|
}, [weaponClone, weaponMixer]);
|
|
|
|
// Build case-insensitive action map for weapon animations.
|
|
const weaponActionsRef = useRef(new Map<string, AnimationAction>());
|
|
const spinActionRef = useRef<AnimationAction | null>(null);
|
|
useEffect(() => {
|
|
const actions = new Map<string, AnimationAction>();
|
|
for (const clip of weaponGltf.animations) {
|
|
actions.set(clip.name.toLowerCase(), weaponMixer.clipAction(clip));
|
|
}
|
|
weaponActionsRef.current = actions;
|
|
|
|
// Set up the spin thread: a looping "spin" animation with variable timeScale.
|
|
const spinAction = actions.get("spin");
|
|
if (spinAction) {
|
|
spinAction.setLoop(LoopRepeat, Infinity);
|
|
spinAction.timeScale = 0;
|
|
spinAction.play();
|
|
}
|
|
spinActionRef.current = spinAction ?? null;
|
|
|
|
// Force initial pose.
|
|
weaponMixer.update(0);
|
|
return () => {
|
|
weaponMixer.stopAllAction();
|
|
weaponActionsRef.current = new Map();
|
|
spinActionRef.current = null;
|
|
stopLoopingSound(loopingSoundRef, loopingSoundStateRef);
|
|
};
|
|
}, [weaponMixer, weaponGltf.animations]);
|
|
|
|
// Initialize IFL materials on the weapon model.
|
|
useEffect(() => {
|
|
const cleanups: (() => void)[] = [];
|
|
for (const { mesh, initialize } of weaponIflInitializers) {
|
|
initialize(mesh, () => streamPlaybackStore.getState().time)
|
|
.then((dispose) => cleanups.push(dispose))
|
|
.catch(() => {});
|
|
}
|
|
return () => cleanups.forEach((fn) => fn());
|
|
}, [weaponIflInitializers]);
|
|
|
|
// Audio context for weapon sounds.
|
|
const { audioLoader, audioListener } = useAudio();
|
|
const settings = useSettings();
|
|
const audioEnabled = settings?.audioEnabled ?? false;
|
|
|
|
// Weapon state machine, lazily initialized on first tick with data.
|
|
const stateMachineRef = useRef<WeaponImageStateMachine | null>(null);
|
|
const currentWeaponAnimRef = useRef<string | null>(null);
|
|
const lastWeaponStatesRef = useRef(entity.weaponImageStates);
|
|
|
|
// Track active looping weapon sound (e.g. chaingun fire).
|
|
const loopingSoundRef = useRef<PositionalAudio | null>(null);
|
|
const loopingSoundStateRef = useRef<number>(-1);
|
|
|
|
// Imperatively attach/detach weapon clone to Mount0.
|
|
useEffect(() => {
|
|
mount0.add(weaponClone);
|
|
return () => {
|
|
mount0.remove(weaponClone);
|
|
};
|
|
}, [weaponClone, mount0]);
|
|
|
|
// Per-frame: tick state machine and drive weapon animation mixer.
|
|
useFrame((_, delta) => {
|
|
const playback = engineStore.getState().playback;
|
|
const isPlaying = playback.status === "playing";
|
|
const actions = weaponActionsRef.current;
|
|
|
|
// Read weapon state directly from entity (mutated per-tick, not via props).
|
|
const imageState = entity.weaponImageState;
|
|
const imageStates = entity.weaponImageStates;
|
|
|
|
// Lazily create or recreate the state machine when the datablock states
|
|
// become available or change (e.g. weapon switch within same shape).
|
|
if (imageStates !== lastWeaponStatesRef.current) {
|
|
lastWeaponStatesRef.current = imageStates;
|
|
if (imageStates && imageStates.length > 0) {
|
|
stateMachineRef.current = new WeaponImageStateMachine(
|
|
imageStates,
|
|
seqIndexToName,
|
|
);
|
|
} else {
|
|
stateMachineRef.current = null;
|
|
}
|
|
currentWeaponAnimRef.current = null;
|
|
stopLoopingSound(loopingSoundRef, loopingSoundStateRef, weaponClone);
|
|
}
|
|
|
|
// Initialize state machine if we have states but haven't created it yet.
|
|
if (!stateMachineRef.current && imageStates && imageStates.length > 0) {
|
|
stateMachineRef.current = new WeaponImageStateMachine(
|
|
imageStates,
|
|
seqIndexToName,
|
|
);
|
|
}
|
|
|
|
const sm = stateMachineRef.current;
|
|
|
|
if (sm && imageState && isPlaying) {
|
|
const effectiveDelta = delta * playback.rate;
|
|
const animState = sm.tick(effectiveDelta, imageState);
|
|
|
|
applyWeaponAnim(
|
|
animState,
|
|
actions,
|
|
currentWeaponAnimRef,
|
|
visNodesBySequence,
|
|
);
|
|
|
|
// Stop active looping sound when the state changes.
|
|
if (
|
|
loopingSoundRef.current &&
|
|
animState.stateIndex !== loopingSoundStateRef.current
|
|
) {
|
|
stopLoopingSound(loopingSoundRef, loopingSoundStateRef, weaponClone);
|
|
}
|
|
|
|
// Play weapon state-entry sounds as positional audio on transitions.
|
|
// The engine plays a sound for every state entered during a transition
|
|
// chain, so there may be multiple sounds per tick.
|
|
if (
|
|
audioEnabled &&
|
|
audioLoader &&
|
|
audioListener &&
|
|
animState.soundDataBlockIds.length > 0
|
|
) {
|
|
const getDb =
|
|
playback.recording?.streamingPlayback.getDataBlockData.bind(
|
|
playback.recording.streamingPlayback,
|
|
);
|
|
if (getDb) {
|
|
for (const soundDbId of animState.soundDataBlockIds) {
|
|
const resolved = resolveAudioProfile(soundDbId, getDb);
|
|
if (!resolved) continue;
|
|
|
|
if (resolved.isLooping) {
|
|
// Looping sounds (e.g. chaingun fire) persist while in this
|
|
// state and stop on transition to a different state.
|
|
if (!loopingSoundRef.current) {
|
|
try {
|
|
const url = audioToUrl(resolved.filename);
|
|
const gen = getSoundGeneration();
|
|
getCachedAudioBuffer(url, audioLoader, (buffer) => {
|
|
// Guard: state may have changed by the time buffer loads.
|
|
if (gen !== getSoundGeneration()) return;
|
|
if (loopingSoundRef.current) return;
|
|
// Read live state index (not the closure-captured one).
|
|
const currentIdx = sm.stateIndex;
|
|
const sound = new PositionalAudio(audioListener);
|
|
sound.setBuffer(buffer);
|
|
sound.setDistanceModel("inverse");
|
|
sound.setRefDistance(resolved.refDist);
|
|
sound.setMaxDistance(resolved.maxDist);
|
|
sound.setRolloffFactor(1);
|
|
sound.setVolume(resolved.volume);
|
|
sound.setPlaybackRate(playback.rate);
|
|
sound.setLoop(true);
|
|
weaponClone.add(sound);
|
|
trackSound(sound);
|
|
sound.play();
|
|
loopingSoundRef.current = sound;
|
|
loopingSoundStateRef.current = currentIdx;
|
|
});
|
|
} catch {
|
|
/* expected */
|
|
}
|
|
}
|
|
} else {
|
|
playOneShotSound(
|
|
resolved,
|
|
audioListener,
|
|
audioLoader,
|
|
undefined,
|
|
weaponClone,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drive the spin thread (e.g. chaingun barrel rotation).
|
|
if (spinActionRef.current) {
|
|
spinActionRef.current.timeScale = animState.spinTimeScale;
|
|
}
|
|
}
|
|
|
|
// Advance the weapon mixer.
|
|
if (isPlaying) {
|
|
weaponMixer.update(delta * playback.rate);
|
|
} else {
|
|
weaponMixer.update(0);
|
|
}
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Applies the weapon state machine output to the weapon's AnimationMixer.
|
|
* Handles crossfading between sequences, configuring loop/timeScale, and
|
|
* toggling DTS vis-node visibility (e.g. disc launcher's disc mesh).
|
|
*/
|
|
function applyWeaponAnim(
|
|
animState: WeaponAnimState,
|
|
actions: Map<string, AnimationAction>,
|
|
currentAnimRef: MutableRefObject<string | null>,
|
|
visNodesBySequence: Map<string, Object3D[]>,
|
|
): void {
|
|
const targetName = animState.sequenceName;
|
|
const currentName = currentAnimRef.current;
|
|
|
|
if (targetName === currentName && !animState.transitioned) {
|
|
return;
|
|
}
|
|
|
|
// Toggle vis-node visibility when the active sequence changes.
|
|
// Meshes with vis_sequence are hidden by default (processShapeScene sets
|
|
// visible=false for vis<0.01). They become visible only when their
|
|
// controlling sequence is the active one. E.g. the disc launcher's Disc
|
|
// mesh has vis_sequence="discspin" and appears only during the discSpin
|
|
// (Ready) state.
|
|
if (targetName !== currentName) {
|
|
// Hide vis nodes from the previous sequence.
|
|
if (currentName) {
|
|
const prevVis = visNodesBySequence.get(currentName);
|
|
if (prevVis) {
|
|
for (const node of prevVis) node.visible = false;
|
|
}
|
|
}
|
|
// Show vis nodes for the new sequence.
|
|
if (targetName) {
|
|
const nextVis = visNodesBySequence.get(targetName);
|
|
if (nextVis) {
|
|
for (const node of nextVis) node.visible = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!targetName) {
|
|
// No sequence for this state -- stop current animation.
|
|
if (currentName) {
|
|
const prev = actions.get(currentName);
|
|
if (prev) prev.fadeOut(ANIM_TRANSITION_TIME);
|
|
currentAnimRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const action = actions.get(targetName);
|
|
if (!action) return;
|
|
|
|
// On state transition, restart the animation.
|
|
if (animState.transitioned || targetName !== currentName) {
|
|
const prevAction = currentName ? actions.get(currentName) : null;
|
|
|
|
// Fire/reload animations play once; others loop.
|
|
if (animState.isFiring || animState.timeoutValue > 0) {
|
|
action.setLoop(LoopOnce, 1);
|
|
action.clampWhenFinished = true;
|
|
} else {
|
|
action.setLoop(LoopRepeat, Infinity);
|
|
action.clampWhenFinished = false;
|
|
}
|
|
|
|
// Scale animation to fit the state timeout if requested.
|
|
if (animState.scaleAnimation && animState.timeoutValue > 0) {
|
|
const clipDuration = action.getClip().duration;
|
|
action.timeScale =
|
|
clipDuration > 0 ? clipDuration / animState.timeoutValue : 1;
|
|
} else {
|
|
action.timeScale = animState.reverse ? -1 : 1;
|
|
}
|
|
|
|
if (prevAction && prevAction !== action) {
|
|
prevAction.fadeOut(ANIM_TRANSITION_TIME);
|
|
action.reset().fadeIn(ANIM_TRANSITION_TIME).play();
|
|
} else {
|
|
action.reset().play();
|
|
}
|
|
|
|
currentAnimRef.current = targetName;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attaches a pack shape to the player's Mount1 bone. Packs are static
|
|
* mounted images (no state machine or animation) — just positioned via
|
|
* the pack shape's Mountpoint node inverse offset, same as weapons.
|
|
*/
|
|
function PackModel({
|
|
packShape,
|
|
mountBone,
|
|
}: {
|
|
packShape: string;
|
|
mountBone: Object3D;
|
|
}) {
|
|
const packGltf = useStaticShape(packShape);
|
|
const anisotropy = useAnisotropy();
|
|
|
|
const { packClone, packIflInitializers } = useMemo(() => {
|
|
const clone = SkeletonUtils.clone(packGltf.scene) as Group;
|
|
const iflInits = processShapeScene(clone, undefined, { anisotropy });
|
|
|
|
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
|
|
const mp = getPosedNodeTransform(
|
|
packGltf.scene,
|
|
packGltf.animations,
|
|
"Mountpoint",
|
|
);
|
|
if (mp) {
|
|
const invQuat = mp.quaternion.clone().invert();
|
|
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
|
|
clone.position.copy(invPos);
|
|
clone.quaternion.copy(invQuat);
|
|
}
|
|
|
|
return { packClone: clone, packIflInitializers: iflInits };
|
|
}, [packGltf, anisotropy]);
|
|
|
|
useEffect(() => {
|
|
mountBone.add(packClone);
|
|
return () => {
|
|
mountBone.remove(packClone);
|
|
disposeClonedScene(packClone);
|
|
};
|
|
}, [packClone, mountBone]);
|
|
|
|
// Initialize IFL materials (animated texture sequences).
|
|
useEffect(() => {
|
|
const cleanups: (() => void)[] = [];
|
|
for (const { mesh, initialize } of packIflInitializers) {
|
|
initialize(mesh, () => streamPlaybackStore.getState().time)
|
|
.then((dispose) => cleanups.push(dispose))
|
|
.catch(() => {});
|
|
}
|
|
return () => cleanups.forEach((fn) => fn());
|
|
}, [packIflInitializers]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
|
|
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton
|
|
* hierarchy. Its world Y in GLB Y-up space gives the height above the player's
|
|
* feet, which we use as the first-person camera offset.
|
|
*/
|
|
export function PlayerEyeOffset({
|
|
shapeName,
|
|
eyeOffsetRef,
|
|
}: {
|
|
shapeName: string;
|
|
eyeOffsetRef: MutableRefObject<Vector3>;
|
|
}) {
|
|
const gltf = useStaticShape(shapeName);
|
|
|
|
useEffect(() => {
|
|
// Get Eye node position from the posed (Root animation) skeleton.
|
|
const eye = getPosedNodeTransform(gltf.scene, gltf.animations, "Eye");
|
|
if (eye) {
|
|
// Convert from GLB space to entity space via ShapeRenderer's R90:
|
|
// R90 maps GLB (x,y,z) -> entity (z, y, -x).
|
|
// This gives ~(0.169, 2.122, 0.0) -- 17cm forward and 2.12m up.
|
|
eyeOffsetRef.current.set(eye.position.z, eye.position.y, -eye.position.x);
|
|
} else {
|
|
eyeOffsetRef.current.set(0, DEFAULT_EYE_HEIGHT, 0);
|
|
}
|
|
}, [gltf, eyeOffsetRef]);
|
|
|
|
return null;
|
|
}
|