t2-mapper/src/components/PlayerModel.tsx

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;
}