Compare commits
3 Commits
88d380fa3f
...
faa5afc604
| Author | SHA1 | Date | |
|---|---|---|---|
| faa5afc604 | |||
| 343fca4889 | |||
| 128b402955 |
@ -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">📝 Level Editor</a>
|
<a href="#/editor" class="editor-link" style="display: none;">📝 Level Editor</a>
|
||||||
<a href="#/settings" class="settings-link">⚙️ Settings</a>
|
<a href="#/settings" class="settings-link" style="display: none;">⚙️ 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,10 +56,13 @@
|
|||||||
<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;">
|
<div style="text-align: center; margin-top: 20px; display: none;">
|
||||||
<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
|
||||||
|
|||||||
BIN
public/assets/themes/default/audio/voice/armed.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/armed.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/disarmed.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/disarmed.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/empty.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/empty.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/exitarm.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/exitarm.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/full.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/full.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/guns.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/guns.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/returncomplete.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/returncomplete.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/returntobase.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/returntobase.mp3
Normal file
Binary file not shown.
161
src/level1.ts
161
src/level1.ts
@ -27,40 +27,44 @@ 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) {
|
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false) {
|
||||||
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);
|
this._ship = new Ship(audioEngine, isReplayMode);
|
||||||
|
|
||||||
|
// Only set up XR observables in game mode (not replay mode)
|
||||||
|
if (!isReplayMode && DefaultScene.XR) {
|
||||||
|
const xr = DefaultScene.XR;
|
||||||
|
|
||||||
const xr = DefaultScene.XR;
|
debugLog('Level1 constructor - Setting up XR observables');
|
||||||
|
debugLog('XR input exists:', !!xr.input);
|
||||||
|
debugLog('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
|
||||||
|
|
||||||
debugLog('Level1 constructor - Setting up XR observables');
|
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
|
||||||
debugLog('XR input exists:', !!xr.input);
|
xr.baseExperience.camera.parent = this._ship.transformNode;
|
||||||
debugLog('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
|
const currPose = xr.baseExperience.camera.globalPosition.y;
|
||||||
|
xr.baseExperience.camera.position = new Vector3(0, 0, 0);
|
||||||
|
|
||||||
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
|
// Start game timer when XR pose is set
|
||||||
xr.baseExperience.camera.parent = this._ship.transformNode;
|
this._ship.gameStats.startTimer();
|
||||||
const currPose = xr.baseExperience.camera.globalPosition.y;
|
debugLog('Game timer started');
|
||||||
xr.baseExperience.camera.position = new Vector3(0, 0, 0);
|
|
||||||
|
|
||||||
// Start game timer when XR pose is set
|
// Start physics recording when gameplay begins
|
||||||
this._ship.gameStats.startTimer();
|
if (this._physicsRecorder) {
|
||||||
debugLog('Game timer started');
|
this._physicsRecorder.startRingBuffer();
|
||||||
|
debugLog('Physics recorder started');
|
||||||
|
}
|
||||||
|
|
||||||
// Start physics recording when gameplay begins
|
const observer = xr.input.onControllerAddedObservable.add((controller) => {
|
||||||
if (this._physicsRecorder) {
|
debugLog('🎮 onControllerAddedObservable FIRED for:', controller.inputSource.handedness);
|
||||||
this._physicsRecorder.startRingBuffer();
|
this._ship.addController(controller);
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,30 +73,68 @@ 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
|
||||||
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
if (this._audioEngine) {
|
||||||
loop: true,
|
const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", {
|
||||||
volume: 0.5
|
loop: true,
|
||||||
});
|
volume: 0.5
|
||||||
background.play();
|
|
||||||
|
|
||||||
// Enter XR mode
|
|
||||||
const xr = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
|
||||||
// Check for controllers that are already connected after entering XR
|
|
||||||
debugLog('Checking for controllers after entering XR. Count:', DefaultScene.XR.input.controllers.length);
|
|
||||||
DefaultScene.XR.input.controllers.forEach((controller, index) => {
|
|
||||||
debugLog(`Controller ${index} - handedness: ${controller.inputSource.handedness}`);
|
|
||||||
this._ship.addController(controller);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
background.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If XR is available and session is active, check for controllers
|
||||||
|
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 4) { // State 4 = IN_XR
|
||||||
|
// XR session already active, just check for controllers
|
||||||
|
debugLog('XR session already active, checking for controllers. Count:', DefaultScene.XR.input.controllers.length);
|
||||||
|
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) => {
|
||||||
|
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() {
|
||||||
@ -134,8 +176,6 @@ 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, {
|
||||||
@ -148,20 +188,28 @@ 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 && DefaultScene.XR.baseExperience.camera) {
|
if (this._backgroundStars) {
|
||||||
this._backgroundStars.followCamera(DefaultScene.XR.baseExperience.camera.position);
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||||
|
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)
|
||||||
setLoadingMessage("Initializing physics recorder...");
|
// Only create recorder in game mode, not replay mode
|
||||||
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene);
|
if (!this._isReplayMode) {
|
||||||
debugLog('Physics recorder initialized (will start on XR pose)');
|
setLoadingMessage("Initializing physics recorder...");
|
||||||
|
this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
|
||||||
|
debugLog('Physics recorder initialized (will start on XR pose)');
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up recording keyboard shortcuts
|
// Wire up recording keyboard shortcuts (only in game mode)
|
||||||
this._ship.keyboardInput.onRecordingActionObservable.add((action) => {
|
if (!this._isReplayMode) {
|
||||||
this.handleRecordingAction(action);
|
this._ship.keyboardInput.onRecordingActionObservable.add((action) => {
|
||||||
});
|
this.handleRecordingAction(action);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
||||||
@ -206,6 +254,7 @@ export class Level1 implements Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the physics recorder instance
|
* Get the physics recorder instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,6 +7,58 @@
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@ -82,6 +134,8 @@ export interface LevelConfig {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
author?: string;
|
author?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
babylonVersion?: string;
|
||||||
|
captureTime?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -93,6 +147,11 @@ 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,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
AbstractMesh, Color3,
|
AbstractMesh,
|
||||||
|
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";
|
||||||
@ -16,7 +17,6 @@ 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,8 +41,11 @@ export class LevelDeserializer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all entities from the configuration
|
* Create all entities from the configuration
|
||||||
|
* @param scoreObservable - Observable for score events
|
||||||
*/
|
*/
|
||||||
public async deserialize(scoreObservable: Observable<ScoreEvent>): Promise<{
|
public async deserialize(
|
||||||
|
scoreObservable: Observable<ScoreEvent>
|
||||||
|
): Promise<{
|
||||||
startBase: AbstractMesh | null;
|
startBase: AbstractMesh | null;
|
||||||
landingAggregate: PhysicsAggregate | null;
|
landingAggregate: PhysicsAggregate | null;
|
||||||
sun: AbstractMesh;
|
sun: AbstractMesh;
|
||||||
@ -51,19 +54,11 @@ 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,7 +16,15 @@ export function populateLevelSelector(): boolean {
|
|||||||
|
|
||||||
const savedLevels = getSavedLevels();
|
const savedLevels = getSavedLevels();
|
||||||
|
|
||||||
if (savedLevels.size === 0) {
|
// Filter to only show recruit and pilot difficulty levels
|
||||||
|
const filteredLevels = new Map<string, LevelConfig>();
|
||||||
|
for (const [name, config] of savedLevels.entries()) {
|
||||||
|
if (config.difficulty === 'recruit' || config.difficulty === 'pilot') {
|
||||||
|
filteredLevels.set(name, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredLevels.size === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="
|
<div style="
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@ -43,7 +51,7 @@ export function populateLevelSelector(): boolean {
|
|||||||
|
|
||||||
// Create level cards
|
// Create level cards
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const [name, config] of savedLevels.entries()) {
|
for (const [name, config] of filteredLevels.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 } from "@babylonjs/core";
|
import { Vector3, Quaternion, Material, PBRMaterial, StandardMaterial, AbstractMesh, TransformNode } from "@babylonjs/core";
|
||||||
import { DefaultScene } from "./defaultScene";
|
import { DefaultScene } from "./defaultScene";
|
||||||
import {
|
import {
|
||||||
LevelConfig,
|
LevelConfig,
|
||||||
@ -7,7 +7,11 @@ 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';
|
||||||
|
|
||||||
@ -19,21 +23,25 @@ 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'): LevelConfig {
|
public serialize(difficulty: string = 'custom', includeFullScene: boolean = true): 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();
|
||||||
|
|
||||||
return {
|
const config: LevelConfig = {
|
||||||
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,
|
||||||
@ -41,6 +49,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -229,6 +248,197 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -240,6 +450,18 @@ 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,6 +27,8 @@ 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;
|
||||||
@ -41,17 +43,11 @@ 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}`);
|
||||||
@ -71,25 +67,43 @@ export class Main {
|
|||||||
if (settingsLink) {
|
if (settingsLink) {
|
||||||
settingsLink.style.display = 'none';
|
settingsLink.style.display = 'none';
|
||||||
}
|
}
|
||||||
setLoadingMessage("Initializing Level...");
|
setLoadingMessage("Initializing...");
|
||||||
|
|
||||||
|
// Initialize engine and XR first
|
||||||
|
await this.initialize();
|
||||||
|
|
||||||
|
// If XR is available, enter XR immediately (while we have user activation)
|
||||||
|
let xrSession = null;
|
||||||
|
if (DefaultScene.XR) {
|
||||||
|
try {
|
||||||
|
setLoadingMessage("Entering VR...");
|
||||||
|
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
|
||||||
|
debugLog('XR session started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('Failed to enter XR, will fall back to flat mode:', error);
|
||||||
|
DefaultScene.XR = null; // Disable XR for this session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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(() => {
|
this._currentLevel.getReadyObservable().add(async () => {
|
||||||
setLoadingMessage("Level Ready! Entering VR...");
|
setLoadingMessage("Starting game...");
|
||||||
|
|
||||||
// Small delay to show message
|
// Remove UI
|
||||||
setTimeout(() => {
|
mainDiv.remove();
|
||||||
mainDiv.remove();
|
|
||||||
this.play();
|
// Start the game (XR session already active, or flat mode)
|
||||||
}, 500);
|
await this.play();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now initialize the level (after observable is registered)
|
// Now initialize the level (after observable is registered)
|
||||||
@ -143,21 +157,17 @@ 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(() => {
|
this._currentLevel.getReadyObservable().add(async () => {
|
||||||
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...');
|
|
||||||
|
|
||||||
// Small delay to show message
|
// Remove UI and play immediately (must maintain user activation for XR)
|
||||||
setTimeout(() => {
|
if (mainDiv) {
|
||||||
debugLog('[Main] Timeout fired, removing mainDiv and calling play()');
|
mainDiv.remove();
|
||||||
if (mainDiv) {
|
debugLog('[Main] mainDiv removed');
|
||||||
mainDiv.remove();
|
}
|
||||||
debugLog('[Main] mainDiv removed');
|
debugLog('[Main] About to call this.play()...');
|
||||||
}
|
await this.play();
|
||||||
debugLog('[Main] About to call this.play()...');
|
|
||||||
this.play();
|
|
||||||
}, 500);
|
|
||||||
});
|
});
|
||||||
debugLog('[Main] Ready observable registered');
|
debugLog('[Main] Ready observable registered');
|
||||||
|
|
||||||
@ -170,6 +180,90 @@ 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;
|
||||||
@ -193,17 +287,26 @@ export class Main {
|
|||||||
setLoadingMessage("Initializing.");
|
setLoadingMessage("Initializing.");
|
||||||
await this.setupScene();
|
await this.setupScene();
|
||||||
|
|
||||||
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
// Try to initialize WebXR if available
|
||||||
disablePointerSelection: true,
|
if (navigator.xr) {
|
||||||
disableTeleportation: true,
|
try {
|
||||||
disableNearInteraction: true,
|
DefaultScene.XR = await WebXRDefaultExperience.CreateAsync(DefaultScene.MainScene, {
|
||||||
disableHandTracking: true,
|
disablePointerSelection: true,
|
||||||
disableDefaultUI: true
|
disableTeleportation: true,
|
||||||
|
disableNearInteraction: true,
|
||||||
});
|
disableHandTracking: true,
|
||||||
debugLog(WebXRFeaturesManager.GetAvailableFeatures());
|
disableDefaultUI: true
|
||||||
//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,6 +1,7 @@
|
|||||||
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
|
||||||
@ -33,6 +34,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,9 +82,11 @@ 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) {
|
constructor(scene: Scene, levelConfig?: LevelConfig) {
|
||||||
this._scene = scene;
|
this._scene = scene;
|
||||||
|
this._levelConfig = levelConfig || null;
|
||||||
|
|
||||||
// Initialize IndexedDB storage
|
// Initialize IndexedDB storage
|
||||||
this._storage = new PhysicsStorage();
|
this._storage = new PhysicsStorage();
|
||||||
@ -168,10 +172,12 @@ 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
|
// Get all physics-enabled meshes AND transform nodes
|
||||||
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 physicsMeshes) {
|
for (const mesh of allPhysicsObjects) {
|
||||||
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)
|
||||||
@ -300,13 +306,17 @@ 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 = {
|
||||||
@ -319,7 +329,8 @@ 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);
|
||||||
debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) to IndexedDB`);
|
const configSize = levelConfig ? `with scene state (${JSON.stringify(levelConfig).length} bytes)` : 'without scene state';
|
||||||
|
debugLog(`PhysicsRecorder: Auto-saved ${snapshotsToSave.length} frames (${duration}s) ${configSize} to IndexedDB`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog("PhysicsRecorder: Error during auto-save", error);
|
debugLog("PhysicsRecorder: Error during auto-save", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,8 @@ export class PhysicsStorage {
|
|||||||
throw new Error("Database not initialized");
|
throw new Error("Database not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordingId = `recording-${Date.now()}`;
|
// Use the provided name as recordingId (for session-based grouping)
|
||||||
|
const recordingId = name;
|
||||||
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
const segmentSize = 1000; // 1 second at ~7 Hz = ~7 snapshots per segment
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -190,23 +191,76 @@ export class PhysicsStorage {
|
|||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const allSegments = request.result;
|
const allSegments = request.result;
|
||||||
|
|
||||||
// Group by recordingId and get first segment (which has metadata)
|
// Group by recordingId and aggregate all segments
|
||||||
const recordingMap = new Map();
|
const sessionMap = new Map<string, {
|
||||||
|
segments: any[];
|
||||||
|
metadata: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Group segments by session
|
||||||
allSegments.forEach(segment => {
|
allSegments.forEach(segment => {
|
||||||
if (!recordingMap.has(segment.recordingId) && segment.metadata) {
|
if (!sessionMap.has(segment.recordingId)) {
|
||||||
recordingMap.set(segment.recordingId, {
|
sessionMap.set(segment.recordingId, {
|
||||||
id: segment.recordingId,
|
segments: [],
|
||||||
name: segment.name,
|
metadata: null
|
||||||
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
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const recordings = Array.from(recordingMap.values());
|
// Build recording list with aggregated data
|
||||||
debugLog(`PhysicsStorage: Found ${recordings.length} recordings`);
|
const recordings: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
timestamp: number;
|
||||||
|
duration: number;
|
||||||
|
frameCount: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
sessionMap.forEach((session, recordingId) => {
|
||||||
|
// Sort segments to get first and last
|
||||||
|
session.segments.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
||||||
|
|
||||||
|
const firstSegment = session.segments[0];
|
||||||
|
const lastSegment = session.segments[session.segments.length - 1];
|
||||||
|
|
||||||
|
// Calculate total frame count across all segments
|
||||||
|
const totalFrames = session.segments.reduce((sum, seg) => sum + seg.snapshots.length, 0);
|
||||||
|
|
||||||
|
// Calculate total duration from first to last snapshot across ALL segments
|
||||||
|
let firstTimestamp = Number.MAX_VALUE;
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
|
||||||
|
session.segments.forEach(seg => {
|
||||||
|
if (seg.snapshots.length > 0) {
|
||||||
|
const segFirstTimestamp = seg.snapshots[0].timestamp;
|
||||||
|
const segLastTimestamp = seg.snapshots[seg.snapshots.length - 1].timestamp;
|
||||||
|
|
||||||
|
if (segFirstTimestamp < firstTimestamp) {
|
||||||
|
firstTimestamp = segFirstTimestamp;
|
||||||
|
}
|
||||||
|
if (segLastTimestamp > lastTimestamp) {
|
||||||
|
lastTimestamp = segLastTimestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDuration = (lastTimestamp - firstTimestamp) / 1000; // Convert to seconds
|
||||||
|
|
||||||
|
recordings.push({
|
||||||
|
id: recordingId,
|
||||||
|
name: recordingId, // Use session ID as name
|
||||||
|
timestamp: firstSegment.timestamp,
|
||||||
|
duration: totalDuration,
|
||||||
|
frameCount: totalFrames
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLog(`PhysicsStorage: Found ${recordings.length} sessions (${allSegments.length} total segments)`);
|
||||||
resolve(recordings);
|
resolve(recordings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
187
src/replay/ReplayCamera.ts
Normal file
187
src/replay/ReplayCamera.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
390
src/replay/ReplayControls.ts
Normal file
390
src/replay/ReplayControls.ts
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
321
src/replay/ReplayManager.ts
Normal file
321
src/replay/ReplayManager.ts
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
397
src/replay/ReplayPlayer.ts
Normal file
397
src/replay/ReplayPlayer.ts
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/replay/ReplaySelectionScreen.ts
Normal file
371
src/replay/ReplaySelectionScreen.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
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,9 +50,11 @@ 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) {
|
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
|
this._isReplayMode = isReplayMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get scoreboard(): Scoreboard {
|
public get scoreboard(): Scoreboard {
|
||||||
@ -138,46 +140,48 @@ 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
|
// Initialize input systems (skip in replay mode)
|
||||||
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
if (!this._isReplayMode) {
|
||||||
this._keyboardInput.setup();
|
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
||||||
|
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();
|
||||||
@ -196,11 +200,18 @@ export class Ship {
|
|||||||
// Setup camera
|
// Setup camera
|
||||||
this._camera = new FreeCamera(
|
this._camera = new FreeCamera(
|
||||||
"Flat Camera",
|
"Flat Camera",
|
||||||
new Vector3(0, 0.5, 0),
|
new Vector3(0, 1.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