space-game/src/replay/ReplayPlayer.ts
Michael Mainguy 343fca4889 Refactor replay system to reuse Level1.initialize() and simplify UI
Major architectural improvements:
- Simplified replay system from ~1,450 lines to ~320 lines (78% reduction)
- Removed scene reconstruction complexity in favor of reusing game logic
- Added isReplayMode parameter to Level1 and Ship constructors
- Level1.initialize() now creates scene for both game and replay modes
- ReplayPlayer simplified to find existing meshes instead of loading assets

Replay system changes:
- ReplayManager now uses Level1.initialize() to populate scene
- Deleted obsolete files: assetCache.ts, ReplayAssetRegistry.ts
- Removed full scene deserialization code from LevelDeserializer
- Fixed keyboard input error when initializing in replay mode
- Physics bodies converted to ANIMATED after Level1 creates them

UI simplification for new users:
- Hidden level editor, settings, test scene, and replay buttons
- Hidden "Create New Level" link
- Filtered level selector to only show recruit and pilot difficulties
- Clean, focused experience for first-time users

Technical improvements:
- PhysicsRecorder now accepts LevelConfig via constructor
- Removed sessionStorage dependency for level state
- Fixed Color3 alpha property error in levelSerializer
- Cleaned up unused imports and dependencies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 19:20:36 -06:00

398 lines
13 KiB
TypeScript

