From 18a9ae997848fce62c954cace88077bb8a263b5f Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Tue, 2 Dec 2025 12:43:24 -0600 Subject: [PATCH] Refactor loading into 3-phase system to mask XR camera repositioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Prefetch assets (ship.glb, asteroid.glb, base.glb, audio) Phase 2: Create level meshes hidden (before XR entry) Phase 3: Enter XR, init physics, show meshes, unlock audio Key changes: - Split Ship.initialize() into addToScene() + initializePhysics() - Split RockFactory.createRock() into createRockMesh() + initPhysics() - Split StarBase.buildStarBase() into addToScene() + initializePhysics() - Add deserializeMeshes() + initializePhysics() to LevelDeserializer - Update Level1 to orchestrate new phased flow - Fix XR camera parenting (use getTransformNodeByName not getMeshById) - Fix asteroid visibility (show meshes before clearing _createdRocks map) - Add audioPrefetch utility for prefetching audio files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/core/handlers/levelSelectedHandler.ts | 88 ++- src/core/xrSetup.ts | 118 +++- src/environment/asteroids/explosionManager.ts | 30 +- src/environment/asteroids/rockFactory.ts | 311 +++++++---- src/environment/stations/starBase.ts | 126 ++++- src/levels/config/levelDeserializer.ts | 145 ++--- src/levels/hints/levelHintSystem.ts | 15 +- src/levels/level1.ts | 262 ++++----- src/ship/ship.ts | 510 +++++++++--------- src/ship/shipAudio.ts | 33 +- src/utils/audioPrefetch.ts | 52 ++ src/utils/loadAsset.ts | 64 ++- 12 files changed, 1058 insertions(+), 696 deletions(-) create mode 100644 src/utils/audioPrefetch.ts diff --git a/src/core/handlers/levelSelectedHandler.ts b/src/core/handlers/levelSelectedHandler.ts index 137d9e3..fa4827d 100644 --- a/src/core/handlers/levelSelectedHandler.ts +++ b/src/core/handlers/levelSelectedHandler.ts @@ -7,6 +7,8 @@ import { LevelConfig } from "../../levels/config/levelConfig"; import { Preloader } from "../../ui/screens/preloader"; import { LevelRegistry } from "../../levels/storage/levelRegistry"; import { enterXRMode } from "./xrEntryHandler"; +import { prefetchAsset } from "../../utils/loadAsset"; +import { prefetchAllAudio } from "../../utils/audioPrefetch"; import log from '../logger'; export interface LevelSelectedContext { @@ -37,8 +39,13 @@ export function createLevelSelectedHandler( context.setProgressCallback((p, m) => preloader.updateProgress(p, m)); try { + // Phase 1: Load engine and prefetch assets await loadEngineAndAssets(context, preloader); await context.initializeXR(); + + // Phase 2: Create level and add meshes (hidden) + const level = await setupLevel(context, config, levelName, preloader); + displayLevelInfo(preloader, levelName); preloader.updateProgress(90, 'Ready to enter VR...'); @@ -48,8 +55,9 @@ export function createLevelSelectedHandler( return; } + // Phase 3: Enter XR, initialize physics, audio preloader.showStartButton(async () => { - await startGameWithXR(context, config, levelName, preloader); + await startGameWithXR(context, level, preloader); }); } catch (error) { log.error('[Main] Level initialization failed:', error); @@ -71,14 +79,43 @@ async function loadEngineAndAssets(context: LevelSelectedContext, preloader: Pre await context.initializeEngine(); } if (!context.areAssetsLoaded()) { - preloader.updateProgress(40, 'Loading 3D models...'); + preloader.updateProgress(20, 'Loading assets...'); ParticleHelper.BaseAssetsUrl = window.location.href; - await RockFactory.init(); + + // Phase 1: Prefetch all GLBs and audio in parallel + await Promise.all([ + prefetchAsset("ship.glb"), + prefetchAsset("asteroid.glb"), + prefetchAsset("base.glb"), + prefetchAllAudio() + ]); + context.setAssetsLoaded(true); - preloader.updateProgress(70, 'Assets loaded'); + preloader.updateProgress(50, 'Assets loaded'); } } +/** + * Phase 2: Create level and add meshes to scene (hidden) + */ +async function setupLevel( + context: LevelSelectedContext, + config: LevelConfig, + levelName: string, + preloader: Preloader +): Promise { + preloader.updateProgress(55, 'Creating level...'); + + const level = new Level1(config, undefined, false, levelName); + context.setCurrentLevel(level); + + // Add meshes to scene (hidden - will show after XR entry) + await level.addToScene(true); + + preloader.updateProgress(80, 'Level ready'); + return level; +} + function displayLevelInfo(preloader: Preloader, levelName: string): void { const entry = LevelRegistry.getInstance().getLevelEntry(levelName); if (entry) { @@ -86,31 +123,41 @@ function displayLevelInfo(preloader: Preloader, levelName: string): void { } } +/** + * Phase 3: Enter XR, initialize physics, show meshes, initialize audio + */ async function startGameWithXR( context: LevelSelectedContext, - config: LevelConfig, - levelName: string, + level: Level1, preloader: Preloader ): Promise { - preloader.updateProgress(92, 'Entering VR...'); const engine = context.getEngine(); - const xrSession = await enterXRMode(config, engine); + const config = (level as any)._levelConfig; + preloader.updateProgress(92, 'Entering VR...'); + + // Enter XR mode + await enterXRMode(config, engine); + + // Initialize physics (Phase 3) + preloader.updateProgress(94, 'Initializing physics...'); + level.initializePhysics(); + + // Show meshes now that XR is active + level.showMeshes(); + + // Initialize audio after XR entry const audioEngine = context.getAudioEngine(); await audioEngine?.unlockAsync(); - preloader.updateProgress(95, 'Loading audio...'); - await RockFactory.initAudio(audioEngine); + preloader.updateProgress(97, 'Loading audio...'); + await Promise.all([ + RockFactory.initAudio(audioEngine), + level.initializeAudio(audioEngine) + ]); attachAudioListener(audioEngine); - preloader.updateProgress(98, 'Creating level...'); - const level = new Level1(config, audioEngine, false, levelName); - context.setCurrentLevel(level); - - level.getReadyObservable().add(async () => { - await finalizeLevelStart(level, xrSession, engine, preloader, context); - }); - - await level.initialize(); + // Finalize + await finalizeLevelStart(level, engine, preloader, context); } function attachAudioListener(audioEngine: AudioEngineV2): void { @@ -122,7 +169,6 @@ function attachAudioListener(audioEngine: AudioEngineV2): void { async function finalizeLevelStart( level: Level1, - xrSession: any, engine: Engine, preloader: Preloader, context: LevelSelectedContext @@ -130,7 +176,7 @@ async function finalizeLevelStart( const ship = (level as any)._ship; ship?.onReplayRequestObservable.add(() => window.location.reload()); - if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) { + if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) { level.setupXRCamera(); await level.showMissionBrief(); } else { diff --git a/src/core/xrSetup.ts b/src/core/xrSetup.ts index ce878d1..e405f2d 100644 --- a/src/core/xrSetup.ts +++ b/src/core/xrSetup.ts @@ -1,8 +1,23 @@ -import { WebXRDefaultExperience, WebXRFeaturesManager } from "@babylonjs/core"; +import { + WebXRDefaultExperience, WebXRFeaturesManager, WebXRFeatureName, WebXRState, + MeshBuilder, StandardMaterial, Color3, Animation, Mesh +} from "@babylonjs/core"; import { DefaultScene } from "./defaultScene"; import { InputControlManager } from "../ship/input/inputControlManager"; import log from './logger'; +const XR_RENDERING_GROUP = 3; +const FADE_DELAY_MS = 500; +const FADE_DURATION_FRAMES = 60; + +let fadeSphere: Mesh | null = null; +let xrStartTime = 0; + +function xrLog(message: string): void { + const elapsed = xrStartTime ? Date.now() - xrStartTime : 0; + log.debug(`[XR +${elapsed}ms] ${message}`); +} + export interface ProgressReporter { reportProgress(percent: number, message: string): void; } @@ -35,19 +50,108 @@ async function createXRExperience(): Promise { disableTeleportation: true, disableNearInteraction: true, disableHandTracking: true, - disableDefaultUI: true + disableDefaultUI: true, + disablePointerSelection: true // Disable to re-enable with custom options }); log.debug(WebXRFeaturesManager.GetAvailableFeatures()); + + // Enable pointer selection with renderingGroupId so laser is never occluded + DefaultScene.XR.baseExperience.featuresManager.enableFeature( + WebXRFeatureName.POINTER_SELECTION, + "stable", + { + xrInput: DefaultScene.XR.input, + renderingGroupId: XR_RENDERING_GROUP, + disablePointerUpOnTouchOut: false, + forceGazeMode: false, + disableScenePointerVectorUpdate: true // VR mode doesn't need scene pointer updates + } + ); + log.debug('Pointer selection enabled with renderingGroupId:', XR_RENDERING_GROUP); + createFadeSphere(); +} + +function createFadeSphere(): void { + const scene = DefaultScene.MainScene; + fadeSphere = MeshBuilder.CreateSphere("xrFade", { diameter: 2, sideOrientation: Mesh.BACKSIDE }, scene); + const mat = new StandardMaterial("xrFadeMat", scene); + mat.emissiveColor = Color3.Black(); + mat.disableLighting = true; + fadeSphere.material = mat; + fadeSphere.isPickable = false; + fadeSphere.setEnabled(false); // Hidden until XR entry +} + +function fadeInScene(): void { + if (!fadeSphere?.material) return; + xrLog(`Scheduling fade-in after ${FADE_DELAY_MS}ms delay`); + setTimeout(() => { + xrLog('Starting fade-in animation'); + Animation.CreateAndStartAnimation( + "xrFadeIn", fadeSphere!.material!, "alpha", + 60, FADE_DURATION_FRAMES, 1, 0, Animation.ANIMATIONLOOPMODE_CONSTANT + ); + }, FADE_DELAY_MS); } function registerXRStateHandler(): void { + const sessionMgr = DefaultScene.XR!.baseExperience.sessionManager; + const xrCamera = DefaultScene.XR!.baseExperience.camera; + + // Earliest hook - session requested and returned + sessionMgr.onXRSessionInit.add(() => { + xrLog('onXRSessionInit - session created'); + xrLog(` Camera pos: ${xrCamera.position.toString()}`); + xrLog(` Camera parent: ${xrCamera?.parent?.id}`); + }); + + // Frame-level logging (first few frames only) + let frameCount = 0; + sessionMgr.onXRFrameObservable.add(() => { + frameCount++; + if (frameCount <= 5) { + xrLog(`Frame ${frameCount} - Camera pos: ${xrCamera.position.toString()}`); + } + }); + DefaultScene.XR!.baseExperience.onStateChangedObservable.add((state) => { - if (state === 2) { - const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager - .getEnabledFeature("xr-controller-pointer-selection"); - if (pointerFeature) { - InputControlManager.getInstance().registerPointerFeature(pointerFeature); + const stateName = WebXRState[state]; + xrLog(`State: ${stateName}`); + xrLog(` Camera pos: ${xrCamera.position.toString()}`); + xrLog(` Fade sphere enabled: ${fadeSphere?.isEnabled()}`); + + if (state === WebXRState.ENTERING_XR) { + xrStartTime = Date.now(); + xrLog('ENTERING_XR - Starting XR entry'); + if (fadeSphere) { + fadeSphere.parent = xrCamera; + const cameraRig = DefaultScene.MainScene.getTransformNodeByName('xrCameraRig'); + if (!cameraRig) { + xrLog(' WARNING: xrCameraRig not found - camera will not be parented to ship'); + } else { + xrLog(` XR Camera Rig found: ${cameraRig.name}`); + xrCamera.parent = cameraRig; + } + fadeSphere.setEnabled(true); + xrLog(' Fade sphere parented and enabled'); } } + if (state === WebXRState.IN_XR) { + xrLog('IN_XR - First frame received, camera positioned'); + registerPointerFeature(); + fadeInScene(); + } + if (state === WebXRState.NOT_IN_XR && fadeSphere) { + fadeSphere.setEnabled(false); + xrStartTime = 0; + } }); } + +function registerPointerFeature(): void { + const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager + .getEnabledFeature("xr-controller-pointer-selection"); + if (pointerFeature) { + InputControlManager.getInstance().registerPointerFeature(pointerFeature); + } +} diff --git a/src/environment/asteroids/explosionManager.ts b/src/environment/asteroids/explosionManager.ts index dfe0fea..8de0bf8 100644 --- a/src/environment/asteroids/explosionManager.ts +++ b/src/environment/asteroids/explosionManager.ts @@ -7,6 +7,7 @@ import { Vector3 } from "@babylonjs/core"; import {DefaultScene} from "../../core/defaultScene"; +import { getAudioSource } from "../../utils/audioPrefetch"; import log from '../../core/logger'; /** @@ -68,27 +69,24 @@ export class ExplosionManager { */ public async initAudio(audioEngine: AudioEngineV2): Promise { this.audioEngine = audioEngine; - log.debug(`ExplosionManager: Initializing audio with pool size ${this.soundPoolSize}`); - // Create sound pool for concurrent explosions + const audioUrl = "/assets/themes/default/audio/explosion.mp3"; + const audioSource = getAudioSource(audioUrl); + const buffer = await audioEngine.createSoundBufferAsync(audioSource); + 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 - } - ); + const sound = await audioEngine.createSoundAsync(`explosionSound_${i}`, buffer, { + 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`); } diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts index b019f52..530d716 100644 --- a/src/environment/asteroids/rockFactory.ts +++ b/src/environment/asteroids/rockFactory.ts @@ -34,37 +34,84 @@ export class Rock { } } +interface RockConfig { + position: Vector3; + scale: number; + linearVelocity: Vector3; + angularVelocity: Vector3; + scoreObservable: Observable; + useOrbitConstraint: boolean; +} + export class RockFactory { private static _asteroidMesh: AbstractMesh | null = null; private static _explosionManager: ExplosionManager | null = null; private static _orbitCenter: PhysicsAggregate | null = null; + // Store created rocks for deferred physics initialization + private static _createdRocks: Map = new Map(); + /** Public getter for explosion manager (used by WeaponSystem for shape-cast hits) */ public static get explosionManager(): ExplosionManager | null { return this._explosionManager; } /** - * Initialize non-audio assets (meshes, explosion manager) - * Call this before audio engine is unlocked + * Initialize mesh only (Phase 2 - before XR) + * Just loads the asteroid mesh template, no physics */ - public static async init() { - // Initialize explosion manager - const node = new TransformNode('orbitCenter', DefaultScene.MainScene); - node.position = Vector3.Zero(); - this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 0}, DefaultScene.MainScene ); - this._orbitCenter.body.setMotionType(PhysicsMotionType.STATIC); - this._explosionManager = new ExplosionManager(DefaultScene.MainScene, { - duration: 2000, - explosionForce: 150.0, - frameRate: 60 - }); - await this._explosionManager.initialize(); - - // Reload mesh if not loaded or if it was disposed during cleanup + public static async initMesh(): Promise { if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) { await this.loadMesh(); } + + // Initialize explosion manager (visual only, audio added later) + if (!this._explosionManager) { + this._explosionManager = new ExplosionManager(DefaultScene.MainScene, { + duration: 2000, + explosionForce: 150.0, + frameRate: 60 + }); + await this._explosionManager.initialize(); + } + + log.debug('[RockFactory] Mesh initialized'); + } + + /** + * Initialize physics systems (Phase 3 - after XR) + * Creates orbit center and initializes physics for all created rocks + */ + public static initPhysics(): void { + // Create orbit center for constraints + if (!this._orbitCenter) { + const node = new TransformNode('orbitCenter', DefaultScene.MainScene); + node.position = Vector3.Zero(); + this._orbitCenter = new PhysicsAggregate( + node, PhysicsShapeType.SPHERE, + { radius: .1, mass: 0 }, + DefaultScene.MainScene + ); + this._orbitCenter.body.setMotionType(PhysicsMotionType.STATIC); + } + + // Initialize physics and show all created rocks + for (const [id, { mesh, config }] of this._createdRocks) { + this.initializeRockPhysics(mesh, config); + mesh.setEnabled(true); + mesh.isVisible = true; + } + this._createdRocks.clear(); + + log.debug('[RockFactory] Physics initialized'); + } + + /** + * Legacy init - calls initMesh + initPhysics for backwards compatibility + */ + public static async init(): Promise { + await this.initMesh(); + this.initPhysics(); } /** @@ -73,6 +120,7 @@ export class RockFactory { public static reset(): void { log.debug('[RockFactory] Resetting static state'); this._asteroidMesh = null; + this._createdRocks.clear(); if (this._explosionManager) { this._explosionManager.dispose(); this._explosionManager = null; @@ -106,119 +154,154 @@ export class RockFactory { log.debug(this._asteroidMesh); } - public static async createRock(i: number, position: Vector3, scale: number, - linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable, - useOrbitConstraint: boolean = true): Promise { - + /** + * Create rock mesh only (Phase 2 - hidden, no physics) + */ + public static createRockMesh( + i: number, + position: Vector3, + scale: number, + linearVelocity: Vector3, + angularVelocity: Vector3, + scoreObservable: Observable, + useOrbitConstraint: boolean = true, + hidden: boolean = false + ): Rock { if (!this._asteroidMesh) { - throw new Error('[RockFactory] Asteroid mesh not loaded. Call init() first.'); + throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.'); } - const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh); - log.debug(rock.id); + const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh); rock.scaling = new Vector3(scale, scale, scale); rock.position = position; - //rock.material = this._rockMaterial; rock.name = "asteroid-" + i; rock.id = "asteroid-" + i; - rock.metadata = {type: 'asteroid'}; - rock.setEnabled(true); + rock.metadata = { type: 'asteroid' }; + rock.setEnabled(!hidden); + rock.isVisible = !hidden; - // Only create physics if enabled in config - const config = GameConfig.getInstance(); - if (config.physicsEnabled) { - // PhysicsAggregate will automatically compute sphere size from mesh bounding info - // The mesh scaling is already applied, so Babylon will create correctly sized physics shape - const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, { - mass: 200, - friction: 0, - restitution: .8 - // Don't pass radius - let Babylon compute from scaled mesh bounds - }, DefaultScene.MainScene); - const body = agg.body; - body.setAngularDamping(0); + // Store config for deferred physics initialization + const config: RockConfig = { + position, + scale, + linearVelocity, + angularVelocity, + scoreObservable, + useOrbitConstraint + }; + this._createdRocks.set(rock.id, { mesh: rock, config }); + log.debug(`[RockFactory] Created rock mesh ${rock.id} (hidden: ${hidden})`); + return new Rock(rock); + } + /** + * Initialize physics for a single rock + */ + private static initializeRockPhysics(rock: InstancedMesh, config: RockConfig): void { + const gameConfig = GameConfig.getInstance(); + if (!gameConfig.physicsEnabled) return; - // Only apply orbit constraint if enabled for this level and orbit center exists - if (useOrbitConstraint && this._orbitCenter) { - log.debug(`[RockFactory] Applying orbit constraint for ${rock.name}`); - const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene); - body.addConstraint(this._orbitCenter.body, constraint); - } else { - log.debug(`[RockFactory] Orbit constraint disabled for ${rock.name} - asteroid will move freely`); - } + const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, { + mass: 200, + friction: 0, + restitution: .8 + }, DefaultScene.MainScene); - body.setLinearDamping(0) - body.setMotionType(PhysicsMotionType.DYNAMIC); - body.setCollisionCallbackEnabled(true); + const body = agg.body; + body.setAngularDamping(0); + body.setLinearDamping(0); + body.setMotionType(PhysicsMotionType.DYNAMIC); + body.setCollisionCallbackEnabled(true); - // Prevent asteroids from sleeping to ensure consistent physics simulation - const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; - if (physicsPlugin) { - physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE); - } - - log.debug(`[RockFactory] Setting velocities for ${rock.name}:`); - log.debug(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`); - log.debug(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`); - - body.setLinearVelocity(linearVelocitry); - body.setAngularVelocity(angularVelocity); - - // Verify velocities were set - const setLinear = body.getLinearVelocity(); - const setAngular = body.getAngularVelocity(); - log.debug(`[RockFactory] Linear velocity after set: ${setLinear.toString()}`); - log.debug(`[RockFactory] Angular velocity after set: ${setAngular.toString()}`); - body.getCollisionObservable().add((eventData) => { - if (eventData.type == 'COLLISION_STARTED') { - if ( eventData.collidedAgainst.transformNode.id == 'ammo') { - log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...'); - - // Get the asteroid mesh before disposing - const asteroidMesh = eventData.collider.transformNode as AbstractMesh; - const asteroidScale = asteroidMesh.scaling.x; - score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed", scale: asteroidScale}); - log.debug('[RockFactory] Asteroid mesh to explode:', { - name: asteroidMesh.name, - id: asteroidMesh.id, - position: asteroidMesh.getAbsolutePosition().toString() - }); - - // Dispose asteroid physics objects BEFORE explosion (to prevent double-disposal) - log.debug('[RockFactory] Disposing asteroid physics objects...'); - if (eventData.collider.shape) { - eventData.collider.shape.dispose(); - } - if (eventData.collider) { - eventData.collider.dispose(); - } - - // Play explosion (visual + audio handled by ExplosionManager) - // Note: ExplosionManager will dispose the asteroid mesh after explosion - if (RockFactory._explosionManager) { - RockFactory._explosionManager.playExplosion(asteroidMesh); - } - - // Dispose projectile physics objects - log.debug('[RockFactory] Disposing projectile physics objects...'); - if (eventData.collidedAgainst.shape) { - eventData.collidedAgainst.shape.dispose(); - } - if (eventData.collidedAgainst.transformNode) { - eventData.collidedAgainst.transformNode.dispose(); - } - if (eventData.collidedAgainst) { - eventData.collidedAgainst.dispose(); - } - log.debug('[RockFactory] Disposal complete'); - } - } - }); + // Apply orbit constraint if enabled + if (config.useOrbitConstraint && this._orbitCenter) { + const constraint = new DistanceConstraint( + Vector3.Distance(config.position, this._orbitCenter.body.transformNode.position), + DefaultScene.MainScene + ); + body.addConstraint(this._orbitCenter.body, constraint); } - return new Rock(rock); + // Prevent sleeping + const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; + if (physicsPlugin) { + physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE); + } + + body.setLinearVelocity(config.linearVelocity); + body.setAngularVelocity(config.angularVelocity); + + // Setup collision handler + this.setupCollisionHandler(body, config.scoreObservable); + + log.debug(`[RockFactory] Physics initialized for ${rock.id}`); + } + + private static setupCollisionHandler(body: PhysicsBody, scoreObservable: Observable): void { + body.getCollisionObservable().add((eventData) => { + if (eventData.type !== 'COLLISION_STARTED') return; + if (eventData.collidedAgainst.transformNode.id !== 'ammo') return; + + const asteroidMesh = eventData.collider.transformNode as AbstractMesh; + const asteroidScale = asteroidMesh.scaling.x; + scoreObservable.notifyObservers({ + score: 1, + remaining: -1, + message: "Asteroid Destroyed", + scale: asteroidScale + }); + + // Dispose asteroid physics + if (eventData.collider.shape) eventData.collider.shape.dispose(); + if (eventData.collider) eventData.collider.dispose(); + + // Play explosion + if (RockFactory._explosionManager) { + RockFactory._explosionManager.playExplosion(asteroidMesh); + } + + // Dispose projectile physics + if (eventData.collidedAgainst.shape) eventData.collidedAgainst.shape.dispose(); + if (eventData.collidedAgainst.transformNode) eventData.collidedAgainst.transformNode.dispose(); + if (eventData.collidedAgainst) eventData.collidedAgainst.dispose(); + }); + } + + /** + * Show all created rock meshes (no-op if initPhysics already showed them) + */ + public static showMeshes(): void { + for (const { mesh } of this._createdRocks.values()) { + mesh.setEnabled(true); + mesh.isVisible = true; + } + log.debug('[RockFactory] showMeshes called'); + } + + /** + * Legacy createRock - creates mesh with immediate physics (backwards compatible) + */ + public static async createRock( + i: number, + position: Vector3, + scale: number, + linearVelocity: Vector3, + angularVelocity: Vector3, + score: Observable, + useOrbitConstraint: boolean = true + ): Promise { + const rock = this.createRockMesh(i, position, scale, linearVelocity, angularVelocity, score, useOrbitConstraint, false); + + // Immediately initialize physics for this rock (legacy behavior) + const meshId = "asteroid-" + i; + const rockData = this._createdRocks.get(meshId); + if (rockData) { + this.initializeRockPhysics(rockData.mesh, rockData.config); + this._createdRocks.delete(meshId); + } + + return rock; } } diff --git a/src/environment/stations/starBase.ts b/src/environment/stations/starBase.ts index f73c707..2ea5c1b 100644 --- a/src/environment/stations/starBase.ts +++ b/src/environment/stations/starBase.ts @@ -13,20 +13,32 @@ import {Vector3Array} from "../../levels/config/levelConfig"; interface StarBaseResult { baseMesh: AbstractMesh; + landingMesh: AbstractMesh; landingAggregate: PhysicsAggregate | null; } +interface StarBaseMeshResult { + baseMesh: AbstractMesh; + landingMesh: AbstractMesh; + container: any; +} + /** * Create and load the star base mesh - * @param position - Position for the star base (defaults to [0, 0, 0]) - * @param baseGlbPath - Path to the base GLB file (defaults to 'base.glb') - * @returns Promise resolving to the loaded star base mesh and landing aggregate */ export default class StarBase { - public static async buildStarBase(position?: Vector3Array, baseGlbPath: string = 'base.glb'): Promise { - const config = GameConfig.getInstance(); - const scene = DefaultScene.MainScene; - const importMeshes = await loadAsset(baseGlbPath); + // Store loaded mesh data for deferred physics + private static _loadedBase: StarBaseMeshResult | null = null; + + /** + * Add base to scene (Phase 2 - mesh only, no physics) + */ + public static async addToScene( + position?: Vector3Array, + baseGlbPath: string = 'base.glb', + hidden: boolean = false + ): Promise { + const importMeshes = await loadAsset(baseGlbPath, "default", { hidden }); const baseMesh = importMeshes.meshes.get('Base'); const landingMesh = importMeshes.meshes.get('BaseLandingZone'); @@ -37,31 +49,91 @@ export default class StarBase { baseMesh.metadata.baseGlbPath = baseGlbPath; } - // Apply position to both meshes (defaults to [0, 0, 0]) - (importMeshes.container.rootNodes[0] as TransformNode).position - = position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0); + // Apply position + (importMeshes.container.rootNodes[0] as TransformNode).position = + position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0); - let landingAgg: PhysicsAggregate | null = null; + this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container }; - if (config.physicsEnabled) { - const agg2 = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, { - mass: 10000 - }, scene); - agg2.body.setMotionType(PhysicsMotionType.ANIMATED); + log.debug(`[StarBase] Added to scene (hidden: ${hidden})`); + return { baseMesh, landingMesh, container: importMeshes.container }; + } - agg2.body.getCollisionObservable().add((collidedBody) => { - log.debug('collidedBody', collidedBody); - }) - - landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH); - landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED); - landingAgg.shape.isTrigger = true; - landingAgg.body.setCollisionCallbackEnabled(true); + /** + * Initialize physics for the base (Phase 3 - after XR) + */ + public static initializePhysics(): PhysicsAggregate | null { + if (!this._loadedBase) { + log.warn('[StarBase] No loaded base to initialize physics for'); + return null; } - //importMesh.rootNodes[0].dispose(); + + const config = GameConfig.getInstance(); + if (!config.physicsEnabled) { + return null; + } + + const scene = DefaultScene.MainScene; + const { baseMesh, landingMesh } = this._loadedBase; + + // Create physics for base + const baseAgg = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, { + mass: 10000 + }, scene); + baseAgg.body.setMotionType(PhysicsMotionType.ANIMATED); + baseAgg.body.getCollisionObservable().add((collidedBody) => { + log.debug('collidedBody', collidedBody); + }); + + // Create physics for landing zone + const landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH); + landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED); + landingAgg.shape.isTrigger = true; + landingAgg.body.setCollisionCallbackEnabled(true); + + log.debug('[StarBase] Physics initialized'); + return landingAgg; + } + + /** + * Show base meshes + */ + public static showMeshes(): void { + if (this._loadedBase) { + const { baseMesh, landingMesh } = this._loadedBase; + if (baseMesh) { + baseMesh.isVisible = true; + baseMesh.setEnabled(true); + } + if (landingMesh) { + landingMesh.isVisible = true; + landingMesh.setEnabled(true); + } + log.debug('[StarBase] Meshes shown'); + } + } + + /** + * Reset static state + */ + public static reset(): void { + this._loadedBase = null; + } + + /** + * Legacy buildStarBase - for backwards compatibility + */ + public static async buildStarBase( + position?: Vector3Array, + baseGlbPath: string = 'base.glb' + ): Promise { + const meshResult = await this.addToScene(position, baseGlbPath, false); + const landingAggregate = this.initializePhysics(); + return { - baseMesh, - landingAggregate: landingAgg + baseMesh: meshResult.baseMesh, + landingMesh: meshResult.landingMesh, + landingAggregate }; } } \ No newline at end of file diff --git a/src/levels/config/levelDeserializer.ts b/src/levels/config/levelDeserializer.ts index 62a960a..861ba60 100644 --- a/src/levels/config/levelDeserializer.ts +++ b/src/levels/config/levelDeserializer.ts @@ -48,29 +48,36 @@ export class LevelDeserializer { this.config = config; } + // Store score observable for deferred physics + private _scoreObservable: Observable | null = null; + /** - * Create all entities from the configuration - * @param scoreObservable - Observable for score events + * Deserialize meshes only (Phase 2 - before XR, hidden) */ - public async deserialize( - scoreObservable: Observable + public async deserializeMeshes( + scoreObservable: Observable, + hidden: boolean = false ): Promise<{ startBase: AbstractMesh | null; - landingAggregate: PhysicsAggregate | null; sun: AbstractMesh; planets: AbstractMesh[]; asteroids: AbstractMesh[]; }> { - log.debug('Deserializing level:', this.config.difficulty); + log.debug(`[LevelDeserializer] Deserializing meshes (hidden: ${hidden})`); + this._scoreObservable = scoreObservable; - const baseResult = await this.createStartBase(); + // Create base mesh (no physics) + const baseResult = await this.createStartBaseMesh(hidden); + + // Create sun and planets (procedural, no physics needed) const sun = this.createSun(); const planets = this.createPlanets(); - const asteroids = await this.createAsteroids(scoreObservable); + + // Create asteroid meshes (no physics) + const asteroids = await this.createAsteroidMeshes(scoreObservable, hidden); return { - startBase: baseResult.baseMesh, - landingAggregate: baseResult.landingAggregate, + startBase: baseResult?.baseMesh || null, sun, planets, asteroids @@ -78,12 +85,36 @@ export class LevelDeserializer { } /** - * Create the start base from config + * Initialize physics for all entities (Phase 3 - after XR) */ - private async createStartBase() { + public initializePhysics(): PhysicsAggregate | null { + log.debug('[LevelDeserializer] Initializing physics'); + + // Initialize base physics + const landingAggregate = StarBase.initializePhysics(); + + // Initialize asteroid physics + RockFactory.initPhysics(); + + return landingAggregate; + } + + /** + * Show all meshes (call after XR entry) + */ + public showMeshes(): void { + StarBase.showMeshes(); + RockFactory.showMeshes(); + log.debug('[LevelDeserializer] All meshes shown'); + } + + /** + * Create base mesh only (no physics) + */ + private async createStartBaseMesh(hidden: boolean) { const position = this.config.startBase?.position; const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb'; - return await StarBase.buildStarBase(position, baseGlbPath); + return await StarBase.addToScene(position, baseGlbPath, hidden); } /** @@ -163,49 +194,41 @@ export class LevelDeserializer { } /** - * Create asteroids from config + * Create asteroid meshes only (no physics) */ - private async createAsteroids( - scoreObservable: Observable + private async createAsteroidMeshes( + scoreObservable: Observable, + hidden: boolean ): Promise { const asteroids: AbstractMesh[] = []; for (let i = 0; i < this.config.asteroids.length; i++) { const asteroidConfig = this.config.asteroids[i]; - - log.debug(`[LevelDeserializer] Creating asteroid ${i} (${asteroidConfig.id}):`); - log.debug(`[LevelDeserializer] Position: [${asteroidConfig.position.join(', ')}]`); - log.debug(`[LevelDeserializer] Scale: ${asteroidConfig.scale}`); - log.debug(`[LevelDeserializer] Linear velocity: [${asteroidConfig.linearVelocity.join(', ')}]`); - log.debug(`[LevelDeserializer] Angular velocity: [${asteroidConfig.angularVelocity.join(', ')}]`); - - // Use orbit constraints by default (true if not specified) const useOrbitConstraints = this.config.useOrbitConstraints !== false; - log.debug(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`); - // Use RockFactory to create the asteroid - const _rock = await RockFactory.createRock( + // Create mesh only (no physics) + RockFactory.createRockMesh( i, this.arrayToVector3(asteroidConfig.position), asteroidConfig.scale, this.arrayToVector3(asteroidConfig.linearVelocity), this.arrayToVector3(asteroidConfig.angularVelocity), scoreObservable, - useOrbitConstraints + useOrbitConstraints, + hidden ); - // Get the actual mesh from the Rock object - // The Rock class wraps the mesh, need to access it via position getter const mesh = this.scene.getMeshByName(asteroidConfig.id); if (mesh) { asteroids.push(mesh); } } - log.debug(`Created ${asteroids.length} asteroids from config`); + log.debug(`[LevelDeserializer] Created ${asteroids.length} asteroid meshes (hidden: ${hidden})`); return asteroids; } + /** * Get ship configuration (for external use to position ship) */ @@ -220,64 +243,4 @@ export class LevelDeserializer { return new Vector3(arr[0], arr[1], arr[2]); } - /** - * Static helper to load from JSON string - */ - public static fromJSON(json: string): LevelDeserializer { - const config = JSON.parse(json) as LevelConfig; - return new LevelDeserializer(config); - } - - /** - * Static helper to load from JSON file URL - */ - public static async fromURL(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to load level config from ${url}: ${response.statusText}`); - } - const json = await response.text(); - return LevelDeserializer.fromJSON(json); - } - - /** - * Static helper to load from uploaded file - */ - public static async fromFile(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - try { - const json = e.target?.result as string; - resolve(LevelDeserializer.fromJSON(json)); - } catch (error) { - reject(error); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsText(file); - }); - } - - /** - * Static helper to load from Level Registry by ID - * This is the preferred method for loading both default and custom levels - */ - public static async fromRegistry(levelId: string): Promise { - const registry = LevelRegistry.getInstance(); - - // Ensure registry is initialized - if (!registry.isInitialized()) { - await registry.initialize(); - } - - // Get level config from registry (loads if not already loaded) - const config = await registry.getLevel(levelId); - - if (!config) { - throw new Error(`Level not found in registry: ${levelId}`); - } - - return new LevelDeserializer(config); - } } diff --git a/src/levels/hints/levelHintSystem.ts b/src/levels/hints/levelHintSystem.ts index 9f836b0..cab9445 100644 --- a/src/levels/hints/levelHintSystem.ts +++ b/src/levels/hints/levelHintSystem.ts @@ -31,7 +31,14 @@ export class LevelHintSystem { // Track triggered thresholds to prevent re-triggering private _triggeredThresholds: Set = new Set(); - constructor(audioEngine: AudioEngineV2) { + constructor(audioEngine?: AudioEngineV2) { + this._audioEngine = audioEngine!; + } + + /** + * Set audio engine (for deferred initialization) + */ + public setAudioEngine(audioEngine: AudioEngineV2): void { this._audioEngine = audioEngine; } @@ -160,6 +167,12 @@ export class LevelHintSystem { * Queue a hint for audio playback */ private queueHint(hint: HintEntry): void { + // Skip if audio engine not initialized yet + if (!this._audioEngine) { + log.debug('[LevelHintSystem] Skipping hint - audio not initialized:', hint.id); + return; + } + // Check if 'once' hint already played if (hint.playMode === 'once' && this._playedHints.has(hint.id)) { return; diff --git a/src/levels/level1.ts b/src/levels/level1.ts index 5406581..e68d5ea 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -21,6 +21,8 @@ import {LevelRegistry} from "./storage/levelRegistry"; import type {CloudLevelEntry} from "../services/cloudLevelService"; import { InputControlManager } from "../ship/input/inputControlManager"; import { LevelHintSystem } from "./hints/levelHintSystem"; +import { getAudioSource } from "../utils/audioPrefetch"; +import { RockFactory } from "../environment/asteroids/rockFactory"; export class Level1 implements Level { private _ship: Ship; @@ -40,37 +42,22 @@ export class Level1 implements Level { private _hintSystem: LevelHintSystem; private _gameStarted: boolean = false; private _missionBriefShown: boolean = false; + private _asteroidCount: number = 0; - constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false, levelId?: string) { + constructor(levelConfig: LevelConfig, audioEngine?: AudioEngineV2, isReplayMode: boolean = false, levelId?: string) { this._levelConfig = levelConfig; this._levelId = levelId || null; - this._audioEngine = audioEngine; + this._audioEngine = audioEngine!; // Will be set later if not provided this._isReplayMode = isReplayMode; this._deserializer = new LevelDeserializer(levelConfig); - this._ship = new Ship(audioEngine, isReplayMode); + this._ship = new Ship(undefined, isReplayMode); // Audio initialized separately this._missionBrief = new MissionBrief(); - this._hintSystem = new LevelHintSystem(audioEngine); + this._hintSystem = new LevelHintSystem(undefined); // Audio initialized separately - // Only set up XR observables in game mode (not replay mode) - if (!isReplayMode && DefaultScene.XR) { - const xr = DefaultScene.XR; - log.debug('Level1 constructor - Setting up XR observables'); - log.debug('XR input exists:', !!xr.input); - log.debug('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable); - - xr.baseExperience.onInitialXRPoseSetObservable.add(() => { - log.debug('[Level1] onInitialXRPoseSetObservable fired'); - - // Use consolidated XR camera setup - this.setupXRCamera(); - - // Show mission brief after camera setup - log.debug('[Level1] Showing mission brief on XR entry'); - this.showMissionBrief(); - }); - } - // Don't call initialize here - let Main call it after registering the observable + // XR camera setup and mission brief are now handled by levelSelectedHandler + // after audio is initialized (see finalizeLevelStart) + // Don't call initialize here - let Main call it after setup } getReadyObservable(): Observable { @@ -99,16 +86,6 @@ export class Level1 implements Level { // Create intermediate TransformNode for camera rotation // WebXR camera only uses rotationQuaternion (not .rotation), and XR frame updates overwrite it // By rotating an intermediate node, we can orient the camera without fighting XR frame updates - const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene); - cameraRig.parent = this._ship.transformNode; - cameraRig.rotation = new Vector3(0, 0, 0); // Rotate 180° to face forward - log.debug('[Level1] Created cameraRig TransformNode, rotated 180°'); - - // Parent XR camera to the rig - xr.baseExperience.camera.parent = cameraRig; - xr.baseExperience.camera.position = new Vector3(0, 1.2, 0); - log.debug('[Level1] XR camera parented to cameraRig at position (0, 1.2, 0)'); - // Show the canvas now that camera is parented const canvas = document.getElementById('gameCanvas'); if (canvas) { @@ -156,6 +133,16 @@ export class Level1 implements Level { xr.input.onControllerAddedObservable.add((controller) => { log.debug('[Level1] 🎮 Controller added:', controller.inputSource.handedness); this._ship.addController(controller); + + // Set controller meshes to render on top (never occluded) + controller.onMeshLoadedObservable.add((mesh) => { + const XR_RENDERING_GROUP = 3; + mesh.renderingGroupId = XR_RENDERING_GROUP; + mesh.getChildMeshes().forEach((child) => { + child.renderingGroupId = XR_RENDERING_GROUP; + }); + log.debug('[Level1] Controller mesh renderingGroupId set to', XR_RENDERING_GROUP); + }); }); log.debug('[Level1] ========== setupXRCamera COMPLETE =========='); @@ -356,98 +343,90 @@ export class Level1 implements Level { } } - public async initialize() { - log.debug('Initializing level from config:', this._levelConfig.difficulty); + /** + * Initialize audio systems (call after audio engine unlock) + * Separated from initialize() to allow level creation before XR entry + */ + public async initializeAudio(audioEngine: AudioEngineV2): Promise { + log.debug('[Level1] Initializing audio systems'); + this._audioEngine = audioEngine; + + // Initialize ship audio + await this._ship.initializeAudio(audioEngine); + + // Initialize hint system audio + this._hintSystem.setAudioEngine(audioEngine); + + // Load background music (uses prefetched audio if available) + const musicUrl = "/assets/themes/default/audio/song1.mp3"; + this._backgroundMusic = await audioEngine.createSoundAsync( + "background", + getAudioSource(musicUrl), + { loop: true, volume: 0.2 } + ); + + // Initialize mission brief audio + this._missionBrief.initialize(audioEngine); + + log.debug('[Level1] Audio initialization complete'); + } + + /** + * Add level meshes to scene (Phase 2 - before XR, hidden) + */ + public async addToScene(hidden: boolean = true): Promise { + log.debug(`[Level1] addToScene called (hidden: ${hidden})`); + if (this._initialized) { - log.error('Initialize called twice'); + log.error('[Level1] Already initialized'); return; } - // Get ship config BEFORE initialize to pass position (avoids physics race condition) + + // Initialize RockFactory mesh (needs to happen before deserialize) + await RockFactory.initMesh(); + + // Get ship config and add ship to scene const shipConfig = this._deserializer.getShipConfig(); - await this._ship.initialize(new Vector3(...shipConfig.position)); + await this._ship.addToScene(new Vector3(...shipConfig.position), hidden); + + // Create XR camera rig + const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene); + //cameraRig.position.set(0, 1.2, 0); + cameraRig.parent = this._ship.transformNode; + setLoadingMessage("Loading level from configuration..."); - if (shipConfig.linearVelocity) { - this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity)); - } else { - this._ship.setLinearVelocity(Vector3.Zero()); - } - - if (shipConfig.angularVelocity) { - this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity)); - } else { - this._ship.setAngularVelocity(Vector3.Zero()); - } - - // Use deserializer to create all entities from config - const entities = await this._deserializer.deserialize(this._ship.scoreboard.onScoreObservable); + // Deserialize level meshes (no physics) + const entities = await this._deserializer.deserializeMeshes( + this._ship.scoreboard.onScoreObservable, + hidden + ); this._startBase = entities.startBase; - this._landingAggregate = entities.landingAggregate; + this._asteroidCount = entities.asteroids.length; - // Setup resupply system if landing aggregate exists - if (this._landingAggregate) { - this._ship.setLandingZone(this._landingAggregate); - } - - // sun and planets are already created by deserializer - - // Initialize scoreboard with total asteroid count - this._ship.scoreboard.setRemainingCount(entities.asteroids.length); - log.debug(`Initialized scoreboard with ${entities.asteroids.length} asteroids`); + // Initialize scoreboard with asteroid count + this._ship.scoreboard.setRemainingCount(this._asteroidCount); // Create background starfield - setLoadingMessage("Creating starfield..."); this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, { count: 5000, - radius: 5000, - minBrightness: 0.3, + radius: 3000, + minBrightness: 0.1, maxBrightness: 1.0, - pointSize: 2 + pointSize: 1 }); - // Set up camera follow for stars (keeps stars at infinite distance) - // Also update hint system audio queue + // Set up render loop updates DefaultScene.MainScene.onBeforeRenderObservable.add(() => { if (this._backgroundStars) { const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera; - if (camera) { - this._backgroundStars.followCamera(camera.position); - } + if (camera) this._backgroundStars.followCamera(camera.globalPosition); } - // Process hint audio queue this._hintSystem?.update(); }); - // Initialize physics recorder (but don't start it yet - will start on XR pose) - // Only create recorder in game mode, not replay mode - if (!this._isReplayMode) { - setLoadingMessage("Initializing physics recorder..."); - //this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig); - log.debug('Physics recorder initialized (will start on XR pose)'); - } - - // Load background music before marking as ready - if (this._audioEngine) { - setLoadingMessage("Loading background music..."); - this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", { - loop: true, - volume: 0.2 - }); - log.debug('Background music loaded successfully'); - } - - // Initialize mission brief (will be shown when entering XR) - setLoadingMessage("Initializing mission brief..."); - log.info('[Level1] ========== ABOUT TO INITIALIZE MISSION BRIEF =========='); - log.info('[Level1] _missionBrief object:', this._missionBrief); - log.info('[Level1] Ship exists:', !!this._ship); - log.info('[Level1] Ship ID in scene:', DefaultScene.MainScene.getNodeById('Ship') !== null); - this._missionBrief.initialize(this._audioEngine); - log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE =========='); - log.debug('Mission brief initialized'); - - // Initialize hint system (need UUID from registry, not slug) + // Load hints (non-physics) if (this._levelId) { const registry = LevelRegistry.getInstance(); const registryEntry = registry.getAllLevels().get(this._levelId); @@ -458,34 +437,73 @@ export class Level1 implements Level { this._ship.scoreboard.onScoreObservable, this._ship.onCollisionObservable ); - log.info('[Level1] Hint system initialized with level UUID:', registryEntry.id); } } - this._initialized = true; + // Set par time and level info + this.setupStatusScreen(); - // Set par time and level info for score calculation and results recording - const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty); - const statusScreen = this._ship.statusScreen; - log.info('[Level1] StatusScreen reference:', statusScreen); - log.info('[Level1] Level config metadata:', this._levelConfig.metadata); - log.info('[Level1] Asteroids count:', entities.asteroids.length); - if (statusScreen) { - statusScreen.setParTime(parTime); - log.info(`[Level1] Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`); + log.debug('[Level1] addToScene complete'); + } - // Set level info for game results recording - const levelId = this._levelId || 'unknown'; - const levelName = this._levelConfig.metadata?.description || 'Unknown Level'; - log.info('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length }); - statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length); - log.info('[Level1] setCurrentLevel called successfully'); - } else { - log.error('[Level1] StatusScreen is null/undefined!'); + /** + * Initialize physics for all level entities (Phase 3 - after XR) + */ + public initializePhysics(): void { + log.debug('[Level1] initializePhysics called'); + + // Initialize ship physics + this._ship.initializePhysics(); + + // Set ship velocities (needs physics body) + const shipConfig = this._deserializer.getShipConfig(); + if (shipConfig.linearVelocity) { + this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity)); + } + if (shipConfig.angularVelocity) { + this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity)); } - // Notify that initialization is complete + // Initialize deserializer physics (base, asteroids) + this._landingAggregate = this._deserializer.initializePhysics(); + + // Setup resupply system + if (this._landingAggregate) { + this._ship.setLandingZone(this._landingAggregate); + } + + this._initialized = true; this._onReadyObservable.notifyObservers(this); + + log.debug('[Level1] initializePhysics complete'); + } + + /** + * Show all level meshes (call after XR entry) + */ + public showMeshes(): void { + this._ship.showMeshes(); + this._deserializer.showMeshes(); + log.debug('[Level1] All meshes shown'); + } + + private setupStatusScreen(): void { + const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty); + const statusScreen = this._ship.statusScreen; + if (statusScreen) { + statusScreen.setParTime(parTime); + const levelId = this._levelId || 'unknown'; + const levelName = this._levelConfig.metadata?.description || 'Unknown Level'; + statusScreen.setCurrentLevel(levelId, levelName, this._asteroidCount); + } + } + + /** + * Legacy initialize - for backwards compatibility + */ + public async initialize(): Promise { + await this.addToScene(false); + this.initializePhysics(); } /** diff --git a/src/ship/ship.ts b/src/ship/ship.ts index 698fc0c..b622468 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -77,6 +77,9 @@ export class Ship { private _physicsObserver: any = null; private _renderObserver: any = null; + // Store loaded asset data for physics initialization + private _loadedAssetData: any = null; + constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) { this._audioEngine = audioEngine; this._isReplayMode = isReplayMode; @@ -155,251 +158,49 @@ export class Ship { } } - public async initialize(initialPosition?: Vector3) { - 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"); - this._ship = data.container.transformNodes[0]; + /** + * Add ship to scene (Phase 2 - before XR entry) + * Loads mesh, creates non-physics systems, optionally hidden + */ + public async addToScene(initialPosition?: Vector3, hidden: boolean = false): Promise { + log.debug(`[Ship] addToScene called (hidden: ${hidden})`); + + this._scoreboard = new Scoreboard(); + this._scoreboard.setShip(this); + this._gameStats = new GameStats(); + + // Load ship mesh (optionally hidden) + this._loadedAssetData = await loadAsset("ship.glb", "default", { hidden }); + this._ship = this._loadedAssetData.container.transformNodes[0]; - // Set position BEFORE creating physics body to avoid collision race condition if (initialPosition) { this._ship.position.copyFrom(initialPosition); } - // Create physics if enabled - const config = GameConfig.getInstance(); - if (config.physicsEnabled) { - log.info("Physics Enabled for Ship"); - if (this._ship) { - const agg = new PhysicsAggregate( - this._ship, - PhysicsShapeType.MESH, - { - mass: 10, - mesh: data.container.rootNodes[0].getChildMeshes()[0] as Mesh, - }, - DefaultScene.MainScene - ); - - agg.body.setMotionType(PhysicsMotionType.DYNAMIC); - agg.body.setLinearDamping(config.shipPhysics.linearDamping); - agg.body.setAngularDamping(config.shipPhysics.angularDamping); - agg.body.setAngularVelocity(new Vector3(0, 0, 0)); - agg.body.setCollisionCallbackEnabled(true); - - // Debug: Log center of mass before override - const massProps = agg.body.getMassProperties(); - log.info(`[Ship] Original center of mass (local): ${massProps.centerOfMass.toString()}`); - log.info(`[Ship] Mass: ${massProps.mass}`); - log.info(`[Ship] Inertia: ${massProps.inertia.toString()}`); - - // Override center of mass to origin to prevent thrust from causing torque - // (mesh-based physics was calculating offset center of mass from geometry) - agg.body.setMassProperties({ - mass: 10, - centerOfMass: new Vector3(0, 0, 0), - inertia: massProps.inertia, - inertiaOrientation: massProps.inertiaOrientation - }); - - log.info(`[Ship] Center of mass overridden to: ${agg.body.getMassProperties().centerOfMass.toString()}`); - - // Configure physics sleep behavior from config - // (disabling sleep prevents abrupt stops at zero linear velocity) - if (config.shipPhysics.alwaysActive) { - const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; - if (physicsPlugin) { - physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE); - } - } - - // Register collision handler for energy-based hull damage - const observable = agg.body.getCollisionObservable(); - 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); - log.debug(`Collision damage: ${damage.toFixed(4)} (energy: ${kineticEnergy.toFixed(1)}, speed: ${relativeSpeed.toFixed(1)} m/s)`); - - // Play collision sound - if (this._audio) { - this._audio.playCollisionSound(); - } - - // Notify collision observable for hint system - this._onCollisionObservable.notifyObservers({ - collisionType: 'any' - }); - } - } - }); - } else { - log.warn("No geometry mesh found, cannot create physics"); - } - } - - // Initialize audio system - if (this._audioEngine) { - this._audio = new ShipAudio(this._audioEngine); - await this._audio.initialize(); - - // Initialize voice audio system - this._voiceAudio = new VoiceAudioSystem(); - await this._voiceAudio.initialize(this._audioEngine); - // Subscribe voice system to ship status events - this._voiceAudio.subscribeToEvents(this._scoreboard.shipStatus); - } - - // Initialize weapon system - this._weapons = new WeaponSystem(DefaultScene.MainScene); - this._weapons.initialize(); - this._weapons.setShipStatus(this._scoreboard.shipStatus); - this._weapons.setGameStats(this._gameStats); - this._weapons.setScoreObservable(this._scoreboard.onScoreObservable); - if (this._ship.physicsBody) { - this._weapons.setShipBody(this._ship.physicsBody); - } - // Initialize input systems (skip in replay mode) if (!this._isReplayMode) { this._keyboardInput = new KeyboardInput(DefaultScene.MainScene); this._keyboardInput.setup(); - this._controllerInput = new ControllerInput(); - // Register input systems with InputControlManager const inputManager = InputControlManager.getInstance(); inputManager.registerInputSystems(this._keyboardInput, this._controllerInput); - // Wire up shooting events - this._keyboardInput.onShootObservable.add(() => { - this.handleShoot(); - }); - - this._controllerInput.onShootObservable.add(() => { - this.handleShoot(); - }); - - // Wire up status screen toggle event - this._controllerInput.onStatusScreenToggleObservable.add(() => { - if (this._statusScreen) { - if (this._statusScreen.isVisible) { - // Hide status screen - InputControlManager will handle control re-enabling - this._statusScreen.hide(); - } else { - // Show status screen (manual pause, not game end) - // InputControlManager will handle control disabling - this._statusScreen.show(false); - } - } - }); - - // Wire up inspector toggle event (Y button) - this._controllerInput.onInspectorToggleObservable.add(() => { - import('@babylonjs/inspector').then(() => { - const scene = DefaultScene.MainScene; - if (scene.debugLayer.isVisible()) { - scene.debugLayer.hide(); - } else { - scene.debugLayer.show({ overlay: true, showExplorer: true }); - } - }); - }); - - // Wire up camera adjustment events - this._keyboardInput.onCameraChangeObservable.add((cameraKey) => { - if (cameraKey === 1) { - this._camera.position.x = 15; - this._camera.rotation.y = -Math.PI / 2; - } - }); - - this._controllerInput.onCameraAdjustObservable.add((adjustment) => { - if (DefaultScene.XR?.baseExperience?.camera) { - const camera = DefaultScene.XR.baseExperience.camera; - if (adjustment.direction === "down") { - camera.position.y = camera.position.y - 0.1; - } else { - camera.position.y = camera.position.y + 0.1; - } - } - }); + this._keyboardInput.onShootObservable.add(() => this.handleShoot()); + this._controllerInput.onShootObservable.add(() => this.handleShoot()); + this._controllerInput.onStatusScreenToggleObservable.add(() => this.toggleStatusScreen()); + this._controllerInput.onInspectorToggleObservable.add(() => this.toggleInspector()); + this._keyboardInput.onCameraChangeObservable.add((key) => this.handleCameraChange(key)); + this._controllerInput.onCameraAdjustObservable.add((adj) => this.handleCameraAdjust(adj)); } - // Initialize physics controller - this._physics = new ShipPhysics(); - this._physics.setShipStatus(this._scoreboard.shipStatus); - this._physics.setGameStats(this._gameStats); - - // Setup physics update loop (every 10 frames) - let p = 0; - this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => { - - this.updatePhysics(); - - }); - let renderFrameCount = 0; - this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => { - // Update voice audio system (checks for completed sounds and plays next in queue) - if (this._voiceAudio) { - this._voiceAudio.update(); - } - // Update projectiles (shape casting collision detection) - if (this._weapons) { - const deltaTime = DefaultScene.MainScene.getEngine().getDeltaTime() / 1000; - this._weapons.update(deltaTime); - } - // Check game end conditions every 30 frames (~0.5 sec at 60fps) - if (renderFrameCount++ % 30 === 0) { - this.checkGameEndConditions(); - } - }); - - // Setup camera - this._camera = new FreeCamera( - "Flat Camera", - new Vector3(0, 1.5, 0), - DefaultScene.MainScene - ); + // Setup camera (non-physics) + this._camera = new FreeCamera("Flat Camera", new Vector3(0, 1.5, 0), DefaultScene.MainScene); this._camera.parent = this._ship; - // Rotate camera 180 degrees around Y to compensate for inverted ship GLB model this._camera.rotation = new Vector3(0, Math.PI, 0); - // Set as active camera if XR is not available if (!DefaultScene.XR && !this._isReplayMode) { DefaultScene.MainScene.activeCamera = this._camera; - //this._camera.attachControl(DefaultScene.MainScene.getEngine().getRenderingCanvas(), true); log.debug('Flat camera set as active camera'); } @@ -414,52 +215,11 @@ export class Ship { centerGap: 0.5, }); - // Initialize scoreboard (it will retrieve and setup its own screen mesh) + // Initialize scoreboard and subscribe to events this._scoreboard.initialize(); + this.setupScoreboardObservers(); - // Subscribe to score events to track asteroids destroyed - this._scoreboard.onScoreObservable.add((event) => { - // Each score event represents an asteroid destroyed, pass scale for point calc - this._gameStats.recordAsteroidDestroyed(event.scale || 1); - - // Track asteroid destruction in analytics - try { - const analytics = getAnalytics(); - analytics.track('asteroid_destroyed', { - weaponType: 'laser', - distance: 0, - asteroidSize: event.scale || 0, - remainingCount: this._scoreboard.remaining - }, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data - } catch (error) { - // Analytics not initialized or failed - don't break gameplay - log.debug('Analytics tracking failed:', error); - } - }); - - // Subscribe to ship status changes to track hull damage - this._scoreboard.shipStatus.onStatusChanged.add((event) => { - if (event.statusType === "hull" && event.delta < 0) { - // Hull damage (delta is negative) - const damageAmount = Math.abs(event.delta); - this._gameStats.recordHullDamage(damageAmount); - - // Track hull damage in analytics - try { - const analytics = getAnalytics(); - analytics.track('hull_damage', { - damageAmount: damageAmount, - remainingHull: this._scoreboard.shipStatus.hull, - damagePercent: damageAmount, - source: 'asteroid_collision' // Default assumption - }); - } catch (error) { - log.debug('Analytics tracking failed:', error); - } - } - }); - - // Initialize status screen with callbacks + // Initialize status screen this._statusScreen = new StatusScreen( DefaultScene.MainScene, this._ship, @@ -470,6 +230,220 @@ export class Ship { () => this.handleNextLevel() ); this._statusScreen.initialize(); + + log.debug('[Ship] addToScene complete'); + } + + /** + * Initialize physics (Phase 3 - after XR entry) + * Creates physics body, collision handlers, weapon system + */ + public initializePhysics(): void { + log.debug('[Ship] initializePhysics called'); + const config = GameConfig.getInstance(); + + if (!config.physicsEnabled || !this._ship || !this._loadedAssetData) { + log.warn('[Ship] Physics disabled or ship not loaded'); + return; + } + + const agg = new PhysicsAggregate( + this._ship, + PhysicsShapeType.MESH, + { + mass: 10, + mesh: this._loadedAssetData.container.rootNodes[0].getChildMeshes()[0] as Mesh, + }, + DefaultScene.MainScene + ); + + agg.body.setMotionType(PhysicsMotionType.DYNAMIC); + agg.body.setLinearDamping(config.shipPhysics.linearDamping); + agg.body.setAngularDamping(config.shipPhysics.angularDamping); + agg.body.setAngularVelocity(new Vector3(0, 0, 0)); + agg.body.setCollisionCallbackEnabled(true); + + // Override center of mass to origin + const massProps = agg.body.getMassProperties(); + agg.body.setMassProperties({ + mass: 10, + centerOfMass: new Vector3(0, 0, 0), + inertia: massProps.inertia, + inertiaOrientation: massProps.inertiaOrientation + }); + + if (config.shipPhysics.alwaysActive) { + const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin; + if (physicsPlugin) { + physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE); + } + } + + // Register collision handler + this.setupCollisionHandler(agg); + + // Initialize weapon system (needs physics) + this._weapons = new WeaponSystem(DefaultScene.MainScene); + this._weapons.initialize(); + this._weapons.setShipStatus(this._scoreboard.shipStatus); + this._weapons.setGameStats(this._gameStats); + this._weapons.setScoreObservable(this._scoreboard.onScoreObservable); + this._weapons.setShipBody(this._ship.physicsBody!); + + // Initialize physics controller + this._physics = new ShipPhysics(); + this._physics.setShipStatus(this._scoreboard.shipStatus); + this._physics.setGameStats(this._gameStats); + + // Setup update loops + this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => { + this.updatePhysics(); + }); + + let renderFrameCount = 0; + this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => { + if (this._voiceAudio) this._voiceAudio.update(); + if (this._weapons) { + const deltaTime = DefaultScene.MainScene.getEngine().getDeltaTime() / 1000; + this._weapons.update(deltaTime); + } + if (renderFrameCount++ % 30 === 0) this.checkGameEndConditions(); + }); + + log.debug('[Ship] initializePhysics complete'); + } + + /** + * Show ship meshes (call after XR entry to make visible) + */ + public showMeshes(): void { + if (this._loadedAssetData) { + for (const mesh of this._loadedAssetData.meshes.values()) { + mesh.isVisible = true; + mesh.setEnabled(true); + } + log.debug('[Ship] Meshes shown'); + } + } + + + private setupCollisionHandler(agg: PhysicsAggregate): void { + agg.body.getCollisionObservable().add((collisionEvent) => { + if (collisionEvent.type !== 'COLLISION_STARTED') return; + + const shipBody = collisionEvent.collider; + const otherBody = collisionEvent.collidedAgainst; + const relativeVelocity = shipBody.getLinearVelocity().subtract(otherBody.getLinearVelocity()); + const relativeSpeed = relativeVelocity.length(); + + const shipMass = 10; + const otherMass = otherBody.getMassProperties().mass; + const reducedMass = (shipMass * otherMass) / (shipMass + otherMass); + const kineticEnergy = 0.5 * reducedMass * relativeSpeed * relativeSpeed; + + const ENERGY_TO_DAMAGE_FACTOR = 0.01 / 1000; + const damage = Math.min(kineticEnergy * ENERGY_TO_DAMAGE_FACTOR, 0.5); + + if (this._scoreboard?.shipStatus && damage > 0.001) { + this._scoreboard.shipStatus.damageHull(damage); + log.debug(`Collision damage: ${damage.toFixed(4)}`); + if (this._audio) this._audio.playCollisionSound(); + this._onCollisionObservable.notifyObservers({ collisionType: 'any' }); + } + }); + } + + private setupScoreboardObservers(): void { + this._scoreboard.onScoreObservable.add((event) => { + this._gameStats.recordAsteroidDestroyed(event.scale || 1); + try { + const analytics = getAnalytics(); + analytics.track('asteroid_destroyed', { + weaponType: 'laser', + distance: 0, + asteroidSize: event.scale || 0, + remainingCount: this._scoreboard.remaining + }, { sampleRate: 0.2 }); + } catch (error) { + log.debug('Analytics tracking failed:', error); + } + }); + + this._scoreboard.shipStatus.onStatusChanged.add((event) => { + if (event.statusType === "hull" && event.delta < 0) { + const damageAmount = Math.abs(event.delta); + this._gameStats.recordHullDamage(damageAmount); + try { + const analytics = getAnalytics(); + analytics.track('hull_damage', { + damageAmount, + remainingHull: this._scoreboard.shipStatus.hull, + damagePercent: damageAmount / 100, + source: 'asteroid_collision' + }); + } catch (error) { + log.debug('Analytics tracking failed:', error); + } + } + }); + } + + private toggleStatusScreen(): void { + if (this._statusScreen) { + if (this._statusScreen.isVisible) { + this._statusScreen.hide(); + } else { + this._statusScreen.show(false); + } + } + } + + private toggleInspector(): void { + import('@babylonjs/inspector').then(() => { + const scene = DefaultScene.MainScene; + if (scene.debugLayer.isVisible()) { + scene.debugLayer.hide(); + } else { + scene.debugLayer.show({ overlay: true, showExplorer: true }); + } + }); + } + + private handleCameraChange(cameraKey: number): void { + if (cameraKey === 1) { + this._camera.position.x = 15; + this._camera.rotation.y = -Math.PI / 2; + } + } + + private handleCameraAdjust(adjustment: { direction: string }): void { + if (DefaultScene.XR?.baseExperience?.camera) { + const camera = DefaultScene.XR.baseExperience.camera; + camera.position.y += adjustment.direction === "down" ? -0.1 : 0.1; + } + } + + /** + * Initialize audio systems (call after audio engine is unlocked) + * Separated from initialize() to allow ship creation before XR entry + */ + public async initializeAudio(audioEngine: AudioEngineV2): Promise { + if (this._audio) { + log.debug('[Ship] Audio already initialized, skipping'); + return; + } + + this._audioEngine = audioEngine; + log.debug('[Ship] Initializing audio systems'); + + this._audio = new ShipAudio(audioEngine); + await this._audio.initialize(); + + this._voiceAudio = new VoiceAudioSystem(); + await this._voiceAudio.initialize(audioEngine); + this._voiceAudio.subscribeToEvents(this._scoreboard.shipStatus); + + log.debug('[Ship] Audio initialization complete'); } /** diff --git a/src/ship/shipAudio.ts b/src/ship/shipAudio.ts index 34b2f7d..a9a6e0c 100644 --- a/src/ship/shipAudio.ts +++ b/src/ship/shipAudio.ts @@ -1,4 +1,5 @@ import type { AudioEngineV2, StaticSound } from "@babylonjs/core"; +import { getAudioSource } from "../utils/audioPrefetch"; /** * Manages ship audio (thrust sounds and weapon fire) @@ -22,40 +23,32 @@ export class ShipAudio { public async initialize(): Promise { if (!this._audioEngine) return; + const thrustUrl = "/assets/themes/default/audio/thrust5.mp3"; + const shotUrl = "/assets/themes/default/audio/shot.mp3"; + const collisionUrl = "/assets/themes/default/audio/collision.mp3"; + this._primaryThrustSound = await this._audioEngine.createSoundAsync( "thrust", - "/assets/themes/default/audio/thrust5.mp3", - { - loop: true, - volume: 0.2, - } + getAudioSource(thrustUrl), + { loop: true, volume: 0.2 } ); this._secondaryThrustSound = await this._audioEngine.createSoundAsync( "thrust2", - "/assets/themes/default/audio/thrust5.mp3", - { - loop: true, - volume: 0.5, - } + getAudioSource(thrustUrl), + { loop: true, volume: 0.5 } ); this._weaponSound = await this._audioEngine.createSoundAsync( "shot", - "/assets/themes/default/audio/shot.mp3", - { - loop: false, - volume: 0.5, - } + getAudioSource(shotUrl), + { loop: false, volume: 0.5 } ); this._collisionSound = await this._audioEngine.createSoundAsync( "collision", - "/assets/themes/default/audio/collision.mp3", - { - loop: false, - volume: 0.25, - } + getAudioSource(collisionUrl), + { loop: false, volume: 0.25 } ); } diff --git a/src/utils/audioPrefetch.ts b/src/utils/audioPrefetch.ts new file mode 100644 index 0000000..6746018 --- /dev/null +++ b/src/utils/audioPrefetch.ts @@ -0,0 +1,52 @@ +import log from '../core/logger'; + +const AUDIO_BASE = '/assets/themes/default/audio'; + +// All audio files to prefetch +const AUDIO_FILES = [ + `${AUDIO_BASE}/explosion.mp3`, + `${AUDIO_BASE}/thrust5.mp3`, + `${AUDIO_BASE}/shot.mp3`, + `${AUDIO_BASE}/collision.mp3`, + `${AUDIO_BASE}/song1.mp3`, +]; + +// Cache for prefetched audio buffers +const prefetchedAudio: Map = new Map(); + +/** + * Prefetch all game audio files as ArrayBuffers + */ +export async function prefetchAllAudio(): Promise { + log.debug('[audioPrefetch] Prefetching all audio files...'); + + const fetches = AUDIO_FILES.map(async (url) => { + if (prefetchedAudio.has(url)) return; + try { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + prefetchedAudio.set(url, buffer); + log.debug(`[audioPrefetch] ✓ Prefetched ${url}`); + } catch (error) { + log.error(`[audioPrefetch] Failed to prefetch ${url}:`, error); + } + }); + + await Promise.all(fetches); + log.debug(`[audioPrefetch] Prefetched ${prefetchedAudio.size}/${AUDIO_FILES.length} audio files`); +} + +/** + * Get prefetched audio buffer (returns clone to avoid detached buffer issues) + */ +export function getPrefetchedAudio(url: string): ArrayBuffer | null { + const buffer = prefetchedAudio.get(url); + return buffer ? buffer.slice(0) : null; +} + +/** + * Get audio source - returns prefetched buffer or falls back to URL + */ +export function getAudioSource(url: string): ArrayBuffer | string { + return getPrefetchedAudio(url) || url; +} diff --git a/src/utils/loadAsset.ts b/src/utils/loadAsset.ts index bb382c2..345ad32 100644 --- a/src/utils/loadAsset.ts +++ b/src/utils/loadAsset.ts @@ -6,13 +6,47 @@ type LoadedAsset = { container: AssetContainer, meshes: Map, } -export default async function loadAsset(file: string, theme: string = "default"): Promise { + +interface LoadAssetOptions { + hidden?: boolean; // If true, meshes are added to scene but disabled/invisible +} + +// Cache for prefetched asset containers (not yet added to scene) +const prefetchedContainers: Map = new Map(); + +/** + * Prefetch an asset (download and parse, but don't add to scene yet) + */ +export async function prefetchAsset(file: string, theme: string = "default"): Promise { + const cacheKey = `${theme}/${file}`; + if (prefetchedContainers.has(cacheKey)) return; + + const assetPath = `/assets/themes/${theme}/models/${file}`; + log.debug(`[prefetchAsset] Prefetching: ${assetPath}`); + const container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene); + prefetchedContainers.set(cacheKey, container); + log.debug(`[prefetchAsset] ✓ Prefetched ${file}`); +} + +export default async function loadAsset( + file: string, + theme: string = "default", + options: LoadAssetOptions = {} +): Promise { + const cacheKey = `${theme}/${file}`; const assetPath = `/assets/themes/${theme}/models/${file}`; log.debug(`[loadAsset] Loading: ${assetPath}`); try { - const container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene); - log.debug(`[loadAsset] ✓ Container loaded for ${file}`); + // Use prefetched container if available, otherwise load fresh + let container = prefetchedContainers.get(cacheKey); + if (container) { + log.debug(`[loadAsset] ✓ Using prefetched container for ${file}`); + prefetchedContainers.delete(cacheKey); // Remove from cache after use + } else { + container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene); + log.debug(`[loadAsset] ✓ Container loaded for ${file}`); + } const map: Map = new Map(); container.addAllToScene(); @@ -23,17 +57,18 @@ export default async function loadAsset(file: string, theme: string = "default") return {container: container, meshes: map}; } + const shouldHide = options.hidden === true; + for (const mesh of container.rootNodes[0].getChildMeshes(false)) { log.info(mesh.id, mesh); - // Ensure mesh is visible and enabled - mesh.isVisible = true; - mesh.setEnabled(true); + + // Set visibility based on hidden option + mesh.isVisible = !shouldHide; + mesh.setEnabled(!shouldHide); // Fix emissive materials to work without lighting if (mesh.material) { const material = mesh.material as any; - - // Disable lighting on materials so emissive works without light sources if (material.disableLighting !== undefined) { material.disableLighting = true; } @@ -42,10 +77,21 @@ export default async function loadAsset(file: string, theme: string = "default") map.set(mesh.id, mesh); } - log.debug(`[loadAsset] ✓ Loaded ${map.size} meshes from ${file}`); + log.debug(`[loadAsset] ✓ Loaded ${map.size} meshes from ${file} (hidden: ${shouldHide})`); return {container: container, meshes: map}; } catch (error) { log.error(`[loadAsset] FAILED to load ${assetPath}:`, error); throw error; } +} + +/** + * Show all meshes in a loaded asset container (for assets loaded with hidden: true) + */ +export function showAssetMeshes(asset: LoadedAsset): void { + for (const mesh of asset.meshes.values()) { + mesh.isVisible = true; + mesh.setEnabled(true); + } + log.debug(`[showAssetMeshes] Showed ${asset.meshes.size} meshes`); } \ No newline at end of file