Adds comprehensive Three.js animation system

Implements a feature-rich animation system for Owen with state
machines and transitions according to clean architecture
principles. Introduces dependency injection and factory patterns
to manage animation clips and states effectively. Supports
emotional transitions, nested animations, and user inactivity
handling.

Initial commit
This commit is contained in:
2025-05-23 21:10:53 +02:00
commit 9e5f576b68
2 changed files with 926 additions and 0 deletions

873
owen-animation-system.js Normal file
View File

@ -0,0 +1,873 @@
// Animation Clip Types
const ClipTypes = {
LOOP: 'L',
QUIRK: 'Q',
NESTED_LOOP: 'NL',
NESTED_QUIRK: 'NQ',
NESTED_IN: 'IN_NT',
NESTED_OUT: 'OUT_NT',
TRANSITION: 'T',
};
// Character States
const States = {
WAIT: 'wait',
REACT: 'react',
TYPE: 'type',
SLEEP: 'sleep',
};
// Emotions
const Emotions = {
NEUTRAL: '',
ANGRY: 'an',
SHOCKED: 'sh',
HAPPY: 'ha',
SAD: 'sa',
};
/**
* Animation Clip Factory - Creates animation clips based on naming convention
*/
class AnimationClipFactory {
constructor(animationLoader) {
this.animationLoader = animationLoader;
this.clipCache = new Map();
}
/**
* Parse animation name and create clip metadata
* Format: [state]_[action]_[type] or [state]_[action]2[toState]_[emotion]_T
*/
parseAnimationName(name) {
const parts = name.split('_');
const state = parts[0];
const action = parts[1];
// Handle transitions with emotions
if (parts[2]?.includes('2') && parts[3] === ClipTypes.TRANSITION) {
const [fromAction, toState] = parts[2].split('2');
const emotion = parts[3] || Emotions.NEUTRAL;
return {
state,
action: fromAction,
toState,
emotion,
type: ClipTypes.TRANSITION,
isEmotional: true,
};
}
// Handle regular transitions
if (parts[2] === ClipTypes.TRANSITION) {
const [fromState, toState] = parts[1].split('2');
return {
state,
action: fromState,
toState,
type: ClipTypes.TRANSITION,
isEmotional: false,
};
}
// Handle nested animations
if (parts[2] === ClipTypes.NESTED_IN || parts[2] === ClipTypes.NESTED_OUT) {
return {
state,
action,
type: parts[2],
isNested: true,
};
}
// Handle nested loops and quirks
if (
parts[3] === ClipTypes.NESTED_LOOP ||
parts[3] === ClipTypes.NESTED_QUIRK
) {
return {
state,
action,
nestedAction: parts[2],
type: parts[3],
isNested: true,
};
}
// Handle standard loops and quirks
return {
state,
action,
type: parts[2],
isStandard: true,
};
}
async createClip(name, model) {
if (this.clipCache.has(name)) {
return this.clipCache.get(name);
}
const metadata = this.parseAnimationName(name);
const animation = await this.animationLoader.loadAnimation(name);
const clip = new AnimationClip(name, animation, metadata);
this.clipCache.set(name, clip);
return clip;
}
async createClipsFromModel(model) {
const clips = new Map();
const animations = model.animations || [];
for (const animation of animations) {
const clip = await this.createClip(animation.name, model);
clips.set(animation.name, clip);
}
return clips;
}
}
/**
* Animation Clip - Represents a single animation with metadata
*/
class AnimationClip {
constructor(name, threeAnimation, metadata) {
this.name = name;
this.animation = threeAnimation;
this.metadata = metadata;
this.action = null;
this.mixer = null;
}
createAction(mixer) {
this.mixer = mixer;
this.action = mixer.clipAction(this.animation);
// Configure based on type
if (
this.metadata.type === ClipTypes.LOOP ||
this.metadata.type === ClipTypes.NESTED_LOOP
) {
this.action.setLoop(THREE.LoopRepeat);
} else {
this.action.setLoop(THREE.LoopOnce);
this.action.clampWhenFinished = true;
}
return this.action;
}
play(fadeInDuration = 0.3) {
if (this.action) {
this.action.reset();
this.action.fadeIn(fadeInDuration);
this.action.play();
}
}
stop(fadeOutDuration = 0.3) {
if (this.action) {
this.action.fadeOut(fadeOutDuration);
}
}
isPlaying() {
return this.action?.isRunning() || false;
}
}
/**
* State Handler Interface
*/
class StateHandler {
constructor(stateName, context) {
this.stateName = stateName;
this.context = context;
this.currentClip = null;
this.nestedState = null;
}
async enter(fromState = null, emotion = Emotions.NEUTRAL) {
throw new Error('enter method must be implemented');
}
async exit(toState = null, emotion = Emotions.NEUTRAL) {
throw new Error('exit method must be implemented');
}
update(deltaTime) {
// Override in subclasses if needed
}
async handleMessage(message) {
// Override in subclasses if needed
}
getAvailableTransitions() {
return [];
}
}
/**
* Wait State Handler
*/
class WaitStateHandler extends StateHandler {
constructor(context) {
super(States.WAIT, context);
this.idleClip = null;
this.quirks = [];
this.quirkTimer = 0;
this.quirkInterval = 5000; // 5 seconds between quirks
}
async enter(fromState = null, emotion = Emotions.NEUTRAL) {
console.log(`Entering WAIT state from ${fromState}`);
// Play idle loop
this.idleClip = this.context.getClip('wait_idle_L');
if (this.idleClip) {
await this.idleClip.play();
}
// Collect available quirks
this.quirks = this.context.getClipsByPattern('wait_*_Q');
this.quirkTimer = 0;
}
async exit(toState = null, emotion = Emotions.NEUTRAL) {
console.log(`Exiting WAIT state to ${toState}`);
if (this.currentClip) {
this.currentClip.stop();
}
// Play transition if available
const transitionName = `wait_2${toState}_T`;
const transition = this.context.getClip(transitionName);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
update(deltaTime) {
this.quirkTimer += deltaTime;
// Randomly play quirks
if (this.quirkTimer > this.quirkInterval && Math.random() < 0.3) {
this.playRandomQuirk();
this.quirkTimer = 0;
}
}
async playRandomQuirk() {
if (this.quirks.length === 0) return;
const quirk = this.quirks[Math.floor(Math.random() * this.quirks.length)];
if (this.idleClip) {
this.idleClip.stop(0.2);
}
await quirk.play();
await this.waitForClipEnd(quirk);
// Return to idle
if (this.idleClip) {
this.idleClip.play(0.2);
}
}
getAvailableTransitions() {
return [States.REACT, States.SLEEP];
}
async waitForClipEnd(clip) {
return new Promise((resolve) => {
const checkEnd = () => {
if (!clip.isPlaying()) {
resolve();
} else {
requestAnimationFrame(checkEnd);
}
};
checkEnd();
});
}
}
/**
* React State Handler
*/
class ReactStateHandler extends StateHandler {
constructor(context) {
super(States.REACT, context);
this.emotion = Emotions.NEUTRAL;
}
async enter(fromState = null, emotion = Emotions.NEUTRAL) {
console.log(`Entering REACT state with emotion: ${emotion}`);
this.emotion = emotion;
// Play appropriate reaction
const reactionClip = this.context.getClip('react_idle_L');
if (reactionClip) {
await reactionClip.play();
}
}
async exit(toState = null, emotion = Emotions.NEUTRAL) {
console.log(`Exiting REACT state to ${toState} with emotion: ${emotion}`);
if (this.currentClip) {
this.currentClip.stop();
}
// Play emotional transition if available
let transitionName;
if (emotion !== Emotions.NEUTRAL) {
transitionName = `react_${this.emotion}2${toState}_${emotion}_T`;
} else {
transitionName = `react_2${toState}_T`;
}
const transition = this.context.getClip(transitionName);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
async handleMessage(message) {
// Analyze message sentiment to determine emotion
const emotion = this.analyzeMessageEmotion(message);
this.emotion = emotion;
// Play emotional reaction if needed
if (emotion !== Emotions.NEUTRAL) {
const emotionalClip = this.context.getClip(`react_${emotion}_L`);
if (emotionalClip) {
await emotionalClip.play();
}
}
}
analyzeMessageEmotion(message) {
const text = message.toLowerCase();
if (
text.includes('!') ||
text.includes('urgent') ||
text.includes('asap')
) {
return Emotions.SHOCKED;
}
if (
text.includes('error') ||
text.includes('problem') ||
text.includes('issue')
) {
return Emotions.ANGRY;
}
if (
text.includes('great') ||
text.includes('awesome') ||
text.includes('good')
) {
return Emotions.HAPPY;
}
return Emotions.NEUTRAL;
}
getAvailableTransitions() {
return [States.TYPE, States.WAIT];
}
async waitForClipEnd(clip) {
return new Promise((resolve) => {
const checkEnd = () => {
if (!clip.isPlaying()) {
resolve();
} else {
requestAnimationFrame(checkEnd);
}
};
checkEnd();
});
}
}
/**
* Type State Handler
*/
class TypeStateHandler extends StateHandler {
constructor(context) {
super(States.TYPE, context);
this.emotion = Emotions.NEUTRAL;
this.isTyping = false;
}
async enter(fromState = null, emotion = Emotions.NEUTRAL) {
console.log(`Entering TYPE state with emotion: ${emotion}`);
this.emotion = emotion;
this.isTyping = true;
// Play appropriate typing animation
let typingClipName = 'type_idle_L';
if (emotion !== Emotions.NEUTRAL) {
typingClipName = `type_${emotion}_L`;
}
const typingClip = this.context.getClip(typingClipName);
if (typingClip) {
this.currentClip = typingClip;
await typingClip.play();
}
}
async exit(toState = null, emotion = Emotions.NEUTRAL) {
console.log(`Exiting TYPE state to ${toState}`);
this.isTyping = false;
if (this.currentClip) {
this.currentClip.stop();
}
// Play appropriate exit transition
let transitionName;
if (this.emotion !== Emotions.NEUTRAL) {
transitionName = `type_${this.emotion}2${toState}_T`;
} else {
transitionName = `type_2${toState}_T`;
}
const transition = this.context.getClip(transitionName);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
async finishTyping() {
this.isTyping = false;
// Transition back to wait state
return this.context.transitionTo(States.WAIT, this.emotion);
}
getAvailableTransitions() {
return [States.WAIT];
}
async waitForClipEnd(clip) {
return new Promise((resolve) => {
const checkEnd = () => {
if (!clip.isPlaying()) {
resolve();
} else {
requestAnimationFrame(checkEnd);
}
};
checkEnd();
});
}
}
/**
* Sleep State Handler
*/
class SleepStateHandler extends StateHandler {
constructor(context) {
super(States.SLEEP, context);
this.sleepDuration = 0;
this.maxSleepDuration = 30000; // 30 seconds max sleep
}
async enter(fromState = null, emotion = Emotions.NEUTRAL) {
console.log(`Entering SLEEP state`);
this.sleepDuration = 0;
const sleepClip = this.context.getClip('sleep_idle_L');
if (sleepClip) {
this.currentClip = sleepClip;
await sleepClip.play();
}
}
async exit(toState = null, emotion = Emotions.NEUTRAL) {
console.log(`Exiting SLEEP state to ${toState}`);
if (this.currentClip) {
this.currentClip.stop();
}
const transition = this.context.getClip(`sleep_2${toState}_T`);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
update(deltaTime) {
this.sleepDuration += deltaTime;
// Wake up after max duration or on user activity
if (this.sleepDuration > this.maxSleepDuration) {
this.context.transitionTo(States.WAIT);
}
}
getAvailableTransitions() {
return [States.WAIT];
}
async waitForClipEnd(clip) {
return new Promise((resolve) => {
const checkEnd = () => {
if (!clip.isPlaying()) {
resolve();
} else {
requestAnimationFrame(checkEnd);
}
};
checkEnd();
});
}
}
/**
* State Factory - Creates state handlers using dependency injection
*/
class StateFactory {
constructor() {
this.stateHandlers = new Map();
}
registerStateHandler(stateName, handlerClass) {
this.stateHandlers.set(stateName, handlerClass);
}
createStateHandler(stateName, context) {
const HandlerClass = this.stateHandlers.get(stateName);
if (!HandlerClass) {
throw new Error(`Unknown state: ${stateName}`);
}
return new HandlerClass(context);
}
getAvailableStates() {
return Array.from(this.stateHandlers.keys());
}
}
/**
* Owen Animation Context - Main controller for the animation system
*/
class OwenAnimationContext {
constructor(model, mixer, animationClipFactory, stateFactory) {
this.model = model;
this.mixer = mixer;
this.animationClipFactory = animationClipFactory;
this.stateFactory = stateFactory;
this.clips = new Map();
this.states = new Map();
this.currentState = null;
this.currentStateName = null;
this.userActivityTimeout = null;
this.lastActivityTime = Date.now();
this.inactivityThreshold = 180000; // 3 minutes
}
async initialize() {
// Load all animation clips
this.clips = await this.animationClipFactory.createClipsFromModel(
this.model
);
// Create actions for all clips
for (const clip of this.clips.values()) {
clip.createAction(this.mixer);
}
// Initialize state handlers
this.initializeStates();
// Start in wait state
await this.transitionTo(States.WAIT);
console.log('Owen Animation System initialized');
}
initializeStates() {
// Register state handlers
this.stateFactory.registerStateHandler(States.WAIT, WaitStateHandler);
this.stateFactory.registerStateHandler(States.REACT, ReactStateHandler);
this.stateFactory.registerStateHandler(States.TYPE, TypeStateHandler);
this.stateFactory.registerStateHandler(States.SLEEP, SleepStateHandler);
// Create state instances
for (const stateName of this.stateFactory.getAvailableStates()) {
const stateHandler = this.stateFactory.createStateHandler(
stateName,
this
);
this.states.set(stateName, stateHandler);
}
}
async transitionTo(newStateName, emotion = Emotions.NEUTRAL) {
const newState = this.states.get(newStateName);
if (!newState) {
throw new Error(`Unknown state: ${newStateName}`);
}
// Exit current state
if (this.currentState) {
await this.currentState.exit(newStateName, emotion);
}
// Enter new state
const fromState = this.currentStateName;
this.currentState = newState;
this.currentStateName = newStateName;
await this.currentState.enter(fromState, emotion);
// Reset activity timer
this.resetActivityTimer();
}
async handleUserMessage(message) {
this.resetActivityTimer();
// Always go to react state first
if (this.currentStateName !== States.REACT) {
await this.transitionTo(States.REACT);
}
// Let the react state handle the message
await this.currentState.handleMessage(message);
// Transition to type state after a brief delay
setTimeout(async () => {
const emotion = this.currentState.emotion || Emotions.NEUTRAL;
await this.transitionTo(States.TYPE, emotion);
// Simulate typing duration based on message length
const typingDuration = Math.min(message.length * 100, 5000);
setTimeout(async () => {
await this.currentState.finishTyping();
}, typingDuration);
}, 1000);
}
onUserActivity() {
this.resetActivityTimer();
// Wake up if sleeping
if (this.currentStateName === States.SLEEP) {
this.transitionTo(States.WAIT);
}
}
resetActivityTimer() {
this.lastActivityTime = Date.now();
if (this.userActivityTimeout) {
clearTimeout(this.userActivityTimeout);
}
this.userActivityTimeout = setTimeout(() => {
this.handleInactivity();
}, this.inactivityThreshold);
}
handleInactivity() {
if (this.currentStateName === States.WAIT) {
this.transitionTo(States.SLEEP);
}
}
update(deltaTime) {
// Update mixer
this.mixer.update(deltaTime);
// Update current state
if (this.currentState) {
this.currentState.update(deltaTime);
}
}
getClip(name) {
return this.clips.get(name);
}
getClipsByPattern(pattern) {
const regex = new RegExp(pattern.replace('*', '.*'));
return Array.from(this.clips.values()).filter((clip) =>
regex.test(clip.name)
);
}
getCurrentState() {
return this.currentStateName;
}
getAvailableTransitions() {
return this.currentState?.getAvailableTransitions() || [];
}
}
/**
* Animation Loader Interface - Loads animations from various sources
*/
class AnimationLoader {
async loadAnimation(name) {
throw new Error('loadAnimation method must be implemented');
}
}
/**
* GLTF Animation Loader - Loads animations from GLTF models
*/
class GLTFAnimationLoader extends AnimationLoader {
constructor(gltfLoader) {
super();
this.gltfLoader = gltfLoader;
this.animationCache = new Map();
}
async loadAnimation(name) {
if (this.animationCache.has(name)) {
return this.animationCache.get(name);
}
// In a real implementation, you would load the specific animation
// For this mockup, we'll assume animations are already loaded in the model
throw new Error(`Animation ${name} not found in model`);
}
}
/**
* Owen System Factory - Main factory for creating the complete Owen system
*/
class OwenSystemFactory {
static async createOwenSystem(gltfModel, scene) {
// Create Three.js mixer
const mixer = new THREE.AnimationMixer(gltfModel);
// Create dependencies
const gltfLoader = new THREE.GLTFLoader();
const animationLoader = new GLTFAnimationLoader(gltfLoader);
const animationClipFactory = new AnimationClipFactory(animationLoader);
const stateFactory = new StateFactory();
// Create the main context
const owenContext = new OwenAnimationContext(
gltfModel,
mixer,
animationClipFactory,
stateFactory
);
// Initialize the system
await owenContext.initialize();
// Add to scene
scene.add(gltfModel);
return owenContext;
}
}
// Usage Example
class OwenDemo {
constructor() {
this.scene = null;
this.camera = null;
this.renderer = null;
this.owenSystem = null;
this.clock = new THREE.Clock();
}
async init() {
// Setup Three.js scene
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(this.renderer.domElement);
// Load Owen model (mockup)
const loader = new THREE.GLTFLoader();
const gltf = await loader.loadAsync('path/to/owen-model.gltf');
// Create Owen system
this.owenSystem = await OwenSystemFactory.createOwenSystem(
gltf.scene,
this.scene
);
// Setup event listeners
this.setupEventListeners();
// Start render loop
this.animate();
}
setupEventListeners() {
// Mouse activity
document.addEventListener('mousemove', () => {
this.owenSystem.onUserActivity();
});
// Simulate user messages
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const message = prompt('Send message to Owen:');
if (message) {
this.owenSystem.handleUserMessage(message);
}
}
});
}
animate() {
requestAnimationFrame(() => this.animate());
const deltaTime = this.clock.getDelta();
// Update Owen system
if (this.owenSystem) {
this.owenSystem.update(deltaTime);
}
// Render scene
this.renderer.render(this.scene, this.camera);
}
}
// Initialize the demo
const demo = new OwenDemo();
demo.init().catch(console.error);
export {
OwenSystemFactory,
OwenAnimationContext,
StateFactory,
AnimationClipFactory,
States,
Emotions,
ClipTypes,
};