();
+ 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();