diff --git a/public/styles.css b/public/styles.css
index 9e9ee85..6824b50 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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;
diff --git a/src/components/game/PlayLevel.svelte b/src/components/game/PlayLevel.svelte
index 6e21bcd..f5cd2c6 100644
--- a/src/components/game/PlayLevel.svelte
+++ b/src/components/game/PlayLevel.svelte
@@ -127,33 +127,28 @@
});
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();
}
-
- // 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';
- }
}
});
diff --git a/src/components/layouts/App.svelte b/src/components/layouts/App.svelte
index 709160b..4ddf9be 100644
--- a/src/components/layouts/App.svelte
+++ b/src/components/layouts/App.svelte
@@ -12,6 +12,7 @@
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 () => {
@@ -45,6 +46,7 @@
+
diff --git a/src/components/layouts/AppHeader.svelte b/src/components/layouts/AppHeader.svelte
index ce452d2..4bd8d74 100644
--- a/src/components/layouts/AppHeader.svelte
+++ b/src/components/layouts/AppHeader.svelte
@@ -16,10 +16,10 @@
Space Combat VR
diff --git a/src/components/leaderboard/Leaderboard.svelte b/src/components/leaderboard/Leaderboard.svelte
new file mode 100644
index 0000000..8d1006a
--- /dev/null
+++ b/src/components/leaderboard/Leaderboard.svelte
@@ -0,0 +1,264 @@
+
+
+
+
← Back to Game
+
+
Leaderboard
+
Top 20 High Scores
+
+
+ {#if $gameResultsStore.length === 0}
+
+
No game results yet!
+
Play a level to see your scores here.
+
+ {:else}
+
+
+
+ | Rank |
+ Player |
+ Level |
+ Score |
+ Stars |
+ Result |
+ Time |
+ Date |
+
+
+
+ {#each $gameResultsStore as result, i}
+
+ |
+
+ {i + 1}
+
+ |
+ {result.playerName} |
+ {result.levelName} |
+
+ {result.finalScore.toLocaleString()}
+ |
+
+ {formatStars(result.starRating)}
+ {result.starRating}/12
+ |
+
+
+ {getEndReasonEmoji(result)} {result.endReason}
+
+ |
+ {formatTime(result.gameTimeSeconds)} |
+ {formatDate(result.timestamp)} |
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/src/levels/level1.ts b/src/levels/level1.ts
index ecdbfb7..671a550 100644
--- a/src/levels/level1.ts
+++ b/src/levels/level1.ts
@@ -390,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
diff --git a/src/main.ts b/src/main.ts
index a663310..7f0ad35 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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');
@@ -564,15 +561,22 @@ export class Main {
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();
+ // 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. Show Discord widget (UI will be shown by Svelte router)
+ // 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');
diff --git a/src/services/gameResultsService.ts b/src/services/gameResultsService.ts
new file mode 100644
index 0000000..a619b13
--- /dev/null
+++ b/src/services/gameResultsService.ts
@@ -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 || '';
+
+ // 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
+ };
+ }
+}
diff --git a/src/ship/ship.ts b/src/ship/ship.ts
index b1d0f7d..f99e8bf 100644
--- a/src/ship/ship.ts
+++ b/src/ship/ship.ts
@@ -91,6 +91,10 @@ export class Ship {
return this._gameStats;
}
+ public get statusScreen(): StatusScreen {
+ return this._statusScreen;
+ }
+
public get keyboardInput(): KeyboardInput {
return this._keyboardInput;
}
@@ -442,6 +446,17 @@ export class Ship {
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');
@@ -506,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;
@@ -515,7 +530,7 @@ 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;
@@ -525,7 +540,7 @@ export class Ship {
// 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;
diff --git a/src/stores/gameResults.ts b/src/stores/gameResults.ts
new file mode 100644
index 0000000..1220f8f
--- /dev/null
+++ b/src/stores/gameResults.ts
@@ -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(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();
diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts
index 2b9f276..0f1c83b 100644
--- a/src/ui/hud/statusScreen.ts
+++ b/src/ui/hud/statusScreen.ts
@@ -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
*/