Skip to content

Instantly share code, notes, and snippets.

@lukevanin
Created March 22, 2025 08:36
Show Gist options
  • Save lukevanin/a71e312a7afd772c177e4a2c1f749fe0 to your computer and use it in GitHub Desktop.
Save lukevanin/a71e312a7afd772c177e4a2c1f749fe0 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
private name: string = '';
private stateMachine: any | null = null;
private animationMixers: THREE.AnimationMixer[] = [];
private animationClips: THREE.AnimationClip[] = [];
private walkingSpeed: number = 1.0;
private runningSpeed: number = 2.0;
private jumpHeight: number = 1.0;
private jumpDuration: number = 1.0;
private modelLoaded: boolean = false;
private deathCallback: (() => void) | null = null;
/**
* 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(modelFile: string): Promise<THREE.Object3D> {
// Use provided path or the default model path
try {
// Use the engine's loadActorModel function
const result = await loadActorModel(
modelFile,
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) {
console.error("Actor: Error loading model:", modelFile, 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) {
console.error("Actor: Error loading animations from dictionary:", 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 = 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 {
// Get rootMotionScale from the animation state if available
let rootMotionScale: [number, number, number] = [1, 1, 0]; // Default
if (this.animationContext) {
try {
// Try to find a state that matches the animation name
const states = this.animationContext.getStates();
// First check if there's a state with the exact key
if (states[animationName]) {
rootMotionScale = states[animationName].getRootMotionScale();
} else {
// If not found directly, look for a state with matching animation name
for (const key in states) {
if (states[key].getAnimationName() === animationName) {
rootMotionScale = states[key].getRootMotionScale();
break;
}
}
}
} catch (error) {
console.warn(`Could not get rootMotionScale for animation ${animationName}:`, error);
}
}
// Remove root motion (position) from the animation with the appropriate scale
removeRootMotion(animation, rootMotionScale);
// 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) {
console.error("Actor: Error loading animation:", animationName, "from file:", animationPath, error);
reject(error);
}
},
(xhr) => {
// Progress callback
},
(error) => {
console.error("Actor: Error loading animation:", animationName, "from file:", animationPath, 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
}
}
/**
* Gets the model of this actor
* @returns THREE.Object3D or null if the model isn't loaded
*/
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
* @param direction Direction vector
*/
setDirection(direction: THREE.Vector3): void {
const newDirection = direction.clone().normalize();
newDirection.y = 0;
this.direction.copy(newDirection);
this.updateOrientation();
}
/**
* Set the actor's rotation using a quaternion
* @param quaternion Rotation quaternion
*/
setQuaternion(quaternion: THREE.Quaternion): void {
if (this.model) {
this.model.quaternion.copy(quaternion);
// Update direction vector based on the rotation
this.direction.set(0, 0, 1).applyQuaternion(quaternion);
}
}
/**
* Set the turn speed
* @param turnSpeed Turn speed in radians per second
*/
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) {
console.error('Actor: Animation context not found');
return;
}
try {
// console.log('Actor: Setting animation state:', stateName);
// 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
console.error('Actor: Error setting animation state:', error);
}
}
/**
* 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;
}
}
/**
* Sets a callback to be called when this actor dies
* @param callback The callback function to call
*/
setDeathCallback(callback: () => void): void {
this.deathCallback = callback;
}
/**
* Attach a model to a specific bone/socket
* @param boneName Name of the bone to attach to (e.g., 'mixamorigRightHand', 'Hand_R')
* @param model The model to attach, or null to just remove any existing attachment
* @param position Optional position offset for the attachment
* @param rotation Optional rotation in radians for the attachment
* @param scale Optional scale for the attachment
* @returns True if attachment was successful, false otherwise
*/
attachModelToBone(
boneName: string,
model: THREE.Object3D | null
): boolean {
if (!this.model) {
console.warn('Actor: Cannot attach model, actor model not loaded');
return false;
}
// Try several common bone naming conventions
const possibleBoneNames = [
boneName,
boneName.replace(/mixamorig/, ''), // Try without 'mixamorig' prefix
`mixamorig${boneName}`, // Try with 'mixamorig' prefix
boneName.replace(/_/g, ''), // Try without underscores
boneName.replace(/ /g, '_') // Try with underscores instead of spaces
];
// Try to find the bone using any of the possible names
let bone: THREE.Object3D | undefined;
for (const name of possibleBoneNames) {
const found = this.model.getObjectByName(name);
if (found) {
bone = found;
console.log(`Actor: Found bone "${name}"`);
break;
}
}
if (!bone) {
console.warn(`Actor: Bone "${boneName}" not found in model`);
return false;
}
// Check if the bone already has attachments (non-default children)
this.removeAttachmentsFromBone(bone);
// If we have a new model to attach
if (model) {
const node = new THREE.Object3D();
node.rotateZ(-Math.PI / 2);
node.rotateY(-Math.PI / 2);
// Add the model to the bone
// Create a helper gizmo to visualize the bone's orientation
// const axesHelper = new THREE.AxesHelper(1); // 10cm axes
// node.add(axesHelper);
// Red = X axis
// Green = Y axis
// Blue = Z axis
node.add(model);
bone.add(node);
console.log(`Actor: Attached model to bone "${boneName}"`);
return true;
}
return false;
}
/**
* Remove any non-default attachments from a bone
* @param bone The bone to remove attachments from
*/
private removeAttachmentsFromBone(bone: THREE.Object3D): void {
// Find all non-bone children (attachments)
const attachments: THREE.Object3D[] = [];
bone.children.forEach(child => {
if (!child.name.includes('Bone') && !child.name.includes('bone')) {
attachments.push(child);
}
});
// Remove the attachments
attachments.forEach(child => {
bone.remove(child);
console.log(`Actor: Removed attachment from bone`);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment