Ai_Assistant/client/animation/movementController.js

546 lines
18 KiB
JavaScript
Raw Permalink Normal View History

2026-05-24 13:31:30 +02:00
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { VRMLoaderPlugin } from '@pixiv/three-vrm';
import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation';
import { loadMixamoAnimation } from './loadMixamoAnimation.js';
import { stripRootMotionFromClip, trimAnimationClip } from './clipUtils.js';
/**
* MovementController - single-mixer home for all VRM body animation.
*
* Every action (idle/walk/run, one-off Mixamo/VRMA, additive gestures) lives
* on `this.mixer`, so any transition is a native Three.js crossfade where the
* two actions' weights sum to ~1.0 throughout the blend. That's what avoids
* the rest-pose (T-pose) blend you get when partial weight lands on a mixer
* with no other active action covering the same bones.
*/
export class MovementController {
constructor(vrm, scene, animationManager) {
this.vrm = vrm;
this.scene = scene;
this.animationManager = animationManager;
this.mixer = new THREE.AnimationMixer(vrm.scene);
// Loader for VRMA files (same plugin stack as the main VRM loader).
this.vrmaLoader = new GLTFLoader();
this.vrmaLoader.register((parser) => new VRMLoaderPlugin(parser));
this.vrmaLoader.register((parser) => new VRMAnimationLoaderPlugin(parser));
// Movement state
this.isMoving = false;
this.targetPosition = null;
this.moveSpeed = 1.5;
this.rotationSpeed = 5.0;
this.arrivalThreshold = 0.1;
// Base actions
this.clips = { idle: null, walk: null, run: null };
this.actions = { idle: null, walk: null, run: null };
this.currentBaseAction = 'idle';
// Additive layer (kept for the loaded wave gesture)
this.additiveClips = new Map();
this.additiveActions = new Map();
// Whichever action is currently driving the skeleton (idle/walk/run/external).
// The `_crossFadeTo` helper keeps this pointing at the most recent crossfade target.
this._activeAction = null;
// Current one-off VRMA/Mixamo action (null when on a base action).
this._externalAction = null;
this._externalFinishListener = null;
// Active per-frame world-position compensation during a play_once's crossfade
// back to idle — used when the VRMA has root motion we want baked into the
// scene position rather than snapping back when idle takes over.
this._positionCompensation = null;
// Transition durations
this.crossFadeDuration = 0.35; // base → base (idle ↔ walk)
this.externalFadeDuration = 0.5; // anything involving a one-off external
this.baseAnimationSpeed = 1.5;
this.debug = false;
}
// ---------- loading (base animations) ----------
async loadIdleAnimation(url) {
try {
const clip = await loadMixamoAnimation(url, this.vrm, {
stripRootMotion: true,
clipName: 'idle',
});
this.clips.idle = clip;
this.actions.idle = this.mixer.clipAction(clip);
this.actions.idle.setLoop(THREE.LoopRepeat);
this.actions.idle.clampWhenFinished = false;
this.setWeight(this.actions.idle, 1.0);
this.actions.idle.play();
this.currentBaseAction = 'idle';
this._activeAction = this.actions.idle;
console.log('✅ Idle animation loaded and playing');
return true;
} catch (err) {
console.error('Failed to load idle animation:', err);
return false;
}
}
async loadWalkAnimation(url) {
try {
const clip = await loadMixamoAnimation(url, this.vrm, {
stripRootMotion: true,
clipName: 'walk',
});
this.clips.walk = clip;
this.actions.walk = this.mixer.clipAction(clip);
this.actions.walk.setLoop(THREE.LoopRepeat);
this.actions.walk.clampWhenFinished = false;
this.setWeight(this.actions.walk, 0.0);
this.actions.walk.play();
console.log('✅ Walk animation loaded');
return true;
} catch (err) {
console.error('Failed to load walk animation:', err);
return false;
}
}
async loadRunAnimation(url) {
try {
const clip = await loadMixamoAnimation(url, this.vrm, {
stripRootMotion: true,
clipName: 'run',
});
this.clips.run = clip;
this.actions.run = this.mixer.clipAction(clip);
this.actions.run.setLoop(THREE.LoopRepeat);
this.actions.run.clampWhenFinished = false;
this.setWeight(this.actions.run, 0.0);
this.actions.run.play();
console.log('✅ Run animation loaded');
return true;
} catch (err) {
console.error('Failed to load run animation:', err);
return false;
}
}
// ---------- additive layer ----------
async loadAdditiveAnimation(url, name) {
const isVRMA = url.toLowerCase().endsWith('.vrma');
try {
let clip;
if (isVRMA) {
clip = await this._loadRawVRMAClip(url, { stripRootMotion: true });
clip.name = name;
THREE.AnimationUtils.makeClipAdditive(clip);
} else {
clip = await loadMixamoAnimation(url, this.vrm, {
stripRootMotion: true,
makeAdditive: true,
clipName: name,
});
}
this.additiveClips.set(name, clip);
const action = this.mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat);
action.blendMode = THREE.AdditiveAnimationBlendMode;
this.setWeight(action, 0.0);
action.play();
this.additiveActions.set(name, action);
console.log(`✅ Additive animation loaded (${isVRMA ? 'VRMA' : 'Mixamo'}): ${name}`);
return true;
} catch (err) {
console.error(`Failed to load additive animation ${name}:`, err);
return false;
}
}
setAdditiveWeight(name, weight, duration = 0.25) {
const action = this.additiveActions.get(name);
if (!action) return;
weight = THREE.MathUtils.clamp(weight, 0.0, 1.0);
if (duration > 0) {
const currentWeight = action.getEffectiveWeight();
action._targetWeight = weight;
action._weightTransitionSpeed = Math.abs(weight - currentWeight) / duration;
} else {
this.setWeight(action, weight);
}
}
playAdditiveOnce(name, fadeIn = 0.25, fadeOut = 0.25) {
const action = this.additiveActions.get(name);
const clip = this.additiveClips.get(name);
if (!action || !clip) return;
action.setLoop(THREE.LoopOnce);
action.reset();
this.setWeight(action, 1.0);
action.play();
const fadeOutTime = (clip.duration - fadeOut) * 1000;
setTimeout(() => this.setAdditiveWeight(name, 0.0, fadeOut), fadeOutTime);
}
// ---------- utilities ----------
setWeight(action, weight) {
if (!action) return;
action.enabled = true;
action.setEffectiveTimeScale(1);
action.setEffectiveWeight(weight);
}
/**
* Native crossfade between any two actions on this mixer. The outgoing action
* fades 1 0 while the incoming fades 0 1, sum stays ~1, so the partial
* weights don't trigger a rest-pose blend.
*/
_crossFadeTo(newAction, duration) {
if (!newAction || newAction === this._activeAction) return;
newAction.reset();
newAction.setEffectiveTimeScale(1);
newAction.setEffectiveWeight(1);
newAction.play();
if (this._activeAction) {
this._activeAction.crossFadeTo(newAction, duration, false);
}
this._activeAction = newAction;
}
_removeExternalFinishListener() {
if (this._externalFinishListener) {
try {
this.mixer.removeEventListener('finished', this._externalFinishListener);
} catch (e) {}
this._externalFinishListener = null;
}
}
crossFadeToBase(actionName, duration = this.crossFadeDuration) {
const newAction = this.actions[actionName];
if (!newAction) {
console.warn(`Action ${actionName} not loaded`);
return;
}
if (actionName === this.currentBaseAction && this._activeAction === newAction) return;
if (this.debug) console.log(`🔄 Crossfade: ${this.currentBaseAction} -> ${actionName}`);
this._crossFadeTo(newAction, duration);
this.currentBaseAction = actionName;
}
// ---------- one-off external animations ----------
/**
* Load + play a VRMA one-off animation with native crossfade from whatever
* is currently active (idle/walk/another VRMA/Mixamo).
*/
async playVRMA(url, {
play_once = false,
crop_start = 0.0,
crop_end = 0.0,
lock_position = false,
track_position = true,
} = {}) {
try {
let clip = await this._loadRawVRMAClip(url, { stripRootMotion: false });
if (lock_position) clip = stripRootMotionFromClip(clip, this.vrm);
const startTime = Math.max(0, parseFloat(crop_start) || 0);
const endTime = Math.max(0, clip.duration - (parseFloat(crop_end) || 0));
if (startTime > 0 || (parseFloat(crop_end) || 0) > 0) {
const trimmed = trimAnimationClip(clip, startTime, endTime);
if (trimmed) clip = trimmed;
}
this._playExternalClip(clip, { play_once, track_position, lock_position, kind: 'vrma' });
} catch (err) {
console.error('Failed to load VRMA animation:', err);
}
}
/**
* Load + play a Mixamo one-off animation with the same crossfade semantics
* as playVRMA.
*/
async playMixamo(url, {
play_once = false,
lock_position = false,
track_position = true,
} = {}) {
try {
const clip = await loadMixamoAnimation(url, this.vrm, {
stripRootMotion: lock_position,
});
this._playExternalClip(clip, { play_once, track_position, lock_position, kind: 'mixamo' });
} catch (err) {
console.error('Failed to load Mixamo animation:', err);
}
}
async _loadRawVRMAClip(url, { stripRootMotion = true } = {}) {
const gltf = await this.vrmaLoader.loadAsync(url);
const vrmAnimation = gltf.userData.vrmAnimations[0];
let clip = createVRMAnimationClip(vrmAnimation, this.vrm);
if (stripRootMotion) clip = stripRootMotionFromClip(clip, this.vrm);
return clip;
}
_playExternalClip(clip, { play_once, track_position, lock_position, kind }) {
// An incoming external takes over from walking, so cancel movement intent.
this.isMoving = false;
this.targetPosition = null;
this._positionCompensation = null;
this._removeExternalFinishListener();
const newAction = this.mixer.clipAction(clip);
if (play_once) {
newAction.setLoop(THREE.LoopOnce, 0);
newAction.clampWhenFinished = true;
} else {
newAction.setLoop(THREE.LoopRepeat, Infinity);
newAction.clampWhenFinished = false;
}
const previousExternal = this._externalAction;
this._crossFadeTo(newAction, this.externalFadeDuration);
this._externalAction = newAction;
// Stop any superseded external after the crossfade completes so old
// LoopOnce actions don't keep firing `finished` events or accumulate.
if (previousExternal && previousExternal !== newAction) {
const stale = previousExternal;
setTimeout(() => {
try { stale.stop(); } catch (e) {}
}, this.externalFadeDuration * 1000 + 100);
}
if (this.animationManager) {
if (kind === 'vrma') this.animationManager.setVRMAPlaying(true);
else this.animationManager.setMixamoPlaying(true);
}
if (play_once) {
const listener = (e) => {
if (e.action !== newAction) return;
this._handleExternalFinished(newAction, { track_position, lock_position, kind });
};
this._externalFinishListener = listener;
this.mixer.addEventListener('finished', listener);
}
}
_handleExternalFinished(action, { track_position, lock_position, kind }) {
const hipsNode = this.vrm.humanoid?.getNormalizedBoneNode('hips');
let targetWorldX = this.vrm.scene.position.x;
let targetWorldZ = this.vrm.scene.position.z;
if (track_position && !lock_position && hipsNode) {
targetWorldX += hipsNode.position.x;
targetWorldZ += hipsNode.position.z;
}
// Crossfade external → idle natively on the same mixer.
this._crossFadeTo(this.actions.idle, this.externalFadeDuration);
this.currentBaseAction = 'idle';
// During the crossfade, hips' local position interpolates between the
// external's last-frame value and idle's pose. Keep world hips at the
// baked target by compensating scene.position each frame. When the fade
// ends, idle is fully driving so hips local is back to the idle pose.
if (track_position && !lock_position) {
this._positionCompensation = {
targetWorldX,
targetWorldZ,
endAt: performance.now() + this.externalFadeDuration * 1000 + 50,
};
}
// Stop the external after the crossfade resolves so it doesn't stay
// around as a zero-weight playing action.
const stale = action;
setTimeout(() => {
try { stale.stop(); } catch (e) {}
if (this._externalAction === stale) this._externalAction = null;
}, this.externalFadeDuration * 1000 + 100);
if (this.animationManager) {
if (kind === 'vrma') this.animationManager.setVRMAPlaying(false);
else this.animationManager.setMixamoPlaying(false);
}
this._removeExternalFinishListener();
}
// ---------- walking ----------
walkTo(x, y, z) {
this.targetPosition = new THREE.Vector3(x, y, z);
this.isMoving = true;
// If a one-off was playing we crossfade out of it straight into walk.
this._removeExternalFinishListener();
const staleExternal = this._externalAction;
this._externalAction = null;
this._positionCompensation = null;
this._crossFadeTo(this.actions.walk, this.externalFadeDuration);
this.currentBaseAction = 'walk';
if (staleExternal) {
setTimeout(() => {
try { staleExternal.stop(); } catch (e) {}
}, this.externalFadeDuration * 1000 + 100);
}
if (this.animationManager) {
this.animationManager.setMixamoPlaying(true);
this.animationManager.setVRMAPlaying(false);
}
console.log(`🚶 Walking to: (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
}
stop() {
this.isMoving = false;
this.targetPosition = null;
this._crossFadeTo(this.actions.idle, this.crossFadeDuration);
this.currentBaseAction = 'idle';
if (this.animationManager) {
this.animationManager.setMixamoPlaying(false);
this.animationManager.setVRMAPlaying(false);
}
console.log('🛑 Movement stopped');
}
teleportTo(x, y, z) {
this.vrm.scene.position.set(x, y, z);
this._removeExternalFinishListener();
const staleExternal = this._externalAction;
this._externalAction = null;
this._positionCompensation = null;
this._crossFadeTo(this.actions.idle, this.crossFadeDuration);
this.currentBaseAction = 'idle';
this.isMoving = false;
this.targetPosition = null;
if (staleExternal) {
setTimeout(() => {
try { staleExternal.stop(); } catch (e) {}
}, this.crossFadeDuration * 1000 + 100);
}
if (this.animationManager) {
this.animationManager.setMixamoPlaying(false);
this.animationManager.setVRMAPlaying(false);
}
console.log(`📍 Teleported to: (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
}
setSpeed(speed) {
this.moveSpeed = speed;
if (this.actions.walk) {
this.actions.walk.setEffectiveTimeScale(speed / this.baseAnimationSpeed);
}
if (this.actions.run) {
this.actions.run.setEffectiveTimeScale(speed / (this.baseAnimationSpeed * 2));
}
}
faceDirection(x, z) {
this.vrm.scene.rotation.y = Math.atan2(x, z);
}
getPosition() { return this.vrm.scene.position.clone(); }
getRotation() { return this.vrm.scene.rotation.y; }
getIsMoving() { return this.isMoving; }
// ---------- per-frame tick ----------
update(deltaTime) {
if (this.mixer) this.mixer.update(deltaTime);
// Per-frame world-position compensation during external → idle crossfade
// so root-motion VRMAs don't snap back when idle takes over.
if (this._positionCompensation) {
const { targetWorldX, targetWorldZ, endAt } = this._positionCompensation;
const hipsNode = this.vrm.humanoid?.getNormalizedBoneNode('hips');
if (hipsNode) {
this.vrm.scene.position.x = targetWorldX - hipsNode.position.x;
this.vrm.scene.position.z = targetWorldZ - hipsNode.position.z;
}
if (performance.now() >= endAt) this._positionCompensation = null;
}
this._updateAdditiveWeightTransitions(deltaTime);
if (this.isMoving && this.targetPosition) {
this._updateMovement(deltaTime);
}
}
_updateAdditiveWeightTransitions(deltaTime) {
for (const action of this.additiveActions.values()) {
if (action._targetWeight !== undefined) {
this._tickWeight(action, deltaTime);
}
}
}
_tickWeight(action, deltaTime) {
const current = action.getEffectiveWeight();
const target = action._targetWeight;
const speed = action._weightTransitionSpeed || 1.0;
if (Math.abs(current - target) < 0.01) {
action.setEffectiveWeight(target);
delete action._targetWeight;
delete action._weightTransitionSpeed;
return;
}
const direction = target > current ? 1 : -1;
const next = current + direction * speed * deltaTime;
action.setEffectiveWeight(direction > 0 ? Math.min(next, target) : Math.max(next, target));
}
_updateMovement(deltaTime) {
const currentPos = this.vrm.scene.position;
const direction = new THREE.Vector3().subVectors(this.targetPosition, currentPos);
const horizontalDir = new THREE.Vector3(direction.x, 0, direction.z);
const distance = horizontalDir.length();
if (distance < this.arrivalThreshold) {
this.stop();
return;
}
horizontalDir.normalize();
const targetAngle = Math.atan2(horizontalDir.x, horizontalDir.z);
const currentRotation = this.vrm.scene.rotation.y;
let angleDiff = targetAngle - currentRotation;
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
const rotationStep = this.rotationSpeed * deltaTime;
if (Math.abs(angleDiff) > rotationStep) {
this.vrm.scene.rotation.y += Math.sign(angleDiff) * rotationStep;
} else {
this.vrm.scene.rotation.y = targetAngle;
}
const moveStep = this.moveSpeed * deltaTime;
currentPos.addScaledVector(horizontalDir, Math.min(moveStep, distance));
}
dispose() {
if (this.mixer) this.mixer.stopAllAction();
this.clips = {};
this.actions = {};
this.additiveClips.clear();
this.additiveActions.clear();
this._activeAction = null;
this._externalAction = null;
this._positionCompensation = null;
}
}