Implement Owen Animation System with core classes, loaders, and state handlers

- Added OwenSystemFactory for creating the animation system.
- Introduced OwenAnimationContext to manage animations and states.
- Created AnimationLoader and GLTFAnimationLoader for loading animations.
- Developed state handlers: WaitStateHandler, ReactStateHandler, TypeStateHandler, SleepStateHandler.
- Implemented StateFactory for managing state handlers.
- Defined constants for clip types, states, and emotions.
- Added type definitions for TypeScript support.
- Configured Vite for building and serving the project.
- Added licenses (dual) to project.
This commit is contained in:
2025-05-23 21:36:52 +02:00
parent 9e5f576b68
commit 658e1e64b2
29 changed files with 6902 additions and 907 deletions

View File

@ -0,0 +1,257 @@
/**
* @fileoverview Core animation classes for clip management and creation
* @module animation
*/
import * as THREE from 'three';
import { ClipTypes, Config } from '../constants.js';
/**
* Represents a single animation clip with metadata and Three.js action
* @class
*/
export class AnimationClip {
/**
* Create an animation clip
* @param {string} name - The name of the animation clip
* @param {THREE.AnimationClip} threeAnimation - The Three.js animation clip
* @param {Object} metadata - Parsed metadata from animation name
*/
constructor(name, threeAnimation, metadata) {
/**
* The name of the animation clip
* @type {string}
*/
this.name = name;
/**
* The Three.js animation clip
* @type {THREE.AnimationClip}
*/
this.animation = threeAnimation;
/**
* Parsed metadata about the animation
* @type {Object}
*/
this.metadata = metadata;
/**
* The Three.js animation action
* @type {THREE.AnimationAction|null}
*/
this.action = null;
/**
* The animation mixer
* @type {THREE.AnimationMixer|null}
*/
this.mixer = null;
}
/**
* Create and configure a Three.js action for this clip
* @param {THREE.AnimationMixer} mixer - The animation mixer
* @returns {THREE.AnimationAction} The created action
*/
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, Infinity);
} else {
this.action.setLoop(THREE.LoopOnce);
this.action.clampWhenFinished = true;
}
return this.action;
}
/**
* Play the animation with optional fade in
* @param {number} [fadeInDuration=0.3] - Fade in duration in seconds
* @returns {Promise<void>} Promise that resolves when fade in completes
*/
play(fadeInDuration = Config.DEFAULT_FADE_IN) {
if (this.action) {
this.action.reset();
this.action.fadeIn(fadeInDuration);
this.action.play();
}
}
/**
* Stop the animation with optional fade out
* @param {number} [fadeOutDuration=0.3] - Fade out duration in seconds
* @returns {Promise<void>} Promise that resolves when fade out completes
*/
stop(fadeOutDuration = Config.DEFAULT_FADE_OUT) {
if (this.action) {
this.action.fadeOut(fadeOutDuration);
setTimeout(() => {
if (this.action) {
this.action.stop();
}
}, fadeOutDuration * 1000);
}
}
/**
* Check if the animation is currently playing
* @returns {boolean} True if playing, false otherwise
*/
isPlaying() {
return this.action?.isRunning() || false;
}
}
/**
* Factory for creating animation clips with parsed metadata
* @class
*/
export class AnimationClipFactory {
/**
* Create an animation clip factory
* @param {AnimationLoader} animationLoader - The animation loader instance
*/
constructor(animationLoader) {
/**
* The animation loader for loading animation data
* @type {AnimationLoader}
*/
this.animationLoader = animationLoader;
/**
* Cache for created animation clips
* @type {Map<string, AnimationClip>}
*/
this.clipCache = new Map();
}
/**
* Parse animation name and create clip metadata
* Format: [state]_[action]_[type] or [state]_[action]2[toState]_[emotion]_T
* @param {string} name - The animation name to parse
* @returns {Object} Parsed metadata object
*/
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 [ , toState ] = parts[ 2 ].split('2');
return {
state,
action,
toState,
emotion: parts[ 2 ] || '',
type: ClipTypes.TRANSITION,
isTransition: true,
hasEmotion: true,
};
}
// Handle regular transitions
if (parts[ 2 ] === ClipTypes.TRANSITION) {
return {
state,
action,
type: ClipTypes.TRANSITION,
isTransition: true,
};
}
// Handle nested animations
if (parts[ 2 ] === ClipTypes.NESTED_IN || parts[ 2 ] === ClipTypes.NESTED_OUT) {
return {
state,
action,
type: parts[ 2 ],
nestedType: parts[ 3 ],
isNested: true,
};
}
// Handle nested loops and quirks
if (
parts[ 3 ] === ClipTypes.NESTED_LOOP ||
parts[ 3 ] === ClipTypes.NESTED_QUIRK
) {
return {
state,
action,
subAction: parts[ 2 ],
type: parts[ 3 ],
isNested: true,
};
}
// Handle standard loops and quirks
return {
state,
action,
type: parts[ 2 ],
isStandard: true,
};
}
/**
* Create an animation clip from a name
* @param {string} name - The animation name
* @returns {Promise<AnimationClip>} The created animation clip
*/
async createClip(name) {
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;
}
/**
* Create all animation clips from a model's animations
* @param {THREE.Object3D} model - The 3D model containing animations
* @returns {Promise<Map<string, AnimationClip>>} Map of animation name to 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;
}
/**
* Clear the clip cache
* @returns {void}
*/
clearCache() {
this.clipCache.clear();
}
/**
* Get cached clip by name
* @param {string} name - The animation name
* @returns {AnimationClip|undefined} The cached clip or undefined
*/
getCachedClip(name) {
return this.clipCache.get(name);
}
}

