All checks were successful
Build / build (push) Successful in 1m20s
Root cause: The old Sound API (new Sound()) is incompatible with AudioEngineV2 in BabylonJS 8.32.0, causing silent failures where the explosion.mp3 file was never fetched from the network. Audio System Fixes: - Migrate explosion sound from Sound class to AudioEngineV2.createSoundAsync() - Use StaticSound with spatial property instead of old Sound API - Configure audio engine with listenerEnabled and listenerAutoUpdate - Attach audio listener to camera for proper 3D positioning Spatial Audio Implementation: - Use spatialEnabled: true with spatial-prefixed properties - Attach sound to explosion node using sound.spatial.attach() - Properly detach and cleanup after explosion finishes (850ms) - Configure exponential distance model with 500 unit max distance Technical Changes: - Replace new Sound() with await audioEngine.createSoundAsync() - Change _explosionSound type from Sound to StaticSound - Update imports: Sound → StaticSound - Use sound.spatial.attach(node) instead of attachToMesh() - Use sound.spatial.detach() for cleanup - Remove incompatible getVolume() calls Audio Engine Configuration: - Add CreateAudioEngineAsync options for spatial audio support - Attach listener to camera after unlock in both level flows - Enable listener auto-update for VR camera movement tracking This fixes the explosion sound loading and enables proper 3D spatial audio with distance attenuation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
537 lines
22 KiB
TypeScript
537 lines
22 KiB
TypeScript
import {
|
|
AudioEngineV2,
|
|
Color3,
|
|
CreateAudioEngineAsync,
|
|
Engine,
|
|
HavokPlugin,
|
|
ParticleHelper,
|
|
Scene,
|
|
Vector3,
|
|
WebGPUEngine,
|
|
WebXRDefaultExperience,
|
|
WebXRFeaturesManager
|
|
} from "@babylonjs/core";
|
|
import '@babylonjs/loaders';
|
|
import HavokPhysics from "@babylonjs/havok";
|
|
|
|
import {DefaultScene} from "./defaultScene";
|
|
import {Level1} from "./level1";
|
|
import {TestLevel} from "./testLevel";
|
|
import Demo from "./demo";
|
|
import Level from "./level";
|
|
import setLoadingMessage from "./setLoadingMessage";
|
|
import {RockFactory} from "./rockFactory";
|
|
import {ControllerDebug} from "./controllerDebug";
|
|
import {router, showView} from "./router";
|
|
import {hasSavedLevels, populateLevelSelector} from "./levelSelector";
|
|
import {LevelConfig} from "./levelConfig";
|
|
import {generateDefaultLevels} from "./levelEditor";
|
|
import debugLog from './debug';
|
|
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
|
import {ReplayManager} from "./replay/ReplayManager";
|
|
|
|
// Set to true to run minimal controller debug test
|
|
const DEBUG_CONTROLLERS = false;
|
|
const webGpu = false;
|
|
const canvas = (document.querySelector('#gameCanvas') as HTMLCanvasElement);
|
|
enum GameState {
|
|
PLAY,
|
|
DEMO
|
|
}
|
|
export class Main {
|
|
private _currentLevel: Level;
|
|
private _gameState: GameState = GameState.DEMO;
|
|
private _engine: Engine | WebGPUEngine;
|
|
private _audioEngine: AudioEngineV2;
|
|
private _replayManager: ReplayManager | null = null;
|
|
constructor() {
|
|
// Listen for level selection event
|
|
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
|
this._started = true;
|
|
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
|
|
|
debugLog(`Starting level: ${levelName}`);
|
|
|
|
// Hide all UI elements
|
|
const mainDiv = document.querySelector('#mainDiv');
|
|
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
|
const editorLink = document.querySelector('.editor-link') as HTMLElement;
|
|
const settingsLink = document.querySelector('.settings-link') as HTMLElement;
|
|
|
|
if (levelSelect) {
|
|
levelSelect.style.display = 'none';
|
|
}
|
|
if (editorLink) {
|
|
editorLink.style.display = 'none';
|
|
}
|
|
if (settingsLink) {
|
|
settingsLink.style.display = 'none';
|
|
}
|
|
setLoadingMessage("Initializing...");
|
|
|
|
// Initialize engine and XR first
|
|
await this.initialize();
|
|
|
|
// If XR is available, enter XR immediately (while we have user activation)
|
|
let xrSession = null;
|
|
if (DefaultScene.XR) {
|
|
try {
|
|
setLoadingMessage("Entering VR...");
|
|
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
|
debugLog('XR session started successfully');
|
|
} catch (error) {
|
|
debugLog('Failed to enter XR, will fall back to flat mode:', error);
|
|
DefaultScene.XR = null; // Disable XR for this session
|
|
}
|
|
}
|
|
|
|
// Unlock audio engine on user interaction
|
|
if (this._audioEngine) {
|
|
await this._audioEngine.unlockAsync();
|
|
}
|
|
|
|
// Now load audio assets (after unlock)
|
|
setLoadingMessage("Loading audio assets...");
|
|
await RockFactory.initAudio(this._audioEngine);
|
|
|
|
// Attach audio listener to camera for spatial audio
|
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
|
if (camera && this._audioEngine.listener) {
|
|
this._audioEngine.listener.attach(camera);
|
|
debugLog('[Main] Audio listener attached to camera for spatial audio');
|
|
} else {
|
|
debugLog('[Main] WARNING: Could not attach audio listener - camera or listener not available');
|
|
}
|
|
|
|
setLoadingMessage("Loading level...");
|
|
|
|
// Create and initialize level from config
|
|
this._currentLevel = new Level1(config, this._audioEngine);
|
|
|
|
// Wait for level to be ready
|
|
this._currentLevel.getReadyObservable().add(async () => {
|
|
setLoadingMessage("Starting game...");
|
|
|
|
// Get ship and set up replay observable
|
|
const level1 = this._currentLevel as Level1;
|
|
const ship = (level1 as any)._ship;
|
|
|
|
// Listen for replay requests from the ship
|
|
if (ship) {
|
|
ship.onReplayRequestObservable.add(() => {
|
|
debugLog('Replay requested - reloading page');
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
// If we entered XR before level creation, manually setup camera parenting
|
|
// (This is needed because onInitialXRPoseSetObservable won't fire if we're already in XR)
|
|
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
|
|
|
|
if (ship && ship.transformNode) {
|
|
debugLog('Manually parenting XR camera to ship transformNode');
|
|
DefaultScene.XR.baseExperience.camera.parent = ship.transformNode;
|
|
DefaultScene.XR.baseExperience.camera.position = new Vector3(0, 1.5, 0);
|
|
|
|
// Also start timer and recording here (since onInitialXRPoseSetObservable won't fire)
|
|
ship.gameStats.startTimer();
|
|
debugLog('Game timer started (manual)');
|
|
|
|
if ((level1 as any)._physicsRecorder) {
|
|
(level1 as any)._physicsRecorder.startRingBuffer();
|
|
debugLog('Physics recorder started (manual)');
|
|
}
|
|
} else {
|
|
debugLog('WARNING: Could not parent XR camera - ship or transformNode not found');
|
|
}
|
|
}
|
|
|
|
// Remove UI
|
|
mainDiv.remove();
|
|
|
|
// Start the game (XR session already active, or flat mode)
|
|
await this.play();
|
|
});
|
|
|
|
// Now initialize the level (after observable is registered)
|
|
await this._currentLevel.initialize();
|
|
});
|
|
|
|
// Listen for test level button click
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const levelSelect = document.querySelector('#levelSelect');
|
|
levelSelect.classList.add('ready');
|
|
debugLog('[Main] DOMContentLoaded fired, looking for test button...');
|
|
const testLevelBtn = document.querySelector('#testLevelBtn');
|
|
debugLog('[Main] Test button found:', !!testLevelBtn);
|
|
|
|
if (testLevelBtn) {
|
|
testLevelBtn.addEventListener('click', async () => {
|
|
debugLog('[Main] ========== TEST LEVEL BUTTON CLICKED ==========');
|
|
|
|
// Hide all UI elements
|
|
const mainDiv = document.querySelector('#mainDiv');
|
|
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
|
const editorLink = document.querySelector('.editor-link') as HTMLElement;
|
|
const settingsLink = document.querySelector('.settings-link') as HTMLElement;
|
|
|
|
debugLog('[Main] mainDiv exists:', !!mainDiv);
|
|
debugLog('[Main] levelSelect exists:', !!levelSelect);
|
|
|
|
if (levelSelect) {
|
|
levelSelect.style.display = 'none';
|
|
debugLog('[Main] levelSelect hidden');
|
|
}
|
|
if (editorLink) {
|
|
editorLink.style.display = 'none';
|
|
}
|
|
if (settingsLink) {
|
|
settingsLink.style.display = 'none';
|
|
}
|
|
setLoadingMessage("Initializing Test Scene...");
|
|
|
|
// Unlock audio engine on user interaction
|
|
if (this._audioEngine) {
|
|
debugLog('[Main] Unlocking audio engine...');
|
|
await this._audioEngine.unlockAsync();
|
|
debugLog('[Main] Audio engine unlocked');
|
|
}
|
|
|
|
// Now load audio assets (after unlock)
|
|
setLoadingMessage("Loading audio assets...");
|
|
await RockFactory.initAudio(this._audioEngine);
|
|
|
|
// Attach audio listener to camera for spatial audio
|
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
|
if (camera && this._audioEngine.listener) {
|
|
this._audioEngine.listener.attach(camera);
|
|
debugLog('[Main] Audio listener attached to camera for spatial audio (test level)');
|
|
} else {
|
|
debugLog('[Main] WARNING: Could not attach audio listener - camera or listener not available (test level)');
|
|
}
|
|
|
|
// Create test level
|
|
debugLog('[Main] Creating TestLevel...');
|
|
this._currentLevel = new TestLevel(this._audioEngine);
|
|
debugLog('[Main] TestLevel created:', !!this._currentLevel);
|
|
|
|
// Wait for level to be ready
|
|
debugLog('[Main] Registering ready observable...');
|
|
this._currentLevel.getReadyObservable().add(async () => {
|
|
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
|
setLoadingMessage("Test Scene Ready! Entering VR...");
|
|
|
|
// Remove UI and play immediately (must maintain user activation for XR)
|
|
if (mainDiv) {
|
|
mainDiv.remove();
|
|
debugLog('[Main] mainDiv removed');
|
|
}
|
|
debugLog('[Main] About to call this.play()...');
|
|
await this.play();
|
|
});
|
|
debugLog('[Main] Ready observable registered');
|
|
|
|
// Now initialize the level (after observable is registered)
|
|
debugLog('[Main] Calling TestLevel.initialize()...');
|
|
await this._currentLevel.initialize();
|
|
debugLog('[Main] TestLevel.initialize() completed');
|
|
});
|
|
debugLog('[Main] Click listener added to test button');
|
|
} else {
|
|
console.warn('[Main] Test level button not found in DOM');
|
|
}
|
|
|
|
// View Replays button handler
|
|
const viewReplaysBtn = document.querySelector('#viewReplaysBtn');
|
|
debugLog('[Main] View Replays button found:', !!viewReplaysBtn);
|
|
|
|
if (viewReplaysBtn) {
|
|
viewReplaysBtn.addEventListener('click', async () => {
|
|
debugLog('[Main] ========== VIEW REPLAYS BUTTON CLICKED ==========');
|
|
|
|
// Initialize engine and physics if not already done
|
|
if (!this._started) {
|
|
this._started = true;
|
|
await this.initialize();
|
|
}
|
|
|
|
// Hide main menu
|
|
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
|
const editorLink = document.querySelector('.editor-link') as HTMLElement;
|
|
const settingsLink = document.querySelector('.settings-link') as HTMLElement;
|
|
|
|
if (levelSelect) {
|
|
levelSelect.style.display = 'none';
|
|
}
|
|
if (editorLink) {
|
|
editorLink.style.display = 'none';
|
|
}
|
|
if (settingsLink) {
|
|
settingsLink.style.display = 'none';
|
|
}
|
|
|
|
// Show replay selection screen
|
|
const selectionScreen = new ReplaySelectionScreen(
|
|
async (recordingId: string) => {
|
|
// Play callback - start replay
|
|
debugLog(`[Main] Starting replay for recording: ${recordingId}`);
|
|
selectionScreen.dispose();
|
|
|
|
// Create replay manager if not exists
|
|
if (!this._replayManager) {
|
|
this._replayManager = new ReplayManager(
|
|
this._engine as Engine,
|
|
() => {
|
|
// On exit callback - return to main menu
|
|
debugLog('[Main] Exiting replay, returning to menu');
|
|
if (levelSelect) {
|
|
levelSelect.style.display = 'block';
|
|
}
|
|
if (editorLink) {
|
|
editorLink.style.display = 'block';
|
|
}
|
|
if (settingsLink) {
|
|
settingsLink.style.display = 'block';
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Start replay
|
|
if (this._replayManager) {
|
|
await this._replayManager.startReplay(recordingId);
|
|
}
|
|
},
|
|
() => {
|
|
// Cancel callback - return to main menu
|
|
debugLog('[Main] Replay selection cancelled');
|
|
selectionScreen.dispose();
|
|
if (levelSelect) {
|
|
levelSelect.style.display = 'block';
|
|
}
|
|
if (editorLink) {
|
|
editorLink.style.display = 'block';
|
|
}
|
|
if (settingsLink) {
|
|
settingsLink.style.display = 'block';
|
|
}
|
|
}
|
|
);
|
|
|
|
await selectionScreen.initialize();
|
|
});
|
|
debugLog('[Main] Click listener added to view replays button');
|
|
} else {
|
|
console.warn('[Main] View Replays button not found in DOM');
|
|
}
|
|
});
|
|
}
|
|
private _started = false;
|
|
public async play() {
|
|
debugLog('[Main] play() called');
|
|
debugLog('[Main] Current level exists:', !!this._currentLevel);
|
|
this._gameState = GameState.PLAY;
|
|
|
|
if (this._currentLevel) {
|
|
debugLog('[Main] Calling level.play()...');
|
|
await this._currentLevel.play();
|
|
debugLog('[Main] level.play() completed');
|
|
} else {
|
|
console.error('[Main] ERROR: No current level to play!');
|
|
}
|
|
}
|
|
public demo() {
|
|
this._gameState = GameState.DEMO;
|
|
}
|
|
private async initialize() {
|
|
setLoadingMessage("Initializing.");
|
|
await this.setupScene();
|
|
|
|
// Try to initialize WebXR if available
|
|
if (navigator.xr) {
|
|
try {
|
|
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
|
// Don't disable pointer selection - we need it for status screen buttons
|
|
// Will detach it during gameplay and attach when status screen is shown
|
|
disableTeleportation: true,
|
|
disableNearInteraction: true,
|
|
disableHandTracking: true,
|
|
disableDefaultUI: true
|
|
});
|
|
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
|
debugLog("WebXR initialized successfully");
|
|
|
|
// Store pointer selection feature reference and detach it initially
|
|
if (DefaultScene.XR) {
|
|
const pointerFeature = DefaultScene.XR.baseExperience.featuresManager.getEnabledFeature(
|
|
"xr-controller-pointer-selection"
|
|
);
|
|
if (pointerFeature) {
|
|
(DefaultScene.XR as any).pointerSelectionFeature = pointerFeature;
|
|
// Detach immediately to prevent interaction during gameplay
|
|
pointerFeature.detach();
|
|
debugLog("Pointer selection feature stored and detached");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
debugLog("WebXR initialization failed, falling back to flat mode:", error);
|
|
DefaultScene.XR = null;
|
|
}
|
|
} else {
|
|
debugLog("WebXR not available, using flat camera mode");
|
|
DefaultScene.XR = null;
|
|
}
|
|
|
|
setLoadingMessage("Get Ready!");
|
|
|
|
//const photoDome1 = new PhotoDome("testdome", '/8192.webp', {size: 1000}, DefaultScene.MainScene);
|
|
//photoDome1.material.diffuseTexture.hasAlpha = true;
|
|
//photoDome1.material.alpha = .3;
|
|
|
|
//const photoDome2 = new PhotoDome("testdome", '/8192.webp', {size: 2000}, DefaultScene.MainScene);
|
|
//photoDome2.rotation.y = Math.PI;
|
|
//photoDome2.rotation.x = Math.PI/2;
|
|
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
|
// photoDome1.position = DefaultScene.MainScene.activeCamera.globalPosition;
|
|
// photoDome2.position = DefaultScene.MainScene.activeCamera.globalPosition;
|
|
});
|
|
}
|
|
|
|
private async setupScene() {
|
|
|
|
if (webGpu) {
|
|
this._engine = new WebGPUEngine(canvas);
|
|
debugLog("Webgpu enabled");
|
|
await (this._engine as WebGPUEngine).initAsync();
|
|
} else {
|
|
debugLog("Standard WebGL enabled");
|
|
this._engine = new Engine(canvas, true);
|
|
}
|
|
|
|
this._engine.setHardwareScalingLevel(1 / window.devicePixelRatio);
|
|
window.onresize = () => {
|
|
this._engine.resize();
|
|
}
|
|
DefaultScene.DemoScene = new Scene(this._engine);
|
|
DefaultScene.MainScene = new Scene(this._engine);
|
|
|
|
DefaultScene.MainScene.ambientColor = new Color3(.2,.2,.2);
|
|
DefaultScene.MainScene.clearColor = new Color3(0, 0, 0).toColor4();
|
|
|
|
|
|
setLoadingMessage("Initializing Physics Engine..");
|
|
await this.setupPhysics();
|
|
setLoadingMessage("Physics Engine Ready!");
|
|
|
|
// Initialize AudioEngineV2 with spatial audio support
|
|
setLoadingMessage("Initializing Audio Engine...");
|
|
this._audioEngine = await CreateAudioEngineAsync({
|
|
volume: 1.0,
|
|
listenerAutoUpdate: true,
|
|
listenerEnabled: true,
|
|
resumeOnInteraction: true
|
|
});
|
|
debugLog('Audio engine created with spatial audio enabled');
|
|
|
|
setLoadingMessage("Loading visual assets...");
|
|
ParticleHelper.BaseAssetsUrl = window.location.href;
|
|
await RockFactory.init();
|
|
setLoadingMessage("Visual assets loaded!");
|
|
|
|
|
|
window.setTimeout(()=>{
|
|
if (!this._started) {
|
|
this._started = true;
|
|
setLoadingMessage("Ready!");
|
|
}
|
|
})
|
|
|
|
this._engine.runRenderLoop(() => {
|
|
DefaultScene.MainScene.render();
|
|
});
|
|
}
|
|
|
|
private async setupPhysics() {
|
|
//DefaultScene.MainScene.useRightHandedSystem = true;
|
|
const havok = await HavokPhysics();
|
|
const havokPlugin = new HavokPlugin(true, havok);
|
|
//DefaultScene.MainScene.ambientColor = new Color3(.1, .1, .1);
|
|
|
|
//const light = new HemisphericLight("mainlight", new Vector3(-1, -1, 0), DefaultScene.MainScene);
|
|
//light.diffuse = new Color3(.4, .4, .3);
|
|
//light.groundColor = new Color3(.2, .2, .1);
|
|
//light.intensity = .5;
|
|
//light.specular = new Color3(0,0,0);
|
|
DefaultScene.MainScene.enablePhysics(new Vector3(0, 0, 0), havokPlugin);
|
|
DefaultScene.MainScene.getPhysicsEngine().setTimeStep(1/60);
|
|
DefaultScene.MainScene.getPhysicsEngine().setSubTimeStep(5);
|
|
|
|
DefaultScene.MainScene.collisionsEnabled = true;
|
|
}
|
|
}
|
|
|
|
// Setup router
|
|
router.on('/', () => {
|
|
// Check if there are saved levels
|
|
if (!hasSavedLevels()) {
|
|
debugLog('No saved levels found, redirecting to editor');
|
|
router.navigate('/editor');
|
|
return;
|
|
}
|
|
|
|
showView('game');
|
|
|
|
// Populate level selector
|
|
populateLevelSelector();
|
|
|
|
// Initialize game if not in debug mode
|
|
if (!DEBUG_CONTROLLERS) {
|
|
// Check if already initialized
|
|
if (!(window as any).__gameInitialized) {
|
|
const main = new Main();
|
|
const demo = new Demo(main);
|
|
(window as any).__gameInitialized = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
router.on('/editor', () => {
|
|
showView('editor');
|
|
// Dynamically import and initialize editor
|
|
if (!(window as any).__editorInitialized) {
|
|
import('./levelEditor').then(() => {
|
|
(window as any).__editorInitialized = true;
|
|
});
|
|
}
|
|
});
|
|
|
|
router.on('/settings', () => {
|
|
showView('settings');
|
|
// Dynamically import and initialize settings
|
|
if (!(window as any).__settingsInitialized) {
|
|
import('./settingsScreen').then((module) => {
|
|
module.initializeSettingsScreen();
|
|
(window as any).__settingsInitialized = true;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Generate default levels if localStorage is empty
|
|
generateDefaultLevels();
|
|
|
|
// Start the router after all routes are registered
|
|
router.start();
|
|
|
|
if (DEBUG_CONTROLLERS) {
|
|
debugLog('🔍 DEBUG MODE: Running minimal controller test');
|
|
// Hide the UI elements
|
|
const mainDiv = document.querySelector('#mainDiv');
|
|
if (mainDiv) {
|
|
(mainDiv as HTMLElement).style.display = 'none';
|
|
}
|
|
new ControllerDebug();
|
|
}
|
|
|
|
|
|
|