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:
parent
4ce5f5f2de
commit
1528f54472
@ -817,6 +817,74 @@ body {
|
|||||||
color: var(--color-text-disabled);
|
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
|
Test Buttons & Links
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { Main } from '../../main';
|
import { Main } from '../../main';
|
||||||
import type { LevelConfig } from '../../levels/config/levelConfig';
|
import type { LevelConfig } from '../../levels/config/levelConfig';
|
||||||
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
||||||
|
import { progressionStore } from '../../stores/progression';
|
||||||
import log from '../../core/logger';
|
import log from '../../core/logger';
|
||||||
import { DefaultScene } from '../../core/defaultScene';
|
import { DefaultScene } from '../../core/defaultScene';
|
||||||
|
|
||||||
@ -85,23 +86,29 @@
|
|||||||
throw new Error('Main instance not found');
|
throw new Error('Main instance not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get level config from registry
|
// Get full level entry from registry
|
||||||
const registry = LevelRegistry.getInstance();
|
const registry = LevelRegistry.getInstance();
|
||||||
const levelEntry = await registry.getLevel(levelName);
|
const levelEntry = registry.getLevelEntry(levelName);
|
||||||
|
|
||||||
if (!levelEntry) {
|
if (!levelEntry) {
|
||||||
throw new Error(`Level "${levelName}" not found`);
|
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);
|
log.debug('[PlayLevel] Level config loaded:', levelEntry);
|
||||||
|
|
||||||
// Dispatch the levelSelected event (existing system expects this)
|
// Dispatch the levelSelected event
|
||||||
// We'll refactor this later to call Main methods directly
|
|
||||||
// Note: registry.getLevel() returns LevelConfig directly, not a wrapper
|
|
||||||
const event = new CustomEvent('levelSelected', {
|
const event = new CustomEvent('levelSelected', {
|
||||||
detail: {
|
detail: {
|
||||||
levelName: levelName,
|
levelName: levelName,
|
||||||
config: levelEntry
|
config: levelEntry.config
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|||||||
@ -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 { DefaultScene } from "../defaultScene";
|
||||||
import { Level1 } from "../../levels/level1";
|
import { Level1 } from "../../levels/level1";
|
||||||
import Level from "../../levels/level";
|
import Level from "../../levels/level";
|
||||||
import { RockFactory } from "../../environment/asteroids/rockFactory";
|
import { RockFactory } from "../../environment/asteroids/rockFactory";
|
||||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||||
import { Preloader } from "../../ui/screens/preloader";
|
import { Preloader } from "../../ui/screens/preloader";
|
||||||
|
import { LevelRegistry } from "../../levels/storage/levelRegistry";
|
||||||
|
import { enterXRMode } from "./xrEntryHandler";
|
||||||
import log from '../logger';
|
import log from '../logger';
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for Main class methods needed by the level selected handler
|
|
||||||
*/
|
|
||||||
export interface LevelSelectedContext {
|
export interface LevelSelectedContext {
|
||||||
isStarted(): boolean;
|
isStarted(): boolean;
|
||||||
setStarted(value: boolean): void;
|
setStarted(value: boolean): void;
|
||||||
@ -25,182 +24,127 @@ export interface LevelSelectedContext {
|
|||||||
play(): Promise<void>;
|
play(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function createLevelSelectedHandler(
|
||||||
* Creates the levelSelected event handler
|
context: LevelSelectedContext
|
||||||
* @param context - Main instance implementing LevelSelectedContext
|
): (e: CustomEvent) => Promise<void> {
|
||||||
* @returns Event handler function
|
|
||||||
*/
|
|
||||||
export function createLevelSelectedHandler(context: LevelSelectedContext): (e: CustomEvent) => Promise<void> {
|
|
||||||
return async (e: CustomEvent) => {
|
return async (e: CustomEvent) => {
|
||||||
context.setStarted(true);
|
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}`);
|
log.debug(`[Main] Starting level: ${levelName}`);
|
||||||
|
|
||||||
// Hide all UI elements
|
hideUIElements();
|
||||||
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
|
|
||||||
const preloader = new Preloader();
|
const preloader = new Preloader();
|
||||||
context.setProgressCallback((percent, message) => {
|
context.setProgressCallback((p, m) => preloader.updateProgress(p, m));
|
||||||
preloader.updateProgress(percent, message);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize engine if this is first time
|
await loadEngineAndAssets(context, preloader);
|
||||||
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 context.initializeXR();
|
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)
|
const xrAvailable = await preloader.checkXRAvailability();
|
||||||
let xrSession = null;
|
if (!xrAvailable) {
|
||||||
const engine = context.getEngine();
|
preloader.showVRNotAvailable();
|
||||||
if (DefaultScene.XR) {
|
return;
|
||||||
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(() => {
|
preloader.showStartButton(async () => {
|
||||||
DefaultScene.MainScene.render();
|
await startGameWithXR(context, config, levelName, preloader);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now initialize the level (after observable is registered)
|
|
||||||
await currentLevel.initialize();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('[Main] Level initialization failed:', 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());
|
||||||
|
}
|
||||||
|
|||||||
48
src/core/handlers/xrEntryHandler.ts
Normal file
48
src/core/handlers/xrEntryHandler.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 {
|
export class Preloader {
|
||||||
private container: HTMLElement | null = null;
|
private container: HTMLElement | null = null;
|
||||||
private progressBar: HTMLElement | null = null;
|
private progressBar: HTMLElement | null = null;
|
||||||
private statusText: HTMLElement | null = null;
|
private statusText: HTMLElement | null = null;
|
||||||
private startButton: HTMLElement | null = null;
|
private startButton: HTMLElement | null = null;
|
||||||
|
private levelInfoEl: HTMLElement | null = null;
|
||||||
|
private errorEl: HTMLElement | null = null;
|
||||||
private onStartCallback: (() => void) | null = null;
|
private onStartCallback: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -14,118 +15,114 @@ export class Preloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createUI(): void {
|
private createUI(): void {
|
||||||
// Create preloader container
|
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement('div');
|
||||||
this.container.className = 'preloader';
|
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">
|
<div class="preloader-content">
|
||||||
<h1 class="preloader-title">
|
<h1 class="preloader-title">🚀 Space Combat VR</h1>
|
||||||
🚀 Space Combat VR
|
<div id="preloaderLevelInfo" class="preloader-level-info" style="display: none;">
|
||||||
</h1>
|
<h2 id="preloaderLevelName" class="preloader-level-name"></h2>
|
||||||
|
<span id="preloaderDifficulty" class="preloader-difficulty"></span>
|
||||||
<div id="preloaderStatus" class="preloader-status">
|
<ul id="preloaderMissionBrief" class="preloader-mission-brief"></ul>
|
||||||
Initializing...
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="preloaderStatus" class="preloader-status">Initializing...</div>
|
||||||
<div class="preloader-progress-container">
|
<div class="preloader-progress-container">
|
||||||
<div id="preloaderProgress" class="preloader-progress"></div>
|
<div id="preloaderProgress" class="preloader-progress"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="preloaderError" class="preloader-error" style="display: none;">
|
||||||
<button id="preloaderStartBtn" class="preloader-button">
|
VR headset not detected. This game requires a VR device.
|
||||||
Start Game
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="preloader-info">
|
|
||||||
<p>Initializing game engine... Assets will load when you select a level.</p>
|
|
||||||
</div>
|
</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
|
private cacheElements(): void {
|
||||||
document.body.appendChild(this.container);
|
|
||||||
|
|
||||||
// Get references
|
|
||||||
this.progressBar = document.getElementById('preloaderProgress');
|
this.progressBar = document.getElementById('preloaderProgress');
|
||||||
this.statusText = document.getElementById('preloaderStatus');
|
this.statusText = document.getElementById('preloaderStatus');
|
||||||
this.startButton = document.getElementById('preloaderStartBtn');
|
this.startButton = document.getElementById('preloaderStartBtn');
|
||||||
|
this.levelInfoEl = document.getElementById('preloaderLevelInfo');
|
||||||
// Add start button click handler
|
this.errorEl = document.getElementById('preloaderError');
|
||||||
if (this.startButton) {
|
}
|
||||||
this.startButton.addEventListener('click', () => {
|
|
||||||
if (this.onStartCallback) {
|
private setupButtonHandler(): void {
|
||||||
this.onStartCallback();
|
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 {
|
public updateProgress(percent: number, message: string): void {
|
||||||
if (this.progressBar) {
|
if (this.progressBar) {
|
||||||
this.progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
|
this.progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
|
||||||
}
|
}
|
||||||
if (this.statusText) {
|
if (this.statusText) this.statusText.textContent = message;
|
||||||
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 {
|
public showStartButton(onStart: () => void): void {
|
||||||
this.onStartCallback = onStart;
|
this.onStartCallback = onStart;
|
||||||
|
if (this.statusText) this.statusText.textContent = 'Ready to enter VR!';
|
||||||
if (this.statusText) {
|
if (this.progressBar) this.progressBar.style.width = '100%';
|
||||||
this.statusText.textContent = 'All systems ready!';
|
this.animateButtonIn();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.progressBar) {
|
public showVRNotAvailable(): void {
|
||||||
this.progressBar.style.width = '100%';
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.startButton) {
|
private animateButtonIn(): void {
|
||||||
|
if (!this.startButton) return;
|
||||||
this.startButton.style.display = 'block';
|
this.startButton.style.display = 'block';
|
||||||
|
|
||||||
// Animate button appearance
|
|
||||||
this.startButton.style.opacity = '0';
|
this.startButton.style.opacity = '0';
|
||||||
this.startButton.style.transform = 'translateY(20px)';
|
this.startButton.style.transform = 'translateY(20px)';
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.startButton) {
|
if (!this.startButton) return;
|
||||||
this.startButton.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
this.startButton.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
||||||
this.startButton.style.opacity = '1';
|
this.startButton.style.opacity = '1';
|
||||||
this.startButton.style.transform = 'translateY(0)';
|
this.startButton.style.transform = 'translateY(0)';
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide and remove the preloader
|
|
||||||
*/
|
|
||||||
public hide(): void {
|
public hide(): void {
|
||||||
if (this.container) {
|
if (!this.container) return;
|
||||||
this.container.style.transition = 'opacity 0.5s ease';
|
this.container.style.transition = 'opacity 0.5s ease';
|
||||||
this.container.style.opacity = '0';
|
this.container.style.opacity = '0';
|
||||||
|
setTimeout(() => this.container?.remove(), 500);
|
||||||
setTimeout(() => {
|
|
||||||
if (this.container && this.container.parentElement) {
|
|
||||||
this.container.remove();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if preloader exists
|
|
||||||
*/
|
|
||||||
public isVisible(): boolean {
|
public isVisible(): boolean {
|
||||||
return this.container !== null && this.container.parentElement !== null;
|
return this.container?.parentElement !== null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user