78
src/constants.js Normal file
View File

@ -0,0 +1,78 @@
/**
* @fileoverview Animation system constants and enumerations
* @module constants
*/
/**
* Animation clip types based on naming convention
* @readonly
* @enum {string}
*/
export const ClipTypes = {
/** Loop animation */
LOOP: 'L',
/** Quirk animation */
QUIRK: 'Q',
/** Nested loop animation */
NESTED_LOOP: 'NL',
/** Nested quirk animation */
NESTED_QUIRK: 'NQ',
/** Nested in transition */
NESTED_IN: 'IN_NT',
/** Nested out transition */
NESTED_OUT: 'OUT_NT',
/** Transition animation */
TRANSITION: 'T',
};
/**
* Character animation states
* @readonly
* @enum {string}
*/
export const States = {
/** Waiting/idle state */
WAIT: 'wait',
/** Reacting to input state */
REACT: 'react',
/** Typing response state */
TYPE: 'type',
/** Sleep/inactive state */
SLEEP: 'sleep',
};
/**
* Character emotional states
* @readonly
* @enum {string}
*/
export const Emotions = {
/** Neutral emotion */
NEUTRAL: '',
/** Angry emotion */
ANGRY: 'an',
/** Shocked emotion */
SHOCKED: 'sh',
/** Happy emotion */
HAPPY: 'ha',
/** Sad emotion */
SAD: 'sa',
};
/**
* Default configuration values
* @readonly
* @type {Object}
*/
export const Config = {
/** Default fade in duration for animations (ms) */
DEFAULT_FADE_IN: 0.3,
/** Default fade out duration for animations (ms) */
DEFAULT_FADE_OUT: 0.3,
/** Default quirk interval (ms) */
QUIRK_INTERVAL: 5000,
/** Default inactivity timeout (ms) */
INACTIVITY_TIMEOUT: 60000,
/** Quirk probability threshold */
QUIRK_PROBABILITY: 0.3,
};

View File

@ -0,0 +1,332 @@
/**
* @fileoverview Main animation context controller
* @module core
*/
import { States, Emotions, Config } from '../constants.js';
/**
* Main controller for the Owen animation system
* Manages state transitions, animation playback, and user interactions
* @class
*/
export class OwenAnimationContext {
/**
* Create an Owen animation context
* @param {THREE.Object3D} model - The 3D character model
* @param {THREE.AnimationMixer} mixer - The Three.js animation mixer
* @param {AnimationClipFactory} animationClipFactory - Factory for creating clips
* @param {StateFactory} stateFactory - Factory for creating state handlers
*/
constructor(model, mixer, animationClipFactory, stateFactory) {
/**
* The 3D character model
* @type {THREE.Object3D}
*/
this.model = model;
/**
* The Three.js animation mixer
* @type {THREE.AnimationMixer}
*/
this.mixer = mixer;
/**
* Factory for creating animation clips
* @type {AnimationClipFactory}
*/
this.animationClipFactory = animationClipFactory;
/**
* Factory for creating state handlers
* @type {StateFactory}
*/
this.stateFactory = stateFactory;
/**
* Map of animation clips by name
* @type {Map<string, AnimationClip>}
*/
this.clips = new Map();
/**
* Map of state handlers by name
* @type {Map<string, StateHandler>}
*/
this.states = new Map();
/**
* Current active state
* @type {string}
*/
this.currentState = States.WAIT;
/**
* Current active state handler
* @type {StateHandler|null}
*/
this.currentStateHandler = null;
/**
* Timer for inactivity detection
* @type {number}
*/
this.inactivityTimer = 0;
/**
* Inactivity timeout in milliseconds
* @type {number}
*/
this.inactivityTimeout = Config.INACTIVITY_TIMEOUT;
/**
* Whether the system is initialized
* @type {boolean}
*/
this.initialized = false;
}
/**
* Initialize the animation system
* @returns {Promise<void>}
*/
async initialize() {
if (this.initialized) return;
// Create animation clips from model
this.clips = await this.animationClipFactory.createClipsFromModel(this.model);
// Create actions for all clips
for (const [ , clip ] of this.clips) {
clip.createAction(this.mixer);
}
// Initialize state handlers
this.initializeStates();
// Start in wait state
await this.transitionTo(States.WAIT);
this.initialized = true;
console.log('Owen Animation System initialized');
}
/**
* Initialize all state handlers
* @private
* @returns {void}
*/
initializeStates() {
const stateNames = this.stateFactory.getAvailableStates();
for (const stateName of stateNames) {
const handler = this.stateFactory.createStateHandler(stateName, this);
this.states.set(stateName, handler);
}
}
/**
* Transition to a new state
* @param {string} newStateName - The name of the state to transition to
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion for the transition
* @returns {Promise<void>}
* @throws {Error} If state is not found or transition is invalid
*/
async transitionTo(newStateName, emotion = Emotions.NEUTRAL) {
if (!this.states.has(newStateName)) {
throw new Error(`State '${newStateName}' not found`);
}
const oldState = this.currentState;
const newStateHandler = this.states.get(newStateName);
console.log(`Transitioning from ${oldState} to ${newStateName}`);
// Exit current state
if (this.currentStateHandler) {
await this.currentStateHandler.exit(newStateName, emotion);
}
// Enter new state
this.currentState = newStateName;
this.currentStateHandler = newStateHandler;
await this.currentStateHandler.enter(oldState, emotion);
// Reset inactivity timer
this.resetActivityTimer();
}
/**
* Handle a user message
* @param {string} message - The user message
* @returns {Promise<void>}
*/
async handleUserMessage(message) {
console.log(`Handling user message: "${message}"`);
this.onUserActivity();
// If sleeping, wake up first
if (this.currentState === States.SLEEP) {
await this.transitionTo(States.REACT);
}
// Let current state handle the message
if (this.currentStateHandler) {
await this.currentStateHandler.handleMessage(message);
}
// Transition to appropriate next state based on current state
if (this.currentState === States.WAIT) {
await this.transitionTo(States.REACT);
} else if (this.currentState === States.REACT) {
await this.transitionTo(States.TYPE);
}
}
/**
* Called when user activity is detected
* @returns {void}
*/
onUserActivity() {
this.resetActivityTimer();
// Wake up if sleeping
if (this.currentState === States.SLEEP) {
this.transitionTo(States.WAIT);
}
}
/**
* Reset the inactivity timer
* @private
* @returns {void}
*/
resetActivityTimer() {
this.inactivityTimer = 0;
}
/**
* Handle inactivity timeout
* @private
* @returns {Promise<void>}
*/
async handleInactivity() {
console.log('Inactivity detected, transitioning to sleep');
await this.transitionTo(States.SLEEP);
}
/**
* Update the animation system (call every frame)
* @param {number} deltaTime - Time elapsed since last update (ms)
* @returns {void}
*/
update(deltaTime) {
if (!this.initialized) return;
// Update Three.js mixer
this.mixer.update(deltaTime / 1000); // Convert to seconds
// Update current state
if (this.currentStateHandler) {
this.currentStateHandler.update(deltaTime);
}
// Update inactivity timer
this.inactivityTimer += deltaTime;
if (this.inactivityTimer > this.inactivityTimeout && this.currentState !== States.SLEEP) {
this.handleInactivity();
}
}
/**
* Get an animation clip by name
* @param {string} name - The animation clip name
* @returns {AnimationClip|undefined} The animation clip or undefined if not found
*/
getClip(name) {
return this.clips.get(name);
}
/**
* Get animation clips matching a pattern
* @param {string} pattern - Pattern to match (supports * wildcards)
* @returns {AnimationClip[]} Array of matching clips
*/
getClipsByPattern(pattern) {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
const matches = [];
for (const [ name, clip ] of this.clips) {
if (regex.test(name)) {
matches.push(clip);
}
}
return matches;
}
/**
* Get the current state name
* @returns {string} The current state name
*/
getCurrentState() {
return this.currentState;
}
/**
* Get the current state handler
* @returns {StateHandler|null} The current state handler
*/
getCurrentStateHandler() {
return this.currentStateHandler;
}
/**
* Get available transitions from current state
* @returns {string[]} Array of available state transitions
*/
getAvailableTransitions() {
if (this.currentStateHandler) {
return this.currentStateHandler.getAvailableTransitions();
}
return [];
}
/**
* Get all available animation clip names
* @returns {string[]} Array of clip names
*/
getAvailableClips() {
return Array.from(this.clips.keys());
}
/**
* Get all available state names
* @returns {string[]} Array of state names
*/
getAvailableStates() {
return Array.from(this.states.keys());
}
/**
* Dispose of the animation system and clean up resources
* @returns {void}
*/
dispose() {
// Stop all animations
for (const [ , clip ] of this.clips) {
if (clip.action) {
clip.action.stop();
}
}
// Clear caches
this.clips.clear();
this.states.clear();
this.animationClipFactory.clearCache();
this.initialized = false;
console.log('Owen Animation System disposed');
}
}

