diff --git a/public/assets/themes/default/models/ship.glb b/public/assets/themes/default/models/ship.glb index 51f1f88..be212a5 100644 Binary files a/public/assets/themes/default/models/ship.glb and b/public/assets/themes/default/models/ship.glb differ diff --git a/src/keyboardInput.ts b/src/keyboardInput.ts index c106696..8b40b50 100644 --- a/src/keyboardInput.ts +++ b/src/keyboardInput.ts @@ -4,6 +4,14 @@ import { FreeCamera, Observable, Scene, Vector2 } from "@babylonjs/core"; * Handles keyboard and mouse input for ship control * Combines both input methods into a unified interface */ +/** + * Recording control action types + */ +export type RecordingAction = + | "exportRingBuffer" // R key + | "toggleLongRecording" // Ctrl+R + | "exportLongRecording"; // Shift+R + export class KeyboardInput { private _leftStick: Vector2 = Vector2.Zero(); private _rightStick: Vector2 = Vector2.Zero(); @@ -11,6 +19,7 @@ export class KeyboardInput { private _mousePos: Vector2 = new Vector2(0, 0); private _onShootObservable: Observable = new Observable(); private _onCameraChangeObservable: Observable = new Observable(); + private _onRecordingActionObservable: Observable = new Observable(); private _scene: Scene; constructor(scene: Scene) { @@ -31,6 +40,13 @@ export class KeyboardInput { return this._onCameraChangeObservable; } + /** + * Get observable that fires when recording action is triggered + */ + public get onRecordingActionObservable(): Observable { + return this._onRecordingActionObservable; + } + /** * Get current input state (stick positions) */ @@ -61,6 +77,24 @@ export class KeyboardInput { }; document.onkeydown = (ev) => { + // Recording controls (with modifiers) + if (ev.key === 'r' || ev.key === 'R') { + if (ev.ctrlKey || ev.metaKey) { + // Ctrl+R or Cmd+R: Toggle long recording + ev.preventDefault(); // Prevent browser reload + this._onRecordingActionObservable.notifyObservers("toggleLongRecording"); + return; + } else if (ev.shiftKey) { + // Shift+R: Export long recording + this._onRecordingActionObservable.notifyObservers("exportLongRecording"); + return; + } else { + // R: Export ring buffer (last 30 seconds) + this._onRecordingActionObservable.notifyObservers("exportRingBuffer"); + return; + } + } + switch (ev.key) { case 'i': // Open Babylon Inspector @@ -148,5 +182,6 @@ export class KeyboardInput { this._scene.onPointerMove = null; this._onShootObservable.clear(); this._onCameraChangeObservable.clear(); + this._onRecordingActionObservable.clear(); } } diff --git a/src/level1.ts b/src/level1.ts index c699715..be31179 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -13,6 +13,7 @@ import {LevelConfig} from "./levelConfig"; import {LevelDeserializer} from "./levelDeserializer"; import {BackgroundStars} from "./backgroundStars"; import debugLog from './debug'; +import {PhysicsRecorder} from "./physicsRecorder"; export class Level1 implements Level { private _ship: Ship; @@ -25,6 +26,7 @@ export class Level1 implements Level { private _audioEngine: AudioEngineV2; private _deserializer: LevelDeserializer; private _backgroundStars: BackgroundStars; + private _physicsRecorder: PhysicsRecorder; constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) { this._levelConfig = levelConfig; @@ -95,6 +97,9 @@ export class Level1 implements Level { if (this._backgroundStars) { this._backgroundStars.dispose(); } + if (this._physicsRecorder) { + this._physicsRecorder.dispose(); + } } public async initialize() { @@ -142,10 +147,64 @@ export class Level1 implements Level { } }); + // Initialize physics recorder + setLoadingMessage("Initializing physics recorder..."); + this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene); + this._physicsRecorder.startRingBuffer(); + debugLog('Physics recorder initialized and running'); + + // Wire up recording keyboard shortcuts + this._ship.keyboardInput.onRecordingActionObservable.add((action) => { + this.handleRecordingAction(action); + }); this._initialized = true; // Notify that initialization is complete this._onReadyObservable.notifyObservers(this); } + + /** + * Handle recording keyboard shortcuts + */ + private handleRecordingAction(action: string): void { + switch (action) { + case "exportRingBuffer": + // R key: Export last 30 seconds from ring buffer + const ringRecording = this._physicsRecorder.exportRingBuffer(30); + this._physicsRecorder.downloadRecording(ringRecording, "ring-buffer-30s"); + debugLog("Exported ring buffer (last 30 seconds)"); + break; + + case "toggleLongRecording": + // Ctrl+R: Toggle long recording + const stats = this._physicsRecorder.getStats(); + if (stats.isLongRecording) { + this._physicsRecorder.stopLongRecording(); + debugLog("Long recording stopped"); + } else { + this._physicsRecorder.startLongRecording(); + debugLog("Long recording started"); + } + break; + + case "exportLongRecording": + // Shift+R: Export long recording + const longRecording = this._physicsRecorder.exportLongRecording(); + if (longRecording.snapshots.length > 0) { + this._physicsRecorder.downloadRecording(longRecording, "long-recording"); + debugLog("Exported long recording"); + } else { + debugLog("No long recording data to export"); + } + break; + } + } + + /** + * Get the physics recorder instance + */ + public get physicsRecorder(): PhysicsRecorder { + return this._physicsRecorder; + } } \ No newline at end of file diff --git a/src/physicsRecorder.ts b/src/physicsRecorder.ts new file mode 100644 index 0000000..ba424ee --- /dev/null +++ b/src/physicsRecorder.ts @@ -0,0 +1,515 @@ +import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core"; +import debugLog from "./debug"; +import { PhysicsStorage } from "./physicsStorage"; + +/** + * Represents the physics state of a single object at a point in time + */ +export 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 + */ +export interface RecordingMetadata { + startTime: number; + endTime: number; + frameCount: number; + recordingDuration: number; // milliseconds + physicsUpdateRate: number; // Hz +} + +/** + * 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; + + constructor(scene: Scene) { + this._scene = scene; + + // 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) + */ + public startRingBuffer(): void { + if (this._isEnabled) { + debugLog("PhysicsRecorder: Ring buffer already running"); + return; + } + + this._isEnabled = true; + this._startTime = performance.now(); + this._frameNumber = 0; + + // Hook into physics update observable + this._scene.onAfterPhysicsObservable.add(() => { + if (this._isEnabled) { + this.captureFrame(); + } + }); + + debugLog("PhysicsRecorder: Ring buffer started (30 second capacity)"); + } + + /** + * 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 + const physicsMeshes = this._scene.meshes.filter(mesh => mesh.physicsBody !== null); + + for (const mesh of physicsMeshes) { + const body = mesh.physicsBody!; + + // 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)) + }); + } + + 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); + } + + 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)`); + } + } + + /** + * 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 dispose(): void { + this.stopRingBuffer(); + this.stopLongRecording(); + this._ringBuffer = []; + this._longRecording = []; + + if (this._storage) { + this._storage.close(); + } + } +} diff --git a/src/physicsStorage.ts b/src/physicsStorage.ts new file mode 100644 index 0000000..eab943c --- /dev/null +++ b/src/physicsStorage.ts @@ -0,0 +1,350 @@ +import { PhysicsRecording, PhysicsSnapshot } from "./physicsRecorder"; +import debugLog from "./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"); + } + + const recordingId = `recording-${Date.now()}`; + 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 get first segment (which has metadata) + const recordingMap = new Map(); + + allSegments.forEach(segment => { + if (!recordingMap.has(segment.recordingId) && segment.metadata) { + recordingMap.set(segment.recordingId, { + id: segment.recordingId, + name: segment.name, + timestamp: segment.timestamp, + duration: segment.metadata.recordingDuration / 1000, // Convert to seconds + frameCount: segment.metadata.frameCount + }); + } + }); + + const recordings = Array.from(recordingMap.values()); + debugLog(`PhysicsStorage: Found ${recordings.length} recordings`); + 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/ship.ts b/src/ship.ts index d9f57db..6b81374 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -63,6 +63,10 @@ export class Ship { return this._gameStats; } + public get keyboardInput(): KeyboardInput { + return this._keyboardInput; + } + public set position(newPosition: Vector3) { const body = this._ship.physicsBody; @@ -111,7 +115,7 @@ export class Ship { // Register collision handler for hull damage const observable = agg.body.getCollisionObservable(); - observable.add((collisionEvent) => { + observable.add(() => { // Damage hull on any collision if (this._scoreboard?.shipStatus) { this._scoreboard.shipStatus.damageHull(0.01); @@ -212,7 +216,7 @@ export class Ship { this._scoreboard.initialize(); // Subscribe to score events to track asteroids destroyed - this._scoreboard.onScoreObservable.add((scoreEvent) => { + this._scoreboard.onScoreObservable.add(() => { // Each score event represents an asteroid destroyed this._gameStats.recordAsteroidDestroyed(); }); diff --git a/src/statusScreen.ts b/src/statusScreen.ts index 2e75027..e8f4cba 100644 --- a/src/statusScreen.ts +++ b/src/statusScreen.ts @@ -6,11 +6,11 @@ import { TextBlock } from "@babylonjs/gui"; import { + Camera, Mesh, MeshBuilder, Scene, StandardMaterial, - TransformNode, Vector3 } from "@babylonjs/core"; import { GameStats } from "./gameStats"; @@ -25,7 +25,7 @@ export class StatusScreen { private _screenMesh: Mesh | null = null; private _texture: AdvancedDynamicTexture | null = null; private _isVisible: boolean = false; - private _camera: TransformNode | null = null; + private _camera: Camera | null = null; // Text blocks for statistics private _gameTimeText: TextBlock; @@ -43,7 +43,7 @@ export class StatusScreen { /** * Initialize the status screen mesh and UI */ - public initialize(camera: TransformNode): void { + public initialize(camera: Camera): void { this._camera = camera; // Create a plane mesh for the status screen diff --git a/themes/default/test.jpg b/themes/default/test.jpg new file mode 100644 index 0000000..4cd81c1 Binary files /dev/null and b/themes/default/test.jpg differ