Add ENTER XR button to preloader with level info display

- Replace automatic XR entry with user-triggered ENTER XR button
- Display level name, difficulty, and mission brief during loading
- Add VR availability check with "VR not available" error for desktop
- Add deep link protection - redirect locked levels to level select
- Extract XR entry logic to xrEntryHandler.ts for code organization
- Refactor levelSelectedHandler.ts from 206 to 150 lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-01 11:51:33 -06:00
parent 4ce5f5f2de
commit 1528f54472
5 changed files with 321 additions and 257 deletions

View File

@ -817,6 +817,74 @@ body {
color: var(--color-text-disabled);
}
.preloader-level-info {
margin-bottom: var(--space-xl);
text-align: center;
}
.preloader-level-name {
font-size: var(--font-size-2xl);
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.preloader-difficulty {
display: inline-block;
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: bold;
text-transform: uppercase;
background: var(--color-bg-overlay);
color: var(--color-text-muted);
}
.preloader-difficulty.difficulty-easy {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.preloader-difficulty.difficulty-medium {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
}
.preloader-difficulty.difficulty-hard {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.preloader-mission-brief {
list-style: none;
padding: 0;
margin: var(--space-md) 0 0 0;
text-align: left;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.preloader-mission-brief li {
padding: var(--space-xs) 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.preloader-mission-brief li::before {
content: "▸ ";
color: var(--color-primary);
}
.preloader-error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-xl);
color: #f44336;
font-size: var(--font-size-base);
}
/* ============================================================================
Test Buttons & Links
========================================================================= */

View File

@ -4,6 +4,7 @@
import { Main } from '../../main';
import type { LevelConfig } from '../../levels/config/levelConfig';
import { LevelRegistry } from '../../levels/storage/levelRegistry';
import { progressionStore } from '../../stores/progression';
import log from '../../core/logger';
import { DefaultScene } from '../../core/defaultScene';
@ -85,23 +86,29 @@
throw new Error('Main instance not found');
}
// Get level config from registry
// Get full level entry from registry
const registry = LevelRegistry.getInstance();
const levelEntry = await registry.getLevel(levelName);
const levelEntry = registry.getLevelEntry(levelName);
if (!levelEntry) {
throw new Error(`Level "${levelName}" not found`);
}
// Check if level is unlocked (deep link protection)
const isDefault = levelEntry.levelType === 'official';
if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) {
log.warn('[PlayLevel] Level locked, redirecting to level select');
navigate('/', { replace: true });
return;
}
log.debug('[PlayLevel] Level config loaded:', levelEntry);
// Dispatch the levelSelected event (existing system expects this)
// We'll refactor this later to call Main methods directly
// Note: registry.getLevel() returns LevelConfig directly, not a wrapper
// Dispatch the levelSelected event
const event = new CustomEvent('levelSelected', {
detail: {
levelName: levelName,
config: levelEntry
config: levelEntry.config
}
});
window.dispatchEvent(event);

View File

@ -1,15 +1,14 @@
import { AudioEngineV2, Engine, FreeCamera, ParticleHelper, Vector3 } from "@babylonjs/core";
import { AudioEngineV2, Engine, ParticleHelper } from "@babylonjs/core";
import { DefaultScene } from "../defaultScene";
import { Level1 } from "../../levels/level1";
import Level from "../../levels/level";
import { RockFactory } from "../../environment/asteroids/rockFactory";
import { LevelConfig } from "../../levels/config/levelConfig";
import { Preloader } from "../../ui/screens/preloader";
import { LevelRegistry } from "../../levels/storage/levelRegistry";
import { enterXRMode } from "./xrEntryHandler";
import log from '../logger';
/**
* Interface for Main class methods needed by the level selected handler
*/
export interface LevelSelectedContext {
isStarted(): boolean;
setStarted(value: boolean): void;
@ -25,182 +24,127 @@ export interface LevelSelectedContext {
play(): Promise<void>;
}
/**
* Creates the levelSelected event handler
* @param context - Main instance implementing LevelSelectedContext
* @returns Event handler function
*/
export function createLevelSelectedHandler(context: LevelSelectedContext): (e: CustomEvent) => Promise<void> {
export function createLevelSelectedHandler(
context: LevelSelectedContext
): (e: CustomEvent) => Promise<void> {
return async (e: CustomEvent) => {
context.setStarted(true);
const { levelName, config } = e.detail as { levelName: string, config: LevelConfig };
const { levelName, config } = e.detail as { levelName: string; config: LevelConfig };
log.debug(`[Main] Starting level: ${levelName}`);
// Hide all UI elements
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
const appHeader = document.querySelector('#appHeader') as HTMLElement;
if (levelSelect) {
levelSelect.style.display = 'none';
}
if (appHeader) {
appHeader.style.display = 'none';
}
// Show preloader for initialization
hideUIElements();
const preloader = new Preloader();
context.setProgressCallback((percent, message) => {
preloader.updateProgress(percent, message);
});
context.setProgressCallback((p, m) => preloader.updateProgress(p, m));
try {
// Initialize engine if this is first time
if (!context.isInitialized()) {
log.debug('[Main] First level selected - initializing engine');
preloader.updateProgress(0, 'Initializing game engine...');
await context.initializeEngine();
}
// Load assets if this is the first level being played
if (!context.areAssetsLoaded()) {
preloader.updateProgress(40, 'Loading 3D models and textures...');
log.debug('[Main] Loading assets for first time');
// Load visual assets (meshes, particles)
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init();
context.setAssetsLoaded(true);
log.debug('[Main] Assets loaded successfully');
preloader.updateProgress(60, 'Assets loaded');
}
preloader.updateProgress(70, 'Preparing VR session...');
// Initialize WebXR for this level
await loadEngineAndAssets(context, preloader);
await context.initializeXR();
displayLevelInfo(preloader, levelName);
preloader.updateProgress(90, 'Ready to enter VR...');
// If XR is available, enter XR immediately (while we have user activation)
let xrSession = null;
const engine = context.getEngine();
if (DefaultScene.XR) {
try {
preloader.updateProgress(75, 'Entering VR...');
// Pre-position XR camera at ship cockpit before entering VR
// This prevents camera jump on Quest when immersive mode starts
const spawnPos = config.ship?.position || [0, 0, 0];
const cockpitPosition = new Vector3(spawnPos[0], spawnPos[1] + 1.2, spawnPos[2]);
const tempCamera = new FreeCamera("tempCockpit", cockpitPosition, DefaultScene.MainScene);
DefaultScene.XR.baseExperience.camera.setTransformationFromNonVRCamera(tempCamera, true);
tempCamera.dispose();
log.debug('[Main] XR camera pre-positioned at cockpit:', cockpitPosition.toString());
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
log.debug('XR session started successfully (render loop paused until camera is ready)');
} catch (error) {
log.debug('Failed to enter XR, will fall back to flat mode:', error);
DefaultScene.XR = null;
// Show canvas for flat mode
const canvas = document.getElementById('gameCanvas');
if (canvas) {
canvas.style.display = 'block';
}
engine.stopRenderLoop();
engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
}
const xrAvailable = await preloader.checkXRAvailability();
if (!xrAvailable) {
preloader.showVRNotAvailable();
return;
}
// Unlock audio engine on user interaction
const audioEngine = context.getAudioEngine();
if (audioEngine) {
await audioEngine.unlockAsync();
}
// Now load audio assets (after unlock)
preloader.updateProgress(80, 'Loading audio...');
await RockFactory.initAudio(audioEngine);
// Attach audio listener to camera for spatial audio
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera && audioEngine.listener) {
audioEngine.listener.attach(camera);
log.debug('[Main] Audio listener attached to camera for spatial audio');
} else {
log.warn('[Main] Could not attach audio listener - camera or listener not available');
}
preloader.updateProgress(90, 'Creating level...');
// Create and initialize level from config
const currentLevel = new Level1(config, audioEngine, false, levelName);
context.setCurrentLevel(currentLevel);
// Wait for level to be ready
currentLevel.getReadyObservable().add(async () => {
preloader.updateProgress(95, 'Starting game...');
// Get ship and set up replay observable
const level1 = currentLevel as Level1;
const ship = (level1 as any)._ship;
// Listen for replay requests from the ship
if (ship) {
ship.onReplayRequestObservable.add(() => {
log.debug('Replay requested - reloading page');
window.location.reload();
});
}
// If we entered XR before level creation, manually setup camera parenting
log.info('[Main] ========== CHECKING XR STATE ==========');
log.info('[Main] DefaultScene.XR exists:', !!DefaultScene.XR);
log.info('[Main] xrSession exists:', !!xrSession);
if (DefaultScene.XR) {
log.info('[Main] XR base experience state:', DefaultScene.XR.baseExperience.state);
}
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) {
log.debug('[Main] XR already active - using consolidated setupXRCamera()');
level1.setupXRCamera();
await level1.showMissionBrief();
log.debug('[Main] XR setup and mission brief complete');
} else {
log.info('[Main] XR not active yet - will use onInitialXRPoseSetObservable instead');
// Show canvas for non-XR mode
const canvas = document.getElementById('gameCanvas');
if (canvas) {
canvas.style.display = 'block';
}
engine.stopRenderLoop();
engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
}
// Hide preloader immediately - SceneFader handles visual transition
preloader.updateProgress(100, 'Ready!');
preloader.hide();
// Hide UI (no longer remove from DOM - let Svelte routing handle it)
log.info('[Main] ========== HIDING UI FOR GAMEPLAY ==========');
log.info('[Main] Timestamp:', Date.now());
// Start the game
log.info('[Main] About to call context.play()');
await context.play();
log.info('[Main] context.play() completed');
preloader.showStartButton(async () => {
await startGameWithXR(context, config, levelName, preloader);
});
// Now initialize the level (after observable is registered)
await currentLevel.initialize();
} catch (error) {
log.error('[Main] Level initialization failed:', error);
preloader.updateProgress(0, 'Failed to load level. Please refresh and try again.');
preloader.updateProgress(0, 'Failed to load level. Please refresh.');
}
};
}
function hideUIElements(): void {
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
const appHeader = document.querySelector('#appHeader') as HTMLElement;
if (levelSelect) levelSelect.style.display = 'none';
if (appHeader) appHeader.style.display = 'none';
}
async function loadEngineAndAssets(context: LevelSelectedContext, preloader: Preloader): Promise<void> {
if (!context.isInitialized()) {
preloader.updateProgress(0, 'Initializing game engine...');
await context.initializeEngine();
}
if (!context.areAssetsLoaded()) {
preloader.updateProgress(40, 'Loading 3D models...');
ParticleHelper.BaseAssetsUrl = window.location.href;
await RockFactory.init();
context.setAssetsLoaded(true);
preloader.updateProgress(70, 'Assets loaded');
}
}
function displayLevelInfo(preloader: Preloader, levelName: string): void {
const entry = LevelRegistry.getInstance().getLevelEntry(levelName);
if (entry) {
preloader.setLevelInfo(entry.name, entry.difficulty, entry.missionBrief || []);
}
}
async function startGameWithXR(
context: LevelSelectedContext,
config: LevelConfig,
levelName: string,
preloader: Preloader
): Promise<void> {
preloader.updateProgress(92, 'Entering VR...');
const engine = context.getEngine();
const xrSession = await enterXRMode(config, engine);
const audioEngine = context.getAudioEngine();
await audioEngine?.unlockAsync();
preloader.updateProgress(95, 'Loading audio...');
await RockFactory.initAudio(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();
}
function attachAudioListener(audioEngine: AudioEngineV2): void {
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera && audioEngine?.listener) {
audioEngine.listener.attach(camera);
}
}
async function finalizeLevelStart(
level: Level1,
xrSession: any,
engine: Engine,
preloader: Preloader,
context: LevelSelectedContext
): Promise<void> {
const ship = (level as any)._ship;
ship?.onReplayRequestObservable.add(() => window.location.reload());
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) {
level.setupXRCamera();
await level.showMissionBrief();
} else {
showCanvasForFlatMode(engine);
}
preloader.updateProgress(100, 'Ready!');
preloader.hide();
await context.play();
}
function showCanvasForFlatMode(engine: Engine): void {
const canvas = document.getElementById('gameCanvas');
if (canvas) canvas.style.display = 'block';
engine.stopRenderLoop();
engine.runRenderLoop(() => DefaultScene.MainScene.render());
}

View File

@ -0,0 +1,48 @@
import { Engine, FreeCamera, Vector3 } from "@babylonjs/core";
import { DefaultScene } from "../defaultScene";
import { LevelConfig } from "../../levels/config/levelConfig";
import log from '../logger';
/**
* Pre-positions XR camera and enters immersive VR mode
* @returns XR session if successful, null otherwise
*/
export async function enterXRMode(
config: LevelConfig,
engine: Engine
): Promise<any> {
if (!DefaultScene.XR) {
return startFlatMode(engine);
}
try {
prePositionCamera(config);
const session = await DefaultScene.XR.baseExperience.enterXRAsync(
'immersive-vr',
'local-floor'
);
log.debug('XR session started successfully');
return session;
} catch (error) {
log.debug('Failed to enter XR, falling back to flat mode:', error);
DefaultScene.XR = null;
return startFlatMode(engine);
}
}
function prePositionCamera(config: LevelConfig): void {
const spawnPos = config.ship?.position || [0, 0, 0];
const cockpitPosition = new Vector3(spawnPos[0], spawnPos[1] + 1.2, spawnPos[2]);
const tempCamera = new FreeCamera("tempCockpit", cockpitPosition, DefaultScene.MainScene);
DefaultScene.XR!.baseExperience.camera.setTransformationFromNonVRCamera(tempCamera, true);
tempCamera.dispose();
log.debug('[XR] Camera pre-positioned at cockpit:', cockpitPosition.toString());
}
function startFlatMode(engine: Engine): null {
const canvas = document.getElementById('gameCanvas');
if (canvas) canvas.style.display = 'block';
engine.stopRenderLoop();
engine.runRenderLoop(() => DefaultScene.MainScene.render());
return null;
}

View File

@ -1,12 +1,13 @@
/**
* Preloader UI - Shows loading progress and start button
* Preloader UI - Shows loading progress, level info, and ENTER XR button
*/
export class Preloader {
private container: HTMLElement | null = null;
private progressBar: HTMLElement | null = null;
private statusText: HTMLElement | null = null;
private startButton: HTMLElement | null = null;
private levelInfoEl: HTMLElement | null = null;
private errorEl: HTMLElement | null = null;
private onStartCallback: (() => void) | null = null;
constructor() {
@ -14,118 +15,114 @@ export class Preloader {
}
private createUI(): void {
// Create preloader container
this.container = document.createElement('div');
this.container.className = 'preloader';
this.container.innerHTML = this.getTemplate();
document.body.appendChild(this.container);
this.cacheElements();
this.setupButtonHandler();
}
this.container.innerHTML = `
private getTemplate(): string {
return `
<div class="preloader-content">
<h1 class="preloader-title">
🚀 Space Combat VR
</h1>
<div id="preloaderStatus" class="preloader-status">
Initializing...
<h1 class="preloader-title">🚀 Space Combat VR</h1>
<div id="preloaderLevelInfo" class="preloader-level-info" style="display: none;">
<h2 id="preloaderLevelName" class="preloader-level-name"></h2>
<span id="preloaderDifficulty" class="preloader-difficulty"></span>
<ul id="preloaderMissionBrief" class="preloader-mission-brief"></ul>
</div>
<div id="preloaderStatus" class="preloader-status">Initializing...</div>
<div class="preloader-progress-container">
<div id="preloaderProgress" class="preloader-progress"></div>
</div>
<button id="preloaderStartBtn" class="preloader-button">
Start Game
</button>
<div class="preloader-info">
<p>Initializing game engine... Assets will load when you select a level.</p>
<div id="preloaderError" class="preloader-error" style="display: none;">
VR headset not detected. This game requires a VR device.
</div>
</div>
`;
<button id="preloaderStartBtn" class="preloader-button">ENTER XR</button>
</div>`;
}
// Append to body so it's visible even when other UI elements are hidden
document.body.appendChild(this.container);
// Get references
private cacheElements(): void {
this.progressBar = document.getElementById('preloaderProgress');
this.statusText = document.getElementById('preloaderStatus');
this.startButton = document.getElementById('preloaderStartBtn');
// Add start button click handler
if (this.startButton) {
this.startButton.addEventListener('click', () => {
if (this.onStartCallback) {
this.onStartCallback();
}
});
}
this.levelInfoEl = document.getElementById('preloaderLevelInfo');
this.errorEl = document.getElementById('preloaderError');
}
private setupButtonHandler(): void {
this.startButton?.addEventListener('click', () => this.onStartCallback?.());
}
public setLevelInfo(name: string, difficulty: string, missionBrief: string[]): void {
if (!this.levelInfoEl) return;
const nameEl = document.getElementById('preloaderLevelName');
const diffEl = document.getElementById('preloaderDifficulty');
const briefEl = document.getElementById('preloaderMissionBrief');
if (nameEl) nameEl.textContent = name;
if (diffEl) {
diffEl.textContent = difficulty;
diffEl.className = `preloader-difficulty difficulty-${difficulty.toLowerCase()}`;
}
if (briefEl) {
briefEl.innerHTML = missionBrief.map(item => `<li>${item}</li>`).join('');
}
this.levelInfoEl.style.display = 'block';
}
/**
* Update loading progress
* @param percent - Progress from 0 to 100
* @param message - Status message to display
*/
public updateProgress(percent: number, message: string): void {
if (this.progressBar) {
this.progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
if (this.statusText) {
this.statusText.textContent = message;
if (this.statusText) this.statusText.textContent = message;
}
public async checkXRAvailability(): Promise<boolean> {
if (!navigator.xr) return false;
try {
return await navigator.xr.isSessionSupported('immersive-vr');
} catch {
return false;
}
}
/**
* Show the start button when loading is complete
* @param onStart - Callback to invoke when user clicks start
*/
public showStartButton(onStart: () => void): void {
this.onStartCallback = onStart;
if (this.statusText) {
this.statusText.textContent = 'All systems ready!';
}
if (this.progressBar) {
this.progressBar.style.width = '100%';
}
if (this.startButton) {
this.startButton.style.display = 'block';
// Animate button appearance
this.startButton.style.opacity = '0';
this.startButton.style.transform = 'translateY(20px)';
setTimeout(() => {
if (this.startButton) {
this.startButton.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
this.startButton.style.opacity = '1';
this.startButton.style.transform = 'translateY(0)';
}
}, 100);
}
if (this.statusText) this.statusText.textContent = 'Ready to enter VR!';
if (this.progressBar) this.progressBar.style.width = '100%';
this.animateButtonIn();
}
public showVRNotAvailable(): void {
if (this.statusText) this.statusText.textContent = 'VR Required';
if (this.progressBar) this.progressBar.style.width = '100%';
if (this.errorEl) this.errorEl.style.display = 'block';
if (this.startButton) this.startButton.style.display = 'none';
}
private animateButtonIn(): void {
if (!this.startButton) return;
this.startButton.style.display = 'block';
this.startButton.style.opacity = '0';
this.startButton.style.transform = 'translateY(20px)';
setTimeout(() => {
if (!this.startButton) return;
this.startButton.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
this.startButton.style.opacity = '1';
this.startButton.style.transform = 'translateY(0)';
}, 100);
}
/**
* Hide and remove the preloader
*/
public hide(): void {
if (this.container) {
this.container.style.transition = 'opacity 0.5s ease';
this.container.style.opacity = '0';
setTimeout(() => {
if (this.container && this.container.parentElement) {
this.container.remove();
}
}, 500);
}
if (!this.container) return;
this.container.style.transition = 'opacity 0.5s ease';
this.container.style.opacity = '0';
setTimeout(() => this.container?.remove(), 500);
}
/**
* Check if preloader exists
*/
public isVisible(): boolean {
return this.container !== null && this.container.parentElement !== null;
return this.container?.parentElement !== null;
}
}