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>
This commit is contained in:
Michael Mainguy 2025-11-25 12:39:23 -06:00
parent 28c1b2b2aa
commit 3f164df9e8
11 changed files with 601 additions and 38 deletions

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;

View File

@ -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';
}
}
});
</script>

View File

@ -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 @@
<Route path="/editor"><LevelEditor /></Route>
<Route path="/settings"><SettingsScreen /></Route>
<Route path="/controls"><ControlsScreen /></Route>
<Route path="/leaderboard"><Leaderboard /></Route>
</div>
</div>
</Router>

View File

@ -17,9 +17,9 @@
</div>
<nav class="header-nav">
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
<UserProfile />
<Link to="/editor" class="nav-link editor-link">📝 Level Editor</Link>
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
</nav>
</div>

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

@ -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

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');
@ -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');

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

@ -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;

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

@ -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
*/