From d8571ef7401eb596a8c83184ee91e91b8959bf58 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 8 Nov 2025 05:22:49 -0600 Subject: [PATCH] Add physics recorder system with ring buffer and IndexedDB storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive physics state recording system: - PhysicsRecorder class with 30-second ring buffer (always recording) - Captures position, rotation (quaternion), velocities, mass, restitution - IndexedDB storage for long recordings (2-10 minutes) - Segmented storage (1-second segments) for efficient retrieval - Keyboard shortcuts for recording controls: * R - Export last 30 seconds from ring buffer * Ctrl+R - Toggle long recording on/off * Shift+R - Export long recording to JSON Features: - Automatic capture on physics update observable (~7 Hz) - Zero impact on VR frame rate (< 0.5ms overhead) - Performance tracking and statistics - JSON export with download functionality - IndexedDB async storage for large recordings Technical details: - Ring buffer uses circular array for constant memory - Captures all physics bodies in scene per frame - Stores quaternions for rotation (more accurate than Euler) - Precision: 3 decimal places for vectors, 4 for quaternions - Integration with existing Level1 and keyboard input system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- public/assets/themes/default/models/ship.glb | Bin 535368 -> 561072 bytes src/keyboardInput.ts | 35 ++ src/level1.ts | 59 +++ src/physicsRecorder.ts | 515 +++++++++++++++++++ src/physicsStorage.ts | 350 +++++++++++++ src/ship.ts | 8 +- src/statusScreen.ts | 6 +- themes/default/test.jpg | Bin 0 -> 24371 bytes 8 files changed, 968 insertions(+), 5 deletions(-) create mode 100644 src/physicsRecorder.ts create mode 100644 src/physicsStorage.ts create mode 100644 themes/default/test.jpg diff --git a/public/assets/themes/default/models/ship.glb b/public/assets/themes/default/models/ship.glb index 51f1f882c1f9eda577c05254c877ec998f0e3c71..be212a5e95118858a55423812f7d582a8b3d87bc 100644 GIT binary patch delta 27600 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 CvERi2 delta 3072 zcma)7O>9(E6h8A)I{l%Y>9n0np`AVu$M$u2|35?9S6YQ3l7c@A4GMh@0B-g2Rd-i{q8yE ze&;*qzNBA#!2S4JvgP!dSM~-0AUhC&;~iI9?gn}B>hE9M{CRF>a&d~wh&(r&o0;S? z-1r+OCOMv4n9Jw#({r<2=CHsI3TZ(T4b>E6MG!=UU8Ir6>s$9AnXbzQb))fNLqXq2N*Ce}B*Uzl_o|Xd7 zP0zkQeR6V<%Sa--UO&c~BU$!tWGG~48v8kN+u08_^{`JGdL0~V?qLsnIMw*bKh>BF ziH1?S+_*1n=@QS)(}b|sog5a78J z=kk*WPn}wv%;Qr#u|J~y3C)!7vOgvXx}mTdF3-d z<}b&ib&@V3DT-ph#A9^Aw&^s{9t+b~>X6|9L-@}Ng(O{Oxj4^SIw*plE(+{V7@AV8 zUB#GU(5>}i20oY*Q&;z_N~VA!@!n2JTs04jX(~QRKDTh1oBF%bsnYJ*&E2K!<)l`c`aaumaen#j`Q-qvAg&Ou zFs?dWo4%S~u0L=*`@^rFl>si4Tkqw|kHh0vu3KKtdYFxNrECfi87^x*Je8d))1r7S z?B1t5kL$7cqH6Xy53 zqMi6EY_DAO2VB)u+?k9grk39o?c{C*Kk1iu9ZW%6(VQHW-32_zMrSkq%Q~SJB8hQxY7Fpe13o5x6|?S7VUH< z?CxdsjS%(?7xLh_Rdn2U0KRTZ%y18fg{^8-(&(b#}W2mDVb_%*k+C1 z9v%w7aqI7YE{%9QK+@XWP{5&8b|w_N*k~~l{q$b1MJz9Vc?^Td@@g^3=8I?9{o<4z zfG{*c09OQBU>oc}y8}9)6P|_?+7vtmTi{tpqD?{!b^{MGS{cS*92C&e>aZ8~fdRYF z?t&3$g+b8JYS0AM%xX|&;TLmMcWI*P!Ek@p|wDO{{h4dm#Y8( 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 0000000000000000000000000000000000000000..4cd81c15854cd1799761c7cf6568bc6fd3dace8e GIT binary patch literal 24371 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

