Fix routing, cleanup, and game restart issues

- Switch from svelte-spa-router to svelte-routing for clean URLs without hashes
- Fix relative asset paths to absolute paths (prevents 404s on nested routes)
- Fix physics engine disposal using scene.disablePhysicsEngine()
- Fix Ship observer cleanup to prevent stale callbacks after level disposal
- Add _gameplayStarted flag to prevent false game-end triggers during init
- Add hasAsteroidsToDestroy check to prevent false victory on restart
- Add RockFactory.reset() to properly reinitialize asteroid mesh between games
- Add null safety checks throughout RockFactory for static properties

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-25 11:21:05 -06:00
parent 622e0a5259
commit 28c1b2b2aa
32 changed files with 470 additions and 109 deletions

27
package-lock.json generated
View File

@ -20,7 +20,7 @@
"@newrelic/browser-agent": "^1.302.0",
"loglevel": "^1.9.2",
"openai": "4.52.3",
"svelte-spa-router": "^4.0.1"
"svelte-routing": "^2.13.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@ -1612,15 +1612,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/regexparam": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz",
"integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -1761,17 +1752,11 @@
}
}
},
"node_modules/svelte-spa-router": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.1.tgz",
"integrity": "sha512-2JkmUQ2f9jRluijL58LtdQBIpynSbem2eBGp4zXdi7aDY1znbR6yjw0KsonD0aq2QLwf4Yx4tBJQjxIjgjXHKg==",
"license": "MIT",
"dependencies": {
"regexparam": "2.0.2"
},
"funding": {
"url": "https://github.com/sponsors/ItalyPaleAle"
}
"node_modules/svelte-routing": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/svelte-routing/-/svelte-routing-2.13.0.tgz",
"integrity": "sha512-/NTxqTwLc7Dq306hARJrH2HLXOBtKd7hu8nxgoFDlK0AC4SOKnzisiX/9m8Uksei1QAWtlAEdF91YphNM8iDMg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",

View File

