Created
March 22, 2025 08:36
-
-
Save lukevanin/a71e312a7afd772c177e4a2c1f749fe0 to your computer and use it in GitHub Desktop.
Vibe coded actor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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