Compare commits

..

2 Commits

Author SHA1 Message Date
3f164df9e8 Add game results leaderboard system
All checks were successful
Build / build (push) Successful in 1m30s
- Create GameResultsService for storing game results in localStorage
- Create gameResultsStore Svelte store for reactive data access
- Add Leaderboard component showing top 20 scores
- Add leaderboard route and navigation link
- Record game results on victory/death/stranded (not manual exits)
- Fix header visibility when exiting game
- Fix camera error by stopping render loop after cleanup
- Clear canvas after cleanup to prevent last frame showing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 12:39:23 -06:00
28c1b2b2aa 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>
2025-11-25 11:21:05 -06:00
37 changed files with 1049 additions and 125 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

View File

@ -179,6 +179,16 @@ body {
transform: scale(1.05);
}
.leaderboard-link {
background: rgba(255, 215, 0, 0.8);
color: #000 !important;
}
.leaderboard-link:hover {
background: rgba(255, 215, 0, 1);
transform: scale(1.05);
}
/* User Profile in Header */
.user-profile {
display: flex;

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,200 @@
<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 () => {
console.log('[PlayLevel] Component unmounting - cleaning up');
debugLog('[PlayLevel] Component unmounting - cleaning up');
// Remove event listeners
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('popstate', handlePopState);
// Ensure UI is visible again FIRST (before any async operations)
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
console.log('[PlayLevel] App UI restored');
debugLog('[PlayLevel] App UI restored');
}
try {
// Call the cleanup method on Main instance
if (mainInstance && typeof mainInstance.cleanupAndExit === 'function') {
await mainInstance.cleanupAndExit();
}
} catch (err) {
console.error('[PlayLevel] Error during cleanup:', err);
}
});
</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,18 @@
<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';
import Leaderboard from '../leaderboard/Leaderboard.svelte';
// Initialize Auth0 when component mounts
onMount(async () => {
@ -50,13 +34,22 @@
});
</script>
<div class="app">
<AppHeader />
<Router>
<div class="app">
<AppHeader />
<div class="app-content">
<Router {routes} on:routeLoaded={routeLoaded} />
<div class="app-content">
<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>
<Route path="/leaderboard"><Leaderboard /></Route>
</div>
</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>
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
<UserProfile />
<a href="/editor" use:link class="nav-link editor-link">📝 Level Editor</a>
<a href="/settings" use:link class="nav-link settings-link">⚙️ Settings</a>
<Link to="/editor" class="nav-link editor-link">📝 Level Editor</Link>
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
</nav>
</div>
</header>

View File

