All checks were successful
Build / build (push) Successful in 1m45s
- Fix duplicate render loops causing 50% FPS drop (70→40) - Add stopRenderLoop() before runRenderLoop() in level1.ts and levelSelectedHandler.ts - Add ?loglevel=debug|info|warn|error query parameter - Add Y button to toggle inspector in XR - Throttle scoreboard updates to every 10 frames - Throttle game-end condition checks to every 30 frames - Remove per-frame logging from explosion animations - Reduce background stars from 5000 to 2500 - Freeze asteroid material after loading - Reduce physics substeps from 5 to 2 - Disable autoClear for Quest 2 performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import {
|
|
AbstractMesh, AudioEngineV2, Color3, InstancedMesh,
|
|
Mesh, MeshBuilder,
|
|
MeshExploder,
|
|
Scene, SoundState, StandardMaterial, StaticSound,
|
|
TransformNode,
|
|
Vector3
|
|
} from "@babylonjs/core";
|
|
import {DefaultScene} from "../../core/defaultScene";
|
|
import log from '../../core/logger';
|
|
|
|
/**
|
|
* Configuration for explosion effects
|
|
*/
|
|
interface ExplosionConfig {
|
|
/** Duration of explosion in milliseconds */
|
|
duration?: number;
|
|
/** Maximum explosion force (how far pieces spread) */
|
|
explosionForce?: number;
|
|
/** Frame rate for explosion animation */
|
|
frameRate?: number;
|
|
}
|
|
|
|
/**
|
|
* Manages mesh explosion effects using BabylonJS MeshExploder
|
|
*/
|
|
export class ExplosionManager {
|
|
private scene: Scene;
|
|
private config: Required<ExplosionConfig>;
|
|
private _debrisBaseMesh: Mesh;
|
|
private audioEngine: AudioEngineV2 | null = null;
|
|
private explosionSounds: StaticSound[] = [];
|
|
private soundPoolSize: number = 5;
|
|
|
|
// Default configuration
|
|
private static readonly DEFAULT_CONFIG: Required<ExplosionConfig> = {
|
|
duration: 2000,
|
|
explosionForce: 10,
|
|
frameRate: 60
|
|
};
|
|
|
|
constructor(scene: Scene, config?: ExplosionConfig) {
|
|
this.scene = scene;
|
|
this.config = { ...ExplosionManager.DEFAULT_CONFIG, ...config };
|
|
log.debug(this.config);
|
|
this._debrisBaseMesh = MeshBuilder.CreateIcoSphere(
|
|
'debrisBase',
|
|
{
|
|
radius: 1,
|
|
subdivisions: 2
|
|
}, DefaultScene.MainScene
|
|
);
|
|
const debrisMaterial = new StandardMaterial('debrisMaterial', DefaultScene.MainScene);
|
|
debrisMaterial.emissiveColor = new Color3(1,1,0);
|
|
this._debrisBaseMesh.material = debrisMaterial;
|
|
this._debrisBaseMesh.setEnabled(false);
|
|
}
|
|
|
|
/**
|
|
* Initialize the explosion manager (no longer needed for MeshExploder, but kept for API compatibility)
|
|
*/
|
|
public async initialize(): Promise<void> {
|
|
log.debug("ExplosionManager initialized with MeshExploder");
|
|
}
|
|
|
|
/**
|
|
* Initialize audio for explosions (called after audio engine is unlocked)
|
|
*/
|
|
public async initAudio(audioEngine: AudioEngineV2): Promise<void> {
|
|
this.audioEngine = audioEngine;
|
|
|
|
log.debug(`ExplosionManager: Initializing audio with pool size ${this.soundPoolSize}`);
|
|
|
|
// Create sound pool for concurrent explosions
|
|
for (let i = 0; i < this.soundPoolSize; i++) {
|
|
const sound = await audioEngine.createSoundAsync(
|
|
`explosionSound_${i}`,
|
|
"/assets/themes/default/audio/explosion.mp3",
|
|
{
|
|
loop: false,
|
|
volume: 1.0,
|
|
spatialEnabled: true,
|
|
spatialDistanceModel: "linear",
|
|
spatialMaxDistance: 500,
|
|
spatialMinUpdateTime: 0.5,
|
|
spatialRolloffFactor: 1
|
|
}
|
|
);
|
|
this.explosionSounds.push(sound);
|
|
}
|
|
|
|
log.debug(`ExplosionManager: Loaded ${this.explosionSounds.length} explosion sounds`);
|
|
}
|
|
|
|
/**
|
|
* Get an available sound from the pool
|
|
*/
|
|
private getAvailableSound(): StaticSound | null {
|
|
// Find a sound that's not currently playing
|
|
for (const sound of this.explosionSounds) {
|
|
if (sound.state !== SoundState.Started && sound.state !== SoundState.Starting) {
|
|
return sound;
|
|
}
|
|
}
|
|
|
|
// If all sounds are playing, reuse the first one (will cut off the oldest)
|
|
log.debug("ExplosionManager: All sounds in pool are playing, reusing sound 0");
|
|
return this.explosionSounds[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Play explosion audio at a specific position
|
|
*/
|
|
private playExplosionAudio(position: Vector3): void {
|
|
if (!this.audioEngine) {
|
|
// Audio not initialized, skip silently
|
|
return;
|
|
}
|
|
|
|
const sound = this.getAvailableSound();
|
|
if (!sound) {
|
|
log.debug("ExplosionManager: No sound available in pool");
|
|
return;
|
|
}
|
|
|
|
// Create lightweight TransformNode for spatial audio positioning
|
|
const explosionNode = new TransformNode(`explosionAudio_${Date.now()}`, this.scene);
|
|
explosionNode.position = position.clone();
|
|
|
|
try {
|
|
// Attach spatial sound to the node
|
|
sound.spatial.attach(explosionNode);
|
|
sound.play();
|
|
sound.onEndedObservable.addOnce(() => {
|
|
//Cleanup after sound ends.
|
|
sound.spatial.detach();
|
|
explosionNode.dispose();
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
log.debug("ExplosionManager: Error playing explosion audio", error);
|
|
explosionNode.dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create sphere debris pieces for explosion
|
|
* MeshExploder requires an array of separate meshes
|
|
* @param mesh The mesh to explode (used for position/scale)
|
|
* @param pieces Number of pieces to create
|
|
* @returns Array of sphere mesh objects
|
|
*/
|
|
private splitIntoSeparateMeshes(position: Vector3, pieces: number = 32): InstancedMesh[] {
|
|
log.debug(`[ExplosionManager] Creating ${pieces} sphere debris pieces`);
|
|
|
|
const meshPieces: InstancedMesh[] = [];
|
|
|
|
// Create material for debris
|
|
|
|
|
|
for (let i = 0; i < pieces; i++) {
|
|
try {
|
|
// Create a small sphere for debris
|
|
const sphere = new InstancedMesh(
|
|
`debris_${i}`,
|
|
this._debrisBaseMesh);
|
|
|
|
|
|
// Position spheres in a small cluster around the original position
|
|
const offsetRadius = 1;
|
|
const angle1 = (i / pieces) * Math.PI * 2;
|
|
const angle2 = Math.random() * Math.PI;
|
|
|
|
sphere.position = new Vector3(
|
|
position.x + Math.sin(angle2) * Math.cos(angle1) * offsetRadius,
|
|
position.y + Math.sin(angle2) * Math.sin(angle1) * offsetRadius,
|
|
position.z + Math.cos(angle2) * offsetRadius
|
|
);
|
|
|
|
sphere.isVisible = true;
|
|
sphere.setEnabled(true);
|
|
meshPieces.push(sphere);
|
|
} catch (error) {
|
|
log.error(`[ExplosionManager] ERROR creating debris piece ${i}:`, error);
|
|
}
|
|
}
|
|
|
|
log.debug(`[ExplosionManager] Successfully created ${meshPieces.length}/${pieces} sphere debris pieces`);
|
|
if (meshPieces.length > 0) {
|
|
log.debug('[ExplosionManager] First piece sample:', {
|
|
name: meshPieces[0].name,
|
|
position: meshPieces[0].position.toString(),
|
|
isVisible: meshPieces[0].isVisible,
|
|
isEnabled: meshPieces[0].isEnabled()
|
|
});
|
|
}
|
|
return meshPieces;
|
|
}
|
|
|
|
/**
|
|
* Explode a mesh by breaking it into pieces and animating them outward
|
|
* @param mesh The mesh to explode (will be cloned internally)
|
|
*/
|
|
public playExplosion(mesh: AbstractMesh): void {
|
|
log.debug('[ExplosionManager] playExplosion called');
|
|
log.debug('[ExplosionManager] Input mesh:', {
|
|
name: mesh.name,
|
|
id: mesh.id,
|
|
isInstancedMesh: !!(mesh as any).sourceMesh,
|
|
position: mesh.position.toString(),
|
|
scaling: mesh.scaling.toString()
|
|
});
|
|
|
|
// Play explosion audio at the mesh's position
|
|
const explosionPosition = mesh.getAbsolutePosition();
|
|
this.playExplosionAudio(explosionPosition);
|
|
|
|
// Get the source mesh if this is an instanced mesh
|
|
let sourceMesh: Mesh;
|
|
if ((mesh as any).sourceMesh) {
|
|
sourceMesh = (mesh as any).sourceMesh as Mesh;
|
|
log.debug('[ExplosionManager] Using source mesh from instance:', sourceMesh.name);
|
|
} else {
|
|
sourceMesh = mesh as Mesh;
|
|
log.debug('[ExplosionManager] Using mesh directly (not instanced)');
|
|
}
|
|
|
|
// Clone the source mesh so we don't affect the original
|
|
log.debug('[ExplosionManager] Cloning mesh...');
|
|
mesh.computeWorldMatrix(true);
|
|
// Apply the instance's transformation to the cloned mesh
|
|
const position = mesh.getAbsolutePosition().clone();
|
|
|
|
// Force world matrix computation
|
|
|
|
|
|
// Check if mesh has proper geometry
|
|
if (!mesh.getTotalVertices || mesh.getTotalVertices() === 0) {
|
|
log.error('[ExplosionManager] ERROR: Mesh has no vertices, cannot explode');
|
|
mesh.dispose();
|
|
return;
|
|
}
|
|
|
|
// Split the mesh into separate mesh objects (MeshExploder requirement)
|
|
log.debug('[ExplosionManager] Splitting mesh into pieces...');
|
|
const meshPieces = this.splitIntoSeparateMeshes(position, 12);
|
|
|
|
if (meshPieces.length === 0) {
|
|
log.error('[ExplosionManager] ERROR: Failed to split mesh into pieces');
|
|
mesh.dispose();
|
|
return;
|
|
}
|
|
|
|
// Original mesh is no longer needed - the pieces replace it
|
|
log.debug('[ExplosionManager] Disposing original cloned mesh');
|
|
mesh.dispose();
|
|
|
|
// Create the exploder with the array of separate meshes
|
|
// The second parameter is optional - it's the center mesh to explode from
|
|
// If not provided, MeshExploder will auto-calculate the center
|
|
log.debug('[ExplosionManager] Creating MeshExploder...');
|
|
try {
|
|
const exploder = new MeshExploder((meshPieces as unknown) as Mesh[]);
|
|
log.debug('[ExplosionManager] MeshExploder created successfully');
|
|
|
|
log.debug(`[ExplosionManager] Starting explosion animation:`, {
|
|
pieceCount: meshPieces.length,
|
|
duration: this.config.duration,
|
|
maxForce: this.config.explosionForce
|
|
});
|
|
|
|
// Animate the explosion using Babylon's render loop instead of requestAnimationFrame
|
|
const startTime = Date.now();
|
|
const animationDuration = this.config.duration;
|
|
const maxForce = this.config.explosionForce;
|
|
let frameCount = 0;
|
|
|
|
const animationObserver = this.scene.onBeforeRenderObservable.add(() => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / animationDuration, 1.0);
|
|
|
|
// Calculate current explosion value (0 to maxForce)
|
|
const currentValue = progress * maxForce;
|
|
|
|
try {
|
|
exploder.explode(currentValue);
|
|
} catch (error) {
|
|
log.error('[ExplosionManager] ERROR in explode():', error);
|
|
}
|
|
|
|
// Animate debris size to zero (1.0 to 0.0)
|
|
const scale = 1.0 - progress;
|
|
meshPieces.forEach(piece => {
|
|
if (piece && !piece.isDisposed()) {
|
|
piece.scaling.set(scale, scale, scale);
|
|
}
|
|
});
|
|
|
|
frameCount++;
|
|
|
|
// Continue animation if not complete
|
|
if (progress >= 1.0) {
|
|
// Animation complete - remove observer and clean up
|
|
log.debug(`[ExplosionManager] Animation complete after ${frameCount} frames, cleaning up`);
|
|
this.scene.onBeforeRenderObservable.remove(animationObserver);
|
|
this.cleanupExplosion(meshPieces);
|
|
}
|
|
});
|
|
|
|
// Log that animation loop is registered
|
|
log.debug('[ExplosionManager] Starting animation loop...');
|
|
} catch (error) {
|
|
log.error('[ExplosionManager] ERROR creating MeshExploder:', error);
|
|
// Clean up pieces if exploder failed
|
|
meshPieces.forEach(piece => {
|
|
if (piece && !piece.isDisposed()) {
|
|
piece.dispose();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up explosion meshes
|
|
*/
|
|
private cleanupExplosion(meshPieces: InstancedMesh[]): void {
|
|
log.debug('[ExplosionManager] Starting cleanup of explosion meshes...');
|
|
|
|
let disposedCount = 0;
|
|
// Dispose all the mesh pieces
|
|
meshPieces.forEach((mesh, index) => {
|
|
if (mesh && !mesh.isDisposed()) {
|
|
try {
|
|
mesh.dispose();
|
|
disposedCount++;
|
|
} catch (error) {
|
|
log.error(`[ExplosionManager] ERROR disposing piece ${index}:`, error);
|
|
}
|
|
}
|
|
});
|
|
|
|
log.debug(`[ExplosionManager] Cleanup complete - disposed ${disposedCount}/${meshPieces.length} pieces`);
|
|
}
|
|
|
|
/**
|
|
* Dispose of the explosion manager
|
|
*/
|
|
public dispose(): void {
|
|
this._debrisBaseMesh.dispose(false, true);
|
|
// Nothing to dispose with MeshExploder approach
|
|
log.debug("ExplosionManager disposed");
|
|
}
|
|
}
|