Add physics-based collision damage, spatial audio, and synchronized audio loading
All checks were successful
Build / build (push) Successful in 1m17s
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:
parent
31b498da7d
commit
56e900d93a
BIN
public/assets/themes/default/audio/collision.mp3
Normal file
BIN
public/assets/themes/default/audio/collision.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/explosion.mp3
Normal file
BIN
public/assets/themes/default/audio/explosion.mp3
Normal file
Binary file not shown.
@ -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
|
||||||
|
|||||||
12
src/main.ts
12
src/main.ts
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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")}`;
|
||||||
|
|||||||
54
src/ship.ts
54
src/ship.ts
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user