View File

@ -0,0 +1,91 @@
/**
* @fileoverview Main system factory for creating the complete Owen system
* @module factories
*/
import * as THREE from 'three';
import { OwenAnimationContext } from '../core/OwenAnimationContext.js';
import { AnimationClipFactory } from '../animation/AnimationClip.js';
import { GLTFAnimationLoader } from '../loaders/AnimationLoader.js';
import { StateFactory } from '../states/StateFactory.js';
/**
* Main factory for creating the complete Owen animation system
* @class
*/
export class OwenSystemFactory {
/**
* Create a complete Owen animation system
* @param {THREE.Object3D} gltfModel - The loaded GLTF model
* @param {THREE.Scene} scene - The Three.js scene
* @param {Object} [options={}] - Configuration options
* @param {THREE.GLTFLoader} [options.gltfLoader] - Custom GLTF loader
* @returns {Promise<OwenAnimationContext>} The configured Owen system
*/
static async createOwenSystem(gltfModel, scene, options = {}) {
// Create Three.js animation mixer
const mixer = new THREE.AnimationMixer(gltfModel);
// Create GLTF loader if not provided
const gltfLoader = options.gltfLoader || new THREE.GLTFLoader();
// Create animation loader
const animationLoader = new GLTFAnimationLoader(gltfLoader);
// Preload animations from the model
await animationLoader.preloadAnimations(gltfModel);
// Create animation clip factory
const animationClipFactory = new AnimationClipFactory(animationLoader);
// Create state factory
const stateFactory = new StateFactory();
// Create the main Owen context
const owenContext = new OwenAnimationContext(
gltfModel,
mixer,
animationClipFactory,
stateFactory
);
// Initialize the system
await owenContext.initialize();
return owenContext;
}
/**
* Create a basic Owen system with minimal configuration
* @param {THREE.Object3D} model - The 3D model
* @returns {Promise<OwenAnimationContext>} The configured Owen system
*/
static async createBasicOwenSystem(model) {
const scene = new THREE.Scene();
scene.add(model);
return await OwenSystemFactory.createOwenSystem(model, scene);
}
/**
* Create an Owen system with custom state handlers
* @param {THREE.Object3D} gltfModel - The loaded GLTF model
* @param {THREE.Scene} scene - The Three.js scene
* @param {Map<string, Function>} customStates - Map of state name to handler class
* @returns {Promise<OwenAnimationContext>} The configured Owen system
*/
static async createCustomOwenSystem(gltfModel, scene, customStates) {
const system = await OwenSystemFactory.createOwenSystem(gltfModel, scene);
// Register custom state handlers
const stateFactory = system.stateFactory;
for (const [ stateName, handlerClass ] of customStates) {
stateFactory.registerStateHandler(stateName, handlerClass);
}
// Reinitialize with custom states
system.initializeStates();
return system;
}
}