@ -0,0 +1,264 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Link } from 'svelte-routing';
import { gameResultsStore } from '../../stores/gameResults';
import type { GameResult } from '../../services/gameResultsService';
import { formatStars } from '../../game/scoreCalculator';
// Refresh data on mount
onMount(() => {
gameResultsStore.refresh();
});
// Format time as MM:SS
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format date as readable string
function formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Get color for end reason badge
function getEndReasonColor(result: GameResult): string {
if (result.endReason === 'victory') return '#4ade80';
if (result.endReason === 'death') return '#ef4444';
return '#f59e0b'; // stranded
}
// Get emoji for end reason
function getEndReasonEmoji(result: GameResult): string {
if (result.endReason === 'victory') return '';
if (result.endReason === 'death') return '';
return '';
}
</script>
<div class="editor-container">
<Link to="/" class="back-link">← Back to Game</Link>
<h1>Leaderboard</h1>
<p class="subtitle">Top 20 High Scores</p>
<div class="leaderboard-wrapper">
{#if $gameResultsStore.length === 0}
<div class="no-results">
<p>No game results yet!</p>
<p class="muted">Play a level to see your scores here.</p>
</div>
{:else}
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank-col">Rank</th>
<th class="player-col">Player</th>
<th class="level-col">Level</th>
<th class="score-col">Score</th>
<th class="stars-col">Stars</th>
<th class="result-col">Result</th>
<th class="time-col">Time</th>
<th class="date-col">Date</th>
</tr>
</thead>
<tbody>
{#each $gameResultsStore as result, i}
<tr class:victory={result.completed}>
<td class="rank-col">
<span class="rank-badge" class:gold={i === 0} class:silver={i === 1} class:bronze={i === 2}>
{i + 1}
</span>
</td>
<td class="player-col">{result.playerName}</td>
<td class="level-col">{result.levelName}</td>
<td class="score-col">
<span class="score-value">{result.finalScore.toLocaleString()}</span>
</td>
<td class="stars-col">
<span class="star-display">{formatStars(result.starRating)}</span>
<span class="star-count">{result.starRating}/12</span>
</td>
<td class="result-col">
<span class="result-badge" style="background-color: {getEndReasonColor(result)}">
{getEndReasonEmoji(result)} {result.endReason}
</span>
</td>
<td class="time-col">{formatTime(result.gameTimeSeconds)}</td>
<td class="date-col">{formatDate(result.timestamp)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="leaderboard-footer">
<p class="muted">Showing top 20 scores sorted by highest score</p>
</div>
</div>
<style>
.leaderboard-wrapper {
background: var(--color-bg-card, rgba(20, 20, 40, 0.9));
border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2));
border-radius: var(--radius-lg, 10px);
overflow: hidden;
margin-top: var(--space-xl, 32px);
}
.no-results {
text-align: center;
padding: var(--space-3xl, 64px) var(--space-xl, 32px);
color: var(--color-text-secondary, #e8e8e8);
}
.no-results .muted {
color: var(--color-text-muted, #aaaaaa);
margin-top: var(--space-md, 16px);
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm, 0.9rem);
}
.leaderboard-table thead {
background: rgba(0, 0, 0, 0.4);
}
.leaderboard-table th {
padding: var(--space-md, 16px) var(--space-sm, 8px);
text-align: left;
font-weight: bold;
color: var(--color-text-secondary, #e8e8e8);
text-transform: uppercase;
font-size: var(--font-size-xs, 0.8rem);
letter-spacing: 0.5px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.leaderboard-table td {
padding: var(--space-md, 16px) var(--space-sm, 8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
vertical-align: middle;
}
.leaderboard-table tbody tr {
transition: background var(--transition-fast, 0.2s ease);
}
.leaderboard-table tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.leaderboard-table tbody tr.victory {
background: rgba(74, 222, 128, 0.05);
}
.leaderboard-table tbody tr.victory:hover {
background: rgba(74, 222, 128, 0.1);
}
/* Column widths */
.rank-col { width: 60px; text-align: center; }
.player-col { min-width: 120px; }
.level-col { min-width: 140px; }
.score-col { width: 100px; text-align: right; }
.stars-col { width: 100px; text-align: center; }
.result-col { width: 100px; text-align: center; }
.time-col { width: 70px; text-align: center; }
.date-col { width: 100px; text-align: right; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
font-weight: bold;
font-size: var(--font-size-sm, 0.9rem);
}
.rank-badge.gold {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #000;
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.5);
}
.rank-badge.silver {
background: linear-gradient(135deg, #C0C0C0, #A0A0A0);
color: #000;
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.5);
}
.rank-badge.bronze {
background: linear-gradient(135deg, #CD7F32, #B87333);
color: #000;
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.5);
}
.score-value {
color: #FFD700;
font-weight: bold;
font-size: var(--font-size-base, 1rem);
}
.star-display {
display: block;
font-size: var(--font-size-sm, 0.9rem);
color: #FFD700;
}
.star-count {
display: block;
font-size: var(--font-size-xs, 0.8rem);
color: var(--color-text-muted, #aaaaaa);
}
.result-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: var(--font-size-xs, 0.8rem);
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.leaderboard-footer {
text-align: center;
padding: var(--space-lg, 24px);
}
.leaderboard-footer .muted {
color: var(--color-text-muted, #aaaaaa);
font-size: var(--font-size-sm, 0.9rem);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.leaderboard-table {
display: block;
overflow-x: auto;
}
.leaderboard-table th,
.leaderboard-table td {
padding: var(--space-sm, 8px) var(--space-xs, 4px);
}
.level-col, .date-col {
display: none;
}
}
</style>

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');
await this._explosionManager.initAudio(audioEngine);
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);
this._asteroidMesh.setEnabled(false);
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
RockFactory._explosionManager.playExplosion(asteroidMesh);
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();
}
this._endBase.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() {
@ -379,12 +390,24 @@ export class Level1 implements Level {
this._initialized = true;
// Set par time for score calculation based on difficulty
// Set par time and level info for score calculation and results recording
const parTime = this.getParTimeForDifficulty(this._levelConfig.difficulty);
const statusScreen = (this._ship as any)._statusScreen; // Access private status screen
const statusScreen = this._ship.statusScreen;
console.log('[Level1] StatusScreen reference:', statusScreen);
console.log('[Level1] Level config metadata:', this._levelConfig.metadata);
console.log('[Level1] Asteroids count:', entities.asteroids.length);
if (statusScreen) {
statusScreen.setParTime(parTime);
debugLog(`Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
console.log(`[Level1] Set par time to ${parTime}s for difficulty: ${this._levelConfig.difficulty}`);
// Set level info for game results recording
const levelId = this._levelId || 'unknown';
const levelName = this._levelConfig.metadata?.description || 'Unknown Level';
console.log('[Level1] About to call setCurrentLevel with:', { levelId, levelName, asteroidCount: entities.asteroids.length });
statusScreen.setCurrentLevel(levelId, levelName, entities.asteroids.length);
console.log('[Level1] setCurrentLevel called successfully');
} else {
console.error('[Level1] StatusScreen is null/undefined!');
}
// Notify that initialization is complete

View File

@ -3,6 +3,7 @@ import {
Color3,
CreateAudioEngineAsync,
Engine,
FreeCamera,
HavokPlugin,
ParticleHelper,
Scene,
@ -203,11 +204,7 @@ export class Main {
// Listen for replay requests from the ship
if (ship) {
// Set current level name for progression tracking
if (ship._statusScreen) {
ship._statusScreen.setCurrentLevel(levelName);
debugLog(`Set current level for progression: ${levelName}`);
}
// Note: Level info for progression/results is now set in Level1.initialize()
ship.onReplayRequestObservable.add(() => {
debugLog('Replay requested - reloading page');
@ -262,14 +259,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 +341,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 +490,109 @@ 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. Clear the canvas so it doesn't show the last frame
debugLog('[Main] Clearing canvas...');
const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
if (canvas) {
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (gl) {
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
}
// 9. Keep render loop stopped until next game starts
// No need to render an empty scene - saves resources
debugLog('[Main] Render loop stopped - will restart when game starts');
// 10. 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 +889,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 +917,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 +944,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 +952,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

@ -0,0 +1,156 @@
import { AuthService } from './authService';
import { GameStats } from '../game/gameStats';
import { Scoreboard } from '../ui/hud/scoreboard';
import debugLog from '../core/debug';
/**
* Represents a completed game session result
*/
export interface GameResult {
id: string;
timestamp: number;
playerName: string;
levelId: string;
levelName: string;
completed: boolean;
endReason: 'victory' | 'death' | 'stranded';
// Game statistics
gameTimeSeconds: number;
asteroidsDestroyed: number;
totalAsteroids: number;
accuracy: number;
hullDamageTaken: number;
fuelConsumed: number;
// Scoring
finalScore: number;
starRating: number;
}
const STORAGE_KEY = 'space-game-results';
/**
* Service for storing and retrieving game results
* Uses localStorage for persistence, designed for future cloud storage expansion
*/
export class GameResultsService {
private static _instance: GameResultsService;
private constructor() {}
/**
* Get the singleton instance
*/
public static getInstance(): GameResultsService {
if (!GameResultsService._instance) {
GameResultsService._instance = new GameResultsService();
}
return GameResultsService._instance;
}
/**
* Save a game result to storage
*/
public saveResult(result: GameResult): void {
console.log('[GameResultsService] saveResult called with:', result);
const results = this.getAllResults();
console.log('[GameResultsService] Existing results count:', results.length);
results.push(result);
this.saveToStorage(results);
console.log('[GameResultsService] Saved result:', result.id, result.finalScore);
debugLog('[GameResultsService] Saved result:', result.id, result.finalScore);
}
/**
* Get all stored results
*/
public getAllResults(): GameResult[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) {
return [];
}
return JSON.parse(data) as GameResult[];
} catch (error) {
debugLog('[GameResultsService] Error loading results:', error);
return [];
}
}
/**
* Get top results sorted by highest score
*/
public getTopResults(limit: number = 20): GameResult[] {
const results = this.getAllResults();
return results
.sort((a, b) => b.finalScore - a.finalScore)
.slice(0, limit);
}
/**
* Clear all stored results (for testing/reset)
*/
public clearAll(): void {
localStorage.removeItem(STORAGE_KEY);
debugLog('[GameResultsService] Cleared all results');
}
/**
* Save results array to localStorage
*/
private saveToStorage(results: GameResult[]): void {
try {
const json = JSON.stringify(results);
console.log('[GameResultsService] Saving to localStorage, key:', STORAGE_KEY, 'size:', json.length);
localStorage.setItem(STORAGE_KEY, json);
console.log('[GameResultsService] Successfully saved to localStorage');
// Verify it was saved
const verify = localStorage.getItem(STORAGE_KEY);
console.log('[GameResultsService] Verification - stored data exists:', !!verify);
} catch (error) {
console.error('[GameResultsService] Error saving results:', error);
debugLog('[GameResultsService] Error saving results:', error);
}
}
/**
* Build a GameResult from current game state
* Call this when the game ends (victory, death, or stranded)
*/
public static buildResult(
levelId: string,
levelName: string,
gameStats: GameStats,
totalAsteroids: number,
endReason: 'victory' | 'death' | 'stranded',
parTime: number
): GameResult {
// Get player name from auth service
const authService = AuthService.getInstance();
const user = authService.getUser();
const playerName = user?.name || user?.email || '<Anonymous>';
// Get stats
const stats = gameStats.getStats();
const scoreCalc = gameStats.calculateFinalScore(parTime);
return {
id: crypto.randomUUID(),
timestamp: Date.now(),
playerName,
levelId,
levelName,
completed: endReason === 'victory',
endReason,
gameTimeSeconds: gameStats.getGameTime(),
asteroidsDestroyed: stats.asteroidsDestroyed,
totalAsteroids,
accuracy: stats.accuracy,
hullDamageTaken: stats.hullDamageTaken,
fuelConsumed: stats.fuelConsumed,
finalScore: scoreCalc.finalScore,
starRating: scoreCalc.stars.total
};
}
}

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;
@ -84,6 +91,10 @@ export class Ship {
return this._gameStats;
}
public get statusScreen(): StatusScreen {
return this._statusScreen;
}
public get keyboardInput(): KeyboardInput {
return this._keyboardInput;
}
@ -92,6 +103,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 +336,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,9 +442,29 @@ export class Ship {
/**
* Handle exit VR button click from status screen
*/
private handleExitVR(): void {
debugLog('Exit VR button clicked - refreshing browser');
window.location.reload();
private async handleExitVR(): Promise<void> {
debugLog('Exit VR button clicked - navigating to home');
try {
// Ensure the app UI is visible before navigating (safety net)
const appElement = document.getElementById('app');
if (appElement) {
appElement.style.display = 'block';
}
const headerElement = document.getElementById('appHeader');
if (headerElement) {
headerElement.style.display = 'block';
}
// 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();
}
}
/**
@ -454,6 +494,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;
@ -476,7 +521,7 @@ export class Ship {
// Check condition 1: Death by hull damage (outside landing zone)
if (!this._isInLandingZone && hull < 0.01) {
debugLog('Game end condition met: Hull critical outside landing zone');
this._statusScreen.show(true, false); // Game ended, not victory
this._statusScreen.show(true, false, 'death'); // Game ended, not victory, death reason
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
@ -485,16 +530,17 @@ export class Ship {
// Check condition 2: Stranded (outside landing zone, no fuel, low velocity)
if (!this._isInLandingZone && fuel < 0.01 && totalVelocity < 5) {
debugLog('Game end condition met: Stranded (no fuel, low velocity)');
this._statusScreen.show(true, false); // Game ended, not victory
this._statusScreen.show(true, false, 'stranded'); // Game ended, not victory, stranded reason
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
}
// 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!
this._statusScreen.show(true, true, 'victory'); // Game ended, VICTORY!
// InputControlManager will handle disabling controls when status screen shows
this._statusScreenAutoShown = true;
return;
@ -679,6 +725,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);

47
src/stores/gameResults.ts Normal file
View File

@ -0,0 +1,47 @@
import { writable } from 'svelte/store';
import { GameResultsService, GameResult } from '../services/gameResultsService';
/**
* Svelte store for game results
* Provides reactive access to leaderboard data
*/
function createGameResultsStore() {
const service = GameResultsService.getInstance();
const { subscribe, set } = writable<GameResult[]>(service.getTopResults(20));
return {
subscribe,
/**
* Refresh the store with latest top results
*/
refresh: () => {
set(service.getTopResults(20));
},
/**
* Add a new result and refresh the store
*/
addResult: (result: GameResult) => {
service.saveResult(result);
set(service.getTopResults(20));
},
/**
* Get all results (not just top 20)
*/
getAll: () => {
return service.getAllResults();
},
/**
* Clear all results (for testing/reset)
*/
clear: () => {
service.clearAll();
set([]);
}
};
}
export const gameResultsStore = createGameResultsStore();

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

@ -21,6 +21,8 @@ import { AuthService } from "../../services/authService";
import { FacebookShare, ShareData } from "../../services/facebookShare";
import { InputControlManager } from "../../ship/input/inputControlManager";
import { formatStars, getStarColor } from "../../game/scoreCalculator";
import { GameResultsService } from "../../services/gameResultsService";
import debugLog from "../../core/debug";
/**
* Status screen that displays game statistics
@ -64,8 +66,13 @@ export class StatusScreen {
// Track whether game has ended
private _isGameEnded: boolean = false;
// Track current level name for progression
// Track current level info for progression and results
private _currentLevelName: string | null = null;
private _currentLevelId: string | null = null;
private _totalAsteroids: number = 0;
// Track if result has been recorded (prevent duplicates)
private _resultRecorded: boolean = false;
constructor(scene: Scene, gameStats: GameStats, onReplay?: () => void, onExit?: () => void, onResume?: () => void, onNextLevel?: () => void) {
this._scene = scene;
@ -364,10 +371,13 @@ export class StatusScreen {
/**
* Set the current level name for progression tracking
* Set the current level info for progression tracking and results
*/
public setCurrentLevel(levelName: string): void {
public setCurrentLevel(levelId: string, levelName: string, totalAsteroids: number): void {
console.log('[StatusScreen] setCurrentLevel called:', { levelId, levelName, totalAsteroids });
this._currentLevelId = levelId;
this._currentLevelName = levelName;
this._totalAsteroids = totalAsteroids;
}
/**
@ -382,8 +392,9 @@ export class StatusScreen {
* Show the status screen
* @param isGameEnded - true if game has ended (death/stranded/victory), false if manually paused
* @param victory - true if the level was completed successfully
* @param endReason - specific reason for game end ('victory' | 'death' | 'stranded')
*/
public show(isGameEnded: boolean = false, victory: boolean = false): void {
public show(isGameEnded: boolean = false, victory: boolean = false, endReason?: 'victory' | 'death' | 'stranded'): void {
if (!this._screenMesh) {
return;
}
@ -402,6 +413,12 @@ export class StatusScreen {
});
}
// Record game result when game ends (not on manual pause)
if (isGameEnded && endReason && !this._resultRecorded) {
this.recordGameResult(endReason);
this._resultRecorded = true;
}
// Determine if there's a next level
const nextLevel = progression.getNextLevel();
const hasNextLevel = nextLevel !== null;
@ -584,6 +601,47 @@ export class StatusScreen {
}
}
/**
* Record game result to the results service
*/
private recordGameResult(endReason: 'victory' | 'death' | 'stranded'): void {
console.log('[StatusScreen] recordGameResult called with endReason:', endReason);
console.log('[StatusScreen] Level info:', {
levelId: this._currentLevelId,
levelName: this._currentLevelName,
totalAsteroids: this._totalAsteroids,
parTime: this._parTime
});
// Only record if we have level info
if (!this._currentLevelId || !this._currentLevelName) {
console.warn('[StatusScreen] Cannot record result - missing level info');
debugLog('[StatusScreen] Cannot record result - missing level info');
return;
}
try {
const result = GameResultsService.buildResult(
this._currentLevelId,
this._currentLevelName,
this._gameStats,
this._totalAsteroids,
endReason,
this._parTime
);
console.log('[StatusScreen] Built result:', result);
const service = GameResultsService.getInstance();
service.saveResult(result);
console.log('[StatusScreen] Game result saved successfully');
debugLog('[StatusScreen] Game result recorded:', result.id, result.finalScore, result.endReason);
} catch (error) {
console.error('[StatusScreen] Failed to record game result:', error);
debugLog('[StatusScreen] Failed to record game result:', error);
}
}
/**
* Dispose of status screen resources
*/

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,
},