import {
AbstractMesh,
Observable,
Quaternion,
Scene,
Vector3
} from "@babylonjs/core";
import { PhysicsRecording, PhysicsSnapshot } from "../physicsRecorder";
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 _replayObjects: Map<string, AbstractMesh> = new Map();
// Playback state
private _currentFrameIndex: number = 0;
private _isPlaying: boolean = false;
private _playbackSpeed: number = 1.0;
// Timing (timestamp-based, not Hz-based)
private _playbackStartTime: number = 0; // Real-world time when playback started
private _recordingStartTimestamp: number = 0; // First snapshot's timestamp
private _lastUpdateTime: number = 0;
// Observables
public onPlayStateChanged: Observable<boolean> = new Observable<boolean>();
public onFrameChanged: Observable<number> = new Observable<number>();
constructor(scene: Scene, recording: PhysicsRecording) {
this._scene = scene;
this._recording = recording;
// Store first snapshot's timestamp as our recording start reference
if (recording.snapshots.length > 0) {
this._recordingStartTimestamp = recording.snapshots[0].timestamp;
}
}
/**
* Initialize replay by finding existing meshes in the scene
* (Level1.initialize() has already created all objects)
*/
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: Initializing replay for ${firstSnapshot.objects.length} objects`);
debugLog(`ReplayPlayer: Object IDs in snapshot: ${firstSnapshot.objects.map(o => o.id).join(', ')}`);
// Find all existing meshes in the scene (already created by Level1.initialize())
for (const objState of firstSnapshot.objects) {
const mesh = this._scene.getMeshByName(objState.id) as AbstractMesh;
if (mesh) {
this._replayObjects.set(objState.id, mesh);
debugLog(`ReplayPlayer: Found ${objState.id} in scene (physics: ${!!mesh.physicsBody})`);
} else {
debugLog(`ReplayPlayer: WARNING - Object ${objState.id} not found in scene`);
}
}
// Apply first frame state
this.applySnapshot(firstSnapshot);
debugLog(`ReplayPlayer: Initialized with ${this._replayObjects.size} objects`);
}
/**
* Start playback
*/
public play(): void {
if (this._isPlaying) {
return;
}
this._isPlaying = true;
this._playbackStartTime = performance.now();
this._lastUpdateTime = this._playbackStartTime;
this.onPlayStateChanged.notifyObservers(true);
// Use scene.onBeforeRenderObservable for smooth updates
this._scene.onBeforeRenderObservable.add(this.updateCallback);
debugLog("ReplayPlayer: Playback started (timestamp-based)");
}
/**
* 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 (timestamp-based)
*/
private updateCallback = (): void => {
if (!this._isPlaying || this._recording.snapshots.length === 0) {
return;
}
const now = performance.now();
// Calculate elapsed playback time (with speed multiplier)
const elapsedPlaybackTime = (now - this._playbackStartTime) * this._playbackSpeed;
// Calculate target recording timestamp
const targetTimestamp = this._recordingStartTimestamp + elapsedPlaybackTime;
// Find the correct frame for this timestamp
let targetFrameIndex = this._currentFrameIndex;
// Advance to the frame that matches our target timestamp
while (targetFrameIndex < this._recording.snapshots.length - 1 &&
this._recording.snapshots[targetFrameIndex + 1].timestamp <= targetTimestamp) {
targetFrameIndex++;
}
// If we advanced frames, update and notify
if (targetFrameIndex !== this._currentFrameIndex) {
this._currentFrameIndex = targetFrameIndex;
// Debug: Log frame advancement every 10 frames
if (this._currentFrameIndex % 10 === 0) {
const snapshot = this._recording.snapshots[this._currentFrameIndex];
debugLog(`ReplayPlayer: Frame ${this._currentFrameIndex}/${this._recording.snapshots.length}, timestamp: ${snapshot.timestamp.toFixed(1)}ms, objects: ${snapshot.objects.length}`);
}
this.applySnapshot(this._recording.snapshots[this._currentFrameIndex]);
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
}
// Check if we reached the end
if (this._currentFrameIndex >= this._recording.snapshots.length - 1 &&
targetTimestamp >= this._recording.snapshots[this._recording.snapshots.length - 1].timestamp) {
this.pause();
debugLog("ReplayPlayer: Reached end of recording");
return;
}
// Interpolate between current and next frame for smooth visuals
if (this._currentFrameIndex < this._recording.snapshots.length - 1) {
const currentSnapshot = this._recording.snapshots[this._currentFrameIndex];
const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1];
const frameDuration = nextSnapshot.timestamp - currentSnapshot.timestamp;
const frameElapsed = targetTimestamp - currentSnapshot.timestamp;
const alpha = frameDuration > 0 ? Math.min(frameElapsed / frameDuration, 1.0) : 0;
this.interpolateFrame(alpha);
}
};
/**
* Apply a snapshot's state to all objects
*/
private applySnapshot(snapshot: PhysicsSnapshot): void {
for (const objState of snapshot.objects) {
const mesh = this._replayObjects.get(objState.id);
if (!mesh) {
continue;
}
const newPosition = new Vector3(
objState.position[0],
objState.position[1],
objState.position[2]
);
const newRotation = new Quaternion(
objState.rotation[0],
objState.rotation[1],
objState.rotation[2],
objState.rotation[3]
);
// Update mesh transform directly
mesh.position.copyFrom(newPosition);
if (!mesh.rotationQuaternion) {
mesh.rotationQuaternion = new Quaternion();
}
mesh.rotationQuaternion.copyFrom(newRotation);
// For ANIMATED bodies, sync physics from mesh
// (ANIMATED bodies should follow their transform node)
if (mesh.physicsBody) {
mesh.physicsBody.disablePreStep = false;
}
}
}
/**
* 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;
}
// Create temporary vectors for interpolation
const interpPosition = new Vector3();
const interpRotation = new Quaternion();
// Lerp position
Vector3.LerpToRef(
new Vector3(...objState.position),
new Vector3(...nextState.position),
alpha,
interpPosition
);
// Slerp rotation
Quaternion.SlerpToRef(
new Quaternion(...objState.rotation),
new Quaternion(...nextState.rotation),
alpha,
interpRotation
);
// Apply interpolated transform to mesh
mesh.position.copyFrom(interpPosition);
if (!mesh.rotationQuaternion) {
mesh.rotationQuaternion = new Quaternion();
}
mesh.rotationQuaternion.copyFrom(interpRotation);
// Physics body will sync from mesh if ANIMATED
if (mesh.physicsBody) {
mesh.physicsBody.disablePreStep = false;
}
}
}
/**
* 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);
// Reset playback timing to match the new frame's timestamp
if (this._isPlaying) {
const targetTimestamp = snapshot.timestamp;
const elapsedRecordingTime = targetTimestamp - this._recordingStartTimestamp;
this._playbackStartTime = performance.now() - (elapsedRecordingTime / this._playbackSpeed);
}
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
}
/**
* 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");
}
}