Ai_Assistant/client/animationManager.js

774 lines
30 KiB
JavaScript
Raw Normal View History

2026-05-24 13:31:30 +02:00
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
}
}