diff --git a/index.html b/index.html index 6fb0592..9314ef6 100644 --- a/index.html +++ b/index.html @@ -19,8 +19,8 @@
- ๐Ÿ“ Level Editor - โš™๏ธ Settings + +
Loading...
@@ -56,10 +56,13 @@
-
+
+
+ Create New Level diff --git a/public/assets/themes/default/audio/ammo.mp3 b/public/assets/themes/default/audio/voice/ammo.mp3 similarity index 100% rename from public/assets/themes/default/audio/ammo.mp3 rename to public/assets/themes/default/audio/voice/ammo.mp3 diff --git a/public/assets/themes/default/audio/voice/armed.mp3 b/public/assets/themes/default/audio/voice/armed.mp3 new file mode 100644 index 0000000..6963c8f Binary files /dev/null and b/public/assets/themes/default/audio/voice/armed.mp3 differ diff --git a/public/assets/themes/default/audio/danger.mp3 b/public/assets/themes/default/audio/voice/danger.mp3 similarity index 100% rename from public/assets/themes/default/audio/danger.mp3 rename to public/assets/themes/default/audio/voice/danger.mp3 diff --git a/public/assets/themes/default/audio/voice/disarmed.mp3 b/public/assets/themes/default/audio/voice/disarmed.mp3 new file mode 100644 index 0000000..9b8b6a6 Binary files /dev/null and b/public/assets/themes/default/audio/voice/disarmed.mp3 differ diff --git a/public/assets/themes/default/audio/voice/empty.mp3 b/public/assets/themes/default/audio/voice/empty.mp3 new file mode 100644 index 0000000..292e4f7 Binary files /dev/null and b/public/assets/themes/default/audio/voice/empty.mp3 differ diff --git a/public/assets/themes/default/audio/voice/exitarm.mp3 b/public/assets/themes/default/audio/voice/exitarm.mp3 new file mode 100644 index 0000000..2906a01 Binary files /dev/null and b/public/assets/themes/default/audio/voice/exitarm.mp3 differ diff --git a/public/assets/themes/default/audio/fuel.mp3 b/public/assets/themes/default/audio/voice/fuel.mp3 similarity index 100% rename from public/assets/themes/default/audio/fuel.mp3 rename to public/assets/themes/default/audio/voice/fuel.mp3 diff --git a/public/assets/themes/default/audio/voice/full.mp3 b/public/assets/themes/default/audio/voice/full.mp3 new file mode 100644 index 0000000..4a293c3 Binary files /dev/null and b/public/assets/themes/default/audio/voice/full.mp3 differ diff --git a/public/assets/themes/default/audio/voice/guns.mp3 b/public/assets/themes/default/audio/voice/guns.mp3 new file mode 100644 index 0000000..1295460 Binary files /dev/null and b/public/assets/themes/default/audio/voice/guns.mp3 differ diff --git a/public/assets/themes/default/audio/hull.mp3 b/public/assets/themes/default/audio/voice/hull.mp3 similarity index 100% rename from public/assets/themes/default/audio/hull.mp3 rename to public/assets/themes/default/audio/voice/hull.mp3 diff --git a/public/assets/themes/default/audio/voice/returncomplete.mp3 b/public/assets/themes/default/audio/voice/returncomplete.mp3 new file mode 100644 index 0000000..d2a6019 Binary files /dev/null and b/public/assets/themes/default/audio/voice/returncomplete.mp3 differ diff --git a/public/assets/themes/default/audio/voice/returntobase.mp3 b/public/assets/themes/default/audio/voice/returntobase.mp3 new file mode 100644 index 0000000..6a36012 Binary files /dev/null and b/public/assets/themes/default/audio/voice/returntobase.mp3 differ diff --git a/public/assets/themes/default/audio/warning.mp3 b/public/assets/themes/default/audio/voice/warning.mp3 similarity index 100% rename from public/assets/themes/default/audio/warning.mp3 rename to public/assets/themes/default/audio/voice/warning.mp3 diff --git a/src/level1.ts b/src/level1.ts index e45b621..9c5dedb 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -27,40 +27,44 @@ export class Level1 implements Level { private _deserializer: LevelDeserializer; private _backgroundStars: BackgroundStars; private _physicsRecorder: PhysicsRecorder; + private _isReplayMode: boolean; - constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) { + constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) { this._levelConfig = levelConfig; this._audioEngine = audioEngine; + this._isReplayMode = isReplayMode; this._deserializer = new LevelDeserializer(levelConfig); - this._ship = new Ship(audioEngine); + this._ship = new Ship(audioEngine, isReplayMode); + // Only set up XR observables in game mode (not replay mode) + if (!isReplayMode) { + const xr = 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); - 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; + const currPose = xr.baseExperience.camera.globalPosition.y; + xr.baseExperience.camera.position = new Vector3(0, 0, 0); - xr.baseExperience.onInitialXRPoseSetObservable.add(() => { - xr.baseExperience.camera.parent = this._ship.transformNode; - const currPose = xr.baseExperience.camera.globalPosition.y; - xr.baseExperience.camera.position = new Vector3(0, 0, 0); + // Start game timer when XR pose is set + this._ship.gameStats.startTimer(); + debugLog('Game timer started'); - // Start game timer when XR pose is set - this._ship.gameStats.startTimer(); - debugLog('Game timer started'); + // Start physics recording when gameplay begins + if (this._physicsRecorder) { + this._physicsRecorder.startRingBuffer(); + debugLog('Physics recorder started'); + } - // Start physics recording when gameplay begins - if (this._physicsRecorder) { - this._physicsRecorder.startRingBuffer(); - debugLog('Physics recorder started'); - } - - const observer = xr.input.onControllerAddedObservable.add((controller) => { - debugLog('๐ŸŽฎ onControllerAddedObservable FIRED for:', controller.inputSource.handedness); - this._ship.addController(controller); + const observer = xr.input.onControllerAddedObservable.add((controller) => { + debugLog('๐ŸŽฎ onControllerAddedObservable FIRED for:', controller.inputSource.handedness); + this._ship.addController(controller); + }); }); - }); + } // Don't call initialize here - let Main call it after registering the observable } @@ -69,6 +73,10 @@ export class Level1 implements Level { } public async play() { + if (this._isReplayMode) { + throw new Error("Cannot call play() in replay mode"); + } + // Create background music using AudioEngineV2 const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { loop: true, @@ -134,8 +142,6 @@ export class Level1 implements Level { 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, { @@ -154,14 +160,19 @@ export class Level1 implements Level { }); // Initialize physics recorder (but don't start it yet - will start on XR pose) - setLoadingMessage("Initializing physics recorder..."); - this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene); - debugLog('Physics recorder initialized (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)'); + } - // Wire up recording keyboard shortcuts - this._ship.keyboardInput.onRecordingActionObservable.add((action) => { - this.handleRecordingAction(action); - }); + // Wire up recording keyboard shortcuts (only in game mode) + if (!this._isReplayMode) { + this._ship.keyboardInput.onRecordingActionObservable.add((action) => { + this.handleRecordingAction(action); + }); + } this._initialized = true; @@ -206,6 +217,7 @@ export class Level1 implements Level { } } + /** * Get the physics recorder instance */ diff --git a/src/levelConfig.ts b/src/levelConfig.ts index fe3100b..59b6b06 100644 --- a/src/levelConfig.ts +++ b/src/levelConfig.ts @@ -7,6 +7,58 @@ */ export type Vector3Array = [number, number, number]; +/** + * 4D quaternion stored as array [x, y, z, w] + */ +export type QuaternionArray = [number, number, number, number]; + +/** + * 4D color stored as array [r, g, b, a] (0-1 range) + */ +export type Color4Array = [number, number, number, number]; + +/** + * Material configuration for PBR materials + */ +export interface MaterialConfig { + id: string; + name: string; + type: "PBR" | "Standard" | "Basic"; + albedoColor?: Color4Array; + metallic?: number; + roughness?: number; + emissiveColor?: Vector3Array; + emissiveIntensity?: number; + alpha?: number; + backFaceCulling?: boolean; + textures?: { + albedo?: string; // Asset reference or data URL + normal?: string; + metallic?: string; + roughness?: string; + emissive?: string; + }; +} + +/** + * Scene hierarchy node (TransformNode or Mesh) + */ +export interface SceneNodeConfig { + id: string; + name: string; + type: "TransformNode" | "Mesh" | "InstancedMesh"; + position: Vector3Array; + rotation?: Vector3Array; + rotationQuaternion?: QuaternionArray; + scaling?: Vector3Array; + parentId?: string; // Reference to parent node + materialId?: string; // Reference to material + assetReference?: string; // For meshes loaded from GLB + isVisible?: boolean; + isEnabled?: boolean; + metadata?: any; +} + /** * Ship configuration */ @@ -82,6 +134,8 @@ export interface LevelConfig { metadata?: { author?: string; description?: string; + babylonVersion?: string; + captureTime?: number; [key: string]: any; }; @@ -93,6 +147,11 @@ export interface LevelConfig { // Optional: include original difficulty config for reference difficultyConfig?: DifficultyConfig; + + // New fields for full scene serialization + materials?: MaterialConfig[]; + sceneHierarchy?: SceneNodeConfig[]; + assetReferences?: { [key: string]: string }; // mesh id -> asset path (e.g., "ship" -> "ship.glb") } /** diff --git a/src/levelDeserializer.ts b/src/levelDeserializer.ts index 8b948e6..d9eb627 100644 --- a/src/levelDeserializer.ts +++ b/src/levelDeserializer.ts @@ -1,11 +1,12 @@ import { - AbstractMesh, Color3, + AbstractMesh, + Color3, MeshBuilder, Observable, PBRMaterial, PhysicsAggregate, Texture, - Vector3 + Vector3, } from "@babylonjs/core"; import { DefaultScene } from "./defaultScene"; import { RockFactory } from "./rockFactory"; @@ -16,7 +17,6 @@ import { Vector3Array, validateLevelConfig } from "./levelConfig"; -import { GameConfig } from "./gameConfig"; import { FireProceduralTexture } from "@babylonjs/procedural-textures"; import { createSphereLightmap } from "./sphereLightmap"; import debugLog from './debug'; @@ -41,8 +41,11 @@ export class LevelDeserializer { /** * Create all entities from the configuration + * @param scoreObservable - Observable for score events */ - public async deserialize(scoreObservable: Observable): Promise<{ + public async deserialize( + scoreObservable: Observable + ): Promise<{ startBase: AbstractMesh | null; landingAggregate: PhysicsAggregate | null; sun: AbstractMesh; @@ -51,19 +54,11 @@ export class LevelDeserializer { }> { debugLog('Deserializing level:', this.config.difficulty); - // Create entities const baseResult = await this.createStartBase(); const sun = this.createSun(); const planets = this.createPlanets(); const asteroids = await this.createAsteroids(scoreObservable); - /* - const dir = new Vector3(-1,-2,-1) - - const light = new DirectionalLight("dirLight", dir, DefaultScene.MainScene); - const light2 = new DirectionalLight("dirLight2", dir.negate(), DefaultScene.MainScene); - light2.intensity = .5; - */ return { startBase: baseResult.baseMesh, landingAggregate: baseResult.landingAggregate, diff --git a/src/levelSelector.ts b/src/levelSelector.ts index 14c8fa7..d87afa2 100644 --- a/src/levelSelector.ts +++ b/src/levelSelector.ts @@ -16,7 +16,15 @@ export function populateLevelSelector(): boolean { const savedLevels = getSavedLevels(); - if (savedLevels.size === 0) { + // Filter to only show recruit and pilot difficulty levels + const filteredLevels = new Map(); + for (const [name, config] of savedLevels.entries()) { + if (config.difficulty === 'recruit' || config.difficulty === 'pilot') { + filteredLevels.set(name, config); + } + } + + if (filteredLevels.size === 0) { container.innerHTML = `
(); + + for (const material of this.scene.materials) { + // Skip duplicates + if (seenIds.has(material.id)) { + continue; + } + seenIds.add(material.id); + + const materialConfig: MaterialConfig = { + id: material.id, + name: material.name, + type: "Basic", + alpha: material.alpha, + backFaceCulling: material.backFaceCulling + }; + + // Handle PBR materials + if (material instanceof PBRMaterial) { + materialConfig.type = "PBR"; + if (material.albedoColor) { + materialConfig.albedoColor = [ + material.albedoColor.r, + material.albedoColor.g, + material.albedoColor.b + ]; + } + materialConfig.metallic = material.metallic; + materialConfig.roughness = material.roughness; + if (material.emissiveColor) { + materialConfig.emissiveColor = [ + material.emissiveColor.r, + material.emissiveColor.g, + material.emissiveColor.b + ]; + } + materialConfig.emissiveIntensity = material.emissiveIntensity; + + // Capture texture references (not data) + materialConfig.textures = {}; + if (material.albedoTexture) { + materialConfig.textures.albedo = material.albedoTexture.name; + } + if (material.bumpTexture) { + materialConfig.textures.normal = material.bumpTexture.name; + } + if (material.metallicTexture) { + materialConfig.textures.metallic = material.metallicTexture.name; + } + if (material.emissiveTexture) { + materialConfig.textures.emissive = material.emissiveTexture.name; + } + } + // Handle Standard materials + else if (material instanceof StandardMaterial) { + materialConfig.type = "Standard"; + if (material.diffuseColor) { + materialConfig.albedoColor = [ + material.diffuseColor.r, + material.diffuseColor.g, + material.diffuseColor.b, + 1.0 + ]; + } + if (material.emissiveColor) { + materialConfig.emissiveColor = [ + material.emissiveColor.r, + material.emissiveColor.g, + material.emissiveColor.b + ]; + } + } + + materials.push(materialConfig); + } + + return materials; + } + + /** + * Serialize scene hierarchy (all transform nodes and meshes) + */ + private serializeSceneHierarchy(): SceneNodeConfig[] { + const nodes: SceneNodeConfig[] = []; + const seenIds = new Set(); + + // Serialize all transform nodes + for (const node of this.scene.transformNodes) { + if (seenIds.has(node.id)) continue; + seenIds.add(node.id); + + const nodeConfig: SceneNodeConfig = { + id: node.id, + name: node.name, + type: "TransformNode", + position: this.vector3ToArray(node.position), + rotation: this.vector3ToArray(node.rotation), + scaling: this.vector3ToArray(node.scaling), + isEnabled: node.isEnabled(), + metadata: node.metadata + }; + + // Capture quaternion if present + if (node.rotationQuaternion) { + nodeConfig.rotationQuaternion = this.quaternionToArray(node.rotationQuaternion); + } + + // Capture parent reference + if (node.parent) { + nodeConfig.parentId = node.parent.id; + } + + nodes.push(nodeConfig); + } + + // Serialize all meshes + for (const mesh of this.scene.meshes) { + if (seenIds.has(mesh.id)) continue; + seenIds.add(mesh.id); + + const nodeConfig: SceneNodeConfig = { + id: mesh.id, + name: mesh.name, + type: mesh.getClassName() === "InstancedMesh" ? "InstancedMesh" : "Mesh", + position: this.vector3ToArray(mesh.position), + rotation: this.vector3ToArray(mesh.rotation), + scaling: this.vector3ToArray(mesh.scaling), + isVisible: mesh.isVisible, + isEnabled: mesh.isEnabled(), + metadata: mesh.metadata + }; + + // Capture quaternion if present + if (mesh.rotationQuaternion) { + nodeConfig.rotationQuaternion = this.quaternionToArray(mesh.rotationQuaternion); + } + + // Capture parent reference + if (mesh.parent) { + nodeConfig.parentId = mesh.parent.id; + } + + // Capture material reference + if (mesh.material) { + nodeConfig.materialId = mesh.material.id; + } + + // Determine asset reference from mesh source (use full paths) + if (mesh.metadata?.source) { + nodeConfig.assetReference = mesh.metadata.source; + } else if (mesh.name.includes("ship") || mesh.name.includes("Ship")) { + nodeConfig.assetReference = "assets/themes/default/models/ship.glb"; + } else if (mesh.name.includes("asteroid") || mesh.name.includes("Asteroid")) { + nodeConfig.assetReference = "assets/themes/default/models/asteroid.glb"; + } else if (mesh.name.includes("base") || mesh.name.includes("Base")) { + nodeConfig.assetReference = "assets/themes/default/models/base.glb"; + } + + nodes.push(nodeConfig); + } + + return nodes; + } + + /** + * Serialize asset references (mesh ID -> GLB file path) + */ + private serializeAssetReferences(): { [key: string]: string } { + const assetRefs: { [key: string]: string } = {}; + + // Map common mesh patterns to their source assets (use full paths as keys) + for (const mesh of this.scene.meshes) { + if (mesh.metadata?.source) { + assetRefs[mesh.id] = mesh.metadata.source; + } else if (mesh.name.toLowerCase().includes("ship")) { + assetRefs[mesh.id] = "assets/themes/default/models/ship.glb"; + } else if (mesh.name.toLowerCase().includes("asteroid")) { + assetRefs[mesh.id] = "assets/themes/default/models/asteroid.glb"; + } else if (mesh.name.toLowerCase().includes("base")) { + assetRefs[mesh.id] = "assets/themes/default/models/base.glb"; + } + } + + return assetRefs; + } + /** * Helper to convert Vector3 to array */ @@ -240,6 +450,18 @@ export class LevelSerializer { ]; } + /** + * Helper to convert Quaternion to array + */ + private quaternionToArray(quat: Quaternion): QuaternionArray { + return [ + parseFloat(quat.x.toFixed(4)), + parseFloat(quat.y.toFixed(4)), + parseFloat(quat.z.toFixed(4)), + parseFloat(quat.w.toFixed(4)) + ]; + } + /** * Export current level to JSON string */ diff --git a/src/main.ts b/src/main.ts index 55436df..4b9d78d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,8 @@ import {hasSavedLevels, populateLevelSelector} from "./levelSelector"; import {LevelConfig} from "./levelConfig"; import {generateDefaultLevels} from "./levelEditor"; import debugLog from './debug'; +import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen"; +import {ReplayManager} from "./replay/ReplayManager"; // Set to true to run minimal controller debug test const DEBUG_CONTROLLERS = false; @@ -41,6 +43,7 @@ export class Main { private _gameState: GameState = GameState.DEMO; private _engine: Engine | WebGPUEngine; private _audioEngine: AudioEngineV2; + private _replayManager: ReplayManager | null = null; constructor() { if (!navigator.xr) { setLoadingMessage("This browser does not support WebXR"); @@ -170,6 +173,90 @@ export class Main { } else { console.warn('[Main] Test level button not found in DOM'); } + + // View Replays button handler + const viewReplaysBtn = document.querySelector('#viewReplaysBtn'); + debugLog('[Main] View Replays button found:', !!viewReplaysBtn); + + if (viewReplaysBtn) { + viewReplaysBtn.addEventListener('click', async () => { + debugLog('[Main] ========== VIEW REPLAYS BUTTON CLICKED =========='); + + // Initialize engine and physics if not already done + if (!this._started) { + this._started = true; + await this.initialize(); + } + + // Hide main menu + const levelSelect = document.querySelector('#levelSelect') as HTMLElement; + const editorLink = document.querySelector('.editor-link') as HTMLElement; + const settingsLink = document.querySelector('.settings-link') as HTMLElement; + + if (levelSelect) { + levelSelect.style.display = 'none'; + } + if (editorLink) { + editorLink.style.display = 'none'; + } + if (settingsLink) { + settingsLink.style.display = 'none'; + } + + // Show replay selection screen + const selectionScreen = new ReplaySelectionScreen( + async (recordingId: string) => { + // Play callback - start replay + debugLog(`[Main] Starting replay for recording: ${recordingId}`); + selectionScreen.dispose(); + + // Create replay manager if not exists + if (!this._replayManager) { + this._replayManager = new ReplayManager( + this._engine as Engine, + () => { + // On exit callback - return to main menu + debugLog('[Main] Exiting replay, returning to menu'); + if (levelSelect) { + levelSelect.style.display = 'block'; + } + if (editorLink) { + editorLink.style.display = 'block'; + } + if (settingsLink) { + settingsLink.style.display = 'block'; + } + } + ); + } + + // Start replay + if (this._replayManager) { + await this._replayManager.startReplay(recordingId); + } + }, + () => { + // Cancel callback - return to main menu + debugLog('[Main] Replay selection cancelled'); + selectionScreen.dispose(); + if (levelSelect) { + levelSelect.style.display = 'block'; + } + if (editorLink) { + editorLink.style.display = 'block'; + } + if (settingsLink) { + settingsLink.style.display = 'block'; + } + } + ); + + await selectionScreen.initialize(); + }); + debugLog('[Main] Click listener added to view replays button'); + } else { + console.warn('[Main] View Replays button not found in DOM'); + } }); } private _started = false; diff --git a/src/physicsRecorder.ts b/src/physicsRecorder.ts index 304fa22..c74fabb 100644 --- a/src/physicsRecorder.ts +++ b/src/physicsRecorder.ts @@ -1,6 +1,7 @@ import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core"; import debugLog from "./debug"; import { PhysicsStorage } from "./physicsStorage"; +import { LevelConfig } from "./levelConfig"; /** * Represents the physics state of a single object at a point in time @@ -33,6 +34,7 @@ export interface RecordingMetadata { frameCount: number; recordingDuration: number; // milliseconds physicsUpdateRate: number; // Hz + levelConfig?: LevelConfig; // Full scene state at recording time } /** @@ -80,9 +82,11 @@ export class PhysicsRecorder { private _autoSaveInterval: number = 10000; // Save every 10 seconds private _lastAutoSaveTime: number = 0; private _currentSessionId: string = ""; + private _levelConfig: LevelConfig | null = null; - constructor(scene: Scene) { + constructor(scene: Scene, levelConfig?: LevelConfig) { this._scene = scene; + this._levelConfig = levelConfig || null; // Initialize IndexedDB storage this._storage = new PhysicsStorage(); @@ -168,10 +172,12 @@ export class PhysicsRecorder { const timestamp = performance.now() - this._startTime; const objects: PhysicsObjectState[] = []; - // Get all physics-enabled meshes + // Get all physics-enabled meshes AND transform nodes const physicsMeshes = this._scene.meshes.filter(mesh => mesh.physicsBody !== null && mesh.physicsBody !== undefined); + const physicsTransformNodes = this._scene.transformNodes.filter(node => node.physicsBody !== null && node.physicsBody !== undefined); + const allPhysicsObjects = [...physicsMeshes, ...physicsTransformNodes]; - for (const mesh of physicsMeshes) { + for (const mesh of allPhysicsObjects) { const body = mesh.physicsBody; // Double-check body still exists and has transformNode (can be disposed between filter and here) @@ -300,13 +306,17 @@ export class PhysicsRecorder { const snapshotsToSave = [...this._autoSaveBuffer]; this._autoSaveBuffer = []; + // Use the LevelConfig passed to constructor + const levelConfig = this._levelConfig || undefined; + // Create a recording from the buffered snapshots const metadata: RecordingMetadata = { startTime: snapshotsToSave[0].timestamp, endTime: snapshotsToSave[snapshotsToSave.length - 1].timestamp, frameCount: snapshotsToSave.length, recordingDuration: snapshotsToSave[snapshotsToSave.length - 1].timestamp - snapshotsToSave[0].timestamp, - physicsUpdateRate: this._physicsUpdateRate + physicsUpdateRate: this._physicsUpdateRate, + levelConfig // Include complete scene state }; const recording: PhysicsRecording = { @@ -319,7 +329,8 @@ export class PhysicsRecorder { await this._storage.saveRecording(this._currentSessionId, recording); const duration = (metadata.recordingDuration / 1000).toFixed(1); - debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) to IndexedDB`); + const configSize = levelConfig ? `with scene state (${JSON.stringify(levelConfig).length} bytes)` : 'without scene state'; + debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) ${configSize} to IndexedDB`); } catch (error) { debugLog("PhysicsRecorder: Error during auto-save", error); } diff --git a/src/physicsStorage.ts b/src/physicsStorage.ts index eab943c..aa1bb22 100644 --- a/src/physicsStorage.ts +++ b/src/physicsStorage.ts @@ -58,7 +58,8 @@ export class PhysicsStorage { throw new Error("Database not initialized"); } - const recordingId = `recording-${Date.now()}`; + // Use the provided name as recordingId (for session-based grouping) + const recordingId = name; const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment return new Promise((resolve, reject) => { @@ -190,23 +191,76 @@ export class PhysicsStorage { request.onsuccess = () => { const allSegments = request.result; - // Group by recordingId and get first segment (which has metadata) - const recordingMap = new Map(); + // Group by recordingId and aggregate all segments + const sessionMap = new Map(); + // Group segments by session allSegments.forEach(segment => { - if (!recordingMap.has(segment.recordingId) && segment.metadata) { - recordingMap.set(segment.recordingId, { - id: segment.recordingId, - name: segment.name, - timestamp: segment.timestamp, - duration: segment.metadata.recordingDuration / 1000, // Convert to seconds - frameCount: segment.metadata.frameCount + if (!sessionMap.has(segment.recordingId)) { + sessionMap.set(segment.recordingId, { + segments: [], + metadata: null }); } + const session = sessionMap.get(segment.recordingId)!; + session.segments.push(segment); + if (segment.metadata) { + session.metadata = segment.metadata; // Keep first metadata for LevelConfig + } }); - const recordings = Array.from(recordingMap.values()); - debugLog(`PhysicsStorage: Found ${recordings.length} recordings`); + // Build recording list with aggregated data + const recordings: Array<{ + id: string; + name: string; + timestamp: number; + duration: number; + frameCount: number; + }> = []; + + sessionMap.forEach((session, recordingId) => { + // Sort segments to get first and last + session.segments.sort((a, b) => a.segmentIndex - b.segmentIndex); + + const firstSegment = session.segments[0]; + const lastSegment = session.segments[session.segments.length - 1]; + + // Calculate total frame count across all segments + const totalFrames = session.segments.reduce((sum, seg) => sum + seg.snapshots.length, 0); + + // Calculate total duration from first to last snapshot across ALL segments + let firstTimestamp = Number.MAX_VALUE; + let lastTimestamp = 0; + + session.segments.forEach(seg => { + if (seg.snapshots.length > 0) { + const segFirstTimestamp = seg.snapshots[0].timestamp; + const segLastTimestamp = seg.snapshots[seg.snapshots.length - 1].timestamp; + + if (segFirstTimestamp < firstTimestamp) { + firstTimestamp = segFirstTimestamp; + } + if (segLastTimestamp > lastTimestamp) { + lastTimestamp = segLastTimestamp; + } + } + }); + + const totalDuration = (lastTimestamp - firstTimestamp) / 1000; // Convert to seconds + + recordings.push({ + id: recordingId, + name: recordingId, // Use session ID as name + timestamp: firstSegment.timestamp, + duration: totalDuration, + frameCount: totalFrames + }); + }); + + debugLog(`PhysicsStorage: Found ${recordings.length} sessions (${allSegments.length} total segments)`); resolve(recordings); }; diff --git a/src/replay/ReplayAssetRegistry.ts b/src/replay/ReplayAssetRegistry.ts deleted file mode 100644 index 9c51e69..0000000 --- a/src/replay/ReplayAssetRegistry.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { AbstractMesh, InstancedMesh, Mesh, Scene } from "@babylonjs/core"; -import loadAsset from "../utils/loadAsset"; -import debugLog from "../debug"; - -/** - * Registry for loading and caching assets used in replay - * Maps object IDs to appropriate mesh templates and creates instances - */ -export class ReplayAssetRegistry { - private _assetCache: Map = new Map(); - private _scene: Scene; - private _initialized: boolean = false; - - constructor(scene: Scene) { - this._scene = scene; - } - - /** - * Pre-load all assets that might be needed for replay - */ - public async initialize(): Promise { - if (this._initialized) { - return; - } - - debugLog("ReplayAssetRegistry: Loading replay assets..."); - - try { - // Load ship mesh - await this.loadShipMesh(); - - // Load asteroid meshes - await this.loadAsteroidMesh(); - - // Load base mesh - await this.loadBaseMesh(); - - this._initialized = true; - debugLog("ReplayAssetRegistry: All assets loaded"); - } catch (error) { - debugLog("ReplayAssetRegistry: Error loading assets", error); - throw error; - } - } - - /** - * Load ship mesh template - */ - private async loadShipMesh(): Promise { - const data = await loadAsset("ship.glb"); - const shipMesh = data.container.transformNodes[0]; - shipMesh.setEnabled(false); // Keep as template - this._assetCache.set("ship-template", shipMesh as AbstractMesh); - debugLog("ReplayAssetRegistry: Ship mesh loaded"); - } - - /** - * Load asteroid mesh template - */ - private async loadAsteroidMesh(): Promise { - const data = await loadAsset("asteroid4.glb"); - const asteroidMesh = data.container.meshes[0]; - asteroidMesh.setEnabled(false); // Keep as template - this._assetCache.set("asteroid-template", asteroidMesh); - debugLog("ReplayAssetRegistry: Asteroid mesh loaded"); - } - - /** - * Load base mesh template - */ - private async loadBaseMesh(): Promise { - const data = await loadAsset("base.glb"); - const baseMesh = data.container.transformNodes[0]; - baseMesh.setEnabled(false); // Keep as template - this._assetCache.set("base-template", baseMesh as AbstractMesh); - debugLog("ReplayAssetRegistry: Base mesh loaded"); - } - - /** - * Create a replay mesh from object ID - * Uses instancedMesh for asteroids, clones for unique objects - */ - public createReplayMesh(objectId: string): AbstractMesh | null { - if (!this._initialized) { - debugLog("ReplayAssetRegistry: Not initialized, cannot create mesh for", objectId); - return null; - } - - // Determine mesh type from object ID - if (objectId.startsWith("asteroid-") || objectId.startsWith("rock-")) { - // Create instance of asteroid template - const template = this._assetCache.get("asteroid-template"); - if (template) { - const instance = new InstancedMesh(objectId, template as Mesh); - instance.setEnabled(true); - return instance; - } - } else if (objectId === "ship" || objectId.startsWith("shipBase")) { - // Clone ship (needs independent properties) - const template = this._assetCache.get("ship-template"); - if (template) { - const clone = template.clone(objectId, null, true); - if (clone) { - clone.setEnabled(true); - return clone; - } - } - } else if (objectId.startsWith("base") || objectId.startsWith("starBase")) { - // Clone base - const template = this._assetCache.get("base-template"); - if (template) { - const clone = template.clone(objectId, null, true); - if (clone) { - clone.setEnabled(true); - return clone; - } - } - } else if (objectId.startsWith("ammo")) { - // Skip projectiles - they're small and numerous - return null; - } - - debugLog(`ReplayAssetRegistry: Unknown object type for ID: ${objectId}`); - return null; - } - - /** - * Get statistics about loaded assets - */ - public getStats(): { - initialized: boolean; - templateCount: number; - templates: string[]; - } { - return { - initialized: this._initialized, - templateCount: this._assetCache.size, - templates: Array.from(this._assetCache.keys()) - }; - } - - /** - * Dispose of all cached assets - */ - public dispose(): void { - debugLog("ReplayAssetRegistry: Disposing assets"); - this._assetCache.forEach((mesh, key) => { - mesh.dispose(); - }); - this._assetCache.clear(); - this._initialized = false; - } -} diff --git a/src/replay/ReplayCamera.ts b/src/replay/ReplayCamera.ts index 29df84a..c7db63a 100644 --- a/src/replay/ReplayCamera.ts +++ b/src/replay/ReplayCamera.ts @@ -48,6 +48,10 @@ export class ReplayCamera { this._camera.lowerBetaLimit = 0.1; this._camera.upperBetaLimit = Math.PI / 2; + // Set clipping planes for visibility + this._camera.minZ = 0.1; // Very close near plane + this._camera.maxZ = 5000; // Far plane for distant objects + // Mouse wheel zoom speed this._camera.wheelPrecision = 20; @@ -55,6 +59,8 @@ export class ReplayCamera { this._camera.panningSensibility = 50; scene.activeCamera = this._camera; + + debugLog("ReplayCamera: Created with clipping planes minZ=0.1, maxZ=5000"); } /** @@ -115,6 +121,7 @@ export class ReplayCamera { objects.forEach(obj => { const pos = obj.position; + debugLog(`ReplayCamera: Framing object ${obj.name} at position ${pos.toString()}`); minX = Math.min(minX, pos.x); minY = Math.min(minY, pos.y); minZ = Math.min(minZ, pos.z); diff --git a/src/replay/ReplayControls.ts b/src/replay/ReplayControls.ts index 873928a..29783b0 100644 --- a/src/replay/ReplayControls.ts +++ b/src/replay/ReplayControls.ts @@ -94,8 +94,9 @@ export class ReplayControls { this._playPauseButton.fontWeight = "bold"; this._playPauseButton.left = "20px"; - this._playPauseButton.top = "-80px"; + this._playPauseButton.top = "20px"; this._playPauseButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + this._playPauseButton.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; this._playPauseButton.onPointerClickObservable.add(() => { this._player.togglePlayPause(); @@ -124,8 +125,9 @@ export class ReplayControls { stepBackBtn.fontSize = "18px"; stepBackBtn.left = "150px"; - stepBackBtn.top = "-80px"; + stepBackBtn.top = "20px"; stepBackBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + stepBackBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; stepBackBtn.onPointerClickObservable.add(() => { this._player.stepBackward(); @@ -144,8 +146,9 @@ export class ReplayControls { stepFwdBtn.fontSize = "18px"; stepFwdBtn.left = "220px"; - stepFwdBtn.top = "-80px"; + stepFwdBtn.top = "20px"; stepFwdBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + stepFwdBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; stepFwdBtn.onPointerClickObservable.add(() => { this._player.stepForward(); @@ -167,8 +170,9 @@ export class ReplayControls { this._speedText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; this._speedText.left = "-320px"; - this._speedText.top = "-95px"; + this._speedText.top = "10px"; this._speedText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + this._speedText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; this._controlBar.addControl(this._speedText); @@ -183,8 +187,9 @@ export class ReplayControls { speed05Btn.fontSize = "14px"; speed05Btn.left = "-250px"; - speed05Btn.top = "-85px"; + speed05Btn.top = "20px"; speed05Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + speed05Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; speed05Btn.onPointerClickObservable.add(() => { this._player.setPlaybackSpeed(0.5); @@ -204,8 +209,9 @@ export class ReplayControls { speed1Btn.fontSize = "14px"; speed1Btn.left = "-180px"; - speed1Btn.top = "-85px"; + speed1Btn.top = "20px"; speed1Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + speed1Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; speed1Btn.onPointerClickObservable.add(() => { this._player.setPlaybackSpeed(1.0); @@ -225,8 +231,9 @@ export class ReplayControls { speed2Btn.fontSize = "14px"; speed2Btn.left = "-110px"; - speed2Btn.top = "-85px"; + speed2Btn.top = "20px"; speed2Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + speed2Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; speed2Btn.onPointerClickObservable.add(() => { this._player.setPlaybackSpeed(2.0); @@ -252,8 +259,9 @@ export class ReplayControls { this._progressSlider.thumbColor = "#00ff88"; this._progressSlider.thumbWidth = "20px"; - this._progressSlider.top = "-30px"; + this._progressSlider.top = "80px"; this._progressSlider.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + this._progressSlider.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; let isDragging = false; @@ -286,9 +294,10 @@ export class ReplayControls { this._timeText.fontSize = "18px"; this._timeText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - this._timeText.top = "-30px"; + this._timeText.top = "80px"; this._timeText.left = "-20px"; this._timeText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + this._timeText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; this._controlBar.addControl(this._timeText); } diff --git a/src/replay/ReplayManager.ts b/src/replay/ReplayManager.ts new file mode 100644 index 0000000..a27df72 --- /dev/null +++ b/src/replay/ReplayManager.ts @@ -0,0 +1,321 @@ +import { + Engine, + HavokPlugin, + PhysicsMotionType, + PhysicsViewer, + Scene, + Vector3 +} from "@babylonjs/core"; +import "@babylonjs/inspector"; +import HavokPhysics from "@babylonjs/havok"; +import { PhysicsStorage } from "../physicsStorage"; +import { ReplayPlayer } from "./ReplayPlayer"; +import { CameraMode, ReplayCamera } from "./ReplayCamera"; +import { ReplayControls } from "./ReplayControls"; +import debugLog from "../debug"; +import { DefaultScene } from "../defaultScene"; +import { Level1 } from "../level1"; + +/** + * Manages the replay scene, loading recordings, and coordinating replay components + */ +export class ReplayManager { + private _engine: Engine; + private _originalScene: Scene; + private _replayScene: Scene | null = null; + private _replayHavokPlugin: HavokPlugin | null = null; + private _physicsViewer: PhysicsViewer | null = null; + + // Replay components + private _level: Level1 | null = null; + private _player: ReplayPlayer | null = null; + private _camera: ReplayCamera | null = null; + private _controls: ReplayControls | null = null; + + private _onExitCallback: () => void; + private _keyboardHandler: ((ev: KeyboardEvent) => void) | null = null; + + constructor(engine: Engine, onExit: () => void) { + this._engine = engine; + this._originalScene = DefaultScene.MainScene; + this._onExitCallback = onExit; + } + + /** + * Start replay for a specific recording + */ + public async startReplay(recordingId: string): Promise { + debugLog(`ReplayManager: Starting replay for ${recordingId}`); + + // Stop any existing render loop immediately + this._engine.stopRenderLoop(); + + try { + // 1. Load recording from IndexedDB + const storage = new PhysicsStorage(); + await storage.initialize(); + const recording = await storage.loadRecording(recordingId); + storage.close(); + + if (!recording || !recording.metadata.levelConfig) { + debugLog("ReplayManager: Recording not found or missing LevelConfig"); + return; + } + + debugLog(`ReplayManager: Loaded recording with ${recording.snapshots.length} frames`); + + // 2. Create replay scene + await this.createReplayScene(); + + // 3. Use Level1 to populate the scene (reuse game logic!) + debugLog('ReplayManager: Initializing Level1 in replay mode'); + this._level = new Level1(recording.metadata.levelConfig, null, true); // isReplayMode = true + await this._level.initialize(); + debugLog('ReplayManager: Level1 initialized successfully'); + + // 4. Convert all physics bodies to ANIMATED (replay-controlled) + let physicsCount = 0; + for (const mesh of this._replayScene!.meshes) { + if (mesh.physicsBody) { + mesh.physicsBody.setMotionType(PhysicsMotionType.ANIMATED); + // Disable collisions for replay objects + const shape = mesh.physicsBody.shape; + if (shape) { + shape.filterMembershipMask = 0; + shape.filterCollideMask = 0; + } + physicsCount++; + } + } + debugLog(`ReplayManager: Set ${physicsCount} objects to ANIMATED motion type`); + + // 5. Create player for physics playback + this._player = new ReplayPlayer(this._replayScene!, recording); + await this._player.initialize(); + + // Enable physics debug for all replay objects + if (this._physicsViewer) { + const replayObjects = this._player.getReplayObjects(); + debugLog(`ReplayManager: Enabling physics debug for ${replayObjects.size} objects`); + replayObjects.forEach((mesh) => { + if (mesh.physicsBody) { + this._physicsViewer!.showBody(mesh.physicsBody); + } + }); + } + + // 6. Setup camera + this._camera = new ReplayCamera(this._replayScene!); + + // Frame all objects initially in FREE mode + const objects = Array.from(this._player.getReplayObjects().values()); + debugLog(`ReplayManager: Framing ${objects.length} objects for camera`); + + if (objects.length > 0) { + this._camera.frameAllObjects(objects); + this._camera.setMode(CameraMode.FREE); + debugLog(`ReplayManager: Camera set to FREE mode`); + } else { + debugLog(`ReplayManager: WARNING - No objects to frame!`); + // Set default camera position if no objects + this._camera.getCamera().position.set(0, 50, -100); + this._camera.getCamera().setTarget(Vector3.Zero()); + } + + // Set ship as follow target for later toggling + const ship = this._player.getShipMesh(); + if (ship) { + this._camera.setFollowTarget(ship); + debugLog(`ReplayManager: Ship set as follow target`); + } + + // 6. Create controls UI + this._controls = new ReplayControls(this._player, this._camera, () => { + this.exitReplay(); + }); + this._controls.initialize(); + + // 7. Setup keyboard handler for inspector + this._keyboardHandler = (ev: KeyboardEvent) => { + // Toggle inspector with 'i' key + if (ev.key === 'i' || ev.key === 'I') { + if (this._replayScene) { + if (this._replayScene.debugLayer.isVisible()) { + this._replayScene.debugLayer.hide(); + debugLog("ReplayManager: Inspector hidden"); + } else { + this._replayScene.debugLayer.show(); + debugLog("ReplayManager: Inspector shown"); + } + } + } + }; + window.addEventListener('keydown', this._keyboardHandler); + debugLog("ReplayManager: Keyboard handler registered (press 'i' for inspector)"); + + // 8. Start render loop + debugLog(`ReplayManager: Starting render loop for replay scene`); + debugLog(`ReplayManager: Replay scene has ${this._replayScene!.meshes.length} meshes, camera: ${this._replayScene!.activeCamera?.name}`); + + this._engine.runRenderLoop(() => { + if (this._replayScene && this._replayScene.activeCamera) { + this._replayScene.render(); + + // Update camera and controls + if (this._camera) { + this._camera.update(); + } + if (this._controls) { + this._controls.update(); + } + } + }); + + // 9. Auto-start playback + this._player.play(); + + debugLog("ReplayManager: Replay started successfully"); + } catch (error) { + debugLog("ReplayManager: Error starting replay", error); + await this.exitReplay(); + } + } + + + /** + * Create a new scene for replay + */ + private async createReplayScene(): Promise { + // Dispose old replay scene if exists + if (this._replayScene) { + await this.disposeReplayScene(); + } + + // Create new scene + this._replayScene = new Scene(this._engine); + + // Create new Havok physics instance for this scene + debugLog("ReplayManager: Creating Havok physics instance for replay scene"); + const havok = await HavokPhysics(); + this._replayHavokPlugin = new HavokPlugin(true, havok); + + // Enable physics + this._replayScene.enablePhysics(Vector3.Zero(), this._replayHavokPlugin); + + // Enable physics debug rendering + this._physicsViewer = new PhysicsViewer(this._replayScene); + debugLog("ReplayManager: Physics debug viewer created"); + + // Update DefaultScene singleton (Level1.initialize will use this scene) + DefaultScene.MainScene = this._replayScene; + + debugLog("ReplayManager: Replay scene created"); + } + + /** + * Exit replay and return to original scene + */ + public async exitReplay(): Promise { + debugLog("ReplayManager: Exiting replay"); + + // Remove keyboard handler + if (this._keyboardHandler) { + window.removeEventListener('keydown', this._keyboardHandler); + this._keyboardHandler = null; + debugLog("ReplayManager: Keyboard handler removed"); + } + + // Stop render loop + this._engine.stopRenderLoop(); + + // Dispose replay components + await this.disposeReplayScene(); + + // Restore original scene + DefaultScene.MainScene = this._originalScene; + + // Restore original render loop + this._engine.runRenderLoop(() => { + this._originalScene.render(); + }); + + // Call exit callback + this._onExitCallback(); + + debugLog("ReplayManager: Exited replay"); + } + + /** + * Dispose of replay scene and all components + */ + private async disposeReplayScene(): Promise { + if (!this._replayScene) { + return; + } + + debugLog("ReplayManager: Disposing replay scene"); + + // 1. Dispose UI + if (this._controls) { + this._controls.dispose(); + this._controls = null; + } + + // 2. Dispose player (stops playback, removes observables) + if (this._player) { + this._player.dispose(); + this._player = null; + } + + // 3. Dispose camera + if (this._camera) { + this._camera.dispose(); + this._camera = null; + } + + // 4. Dispose level (if exists) + if (this._level) { + // Level disposal would happen here if needed + this._level = null; + } + + // 6. Dispose all meshes with physics + this._replayScene.meshes.forEach(mesh => { + if (mesh.physicsBody) { + mesh.physicsBody.dispose(); + } + if (mesh.skeleton) { + mesh.skeleton.dispose(); + } + mesh.dispose(); + }); + + // 7. Dispose materials and textures + this._replayScene.materials.forEach(mat => mat.dispose()); + this._replayScene.textures.forEach(tex => tex.dispose()); + + // 8. Dispose scene + this._replayScene.dispose(); + this._replayScene = null; + + // 9. Clean up physics viewer + if (this._physicsViewer) { + this._physicsViewer.dispose(); + this._physicsViewer = null; + } + + // 10. Clean up Havok plugin + if (this._replayHavokPlugin) { + this._replayHavokPlugin = null; + } + + debugLog("ReplayManager: Replay scene disposed"); + } + + /** + * Get current replay scene + */ + public getReplayScene(): Scene | null { + return this._replayScene; + } +} diff --git a/src/replay/ReplayPlayer.ts b/src/replay/ReplayPlayer.ts index 09f2465..80fe51e 100644 --- a/src/replay/ReplayPlayer.ts +++ b/src/replay/ReplayPlayer.ts @@ -1,15 +1,11 @@ import { AbstractMesh, Observable, - PhysicsAggregate, - PhysicsMotionType, - PhysicsShapeType, Quaternion, Scene, Vector3 } from "@babylonjs/core"; import { PhysicsRecording, PhysicsSnapshot } from "../physicsRecorder"; -import { ReplayAssetRegistry } from "./ReplayAssetRegistry"; import debugLog from "../debug"; /** @@ -19,7 +15,6 @@ import debugLog from "../debug"; export class ReplayPlayer { private _scene: Scene; private _recording: PhysicsRecording; - private _assetRegistry: ReplayAssetRegistry; private _replayObjects: Map = new Map(); // Playback state @@ -27,26 +22,28 @@ export class ReplayPlayer { private _isPlaying: boolean = false; private _playbackSpeed: number = 1.0; - // Timing - private _physicsHz: number; - private _frameDuration: number; // milliseconds per physics frame + // Timing (timestamp-based, not Hz-based) + private _playbackStartTime: number = 0; // Real-world time when playback started + private _recordingStartTimestamp: number = 0; // First snapshot's timestamp private _lastUpdateTime: number = 0; - private _accumulatedTime: number = 0; // Observables public onPlayStateChanged: Observable = new Observable(); public onFrameChanged: Observable = new Observable(); - constructor(scene: Scene, recording: PhysicsRecording, assetRegistry: ReplayAssetRegistry) { + constructor(scene: Scene, recording: PhysicsRecording) { this._scene = scene; this._recording = recording; - this._assetRegistry = assetRegistry; - this._physicsHz = recording.metadata.physicsUpdateRate || 7.2; - this._frameDuration = 1000 / this._physicsHz; // ~138.9ms at 7.2 Hz + + // Store first snapshot's timestamp as our recording start reference + if (recording.snapshots.length > 0) { + this._recordingStartTimestamp = recording.snapshots[0].timestamp; + } } /** - * Initialize replay by creating all meshes from first snapshot + * Initialize replay by finding existing meshes in the scene + * (Level1.initialize() has already created all objects) */ public async initialize(): Promise { if (this._recording.snapshots.length === 0) { @@ -55,35 +52,24 @@ export class ReplayPlayer { } const firstSnapshot = this._recording.snapshots[0]; - debugLog(`ReplayPlayer: Creating ${firstSnapshot.objects.length} replay objects`); + debugLog(`ReplayPlayer: Initializing replay for ${firstSnapshot.objects.length} objects`); + debugLog(`ReplayPlayer: Object IDs in snapshot: ${firstSnapshot.objects.map(o => o.id).join(', ')}`); + // Find all existing meshes in the scene (already created by Level1.initialize()) for (const objState of firstSnapshot.objects) { - const mesh = this._assetRegistry.createReplayMesh(objState.id); - if (!mesh) { - continue; // Skip objects we can't create (like ammo) - } + const mesh = this._scene.getMeshByName(objState.id) as AbstractMesh; - this._replayObjects.set(objState.id, mesh); - - // Create physics body (ANIMATED = kinematic, we control position directly) - try { - const agg = new PhysicsAggregate( - mesh, - PhysicsShapeType.MESH, - { - mass: objState.mass, - restitution: objState.restitution - }, - this._scene - ); - agg.body.setMotionType(PhysicsMotionType.ANIMATED); - } catch (error) { - debugLog(`ReplayPlayer: Could not create physics for ${objState.id}`, error); + if (mesh) { + this._replayObjects.set(objState.id, mesh); + debugLog(`ReplayPlayer: Found ${objState.id} in scene (physics: ${!!mesh.physicsBody})`); + } else { + debugLog(`ReplayPlayer: WARNING - Object ${objState.id} not found in scene`); } } // Apply first frame state this.applySnapshot(firstSnapshot); + debugLog(`ReplayPlayer: Initialized with ${this._replayObjects.size} objects`); } @@ -96,13 +82,14 @@ export class ReplayPlayer { } this._isPlaying = true; - this._lastUpdateTime = performance.now(); + this._playbackStartTime = performance.now(); + this._lastUpdateTime = this._playbackStartTime; this.onPlayStateChanged.notifyObservers(true); // Use scene.onBeforeRenderObservable for smooth updates this._scene.onBeforeRenderObservable.add(this.updateCallback); - debugLog("ReplayPlayer: Playback started"); + debugLog("ReplayPlayer: Playback started (timestamp-based)"); } /** @@ -132,48 +119,65 @@ export class ReplayPlayer { } /** - * Update callback for render loop + * Update callback for render loop (timestamp-based) */ private updateCallback = (): void => { - if (!this._isPlaying) { + if (!this._isPlaying || this._recording.snapshots.length === 0) { return; } const now = performance.now(); - const deltaTime = (now - this._lastUpdateTime) * this._playbackSpeed; - this._lastUpdateTime = now; - this._accumulatedTime += deltaTime; + // Calculate elapsed playback time (with speed multiplier) + const elapsedPlaybackTime = (now - this._playbackStartTime) * this._playbackSpeed; - // Update when enough time has passed for next frame - while (this._accumulatedTime >= this._frameDuration) { - this._accumulatedTime -= this._frameDuration; - this.advanceFrame(); + // Calculate target recording timestamp + const targetTimestamp = this._recordingStartTimestamp + elapsedPlaybackTime; + + // Find the correct frame for this timestamp + let targetFrameIndex = this._currentFrameIndex; + + // Advance to the frame that matches our target timestamp + while (targetFrameIndex < this._recording.snapshots.length - 1 && + this._recording.snapshots[targetFrameIndex + 1].timestamp <= targetTimestamp) { + targetFrameIndex++; } - // Interpolate between frames for smooth motion - const alpha = this._accumulatedTime / this._frameDuration; - this.interpolateFrame(alpha); - }; + // If we advanced frames, update and notify + if (targetFrameIndex !== this._currentFrameIndex) { + this._currentFrameIndex = targetFrameIndex; - /** - * Advance to next frame - */ - private advanceFrame(): void { - this._currentFrameIndex++; + // Debug: Log frame advancement every 10 frames + if (this._currentFrameIndex % 10 === 0) { + const snapshot = this._recording.snapshots[this._currentFrameIndex]; + debugLog(`ReplayPlayer: Frame ${this._currentFrameIndex}/${this._recording.snapshots.length}, timestamp: ${snapshot.timestamp.toFixed(1)}ms, objects: ${snapshot.objects.length}`); + } - if (this._currentFrameIndex >= this._recording.snapshots.length) { - // End of recording - this._currentFrameIndex = this._recording.snapshots.length - 1; + this.applySnapshot(this._recording.snapshots[this._currentFrameIndex]); + this.onFrameChanged.notifyObservers(this._currentFrameIndex); + } + + // Check if we reached the end + if (this._currentFrameIndex >= this._recording.snapshots.length - 1 && + targetTimestamp >= this._recording.snapshots[this._recording.snapshots.length - 1].timestamp) { this.pause(); debugLog("ReplayPlayer: Reached end of recording"); return; } - const snapshot = this._recording.snapshots[this._currentFrameIndex]; - this.applySnapshot(snapshot); - this.onFrameChanged.notifyObservers(this._currentFrameIndex); - } + // Interpolate between current and next frame for smooth visuals + if (this._currentFrameIndex < this._recording.snapshots.length - 1) { + const currentSnapshot = this._recording.snapshots[this._currentFrameIndex]; + const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1]; + + const frameDuration = nextSnapshot.timestamp - currentSnapshot.timestamp; + const frameElapsed = targetTimestamp - currentSnapshot.timestamp; + const alpha = frameDuration > 0 ? Math.min(frameElapsed / frameDuration, 1.0) : 0; + + this.interpolateFrame(alpha); + } + }; + /** * Apply a snapshot's state to all objects @@ -185,30 +189,30 @@ export class ReplayPlayer { continue; } - // Apply position - mesh.position.set( + const newPosition = new Vector3( objState.position[0], objState.position[1], objState.position[2] ); - // Apply rotation (quaternion) - if (!mesh.rotationQuaternion) { - mesh.rotationQuaternion = new Quaternion(); - } - mesh.rotationQuaternion.set( + const newRotation = new Quaternion( objState.rotation[0], objState.rotation[1], objState.rotation[2], objState.rotation[3] ); - // Update physics body transform if exists + // Update mesh transform directly + mesh.position.copyFrom(newPosition); + if (!mesh.rotationQuaternion) { + mesh.rotationQuaternion = new Quaternion(); + } + mesh.rotationQuaternion.copyFrom(newRotation); + + // For ANIMATED bodies, sync physics from mesh + // (ANIMATED bodies should follow their transform node) if (mesh.physicsBody) { - mesh.physicsBody.setTargetTransform( - mesh.position, - mesh.rotationQuaternion - ); + mesh.physicsBody.disablePreStep = false; } } } @@ -235,24 +239,37 @@ export class ReplayPlayer { continue; } + // Create temporary vectors for interpolation + const interpPosition = new Vector3(); + const interpRotation = new Quaternion(); + // Lerp position Vector3.LerpToRef( new Vector3(...objState.position), new Vector3(...nextState.position), alpha, - mesh.position + interpPosition ); // Slerp rotation - if (!mesh.rotationQuaternion) { - mesh.rotationQuaternion = new Quaternion(); - } Quaternion.SlerpToRef( new Quaternion(...objState.rotation), new Quaternion(...nextState.rotation), alpha, - mesh.rotationQuaternion + interpRotation ); + + // Apply interpolated transform to mesh + mesh.position.copyFrom(interpPosition); + if (!mesh.rotationQuaternion) { + mesh.rotationQuaternion = new Quaternion(); + } + mesh.rotationQuaternion.copyFrom(interpRotation); + + // Physics body will sync from mesh if ANIMATED + if (mesh.physicsBody) { + mesh.physicsBody.disablePreStep = false; + } } } @@ -263,7 +280,14 @@ export class ReplayPlayer { this._currentFrameIndex = Math.max(0, Math.min(frameIndex, this._recording.snapshots.length - 1)); const snapshot = this._recording.snapshots[this._currentFrameIndex]; this.applySnapshot(snapshot); - this._accumulatedTime = 0; // Reset interpolation + + // Reset playback timing to match the new frame's timestamp + if (this._isPlaying) { + const targetTimestamp = snapshot.timestamp; + const elapsedRecordingTime = targetTimestamp - this._recordingStartTimestamp; + this._playbackStartTime = performance.now() - (elapsedRecordingTime / this._playbackSpeed); + } + this.onFrameChanged.notifyObservers(this._currentFrameIndex); } diff --git a/src/replay/ReplaySelectionScreen.ts b/src/replay/ReplaySelectionScreen.ts new file mode 100644 index 0000000..8c9f906 --- /dev/null +++ b/src/replay/ReplaySelectionScreen.ts @@ -0,0 +1,371 @@ +import { + AdvancedDynamicTexture, + Button, + Control, + Rectangle, + ScrollViewer, + StackPanel, + TextBlock +} from "@babylonjs/gui"; +import { PhysicsStorage } from "../physicsStorage"; +import debugLog from "../debug"; + +/** + * Recording info for display + */ +interface RecordingInfo { + id: string; + name: string; + timestamp: number; + duration: number; + frameCount: number; +} + +/** + * Fullscreen UI for selecting a recording to replay + */ +export class ReplaySelectionScreen { + private _texture: AdvancedDynamicTexture; + private _scrollViewer: ScrollViewer; + private _recordingsList: StackPanel; + private _selectedRecording: string | null = null; + private _playButton: Button; + private _deleteButton: Button; + + private _onPlayCallback: (recordingId: string) => void; + private _onCancelCallback: () => void; + + private _selectedContainer: Rectangle | null = null; + + constructor(onPlay: (recordingId: string) => void, onCancel: () => void) { + this._onPlayCallback = onPlay; + this._onCancelCallback = onCancel; + } + + /** + * Initialize and show the selection screen + */ + public async initialize(): Promise { + this._texture = AdvancedDynamicTexture.CreateFullscreenUI("replaySelection"); + + // Semi-transparent background + const background = new Rectangle("background"); + background.width = "100%"; + background.height = "100%"; + background.background = "rgba(10, 10, 20, 0.95)"; + background.thickness = 0; + this._texture.addControl(background); + + // Main panel + const mainPanel = new Rectangle("mainPanel"); + mainPanel.width = "900px"; + mainPanel.height = "700px"; + mainPanel.thickness = 2; + mainPanel.color = "#00ff88"; + mainPanel.background = "#1a1a2e"; + mainPanel.cornerRadius = 10; + mainPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + mainPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; + this._texture.addControl(mainPanel); + + // Title + const title = new TextBlock("title", "RECORDED SESSIONS"); + title.width = "100%"; + title.height = "80px"; + title.color = "#00ff88"; + title.fontSize = "40px"; + title.fontWeight = "bold"; + title.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + title.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + title.top = "20px"; + mainPanel.addControl(title); + + // ScrollViewer for recordings list + this._scrollViewer = new ScrollViewer("scrollViewer"); + this._scrollViewer.width = "840px"; + this._scrollViewer.height = "480px"; + this._scrollViewer.thickness = 1; + this._scrollViewer.color = "#444"; + this._scrollViewer.background = "#0a0a1e"; + this._scrollViewer.top = "110px"; + this._scrollViewer.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + mainPanel.addControl(this._scrollViewer); + + // StackPanel inside ScrollViewer + this._recordingsList = new StackPanel("recordingsList"); + this._recordingsList.width = "100%"; + this._recordingsList.isVertical = true; + this._recordingsList.spacing = 10; + this._recordingsList.paddingTop = "10px"; + this._recordingsList.paddingBottom = "10px"; + this._scrollViewer.addControl(this._recordingsList); + + // Bottom button bar + this.createButtonBar(mainPanel); + + // Load recordings + await this.loadRecordings(); + + debugLog("ReplaySelectionScreen: Initialized"); + } + + /** + * Create button bar at bottom + */ + private createButtonBar(parent: Rectangle): void { + const buttonBar = new StackPanel("buttonBar"); + buttonBar.isVertical = false; + buttonBar.width = "100%"; + buttonBar.height = "80px"; + buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + buttonBar.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM; + buttonBar.spacing = 20; + buttonBar.paddingBottom = "20px"; + parent.addControl(buttonBar); + + // Play button + this._playButton = Button.CreateSimpleButton("play", "โ–ถ Play Selected"); + this._playButton.width = "200px"; + this._playButton.height = "50px"; + this._playButton.color = "white"; + this._playButton.background = "#00ff88"; + this._playButton.cornerRadius = 10; + this._playButton.thickness = 0; + this._playButton.fontSize = "20px"; + this._playButton.fontWeight = "bold"; + this._playButton.isEnabled = false; // Disabled until selection + + this._playButton.onPointerClickObservable.add(() => { + if (this._selectedRecording) { + this._onPlayCallback(this._selectedRecording); + } + }); + + buttonBar.addControl(this._playButton); + + // Delete button + this._deleteButton = Button.CreateSimpleButton("delete", "๐Ÿ—‘ Delete"); + this._deleteButton.width = "150px"; + this._deleteButton.height = "50px"; + this._deleteButton.color = "white"; + this._deleteButton.background = "#cc3333"; + this._deleteButton.cornerRadius = 10; + this._deleteButton.thickness = 0; + this._deleteButton.fontSize = "18px"; + this._deleteButton.fontWeight = "bold"; + this._deleteButton.isEnabled = false; // Disabled until selection + + this._deleteButton.onPointerClickObservable.add(async () => { + if (this._selectedRecording) { + await this.deleteRecording(this._selectedRecording); + } + }); + + buttonBar.addControl(this._deleteButton); + + // Cancel button + const cancelButton = Button.CreateSimpleButton("cancel", "โœ• Cancel"); + cancelButton.width = "150px"; + cancelButton.height = "50px"; + cancelButton.color = "white"; + cancelButton.background = "#555"; + cancelButton.cornerRadius = 10; + cancelButton.thickness = 0; + cancelButton.fontSize = "18px"; + cancelButton.fontWeight = "bold"; + + cancelButton.onPointerClickObservable.add(() => { + this._onCancelCallback(); + }); + + buttonBar.addControl(cancelButton); + } + + /** + * Load recordings from IndexedDB + */ + private async loadRecordings(): Promise { + const storage = new PhysicsStorage(); + await storage.initialize(); + const recordings = await storage.listRecordings(); + storage.close(); + + if (recordings.length === 0) { + this.showNoRecordingsMessage(); + return; + } + + // Sort by timestamp (newest first) + recordings.sort((a, b) => b.timestamp - a.timestamp); + + recordings.forEach(rec => { + const item = this.createRecordingItem(rec); + this._recordingsList.addControl(item); + }); + + debugLog(`ReplaySelectionScreen: Loaded ${recordings.length} recordings`); + } + + /** + * Show message when no recordings are available + */ + private showNoRecordingsMessage(): void { + const message = new TextBlock("noRecordings", "No recordings available yet.\n\nPlay the game to create recordings!"); + message.width = "100%"; + message.height = "200px"; + message.color = "#888"; + message.fontSize = "24px"; + message.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + message.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; + message.textWrapping = true; + this._recordingsList.addControl(message); + } + + /** + * Create a selectable recording item + */ + private createRecordingItem(recording: RecordingInfo): Rectangle { + const itemContainer = new Rectangle(); + itemContainer.width = "800px"; + itemContainer.height = "90px"; + itemContainer.thickness = 1; + itemContainer.color = "#555"; + itemContainer.background = "#2a2a3e"; + itemContainer.cornerRadius = 5; + itemContainer.isPointerBlocker = true; + itemContainer.hoverCursor = "pointer"; + + // Hover effect + itemContainer.onPointerEnterObservable.add(() => { + if (this._selectedRecording !== recording.id) { + itemContainer.background = "#3a3a4e"; + } + }); + + itemContainer.onPointerOutObservable.add(() => { + if (this._selectedRecording !== recording.id) { + itemContainer.background = "#2a2a3e"; + } + }); + + // Click to select + itemContainer.onPointerClickObservable.add(() => { + this.selectRecording(recording.id, itemContainer); + }); + + // Content panel + const contentPanel = new StackPanel(); + contentPanel.isVertical = true; + contentPanel.width = "100%"; + contentPanel.paddingLeft = "20px"; + contentPanel.paddingRight = "20px"; + contentPanel.paddingTop = "10px"; + itemContainer.addControl(contentPanel); + + // Session name (first line) - Format session ID nicely + const sessionName = this.formatSessionName(recording.name); + const nameText = new TextBlock("name", sessionName); + nameText.height = "30px"; + nameText.color = "#00ff88"; + nameText.fontSize = "20px"; + nameText.fontWeight = "bold"; + nameText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + contentPanel.addControl(nameText); + + // Details (second line) + const date = new Date(recording.timestamp); + const dateStr = date.toLocaleString(); + const durationStr = this.formatDuration(recording.duration); + const detailsText = new TextBlock( + "details", + `๐Ÿ“… ${dateStr} | โฑ ${durationStr} | ๐Ÿ“Š ${recording.frameCount} frames` + ); + detailsText.height = "25px"; + detailsText.color = "#aaa"; + detailsText.fontSize = "16px"; + detailsText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + contentPanel.addControl(detailsText); + + return itemContainer; + } + + /** + * Select a recording + */ + private selectRecording(recordingId: string, container: Rectangle): void { + // Deselect previous + if (this._selectedContainer) { + this._selectedContainer.background = "#2a2a3e"; + this._selectedContainer.color = "#555"; + } + + // Select new + this._selectedRecording = recordingId; + this._selectedContainer = container; + container.background = "#00ff88"; + container.color = "#00ff88"; + + // Enable buttons + this._playButton.isEnabled = true; + this._deleteButton.isEnabled = true; + + debugLog(`ReplaySelectionScreen: Selected recording ${recordingId}`); + } + + /** + * Delete a recording + */ + private async deleteRecording(recordingId: string): Promise { + const storage = new PhysicsStorage(); + await storage.initialize(); + await storage.deleteRecording(recordingId); + storage.close(); + + debugLog(`ReplaySelectionScreen: Deleted recording ${recordingId}`); + + // Refresh list + this._recordingsList.clearControls(); + this._selectedRecording = null; + this._selectedContainer = null; + this._playButton.isEnabled = false; + this._deleteButton.isEnabled = false; + + await this.loadRecordings(); + } + + /** + * Format session name for display + */ + private formatSessionName(sessionId: string): string { + // Convert "session-1762606365166" to "Session 2024-11-08 07:06" + if (sessionId.startsWith('session-')) { + const timestamp = parseInt(sessionId.replace('session-', '')); + const date = new Date(timestamp); + const dateStr = date.toLocaleDateString(); + const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return `Session ${dateStr} ${timeStr}`; + } + return sessionId; + } + + /** + * Format duration for display + */ + private formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + if (mins > 0) { + return `${mins}m ${secs}s`; + } else { + return `${secs}s`; + } + } + + /** + * Dispose of UI + */ + public dispose(): void { + this._texture.dispose(); + debugLog("ReplaySelectionScreen: Disposed"); + } +} diff --git a/src/ship.ts b/src/ship.ts index 6b81374..a19e94e 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -50,9 +50,11 @@ export class Ship { private _landingAggregate: PhysicsAggregate | null = null; private _resupplyTimer: number = 0; private _isInLandingZone: boolean = false; + private _isReplayMode: boolean; - constructor(audioEngine?: AudioEngineV2) { + constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) { this._audioEngine = audioEngine; + this._isReplayMode = isReplayMode; } public get scoreboard(): Scoreboard { @@ -138,46 +140,48 @@ export class Ship { this._weapons.setShipStatus(this._scoreboard.shipStatus); this._weapons.setGameStats(this._gameStats); - // Initialize input systems - this._keyboardInput = new KeyboardInput(DefaultScene.MainScene); - this._keyboardInput.setup(); + // Initialize input systems (skip in replay mode) + if (!this._isReplayMode) { + this._keyboardInput = new KeyboardInput(DefaultScene.MainScene); + this._keyboardInput.setup(); - this._controllerInput = new ControllerInput(); + this._controllerInput = new ControllerInput(); - // Wire up shooting events - this._keyboardInput.onShootObservable.add(() => { - this.handleShoot(); - }); + // Wire up shooting events + this._keyboardInput.onShootObservable.add(() => { + this.handleShoot(); + }); - this._controllerInput.onShootObservable.add(() => { - this.handleShoot(); - }); + this._controllerInput.onShootObservable.add(() => { + this.handleShoot(); + }); - // Wire up status screen toggle event - this._controllerInput.onStatusScreenToggleObservable.add(() => { - if (this._statusScreen) { - this._statusScreen.toggle(); - } - }); - - // Wire up camera adjustment events - this._keyboardInput.onCameraChangeObservable.add((cameraKey) => { - if (cameraKey === 1) { - this._camera.position.x = 15; - this._camera.rotation.y = -Math.PI / 2; - } - }); - - this._controllerInput.onCameraAdjustObservable.add((adjustment) => { - if (DefaultScene.XR?.baseExperience?.camera) { - const camera = DefaultScene.XR.baseExperience.camera; - if (adjustment.direction === "down") { - camera.position.y = camera.position.y - 0.1; - } else { - camera.position.y = camera.position.y + 0.1; + // Wire up status screen toggle event + this._controllerInput.onStatusScreenToggleObservable.add(() => { + if (this._statusScreen) { + this._statusScreen.toggle(); } - } - }); + }); + + // Wire up camera adjustment events + this._keyboardInput.onCameraChangeObservable.add((cameraKey) => { + if (cameraKey === 1) { + this._camera.position.x = 15; + this._camera.rotation.y = -Math.PI / 2; + } + }); + + this._controllerInput.onCameraAdjustObservable.add((adjustment) => { + if (DefaultScene.XR?.baseExperience?.camera) { + const camera = DefaultScene.XR.baseExperience.camera; + if (adjustment.direction === "down") { + camera.position.y = camera.position.y - 0.1; + } else { + camera.position.y = camera.position.y + 0.1; + } + } + }); + } // Initialize physics controller this._physics = new ShipPhysics();