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:
parent
88d380fa3f
commit
128b402955
153
src/replay/ReplayAssetRegistry.ts
Normal file
153
src/replay/ReplayAssetRegistry.ts
Normal 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
180
src/replay/ReplayCamera.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/replay/ReplayControls.ts
Normal file
381
src/replay/ReplayControls.ts
Normal 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
373
src/replay/ReplayPlayer.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user