Add game results leaderboard system
All checks were successful
Build / build (push) Successful in 1m30s
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:
parent
28c1b2b2aa
commit
3f164df9e8
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
<h1 class="app-title">Space Combat VR</h1>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
|
||||
<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>
|
||||
|
||||
264
src/components/leaderboard/Leaderboard.svelte
Normal file
264
src/components/leaderboard/Leaderboard.svelte
Normal 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>
|
||||
@ -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
|
||||
|
||||
28
src/main.ts
28
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');
|
||||
|
||||
156
src/services/gameResultsService.ts
Normal file
156
src/services/gameResultsService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
47
src/stores/gameResults.ts
Normal 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();
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user