Add physics-based collision damage, spatial audio, and synchronized audio loading
All checks were successful
Build / build (push) Successful in 1m17s

Audio Loading Improvements:
- Ensure all sounds load before gameplay starts
- Wrap RockFactory explosion sound in Promise for async/await support
- Move background music loading from play() to initialize() in Level1
- Update loading messages to reflect audio loading progress

Collision and Audio Features:
- Implement energy-based collision damage using reduced mass and kinetic energy
- Add ship velocity property and display on scoreboard HUD
- Add collision sound effect with volume-adjusted playback (0.35) on ship impacts
- Move explosion sound to RockFactory with spatial audio positioning
- Configure explosion sound with exponential rolloff for better audibility

Technical Changes:
- Reorder audio engine initialization to load before RockFactory
- Background music now preloaded and ready when play() is called
- All audio assets guaranteed loaded before ready observable fires

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-09 11:28:31 -06:00
parent 31b498da7d
commit 56e900d93a
8 changed files with 174 additions and 24 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,5 @@
import {DefaultScene} from "./defaultScene"; import {DefaultScene} from "./defaultScene";
import type {AudioEngineV2} from "@babylonjs/core"; import type {AudioEngineV2, StaticSound} from "@babylonjs/core";
import { import {
AbstractMesh, AbstractMesh,
Observable, Observable,
@ -29,6 +29,7 @@ export class Level1 implements Level {
private _backgroundStars: BackgroundStars; private _backgroundStars: BackgroundStars;
private _physicsRecorder: PhysicsRecorder; private _physicsRecorder: PhysicsRecorder;
private _isReplayMode: boolean; private _isReplayMode: boolean;
private _backgroundMusic: StaticSound;
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) { constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) {
this._levelConfig = levelConfig; this._levelConfig = levelConfig;
@ -78,13 +79,10 @@ export class Level1 implements Level {
throw new Error("Cannot call play() in replay mode"); throw new Error("Cannot call play() in replay mode");
} }
// Create background music using AudioEngineV2 // Play background music (already loaded during initialization)
if (this._audioEngine) { if (this._backgroundMusic) {
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { this._backgroundMusic.play();
loop: true, debugLog('Started playing background music');
volume: 0.5
});
background.play();
} }
// If XR is available and session is active, check for controllers // If XR is available and session is active, check for controllers
@ -212,6 +210,16 @@ export class Level1 implements Level {
}); });
} }
// Load background music before marking as ready
if (this._audioEngine) {
setLoadingMessage("Loading background music...");
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
loop: true,
volume: 0.5
});
debugLog('Background music loaded successfully');
}
this._initialized = true; this._initialized = true;
// Notify that initialization is complete // Notify that initialization is complete

View File

