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

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# Owen
> Looking at this animation brief for Owen, I'll create a Three.js animation system that handles the state machine, animation clips, and transitions described in the document. This will use clean architecture principles with dependency injection and factory patterns.
>
> I've created a comprehensive Three.js animation system for Owen that implements the state machine and animation handling described in your brief. Here are the key architectural features:
## **Clean Architecture Principles:**
### **Dependency Injection:**
- `OwenAnimationContext` receives its dependencies (mixer, factory, etc.) through constructor injection
- State handlers are injected with the context they need
- Animation loaders are injected into factories
### **Factory Patterns:**
- `AnimationClipFactory` - Creates animation clips with proper metadata parsing
- `StateFactory` - Creates state handlers dynamically
- `OwenSystemFactory` - Main factory that assembles the entire system
### **State Machine Implementation:**
- Each state (`Wait`, `React`, `Type`, `Sleep`) has its own handler class
- States manage their own entry/exit logic and transitions
- Emotional transitions are handled with proper animation sequencing
## **Key Features:**
1. **Animation Naming Convention Parser** - Automatically parses the naming convention from your brief (e.g., `wait_idle_L`, `react_angry2type_an_T`)
2. **Emotional State Management** - Handles emotional transitions like angry typing or shocked reactions
3. **Nested Animation Support** - Supports the nested sequences described in your brief
4. **Activity Monitoring** - Automatically transitions to sleep after inactivity
5. **Message Analysis** - Analyzes user messages to determine appropriate emotional responses
6. **Clean Separation of Concerns:**
- Animation loading is separate from clip management
- State logic is isolated in individual handlers
- The main context orchestrates everything without tight coupling
## **Usage:**
The system is designed to be easily extensible. You can:
- Add new states by creating new handler classes
- Modify emotional analysis logic
- Swap out animation loaders for different formats
- Add new animation types by extending the factory
The code follows the workflows described in your brief, handling the transitions between Wait → React → Type → Wait states, with proper emotional branching and nested animation support.

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