Compare commits
No commits in common. "faa5afc604c4a96b1cb3d7e377fad5e31b1a04dc" and "88d380fa3fbae7efcab0e5b5fce89c02e5874d5c" have entirely different histories.
faa5afc604
...
88d380fa3f
@ -19,8 +19,8 @@
|
|||||||
<!-- Game View -->
|
<!-- Game View -->
|
||||||
<div data-view="game">
|
<div data-view="game">
|
||||||
<canvas id="gameCanvas"></canvas>
|
<canvas id="gameCanvas"></canvas>
|
||||||
<a href="#/editor" class="editor-link" style="display: none;">📝 Level Editor</a>
|
<a href="#/editor" class="editor-link">📝 Level Editor</a>
|
||||||
<a href="#/settings" class="settings-link" style="display: none;">⚙️ Settings</a>
|
<a href="#/settings" class="settings-link">⚙️ Settings</a>
|
||||||
<div id="mainDiv">
|
<div id="mainDiv">
|
||||||
<div id="loadingDiv">Loading...</div>
|
<div id="loadingDiv">Loading...</div>
|
||||||
<div id="levelSelect">
|
<div id="levelSelect">
|
||||||
@ -56,13 +56,10 @@
|
|||||||
<div id="levelCardsContainer" class="card-container">
|
<div id="levelCardsContainer" class="card-container">
|
||||||
<!-- Level cards will be dynamically populated from localStorage -->
|
<!-- Level cards will be dynamically populated from localStorage -->
|
||||||
</div>
|
</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">
|
<button id="testLevelBtn" class="test-level-button">
|
||||||
🧪 Test Scene (Debug)
|
🧪 Test Scene (Debug)
|
||||||
</button>
|
</button>
|
||||||
<button id="viewReplaysBtn" class="test-level-button" style="margin-left: 10px;">
|
|
||||||
📹 View Replays
|
|
||||||
</button>
|
|
||||||
<br>
|
<br>
|
||||||
<a href="#/editor" style="color: #4CAF50; text-decoration: none; font-size: 1.1em;">
|
<a href="#/editor" style="color: #4CAF50; text-decoration: none; font-size: 1.1em;">
|
||||||
+ Create New Level
|
+ 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.
157
src/level1.ts
157
src/level1.ts
@ -27,44 +27,40 @@ export class Level1 implements Level {
|
|||||||
private _deserializer: LevelDeserializer;
|
private _deserializer: LevelDeserializer;
|
||||||
private _backgroundStars: BackgroundStars;
|
private _backgroundStars: BackgroundStars;
|
||||||
private _physicsRecorder: PhysicsRecorder;
|
private _physicsRecorder: PhysicsRecorder;
|
||||||
private _isReplayMode: boolean;
|
|
||||||
|
|
||||||
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) {
|
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2) {
|
||||||
this._levelConfig = levelConfig;
|
this._levelConfig = levelConfig;
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
this._isReplayMode = isReplayMode;
|
|
||||||
this._deserializer = new LevelDeserializer(levelConfig);
|
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');
|
const xr = DefaultScene.XR;
|
||||||
debugLog('XR input exists:', !!xr.input);
|
|
||||||
debugLog('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
|
|
||||||
|
|
||||||
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
|
debugLog('Level1 constructor - Setting up XR observables');
|
||||||
xr.baseExperience.camera.parent = this._ship.transformNode;
|
debugLog('XR input exists:', !!xr.input);
|
||||||
const currPose = xr.baseExperience.camera.globalPosition.y;
|
debugLog('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
|
||||||
xr.baseExperience.camera.position = new Vector3(0, 0, 0);
|
|
||||||
|
|
||||||
// Start game timer when XR pose is set
|
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
|
||||||
this._ship.gameStats.startTimer();
|
xr.baseExperience.camera.parent = this._ship.transformNode;
|
||||||
debugLog('Game timer started');
|
const currPose = xr.baseExperience.camera.globalPosition.y;
|
||||||
|
xr.baseExperience.camera.position = new Vector3(0, 0, 0);
|
||||||
|
|
||||||
// Start physics recording when gameplay begins
|
// Start game timer when XR pose is set
|
||||||
if (this._physicsRecorder) {
|
this._ship.gameStats.startTimer();
|
||||||
this._physicsRecorder.startRingBuffer();
|
debugLog('Game timer started');
|
||||||
debugLog('Physics recorder started');
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = xr.input.onControllerAddedObservable.add((controller) => {
|
// Start physics recording when gameplay begins
|
||||||
debugLog('🎮 onControllerAddedObservable FIRED for:', controller.inputSource.handedness);
|
if (this._physicsRecorder) {
|
||||||
this._ship.addController(controller);
|
this._physicsRecorder.startRingBuffer();
|
||||||
});
|
debugLog('Physics recorder started');
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = xr.input.onControllerAddedObservable.add((controller) => {
|
||||||
|
debugLog('🎮 onControllerAddedObservable FIRED for:', controller.inputSource.handedness);
|
||||||
|
this._ship.addController(controller);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
// Don't call initialize here - let Main call it after registering the observable
|
// Don't call initialize here - let Main call it after registering the observable
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,68 +69,30 @@ export class Level1 implements Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
if (this._isReplayMode) {
|
|
||||||
throw new Error("Cannot call play() in replay mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create background music using AudioEngineV2
|
// Create background music using AudioEngineV2
|
||||||
if (this._audioEngine) {
|
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
||||||
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
loop: true,
|
||||||
loop: true,
|
volume: 0.5
|
||||||
volume: 0.5
|
});
|
||||||
});
|
background.play();
|
||||||
background.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If XR is available and session is active, check for controllers
|
// Enter XR mode
|
||||||
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 4) { // State 4 = IN_XR
|
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||||
// XR session already active, just check for controllers
|
// Check for controllers that are already connected after entering XR
|
||||||
debugLog('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait and check again after a delay (controllers might connect later)
|
||||||
|
debugLog('Waiting 2 seconds to check for controllers again...');
|
||||||
|
setTimeout(() => {
|
||||||
|
debugLog('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length);
|
||||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
||||||
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
debugLog(` Late controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
||||||
this._ship.addController(controller);
|
|
||||||
});
|
});
|
||||||
|
}, 2000);
|
||||||
// Wait and check again after a delay (controllers might connect later)
|
|
||||||
debugLog('Waiting 2 seconds to check for controllers again...');
|
|
||||||
setTimeout(() => {
|
|
||||||
debugLog('After 2 second delay - controller count:', DefaultScene.XR.input.controllers.length);
|
|
||||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
|
||||||
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() {
|
public dispose() {
|
||||||
@ -176,6 +134,8 @@ export class Level1 implements Level {
|
|||||||
this._ship.scoreboard.setRemainingCount(entities.asteroids.length);
|
this._ship.scoreboard.setRemainingCount(entities.asteroids.length);
|
||||||
debugLog(`Initialized scoreboard with ${entities.asteroids.length} asteroids`);
|
debugLog(`Initialized scoreboard with ${entities.asteroids.length} asteroids`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Create background starfield
|
// Create background starfield
|
||||||
setLoadingMessage("Creating starfield...");
|
setLoadingMessage("Creating starfield...");
|
||||||
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
|
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)
|
// Set up camera follow for stars (keeps stars at infinite distance)
|
||||||
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
||||||
if (this._backgroundStars) {
|
if (this._backgroundStars && DefaultScene.XR.baseExperience.camera) {
|
||||||
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
this._backgroundStars.followCamera(DefaultScene.XR.baseExperience.camera.position);
|
||||||
if (camera) {
|
|
||||||
this._backgroundStars.followCamera(camera.position);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize physics recorder (but don't start it yet - will start on XR pose)
|
// Initialize physics recorder (but don't start it yet - will start on XR pose)
|
||||||
// Only create recorder in game mode, not replay mode
|
setLoadingMessage("Initializing physics recorder...");
|
||||||
if (!this._isReplayMode) {
|
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene);
|
||||||
setLoadingMessage("Initializing physics recorder...");
|
debugLog('Physics recorder initialized (will start on XR pose)');
|
||||||
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
|
|
||||||
debugLog('Physics recorder initialized (will start on XR pose)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire up recording keyboard shortcuts (only in game mode)
|
// Wire up recording keyboard shortcuts
|
||||||
if (!this._isReplayMode) {
|
this._ship.keyboardInput.onRecordingActionObservable.add((action) => {
|
||||||
this._ship.keyboardInput.onRecordingActionObservable.add((action) => {
|
this.handleRecordingAction(action);
|
||||||
this.handleRecordingAction(action);
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
||||||
@ -254,7 +206,6 @@ export class Level1 implements Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the physics recorder instance
|
* Get the physics recorder instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,58 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
export type Vector3Array = [number, number, number];
|
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
|
* Ship configuration
|
||||||
*/
|
*/
|
||||||
@ -134,8 +82,6 @@ export interface LevelConfig {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
author?: string;
|
author?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
babylonVersion?: string;
|
|
||||||
captureTime?: number;
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -147,11 +93,6 @@ export interface LevelConfig {
|
|||||||
|
|
||||||
// Optional: include original difficulty config for reference
|
// Optional: include original difficulty config for reference
|
||||||
difficultyConfig?: DifficultyConfig;
|
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 {
|
import {
|
||||||
AbstractMesh,
|
AbstractMesh, Color3,
|
||||||
Color3,
|
|
||||||
MeshBuilder,
|
MeshBuilder,
|
||||||
Observable,
|
Observable,
|
||||||
PBRMaterial,
|
PBRMaterial,
|
||||||
PhysicsAggregate,
|
PhysicsAggregate,
|
||||||
Texture,
|
Texture,
|
||||||
Vector3,
|
Vector3
|
||||||
} from "@babylonjs/core";
|
} from "@babylonjs/core";
|
||||||
import { DefaultScene } from "./defaultScene";
|
import { DefaultScene } from "./defaultScene";
|
||||||
import { RockFactory } from "./rockFactory";
|
import { RockFactory } from "./rockFactory";
|
||||||
@ -17,6 +16,7 @@ import {
|
|||||||
Vector3Array,
|
Vector3Array,
|
||||||
validateLevelConfig
|
validateLevelConfig
|
||||||
} from "./levelConfig";
|
} from "./levelConfig";
|
||||||
|
import { GameConfig } from "./gameConfig";
|
||||||
import { FireProceduralTexture } from "@babylonjs/procedural-textures";
|
import { FireProceduralTexture } from "@babylonjs/procedural-textures";
|
||||||
import { createSphereLightmap } from "./sphereLightmap";
|
import { createSphereLightmap } from "./sphereLightmap";
|
||||||
import debugLog from './debug';
|
import debugLog from './debug';
|
||||||
@ -41,11 +41,8 @@ export class LevelDeserializer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all entities from the configuration
|
* Create all entities from the configuration
|
||||||
* @param scoreObservable - Observable for score events
|
|
||||||
*/
|
*/
|
||||||
public async deserialize(
|
public async deserialize(scoreObservable: Observable<ScoreEvent>): Promise<{
|
||||||
scoreObservable: Observable<ScoreEvent>
|
|
||||||
): Promise<{
|
|
||||||
startBase: AbstractMesh | null;
|
startBase: AbstractMesh | null;
|
||||||
landingAggregate: PhysicsAggregate | null;
|
landingAggregate: PhysicsAggregate | null;
|
||||||
sun: AbstractMesh;
|
sun: AbstractMesh;
|
||||||
@ -54,11 +51,19 @@ export class LevelDeserializer {
|
|||||||
}> {
|
}> {
|
||||||
debugLog('Deserializing level:', this.config.difficulty);
|
debugLog('Deserializing level:', this.config.difficulty);
|
||||||
|
|
||||||
|
// Create entities
|
||||||
const baseResult = await this.createStartBase();
|
const baseResult = await this.createStartBase();
|
||||||
const sun = this.createSun();
|
const sun = this.createSun();
|
||||||
const planets = this.createPlanets();
|
const planets = this.createPlanets();
|
||||||
const asteroids = await this.createAsteroids(scoreObservable);
|
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 {
|
return {
|
||||||
startBase: baseResult.baseMesh,
|
startBase: baseResult.baseMesh,
|
||||||
landingAggregate: baseResult.landingAggregate,
|
landingAggregate: baseResult.landingAggregate,
|
||||||
|
|||||||
@ -16,15 +16,7 @@ export function populateLevelSelector(): boolean {
|
|||||||
|
|
||||||
const savedLevels = getSavedLevels();
|
const savedLevels = getSavedLevels();
|
||||||
|
|
||||||
// Filter to only show recruit and pilot difficulty levels
|
if (savedLevels.size === 0) {
|
||||||
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) {
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="
|
<div style="
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@ -51,7 +43,7 @@ export function populateLevelSelector(): boolean {
|
|||||||
|
|
||||||
// Create level cards
|
// Create level cards
|
||||||
let html = '';
|
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 timestamp = config.timestamp ? new Date(config.timestamp).toLocaleDateString() : '';
|
||||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
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 { DefaultScene } from "./defaultScene";
|
||||||
import {
|
import {
|
||||||
LevelConfig,
|
LevelConfig,
|
||||||
@ -7,11 +7,7 @@ import {
|
|||||||
SunConfig,
|
SunConfig,
|
||||||
PlanetConfig,
|
PlanetConfig,
|
||||||
AsteroidConfig,
|
AsteroidConfig,
|
||||||
Vector3Array,
|
Vector3Array
|
||||||
QuaternionArray,
|
|
||||||
Color4Array,
|
|
||||||
MaterialConfig,
|
|
||||||
SceneNodeConfig
|
|
||||||
} from "./levelConfig";
|
} from "./levelConfig";
|
||||||
import debugLog from './debug';
|
import debugLog from './debug';
|
||||||
|
|
||||||
@ -23,25 +19,21 @@ export class LevelSerializer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize the current level state to a LevelConfig object
|
* 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 ship = this.serializeShip();
|
||||||
const startBase = this.serializeStartBase();
|
const startBase = this.serializeStartBase();
|
||||||
const sun = this.serializeSun();
|
const sun = this.serializeSun();
|
||||||
const planets = this.serializePlanets();
|
const planets = this.serializePlanets();
|
||||||
const asteroids = this.serializeAsteroids();
|
const asteroids = this.serializeAsteroids();
|
||||||
|
|
||||||
const config: LevelConfig = {
|
return {
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
difficulty,
|
difficulty,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
metadata: {
|
metadata: {
|
||||||
generator: "LevelSerializer",
|
generator: "LevelSerializer",
|
||||||
description: `Captured level state at ${new Date().toLocaleString()}`,
|
description: `Captured level state at ${new Date().toLocaleString()}`
|
||||||
captureTime: performance.now(),
|
|
||||||
babylonVersion: "8.32.0"
|
|
||||||
},
|
},
|
||||||
ship,
|
ship,
|
||||||
startBase,
|
startBase,
|
||||||
@ -49,17 +41,6 @@ export class LevelSerializer {
|
|||||||
planets,
|
planets,
|
||||||
asteroids
|
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;
|
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
|
* 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
|
* Export current level to JSON string
|
||||||
*/
|
*/
|
||||||
|
|||||||
179
src/main.ts
179
src/main.ts
@ -27,8 +27,6 @@ import {hasSavedLevels, populateLevelSelector} from "./levelSelector";
|
|||||||
import {LevelConfig} from "./levelConfig";
|
import {LevelConfig} from "./levelConfig";
|
||||||
import {generateDefaultLevels} from "./levelEditor";
|
import {generateDefaultLevels} from "./levelEditor";
|
||||||
import debugLog from './debug';
|
import debugLog from './debug';
|
||||||
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
|
||||||
import {ReplayManager} from "./replay/ReplayManager";
|
|
||||||
|
|
||||||
// Set to true to run minimal controller debug test
|
// Set to true to run minimal controller debug test
|
||||||
const DEBUG_CONTROLLERS = false;
|
const DEBUG_CONTROLLERS = false;
|
||||||
@ -43,11 +41,17 @@ export class Main {
|
|||||||
private _gameState: GameState = GameState.DEMO;
|
private _gameState: GameState = GameState.DEMO;
|
||||||
private _engine: Engine | WebGPUEngine;
|
private _engine: Engine | WebGPUEngine;
|
||||||
private _audioEngine: AudioEngineV2;
|
private _audioEngine: AudioEngineV2;
|
||||||
private _replayManager: ReplayManager | null = null;
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
if (!navigator.xr) {
|
||||||
|
setLoadingMessage("This browser does not support WebXR");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Listen for level selection event
|
// Listen for level selection event
|
||||||
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
window.addEventListener('levelSelected', async (e: CustomEvent) => {
|
||||||
this._started = true;
|
this._started = true;
|
||||||
|
await this.initialize();
|
||||||
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
const {levelName, config} = e.detail as {levelName: string, config: LevelConfig};
|
||||||
|
|
||||||
debugLog(`Starting level: ${levelName}`);
|
debugLog(`Starting level: ${levelName}`);
|
||||||
@ -67,43 +71,25 @@ export class Main {
|
|||||||
if (settingsLink) {
|
if (settingsLink) {
|
||||||
settingsLink.style.display = 'none';
|
settingsLink.style.display = 'none';
|
||||||
}
|
}
|
||||||
setLoadingMessage("Initializing...");
|
setLoadingMessage("Initializing Level...");
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock audio engine on user interaction
|
// Unlock audio engine on user interaction
|
||||||
if (this._audioEngine) {
|
if (this._audioEngine) {
|
||||||
await this._audioEngine.unlockAsync();
|
await this._audioEngine.unlockAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingMessage("Loading level...");
|
|
||||||
|
|
||||||
// Create and initialize level from config
|
// Create and initialize level from config
|
||||||
this._currentLevel = new Level1(config, this._audioEngine);
|
this._currentLevel = new Level1(config, this._audioEngine);
|
||||||
|
|
||||||
// Wait for level to be ready
|
// Wait for level to be ready
|
||||||
this._currentLevel.getReadyObservable().add(async () => {
|
this._currentLevel.getReadyObservable().add(() => {
|
||||||
setLoadingMessage("Starting game...");
|
setLoadingMessage("Level Ready! Entering VR...");
|
||||||
|
|
||||||
// Remove UI
|
// Small delay to show message
|
||||||
mainDiv.remove();
|
setTimeout(() => {
|
||||||
|
mainDiv.remove();
|
||||||
// Start the game (XR session already active, or flat mode)
|
this.play();
|
||||||
await this.play();
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now initialize the level (after observable is registered)
|
// Now initialize the level (after observable is registered)
|
||||||
@ -157,17 +143,21 @@ export class Main {
|
|||||||
|
|
||||||
// Wait for level to be ready
|
// Wait for level to be ready
|
||||||
debugLog('[Main] Registering ready observable...');
|
debugLog('[Main] Registering ready observable...');
|
||||||
this._currentLevel.getReadyObservable().add(async () => {
|
this._currentLevel.getReadyObservable().add(() => {
|
||||||
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
|
||||||
setLoadingMessage("Test Scene Ready! Entering VR...");
|
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
|
||||||
if (mainDiv) {
|
setTimeout(() => {
|
||||||
mainDiv.remove();
|
debugLog('[Main] Timeout fired, removing mainDiv and calling play()');
|
||||||
debugLog('[Main] mainDiv removed');
|
if (mainDiv) {
|
||||||
}
|
mainDiv.remove();
|
||||||
debugLog('[Main] About to call this.play()...');
|
debugLog('[Main] mainDiv removed');
|
||||||
await this.play();
|
}
|
||||||
|
debugLog('[Main] About to call this.play()...');
|
||||||
|
this.play();
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
debugLog('[Main] Ready observable registered');
|
debugLog('[Main] Ready observable registered');
|
||||||
|
|
||||||
@ -180,90 +170,6 @@ export class Main {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[Main] Test level button not found in DOM');
|
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;
|
private _started = false;
|
||||||
@ -287,26 +193,17 @@ export class Main {
|
|||||||
setLoadingMessage("Initializing.");
|
setLoadingMessage("Initializing.");
|
||||||
await this.setupScene();
|
await this.setupScene();
|
||||||
|
|
||||||
// Try to initialize WebXR if available
|
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
||||||
if (navigator.xr) {
|
disablePointerSelection: true,
|
||||||
try {
|
disableTeleportation: true,
|
||||||
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
disableNearInteraction: true,
|
||||||
disablePointerSelection: true,
|
disableHandTracking: true,
|
||||||
disableTeleportation: true,
|
disableDefaultUI: true
|
||||||
disableNearInteraction: true,
|
|
||||||
disableHandTracking: true,
|
});
|
||||||
disableDefaultUI: true
|
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
||||||
});
|
//DefaultScene.XR.baseExperience.featuresManager.enableFeature(WebXRFeatureName.LAYERS, "latest", {preferMultiviewOnInit: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingMessage("Get Ready!");
|
setLoadingMessage("Get Ready!");
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core";
|
import { Scene, Vector3, Quaternion, AbstractMesh } from "@babylonjs/core";
|
||||||
import debugLog from "./debug";
|
import debugLog from "./debug";
|
||||||
import { PhysicsStorage } from "./physicsStorage";
|
import { PhysicsStorage } from "./physicsStorage";
|
||||||
import { LevelConfig } from "./levelConfig";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the physics state of a single object at a point in time
|
* Represents the physics state of a single object at a point in time
|
||||||
@ -34,7 +33,6 @@ export interface RecordingMetadata {
|
|||||||
frameCount: number;
|
frameCount: number;
|
||||||
recordingDuration: number; // milliseconds
|
recordingDuration: number; // milliseconds
|
||||||
physicsUpdateRate: number; // Hz
|
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 _autoSaveInterval: number = 10000; // Save every 10 seconds
|
||||||
private _lastAutoSaveTime: number = 0;
|
private _lastAutoSaveTime: number = 0;
|
||||||
private _currentSessionId: string = "";
|
private _currentSessionId: string = "";
|
||||||
private _levelConfig: LevelConfig | null = null;
|
|
||||||
|
|
||||||
constructor(scene: Scene, levelConfig?: LevelConfig) {
|
constructor(scene: Scene) {
|
||||||
this._scene = scene;
|
this._scene = scene;
|
||||||
this._levelConfig = levelConfig || null;
|
|
||||||
|
|
||||||
// Initialize IndexedDB storage
|
// Initialize IndexedDB storage
|
||||||
this._storage = new PhysicsStorage();
|
this._storage = new PhysicsStorage();
|
||||||
@ -172,12 +168,10 @@ export class PhysicsRecorder {
|
|||||||
const timestamp = performance.now() - this._startTime;
|
const timestamp = performance.now() - this._startTime;
|
||||||
const objects: PhysicsObjectState[] = [];
|
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 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;
|
const body = mesh.physicsBody;
|
||||||
|
|
||||||
// Double-check body still exists and has transformNode (can be disposed between filter and here)
|
// 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];
|
const snapshotsToSave = [...this._autoSaveBuffer];
|
||||||
this._autoSaveBuffer = [];
|
this._autoSaveBuffer = [];
|
||||||
|
|
||||||
// Use the LevelConfig passed to constructor
|
|
||||||
const levelConfig = this._levelConfig || undefined;
|
|
||||||
|
|
||||||
// Create a recording from the buffered snapshots
|
// Create a recording from the buffered snapshots
|
||||||
const metadata: RecordingMetadata = {
|
const metadata: RecordingMetadata = {
|
||||||
startTime: snapshotsToSave[0].timestamp,
|
startTime: snapshotsToSave[0].timestamp,
|
||||||
endTime: snapshotsToSave[snapshotsToSave.length - 1].timestamp,
|
endTime: snapshotsToSave[snapshotsToSave.length - 1].timestamp,
|
||||||
frameCount: snapshotsToSave.length,
|
frameCount: snapshotsToSave.length,
|
||||||
recordingDuration: snapshotsToSave[snapshotsToSave.length - 1].timestamp - snapshotsToSave[0].timestamp,
|
recordingDuration: snapshotsToSave[snapshotsToSave.length - 1].timestamp - snapshotsToSave[0].timestamp,
|
||||||
physicsUpdateRate: this._physicsUpdateRate,
|
physicsUpdateRate: this._physicsUpdateRate
|
||||||
levelConfig // Include complete scene state
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const recording: PhysicsRecording = {
|
const recording: PhysicsRecording = {
|
||||||
@ -329,8 +319,7 @@ export class PhysicsRecorder {
|
|||||||
await this._storage.saveRecording(this._currentSessionId, recording);
|
await this._storage.saveRecording(this._currentSessionId, recording);
|
||||||
|
|
||||||
const duration = (metadata.recordingDuration / 1000).toFixed(1);
|
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) to IndexedDB`);
|
||||||
debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) ${configSize} to IndexedDB`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog("PhysicsRecorder: Error during auto-save", error);
|
debugLog("PhysicsRecorder: Error during auto-save", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,8 +58,7 @@ export class PhysicsStorage {
|
|||||||
throw new Error("Database not initialized");
|
throw new Error("Database not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the provided name as recordingId (for session-based grouping)
|
const recordingId = `recording-${Date.now()}`;
|
||||||
const recordingId = name;
|
|
||||||
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -191,76 +190,23 @@ export class PhysicsStorage {
|
|||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const allSegments = request.result;
|
const allSegments = request.result;
|
||||||
|
|
||||||
// Group by recordingId and aggregate all segments
|
// Group by recordingId and get first segment (which has metadata)
|
||||||
const sessionMap = new Map<string, {
|
const recordingMap = new Map();
|
||||||
segments: any[];
|
|
||||||
metadata: any;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Group segments by session
|
|
||||||
allSegments.forEach(segment => {
|
allSegments.forEach(segment => {
|
||||||
if (!sessionMap.has(segment.recordingId)) {
|
if (!recordingMap.has(segment.recordingId) && segment.metadata) {
|
||||||
sessionMap.set(segment.recordingId, {
|
recordingMap.set(segment.recordingId, {
|
||||||
segments: [],
|
id: segment.recordingId,
|
||||||
metadata: null
|
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.from(recordingMap.values());
|
||||||
const recordings: Array<{
|
debugLog(`PhysicsStorage: Found ${recordings.length} recordings`);
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
timestamp: number;
|
|
||||||
duration: number;
|
|
||||||
frameCount: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
sessionMap.forEach((session, recordingId) => {
|
|
||||||
// Sort segments to get first and last
|
|
||||||
session.segments.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
||||||
|
|
||||||
const firstSegment = session.segments[0];
|
|
||||||
const lastSegment = session.segments[session.segments.length - 1];
|
|
||||||
|
|
||||||
// Calculate total frame count across all segments
|
|
||||||
const totalFrames = session.segments.reduce((sum, seg) => sum + seg.snapshots.length, 0);
|
|
||||||
|
|
||||||
// Calculate total duration from first to last snapshot across ALL segments
|
|
||||||
let firstTimestamp = Number.MAX_VALUE;
|
|
||||||
let lastTimestamp = 0;
|
|
||||||
|
|
||||||
session.segments.forEach(seg => {
|
|
||||||
if (seg.snapshots.length > 0) {
|
|
||||||
const segFirstTimestamp = seg.snapshots[0].timestamp;
|
|
||||||
const segLastTimestamp = seg.snapshots[seg.snapshots.length - 1].timestamp;
|
|
||||||
|
|
||||||
if (segFirstTimestamp < firstTimestamp) {
|
|
||||||
firstTimestamp = segFirstTimestamp;
|
|
||||||
}
|
|
||||||
if (segLastTimestamp > lastTimestamp) {
|
|
||||||
lastTimestamp = segLastTimestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalDuration = (lastTimestamp - firstTimestamp) / 1000; // Convert to seconds
|
|
||||||
|
|
||||||
recordings.push({
|
|
||||||
id: recordingId,
|
|
||||||
name: recordingId, // Use session ID as name
|
|
||||||
timestamp: firstSegment.timestamp,
|
|
||||||
duration: totalDuration,
|
|
||||||
frameCount: totalFrames
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
debugLog(`PhysicsStorage: Found ${recordings.length} sessions (${allSegments.length} total segments)`);
|
|
||||||
resolve(recordings);
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
src/ship.ts
85
src/ship.ts
@ -50,11 +50,9 @@ export class Ship {
|
|||||||
private _landingAggregate: PhysicsAggregate | null = null;
|
private _landingAggregate: PhysicsAggregate | null = null;
|
||||||
private _resupplyTimer: number = 0;
|
private _resupplyTimer: number = 0;
|
||||||
private _isInLandingZone: boolean = false;
|
private _isInLandingZone: boolean = false;
|
||||||
private _isReplayMode: boolean;
|
|
||||||
|
|
||||||
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
constructor(audioEngine?: AudioEngineV2) {
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
this._isReplayMode = isReplayMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get scoreboard(): Scoreboard {
|
public get scoreboard(): Scoreboard {
|
||||||
@ -140,48 +138,46 @@ export class Ship {
|
|||||||
this._weapons.setShipStatus(this._scoreboard.shipStatus);
|
this._weapons.setShipStatus(this._scoreboard.shipStatus);
|
||||||
this._weapons.setGameStats(this._gameStats);
|
this._weapons.setGameStats(this._gameStats);
|
||||||
|
|
||||||
// Initialize input systems (skip in replay mode)
|
// Initialize input systems
|
||||||
if (!this._isReplayMode) {
|
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
||||||
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
this._keyboardInput.setup();
|
||||||
this._keyboardInput.setup();
|
|
||||||
|
|
||||||
this._controllerInput = new ControllerInput();
|
this._controllerInput = new ControllerInput();
|
||||||
|
|
||||||
// Wire up shooting events
|
// Wire up shooting events
|
||||||
this._keyboardInput.onShootObservable.add(() => {
|
this._keyboardInput.onShootObservable.add(() => {
|
||||||
this.handleShoot();
|
this.handleShoot();
|
||||||
});
|
});
|
||||||
|
|
||||||
this._controllerInput.onShootObservable.add(() => {
|
this._controllerInput.onShootObservable.add(() => {
|
||||||
this.handleShoot();
|
this.handleShoot();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire up status screen toggle event
|
// Wire up status screen toggle event
|
||||||
this._controllerInput.onStatusScreenToggleObservable.add(() => {
|
this._controllerInput.onStatusScreenToggleObservable.add(() => {
|
||||||
if (this._statusScreen) {
|
if (this._statusScreen) {
|
||||||
this._statusScreen.toggle();
|
this._statusScreen.toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up camera adjustment events
|
||||||
|
this._keyboardInput.onCameraChangeObservable.add((cameraKey) => {
|
||||||
|
if (cameraKey === 1) {
|
||||||
|
this._camera.position.x = 15;
|
||||||
|
this._camera.rotation.y = -Math.PI / 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._controllerInput.onCameraAdjustObservable.add((adjustment) => {
|
||||||
|
if (DefaultScene.XR?.baseExperience?.camera) {
|
||||||
|
const camera = DefaultScene.XR.baseExperience.camera;
|
||||||
|
if (adjustment.direction === "down") {
|
||||||
|
camera.position.y = camera.position.y - 0.1;
|
||||||
|
} else {
|
||||||
|
camera.position.y = camera.position.y + 0.1;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
||||||
// Wire up camera adjustment events
|
|
||||||
this._keyboardInput.onCameraChangeObservable.add((cameraKey) => {
|
|
||||||
if (cameraKey === 1) {
|
|
||||||
this._camera.position.x = 15;
|
|
||||||
this._camera.rotation.y = -Math.PI / 2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this._controllerInput.onCameraAdjustObservable.add((adjustment) => {
|
|
||||||
if (DefaultScene.XR?.baseExperience?.camera) {
|
|
||||||
const camera = DefaultScene.XR.baseExperience.camera;
|
|
||||||
if (adjustment.direction === "down") {
|
|
||||||
camera.position.y = camera.position.y - 0.1;
|
|
||||||
} else {
|
|
||||||
camera.position.y = camera.position.y + 0.1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize physics controller
|
// Initialize physics controller
|
||||||
this._physics = new ShipPhysics();
|
this._physics = new ShipPhysics();
|
||||||
@ -200,18 +196,11 @@ export class Ship {
|
|||||||
// Setup camera
|
// Setup camera
|
||||||
this._camera = new FreeCamera(
|
this._camera = new FreeCamera(
|
||||||
"Flat Camera",
|
"Flat Camera",
|
||||||
new Vector3(0, 1.5, 0),
|
new Vector3(0, 0.5, 0),
|
||||||
DefaultScene.MainScene
|
DefaultScene.MainScene
|
||||||
);
|
);
|
||||||
this._camera.parent = this._ship;
|
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
|
// Create sight reticle
|
||||||
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
|
this._sight = new Sight(DefaultScene.MainScene, this._ship, {
|
||||||
position: new Vector3(0, 0.1, 125),
|
position: new Vector3(0, 0.1, 125),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user