222
src/index.d.ts vendored Normal file
View File

@ -0,0 +1,222 @@
// Type definitions for Owen Animation System
// Project: Owen Animation System
// Definitions by: Owen Animation System
export as namespace Owen;
// Constants
export const ClipTypes: {
readonly LOOP: 'L';
readonly QUIRK: 'Q';
readonly NESTED_LOOP: 'NL';
readonly NESTED_QUIRK: 'NQ';
readonly NESTED_IN: 'IN_NT';
readonly NESTED_OUT: 'OUT_NT';
readonly TRANSITION: 'T';
};
export const States: {
readonly WAIT: 'wait';
readonly REACT: 'react';
readonly TYPE: 'type';
readonly SLEEP: 'sleep';
};
export const Emotions: {
readonly NEUTRAL: '';
readonly ANGRY: 'an';
readonly SHOCKED: 'sh';
readonly HAPPY: 'ha';
readonly SAD: 'sa';
};
export const Config: {
DEFAULT_FADE_IN: number;
DEFAULT_FADE_OUT: number;
QUIRK_INTERVAL: number;
INACTIVITY_TIMEOUT: number;
QUIRK_PROBABILITY: number;
};
// Interfaces
export interface AnimationMetadata {
state: string;
action: string;
type: string;
toState?: string;
emotion?: string;
isTransition?: boolean;
hasEmotion?: boolean;
isNested?: boolean;
isStandard?: boolean;
subAction?: string;
nestedType?: string;
}
// Classes
export class AnimationClip {
constructor(name: string, threeAnimation: any, metadata: AnimationMetadata);
readonly name: string;
readonly animation: any;
readonly metadata: AnimationMetadata;
action: any | null;
mixer: any | null;
createAction(mixer: any): any;
play(fadeInDuration?: number): Promise<void>;
stop(fadeOutDuration?: number): Promise<void>;
isPlaying(): boolean;
}
export class AnimationClipFactory {
constructor(animationLoader: AnimationLoader);
parseAnimationName(name: string): AnimationMetadata;
createClip(name: string, model: any): Promise<AnimationClip>;
createClipsFromModel(model: any): Promise<Map<string, AnimationClip>>;
clearCache(): void;
getCachedClip(name: string): AnimationClip | undefined;
}
export abstract class AnimationLoader {
abstract loadAnimation(name: string): Promise<any>;
}
export class GLTFAnimationLoader extends AnimationLoader {
constructor(gltfLoader: any);
loadAnimation(name: string): Promise<any>;
preloadAnimations(gltfModel: any): Promise<void>;
clearCache(): void;
getCachedAnimationNames(): string[];
}
export abstract class StateHandler {
constructor(stateName: string, context: OwenAnimationContext);
readonly stateName: string;
readonly context: OwenAnimationContext;
currentClip: AnimationClip | null;
nestedState: any | null;
abstract enter(fromState?: string | null, emotion?: string): Promise<void>;
abstract exit(toState?: string | null, emotion?: string): Promise<void>;
update(deltaTime: number): void;
handleMessage(message: string): Promise<void>;
getAvailableTransitions(): string[];
protected waitForClipEnd(clip: AnimationClip): Promise<void>;
protected stopCurrentClip(fadeOutDuration?: number): Promise<void>;
}
export class WaitStateHandler extends StateHandler {
constructor(context: OwenAnimationContext);
enter(fromState?: string | null, emotion?: string): Promise<void>;
exit(toState?: string | null, emotion?: string): Promise<void>;
update(deltaTime: number): void;
getAvailableTransitions(): string[];
}
export class ReactStateHandler extends StateHandler {
constructor(context: OwenAnimationContext);
enter(fromState?: string | null, emotion?: string): Promise<void>;
exit(toState?: string | null, emotion?: string): Promise<void>;
handleMessage(message: string): Promise<void>;
getAvailableTransitions(): string[];
}
export class TypeStateHandler extends StateHandler {
constructor(context: OwenAnimationContext);
enter(fromState?: string | null, emotion?: string): Promise<void>;
exit(toState?: string | null, emotion?: string): Promise<void>;
finishTyping(): Promise<void>;
getAvailableTransitions(): string[];
getIsTyping(): boolean;
setTyping(typing: boolean): void;
}
export class SleepStateHandler extends StateHandler {
constructor(context: OwenAnimationContext);
enter(fromState?: string | null, emotion?: string): Promise<void>;
exit(toState?: string | null, emotion?: string): Promise<void>;
update(deltaTime: number): void;
handleMessage(message: string): Promise<void>;
getAvailableTransitions(): string[];
isInDeepSleep(): boolean;
wakeUp(): Promise<void>;
}
export class StateFactory {
constructor();
registerStateHandler(stateName: string, handlerClass: new (context: OwenAnimationContext) => StateHandler): void;
createStateHandler(stateName: string, context: OwenAnimationContext): StateHandler;
getAvailableStates(): string[];
isStateRegistered(stateName: string): boolean;
unregisterStateHandler(stateName: string): boolean;
}
export class OwenAnimationContext {
constructor(
model: any,
mixer: any,
animationClipFactory: AnimationClipFactory,
stateFactory: StateFactory
);
readonly model: any;
readonly mixer: any;
readonly animationClipFactory: AnimationClipFactory;
readonly stateFactory: StateFactory;
readonly clips: Map<string, AnimationClip>;
readonly states: Map<string, StateHandler>;
currentState: string;
currentStateHandler: StateHandler | null;
initialized: boolean;
initialize(): Promise<void>;
transitionTo(newStateName: string, emotion?: string): Promise<void>;
handleUserMessage(message: string): Promise<void>;
onUserActivity(): void;
update(deltaTime: number): void;
getClip(name: string): AnimationClip | undefined;
getClipsByPattern(pattern: string): AnimationClip[];
getCurrentState(): string;
getCurrentStateHandler(): StateHandler | null;
getAvailableTransitions(): string[];
getAvailableClips(): string[];
getAvailableStates(): string[];
dispose(): void;
}
export class OwenSystemFactory {
static createOwenSystem(
gltfModel: any,
scene: any,
options?: { gltfLoader?: any; }
): Promise<OwenAnimationContext>;
static createBasicOwenSystem(model: any): Promise<OwenAnimationContext>;
static createCustomOwenSystem(
gltfModel: any,
scene: any,
customStates: Map<string, new (context: OwenAnimationContext) => StateHandler>
): Promise<OwenAnimationContext>;
}
// Default export
declare const Owen: {
OwenSystemFactory: typeof OwenSystemFactory;
OwenAnimationContext: typeof OwenAnimationContext;
States: typeof States;
Emotions: typeof Emotions;
ClipTypes: typeof ClipTypes;
Config: typeof Config;
};
export default Owen;

