From 128b4029551fbadd6d8bd1309d2c4293d7395cbf Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 8 Nov 2025 06:05:38 -0600 Subject: [PATCH] Add core replay system components (part 1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented 4 core classes for physics replay functionality: 1. ReplayAssetRegistry - Loads and caches mesh templates - Pre-loads ship, asteroid, and base meshes - Creates instances/clones based on object ID - Handles asset disposal and caching 2. ReplayPlayer - Frame-by-frame playback engine - Fixed timestep at 7.2 Hz with frame interpolation - Play/pause/scrub/step forward/step backward - Variable playback speed (0.5x, 1x, 2x) - ANIMATED physics bodies (kinematic control) - Observable events for state changes 3. ReplayCamera - Dual-mode camera system - Free camera (ArcRotateCamera user-controlled) - Ship-following mode with smooth lerp - Toggle between modes - Auto-framing to fit all objects - Camera limits and controls 4. ReplayControls - Full playback UI - Play/pause, step forward/backward buttons - Speed control (0.5x, 1x, 2x) - Progress slider for scrubbing - Time display (current/total) - Camera mode toggle button - Exit button Next: ReplaySelectionScreen, ReplayManager, and integration πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/replay/ReplayAssetRegistry.ts | 153 ++++++++++++ src/replay/ReplayCamera.ts | 180 ++++++++++++++ src/replay/ReplayControls.ts | 381 ++++++++++++++++++++++++++++++ src/replay/ReplayPlayer.ts | 373 +++++++++++++++++++++++++++++ 4 files changed, 1087 insertions(+) create mode 100644 src/replay/ReplayAssetRegistry.ts create mode 100644 src/replay/ReplayCamera.ts create mode 100644 src/replay/ReplayControls.ts create mode 100644 src/replay/ReplayPlayer.ts diff --git a/src/replay/ReplayAssetRegistry.ts b/src/replay/ReplayAssetRegistry.ts new file mode 100644 index 0000000..9c51e69 --- /dev/null +++ b/src/replay/ReplayAssetRegistry.ts @@ -0,0 +1,153 @@ +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 new file mode 100644 index 0000000..29df84a --- /dev/null +++ b/src/replay/ReplayCamera.ts @@ -0,0 +1,180 @@ +import { + AbstractMesh, + ArcRotateCamera, + Scene, + Vector3 +} from "@babylonjs/core"; +import debugLog from "../debug"; + +/** + * Camera modes for replay viewing + */ +export enum CameraMode { + FREE = "free", + FOLLOW_SHIP = "follow_ship" +} + +/** + * Manages camera for replay viewing with free and follow modes + */ +export class ReplayCamera { + private _camera: ArcRotateCamera; + private _scene: Scene; + private _mode: CameraMode = CameraMode.FREE; + private _followTarget: AbstractMesh | null = null; + + constructor(scene: Scene) { + this._scene = scene; + + // Create orbiting camera + this._camera = new ArcRotateCamera( + "replayCamera", + Math.PI / 2, // alpha (horizontal rotation) + Math.PI / 3, // beta (vertical rotation) + 50, // radius (distance from target) + Vector3.Zero(), + scene + ); + + // Attach controls for user interaction + const canvas = scene.getEngine().getRenderingCanvas(); + if (canvas) { + this._camera.attachControl(canvas, true); + } + + // Set camera limits + this._camera.lowerRadiusLimit = 10; + this._camera.upperRadiusLimit = 500; + this._camera.lowerBetaLimit = 0.1; + this._camera.upperBetaLimit = Math.PI / 2; + + // Mouse wheel zoom speed + this._camera.wheelPrecision = 20; + + // Panning speed + this._camera.panningSensibility = 50; + + scene.activeCamera = this._camera; + } + + /** + * Get the camera instance + */ + public getCamera(): ArcRotateCamera { + return this._camera; + } + + /** + * Set camera mode + */ + public setMode(mode: CameraMode): void { + this._mode = mode; + debugLog(`ReplayCamera: Mode set to ${mode}`); + } + + /** + * Get current mode + */ + public getMode(): CameraMode { + return this._mode; + } + + /** + * Toggle between free and follow modes + */ + public toggleMode(): void { + if (this._mode === CameraMode.FREE) { + this.setMode(CameraMode.FOLLOW_SHIP); + } else { + this.setMode(CameraMode.FREE); + } + } + + /** + * Set target to follow (usually the ship) + */ + public setFollowTarget(mesh: AbstractMesh | null): void { + this._followTarget = mesh; + if (mesh) { + this._camera.setTarget(mesh.position); + debugLog("ReplayCamera: Follow target set"); + } + } + + /** + * Calculate optimal viewpoint to frame all objects + */ + public frameAllObjects(objects: AbstractMesh[]): void { + if (objects.length === 0) { + return; + } + + // Calculate bounding box of all objects + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + objects.forEach(obj => { + const pos = obj.position; + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + minZ = Math.min(minZ, pos.z); + maxX = Math.max(maxX, pos.x); + maxY = Math.max(maxY, pos.y); + maxZ = Math.max(maxZ, pos.z); + }); + + // Calculate center + const center = new Vector3( + (minX + maxX) / 2, + (minY + maxY) / 2, + (minZ + maxZ) / 2 + ); + + // Calculate size + const size = Math.max( + maxX - minX, + maxY - minY, + maxZ - minZ + ); + + // Position camera to frame everything + this._camera.setTarget(center); + this._camera.radius = Math.max(50, size * 1.5); // At least 50 units away + + debugLog(`ReplayCamera: Framed ${objects.length} objects (radius: ${this._camera.radius.toFixed(1)})`); + } + + /** + * Update camera (call every frame) + */ + public update(): void { + if (this._mode === CameraMode.FOLLOW_SHIP && this._followTarget) { + // Smooth camera following with lerp + Vector3.LerpToRef( + this._camera.target, + this._followTarget.position, + 0.1, // Smoothing factor (0 = no follow, 1 = instant) + this._camera.target + ); + } + } + + /** + * Reset camera to default position + */ + public reset(): void { + this._camera.alpha = Math.PI / 2; + this._camera.beta = Math.PI / 3; + this._camera.radius = 50; + this._camera.setTarget(Vector3.Zero()); + debugLog("ReplayCamera: Reset to default"); + } + + /** + * Dispose of camera + */ + public dispose(): void { + this._camera.dispose(); + debugLog("ReplayCamera: Disposed"); + } +} diff --git a/src/replay/ReplayControls.ts b/src/replay/ReplayControls.ts new file mode 100644 index 0000000..873928a --- /dev/null +++ b/src/replay/ReplayControls.ts @@ -0,0 +1,381 @@ +import { + AdvancedDynamicTexture, + Button, + Control, + Rectangle, + Slider, + StackPanel, + TextBlock +} from "@babylonjs/gui"; +import { ReplayPlayer } from "./ReplayPlayer"; +import { CameraMode, ReplayCamera } from "./ReplayCamera"; +import debugLog from "../debug"; + +/** + * UI controls for replay playback + * Bottom control bar with play/pause, speed, scrubbing, etc. + */ +export class ReplayControls { + private _texture: AdvancedDynamicTexture; + private _player: ReplayPlayer; + private _camera: ReplayCamera; + + // UI Elements + private _controlBar: Rectangle; + private _playPauseButton: Button; + private _progressSlider: Slider; + private _timeText: TextBlock; + private _speedText: TextBlock; + private _cameraButton: Button; + + private _onExitCallback: () => void; + + constructor(player: ReplayPlayer, camera: ReplayCamera, onExit: () => void) { + this._player = player; + this._camera = camera; + this._onExitCallback = onExit; + } + + /** + * Initialize UI elements + */ + public initialize(): void { + this._texture = AdvancedDynamicTexture.CreateFullscreenUI("replayControls"); + + // Create control bar at bottom + this.createControlBar(); + + // Create buttons and controls + this.createPlayPauseButton(); + this.createStepButtons(); + this.createSpeedButtons(); + this.createProgressSlider(); + this.createTimeDisplay(); + this.createCameraButton(); + this.createExitButton(); + + debugLog("ReplayControls: UI initialized"); + } + + /** + * Create bottom control bar container + */ + private createControlBar(): void { + this._controlBar = new Rectangle("controlBar"); + this._controlBar.width = "100%"; + this._controlBar.height = "140px"; + this._controlBar.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM; + this._controlBar.background = "rgba(26, 26, 46, 0.95)"; + this._controlBar.thickness = 0; + this._texture.addControl(this._controlBar); + + // Inner container for spacing + const innerPanel = new StackPanel("innerPanel"); + innerPanel.isVertical = true; + innerPanel.paddingTop = "10px"; + innerPanel.paddingBottom = "10px"; + innerPanel.paddingLeft = "20px"; + innerPanel.paddingRight = "20px"; + this._controlBar.addControl(innerPanel); + } + + /** + * Create play/pause button + */ + private createPlayPauseButton(): void { + this._playPauseButton = Button.CreateSimpleButton("playPause", "β–Ά Play"); + this._playPauseButton.width = "120px"; + this._playPauseButton.height = "50px"; + this._playPauseButton.color = "white"; + this._playPauseButton.background = "#00ff88"; + this._playPauseButton.cornerRadius = 10; + this._playPauseButton.thickness = 0; + this._playPauseButton.fontSize = "20px"; + this._playPauseButton.fontWeight = "bold"; + + this._playPauseButton.left = "20px"; + this._playPauseButton.top = "-80px"; + this._playPauseButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + + this._playPauseButton.onPointerClickObservable.add(() => { + this._player.togglePlayPause(); + }); + + // Update button text based on play state + this._player.onPlayStateChanged.add((isPlaying) => { + this._playPauseButton.textBlock!.text = isPlaying ? "⏸ Pause" : "β–Ά Play"; + }); + + this._controlBar.addControl(this._playPauseButton); + } + + /** + * Create frame step buttons + */ + private createStepButtons(): void { + // Step backward button + const stepBackBtn = Button.CreateSimpleButton("stepBack", "β—€β—€"); + stepBackBtn.width = "60px"; + stepBackBtn.height = "50px"; + stepBackBtn.color = "white"; + stepBackBtn.background = "#555"; + stepBackBtn.cornerRadius = 10; + stepBackBtn.thickness = 0; + stepBackBtn.fontSize = "18px"; + + stepBackBtn.left = "150px"; + stepBackBtn.top = "-80px"; + stepBackBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + + stepBackBtn.onPointerClickObservable.add(() => { + this._player.stepBackward(); + }); + + this._controlBar.addControl(stepBackBtn); + + // Step forward button + const stepFwdBtn = Button.CreateSimpleButton("stepFwd", "β–Άβ–Ά"); + stepFwdBtn.width = "60px"; + stepFwdBtn.height = "50px"; + stepFwdBtn.color = "white"; + stepFwdBtn.background = "#555"; + stepFwdBtn.cornerRadius = 10; + stepFwdBtn.thickness = 0; + stepFwdBtn.fontSize = "18px"; + + stepFwdBtn.left = "220px"; + stepFwdBtn.top = "-80px"; + stepFwdBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + + stepFwdBtn.onPointerClickObservable.add(() => { + this._player.stepForward(); + }); + + this._controlBar.addControl(stepFwdBtn); + } + + /** + * Create speed control buttons + */ + private createSpeedButtons(): void { + // Speed label + this._speedText = new TextBlock("speedLabel", "Speed: 1.0x"); + this._speedText.width = "120px"; + this._speedText.height = "30px"; + this._speedText.color = "white"; + this._speedText.fontSize = "16px"; + this._speedText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + + this._speedText.left = "-320px"; + this._speedText.top = "-95px"; + this._speedText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + this._controlBar.addControl(this._speedText); + + // 0.5x button + const speed05Btn = Button.CreateSimpleButton("speed05", "0.5x"); + speed05Btn.width = "60px"; + speed05Btn.height = "40px"; + speed05Btn.color = "white"; + speed05Btn.background = "#444"; + speed05Btn.cornerRadius = 5; + speed05Btn.thickness = 0; + speed05Btn.fontSize = "14px"; + + speed05Btn.left = "-250px"; + speed05Btn.top = "-85px"; + speed05Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + speed05Btn.onPointerClickObservable.add(() => { + this._player.setPlaybackSpeed(0.5); + this._speedText.text = "Speed: 0.5x"; + }); + + this._controlBar.addControl(speed05Btn); + + // 1x button + const speed1Btn = Button.CreateSimpleButton("speed1", "1.0x"); + speed1Btn.width = "60px"; + speed1Btn.height = "40px"; + speed1Btn.color = "white"; + speed1Btn.background = "#444"; + speed1Btn.cornerRadius = 5; + speed1Btn.thickness = 0; + speed1Btn.fontSize = "14px"; + + speed1Btn.left = "-180px"; + speed1Btn.top = "-85px"; + speed1Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + speed1Btn.onPointerClickObservable.add(() => { + this._player.setPlaybackSpeed(1.0); + this._speedText.text = "Speed: 1.0x"; + }); + + this._controlBar.addControl(speed1Btn); + + // 2x button + const speed2Btn = Button.CreateSimpleButton("speed2", "2.0x"); + speed2Btn.width = "60px"; + speed2Btn.height = "40px"; + speed2Btn.color = "white"; + speed2Btn.background = "#444"; + speed2Btn.cornerRadius = 5; + speed2Btn.thickness = 0; + speed2Btn.fontSize = "14px"; + + speed2Btn.left = "-110px"; + speed2Btn.top = "-85px"; + speed2Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + speed2Btn.onPointerClickObservable.add(() => { + this._player.setPlaybackSpeed(2.0); + this._speedText.text = "Speed: 2.0x"; + }); + + this._controlBar.addControl(speed2Btn); + } + + /** + * Create progress slider for scrubbing + */ + private createProgressSlider(): void { + this._progressSlider = new Slider("progress"); + this._progressSlider.minimum = 0; + this._progressSlider.maximum = this._player.getTotalFrames() - 1; + this._progressSlider.value = 0; + this._progressSlider.width = "60%"; + this._progressSlider.height = "30px"; + this._progressSlider.color = "#00ff88"; + this._progressSlider.background = "#333"; + this._progressSlider.borderColor = "#555"; + this._progressSlider.thumbColor = "#00ff88"; + this._progressSlider.thumbWidth = "20px"; + + this._progressSlider.top = "-30px"; + this._progressSlider.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + + let isDragging = false; + + this._progressSlider.onPointerDownObservable.add(() => { + isDragging = true; + this._player.pause(); // Pause while scrubbing + }); + + this._progressSlider.onPointerUpObservable.add(() => { + isDragging = false; + }); + + this._progressSlider.onValueChangedObservable.add((value) => { + if (isDragging) { + this._player.scrubTo(Math.floor(value)); + } + }); + + this._controlBar.addControl(this._progressSlider); + } + + /** + * Create time display + */ + private createTimeDisplay(): void { + this._timeText = new TextBlock("time", "00:00 / 00:00"); + this._timeText.width = "150px"; + this._timeText.height = "30px"; + this._timeText.color = "white"; + this._timeText.fontSize = "18px"; + this._timeText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; + + this._timeText.top = "-30px"; + this._timeText.left = "-20px"; + this._timeText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + this._controlBar.addControl(this._timeText); + } + + /** + * Create camera mode toggle button + */ + private createCameraButton(): void { + this._cameraButton = Button.CreateSimpleButton("cameraMode", "πŸ“· Free Camera"); + this._cameraButton.width = "180px"; + this._cameraButton.height = "40px"; + this._cameraButton.color = "white"; + this._cameraButton.background = "#3a3a4e"; + this._cameraButton.cornerRadius = 5; + this._cameraButton.thickness = 0; + this._cameraButton.fontSize = "16px"; + + this._cameraButton.top = "20px"; + this._cameraButton.left = "-20px"; + this._cameraButton.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + this._cameraButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT; + + this._cameraButton.onPointerClickObservable.add(() => { + this._camera.toggleMode(); + const mode = this._camera.getMode(); + this._cameraButton.textBlock!.text = mode === CameraMode.FREE ? "πŸ“· Free Camera" : "🎯 Following Ship"; + }); + + this._texture.addControl(this._cameraButton); + } + + /** + * Create exit button + */ + private createExitButton(): void { + const exitBtn = Button.CreateSimpleButton("exit", "βœ• Exit Replay"); + exitBtn.width = "150px"; + exitBtn.height = "40px"; + exitBtn.color = "white"; + exitBtn.background = "#cc3333"; + exitBtn.cornerRadius = 5; + exitBtn.thickness = 0; + exitBtn.fontSize = "16px"; + exitBtn.fontWeight = "bold"; + + exitBtn.top = "20px"; + exitBtn.left = "20px"; + exitBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; + exitBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; + + exitBtn.onPointerClickObservable.add(() => { + this._onExitCallback(); + }); + + this._texture.addControl(exitBtn); + } + + /** + * Update UI (call every frame) + */ + public update(): void { + // Update progress slider (only if not being dragged by user) + const currentFrame = this._player.getCurrentFrame(); + if (Math.abs(this._progressSlider.value - currentFrame) > 1) { + this._progressSlider.value = currentFrame; + } + + // Update time display + const currentTime = this._player.getCurrentTime(); + const totalTime = this._player.getTotalDuration(); + this._timeText.text = `${this.formatTime(currentTime)} / ${this.formatTime(totalTime)}`; + } + + /** + * Format time in MM:SS + */ + private formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + + /** + * Dispose of UI + */ + public dispose(): void { + this._texture.dispose(); + debugLog("ReplayControls: Disposed"); + } +} diff --git a/src/replay/ReplayPlayer.ts b/src/replay/ReplayPlayer.ts new file mode 100644 index 0000000..09f2465 --- /dev/null +++ b/src/replay/ReplayPlayer.ts @@ -0,0 +1,373 @@ +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"; + +/** + * Handles frame-by-frame playback of physics recordings + * with interpolation for smooth visuals + */ +export class ReplayPlayer { + private _scene: Scene; + private _recording: PhysicsRecording; + private _assetRegistry: ReplayAssetRegistry; + private _replayObjects: Map = new Map(); + + // Playback state + private _currentFrameIndex: number = 0; + private _isPlaying: boolean = false; + private _playbackSpeed: number = 1.0; + + // Timing + private _physicsHz: number; + private _frameDuration: number; // milliseconds per physics frame + 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) { + 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 + } + + /** + * Initialize replay by creating all meshes from first snapshot + */ + public async initialize(): Promise { + if (this._recording.snapshots.length === 0) { + debugLog("ReplayPlayer: No snapshots in recording"); + return; + } + + const firstSnapshot = this._recording.snapshots[0]; + debugLog(`ReplayPlayer: Creating ${firstSnapshot.objects.length} replay objects`); + + for (const objState of firstSnapshot.objects) { + const mesh = this._assetRegistry.createReplayMesh(objState.id); + if (!mesh) { + continue; // Skip objects we can't create (like ammo) + } + + 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); + } + } + + // Apply first frame state + this.applySnapshot(firstSnapshot); + debugLog(`ReplayPlayer: Initialized with ${this._replayObjects.size} objects`); + } + + /** + * Start playback + */ + public play(): void { + if (this._isPlaying) { + return; + } + + this._isPlaying = true; + this._lastUpdateTime = performance.now(); + this.onPlayStateChanged.notifyObservers(true); + + // Use scene.onBeforeRenderObservable for smooth updates + this._scene.onBeforeRenderObservable.add(this.updateCallback); + + debugLog("ReplayPlayer: Playback started"); + } + + /** + * Pause playback + */ + public pause(): void { + if (!this._isPlaying) { + return; + } + + this._isPlaying = false; + this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback); + this.onPlayStateChanged.notifyObservers(false); + + debugLog("ReplayPlayer: Playback paused"); + } + + /** + * Toggle play/pause + */ + public togglePlayPause(): void { + if (this._isPlaying) { + this.pause(); + } else { + this.play(); + } + } + + /** + * Update callback for render loop + */ + private updateCallback = (): void => { + if (!this._isPlaying) { + return; + } + + const now = performance.now(); + const deltaTime = (now - this._lastUpdateTime) * this._playbackSpeed; + this._lastUpdateTime = now; + + this._accumulatedTime += deltaTime; + + // Update when enough time has passed for next frame + while (this._accumulatedTime >= this._frameDuration) { + this._accumulatedTime -= this._frameDuration; + this.advanceFrame(); + } + + // Interpolate between frames for smooth motion + const alpha = this._accumulatedTime / this._frameDuration; + this.interpolateFrame(alpha); + }; + + /** + * Advance to next frame + */ + private advanceFrame(): void { + this._currentFrameIndex++; + + if (this._currentFrameIndex >= this._recording.snapshots.length) { + // End of recording + this._currentFrameIndex = this._recording.snapshots.length - 1; + this.pause(); + debugLog("ReplayPlayer: Reached end of recording"); + return; + } + + const snapshot = this._recording.snapshots[this._currentFrameIndex]; + this.applySnapshot(snapshot); + this.onFrameChanged.notifyObservers(this._currentFrameIndex); + } + + /** + * Apply a snapshot's state to all objects + */ + private applySnapshot(snapshot: PhysicsSnapshot): void { + for (const objState of snapshot.objects) { + const mesh = this._replayObjects.get(objState.id); + if (!mesh) { + continue; + } + + // Apply position + mesh.position.set( + objState.position[0], + objState.position[1], + objState.position[2] + ); + + // Apply rotation (quaternion) + if (!mesh.rotationQuaternion) { + mesh.rotationQuaternion = new Quaternion(); + } + mesh.rotationQuaternion.set( + objState.rotation[0], + objState.rotation[1], + objState.rotation[2], + objState.rotation[3] + ); + + // Update physics body transform if exists + if (mesh.physicsBody) { + mesh.physicsBody.setTargetTransform( + mesh.position, + mesh.rotationQuaternion + ); + } + } + } + + /** + * Interpolate between current and next frame for smooth visuals + */ + private interpolateFrame(alpha: number): void { + if (this._currentFrameIndex + 1 >= this._recording.snapshots.length) { + return; // No next frame + } + + const currentSnapshot = this._recording.snapshots[this._currentFrameIndex]; + const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1]; + + for (const objState of currentSnapshot.objects) { + const mesh = this._replayObjects.get(objState.id); + if (!mesh) { + continue; + } + + const nextState = nextSnapshot.objects.find(o => o.id === objState.id); + if (!nextState) { + continue; + } + + // Lerp position + Vector3.LerpToRef( + new Vector3(...objState.position), + new Vector3(...nextState.position), + alpha, + mesh.position + ); + + // Slerp rotation + if (!mesh.rotationQuaternion) { + mesh.rotationQuaternion = new Quaternion(); + } + Quaternion.SlerpToRef( + new Quaternion(...objState.rotation), + new Quaternion(...nextState.rotation), + alpha, + mesh.rotationQuaternion + ); + } + } + + /** + * Scrub to specific frame + */ + public scrubTo(frameIndex: number): void { + 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 + this.onFrameChanged.notifyObservers(this._currentFrameIndex); + } + + /** + * Step forward one frame + */ + public stepForward(): void { + if (this._currentFrameIndex < this._recording.snapshots.length - 1) { + this.scrubTo(this._currentFrameIndex + 1); + } + } + + /** + * Step backward one frame + */ + public stepBackward(): void { + if (this._currentFrameIndex > 0) { + this.scrubTo(this._currentFrameIndex - 1); + } + } + + /** + * Set playback speed multiplier + */ + public setPlaybackSpeed(speed: number): void { + this._playbackSpeed = Math.max(0.1, Math.min(speed, 4.0)); + debugLog(`ReplayPlayer: Playback speed set to ${this._playbackSpeed}x`); + } + + /** + * Get current frame index + */ + public getCurrentFrame(): number { + return this._currentFrameIndex; + } + + /** + * Get total number of frames + */ + public getTotalFrames(): number { + return this._recording.snapshots.length; + } + + /** + * Get current playback time in seconds + */ + public getCurrentTime(): number { + if (this._recording.snapshots.length === 0) { + return 0; + } + return this._recording.snapshots[this._currentFrameIndex].timestamp / 1000; + } + + /** + * Get total duration in seconds + */ + public getTotalDuration(): number { + return this._recording.metadata.recordingDuration / 1000; + } + + /** + * Check if playing + */ + public isPlaying(): boolean { + return this._isPlaying; + } + + /** + * Get replay objects map + */ + public getReplayObjects(): Map { + return this._replayObjects; + } + + /** + * Get ship mesh if it exists + */ + public getShipMesh(): AbstractMesh | null { + for (const [id, mesh] of this._replayObjects.entries()) { + if (id === "ship" || id.startsWith("shipBase")) { + return mesh; + } + } + return null; + } + + /** + * Dispose of replay player + */ + public dispose(): void { + this.pause(); + this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback); + this.onPlayStateChanged.clear(); + this.onFrameChanged.clear(); + + // Dispose all replay objects + this._replayObjects.forEach((mesh) => { + if (mesh.physicsBody) { + mesh.physicsBody.dispose(); + } + mesh.dispose(); + }); + this._replayObjects.clear(); + + debugLog("ReplayPlayer: Disposed"); + } +}