Add core replay system components (part 1/2)

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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-08 06:05:38 -06:00
parent 88d380fa3f
commit 128b402955
4 changed files with 1087 additions and 0 deletions

View File

@ -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<string, AbstractMesh> = 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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}

180
src/replay/ReplayCamera.ts Normal file
View File

@ -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");
}
}

View File

@ -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");
}
}

373
src/replay/ReplayPlayer.ts Normal file
View File

@ -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<string, AbstractMesh> = 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<boolean> = new Observable<boolean>();
public onFrameChanged: Observable<number> = new Observable<number>();
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<void> {
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<string, AbstractMesh> {
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");
}
}