44
src/index.js Normal file
View File

@ -0,0 +1,44 @@
/**
* @fileoverview Main entry point for the Owen Animation System
* @module owen
*/
// Core exports
export { OwenAnimationContext } from './core/OwenAnimationContext.js';
// Animation system exports
export { AnimationClip, AnimationClipFactory } from './animation/AnimationClip.js';
// Loader exports
export { AnimationLoader, GLTFAnimationLoader } from './loaders/AnimationLoader.js';
// State system exports
export { StateHandler } from './states/StateHandler.js';
export { WaitStateHandler } from './states/WaitStateHandler.js';
export { ReactStateHandler } from './states/ReactStateHandler.js';
export { TypeStateHandler } from './states/TypeStateHandler.js';
export { SleepStateHandler } from './states/SleepStateHandler.js';
export { StateFactory } from './states/StateFactory.js';
// Factory exports
export { OwenSystemFactory } from './factories/OwenSystemFactory.js';
// Constants exports
export { ClipTypes, States, Emotions, Config } from './constants.js';
// Import for default export
import { OwenSystemFactory } from './factories/OwenSystemFactory.js';
import { OwenAnimationContext } from './core/OwenAnimationContext.js';
import { States, Emotions, ClipTypes, Config } from './constants.js';
/**
* Default export - the main factory for easy usage
*/
export default {
OwenSystemFactory,
OwenAnimationContext,
States,
Emotions,
ClipTypes,
Config
};

View File

@ -0,0 +1,94 @@
/**
* @fileoverview Animation loader interfaces and implementations
* @module loaders
*/
/**
* Abstract base class for animation loaders
* @abstract
* @class
*/
export class AnimationLoader {
/**
* Load an animation by name
* @abstract
* @param {string} _name - The animation name to load (unused in base class)
* @returns {Promise<THREE.AnimationClip>} The loaded animation clip
* @throws {Error} Must be implemented by subclasses
*/
async loadAnimation(_name) {
throw new Error('loadAnimation method must be implemented by subclasses');
}
}
/**
* GLTF animation loader implementation
* @class
* @extends AnimationLoader
*/
export class GLTFAnimationLoader extends AnimationLoader {
/**
* Create a GLTF animation loader
* @param {THREE.GLTFLoader} gltfLoader - The Three.js GLTF loader instance
*/
constructor(gltfLoader) {
super();
/**
* The Three.js GLTF loader
* @type {THREE.GLTFLoader}
*/
this.gltfLoader = gltfLoader;
/**
* Cache for loaded animations
* @type {Map<string, THREE.AnimationClip>}
*/
this.animationCache = new Map();
}
/**
* Load an animation from GLTF by name
* @param {string} name - The animation name to load
* @returns {Promise<THREE.AnimationClip>} The loaded animation clip
* @throws {Error} If animation is not found
*/
async loadAnimation(name) {
if (this.animationCache.has(name)) {
return this.animationCache.get(name);
}
// In a real implementation, this would load from GLTF files
// For now, we'll assume animations are already loaded in the model
throw new Error(`Animation '${name}' not found. Implement GLTF loading logic.`);
}
/**
* Preload animations from a GLTF model
* @param {Object} gltfModel - The loaded GLTF model
* @returns {Promise<void>}
*/
async preloadAnimations(gltfModel) {
if (gltfModel.animations) {
for (const animation of gltfModel.animations) {
this.animationCache.set(animation.name, animation);
}
}
}
/**
* Clear the animation cache
* @returns {void}
*/
clearCache() {
this.animationCache.clear();
}
/**
* Get all cached animation names
* @returns {string[]} Array of cached animation names
*/
getCachedAnimationNames() {
return Array.from(this.animationCache.keys());
}
}

