Compare commits
No commits in common. "faa5afc604c4a96b1cb3d7e377fad5e31b1a04dc" and "88d380fa3fbae7efcab0e5b5fce89c02e5874d5c" have entirely different histories.
faa5afc604
...
88d380fa3f
@ -19,8 +19,8 @@
|
||||
<!-- Game View -->
|
||||
<div data-view="game">
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<a href="#/editor" class="editor-link" style="display: none;">📝 Level Editor</a>
|
||||
<a href="#/settings" class="settings-link" style="display: none;">⚙️ Settings</a>
|
||||
<a href="#/editor" class="editor-link">📝 Level Editor</a>
|
||||
<a href="#/settings" class="settings-link">⚙️ Settings</a>
|
||||
<div id="mainDiv">
|
||||
<div id="loadingDiv">Loading...</div>
|
||||
<div id="levelSelect">
|
||||
@ -56,13 +56,10 @@
|
||||
<div id="levelCardsContainer" class="card-container">
|
||||
<!-- Level cards will be dynamically populated from localStorage -->
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 20px; display: none;">
|
||||
<div style="text-align: center; margin-top: 20px;">
|
||||
<button id="testLevelBtn" class="test-level-button">
|
||||
🧪 Test Scene (Debug)
|
||||
</button>
|
||||
<button id="viewReplaysBtn" class="test-level-button" style="margin-left: 10px;">
|
||||
📹 View Replays
|
||||
</button>
|
||||
<br>
|
||||
<a href="#/editor" style="color: #4CAF50; text-decoration: none; font-size: 1.1em;">
|
||||
+ Create New Level
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -27,17 +27,14 @@ export class Level1 implements Level {
|
||||
private _deserializer: LevelDeserializer;
|
||||
private _backgroundStars: BackgroundStars;
|
||||
private _physicsRecorder: PhysicsRecorder;
|
||||
private _isReplayMode: boolean;
|
||||
|
||||
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) {
|
||||
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) {
|
||||
this._levelConfig = levelConfig;
|
||||
this._audioEngine = audioEngine;
|
||||
this._isReplayMode = isReplayMode;
|
||||
this._deserializer = new LevelDeserializer(levelConfig);
|
||||
this._ship = new Ship(audioEngine, isReplayMode);
|
||||
this._ship = new Ship(audioEngine);
|
||||
|
||||
|
||||
// Only set up XR observables in game mode (not replay mode)
|
||||
if (!isReplayMode && DefaultScene.XR) {
|
||||
const xr = DefaultScene.XR;
|
||||
|
||||
debugLog('Level1 constructor - Setting up XR observables');
|
||||
@ -64,7 +61,6 @@ export class Level1 implements Level {
|
||||
this._ship.addController(controller);
|
||||
});
|
||||
});
|
||||
}
|
||||
// Don't call initialize here - let Main call it after registering the observable
|
||||
}
|
||||
|
||||
@ -73,23 +69,17 @@ export class Level1 implements Level {
|
||||
}
|
||||
|
||||
public async play() {
|
||||
if (this._isReplayMode) {
|
||||
throw new Error("Cannot call play() in replay mode");
|
||||
}
|
||||
|
||||
// Create background music using AudioEngineV2
|
||||
if (this._audioEngine) {
|
||||
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
||||
loop: true,
|
||||
volume: 0.5
|
||||
});
|
||||
background.play();
|
||||
}
|
||||
|
||||
// If XR is available and session is active, check for controllers
|
||||
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 4) { // State 4 = IN_XR
|
||||
// XR session already active, just check for controllers
|
||||
debugLog('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length);
|
||||
// Enter XR mode
|
||||
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
// Check for controllers that are already connected after entering XR
|
||||
debugLog('Checking for controllers after entering XR. Count:', DefaultScene.XR.input.controllers.length);
|
||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
||||
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
||||
this._ship.addController(controller);
|
||||
@ -103,38 +93,6 @@ export class Level1 implements Level {
|
||||
debugLog(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
||||
});
|
||||
}, 2000);
|
||||
} else if (DefaultScene.XR) {
|
||||
// XR available but not entered yet, try to enter
|
||||
try {
|
||||
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('Entered XR mode from play()');
|
||||
// Check for controllers
|
||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
||||
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
||||
this._ship.addController(controller);
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog('Failed to enter XR from play(), falling back to flat mode:', error);
|
||||
// Start flat mode
|
||||
this._ship.gameStats.startTimer();
|
||||
debugLog('Game timer started (flat mode)');
|
||||
|
||||
if (this._physicsRecorder) {
|
||||
this._physicsRecorder.startRingBuffer();
|
||||
debugLog('Physics recorder started (flat mode)');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flat camera mode - start game timer and physics recording immediately
|
||||
debugLog('Playing in flat camera mode (no XR)');
|
||||
this._ship.gameStats.startTimer();
|
||||
debugLog('Game timer started');
|
||||
|
||||
if (this._physicsRecorder) {
|
||||
this._physicsRecorder.startRingBuffer();
|
||||
debugLog('Physics recorder started');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
@ -176,6 +134,8 @@ export class Level1 implements Level {
|
||||
this._ship.scoreboard.setRemainingCount(entities.asteroids.length);
|
||||
debugLog(`Initialized scoreboard with ${entities.asteroids.length} asteroids`);
|
||||
|
||||
|
||||
|
||||
// Create background starfield
|
||||
setLoadingMessage("Creating starfield...");
|
||||
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
|
||||
@ -188,28 +148,20 @@ export class Level1 implements Level {
|
||||
|
||||
// Set up camera follow for stars (keeps stars at infinite distance)
|
||||
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
||||
if (this._backgroundStars) {
|
||||
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||
if (camera) {
|
||||
this._backgroundStars.followCamera(camera.position);
|
||||
}
|
||||
if (this._backgroundStars && DefaultScene.XR.baseExperience.camera) {
|
||||
this._backgroundStars.followCamera(DefaultScene.XR.baseExperience.camera.position);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize physics recorder (but don't start it yet - will start on XR pose)
|
||||
// Only create recorder in game mode, not replay mode
|
||||
if (!this._isReplayMode) {
|
||||
setLoadingMessage("Initializing physics recorder...");
|
||||
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
|
||||
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene);
|
||||
debugLog('Physics recorder initialized (will start on XR pose)');
|
||||
}
|
||||
|
||||
// Wire up recording keyboard shortcuts (only in game mode)
|
||||
if (!this._isReplayMode) {
|
||||
// Wire up recording keyboard shortcuts
|
||||
this._ship.keyboardInput.onRecordingActionObservable.add((action) => {
|
||||
this.handleRecordingAction(action);
|
||||
});
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
|
||||
@ -254,7 +206,6 @@ export class Level1 implements Level {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the physics recorder instance
|
||||
*/
|
||||
|
||||
@ -7,58 +7,6 @@
|
||||
*/
|
||||
export type Vector3Array = [number, number, number];
|
||||
|
||||
/**
|
||||
* 4D quaternion stored as array [x, y, z, w]
|
||||
*/
|
||||
export type QuaternionArray = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* 4D color stored as array [r, g, b, a] (0-1 range)
|
||||
*/
|
||||
export type Color4Array = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* Material configuration for PBR materials
|
||||
*/
|
||||
export interface MaterialConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "PBR" | "Standard" | "Basic";
|
||||
albedoColor?: Color4Array;
|
||||
metallic?: number;
|
||||
roughness?: number;
|
||||
emissiveColor?: Vector3Array;
|
||||
emissiveIntensity?: number;
|
||||
alpha?: number;
|
||||
backFaceCulling?: boolean;
|
||||
textures?: {
|
||||
albedo?: string; // Asset reference or data URL
|
||||
normal?: string;
|
||||
metallic?: string;
|
||||
roughness?: string;
|
||||
emissive?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene hierarchy node (TransformNode or Mesh)
|
||||
*/
|
||||
export interface SceneNodeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "TransformNode" | "Mesh" | "InstancedMesh";
|
||||
position: Vector3Array;
|
||||
rotation?: Vector3Array;
|
||||
rotationQuaternion?: QuaternionArray;
|
||||
scaling?: Vector3Array;
|
||||
parentId?: string; // Reference to parent node
|
||||
materialId?: string; // Reference to material
|
||||
assetReference?: string; // For meshes loaded from GLB
|
||||
isVisible?: boolean;
|
||||
isEnabled?: boolean;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship configuration
|
||||
*/
|
||||
@ -134,8 +82,6 @@ export interface LevelConfig {
|
||||
metadata?: {
|
||||
author?: string;
|
||||
description?: string;
|
||||
babylonVersion?: string;
|
||||
captureTime?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
@ -147,11 +93,6 @@ export interface LevelConfig {
|
||||
|
||||
// Optional: include original difficulty config for reference
|
||||
difficultyConfig?: DifficultyConfig;
|
||||
|
||||
// New fields for full scene serialization
|
||||
materials?: MaterialConfig[];
|
||||
sceneHierarchy?: SceneNodeConfig[];
|
||||
assetReferences?: { [key: string]: string }; // mesh id -> asset path (e.g., "ship" -> "ship.glb")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
Color3,
|
||||
AbstractMesh, Color3,
|
||||
MeshBuilder,
|
||||
Observable,
|
||||
PBRMaterial,
|
||||
PhysicsAggregate,
|
||||
Texture,
|
||||
Vector3,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import { DefaultScene } from "./defaultScene";
|
||||
import { RockFactory } from "./rockFactory";
|
||||
@ -17,6 +16,7 @@ import {
|
||||
Vector3Array,
|
||||
validateLevelConfig
|
||||
} from "./levelConfig";
|
||||
import { GameConfig } from "./gameConfig";
|
||||
import { FireProceduralTexture } from "@babylonjs/procedural-textures";
|
||||
import { createSphereLightmap } from "./sphereLightmap";
|
||||
import debugLog from './debug';
|
||||
@ -41,11 +41,8 @@ export class LevelDeserializer {
|
||||
|
||||
/**
|
||||
* Create all entities from the configuration
|
||||
* @param scoreObservable - Observable for score events
|
||||
*/
|
||||
public async deserialize(
|
||||
scoreObservable: Observable<ScoreEvent>
|
||||
): Promise<{
|
||||
public async deserialize(scoreObservable: Observable<ScoreEvent>): Promise<{
|
||||
startBase: AbstractMesh | null;
|
||||
landingAggregate: PhysicsAggregate | null;
|
||||
sun: AbstractMesh;
|
||||
@ -54,11 +51,19 @@ export class LevelDeserializer {
|
||||
}> {
|
||||
debugLog('Deserializing level:', this.config.difficulty);
|
||||
|
||||
// Create entities
|
||||
const baseResult = await this.createStartBase();
|
||||
const sun = this.createSun();
|
||||
const planets = this.createPlanets();
|
||||
const asteroids = await this.createAsteroids(scoreObservable);
|
||||
|
||||
/*
|
||||
const dir = new Vector3(-1,-2,-1)
|
||||
|
||||
const light = new DirectionalLight("dirLight", dir, DefaultScene.MainScene);
|
||||
const light2 = new DirectionalLight("dirLight2", dir.negate(), DefaultScene.MainScene);
|
||||
light2.intensity = .5;
|
||||
*/
|
||||
return {
|
||||
startBase: baseResult.baseMesh,
|
||||
landingAggregate: baseResult.landingAggregate,
|
||||
|
||||
@ -16,15 +16,7 @@ export function populateLevelSelector(): boolean {
|
||||
|
||||
const savedLevels = getSavedLevels();
|
||||
|
||||
// Filter to only show recruit and pilot difficulty levels
|
||||
const filteredLevels = new Map<string, LevelConfig>();
|
||||
for (const [name, config] of savedLevels.entries()) {
|
||||
if (config.difficulty === 'recruit' || config.difficulty === 'pilot') {
|
||||
filteredLevels.set(name, config);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredLevels.size === 0) {
|
||||
if (savedLevels.size === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="
|
||||
grid-column: 1 / -1;
|
||||
@ -51,7 +43,7 @@ export function populateLevelSelector(): boolean {
|
||||
|
||||
// Create level cards
|
||||
let html = '';
|
||||
for (const [name, config] of filteredLevels.entries()) {
|
||||
for (const [name, config] of savedLevels.entries()) {
|
||||
const timestamp = config.timestamp ? new Date(config.timestamp).toLocaleDateString() : '';
|
||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Vector3, Quaternion, Material, PBRMaterial, StandardMaterial, AbstractMesh, TransformNode } from "@babylonjs/core";
|
||||
import { Vector3 } from "@babylonjs/core";
|
||||
import { DefaultScene } from "./defaultScene";
|
||||
import {
|
||||
LevelConfig,
|
||||
@ -7,11 +7,7 @@ import {
|
||||
SunConfig,
|
||||
PlanetConfig,
|
||||
AsteroidConfig,
|
||||
Vector3Array,
|
||||
QuaternionArray,
|
||||
Color4Array,
|
||||
MaterialConfig,
|
||||
SceneNodeConfig
|
||||
Vector3Array
|
||||
} from "./levelConfig";
|
||||
import debugLog from './debug';
|
||||
|
||||
@ -23,25 +19,21 @@ export class LevelSerializer {
|
||||
|
||||
/**
|
||||
* Serialize the current level state to a LevelConfig object
|
||||
* @param difficulty - Difficulty level string
|
||||
* @param includeFullScene - If true, serialize complete scene (materials, hierarchy, assets)
|
||||
*/
|
||||
public serialize(difficulty: string = 'custom', includeFullScene: boolean = true): LevelConfig {
|
||||
public serialize(difficulty: string = 'custom'): LevelConfig {
|
||||
const ship = this.serializeShip();
|
||||
const startBase = this.serializeStartBase();
|
||||
const sun = this.serializeSun();
|
||||
const planets = this.serializePlanets();
|
||||
const asteroids = this.serializeAsteroids();
|
||||
|
||||
const config: LevelConfig = {
|
||||
return {
|
||||
version: "1.0",
|
||||
difficulty,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
generator: "LevelSerializer",
|
||||
description: `Captured level state at ${new Date().toLocaleString()}`,
|
||||
captureTime: performance.now(),
|
||||
babylonVersion: "8.32.0"
|
||||
description: `Captured level state at ${new Date().toLocaleString()}`
|
||||
},
|
||||
ship,
|
||||
startBase,
|
||||
@ -49,17 +41,6 @@ export class LevelSerializer {
|
||||
planets,
|
||||
asteroids
|
||||
};
|
||||
|
||||
// Include full scene serialization if requested
|
||||
if (includeFullScene) {
|
||||
config.materials = this.serializeMaterials();
|
||||
config.sceneHierarchy = this.serializeSceneHierarchy();
|
||||
config.assetReferences = this.serializeAssetReferences();
|
||||
|
||||
debugLog(`LevelSerializer: Serialized ${config.materials.length} materials, ${config.sceneHierarchy.length} scene nodes`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -248,197 +229,6 @@ export class LevelSerializer {
|
||||
return asteroids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all materials in the scene
|
||||
*/
|
||||
private serializeMaterials(): MaterialConfig[] {
|
||||
const materials: MaterialConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const material of this.scene.materials) {
|
||||
// Skip duplicates
|
||||
if (seenIds.has(material.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(material.id);
|
||||
|
||||
const materialConfig: MaterialConfig = {
|
||||
id: material.id,
|
||||
name: material.name,
|
||||
type: "Basic",
|
||||
alpha: material.alpha,
|
||||
backFaceCulling: material.backFaceCulling
|
||||
};
|
||||
|
||||
// Handle PBR materials
|
||||
if (material instanceof PBRMaterial) {
|
||||
materialConfig.type = "PBR";
|
||||
if (material.albedoColor) {
|
||||
materialConfig.albedoColor = [
|
||||
material.albedoColor.r,
|
||||
material.albedoColor.g,
|
||||
material.albedoColor.b
|
||||
];
|
||||
}
|
||||
materialConfig.metallic = material.metallic;
|
||||
materialConfig.roughness = material.roughness;
|
||||
if (material.emissiveColor) {
|
||||
materialConfig.emissiveColor = [
|
||||
material.emissiveColor.r,
|
||||
material.emissiveColor.g,
|
||||
material.emissiveColor.b
|
||||
];
|
||||
}
|
||||
materialConfig.emissiveIntensity = material.emissiveIntensity;
|
||||
|
||||
// Capture texture references (not data)
|
||||
materialConfig.textures = {};
|
||||
if (material.albedoTexture) {
|
||||
materialConfig.textures.albedo = material.albedoTexture.name;
|
||||
}
|
||||
if (material.bumpTexture) {
|
||||
materialConfig.textures.normal = material.bumpTexture.name;
|
||||
}
|
||||
if (material.metallicTexture) {
|
||||
materialConfig.textures.metallic = material.metallicTexture.name;
|
||||
}
|
||||
if (material.emissiveTexture) {
|
||||
materialConfig.textures.emissive = material.emissiveTexture.name;
|
||||
}
|
||||
}
|
||||
// Handle Standard materials
|
||||
else if (material instanceof StandardMaterial) {
|
||||
materialConfig.type = "Standard";
|
||||
if (material.diffuseColor) {
|
||||
materialConfig.albedoColor = [
|
||||
material.diffuseColor.r,
|
||||
material.diffuseColor.g,
|
||||
material.diffuseColor.b,
|
||||
1.0
|
||||
];
|
||||
}
|
||||
if (material.emissiveColor) {
|
||||
materialConfig.emissiveColor = [
|
||||
material.emissiveColor.r,
|
||||
material.emissiveColor.g,
|
||||
material.emissiveColor.b
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
materials.push(materialConfig);
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize scene hierarchy (all transform nodes and meshes)
|
||||
*/
|
||||
private serializeSceneHierarchy(): SceneNodeConfig[] {
|
||||
const nodes: SceneNodeConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
// Serialize all transform nodes
|
||||
for (const node of this.scene.transformNodes) {
|
||||
if (seenIds.has(node.id)) continue;
|
||||
seenIds.add(node.id);
|
||||
|
||||
const nodeConfig: SceneNodeConfig = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: "TransformNode",
|
||||
position: this.vector3ToArray(node.position),
|
||||
rotation: this.vector3ToArray(node.rotation),
|
||||
scaling: this.vector3ToArray(node.scaling),
|
||||
isEnabled: node.isEnabled(),
|
||||
metadata: node.metadata
|
||||
};
|
||||
|
||||
// Capture quaternion if present
|
||||
if (node.rotationQuaternion) {
|
||||
nodeConfig.rotationQuaternion = this.quaternionToArray(node.rotationQuaternion);
|
||||
}
|
||||
|
||||
// Capture parent reference
|
||||
if (node.parent) {
|
||||
nodeConfig.parentId = node.parent.id;
|
||||
}
|
||||
|
||||
nodes.push(nodeConfig);
|
||||
}
|
||||
|
||||
// Serialize all meshes
|
||||
for (const mesh of this.scene.meshes) {
|
||||
if (seenIds.has(mesh.id)) continue;
|
||||
seenIds.add(mesh.id);
|
||||
|
||||
const nodeConfig: SceneNodeConfig = {
|
||||
id: mesh.id,
|
||||
name: mesh.name,
|
||||
type: mesh.getClassName() === "InstancedMesh" ? "InstancedMesh" : "Mesh",
|
||||
position: this.vector3ToArray(mesh.position),
|
||||
rotation: this.vector3ToArray(mesh.rotation),
|
||||
scaling: this.vector3ToArray(mesh.scaling),
|
||||
isVisible: mesh.isVisible,
|
||||
isEnabled: mesh.isEnabled(),
|
||||
metadata: mesh.metadata
|
||||
};
|
||||
|
||||
// Capture quaternion if present
|
||||
if (mesh.rotationQuaternion) {
|
||||
nodeConfig.rotationQuaternion = this.quaternionToArray(mesh.rotationQuaternion);
|
||||
}
|
||||
|
||||
// Capture parent reference
|
||||
if (mesh.parent) {
|
||||
nodeConfig.parentId = mesh.parent.id;
|
||||
}
|
||||
|
||||
// Capture material reference
|
||||
if (mesh.material) {
|
||||
nodeConfig.materialId = mesh.material.id;
|
||||
}
|
||||
|
||||
// Determine asset reference from mesh source (use full paths)
|
||||
if (mesh.metadata?.source) {
|
||||
nodeConfig.assetReference = mesh.metadata.source;
|
||||
} else if (mesh.name.includes("ship") || mesh.name.includes("Ship")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/ship.glb";
|
||||
} else if (mesh.name.includes("asteroid") || mesh.name.includes("Asteroid")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/asteroid.glb";
|
||||
} else if (mesh.name.includes("base") || mesh.name.includes("Base")) {
|
||||
nodeConfig.assetReference = "assets/themes/default/models/base.glb";
|
||||
}
|
||||
|
||||
nodes.push(nodeConfig);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize asset references (mesh ID -> GLB file path)
|
||||
*/
|
||||
private serializeAssetReferences(): { [key: string]: string } {
|
||||
const assetRefs: { [key: string]: string } = {};
|
||||
|
||||
// Map common mesh patterns to their source assets (use full paths as keys)
|
||||
for (const mesh of this.scene.meshes) {
|
||||
if (mesh.metadata?.source) {
|
||||
assetRefs[mesh.id] = mesh.metadata.source;
|
||||
} else if (mesh.name.toLowerCase().includes("ship")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/ship.glb";
|
||||
} else if (mesh.name.toLowerCase().includes("asteroid")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/asteroid.glb";
|
||||
} else if (mesh.name.toLowerCase().includes("base")) {
|
||||
assetRefs[mesh.id] = "assets/themes/default/models/base.glb";
|
||||
}
|
||||
}
|
||||
|
||||
return assetRefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Vector3 to array
|
||||
*/
|
||||
@ -450,18 +240,6 @@ export class LevelSerializer {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert Quaternion to array
|
||||
*/
|
||||
private quaternionToArray(quat: Quaternion): QuaternionArray {
|
||||
return [
|
||||
parseFloat(quat.x.toFixed(4)),
|
||||
parseFloat(quat.y.toFixed(4)),
|
||||
parseFloat(quat.z.toFixed(4)),
|
||||
parseFloat(quat.w.toFixed(4))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current level to JSON string
|
||||
*/
|
||||
|
||||
151
src/main.ts
151
src/main.ts
@ -27,8 +27,6 @@ import {hasSavedLevels, populateLevelSelector} from "./levelSelector";
|
||||
import {LevelConfig} from "./levelConfig";
|
||||
import {generateDefaultLevels} from "./levelEditor";
|
||||
import debugLog from './debug';
|
||||
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
||||
import {ReplayManager} from "./replay/ReplayManager";
|
||||
|
||||
// Set to true to run minimal controller debug test
|
||||
const DEBUG_CONTROLLERS = false;
|
||||
@ -43,11 +41,17 @@ export class Main {
|
||||
private _gameState: GameState = GameState.DEMO;
|
||||
private _engine: Engine | WebGPUEngine;
|
||||
private _audioEngine: AudioEngineV2;
|
||||
private _replayManager: ReplayManager | null = null;
|
||||
constructor() {
|
||||
if (!navigator.xr) {
|
||||
setLoadingMessage("This browser does not support WebXR");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Listen for level selection event
|
||||
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
||||
this._started = true;
|
||||
await this.initialize();
|
||||
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
||||
|
||||
debugLog(`Starting level: ${levelName}`);
|
||||
@ -67,43 +71,25 @@ export class Main {
|
||||
if (settingsLink) {
|
||||
settingsLink.style.display = 'none';
|
||||
}
|
||||
setLoadingMessage("Initializing...");
|
||||
|
||||
// Initialize engine and XR first
|
||||
await this.initialize();
|
||||
|
||||
// If XR is available, enter XR immediately (while we have user activation)
|
||||
let xrSession = null;
|
||||
if (DefaultScene.XR) {
|
||||
try {
|
||||
setLoadingMessage("Entering VR...");
|
||||
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||
debugLog('XR session started successfully');
|
||||
} catch (error) {
|
||||
debugLog('Failed to enter XR, will fall back to flat mode:', error);
|
||||
DefaultScene.XR = null; // Disable XR for this session
|
||||
}
|
||||
}
|
||||
setLoadingMessage("Initializing Level...");
|
||||
|
||||
// Unlock audio engine on user interaction
|
||||
if (this._audioEngine) {
|
||||
await this._audioEngine.unlockAsync();
|
||||
}
|
||||
|
||||
setLoadingMessage("Loading level...");
|
||||
|
||||
// Create and initialize level from config
|
||||
this._currentLevel = new Level1(config, this._audioEngine);
|
||||
|
||||
// Wait for level to be ready
|
||||
this._currentLevel.getReadyObservable().add(async () => {
|
||||
setLoadingMessage("Starting game...");
|
||||
this._currentLevel.getReadyObservable().add(() => {
|
||||
setLoadingMessage("Level Ready! Entering VR...");
|
||||
|
||||
// Remove UI
|
||||
// Small delay to show message
|
||||
setTimeout(() => {
|
||||
mainDiv.remove();
|
||||
|
||||
// Start the game (XR session already active, or flat mode)
|
||||
await this.play();
|
||||
this.play();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Now initialize the level (after observable is registered)
|
||||
@ -157,17 +143,21 @@ export class Main {
|
||||
|
||||
// Wait for level to be ready
|
||||
debugLog('[Main] Registering ready observable...');
|
||||
this._currentLevel.getReadyObservable().add(async () => {
|
||||
this._currentLevel.getReadyObservable().add(() => {
|
||||
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
||||
setLoadingMessage("Test Scene Ready! Entering VR...");
|
||||
debugLog('[Main] Setting timeout to enter VR...');
|
||||
|
||||
// Remove UI and play immediately (must maintain user activation for XR)
|
||||
// Small delay to show message
|
||||
setTimeout(() => {
|
||||
debugLog('[Main] Timeout fired, removing mainDiv and calling play()');
|
||||
if (mainDiv) {
|
||||
mainDiv.remove();
|
||||
debugLog('[Main] mainDiv removed');
|
||||
}
|
||||
debugLog('[Main] About to call this.play()...');
|
||||
await this.play();
|
||||
this.play();
|
||||
}, 500);
|
||||
});
|
||||
debugLog('[Main] Ready observable registered');
|
||||
|
||||
@ -180,90 +170,6 @@ export class Main {
|
||||
} else {
|
||||
console.warn('[Main] Test level button not found in DOM');
|
||||
}
|
||||
|
||||
// View Replays button handler
|
||||
const viewReplaysBtn = document.querySelector('#viewReplaysBtn');
|
||||
debugLog('[Main] View Replays button found:', !!viewReplaysBtn);
|
||||
|
||||
if (viewReplaysBtn) {
|
||||
viewReplaysBtn.addEventListener('click', async () => {
|
||||
debugLog('[Main] ========== VIEW REPLAYS BUTTON CLICKED ==========');
|
||||
|
||||
// Initialize engine and physics if not already done
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Hide main menu
|
||||
const levelSelect = document.querySelector('#levelSelect') as HTMLElement;
|
||||
const editorLink = document.querySelector('.editor-link') as HTMLElement;
|
||||
const settingsLink = document.querySelector('.settings-link') as HTMLElement;
|
||||
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'none';
|
||||
}
|
||||
if (editorLink) {
|
||||
editorLink.style.display = 'none';
|
||||
}
|
||||
if (settingsLink) {
|
||||
settingsLink.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
|
||||
if (!this._replayManager) {
|
||||
this._replayManager = new ReplayManager(
|
||||
this._engine as Engine,
|
||||
() => {
|
||||
// On exit callback - return to main menu
|
||||
debugLog('[Main] Exiting replay, returning to menu');
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'block';
|
||||
}
|
||||
if (editorLink) {
|
||||
editorLink.style.display = 'block';
|
||||
}
|
||||
if (settingsLink) {
|
||||
settingsLink.style.display = 'block';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Start replay
|
||||
if (this._replayManager) {
|
||||
await this._replayManager.startReplay(recordingId);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Cancel callback - return to main menu
|
||||
debugLog('[Main] Replay selection cancelled');
|
||||
selectionScreen.dispose();
|
||||
if (levelSelect) {
|
||||
levelSelect.style.display = 'block';
|
||||
}
|
||||
if (editorLink) {
|
||||
editorLink.style.display = 'block';
|
||||
}
|
||||
if (settingsLink) {
|
||||
settingsLink.style.display = 'block';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await selectionScreen.initialize();
|
||||
});
|
||||
debugLog('[Main] Click listener added to view replays button');
|
||||
} else {
|
||||
console.warn('[Main] View Replays button not found in DOM');
|
||||
}
|
||||
});
|
||||
}
|
||||
private _started = false;
|
||||
@ -287,26 +193,17 @@ export class Main {
|
||||
setLoadingMessage("Initializing.");
|
||||
await this.setupScene();
|
||||
|
||||
// Try to initialize WebXR if available
|
||||
if (navigator.xr) {
|
||||
try {
|
||||
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
||||
disablePointerSelection: true,
|
||||
disableTeleportation: true,
|
||||
disableNearInteraction: true,
|
||||
disableHandTracking: true,
|
||||
disableDefaultUI: true
|
||||
|
||||
});
|
||||
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
||||
debugLog("WebXR initialized successfully");
|
||||
} catch (error) {
|
||||
debugLog("WebXR initialization failed, falling back to flat mode:", error);
|
||||
DefaultScene.XR = null;
|
||||
}
|
||||
} else {
|
||||
debugLog("WebXR not available, using flat camera mode");
|
||||
DefaultScene.XR = null;
|
||||
}
|
||||
//DefaultScene.XR.baseExperience.featuresManager.enableFeature(WebXRFeatureName.LAYERS, "latest", {preferMultiviewOnInit: true});
|
||||
|
||||
|
||||
setLoadingMessage("Get Ready!");
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core";
|
||||
import debugLog from "./debug";
|
||||
import { PhysicsStorage } from "./physicsStorage";
|
||||
import { LevelConfig } from "./levelConfig";
|
||||
|
||||
/**
|
||||
* Represents the physics state of a single object at a point in time
|
||||
@ -34,7 +33,6 @@ export interface RecordingMetadata {
|
||||
frameCount: number;
|
||||
recordingDuration: number; // milliseconds
|
||||
physicsUpdateRate: number; // Hz
|
||||
levelConfig?: LevelConfig; // Full scene state at recording time
|
||||
}
|
||||
|
||||
/**
|
||||
@ -82,11 +80,9 @@ export class PhysicsRecorder {
|
||||
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) {
|
||||
constructor(scene: Scene) {
|
||||
this._scene = scene;
|
||||
this._levelConfig = levelConfig || null;
|
||||
|
||||
// Initialize IndexedDB storage
|
||||
this._storage = new PhysicsStorage();
|
||||
@ -172,12 +168,10 @@ export class PhysicsRecorder {
|
||||
const timestamp = performance.now() - this._startTime;
|
||||
const objects: PhysicsObjectState[] = [];
|
||||
|
||||
// Get all physics-enabled meshes AND transform nodes
|
||||
// Get all physics-enabled meshes
|
||||
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) {
|
||||
for (const mesh of physicsMeshes) {
|
||||
const body = mesh.physicsBody;
|
||||
|
||||
// Double-check body still exists and has transformNode (can be disposed between filter and here)
|
||||
@ -306,17 +300,13 @@ export class PhysicsRecorder {
|
||||
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
|
||||
physicsUpdateRate: this._physicsUpdateRate
|
||||
};
|
||||
|
||||
const recording: PhysicsRecording = {
|
||||
@ -329,8 +319,7 @@ export class PhysicsRecorder {
|
||||
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`);
|
||||
debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) to IndexedDB`);
|
||||
} catch (error) {
|
||||
debugLog("PhysicsRecorder: Error during auto-save", error);
|
||||
}
|
||||
|
||||
@ -58,8 +58,7 @@ export class PhysicsStorage {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
|
||||
// Use the provided name as recordingId (for session-based grouping)
|
||||
const recordingId = name;
|
||||
const recordingId = `recording-${Date.now()}`;
|
||||
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -191,76 +190,23 @@ export class PhysicsStorage {
|
||||
request.onsuccess = () => {
|
||||
const allSegments = request.result;
|
||||
|
||||
// Group by recordingId and aggregate all segments
|
||||
const sessionMap = new Map<string, {
|
||||
segments: any[];
|
||||
metadata: any;
|
||||
}>();
|
||||
// Group by recordingId and get first segment (which has metadata)
|
||||
const recordingMap = new Map();
|
||||
|
||||
// Group segments by session
|
||||
allSegments.forEach(segment => {
|
||||
if (!sessionMap.has(segment.recordingId)) {
|
||||
sessionMap.set(segment.recordingId, {
|
||||
segments: [],
|
||||
metadata: null
|
||||
if (!recordingMap.has(segment.recordingId) && segment.metadata) {
|
||||
recordingMap.set(segment.recordingId, {
|
||||
id: segment.recordingId,
|
||||
name: segment.name,
|
||||
timestamp: segment.timestamp,
|
||||
duration: segment.metadata.recordingDuration / 1000, // Convert to seconds
|
||||
frameCount: segment.metadata.frameCount
|
||||
});
|
||||
}
|
||||
const 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)`);
|
||||
const recordings = Array.from(recordingMap.values());
|
||||
debugLog(`PhysicsStorage: Found ${recordings.length} recordings`);
|
||||
resolve(recordings);
|
||||
};
|
||||
|
||||
|
||||
@ -1,187 +0,0 @@
|
||||
import {
|
||||
AbstractMesh,
|
||||
ArcRotateCamera,
|
||||
Scene,
|
||||
Vector3
|
||||
} from "@babylonjs/core";
|
||||
import debugLog from "../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 "../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 "../physicsStorage";
|
||||
import { ReplayPlayer } from "./ReplayPlayer";
|
||||
import { CameraMode, ReplayCamera } from "./ReplayCamera";
|
||||
import { ReplayControls } from "./ReplayControls";
|
||||
import debugLog from "../debug";
|
||||
import { DefaultScene } from "../defaultScene";
|
||||
import { Level1 } from "../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 "../physicsRecorder";
|
||||
import debugLog from "../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 "../physicsStorage";
|
||||
import debugLog from "../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");
|
||||
}
|
||||
}
|
||||
17
src/ship.ts
17
src/ship.ts
@ -50,11 +50,9 @@ export class Ship {
|
||||
private _landingAggregate: PhysicsAggregate | null = null;
|
||||
private _resupplyTimer: number = 0;
|
||||
private _isInLandingZone: boolean = false;
|
||||
private _isReplayMode: boolean;
|
||||
|
||||
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
||||
constructor(audioEngine?: AudioEngineV2) {
|
||||
this._audioEngine = audioEngine;
|
||||
this._isReplayMode = isReplayMode;
|
||||
}
|
||||
|
||||
public get scoreboard(): Scoreboard {
|
||||
@ -140,8 +138,7 @@ export class Ship {
|
||||
this._weapons.setShipStatus(this._scoreboard.shipStatus);
|
||||
this._weapons.setGameStats(this._gameStats);
|
||||
|
||||
// Initialize input systems (skip in replay mode)
|
||||
if (!this._isReplayMode) {
|
||||
// Initialize input systems
|
||||
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
||||
this._keyboardInput.setup();
|
||||
|
||||
@ -181,7 +178,6 @@ export class Ship {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize physics controller
|
||||
this._physics = new ShipPhysics();
|
||||
@ -200,18 +196,11 @@ export class Ship {
|
||||
// Setup camera
|
||||
this._camera = new FreeCamera(
|
||||
"Flat Camera",
|
||||
new Vector3(0, 1.5, 0),
|
||||
new Vector3(0, 0.5, 0),
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
this._camera.parent = this._ship;
|
||||
|
||||
// Set as active camera if XR is not available
|
||||
if (!DefaultScene.XR && !this._isReplayMode) {
|
||||
DefaultScene.MainScene.activeCamera = this._camera;
|
||||
//this._camera.attachControl(DefaultScene.MainScene.getEngine().getRenderingCanvas(), true);
|
||||
debugLog('Flat camera set as active camera');
|
||||
}
|
||||
|
||||
// Create sight reticle
|
||||
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
|
||||
position: new Vector3(0, 0.1, 125),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user