Remove unused files and replay system
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
c0b9f772ee
commit
71ec1f162c
121
CLAUDE.md
121
CLAUDE.md
@ -24,122 +24,17 @@ npm run speech
|
|||||||
|
|
||||||
**Note**: Do not run `npm run dev` per global user instructions.
|
**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
|
## Important Implementation Notes
|
||||||
|
|
||||||
- Never modify git config or use force push operations
|
- 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
|
- TypeScript target is ES6 with ESNext modules
|
||||||
- Vite handles bundling and dev server (though dev mode is disabled per user preference)
|
- 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)
|
- 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
|
- 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.
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import Level from "../../levels/level";
|
|||||||
import { RockFactory } from "../../environment/asteroids/rockFactory";
|
import { RockFactory } from "../../environment/asteroids/rockFactory";
|
||||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||||
import { Preloader } from "../../ui/screens/preloader";
|
import { Preloader } from "../../ui/screens/preloader";
|
||||||
import { DiscordWidget } from "../../ui/widgets/discordWidget";
|
|
||||||
import debugLog from '../debug';
|
import debugLog from '../debug';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,13 +48,6 @@ export function createLevelSelectedHandler(context: LevelSelectedContext): (e: C
|
|||||||
appHeader.style.display = 'none';
|
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
|
// Show preloader for initialization
|
||||||
const preloader = new Preloader();
|
const preloader = new Preloader();
|
||||||
context.setProgressCallback((percent, message) => {
|
context.setProgressCallback((percent, message) => {
|
||||||
|
|||||||
@ -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<void>;
|
|
||||||
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<void> {
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ import {LevelConfig} from "./config/levelConfig";
|
|||||||
import {LevelDeserializer} from "./config/levelDeserializer";
|
import {LevelDeserializer} from "./config/levelDeserializer";
|
||||||
import {BackgroundStars} from "../environment/background/backgroundStars";
|
import {BackgroundStars} from "../environment/background/backgroundStars";
|
||||||
import debugLog from '../core/debug';
|
import debugLog from '../core/debug';
|
||||||
import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
|
|
||||||
import {getAnalytics} from "../analytics";
|
import {getAnalytics} from "../analytics";
|
||||||
import {MissionBrief} from "../ui/hud/missionBrief";
|
import {MissionBrief} from "../ui/hud/missionBrief";
|
||||||
import {LevelRegistry} from "./storage/levelRegistry";
|
import {LevelRegistry} from "./storage/levelRegistry";
|
||||||
@ -34,7 +33,6 @@ export class Level1 implements Level {
|
|||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
private _deserializer: LevelDeserializer;
|
private _deserializer: LevelDeserializer;
|
||||||
private _backgroundStars: BackgroundStars;
|
private _backgroundStars: BackgroundStars;
|
||||||
private _physicsRecorder: PhysicsRecorder | null = null;
|
|
||||||
private _isReplayMode: boolean;
|
private _isReplayMode: boolean;
|
||||||
private _backgroundMusic: StaticSound;
|
private _backgroundMusic: StaticSound;
|
||||||
private _missionBrief: MissionBrief;
|
private _missionBrief: MissionBrief;
|
||||||
@ -252,12 +250,6 @@ export class Level1 implements Level {
|
|||||||
// Start game timer
|
// Start game timer
|
||||||
this._ship.gameStats.startTimer();
|
this._ship.gameStats.startTimer();
|
||||||
debugLog('Game timer started');
|
debugLog('Game timer started');
|
||||||
|
|
||||||
// Start physics recording
|
|
||||||
if (this._physicsRecorder) {
|
|
||||||
this._physicsRecorder.startRingBuffer();
|
|
||||||
debugLog('Physics recorder started');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
@ -337,9 +329,6 @@ export class Level1 implements Level {
|
|||||||
if (this._backgroundStars) {
|
if (this._backgroundStars) {
|
||||||
this._backgroundStars.dispose();
|
this._backgroundStars.dispose();
|
||||||
}
|
}
|
||||||
if (this._physicsRecorder) {
|
|
||||||
this._physicsRecorder.dispose();
|
|
||||||
}
|
|
||||||
if (this._missionBrief) {
|
if (this._missionBrief) {
|
||||||
this._missionBrief.dispose();
|
this._missionBrief.dispose();
|
||||||
}
|
}
|
||||||
@ -488,11 +477,4 @@ export class Level1 implements Level {
|
|||||||
|
|
||||||
return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes
|
return difficultyMap[difficulty.toLowerCase()] || 120; // Default to 2 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the physics recorder instance
|
|
||||||
*/
|
|
||||||
public get physicsRecorder(): PhysicsRecorder {
|
|
||||||
return this._physicsRecorder;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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<Level> = new Observable<Level>();
|
|
||||||
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<Level> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
src/main.ts
21
src/main.ts
@ -15,14 +15,11 @@ import HavokPhysics from "@babylonjs/havok";
|
|||||||
import { DefaultScene } from "./core/defaultScene";
|
import { DefaultScene } from "./core/defaultScene";
|
||||||
import Level from "./levels/level";
|
import Level from "./levels/level";
|
||||||
import { RockFactory } from "./environment/asteroids/rockFactory";
|
import { RockFactory } from "./environment/asteroids/rockFactory";
|
||||||
import { DiscordWidget } from "./ui/widgets/discordWidget";
|
|
||||||
import debugLog from './core/debug';
|
import debugLog from './core/debug';
|
||||||
import { ReplayManager } from "./replay/ReplayManager";
|
|
||||||
import { InputControlManager } from './ship/input/inputControlManager';
|
import { InputControlManager } from './ship/input/inputControlManager';
|
||||||
|
|
||||||
import { initializeAnalytics } from './analytics/initAnalytics';
|
import { initializeAnalytics } from './analytics/initAnalytics';
|
||||||
import { createLevelSelectedHandler, LevelSelectedContext } from './core/handlers/levelSelectedHandler';
|
import { createLevelSelectedHandler, LevelSelectedContext } from './core/handlers/levelSelectedHandler';
|
||||||
import { createViewReplaysHandler, ViewReplaysContext } from './core/handlers/viewReplaysHandler';
|
|
||||||
import { initializeApp, setupErrorHandler } from './core/appInitializer';
|
import { initializeApp, setupErrorHandler } from './core/appInitializer';
|
||||||
|
|
||||||
// Initialize analytics
|
// Initialize analytics
|
||||||
@ -38,12 +35,11 @@ enum GameState {
|
|||||||
DEMO
|
DEMO
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Main implements LevelSelectedContext, ViewReplaysContext {
|
export class Main implements LevelSelectedContext {
|
||||||
private _currentLevel: Level | null = null;
|
private _currentLevel: Level | null = null;
|
||||||
private _gameState: GameState = GameState.DEMO;
|
private _gameState: GameState = GameState.DEMO;
|
||||||
private _engine: Engine;
|
private _engine: Engine;
|
||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
private _replayManager: ReplayManager | null = null;
|
|
||||||
private _initialized: boolean = false;
|
private _initialized: boolean = false;
|
||||||
private _assetsLoaded: boolean = false;
|
private _assetsLoaded: boolean = false;
|
||||||
private _started: boolean = false;
|
private _started: boolean = false;
|
||||||
@ -58,11 +54,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext {
|
|||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
const levelSelect = document.querySelector('#levelSelect');
|
const levelSelect = document.querySelector('#levelSelect');
|
||||||
if (levelSelect) levelSelect.classList.add('ready');
|
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;
|
this._progressCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewReplaysContext interface implementation
|
|
||||||
getReplayManager(): ReplayManager | null { return this._replayManager; }
|
|
||||||
setReplayManager(manager: ReplayManager): void { this._replayManager = manager; }
|
|
||||||
|
|
||||||
public async initializeEngine(): Promise<void> {
|
public async initializeEngine(): Promise<void> {
|
||||||
if (this._initialized) return;
|
if (this._initialized) return;
|
||||||
debugLog('[Main] Starting engine initialization');
|
debugLog('[Main] Starting engine initialization');
|
||||||
@ -125,9 +112,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext {
|
|||||||
|
|
||||||
const gl = canvas?.getContext('webgl2') || canvas?.getContext('webgl');
|
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); }
|
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) {
|
} catch (error) {
|
||||||
console.error('[Main] Cleanup failed:', error);
|
console.error('[Main] Cleanup failed:', error);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@ -155,9 +139,6 @@ export class Main implements LevelSelectedContext, ViewReplaysContext {
|
|||||||
if (state === 2) {
|
if (state === 2) {
|
||||||
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager.getEnabledFeature("xr-controller-pointer-selection");
|
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager.getEnabledFeature("xr-controller-pointer-selection");
|
||||||
if (pointerFeature) InputControlManager.getInstance().registerPointerFeature(pointerFeature);
|
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');
|
this.reportProgress(40, 'VR support enabled');
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
// 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<void> {
|
|
||||||
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<void> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<string, AbstractMesh> = new Map();
|
|
||||||
|
|
||||||
// Playback state
|
|
||||||
private _currentFrameIndex: number = 0;
|
|
||||||
private _isPlaying: boolean = false;
|
|
||||||
private _playbackSpeed: number = 1.0;
|
|
||||||
|
|
||||||
// Timing (timestamp-based, not Hz-based)
|
|
||||||
private _playbackStartTime: number = 0; // Real-world time when playback started
|
|
||||||
private _recordingStartTimestamp: number = 0; // First snapshot's timestamp
|
|
||||||
private _lastUpdateTime: number = 0;
|
|
||||||
|
|
||||||
// Observables
|
|
||||||
public onPlayStateChanged: Observable<boolean> = new Observable<boolean>();
|
|
||||||
public onFrameChanged: Observable<number> = new Observable<number>();
|
|
||||||
|
|
||||||
constructor(scene: Scene, recording: PhysicsRecording) {
|
|
||||||
this._scene = scene;
|
|
||||||
this._recording = recording;
|
|
||||||
|
|
||||||
// Store first snapshot's timestamp as our recording start reference
|
|
||||||
if (recording.snapshots.length > 0) {
|
|
||||||
this._recordingStartTimestamp = recording.snapshots[0].timestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize replay by finding existing meshes in the scene
|
|
||||||
* (Level1.initialize() has already created all objects)
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
if (this._recording.snapshots.length === 0) {
|
|
||||||
debugLog("ReplayPlayer: No snapshots in recording");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstSnapshot = this._recording.snapshots[0];
|
|
||||||
debugLog(`ReplayPlayer: Initializing replay for ${firstSnapshot.objects.length} objects`);
|
|
||||||
debugLog(`ReplayPlayer: Object IDs in snapshot: ${firstSnapshot.objects.map(o => o.id).join(', ')}`);
|
|
||||||
|
|
||||||
// Find all existing meshes in the scene (already created by Level1.initialize())
|
|
||||||
for (const objState of firstSnapshot.objects) {
|
|
||||||
const mesh = this._scene.getMeshByName(objState.id) as AbstractMesh;
|
|
||||||
|
|
||||||
if (mesh) {
|
|
||||||
this._replayObjects.set(objState.id, mesh);
|
|
||||||
debugLog(`ReplayPlayer: Found ${objState.id} in scene (physics: ${!!mesh.physicsBody})`);
|
|
||||||
} else {
|
|
||||||
debugLog(`ReplayPlayer: WARNING - Object ${objState.id} not found in scene`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply first frame state
|
|
||||||
this.applySnapshot(firstSnapshot);
|
|
||||||
|
|
||||||
debugLog(`ReplayPlayer: Initialized with ${this._replayObjects.size} objects`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start playback
|
|
||||||
*/
|
|
||||||
public play(): void {
|
|
||||||
if (this._isPlaying) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isPlaying = true;
|
|
||||||
this._playbackStartTime = performance.now();
|
|
||||||
this._lastUpdateTime = this._playbackStartTime;
|
|
||||||
this.onPlayStateChanged.notifyObservers(true);
|
|
||||||
|
|
||||||
// Use scene.onBeforeRenderObservable for smooth updates
|
|
||||||
this._scene.onBeforeRenderObservable.add(this.updateCallback);
|
|
||||||
|
|
||||||
debugLog("ReplayPlayer: Playback started (timestamp-based)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback
|
|
||||||
*/
|
|
||||||
public pause(): void {
|
|
||||||
if (!this._isPlaying) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isPlaying = false;
|
|
||||||
this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback);
|
|
||||||
this.onPlayStateChanged.notifyObservers(false);
|
|
||||||
|
|
||||||
debugLog("ReplayPlayer: Playback paused");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle play/pause
|
|
||||||
*/
|
|
||||||
public togglePlayPause(): void {
|
|
||||||
if (this._isPlaying) {
|
|
||||||
this.pause();
|
|
||||||
} else {
|
|
||||||
this.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update callback for render loop (timestamp-based)
|
|
||||||
*/
|
|
||||||
private updateCallback = (): void => {
|
|
||||||
if (!this._isPlaying || this._recording.snapshots.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
|
|
||||||
// Calculate elapsed playback time (with speed multiplier)
|
|
||||||
const elapsedPlaybackTime = (now - this._playbackStartTime) * this._playbackSpeed;
|
|
||||||
|
|
||||||
// Calculate target recording timestamp
|
|
||||||
const targetTimestamp = this._recordingStartTimestamp + elapsedPlaybackTime;
|
|
||||||
|
|
||||||
// Find the correct frame for this timestamp
|
|
||||||
let targetFrameIndex = this._currentFrameIndex;
|
|
||||||
|
|
||||||
// Advance to the frame that matches our target timestamp
|
|
||||||
while (targetFrameIndex < this._recording.snapshots.length - 1 &&
|
|
||||||
this._recording.snapshots[targetFrameIndex + 1].timestamp <= targetTimestamp) {
|
|
||||||
targetFrameIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we advanced frames, update and notify
|
|
||||||
if (targetFrameIndex !== this._currentFrameIndex) {
|
|
||||||
this._currentFrameIndex = targetFrameIndex;
|
|
||||||
|
|
||||||
// Debug: Log frame advancement every 10 frames
|
|
||||||
if (this._currentFrameIndex % 10 === 0) {
|
|
||||||
const snapshot = this._recording.snapshots[this._currentFrameIndex];
|
|
||||||
debugLog(`ReplayPlayer: Frame ${this._currentFrameIndex}/${this._recording.snapshots.length}, timestamp: ${snapshot.timestamp.toFixed(1)}ms, objects: ${snapshot.objects.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.applySnapshot(this._recording.snapshots[this._currentFrameIndex]);
|
|
||||||
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we reached the end
|
|
||||||
if (this._currentFrameIndex >= this._recording.snapshots.length - 1 &&
|
|
||||||
targetTimestamp >= this._recording.snapshots[this._recording.snapshots.length - 1].timestamp) {
|
|
||||||
this.pause();
|
|
||||||
debugLog("ReplayPlayer: Reached end of recording");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate between current and next frame for smooth visuals
|
|
||||||
if (this._currentFrameIndex < this._recording.snapshots.length - 1) {
|
|
||||||
const currentSnapshot = this._recording.snapshots[this._currentFrameIndex];
|
|
||||||
const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1];
|
|
||||||
|
|
||||||
const frameDuration = nextSnapshot.timestamp - currentSnapshot.timestamp;
|
|
||||||
const frameElapsed = targetTimestamp - currentSnapshot.timestamp;
|
|
||||||
const alpha = frameDuration > 0 ? Math.min(frameElapsed / frameDuration, 1.0) : 0;
|
|
||||||
|
|
||||||
this.interpolateFrame(alpha);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a snapshot's state to all objects
|
|
||||||
*/
|
|
||||||
private applySnapshot(snapshot: PhysicsSnapshot): void {
|
|
||||||
for (const objState of snapshot.objects) {
|
|
||||||
const mesh = this._replayObjects.get(objState.id);
|
|
||||||
if (!mesh) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPosition = new Vector3(
|
|
||||||
objState.position[0],
|
|
||||||
objState.position[1],
|
|
||||||
objState.position[2]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newRotation = new Quaternion(
|
|
||||||
objState.rotation[0],
|
|
||||||
objState.rotation[1],
|
|
||||||
objState.rotation[2],
|
|
||||||
objState.rotation[3]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update mesh transform directly
|
|
||||||
mesh.position.copyFrom(newPosition);
|
|
||||||
if (!mesh.rotationQuaternion) {
|
|
||||||
mesh.rotationQuaternion = new Quaternion();
|
|
||||||
}
|
|
||||||
mesh.rotationQuaternion.copyFrom(newRotation);
|
|
||||||
|
|
||||||
// For ANIMATED bodies, sync physics from mesh
|
|
||||||
// (ANIMATED bodies should follow their transform node)
|
|
||||||
if (mesh.physicsBody) {
|
|
||||||
mesh.physicsBody.disablePreStep = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interpolate between current and next frame for smooth visuals
|
|
||||||
*/
|
|
||||||
private interpolateFrame(alpha: number): void {
|
|
||||||
if (this._currentFrameIndex + 1 >= this._recording.snapshots.length) {
|
|
||||||
return; // No next frame
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSnapshot = this._recording.snapshots[this._currentFrameIndex];
|
|
||||||
const nextSnapshot = this._recording.snapshots[this._currentFrameIndex + 1];
|
|
||||||
|
|
||||||
for (const objState of currentSnapshot.objects) {
|
|
||||||
const mesh = this._replayObjects.get(objState.id);
|
|
||||||
if (!mesh) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextState = nextSnapshot.objects.find(o => o.id === objState.id);
|
|
||||||
if (!nextState) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary vectors for interpolation
|
|
||||||
const interpPosition = new Vector3();
|
|
||||||
const interpRotation = new Quaternion();
|
|
||||||
|
|
||||||
// Lerp position
|
|
||||||
Vector3.LerpToRef(
|
|
||||||
new Vector3(...objState.position),
|
|
||||||
new Vector3(...nextState.position),
|
|
||||||
alpha,
|
|
||||||
interpPosition
|
|
||||||
);
|
|
||||||
|
|
||||||
// Slerp rotation
|
|
||||||
Quaternion.SlerpToRef(
|
|
||||||
new Quaternion(...objState.rotation),
|
|
||||||
new Quaternion(...nextState.rotation),
|
|
||||||
alpha,
|
|
||||||
interpRotation
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply interpolated transform to mesh
|
|
||||||
mesh.position.copyFrom(interpPosition);
|
|
||||||
if (!mesh.rotationQuaternion) {
|
|
||||||
mesh.rotationQuaternion = new Quaternion();
|
|
||||||
}
|
|
||||||
mesh.rotationQuaternion.copyFrom(interpRotation);
|
|
||||||
|
|
||||||
// Physics body will sync from mesh if ANIMATED
|
|
||||||
if (mesh.physicsBody) {
|
|
||||||
mesh.physicsBody.disablePreStep = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scrub to specific frame
|
|
||||||
*/
|
|
||||||
public scrubTo(frameIndex: number): void {
|
|
||||||
this._currentFrameIndex = Math.max(0, Math.min(frameIndex, this._recording.snapshots.length - 1));
|
|
||||||
const snapshot = this._recording.snapshots[this._currentFrameIndex];
|
|
||||||
this.applySnapshot(snapshot);
|
|
||||||
|
|
||||||
// Reset playback timing to match the new frame's timestamp
|
|
||||||
if (this._isPlaying) {
|
|
||||||
const targetTimestamp = snapshot.timestamp;
|
|
||||||
const elapsedRecordingTime = targetTimestamp - this._recordingStartTimestamp;
|
|
||||||
this._playbackStartTime = performance.now() - (elapsedRecordingTime / this._playbackSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onFrameChanged.notifyObservers(this._currentFrameIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step forward one frame
|
|
||||||
*/
|
|
||||||
public stepForward(): void {
|
|
||||||
if (this._currentFrameIndex < this._recording.snapshots.length - 1) {
|
|
||||||
this.scrubTo(this._currentFrameIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step backward one frame
|
|
||||||
*/
|
|
||||||
public stepBackward(): void {
|
|
||||||
if (this._currentFrameIndex > 0) {
|
|
||||||
this.scrubTo(this._currentFrameIndex - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set playback speed multiplier
|
|
||||||
*/
|
|
||||||
public setPlaybackSpeed(speed: number): void {
|
|
||||||
this._playbackSpeed = Math.max(0.1, Math.min(speed, 4.0));
|
|
||||||
debugLog(`ReplayPlayer: Playback speed set to ${this._playbackSpeed}x`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current frame index
|
|
||||||
*/
|
|
||||||
public getCurrentFrame(): number {
|
|
||||||
return this._currentFrameIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total number of frames
|
|
||||||
*/
|
|
||||||
public getTotalFrames(): number {
|
|
||||||
return this._recording.snapshots.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current playback time in seconds
|
|
||||||
*/
|
|
||||||
public getCurrentTime(): number {
|
|
||||||
if (this._recording.snapshots.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return this._recording.snapshots[this._currentFrameIndex].timestamp / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total duration in seconds
|
|
||||||
*/
|
|
||||||
public getTotalDuration(): number {
|
|
||||||
return this._recording.metadata.recordingDuration / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if playing
|
|
||||||
*/
|
|
||||||
public isPlaying(): boolean {
|
|
||||||
return this._isPlaying;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get replay objects map
|
|
||||||
*/
|
|
||||||
public getReplayObjects(): Map<string, AbstractMesh> {
|
|
||||||
return this._replayObjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ship mesh if it exists
|
|
||||||
*/
|
|
||||||
public getShipMesh(): AbstractMesh | null {
|
|
||||||
for (const [id, mesh] of this._replayObjects.entries()) {
|
|
||||||
if (id === "ship" || id.startsWith("shipBase")) {
|
|
||||||
return mesh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose of replay player
|
|
||||||
*/
|
|
||||||
public dispose(): void {
|
|
||||||
this.pause();
|
|
||||||
this._scene.onBeforeRenderObservable.removeCallback(this.updateCallback);
|
|
||||||
this.onPlayStateChanged.clear();
|
|
||||||
this.onFrameChanged.clear();
|
|
||||||
|
|
||||||
// Dispose all replay objects
|
|
||||||
this._replayObjects.forEach((mesh) => {
|
|
||||||
if (mesh.physicsBody) {
|
|
||||||
mesh.physicsBody.dispose();
|
|
||||||
}
|
|
||||||
mesh.dispose();
|
|
||||||
});
|
|
||||||
this._replayObjects.clear();
|
|
||||||
|
|
||||||
debugLog("ReplayPlayer: Disposed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<void> {
|
|
||||||
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<string | null> {
|
|
||||||
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<string | null> {
|
|
||||||
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<PhysicsRecording | null> {
|
|
||||||
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<Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
timestamp: number;
|
|
||||||
duration: number;
|
|
||||||
frameCount: number;
|
|
||||||
}>> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<void> {
|
|
||||||
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<string> {
|
|
||||||
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<PhysicsRecording | null> {
|
|
||||||
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<Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
timestamp: number;
|
|
||||||
duration: number;
|
|
||||||
frameCount: 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;
|
|
||||||
|
|
||||||
// Group by recordingId and aggregate all segments
|
|
||||||
const sessionMap = new Map<string, {
|
|
||||||
segments: any[];
|
|
||||||
metadata: any;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 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<void> {
|
|
||||||
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<void> {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 = `
|
|
||||||
<span class="user-profile-name">
|
|
||||||
Welcome, ${username}
|
|
||||||
</span>
|
|
||||||
<button id="logoutBtn" class="user-profile-button">
|
|
||||||
Log Out
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<button id="loginBtn" class="user-profile-button">
|
|
||||||
Sign Up / Log In
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const loginBtn = document.getElementById('loginBtn');
|
|
||||||
if (loginBtn) {
|
|
||||||
loginBtn.addEventListener('click', async () => {
|
|
||||||
const authService = AuthService.getInstance();
|
|
||||||
await authService.login();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user