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