@ -25,9 +25,9 @@
"@babylonjs/procedural-textures": "8.36.1",
"@babylonjs/serializers": "8.36.1",
"@newrelic/browser-agent": "^1.302.0",
"openai": "4.52.3",
"loglevel": "^1.9.2",
"svelte-spa-router": "^4.0.1"
"openai": "4.52.3",
"svelte-routing": "^2.13.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { link } from 'svelte-spa-router';
import { Link } from 'svelte-routing';
import { controllerMappingStore } from '../../stores/controllerMapping';
import { ControllerMappingConfig } from '../../ship/input/controllerMapping';
import Button from '../shared/Button.svelte';
@ -81,7 +81,7 @@
</script>
<div class="editor-container">
<a href="/" use:link class="back-link">← Back to Game</a>
<Link to="/" class="back-link">← Back to Game</Link>
<h1>🎮 Controller Mapping</h1>
<p class="subtitle">Customize VR controller button and stick mappings</p>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { link } from 'svelte-spa-router';
import { Link } from 'svelte-routing';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
@ -8,7 +8,7 @@
</script>
<div class="editor-container">
<a href="/" use:link class="back-link">← Back to Game</a>
<Link to="/" class="back-link">← Back to Game</Link>
<h1>📝 Level Editor</h1>
<p class="subtitle">Create and customize your own asteroid field levels</p>

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { navigate } from 'svelte-routing';
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry';
import { levelRegistryStore } from '../../stores/levelRegistry';
import { authStore } from '../../stores/auth';
@ -37,17 +38,9 @@
return;
}
// Dispatch custom event for main.ts to handle
console.log('[LevelCard] Level unlocked, loading config...');
const config = await levelRegistryStore.getLevel(levelId);
if (config) {
console.log('[LevelCard] Config loaded, dispatching levelSelected event');
window.dispatchEvent(new CustomEvent('levelSelected', {
detail: { levelName: levelId, config }
}));
} else {
console.error('[LevelCard] Failed to load level config');
}
// Navigate to level play route
console.log('[LevelCard] Level unlocked, navigating to /play/' + levelId);
navigate(`/play/${levelId}`);
}
async function handleDelete() {

View File

@ -1,7 +1,6 @@
<script lang="ts">
import { levelRegistryStore } from '../../stores/levelRegistry';
import { authStore } from '../../stores/auth';
import { link } from 'svelte-spa-router';
import LevelCard from './LevelCard.svelte';
import ProgressBar from './ProgressBar.svelte';

View File

@ -0,0 +1,205 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { navigate } from 'svelte-routing';
import { Main } from '../../main';
import type { LevelConfig } from '../../levels/config/levelConfig';
import { LevelRegistry } from '../../levels/storage/levelRegistry';
import debugLog from '../../core/debug';
import { DefaultScene } from '../../core/defaultScene';
// svelte-routing passes params as an object with route params
export let params: { levelId?: string } = {};
// Also accept levelId directly in case it's passed that way
export let levelId: string = '';
let mainInstance: Main | null = null;
let levelName: string = '';
let isInitialized = false;
let error: string | null = null;
let isExiting = false;
// Get the actual levelId from either source
$: actualLevelId = params?.levelId || levelId || '';
// Handle browser back button
function handleBeforeUnload(event: BeforeUnloadEvent) {
// Only prompt if in XR session
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) {
event.preventDefault();
event.returnValue = 'You are currently in VR. Are you sure you want to exit?';
return event.returnValue;
}
}
// Handle popstate (browser back/forward buttons)
function handlePopState() {
if (isInitialized && !isExiting && !window.location.pathname.startsWith('/play/')) {
debugLog('[PlayLevel] Navigation detected via popstate, starting cleanup');
isExiting = true;
}
}
onMount(async () => {
console.log('[PlayLevel] Component mounted');
console.log('[PlayLevel] params:', params);
console.log('[PlayLevel] levelId prop:', levelId);
console.log('[PlayLevel] actualLevelId:', actualLevelId);
console.log('[PlayLevel] window.location.pathname:', window.location.pathname);
// Try to extract levelId from URL if props don't have it
let extractedLevelId = actualLevelId;
if (!extractedLevelId) {
const match = window.location.pathname.match(/\/play\/(.+)/);
if (match) {
extractedLevelId = match[1];
console.log('[PlayLevel] Extracted levelId from URL:', extractedLevelId);
}
}
levelName = extractedLevelId;
if (!levelName) {
console.error('[PlayLevel] No levelId found!');
error = 'No level specified';
return;
}
console.log('[PlayLevel] Using levelName:', levelName);
// Add event listeners
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('popstate', handlePopState);
try {
// Hide the Svelte UI overlay (keep in DOM for reactivity)
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'none';
debugLog('[PlayLevel] App UI hidden');
}
// Get the main instance (should already exist from app initialization)
mainInstance = (window as any).__mainInstance as Main;
if (!mainInstance) {
throw new Error('Main instance not found');
}
// Get level config from registry
const registry = LevelRegistry.getInstance();
const levelEntry = await registry.getLevel(levelName);
if (!levelEntry) {
throw new Error(`Level "${levelName}" not found`);
}
debugLog('[PlayLevel] Level config loaded:', levelEntry);
// Dispatch the levelSelected event (existing system expects this)
// We'll refactor this later to call Main methods directly
// Note: registry.getLevel() returns LevelConfig directly, not a wrapper
const event = new CustomEvent('levelSelected', {
detail: {
levelName: levelName,
config: levelEntry
}
});
window.dispatchEvent(event);
isInitialized = true;
debugLog('[PlayLevel] Level initialization started');
} catch (err) {
console.error('[PlayLevel] Error initializing level:', err);
error = err instanceof Error ? err.message : 'Unknown error';
// Show UI again on error
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
}
// Navigate back to home after showing error
setTimeout(() => {
navigate('/', { replace: true });
}, 3000);
}
});
onDestroy(async () => {
debugLog('[PlayLevel] Component unmounting - cleaning up');
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('popstate', handlePopState);
try {
// Call the cleanup method on Main instance
if (mainInstance && typeof mainInstance.cleanupAndExit === 'function') {
await mainInstance.cleanupAndExit();
}
// Ensure UI is visible again
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
debugLog('[PlayLevel] App UI restored');
}
} catch (err) {
console.error('[PlayLevel] Error during cleanup:', err);
// Force UI to show even if cleanup failed
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
}
}
});
</script>
<!-- Minimal template - BabylonJS canvas is fixed background -->
<div class="play-level-container">
{#if error}
<div class="error-overlay">
<h2>Error Loading Level</h2>
<p>{error}</p>
<p>Returning to level select...</p>
</div>
{/if}
</div>
<style>
.play-level-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
.error-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
padding: 2rem;
border-radius: 10px;
color: white;
text-align: center;
z-index: 1000;
pointer-events: auto;
}
.error-overlay h2 {
color: #ff4444;
margin-bottom: 1rem;
}
.error-overlay p {
margin: 0.5rem 0;
}
</style>