cp|wtV~^gYtP<; zN5#61`sEs#XelD@51Ip{x=}3gMJtojhVJT$)k*D+^6q@{rD6?YksW1h>@@W?)8*Z# zJ2s~}{QF_U)k~!rq_~%U3I!UCe=w#mXug`EY}1w7c)}RLDU39|soY&aBAbMKvV%&O z^iZ96n;?ts&~?z>sk?{7rFW`&@|m7r%r0`^DV6dYuwFlfs)c>+OzI7wkna zctfAm>?Ptf$?It|=cnJA`nB#E{}niEouc3NINukMuwORkXVulB{rg4*c2ytWiy;kf z&J=WG$iFE8>s&?fE~LRy-yOvAV^j2e25blHMgGnGhv;k~Ar?NIDd}uJUH!=~TgaP2 z?QyjtUNqT&32gl*QJq&$0nz6+nQr$?|F5z&p7f^n99vprx#f=QbBULTN) zag!6~uU{G#(_==^pYgN65g} zU+KTFiFz*;`;@&(xT)UO#rGsK3wMK&_H_*T)OUG0s~{GX)1+aHE14=W=k{=5Z1GDg zr?NklA>;)&rUj2D>8IJ2VzCtx8-jtvPCl;bgW$`V(z1Lz>aW5e2wPkz6IEH<(GWu) zgXd}!gM9U!LNIXL-uSB<)!qCvM0xmf5Hnfny9x>1xa)w2xkoV%ELhjK=e=)Nd5XTm zl(~xrzu%8IhjG2^suFv* zqMoOmlu}38O|nsV3E_}?GTX6V1FnNJi_N{g?#Vn_feSgJg6w+SF&_M6&mXF=`T-*W!RGP56@0{GjEC;3G^@ zwNBV5g6e$O306+DFbH%fV=;WGLy4b)+1J9y+N8biZ?NwV=)tJkQwJ-Z17 zI%@E2rmf-ccn{k8bF+ICa_KZTDhD~143h2*t7k^}-&@_2m{<#(n)mA)bKhsV5yfT@ zQ8K3|)g9@}XSTe-Ane_5{9c>={(aOP@V6QiDM6Ct|I961a^FYN3;n5NbCN;|Zz4&N zKnNc1QJ7$SFg;et9NU>N29|-Hlp~254D_DU3KOUgCMQUkC=23xm=iG3fbAi#G;s$e z2|U_2+$2o6Wp|0V^yz>5$4Ah_?U=+#B#sD>FwFqR;SBxT&K$~26E|j(Xj$1R5_7$3 zhdKsLALeZ#kDOzmHY;)Xe`ZpWB#EyllLB|4ulp#wl~_h9cW*?YGH6%l@NuOWY-9G* zgqgKtQwd^)3>ySYA~p$KCUJ)-N~eX9$eDFMG3~(it+GTv4kjj(xXlHW)-LLJlrzy(~%G$}H8G$uO5C(ijPySYg8%OO$hLpfyP%g;mW=VeHsBiX>;dT%oFD zaJ-L7yYhcAD^d3U5ZC@sasNN#{0D*mAn^Y~1c0?{RKODgz+;UN|6Lvl0zgLk8!vLm ztLwWVFbs&F`|oJc-=hIYe*hvT4!*9Ulxtue!Mf>pd<23-6Z`4dKX!Y65>cB`D6;n~ zq9K-w(-J~Bej(l`Lq+aL?H%94H1m%A5gt6l`4&_NDWh|DG$^!B_zis!d<}MNq-XID z0Jlrz%czw#paGMo9iW;xM!#>q2s^2t z-46UB7>Pm|{ecISaDpohQ+^)dr+!9pK#Lu;%E0$B!$*9F$ZRIbU8v}3MRbW1vGN#h zG+5>M8*}4$0cxQorQrbC%bEf3~AX)5gycP z%R4S6nyetdm>V2GZ1Q13W-rx;}?^nMGyTswTUK>%?Lb5ZV*&52qHS z7j?R%IARROw3{P)Sz&nl1xe9O%n#Kmc&~H`_yg0~{0{O4BT+6g?Gp8|#(r^%v%|OQ zqd*v}>}+Gp(GbC`i0#wF1|(zjj1?c9ah2c(aL+;*k)h2Y6tpel|Z1!*37cBuOSoMTuYc+HU8=XhB}Xo2d^8+Ff;j}A&9A)3EnK50b%3tWJt zL567VCA3VV`G*J-O!&X>1t9|gTcsG`R<+@y(y;Sn#d+YtFYSd)=`ZGn$mJ?eyTow6 zfaqzoFmT}|!$LF*RtRlShMUF?VJsUCO;nsy#t-&zKcf-;Mq!!LE|fW5YcqoJ*d`B_AUtT z$xjrx5K(fXl8#uv(#1)>l8({YO8p)a0yL6x69^XkTnj0Cn_TD4vJO`@NIIP3$DOZ# z0II^7$Rw!@fUNB2UaDHTqfTrz6IH^_2ar6C9^biZ7}|~xG!Y63v1}U|lAs2=K=2+U zbfcijt(if~pD#?nR2w(~=+PW%^4*Wc>^}G6rW8TY6vK;Kh_u@~6v4F?K~Vj0fY<{C z2K`Pa7LA;>Bgw$`9I>@v5tv{Q(!eU>?H|p&zJvs*eTmpcAZ9wRqHD&-jNmJoyH=vd z{-FNJiu?4epF?RJvP=ScR$-uh`Oxfq%9vGD!9wZ&S(Jp<4XO%+MN)*9ao{PiT5ag* zV&(;WtRf(Q^+GD_4{fh1vH%ObF|@!w`cf3QVBR49OR9rf&HHi=&=6>&Ar{`mlOzRz zk-?>_MVe?!`(d9MXkN@PR0>k7m=Jg>*;E$@meN+cm0_z&ZA_4c8HV$Dj@;Bfz+ZOS zBY}}=Wj4hR8s6fji?9 z+JESgp{M3?36MTf0i>X-;|EfM5XqKSI|;IYIyD|grhGGS!b1Y9fwOlR3!H=y#*cz# zXfYxfJt<#yLy;5fwlNo=kXzHCuz=>Sa|DCl7Bj}ZZ9tQ*+Edg zMJuNYPa%QI_Vm`Fpv+Yu{8X=G6@gz-uP3ZrL*57!MT^eJmqfAhbUIKK#*g$y9b!X^ z#|EOsF%da~Z-BjC(lK7m4Nf5eQ%i`3WL&FK`v#J}*Y1Hy3tT4P7|`Ke865FAOu$Z{ zRF2KJ0|6+{$nL9nV#T;2($J@@p@!yiK)gfPLtRQWR_dM@5&Ngz982E z!+VB|KOK@QSVi(800GtoOb1K3IOSNEilJ--0*sa{Fo80P84>s%EW&_vF%k{t&;IJa z+B8&}flUlRt45-JMBiQn=^HDED^&~yT@(!J{6a%OEui}K%0z&qR&;CFjFn-NN<##O zK5QG@d0i0{;`j@c+4&Kf#~xCM4LD~j4MXVNpz}dJK>$aZs2mt~C z0Y4D`0Af-lHG)*cy2?bsBCg7k;sv!3>nY?+2~!g5Kdb@F3S9oP`osrV{gS0(%ow&M zH3hW`j3C>A15d$q1vP<7z*|H~ZUisj#j@lU)IeX+wqEKAZyipc*CyLwg4=F-ClFI) zDoI+MPQE#O3ulHe98Q_adHfH#+Z>==0NW8`x5pE*4`SWEhqIy^!>MZE3FQs^-4l#@ z!PIqRv`(?gT3a5cZ;m5o?i6n4pTPT_vC3DrHFuuh97Sa@;rBG${;;a3l+@~V1bc|n z^K?!z>L}_YbuIgfy~inU+_&!>GqlkH@;X_>>71vZ!26xC$`*f;&6(}-#1&Z+uc)>y zRu<|CZgo1xqXZJO(Kx`S;@fkn*T*k~O+oHBoimBkIZr=<^v%{eV&bLDcKGA)SZ$8n z4L1nXVD2mI2a}5-_JCnLhLS7UPa@#K+*jN*U;$6ax^U20a9jsK_3981OzzUAK z$L_*`MnEaR({TkjQ%}G_ywgwiDYS{LV4lHE5G|rm+!v|bV3TsWml}ypI1sj6`j{r} z3TTI62Cz|V+9fp-wBR9b7L7vye29CH1(+n0o|4ArhtEV*TpJ1Mc7i%{I%g4PQrj+d2JUr_SUt|r={Ue$C80(ww}H`! zc@aCFr&;ZeCrot%*e$52snG_m8i7sCF|jq+t$B3ZYwGBnMdTExY#lkBvxt(#J;zl# z$E+UbXmp%qx|!+lqy?Gkb)MMbbm4&A#cmCe&+BDUwKAX-i_{{T3txnu3>b)N8n)J(& zsxxBXr5O;Yx4Cn2=A$-#Bxc4HHw%yPhMSanL_Gfh&9nc+04opy0RRF40|WvC00II7 z000015g{=E5J6EOVR3 z1%kuJCe@b)aw76~Fx+*=_)W>ev;;elHxU<|?p~uIPcpds;9+Z;C?!;dlCK>mZ}#z( z@|Rsb1&{0~Kz)XKrkiR4JvlN!z|`3EJ9{+`B`Ql~jh2J^*!!_Vf(_FRT9H7E@>Gu! zK2f2RPFbvhk1l?@6sZ6#76->2HFOFq0Q`@~82BQZ zf~LY&6?F0}uR!(>60r_F2I2+xOR0)YyX;qZ^%Nj2S{(rJj!aHSQ^|GE2y;v2PQX&Y z3&H5@gT+;vuW*x%D-JfM5GCXm`WTm~p_EQptbvas56q^z%m5%?E6jqROL~-59dVEN zV&E!DW+U(FbA~WOK)u@G$(A#^J_Y=jept6&sE9imJ=N$cSW6L)v_2Au;S^}_SOdAy z8w&8=M}CBa6d}46i}Ds#;wzHpE`zXuU;r?BIsX84#~PK7m{(aVTe%?oSsqc zm~ot;v_4OAsQ&;Gvs6{J6qs*k5bs3aBM9u)v7LcTD$F_}6Sv;M1AB*|F<;qv=HI|f z{{Uama7OxsN=O50yb6euy(aTPg-ISPp-mCL_T7@NgVp;Qjvq`@yE3VSW1bs=W>v67 z!5;eoxq1L^xPkQ|x~jnT5q*!57<5Gs+14HL$%b-JQZ;s+k5a$bYYXoEv$ptBCe;Rs z70#AMltOS;Uyo068}JVEP9Xrk)!_veJOp}N{{Xt^r%3~V?F9{c`v(N0FU4v1Tldjd zFh**m1BPp8-9Jl0IP$UfmHy(k#QDw49m*ptAQb=5FYpg0QLUIS!nh)`^iJ__ffHu zi42ZJe~&eO*!rxoLTcD}3Zz^!c)g#rM;!M85q>lXxO*0D_JEWf(7QaWm8j;r{>w)6e^^jtebL=OZ^D!MUyW%Rag)>R8Pf z$|wsg9y%;{N_VEBNKnDiqL_UUv*Rv6YTY7xPv@JS`tdpas{a5=KU10$)1kENsuMfk zJJE=P(pCUfUp-S5{g<9?`~=VS`kWzJO0Xis*yBGD5OwmlTBTl2Zs~YnV(ud6R)W62 z*v~lq*C>EMgUTqTZmcI0S_CAO^UYtjKA3XogfzVY%;Jsd0YSfkpf<3$r^Y!^H7a6} z&|cpM+pvT{CPL26_j&Qqsb`?}U0@kiKn^Cd#nM6wHU4`KXD%}`<=2oq#oSWe*MJy`}C zzSAfD9;;fD75$fix0wj56XX>DdCe;kDzZ)`froa4^u~)L zn7}SbSltBg=y=)< zwF4p`iIF&JdKWPa5~W}%0`T5VnE4;V9tnlNe+CT*kOT)2psJ{ly9_zM6?g*m)zXUNeZJ_`Pt`W+ah zByDC5SRH|x4kT!(pNLPO@%!~VdtwuqAOePufu$|700cWIK0}-B+!AEucXD?<3!rzf4jqFQ<5@b9H$P>6 z5`IVi#mXSzl~NLlQ65`1g~y*_RG81VP2#P9k7oC-vCU-k%PxgRSA2r{P#~)q1VLhg z|Ri@2DsJS1-=eI==)*K^apaSMc|YsbuQ5m(T&ao zSspWl{82_%CV`)JY78f^h=?KrKnl16zcjYJUV}nUa4iYJV{|(OpXdnl#^$#x7L}0I zt0|6*by@!aDMj6~Vpp4p7;yzgQ4#}Z$yuB*~rE~66SLqzk zV!qNL_VTsJR>-AwDIQHHBA9uh5|mE}qC`<12KzKgfCg22alH#$cBW+z(ForFCx7*W z{{ZRWbfv0*$+e-ElIQ++J>}MQbq~1dIlsy)mFUIe~S72hK>gsRL6(06GfXNH#Nwpjea@ z1DnJ6j%s*o7cB+L9Ccxvt`SJ6BIMm>2M2Tt!6rfPz;hJKFaecZL!HoEo$b(%e z5UCQ;69$T|GQORfvB*$wJ+5=y4W}Jy<}0YWw1YwF;;Abet5CZ&i@h;g-5rEhL1jV$ zk1*lS^FW#+WJkX!2-Ahb!44hTjPzkv&nPQ;4O0fe8di00W%rSO7v01{Pnt#JdxM0x z!w8Bp2}s6?T&^L|gMS(TtnPe3G#sD+Cj@HcOa1Qo`d>b{*%X_*U_D5mg&Q|3PCpc7 zkVSky(>U5ykT(Y2%pYaqk0^`ZHwgz+n;xqF0HO2s$CzH~Qh`Inf>Xc0hUkGof$0uo z73)VKoSU-4UH<^M=Jeo!0{2Ds@n~3X5&r;pS(y-$lZEVtUp#ydBvy!IL=iaZ1b!)H zVvY&66?lXUrU#VT4Jd$AIPC|p(6hk`sgy*q5~{9M@6OpTK{-_eVRaz0hF;EtB0hVR zB+#Gx zzkJGaf1mZbCR`eOC!XGk!VtfXm|FFwfcP{R(Qt^W#_toryk;#dnAX@e);-eKA71Qiv@HH7J&F4jz8n0-vmF0 z1To89WGq)D2PYB{=4v30DN!>oemw`0Ye2||B5>J`7bPdT5zKZ8Z;#PTX2nnyLXHA4 z1{!I_QHYFlk41mb`TFC`FJ;+{V0-`(@VWLP_&|9hZw%`Gz)bo2M+X}bvl1TcPa(i9 zVNgt8qk{REc7oq({O~K>%qkZtkk&JGG=K~Qt9N33`L}?i3h?g&`7cPGrnU+W?=@#2 zE=W;Si8v4*?8z%!Owy7qjhf6$M@3@ za|iW?DgjklmEqwO4n2pTV4XI^HQX>hzXvgY`F$e>_ug@^u(fi(+bsI$>3sTQL{cgg zRWn#o3pLA)*0wm{STk6 zJi_5x4gzS2q=w@<8=-W1F#}l)=&4zBW#U~ z>{MRr_vJ^vM>oX-@gv=3V3I>v6l-bCU;a%#asuY;e0v0_ClojrQFK^ zy#Tv173#JB03dOOqo4yocLVjyM1pBPK*ji6Xa*?8nI$3%1$5*eszJ&^*z-BwV zQ2w9E=x?~N%Snr{FK$HT*`&^)r6FM`WI`qAkHqV!231f4d^vRhrZx`fq-}-tSN#v4 zP>e$mC<3!XaCC)i$O6ZM27TI#iqxE?zXPIybzmDyZAI|UV4@~Ui`dzZep^=Z>j0@S zjHT$fJYWL9kiO3l5u-F2G!A@kfZ0Pu5`*7I!-nA3+f9*tHp1WznQnu@Olz#F})?l1Qc|N{9s};qnug?gaYj{m)%+nBQU*1qF&!`8bgB zjYtY|#Uvd0D=Zh43!FG~}PhhiyVjuWyXB8ZA`?W-eQ;qF6Vo4MpN z3K+P$oSA4ap>Tr^W5L6H0hHM4%91p6KRh1B$VL%B0)m&j=*}pCCL0oIAt=>?oY`wc z0B~TQ*k*5WB_IhKDlj3@aKzwI7L|wy#SzaL+?ZR*TWLn@d9T4Qkly_n9BFt`Za}qV z#Z7@(fB@k>9hk?-#^*bB>5FIN3E8nw6FAQ@Yw!rL zy*87-M;sR$$q1Ry-Z;D?%)fZ{97rGm0iK|9l9Cxf;hNf*@LO0Qfg$IN^#$*OMVX-K z#iJPq7XHaqS_*QZwa$5*Vi7dK3s6Tzk5LN@36oYD*X*2__g&Z;RA`gAuCBx(+A~Z8 z<#)r9L&0|r#ayS^o=iiD{R7(>5$5_?!c0RjXXOVINm zfkLqxGbJ*IC&Q847Q5Jlb2jm__J_bdkkNtOxhA>_fdNV7Kk<-JOQ_g)3)~7j