View File

@ -0,0 +1,159 @@
/**
* @fileoverview React state handler implementation
* @module states
*/
import { StateHandler } from './StateHandler.js';
import { States, Emotions } from '../constants.js';
/**
* Handler for the React state
* @class
* @extends StateHandler
*/
export class ReactStateHandler extends StateHandler {
/**
* Create a react state handler
* @param {OwenAnimationContext} context - The animation context
*/
constructor(context) {
super(States.REACT, context);
/**
* Current emotional state
* @type {string}
*/
this.emotion = Emotions.NEUTRAL;
}
/**
* Enter the react state
* @param {string|null} [_fromState=null] - The previous state (unused)
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to enter with
* @returns {Promise<void>}
*/
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();
this.currentClip = reactionClip;
}
}
/**
* Exit the react state
* @param {string|null} [toState=null] - The next state
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to exit with
* @returns {Promise<void>}
*/
async exit(toState = null, emotion = Emotions.NEUTRAL) {
console.log(`Exiting REACT state to ${toState} with emotion: ${emotion}`);
if (this.currentClip) {
await this.stopCurrentClip();
}
// 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);
}
}
/**
* Handle a user message in react state
* @param {string} message - The user message
* @returns {Promise<void>}
*/
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 emotionalReaction = this.context.getClip(`react_${emotion}_Q`);
if (emotionalReaction) {
if (this.currentClip) {
await this.stopCurrentClip(0.2);
}
await emotionalReaction.play();
await this.waitForClipEnd(emotionalReaction);
}
}
}
/**
* Analyze message to determine emotional response
* @private
* @param {string} message - The message to analyze
* @returns {string} The determined emotion
*/
analyzeMessageEmotion(message) {
const text = message.toLowerCase();
// Check for urgent/angry indicators
if (
text.includes('!') ||
text.includes('urgent') ||
text.includes('asap') ||
text.includes('hurry')
) {
return Emotions.ANGRY;
}
// Check for error/shocked indicators
if (
text.includes('error') ||
text.includes('problem') ||
text.includes('issue') ||
text.includes('bug') ||
text.includes('broken')
) {
return Emotions.SHOCKED;
}
// Check for positive/happy indicators
if (
text.includes('great') ||
text.includes('awesome') ||
text.includes('good') ||
text.includes('excellent') ||
text.includes('perfect')
) {
return Emotions.HAPPY;
}
// Check for sad indicators
if (
text.includes('sad') ||
text.includes('disappointed') ||
text.includes('failed') ||
text.includes('wrong')
) {
return Emotions.SAD;
}
return Emotions.NEUTRAL;
}
/**
* Get available transitions from react state
* @returns {string[]} Array of available state transitions
*/
getAvailableTransitions() {
return [ States.TYPE, States.WAIT ];
}
}

View File

