Refactor loading into 3-phase system to mask XR camera repositioning
Phase 1: Prefetch assets (ship.glb, asteroid.glb, base.glb, audio) Phase 2: Create level meshes hidden (before XR entry) Phase 3: Enter XR, init physics, show meshes, unlock audio Key changes: - Split Ship.initialize() into addToScene() + initializePhysics() - Split RockFactory.createRock() into createRockMesh() + initPhysics() - Split StarBase.buildStarBase() into addToScene() + initializePhysics() - Add deserializeMeshes() + initializePhysics() to LevelDeserializer - Update Level1 to orchestrate new phased flow - Fix XR camera parenting (use getTransformNodeByName not getMeshById) - Fix asteroid visibility (show meshes before clearing _createdRocks map) - Add audioPrefetch utility for prefetching audio files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ad2656a61f
commit
18a9ae9978
@ -7,6 +7,8 @@ import { LevelConfig } from "../../levels/config/levelConfig";
|
|||||||
import { Preloader } from "../../ui/screens/preloader";
|
import { Preloader } from "../../ui/screens/preloader";
|
||||||
import { LevelRegistry } from "../../levels/storage/levelRegistry";
|
import { LevelRegistry } from "../../levels/storage/levelRegistry";
|
||||||
import { enterXRMode } from "./xrEntryHandler";
|
import { enterXRMode } from "./xrEntryHandler";
|
||||||
|
import { prefetchAsset } from "../../utils/loadAsset";
|
||||||
|
import { prefetchAllAudio } from "../../utils/audioPrefetch";
|
||||||
import log from '../logger';
|
import log from '../logger';
|
||||||
|
|
||||||
export interface LevelSelectedContext {
|
export interface LevelSelectedContext {
|
||||||
@ -37,8 +39,13 @@ export function createLevelSelectedHandler(
|
|||||||
context.setProgressCallback((p, m) => preloader.updateProgress(p, m));
|
context.setProgressCallback((p, m) => preloader.updateProgress(p, m));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Phase 1: Load engine and prefetch assets
|
||||||
await loadEngineAndAssets(context, preloader);
|
await loadEngineAndAssets(context, preloader);
|
||||||
await context.initializeXR();
|
await context.initializeXR();
|
||||||
|
|
||||||
|
// Phase 2: Create level and add meshes (hidden)
|
||||||
|
const level = await setupLevel(context, config, levelName, preloader);
|
||||||
|
|
||||||
displayLevelInfo(preloader, levelName);
|
displayLevelInfo(preloader, levelName);
|
||||||
preloader.updateProgress(90, 'Ready to enter VR...');
|
preloader.updateProgress(90, 'Ready to enter VR...');
|
||||||
|
|
||||||
@ -48,8 +55,9 @@ export function createLevelSelectedHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3: Enter XR, initialize physics, audio
|
||||||
preloader.showStartButton(async () => {
|
preloader.showStartButton(async () => {
|
||||||
await startGameWithXR(context, config, levelName, preloader);
|
await startGameWithXR(context, level, preloader);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('[Main] Level initialization failed:', error);
|
log.error('[Main] Level initialization failed:', error);
|
||||||
@ -71,14 +79,43 @@ async function loadEngineAndAssets(context: LevelSelectedContext, preloader: Pre
|
|||||||
await context.initializeEngine();
|
await context.initializeEngine();
|
||||||
}
|
}
|
||||||
if (!context.areAssetsLoaded()) {
|
if (!context.areAssetsLoaded()) {
|
||||||
preloader.updateProgress(40, 'Loading 3D models...');
|
preloader.updateProgress(20, 'Loading assets...');
|
||||||
ParticleHelper.BaseAssetsUrl = window.location.href;
|
ParticleHelper.BaseAssetsUrl = window.location.href;
|
||||||
await RockFactory.init();
|
|
||||||
|
// Phase 1: Prefetch all GLBs and audio in parallel
|
||||||
|
await Promise.all([
|
||||||
|
prefetchAsset("ship.glb"),
|
||||||
|
prefetchAsset("asteroid.glb"),
|
||||||
|
prefetchAsset("base.glb"),
|
||||||
|
prefetchAllAudio()
|
||||||
|
]);
|
||||||
|
|
||||||
context.setAssetsLoaded(true);
|
context.setAssetsLoaded(true);
|
||||||
preloader.updateProgress(70, 'Assets loaded');
|
preloader.updateProgress(50, 'Assets loaded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Create level and add meshes to scene (hidden)
|
||||||
|
*/
|
||||||
|
async function setupLevel(
|
||||||
|
context: LevelSelectedContext,
|
||||||
|
config: LevelConfig,
|
||||||
|
levelName: string,
|
||||||
|
preloader: Preloader
|
||||||
|
): Promise<Level1> {
|
||||||
|
preloader.updateProgress(55, 'Creating level...');
|
||||||
|
|
||||||
|
const level = new Level1(config, undefined, false, levelName);
|
||||||
|
context.setCurrentLevel(level);
|
||||||
|
|
||||||
|
// Add meshes to scene (hidden - will show after XR entry)
|
||||||
|
await level.addToScene(true);
|
||||||
|
|
||||||
|
preloader.updateProgress(80, 'Level ready');
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
function displayLevelInfo(preloader: Preloader, levelName: string): void {
|
function displayLevelInfo(preloader: Preloader, levelName: string): void {
|
||||||
const entry = LevelRegistry.getInstance().getLevelEntry(levelName);
|
const entry = LevelRegistry.getInstance().getLevelEntry(levelName);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
@ -86,31 +123,41 @@ function displayLevelInfo(preloader: Preloader, levelName: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: Enter XR, initialize physics, show meshes, initialize audio
|
||||||
|
*/
|
||||||
async function startGameWithXR(
|
async function startGameWithXR(
|
||||||
context: LevelSelectedContext,
|
context: LevelSelectedContext,
|
||||||
config: LevelConfig,
|
level: Level1,
|
||||||
levelName: string,
|
|
||||||
preloader: Preloader
|
preloader: Preloader
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
preloader.updateProgress(92, 'Entering VR...');
|
|
||||||
const engine = context.getEngine();
|
const engine = context.getEngine();
|
||||||
const xrSession = await enterXRMode(config, engine);
|
const config = (level as any)._levelConfig;
|
||||||
|
|
||||||
|
preloader.updateProgress(92, 'Entering VR...');
|
||||||
|
|
||||||
|
// Enter XR mode
|
||||||
|
await enterXRMode(config, engine);
|
||||||
|
|
||||||
|
// Initialize physics (Phase 3)
|
||||||
|
preloader.updateProgress(94, 'Initializing physics...');
|
||||||
|
level.initializePhysics();
|
||||||
|
|
||||||
|
// Show meshes now that XR is active
|
||||||
|
level.showMeshes();
|
||||||
|
|
||||||
|
// Initialize audio after XR entry
|
||||||
const audioEngine = context.getAudioEngine();
|
const audioEngine = context.getAudioEngine();
|
||||||
await audioEngine?.unlockAsync();
|
await audioEngine?.unlockAsync();
|
||||||
preloader.updateProgress(95, 'Loading audio...');
|
preloader.updateProgress(97, 'Loading audio...');
|
||||||
await RockFactory.initAudio(audioEngine);
|
await Promise.all([
|
||||||
|
RockFactory.initAudio(audioEngine),
|
||||||
|
level.initializeAudio(audioEngine)
|
||||||
|
]);
|
||||||
attachAudioListener(audioEngine);
|
attachAudioListener(audioEngine);
|
||||||
|
|
||||||
preloader.updateProgress(98, 'Creating level...');
|
// Finalize
|
||||||
const level = new Level1(config, audioEngine, false, levelName);
|
await finalizeLevelStart(level, engine, preloader, context);
|
||||||
context.setCurrentLevel(level);
|
|
||||||
|
|
||||||
level.getReadyObservable().add(async () => {
|
|
||||||
await finalizeLevelStart(level, xrSession, engine, preloader, context);
|
|
||||||
});
|
|
||||||
|
|
||||||
await level.initialize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachAudioListener(audioEngine: AudioEngineV2): void {
|
function attachAudioListener(audioEngine: AudioEngineV2): void {
|
||||||
@ -122,7 +169,6 @@ function attachAudioListener(audioEngine: AudioEngineV2): void {
|
|||||||
|
|
||||||
async function finalizeLevelStart(
|
async function finalizeLevelStart(
|
||||||
level: Level1,
|
level: Level1,
|
||||||
xrSession: any,
|
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
preloader: Preloader,
|
preloader: Preloader,
|
||||||
context: LevelSelectedContext
|
context: LevelSelectedContext
|
||||||
@ -130,7 +176,7 @@ async function finalizeLevelStart(
|
|||||||
const ship = (level as any)._ship;
|
const ship = (level as any)._ship;
|
||||||
ship?.onReplayRequestObservable.add(() => window.location.reload());
|
ship?.onReplayRequestObservable.add(() => window.location.reload());
|
||||||
|
|
||||||
if (DefaultScene.XR && xrSession && DefaultScene.XR.baseExperience.state === 2) {
|
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) {
|
||||||
level.setupXRCamera();
|
level.setupXRCamera();
|
||||||
await level.showMissionBrief();
|
await level.showMissionBrief();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
import { WebXRDefaultExperience, WebXRFeaturesManager } from "@babylonjs/core";
|
import {
|
||||||
|
WebXRDefaultExperience, WebXRFeaturesManager, WebXRFeatureName, WebXRState,
|
||||||
|
MeshBuilder, StandardMaterial, Color3, Animation, Mesh
|
||||||
|
} from "@babylonjs/core";
|
||||||
import { DefaultScene } from "./defaultScene";
|
import { DefaultScene } from "./defaultScene";
|
||||||
import { InputControlManager } from "../ship/input/inputControlManager";
|
import { InputControlManager } from "../ship/input/inputControlManager";
|
||||||
import log from './logger';
|
import log from './logger';
|
||||||
|
|
||||||
|
const XR_RENDERING_GROUP = 3;
|
||||||
|
const FADE_DELAY_MS = 500;
|
||||||
|
const FADE_DURATION_FRAMES = 60;
|
||||||
|
|
||||||
|
let fadeSphere: Mesh | null = null;
|
||||||
|
let xrStartTime = 0;
|
||||||
|
|
||||||
|
function xrLog(message: string): void {
|
||||||
|
const elapsed = xrStartTime ? Date.now() - xrStartTime : 0;
|
||||||
|
log.debug(`[XR +${elapsed}ms] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProgressReporter {
|
export interface ProgressReporter {
|
||||||
reportProgress(percent: number, message: string): void;
|
reportProgress(percent: number, message: string): void;
|
||||||
}
|
}
|
||||||
@ -35,19 +50,108 @@ async function createXRExperience(): Promise<void> {
|
|||||||
disableTeleportation: true,
|
disableTeleportation: true,
|
||||||
disableNearInteraction: true,
|
disableNearInteraction: true,
|
||||||
disableHandTracking: true,
|
disableHandTracking: true,
|
||||||
disableDefaultUI: true
|
disableDefaultUI: true,
|
||||||
|
disablePointerSelection: true // Disable to re-enable with custom options
|
||||||
});
|
});
|
||||||
log.debug(WebXRFeaturesManager.GetAvailableFeatures());
|
log.debug(WebXRFeaturesManager.GetAvailableFeatures());
|
||||||
|
|
||||||
|
// Enable pointer selection with renderingGroupId so laser is never occluded
|
||||||
|
DefaultScene.XR.baseExperience.featuresManager.enableFeature(
|
||||||
|
WebXRFeatureName.POINTER_SELECTION,
|
||||||
|
"stable",
|
||||||
|
{
|
||||||
|
xrInput: DefaultScene.XR.input,
|
||||||
|
renderingGroupId: XR_RENDERING_GROUP,
|
||||||
|
disablePointerUpOnTouchOut: false,
|
||||||
|
forceGazeMode: false,
|
||||||
|
disableScenePointerVectorUpdate: true // VR mode doesn't need scene pointer updates
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log.debug('Pointer selection enabled with renderingGroupId:', XR_RENDERING_GROUP);
|
||||||
|
createFadeSphere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFadeSphere(): void {
|
||||||
|
const scene = DefaultScene.MainScene;
|
||||||
|
fadeSphere = MeshBuilder.CreateSphere("xrFade", { diameter: 2, sideOrientation: Mesh.BACKSIDE }, scene);
|
||||||
|
const mat = new StandardMaterial("xrFadeMat", scene);
|
||||||
|
mat.emissiveColor = Color3.Black();
|
||||||
|
mat.disableLighting = true;
|
||||||
|
fadeSphere.material = mat;
|
||||||
|
fadeSphere.isPickable = false;
|
||||||
|
fadeSphere.setEnabled(false); // Hidden until XR entry
|
||||||
|
}
|
||||||
|
|
||||||
|
function fadeInScene(): void {
|
||||||
|
if (!fadeSphere?.material) return;
|
||||||
|
xrLog(`Scheduling fade-in after ${FADE_DELAY_MS}ms delay`);
|
||||||
|
setTimeout(() => {
|
||||||
|
xrLog('Starting fade-in animation');
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
"xrFadeIn", fadeSphere!.material!, "alpha",
|
||||||
|
60, FADE_DURATION_FRAMES, 1, 0, Animation.ANIMATIONLOOPMODE_CONSTANT
|
||||||
|
);
|
||||||
|
}, FADE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerXRStateHandler(): void {
|
function registerXRStateHandler(): void {
|
||||||
|
const sessionMgr = DefaultScene.XR!.baseExperience.sessionManager;
|
||||||
|
const xrCamera = DefaultScene.XR!.baseExperience.camera;
|
||||||
|
|
||||||
|
// Earliest hook - session requested and returned
|
||||||
|
sessionMgr.onXRSessionInit.add(() => {
|
||||||
|
xrLog('onXRSessionInit - session created');
|
||||||
|
xrLog(` Camera pos: ${xrCamera.position.toString()}`);
|
||||||
|
xrLog(` Camera parent: ${xrCamera?.parent?.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frame-level logging (first few frames only)
|
||||||
|
let frameCount = 0;
|
||||||
|
sessionMgr.onXRFrameObservable.add(() => {
|
||||||
|
frameCount++;
|
||||||
|
if (frameCount <= 5) {
|
||||||
|
xrLog(`Frame ${frameCount} - Camera pos: ${xrCamera.position.toString()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
DefaultScene.XR!.baseExperience.onStateChangedObservable.add((state) => {
|
DefaultScene.XR!.baseExperience.onStateChangedObservable.add((state) => {
|
||||||
if (state === 2) {
|
const stateName = WebXRState[state];
|
||||||
|
xrLog(`State: ${stateName}`);
|
||||||
|
xrLog(` Camera pos: ${xrCamera.position.toString()}`);
|
||||||
|
xrLog(` Fade sphere enabled: ${fadeSphere?.isEnabled()}`);
|
||||||
|
|
||||||
|
if (state === WebXRState.ENTERING_XR) {
|
||||||
|
xrStartTime = Date.now();
|
||||||
|
xrLog('ENTERING_XR - Starting XR entry');
|
||||||
|
if (fadeSphere) {
|
||||||
|
fadeSphere.parent = xrCamera;
|
||||||
|
const cameraRig = DefaultScene.MainScene.getTransformNodeByName('xrCameraRig');
|
||||||
|
if (!cameraRig) {
|
||||||
|
xrLog(' WARNING: xrCameraRig not found - camera will not be parented to ship');
|
||||||
|
} else {
|
||||||
|
xrLog(` XR Camera Rig found: ${cameraRig.name}`);
|
||||||
|
xrCamera.parent = cameraRig;
|
||||||
|
}
|
||||||
|
fadeSphere.setEnabled(true);
|
||||||
|
xrLog(' Fade sphere parented and enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state === WebXRState.IN_XR) {
|
||||||
|
xrLog('IN_XR - First frame received, camera positioned');
|
||||||
|
registerPointerFeature();
|
||||||
|
fadeInScene();
|
||||||
|
}
|
||||||
|
if (state === WebXRState.NOT_IN_XR && fadeSphere) {
|
||||||
|
fadeSphere.setEnabled(false);
|
||||||
|
xrStartTime = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPointerFeature(): void {
|
||||||
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager
|
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager
|
||||||
.getEnabledFeature("xr-controller-pointer-selection");
|
.getEnabledFeature("xr-controller-pointer-selection");
|
||||||
if (pointerFeature) {
|
if (pointerFeature) {
|
||||||
InputControlManager.getInstance().registerPointerFeature(pointerFeature);
|
InputControlManager.getInstance().registerPointerFeature(pointerFeature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Vector3
|
Vector3
|
||||||
} from "@babylonjs/core";
|
} from "@babylonjs/core";
|
||||||
import {DefaultScene} from "../../core/defaultScene";
|
import {DefaultScene} from "../../core/defaultScene";
|
||||||
|
import { getAudioSource } from "../../utils/audioPrefetch";
|
||||||
import log from '../../core/logger';
|
import log from '../../core/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,15 +69,14 @@ export class ExplosionManager {
|
|||||||
*/
|
*/
|
||||||
public async initAudio(audioEngine: AudioEngineV2): Promise<void> {
|
public async initAudio(audioEngine: AudioEngineV2): Promise<void> {
|
||||||
this.audioEngine = audioEngine;
|
this.audioEngine = audioEngine;
|
||||||
|
|
||||||
log.debug(`ExplosionManager: Initializing audio with pool size ${this.soundPoolSize}`);
|
log.debug(`ExplosionManager: Initializing audio with pool size ${this.soundPoolSize}`);
|
||||||
|
|
||||||
// Create sound pool for concurrent explosions
|
const audioUrl = "/assets/themes/default/audio/explosion.mp3";
|
||||||
|
const audioSource = getAudioSource(audioUrl);
|
||||||
|
const buffer = await audioEngine.createSoundBufferAsync(audioSource);
|
||||||
|
|
||||||
for (let i = 0; i < this.soundPoolSize; i++) {
|
for (let i = 0; i < this.soundPoolSize; i++) {
|
||||||
const sound = await audioEngine.createSoundAsync(
|
const sound = await audioEngine.createSoundAsync(`explosionSound_${i}`, buffer, {
|
||||||
`explosionSound_${i}`,
|
|
||||||
"/assets/themes/default/audio/explosion.mp3",
|
|
||||||
{
|
|
||||||
loop: false,
|
loop: false,
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
spatialEnabled: true,
|
spatialEnabled: true,
|
||||||
@ -84,11 +84,9 @@ export class ExplosionManager {
|
|||||||
spatialMaxDistance: 500,
|
spatialMaxDistance: 500,
|
||||||
spatialMinUpdateTime: 0.5,
|
spatialMinUpdateTime: 0.5,
|
||||||
spatialRolloffFactor: 1
|
spatialRolloffFactor: 1
|
||||||
}
|
});
|
||||||
);
|
|
||||||
this.explosionSounds.push(sound);
|
this.explosionSounds.push(sound);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`ExplosionManager: Loaded ${this.explosionSounds.length} explosion sounds`);
|
log.debug(`ExplosionManager: Loaded ${this.explosionSounds.length} explosion sounds`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,37 +34,84 @@ export class Rock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RockConfig {
|
||||||
|
position: Vector3;
|
||||||
|
scale: number;
|
||||||
|
linearVelocity: Vector3;
|
||||||
|
angularVelocity: Vector3;
|
||||||
|
scoreObservable: Observable<ScoreEvent>;
|
||||||
|
useOrbitConstraint: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class RockFactory {
|
export class RockFactory {
|
||||||
private static _asteroidMesh: AbstractMesh | null = null;
|
private static _asteroidMesh: AbstractMesh | null = null;
|
||||||
private static _explosionManager: ExplosionManager | null = null;
|
private static _explosionManager: ExplosionManager | null = null;
|
||||||
private static _orbitCenter: PhysicsAggregate | null = null;
|
private static _orbitCenter: PhysicsAggregate | null = null;
|
||||||
|
|
||||||
|
// Store created rocks for deferred physics initialization
|
||||||
|
private static _createdRocks: Map<string, { mesh: InstancedMesh; config: RockConfig }> = new Map();
|
||||||
|
|
||||||
/** Public getter for explosion manager (used by WeaponSystem for shape-cast hits) */
|
/** Public getter for explosion manager (used by WeaponSystem for shape-cast hits) */
|
||||||
public static get explosionManager(): ExplosionManager | null {
|
public static get explosionManager(): ExplosionManager | null {
|
||||||
return this._explosionManager;
|
return this._explosionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize non-audio assets (meshes, explosion manager)
|
* Initialize mesh only (Phase 2 - before XR)
|
||||||
* Call this before audio engine is unlocked
|
* Just loads the asteroid mesh template, no physics
|
||||||
*/
|
*/
|
||||||
public static async init() {
|
public static async initMesh(): Promise<void> {
|
||||||
// Initialize explosion manager
|
if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) {
|
||||||
const node = new TransformNode('orbitCenter', DefaultScene.MainScene);
|
await this.loadMesh();
|
||||||
node.position = Vector3.Zero();
|
}
|
||||||
this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 0}, DefaultScene.MainScene );
|
|
||||||
this._orbitCenter.body.setMotionType(PhysicsMotionType.STATIC);
|
// Initialize explosion manager (visual only, audio added later)
|
||||||
|
if (!this._explosionManager) {
|
||||||
this._explosionManager = new ExplosionManager(DefaultScene.MainScene, {
|
this._explosionManager = new ExplosionManager(DefaultScene.MainScene, {
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
explosionForce: 150.0,
|
explosionForce: 150.0,
|
||||||
frameRate: 60
|
frameRate: 60
|
||||||
});
|
});
|
||||||
await this._explosionManager.initialize();
|
await this._explosionManager.initialize();
|
||||||
|
|
||||||
// Reload mesh if not loaded or if it was disposed during cleanup
|
|
||||||
if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) {
|
|
||||||
await this.loadMesh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('[RockFactory] Mesh initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize physics systems (Phase 3 - after XR)
|
||||||
|
* Creates orbit center and initializes physics for all created rocks
|
||||||
|
*/
|
||||||
|
public static initPhysics(): void {
|
||||||
|
// Create orbit center for constraints
|
||||||
|
if (!this._orbitCenter) {
|
||||||
|
const node = new TransformNode('orbitCenter', DefaultScene.MainScene);
|
||||||
|
node.position = Vector3.Zero();
|
||||||
|
this._orbitCenter = new PhysicsAggregate(
|
||||||
|
node, PhysicsShapeType.SPHERE,
|
||||||
|
{ radius: .1, mass: 0 },
|
||||||
|
DefaultScene.MainScene
|
||||||
|
);
|
||||||
|
this._orbitCenter.body.setMotionType(PhysicsMotionType.STATIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize physics and show all created rocks
|
||||||
|
for (const [id, { mesh, config }] of this._createdRocks) {
|
||||||
|
this.initializeRockPhysics(mesh, config);
|
||||||
|
mesh.setEnabled(true);
|
||||||
|
mesh.isVisible = true;
|
||||||
|
}
|
||||||
|
this._createdRocks.clear();
|
||||||
|
|
||||||
|
log.debug('[RockFactory] Physics initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy init - calls initMesh + initPhysics for backwards compatibility
|
||||||
|
*/
|
||||||
|
public static async init(): Promise<void> {
|
||||||
|
await this.initMesh();
|
||||||
|
this.initPhysics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -73,6 +120,7 @@ export class RockFactory {
|
|||||||
public static reset(): void {
|
public static reset(): void {
|
||||||
log.debug('[RockFactory] Resetting static state');
|
log.debug('[RockFactory] Resetting static state');
|
||||||
this._asteroidMesh = null;
|
this._asteroidMesh = null;
|
||||||
|
this._createdRocks.clear();
|
||||||
if (this._explosionManager) {
|
if (this._explosionManager) {
|
||||||
this._explosionManager.dispose();
|
this._explosionManager.dispose();
|
||||||
this._explosionManager = null;
|
this._explosionManager = null;
|
||||||
@ -106,119 +154,154 @@ export class RockFactory {
|
|||||||
log.debug(this._asteroidMesh);
|
log.debug(this._asteroidMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async createRock(i: number, position: Vector3, scale: number,
|
/**
|
||||||
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
|
* Create rock mesh only (Phase 2 - hidden, no physics)
|
||||||
useOrbitConstraint: boolean = true): Promise<Rock> {
|
*/
|
||||||
|
public static createRockMesh(
|
||||||
|
i: number,
|
||||||
|
position: Vector3,
|
||||||
|
scale: number,
|
||||||
|
linearVelocity: Vector3,
|
||||||
|
angularVelocity: Vector3,
|
||||||
|
scoreObservable: Observable<ScoreEvent>,
|
||||||
|
useOrbitConstraint: boolean = true,
|
||||||
|
hidden: boolean = false
|
||||||
|
): Rock {
|
||||||
if (!this._asteroidMesh) {
|
if (!this._asteroidMesh) {
|
||||||
throw new Error('[RockFactory] Asteroid mesh not loaded. Call init() first.');
|
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
|
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
|
||||||
log.debug(rock.id);
|
|
||||||
rock.scaling = new Vector3(scale, scale, scale);
|
rock.scaling = new Vector3(scale, scale, scale);
|
||||||
rock.position = position;
|
rock.position = position;
|
||||||
//rock.material = this._rockMaterial;
|
|
||||||
rock.name = "asteroid-" + i;
|
rock.name = "asteroid-" + i;
|
||||||
rock.id = "asteroid-" + i;
|
rock.id = "asteroid-" + i;
|
||||||
rock.metadata = { type: 'asteroid' };
|
rock.metadata = { type: 'asteroid' };
|
||||||
rock.setEnabled(true);
|
rock.setEnabled(!hidden);
|
||||||
|
rock.isVisible = !hidden;
|
||||||
|
|
||||||
|
// Store config for deferred physics initialization
|
||||||
|
const config: RockConfig = {
|
||||||
|
position,
|
||||||
|
scale,
|
||||||
|
linearVelocity,
|
||||||
|
angularVelocity,
|
||||||
|
scoreObservable,
|
||||||
|
useOrbitConstraint
|
||||||
|
};
|
||||||
|
this._createdRocks.set(rock.id, { mesh: rock, config });
|
||||||
|
|
||||||
|
log.debug(`[RockFactory] Created rock mesh ${rock.id} (hidden: ${hidden})`);
|
||||||
|
return new Rock(rock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize physics for a single rock
|
||||||
|
*/
|
||||||
|
private static initializeRockPhysics(rock: InstancedMesh, config: RockConfig): void {
|
||||||
|
const gameConfig = GameConfig.getInstance();
|
||||||
|
if (!gameConfig.physicsEnabled) return;
|
||||||
|
|
||||||
// Only create physics if enabled in config
|
|
||||||
const config = GameConfig.getInstance();
|
|
||||||
if (config.physicsEnabled) {
|
|
||||||
// PhysicsAggregate will automatically compute sphere size from mesh bounding info
|
|
||||||
// The mesh scaling is already applied, so Babylon will create correctly sized physics shape
|
|
||||||
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
|
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
|
||||||
mass: 200,
|
mass: 200,
|
||||||
friction: 0,
|
friction: 0,
|
||||||
restitution: .8
|
restitution: .8
|
||||||
// Don't pass radius - let Babylon compute from scaled mesh bounds
|
|
||||||
}, DefaultScene.MainScene);
|
}, DefaultScene.MainScene);
|
||||||
|
|
||||||
const body = agg.body;
|
const body = agg.body;
|
||||||
body.setAngularDamping(0);
|
body.setAngularDamping(0);
|
||||||
|
body.setLinearDamping(0);
|
||||||
|
|
||||||
|
|
||||||
// Only apply orbit constraint if enabled for this level and orbit center exists
|
|
||||||
if (useOrbitConstraint && this._orbitCenter) {
|
|
||||||
log.debug(`[RockFactory] Applying orbit constraint for ${rock.name}`);
|
|
||||||
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
|
|
||||||
body.addConstraint(this._orbitCenter.body, constraint);
|
|
||||||
} else {
|
|
||||||
log.debug(`[RockFactory] Orbit constraint disabled for ${rock.name} - asteroid will move freely`);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.setLinearDamping(0)
|
|
||||||
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
||||||
body.setCollisionCallbackEnabled(true);
|
body.setCollisionCallbackEnabled(true);
|
||||||
|
|
||||||
// Prevent asteroids from sleeping to ensure consistent physics simulation
|
// Apply orbit constraint if enabled
|
||||||
|
if (config.useOrbitConstraint && this._orbitCenter) {
|
||||||
|
const constraint = new DistanceConstraint(
|
||||||
|
Vector3.Distance(config.position, this._orbitCenter.body.transformNode.position),
|
||||||
|
DefaultScene.MainScene
|
||||||
|
);
|
||||||
|
body.addConstraint(this._orbitCenter.body, constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent sleeping
|
||||||
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
|
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
|
||||||
if (physicsPlugin) {
|
if (physicsPlugin) {
|
||||||
physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE);
|
physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`[RockFactory] Setting velocities for ${rock.name}:`);
|
body.setLinearVelocity(config.linearVelocity);
|
||||||
log.debug(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`);
|
body.setAngularVelocity(config.angularVelocity);
|
||||||
log.debug(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`);
|
|
||||||
|
|
||||||
body.setLinearVelocity(linearVelocitry);
|
// Setup collision handler
|
||||||
body.setAngularVelocity(angularVelocity);
|
this.setupCollisionHandler(body, config.scoreObservable);
|
||||||
|
|
||||||
// Verify velocities were set
|
log.debug(`[RockFactory] Physics initialized for ${rock.id}`);
|
||||||
const setLinear = body.getLinearVelocity();
|
}
|
||||||
const setAngular = body.getAngularVelocity();
|
|
||||||
log.debug(`[RockFactory] Linear velocity after set: ${setLinear.toString()}`);
|
private static setupCollisionHandler(body: PhysicsBody, scoreObservable: Observable<ScoreEvent>): void {
|
||||||
log.debug(`[RockFactory] Angular velocity after set: ${setAngular.toString()}`);
|
|
||||||
body.getCollisionObservable().add((eventData) => {
|
body.getCollisionObservable().add((eventData) => {
|
||||||
if (eventData.type == 'COLLISION_STARTED') {
|
if (eventData.type !== 'COLLISION_STARTED') return;
|
||||||
if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
|
if (eventData.collidedAgainst.transformNode.id !== 'ammo') return;
|
||||||
log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...');
|
|
||||||
|
|
||||||
// Get the asteroid mesh before disposing
|
|
||||||
const asteroidMesh = eventData.collider.transformNode as AbstractMesh;
|
const asteroidMesh = eventData.collider.transformNode as AbstractMesh;
|
||||||
const asteroidScale = asteroidMesh.scaling.x;
|
const asteroidScale = asteroidMesh.scaling.x;
|
||||||
score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed", scale: asteroidScale});
|
scoreObservable.notifyObservers({
|
||||||
log.debug('[RockFactory] Asteroid mesh to explode:', {
|
score: 1,
|
||||||
name: asteroidMesh.name,
|
remaining: -1,
|
||||||
id: asteroidMesh.id,
|
message: "Asteroid Destroyed",
|
||||||
position: asteroidMesh.getAbsolutePosition().toString()
|
scale: asteroidScale
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dispose asteroid physics objects BEFORE explosion (to prevent double-disposal)
|
// Dispose asteroid physics
|
||||||
log.debug('[RockFactory] Disposing asteroid physics objects...');
|
if (eventData.collider.shape) eventData.collider.shape.dispose();
|
||||||
if (eventData.collider.shape) {
|
if (eventData.collider) eventData.collider.dispose();
|
||||||
eventData.collider.shape.dispose();
|
|
||||||
}
|
|
||||||
if (eventData.collider) {
|
|
||||||
eventData.collider.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play explosion (visual + audio handled by ExplosionManager)
|
// Play explosion
|
||||||
// Note: ExplosionManager will dispose the asteroid mesh after explosion
|
|
||||||
if (RockFactory._explosionManager) {
|
if (RockFactory._explosionManager) {
|
||||||
RockFactory._explosionManager.playExplosion(asteroidMesh);
|
RockFactory._explosionManager.playExplosion(asteroidMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispose projectile physics objects
|
// Dispose projectile physics
|
||||||
log.debug('[RockFactory] Disposing projectile physics objects...');
|
if (eventData.collidedAgainst.shape) eventData.collidedAgainst.shape.dispose();
|
||||||
if (eventData.collidedAgainst.shape) {
|
if (eventData.collidedAgainst.transformNode) eventData.collidedAgainst.transformNode.dispose();
|
||||||
eventData.collidedAgainst.shape.dispose();
|
if (eventData.collidedAgainst) eventData.collidedAgainst.dispose();
|
||||||
}
|
|
||||||
if (eventData.collidedAgainst.transformNode) {
|
|
||||||
eventData.collidedAgainst.transformNode.dispose();
|
|
||||||
}
|
|
||||||
if (eventData.collidedAgainst) {
|
|
||||||
eventData.collidedAgainst.dispose();
|
|
||||||
}
|
|
||||||
log.debug('[RockFactory] Disposal complete');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Rock(rock);
|
/**
|
||||||
|
* Show all created rock meshes (no-op if initPhysics already showed them)
|
||||||
|
*/
|
||||||
|
public static showMeshes(): void {
|
||||||
|
for (const { mesh } of this._createdRocks.values()) {
|
||||||
|
mesh.setEnabled(true);
|
||||||
|
mesh.isVisible = true;
|
||||||
|
}
|
||||||
|
log.debug('[RockFactory] showMeshes called');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy createRock - creates mesh with immediate physics (backwards compatible)
|
||||||
|
*/
|
||||||
|
public static async createRock(
|
||||||
|
i: number,
|
||||||
|
position: Vector3,
|
||||||
|
scale: number,
|
||||||
|
linearVelocity: Vector3,
|
||||||
|
angularVelocity: Vector3,
|
||||||
|
score: Observable<ScoreEvent>,
|
||||||
|
useOrbitConstraint: boolean = true
|
||||||
|
): Promise<Rock> {
|
||||||
|
const rock = this.createRockMesh(i, position, scale, linearVelocity, angularVelocity, score, useOrbitConstraint, false);
|
||||||
|
|
||||||
|
// Immediately initialize physics for this rock (legacy behavior)
|
||||||
|
const meshId = "asteroid-" + i;
|
||||||
|
const rockData = this._createdRocks.get(meshId);
|
||||||
|
if (rockData) {
|
||||||
|
this.initializeRockPhysics(rockData.mesh, rockData.config);
|
||||||
|
this._createdRocks.delete(meshId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,20 +13,32 @@ import {Vector3Array} from "../../levels/config/levelConfig";
|
|||||||
|
|
||||||
interface StarBaseResult {
|
interface StarBaseResult {
|
||||||
baseMesh: AbstractMesh;
|
baseMesh: AbstractMesh;
|
||||||
|
landingMesh: AbstractMesh;
|
||||||
landingAggregate: PhysicsAggregate | null;
|
landingAggregate: PhysicsAggregate | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StarBaseMeshResult {
|
||||||
|
baseMesh: AbstractMesh;
|
||||||
|
landingMesh: AbstractMesh;
|
||||||
|
container: any;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and load the star base mesh
|
* Create and load the star base mesh
|
||||||
* @param position - Position for the star base (defaults to [0, 0, 0])
|
|
||||||
* @param baseGlbPath - Path to the base GLB file (defaults to 'base.glb')
|
|
||||||
* @returns Promise resolving to the loaded star base mesh and landing aggregate
|
|
||||||
*/
|
*/
|
||||||
export default class StarBase {
|
export default class StarBase {
|
||||||
public static async buildStarBase(position?: Vector3Array, baseGlbPath: string = 'base.glb'): Promise<StarBaseResult> {
|
// Store loaded mesh data for deferred physics
|
||||||
const config = GameConfig.getInstance();
|
private static _loadedBase: StarBaseMeshResult | null = null;
|
||||||
const scene = DefaultScene.MainScene;
|
|
||||||
const importMeshes = await loadAsset(baseGlbPath);
|
/**
|
||||||
|
* Add base to scene (Phase 2 - mesh only, no physics)
|
||||||
|
*/
|
||||||
|
public static async addToScene(
|
||||||
|
position?: Vector3Array,
|
||||||
|
baseGlbPath: string = 'base.glb',
|
||||||
|
hidden: boolean = false
|
||||||
|
): Promise<StarBaseMeshResult> {
|
||||||
|
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
|
||||||
|
|
||||||
const baseMesh = importMeshes.meshes.get('Base');
|
const baseMesh = importMeshes.meshes.get('Base');
|
||||||
const landingMesh = importMeshes.meshes.get('BaseLandingZone');
|
const landingMesh = importMeshes.meshes.get('BaseLandingZone');
|
||||||
@ -37,31 +49,91 @@ export default class StarBase {
|
|||||||
baseMesh.metadata.baseGlbPath = baseGlbPath;
|
baseMesh.metadata.baseGlbPath = baseGlbPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply position to both meshes (defaults to [0, 0, 0])
|
// Apply position
|
||||||
(importMeshes.container.rootNodes[0] as TransformNode).position
|
(importMeshes.container.rootNodes[0] as TransformNode).position =
|
||||||
= position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
|
position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
|
||||||
|
|
||||||
let landingAgg: PhysicsAggregate | null = null;
|
this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container };
|
||||||
|
|
||||||
if (config.physicsEnabled) {
|
log.debug(`[StarBase] Added to scene (hidden: ${hidden})`);
|
||||||
const agg2 = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, {
|
return { baseMesh, landingMesh, container: importMeshes.container };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize physics for the base (Phase 3 - after XR)
|
||||||
|
*/
|
||||||
|
public static initializePhysics(): PhysicsAggregate | null {
|
||||||
|
if (!this._loadedBase) {
|
||||||
|
log.warn('[StarBase] No loaded base to initialize physics for');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = GameConfig.getInstance();
|
||||||
|
if (!config.physicsEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = DefaultScene.MainScene;
|
||||||
|
const { baseMesh, landingMesh } = this._loadedBase;
|
||||||
|
|
||||||
|
// Create physics for base
|
||||||
|
const baseAgg = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, {
|
||||||
mass: 10000
|
mass: 10000
|
||||||
}, scene);
|
}, scene);
|
||||||
agg2.body.setMotionType(PhysicsMotionType.ANIMATED);
|
baseAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
||||||
|
baseAgg.body.getCollisionObservable().add((collidedBody) => {
|
||||||
agg2.body.getCollisionObservable().add((collidedBody) => {
|
|
||||||
log.debug('collidedBody', collidedBody);
|
log.debug('collidedBody', collidedBody);
|
||||||
})
|
});
|
||||||
|
|
||||||
landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH);
|
// Create physics for landing zone
|
||||||
|
const landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH);
|
||||||
landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
||||||
landingAgg.shape.isTrigger = true;
|
landingAgg.shape.isTrigger = true;
|
||||||
landingAgg.body.setCollisionCallbackEnabled(true);
|
landingAgg.body.setCollisionCallbackEnabled(true);
|
||||||
|
|
||||||
|
log.debug('[StarBase] Physics initialized');
|
||||||
|
return landingAgg;
|
||||||
}
|
}
|
||||||
//importMesh.rootNodes[0].dispose();
|
|
||||||
|
/**
|
||||||
|
* Show base meshes
|
||||||
|
*/
|
||||||
|
public static showMeshes(): void {
|
||||||
|
if (this._loadedBase) {
|
||||||
|
const { baseMesh, landingMesh } = this._loadedBase;
|
||||||
|
if (baseMesh) {
|
||||||
|
baseMesh.isVisible = true;
|
||||||
|
baseMesh.setEnabled(true);
|
||||||
|
}
|
||||||
|
if (landingMesh) {
|
||||||
|
landingMesh.isVisible = true;
|
||||||
|
landingMesh.setEnabled(true);
|
||||||
|
}
|
||||||
|
log.debug('[StarBase] Meshes shown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset static state
|
||||||
|
*/
|
||||||
|
public static reset(): void {
|
||||||
|
this._loadedBase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy buildStarBase - for backwards compatibility
|
||||||
|
*/
|
||||||
|
public static async buildStarBase(
|
||||||
|
position?: Vector3Array,
|
||||||
|
baseGlbPath: string = 'base.glb'
|
||||||
|
): Promise<StarBaseResult> {
|
||||||
|
const meshResult = await this.addToScene(position, baseGlbPath, false);
|
||||||
|
const landingAggregate = this.initializePhysics();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseMesh,
|
baseMesh: meshResult.baseMesh,
|
||||||
landingAggregate: landingAgg
|
landingMesh: meshResult.landingMesh,
|
||||||
|
landingAggregate
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -48,29 +48,36 @@ export class LevelDeserializer {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store score observable for deferred physics
|
||||||
|
private _scoreObservable: Observable<ScoreEvent> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all entities from the configuration
|
* Deserialize meshes only (Phase 2 - before XR, hidden)
|
||||||
* @param scoreObservable - Observable for score events
|
|
||||||
*/
|
*/
|
||||||
public async deserialize(
|
public async deserializeMeshes(
|
||||||
scoreObservable: Observable<ScoreEvent>
|
scoreObservable: Observable<ScoreEvent>,
|
||||||
|
hidden: boolean = false
|
||||||
): Promise<{
|
): Promise<{
|
||||||
startBase: AbstractMesh | null;
|
startBase: AbstractMesh | null;
|
||||||
landingAggregate: PhysicsAggregate | null;
|
|
||||||
sun: AbstractMesh;
|
sun: AbstractMesh;
|
||||||
planets: AbstractMesh[];
|
planets: AbstractMesh[];
|
||||||
asteroids: AbstractMesh[];
|
asteroids: AbstractMesh[];
|
||||||
}> {
|
}> {
|
||||||
log.debug('Deserializing level:', this.config.difficulty);
|
log.debug(`[LevelDeserializer] Deserializing meshes (hidden: ${hidden})`);
|
||||||
|
this._scoreObservable = scoreObservable;
|
||||||
|
|
||||||
const baseResult = await this.createStartBase();
|
// Create base mesh (no physics)
|
||||||
|
const baseResult = await this.createStartBaseMesh(hidden);
|
||||||
|
|
||||||
|
// Create sun and planets (procedural, no physics needed)
|
||||||
const sun = this.createSun();
|
const sun = this.createSun();
|
||||||
const planets = this.createPlanets();
|
const planets = this.createPlanets();
|
||||||
const asteroids = await this.createAsteroids(scoreObservable);
|
|
||||||
|
// Create asteroid meshes (no physics)
|
||||||
|
const asteroids = await this.createAsteroidMeshes(scoreObservable, hidden);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBase: baseResult.baseMesh,
|
startBase: baseResult?.baseMesh || null,
|
||||||
landingAggregate: baseResult.landingAggregate,
|
|
||||||
sun,
|
sun,
|
||||||
planets,
|
planets,
|
||||||
asteroids
|
asteroids
|
||||||
@ -78,12 +85,36 @@ export class LevelDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the start base from config
|
* Initialize physics for all entities (Phase 3 - after XR)
|
||||||
*/
|
*/
|
||||||
private async createStartBase() {
|
public initializePhysics(): PhysicsAggregate | null {
|
||||||
|
log.debug('[LevelDeserializer] Initializing physics');
|
||||||
|
|
||||||
|
// Initialize base physics
|
||||||
|
const landingAggregate = StarBase.initializePhysics();
|
||||||
|
|
||||||
|
// Initialize asteroid physics
|
||||||
|
RockFactory.initPhysics();
|
||||||
|
|
||||||
|
return landingAggregate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show all meshes (call after XR entry)
|
||||||
|
*/
|
||||||
|
public showMeshes(): void {
|
||||||
|
StarBase.showMeshes();
|
||||||
|
RockFactory.showMeshes();
|
||||||
|
log.debug('[LevelDeserializer] All meshes shown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create base mesh only (no physics)
|
||||||
|
*/
|
||||||
|
private async createStartBaseMesh(hidden: boolean) {
|
||||||
const position = this.config.startBase?.position;
|
const position = this.config.startBase?.position;
|
||||||
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb';
|
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb';
|
||||||
return await StarBase.buildStarBase(position, baseGlbPath);
|
return await StarBase.addToScene(position, baseGlbPath, hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,49 +194,41 @@ export class LevelDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create asteroids from config
|
* Create asteroid meshes only (no physics)
|
||||||
*/
|
*/
|
||||||
private async createAsteroids(
|
private async createAsteroidMeshes(
|
||||||
scoreObservable: Observable<ScoreEvent>
|
scoreObservable: Observable<ScoreEvent>,
|
||||||
|
hidden: boolean
|
||||||
): Promise<AbstractMesh[]> {
|
): Promise<AbstractMesh[]> {
|
||||||
const asteroids: AbstractMesh[] = [];
|
const asteroids: AbstractMesh[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < this.config.asteroids.length; i++) {
|
for (let i = 0; i < this.config.asteroids.length; i++) {
|
||||||
const asteroidConfig = this.config.asteroids[i];
|
const asteroidConfig = this.config.asteroids[i];
|
||||||
|
|
||||||
log.debug(`[LevelDeserializer] Creating asteroid ${i} (${asteroidConfig.id}):`);
|
|
||||||
log.debug(`[LevelDeserializer] Position: [${asteroidConfig.position.join(', ')}]`);
|
|
||||||
log.debug(`[LevelDeserializer] Scale: ${asteroidConfig.scale}`);
|
|
||||||
log.debug(`[LevelDeserializer] Linear velocity: [${asteroidConfig.linearVelocity.join(', ')}]`);
|
|
||||||
log.debug(`[LevelDeserializer] Angular velocity: [${asteroidConfig.angularVelocity.join(', ')}]`);
|
|
||||||
|
|
||||||
// Use orbit constraints by default (true if not specified)
|
|
||||||
const useOrbitConstraints = this.config.useOrbitConstraints !== false;
|
const useOrbitConstraints = this.config.useOrbitConstraints !== false;
|
||||||
log.debug(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`);
|
|
||||||
|
|
||||||
// Use RockFactory to create the asteroid
|
// Create mesh only (no physics)
|
||||||
const _rock = await RockFactory.createRock(
|
RockFactory.createRockMesh(
|
||||||
i,
|
i,
|
||||||
this.arrayToVector3(asteroidConfig.position),
|
this.arrayToVector3(asteroidConfig.position),
|
||||||
asteroidConfig.scale,
|
asteroidConfig.scale,
|
||||||
this.arrayToVector3(asteroidConfig.linearVelocity),
|
this.arrayToVector3(asteroidConfig.linearVelocity),
|
||||||
this.arrayToVector3(asteroidConfig.angularVelocity),
|
this.arrayToVector3(asteroidConfig.angularVelocity),
|
||||||
scoreObservable,
|
scoreObservable,
|
||||||
useOrbitConstraints
|
useOrbitConstraints,
|
||||||
|
hidden
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the actual mesh from the Rock object
|
|
||||||
// The Rock class wraps the mesh, need to access it via position getter
|
|
||||||
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
asteroids.push(mesh);
|
asteroids.push(mesh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Created ${asteroids.length} asteroids from config`);
|
log.debug(`[LevelDeserializer] Created ${asteroids.length} asteroid meshes (hidden: ${hidden})`);
|
||||||
return asteroids;
|
return asteroids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ship configuration (for external use to position ship)
|
* Get ship configuration (for external use to position ship)
|
||||||
*/
|
*/
|
||||||
@ -220,64 +243,4 @@ export class LevelDeserializer {
|
|||||||
return new Vector3(arr[0], arr[1], arr[2]);
|
return new Vector3(arr[0], arr[1], arr[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Static helper to load from JSON string
|
|
||||||
*/
|
|
||||||
public static fromJSON(json: string): LevelDeserializer {
|
|
||||||
const config = JSON.parse(json) as LevelConfig;
|
|
||||||
return new LevelDeserializer(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static helper to load from JSON file URL
|
|
||||||
*/
|
|
||||||
public static async fromURL(url: string): Promise<LevelDeserializer> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load level config from ${url}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const json = await response.text();
|
|
||||||
return LevelDeserializer.fromJSON(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static helper to load from uploaded file
|
|
||||||
*/
|
|
||||||
public static async fromFile(file: File): Promise<LevelDeserializer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const json = e.target?.result as string;
|
|
||||||
resolve(LevelDeserializer.fromJSON(json));
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static helper to load from Level Registry by ID
|
|
||||||
* This is the preferred method for loading both default and custom levels
|
|
||||||
*/
|
|
||||||
public static async fromRegistry(levelId: string): Promise<LevelDeserializer> {
|
|
||||||
const registry = LevelRegistry.getInstance();
|
|
||||||
|
|
||||||
// Ensure registry is initialized
|
|
||||||
if (!registry.isInitialized()) {
|
|
||||||
await registry.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get level config from registry (loads if not already loaded)
|
|
||||||
const config = await registry.getLevel(levelId);
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
throw new Error(`Level not found in registry: ${levelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LevelDeserializer(config);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,14 @@ export class LevelHintSystem {
|
|||||||
// Track triggered thresholds to prevent re-triggering
|
// Track triggered thresholds to prevent re-triggering
|
||||||
private _triggeredThresholds: Set<string> = new Set();
|
private _triggeredThresholds: Set<string> = new Set();
|
||||||
|
|
||||||
constructor(audioEngine: AudioEngineV2) {
|
constructor(audioEngine?: AudioEngineV2) {
|
||||||
|
this._audioEngine = audioEngine!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set audio engine (for deferred initialization)
|
||||||
|
*/
|
||||||
|
public setAudioEngine(audioEngine: AudioEngineV2): void {
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +167,12 @@ export class LevelHintSystem {
|
|||||||
* Queue a hint for audio playback
|
* Queue a hint for audio playback
|
||||||
*/
|
*/
|
||||||
private queueHint(hint: HintEntry): void {
|
private queueHint(hint: HintEntry): void {
|
||||||
|
// Skip if audio engine not initialized yet
|
||||||
|
if (!this._audioEngine) {
|
||||||
|
log.debug('[LevelHintSystem] Skipping hint - audio not initialized:', hint.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if 'once' hint already played
|
// Check if 'once' hint already played
|
||||||
if (hint.playMode === 'once' && this._playedHints.has(hint.id)) {
|
if (hint.playMode === 'once' && this._playedHints.has(hint.id)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {LevelRegistry} from "./storage/levelRegistry";
|
|||||||
import type {CloudLevelEntry} from "../services/cloudLevelService";
|
import type {CloudLevelEntry} from "../services/cloudLevelService";
|
||||||
import { InputControlManager } from "../ship/input/inputControlManager";
|
import { InputControlManager } from "../ship/input/inputControlManager";
|
||||||
import { LevelHintSystem } from "./hints/levelHintSystem";
|
import { LevelHintSystem } from "./hints/levelHintSystem";
|
||||||
|
import { getAudioSource } from "../utils/audioPrefetch";
|
||||||
|
import { RockFactory } from "../environment/asteroids/rockFactory";
|
||||||
|
|
||||||
export class Level1 implements Level {
|
export class Level1 implements Level {
|
||||||
private _ship: Ship;
|
private _ship: Ship;
|
||||||
@ -40,37 +42,22 @@ export class Level1 implements Level {
|
|||||||
private _hintSystem: LevelHintSystem;
|
private _hintSystem: LevelHintSystem;
|
||||||
private _gameStarted: boolean = false;
|
private _gameStarted: boolean = false;
|
||||||
private _missionBriefShown: boolean = false;
|
private _missionBriefShown: boolean = false;
|
||||||
|
private _asteroidCount: number = 0;
|
||||||
|
|
||||||
constructor(levelConfig: LevelConfig, audioEngine: AudioEngineV2, isReplayMode: boolean = false, levelId?: string) {
|
constructor(levelConfig: LevelConfig, audioEngine?: AudioEngineV2, isReplayMode: boolean = false, levelId?: string) {
|
||||||
this._levelConfig = levelConfig;
|
this._levelConfig = levelConfig;
|
||||||
this._levelId = levelId || null;
|
this._levelId = levelId || null;
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine!; // Will be set later if not provided
|
||||||
this._isReplayMode = isReplayMode;
|
this._isReplayMode = isReplayMode;
|
||||||
this._deserializer = new LevelDeserializer(levelConfig);
|
this._deserializer = new LevelDeserializer(levelConfig);
|
||||||
this._ship = new Ship(audioEngine, isReplayMode);
|
this._ship = new Ship(undefined, isReplayMode); // Audio initialized separately
|
||||||
this._missionBrief = new MissionBrief();
|
this._missionBrief = new MissionBrief();
|
||||||
this._hintSystem = new LevelHintSystem(audioEngine);
|
this._hintSystem = new LevelHintSystem(undefined); // Audio initialized separately
|
||||||
|
|
||||||
// Only set up XR observables in game mode (not replay mode)
|
|
||||||
if (!isReplayMode && DefaultScene.XR) {
|
|
||||||
const xr = DefaultScene.XR;
|
|
||||||
|
|
||||||
log.debug('Level1 constructor - Setting up XR observables');
|
// XR camera setup and mission brief are now handled by levelSelectedHandler
|
||||||
log.debug('XR input exists:', !!xr.input);
|
// after audio is initialized (see finalizeLevelStart)
|
||||||
log.debug('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
|
// Don't call initialize here - let Main call it after setup
|
||||||
|
|
||||||
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
|
|
||||||
log.debug('[Level1] onInitialXRPoseSetObservable fired');
|
|
||||||
|
|
||||||
// Use consolidated XR camera setup
|
|
||||||
this.setupXRCamera();
|
|
||||||
|
|
||||||
// Show mission brief after camera setup
|
|
||||||
log.debug('[Level1] Showing mission brief on XR entry');
|
|
||||||
this.showMissionBrief();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Don't call initialize here - let Main call it after registering the observable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getReadyObservable(): Observable<Level> {
|
getReadyObservable(): Observable<Level> {
|
||||||
@ -99,16 +86,6 @@ export class Level1 implements Level {
|
|||||||
// Create intermediate TransformNode for camera rotation
|
// Create intermediate TransformNode for camera rotation
|
||||||
// WebXR camera only uses rotationQuaternion (not .rotation), and XR frame updates overwrite it
|
// WebXR camera only uses rotationQuaternion (not .rotation), and XR frame updates overwrite it
|
||||||
// By rotating an intermediate node, we can orient the camera without fighting XR frame updates
|
// By rotating an intermediate node, we can orient the camera without fighting XR frame updates
|
||||||
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
|
|
||||||
cameraRig.parent = this._ship.transformNode;
|
|
||||||
cameraRig.rotation = new Vector3(0, 0, 0); // Rotate 180° to face forward
|
|
||||||
log.debug('[Level1] Created cameraRig TransformNode, rotated 180°');
|
|
||||||
|
|
||||||
// Parent XR camera to the rig
|
|
||||||
xr.baseExperience.camera.parent = cameraRig;
|
|
||||||
xr.baseExperience.camera.position = new Vector3(0, 1.2, 0);
|
|
||||||
log.debug('[Level1] XR camera parented to cameraRig at position (0, 1.2, 0)');
|
|
||||||
|
|
||||||
// Show the canvas now that camera is parented
|
// Show the canvas now that camera is parented
|
||||||
const canvas = document.getElementById('gameCanvas');
|
const canvas = document.getElementById('gameCanvas');
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
@ -156,6 +133,16 @@ export class Level1 implements Level {
|
|||||||
xr.input.onControllerAddedObservable.add((controller) => {
|
xr.input.onControllerAddedObservable.add((controller) => {
|
||||||
log.debug('[Level1] 🎮 Controller added:', controller.inputSource.handedness);
|
log.debug('[Level1] 🎮 Controller added:', controller.inputSource.handedness);
|
||||||
this._ship.addController(controller);
|
this._ship.addController(controller);
|
||||||
|
|
||||||
|
// Set controller meshes to render on top (never occluded)
|
||||||
|
controller.onMeshLoadedObservable.add((mesh) => {
|
||||||
|
const XR_RENDERING_GROUP = 3;
|
||||||
|
mesh.renderingGroupId = XR_RENDERING_GROUP;
|
||||||
|
mesh.getChildMeshes().forEach((child) => {
|
||||||
|
child.renderingGroupId = XR_RENDERING_GROUP;
|
||||||
|
});
|
||||||
|
log.debug('[Level1] Controller mesh renderingGroupId set to', XR_RENDERING_GROUP);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug('[Level1] ========== setupXRCamera COMPLETE ==========');
|
log.debug('[Level1] ========== setupXRCamera COMPLETE ==========');
|
||||||
@ -356,98 +343,90 @@ export class Level1 implements Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize() {
|
/**
|
||||||
log.debug('Initializing level from config:', this._levelConfig.difficulty);
|
* Initialize audio systems (call after audio engine unlock)
|
||||||
|
* Separated from initialize() to allow level creation before XR entry
|
||||||
|
*/
|
||||||
|
public async initializeAudio(audioEngine: AudioEngineV2): Promise<void> {
|
||||||
|
log.debug('[Level1] Initializing audio systems');
|
||||||
|
this._audioEngine = audioEngine;
|
||||||
|
|
||||||
|
// Initialize ship audio
|
||||||
|
await this._ship.initializeAudio(audioEngine);
|
||||||
|
|
||||||
|
// Initialize hint system audio
|
||||||
|
this._hintSystem.setAudioEngine(audioEngine);
|
||||||
|
|
||||||
|
// Load background music (uses prefetched audio if available)
|
||||||
|
const musicUrl = "/assets/themes/default/audio/song1.mp3";
|
||||||
|
this._backgroundMusic = await audioEngine.createSoundAsync(
|
||||||
|
"background",
|
||||||
|
getAudioSource(musicUrl),
|
||||||
|
{ loop: true, volume: 0.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize mission brief audio
|
||||||
|
this._missionBrief.initialize(audioEngine);
|
||||||
|
|
||||||
|
log.debug('[Level1] Audio initialization complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add level meshes to scene (Phase 2 - before XR, hidden)
|
||||||
|
*/
|
||||||
|
public async addToScene(hidden: boolean = true): Promise<void> {
|
||||||
|
log.debug(`[Level1] addToScene called (hidden: ${hidden})`);
|
||||||
|
|
||||||
if (this._initialized) {
|
if (this._initialized) {
|
||||||
log.error('Initialize called twice');
|
log.error('[Level1] Already initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Get ship config BEFORE initialize to pass position (avoids physics race condition)
|
|
||||||
|
// Initialize RockFactory mesh (needs to happen before deserialize)
|
||||||
|
await RockFactory.initMesh();
|
||||||
|
|
||||||
|
// Get ship config and add ship to scene
|
||||||
const shipConfig = this._deserializer.getShipConfig();
|
const shipConfig = this._deserializer.getShipConfig();
|
||||||
await this._ship.initialize(new Vector3(...shipConfig.position));
|
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
|
||||||
|
|
||||||
|
// Create XR camera rig
|
||||||
|
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
|
||||||
|
//cameraRig.position.set(0, 1.2, 0);
|
||||||
|
cameraRig.parent = this._ship.transformNode;
|
||||||
|
|
||||||
setLoadingMessage("Loading level from configuration...");
|
setLoadingMessage("Loading level from configuration...");
|
||||||
|
|
||||||
if (shipConfig.linearVelocity) {
|
// Deserialize level meshes (no physics)
|
||||||
this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity));
|
const entities = await this._deserializer.deserializeMeshes(
|
||||||
} else {
|
this._ship.scoreboard.onScoreObservable,
|
||||||
this._ship.setLinearVelocity(Vector3.Zero());
|
hidden
|
||||||
}
|
);
|
||||||
|
|
||||||
if (shipConfig.angularVelocity) {
|
|
||||||
this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity));
|
|
||||||
} else {
|
|
||||||
this._ship.setAngularVelocity(Vector3.Zero());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use deserializer to create all entities from config
|
|
||||||
const entities = await this._deserializer.deserialize(this._ship.scoreboard.onScoreObservable);
|
|
||||||
|
|
||||||
this._startBase = entities.startBase;
|
this._startBase = entities.startBase;
|
||||||
this._landingAggregate = entities.landingAggregate;
|
this._asteroidCount = entities.asteroids.length;
|
||||||
|
|
||||||
// Setup resupply system if landing aggregate exists
|
// Initialize scoreboard with asteroid count
|
||||||
if (this._landingAggregate) {
|
this._ship.scoreboard.setRemainingCount(this._asteroidCount);
|
||||||
this._ship.setLandingZone(this._landingAggregate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// sun and planets are already created by deserializer
|
|
||||||
|
|
||||||
// Initialize scoreboard with total asteroid count
|
|
||||||
this._ship.scoreboard.setRemainingCount(entities.asteroids.length);
|
|
||||||
log.debug(`Initialized scoreboard with ${entities.asteroids.length} asteroids`);
|
|
||||||
|
|
||||||
// Create background starfield
|
// Create background starfield
|
||||||
setLoadingMessage("Creating starfield...");
|
|
||||||
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
|
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
|
||||||
count: 5000,
|
count: 5000,
|
||||||
radius: 5000,
|
radius: 3000,
|
||||||
minBrightness: 0.3,
|
minBrightness: 0.1,
|
||||||
maxBrightness: 1.0,
|
maxBrightness: 1.0,
|
||||||
pointSize: 2
|
pointSize: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up camera follow for stars (keeps stars at infinite distance)
|
// Set up render loop updates
|
||||||
// Also update hint system audio queue
|
|
||||||
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
||||||
if (this._backgroundStars) {
|
if (this._backgroundStars) {
|
||||||
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||||
if (camera) {
|
if (camera) this._backgroundStars.followCamera(camera.globalPosition);
|
||||||
this._backgroundStars.followCamera(camera.position);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Process hint audio queue
|
|
||||||
this._hintSystem?.update();
|
this._hintSystem?.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize physics recorder (but don't start it yet - will start on XR pose)
|
// Load hints (non-physics)
|
||||||
// Only create recorder in game mode, not replay mode
|
|
||||||
if (!this._isReplayMode) {
|
|
||||||
setLoadingMessage("Initializing physics recorder...");
|
|
||||||
//this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
|
|
||||||
log.debug('Physics recorder initialized (will start on XR pose)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load background music before marking as ready
|
|
||||||
if (this._audioEngine) {
|
|
||||||
setLoadingMessage("Loading background music...");
|
|
||||||
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", {
|
|
||||||
loop: true,
|
|
||||||
volume: 0.2
|
|
||||||
});
|
|
||||||
log.debug('Background music loaded successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize mission brief (will be shown when entering XR)
|
|
||||||
setLoadingMessage("Initializing mission brief...");
|
|
||||||
log.info('[Level1] ========== ABOUT TO INITIALIZE MISSION BRIEF ==========');
|
|
||||||
log.info('[Level1] _missionBrief object:', this._missionBrief);
|
|
||||||
log.info('[Level1] Ship exists:', !!this._ship);
|
|
||||||
log.info('[Level1] Ship ID in scene:', DefaultScene.MainScene.getNodeById('Ship') !== null);
|
|
||||||
this._missionBrief.initialize(this._audioEngine);
|
|
||||||
log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
|
|
||||||
log.debug('Mission brief initialized');
|
|
||||||
|
|
||||||
// Initialize hint system (need UUID from registry, not slug)
|
|
||||||
if (this._levelId) {
|
if (this._levelId) {
|
||||||
const registry = LevelRegistry.getInstance();
|
const registry = LevelRegistry.getInstance();
|
||||||
const registryEntry = registry.getAllLevels().get(this._levelId);
|
const registryEntry = registry.getAllLevels().get(this._levelId);
|
||||||
@ -458,34 +437,73 @@ export class Level1 implements Level {
|
|||||||
this._ship.scoreboard.onScoreObservable,
|
this._ship.scoreboard.onScoreObservable,
|
||||||
this._ship.onCollisionObservable
|
this._ship.onCollisionObservable
|
||||||
);
|
);
|
||||||
log.info('[Level1] Hint system initialized with level UUID:', registryEntry.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set par time and level info
|
||||||
|
this.setupStatusScreen();
|
||||||
|
|
||||||
|
log.debug('[Level1] addToScene complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize physics for all level entities (Phase 3 - after XR)
|
||||||
|
*/
|
||||||
|
public initializePhysics(): void {
|
||||||
|
log.debug('[Level1] initializePhysics called');
|
||||||
|
|
||||||
|
// Initialize ship physics
|
||||||
|
this._ship.initializePhysics();
|
||||||
|
|
||||||
|
// Set ship velocities (needs physics body)
|
||||||
|
const shipConfig = this._deserializer.getShipConfig();
|
||||||
|
if (shipConfig.linearVelocity) {
|
||||||
|
this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity));
|
||||||
|
}
|
||||||
|
if (shipConfig.angularVelocity) {
|
||||||
|
this._ship.setAngularVelocity(new Vector3(...shipConfig.angularVelocity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize deserializer physics (base, asteroids)
|
||||||
|
this._landingAggregate = this._deserializer.initializePhysics();
|
||||||
|
|
||||||
|
// Setup resupply system
|
||||||
|
if (this._landingAggregate) {
|
||||||
|
this._ship.setLandingZone(this._landingAggregate);
|
||||||
|
}
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
this._onReadyObservable.notifyObservers(this);
|
||||||
|
|
||||||
// Set par time and level info for score calculation and results recording
|
log.debug('[Level1] initializePhysics complete');
|
||||||
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
|
|
||||||
const statusScreen = this._ship.statusScreen;
|
|
||||||
log.info('[Level1] StatusScreen reference:', statusScreen);
|
|
||||||
log.info('[Level1] Level config metadata:', this._levelConfig.metadata);
|
|
||||||
log.info('[Level1] Asteroids count:', entities.asteroids.length);
|
|
||||||
if (statusScreen) {
|
|
||||||
statusScreen.setParTime(parTime);
|
|
||||||
log.info(`[Level1] Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
|
|
||||||
|
|
||||||
// Set level info for game results recording
|
|
||||||
const levelId = this._levelId || 'unknown';
|
|
||||||
const levelName = this._levelConfig.metadata?.description || 'Unknown Level';
|
|
||||||
log.info('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length });
|
|
||||||
statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length);
|
|
||||||
log.info('[Level1] setCurrentLevel called successfully');
|
|
||||||
} else {
|
|
||||||
log.error('[Level1] StatusScreen is null/undefined!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify that initialization is complete
|
/**
|
||||||
this._onReadyObservable.notifyObservers(this);
|
* Show all level meshes (call after XR entry)
|
||||||
|
*/
|
||||||
|
public showMeshes(): void {
|
||||||
|
this._ship.showMeshes();
|
||||||
|
this._deserializer.showMeshes();
|
||||||
|
log.debug('[Level1] All meshes shown');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupStatusScreen(): void {
|
||||||
|
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
|
||||||
|
const statusScreen = this._ship.statusScreen;
|
||||||
|
if (statusScreen) {
|
||||||
|
statusScreen.setParTime(parTime);
|
||||||
|
const levelId = this._levelId || 'unknown';
|
||||||
|
const levelName = this._levelConfig.metadata?.description || 'Unknown Level';
|
||||||
|
statusScreen.setCurrentLevel(levelId, levelName, this._asteroidCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy initialize - for backwards compatibility
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.addToScene(false);
|
||||||
|
this.initializePhysics();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
510
src/ship/ship.ts
510
src/ship/ship.ts
@ -77,6 +77,9 @@ export class Ship {
|
|||||||
private _physicsObserver: any = null;
|
private _physicsObserver: any = null;
|
||||||
private _renderObserver: any = null;
|
private _renderObserver: any = null;
|
||||||
|
|
||||||
|
// Store loaded asset data for physics initialization
|
||||||
|
private _loadedAssetData: any = null;
|
||||||
|
|
||||||
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
|
||||||
this._audioEngine = audioEngine;
|
this._audioEngine = audioEngine;
|
||||||
this._isReplayMode = isReplayMode;
|
this._isReplayMode = isReplayMode;
|
||||||
@ -155,251 +158,49 @@ export class Ship {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize(initialPosition?: Vector3) {
|
/**
|
||||||
this._scoreboard = new Scoreboard();
|
* Add ship to scene (Phase 2 - before XR entry)
|
||||||
this._scoreboard.setShip(this); // Pass ship reference for velocity reading
|
* Loads mesh, creates non-physics systems, optionally hidden
|
||||||
this._gameStats = new GameStats();
|
*/
|
||||||
this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
|
public async addToScene(initialPosition?: Vector3, hidden: boolean = false): Promise<void> {
|
||||||
const data = await loadAsset("ship.glb");
|
log.debug(`[Ship] addToScene called (hidden: ${hidden})`);
|
||||||
this._ship = data.container.transformNodes[0];
|
|
||||||
|
this._scoreboard = new Scoreboard();
|
||||||
|
this._scoreboard.setShip(this);
|
||||||
|
this._gameStats = new GameStats();
|
||||||
|
|
||||||
|
// Load ship mesh (optionally hidden)
|
||||||
|
this._loadedAssetData = await loadAsset("ship.glb", "default", { hidden });
|
||||||
|
this._ship = this._loadedAssetData.container.transformNodes[0];
|
||||||
|
|
||||||
// Set position BEFORE creating physics body to avoid collision race condition
|
|
||||||
if (initialPosition) {
|
if (initialPosition) {
|
||||||
this._ship.position.copyFrom(initialPosition);
|
this._ship.position.copyFrom(initialPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create physics if enabled
|
|
||||||
const config = GameConfig.getInstance();
|
|
||||||
if (config.physicsEnabled) {
|
|
||||||
log.info("Physics Enabled for Ship");
|
|
||||||
if (this._ship) {
|
|
||||||
const agg = new PhysicsAggregate(
|
|
||||||
this._ship,
|
|
||||||
PhysicsShapeType.MESH,
|
|
||||||
{
|
|
||||||
mass: 10,
|
|
||||||
mesh: data.container.rootNodes[0].getChildMeshes()[0] as Mesh,
|
|
||||||
},
|
|
||||||
DefaultScene.MainScene
|
|
||||||
);
|
|
||||||
|
|
||||||
agg.body.setMotionType(PhysicsMotionType.DYNAMIC);
|
|
||||||
agg.body.setLinearDamping(config.shipPhysics.linearDamping);
|
|
||||||
agg.body.setAngularDamping(config.shipPhysics.angularDamping);
|
|
||||||
agg.body.setAngularVelocity(new Vector3(0, 0, 0));
|
|
||||||
agg.body.setCollisionCallbackEnabled(true);
|
|
||||||
|
|
||||||
// Debug: Log center of mass before override
|
|
||||||
const massProps = agg.body.getMassProperties();
|
|
||||||
log.info(`[Ship] Original center of mass (local): ${massProps.centerOfMass.toString()}`);
|
|
||||||
log.info(`[Ship] Mass: ${massProps.mass}`);
|
|
||||||
log.info(`[Ship] Inertia: ${massProps.inertia.toString()}`);
|
|
||||||
|
|
||||||
// Override center of mass to origin to prevent thrust from causing torque
|
|
||||||
// (mesh-based physics was calculating offset center of mass from geometry)
|
|
||||||
agg.body.setMassProperties({
|
|
||||||
mass: 10,
|
|
||||||
centerOfMass: new Vector3(0, 0, 0),
|
|
||||||
inertia: massProps.inertia,
|
|
||||||
inertiaOrientation: massProps.inertiaOrientation
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info(`[Ship] Center of mass overridden to: ${agg.body.getMassProperties().centerOfMass.toString()}`);
|
|
||||||
|
|
||||||
// Configure physics sleep behavior from config
|
|
||||||
// (disabling sleep prevents abrupt stops at zero linear velocity)
|
|
||||||
if (config.shipPhysics.alwaysActive) {
|
|
||||||
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
|
|
||||||
if (physicsPlugin) {
|
|
||||||
physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register collision handler for energy-based hull damage
|
|
||||||
const observable = agg.body.getCollisionObservable();
|
|
||||||
observable.add((collisionEvent) => {
|
|
||||||
// Only calculate damage on collision start to avoid double-counting
|
|
||||||
if (collisionEvent.type === 'COLLISION_STARTED') {
|
|
||||||
// Get collision bodies
|
|
||||||
const shipBody = collisionEvent.collider;
|
|
||||||
const otherBody = collisionEvent.collidedAgainst;
|
|
||||||
|
|
||||||
// Get velocities
|
|
||||||
const shipVelocity = shipBody.getLinearVelocity();
|
|
||||||
const otherVelocity = otherBody.getLinearVelocity();
|
|
||||||
|
|
||||||
// Calculate relative velocity
|
|
||||||
const relativeVelocity = shipVelocity.subtract(otherVelocity);
|
|
||||||
const relativeSpeed = relativeVelocity.length();
|
|
||||||
|
|
||||||
// Get masses
|
|
||||||
const shipMass = 10; // Known ship mass from aggregate creation
|
|
||||||
const otherMass = otherBody.getMassProperties().mass;
|
|
||||||
|
|
||||||
// Calculate reduced mass for collision
|
|
||||||
const reducedMass = (shipMass * otherMass) / (shipMass + otherMass);
|
|
||||||
|
|
||||||
// Calculate kinetic energy of collision
|
|
||||||
const kineticEnergy = 0.5 * reducedMass * relativeSpeed * relativeSpeed;
|
|
||||||
|
|
||||||
// Convert energy to damage (tuning factor)
|
|
||||||
// 1000 units of energy = 0.01 (1%) damage
|
|
||||||
const ENERGY_TO_DAMAGE_FACTOR = 0.01 / 1000;
|
|
||||||
const damage = Math.min(kineticEnergy * ENERGY_TO_DAMAGE_FACTOR, 0.5); // Cap at 50% per hit
|
|
||||||
|
|
||||||
// Apply damage if above minimum threshold
|
|
||||||
if (this._scoreboard?.shipStatus && damage > 0.001) {
|
|
||||||
this._scoreboard.shipStatus.damageHull(damage);
|
|
||||||
log.debug(`Collision damage: ${damage.toFixed(4)} (energy: ${kineticEnergy.toFixed(1)}, speed: ${relativeSpeed.toFixed(1)} m/s)`);
|
|
||||||
|
|
||||||
// Play collision sound
|
|
||||||
if (this._audio) {
|
|
||||||
this._audio.playCollisionSound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify collision observable for hint system
|
|
||||||
this._onCollisionObservable.notifyObservers({
|
|
||||||
collisionType: 'any'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.warn("No geometry mesh found, cannot create physics");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize audio system
|
|
||||||
if (this._audioEngine) {
|
|
||||||
this._audio = new ShipAudio(this._audioEngine);
|
|
||||||
await this._audio.initialize();
|
|
||||||
|
|
||||||
// Initialize voice audio system
|
|
||||||
this._voiceAudio = new VoiceAudioSystem();
|
|
||||||
await this._voiceAudio.initialize(this._audioEngine);
|
|
||||||
// Subscribe voice system to ship status events
|
|
||||||
this._voiceAudio.subscribeToEvents(this._scoreboard.shipStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize weapon system
|
|
||||||
this._weapons = new WeaponSystem(DefaultScene.MainScene);
|
|
||||||
this._weapons.initialize();
|
|
||||||
this._weapons.setShipStatus(this._scoreboard.shipStatus);
|
|
||||||
this._weapons.setGameStats(this._gameStats);
|
|
||||||
this._weapons.setScoreObservable(this._scoreboard.onScoreObservable);
|
|
||||||
if (this._ship.physicsBody) {
|
|
||||||
this._weapons.setShipBody(this._ship.physicsBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize input systems (skip in replay mode)
|
// Initialize input systems (skip in replay mode)
|
||||||
if (!this._isReplayMode) {
|
if (!this._isReplayMode) {
|
||||||
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
|
||||||
this._keyboardInput.setup();
|
this._keyboardInput.setup();
|
||||||
|
|
||||||
this._controllerInput = new ControllerInput();
|
this._controllerInput = new ControllerInput();
|
||||||
|
|
||||||
// Register input systems with InputControlManager
|
|
||||||
const inputManager = InputControlManager.getInstance();
|
const inputManager = InputControlManager.getInstance();
|
||||||
inputManager.registerInputSystems(this._keyboardInput, this._controllerInput);
|
inputManager.registerInputSystems(this._keyboardInput, this._controllerInput);
|
||||||
|
|
||||||
// Wire up shooting events
|
this._keyboardInput.onShootObservable.add(() => this.handleShoot());
|
||||||
this._keyboardInput.onShootObservable.add(() => {
|
this._controllerInput.onShootObservable.add(() => this.handleShoot());
|
||||||
this.handleShoot();
|
this._controllerInput.onStatusScreenToggleObservable.add(() => this.toggleStatusScreen());
|
||||||
});
|
this._controllerInput.onInspectorToggleObservable.add(() => this.toggleInspector());
|
||||||
|
this._keyboardInput.onCameraChangeObservable.add((key) => this.handleCameraChange(key));
|
||||||
this._controllerInput.onShootObservable.add(() => {
|
this._controllerInput.onCameraAdjustObservable.add((adj) => this.handleCameraAdjust(adj));
|
||||||
this.handleShoot();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire up status screen toggle event
|
|
||||||
this._controllerInput.onStatusScreenToggleObservable.add(() => {
|
|
||||||
if (this._statusScreen) {
|
|
||||||
if (this._statusScreen.isVisible) {
|
|
||||||
// Hide status screen - InputControlManager will handle control re-enabling
|
|
||||||
this._statusScreen.hide();
|
|
||||||
} else {
|
|
||||||
// Show status screen (manual pause, not game end)
|
|
||||||
// InputControlManager will handle control disabling
|
|
||||||
this._statusScreen.show(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire up inspector toggle event (Y button)
|
|
||||||
this._controllerInput.onInspectorToggleObservable.add(() => {
|
|
||||||
import('@babylonjs/inspector').then(() => {
|
|
||||||
const scene = DefaultScene.MainScene;
|
|
||||||
if (scene.debugLayer.isVisible()) {
|
|
||||||
scene.debugLayer.hide();
|
|
||||||
} else {
|
|
||||||
scene.debugLayer.show({ overlay: true, showExplorer: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
// Setup camera (non-physics)
|
||||||
this._physics = new ShipPhysics();
|
this._camera = new FreeCamera("Flat Camera", new Vector3(0, 1.5, 0), DefaultScene.MainScene);
|
||||||
this._physics.setShipStatus(this._scoreboard.shipStatus);
|
|
||||||
this._physics.setGameStats(this._gameStats);
|
|
||||||
|
|
||||||
// Setup physics update loop (every 10 frames)
|
|
||||||
let p = 0;
|
|
||||||
this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
|
|
||||||
|
|
||||||
this.updatePhysics();
|
|
||||||
|
|
||||||
});
|
|
||||||
let renderFrameCount = 0;
|
|
||||||
this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
|
||||||
// Update voice audio system (checks for completed sounds and plays next in queue)
|
|
||||||
if (this._voiceAudio) {
|
|
||||||
this._voiceAudio.update();
|
|
||||||
}
|
|
||||||
// Update projectiles (shape casting collision detection)
|
|
||||||
if (this._weapons) {
|
|
||||||
const deltaTime = DefaultScene.MainScene.getEngine().getDeltaTime() / 1000;
|
|
||||||
this._weapons.update(deltaTime);
|
|
||||||
}
|
|
||||||
// Check game end conditions every 30 frames (~0.5 sec at 60fps)
|
|
||||||
if (renderFrameCount++ % 30 === 0) {
|
|
||||||
this.checkGameEndConditions();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup camera
|
|
||||||
this._camera = new FreeCamera(
|
|
||||||
"Flat Camera",
|
|
||||||
new Vector3(0, 1.5, 0),
|
|
||||||
DefaultScene.MainScene
|
|
||||||
);
|
|
||||||
this._camera.parent = this._ship;
|
this._camera.parent = this._ship;
|
||||||
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
|
|
||||||
this._camera.rotation = new Vector3(0, Math.PI, 0);
|
this._camera.rotation = new Vector3(0, Math.PI, 0);
|
||||||
|
|
||||||
// Set as active camera if XR is not available
|
|
||||||
if (!DefaultScene.XR && !this._isReplayMode) {
|
if (!DefaultScene.XR && !this._isReplayMode) {
|
||||||
DefaultScene.MainScene.activeCamera = this._camera;
|
DefaultScene.MainScene.activeCamera = this._camera;
|
||||||
//this._camera.attachControl(DefaultScene.MainScene.getEngine().getRenderingCanvas(), true);
|
|
||||||
log.debug('Flat camera set as active camera');
|
log.debug('Flat camera set as active camera');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,52 +215,11 @@ export class Ship {
|
|||||||
centerGap: 0.5,
|
centerGap: 0.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize scoreboard (it will retrieve and setup its own screen mesh)
|
// Initialize scoreboard and subscribe to events
|
||||||
this._scoreboard.initialize();
|
this._scoreboard.initialize();
|
||||||
|
this.setupScoreboardObservers();
|
||||||
|
|
||||||
// Subscribe to score events to track asteroids destroyed
|
// Initialize status screen
|
||||||
this._scoreboard.onScoreObservable.add((event) => {
|
|
||||||
// Each score event represents an asteroid destroyed, pass scale for point calc
|
|
||||||
this._gameStats.recordAsteroidDestroyed(event.scale || 1);
|
|
||||||
|
|
||||||
// Track asteroid destruction in analytics
|
|
||||||
try {
|
|
||||||
const analytics = getAnalytics();
|
|
||||||
analytics.track('asteroid_destroyed', {
|
|
||||||
weaponType: 'laser',
|
|
||||||
distance: 0,
|
|
||||||
asteroidSize: event.scale || 0,
|
|
||||||
remainingCount: this._scoreboard.remaining
|
|
||||||
}, { sampleRate: 0.2 }); // Sample 20% of asteroid events to reduce data
|
|
||||||
} catch (error) {
|
|
||||||
// Analytics not initialized or failed - don't break gameplay
|
|
||||||
log.debug('Analytics tracking failed:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to ship status changes to track hull damage
|
|
||||||
this._scoreboard.shipStatus.onStatusChanged.add((event) => {
|
|
||||||
if (event.statusType === "hull" && event.delta < 0) {
|
|
||||||
// Hull damage (delta is negative)
|
|
||||||
const damageAmount = Math.abs(event.delta);
|
|
||||||
this._gameStats.recordHullDamage(damageAmount);
|
|
||||||
|
|
||||||
// Track hull damage in analytics
|
|
||||||
try {
|
|
||||||
const analytics = getAnalytics();
|
|
||||||
analytics.track('hull_damage', {
|
|
||||||
damageAmount: damageAmount,
|
|
||||||
remainingHull: this._scoreboard.shipStatus.hull,
|
|
||||||
damagePercent: damageAmount,
|
|
||||||
source: 'asteroid_collision' // Default assumption
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.debug('Analytics tracking failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize status screen with callbacks
|
|
||||||
this._statusScreen = new StatusScreen(
|
this._statusScreen = new StatusScreen(
|
||||||
DefaultScene.MainScene,
|
DefaultScene.MainScene,
|
||||||
this._ship,
|
this._ship,
|
||||||
@ -470,6 +230,220 @@ export class Ship {
|
|||||||
() => this.handleNextLevel()
|
() => this.handleNextLevel()
|
||||||
);
|
);
|
||||||
this._statusScreen.initialize();
|
this._statusScreen.initialize();
|
||||||
|
|
||||||
|
log.debug('[Ship] addToScene complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize physics (Phase 3 - after XR entry)
|
||||||
|
* Creates physics body, collision handlers, weapon system
|
||||||
|
*/
|
||||||
|
public initializePhysics(): void {
|
||||||
|
log.debug('[Ship] initializePhysics called');
|
||||||
|
const config = GameConfig.getInstance();
|
||||||
|
|
||||||
|
if (!config.physicsEnabled || !this._ship || !this._loadedAssetData) {
|
||||||
|
log.warn('[Ship] Physics disabled or ship not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agg = new PhysicsAggregate(
|
||||||
|
this._ship,
|
||||||
|
PhysicsShapeType.MESH,
|
||||||
|
{
|
||||||
|
mass: 10,
|
||||||
|
mesh: this._loadedAssetData.container.rootNodes[0].getChildMeshes()[0] as Mesh,
|
||||||
|
},
|
||||||
|
DefaultScene.MainScene
|
||||||
|
);
|
||||||
|
|
||||||
|
agg.body.setMotionType(PhysicsMotionType.DYNAMIC);
|
||||||
|
agg.body.setLinearDamping(config.shipPhysics.linearDamping);
|
||||||
|
agg.body.setAngularDamping(config.shipPhysics.angularDamping);
|
||||||
|
agg.body.setAngularVelocity(new Vector3(0, 0, 0));
|
||||||
|
agg.body.setCollisionCallbackEnabled(true);
|
||||||
|
|
||||||
|
// Override center of mass to origin
|
||||||
|
const massProps = agg.body.getMassProperties();
|
||||||
|
agg.body.setMassProperties({
|
||||||
|
mass: 10,
|
||||||
|
centerOfMass: new Vector3(0, 0, 0),
|
||||||
|
inertia: massProps.inertia,
|
||||||
|
inertiaOrientation: massProps.inertiaOrientation
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.shipPhysics.alwaysActive) {
|
||||||
|
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
|
||||||
|
if (physicsPlugin) {
|
||||||
|
physicsPlugin.setActivationControl(agg.body, PhysicsActivationControl.ALWAYS_ACTIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register collision handler
|
||||||
|
this.setupCollisionHandler(agg);
|
||||||
|
|
||||||
|
// Initialize weapon system (needs physics)
|
||||||
|
this._weapons = new WeaponSystem(DefaultScene.MainScene);
|
||||||
|
this._weapons.initialize();
|
||||||
|
this._weapons.setShipStatus(this._scoreboard.shipStatus);
|
||||||
|
this._weapons.setGameStats(this._gameStats);
|
||||||
|
this._weapons.setScoreObservable(this._scoreboard.onScoreObservable);
|
||||||
|
this._weapons.setShipBody(this._ship.physicsBody!);
|
||||||
|
|
||||||
|
// Initialize physics controller
|
||||||
|
this._physics = new ShipPhysics();
|
||||||
|
this._physics.setShipStatus(this._scoreboard.shipStatus);
|
||||||
|
this._physics.setGameStats(this._gameStats);
|
||||||
|
|
||||||
|
// Setup update loops
|
||||||
|
this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
|
||||||
|
this.updatePhysics();
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderFrameCount = 0;
|
||||||
|
this._renderObserver = DefaultScene.MainScene.onAfterRenderObservable.add(() => {
|
||||||
|
if (this._voiceAudio) this._voiceAudio.update();
|
||||||
|
if (this._weapons) {
|
||||||
|
const deltaTime = DefaultScene.MainScene.getEngine().getDeltaTime() / 1000;
|
||||||
|
this._weapons.update(deltaTime);
|
||||||
|
}
|
||||||
|
if (renderFrameCount++ % 30 === 0) this.checkGameEndConditions();
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug('[Ship] initializePhysics complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show ship meshes (call after XR entry to make visible)
|
||||||
|
*/
|
||||||
|
public showMeshes(): void {
|
||||||
|
if (this._loadedAssetData) {
|
||||||
|
for (const mesh of this._loadedAssetData.meshes.values()) {
|
||||||
|
mesh.isVisible = true;
|
||||||
|
mesh.setEnabled(true);
|
||||||
|
}
|
||||||
|
log.debug('[Ship] Meshes shown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private setupCollisionHandler(agg: PhysicsAggregate): void {
|
||||||
|
agg.body.getCollisionObservable().add((collisionEvent) => {
|
||||||
|
if (collisionEvent.type !== 'COLLISION_STARTED') return;
|
||||||
|
|
||||||
|
const shipBody = collisionEvent.collider;
|
||||||
|
const otherBody = collisionEvent.collidedAgainst;
|
||||||
|
const relativeVelocity = shipBody.getLinearVelocity().subtract(otherBody.getLinearVelocity());
|
||||||
|
const relativeSpeed = relativeVelocity.length();
|
||||||
|
|
||||||
|
const shipMass = 10;
|
||||||
|
const otherMass = otherBody.getMassProperties().mass;
|
||||||
|
const reducedMass = (shipMass * otherMass) / (shipMass + otherMass);
|
||||||
|
const kineticEnergy = 0.5 * reducedMass * relativeSpeed * relativeSpeed;
|
||||||
|
|
||||||
|
const ENERGY_TO_DAMAGE_FACTOR = 0.01 / 1000;
|
||||||
|
const damage = Math.min(kineticEnergy * ENERGY_TO_DAMAGE_FACTOR, 0.5);
|
||||||
|
|
||||||
|
if (this._scoreboard?.shipStatus && damage > 0.001) {
|
||||||
|
this._scoreboard.shipStatus.damageHull(damage);
|
||||||
|
log.debug(`Collision damage: ${damage.toFixed(4)}`);
|
||||||
|
if (this._audio) this._audio.playCollisionSound();
|
||||||
|
this._onCollisionObservable.notifyObservers({ collisionType: 'any' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupScoreboardObservers(): void {
|
||||||
|
this._scoreboard.onScoreObservable.add((event) => {
|
||||||
|
this._gameStats.recordAsteroidDestroyed(event.scale || 1);
|
||||||
|
try {
|
||||||
|
const analytics = getAnalytics();
|
||||||
|
analytics.track('asteroid_destroyed', {
|
||||||
|
weaponType: 'laser',
|
||||||
|
distance: 0,
|
||||||
|
asteroidSize: event.scale || 0,
|
||||||
|
remainingCount: this._scoreboard.remaining
|
||||||
|
}, { sampleRate: 0.2 });
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('Analytics tracking failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._scoreboard.shipStatus.onStatusChanged.add((event) => {
|
||||||
|
if (event.statusType === "hull" && event.delta < 0) {
|
||||||
|
const damageAmount = Math.abs(event.delta);
|
||||||
|
this._gameStats.recordHullDamage(damageAmount);
|
||||||
|
try {
|
||||||
|
const analytics = getAnalytics();
|
||||||
|
analytics.track('hull_damage', {
|
||||||
|
damageAmount,
|
||||||
|
remainingHull: this._scoreboard.shipStatus.hull,
|
||||||
|
damagePercent: damageAmount / 100,
|
||||||
|
source: 'asteroid_collision'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('Analytics tracking failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleStatusScreen(): void {
|
||||||
|
if (this._statusScreen) {
|
||||||
|
if (this._statusScreen.isVisible) {
|
||||||
|
this._statusScreen.hide();
|
||||||
|
} else {
|
||||||
|
this._statusScreen.show(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleInspector(): void {
|
||||||
|
import('@babylonjs/inspector').then(() => {
|
||||||
|
const scene = DefaultScene.MainScene;
|
||||||
|
if (scene.debugLayer.isVisible()) {
|
||||||
|
scene.debugLayer.hide();
|
||||||
|
} else {
|
||||||
|
scene.debugLayer.show({ overlay: true, showExplorer: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCameraChange(cameraKey: number): void {
|
||||||
|
if (cameraKey === 1) {
|
||||||
|
this._camera.position.x = 15;
|
||||||
|
this._camera.rotation.y = -Math.PI / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCameraAdjust(adjustment: { direction: string }): void {
|
||||||
|
if (DefaultScene.XR?.baseExperience?.camera) {
|
||||||
|
const camera = DefaultScene.XR.baseExperience.camera;
|
||||||
|
camera.position.y += adjustment.direction === "down" ? -0.1 : 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize audio systems (call after audio engine is unlocked)
|
||||||
|
* Separated from initialize() to allow ship creation before XR entry
|
||||||
|
*/
|
||||||
|
public async initializeAudio(audioEngine: AudioEngineV2): Promise<void> {
|
||||||
|
if (this._audio) {
|
||||||
|
log.debug('[Ship] Audio already initialized, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._audioEngine = audioEngine;
|
||||||
|
log.debug('[Ship] Initializing audio systems');
|
||||||
|
|
||||||
|
this._audio = new ShipAudio(audioEngine);
|
||||||
|
await this._audio.initialize();
|
||||||
|
|
||||||
|
this._voiceAudio = new VoiceAudioSystem();
|
||||||
|
await this._voiceAudio.initialize(audioEngine);
|
||||||
|
this._voiceAudio.subscribeToEvents(this._scoreboard.shipStatus);
|
||||||
|
|
||||||
|
log.debug('[Ship] Audio initialization complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AudioEngineV2, StaticSound } from "@babylonjs/core";
|
import type { AudioEngineV2, StaticSound } from "@babylonjs/core";
|
||||||
|
import { getAudioSource } from "../utils/audioPrefetch";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages ship audio (thrust sounds and weapon fire)
|
* Manages ship audio (thrust sounds and weapon fire)
|
||||||
@ -22,40 +23,32 @@ export class ShipAudio {
|
|||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
if (!this._audioEngine) return;
|
if (!this._audioEngine) return;
|
||||||
|
|
||||||
|
const thrustUrl = "/assets/themes/default/audio/thrust5.mp3";
|
||||||
|
const shotUrl = "/assets/themes/default/audio/shot.mp3";
|
||||||
|
const collisionUrl = "/assets/themes/default/audio/collision.mp3";
|
||||||
|
|
||||||
this._primaryThrustSound = await this._audioEngine.createSoundAsync(
|
this._primaryThrustSound = await this._audioEngine.createSoundAsync(
|
||||||
"thrust",
|
"thrust",
|
||||||
"/assets/themes/default/audio/thrust5.mp3",
|
getAudioSource(thrustUrl),
|
||||||
{
|
{ loop: true, volume: 0.2 }
|
||||||
loop: true,
|
|
||||||
volume: 0.2,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this._secondaryThrustSound = await this._audioEngine.createSoundAsync(
|
this._secondaryThrustSound = await this._audioEngine.createSoundAsync(
|
||||||
"thrust2",
|
"thrust2",
|
||||||
"/assets/themes/default/audio/thrust5.mp3",
|
getAudioSource(thrustUrl),
|
||||||
{
|
{ loop: true, volume: 0.5 }
|
||||||
loop: true,
|
|
||||||
volume: 0.5,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this._weaponSound = await this._audioEngine.createSoundAsync(
|
this._weaponSound = await this._audioEngine.createSoundAsync(
|
||||||
"shot",
|
"shot",
|
||||||
"/assets/themes/default/audio/shot.mp3",
|
getAudioSource(shotUrl),
|
||||||
{
|
{ loop: false, volume: 0.5 }
|
||||||
loop: false,
|
|
||||||
volume: 0.5,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this._collisionSound = await this._audioEngine.createSoundAsync(
|
this._collisionSound = await this._audioEngine.createSoundAsync(
|
||||||
"collision",
|
"collision",
|
||||||
"/assets/themes/default/audio/collision.mp3",
|
getAudioSource(collisionUrl),
|
||||||
{
|
{ loop: false, volume: 0.25 }
|
||||||
loop: false,
|
|
||||||
volume: 0.25,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
src/utils/audioPrefetch.ts
Normal file
52
src/utils/audioPrefetch.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import log from '../core/logger';
|
||||||
|
|
||||||
|
const AUDIO_BASE = '/assets/themes/default/audio';
|
||||||
|
|
||||||
|
// All audio files to prefetch
|
||||||
|
const AUDIO_FILES = [
|
||||||
|
`${AUDIO_BASE}/explosion.mp3`,
|
||||||
|
`${AUDIO_BASE}/thrust5.mp3`,
|
||||||
|
`${AUDIO_BASE}/shot.mp3`,
|
||||||
|
`${AUDIO_BASE}/collision.mp3`,
|
||||||
|
`${AUDIO_BASE}/song1.mp3`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cache for prefetched audio buffers
|
||||||
|
const prefetchedAudio: Map<string, ArrayBuffer> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch all game audio files as ArrayBuffers
|
||||||
|
*/
|
||||||
|
export async function prefetchAllAudio(): Promise<void> {
|
||||||
|
log.debug('[audioPrefetch] Prefetching all audio files...');
|
||||||
|
|
||||||
|
const fetches = AUDIO_FILES.map(async (url) => {
|
||||||
|
if (prefetchedAudio.has(url)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
prefetchedAudio.set(url, buffer);
|
||||||
|
log.debug(`[audioPrefetch] ✓ Prefetched ${url}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`[audioPrefetch] Failed to prefetch ${url}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(fetches);
|
||||||
|
log.debug(`[audioPrefetch] Prefetched ${prefetchedAudio.size}/${AUDIO_FILES.length} audio files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prefetched audio buffer (returns clone to avoid detached buffer issues)
|
||||||
|
*/
|
||||||
|
export function getPrefetchedAudio(url: string): ArrayBuffer | null {
|
||||||
|
const buffer = prefetchedAudio.get(url);
|
||||||
|
return buffer ? buffer.slice(0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio source - returns prefetched buffer or falls back to URL
|
||||||
|
*/
|
||||||
|
export function getAudioSource(url: string): ArrayBuffer | string {
|
||||||
|
return getPrefetchedAudio(url) || url;
|
||||||
|
}
|
||||||
@ -6,13 +6,47 @@ type LoadedAsset = {
|
|||||||
container: AssetContainer,
|
container: AssetContainer,
|
||||||
meshes: Map<string, AbstractMesh>,
|
meshes: Map<string, AbstractMesh>,
|
||||||
}
|
}
|
||||||
export default async function loadAsset(file: string, theme: string = "default"): Promise<LoadedAsset> {
|
|
||||||
|
interface LoadAssetOptions {
|
||||||
|
hidden?: boolean; // If true, meshes are added to scene but disabled/invisible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for prefetched asset containers (not yet added to scene)
|
||||||
|
const prefetchedContainers: Map<string, AssetContainer> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch an asset (download and parse, but don't add to scene yet)
|
||||||
|
*/
|
||||||
|
export async function prefetchAsset(file: string, theme: string = "default"): Promise<void> {
|
||||||
|
const cacheKey = `${theme}/${file}`;
|
||||||
|
if (prefetchedContainers.has(cacheKey)) return;
|
||||||
|
|
||||||
|
const assetPath = `/assets/themes/${theme}/models/${file}`;
|
||||||
|
log.debug(`[prefetchAsset] Prefetching: ${assetPath}`);
|
||||||
|
const container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene);
|
||||||
|
prefetchedContainers.set(cacheKey, container);
|
||||||
|
log.debug(`[prefetchAsset] ✓ Prefetched ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function loadAsset(
|
||||||
|
file: string,
|
||||||
|
theme: string = "default",
|
||||||
|
options: LoadAssetOptions = {}
|
||||||
|
): Promise<LoadedAsset> {
|
||||||
|
const cacheKey = `${theme}/${file}`;
|
||||||
const assetPath = `/assets/themes/${theme}/models/${file}`;
|
const assetPath = `/assets/themes/${theme}/models/${file}`;
|
||||||
log.debug(`[loadAsset] Loading: ${assetPath}`);
|
log.debug(`[loadAsset] Loading: ${assetPath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene);
|
// Use prefetched container if available, otherwise load fresh
|
||||||
|
let container = prefetchedContainers.get(cacheKey);
|
||||||
|
if (container) {
|
||||||
|
log.debug(`[loadAsset] ✓ Using prefetched container for ${file}`);
|
||||||
|
prefetchedContainers.delete(cacheKey); // Remove from cache after use
|
||||||
|
} else {
|
||||||
|
container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene);
|
||||||
log.debug(`[loadAsset] ✓ Container loaded for ${file}`);
|
log.debug(`[loadAsset] ✓ Container loaded for ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
const map: Map<string, AbstractMesh> = new Map();
|
const map: Map<string, AbstractMesh> = new Map();
|
||||||
container.addAllToScene();
|
container.addAllToScene();
|
||||||
@ -23,17 +57,18 @@ export default async function loadAsset(file: string, theme: string = "default")
|
|||||||
return {container: container, meshes: map};
|
return {container: container, meshes: map};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldHide = options.hidden === true;
|
||||||
|
|
||||||
for (const mesh of container.rootNodes[0].getChildMeshes(false)) {
|
for (const mesh of container.rootNodes[0].getChildMeshes(false)) {
|
||||||
log.info(mesh.id, mesh);
|
log.info(mesh.id, mesh);
|
||||||
// Ensure mesh is visible and enabled
|
|
||||||
mesh.isVisible = true;
|
// Set visibility based on hidden option
|
||||||
mesh.setEnabled(true);
|
mesh.isVisible = !shouldHide;
|
||||||
|
mesh.setEnabled(!shouldHide);
|
||||||
|
|
||||||
// Fix emissive materials to work without lighting
|
// Fix emissive materials to work without lighting
|
||||||
if (mesh.material) {
|
if (mesh.material) {
|
||||||
const material = mesh.material as any;
|
const material = mesh.material as any;
|
||||||
|
|
||||||
// Disable lighting on materials so emissive works without light sources
|
|
||||||
if (material.disableLighting !== undefined) {
|
if (material.disableLighting !== undefined) {
|
||||||
material.disableLighting = true;
|
material.disableLighting = true;
|
||||||
}
|
}
|
||||||
@ -42,10 +77,21 @@ export default async function loadAsset(file: string, theme: string = "default")
|
|||||||
map.set(mesh.id, mesh);
|
map.set(mesh.id, mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`[loadAsset] ✓ Loaded ${map.size} meshes from ${file}`);
|
log.debug(`[loadAsset] ✓ Loaded ${map.size} meshes from ${file} (hidden: ${shouldHide})`);
|
||||||
return {container: container, meshes: map};
|
return {container: container, meshes: map};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`[loadAsset] FAILED to load ${assetPath}:`, error);
|
log.error(`[loadAsset] FAILED to load ${assetPath}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show all meshes in a loaded asset container (for assets loaded with hidden: true)
|
||||||
|
*/
|
||||||
|
export function showAssetMeshes(asset: LoadedAsset): void {
|
||||||
|
for (const mesh of asset.meshes.values()) {
|
||||||
|
mesh.isVisible = true;
|
||||||
|
mesh.setEnabled(true);
|
||||||
|
}
|
||||||
|
log.debug(`[showAssetMeshes] Showed ${asset.meshes.size} meshes`);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user