From faa5afc604c4a96b1cb3d7e377fad5e31b1a04dc Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sun, 9 Nov 2025 06:30:59 -0600 Subject: [PATCH] Add flat camera mode support and fix WebXR user activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebXR-optional gameplay: - Removed WebXR requirement check, game now works without VR - Made WebXR initialization optional with graceful fallback - Flat camera mode automatically activates when XR unavailable - Keyboard/mouse controls work in flat camera mode - Camera following works in both XR and flat modes Fixed WebXR user activation issue: - Restructured initialization to enter XR immediately after button click - Moved enterXRAsync() before asset loading to maintain user gesture - Level1.play() now detects if XR session already active (state === 4) - Removed setTimeout delays that broke user activation chain - Falls back to flat mode if XR entry fails at any point Game initialization improvements: - Game timer and physics recorder start in both XR and flat modes - Level1 constructor only sets up XR observables if XR available - Ship.initialize() activates flat camera when XR not present - Background stars follow active camera (XR or flat) - Ready observable calls play() immediately to maintain activation User experience: - Game starts immediately in available mode (VR or flat) - Seamless fallback if VR headset disconnects or unavailable - Desktop users can now play with keyboard/mouse - No error messages blocking non-VR users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/level1.ts | 87 ++++++++++++++++++++++++++++++++++-------------- src/main.ts | 92 ++++++++++++++++++++++++++++++--------------------- src/ship.ts | 9 ++++- 3 files changed, 124 insertions(+), 64 deletions(-) diff --git a/src/level1.ts b/src/level1.ts index 9c5dedb..b285f02 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -37,7 +37,7 @@ export class Level1 implements Level { this._ship = new Ship(audioEngine, isReplayMode); // Only set up XR observables in game mode (not replay mode) - if (!isReplayMode) { + if (!isReplayMode && DefaultScene.XR) { const xr = DefaultScene.XR; debugLog('Level1 constructor - Setting up XR observables'); @@ -78,29 +78,63 @@ export class Level1 implements Level { } // Create background music using AudioEngineV2 - const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { - loop: true, - volume: 0.5 - }); - background.play(); - - // Enter XR mode - const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor'); - // Check for controllers that are already connected after entering XR - debugLog('Checking for controllers after entering XR. Count:', DefaultScene.XR.input.controllers.length); - DefaultScene.XR.input.controllers.forEach((controller, index) => { - debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`); - this._ship.addController(controller); - }); - - // Wait and check again after a delay (controllers might connect later) - debugLog('Waiting 2 seconds to check for controllers again...'); - setTimeout(() => { - debugLog('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length); - DefaultScene.XR.input.controllers.forEach((controller, index) => { - debugLog(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`); + if (this._audioEngine) { + const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { + loop: true, + volume: 0.5 }); - }, 2000); + background.play(); + } + + // If XR is available and session is active, check for controllers + if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 4) { // State 4 = IN_XR + // XR session already active, just check for controllers + debugLog('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length); + DefaultScene.XR.input.controllers.forEach((controller, index) => { + debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`); + this._ship.addController(controller); + }); + + // Wait and check again after a delay (controllers might connect later) + debugLog('Waiting 2 seconds to check for controllers again...'); + setTimeout(() => { + debugLog('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length); + DefaultScene.XR.input.controllers.forEach((controller, index) => { + debugLog(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`); + }); + }, 2000); + } else if (DefaultScene.XR) { + // XR available but not entered yet, try to enter + try { + const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor'); + debugLog('Entered XR mode from play()'); + // Check for controllers + DefaultScene.XR.input.controllers.forEach((controller, index) => { + debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`); + this._ship.addController(controller); + }); + } catch (error) { + debugLog('Failed to enter XR from play(), falling back to flat mode:', error); + // Start flat mode + this._ship.gameStats.startTimer(); + debugLog('Game timer started (flat mode)'); + + if (this._physicsRecorder) { + this._physicsRecorder.startRingBuffer(); + debugLog('Physics recorder started (flat mode)'); + } + } + } else { + // Flat camera mode - start game timer and physics recording immediately + debugLog('Playing in flat camera mode (no XR)'); + this._ship.gameStats.startTimer(); + debugLog('Game timer started'); + + if (this._physicsRecorder) { + this._physicsRecorder.startRingBuffer(); + debugLog('Physics recorder started'); + } + } } public dispose() { @@ -154,8 +188,11 @@ export class Level1 implements Level { // Set up camera follow for stars (keeps stars at infinite distance) DefaultScene.MainScene.onBeforeRenderObservable.add(() => { - if (this._backgroundStars && DefaultScene.XR.baseExperience.camera) { - this._backgroundStars.followCamera(DefaultScene.XR.baseExperience.camera.position); + if (this._backgroundStars) { + const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera; + if (camera) { + this._backgroundStars.followCamera(camera.position); + } } }); diff --git a/src/main.ts b/src/main.ts index 4b9d78d..d8fcaf8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,16 +45,9 @@ export class Main { private _audioEngine: AudioEngineV2; private _replayManager: ReplayManager | null = null; constructor() { - if (!navigator.xr) { - setLoadingMessage("This browser does not support WebXR"); - return; - } - - // Listen for level selection event window.addEventListener('levelSelected', async (e: CustomEvent) => { this._started = true; - await this.initialize(); const {levelName, config} = e.detail as {levelName: string, config: LevelConfig}; debugLog(`Starting level: ${levelName}`); @@ -74,25 +67,43 @@ export class Main { if (settingsLink) { settingsLink.style.display = 'none'; } - setLoadingMessage("Initializing Level..."); + 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(); } + 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(() => { - setLoadingMessage("Level Ready! Entering VR..."); + this._currentLevel.getReadyObservable().add(async () => { + setLoadingMessage("Starting game..."); - // Small delay to show message - setTimeout(() => { - mainDiv.remove(); - this.play(); - }, 500); + // 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) @@ -146,21 +157,17 @@ export class Main { // Wait for level to be ready debugLog('[Main] Registering ready observable...'); - this._currentLevel.getReadyObservable().add(() => { + this._currentLevel.getReadyObservable().add(async () => { debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED =========='); setLoadingMessage("Test Scene Ready! Entering VR..."); - debugLog('[Main] Setting timeout to enter VR...'); - // Small delay to show message - setTimeout(() => { - debugLog('[Main] Timeout fired, removing mainDiv and calling play()'); - if (mainDiv) { - mainDiv.remove(); - debugLog('[Main] mainDiv removed'); - } - debugLog('[Main] About to call this.play()...'); - this.play(); - }, 500); + // 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'); @@ -280,17 +287,26 @@ export class Main { setLoadingMessage("Initializing."); await this.setupScene(); - DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, { - disablePointerSelection: true, - disableTeleportation: true, - disableNearInteraction: true, - disableHandTracking: true, - disableDefaultUI: true - - }); - debugLog(WebXRFeaturesManager.GetAvailableFeatures()); - //DefaultScene.XR.baseExperience.featuresManager.enableFeature(WebXRFeatureName.LAYERS, "latest", {preferMultiviewOnInit: true}); - + // Try to initialize WebXR if available + if (navigator.xr) { + try { + DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, { + disablePointerSelection: true, + disableTeleportation: true, + disableNearInteraction: true, + disableHandTracking: true, + disableDefaultUI: true + }); + debugLog(WebXRFeaturesManager.GetAvailableFeatures()); + debugLog("WebXR initialized successfully"); + } 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!"); diff --git a/src/ship.ts b/src/ship.ts index a19e94e..797d359 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -200,11 +200,18 @@ export class Ship { // Setup camera this._camera = new FreeCamera( "Flat Camera", - new Vector3(0, 0.5, 0), + new Vector3(0, 1.5, 0), DefaultScene.MainScene ); this._camera.parent = this._ship; + // 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); + debugLog('Flat camera set as active camera'); + } + // Create sight reticle this._sight = new Sight(DefaultScene.MainScene, this._ship, { position: new Vector3(0, 0.1, 125),