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} + + + + + + + + + + + + + + + {#each $gameResultsStore as result, i} + + + + + + + + + + + {/each} + +
RankPlayerLevelScoreStarsResultTimeDate
+ + {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)}
+ {/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 */