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:
Michael Mainguy 2025-12-02 12:43:24 -06:00
parent ad2656a61f
commit 18a9ae9978
12 changed files with 1058 additions and 696 deletions

View File

@ -7,6 +7,8 @@ import { LevelConfig } from "../../levels/config/levelConfig";
import { Preloader } from "../../ui/screens/preloader";
import { LevelRegistry } from "../../levels/storage/levelRegistry";
import { enterXRMode } from "./xrEntryHandler";
import { prefetchAsset } from "../../utils/loadAsset";
import { prefetchAllAudio } from "../../utils/audioPrefetch";
import log from '../logger';
export interface LevelSelectedContext {
@ -37,8 +39,13 @@ export function createLevelSelectedHandler(
context.setProgressCallback((p, m) => preloader.updateProgress(p, m));
try {
// Phase 1: Load engine and prefetch assets
await loadEngineAndAssets(context, preloader);
await context.initializeXR();
// Phase 2: Create level and add meshes (hidden)
const level = await setupLevel(context, config, levelName, preloader);
displayLevelInfo(preloader, levelName);
preloader.updateProgress(90, 'Ready to enter VR...');
@ -48,8 +55,9 @@ export function createLevelSelectedHandler(
return;
}
// Phase 3: Enter XR, initialize physics, audio
preloader.showStartButton(async () => {
await startGameWithXR(context, config, levelName, preloader);
await startGameWithXR(context, level, preloader);
});
} catch (error) {
log.error('[Main] Level initialization failed:', error);
@ -71,14 +79,43 @@ async function loadEngineAndAssets(context: LevelSelectedContext, preloader: Pre
await context.initializeEngine();
}
if (!context.areAssetsLoaded()) {
preloader.updateProgress(40, 'Loading 3D models...');
preloader.updateProgress(20, 'Loading assets...');
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);
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 {
const entry = LevelRegistry.getInstance().getLevelEntry(levelName);
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(
context: LevelSelectedContext,
config: LevelConfig,
levelName: string,
level: Level1,
preloader: Preloader
): Promise<void> {
preloader.updateProgress(92, 'Entering VR...');
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();
await audioEngine?.unlockAsync();
preloader.updateProgress(95, 'Loading audio...');
await RockFactory.initAudio(audioEngine);
preloader.updateProgress(97, 'Loading audio...');
await Promise.all([
RockFactory.initAudio(audioEngine),
level.initializeAudio(audioEngine)
]);
attachAudioListener(audioEngine);
preloader.updateProgress(98, 'Creating level...');
const level = new Level1(config, audioEngine, false, levelName);
context.setCurrentLevel(level);
level.getReadyObservable().add(async () => {
await finalizeLevelStart(level, xrSession, engine, preloader, context);
});
await level.initialize();
// Finalize
await finalizeLevelStart(level, engine, preloader, context);
}
function attachAudioListener(audioEngine: AudioEngineV2): void {
@ -122,7 +169,6 @@ function attachAudioListener(audioEngine: AudioEngineV2): void {
async function finalizeLevelStart(
level: Level1,
xrSession: any,
engine: Engine,
preloader: Preloader,
context: LevelSelectedContext
@ -130,7 +176,7 @@ async function finalizeLevelStart(
const ship = (level as any)._ship;
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();
await level.showMissionBrief();
} else {

View File

@ -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 { InputControlManager } from "../ship/input/inputControlManager";
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 {
reportProgress(percent: number, message: string): void;
}
@ -35,19 +50,108 @@ async function createXRExperience(): Promise<void> {
disableTeleportation: true,
disableNearInteraction: true,
disableHandTracking: true,
disableDefaultUI: true
disableDefaultUI: true,
disablePointerSelection: true // Disable to re-enable with custom options
});
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 {
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) => {
if (state === 2) {
const pointerFeature = DefaultScene.XR!.baseExperience.featuresManager
.getEnabledFeature("xr-controller-pointer-selection");
if (pointerFeature) {
InputControlManager.getInstance().registerPointerFeature(pointerFeature);
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
.getEnabledFeature("xr-controller-pointer-selection");
if (pointerFeature) {
InputControlManager.getInstance().registerPointerFeature(pointerFeature);
}
}

View File

@ -7,6 +7,7 @@ import {
Vector3
} from "@babylonjs/core";
import {DefaultScene} from "../../core/defaultScene";
import { getAudioSource } from "../../utils/audioPrefetch";
import log from '../../core/logger';
/**
@ -68,27 +69,24 @@ export class ExplosionManager {
*/
public async initAudio(audioEngine: AudioEngineV2): Promise<void> {
this.audioEngine = audioEngine;
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++) {
const sound = await audioEngine.createSoundAsync(
`explosionSound_${i}`,
"/assets/themes/default/audio/explosion.mp3",
{
loop: false,
volume: 1.0,
spatialEnabled: true,
spatialDistanceModel: "linear",
spatialMaxDistance: 500,
spatialMinUpdateTime: 0.5,
spatialRolloffFactor: 1
}
);
const sound = await audioEngine.createSoundAsync(`explosionSound_${i}`, buffer, {
loop: false,
volume: 1.0,
spatialEnabled: true,
spatialDistanceModel: "linear",
spatialMaxDistance: 500,
spatialMinUpdateTime: 0.5,
spatialRolloffFactor: 1
});
this.explosionSounds.push(sound);
}
log.debug(`ExplosionManager: Loaded ${this.explosionSounds.length} explosion sounds`);
}

View File

@ -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 {
private static _asteroidMesh: AbstractMesh | null = null;
private static _explosionManager: ExplosionManager | 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 static get explosionManager(): ExplosionManager | null {
return this._explosionManager;
}
/**
* Initialize non-audio assets (meshes, explosion manager)
* Call this before audio engine is unlocked
* Initialize mesh only (Phase 2 - before XR)
* Just loads the asteroid mesh template, no physics
*/
public static async init() {
// Initialize explosion manager
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);
this._explosionManager = new ExplosionManager(DefaultScene.MainScene, {
duration: 2000,
explosionForce: 150.0,
frameRate: 60
});
await this._explosionManager.initialize();
// Reload mesh if not loaded or if it was disposed during cleanup
public static async initMesh(): Promise<void> {
if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) {
await this.loadMesh();
}
// Initialize explosion manager (visual only, audio added later)
if (!this._explosionManager) {
this._explosionManager = new ExplosionManager(DefaultScene.MainScene, {
duration: 2000,
explosionForce: 150.0,
frameRate: 60
});
await this._explosionManager.initialize();
}
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 {
log.debug('[RockFactory] Resetting static state');
this._asteroidMesh = null;
this._createdRocks.clear();
if (this._explosionManager) {
this._explosionManager.dispose();
this._explosionManager = null;
@ -106,119 +154,154 @@ export class RockFactory {
log.debug(this._asteroidMesh);
}
public static async createRock(i: number, position: Vector3, scale: number,
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
useOrbitConstraint: boolean = true): Promise<Rock> {
/**
* Create rock mesh only (Phase 2 - hidden, no physics)
*/
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) {
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);
log.debug(rock.id);
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
rock.scaling = new Vector3(scale, scale, scale);
rock.position = position;
//rock.material = this._rockMaterial;
rock.name = "asteroid-" + i;
rock.id = "asteroid-" + i;
rock.metadata = {type: 'asteroid'};
rock.setEnabled(true);
rock.metadata = { type: 'asteroid' };
rock.setEnabled(!hidden);
rock.isVisible = !hidden;
// 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, {
mass: 200,
friction: 0,
restitution: .8
// Don't pass radius - let Babylon compute from scaled mesh bounds
}, DefaultScene.MainScene);
const body = agg.body;
body.setAngularDamping(0);
// 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 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`);
}
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
mass: 200,
friction: 0,
restitution: .8
}, DefaultScene.MainScene);
body.setLinearDamping(0)
body.setMotionType(PhysicsMotionType.DYNAMIC);
body.setCollisionCallbackEnabled(true);
const body = agg.body;
body.setAngularDamping(0);
body.setLinearDamping(0);
body.setMotionType(PhysicsMotionType.DYNAMIC);
body.setCollisionCallbackEnabled(true);
// Prevent asteroids from sleeping to ensure consistent physics simulation
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
if (physicsPlugin) {
physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE);
}
log.debug(`[RockFactory] Setting velocities for ${rock.name}:`);
log.debug(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`);
log.debug(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`);
body.setLinearVelocity(linearVelocitry);
body.setAngularVelocity(angularVelocity);
// Verify velocities were set
const setLinear = body.getLinearVelocity();
const setAngular = body.getAngularVelocity();
log.debug(`[RockFactory] Linear velocity after set: ${setLinear.toString()}`);
log.debug(`[RockFactory] Angular velocity after set: ${setAngular.toString()}`);
body.getCollisionObservable().add((eventData) => {
if (eventData.type == 'COLLISION_STARTED') {
if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
log.debug('[RockFactory] ASTEROID HIT! Triggering explosion...');
// Get the asteroid mesh before disposing
const asteroidMesh = eventData.collider.transformNode as AbstractMesh;
const asteroidScale = asteroidMesh.scaling.x;
score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed", scale: asteroidScale});
log.debug('[RockFactory] Asteroid mesh to explode:', {
name: asteroidMesh.name,
id: asteroidMesh.id,
position: asteroidMesh.getAbsolutePosition().toString()
});
// Dispose asteroid physics objects BEFORE explosion (to prevent double-disposal)
log.debug('[RockFactory] Disposing asteroid physics objects...');
if (eventData.collider.shape) {
eventData.collider.shape.dispose();
}
if (eventData.collider) {
eventData.collider.dispose();
}
// Play explosion (visual + audio handled by ExplosionManager)
// Note: ExplosionManager will dispose the asteroid mesh after explosion
if (RockFactory._explosionManager) {
RockFactory._explosionManager.playExplosion(asteroidMesh);
}
// Dispose projectile physics objects
log.debug('[RockFactory] Disposing projectile physics objects...');
if (eventData.collidedAgainst.shape) {
eventData.collidedAgainst.shape.dispose();
}
if (eventData.collidedAgainst.transformNode) {
eventData.collidedAgainst.transformNode.dispose();
}
if (eventData.collidedAgainst) {
eventData.collidedAgainst.dispose();
}
log.debug('[RockFactory] Disposal complete');
}
}
});
// 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);
}
return new Rock(rock);
// Prevent sleeping
const physicsPlugin = DefaultScene.MainScene.getPhysicsEngine()?.getPhysicsPlugin() as HavokPlugin;
if (physicsPlugin) {
physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE);
}
body.setLinearVelocity(config.linearVelocity);
body.setAngularVelocity(config.angularVelocity);
// Setup collision handler
this.setupCollisionHandler(body, config.scoreObservable);
log.debug(`[RockFactory] Physics initialized for ${rock.id}`);
}
private static setupCollisionHandler(body: PhysicsBody, scoreObservable: Observable<ScoreEvent>): void {
body.getCollisionObservable().add((eventData) => {
if (eventData.type !== 'COLLISION_STARTED') return;
if (eventData.collidedAgainst.transformNode.id !== 'ammo') return;
const asteroidMesh = eventData.collider.transformNode as AbstractMesh;
const asteroidScale = asteroidMesh.scaling.x;
scoreObservable.notifyObservers({
score: 1,
remaining: -1,
message: "Asteroid Destroyed",
scale: asteroidScale
});
// Dispose asteroid physics
if (eventData.collider.shape) eventData.collider.shape.dispose();
if (eventData.collider) eventData.collider.dispose();
// Play explosion
if (RockFactory._explosionManager) {
RockFactory._explosionManager.playExplosion(asteroidMesh);
}
// Dispose projectile physics
if (eventData.collidedAgainst.shape) eventData.collidedAgainst.shape.dispose();
if (eventData.collidedAgainst.transformNode) eventData.collidedAgainst.transformNode.dispose();
if (eventData.collidedAgainst) eventData.collidedAgainst.dispose();
});
}
/**
* 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;
}
}

View File

@ -13,20 +13,32 @@ import {Vector3Array} from "../../levels/config/levelConfig";
interface StarBaseResult {
baseMesh: AbstractMesh;
landingMesh: AbstractMesh;
landingAggregate: PhysicsAggregate | null;
}
interface StarBaseMeshResult {
baseMesh: AbstractMesh;
landingMesh: AbstractMesh;
container: any;
}
/**
* 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 {
public static async buildStarBase(position?: Vector3Array, baseGlbPath: string = 'base.glb'): Promise<StarBaseResult> {
const config = GameConfig.getInstance();
const scene = DefaultScene.MainScene;
const importMeshes = await loadAsset(baseGlbPath);
// Store loaded mesh data for deferred physics
private static _loadedBase: StarBaseMeshResult | null = null;
/**
* 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 landingMesh = importMeshes.meshes.get('BaseLandingZone');
@ -37,31 +49,91 @@ export default class StarBase {
baseMesh.metadata.baseGlbPath = baseGlbPath;
}
// Apply position to both meshes (defaults to [0, 0, 0])
(importMeshes.container.rootNodes[0] as TransformNode).position
= position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
// Apply position
(importMeshes.container.rootNodes[0] as TransformNode).position =
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) {
const agg2 = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, {
mass: 10000
}, scene);
agg2.body.setMotionType(PhysicsMotionType.ANIMATED);
log.debug(`[StarBase] Added to scene (hidden: ${hidden})`);
return { baseMesh, landingMesh, container: importMeshes.container };
}
agg2.body.getCollisionObservable().add((collidedBody) => {
log.debug('collidedBody', collidedBody);
})
landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH);
landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
landingAgg.shape.isTrigger = true;
landingAgg.body.setCollisionCallbackEnabled(true);
/**
* 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;
}
//importMesh.rootNodes[0].dispose();
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
}, scene);
baseAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
baseAgg.body.getCollisionObservable().add((collidedBody) => {
log.debug('collidedBody', collidedBody);
});
// Create physics for landing zone
const landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH);
landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED);
landingAgg.shape.isTrigger = true;
landingAgg.body.setCollisionCallbackEnabled(true);
log.debug('[StarBase] Physics initialized');
return landingAgg;
}
/**
* 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 {
baseMesh,
landingAggregate: landingAgg
baseMesh: meshResult.baseMesh,
landingMesh: meshResult.landingMesh,
landingAggregate
};
}
}

View File

@ -48,29 +48,36 @@ export class LevelDeserializer {
this.config = config;
}
// Store score observable for deferred physics
private _scoreObservable: Observable<ScoreEvent> | null = null;
/**
* Create all entities from the configuration
* @param scoreObservable - Observable for score events
* Deserialize meshes only (Phase 2 - before XR, hidden)
*/
public async deserialize(
scoreObservable: Observable<ScoreEvent>
public async deserializeMeshes(
scoreObservable: Observable<ScoreEvent>,
hidden: boolean = false
): Promise<{
startBase: AbstractMesh | null;
landingAggregate: PhysicsAggregate | null;
sun: AbstractMesh;
planets: 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 planets = this.createPlanets();
const asteroids = await this.createAsteroids(scoreObservable);
// Create asteroid meshes (no physics)
const asteroids = await this.createAsteroidMeshes(scoreObservable, hidden);
return {
startBase: baseResult.baseMesh,
landingAggregate: baseResult.landingAggregate,
startBase: baseResult?.baseMesh || null,
sun,
planets,
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 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(
scoreObservable: Observable<ScoreEvent>
private async createAsteroidMeshes(
scoreObservable: Observable<ScoreEvent>,
hidden: boolean
): Promise<AbstractMesh[]> {
const asteroids: AbstractMesh[] = [];
for (let i = 0; i < this.config.asteroids.length; 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;
log.debug(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`);
// Use RockFactory to create the asteroid
const _rock = await RockFactory.createRock(
// Create mesh only (no physics)
RockFactory.createRockMesh(
i,
this.arrayToVector3(asteroidConfig.position),
asteroidConfig.scale,
this.arrayToVector3(asteroidConfig.linearVelocity),
this.arrayToVector3(asteroidConfig.angularVelocity),
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);
if (mesh) {
asteroids.push(mesh);
}
}
log.debug(`Created ${asteroids.length} asteroids from config`);
log.debug(`[LevelDeserializer] Created ${asteroids.length} asteroid meshes (hidden: ${hidden})`);
return asteroids;
}
/**
* 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]);
}
/**
* 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);
}
}

View File

@ -31,7 +31,14 @@ export class LevelHintSystem {
// Track triggered thresholds to prevent re-triggering
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;
}
@ -160,6 +167,12 @@ export class LevelHintSystem {
* Queue a hint for audio playback
*/
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
if (hint.playMode === 'once' && this._playedHints.has(hint.id)) {
return;

View File

@ -21,6 +21,8 @@ import {LevelRegistry} from "./storage/levelRegistry";
import type {CloudLevelEntry} from "../services/cloudLevelService";
import { InputControlManager } from "../ship/input/inputControlManager";
import { LevelHintSystem } from "./hints/levelHintSystem";
import { getAudioSource } from "../utils/audioPrefetch";
import { RockFactory } from "../environment/asteroids/rockFactory";
export class Level1 implements Level {
private _ship: Ship;
@ -40,37 +42,22 @@ export class Level1 implements Level {
private _hintSystem: LevelHintSystem;
private _gameStarted: 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._levelId = levelId || null;
this._audioEngine = audioEngine;
this._audioEngine = audioEngine!; // Will be set later if not provided
this._isReplayMode = isReplayMode;
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._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');
log.debug('XR input exists:', !!xr.input);
log.debug('onControllerAddedObservable exists:', !!xr.input?.onControllerAddedObservable);
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
// XR camera setup and mission brief are now handled by levelSelectedHandler
// after audio is initialized (see finalizeLevelStart)
// Don't call initialize here - let Main call it after setup
}
getReadyObservable(): Observable<Level> {
@ -99,16 +86,6 @@ export class Level1 implements Level {
// Create intermediate TransformNode for camera rotation
// 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
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
const canvas = document.getElementById('gameCanvas');
if (canvas) {
@ -156,6 +133,16 @@ export class Level1 implements Level {
xr.input.onControllerAddedObservable.add((controller) => {
log.debug('[Level1] 🎮 Controller added:', controller.inputSource.handedness);
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 ==========');
@ -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) {
log.error('Initialize called twice');
log.error('[Level1] Already initialized');
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();
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...");
if (shipConfig.linearVelocity) {
this._ship.setLinearVelocity(new Vector3(...shipConfig.linearVelocity));
} else {
this._ship.setLinearVelocity(Vector3.Zero());
}
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);
// Deserialize level meshes (no physics)
const entities = await this._deserializer.deserializeMeshes(
this._ship.scoreboard.onScoreObservable,
hidden
);
this._startBase = entities.startBase;
this._landingAggregate = entities.landingAggregate;
this._asteroidCount = entities.asteroids.length;
// Setup resupply system if landing aggregate exists
if (this._landingAggregate) {
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`);
// Initialize scoreboard with asteroid count
this._ship.scoreboard.setRemainingCount(this._asteroidCount);
// Create background starfield
setLoadingMessage("Creating starfield...");
this._backgroundStars = new BackgroundStars(DefaultScene.MainScene, {
count: 5000,
radius: 5000,
minBrightness: 0.3,
radius: 3000,
minBrightness: 0.1,
maxBrightness: 1.0,
pointSize: 2
pointSize: 1
});
// Set up camera follow for stars (keeps stars at infinite distance)
// Also update hint system audio queue
// Set up render loop updates
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
if (this._backgroundStars) {
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
if (camera) {
this._backgroundStars.followCamera(camera.position);
}
if (camera) this._backgroundStars.followCamera(camera.globalPosition);
}
// Process hint audio queue
this._hintSystem?.update();
});
// Initialize physics recorder (but don't start it yet - will start on XR pose)
// Only create recorder in game mode, not replay mode
if (!this._isReplayMode) {
setLoadingMessage("Initializing physics recorder...");
//this._physicsRecorder = new PhysicsRecorder(DefaultScene.MainScene, this._levelConfig);
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)
// Load hints (non-physics)
if (this._levelId) {
const registry = LevelRegistry.getInstance();
const registryEntry = registry.getAllLevels().get(this._levelId);
@ -458,34 +437,73 @@ export class Level1 implements Level {
this._ship.scoreboard.onScoreObservable,
this._ship.onCollisionObservable
);
log.info('[Level1] Hint system initialized with level UUID:', registryEntry.id);
}
}
this._initialized = true;
// Set par time and level info
this.setupStatusScreen();
// Set par time and level info for score calculation and results recording
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}`);
log.debug('[Level1] addToScene complete');
}
// 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!');
/**
* 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));
}
// Notify that initialization is complete
// 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._onReadyObservable.notifyObservers(this);
log.debug('[Level1] initializePhysics complete');
}
/**
* 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();
}
/**

View File

@ -77,6 +77,9 @@ export class Ship {
private _physicsObserver: any = null;
private _renderObserver: any = null;
// Store loaded asset data for physics initialization
private _loadedAssetData: any = null;
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
this._audioEngine = audioEngine;
this._isReplayMode = isReplayMode;
@ -155,251 +158,49 @@ export class Ship {
}
}
public async initialize(initialPosition?: Vector3) {
this._scoreboard = new Scoreboard();
this._scoreboard.setShip(this); // Pass ship reference for velocity reading
this._gameStats = new GameStats();
this._ship = new TransformNode("shipBase", DefaultScene.MainScene);
const data = await loadAsset("ship.glb");
this._ship = data.container.transformNodes[0];
/**
* Add ship to scene (Phase 2 - before XR entry)
* Loads mesh, creates non-physics systems, optionally hidden
*/
public async addToScene(initialPosition?: Vector3, hidden: boolean = false): Promise<void> {
log.debug(`[Ship] addToScene called (hidden: ${hidden})`);
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) {
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)
if (!this._isReplayMode) {
this._keyboardInput = new KeyboardInput(DefaultScene.MainScene);
this._keyboardInput.setup();
this._controllerInput = new ControllerInput();
// Register input systems with InputControlManager
const inputManager = InputControlManager.getInstance();
inputManager.registerInputSystems(this._keyboardInput, this._controllerInput);
// Wire up shooting events
this._keyboardInput.onShootObservable.add(() => {
this.handleShoot();
});
this._controllerInput.onShootObservable.add(() => {
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;
}
}
});
this._keyboardInput.onShootObservable.add(() => this.handleShoot());
this._controllerInput.onShootObservable.add(() => this.handleShoot());
this._controllerInput.onStatusScreenToggleObservable.add(() => this.toggleStatusScreen());
this._controllerInput.onInspectorToggleObservable.add(() => this.toggleInspector());
this._keyboardInput.onCameraChangeObservable.add((key) => this.handleCameraChange(key));
this._controllerInput.onCameraAdjustObservable.add((adj) => this.handleCameraAdjust(adj));
}
// Initialize physics controller
this._physics = new ShipPhysics();
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
);
// Setup camera (non-physics)
this._camera = new FreeCamera("Flat Camera", new Vector3(0, 1.5, 0), DefaultScene.MainScene);
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);
// Set as active camera if XR is not available
if (!DefaultScene.XR && !this._isReplayMode) {
DefaultScene.MainScene.activeCamera = this._camera;
//this._camera.attachControl(DefaultScene.MainScene.getEngine().getRenderingCanvas(), true);
log.debug('Flat camera set as active camera');
}
@ -414,52 +215,11 @@ export class Ship {
centerGap: 0.5,
});
// Initialize scoreboard (it will retrieve and setup its own screen mesh)
// Initialize scoreboard and subscribe to events
this._scoreboard.initialize();
this.setupScoreboardObservers();
// Subscribe to score events to track asteroids destroyed
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
// Initialize status screen
this._statusScreen = new StatusScreen(
DefaultScene.MainScene,
this._ship,
@ -470,6 +230,220 @@ export class Ship {
() => this.handleNextLevel()
);
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');
}
/**

View File

@ -1,4 +1,5 @@
import type { AudioEngineV2, StaticSound } from "@babylonjs/core";
import { getAudioSource } from "../utils/audioPrefetch";
/**
* Manages ship audio (thrust sounds and weapon fire)
@ -22,40 +23,32 @@ export class ShipAudio {
public async initialize(): Promise<void> {
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(
"thrust",
"/assets/themes/default/audio/thrust5.mp3",
{
loop: true,
volume: 0.2,
}
getAudioSource(thrustUrl),
{ loop: true, volume: 0.2 }
);
this._secondaryThrustSound = await this._audioEngine.createSoundAsync(
"thrust2",
"/assets/themes/default/audio/thrust5.mp3",
{
loop: true,
volume: 0.5,
}
getAudioSource(thrustUrl),
{ loop: true, volume: 0.5 }
);
this._weaponSound = await this._audioEngine.createSoundAsync(
"shot",
"/assets/themes/default/audio/shot.mp3",
{
loop: false,
volume: 0.5,
}
getAudioSource(shotUrl),
{ loop: false, volume: 0.5 }
);
this._collisionSound = await this._audioEngine.createSoundAsync(
"collision",
"/assets/themes/default/audio/collision.mp3",
{
loop: false,
volume: 0.25,
}
getAudioSource(collisionUrl),
{ loop: false, volume: 0.25 }
);
}

View 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;
}

View File

@ -6,13 +6,47 @@ type LoadedAsset = {
container: AssetContainer,
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}`;
log.debug(`[loadAsset] Loading: ${assetPath}`);
try {
const container = await LoadAssetContainerAsync(assetPath, DefaultScene.MainScene);
log.debug(`[loadAsset] ✓ Container loaded for ${file}`);
// 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}`);
}
const map: Map<string, AbstractMesh> = new Map();
container.addAllToScene();
@ -23,17 +57,18 @@ export default async function loadAsset(file: string, theme: string = "default")
return {container: container, meshes: map};
}
const shouldHide = options.hidden === true;
for (const mesh of container.rootNodes[0].getChildMeshes(false)) {
log.info(mesh.id, mesh);
// Ensure mesh is visible and enabled
mesh.isVisible = true;
mesh.setEnabled(true);
// Set visibility based on hidden option
mesh.isVisible = !shouldHide;
mesh.setEnabled(!shouldHide);
// Fix emissive materials to work without lighting
if (mesh.material) {
const material = mesh.material as any;
// Disable lighting on materials so emissive works without light sources
if (material.disableLighting !== undefined) {
material.disableLighting = true;
}
@ -42,10 +77,21 @@ export default async function loadAsset(file: string, theme: string = "default")
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};
} catch (error) {
log.error(`[loadAsset] FAILED to load ${assetPath}:`, 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`);
}