@ -0,0 +1,140 @@
/**
* @fileoverview Sleep state handler implementation
* @module states
*/
import { StateHandler } from './StateHandler.js';
import { States, Emotions } from '../constants.js';
/**
* Handler for the Sleep state
* @class
* @extends StateHandler
*/
export class SleepStateHandler extends StateHandler {
/**
* Create a sleep state handler
* @param {OwenAnimationContext} context - The animation context
*/
constructor(context) {
super(States.SLEEP, context);
/**
* Sleep animation clip
* @type {AnimationClip|null}
*/
this.sleepClip = null;
/**
* Whether the character is in deep sleep
* @type {boolean}
*/
this.isDeepSleep = false;
}
/**
* Enter the sleep state
* @param {string|null} [fromState=null] - The previous state
* @param {string} [_emotion=Emotions.NEUTRAL] - The emotion to enter with (unused)
* @returns {Promise<void>}
*/
async enter(fromState = null, _emotion = Emotions.NEUTRAL) {
console.log(`Entering SLEEP state from ${fromState}`);
// Play sleep transition if available
const sleepTransition = this.context.getClip('wait_2sleep_T');
if (sleepTransition) {
await sleepTransition.play();
await this.waitForClipEnd(sleepTransition);
}
// Start sleep loop
this.sleepClip = this.context.getClip('sleep_idle_L');
if (this.sleepClip) {
await this.sleepClip.play();
this.currentClip = this.sleepClip;
}
this.isDeepSleep = true;
}
/**
* Exit the sleep state
* @param {string|null} [toState=null] - The next state
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to exit with
* @returns {Promise<void>}
*/
async exit(toState = null, _emotion = Emotions.NEUTRAL) {
console.log(`Exiting SLEEP state to ${toState}`);
this.isDeepSleep = false;
if (this.currentClip) {
await this.stopCurrentClip();
}
// Play wake up animation
const wakeUpClip = this.context.getClip('sleep_wakeup_T');
if (wakeUpClip) {
await wakeUpClip.play();
await this.waitForClipEnd(wakeUpClip);
}
// Play transition to next state if available
const transitionName = `sleep_2${toState}_T`;
const transition = this.context.getClip(transitionName);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
/**
* Update the sleep state
* @param {number} _deltaTime - Time elapsed since last update (ms, unused)
* @returns {void}
*/
update(_deltaTime) {
// Sleep state doesn't need regular updates
// Character remains asleep until external stimulus
}
/**
* Handle a user message in sleep state (wake up)
* @param {string} _message - The user message (unused, just triggers wake up)
* @returns {Promise<void>}
*/
async handleMessage(_message) {
// Any message should wake up the character
if (this.isDeepSleep) {
console.log('Waking up due to user message');
// This will trigger a state transition to REACT
await this.context.transitionTo(States.REACT);
}
}
/**
* Get available transitions from sleep state
* @returns {string[]} Array of available state transitions
*/
getAvailableTransitions() {
return [ States.WAIT, States.REACT ];
}
/**
* Check if in deep sleep
* @returns {boolean} True if in deep sleep, false otherwise
*/
isInDeepSleep() {
return this.isDeepSleep;
}
/**
* Force wake up from sleep
* @returns {Promise<void>}
*/
async wakeUp() {
if (this.isDeepSleep) {
await this.context.transitionTo(States.WAIT);
}
}
}

View File

@ -0,0 +1,86 @@
/**
* @fileoverview State factory for creating state handlers
* @module states
*/
import { WaitStateHandler } from './WaitStateHandler.js';
import { ReactStateHandler } from './ReactStateHandler.js';
import { TypeStateHandler } from './TypeStateHandler.js';
import { SleepStateHandler } from './SleepStateHandler.js';
import { States } from '../constants.js';
/**
* Factory for creating state handlers using dependency injection
* @class
*/
export class StateFactory {
/**
* Create a state factory
*/
constructor() {
/**
* Registry of state handler classes
* @type {Map<string, Function>}
* @private
*/
this.stateHandlers = new Map();
// Register default state handlers
this.registerStateHandler(States.WAIT, WaitStateHandler);
this.registerStateHandler(States.REACT, ReactStateHandler);
this.registerStateHandler(States.TYPE, TypeStateHandler);
this.registerStateHandler(States.SLEEP, SleepStateHandler);
}
/**
* Register a state handler class
* @param {string} stateName - The name of the state
* @param {Function} handlerClass - The handler class constructor
* @returns {void}
*/
registerStateHandler(stateName, handlerClass) {
this.stateHandlers.set(stateName, handlerClass);
}
/**
* Create a state handler instance
* @param {string} stateName - The name of the state
* @param {OwenAnimationContext} context - The animation context
* @returns {StateHandler} The created state handler
* @throws {Error} If state handler is not registered
*/
createStateHandler(stateName, context) {
const HandlerClass = this.stateHandlers.get(stateName);
if (!HandlerClass) {
throw new Error(`No handler registered for state: ${stateName}`);
}
return new HandlerClass(context);
}
/**
* Get all available state names
* @returns {string[]} Array of registered state names
*/
getAvailableStates() {
return Array.from(this.stateHandlers.keys());
}
/**
* Check if a state is registered
* @param {string} stateName - The state name to check
* @returns {boolean} True if registered, false otherwise
*/
isStateRegistered(stateName) {
return this.stateHandlers.has(stateName);
}
/**
* Unregister a state handler
* @param {string} stateName - The state name to unregister
* @returns {boolean} True if removed, false if not found
*/
unregisterStateHandler(stateName) {
return this.stateHandlers.delete(stateName);
}
}

126
src/states/StateHandler.js Normal file
View File

@ -0,0 +1,126 @@
/**
* @fileoverview Base state handler class and utilities
* @module StateHandler
*/
import { Emotions, Config } from '../constants.js';
/**
* Abstract base class for state handlers
* @abstract
* @class
*/
export class StateHandler {
/**
* Create a state handler
* @param {string} stateName - The name of the state
* @param {OwenAnimationContext} context - The animation context
*/
constructor(stateName, context) {
/**
* The name of this state
* @type {string}
*/
this.stateName = stateName;
/**
* The animation context
* @type {OwenAnimationContext}
*/
this.context = context;
/**
* Currently playing animation clip
* @type {AnimationClip|null}
*/
this.currentClip = null;
/**
* Nested state information
* @type {Object|null}
*/
this.nestedState = null;
}
/**
* Enter this state
* @abstract
* @param {string|null} [_fromState=null] - The previous state (unused in base class)
* @param {string} [_emotion=Emotions.NEUTRAL] - The emotion to enter with (unused in base class)
* @returns {Promise<void>}
* @throws {Error} Must be implemented by subclasses
*/
async enter(_fromState = null, _emotion = Emotions.NEUTRAL) {
throw new Error('enter method must be implemented by subclasses');
}
/**
* Exit this state
* @abstract
* @param {string|null} [_toState=null] - The next state (unused in base class)
* @param {string} [_emotion=Emotions.NEUTRAL] - The emotion to exit with (unused in base class)
* @returns {Promise<void>}
* @throws {Error} Must be implemented by subclasses
*/
async exit(_toState = null, _emotion = Emotions.NEUTRAL) {
throw new Error('exit method must be implemented by subclasses');
}
/**
* Update this state (called every frame)
* @param {number} _deltaTime - Time elapsed since last update (ms, unused in base class)
* @returns {void}
*/
update(_deltaTime) {
// Override in subclasses if needed
}
/**
* Handle a user message while in this state
* @param {string} _message - The user message (unused in base class)
* @returns {Promise<void>}
*/
async handleMessage(_message) {
// Override in subclasses if needed
}
/**
* Get available transitions from this state
* @returns {string[]} Array of state names that can be transitioned to
*/
getAvailableTransitions() {
return [];
}
/**
* Wait for an animation clip to finish playing
* @protected
* @param {AnimationClip} clip - The animation clip to wait for
* @returns {Promise<void>} Promise that resolves when the clip finishes
*/
async waitForClipEnd(clip) {
return new Promise((resolve) => {
const checkFinished = () => {
if (!clip.isPlaying()) {
resolve();
} else {
requestAnimationFrame(checkFinished);
}
};
checkFinished();
});
}
/**
* Stop the currently playing clip
* @protected
* @param {number} [fadeOutDuration] - Fade out duration
* @returns {Promise<void>}
*/
async stopCurrentClip(fadeOutDuration = Config.DEFAULT_FADE_OUT) {
if (this.currentClip) {
await this.currentClip.stop(fadeOutDuration);
this.currentClip = null;
}
}
}

View File

@ -0,0 +1,128 @@
/**
* @fileoverview Type state handler implementation
* @module states
*/
import { StateHandler } from './StateHandler.js';
import { States, Emotions } from '../constants.js';
/**
* Handler for the Type state
* @class
* @extends StateHandler
*/
export class TypeStateHandler extends StateHandler {
/**
* Create a type state handler
* @param {OwenAnimationContext} context - The animation context
*/
constructor(context) {
super(States.TYPE, context);
/**
* Current emotional state
* @type {string}
*/
this.emotion = Emotions.NEUTRAL;
/**
* Whether currently typing
* @type {boolean}
*/
this.isTyping = false;
}
/**
* Enter the type state
* @param {string|null} [_fromState=null] - The previous state (unused)
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to enter with
* @returns {Promise<void>}
*/
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) {
await typingClip.play();
this.currentClip = typingClip;
}
}
/**
* Exit the type state
* @param {string|null} [toState=null] - The next state
* @param {string} [_emotion=Emotions.NEUTRAL] - The emotion to exit with (unused)
* @returns {Promise<void>}
*/
async exit(toState = null, _emotion = Emotions.NEUTRAL) {
console.log(`Exiting TYPE state to ${toState}`);
this.isTyping = false;
if (this.currentClip) {
await this.stopCurrentClip();
}
// Play transition if available
let transitionName = `type_2${toState}_T`;
if (this.emotion !== Emotions.NEUTRAL) {
transitionName = `type_${this.emotion}2${toState}_T`;
}
const transition = this.context.getClip(transitionName);
if (transition) {
await transition.play();
await this.waitForClipEnd(transition);
}
}
/**
* Finish typing and prepare to transition
* @returns {Promise<void>}
*/
async finishTyping() {
if (!this.isTyping) return;
// Play typing finish animation if available
const finishClip = this.context.getClip('type_finish_Q');
if (finishClip && this.currentClip) {
await this.stopCurrentClip(0.2);
await finishClip.play();
await this.waitForClipEnd(finishClip);
}
this.isTyping = false;
}
/**
* Get available transitions from type state
* @returns {string[]} Array of available state transitions
*/
getAvailableTransitions() {
return [ States.WAIT, States.REACT ];
}
/**
* Check if currently typing
* @returns {boolean} True if typing, false otherwise
*/
getIsTyping() {
return this.isTyping;
}
/**
* Set typing state
* @param {boolean} typing - Whether currently typing
* @returns {void}
*/
setTyping(typing) {
this.isTyping = typing;
}
}

