diff --git a/public/assets/themes/default/audio/collision.mp3 b/public/assets/themes/default/audio/collision.mp3 new file mode 100644 index 0000000..5b55eab Binary files /dev/null and b/public/assets/themes/default/audio/collision.mp3 differ diff --git a/public/assets/themes/default/audio/explosion.mp3 b/public/assets/themes/default/audio/explosion.mp3 new file mode 100644 index 0000000..74e4114 Binary files /dev/null and b/public/assets/themes/default/audio/explosion.mp3 differ diff --git a/src/level1.ts b/src/level1.ts index 4065820..77251bc 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -1,5 +1,5 @@ import {DefaultScene} from "./defaultScene"; -import type {AudioEngineV2} from "@babylonjs/core"; +import type {AudioEngineV2, StaticSound} from "@babylonjs/core"; import { AbstractMesh, Observable, @@ -29,6 +29,7 @@ export class Level1 implements Level { private _backgroundStars: BackgroundStars; private _physicsRecorder: PhysicsRecorder; private _isReplayMode: boolean; + private _backgroundMusic: StaticSound; constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) { this._levelConfig = levelConfig; @@ -78,13 +79,10 @@ export class Level1 implements Level { throw new Error("Cannot call play() in replay mode"); } - // Create background music using AudioEngineV2 - if (this._audioEngine) { - const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { - loop: true, - volume: 0.5 - }); - background.play(); + // Play background music (already loaded during initialization) + if (this._backgroundMusic) { + this._backgroundMusic.play(); + debugLog('Started playing background music'); } // 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; // Notify that initialization is complete diff --git a/src/main.ts b/src/main.ts index 0c898bc..8d3eed7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -397,15 +397,15 @@ export class Main { await this.setupPhysics(); setLoadingMessage("Physics Engine Ready!"); - setLoadingMessage("Loading Assets and animations..."); - ParticleHelper.BaseAssetsUrl = window.location.href; - await RockFactory.init(); - setLoadingMessage("Ready!"); - - // Initialize AudioEngineV2 + // Initialize AudioEngineV2 first setLoadingMessage("Initializing Audio Engine..."); 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(()=>{ if (!this._started) { diff --git a/src/rockFactory.ts b/src/rockFactory.ts index 5b9671f..9dab2da 100644 --- a/src/rockFactory.ts +++ b/src/rockFactory.ts @@ -1,5 +1,6 @@ import { AbstractMesh, + AudioEngineV2, DistanceConstraint, InstancedMesh, Mesh, @@ -7,7 +8,9 @@ import { PhysicsAggregate, PhysicsBody, PhysicsMotionType, - PhysicsShapeType, TransformNode, + PhysicsShapeType, + Sound, + TransformNode, Vector3 } from "@babylonjs/core"; import {DefaultScene} from "./defaultScene"; @@ -34,13 +37,18 @@ export class RockFactory { private static _asteroidMesh: AbstractMesh; private static _explosionManager: ExplosionManager; 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 const node = new TransformNode('orbitCenter', DefaultScene.MainScene); node.position = Vector3.Zero(); 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, { duration: 800, explosionForce: 20.0, @@ -48,6 +56,35 @@ export class RockFactory { }); 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((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) { await this.loadMesh(); } @@ -100,12 +137,23 @@ export class RockFactory { // Get the asteroid mesh before disposing const asteroidMesh = eventData.collider.transformNode as AbstractMesh; + const asteroidPosition = asteroidMesh.getAbsolutePosition(); debugLog('[RockFactory] Asteroid mesh to explode:', { name: asteroidMesh.name, 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) debugLog('[RockFactory] Calling ExplosionManager.playExplosion()...'); RockFactory._explosionManager.playExplosion(asteroidMesh); diff --git a/src/scoreboard.ts b/src/scoreboard.ts index 5108e7f..dcac546 100644 --- a/src/scoreboard.ts +++ b/src/scoreboard.ts @@ -32,6 +32,9 @@ export class Scoreboard { // Ship status manager private _shipStatus: ShipStatus; + // Reference to ship for velocity reading + private _ship: any = null; + constructor() { this._shipStatus = new ShipStatus(); @@ -64,9 +67,24 @@ export class Scoreboard { return this._shipStatus; } + /** + * Get the number of asteroids remaining + */ + public get remaining(): number { + return this._remaining; + } + public setRemainingCount(count: number) { this._remaining = count; } + + /** + * Set the ship reference for velocity reading + */ + public setShip(ship: any): void { + this._ship = ship; + } + public initialize(): void { const scene = DefaultScene.MainScene; @@ -140,7 +158,11 @@ export class Scoreboard { remainingText.text = 'Remaining: 0'; 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(); panel.isVertical = true; @@ -151,11 +173,21 @@ export class Scoreboard { panel.addControl(fpsText); panel.addControl(hullText); panel.addControl(timeRemainingText); + panel.addControl(velocityText); advancedTexture.addControl(panel); let i = 0; const afterRender = scene.onAfterRenderObservable.add(() => { scoreText.text = `Score: ${this.calculateScore()}`; 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; 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")}`; diff --git a/src/ship.ts b/src/ship.ts index 4d08877..db5f1fa 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -80,6 +80,13 @@ export class Ship { return this._isInLandingZone; } + public get velocity(): Vector3 { + if (this._ship?.physicsBody) { + return this._ship.physicsBody.getLinearVelocity(); + } + return Vector3.Zero(); + } + public set position(newPosition: Vector3) { const body = this._ship.physicsBody; @@ -99,6 +106,7 @@ export class Ship { public async initialize() { this._scoreboard = new Scoreboard(); + this._scoreboard.setShip(this); // Pass ship reference for velocity reading this._gameStats = new GameStats(); this._ship = new TransformNode("shipBase", DefaultScene.MainScene); const data = await loadAsset("ship.glb"); @@ -126,12 +134,48 @@ export class Ship { agg.body.setAngularVelocity(new Vector3(0, 0, 0)); agg.body.setCollisionCallbackEnabled(true); - // Register collision handler for hull damage + // Register collision handler for energy-based hull damage const observable = agg.body.getCollisionObservable(); - observable.add(() => { - // Damage hull on any collision - if (this._scoreboard?.shipStatus) { - this._scoreboard.shipStatus.damageHull(0.01); + observable.add((collisionEvent) => { + // Only calculate damage on collision start to avoid double-counting + if (collisionEvent.type === 'COLLISION_STARTED') { + // 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 { diff --git a/src/shipAudio.ts b/src/shipAudio.ts index c1ac127..4b43f16 100644 --- a/src/shipAudio.ts +++ b/src/shipAudio.ts @@ -8,6 +8,7 @@ export class ShipAudio { private _primaryThrustSound: StaticSound; private _secondaryThrustSound: StaticSound; private _weaponSound: StaticSound; + private _collisionSound: StaticSound; private _primaryThrustPlaying: boolean = false; private _secondaryThrustPlaying: boolean = false; @@ -47,6 +48,15 @@ export class ShipAudio { 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(); } + /** + * Play collision sound + */ + public playCollisionSound(): void { + this._collisionSound?.play(); + } + /** * Cleanup audio resources */ @@ -105,5 +122,6 @@ export class ShipAudio { this._primaryThrustSound?.dispose(); this._secondaryThrustSound?.dispose(); this._weaponSound?.dispose(); + this._collisionSound?.dispose(); } }