Add leaderboard infinite scroll and improve seed script scoring
All checks were successful
Build / build (push) Successful in 1m45s

- Add pagination support to CloudLeaderboardService with offset parameter
- Implement infinite scroll in Leaderboard.svelte using IntersectionObserver
- Update seed script to use actual game scoring formulas (time, accuracy, fuel, hull multipliers)
- Add level-specific asteroid counts and par times to seed data
- Create BUGS.md to track known issues
- Partial work on XR camera orientation (documented in BUGS.md)

🤖 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-26 12:51:43 -06:00
parent e5607a564f
commit a9070a5d8f
7 changed files with 293 additions and 49 deletions

26
BUGS.md Normal file
View File

@ -0,0 +1,26 @@
# Known Bugs
## Low Priority
### Camera briefly faces backwards when entering VR
**Status:** Open
**Priority:** Low
When entering immersive VR mode, the camera briefly shows the wrong direction (facing backwards) before correcting itself. This is due to the ship GLB model being exported with an inverted orientation (facing -Z instead of +Z).
**Root Cause:**
- The ship.glb model's forward direction is inverted
- When the XR camera is parented to the ship with rotation (0,0,0), it inherits this inverted orientation
- There's a timing gap between entering XR and properly configuring the camera
**Potential Solutions:**
1. Re-export the ship.glb model with correct orientation
2. Stop render loop before entering XR, resume after camera is configured (partially implemented)
3. Rotate camera 180° around Y axis to compensate for inverted model
4. Add a fade-to-black transition when entering VR to hide the orientation flash
**Affected Files:**
- `src/main.ts` - XR entry and camera parenting
- `src/levels/level1.ts` - onInitialXRPoseSetObservable camera setup
- `src/ship/ship.ts` - Flat camera setup
- `public/ship.glb` - The inverted model

View File