View File

@ -1,34 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import Router from 'svelte-spa-router';
import { wrap } from 'svelte-spa-router/wrap';
import { Router, Route } from 'svelte-routing';
import AppHeader from './AppHeader.svelte';
import { navigationStore } from '../../stores/navigation';
import { AuthService } from '../../services/authService';
import { authStore } from '../../stores/auth';
// Import game view directly (most common route)
// Import game views
import LevelSelect from '../game/LevelSelect.svelte';
// Lazy load other views for better performance
const routes = {
'/': LevelSelect,
'/editor': wrap({
asyncComponent: () => import('../editor/LevelEditor.svelte')
}),
'/settings': wrap({
asyncComponent: () => import('../settings/SettingsScreen.svelte')
}),
'/controls': wrap({
asyncComponent: () => import('../controls/ControlsScreen.svelte')
}),
};
// Track route changes
function routeLoaded(event: CustomEvent) {
const { route } = event.detail;
navigationStore.setRoute(route);
}
import PlayLevel from '../game/PlayLevel.svelte';
import LevelEditor from '../editor/LevelEditor.svelte';
import SettingsScreen from '../settings/SettingsScreen.svelte';
import ControlsScreen from '../controls/ControlsScreen.svelte';
// Initialize Auth0 when component mounts
onMount(async () => {
@ -50,13 +33,21 @@
});
</script>
<Router>
<div class="app">
<AppHeader />
<div class="app-content">
<Router {routes} on:routeLoaded={routeLoaded} />
<Route path="/"><LevelSelect /></Route>
<Route path="/play/:levelId" let:params>
<PlayLevel {params} />
</Route>
<Route path="/editor"><LevelEditor /></Route>
<Route path="/settings"><SettingsScreen /></Route>
<Route path="/controls"><ControlsScreen /></Route>
</div>
</div>
</Router>
<style>
.app {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { link } from 'svelte-spa-router';
import { Link } from 'svelte-routing';
import { authStore } from '../../stores/auth';
import UserProfile from '../auth/UserProfile.svelte';
@ -16,11 +16,11 @@
<h1 class="app-title">Space Combat VR</h1>
</div>
<nav class="header-nav">
<a href="/controls" use:link class="nav-link controls-link">🎮 Customize Controls</a>
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
<UserProfile />
<a href="/editor" use:link class="nav-link editor-link">📝 Level Editor</a>
<Link to="/editor" class="nav-link editor-link">📝 Level Editor</Link>
<a href="/settings" use:link class="nav-link settings-link">⚙️ Settings</a>
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
</nav>
</div>
</header>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { link } from 'svelte-spa-router';
import { Link } from 'svelte-routing';
import { gameConfigStore } from '../../stores/gameConfig';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
@ -46,7 +46,7 @@
</script>
<div class="editor-container">
<a href="/" use:link class="back-link">← Back to Game</a>
<Link to="/" class="back-link">← Back to Game</Link>
<h1>⚙️ Game Settings</h1>
<p class="subtitle">Configure graphics quality and physics settings</p>

View File

@ -35,9 +35,9 @@ export class Rock {
}
export class RockFactory {
private static _asteroidMesh: AbstractMesh;
private static _explosionManager: ExplosionManager;
private static _orbitCenter: PhysicsAggregate;
private static _asteroidMesh: AbstractMesh | null = null;
private static _explosionManager: ExplosionManager | null = null;
private static _orbitCenter: PhysicsAggregate | null = null;
/**
* Initialize non-audio assets (meshes, explosion manager)
@ -56,25 +56,46 @@ export class RockFactory {
});
await this._explosionManager.initialize();
if (!this._asteroidMesh) {
// Reload mesh if not loaded or if it was disposed during cleanup
if (!this._asteroidMesh || this._asteroidMesh.isDisposed()) {
await this.loadMesh();
}
}
/**
* Reset static state - call during game cleanup
*/
public static reset(): void {
debugLog('[RockFactory] Resetting static state');
this._asteroidMesh = null;
if (this._explosionManager) {
this._explosionManager.dispose();
this._explosionManager = null;
}
if (this._orbitCenter) {
this._orbitCenter.dispose();
this._orbitCenter = null;
}
}
/**
* Initialize audio (explosion sound)
* Call this AFTER audio engine is unlocked
*/
public static async initAudio(audioEngine: AudioEngineV2) {
debugLog('[RockFactory] Initializing audio via ExplosionManager');
if (this._explosionManager) {
await this._explosionManager.initAudio(audioEngine);
}
debugLog('[RockFactory] Audio initialization complete');
}
private static async loadMesh() {
debugLog('loading mesh');
this._asteroidMesh = (await loadAsset("asteroid.glb")).meshes.get('Asteroid');
//this._asteroidMesh.setParent(null);
const asset = await loadAsset("asteroid.glb");
this._asteroidMesh = asset.meshes.get('Asteroid') || null;
if (this._asteroidMesh) {
this._asteroidMesh.setEnabled(false);
}
debugLog(this._asteroidMesh);
}
@ -82,6 +103,10 @@ export class RockFactory {
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
useOrbitConstraint: boolean = true): Promise<Rock> {
if (!this._asteroidMesh) {
throw new Error('[RockFactory] Asteroid mesh not loaded. Call init() first.');
}
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
debugLog(rock.id);
rock.scaling = new Vector3(scale, scale, scale);
@ -104,8 +129,8 @@ export class RockFactory {
}, DefaultScene.MainScene);
const body = agg.body;
// Only apply orbit constraint if enabled for this level
if (useOrbitConstraint) {
// Only apply orbit constraint if enabled for this level and orbit center exists
if (useOrbitConstraint && this._orbitCenter) {
debugLog(`[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);
@ -160,7 +185,9 @@ export class RockFactory {
// 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
debugLog('[RockFactory] Disposing projectile physics objects...');

View File

@ -193,6 +193,9 @@ export class Level1 implements Level {
this._gameStarted = true;
debugLog('[Level1] Starting gameplay');
// Enable game end condition checking on ship
this._ship.startGameplay();
// Start game timer
this._ship.gameStats.startTimer();
debugLog('Game timer started');
@ -275,7 +278,9 @@ export class Level1 implements Level {
if (this._startBase) {
this._startBase.dispose();
}
if (this._endBase) {
this._endBase.dispose();
}
if (this._backgroundStars) {
this._backgroundStars.dispose();
}
@ -285,6 +290,12 @@ export class Level1 implements Level {
if (this._missionBrief) {
this._missionBrief.dispose();
}
if (this._ship) {
this._ship.dispose();
}
if (this._backgroundMusic) {
this._backgroundMusic.dispose();
}
}
public async initialize() {

View File

@ -262,14 +262,12 @@ export class Main {
preloader.hide();
}, 500);
// Remove UI
console.log('[Main] ========== ABOUT TO REMOVE MAIN DIV ==========');
// Hide UI (no longer remove from DOM - let Svelte routing handle it)
console.log('[Main] ========== HIDING UI FOR GAMEPLAY ==========');
console.log('[Main] mainDiv exists:', !!mainDiv);
console.log('[Main] Timestamp:', Date.now());
if (mainDiv) {
mainDiv.remove();
console.log('[Main] mainDiv removed from DOM');
}
// Note: With route-based loading, the app will be hidden by PlayLevel component
// This code path is only used when dispatching levelSelected event (legacy support)
// Start the game (XR session already active, or flat mode)
console.log('[Main] About to call this.play()');
@ -346,10 +344,12 @@ export class Main {
debugLog('[Main] ========== TEST LEVEL READY OBSERVABLE FIRED ==========');
setLoadingMessage("Test Scene Ready! Entering VR...");
// Remove UI and play immediately (must maintain user activation for XR)
if (mainDiv) {
mainDiv.remove();
debugLog('[Main] mainDiv removed');
// Hide UI for gameplay (no longer remove from DOM)
// Test level doesn't use routing, so we need to hide the app element
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'none';
debugLog('[Main] App UI hidden for test level');
}
debugLog('[Main] About to call this.play()...');
await this.play();
@ -493,6 +493,102 @@ export class Main {
return this._audioEngine;
}
/**
* Cleanup and exit XR gracefully, returning to main menu
*/
public async cleanupAndExit(): Promise<void> {
debugLog('[Main] cleanupAndExit() called - starting graceful shutdown');
try {
// 1. Stop render loop first (before disposing anything)
debugLog('[Main] Stopping render loop...');
this._engine.stopRenderLoop();
// 2. Dispose current level and all its resources (includes ship, weapons, etc.)
if (this._currentLevel) {
debugLog('[Main] Disposing level...');
this._currentLevel.dispose();
this._currentLevel = null;
}
// 2.5. Reset RockFactory static state (asteroid mesh, explosion manager, etc.)
RockFactory.reset();
// 3. Exit XR session if active (after disposing level to avoid state issues)
if (DefaultScene.XR && DefaultScene.XR.baseExperience.state === 2) { // WebXRState.IN_XR = 2
debugLog('[Main] Exiting XR session...');
try {
await DefaultScene.XR.baseExperience.exitXRAsync();
debugLog('[Main] XR session exited successfully');
} catch (error) {
debugLog('[Main] Error exiting XR session:', error);
}
}
// 4. Clear remaining scene objects (anything not disposed by level)
if (DefaultScene.MainScene) {
debugLog('[Main] Disposing remaining scene meshes and materials...');
// Clone arrays to avoid modification during iteration
const meshes = DefaultScene.MainScene.meshes.slice();
const materials = DefaultScene.MainScene.materials.slice();
meshes.forEach(mesh => {
if (!mesh.isDisposed()) {
try {
mesh.dispose();
} catch (error) {
debugLog('[Main] Error disposing mesh:', error);
}
}
});
materials.forEach(material => {
try {
material.dispose();
} catch (error) {
debugLog('[Main] Error disposing material:', error);
}
});
}
// 5. Disable physics engine (properly disposes AND clears scene reference)
if (DefaultScene.MainScene && DefaultScene.MainScene.isPhysicsEnabled()) {
debugLog('[Main] Disabling physics engine...');
DefaultScene.MainScene.disablePhysicsEngine();
}
// 6. Clear XR reference (will be recreated on next game start)
DefaultScene.XR = null;
// 7. Reset initialization flags so game can be restarted
this._initialized = false;
this._assetsLoaded = false;
this._started = false;
// 8. Restart render loop with empty scene
debugLog('[Main] Restarting render loop with empty scene...');
this._engine.runRenderLoop(() => {
if (DefaultScene.MainScene) {
DefaultScene.MainScene.render();
}
});
// 9. Show Discord widget (UI will be shown by Svelte router)
const discord = (window as any).__discordWidget as DiscordWidget;
if (discord) {
debugLog('[Main] Showing Discord widget');
discord.show();
}
debugLog('[Main] Cleanup complete - ready for new game');
} catch (error) {
console.error('[Main] Error during cleanup:', error);
// If cleanup fails, fall back to page reload
debugLog('[Main] Cleanup failed, falling back to page reload');
window.location.reload();
}
}
public async play() {
debugLog('[Main] play() called');
debugLog('[Main] Current level exists:', !!this._currentLevel);
@ -789,7 +885,7 @@ async function initializeApp() {
await LevelRegistry.getInstance().initialize();
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
debugLog('[Main] LevelRegistry initialized after migration');
// NOTE: Old router disabled - now using svelte-spa-router
// NOTE: Old router disabled - now using svelte-routing
// router.start();
// Mount Svelte app
@ -817,7 +913,7 @@ async function initializeApp() {
resolve();
} catch (error) {
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
// NOTE: Old router disabled - now using svelte-spa-router
// NOTE: Old router disabled - now using svelte-routing
// router.start(); // Start anyway to show error state
resolve();
}
@ -844,7 +940,7 @@ async function initializeApp() {
console.log('[Main] To clear caches: window.__levelRegistry.clearAllCaches().then(() => location.reload())');
}
// NOTE: Old router disabled - now using svelte-spa-router
// NOTE: Old router disabled - now using svelte-routing
// console.log('[Main] About to call router.start()');
// router.start();
// console.log('[Main] router.start() completed');
@ -852,7 +948,7 @@ async function initializeApp() {
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
console.error('[Main] Failed to initialize LevelRegistry:', error);
console.error('[Main] Error stack:', error?.stack);
// NOTE: Old router disabled - now using svelte-spa-router
// NOTE: Old router disabled - now using svelte-routing
// router.start(); // Start anyway to show error state
}
}

View File

@ -68,9 +68,16 @@ export class Ship {
// Auto-show status screen flag
private _statusScreenAutoShown: boolean = false;
// Flag to prevent game end checks until gameplay has started
private _gameplayStarted: boolean = false;
// Controls enabled state
private _controlsEnabled: boolean = true;
// Scene observer references (for cleanup)
private _physicsObserver: any = null;
private _renderObserver: any = null;
constructor(audioEngine?: AudioEngineV2, isReplayMode: boolean = false) {
this._audioEngine = audioEngine;
this._isReplayMode = isReplayMode;
@ -92,6 +99,15 @@ export class Ship {
return this._isInLandingZone;
}
/**
* Start gameplay - enables game end condition checking
* Call this after level initialization is complete
*/
public startGameplay(): void {
this._gameplayStarted = true;
debugLog('[Ship] Gameplay started - game end conditions now active');
}
public get onMissionBriefTriggerObservable(): Observable<void> {
return this._onMissionBriefTriggerObservable;
}
@ -316,10 +332,10 @@ export class Ship {
this._physics.setGameStats(this._gameStats);
// Setup physics update loop (every 10 frames)
DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
this._physicsObserver = DefaultScene.MainScene.onAfterPhysicsObservable.add(() => {
this.updatePhysics();
})
DefaultScene.MainScene.onAfterRenderObservable.add(() => {
});
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();
@ -422,10 +438,19 @@ export class Ship {
/**
* Handle exit VR button click from status screen
*/
private handleExitVR(): void {
debugLog('Exit VR button clicked - refreshing browser');
private async handleExitVR(): Promise<void> {
debugLog('Exit VR button clicked - navigating to home');
try {
// Navigate back to home route
// The PlayLevel component's onDestroy will handle cleanup
const { navigate } = await import('svelte-routing');
navigate('/', { replace: true });
} catch (error) {
console.error('Failed to navigate, falling back to reload:', error);
window.location.reload();
}
}
/**
* Handle resume button click from status screen
@ -454,6 +479,11 @@ export class Ship {
* 3. All asteroids destroyed AND ship inside landing zone (victory)
*/
private checkGameEndConditions(): void {
// Skip if gameplay hasn't started yet (prevents false triggers during initialization)
if (!this._gameplayStarted) {
return;
}
// Skip if already auto-shown or status screen doesn't exist
if (this._statusScreenAutoShown || !this._statusScreen || !this._scoreboard) {
return;
@ -492,7 +522,8 @@ export class Ship {
}
// Check condition 3: Victory (all asteroids destroyed, inside landing zone)
if (asteroidsRemaining <= 0 && this._isInLandingZone) {
// Must have had asteroids to destroy in the first place (prevents false victory on init)
if (asteroidsRemaining <= 0 && this._isInLandingZone && this._scoreboard.hasAsteroidsToDestroy) {
debugLog('Game end condition met: Victory (all asteroids destroyed)');
this._statusScreen.show(true, true); // Game ended, VICTORY!
// InputControlManager will handle disabling controls when status screen shows
@ -679,6 +710,17 @@ export class Ship {
* Dispose of ship resources
*/
public dispose(): void {
// Remove scene observers first to stop update loops
if (this._physicsObserver) {
DefaultScene.MainScene?.onAfterPhysicsObservable.remove(this._physicsObserver);
this._physicsObserver = null;
}
if (this._renderObserver) {
DefaultScene.MainScene?.onAfterRenderObservable.remove(this._renderObserver);
this._renderObserver = null;
}
if (this._sight) {
this._sight.dispose();
}

View File

@ -66,7 +66,7 @@ export class ShipEngine {
//myParticleSystem.minEmitPower = 2;
//myParticleSystem.maxEmitPower = 10;
myParticleSystem.particleTexture = new Texture("./flare.png");
myParticleSystem.particleTexture = new Texture("/flare.png");
myParticleSystem.emitter = mesh;
const coneEmitter = myParticleSystem.createConeEmitter(0.1, Math.PI / 9);
myParticleSystem.addSizeGradient(0, .01);

View File

@ -18,6 +18,7 @@ export type ScoreEvent = {
export class Scoreboard {
private _score: number = 0;
private _remaining: number = 0;
private _initialAsteroidCount: number = 0;
private _startTime: number = Date.now();
private _active = false;
@ -76,6 +77,17 @@ export class Scoreboard {
public setRemainingCount(count: number) {
this._remaining = count;
// Track initial count for victory validation
if (this._initialAsteroidCount === 0 && count > 0) {
this._initialAsteroidCount = count;
}
}
/**
* Check if asteroids were properly initialized (count > 0)
*/
public get hasAsteroidsToDestroy(): boolean {
return this._initialAsteroidCount > 0;
}
/**

View File

@ -7,7 +7,7 @@ export type LoadedAsset = {
meshes: Map<string, AbstractMesh>,
}
export default async function loadAsset(file: string, theme: string = "default"): Promise<LoadedAsset> {
const assetPath = `assets/themes/${theme}/models/${file}`;
const assetPath = `/assets/themes/${theme}/models/${file}`;
debugLog(`[loadAsset] Loading: ${assetPath}`);
try {

View File

@ -39,9 +39,9 @@ export default defineConfig({
},
server: {
port: 3000,
allowedHosts: true
},
// appType: 'spa' is default - Vite automatically serves index.html for SPA routes
preview: {
port: 3000,
},