Add flat camera mode support and fix WebXR user activation
Some checks failed
Build / build (push) Failing after 24s
Some checks failed
Build / build (push) Failing after 24s
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 <noreply@anthropic.com>
This commit is contained in:
parent
343fca4889
commit
faa5afc604
@ -37,7 +37,7 @@ export class Level1 implements Level {
|
|||||||
this._ship = new Ship(audioEngine, isReplayMode);
|
this._ship = new Ship(audioEngine, isReplayMode);
|
||||||
|
|
||||||
// Only set up XR observables in game mode (not replay mode)
|
// Only set up XR observables in game mode (not replay mode)
|
||||||
if (!isReplayMode) {
|
if (!isReplayMode && DefaultScene.XR) {
|
||||||
const xr = DefaultScene.XR;
|
const xr = DefaultScene.XR;
|
||||||
|
|
||||||
debugLog('Level1 constructor - Setting up XR observables');
|
debugLog('Level1 constructor - Setting up XR observables');
|
||||||
@ -78,29 +78,63 @@ export class Level1 implements Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create background music using AudioEngineV2
|
// Create background music using AudioEngineV2
|
||||||
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
if (this._audioEngine) {
|
||||||
loop: true,
|
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
||||||
volume: 0.5
|
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}`);
|
|
||||||
});
|
});
|
||||||
}, 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() {
|
public dispose() {
|
||||||
@ -154,8 +188,11 @@ export class Level1 implements Level {
|
|||||||
|
|
||||||
// Set up camera follow for stars (keeps stars at infinite distance)
|
// Set up camera follow for stars (keeps stars at infinite distance)
|
||||||
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
||||||
if (this._backgroundStars && DefaultScene.XR.baseExperience.camera) {
|
if (this._backgroundStars) {
|
||||||
this._backgroundStars.followCamera(DefaultScene.XR.baseExperience.camera.position);
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||||
|
if (camera) {
|
||||||
|
this._backgroundStars.followCamera(camera.position);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
92
src/main.ts
92
src/main.ts
@ -45,16 +45,9 @@ export class Main {
|
|||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
private _replayManager: ReplayManager | null = null;
|
private _replayManager: ReplayManager | null = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!navigator.xr) {
|
|
||||||
setLoadingMessage("This browser does not support WebXR");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Listen for level selection event
|
// Listen for level selection event
|
||||||
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
||||||
this._started = true;
|
this._started = true;
|
||||||
await this.initialize();
|
|
||||||
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
||||||
|
|
||||||
debugLog(`Starting level: ${levelName}`);
|
debugLog(`Starting level: ${levelName}`);
|
||||||
@ -74,25 +67,43 @@ export class Main {
|
|||||||
if (settingsLink) {
|
if (settingsLink) {
|
||||||
settingsLink.style.display = 'none';
|
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
|
// Unlock audio engine on user interaction
|
||||||
if (this._audioEngine) {
|
if (this._audioEngine) {
|
||||||
await this._audioEngine.unlockAsync();
|
await this._audioEngine.unlockAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoadingMessage("Loading level...");
|
||||||
|
|
||||||
// Create and initialize level from config
|
// Create and initialize level from config
|
||||||
this._currentLevel = new Level1(config, this._audioEngine);
|
this._currentLevel = new Level1(config, this._audioEngine);
|
||||||
|
|
||||||
// Wait for level to be ready
|
// Wait for level to be ready
|
||||||
this._currentLevel.getReadyObservable().add(() => {
|
this._currentLevel.getReadyObservable().add(async () => {
|
||||||
setLoadingMessage("Level Ready! Entering VR...");
|
setLoadingMessage("Starting game...");
|
||||||
|
|
||||||
// Small delay to show message
|
// Remove UI
|
||||||
setTimeout(() => {
|
mainDiv.remove();
|
||||||
mainDiv.remove();
|
|
||||||
this.play();
|
// Start the game (XR session already active, or flat mode)
|
||||||
}, 500);
|
await this.play();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now initialize the level (after observable is registered)
|
// Now initialize the level (after observable is registered)
|
||||||
@ -146,21 +157,17 @@ export class Main {
|
|||||||
|
|
||||||
// Wait for level to be ready
|
// Wait for level to be ready
|
||||||
debugLog('[Main] Registering ready observable...');
|
debugLog('[Main] Registering ready observable...');
|
||||||
this._currentLevel.getReadyObservable().add(() => {
|
this._currentLevel.getReadyObservable().add(async () => {
|
||||||
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
||||||
setLoadingMessage("Test Scene Ready! Entering VR...");
|
setLoadingMessage("Test Scene Ready! Entering VR...");
|
||||||
debugLog('[Main] Setting timeout to enter VR...');
|
|
||||||
|
|
||||||
// Small delay to show message
|
// Remove UI and play immediately (must maintain user activation for XR)
|
||||||
setTimeout(() => {
|
if (mainDiv) {
|
||||||
debugLog('[Main] Timeout fired, removing mainDiv and calling play()');
|
mainDiv.remove();
|
||||||
if (mainDiv) {
|
debugLog('[Main] mainDiv removed');
|
||||||
mainDiv.remove();
|
}
|
||||||
debugLog('[Main] mainDiv removed');
|
debugLog('[Main] About to call this.play()...');
|
||||||
}
|
await this.play();
|
||||||
debugLog('[Main] About to call this.play()...');
|
|
||||||
this.play();
|
|
||||||
}, 500);
|
|
||||||
});
|
});
|
||||||
debugLog('[Main] Ready observable registered');
|
debugLog('[Main] Ready observable registered');
|
||||||
|
|
||||||
@ -280,17 +287,26 @@ export class Main {
|
|||||||
setLoadingMessage("Initializing.");
|
setLoadingMessage("Initializing.");
|
||||||
await this.setupScene();
|
await this.setupScene();
|
||||||
|
|
||||||
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
// Try to initialize WebXR if available
|
||||||
disablePointerSelection: true,
|
if (navigator.xr) {
|
||||||
disableTeleportation: true,
|
try {
|
||||||
disableNearInteraction: true,
|
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
||||||
disableHandTracking: true,
|
disablePointerSelection: true,
|
||||||
disableDefaultUI: true
|
disableTeleportation: true,
|
||||||
|
disableNearInteraction: true,
|
||||||
});
|
disableHandTracking: true,
|
||||||
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
disableDefaultUI: true
|
||||||
//DefaultScene.XR.baseExperience.featuresManager.enableFeature(WebXRFeatureName.LAYERS, "latest", {preferMultiviewOnInit: 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!");
|
setLoadingMessage("Get Ready!");
|
||||||
|
|
||||||
|
|||||||
@ -200,11 +200,18 @@ export class Ship {
|
|||||||
// Setup camera
|
// Setup camera
|
||||||
this._camera = new FreeCamera(
|
this._camera = new FreeCamera(
|
||||||
"Flat Camera",
|
"Flat Camera",
|
||||||
new Vector3(0, 0.5, 0),
|
new Vector3(0, 1.5, 0),
|
||||||
DefaultScene.MainScene
|
DefaultScene.MainScene
|
||||||
);
|
);
|
||||||
this._camera.parent = this._ship;
|
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
|
// Create sight reticle
|
||||||
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
|
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
|
||||||
position: new Vector3(0, 0.1, 125),
|
position: new Vector3(0, 0.1, 125),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user