From 1528f54472cb4c00fe63f153bf3d2e276f93fd1f Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 1 Dec 2025 11:51:33 -0600 Subject: [PATCH] Add ENTER XR button to preloader with level info display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/styles.css | 68 ++++++ src/components/game/PlayLevel.svelte | 19 +- src/core/handlers/levelSelectedHandler.ts | 276 +++++++++------------- src/core/handlers/xrEntryHandler.ts | 48 ++++ src/ui/screens/preloader.ts | 167 +++++++------ 5 files changed, 321 insertions(+), 257 deletions(-) create mode 100644 src/core/handlers/xrEntryHandler.ts diff --git a/public/styles.css b/public/styles.css index 0b011ae..bb34659 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 ========================================================================= */ diff --git a/src/components/game/PlayLevel.svelte b/src/components/game/PlayLevel.svelte index 3ebb591..37e4d84 100644 --- a/src/components/game/PlayLevel.svelte +++ b/src/components/game/PlayLevel.svelte @@ -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); diff --git a/src/core/handlers/levelSelectedHandler.ts b/src/core/handlers/levelSelectedHandler.ts index 48cd549..137d9e3 100644 --- a/src/core/handlers/levelSelectedHandler.ts +++ b/src/core/handlers/levelSelectedHandler.ts @@ -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; } -/** - * Creates the levelSelected event handler - * @param context - Main instance implementing LevelSelectedContext - * @returns Event handler function - */ -export function createLevelSelectedHandler(context: LevelSelectedContext): (e: CustomEvent) => Promise { +export function createLevelSelectedHandler( + context: LevelSelectedContext +): (e: CustomEvent) => Promise { 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 { + 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 { + 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 { + 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()); +} diff --git a/src/core/handlers/xrEntryHandler.ts b/src/core/handlers/xrEntryHandler.ts new file mode 100644 index 0000000..3803563 --- /dev/null +++ b/src/core/handlers/xrEntryHandler.ts @@ -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 { + 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; +} diff --git a/src/ui/screens/preloader.ts b/src/ui/screens/preloader.ts index 186842b..0100509 100644 --- a/src/ui/screens/preloader.ts +++ b/src/ui/screens/preloader.ts @@ -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 `
-

- 🚀 Space Combat VR -

- -
- Initializing... +

🚀 Space Combat VR

+ - +
Initializing...
- - - -
-

Initializing game engine... Assets will load when you select a level.

+ -
- `; + +
`; + } - // 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 => `
  • ${item}
  • `).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 { + 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; } }