space-game/src/levels/level1.ts
Michael Mainguy b46f44e32d
All checks were successful
Build / build (push) Successful in 1m45s
Performance fixes and debug features
- Fix duplicate render loops causing 50% FPS drop (70→40)
  - Add stopRenderLoop() before runRenderLoop() in level1.ts and levelSelectedHandler.ts
- Add ?loglevel=debug|info|warn|error query parameter
- Add Y button to toggle inspector in XR
- Throttle scoreboard updates to every 10 frames
- Throttle game-end condition checks to every 30 frames
- Remove per-frame logging from explosion animations
- Reduce background stars from 5000 to 2500
- Freeze asteroid material after loading
- Reduce physics substeps from 5 to 2
- Disable autoClear for Quest 2 performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:58:15 -06:00

487 lines
21 KiB
TypeScript

import {DefaultScene} from "../core/defaultScene";
import type {AudioEngineV2, StaticSound} from "@babylonjs/core";
import {
AbstractMesh,
Observable,
PhysicsAggregate,
TransformNode,
Vector3,
WebXRState
} from "@babylonjs/core";
import {Ship} from "../ship/ship";
import Level from "./level";
import setLoadingMessage from "../utils/setLoadingMessage";
import {LevelConfig} from "./config/levelConfig";
import {LevelDeserializer} from "./config/levelDeserializer";
import {BackgroundStars} from "../environment/background/backgroundStars";
import log from '../core/logger';
import {getAnalytics} from "../analytics";
import {MissionBrief} from "../ui/hud/missionBrief";
import {LevelRegistry} from "./storage/levelRegistry";
import type {CloudLevelEntry} from "../services/cloudLevelService";
import { InputControlManager } from "../ship/input/inputControlManager";
export class Level1 implements Level {
private _ship: Ship;
private _onReadyObservable: Observable<Level> = new Observable<Level>();
private _initialized: boolean = false;
private _startBase: AbstractMesh | null;
private _landingAggregate: PhysicsAggregate | null;
private _endBase: AbstractMesh;
private _levelConfig: LevelConfig;
private _levelId: string | null = null;
private _audioEngine: AudioEngineV2;
private _deserializer: LevelDeserializer;
private _backgroundStars: BackgroundStars;
private _isReplayMode: boolean;
private _backgroundMusic: StaticSound;
private _missionBrief: MissionBrief;
private _gameStarted: boolean = false;
private _missionBriefShown: boolean = false;
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false, levelId?: string) {
this._levelConfig = levelConfig;
this._levelId = levelId || null;
this._audioEngine = audioEngine;
this._isReplayMode = isReplayMode;
this._deserializer = new LevelDeserializer(levelConfig);
this._ship = new Ship(audioEngine, isReplayMode);
this._missionBrief = new MissionBrief();
// Only set up XR observables in game mode (not replay mode)
if (!isReplayMode && DefaultScene.XR) {
const xr = DefaultScene.XR;
log.debug('Level1 constructor - Setting up XR observables');
log.debug('XR input exists:', !!xr.input);
log.debug('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
log.debug('[Level1] onInitialXRPoseSetObservable fired');
// Use consolidated XR camera setup
this.setupXRCamera();
// Show mission brief after camera setup
log.debug('[Level1] Showing mission brief on XR entry');
this.showMissionBrief();
});
}
// Don't call initialize here - let Main call it after registering the observable
}
getReadyObservable(): Observable<Level> {
return this._onReadyObservable;
}
/**
* Setup XR camera, pointer selection, and controllers
* Consolidated function called from both onInitialXRPoseSetObservable and main.ts
* when XR is already active before level creation
*/
public setupXRCamera(): void {
const xr = DefaultScene.XR;
if (!xr) {
log.debug('[Level1] setupXRCamera: No XR experience available');
return;
}
if (!this._ship?.transformNode) {
log.error('[Level1] setupXRCamera: Ship or transformNode not available');
return;
}
log.debug('[Level1] ========== setupXRCamera START ==========');
// Create intermediate TransformNode for camera rotation
// WebXR camera only uses rotationQuaternion (not .rotation), and XR frame updates overwrite it
// By rotating an intermediate node, we can orient the camera without fighting XR frame updates
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
cameraRig.parent = this._ship.transformNode;
cameraRig.rotation = new Vector3(0, 0, 0); // Rotate 180° to face forward
log.debug('[Level1] Created cameraRig TransformNode, rotated 180°');
// Parent XR camera to the rig
xr.baseExperience.camera.parent = cameraRig;
xr.baseExperience.camera.position = new Vector3(0, 1.2, 0);
log.debug('[Level1] XR camera parented to cameraRig at position (0, 1.2, 0)');
// Show the canvas now that camera is properly positioned in ship
const canvas = document.getElementById('gameCanvas');
if (canvas) {
canvas.style.display = 'block';
}
// Ensure render loop is running (stop first to prevent duplicates)
const engine = DefaultScene.MainScene.getEngine();
engine.stopRenderLoop();
engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
log.debug('[Level1] Render loop started/resumed');
// Disable keyboard input in VR mode to prevent interference
if (this._ship.keyboardInput) {
this._ship.keyboardInput.setEnabled(false);
log.debug('[Level1] Keyboard input disabled for VR mode');
}
// Register pointer selection feature
const pointerFeature = xr.baseExperience.featuresManager.getEnabledFeature(
"xr-controller-pointer-selection"
);
if (pointerFeature) {
const inputManager = InputControlManager.getInstance();
inputManager.registerPointerFeature(pointerFeature);
log.debug('[Level1] Pointer selection feature registered');
} else {
log.debug('[Level1] WARNING: Pointer selection feature not available');
}
// Track WebXR session start
try {
const analytics = getAnalytics();
analytics.track('webxr_session_start', {
deviceName: navigator.userAgent,
isImmersive: true
});
} catch (error) {
log.debug('[Level1] Analytics tracking failed:', error);
}
// Setup controller observer
xr.input.onControllerAddedObservable.add((controller) => {
log.debug('[Level1] 🎮 Controller added:', controller.inputSource.handedness);
this._ship.addController(controller);
});
log.debug('[Level1] ========== setupXRCamera COMPLETE ==========');
}
/**
* Show mission brief with directory entry data
* Public so it can be called from main.ts when XR is already active
*/
public async showMissionBrief(): Promise<void> {
// Prevent showing twice
if (this._missionBriefShown) {
log.info('[Level1] Mission brief already shown, skipping');
return;
}
this._missionBriefShown = true;
log.info('[Level1] showMissionBrief() called');
let directoryEntry: CloudLevelEntry | null = null;
// Try to get directory entry if we have a level ID
if (this._levelId) {
try {
const registry = LevelRegistry.getInstance();
log.info('[Level1] ======================================');
log.info('[Level1] Getting all levels from registry...');
const allLevels = registry.getAllLevels();
log.info('[Level1] Total levels in registry:', allLevels.size);
log.info('[Level1] Looking for level ID:', this._levelId);
const registryEntry = allLevels.get(this._levelId);
log.info('[Level1] Registry entry found:', !!registryEntry);
if (registryEntry) {
directoryEntry = registryEntry;
log.info('[Level1] Level entry data:', {
id: directoryEntry?.id,
slug: directoryEntry?.slug,
name: directoryEntry?.name,
description: directoryEntry?.description,
missionBriefCount: directoryEntry?.missionBrief?.length || 0,
estimatedTime: directoryEntry?.estimatedTime,
difficulty: directoryEntry?.difficulty
});
if (directoryEntry?.missionBrief) {
log.info('[Level1] Mission brief objectives:');
directoryEntry.missionBrief.forEach((item, i) => {
log.info(` ${i + 1}. ${item}`);
});
} else {
log.warn('[Level1] ⚠️ No missionBrief found in level entry!');
}
} else {
log.error('[Level1] ❌ No registry entry found for level ID:', this._levelId);
log.info('[Level1] Available level IDs:', Array.from(allLevels.keys()));
}
log.info('[Level1] ======================================');
log.debug('[Level1] Retrieved directory entry for level:', this._levelId, directoryEntry);
} catch (error) {
log.error('[Level1] ❌ Exception while getting directory entry:', error);
log.debug('[Level1] Failed to get directory entry:', error);
}
} else {
log.warn('[Level1] ⚠️ No level ID available, using config-only mission brief');
log.debug('[Level1] No level ID available, using config-only mission brief');
}
log.info('[Level1] About to show mission brief. Has directoryEntry:', !!directoryEntry);
// Disable ship controls while mission brief is showing
log.debug('[Level1] Disabling ship controls for mission brief');
const inputManager = InputControlManager.getInstance();
inputManager.disableShipControls("MissionBrief");
// Show mission brief with trigger observable
this._missionBrief.show(this._levelConfig, directoryEntry, this._ship.onMissionBriefTriggerObservable, () => {
log.debug('[Level1] Mission brief dismissed - enabling controls and starting game');
inputManager.enableShipControls("MissionBrief");
this.startGameplay();
});
}
/**
* Start gameplay - called when mission brief start button is clicked
* or immediately if not in XR mode
*/
private startGameplay(): void {
if (this._gameStarted) {
log.debug('[Level1] startGameplay called but game already started');
return;
}
this._gameStarted = true;
log.debug('[Level1] Starting gameplay');
// Enable game end condition checking on ship
this._ship.startGameplay();
// Start game timer
this._ship.gameStats.startTimer();
log.debug('Game timer started');
}
public async play() {
if (this._isReplayMode) {
throw new Error("Cannot call play() in replay mode");
}
// Track level start
try {
const analytics = getAnalytics();
analytics.track('level_start', {
levelName: this._levelConfig.metadata?.description || 'level_1',
difficulty: this._levelConfig.difficulty as any || 'captain',
playCount: 1 // TODO: Get actual play count from progression system
});
} catch (error) {
log.debug('Analytics tracking failed:', error);
}
// Play background music (already loaded during initialization)
if (this._backgroundMusic) {
this._backgroundMusic.play();
log.debug('Started playing background music');
}
// If XR is available and session is active, mission brief will handle starting gameplay
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === WebXRState.IN_XR) {
// XR session already active, mission brief is showing or has been dismissed
log.debug('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length);
DefaultScene.XR.input.controllers.forEach((controller, index) => {
log.debug(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
this._ship.addController(controller);
});
// Wait and check again after a delay (controllers might connect later)
log.debug('Waiting 2 seconds to check for controllers again...');
setTimeout(() => {
log.debug('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length);
DefaultScene.XR.input.controllers.forEach((controller, index) => {
log.debug(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`);
});
}, 2000);
// Note: Mission brief will call startGameplay() when start button is clicked
log.debug('XR mode: Mission brief will control game start');
} 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');
log.debug('Entered XR mode from play()');
// Check for controllers
DefaultScene.XR.input.controllers.forEach((controller, index) => {
log.debug(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
this._ship.addController(controller);
});
// Mission brief will show and handle starting gameplay
log.debug('XR mode entered: Mission brief will control game start');
} catch (error) {
log.debug('Failed to enter XR from play(), falling back to flat mode:', error);
// Start flat mode immediately
this.startGameplay();
}
} else {
// Flat camera mode - start game timer and physics recording immediately
log.debug('Playing in flat camera mode (no XR)');
this.startGameplay();
}
}
public dispose() {
if (this._startBase) {
this._startBase.dispose();
}
if (this._endBase) {
this._endBase.dispose();
}
if (this._backgroundStars) {
this._backgroundStars.dispose();
}
if (this._missionBrief) {
this._missionBrief.dispose();
}
if (this._ship) {
this._ship.dispose();
}
if (this._backgroundMusic) {
this._backgroundMusic.dispose();
}
}
public async initialize() {
log.debug('Initializing level from config:', this._levelConfig.difficulty);
if (this._initialized) {
log.error('Initialize called twice');
return;
}
await this._ship.initialize();
setLoadingMessage("Loading level from configuration...");
// Apply ship configuration from level config
const shipConfig = this._deserializer.getShipConfig();
this._ship.position = new Vector3(...shipConfig.position);
if (shipConfig.linearVelocity) {
this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity));
} else {
this._ship.setLinearVelocity(Vector3.Zero());
}
if (shipConfig.angularVelocity) {
this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity));
} else {
this._ship.setAngularVelocity(Vector3.Zero());
}
// Use deserializer to create all entities from config
const entities = await this._deserializer.deserialize(this._ship.scoreboard.onScoreObservable);
this._startBase = entities.startBase;
this._landingAggregate = entities.landingAggregate;
// Setup resupply system if landing aggregate exists
if (this._landingAggregate) {
this._ship.setLandingZone(this._landingAggregate);
}
// sun and planets are already created by deserializer
// Initialize scoreboard with total asteroid count
this._ship.scoreboard.setRemainingCount(entities.asteroids.length);
log.debug(`Initialized scoreboard with ${entities.asteroids.length} asteroids`);
// Create background starfield
setLoadingMessage("Creating starfield...");
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
count: 5000,
radius: 5000,
minBrightness: 0.3,
maxBrightness: 1.0,
pointSize: 2
});
// Set up camera follow for stars (keeps stars at infinite distance)
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
if (this._backgroundStars) {
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera) {
this._backgroundStars.followCamera(camera.position);
}
}
});
// Initialize physics recorder (but don't start it yet - will start on XR pose)
// Only create recorder in game mode, not replay mode
if (!this._isReplayMode) {
setLoadingMessage("Initializing physics recorder...");
//this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
log.debug('Physics recorder initialized (will start on XR pose)');
}
// Load background music before marking as ready
if (this._audioEngine) {
setLoadingMessage("Loading background music...");
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", {
loop: true,
volume: 0.5
});
log.debug('Background music loaded successfully');
}
// Initialize mission brief (will be shown when entering XR)
setLoadingMessage("Initializing mission brief...");
log.info('[Level1] ========== ABOUT TO INITIALIZE MISSION BRIEF ==========');
log.info('[Level1] _missionBrief object:', this._missionBrief);
log.info('[Level1] Ship exists:', !!this._ship);
log.info('[Level1] Ship ID in scene:', DefaultScene.MainScene.getNodeById('Ship') !== null);
this._missionBrief.initialize();
log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
log.debug('Mission brief initialized');
this._initialized = true;
// Set par time and level info for score calculation and results recording
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
const statusScreen = this._ship.statusScreen;
log.info('[Level1] StatusScreen reference:', statusScreen);
log.info('[Level1] Level config metadata:', this._levelConfig.metadata);
log.info('[Level1] Asteroids count:', entities.asteroids.length);
if (statusScreen) {
statusScreen.setParTime(parTime);
log.info(`[Level1] Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
// Set level info for game results recording
const levelId = this._levelId || 'unknown';
const levelName = this._levelConfig.metadata?.description || 'Unknown Level';
log.info('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length });
statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length);
log.info('[Level1] setCurrentLevel called successfully');
} else {
log.error('[Level1] StatusScreen is null/undefined!');
}
// Notify that initialization is complete
this._onReadyObservable.notifyObservers(this);
}
/**
* Get par time based on difficulty level
* Can be overridden by level config metadata
*/
private getParTimeForDifficulty(difficulty: string): number {
// Check if level config has explicit par time
if (this._levelConfig.metadata?.parTime) {
return this._levelConfig.metadata.parTime;
}
// Default par times by difficulty
const difficultyMap: { [key: string]: number } = {
'recruit': 300, // 5 minutes
'pilot': 180, // 3 minutes
'captain': 120, // 2 minutes
'commander': 90, // 1.5 minutes
'test': 60 // 1 minute
};
return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes
}
}