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:
257
src/animation/AnimationClip.js
Normal file
257
src/animation/AnimationClip.js
Normal 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
78
src/constants.js
Normal 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,
|
||||
};
|
||||
332
src/core/OwenAnimationContext.js
Normal file
332
src/core/OwenAnimationContext.js
Normal 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');
|
||||
}
|
||||
}
|
||||
91
src/factories/OwenSystemFactory.js
Normal file
91
src/factories/OwenSystemFactory.js
Normal 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
222
src/index.d.ts
vendored
Normal 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
44
src/index.js
Normal 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
|
||||
};
|
||||
94
src/loaders/AnimationLoader.js
Normal file
94
src/loaders/AnimationLoader.js
Normal 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());
|
||||
}
|
||||
}
|
||||
159
src/states/ReactStateHandler.js
Normal file
159
src/states/ReactStateHandler.js
Normal 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 ];
|
||||
}
|
||||
}
|
||||
140
src/states/SleepStateHandler.js
Normal file
140
src/states/SleepStateHandler.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/states/StateFactory.js
Normal file
86
src/states/StateFactory.js
Normal 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
126
src/states/StateHandler.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/states/TypeStateHandler.js
Normal file
128
src/states/TypeStateHandler.js
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/states/WaitStateHandler.js
Normal file
138
src/states/WaitStateHandler.js
Normal 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 ];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user