@ -397,15 +397,15 @@ export class Main {
await this.setupPhysics(); await this.setupPhysics();
setLoadingMessage("Physics Engine Ready!"); setLoadingMessage("Physics Engine Ready!");
setLoadingMessage("Loading Assets and animations..."); // Initialize AudioEngineV2 first
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init();
setLoadingMessage("Ready!");
// Initialize AudioEngineV2
setLoadingMessage("Initializing Audio Engine..."); setLoadingMessage("Initializing Audio Engine...");
this._audioEngine = await CreateAudioEngineAsync(); this._audioEngine = await CreateAudioEngineAsync();
setLoadingMessage("Loading audio and visual assets...");
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init(this._audioEngine);
setLoadingMessage("All assets loaded!");
window.setTimeout(()=>{ window.setTimeout(()=>{
if (!this._started) { if (!this._started) {

View File

@ -1,5 +1,6 @@
import { import {
AbstractMesh, AbstractMesh,
AudioEngineV2,
DistanceConstraint, DistanceConstraint,
InstancedMesh, InstancedMesh,
Mesh, Mesh,
@ -7,7 +8,9 @@ import {
PhysicsAggregate, PhysicsAggregate,
PhysicsBody, PhysicsBody,
PhysicsMotionType, PhysicsMotionType,
PhysicsShapeType, TransformNode, PhysicsShapeType,
Sound,
TransformNode,
Vector3 Vector3
} from "@babylonjs/core"; } from "@babylonjs/core";
import {DefaultScene} from "./defaultScene"; import {DefaultScene} from "./defaultScene";
@ -34,13 +37,18 @@ export class RockFactory {
private static _asteroidMesh: AbstractMesh; private static _asteroidMesh: AbstractMesh;
private static _explosionManager: ExplosionManager; private static _explosionManager: ExplosionManager;
private static _orbitCenter: PhysicsAggregate; private static _orbitCenter: PhysicsAggregate;
private static _explosionSound: Sound;
private static _audioEngine: AudioEngineV2 | null = null;
public static async init() { public static async init(audioEngine?: AudioEngineV2) {
if (audioEngine) {
this._audioEngine = audioEngine;
}
// Initialize explosion manager // Initialize explosion manager
const node = new TransformNode('orbitCenter', DefaultScene.MainScene); const node = new TransformNode('orbitCenter', DefaultScene.MainScene);
node.position = Vector3.Zero(); node.position = Vector3.Zero();
this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 1000}, DefaultScene.MainScene ); this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 1000}, DefaultScene.MainScene );
this._orbitCenter.body.setMotionType(PhysicsMotionType.ANIMATED);
this._explosionManager = new ExplosionManager(DefaultScene.MainScene, { this._explosionManager = new ExplosionManager(DefaultScene.MainScene, {
duration: 800, duration: 800,
explosionForce: 20.0, explosionForce: 20.0,
@ -48,6 +56,35 @@ export class RockFactory {
}); });
await this._explosionManager.initialize(); await this._explosionManager.initialize();
// Load explosion sound with spatial audio (only if audio engine available)
if (this._audioEngine) {
debugLog('[RockFactory] Loading explosion sound with AudioEngineV2...');
// Wrap Sound loading in a Promise to ensure it's loaded before continuing
await new Promise<void>((resolve) => {
this._explosionSound = new Sound(
"explosionSound",
"/assets/themes/default/audio/explosion.mp3",
DefaultScene.MainScene,
() => {
debugLog('[RockFactory] Explosion sound loaded successfully');
resolve();
},
{
loop: false,
autoplay: false,
spatialSound: true,
maxDistance: 500,
distanceModel: "exponential",
rolloffFactor: 1,
volume: 5.0
}
);
});
} else {
debugLog('[RockFactory] WARNING: No audio engine provided, explosion sounds will be disabled');
}
if (!this._asteroidMesh) { if (!this._asteroidMesh) {
await this.loadMesh(); await this.loadMesh();
} }
@ -100,12 +137,23 @@ export class RockFactory {
// Get the asteroid mesh before disposing // Get the asteroid mesh before disposing
const asteroidMesh = eventData.collider.transformNode as AbstractMesh; const asteroidMesh = eventData.collider.transformNode as AbstractMesh;
const asteroidPosition = asteroidMesh.getAbsolutePosition();
debugLog('[RockFactory] Asteroid mesh to explode:', { debugLog('[RockFactory] Asteroid mesh to explode:', {
name: asteroidMesh.name, name: asteroidMesh.name,
id: asteroidMesh.id, id: asteroidMesh.id,
position: asteroidMesh.position.toString() position: asteroidPosition.toString()
}); });
// Play spatial explosion sound at asteroid position
if (RockFactory._explosionSound) {
debugLog('[RockFactory] Explosion sound exists, isReady:', RockFactory._explosionSound.isReady());
RockFactory._explosionSound.setPosition(asteroidPosition);
const playResult = RockFactory._explosionSound.play();
debugLog('[RockFactory] Playing explosion sound at position:', asteroidPosition.toString(), 'play() returned:', playResult);
} else {
debugLog('[RockFactory] WARNING: Explosion sound not loaded!');
}
// Play explosion using ExplosionManager (clones mesh internally) // Play explosion using ExplosionManager (clones mesh internally)
debugLog('[RockFactory] Calling ExplosionManager.playExplosion()...'); debugLog('[RockFactory] Calling ExplosionManager.playExplosion()...');
RockFactory._explosionManager.playExplosion(asteroidMesh); RockFactory._explosionManager.playExplosion(asteroidMesh);

View File

@ -32,6 +32,9 @@ export class Scoreboard {
// Ship status manager // Ship status manager
private _shipStatus: ShipStatus; private _shipStatus: ShipStatus;
// Reference to ship for velocity reading
private _ship: any = null;
constructor() { constructor() {
this._shipStatus = new ShipStatus(); this._shipStatus = new ShipStatus();
@ -64,9 +67,24 @@ export class Scoreboard {
return this._shipStatus; return this._shipStatus;
} }
/**
* Get the number of asteroids remaining
*/
public get remaining(): number {
return this._remaining;
}
public setRemainingCount(count: number) { public setRemainingCount(count: number) {
this._remaining = count; this._remaining = count;
} }
/**
* Set the ship reference for velocity reading
*/
public setShip(ship: any): void {
this._ship = ship;
}
public initialize(): void { public initialize(): void {
const scene = DefaultScene.MainScene; const scene = DefaultScene.MainScene;
@ -140,7 +158,11 @@ export class Scoreboard {
remainingText.text = 'Remaining: 0'; remainingText.text = 'Remaining: 0';
const timeRemainingText = this.createText(); const timeRemainingText = this.createText();
timeRemainingText.text = 'Time: 2:00'; timeRemainingText.text = 'Time: 00:00';
const velocityText = this.createText();
velocityText.text = 'Velocity: 0 m/s';
const panel = new StackPanel(); const panel = new StackPanel();
panel.isVertical = true; panel.isVertical = true;
@ -151,11 +173,21 @@ export class Scoreboard {
panel.addControl(fpsText); panel.addControl(fpsText);
panel.addControl(hullText); panel.addControl(hullText);
panel.addControl(timeRemainingText); panel.addControl(timeRemainingText);
panel.addControl(velocityText);
advancedTexture.addControl(panel); advancedTexture.addControl(panel);
let i = 0; let i = 0;
const afterRender = scene.onAfterRenderObservable.add(() => { const afterRender = scene.onAfterRenderObservable.add(() => {
scoreText.text = `Score: ${this.calculateScore()}`; scoreText.text = `Score: ${this.calculateScore()}`;
remainingText.text = `Remaining: ${this._remaining}`; remainingText.text = `Remaining: ${this._remaining}`;
// Update velocity from ship if available
if (this._ship && this._ship.velocity) {
const velocityMagnitude = this._ship.velocity.length();
velocityText.text = `Velocity: ${velocityMagnitude.toFixed(1)} m/s`;
} else {
velocityText.text = `Velocity: 0.0 m/s`;
}
const elapsed = Date.now() - this._startTime; const elapsed = Date.now() - this._startTime;
if (this._active && i++%30 == 0) { if (this._active && i++%30 == 0) {
timeRemainingText.text = `Time: ${Math.floor(elapsed/60000).toString().padStart(2,"0")}:${(Math.floor(elapsed/1000)%60).toString().padStart(2,"0")}`; timeRemainingText.text = `Time: ${Math.floor(elapsed/60000).toString().padStart(2,"0")}:${(Math.floor(elapsed/1000)%60).toString().padStart(2,"0")}`;

View File

@ -80,6 +80,13 @@ export class Ship {
return this._isInLandingZone; return this._isInLandingZone;
} }
public get velocity(): Vector3 {
if (this._ship?.physicsBody) {
return this._ship.physicsBody.getLinearVelocity();
}
return Vector3.Zero();
}
public set position(newPosition: Vector3) { public set position(newPosition: Vector3) {
const body = this._ship.physicsBody; const body = this._ship.physicsBody;
@ -99,6 +106,7 @@ export class Ship {
public async initialize() { public async initialize() {
this._scoreboard = new Scoreboard(); this._scoreboard = new Scoreboard();
this._scoreboard.setShip(this); // Pass ship reference for velocity reading
this._gameStats = new GameStats(); this._gameStats = new GameStats();
this._ship = new TransformNode("shipBase", DefaultScene.MainScene); this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
const data = await loadAsset("ship.glb"); const data = await loadAsset("ship.glb");
@ -126,12 +134,48 @@ export class Ship {
agg.body.setAngularVelocity(new Vector3(0, 0, 0)); agg.body.setAngularVelocity(new Vector3(0, 0, 0));
agg.body.setCollisionCallbackEnabled(true); agg.body.setCollisionCallbackEnabled(true);
// Register collision handler for hull damage // Register collision handler for energy-based hull damage
const observable = agg.body.getCollisionObservable(); const observable = agg.body.getCollisionObservable();
observable.add(() => { observable.add((collisionEvent) => {
// Damage hull on any collision // Only calculate damage on collision start to avoid double-counting
if (this._scoreboard?.shipStatus) { if (collisionEvent.type === 'COLLISION_STARTED') {
this._scoreboard.shipStatus.damageHull(0.01); // Get collision bodies
const shipBody = collisionEvent.collider;
const otherBody = collisionEvent.collidedAgainst;
// Get velocities
const shipVelocity = shipBody.getLinearVelocity();
const otherVelocity = otherBody.getLinearVelocity();
// Calculate relative velocity
const relativeVelocity = shipVelocity.subtract(otherVelocity);
const relativeSpeed = relativeVelocity.length();
// Get masses
const shipMass = 10; // Known ship mass from aggregate creation
const otherMass = otherBody.getMassProperties().mass;
// Calculate reduced mass for collision
const reducedMass = (shipMass * otherMass) / (shipMass + otherMass);
// Calculate kinetic energy of collision
const kineticEnergy = 0.5 * reducedMass * relativeSpeed * relativeSpeed;
// Convert energy to damage (tuning factor)
// 1000 units of energy = 0.01 (1%) damage
const ENERGY_TO_DAMAGE_FACTOR = 0.01 / 1000;
const damage = Math.min(kineticEnergy * ENERGY_TO_DAMAGE_FACTOR, 0.5); // Cap at 50% per hit
// Apply damage if above minimum threshold
if (this._scoreboard?.shipStatus && damage > 0.001) {
this._scoreboard.shipStatus.damageHull(damage);
debugLog(`Collision damage: ${damage.toFixed(4)} (energy: ${kineticEnergy.toFixed(1)}, speed: ${relativeSpeed.toFixed(1)} m/s)`);
// Play collision sound
if (this._audio) {
this._audio.playCollisionSound();
}
}
} }
}); });
} else { } else {

View File

@ -8,6 +8,7 @@ export class ShipAudio {
private _primaryThrustSound: StaticSound; private _primaryThrustSound: StaticSound;
private _secondaryThrustSound: StaticSound; private _secondaryThrustSound: StaticSound;
private _weaponSound: StaticSound; private _weaponSound: StaticSound;
private _collisionSound: StaticSound;
private _primaryThrustPlaying: boolean = false; private _primaryThrustPlaying: boolean = false;
private _secondaryThrustPlaying: boolean = false; private _secondaryThrustPlaying: boolean = false;
@ -47,6 +48,15 @@ export class ShipAudio {
volume: 0.5, volume: 0.5,
} }
); );
this._collisionSound = await this._audioEngine.createSoundAsync(
"collision",
"/assets/themes/default/audio/collision.mp3",
{
loop: false,
volume: 0.35,
}
);
} }
/** /**
@ -98,6 +108,13 @@ export class ShipAudio {
this._weaponSound?.play(); this._weaponSound?.play();
} }
/**
* Play collision sound
*/
public playCollisionSound(): void {
this._collisionSound?.play();
}
/** /**
* Cleanup audio resources * Cleanup audio resources
*/ */
@ -105,5 +122,6 @@ export class ShipAudio {
this._primaryThrustSound?.dispose(); this._primaryThrustSound?.dispose();
this._secondaryThrustSound?.dispose(); this._secondaryThrustSound?.dispose();
this._weaponSound?.dispose(); this._weaponSound?.dispose();
this._collisionSound?.dispose();
} }
} }