From 71ec1f162ccbef47e2387f54fd61f6e079d56692 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 29 Nov 2025 04:54:54 -0600 Subject: [PATCH] Remove unused files and replay system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete unused files: testLevel.ts, loginScreen.ts, controllerDebug.ts - Remove entire replay system (ReplayManager, ReplayPlayer, ReplayCamera, etc.) - Remove viewReplaysHandler.ts and discordWidget.ts - Clean up related imports and references πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 121 +---- src/core/handlers/levelSelectedHandler.ts | 8 - src/core/handlers/viewReplaysHandler.ts | 90 ---- src/levels/level1.ts | 18 - src/levels/testLevel.ts | 232 -------- src/main.ts | 21 +- src/replay/ReplayCamera.ts | 187 ------- src/replay/ReplayControls.ts | 390 -------------- src/replay/ReplayManager.ts | 321 ----------- src/replay/ReplayPlayer.ts | 397 -------------- src/replay/ReplaySelectionScreen.ts | 371 ------------- src/replay/recording/physicsRecorder.ts | 617 ---------------------- src/replay/recording/physicsStorage.ts | 404 -------------- src/ui/screens/loginScreen.ts | 48 -- src/ui/widgets/discordWidget.ts | 262 --------- src/utils/controllerDebug.ts | 161 ------ 16 files changed, 9 insertions(+), 3639 deletions(-) delete mode 100644 src/core/handlers/viewReplaysHandler.ts delete mode 100644 src/levels/testLevel.ts delete mode 100644 src/replay/ReplayCamera.ts delete mode 100644 src/replay/ReplayControls.ts delete mode 100644 src/replay/ReplayManager.ts delete mode 100644 src/replay/ReplayPlayer.ts delete mode 100644 src/replay/ReplaySelectionScreen.ts delete mode 100644 src/replay/recording/physicsRecorder.ts delete mode 100644 src/replay/recording/physicsStorage.ts delete mode 100644 src/ui/screens/loginScreen.ts delete mode 100644 src/ui/widgets/discordWidget.ts delete mode 100644 src/utils/controllerDebug.ts diff --git a/CLAUDE.md b/CLAUDE.md index 947380e..b89f211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,122 +24,17 @@ npm run speech **Note**: Do not run `npm run dev` per global user instructions. -## Core Architecture - -### Scene Management Pattern -The project uses a singleton pattern for scene access via `DefaultScene`: -- `DefaultScene.MainScene` - Primary game scene -- `DefaultScene.XR` - WebXR experience instance - -All game objects reference these static properties rather than passing scene instances. - -### Level System -Levels implement the `Level` interface with: -- `initialize()` - Setup level geometry and physics -- `play()` - Start level gameplay -- `dispose()` - Cleanup -- `getReadyObservable()` - Async loading notification - -Current implementation: `Level1` with 5 difficulty modes (recruit, pilot, captain, commander, test) - -### Ship and Controller System -The `Ship` class manages: -- Player spaceship rendering and physics -- VR controller input handling (Meta Quest 2 controllers) -- Weapon firing system -- Audio for thrust and weapons -- Camera parent transform for VR positioning - -Controllers are added dynamically via WebXR observables when detected. - -### Physics and Collision -- Uses Havok Physics engine (WASM-based) -- Fixed timestep: 1/45 second with 5 sub-steps -- Zero gravity environment -- Collision detection for projectiles vs asteroids -- Physics bodies use `PhysicsAggregate` pattern - -### Asteroid Factory Pattern -`RockFactory` uses: -- Pre-loaded mesh instances for performance -- Particle system pooling for explosions (pool size: 10) -- Observable pattern for score events via collision callbacks -- Dynamic spawning based on difficulty configuration - -### Rendering Optimization -The codebase uses rendering groups to control draw order: -- Group 1: Particle effects (explosions) -- Group 3: Ship cockpit and UI (always rendered on top) - -This prevents z-fighting and ensures HUD elements are always visible in VR. - -### Audio Architecture -Uses BabylonJS AudioEngineV2: -- Requires unlock via user interaction before VR entry -- Spatial audio for thrust sounds -- StaticSound for weapon fire -- Audio engine passed to Level and Ship constructors - -### Difficulty System -Each difficulty level configures: -- `rockCount` - Number of asteroids to destroy -- `forceMultiplier` - Asteroid movement speed -- `rockSizeMin/Max` - Size range of asteroids -- `distanceMin/Max` - Spawn distance from player - -Located in `level1.ts:getDifficultyConfig()` - -## Key Technical Constraints - -### WebXR Requirements -- Must have `navigator.xr` support -- Controllers are added asynchronously via observables -- Camera must be parented to ship transform before entering VR -- XR features enabled: LAYERS with multiview for performance - -### Asset Loading -- 3D models: GLB format (cockpit, asteroids) -- Particle systems: JSON format in `public/systems/` -- Planet textures: Organized by biome in `public/assets/materials/planetTextures/` -- Audio: MP3 format in public root - -### Performance Considerations -- Hardware scaling set to match device pixel ratio -- Particle system pooling prevents allocation during gameplay -- Instance meshes used where possible -- Physics sub-stepping for stability without high timestep cost - -## Project Structure - -``` -src/ - main.ts - Entry point, game initialization, WebXR setup - defaultScene.ts - Singleton scene accessor - level.ts - Level interface - level1.ts - Main game level implementation - ship.ts - Player ship, controls, weapons - rockFactory.ts - Rock factory and collision handling - scoreboard.ts - In-cockpit HUD display - createSun.ts - Sun mesh generation - createPlanets.ts - Procedural planet generation - planetTextures.ts - Planet texture library - -public/ - systems/ - Particle system definitions - assets/ - materials/ - planetTextures/ - Biome-based planet textures - themes/ - Themed assets - cockpit*.glb - Ship interior models - asteroid*.glb - Asteroid mesh variants - *.mp3 - Audio assets -``` - ## Important Implementation Notes - Never modify git config or use force push operations -- Deploy target hostname: `space.digital-experiment.com` (from package.json) +- Deploy target hostname: `www.flatearhdefense.com` (from package.json) - TypeScript target is ES6 with ESNext modules - Vite handles bundling and dev server (though dev mode is disabled per user preference) - Inspector can be toggled with 'i' key for debugging (only in development) -- https://dev.flatearthdefense.com is local development, it's proxied back to my localhost which is running npm run dev \ No newline at end of file +- https://dev.flatearthdefense.com is local development, it's proxied back to my localhost which is running npm run dev + +## Coding Standards +- files should be under 100 lines. If they exceed 100 lines please suggest refactoring into multiple files +- functions and methods should be under 20 lines. If they exceed 20 lines, suggest reefactoring. +- game should be able to reload and restart via a deep link and page refresh. If there are reasons this won't work or we're making a change the breaks this, don't do it. +- unused imports, functions, methods, and classes should have a comment added explaining why it's unused. diff --git a/src/core/handlers/levelSelectedHandler.ts b/src/core/handlers/levelSelectedHandler.ts index 4d0c181..c25843b 100644 --- a/src/core/handlers/levelSelectedHandler.ts +++ b/src/core/handlers/levelSelectedHandler.ts @@ -5,7 +5,6 @@ import Level from "../../levels/level"; import { RockFactory } from "../../environment/asteroids/rockFactory"; import { LevelConfig } from "../../levels/config/levelConfig"; import { Preloader } from "../../ui/screens/preloader"; -import { DiscordWidget } from "../../ui/widgets/discordWidget"; import debugLog from '../debug'; /** @@ -49,13 +48,6 @@ export function createLevelSelectedHandler(context: LevelSelectedContext): (e: C appHeader.style.display = 'none'; } - // Hide Discord widget during gameplay - const discord = (window as any).__discordWidget as DiscordWidget; - if (discord) { - debugLog('[Main] Hiding Discord widget for gameplay'); - discord.hide(); - } - // Show preloader for initialization const preloader = new Preloader(); context.setProgressCallback((percent, message) => { diff --git a/src/core/handlers/viewReplaysHandler.ts b/src/core/handlers/viewReplaysHandler.ts deleted file mode 100644 index 88bee28..0000000 --- a/src/core/handlers/viewReplaysHandler.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Engine } from "@babylonjs/core"; -import { ReplaySelectionScreen } from "../../replay/ReplaySelectionScreen"; -import { ReplayManager } from "../../replay/ReplayManager"; -import debugLog from '../debug'; - -/** - * Interface for Main class methods needed by the view replays handler - */ -export interface ViewReplaysContext { - isStarted(): boolean; - setStarted(value: boolean): void; - initializeXR(): Promise; - getEngine(): Engine; - getReplayManager(): ReplayManager | null; - setReplayManager(manager: ReplayManager): void; -} - -/** - * Creates the view replays button click handler - * @param context - Main instance implementing ViewReplaysContext - * @returns Click handler function - */ -export function createViewReplaysHandler(context: ViewReplaysContext): () => Promise { - return async () => { - debugLog('[Main] ========== VIEW REPLAYS BUTTON CLICKED =========='); - - // Initialize engine and physics if not already done - if (!context.isStarted()) { - context.setStarted(true); - await context.initializeXR(); - } - - // Hide main menu - const levelSelect = document.querySelector('#levelSelect') as HTMLElement; - const appHeader = document.querySelector('#appHeader') as HTMLElement; - - if (levelSelect) { - levelSelect.style.display = 'none'; - } - if (appHeader) { - appHeader.style.display = 'none'; - } - - // Show replay selection screen - const selectionScreen = new ReplaySelectionScreen( - async (recordingId: string) => { - // Play callback - start replay - debugLog(`[Main] Starting replay for recording: ${recordingId}`); - selectionScreen.dispose(); - - // Create replay manager if not exists - let replayManager = context.getReplayManager(); - if (!replayManager) { - replayManager = new ReplayManager( - context.getEngine() as Engine, - () => { - // On exit callback - return to main menu - debugLog('[Main] Exiting replay, returning to menu'); - if (levelSelect) { - levelSelect.style.display = 'block'; - } - const appHeader = document.querySelector('#appHeader') as HTMLElement; - if (appHeader) { - appHeader.style.display = 'block'; - } - } - ); - context.setReplayManager(replayManager); - } - - // Start replay - await replayManager.startReplay(recordingId); - }, - () => { - // Cancel callback - return to main menu - debugLog('[Main] Replay selection cancelled'); - selectionScreen.dispose(); - if (levelSelect) { - levelSelect.style.display = 'block'; - } - const appHeader = document.querySelector('#appHeader') as HTMLElement; - if (appHeader) { - appHeader.style.display = 'block'; - } - } - ); - - await selectionScreen.initialize(); - }; -} diff --git a/src/levels/level1.ts b/src/levels/level1.ts index 5478813..a6f4513 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -15,7 +15,6 @@ import {LevelConfig} from "./config/levelConfig"; import {LevelDeserializer} from "./config/levelDeserializer"; import {BackgroundStars} from "../environment/background/backgroundStars"; import debugLog from '../core/debug'; -import {PhysicsRecorder} from "../replay/recording/physicsRecorder"; import {getAnalytics} from "../analytics"; import {MissionBrief} from "../ui/hud/missionBrief"; import {LevelRegistry} from "./storage/levelRegistry"; @@ -34,7 +33,6 @@ export class Level1 implements Level { private _audioEngine: AudioEngineV2; private _deserializer: LevelDeserializer; private _backgroundStars: BackgroundStars; - private _physicsRecorder: PhysicsRecorder | null = null; private _isReplayMode: boolean; private _backgroundMusic: StaticSound; private _missionBrief: MissionBrief; @@ -252,12 +250,6 @@ export class Level1 implements Level { // Start game timer this._ship.gameStats.startTimer(); debugLog('Game timer started'); - - // Start physics recording - if (this._physicsRecorder) { - this._physicsRecorder.startRingBuffer(); - debugLog('Physics recorder started'); - } } public async play() { @@ -337,9 +329,6 @@ export class Level1 implements Level { if (this._backgroundStars) { this._backgroundStars.dispose(); } - if (this._physicsRecorder) { - this._physicsRecorder.dispose(); - } if (this._missionBrief) { this._missionBrief.dispose(); } @@ -488,11 +477,4 @@ export class Level1 implements Level { return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes } - - /** - * Get the physics recorder instance - */ - public get physicsRecorder(): PhysicsRecorder { - return this._physicsRecorder; - } } \ No newline at end of file diff --git a/src/levels/testLevel.ts b/src/levels/testLevel.ts deleted file mode 100644 index aab36b0..0000000 --- a/src/levels/testLevel.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { DefaultScene } from "../core/defaultScene"; -import { - Color3, - DirectionalLight, - MeshBuilder, - Observable, - StandardMaterial, - Vector3 -} from "@babylonjs/core"; -import type { AudioEngineV2 } from "@babylonjs/core"; -import Level from "./level"; -import debugLog from '../core/debug'; - -/** - * Minimal test level with just a box and a light for debugging - */ -export class TestLevel implements Level { - private _onReadyObservable: Observable = new Observable(); - private _initialized: boolean = false; - private _audioEngine: AudioEngineV2; - private _boxCreationInterval: number | null = null; - private _totalBoxesCreated: number = 0; - private _boxesPerIteration: number = 1; - - constructor(audioEngine: AudioEngineV2) { - this._audioEngine = audioEngine; - debugLog('[TestLevel] Constructor called'); - // Don't call initialize here - let Main call it after registering the observable - } - - getReadyObservable(): Observable { - return this._onReadyObservable; - } - - public async play() { - debugLog('[TestLevel] play() called - entering XR'); - debugLog('[TestLevel] XR available:', !!DefaultScene.XR); - debugLog('[TestLevel] XR baseExperience:', !!DefaultScene.XR?.baseExperience); - - try { - // Enter XR mode - const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor'); - debugLog('[TestLevel] XR mode entered successfully'); - debugLog('[TestLevel] XR session:', xr); - debugLog('[TestLevel] Camera position:', DefaultScene.XR.baseExperience.camera.position.toString()); - this.startBoxCreation(); - } catch (error) { - console.error('[TestLevel] ERROR entering XR:', error); - } - } - - public dispose() { - debugLog('[TestLevel] dispose() called'); - - // Stop box creation timer - if (this._boxCreationInterval) { - clearInterval(this._boxCreationInterval); - this._boxCreationInterval = null; - debugLog('[TestLevel] Box creation timer stopped'); - } - } - - /** - * Create a box at the specified position with the specified color - */ - private createBox(position: Vector3, color: Color3, name?: string): void { - const box = MeshBuilder.CreateBox( - name || `box_${this._totalBoxesCreated}`, - { size: 0.5 }, - DefaultScene.MainScene - ); - box.position = position; - - const material = new StandardMaterial(`material_${this._totalBoxesCreated}`, DefaultScene.MainScene); - material.diffuseColor = color; - material.specularColor = new Color3(0.5, 0.5, 0.5); - box.material = material; - - this._totalBoxesCreated++; - } - - /** - * Start the box creation timer that doubles the number of boxes each iteration - */ - private startBoxCreation(): void { - debugLog('[TestLevel] Starting box creation timer...'); - - const createBatch = () => { - const boxesToCreate = Math.min( - this._boxesPerIteration, - 1000 - this._totalBoxesCreated - ); - - debugLog(`[TestLevel] Creating ${boxesToCreate} boxes (total will be: ${this._totalBoxesCreated + boxesToCreate}/1000)`); - - for (let i = 0; i < boxesToCreate; i++) { - // Random position in a 20x20x20 cube around origin - const position = new Vector3( - Math.random() * 20 - 10, - Math.random() * 20, - Math.random() * 20 - 10 - ); - - // Random color - const color = new Color3( - Math.random(), - Math.random(), - Math.random() - ); - - this.createBox(position, color); - } - - debugLog(`[TestLevel] Created ${boxesToCreate} boxes. Total: ${this._totalBoxesCreated}/1000`); - - // Log performance metrics - const fps = DefaultScene.MainScene.getEngine().getFps(); - - // Directly compute triangle count from all meshes - const totalIndices = DefaultScene.MainScene.meshes.reduce((sum, mesh) => { - if (mesh.isEnabled() && mesh.isVisible) { - return sum + mesh.getTotalIndices(); - } - return sum; - }, 0); - const triangleCount = Math.floor(totalIndices / 3); - - debugLog(`[TestLevel] Performance Metrics:`, { - fps: fps.toFixed(2), - triangleCount: triangleCount, - totalIndices: totalIndices, - totalMeshes: DefaultScene.MainScene.meshes.length, - activeMeshes: DefaultScene.MainScene.meshes.filter(m => m.isEnabled() && m.isVisible).length, - totalBoxes: this._totalBoxesCreated - }); - - // Check if we've reached 1000 boxes - if (this._totalBoxesCreated >= 1000) { - debugLog('[TestLevel] Reached 1000 boxes, stopping timer'); - if (this._boxCreationInterval) { - clearInterval(this._boxCreationInterval); - this._boxCreationInterval = null; - } - return; - } - - // Double the number for next iteration - this._boxesPerIteration *= 2; - }; - - // Create first batch immediately - createBatch(); - - // Set up interval for subsequent batches - this._boxCreationInterval = setInterval(createBatch, 5000); - } - - public async initialize() { - debugLog('[TestLevel] initialize() called'); - debugLog('[TestLevel] Scene info:', { - meshCount: DefaultScene.MainScene.meshes.length, - lightCount: DefaultScene.MainScene.lights.length - }); - - if (this._initialized) { - debugLog('[TestLevel] Already initialized, skipping'); - return; - } - - // Create a simple directional light - const light = new DirectionalLight( - "testLight", - new Vector3(-1, -2, 1), - DefaultScene.MainScene - ); - light.intensity = 1.0; - debugLog('[TestLevel] Created directional light:', { - name: light.name, - direction: light.direction.toString(), - intensity: light.intensity - }); - - // Create a simple colored box - const box = MeshBuilder.CreateBox( - "testBox", - { size: 2 }, - DefaultScene.MainScene - ); - box.position = new Vector3(0, 1, 5); // In front of camera - - // Create a simple material - const material = new StandardMaterial("testMaterial", DefaultScene.MainScene); - material.diffuseColor = new Color3(1, 0, 0); // Red - material.specularColor = new Color3(0.5, 0.5, 0.5); - box.material = material; - debugLog('[TestLevel] Created test box:', { - name: box.name, - position: box.position.toString(), - size: 2, - color: 'red' - }); - - // Create a ground plane for reference - const ground = MeshBuilder.CreateGround( - "testGround", - { width: 10, height: 10 }, - DefaultScene.MainScene - ); - ground.position.y = 0; - - const groundMaterial = new StandardMaterial("groundMaterial", DefaultScene.MainScene); - groundMaterial.diffuseColor = new Color3(0.3, 0.3, 0.3); // Grey - ground.material = groundMaterial; - debugLog('[TestLevel] Created ground plane:', { - name: ground.name, - dimensions: '10x10', - position: ground.position.toString() - }); - - debugLog('[TestLevel] Final scene state:', { - totalMeshes: DefaultScene.MainScene.meshes.length, - totalLights: DefaultScene.MainScene.lights.length, - meshNames: DefaultScene.MainScene.meshes.map(m => m.name) - }); - - this._initialized = true; - debugLog('[TestLevel] Initialization complete - scene ready for XR'); - - // Notify that initialization is complete - this._onReadyObservable.notifyObservers(this); - } -} diff --git a/src/main.ts b/src/main.ts index ec9c8aa..5bd2f8d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,14 +15,11 @@ import HavokPhysics from "@babylonjs/havok"; import { DefaultScene } from "./core/defaultScene"; import Level from "./levels/level"; import { RockFactory } from "./environment/asteroids/rockFactory"; -import { DiscordWidget } from "./ui/widgets/discordWidget"; import debugLog from './core/debug'; -import { ReplayManager } from "./replay/ReplayManager"; import { InputControlManager } from './ship/input/inputControlManager'; import { initializeAnalytics } from './analytics/initAnalytics'; import { createLevelSelectedHandler, LevelSelectedContext } from './core/handlers/levelSelectedHandler'; -import { createViewReplaysHandler, ViewReplaysContext } from './core/handlers/viewReplaysHandler'; import { initializeApp, setupErrorHandler } from './core/appInitializer'; // Initialize analytics @@ -38,12 +35,11 @@ enum GameState { DEMO } -export class Main implements LevelSelectedContext, ViewReplaysContext { +export class Main implements LevelSelectedContext { private _currentLevel: Level | null = null; private _gameState: GameState = GameState.DEMO; private _engine: Engine; private _audioEngine: AudioEngineV2; - private _replayManager: ReplayManager | null = null; private _initialized: boolean = false; private _assetsLoaded: boolean = false; private _started: boolean = false; @@ -58,11 +54,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext { window.addEventListener('DOMContentLoaded', () => { const levelSelect = document.querySelector('#levelSelect'); if (levelSelect) levelSelect.classList.add('ready'); - - const viewReplaysBtn = document.querySelector('#viewReplaysBtn'); - if (viewReplaysBtn) { - viewReplaysBtn.addEventListener('click', createViewReplaysHandler(this)); - } }); } @@ -79,10 +70,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext { this._progressCallback = callback; } - // ViewReplaysContext interface implementation - getReplayManager(): ReplayManager | null { return this._replayManager; } - setReplayManager(manager: ReplayManager): void { this._replayManager = manager; } - public async initializeEngine(): Promise { if (this._initialized) return; debugLog('[Main] Starting engine initialization'); @@ -125,9 +112,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext { const gl = canvas?.getContext('webgl2') || canvas?.getContext('webgl'); if (gl) { gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); } - - const discord = (window as any).__discordWidget as DiscordWidget; - if (discord) discord.show(); } catch (error) { console.error('[Main] Cleanup failed:', error); window.location.reload(); @@ -155,9 +139,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext { if (state === 2) { const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager.getEnabledFeature("xr-controller-pointer-selection"); if (pointerFeature) InputControlManager.getInstance().registerPointerFeature(pointerFeature); - ((window as any).__discordWidget as DiscordWidget)?.hide(); - } else if (state === 0) { - ((window as any).__discordWidget as DiscordWidget)?.show(); } }); this.reportProgress(40, 'VR support enabled'); diff --git a/src/replay/ReplayCamera.ts b/src/replay/ReplayCamera.ts deleted file mode 100644 index 6192827..0000000 --- a/src/replay/ReplayCamera.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - AbstractMesh, - ArcRotateCamera, - Scene, - Vector3 -} from "@babylonjs/core"; -import debugLog from "../core/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; - - // Set clipping planes for visibility - this._camera.minZ = 0.1; // Very close near plane - this._camera.maxZ = 5000; // Far plane for distant objects - - // Mouse wheel zoom speed - this._camera.wheelPrecision = 20; - - // Panning speed - this._camera.panningSensibility = 50; - - scene.activeCamera = this._camera; - - debugLog("ReplayCamera: Created with clipping planes minZ=0.1, maxZ=5000"); - } - - /** - * 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; - debugLog(`ReplayCamera: Framing object ${obj.name} at position ${pos.toString()}`); - minX = Math.min(minX, pos.x); - minY = Math.min(minY, pos.y); - minZ = Math.min(minZ, pos.z); - maxX = Math.max(maxX, pos.x); - maxY = Math.max(maxY, pos.y); - maxZ = Math.max(maxZ, pos.z); - }); - - // Calculate center - const center = new Vector3( - (minX + maxX) / 2, - (minY + maxY) / 2, - (minZ + maxZ) / 2 - ); - - // Calculate size - const size = Math.max( - maxX - minX, - maxY - minY, - maxZ - minZ - ); - - // Position camera to frame everything - this._camera.setTarget(center); - this._camera.radius = Math.max(50, size * 1.5); // At least 50 units away - - debugLog(`ReplayCamera: Framed ${objects.length} objects (radius: ${this._camera.radius.toFixed(1)})`); - } - - /** - * Update camera (call every frame) - */ - public update(): void { - if (this._mode === CameraMode.FOLLOW_SHIP && this._followTarget) { - // Smooth camera following with lerp - Vector3.LerpToRef( - this._camera.target, - this._followTarget.position, - 0.1, // Smoothing factor (0 = no follow, 1 = instant) - this._camera.target - ); - } - } - - /** - * Reset camera to default position - */ - public reset(): void { - this._camera.alpha = Math.PI / 2; - this._camera.beta = Math.PI / 3; - this._camera.radius = 50; - this._camera.setTarget(Vector3.Zero()); - debugLog("ReplayCamera: Reset to default"); - } - - /** - * Dispose of camera - */ - public dispose(): void { - this._camera.dispose(); - debugLog("ReplayCamera: Disposed"); - } -} diff --git a/src/replay/ReplayControls.ts b/src/replay/ReplayControls.ts deleted file mode 100644 index 6eb609e..0000000 --- a/src/replay/ReplayControls.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - AdvancedDynamicTexture, - Button, - Control, - Rectangle, - Slider, - StackPanel, - TextBlock -} from "@babylonjs/gui"; -import { ReplayPlayer } from "./ReplayPlayer"; -import { CameraMode, ReplayCamera } from "./ReplayCamera"; -import debugLog from "../core/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"); - } -} diff --git a/src/replay/ReplayManager.ts b/src/replay/ReplayManager.ts deleted file mode 100644 index 603bde3..0000000 --- a/src/replay/ReplayManager.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { - Engine, - HavokPlugin, - PhysicsMotionType, - PhysicsViewer, - Scene, - Vector3 -} from "@babylonjs/core"; -import "@babylonjs/inspector"; -import HavokPhysics from "@babylonjs/havok"; -import { PhysicsStorage } from "./recording/physicsStorage"; -import { ReplayPlayer } from "./ReplayPlayer"; -import { CameraMode, ReplayCamera } from "./ReplayCamera"; -import { ReplayControls } from "./ReplayControls"; -import debugLog from "../core/debug"; -import { DefaultScene } from "../core/defaultScene"; -import { Level1 } from "../levels/level1"; - -/** - * Manages the replay scene, loading recordings, and coordinating replay components - */ -export class ReplayManager { - private _engine: Engine; - private _originalScene: Scene; - private _replayScene: Scene | null = null; - private _replayHavokPlugin: HavokPlugin | null = null; - private _physicsViewer: PhysicsViewer | null = null; - - // Replay components - private _level: Level1 | null = null; - private _player: ReplayPlayer | null = null; - private _camera: ReplayCamera | null = null; - private _controls: ReplayControls | null = null; - - private _onExitCallback: () => void; - private _keyboardHandler: ((ev: KeyboardEvent) => void) | null = null; - - constructor(engine: Engine, onExit: () => void) { - this._engine = engine; - this._originalScene = DefaultScene.MainScene; - this._onExitCallback = onExit; - } - - /** - * Start replay for a specific recording - */ - public async startReplay(recordingId: string): Promise { - debugLog(`ReplayManager: Starting replay for ${recordingId}`); - - // Stop any existing render loop immediately - this._engine.stopRenderLoop(); - - try { - // 1. Load recording from IndexedDB - const storage = new PhysicsStorage(); - await storage.initialize(); - const recording = await storage.loadRecording(recordingId); - storage.close(); - - if (!recording || !recording.metadata.levelConfig) { - debugLog("ReplayManager: Recording not found or missing LevelConfig"); - return; - } - - debugLog(`ReplayManager: Loaded recording with ${recording.snapshots.length} frames`); - - // 2. Create replay scene - await this.createReplayScene(); - - // 3. Use Level1 to populate the scene (reuse game logic!) - debugLog('ReplayManager: Initializing Level1 in replay mode'); - this._level = new Level1(recording.metadata.levelConfig, null, true); // isReplayMode = true - await this._level.initialize(); - debugLog('ReplayManager: Level1 initialized successfully'); - - // 4. Convert all physics bodies to ANIMATED (replay-controlled) - let physicsCount = 0; - for (const mesh of this._replayScene!.meshes) { - if (mesh.physicsBody) { - mesh.physicsBody.setMotionType(PhysicsMotionType.ANIMATED); - // Disable collisions for replay objects - const shape = mesh.physicsBody.shape; - if (shape) { - shape.filterMembershipMask = 0; - shape.filterCollideMask = 0; - } - physicsCount++; - } - } - debugLog(`ReplayManager: Set ${physicsCount} objects to ANIMATED motion type`); - - // 5. Create player for physics playback - this._player = new ReplayPlayer(this._replayScene!, recording); - await this._player.initialize(); - - // Enable physics debug for all replay objects - if (this._physicsViewer) { - const replayObjects = this._player.getReplayObjects(); - debugLog(`ReplayManager: Enabling physics debug for ${replayObjects.size} objects`); - replayObjects.forEach((mesh) => { - if (mesh.physicsBody) { - this._physicsViewer!.showBody(mesh.physicsBody); - } - }); - } - - // 6. Setup camera - this._camera = new ReplayCamera(this._replayScene!); - - // Frame all objects initially in FREE mode - const objects = Array.from(this._player.getReplayObjects().values()); - debugLog(`ReplayManager: Framing ${objects.length} objects for camera`); - - if (objects.length > 0) { - this._camera.frameAllObjects(objects); - this._camera.setMode(CameraMode.FREE); - debugLog(`ReplayManager: Camera set to FREE mode`); - } else { - debugLog(`ReplayManager: WARNING - No objects to frame!`); - // Set default camera position if no objects - this._camera.getCamera().position.set(0, 50, -100); - this._camera.getCamera().setTarget(Vector3.Zero()); - } - - // Set ship as follow target for later toggling - const ship = this._player.getShipMesh(); - if (ship) { - this._camera.setFollowTarget(ship); - debugLog(`ReplayManager: Ship set as follow target`); - } - - // 6. Create controls UI - this._controls = new ReplayControls(this._player, this._camera, () => { - this.exitReplay(); - }); - this._controls.initialize(); - - // 7. Setup keyboard handler for inspector - this._keyboardHandler = (ev: KeyboardEvent) => { - // Toggle inspector with 'i' key - if (ev.key === 'i' || ev.key === 'I') { - if (this._replayScene) { - if (this._replayScene.debugLayer.isVisible()) { - this._replayScene.debugLayer.hide(); - debugLog("ReplayManager: Inspector hidden"); - } else { - this._replayScene.debugLayer.show(); - debugLog("ReplayManager: Inspector shown"); - } - } - } - }; - window.addEventListener('keydown', this._keyboardHandler); - debugLog("ReplayManager: Keyboard handler registered (press 'i' for inspector)"); - - // 8. Start render loop - debugLog(`ReplayManager: Starting render loop for replay scene`); - debugLog(`ReplayManager: Replay scene has ${this._replayScene!.meshes.length} meshes, camera: ${this._replayScene!.activeCamera?.name}`); - - this._engine.runRenderLoop(() => { - if (this._replayScene && this._replayScene.activeCamera) { - this._replayScene.render(); - - // Update camera and controls - if (this._camera) { - this._camera.update(); - } - if (this._controls) { - this._controls.update(); - } - } - }); - - // 9. Auto-start playback - this._player.play(); - - debugLog("ReplayManager: Replay started successfully"); - } catch (error) { - debugLog("ReplayManager: Error starting replay", error); - await this.exitReplay(); - } - } - - - /** - * Create a new scene for replay - */ - private async createReplayScene(): Promise { - // Dispose old replay scene if exists - if (this._replayScene) { - await this.disposeReplayScene(); - } - - // Create new scene - this._replayScene = new Scene(this._engine); - - // Create new Havok physics instance for this scene - debugLog("ReplayManager: Creating Havok physics instance for replay scene"); - const havok = await HavokPhysics(); - this._replayHavokPlugin = new HavokPlugin(true, havok); - - // Enable physics - this._replayScene.enablePhysics(Vector3.Zero(), this._replayHavokPlugin); - - // Enable physics debug rendering - this._physicsViewer = new PhysicsViewer(this._replayScene); - debugLog("ReplayManager: Physics debug viewer created"); - - // Update DefaultScene singleton (Level1.initialize will use this scene) - DefaultScene.MainScene = this._replayScene; - - debugLog("ReplayManager: Replay scene created"); - } - - /** - * Exit replay and return to original scene - */ - public async exitReplay(): Promise { - debugLog("ReplayManager: Exiting replay"); - - // Remove keyboard handler - if (this._keyboardHandler) { - window.removeEventListener('keydown', this._keyboardHandler); - this._keyboardHandler = null; - debugLog("ReplayManager: Keyboard handler removed"); - } - - // Stop render loop - this._engine.stopRenderLoop(); - - // Dispose replay components - await this.disposeReplayScene(); - - // Restore original scene - DefaultScene.MainScene = this._originalScene; - - // Restore original render loop - this._engine.runRenderLoop(() => { - this._originalScene.render(); - }); - - // Call exit callback - this._onExitCallback(); - - debugLog("ReplayManager: Exited replay"); - } - - /** - * Dispose of replay scene and all components - */ - private async disposeReplayScene(): Promise { - if (!this._replayScene) { - return; - } - - debugLog("ReplayManager: Disposing replay scene"); - - // 1. Dispose UI - if (this._controls) { - this._controls.dispose(); - this._controls = null; - } - - // 2. Dispose player (stops playback, removes observables) - if (this._player) { - this._player.dispose(); - this._player = null; - } - - // 3. Dispose camera - if (this._camera) { - this._camera.dispose(); - this._camera = null; - } - - // 4. Dispose level (if exists) - if (this._level) { - // Level disposal would happen here if needed - this._level = null; - } - - // 6. Dispose all meshes with physics - this._replayScene.meshes.forEach(mesh => { - if (mesh.physicsBody) { - mesh.physicsBody.dispose(); - } - if (mesh.skeleton) { - mesh.skeleton.dispose(); - } - mesh.dispose(); - }); - - // 7. Dispose materials and textures - this._replayScene.materials.forEach(mat => mat.dispose()); - this._replayScene.textures.forEach(tex => tex.dispose()); - - // 8. Dispose scene - this._replayScene.dispose(); - this._replayScene = null; - - // 9. Clean up physics viewer - if (this._physicsViewer) { - this._physicsViewer.dispose(); - this._physicsViewer = null; - } - - // 10. Clean up Havok plugin - if (this._replayHavokPlugin) { - this._replayHavokPlugin = null; - } - - debugLog("ReplayManager: Replay scene disposed"); - } - - /** - * Get current replay scene - */ - public getReplayScene(): Scene | null { - return this._replayScene; - } -} diff --git a/src/replay/ReplayPlayer.ts b/src/replay/ReplayPlayer.ts deleted file mode 100644 index 170b55a..0000000 --- a/src/replay/ReplayPlayer.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { - AbstractMesh, - Observable, - Quaternion, - Scene, - Vector3 -} from "@babylonjs/core"; -import { PhysicsRecording, PhysicsSnapshot } from "./recording/physicsRecorder"; -import debugLog from "../core/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 = 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 = new Observable(); - public onFrameChanged: Observable = new Observable(); - - 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 { - 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 { - 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"); - } -} diff --git a/src/replay/ReplaySelectionScreen.ts b/src/replay/ReplaySelectionScreen.ts deleted file mode 100644 index 9981d99..0000000 --- a/src/replay/ReplaySelectionScreen.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - AdvancedDynamicTexture, - Button, - Control, - Rectangle, - ScrollViewer, - StackPanel, - TextBlock -} from "@babylonjs/gui"; -import { PhysicsStorage } from "./recording/physicsStorage"; -import debugLog from "../core/debug"; - -/** - * Recording info for display - */ -interface RecordingInfo { - id: string; - name: string; - timestamp: number; - duration: number; - frameCount: number; -} - -/** - * Fullscreen UI for selecting a recording to replay - */ -export class ReplaySelectionScreen { - private _texture: AdvancedDynamicTexture; - private _scrollViewer: ScrollViewer; - private _recordingsList: StackPanel; - private _selectedRecording: string | null = null; - private _playButton: Button; - private _deleteButton: Button; - - private _onPlayCallback: (recordingId: string) => void; - private _onCancelCallback: () => void; - - private _selectedContainer: Rectangle | null = null; - - constructor(onPlay: (recordingId: string) => void, onCancel: () => void) { - this._onPlayCallback = onPlay; - this._onCancelCallback = onCancel; - } - - /** - * Initialize and show the selection screen - */ - public async initialize(): Promise { - this._texture = AdvancedDynamicTexture.CreateFullscreenUI("replaySelection"); - - // Semi-transparent background - const background = new Rectangle("background"); - background.width = "100%"; - background.height = "100%"; - background.background = "rgba(10, 10, 20, 0.95)"; - background.thickness = 0; - this._texture.addControl(background); - - // Main panel - const mainPanel = new Rectangle("mainPanel"); - mainPanel.width = "900px"; - mainPanel.height = "700px"; - mainPanel.thickness = 2; - mainPanel.color = "#00ff88"; - mainPanel.background = "#1a1a2e"; - mainPanel.cornerRadius = 10; - mainPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - mainPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; - this._texture.addControl(mainPanel); - - // Title - const title = new TextBlock("title", "RECORDED SESSIONS"); - title.width = "100%"; - title.height = "80px"; - title.color = "#00ff88"; - title.fontSize = "40px"; - title.fontWeight = "bold"; - title.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - title.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; - title.top = "20px"; - mainPanel.addControl(title); - - // ScrollViewer for recordings list - this._scrollViewer = new ScrollViewer("scrollViewer"); - this._scrollViewer.width = "840px"; - this._scrollViewer.height = "480px"; - this._scrollViewer.thickness = 1; - this._scrollViewer.color = "#444"; - this._scrollViewer.background = "#0a0a1e"; - this._scrollViewer.top = "110px"; - this._scrollViewer.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP; - mainPanel.addControl(this._scrollViewer); - - // StackPanel inside ScrollViewer - this._recordingsList = new StackPanel("recordingsList"); - this._recordingsList.width = "100%"; - this._recordingsList.isVertical = true; - this._recordingsList.spacing = 10; - this._recordingsList.paddingTop = "10px"; - this._recordingsList.paddingBottom = "10px"; - this._scrollViewer.addControl(this._recordingsList); - - // Bottom button bar - this.createButtonBar(mainPanel); - - // Load recordings - await this.loadRecordings(); - - debugLog("ReplaySelectionScreen: Initialized"); - } - - /** - * Create button bar at bottom - */ - private createButtonBar(parent: Rectangle): void { - const buttonBar = new StackPanel("buttonBar"); - buttonBar.isVertical = false; - buttonBar.width = "100%"; - buttonBar.height = "80px"; - buttonBar.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - buttonBar.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM; - buttonBar.spacing = 20; - buttonBar.paddingBottom = "20px"; - parent.addControl(buttonBar); - - // Play button - this._playButton = Button.CreateSimpleButton("play", "β–Ά Play Selected"); - this._playButton.width = "200px"; - this._playButton.height = "50px"; - this._playButton.color = "white"; - this._playButton.background = "#00ff88"; - this._playButton.cornerRadius = 10; - this._playButton.thickness = 0; - this._playButton.fontSize = "20px"; - this._playButton.fontWeight = "bold"; - this._playButton.isEnabled = false; // Disabled until selection - - this._playButton.onPointerClickObservable.add(() => { - if (this._selectedRecording) { - this._onPlayCallback(this._selectedRecording); - } - }); - - buttonBar.addControl(this._playButton); - - // Delete button - this._deleteButton = Button.CreateSimpleButton("delete", "πŸ—‘ Delete"); - this._deleteButton.width = "150px"; - this._deleteButton.height = "50px"; - this._deleteButton.color = "white"; - this._deleteButton.background = "#cc3333"; - this._deleteButton.cornerRadius = 10; - this._deleteButton.thickness = 0; - this._deleteButton.fontSize = "18px"; - this._deleteButton.fontWeight = "bold"; - this._deleteButton.isEnabled = false; // Disabled until selection - - this._deleteButton.onPointerClickObservable.add(async () => { - if (this._selectedRecording) { - await this.deleteRecording(this._selectedRecording); - } - }); - - buttonBar.addControl(this._deleteButton); - - // Cancel button - const cancelButton = Button.CreateSimpleButton("cancel", "βœ• Cancel"); - cancelButton.width = "150px"; - cancelButton.height = "50px"; - cancelButton.color = "white"; - cancelButton.background = "#555"; - cancelButton.cornerRadius = 10; - cancelButton.thickness = 0; - cancelButton.fontSize = "18px"; - cancelButton.fontWeight = "bold"; - - cancelButton.onPointerClickObservable.add(() => { - this._onCancelCallback(); - }); - - buttonBar.addControl(cancelButton); - } - - /** - * Load recordings from IndexedDB - */ - private async loadRecordings(): Promise { - const storage = new PhysicsStorage(); - await storage.initialize(); - const recordings = await storage.listRecordings(); - storage.close(); - - if (recordings.length === 0) { - this.showNoRecordingsMessage(); - return; - } - - // Sort by timestamp (newest first) - recordings.sort((a, b) => b.timestamp - a.timestamp); - - recordings.forEach(rec => { - const item = this.createRecordingItem(rec); - this._recordingsList.addControl(item); - }); - - debugLog(`ReplaySelectionScreen: Loaded ${recordings.length} recordings`); - } - - /** - * Show message when no recordings are available - */ - private showNoRecordingsMessage(): void { - const message = new TextBlock("noRecordings", "No recordings available yet.\n\nPlay the game to create recordings!"); - message.width = "100%"; - message.height = "200px"; - message.color = "#888"; - message.fontSize = "24px"; - message.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER; - message.textVerticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER; - message.textWrapping = true; - this._recordingsList.addControl(message); - } - - /** - * Create a selectable recording item - */ - private createRecordingItem(recording: RecordingInfo): Rectangle { - const itemContainer = new Rectangle(); - itemContainer.width = "800px"; - itemContainer.height = "90px"; - itemContainer.thickness = 1; - itemContainer.color = "#555"; - itemContainer.background = "#2a2a3e"; - itemContainer.cornerRadius = 5; - itemContainer.isPointerBlocker = true; - itemContainer.hoverCursor = "pointer"; - - // Hover effect - itemContainer.onPointerEnterObservable.add(() => { - if (this._selectedRecording !== recording.id) { - itemContainer.background = "#3a3a4e"; - } - }); - - itemContainer.onPointerOutObservable.add(() => { - if (this._selectedRecording !== recording.id) { - itemContainer.background = "#2a2a3e"; - } - }); - - // Click to select - itemContainer.onPointerClickObservable.add(() => { - this.selectRecording(recording.id, itemContainer); - }); - - // Content panel - const contentPanel = new StackPanel(); - contentPanel.isVertical = true; - contentPanel.width = "100%"; - contentPanel.paddingLeft = "20px"; - contentPanel.paddingRight = "20px"; - contentPanel.paddingTop = "10px"; - itemContainer.addControl(contentPanel); - - // Session name (first line) - Format session ID nicely - const sessionName = this.formatSessionName(recording.name); - const nameText = new TextBlock("name", sessionName); - nameText.height = "30px"; - nameText.color = "#00ff88"; - nameText.fontSize = "20px"; - nameText.fontWeight = "bold"; - nameText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; - contentPanel.addControl(nameText); - - // Details (second line) - const date = new Date(recording.timestamp); - const dateStr = date.toLocaleString(); - const durationStr = this.formatDuration(recording.duration); - const detailsText = new TextBlock( - "details", - `πŸ“… ${dateStr} | ⏱ ${durationStr} | πŸ“Š ${recording.frameCount} frames` - ); - detailsText.height = "25px"; - detailsText.color = "#aaa"; - detailsText.fontSize = "16px"; - detailsText.textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_LEFT; - contentPanel.addControl(detailsText); - - return itemContainer; - } - - /** - * Select a recording - */ - private selectRecording(recordingId: string, container: Rectangle): void { - // Deselect previous - if (this._selectedContainer) { - this._selectedContainer.background = "#2a2a3e"; - this._selectedContainer.color = "#555"; - } - - // Select new - this._selectedRecording = recordingId; - this._selectedContainer = container; - container.background = "#00ff88"; - container.color = "#00ff88"; - - // Enable buttons - this._playButton.isEnabled = true; - this._deleteButton.isEnabled = true; - - debugLog(`ReplaySelectionScreen: Selected recording ${recordingId}`); - } - - /** - * Delete a recording - */ - private async deleteRecording(recordingId: string): Promise { - const storage = new PhysicsStorage(); - await storage.initialize(); - await storage.deleteRecording(recordingId); - storage.close(); - - debugLog(`ReplaySelectionScreen: Deleted recording ${recordingId}`); - - // Refresh list - this._recordingsList.clearControls(); - this._selectedRecording = null; - this._selectedContainer = null; - this._playButton.isEnabled = false; - this._deleteButton.isEnabled = false; - - await this.loadRecordings(); - } - - /** - * Format session name for display - */ - private formatSessionName(sessionId: string): string { - // Convert "session-1762606365166" to "Session 2024-11-08 07:06" - if (sessionId.startsWith('session-')) { - const timestamp = parseInt(sessionId.replace('session-', '')); - const date = new Date(timestamp); - const dateStr = date.toLocaleDateString(); - const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - return `Session ${dateStr} ${timeStr}`; - } - return sessionId; - } - - /** - * Format duration for display - */ - private formatDuration(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - if (mins > 0) { - return `${mins}m ${secs}s`; - } else { - return `${secs}s`; - } - } - - /** - * Dispose of UI - */ - public dispose(): void { - this._texture.dispose(); - debugLog("ReplaySelectionScreen: Disposed"); - } -} diff --git a/src/replay/recording/physicsRecorder.ts b/src/replay/recording/physicsRecorder.ts deleted file mode 100644 index 896b450..0000000 --- a/src/replay/recording/physicsRecorder.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { Scene, Quaternion } from "@babylonjs/core"; -import debugLog from "../../core/debug"; -import { PhysicsStorage } from "./physicsStorage"; -import { LevelConfig } from "../../levels/config/levelConfig"; - -/** - * Represents the physics state of a single object at a point in time - */ -interface PhysicsObjectState { - id: string; - position: [number, number, number]; - rotation: [number, number, number, number]; // Quaternion (x, y, z, w) - linearVelocity: [number, number, number]; - angularVelocity: [number, number, number]; - mass: number; - restitution: number; -} - -/** - * Snapshot of all physics objects at a specific time - */ -export interface PhysicsSnapshot { - timestamp: number; // Physics time in milliseconds - frameNumber: number; // Sequential frame counter - objects: PhysicsObjectState[]; -} - -/** - * Recording metadata - */ -interface RecordingMetadata { - startTime: number; - endTime: number; - frameCount: number; - recordingDuration: number; // milliseconds - physicsUpdateRate: number; // Hz - levelConfig?: LevelConfig; // Full scene state at recording time -} - -/** - * Complete recording with metadata and snapshots - */ -export interface PhysicsRecording { - metadata: RecordingMetadata; - snapshots: PhysicsSnapshot[]; -} - -/** - * Physics state recorder that continuously captures physics state - * - Ring buffer mode: Always captures last N seconds (low memory, quick export) - * - Long recording mode: Saves to IndexedDB for 2-10 minute recordings - */ -export class PhysicsRecorder { - private _scene: Scene; - private _isEnabled: boolean = false; - private _isLongRecording: boolean = false; - - // Ring buffer for continuous recording - private _ringBuffer: PhysicsSnapshot[] = []; - private _maxRingBufferFrames: number = 216; // 30 seconds at 7.2 Hz - private _ringBufferIndex: number = 0; - - // Long recording storage - private _longRecording: PhysicsSnapshot[] = []; - private _longRecordingStartTime: number = 0; - - // Frame tracking - private _frameNumber: number = 0; - private _startTime: number = 0; - private _physicsUpdateRate: number = 7.2; // Hz (estimated) - - // Performance tracking - private _captureTimeAccumulator: number = 0; - private _captureCount: number = 0; - - // IndexedDB storage - private _storage: PhysicsStorage | null = null; - - // Auto-save to IndexedDB - private _autoSaveEnabled: boolean = true; - private _autoSaveBuffer: PhysicsSnapshot[] = []; - private _autoSaveInterval: number = 10000; // Save every 10 seconds - private _lastAutoSaveTime: number = 0; - private _currentSessionId: string = ""; - private _levelConfig: LevelConfig | null = null; - - constructor(scene: Scene, levelConfig?: LevelConfig) { - this._scene = scene; - this._levelConfig = levelConfig || null; - - // Initialize IndexedDB storage - this._storage = new PhysicsStorage(); - this._storage.initialize().catch(error => { - debugLog("PhysicsRecorder: Failed to initialize storage", error); - }); - } - - /** - * Start the ring buffer recorder (always capturing last 30 seconds) - * Also starts auto-save to IndexedDB - */ - public startRingBuffer(): void { - if (this._isEnabled) { - debugLog("PhysicsRecorder: Ring buffer already running"); - return; - } - - this._isEnabled = true; - this._startTime = performance.now(); - this._lastAutoSaveTime = performance.now(); - this._frameNumber = 0; - - // Create unique session ID for this recording - this._currentSessionId = `session-${Date.now()}`; - - // Hook into physics update observable - this._scene.onAfterPhysicsObservable.add(() => { - if (this._isEnabled) { - this.captureFrame(); - this.checkAutoSave(); - } - }); - - debugLog("PhysicsRecorder: Recording started (ring buffer + auto-save to IndexedDB)"); - debugLog(`PhysicsRecorder: Session ID: ${this._currentSessionId}`); - } - - /** - * Stop the ring buffer recorder - */ - public stopRingBuffer(): void { - this._isEnabled = false; - debugLog("PhysicsRecorder: Ring buffer stopped"); - } - - /** - * Start a long-term recording (saves all frames to memory) - */ - public startLongRecording(): void { - if (this._isLongRecording) { - debugLog("PhysicsRecorder: Long recording already in progress"); - return; - } - - this._isLongRecording = true; - this._longRecording = []; - this._longRecordingStartTime = performance.now(); - - debugLog("PhysicsRecorder: Long recording started"); - } - - /** - * Stop long-term recording - */ - public stopLongRecording(): void { - if (!this._isLongRecording) { - debugLog("PhysicsRecorder: No long recording in progress"); - return; - } - - this._isLongRecording = false; - const duration = ((performance.now() - this._longRecordingStartTime) / 1000).toFixed(1); - debugLog(`PhysicsRecorder: Long recording stopped (${duration}s, ${this._longRecording.length} frames)`); - } - - /** - * Capture current physics state of all objects - */ - private captureFrame(): void { - const captureStart = performance.now(); - - const timestamp = performance.now() - this._startTime; - const objects: PhysicsObjectState[] = []; - - // Get all physics-enabled meshes AND transform nodes - const physicsMeshes = this._scene.meshes.filter(mesh => mesh.physicsBody !== null && mesh.physicsBody !== undefined); - const physicsTransformNodes = this._scene.transformNodes.filter(node => node.physicsBody !== null && node.physicsBody !== undefined); - const allPhysicsObjects = [...physicsMeshes, ...physicsTransformNodes]; - - for (const mesh of allPhysicsObjects) { - const body = mesh.physicsBody; - - // Double-check body still exists and has transformNode (can be disposed between filter and here) - if (!body || !body.transformNode) { - continue; - } - - try { - // Get position - const pos = body.transformNode.position; - - // Get rotation as quaternion - let quat = body.transformNode.rotationQuaternion; - if (!quat) { - // Convert Euler to Quaternion if needed - const rot = body.transformNode.rotation; - quat = Quaternion.FromEulerAngles(rot.x, rot.y, rot.z); - } - - // Get velocities - const linVel = body.getLinearVelocity(); - const angVel = body.getAngularVelocity(); - - // Get mass - const mass = body.getMassProperties().mass; - - // Get restitution (from shape material if available) - let restitution = 0; - if (body.shape && (body.shape as any).material) { - restitution = (body.shape as any).material.restitution || 0; - } - - objects.push({ - id: mesh.id, - position: [ - parseFloat(pos.x.toFixed(3)), - parseFloat(pos.y.toFixed(3)), - parseFloat(pos.z.toFixed(3)) - ], - rotation: [ - parseFloat(quat.x.toFixed(4)), - parseFloat(quat.y.toFixed(4)), - parseFloat(quat.z.toFixed(4)), - parseFloat(quat.w.toFixed(4)) - ], - linearVelocity: [ - parseFloat(linVel.x.toFixed(3)), - parseFloat(linVel.y.toFixed(3)), - parseFloat(linVel.z.toFixed(3)) - ], - angularVelocity: [ - parseFloat(angVel.x.toFixed(3)), - parseFloat(angVel.y.toFixed(3)), - parseFloat(angVel.z.toFixed(3)) - ], - mass: parseFloat(mass.toFixed(2)), - restitution: parseFloat(restitution.toFixed(2)) - }); - } catch (_error) { - // Physics body was disposed during capture, skip this object - continue; - } - } - - const snapshot: PhysicsSnapshot = { - timestamp: parseFloat(timestamp.toFixed(1)), - frameNumber: this._frameNumber, - objects - }; - - // Add to ring buffer (circular overwrite) - this._ringBuffer[this._ringBufferIndex] = snapshot; - this._ringBufferIndex = (this._ringBufferIndex + 1) % this._maxRingBufferFrames; - - // Add to long recording if active - if (this._isLongRecording) { - this._longRecording.push(snapshot); - } - - // Add to auto-save buffer if enabled - if (this._autoSaveEnabled) { - this._autoSaveBuffer.push(snapshot); - } - - this._frameNumber++; - - // Track performance - const captureTime = performance.now() - captureStart; - this._captureTimeAccumulator += captureTime; - this._captureCount++; - - // Log average capture time every 100 frames - if (this._captureCount % 100 === 0) { - const avgTime = (this._captureTimeAccumulator / this._captureCount).toFixed(3); - debugLog(`PhysicsRecorder: Average capture time: ${avgTime}ms (${objects.length} objects)`); - } - } - - /** - * Check if it's time to auto-save to IndexedDB - */ - private checkAutoSave(): void { - if (!this._autoSaveEnabled || !this._storage) { - return; - } - - const now = performance.now(); - const timeSinceLastSave = now - this._lastAutoSaveTime; - - // Save every 10 seconds - if (timeSinceLastSave >= this._autoSaveInterval && this._autoSaveBuffer.length > 0) { - this.performAutoSave(); - this._lastAutoSaveTime = now; - } - } - - /** - * Save buffered snapshots to IndexedDB - */ - private async performAutoSave(): Promise { - if (!this._storage || this._autoSaveBuffer.length === 0) { - return; - } - - // Copy buffer and clear it immediately to avoid blocking next frame - const snapshotsToSave = [...this._autoSaveBuffer]; - this._autoSaveBuffer = []; - - // Use the LevelConfig passed to constructor - const levelConfig = this._levelConfig || undefined; - - // Create a recording from the buffered snapshots - const metadata: RecordingMetadata = { - startTime: snapshotsToSave[0].timestamp, - endTime: snapshotsToSave[snapshotsToSave.length - 1].timestamp, - frameCount: snapshotsToSave.length, - recordingDuration: snapshotsToSave[snapshotsToSave.length - 1].timestamp - snapshotsToSave[0].timestamp, - physicsUpdateRate: this._physicsUpdateRate, - levelConfig // Include complete scene state - }; - - const recording: PhysicsRecording = { - metadata, - snapshots: snapshotsToSave - }; - - try { - // Save to IndexedDB with session ID as name - await this._storage.saveRecording(this._currentSessionId, recording); - - const duration = (metadata.recordingDuration / 1000).toFixed(1); - const configSize = levelConfig ? `with scene state (${JSON.stringify(levelConfig).length} bytes)` : 'without scene state'; - debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) ${configSize} to IndexedDB`); - } catch (error) { - debugLog("PhysicsRecorder: Error during auto-save", error); - } - } - - /** - * Export last N seconds from ring buffer - */ - public exportRingBuffer(seconds: number = 30): PhysicsRecording { - const maxFrames = Math.min( - Math.floor(seconds * this._physicsUpdateRate), - this._maxRingBufferFrames - ); - - // Extract frames from ring buffer (handling circular nature) - const snapshots: PhysicsSnapshot[] = []; - const startIndex = (this._ringBufferIndex - maxFrames + this._maxRingBufferFrames) % this._maxRingBufferFrames; - - for (let i = 0; i < maxFrames; i++) { - const index = (startIndex + i) % this._maxRingBufferFrames; - const snapshot = this._ringBuffer[index]; - if (snapshot) { - snapshots.push(snapshot); - } - } - - // Sort by frame number to ensure correct order - snapshots.sort((a, b) => a.frameNumber - b.frameNumber); - - const metadata: RecordingMetadata = { - startTime: snapshots[0]?.timestamp || 0, - endTime: snapshots[snapshots.length - 1]?.timestamp || 0, - frameCount: snapshots.length, - recordingDuration: (snapshots[snapshots.length - 1]?.timestamp || 0) - (snapshots[0]?.timestamp || 0), - physicsUpdateRate: this._physicsUpdateRate - }; - - return { - metadata, - snapshots - }; - } - - /** - * Export long recording - */ - public exportLongRecording(): PhysicsRecording { - if (this._longRecording.length === 0) { - debugLog("PhysicsRecorder: No long recording data to export"); - return { - metadata: { - startTime: 0, - endTime: 0, - frameCount: 0, - recordingDuration: 0, - physicsUpdateRate: this._physicsUpdateRate - }, - snapshots: [] - }; - } - - const metadata: RecordingMetadata = { - startTime: this._longRecording[0].timestamp, - endTime: this._longRecording[this._longRecording.length - 1].timestamp, - frameCount: this._longRecording.length, - recordingDuration: this._longRecording[this._longRecording.length - 1].timestamp - this._longRecording[0].timestamp, - physicsUpdateRate: this._physicsUpdateRate - }; - - return { - metadata, - snapshots: this._longRecording - }; - } - - /** - * Download recording as JSON file - */ - public downloadRecording(recording: PhysicsRecording, filename: string = "physics-recording"): void { - const json = JSON.stringify(recording, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.download = `${filename}-${Date.now()}.json`; - link.click(); - - URL.revokeObjectURL(url); - - const sizeMB = (blob.size / 1024 / 1024).toFixed(2); - const duration = (recording.metadata.recordingDuration / 1000).toFixed(1); - debugLog(`PhysicsRecorder: Downloaded ${filename} (${sizeMB} MB, ${duration}s, ${recording.metadata.frameCount} frames)`); - } - - /** - * Get recording statistics - */ - public getStats(): { - isRecording: boolean; - isLongRecording: boolean; - ringBufferFrames: number; - ringBufferDuration: number; - longRecordingFrames: number; - longRecordingDuration: number; - averageCaptureTime: number; - } { - const ringBufferDuration = this._ringBuffer.length > 0 - ? (this._ringBuffer[this._ringBuffer.length - 1]?.timestamp || 0) - (this._ringBuffer[0]?.timestamp || 0) - : 0; - - const longRecordingDuration = this._longRecording.length > 0 - ? this._longRecording[this._longRecording.length - 1].timestamp - this._longRecording[0].timestamp - : 0; - - return { - isRecording: this._isEnabled, - isLongRecording: this._isLongRecording, - ringBufferFrames: this._ringBuffer.filter(s => s !== undefined).length, - ringBufferDuration: ringBufferDuration / 1000, // Convert to seconds - longRecordingFrames: this._longRecording.length, - longRecordingDuration: longRecordingDuration / 1000, // Convert to seconds - averageCaptureTime: this._captureCount > 0 ? this._captureTimeAccumulator / this._captureCount : 0 - }; - } - - /** - * Clear long recording data - */ - public clearLongRecording(): void { - this._longRecording = []; - this._isLongRecording = false; - debugLog("PhysicsRecorder: Long recording data cleared"); - } - - /** - * Save current long recording to IndexedDB - */ - public async saveLongRecordingToStorage(name: string): Promise { - if (!this._storage) { - debugLog("PhysicsRecorder: Storage not initialized"); - return null; - } - - const recording = this.exportLongRecording(); - if (recording.snapshots.length === 0) { - debugLog("PhysicsRecorder: No recording data to save"); - return null; - } - - try { - const recordingId = await this._storage.saveRecording(name, recording); - debugLog(`PhysicsRecorder: Saved to IndexedDB with ID: ${recordingId}`); - return recordingId; - } catch (error) { - debugLog("PhysicsRecorder: Error saving to IndexedDB", error); - return null; - } - } - - /** - * Save ring buffer to IndexedDB - */ - public async saveRingBufferToStorage(name: string, seconds: number = 30): Promise { - if (!this._storage) { - debugLog("PhysicsRecorder: Storage not initialized"); - return null; - } - - const recording = this.exportRingBuffer(seconds); - if (recording.snapshots.length === 0) { - debugLog("PhysicsRecorder: No ring buffer data to save"); - return null; - } - - try { - const recordingId = await this._storage.saveRecording(name, recording); - debugLog(`PhysicsRecorder: Saved ring buffer to IndexedDB with ID: ${recordingId}`); - return recordingId; - } catch (error) { - debugLog("PhysicsRecorder: Error saving ring buffer to IndexedDB", error); - return null; - } - } - - /** - * Load a recording from IndexedDB - */ - public async loadRecordingFromStorage(recordingId: string): Promise { - if (!this._storage) { - debugLog("PhysicsRecorder: Storage not initialized"); - return null; - } - - try { - return await this._storage.loadRecording(recordingId); - } catch (error) { - debugLog("PhysicsRecorder: Error loading from IndexedDB", error); - return null; - } - } - - /** - * List all recordings in IndexedDB - */ - public async listStoredRecordings(): Promise> { - if (!this._storage) { - debugLog("PhysicsRecorder: Storage not initialized"); - return []; - } - - try { - return await this._storage.listRecordings(); - } catch (error) { - debugLog("PhysicsRecorder: Error listing recordings", error); - return []; - } - } - - /** - * Delete a recording from IndexedDB - */ - public async deleteStoredRecording(recordingId: string): Promise { - if (!this._storage) { - debugLog("PhysicsRecorder: Storage not initialized"); - return false; - } - - try { - await this._storage.deleteRecording(recordingId); - return true; - } catch (error) { - debugLog("PhysicsRecorder: Error deleting recording", error); - return false; - } - } - - /** - * Get storage statistics - */ - public async getStorageStats(): Promise<{ - recordingCount: number; - totalSegments: number; - estimatedSizeMB: number; - } | null> { - if (!this._storage) { - return null; - } - - try { - return await this._storage.getStats(); - } catch (error) { - debugLog("PhysicsRecorder: Error getting storage stats", error); - return null; - } - } - - /** - * Dispose of recorder resources - */ - public async dispose(): Promise { - // Save any remaining buffered data before disposing - if (this._autoSaveBuffer.length > 0) { - debugLog(`PhysicsRecorder: Saving ${this._autoSaveBuffer.length} remaining frames before disposal`); - await this.performAutoSave(); - } - - this.stopRingBuffer(); - this.stopLongRecording(); - this._ringBuffer = []; - this._longRecording = []; - this._autoSaveBuffer = []; - - if (this._storage) { - this._storage.close(); - } - } -} diff --git a/src/replay/recording/physicsStorage.ts b/src/replay/recording/physicsStorage.ts deleted file mode 100644 index c974997..0000000 --- a/src/replay/recording/physicsStorage.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { PhysicsRecording, PhysicsSnapshot } from "./physicsRecorder"; -import debugLog from "../../core/debug"; - -/** - * IndexedDB storage for physics recordings - * Stores recordings in 1-second segments for efficient retrieval and seeking - */ -export class PhysicsStorage { - private static readonly DB_NAME = "PhysicsRecordings"; - private static readonly DB_VERSION = 1; - private static readonly STORE_NAME = "recordings"; - private _db: IDBDatabase | null = null; - - /** - * Initialize the IndexedDB database - */ - public async initialize(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(PhysicsStorage.DB_NAME, PhysicsStorage.DB_VERSION); - - request.onerror = () => { - debugLog("PhysicsStorage: Failed to open IndexedDB", request.error); - reject(request.error); - }; - - request.onsuccess = () => { - this._db = request.result; - debugLog("PhysicsStorage: IndexedDB opened successfully"); - resolve(); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains(PhysicsStorage.STORE_NAME)) { - const objectStore = db.createObjectStore(PhysicsStorage.STORE_NAME, { - keyPath: "id", - autoIncrement: true - }); - - // Create indexes for efficient querying - objectStore.createIndex("recordingId", "recordingId", { unique: false }); - objectStore.createIndex("timestamp", "timestamp", { unique: false }); - objectStore.createIndex("name", "name", { unique: false }); - - debugLog("PhysicsStorage: Object store created"); - } - }; - }); - } - - /** - * Save a recording to IndexedDB - */ - public async saveRecording(name: string, recording: PhysicsRecording): Promise { - if (!this._db) { - throw new Error("Database not initialized"); - } - - // Use the provided name as recordingId (for session-based grouping) - const recordingId = name; - const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - - // Split recording into 1-second segments - const segments: PhysicsSnapshot[][] = []; - for (let i = 0; i < recording.snapshots.length; i += segmentSize) { - segments.push(recording.snapshots.slice(i, i + segmentSize)); - } - - let savedCount = 0; - - // Save each segment - segments.forEach((segment, index) => { - const segmentData = { - recordingId, - name, - segmentIndex: index, - timestamp: segment[0].timestamp, - snapshots: segment, - metadata: index === 0 ? recording.metadata : null // Only store metadata in first segment - }; - - const request = objectStore.add(segmentData); - - request.onsuccess = () => { - savedCount++; - if (savedCount === segments.length) { - const sizeMB = (JSON.stringify(recording).length / 1024 / 1024).toFixed(2); - debugLog(`PhysicsStorage: Saved recording "${name}" (${segments.length} segments, ${sizeMB} MB)`); - resolve(recordingId); - } - }; - - request.onerror = () => { - debugLog("PhysicsStorage: Error saving segment", request.error); - reject(request.error); - }; - }); - - transaction.onerror = () => { - debugLog("PhysicsStorage: Transaction error", transaction.error); - reject(transaction.error); - }; - }); - } - - /** - * Load a recording from IndexedDB - */ - public async loadRecording(recordingId: string): Promise { - if (!this._db) { - throw new Error("Database not initialized"); - } - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - const index = objectStore.index("recordingId"); - - const request = index.getAll(recordingId); - - request.onsuccess = () => { - const segments = request.result; - - if (segments.length === 0) { - resolve(null); - return; - } - - // Sort segments by index - segments.sort((a, b) => a.segmentIndex - b.segmentIndex); - - // Combine all snapshots - const allSnapshots: PhysicsSnapshot[] = []; - let metadata = null; - - segments.forEach(segment => { - allSnapshots.push(...segment.snapshots); - if (segment.metadata) { - metadata = segment.metadata; - } - }); - - if (!metadata) { - debugLog("PhysicsStorage: Warning - no metadata found in recording"); - resolve(null); - return; - } - - const recording: PhysicsRecording = { - metadata, - snapshots: allSnapshots - }; - - debugLog(`PhysicsStorage: Loaded recording "${recordingId}" (${allSnapshots.length} frames)`); - resolve(recording); - }; - - request.onerror = () => { - debugLog("PhysicsStorage: Error loading recording", request.error); - reject(request.error); - }; - }); - } - - /** - * List all available recordings - */ - public async listRecordings(): Promise> { - if (!this._db) { - throw new Error("Database not initialized"); - } - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - - const request = objectStore.getAll(); - - request.onsuccess = () => { - const allSegments = request.result; - - // Group by recordingId and aggregate all segments - const sessionMap = new Map(); - - // Group segments by session - allSegments.forEach(segment => { - if (!sessionMap.has(segment.recordingId)) { - sessionMap.set(segment.recordingId, { - segments: [], - metadata: null - }); - } - const session = sessionMap.get(segment.recordingId)!; - session.segments.push(segment); - if (segment.metadata) { - session.metadata = segment.metadata; // Keep first metadata for LevelConfig - } - }); - - // Build recording list with aggregated data - const recordings: Array<{ - id: string; - name: string; - timestamp: number; - duration: number; - frameCount: number; - }> = []; - - sessionMap.forEach((session, recordingId) => { - // Sort segments to get first and last - session.segments.sort((a, b) => a.segmentIndex - b.segmentIndex); - - const firstSegment = session.segments[0]; - const _lastSegment = session.segments[session.segments.length - 1]; - - // Calculate total frame count across all segments - const totalFrames = session.segments.reduce((sum, seg) => sum + seg.snapshots.length, 0); - - // Calculate total duration from first to last snapshot across ALL segments - let firstTimestamp = Number.MAX_VALUE; - let lastTimestamp = 0; - - session.segments.forEach(seg => { - if (seg.snapshots.length > 0) { - const segFirstTimestamp = seg.snapshots[0].timestamp; - const segLastTimestamp = seg.snapshots[seg.snapshots.length - 1].timestamp; - - if (segFirstTimestamp < firstTimestamp) { - firstTimestamp = segFirstTimestamp; - } - if (segLastTimestamp > lastTimestamp) { - lastTimestamp = segLastTimestamp; - } - } - }); - - const totalDuration = (lastTimestamp - firstTimestamp) / 1000; // Convert to seconds - - recordings.push({ - id: recordingId, - name: recordingId, // Use session ID as name - timestamp: firstSegment.timestamp, - duration: totalDuration, - frameCount: totalFrames - }); - }); - - debugLog(`PhysicsStorage: Found ${recordings.length} sessions (${allSegments.length} total segments)`); - resolve(recordings); - }; - - request.onerror = () => { - debugLog("PhysicsStorage: Error listing recordings", request.error); - reject(request.error); - }; - }); - } - - /** - * Delete a recording from IndexedDB - */ - public async deleteRecording(recordingId: string): Promise { - if (!this._db) { - throw new Error("Database not initialized"); - } - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - const index = objectStore.index("recordingId"); - - // Get all segments with this recordingId - const getAllRequest = index.getAll(recordingId); - - getAllRequest.onsuccess = () => { - const segments = getAllRequest.result; - let deletedCount = 0; - - if (segments.length === 0) { - resolve(); - return; - } - - // Delete each segment - segments.forEach(segment => { - const deleteRequest = objectStore.delete(segment.id); - - deleteRequest.onsuccess = () => { - deletedCount++; - if (deletedCount === segments.length) { - debugLog(`PhysicsStorage: Deleted recording "${recordingId}" (${segments.length} segments)`); - resolve(); - } - }; - - deleteRequest.onerror = () => { - debugLog("PhysicsStorage: Error deleting segment", deleteRequest.error); - reject(deleteRequest.error); - }; - }); - }; - - getAllRequest.onerror = () => { - debugLog("PhysicsStorage: Error getting segments for deletion", getAllRequest.error); - reject(getAllRequest.error); - }; - }); - } - - /** - * Clear all recordings from IndexedDB - */ - public async clearAll(): Promise { - if (!this._db) { - throw new Error("Database not initialized"); - } - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readwrite"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - - const request = objectStore.clear(); - - request.onsuccess = () => { - debugLog("PhysicsStorage: All recordings cleared"); - resolve(); - }; - - request.onerror = () => { - debugLog("PhysicsStorage: Error clearing recordings", request.error); - reject(request.error); - }; - }); - } - - /** - * Get database statistics - */ - public async getStats(): Promise<{ - recordingCount: number; - totalSegments: number; - estimatedSizeMB: number; - }> { - if (!this._db) { - throw new Error("Database not initialized"); - } - - return new Promise((resolve, reject) => { - const transaction = this._db!.transaction([PhysicsStorage.STORE_NAME], "readonly"); - const objectStore = transaction.objectStore(PhysicsStorage.STORE_NAME); - - const request = objectStore.getAll(); - - request.onsuccess = () => { - const allSegments = request.result; - - // Count unique recordings - const uniqueRecordings = new Set(allSegments.map(s => s.recordingId)); - - // Estimate size (rough approximation) - const estimatedSizeMB = allSegments.length > 0 - ? (JSON.stringify(allSegments).length / 1024 / 1024) - : 0; - - resolve({ - recordingCount: uniqueRecordings.size, - totalSegments: allSegments.length, - estimatedSizeMB: parseFloat(estimatedSizeMB.toFixed(2)) - }); - }; - - request.onerror = () => { - debugLog("PhysicsStorage: Error getting stats", request.error); - reject(request.error); - }; - }); - } - - /** - * Close the database connection - */ - public close(): void { - if (this._db) { - this._db.close(); - this._db = null; - debugLog("PhysicsStorage: Database closed"); - } - } -} diff --git a/src/ui/screens/loginScreen.ts b/src/ui/screens/loginScreen.ts deleted file mode 100644 index fef6405..0000000 --- a/src/ui/screens/loginScreen.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AuthService } from '../../services/authService'; - -/** - * Updates the user profile display in the header - * Shows username and logout button when authenticated, or login button when not - * @param username - The username to display, or null to show login button - */ -export function updateUserProfile(username: string | null): void { - const profileContainer = document.getElementById('userProfile'); - if (!profileContainer) return; - - if (username) { - // User is authenticated - show profile and logout - profileContainer.className = 'user-profile'; - profileContainer.innerHTML = ` - - - `; - - const logoutBtn = document.getElementById('logoutBtn'); - if (logoutBtn) { - logoutBtn.addEventListener('click', async () => { - const authService = AuthService.getInstance(); - await authService.logout(); - }); - } - } else { - // User not authenticated - show login/signup button - profileContainer.className = ''; - profileContainer.innerHTML = ` - - `; - - const loginBtn = document.getElementById('loginBtn'); - if (loginBtn) { - loginBtn.addEventListener('click', async () => { - const authService = AuthService.getInstance(); - await authService.login(); - }); - } - } -} diff --git a/src/ui/widgets/discordWidget.ts b/src/ui/widgets/discordWidget.ts deleted file mode 100644 index 899a4ff..0000000 --- a/src/ui/widgets/discordWidget.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Discord Widget Integration using Widgetbot Crate - * Dynamically loads the widget script to avoid npm bundling issues - */ - -interface DiscordWidgetOptions { - server: string; - channel: string; - location?: string[]; - color?: string; - glyph?: string[]; - notifications?: boolean; - indicator?: boolean; - allChannelNotifications?: boolean; -} - -export class DiscordWidget { - private crate: any = null; - private scriptLoaded = false; - private isVisible = false; - - /** - * Initialize the Discord widget - * @param options - Widget configuration - */ - async initialize(options: DiscordWidgetOptions): Promise { - try { - // Suppress WidgetBot console errors (CSP and CORS issues from their side) - this.suppressWidgetBotErrors(); - - // Load the Crate script if not already loaded - if (!this.scriptLoaded) { - console.log('[DiscordWidget] Loading Crate script...'); - await this.loadCrateScript(); - this.scriptLoaded = true; - console.log('[DiscordWidget] Crate script loaded'); - } - - // Wait for Crate to be available on window - console.log('[DiscordWidget] Waiting for Crate constructor...'); - await this.waitForCrate(); - console.log('[DiscordWidget] Crate constructor available'); - - // Initialize the Crate widget - const defaultOptions: DiscordWidgetOptions = { - location: ['bottom', 'right'], - color: '#7289DA', - glyph: ['πŸ’¬', 'βœ–οΈ'], - notifications: true, - indicator: true, - ...options - }; - - console.log('[DiscordWidget] Initializing Crate with options:', defaultOptions); - - // @ts-ignore - Crate is loaded from CDN - this.crate = new window.Crate(defaultOptions); - - console.log('[DiscordWidget] Crate instance created, setting up event listeners...'); - this.setupEventListeners(); - console.log('[DiscordWidget] Successfully initialized'); - } catch (error) { - console.error('[DiscordWidget] Initialization failed:', error); - console.error('[DiscordWidget] Error details:', { - name: error?.constructor?.name, - message: error?.message, - stack: error?.stack - }); - throw error; // Re-throw to be caught by caller - } - } - - /** - * Dynamically load the Crate script from CDN - */ - private loadCrateScript(): Promise { - return new Promise((resolve, reject) => { - // Check if script already exists - const existingScript = document.querySelector('script[src*="widgetbot"]'); - if (existingScript) { - resolve(); - return; - } - - const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/@widgetbot/crate@3'; - script.async = true; - script.defer = true; - script.crossOrigin = 'anonymous'; - - script.onload = () => { - console.log('[DiscordWidget] Script loaded successfully'); - resolve(); - }; - - script.onerror = () => { - console.error('[DiscordWidget] Failed to load script'); - reject(new Error('Failed to load Widgetbot Crate script')); - }; - - document.head.appendChild(script); - }); - } - - /** - * Wait for Crate constructor to be available on window - */ - private waitForCrate(): Promise { - return new Promise((resolve) => { - const checkCrate = () => { - // @ts-ignore - if (window.Crate) { - resolve(); - } else { - setTimeout(checkCrate, 50); - } - }; - checkCrate(); - }); - } - - /** - * Suppress WidgetBot console errors (CSP/CORS issues from their infrastructure) - */ - private suppressWidgetBotErrors(): void { - // Filter console.error to suppress known WidgetBot issues - const originalError = console.error; - console.error = (...args: any[]) => { - const message = args.join(' '); - - // Skip known WidgetBot infrastructure errors - if ( - message.includes('widgetbot') || - message.includes('stonks.widgetbot.io') || - message.includes('e.widgetbot.io') || - message.includes('Content Security Policy') || - message.includes('[embed-api]') || - message.includes('[mobx]') || - message.includes('GraphQL') && message.includes('widgetbot') - ) { - return; // Suppress these errors - } - - // Pass through all other errors - originalError.apply(console, args); - }; - - // Filter console.log for WidgetBot verbose logging - const originalLog = console.log; - console.log = (...args: any[]) => { - const message = args.join(' '); - - // Skip WidgetBot internal logging - if (message.includes('[embed-api]')) { - return; // Suppress verbose embed-api logs - } - - originalLog.apply(console, args); - }; - } - - /** - * Setup event listeners for widget events - */ - private setupEventListeners(): void { - if (!this.crate) return; - - // Listen for when user signs in - this.crate.on('signIn', (user: any) => { - console.log('[DiscordWidget] User signed in:', user.username); - }); - - // Listen for widget visibility changes - this.crate.on('toggleChat', (visible: boolean) => { - this.isVisible = visible; - console.log('[DiscordWidget] Chat visibility:', visible); - }); - - // Suppress widget internal errors - they're from WidgetBot's infrastructure - this.crate.on('error', () => { - // Silently ignore - these are CSP/CORS issues on WidgetBot's side - }); - } - - /** - * Toggle the Discord chat widget - */ - toggle(): void { - if (this.crate) { - this.crate.toggle(); - } - } - - /** - * Show a notification on the widget button - * @param message - Notification message - */ - notify(message: string): void { - if (this.crate) { - this.crate.notify(message); - } - } - - /** - * Show the widget - */ - show(): void { - if (this.crate && !this.isVisible) { - this.crate.show(); - this.isVisible = true; - } - } - - /** - * Hide the widget - */ - hide(): void { - if (this.crate && this.isVisible) { - this.crate.hide(); - this.isVisible = false; - } - } - - /** - * Check if widget is currently visible - */ - getIsVisible(): boolean { - return this.isVisible; - } - - /** - * Emit a custom event to the widget - * @param event - Event name - * @param data - Event data - */ - emit(event: string, data?: any): void { - if (this.crate) { - this.crate.emit(event, data); - } - } - - /** - * Listen for widget events - * @param event - Event name - * @param callback - Event callback - */ - on(event: string, callback: (data: any) => void): void { - if (this.crate) { - this.crate.on(event, callback); - } - } - - /** - * Send a message to the Discord channel (if user is signed in) - * @param message - Message text - */ - sendMessage(message: string): void { - if (this.crate) { - this.emit('sendMessage', message); - } - } -} diff --git a/src/utils/controllerDebug.ts b/src/utils/controllerDebug.ts deleted file mode 100644 index b9d31f4..0000000 --- a/src/utils/controllerDebug.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Engine, - Scene, - MeshBuilder, - Color3, - WebXRDefaultExperience -} from "@babylonjs/core"; -import debugLog from '../core/debug'; - -/** - * Minimal standalone class to debug WebXR controller detection - * Usage: import and instantiate in main.ts instead of normal flow - */ -export class ControllerDebug { - private engine: Engine; - private scene: Scene; - - constructor() { - debugLog('πŸ” ControllerDebug: Starting minimal test...'); - this.init(); - } - - private async init() { - // Get canvas - const canvas = document.querySelector('#gameCanvas') as HTMLCanvasElement; - - // Create engine (no antialiasing for Quest compatibility) - debugLog('πŸ” Creating engine...'); - this.engine = new Engine(canvas, false); - - // Create scene - debugLog('πŸ” Creating scene...'); - this.scene = new Scene(this.engine); - this.scene.clearColor = new Color3(0.1, 0.1, 0.2).toColor4(); - - // Add light - //const light = new HemisphericLight("light", new Vector3(0, 1, 0), this.scene); - - // Add ground for reference - const _ground = MeshBuilder.CreateGround("ground", { width: 10, height: 10 }, this.scene); - - // Create WebXR - //consol e.log('πŸ” Creating WebXR...'); - //await navigator.xr.offerSession("immersive-vr"); - const xr = await this.scene.createDefaultXRExperienceAsync( { - disablePointerSelection: true, - disableTeleportation: true, - inputOptions: { - disableOnlineControllerRepository: true - }, - disableDefaultUI: false, // Enable UI for this test - disableHandTracking: true - }); - - debugLog('πŸ” WebXR created successfully'); - debugLog('πŸ” XR input exists:', !!xr.input); - debugLog('πŸ” XR input controllers:', xr.input.controllers.length); - - // Set up controller observable - debugLog('πŸ” Setting up onControllerAddedObservable...'); - - - xr.input.onControllerAddedObservable.add((controller) => { - debugLog('βœ… CONTROLLER ADDED! Handedness:', controller.inputSource.handedness); - debugLog(' - Input source:', controller.inputSource); - debugLog(' - Has motion controller:', !!controller.motionController); - - // Wait for motion controller - controller.onMotionControllerInitObservable.add((motionController) => { - debugLog('βœ… MOTION CONTROLLER INITIALIZED:', motionController.handness); - debugLog(' - Profile:', motionController.profileId); - debugLog(' - Components:', Object.keys(motionController.components)); - - // Log when any component changes - Object.keys(motionController.components).forEach(componentId => { - const component = motionController.components[componentId]; - - if (component.onAxisValueChangedObservable) { - component.onAxisValueChangedObservable.add((axes) => { - debugLog(`πŸ“ ${motionController.handness} ${componentId} axes:`, axes); - }); - } - - if (component.onButtonStateChangedObservable) { - component.onButtonStateChangedObservable.add((state) => { - debugLog(`πŸ”˜ ${motionController.handness} ${componentId} button:`, { - pressed: state.pressed, - touched: state.touched, - value: state.value - }); - }); - } - }); - }); - }); - - debugLog('πŸ” Observable registered. Waiting for controllers...'); - - // Render loop - this.engine.runRenderLoop(() => { - this.scene.render(); - }); - - // Create button to enter VR (requires user gesture) - this.createEnterVRButton(xr); - } - - private createEnterVRButton(xr: WebXRDefaultExperience) { - const button = document.createElement('button'); - button.textContent = 'Enter VR (Controller Debug)'; - button.style.cssText = ` - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - padding: 20px 40px; - font-size: 24px; - background: #4CAF50; - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - z-index: 9999; - `; - - button.onclick = async () => { - debugLog('πŸ” Button clicked - Entering VR mode...'); - button.remove(); - - try { - await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor', undefined, { - requiredFeatures: ['local-floor'], - - }); - debugLog(xr.baseExperience.featuresManager.getEnabledFeatures()); - //await xr.baseExperience.exitXRAsync(); - //await xr.baseExperience.enterXRAsync('immersive-vr', 'local-floor'); - debugLog('πŸ” βœ… Entered VR mode successfully'); - debugLog('πŸ” Controllers after entering VR:', xr.input.controllers.length); - - // Check again after delays - setTimeout(() => { - debugLog('πŸ” [+1s after VR] Controller count:', xr.input.controllers.length); - }, 1000); - - setTimeout(() => { - debugLog('πŸ” [+3s after VR] Controller count:', xr.input.controllers.length); - }, 3000); - - setTimeout(() => { - debugLog('πŸ” [+5s after VR] Controller count:', xr.input.controllers.length); - }, 5000); - } catch (error) { - console.error('πŸ” ❌ Failed to enter VR:', error); - } - }; - - document.body.appendChild(button); - debugLog('πŸ” Click the button to enter VR mode'); - } -}