space-game/src/levels/level1.ts
Michael Mainguy a9070a5d8f
All checks were successful
Build / build (push) Successful in 1m45s
Add leaderboard infinite scroll and improve seed script scoring
- Add pagination support to CloudLeaderboardService with offset parameter
- Implement infinite scroll in Leaderboard.svelte using IntersectionObserver
- Update seed script to use actual game scoring formulas (time, accuracy, fuel, hull multipliers)
- Add level-specific asteroid counts and par times to seed data
- Create BUGS.md to track known issues
- Partial work on XR camera orientation (documented in BUGS.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 12:51:43 -06:00

454 lines
20 KiB
TypeScript

import {DefaultScene} from "../core/defaultScene";
import type {AudioEngineV2, StaticSound} from "@babylonjs/core";
import {
AbstractMesh,
Observable,
PhysicsAggregate,
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 debugLog from '../core/debug';
import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
import {getAnalytics} from "../analytics";
import {MissionBrief} from "../ui/hud/missionBrief";
import {LevelRegistry, LevelDirectoryEntry} from "./storage/levelRegistry";
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 _physicsRecorder: PhysicsRecorder | null = null;
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;
debugLog('Level1 constructor - Setting up XR observables');
debugLog('XR input exists:', !!xr.input);
debugLog('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
xr.baseExperience.camera.parent = this._ship.transformNode;
xr.baseExperience.camera.position = new Vector3(0, 1.5, 0);
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
xr.baseExperience.camera.rotationQuaternion = null;
xr.baseExperience.camera.rotation = new Vector3(0, 0, 0);
// Resume render loop if it was stopped (ensures camera is properly set before first visible frame)
const engine = DefaultScene.MainScene.getEngine();
engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
debugLog('[Level1] Render loop resumed after XR camera setup');
// Disable keyboard input in VR mode to prevent interference
if (this._ship.keyboardInput) {
this._ship.keyboardInput.setEnabled(false);
debugLog('[Level1] Keyboard input disabled for VR mode');
}
// Track WebXR session start
try {
const analytics = getAnalytics();
analytics.track('webxr_session_start', {
deviceName: navigator.userAgent,
isImmersive: true
});
} catch (error) {
debugLog('Analytics tracking failed:', error);
}
// Add controllers
const observer = xr.input.onControllerAddedObservable.add((controller) => {
debugLog('🎮 onControllerAddedObservable FIRED for:', controller.inputSource.handedness);
this._ship.addController(controller);
});
// Show mission brief instead of starting immediately
debugLog('[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;
}
/**
* 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) {
console.log('[Level1] Mission brief already shown, skipping');
return;
}
this._missionBriefShown = true;
console.log('[Level1] showMissionBrief() called');
let directoryEntry: LevelDirectoryEntry | null = null;
// Try to get directory entry if we have a level ID
if (this._levelId) {
try {
const registry = LevelRegistry.getInstance();
console.log('[Level1] ======================================');
console.log('[Level1] Getting all levels from registry...');
const allLevels = registry.getAllLevels();
console.log('[Level1] Total levels in registry:', allLevels.size);
console.log('[Level1] Looking for level ID:', this._levelId);
const registryEntry = allLevels.get(this._levelId);
console.log('[Level1] Registry entry found:', !!registryEntry);
if (registryEntry) {
directoryEntry = registryEntry.directoryEntry;
console.log('[Level1] Directory entry data:', {
id: directoryEntry?.id,
name: directoryEntry?.name,
description: directoryEntry?.description,
levelPath: directoryEntry?.levelPath,
missionBriefCount: directoryEntry?.missionBrief?.length || 0,
estimatedTime: directoryEntry?.estimatedTime,
difficulty: directoryEntry?.difficulty
});
if (directoryEntry?.missionBrief) {
console.log('[Level1] Mission brief objectives:');
directoryEntry.missionBrief.forEach((item, i) => {
console.log(` ${i + 1}. ${item}`);
});
} else {
console.warn('[Level1] ⚠️ No missionBrief found in directory entry!');
}
if (!directoryEntry?.levelPath) {
console.warn('[Level1] ⚠️ No levelPath found in directory entry!');
}
} else {
console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId);
console.log('[Level1] Available level IDs:', Array.from(allLevels.keys()));
}
console.log('[Level1] ======================================');
debugLog('[Level1] Retrieved directory entry for level:', this._levelId, directoryEntry);
} catch (error) {
console.error('[Level1] ❌ Exception while getting directory entry:', error);
debugLog('[Level1] Failed to get directory entry:', error);
}
} else {
console.warn('[Level1] ⚠️ No level ID available, using config-only mission brief');
debugLog('[Level1] No level ID available, using config-only mission brief');
}
console.log('[Level1] About to show mission brief. Has directoryEntry:', !!directoryEntry);
// Disable ship controls while mission brief is showing
debugLog('[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, () => {
debugLog('[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) {
debugLog('[Level1] startGameplay called but game already started');
return;
}
this._gameStarted = true;
debugLog('[Level1] Starting gameplay');
// Enable game end condition checking on ship
this._ship.startGameplay();
// Start game timer
this._ship.gameStats.startTimer();
debugLog('Game timer started');
// Start physics recording
if (this._physicsRecorder) {
this._physicsRecorder.startRingBuffer();
debugLog('Physics recorder 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) {
debugLog('Analytics tracking failed:', error);
}
// Play background music (already loaded during initialization)
if (this._backgroundMusic) {
this._backgroundMusic.play();
debugLog('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
debugLog('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length);
DefaultScene.XR.input.controllers.forEach((controller, index) => {
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
this._ship.addController(controller);
});
// Wait and check again after a delay (controllers might connect later)
debugLog('Waiting 2 seconds to check for controllers again...');
setTimeout(() => {
debugLog('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length);
DefaultScene.XR.input.controllers.forEach((controller, index) => {
debugLog(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`);
});
}, 2000);
// Note: Mission brief will call startGameplay() when start button is clicked
debugLog('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');
debugLog('Entered XR mode from play()');
// Check for controllers
DefaultScene.XR.input.controllers.forEach((controller, index) => {
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
this._ship.addController(controller);
});
// Mission brief will show and handle starting gameplay
debugLog('XR mode entered: Mission brief will control game start');
} catch (error) {
debugLog('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
debugLog('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._physicsRecorder) {
this._physicsRecorder.dispose();
}
if (this._missionBrief) {
this._missionBrief.dispose();
}
if (this._ship) {
this._ship.dispose();
}
if (this._backgroundMusic) {
this._backgroundMusic.dispose();
}
}
public async initialize() {
debugLog('Initializing level from config:', this._levelConfig.difficulty);
if (this._initialized) {
console.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);
debugLog(`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);
debugLog('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", "/song1.mp3", {
loop: true,
volume: 0.5
});
debugLog('Background music loaded successfully');
}
// Initialize mission brief (will be shown when entering XR)
setLoadingMessage("Initializing mission brief...");
console.log('[Level1] ========== ABOUT TO INITIALIZE MISSION BRIEF ==========');
console.log('[Level1] _missionBrief object:', this._missionBrief);
console.log('[Level1] Ship exists:', !!this._ship);
console.log('[Level1] Ship ID in scene:', DefaultScene.MainScene.getNodeById('Ship') !== null);
this._missionBrief.initialize();
console.log('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
debugLog('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;
console.log('[Level1] StatusScreen reference:', statusScreen);
console.log('[Level1] Level config metadata:', this._levelConfig.metadata);
console.log('[Level1] Asteroids count:', entities.asteroids.length);
if (statusScreen) {
statusScreen.setParTime(parTime);
console.log(`[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';
console.log('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length });
statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length);
console.log('[Level1] setCurrentLevel called successfully');
} else {
console.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
}
/**
* Get the physics recorder instance
*/
public get physicsRecorder(): PhysicsRecorder {
return this._physicsRecorder;
}
}