774 lines
30 KiB
JavaScript
774 lines
30 KiB
JavaScript
|
|
import * as THREE from 'three';
|
||
|
|
|
||
|
|
export class AnimationManager {
|
||
|
|
constructor(vrm, audioMgr, renderer, scene, camera, controls) {
|
||
|
|
this.vrm = vrm;
|
||
|
|
this.audioMgr = audioMgr;
|
||
|
|
this.renderer = renderer;
|
||
|
|
this.scene = scene;
|
||
|
|
this.camera = camera;
|
||
|
|
this.controls = controls;
|
||
|
|
this.clock = new THREE.Clock();
|
||
|
|
this.isPlaying = false;
|
||
|
|
this.isVRMAPlaying = false;
|
||
|
|
this.isMixamoPlaying = false;
|
||
|
|
|
||
|
|
// ========== STATE SYSTEM ==========
|
||
|
|
this.state = 'idle'; // idle, listening, thinking, talking
|
||
|
|
this.previousState = 'idle';
|
||
|
|
this.stateTimer = 0;
|
||
|
|
this.isTransitioning = false;
|
||
|
|
this.transitionTimer = 0;
|
||
|
|
this.transitionDuration = 0.5; // How long the smooth reset takes
|
||
|
|
|
||
|
|
// Movement lock - prevents new movements after state change
|
||
|
|
this.movementLocked = false;
|
||
|
|
this.movementLockTimer = 0;
|
||
|
|
this.movementLockDuration = 1.0; // Default 1 second lock after transition
|
||
|
|
|
||
|
|
// Head state - current and target with smooth interpolation
|
||
|
|
this.headTimer = 0;
|
||
|
|
this.nextHead = 0;
|
||
|
|
this.headTgt = { x: 0, y: 0, z: 0 };
|
||
|
|
this.headCur = { x: 0, y: 0, z: 0 };
|
||
|
|
this.headCenter = { x: 0, y: 0, z: 0 };
|
||
|
|
this.headVelocity = { x: 0, y: 0, z: 0 }; // For smooth acceleration/deceleration
|
||
|
|
|
||
|
|
// Separate eye target tracking (for eye-leads-head)
|
||
|
|
this.eyeTargetCur = { x: 0, y: 0, z: 5 };
|
||
|
|
this.eyeTargetTgt = { x: 0, y: 0, z: 5 };
|
||
|
|
this.eyeLeadTimer = 0; // Timer for when eyes moved to new target
|
||
|
|
this.eyeHasReachedTarget = true;
|
||
|
|
|
||
|
|
// Eye lookAt target (for VRM lookAt system)
|
||
|
|
this.eyeLookAtTarget = new THREE.Object3D();
|
||
|
|
this.eyeLookAtTarget.position.set(0, 0, 5); // Start looking forward
|
||
|
|
this.vrm.scene.add(this.eyeLookAtTarget);
|
||
|
|
if (this.vrm.lookAt) {
|
||
|
|
this.vrm.lookAt.target = this.eyeLookAtTarget;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.eyeTimer = 0;
|
||
|
|
this.eyeTgtPos = new THREE.Vector3(0, 0, 5); // Default: looking forward
|
||
|
|
|
||
|
|
this.bodyTimer = 0;
|
||
|
|
this.nextBody = 0;
|
||
|
|
this.bodyTgt = { x: 0 };
|
||
|
|
this.bodyCur = { x: 0 };
|
||
|
|
|
||
|
|
this.blinkTimer = 0;
|
||
|
|
this.nextBlink = 0;
|
||
|
|
this.blinkVal = 0;
|
||
|
|
|
||
|
|
// Idle state tracking
|
||
|
|
this.idleLookingAtUser = false;
|
||
|
|
this.idleLookAtUserTimer = 0;
|
||
|
|
|
||
|
|
// Listening state tracking
|
||
|
|
this.listeningSideLook = false;
|
||
|
|
this.listeningSideLookTimer = 0;
|
||
|
|
this.listeningSideLookDuration = 0;
|
||
|
|
this.listeningSideDirection = 1; // 1 or -1
|
||
|
|
|
||
|
|
// Talking state tracking
|
||
|
|
this.talkingNodPhase = 0;
|
||
|
|
this.talkingCurrentNodFreq = 2.0;
|
||
|
|
this.talkingCurrentNodIntensity = 0.2;
|
||
|
|
this.talkingNextNodChange = 0;
|
||
|
|
|
||
|
|
// Thinking state tracking
|
||
|
|
this.thinkingLookingAtUser = false;
|
||
|
|
this.thinkingLookAtUserTimer = 0;
|
||
|
|
|
||
|
|
this.config = {
|
||
|
|
// head motion range
|
||
|
|
headNod: 0.2,
|
||
|
|
headTurn: 0.13,
|
||
|
|
headTilt: 0.15,
|
||
|
|
|
||
|
|
// frequency and speed (idle vs talk)
|
||
|
|
headFreqIdle: 1.8, headFreqTalk: 0.8, // lower is faster
|
||
|
|
headEaseIdle: 0.02, headEaseTalk: 0.04,
|
||
|
|
|
||
|
|
// body motion
|
||
|
|
sway: 0.1,
|
||
|
|
swayFreqIdle: 2.8, swayFreqTalk: 1.8,
|
||
|
|
swayEaseIdle: 0.01, swayEaseTalk: 0.02,
|
||
|
|
|
||
|
|
// blink
|
||
|
|
blinkMin: 0.5,
|
||
|
|
blinkMax: 3.0,
|
||
|
|
blinkSpeed: 8.0,
|
||
|
|
|
||
|
|
// Transition settings
|
||
|
|
transitionLockDuration: 0.5, // Lock movements for 1 second after transition
|
||
|
|
transitionEaseSpeed: 0.08, // Speed of smooth reset to center (direct lerp, not physics)
|
||
|
|
|
||
|
|
// Smooth movement physics (for idle state - slow, smooth movements)
|
||
|
|
headAcceleration: 0.001, // Base acceleration (idle uses this for smooth look-around)
|
||
|
|
headDamping: 0.85, // Velocity damping (1 = no damping, 0 = instant stop)
|
||
|
|
|
||
|
|
// State-specific acceleration multipliers (relative to base)
|
||
|
|
stateAcceleration: {
|
||
|
|
idle: 1.0, // Use base acceleration (smooth, slow)
|
||
|
|
listening: 8.0, // 8x faster for responsive nods
|
||
|
|
thinking: 2.0, // 2x for moderate responsiveness
|
||
|
|
talking: 10.0 // 15x faster for visible nods and tilts
|
||
|
|
},
|
||
|
|
|
||
|
|
// State-specific config
|
||
|
|
stateConfig: {
|
||
|
|
idle: {
|
||
|
|
lookDuration: 3.0, // Hold a look for 3 seconds
|
||
|
|
lookChangeChance: 0.3, // Chance per second to change look
|
||
|
|
headEase: 0.014, // Smooth easing
|
||
|
|
headRangeX: 0.25, // Max nod range
|
||
|
|
headRangeY: 0.75, // Max turn range to sides
|
||
|
|
headRangeZ: 0.18, // Max tilt range
|
||
|
|
eyeRange: 7.0, // Eye look distance from center
|
||
|
|
// NEW: Look at user/reset settings
|
||
|
|
lookAtUserChance: 0.35, // 35% chance to look back at user after each look
|
||
|
|
lookAtUserDurationMin: 1.5, // Min duration looking at user
|
||
|
|
lookAtUserDurationMax: 3.5, // Max duration looking at user
|
||
|
|
lookAtUserEyeReset: true // Also reset eyes to center when looking at user
|
||
|
|
},
|
||
|
|
listening: {
|
||
|
|
nodIntensity: 0.35, // Gentler nods
|
||
|
|
nodCount: 2, // Do 2 nods
|
||
|
|
nodDuration: 2.0, // Duration of each nod
|
||
|
|
nodsChance: 0.3, // Chance per 2 seconds to nod
|
||
|
|
headEase: 0.01, // Smooth easing
|
||
|
|
eyeRange: 5.0, // Smaller eye range - focused on user
|
||
|
|
// NEW: Side glance settings
|
||
|
|
sideLookChance: 0.15, // Chance per second to glance to side
|
||
|
|
sideLookDurationMin: 1.0, // Min duration of side look
|
||
|
|
sideLookDurationMax: 3.0, // Max duration of side look
|
||
|
|
sideLookHeadTurn: 0.15, // How much head turns during side look
|
||
|
|
sideLookEyeRange: 4.0, // Eye movement during side look
|
||
|
|
focusOnUser: true // Default: eyes stay on user
|
||
|
|
},
|
||
|
|
thinking: {
|
||
|
|
lookDuration: 1.5, // Hold contemplative looks (shorter for more movement)
|
||
|
|
lookChangeChance: 0.35, // More frequent look changes
|
||
|
|
headEase: 0.02, // Very smooth
|
||
|
|
headRangeX: 0.12, // Subtle nod movements
|
||
|
|
headRangeY: 0.25, // Good turn for thinking
|
||
|
|
headRangeZ: 0.12, // Subtle tilts
|
||
|
|
eyeRange: 6.0, // Look far away while thinking
|
||
|
|
lookUpBias: 0.6, // Bias toward looking up (0-1)
|
||
|
|
// Eye leads head settings
|
||
|
|
eyeLeadTime: 0.1, // Eyes move 0.2s before head
|
||
|
|
eyeLeadAmount: 1.1, // Eyes move 15% further than head target
|
||
|
|
eyeHeadSync: 0.8, // How often eyes and head move same direction (0-1)
|
||
|
|
// Look at user reset (like idle)
|
||
|
|
lookAtUserChance: 0.3, // 30% chance to look back at user
|
||
|
|
lookAtUserDurationMin: 1.0, // Min duration looking at user
|
||
|
|
lookAtUserDurationMax: 2.0 // Max duration looking at user
|
||
|
|
},
|
||
|
|
talking: {
|
||
|
|
nodIntensity: 0.5, // Base nod intensity
|
||
|
|
nodFrequency: 1.8, // Base nods per second
|
||
|
|
nodVariation: 0.6, // Variation in nod strength
|
||
|
|
headEase: 0.045, // Moderate easing
|
||
|
|
occasionalTurn: 0.2, // Occasional head turns
|
||
|
|
eyeRange: 6.0, // Normal eye range while talking
|
||
|
|
// NEW: Variable nodding settings
|
||
|
|
nodIntensityVariation: 0.4, // +/- 40% intensity variation
|
||
|
|
nodFrequencyVariation: 0.5, // +/- 50% frequency variation
|
||
|
|
nodChangeInterval: 1.5, // Change nod params every 1.5 seconds
|
||
|
|
// NEW: Head tilt while talking
|
||
|
|
tiltChance: 0.25, // Chance per second for head tilt
|
||
|
|
tiltIntensity: 0.08, // How much to tilt
|
||
|
|
tiltDuration: 1.2 // How long tilt lasts
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// DON'T START THE ANIMATION LOOP HERE - LET THE MAIN LOOP HANDLE IT
|
||
|
|
// this.animate();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========== STATE MANAGEMENT ==========
|
||
|
|
setState(newState) {
|
||
|
|
if (!['idle', 'listening', 'thinking', 'talking'].includes(newState)) {
|
||
|
|
console.warn(`❌ Invalid state: ${newState}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this.state === newState) {
|
||
|
|
console.log(`⚠️ Already in ${newState} state`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`✅ [AnimationManager] State transition: ${this.state} -> ${newState}`);
|
||
|
|
|
||
|
|
// Store previous state
|
||
|
|
this.previousState = this.state;
|
||
|
|
this.state = newState;
|
||
|
|
this.stateTimer = 0;
|
||
|
|
|
||
|
|
// Start smooth transition - head will ease back to center
|
||
|
|
this.isTransitioning = true;
|
||
|
|
this.transitionTimer = 0;
|
||
|
|
|
||
|
|
// Set target to center for smooth reset
|
||
|
|
this.headTgt = { x: 0, y: 0, z: 0 };
|
||
|
|
this.eyeTargetTgt = { x: 0, y: 0, z: 5 };
|
||
|
|
this.eyeTgtPos.set(0, 0, 5);
|
||
|
|
|
||
|
|
// Reset state-specific timers
|
||
|
|
this.headTimer = 0;
|
||
|
|
this.eyeTimer = 0;
|
||
|
|
this.nextHead = 0;
|
||
|
|
this.eyeLeadTimer = 0;
|
||
|
|
|
||
|
|
// Reset state-specific tracking
|
||
|
|
this.idleLookingAtUser = false;
|
||
|
|
this.idleLookAtUserTimer = 0;
|
||
|
|
this.listeningSideLook = false;
|
||
|
|
this.listeningSideLookTimer = 0;
|
||
|
|
this.talkingNextNodChange = 0;
|
||
|
|
this.thinkingLookingAtUser = false;
|
||
|
|
this.thinkingLookAtUserTimer = 0;
|
||
|
|
this._pendingHeadTarget = null;
|
||
|
|
|
||
|
|
// Movement lock will be activated after transition completes
|
||
|
|
this.movementLocked = false;
|
||
|
|
this.movementLockTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set the movement lock duration (can be called from outside)
|
||
|
|
setMovementLockDuration(duration) {
|
||
|
|
this.movementLockDuration = Math.max(0, duration);
|
||
|
|
this.config.transitionLockDuration = this.movementLockDuration;
|
||
|
|
console.log(`🔒 Movement lock duration set to ${duration}s`);
|
||
|
|
}
|
||
|
|
|
||
|
|
getState() {
|
||
|
|
return this.state;
|
||
|
|
}
|
||
|
|
|
||
|
|
// NEW: Method to set VRMA state
|
||
|
|
setVRMAPlaying(isPlaying) {
|
||
|
|
this.isVRMAPlaying = isPlaying;
|
||
|
|
}
|
||
|
|
|
||
|
|
// NEW: Method to set Mixamo state
|
||
|
|
setMixamoPlaying(isPlaying) {
|
||
|
|
this.isMixamoPlaying = isPlaying;
|
||
|
|
}
|
||
|
|
|
||
|
|
play() {
|
||
|
|
this.audioMgr.resetMouth();
|
||
|
|
this.audioMgr.audioElement.currentTime = 0;
|
||
|
|
this.audioMgr.audioElement.play().catch(() => {});
|
||
|
|
this.isPlaying = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
stop() {
|
||
|
|
this.audioMgr.audioElement.pause();
|
||
|
|
this.isPlaying = false;
|
||
|
|
this.audioMgr.resetMouth();
|
||
|
|
}
|
||
|
|
|
||
|
|
rand(min, max) {
|
||
|
|
return min + Math.random() * (max - min);
|
||
|
|
}
|
||
|
|
|
||
|
|
getCurrentParams() {
|
||
|
|
const talking = this.isPlaying && this.audioMgr.audioElement && !this.audioMgr.audioElement.ended;
|
||
|
|
return {
|
||
|
|
headFreq: talking ? this.config.headFreqTalk : this.config.headFreqIdle,
|
||
|
|
headEase: talking ? this.config.headEaseTalk : this.config.headEaseIdle,
|
||
|
|
swayFreq: talking ? this.config.swayFreqTalk : this.config.swayFreqIdle,
|
||
|
|
swayEase: talking ? this.config.swayEaseTalk : this.config.swayEaseIdle
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========== HELPER METHODS FOR STATES ==========
|
||
|
|
|
||
|
|
// Smooth ease function with acceleration/deceleration (ease in-out)
|
||
|
|
smoothEase(current, target, velocity, acceleration, damping, deltaTime) {
|
||
|
|
const diff = target - current;
|
||
|
|
const force = diff * acceleration;
|
||
|
|
const newVelocity = (velocity + force) * damping;
|
||
|
|
const newValue = current + newVelocity * deltaTime * 60; // Normalize for 60fps
|
||
|
|
return { value: newValue, velocity: newVelocity };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply smooth physics-based movement to head (with state-specific acceleration)
|
||
|
|
updateHeadWithPhysics(deltaTime) {
|
||
|
|
// Get state-specific acceleration multiplier
|
||
|
|
const stateMultiplier = this.config.stateAcceleration[this.state] || 1.0;
|
||
|
|
const acc = this.config.headAcceleration * stateMultiplier;
|
||
|
|
const damp = this.config.headDamping;
|
||
|
|
|
||
|
|
// Update each axis with physics
|
||
|
|
const xResult = this.smoothEase(this.headCur.x, this.headTgt.x, this.headVelocity.x, acc, damp, deltaTime);
|
||
|
|
const yResult = this.smoothEase(this.headCur.y, this.headTgt.y, this.headVelocity.y, acc, damp, deltaTime);
|
||
|
|
const zResult = this.smoothEase(this.headCur.z, this.headTgt.z, this.headVelocity.z, acc, damp, deltaTime);
|
||
|
|
|
||
|
|
this.headCur.x = xResult.value;
|
||
|
|
this.headCur.y = yResult.value;
|
||
|
|
this.headCur.z = zResult.value;
|
||
|
|
|
||
|
|
this.headVelocity.x = xResult.velocity;
|
||
|
|
this.headVelocity.y = yResult.velocity;
|
||
|
|
this.headVelocity.z = zResult.velocity;
|
||
|
|
}
|
||
|
|
|
||
|
|
centerHead(deltaTime, easeSpeed = 0.04) {
|
||
|
|
// Smoothly center the head to neutral position using DIRECT LERP (not physics)
|
||
|
|
// This ensures transitions complete reliably regardless of physics settings
|
||
|
|
this.headCur.x += (0 - this.headCur.x) * easeSpeed;
|
||
|
|
this.headCur.y += (0 - this.headCur.y) * easeSpeed;
|
||
|
|
this.headCur.z += (0 - this.headCur.z) * easeSpeed;
|
||
|
|
|
||
|
|
// Also dampen velocity during centering
|
||
|
|
this.headVelocity.x *= 0.9;
|
||
|
|
this.headVelocity.y *= 0.9;
|
||
|
|
this.headVelocity.z *= 0.9;
|
||
|
|
|
||
|
|
// Also center eyes
|
||
|
|
this.eyeTgtPos.set(0, 0, 5);
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, easeSpeed * 1.5);
|
||
|
|
|
||
|
|
// Check if centered (within threshold)
|
||
|
|
const threshold = 0.02;
|
||
|
|
const isCentered = Math.abs(this.headCur.x) < threshold &&
|
||
|
|
Math.abs(this.headCur.y) < threshold &&
|
||
|
|
Math.abs(this.headCur.z) < threshold;
|
||
|
|
|
||
|
|
return isCentered;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if movement is currently locked
|
||
|
|
isMovementLocked() {
|
||
|
|
return this.movementLocked || this.isTransitioning;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateIdleState(deltaTime, cfg) {
|
||
|
|
// Idle: head looks around smoothly, with chance to look back at user
|
||
|
|
|
||
|
|
this.headTimer += deltaTime;
|
||
|
|
this.eyeTimer += deltaTime;
|
||
|
|
|
||
|
|
// If currently looking at user, count down the timer
|
||
|
|
if (this.idleLookingAtUser) {
|
||
|
|
this.idleLookAtUserTimer -= deltaTime;
|
||
|
|
|
||
|
|
if (this.idleLookAtUserTimer <= 0) {
|
||
|
|
// Done looking at user, time to look somewhere else
|
||
|
|
this.idleLookingAtUser = false;
|
||
|
|
this.headTimer = 0; // Reset timer to trigger new look soon
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Normal idle behavior - look around
|
||
|
|
if (this.headTimer > cfg.lookDuration) {
|
||
|
|
// Chance to look back at user/center
|
||
|
|
if (Math.random() < cfg.lookAtUserChance) {
|
||
|
|
// Look at user - reset to center
|
||
|
|
this.idleLookingAtUser = true;
|
||
|
|
this.idleLookAtUserTimer = this.rand(cfg.lookAtUserDurationMin, cfg.lookAtUserDurationMax);
|
||
|
|
|
||
|
|
// Set targets to center (looking at camera/user)
|
||
|
|
this.headTgt.x = 0;
|
||
|
|
this.headTgt.y = 0;
|
||
|
|
this.headTgt.z = 0;
|
||
|
|
|
||
|
|
// Reset eyes to center if configured
|
||
|
|
if (cfg.lookAtUserEyeReset) {
|
||
|
|
this.eyeTgtPos.set(0, 0, 5);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.headTimer = 0;
|
||
|
|
} else if (Math.random() < cfg.lookChangeChance) {
|
||
|
|
// Random look direction (not at user)
|
||
|
|
const angle = Math.random() * Math.PI * 2;
|
||
|
|
|
||
|
|
// Add some variation to make it more natural
|
||
|
|
const rangeMultiplier = 0.6 + Math.random() * 0.4; // 60-100% of max range
|
||
|
|
|
||
|
|
this.headTgt.x = Math.sin(angle) * cfg.headRangeX * rangeMultiplier;
|
||
|
|
this.headTgt.y = Math.cos(angle) * cfg.headRangeY * rangeMultiplier;
|
||
|
|
this.headTgt.z = this.rand(-cfg.headRangeZ, cfg.headRangeZ) * rangeMultiplier;
|
||
|
|
|
||
|
|
// Eyes look in same direction (slightly exaggerated)
|
||
|
|
this.eyeTgtPos.x = Math.sin(angle) * cfg.eyeRange;
|
||
|
|
this.eyeTgtPos.y = Math.cos(angle) * cfg.eyeRange * 0.4;
|
||
|
|
this.eyeTgtPos.z = 5 + Math.cos(angle) * 1.5;
|
||
|
|
|
||
|
|
this.headTimer = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use physics-based movement for smooth acceleration/deceleration
|
||
|
|
this.updateHeadWithPhysics(deltaTime);
|
||
|
|
|
||
|
|
// Update eye look target smoothly
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, 0.025);
|
||
|
|
}
|
||
|
|
|
||
|
|
updateListeningState(deltaTime, cfg) {
|
||
|
|
// Listening: focus on user with occasional side glances and DETERMINISTIC nods
|
||
|
|
|
||
|
|
this.headTimer += deltaTime;
|
||
|
|
this.eyeTimer += deltaTime;
|
||
|
|
|
||
|
|
// Handle side look behavior
|
||
|
|
if (this.listeningSideLook) {
|
||
|
|
this.listeningSideLookTimer -= deltaTime;
|
||
|
|
|
||
|
|
if (this.listeningSideLookTimer <= 0) {
|
||
|
|
// End side look - return to focusing on user
|
||
|
|
this.listeningSideLook = false;
|
||
|
|
this.headTgt.y = 0; // Return head to center
|
||
|
|
this.eyeTgtPos.set(0, 0, 5); // Eyes back to user
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Check for triggering a side glance
|
||
|
|
if (Math.random() < cfg.sideLookChance * deltaTime) {
|
||
|
|
this.listeningSideLook = true;
|
||
|
|
this.listeningSideLookTimer = this.rand(cfg.sideLookDurationMin, cfg.sideLookDurationMax);
|
||
|
|
this.listeningSideDirection = Math.random() < 0.5 ? -1 : 1;
|
||
|
|
|
||
|
|
// Turn head slightly to side
|
||
|
|
this.headTgt.y = cfg.sideLookHeadTurn * this.listeningSideDirection;
|
||
|
|
|
||
|
|
// Eyes glance to side
|
||
|
|
this.eyeTgtPos.x = cfg.sideLookEyeRange * this.listeningSideDirection;
|
||
|
|
this.eyeTgtPos.y = 0;
|
||
|
|
this.eyeTgtPos.z = 5;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// DETERMINISTIC NODDING - nods happen on a schedule, not by random chance
|
||
|
|
// Nod cycle: every 2-3 seconds, do a series of nods
|
||
|
|
const nodCycleDuration = 2.5; // Full cycle takes 2.5 seconds
|
||
|
|
const nodActivePortion = 0.4; // Nods happen in first 40% of cycle (1 second)
|
||
|
|
const cycleTime = this.stateTimer % nodCycleDuration;
|
||
|
|
const cyclePhase = cycleTime / nodCycleDuration;
|
||
|
|
|
||
|
|
// Only nod when not doing a side look
|
||
|
|
if (!this.listeningSideLook) {
|
||
|
|
if (cyclePhase < nodActivePortion) {
|
||
|
|
// During nod portion: create smooth nod motion
|
||
|
|
// Map cyclePhase [0, nodActivePortion] to [0, 2*PI*nodCount] for multiple nods
|
||
|
|
const nodProgress = cyclePhase / nodActivePortion;
|
||
|
|
const nodPhase = nodProgress * Math.PI * 2 * cfg.nodCount;
|
||
|
|
const nodAmount = Math.sin(nodPhase) * this.config.headNod * cfg.nodIntensity;
|
||
|
|
this.headTgt.x = nodAmount;
|
||
|
|
} else {
|
||
|
|
// Between nod cycles - smoothly return to neutral
|
||
|
|
this.headTgt.x *= 0.9;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Keep eyes on user when not side-looking
|
||
|
|
if (cfg.focusOnUser) {
|
||
|
|
// Subtle micro-movements while focused
|
||
|
|
if (this.eyeTimer > 2.0) {
|
||
|
|
// Very small eye movements to simulate natural focus
|
||
|
|
this.eyeTgtPos.x = this.rand(-1, 1);
|
||
|
|
this.eyeTgtPos.y = this.rand(-0.5, 0.5);
|
||
|
|
this.eyeTgtPos.z = 5;
|
||
|
|
this.eyeTimer = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use physics-based movement (with state-specific acceleration)
|
||
|
|
this.updateHeadWithPhysics(deltaTime);
|
||
|
|
|
||
|
|
// Update eye look target
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, 0.03);
|
||
|
|
}
|
||
|
|
|
||
|
|
updateThinkingState(deltaTime, cfg) {
|
||
|
|
// Thinking: look away thoughtfully with eyes leading head, with chance to look at user
|
||
|
|
|
||
|
|
this.headTimer += deltaTime;
|
||
|
|
this.eyeTimer += deltaTime;
|
||
|
|
this.eyeLeadTimer += deltaTime;
|
||
|
|
|
||
|
|
// Handle look-at-user state (similar to idle)
|
||
|
|
if (this.thinkingLookingAtUser) {
|
||
|
|
this.thinkingLookAtUserTimer -= deltaTime;
|
||
|
|
|
||
|
|
if (this.thinkingLookAtUserTimer <= 0) {
|
||
|
|
// Done looking at user, time to think/look away again
|
||
|
|
this.thinkingLookingAtUser = false;
|
||
|
|
this.headTimer = 0;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Check if it's time to pick a new look direction
|
||
|
|
if (this.headTimer > cfg.lookDuration) {
|
||
|
|
// Chance to look at user (brief focus before thinking again)
|
||
|
|
if (Math.random() < cfg.lookAtUserChance) {
|
||
|
|
this.thinkingLookingAtUser = true;
|
||
|
|
this.thinkingLookAtUserTimer = this.rand(cfg.lookAtUserDurationMin, cfg.lookAtUserDurationMax);
|
||
|
|
|
||
|
|
// Eyes move to user first
|
||
|
|
this.eyeTgtPos.set(0, 0, 5);
|
||
|
|
|
||
|
|
// Head will follow after eye lead time
|
||
|
|
this._pendingHeadTarget = { x: 0, y: 0, z: 0 };
|
||
|
|
this.eyeLeadTimer = 0;
|
||
|
|
this.headTimer = 0;
|
||
|
|
} else if (Math.random() < cfg.lookChangeChance) {
|
||
|
|
// Look away thoughtfully
|
||
|
|
// Bias toward looking up and to the sides (contemplative)
|
||
|
|
const angle = (Math.random() * Math.PI * 1.6) - (Math.PI * 0.3);
|
||
|
|
const upBias = cfg.lookUpBias * 0.25;
|
||
|
|
|
||
|
|
// Calculate intended head target
|
||
|
|
const newHeadX = (Math.sin(angle) * cfg.headRangeX) + upBias;
|
||
|
|
const newHeadY = Math.cos(angle) * cfg.headRangeY;
|
||
|
|
const newHeadZ = this.rand(-cfg.headRangeZ, cfg.headRangeZ);
|
||
|
|
|
||
|
|
// Eyes move FIRST - set eye target immediately
|
||
|
|
const eyeSync = Math.random() < cfg.eyeHeadSync;
|
||
|
|
if (eyeSync) {
|
||
|
|
// Eyes move in same direction as head will go
|
||
|
|
this.eyeTgtPos.x = Math.sin(angle) * cfg.eyeRange * cfg.eyeLeadAmount;
|
||
|
|
this.eyeTgtPos.y = (cfg.eyeRange * 0.6 + upBias * 10) * cfg.eyeLeadAmount;
|
||
|
|
this.eyeTgtPos.z = 4;
|
||
|
|
} else {
|
||
|
|
// Occasional divergent eye movement
|
||
|
|
const divergeAngle = Math.random() * Math.PI * 2;
|
||
|
|
this.eyeTgtPos.x = Math.sin(divergeAngle) * cfg.eyeRange * 0.6;
|
||
|
|
this.eyeTgtPos.y = cfg.eyeRange * 0.4;
|
||
|
|
this.eyeTgtPos.z = 5;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store pending head target (applied after eye lead time)
|
||
|
|
this._pendingHeadTarget = { x: newHeadX, y: newHeadY, z: newHeadZ };
|
||
|
|
this.eyeLeadTimer = 0;
|
||
|
|
this.headTimer = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply pending head target after eye lead time
|
||
|
|
if (this._pendingHeadTarget && this.eyeLeadTimer >= cfg.eyeLeadTime) {
|
||
|
|
this.headTgt.x = this._pendingHeadTarget.x;
|
||
|
|
this.headTgt.y = this._pendingHeadTarget.y;
|
||
|
|
this.headTgt.z = this._pendingHeadTarget.z;
|
||
|
|
this._pendingHeadTarget = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use physics-based movement (with state-specific acceleration)
|
||
|
|
this.updateHeadWithPhysics(deltaTime);
|
||
|
|
|
||
|
|
// Eyes move faster than head (they lead)
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, 0.04);
|
||
|
|
}
|
||
|
|
|
||
|
|
updateTalkingState(deltaTime, cfg) {
|
||
|
|
// Talking: variable nodding with randomness in intensity/frequency, plus head tilts
|
||
|
|
|
||
|
|
this.headTimer += deltaTime;
|
||
|
|
this.eyeTimer += deltaTime;
|
||
|
|
this.talkingNodPhase += deltaTime;
|
||
|
|
|
||
|
|
// Periodically change nod parameters for natural variation
|
||
|
|
if (this.stateTimer > this.talkingNextNodChange) {
|
||
|
|
// Randomize nod frequency within configured variation
|
||
|
|
const freqVariation = 1 + (Math.random() * 2 - 1) * cfg.nodFrequencyVariation;
|
||
|
|
this.talkingCurrentNodFreq = cfg.nodFrequency * freqVariation;
|
||
|
|
|
||
|
|
// Randomize nod intensity within configured variation
|
||
|
|
const intensityVariation = 1 + (Math.random() * 2 - 1) * cfg.nodIntensityVariation;
|
||
|
|
this.talkingCurrentNodIntensity = cfg.nodIntensity * intensityVariation;
|
||
|
|
|
||
|
|
// Set next change time
|
||
|
|
this.talkingNextNodChange = this.stateTimer + cfg.nodChangeInterval * (0.7 + Math.random() * 0.6);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate nod with current (randomized) parameters
|
||
|
|
// Add micro-pauses by occasionally flattening the nod
|
||
|
|
const nodActive = Math.random() > 0.15; // 85% of time actively nodding
|
||
|
|
if (nodActive) {
|
||
|
|
const nodPhase = (this.talkingNodPhase * this.talkingCurrentNodFreq * Math.PI * 2) % (Math.PI * 2);
|
||
|
|
|
||
|
|
// Create non-uniform nodding - more variation in the sine wave
|
||
|
|
const rawNod = Math.sin(nodPhase);
|
||
|
|
const nodVariation = rawNod * (0.7 + Math.random() * 0.3); // Add per-frame randomness
|
||
|
|
const nodStrength = nodVariation * this.talkingCurrentNodIntensity * cfg.nodVariation;
|
||
|
|
this.headTgt.x = nodStrength * this.config.headNod;
|
||
|
|
} else {
|
||
|
|
// Brief pause in nodding
|
||
|
|
this.headTgt.x *= 0.9;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Head tilts while talking (for emphasis)
|
||
|
|
if (Math.random() < cfg.tiltChance * deltaTime) {
|
||
|
|
// Apply a tilt in random direction
|
||
|
|
const tiltDirection = Math.random() < 0.5 ? -1 : 1;
|
||
|
|
this.headTgt.z = cfg.tiltIntensity * tiltDirection * (0.6 + Math.random() * 0.4);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Occasional head turns for emphasis (less frequent than tilts)
|
||
|
|
if (Math.random() < cfg.occasionalTurn * deltaTime * 0.5) {
|
||
|
|
this.headTgt.y = this.rand(-this.config.headTurn * 0.15, this.config.headTurn * 0.15);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Gradual decay of turns and tilts back to center (not instant)
|
||
|
|
this.headTgt.y *= 0.95;
|
||
|
|
this.headTgt.z *= 0.94;
|
||
|
|
|
||
|
|
// Eye gaze - mostly on user with subtle shifts
|
||
|
|
if (this.eyeTimer > 2.5) {
|
||
|
|
// Small variations while maintaining user focus
|
||
|
|
const eyeShift = Math.random() * 0.4 - 0.2;
|
||
|
|
this.eyeTgtPos.x = Math.sin(eyeShift) * cfg.eyeRange * 0.3;
|
||
|
|
this.eyeTgtPos.y = this.rand(-1, 1);
|
||
|
|
this.eyeTgtPos.z = 5;
|
||
|
|
this.eyeTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use physics-based movement for natural acceleration/deceleration
|
||
|
|
this.updateHeadWithPhysics(deltaTime);
|
||
|
|
|
||
|
|
// Update eye look target
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, 0.025);
|
||
|
|
}
|
||
|
|
|
||
|
|
applyEyeMovement() {
|
||
|
|
// Apply eye movement to VRM if supported
|
||
|
|
if (!this.vrm.expressionManager) return;
|
||
|
|
|
||
|
|
// Try different possible eye expression names that VRM models might have
|
||
|
|
const eyeExpressions = ['eyeLookUp', 'eyeLookDown', 'eyeLookLeft', 'eyeLookRight',
|
||
|
|
'eye_look_up', 'eye_look_down', 'eye_look_left', 'eye_look_right'];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const upVal = Math.max(0, Math.min(1, this.eyeCur.y));
|
||
|
|
const downVal = Math.max(0, Math.min(1, -this.eyeCur.y));
|
||
|
|
const leftVal = Math.max(0, Math.min(1, -this.eyeCur.x));
|
||
|
|
const rightVal = Math.max(0, Math.min(1, this.eyeCur.x));
|
||
|
|
|
||
|
|
// Try to set eye expressions
|
||
|
|
['eyeLookUp', 'eye_look_up'].forEach(expr => {
|
||
|
|
try { this.vrm.expressionManager.setValue(expr, upVal); } catch (e) {}
|
||
|
|
});
|
||
|
|
['eyeLookDown', 'eye_look_down'].forEach(expr => {
|
||
|
|
try { this.vrm.expressionManager.setValue(expr, downVal); } catch (e) {}
|
||
|
|
});
|
||
|
|
['eyeLookLeft', 'eye_look_left'].forEach(expr => {
|
||
|
|
try { this.vrm.expressionManager.setValue(expr, leftVal); } catch (e) {}
|
||
|
|
});
|
||
|
|
['eyeLookRight', 'eye_look_right'].forEach(expr => {
|
||
|
|
try { this.vrm.expressionManager.setValue(expr, rightVal); } catch (e) {}
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
// Silently ignore if eyes not supported
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MODIFIED: Remove the animation loop, make this a simple update method
|
||
|
|
update(deltaTime) {
|
||
|
|
if (!this.vrm) return;
|
||
|
|
|
||
|
|
// ALWAYS HANDLE BLINKING - regardless of state or VRMA/Mixamo
|
||
|
|
this.blinkTimer += deltaTime;
|
||
|
|
if (this.blinkTimer > this.nextBlink) {
|
||
|
|
this.blinkTimer = 0;
|
||
|
|
this.nextBlink = this.rand(this.config.blinkMin, this.config.blinkMax);
|
||
|
|
}
|
||
|
|
this.blinkVal += (this.blinkTimer < 0.1 ? deltaTime : -deltaTime) * this.config.blinkSpeed;
|
||
|
|
this.blinkVal = Math.max(0, Math.min(1, this.blinkVal));
|
||
|
|
this.vrm.expressionManager.setValue('blink', this.blinkVal);
|
||
|
|
this.vrm.expressionManager.setValue('neutral', 1.0);
|
||
|
|
|
||
|
|
// Handle audio/lip sync regardless of animation state
|
||
|
|
if (this.isPlaying && this.audioMgr.audioElement) {
|
||
|
|
this.audioMgr.updateLipSync(this.audioMgr.audioElement.currentTime);
|
||
|
|
if (this.audioMgr.audioElement.ended) this.stop();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========== TRANSITION HANDLING ==========
|
||
|
|
// Smooth transition: ease head to center, then lock movements for a duration
|
||
|
|
|
||
|
|
if (this.isTransitioning) {
|
||
|
|
this.transitionTimer += deltaTime;
|
||
|
|
|
||
|
|
// Phase 1: Smooth reset to center with physics-based easing
|
||
|
|
const isCentered = this.centerHead(deltaTime, this.config.transitionEaseSpeed);
|
||
|
|
|
||
|
|
// Check if transition is complete (head centered and enough time passed)
|
||
|
|
const minTransitionTime = 0.4; // Minimum time for smooth visual
|
||
|
|
if (isCentered && this.transitionTimer >= minTransitionTime) {
|
||
|
|
// Transition complete - start movement lock
|
||
|
|
this.isTransitioning = false;
|
||
|
|
this.movementLocked = true;
|
||
|
|
this.movementLockTimer = 0;
|
||
|
|
|
||
|
|
// Reset velocity for clean start
|
||
|
|
this.headVelocity = { x: 0, y: 0, z: 0 };
|
||
|
|
|
||
|
|
console.log(`🔒 Transition complete, movement locked for ${this.config.transitionLockDuration}s`);
|
||
|
|
}
|
||
|
|
} else if (this.movementLocked) {
|
||
|
|
// Phase 2: Movement lock - head stays centered
|
||
|
|
this.movementLockTimer += deltaTime;
|
||
|
|
|
||
|
|
// Keep head and eyes centered during lock
|
||
|
|
this.headTgt = { x: 0, y: 0, z: 0 };
|
||
|
|
this.updateHeadWithPhysics(deltaTime);
|
||
|
|
this.eyeTgtPos.set(0, 0, 5);
|
||
|
|
this.eyeLookAtTarget.position.lerp(this.eyeTgtPos, 0.05);
|
||
|
|
|
||
|
|
if (this.movementLockTimer >= this.config.transitionLockDuration) {
|
||
|
|
// Lock period over - allow state animations
|
||
|
|
this.movementLocked = false;
|
||
|
|
console.log(`🔓 Movement unlocked, ${this.state} animations starting`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// ========== STATE-SPECIFIC ANIMATIONS ==========
|
||
|
|
// Only run when not transitioning and not locked
|
||
|
|
const stateCfg = this.config.stateConfig[this.state];
|
||
|
|
|
||
|
|
if (this.state === 'idle') {
|
||
|
|
this.updateIdleState(deltaTime, stateCfg);
|
||
|
|
} else if (this.state === 'listening') {
|
||
|
|
this.updateListeningState(deltaTime, stateCfg);
|
||
|
|
} else if (this.state === 'thinking') {
|
||
|
|
this.updateThinkingState(deltaTime, stateCfg);
|
||
|
|
} else if (this.state === 'talking') {
|
||
|
|
this.updateTalkingState(deltaTime, stateCfg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.stateTimer += deltaTime;
|
||
|
|
|
||
|
|
// Apply head and neck rotations from state animations
|
||
|
|
// This runs regardless of VRMA/Mixamo playing
|
||
|
|
const neck = this.vrm.humanoid.getNormalizedBoneNode('neck');
|
||
|
|
neck.rotation.set(this.headCur.x, this.headCur.y, this.headCur.z);
|
||
|
|
|
||
|
|
// SKIP BODY ANIMATIONS IF VRMA OR MIXAMO IS PLAYING
|
||
|
|
if (this.isVRMAPlaying || this.isMixamoPlaying) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// *** enforce arms-down every frame (only when not playing VRMA/Mixamo) ***
|
||
|
|
const leftArm = this.vrm.humanoid.getNormalizedBoneNode('leftUpperArm');
|
||
|
|
const rightArm = this.vrm.humanoid.getNormalizedBoneNode('rightUpperArm');
|
||
|
|
leftArm.rotation.z = -1.2;
|
||
|
|
rightArm.rotation.z = 1.2;
|
||
|
|
|
||
|
|
// Body sway (gentle regardless of state)
|
||
|
|
this.bodyTimer += deltaTime;
|
||
|
|
const swayFreq = 2.8;
|
||
|
|
const swayEase = 0.01;
|
||
|
|
if (this.bodyTimer > swayFreq) {
|
||
|
|
this.bodyTgt.x = this.rand(-this.config.sway * 0.5, this.config.sway * 0.5);
|
||
|
|
this.bodyTimer = 0;
|
||
|
|
}
|
||
|
|
this.bodyCur.x += (this.bodyTgt.x - this.bodyCur.x) * swayEase;
|
||
|
|
const spine = this.vrm.humanoid.getNormalizedBoneNode('spine');
|
||
|
|
spine.rotation.x = this.bodyCur.x;
|
||
|
|
|
||
|
|
// DON'T CALL vrm.update() OR renderer.render() HERE - LET THE MAIN LOOP HANDLE IT
|
||
|
|
}
|
||
|
|
}
|