@ -83,10 +83,22 @@ async function ensureTestDataColumn(): Promise<void> {
console.log(' Column and index created ✓');
}
// Levels from directory.json
// Levels from directory.json with actual config values
const LEVELS = [
{ id: 'rookie-training', name: 'Rookie Training', difficulty: 'recruit' },
{ id: 'asteroid-mania', name: 'Asteroid Mania!!!', difficulty: 'recruit' },
{
id: 'rookie-training',
name: 'Rookie Training',
difficulty: 'recruit',
asteroids: 5,
parTime: 120 // 2 minutes expected
},
{
id: 'asteroid-mania',
name: 'Asteroid Mania!!!',
difficulty: 'pilot',
asteroids: 12,
parTime: 180 // 3 minutes expected (more asteroids, farther away)
},
];
// Pool of realistic player names
@ -136,49 +148,105 @@ function randomDate(daysBack: number): string {
return pastDate.toISOString();
}
/**
* Calculate score using the actual game's scoring formula
* Base: 10,000 × timeMultiplier × accuracyMultiplier × fuelMultiplier × hullMultiplier
*/
function calculateScore(
gameTime: number,
parTime: number,
accuracy: number,
fuelConsumed: number,
hullDamage: number
): number {
const BASE_SCORE = 10000;
// Time multiplier: exponential decay from par time (0.1x to 3.0x)
const timeRatio = gameTime / parTime;
const timeMultiplier = Math.min(3.0, Math.max(0.1, Math.exp(-timeRatio + 1) * 2));
// Accuracy multiplier: 1.0x to 2.0x
const accuracyMultiplier = 1.0 + (accuracy / 100);
// Fuel efficiency multiplier: 0.5x to 2.0x
const fuelMultiplier = Math.max(0.5, 1.0 + ((100 - fuelConsumed) / 100));
// Hull integrity multiplier: 0.5x to 2.0x
const hullMultiplier = Math.max(0.5, 1.0 + ((100 - hullDamage) / 100));
return Math.floor(BASE_SCORE * timeMultiplier * accuracyMultiplier * fuelMultiplier * hullMultiplier);
}
/**
* Calculate star rating using the actual game's star system (0-12 total)
*/
function calculateStars(
gameTime: number,
parTime: number,
accuracy: number,
fuelConsumed: number,
hullDamage: number
): number {
const timeRatio = gameTime / parTime;
// Time stars (3 = ≤50% par, 2 = ≤100%, 1 = ≤150%, 0 = >150%)
const timeStars = timeRatio <= 0.5 ? 3 : timeRatio <= 1.0 ? 2 : timeRatio <= 1.5 ? 1 : 0;
// Accuracy stars (3 = ≥75%, 2 = ≥50%, 1 = ≥25%, 0 = <25%)
const accuracyStars = accuracy >= 75 ? 3 : accuracy >= 50 ? 2 : accuracy >= 25 ? 1 : 0;
// Fuel stars (3 = ≤30%, 2 = ≤60%, 1 = ≤80%, 0 = >80%)
const fuelStars = fuelConsumed <= 30 ? 3 : fuelConsumed <= 60 ? 2 : fuelConsumed <= 80 ? 1 : 0;
// Hull stars (3 = ≤10%, 2 = ≤30%, 1 = ≤60%, 0 = >60%)
const hullStars = hullDamage <= 10 ? 3 : hullDamage <= 30 ? 2 : hullDamage <= 60 ? 1 : 0;
return timeStars + accuracyStars + fuelStars + hullStars;
}
function generateFakeEntry() {
const level = LEVELS[randomInt(0, LEVELS.length - 1)];
const endReasonObj = weightedRandom(END_REASONS);
const completed = endReasonObj.reason === 'victory';
// Harder levels tend to have lower scores
const difficultyMultiplier = {
'recruit': 1.0,
'pilot': 0.9,
'captain': 0.8,
'commander': 0.7,
}[level.difficulty] || 0.8;
// Generate stats
const totalAsteroids = randomInt(5, 50);
// Use level-specific asteroid count
const totalAsteroids = level.asteroids;
const asteroidsDestroyed = completed
? totalAsteroids
: randomInt(Math.floor(totalAsteroids * 0.2), totalAsteroids - 1);
: randomInt(Math.floor(totalAsteroids * 0.3), totalAsteroids - 1);
const accuracy = completed
? randomFloat(50, 95)
: randomFloat(30, 70);
// Generate realistic stats based on 2-5 minute gameplay
let gameTimeSeconds: number;
let accuracy: number;
let hullDamageTaken: number;
let fuelConsumed: number;
const gameTimeSeconds = randomInt(60, 300);
const hullDamageTaken = completed
? randomFloat(0, 60)
: randomFloat(40, 100);
if (completed) {
// Victory: 2-5 minutes, decent stats
gameTimeSeconds = randomInt(level.parTime * 0.8, level.parTime * 2.5); // 80% to 250% of par
accuracy = randomFloat(45, 85); // Most players hit 45-85%
hullDamageTaken = randomFloat(5, 55); // Some damage but survived
fuelConsumed = randomFloat(25, 70); // Used fuel but made it back
} else if (endReasonObj.reason === 'death') {
// Death: Usually faster (died before completing), worse stats
gameTimeSeconds = randomInt(level.parTime * 0.5, level.parTime * 1.5);
accuracy = randomFloat(25, 60); // Struggled with aim
hullDamageTaken = randomFloat(80, 100); // Took fatal damage
fuelConsumed = randomFloat(30, 80); // Died before fuel was an issue
} else {
// Stranded: Ran out of fuel far from base
gameTimeSeconds = randomInt(level.parTime * 1.5, level.parTime * 3);
accuracy = randomFloat(35, 70); // Okay aim
hullDamageTaken = randomFloat(20, 60); // Some damage
fuelConsumed = randomFloat(95, 100); // Ran out of fuel!
}
const fuelConsumed = completed
? randomFloat(20, 80)
: randomFloat(50, 100);
// Calculate score and stars using actual game formulas
const finalScore = completed
? calculateScore(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken)
: Math.floor(calculateScore(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken) * 0.3); // 30% penalty for not completing
// Calculate score (simplified version)
const baseScore = asteroidsDestroyed * 1000;
const accuracyBonus = Math.floor(accuracy * 10);
const timeBonus = Math.max(0, 300 - gameTimeSeconds);
const survivalBonus = completed ? 500 : 0;
const finalScore = Math.floor((baseScore + accuracyBonus + timeBonus + survivalBonus) * difficultyMultiplier);
// Star rating based on performance (0-12)
const starRating = completed
? randomInt(4, 12)
: randomInt(0, 4);
const starRating = calculateStars(gameTimeSeconds, level.parTime, accuracy, fuelConsumed, hullDamageTaken);
return {
user_id: `test-data|fake-${randomInt(1000, 9999)}`,

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { Link } from 'svelte-routing';
import { gameResultsStore } from '../../stores/gameResults';
import type { GameResult } from '../../services/gameResultsService';
@ -10,23 +10,52 @@
let activeView: 'local' | 'cloud' = 'cloud';
let cloudResults: CloudLeaderboardEntry[] = [];
let cloudLoading = false;
let cloudLoadingMore = false;
let cloudError = '';
let hasMore = true;
const PAGE_SIZE = 20;
// Reference to the scroll container for infinite scroll
let scrollContainer: HTMLElement;
let sentinel: HTMLElement;
let observer: IntersectionObserver;
// Check if cloud is available
const cloudService = CloudLeaderboardService.getInstance();
const cloudAvailable = cloudService.isAvailable();
// Load cloud leaderboard
async function loadCloudLeaderboard() {
cloudLoading = true;
// Load cloud leaderboard (initial or more)
async function loadCloudLeaderboard(loadMore = false) {
if (loadMore) {
if (cloudLoadingMore || !hasMore) return;
cloudLoadingMore = true;
} else {
cloudLoading = true;
cloudResults = [];
hasMore = true;
}
cloudError = '';
try {
cloudResults = await cloudService.getGlobalLeaderboard(20);
const offset = loadMore ? cloudResults.length : 0;
const newResults = await cloudService.getGlobalLeaderboard(PAGE_SIZE, offset);
if (loadMore) {
cloudResults = [...cloudResults, ...newResults];
} else {
cloudResults = newResults;
}
// If we got fewer results than requested, there are no more
if (newResults.length < PAGE_SIZE) {
hasMore = false;
}
} catch (error) {
cloudError = 'Failed to load cloud leaderboard';
console.error('[Leaderboard] Cloud load error:', error);
} finally {
cloudLoading = false;
cloudLoadingMore = false;
}
}
@ -38,6 +67,32 @@
}
}
// Setup intersection observer for infinite scroll
function setupInfiniteScroll() {
// Disconnect previous observer if exists
if (observer) {
observer.disconnect();
}
if (!sentinel) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && activeView === 'cloud' && hasMore && !cloudLoadingMore) {
loadCloudLeaderboard(true);
}
},
{ rootMargin: '200px' } // Trigger earlier for smoother experience
);
observer.observe(sentinel);
}
// Reactively setup observer when sentinel element is bound
$: if (sentinel && cloudResults.length > 0) {
setupInfiniteScroll();
}
// Refresh data on mount
onMount(() => {
gameResultsStore.refresh();
@ -46,6 +101,12 @@
}
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
// Format time as MM:SS
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
@ -101,7 +162,7 @@
<Link to="/" class="back-link">← Back to Game</Link>
<h1>Leaderboard</h1>
<p class="subtitle">Top 20 High Scores</p>
<p class="subtitle">Global High Scores</p>
<!-- View Toggle -->
{#if cloudAvailable}
@ -180,15 +241,31 @@
{/each}
</tbody>
</table>
<!-- Infinite scroll sentinel and loading indicator -->
{#if activeView === 'cloud'}
<div bind:this={sentinel} class="scroll-sentinel">
{#if cloudLoadingMore}
<div class="loading-more">
<span class="spinner"></span>
Loading more...
</div>
{:else if !hasMore && cloudResults.length > 0}
<div class="end-of-list">
You've reached the end!
</div>
{/if}
</div>
{/if}
{/if}
</div>
<div class="leaderboard-footer">
<p class="muted">
{#if activeView === 'cloud'}
Showing top 20 global scores
Showing {displayResults.length} global scores
{:else}
Showing top 20 local scores (this device only)
Showing {displayResults.length} local scores (this device only)
{/if}
</p>
</div>
@ -242,7 +319,7 @@
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;
overflow-x: auto; /* Allow horizontal scroll on mobile, but not hidden */
margin-top: var(--space-xl, 32px);
}
@ -378,6 +455,39 @@
font-size: var(--font-size-sm, 0.9rem);
}
/* Infinite scroll */
.scroll-sentinel {
padding: var(--space-lg, 24px);
text-align: center;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
color: var(--color-text-secondary, #e8e8e8);
font-size: var(--font-size-sm, 0.9rem);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--color-primary, #4f46e5);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.end-of-list {
color: var(--color-text-muted, #aaaaaa);
font-size: var(--font-size-sm, 0.9rem);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.leaderboard-table {

View File

@ -58,8 +58,17 @@ export class Level1 implements Level {
xr.baseExperience.onInitialXRPoseSetObservable.add(() => {
xr.baseExperience.camera.parent = this._ship.transformNode;
const currPose = xr.baseExperience.camera.globalPosition.y;
xr.baseExperience.camera.position = new Vector3(0, 1.5, 0);
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
xr.baseExperience.camera.rotationQuaternion = null;
xr.baseExperience.camera.rotation = new Vector3(0, 0, 0);
// Resume render loop if it was stopped (ensures camera is properly set before first visible frame)
const engine = DefaultScene.MainScene.getEngine();
engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
debugLog('[Level1] Render loop resumed after XR camera setup');
// Disable keyboard input in VR mode to prevent interference
if (this._ship.keyboardInput) {

View File

@ -161,11 +161,22 @@ export class Main {
if (DefaultScene.XR) {
try {
preloader.updateProgress(75, 'Entering VR...');
// Stop render loop BEFORE entering XR to prevent showing wrong camera orientation
// The ship model is rotated 180 degrees, so the XR camera would briefly face backwards
// We'll resume rendering after the camera is properly parented to the ship
this._engine.stopRenderLoop();
debugLog('Render loop stopped before entering XR');
xrSession = await DefaultScene.XR.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
debugLog('XR session started successfully');
debugLog('XR session started successfully (render loop paused until camera is ready)');
} catch (error) {
debugLog('Failed to enter XR, will fall back to flat mode:', error);
DefaultScene.XR = null; // Disable XR for this session
// Resume render loop for flat mode
this._engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
}
}
@ -227,7 +238,16 @@ export class Main {
debugLog('Manually parenting XR camera to ship transformNode');
DefaultScene.XR.baseExperience.camera.parent = ship.transformNode;
DefaultScene.XR.baseExperience.camera.position = new Vector3(0, 1.5, 0);
console.log('[Main] Camera parented successfully');
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
DefaultScene.XR.baseExperience.camera.rotationQuaternion = null;
DefaultScene.XR.baseExperience.camera.rotation = new Vector3(0, Math.PI, 0);
console.log('[Main] Camera parented and rotated 180° to face forward');
// NOW resume the render loop - camera is properly positioned
this._engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
debugLog('Render loop resumed after camera setup');
console.log('[Main] ========== ABOUT TO SHOW MISSION BRIEF ==========');
console.log('[Main] level1 object:', level1);
@ -246,9 +266,17 @@ export class Main {
console.log('[Main] ship exists:', !!ship);
console.log('[Main] ship.transformNode exists:', ship ? !!ship.transformNode : 'N/A');
debugLog('WARNING: Could not parent XR camera - ship or transformNode not found');
// Resume render loop anyway to avoid black screen
this._engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
}
} else {
console.log('[Main] XR not active yet - will use onInitialXRPoseSetObservable instead');
// Resume render loop for non-XR path (flat mode or XR entry via observable)
this._engine.runRenderLoop(() => {
DefaultScene.MainScene.render();
});
}
// Hide preloader

View File

@ -116,8 +116,9 @@ export class CloudLeaderboardService {
/**
* Fetch the global leaderboard (top scores across all players)
* Supports pagination with offset for infinite scroll
*/
public async getGlobalLeaderboard(limit: number = 20): Promise<CloudLeaderboardEntry[]> {
public async getGlobalLeaderboard(limit: number = 20, offset: number = 0): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const client = supabase.getClient();
@ -130,7 +131,7 @@ export class CloudLeaderboardService {
.from('leaderboard')
.select('*')
.order('final_score', { ascending: false })
.limit(limit);
.range(offset, offset + limit - 1);
if (error) {
console.error('[CloudLeaderboardService] Failed to fetch leaderboard:', error);

View File

@ -355,6 +355,8 @@ export class Ship {
DefaultScene.MainScene
);
this._camera.parent = this._ship;
// Rotate camera 180 degrees around Y to compensate for inverted ship GLB model
this._camera.rotation = new Vector3(0, Math.PI, 0);
// Set as active camera if XR is not available
if (!DefaultScene.XR && !this._isReplayMode) {