Skip to content

Instantly share code, notes, and snippets.

@lukevanin
Created March 16, 2025 16:21
Show Gist options
  • Save lukevanin/3d4c852d8b868daf93805b28e030c22e to your computer and use it in GitHub Desktop.
Save lukevanin/3d4c852d8b868daf93805b28e030c22e to your computer and use it in GitHub Desktop.
Vibe coded actor
import * as THREE from 'three';
import {
AnimationContext,
AnimationState,
AnimationStates
} from './animationStateMachine';
import { AnimationAction, AnimationClip, AnimationMixer, Clock, Object3D, Scene } from 'three';
import { loadActorModel, removeRootMotion } from './engine';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { AudioSystem } from './audioSystem';
// Animation map interface
interface AnimationActionsMap {
[key: string]: AnimationAction;
}
/**
* Actor class that encapsulates an animated character model
* Handles model loading, animations, position, and direction
*/
export class Actor {
// Model and animation properties
private modelPath: string;
private model: THREE.Object3D | null = null;
private mixer: THREE.AnimationMixer | null = null;
private animationActions: AnimationActionsMap = {};
private currentAction: AnimationAction | null = null;
private clock: THREE.Clock;
private scene: THREE.Scene;
// Store animation filenames
private animationFilenames: Record<string, string> = {};
// Animation state machine
private animationContext: AnimationContext | null = null;
// Movement properties
private position: THREE.Vector3 = new THREE.Vector3(0, 0, 0);
private direction: THREE.Vector3 = new THREE.Vector3(0, 0, 1);
private turnSpeed: number = 2.0;
// Model scale factor
private scale: number = 1.0;
// Animation speed multiplier
private readonly ANIMATION_SPEED_MULTIPLIER = 1.2;
private audioSystem: AudioSystem | null = null;
private previousAnimationState: AnimationState | null = null;
private currentSoundEffect: string | null = null; // Track the current sound effect being played
/**
* Constructor for the Actor class
* @param modelPath Path to the directory containing model and animations
* @param scene The THREE.Scene to add the model to
* @param clock The animation clock
* @param animationStates Map of animation states for this actor
* @param animationFilenames Dictionary of animation names to file names to load
* @param initialPosition Initial position vector (optional)
* @param initialDirection Initial direction vector (optional)
* @param turnSpeed Initial turn speed (optional)
* @param audioSystem Audio system for playing sounds (optional)
* @param scale Model scale factor (optional, default 1.0)
*/
constructor(
modelPath: string,
scene: THREE.Scene,
clock: THREE.Clock,
animationStates: AnimationStates,
animationFilenames: Record<string, string>,
initialPosition?: THREE.Vector3,
initialDirection?: THREE.Vector3,
turnSpeed?: number,
audioSystem?: AudioSystem,
scale: number = 1.0
) {
this.modelPath = modelPath;
this.scene = scene;
this.clock = clock;
this.audioSystem = audioSystem || null;
this.animationFilenames = animationFilenames; // Store animation filenames
this.scale = scale; // Store the scale factor
// Set initial values if provided
if (initialPosition) this.position = initialPosition;
if (initialDirection) this.direction = initialDirection;
if (turnSpeed) this.turnSpeed = turnSpeed;
// Initialize animation state machine with provided animation states
try {
// Create animation context with the provided states
this.animationContext = new AnimationContext(animationStates);
// Set idle as the default state if it exists
if (this.animationContext && animationStates['idle']) {
this.animationContext.setAnimationByName('idle');
}
} catch (error) {
// Error initializing animation context
}
}
/**
* Load the model and animations
* @returns Promise that resolves when loading is complete
*/
async load(modelFilePath?: string): Promise<THREE.Object3D> {
// Use provided path or the default model path
const path = modelFilePath || this.modelPath;
try {
// Use the engine's loadActorModel function
const result = await loadActorModel(
path,
this.scene,
this.position,
this.scale
);
// Save both the model and mixer returned from the engine
this.model = result.model;
this.mixer = result.mixer;
// Get animation names from animation context
// const animationNames: string[] = [];
// if (this.animationContext) {
// const states = this.animationContext.getStates();
// for (const key in states) {
// const animName = states[key].getAnimationName();
// if (animName && !animationNames.includes(animName)) {
// animationNames.push(animName);
// }
// }
// }
// Combine animation names from context with explicitly provided filenames
// const allAnimationNames = [...animationNames];
// Make sure we have animations to load
// if (allAnimationNames.length === 0) {
// No animation names found in context or provided filenames
// In this case, load animations directly from the provided dictionary
// if (Object.keys(this.animationFilenames).length > 0) {
await this.loadAnimationsFromDictionary();
// Update orientation to match initial direction
this.updateOrientation();
// Set the default animation
if (this.animationContext && this.animationContext.getAnimationState()) {
const defaultAnimName = this.animationContext.getAnimationState()?.getAnimationName() || 'idle';
this.fadeToAction(defaultAnimName, 0.0);
} else if (Object.keys(this.animationActions).length > 0) {
// If no animation context or no state, but we have loaded animations, use the first one as default
const firstAnimName = Object.keys(this.animationActions)[0];
this.fadeToAction(firstAnimName, 0.0);
}
return this.model;
} catch (error) {
throw error;
}
}
/**
* Load all animations for the actor
* @param animationNames Array of animation names to load
*/
// private async loadAnimations(animationNames: string[]): Promise<void> {
// if (!this.mixer) {
// throw new Error("Cannot load animations: mixer is null");
// }
// console.log("Actor: Loading animations:", animationNames);
// try {
// // Load all animations in parallel
// await Promise.all(animationNames.map(animName =>
// this.loadAnimationByName(animName)
// ));
// } catch (error) {
// throw error;
// }
// }
// /**
// * Load a specific animation by name
// * @param animationName Name of the animation to load
// */
// private async loadAnimationByName(animationName: string): Promise<void> {
// if (!this.mixer) {
// throw new Error("Cannot load animations: mixer is null");
// }
// const fbxLoader = new FBXLoader();
// // Get the animation file name from the context or from our dictionary
// let animationFileName = this.animationFilenames[animationName] || null;
// // If not found in our dictionary, try to get it from the animation context
// if (!animationFileName && this.animationContext) {
// animationFileName = this.animationContext.getState(animationName)?.getAnimationName() || null;
// }
// // The animation name might have the full path or just the filename
// // Handle both cases
// const animationPath = animationFileName ?
// `${this.modelPath}/${animationFileName}` : // Use the file name from our dictionary
// animationName.includes('.fbx') ?
// `${this.modelPath}/${animationName}` : // Full filename provided in animationName
// `${this.modelPath}/${animationName}.fbx`; // Just animation name provided
// console.log("Actor: Loading animation:", animationPath);
// return new Promise<void>((resolve, reject) => {
// fbxLoader.load(
// animationPath,
// (animationFBX) => {
// // Get the animation
// if (animationFBX.animations.length === 0) {
// const error = new Error(`No animations found in ${animationName} file!`);
// reject(error);
// return;
// }
// const animation = animationFBX.animations[0];
// try {
// // Remove root motion (position) from the animation
// removeRootMotion(animation);
// // Create the action
// const action = this.mixer!.clipAction(animation);
// // Set animation playback speed
// action.setEffectiveTimeScale(this.ANIMATION_SPEED_MULTIPLIER);
// // Extract the base animation name without path or extension
// const baseName = animationName.split('/').pop()?.split('.')[0] || animationName;
// // Store the animation in the map using both the original name and the base name
// // This provides flexibility in how animations are referenced
// this.animationActions[baseName] = action;
// // Add indexing by full animation name if different from baseName
// if (baseName !== animationName) {
// this.animationActions[animationName] = action;
// }
// // If animation name has spaces, also add a version with underscores
// // This can help with name matching in certain cases
// if (baseName.includes(' ')) {
// const underscoreName = baseName.replace(/ /g, '_');
// this.animationActions[underscoreName] = action;
// }
// resolve();
// } catch (error) {
// reject(error);
// }
// },
// (xhr) => {
// // Progress callback
// },
// (error) => {
// reject(error);
// }
// );
// });
// }
/**
* Load animations directly from the animation filenames dictionary
*/
private async loadAnimationsFromDictionary(): Promise<void> {
if (!this.mixer) {
throw new Error("Cannot load animations: mixer is null");
}
console.log("Actor: Loading animations from dictionary:", this.animationFilenames);
try {
// Load all animations in parallel
const loadPromises = Object.entries(this.animationFilenames).map(
([animName, fileName]) => this.loadAnimationFile(animName, fileName)
);
await Promise.all(loadPromises);
} catch (error) {
throw error;
}
}
/**
* Load a specific animation file using the name and filename
* @param animationName The name to use for the animation (key)
* @param fileName The filename to load (value)
*/
private async loadAnimationFile(animationName: string, fileName: string): Promise<void> {
if (!this.mixer) {
throw new Error("Cannot load animations: mixer is null");
}
const fbxLoader = new FBXLoader();
// Construct the full path using the modelPath and the exact filename
const animationPath = `${this.modelPath}/${fileName}`;
console.log("Actor: Loading animation:", animationName, "from file:", animationPath);
return new Promise<void>((resolve, reject) => {
fbxLoader.load(
animationPath,
(animationFBX) => {
// Get the animation
if (animationFBX.animations.length === 0) {
const error = new Error(`No animations found in ${fileName} file!`);
reject(error);
return;
}
const animation = animationFBX.animations[0];
try {
// Remove root motion (position) from the animation
removeRootMotion(animation);
// Create the action
const action = this.mixer!.clipAction(animation);
// Set animation playback speed
action.setEffectiveTimeScale(this.ANIMATION_SPEED_MULTIPLIER);
// Store the animation in the map using the animation name as the key
this.animationActions[animationName] = action;
resolve();
} catch (error) {
reject(error);
}
},
(xhr) => {
// Progress callback
},
(error) => {
reject(error);
}
);
});
}
/**
* Transition to a new animation
* @param animationName Name of the animation to transition to
* @param duration Duration of the transition in seconds
*/
fadeToAction(animationName: string, duration: number = 0.2): void {
if (!this.animationContext) {
return;
}
if (!this.mixer) {
return;
}
try {
// PART 1: Find and set animation state in the context if possible
// Get all animation states
const states = this.animationContext.getStates();
// Try to find a state that matches the animation name directly
let targetState: AnimationState | null = null;
let targetStateName: string = '';
// First check if there's a state with the exact key
if (states[animationName]) {
targetState = states[animationName];
targetStateName = animationName;
}
// If not found directly, look for a state with matching animation name
if (!targetState) {
for (const key in states) {
const stateAnimName = states[key].getAnimationName();
// Check for exact match or basename match
const baseAnimName = animationName.split('/').pop()?.split('.')[0] || animationName;
const baseStateAnimName = stateAnimName.split('/').pop()?.split('.')[0] || stateAnimName;
if (stateAnimName === animationName ||
baseStateAnimName === baseAnimName ||
stateAnimName.replace(/ /g, '_') === animationName.replace(/ /g, '_')) {
targetState = states[key];
targetStateName = key;
break;
}
}
}
// If we found a target state, set it in the context
if (targetState) {
this.animationContext.setAnimationByName(targetStateName);
}
// PART 2: Find the correct animation action for the visual effect
// Try different variations of the animation name to find the action
let action = null;
// Try exact match first
if (this.animationActions[animationName]) {
action = this.animationActions[animationName];
}
// Try to find by base name (without path or extension)
else {
const baseName = animationName.split('/').pop()?.split('.')[0] || animationName;
if (this.animationActions[baseName]) {
action = this.animationActions[baseName];
}
// Try with spaces replaced by underscores
else if (this.animationActions[baseName.replace(/ /g, '_')]) {
action = this.animationActions[baseName.replace(/ /g, '_')];
}
// Try with underscores replaced by spaces
else if (this.animationActions[baseName.replace(/_/g, ' ')]) {
action = this.animationActions[baseName.replace(/_/g, ' ')];
}
}
// If we still don't have an action, return
if (!action) {
return;
}
// Get the animation speed from the state if available
let speed = this.ANIMATION_SPEED_MULTIPLIER;
if (targetState) {
speed = targetState.getAnimationSpeed() * this.ANIMATION_SPEED_MULTIPLIER;
// Set loop mode based on the animation state's loop parameter
const shouldLoop = targetState.getLoop();
if (!shouldLoop) {
// If not looping, set to play once and clamp at the end
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
} else {
// If looping, set to repeat infinitely (default behavior)
action.setLoop(THREE.LoopRepeat, Infinity);
action.clampWhenFinished = false;
}
}
// Apply the animation speed
action.setEffectiveTimeScale(speed);
// If there's a previous animation action, handle crossfade
if (this.currentAction) {
// Don't crossfade to the same animation
if (this.currentAction === action) {
return;
}
// Start new animation
action.reset();
action.play();
action.crossFadeFrom(this.currentAction, duration, true);
// Play animation state sound
const currentState = this.animationContext.getAnimationState();
if (currentState) {
this.playAnimationStateSound(currentState);
}
} else {
// No previous animation, just play it
action.reset();
action.play();
// Play animation state sound
const currentState = this.animationContext.getAnimationState();
if (currentState) {
this.playAnimationStateSound(currentState);
}
}
// Update current action
this.currentAction = action;
} catch (error) {
// Error in fadeToAction
}
}
/**
* Update the actor on each frame
* @param deltaTime Time since last frame in seconds
*/
update(deltaTime: number): void {
if (!this.mixer) return;
try {
// Update the animation mixer
this.mixer.update(deltaTime);
// Update the animation context
if (this.animationContext) {
this.animationContext.update(deltaTime);
// Check if animation state changed due to context update
this.syncAnimationStateWithVisuals();
}
} catch (error) {
// Handle animation errors
const currentAnimName = this.animationContext?.getAnimationState()?.getAnimationName() || 'unknown';
this.handleAnimationError(currentAnimName, error);
}
}
/**
* Synchronize the current animation state with the visual animations
* Called each frame to ensure visual animations match the logical state
*/
private syncAnimationStateWithVisuals(): void {
if (!this.animationContext) return;
const currentState = this.animationContext.getAnimationState();
if (!currentState) return;
const animationName = currentState.getAnimationName();
// If we don't have this animation loaded, return
if (!this.animationActions[animationName]) {
return;
}
// Get the animation action
const action = this.animationActions[animationName];
// Always ensure the correct animation speed is applied
const speed = currentState.getAnimationSpeed() * this.ANIMATION_SPEED_MULTIPLIER;
if (action.getEffectiveTimeScale() !== speed) {
action.setEffectiveTimeScale(speed);
}
// If this isn't the current action, fade to it
if (action && this.currentAction !== action) {
this.fadeToAction(animationName, currentState.getTransitionDuration());
}
// Handle sound effects based on animation state changes
if (this.audioSystem && currentState !== this.previousAnimationState) {
// Only play sounds when the state actually changes
this.playAnimationStateSound(currentState);
this.previousAnimationState = currentState;
}
}
/**
* Play the sound associated with the animation state
* @param state The current animation state
*/
private playAnimationStateSound(state: AnimationState): void {
if (!this.audioSystem) return;
// Get the audio effect from the state
const audioEffect = state.getAudioEffect();
// Check if the sound effect has changed
if (audioEffect !== this.currentSoundEffect) {
// Stop the current sound if one is playing
if (this.currentSoundEffect) {
try {
this.audioSystem.stopSound(this.currentSoundEffect);
} catch (error) {
// Error stopping previous sound
}
}
// Play the new sound if there is one
if (audioEffect && this.audioSystem) {
try {
this.audioSystem.playSound(audioEffect);
// Update the current sound effect
this.currentSoundEffect = audioEffect;
} catch (error) {
this.currentSoundEffect = null; // Reset on error
}
} else {
// No new sound to play
this.currentSoundEffect = null;
}
}
}
/**
* Move the character in its facing direction
* @param distance Distance to move
*/
moveForward(distance: number): void {
if (!this.model) return;
// Move in the direction the character is facing
this.position.x += this.direction.x * distance;
this.position.z += this.direction.z * distance;
// Update the model position
this.updateModelPosition();
}
/**
* Move the character in any direction (not just forward)
* @param movement The movement vector to apply
*/
move(movement: THREE.Vector3): void {
if (!this.model) return;
// Apply the movement to the position
this.position.add(movement);
// Update the model position
this.updateModelPosition();
}
/**
* Update the model position to match the actor's position
* This centralizes all model position updates
*/
private updateModelPosition(): void {
if (!this.model) return;
// Apply the actor's position to the model
this.model.position.copy(this.position);
}
/**
* Rotate the actor's direction by an angle (in radians)
* @param angleRadians Angle in radians to rotate
*/
rotateDirection(angleRadians: number): void {
// Create a rotation matrix around Y axis
const rotationMatrix = new THREE.Matrix4().makeRotationY(angleRadians);
// Apply rotation to direction vector
this.direction.applyMatrix4(rotationMatrix);
// Make sure it stays normalized
this.direction.normalize();
// Update the orientation to match the new direction
this.updateOrientation();
}
/**
* Update the visual orientation to match the direction
*/
updateOrientation(): void {
if (!this.model) return;
try {
// Create quaternion to rotate from default forward (0,0,1) to current direction
const targetQuat = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 0, 1), // Default forward direction
this.direction.clone().normalize()
);
// Apply the rotation to the character
this.model.quaternion.copy(targetQuat);
} catch (error) {
// Error updating character orientation
}
}
// Getter methods
getModel(): THREE.Object3D | null {
return this.model;
}
getPosition(): THREE.Vector3 {
return this.position.clone();
}
getDirection(): THREE.Vector3 {
return this.direction.clone();
}
getMixer(): THREE.AnimationMixer | null {
return this.mixer;
}
getAnimationContext(): AnimationContext | null {
return this.animationContext;
}
// Get animation state by name
getAnimationState(animationName: string): AnimationState {
const state = this.animationContext?.getState(animationName);
if (!state) {
throw new Error(`Animation state "${animationName}" not found`);
}
return state;
}
// Movement property setters
/**
* Set the actor's position and update the model
* @param position New position vector
*/
setPosition(position: THREE.Vector3): void {
// Copy to avoid reference issues
this.position.copy(position);
// Update the model position
this.updateModelPosition();
}
/**
* Set the actor's direction and update orientation
* @param direction New direction vector
*/
setDirection(direction: THREE.Vector3): void {
// Copy and normalize to avoid reference issues
this.direction.copy(direction).normalize();
// Update the visual orientation
this.updateOrientation();
}
setTurnSpeed(turnSpeed: number): void {
this.turnSpeed = turnSpeed;
}
getTurnSpeed(): number {
return this.turnSpeed;
}
/**
* Set the current animation state by name
* @param stateName Name of the animation state to set
*/
setAnimationState(stateName: string): void {
if (!this.animationContext) {
return;
}
try {
// Set the animation by name
this.animationContext.setAnimationByName(stateName);
// Get the state
const state = this.animationContext.getState(stateName);
// Force a sync to ensure animation speed is updated
this.syncAnimationStateWithVisuals();
} catch (error) {
// Error setting animation state
}
}
/**
* Log all available animations for debugging
*/
public logAvailableAnimations(): void {
// Method kept for API compatibility but no longer logs anything
}
/**
* Handle animation errors - try to recover if possible
*/
private handleAnimationError(animationName: string, error: any): void {
// Try to recover by going back to idle
if (this.animationContext) {
try {
// Try to set a safe default animation
this.animationContext.setAnimationByName('idle');
} catch (e) {
// Failed to recover from animation error
}
}
}
/**
* Stop all sounds currently playing for this actor
*/
stopAllSounds(): void {
if (!this.audioSystem) return;
// Stop the current sound effect if one is playing
if (this.currentSoundEffect) {
try {
this.audioSystem.stopSound(this.currentSoundEffect);
} catch (error) {
// Error stopping sound
}
// Reset the current sound effect
this.currentSoundEffect = null;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment