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>
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
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 = "20px";
|
|
this._playPauseButton.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
|
this._playPauseButton.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "20px";
|
|
stepBackBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
|
stepBackBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "20px";
|
|
stepFwdBtn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT;
|
|
stepFwdBtn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "10px";
|
|
this._speedText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
|
this._speedText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "20px";
|
|
speed05Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
|
speed05Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "20px";
|
|
speed1Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
|
speed1Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "20px";
|
|
speed2Btn.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
|
speed2Btn.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "80px";
|
|
this._progressSlider.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
|
|
this._progressSlider.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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 = "80px";
|
|
this._timeText.left = "-20px";
|
|
this._timeText.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT;
|
|
this._timeText.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP;
|
|
|
|
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");
|
|
}
|
|
}
|