View File

@ -0,0 +1,138 @@
/**
* @fileoverview Wait state handler implementation
* @module states
*/
import { StateHandler } from './StateHandler.js';
import { States, Emotions, Config } from '../constants.js';
/**
* Handler for the Wait/Idle state
* @class
* @extends StateHandler
*/
export class WaitStateHandler extends StateHandler {
/**
* Create a wait state handler
* @param {OwenAnimationContext} context - The animation context
*/
constructor(context) {
super(States.WAIT, context);
/**
* The main idle animation clip
* @type {AnimationClip|null}
*/
this.idleClip = null;
/**
* Available quirk animations
* @type {AnimationClip[]}
*/
this.quirks = [];
/**
* Timer for quirk animations
* @type {number}
*/
this.quirkTimer = 0;
/**
* Interval between quirk attempts (ms)
* @type {number}
*/
this.quirkInterval = Config.QUIRK_INTERVAL;
}
/**
* Enter the wait state
* @param {string|null} [fromState=null] - The previous state
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to enter with
* @returns {Promise<void>}
*/
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();
this.currentClip = this.idleClip;
}
// Collect available quirks
this.quirks = this.context.getClipsByPattern('wait_*_Q');
this.quirkTimer = 0;
}
/**
* Exit the wait state
* @param {string|null} [toState=null] - The next state
* @param {string} [emotion=Emotions.NEUTRAL] - The emotion to exit with
* @returns {Promise<void>}
*/
async exit(toState = null, _emotion = Emotions.NEUTRAL) {
console.log(`Exiting WAIT state to ${toState}`);
if (this.currentClip) {
await this.stopCurrentClip();
}
// 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 the wait state
* @param {number} deltaTime - Time elapsed since last update (ms)
* @returns {void}
*/
update(deltaTime) {
this.quirkTimer += deltaTime;
// Randomly play quirks
if (this.quirkTimer > this.quirkInterval && Math.random() < Config.QUIRK_PROBABILITY) {
this.playRandomQuirk();
this.quirkTimer = 0;
}
}
/**
* Play a random quirk animation
* @private
* @returns {Promise<void>}
*/
async playRandomQuirk() {
if (this.quirks.length === 0) return;
const quirk = this.quirks[ Math.floor(Math.random() * this.quirks.length) ];
// Fade out idle
if (this.idleClip) {
await this.idleClip.stop(0.2);
}
// Play quirk
await quirk.play();
await this.waitForClipEnd(quirk);
// Return to idle
if (this.idleClip) {
await this.idleClip.play();
this.currentClip = this.idleClip;
}
}
/**
* Get available transitions from wait state
* @returns {string[]} Array of available state transitions
*/
getAvailableTransitions() {
return [ States.REACT, States.SLEEP ];
}
}