Simplify level system and add asteroid-mania level
All checks were successful
Build / build (push) Successful in 1m29s

- Add new asteroid-mania level to directory and DEFAULT_LEVEL_ORDER
- Remove level caching entirely (always fetch fresh from network)
- Delete legacy router.ts, levelSelector.ts, and levelVersionManager.ts
- Remove unused router handlers from main.ts (~120 lines)
- Fix projectile curving by cloning velocity vector in weaponSystem.ts
- Update LevelSelect.svelte to include asteroid-mania

🤖 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 15:34:23 -06:00
parent 3a5cf3e074
commit 3ff1ffeb45
12 changed files with 701 additions and 1194 deletions

357
CLOUD_STORAGE_PLAN.md Normal file
View File

@ -0,0 +1,357 @@
# Cloud Leaderboard Implementation Plan
## Overview
Implement a global cloud-based leaderboard using **Supabase** with existing **Auth0** authentication. No backend server required - uses direct client-to-database communication with Row Level Security.
## Architecture
```
┌─────────────┐ ┌─────────┐ ┌───────────┐
│ Browser │────▶│ Auth0 │────▶│ JWT Token │
│ (Game) │ └─────────┘ └─────┬─────┘
│ │ │
│ Supabase │◀──────────────────────────┘
│ JS Client │
│ │ ┌───────────────────────────┐
│ │────▶│ Supabase (Postgres + RLS)│
└─────────────┘ │ - leaderboard table │
│ - Row Level Security │
└───────────────────────────┘
```
## Implementation Steps
### Phase 1: Supabase Project Setup (Manual - Dashboard)
1. Create free Supabase project at https://supabase.com
2. Get project credentials:
- `SUPABASE_URL` (e.g., `https://xxx.supabase.co`)
- `SUPABASE_ANON_KEY` (public, safe for client)
3. Configure Third-Party Auth for Auth0:
- Navigate to Authentication → Third-Party Auth
- Add Auth0 integration with your tenant ID
4. Create `leaderboard` table:
```sql
CREATE TABLE leaderboard (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id TEXT NOT NULL, -- Auth0 sub claim
player_name TEXT NOT NULL,
level_id TEXT NOT NULL,
level_name TEXT NOT NULL,
completed BOOLEAN NOT NULL,
end_reason TEXT NOT NULL, -- 'victory' | 'death' | 'stranded'
-- Stats
game_time_seconds NUMERIC NOT NULL,
asteroids_destroyed INTEGER NOT NULL,
total_asteroids INTEGER NOT NULL,
accuracy NUMERIC NOT NULL,
hull_damage_taken NUMERIC NOT NULL,
fuel_consumed NUMERIC NOT NULL,
-- Scoring
final_score INTEGER NOT NULL,
star_rating INTEGER NOT NULL, -- 0-12
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for leaderboard queries
CREATE INDEX idx_leaderboard_score ON leaderboard(final_score DESC);
CREATE INDEX idx_leaderboard_user ON leaderboard(user_id);
```
5. Enable Row Level Security:
```sql
-- Enable RLS
ALTER TABLE leaderboard ENABLE ROW LEVEL SECURITY;
-- Anyone can read leaderboard (global leaderboard)
CREATE POLICY "Anyone can read leaderboard" ON leaderboard
FOR SELECT USING (true);
-- Authenticated users can insert their own scores
CREATE POLICY "Users can insert own scores" ON leaderboard
FOR INSERT WITH CHECK (
auth.jwt() ->> 'sub' = user_id
);
```
### Phase 2: Auth0 Action Configuration (Manual - Auth0 Dashboard)
Create a Post-Login Action to add required claims to JWT:
```javascript
// Auth0 Action: Add Supabase Claims
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://supabase.com/';
// Add the 'authenticated' role claim for Supabase RLS
api.accessToken.setCustomClaim(`${namespace}role`, 'authenticated');
// Supabase expects 'sub' claim which Auth0 already provides
};
```
### Phase 3: Install Supabase Client
```bash
npm install @supabase/supabase-js
```
### Phase 4: Create Supabase Service
**New file: `src/services/supabaseService.ts`**
```typescript
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { AuthService } from './authService';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
export class SupabaseService {
private static instance: SupabaseService;
private client: SupabaseClient;
private constructor() {
this.client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
}
static getInstance(): SupabaseService {
if (!SupabaseService.instance) {
SupabaseService.instance = new SupabaseService();
}
return SupabaseService.instance;
}
// Update client with Auth0 token for authenticated requests
async setAuthToken(): Promise<void> {
const authService = AuthService.getInstance();
const token = await authService.getAccessToken();
if (token) {
this.client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${token}`
}
}
});
}
}
getClient(): SupabaseClient {
return this.client;
}
}
```
### Phase 5: Create Cloud Leaderboard Service
**New file: `src/services/cloudLeaderboardService.ts`**
```typescript
import { SupabaseService } from './supabaseService';
import { AuthService } from './authService';
import type { GameResult } from './gameResultsService';
export interface CloudLeaderboardEntry {
id: string;
user_id: string;
player_name: string;
level_id: string;
level_name: string;
completed: boolean;
end_reason: string;
game_time_seconds: number;
asteroids_destroyed: number;
total_asteroids: number;
accuracy: number;
hull_damage_taken: number;
fuel_consumed: number;
final_score: number;
star_rating: number;
created_at: string;
}
export class CloudLeaderboardService {
private static instance: CloudLeaderboardService;
static getInstance(): CloudLeaderboardService {
if (!CloudLeaderboardService.instance) {
CloudLeaderboardService.instance = new CloudLeaderboardService();
}
return CloudLeaderboardService.instance;
}
// Submit a score to cloud leaderboard
async submitScore(result: GameResult): Promise<boolean> {
const supabase = SupabaseService.getInstance();
await supabase.setAuthToken();
const authService = AuthService.getInstance();
const user = authService.getUser();
if (!user?.sub) {
console.warn('Cannot submit score: user not authenticated');
return false;
}
const entry = {
user_id: user.sub,
player_name: result.playerName,
level_id: result.levelId,
level_name: result.levelName,
completed: result.completed,
end_reason: result.endReason,
game_time_seconds: result.gameTimeSeconds,
asteroids_destroyed: result.asteroidsDestroyed,
total_asteroids: result.totalAsteroids,
accuracy: result.accuracy,
hull_damage_taken: result.hullDamageTaken,
fuel_consumed: result.fuelConsumed,
final_score: result.finalScore,
star_rating: result.starRating
};
const { error } = await supabase.getClient()
.from('leaderboard')
.insert(entry);
if (error) {
console.error('Failed to submit score:', error);
return false;
}
return true;
}
// Fetch global leaderboard (top scores)
async getGlobalLeaderboard(limit = 20): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const { data, error } = await supabase.getClient()
.from('leaderboard')
.select('*')
.order('final_score', { ascending: false })
.limit(limit);
if (error) {
console.error('Failed to fetch leaderboard:', error);
return [];
}
return data || [];
}
// Get user's personal best scores
async getUserScores(userId: string, limit = 10): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const { data, error } = await supabase.getClient()
.from('leaderboard')
.select('*')
.eq('user_id', userId)
.order('final_score', { ascending: false })
.limit(limit);
if (error) {
console.error('Failed to fetch user scores:', error);
return [];
}
return data || [];
}
}
```
### Phase 6: Integrate with Existing Code
**Modify: `src/services/gameResultsService.ts`**
Add cloud submission after local save:
```typescript
import { CloudLeaderboardService } from './cloudLeaderboardService';
// In saveResult() method, after localStorage save:
async saveResult(result: GameResult): Promise<void> {
// Existing localStorage save
const results = this.getAllResults();
results.push(result);
localStorage.setItem(STORAGE_KEY, JSON.stringify(results));
// NEW: Submit to cloud leaderboard
try {
const cloudService = CloudLeaderboardService.getInstance();
await cloudService.submitScore(result);
} catch (error) {
console.warn('Cloud leaderboard submission failed:', error);
// Don't block on cloud failure - local save succeeded
}
}
```
**Modify: `src/components/leaderboard/Leaderboard.svelte`**
Add toggle between local and cloud leaderboard:
```svelte
<script>
import { CloudLeaderboardService } from '../../services/cloudLeaderboardService';
let showCloud = true;
let cloudResults = [];
async function loadCloudLeaderboard() {
const service = CloudLeaderboardService.getInstance();
cloudResults = await service.getGlobalLeaderboard(20);
}
onMount(() => {
if (showCloud) loadCloudLeaderboard();
});
</script>
<!-- Add toggle UI and display cloudResults when showCloud is true -->
```
### Phase 7: Environment Variables
**Create/update: `.env`**
```
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
```
## Files to Modify
| File | Action | Purpose |
|------|--------|---------|
| `src/services/supabaseService.ts` | CREATE | Supabase client singleton |
| `src/services/cloudLeaderboardService.ts` | CREATE | Cloud leaderboard API |
| `src/services/gameResultsService.ts` | MODIFY | Add cloud submission |
| `src/components/leaderboard/Leaderboard.svelte` | MODIFY | Add cloud data source |
| `.env` | CREATE/MODIFY | Add Supabase credentials |
| `package.json` | MODIFY | Add @supabase/supabase-js |
## External Setup Required (Manual)
1. **Supabase Dashboard**: Create project, table, RLS policies, Auth0 integration
2. **Auth0 Dashboard**: Create Post-Login Action for role claim
## Cost Analysis
- **Supabase Free Tier**: 500MB database, 10k MAU, 1GB file storage
- **Your usage** (<100 players): Well within free tier
- **Cost**: $0/month
## Considerations
- **Offline/failure handling**: Falls back to localStorage if cloud fails
- **Inactivity pause**: Free tier projects pause after 7 days inactivity (easy to unpause)
- **No anti-cheat**: Scores submitted directly from client (per your requirements)

View File

@ -0,0 +1,289 @@
{
"version": "1.2",
"difficulty": "rookie",
"timestamp": "2025-11-11T23:44:24.810Z",
"metadata": {
"author": "System",
"description": "Asteroid Mania!",
"estimatedTime": "5-8 minutes",
"type": "default"
},
"ship": {
"position": [
0,
1,
0
],
"rotation": [
0,
0,
0
],
"linearVelocity": [
0,
0,
0
],
"angularVelocity": [
0,
0,
0
]
},
"startBase": {
"position": [
0,
0,
0
],
"baseGlbPath": "base.glb"
},
"sun": {
"position": [
0,
0,
400
],
"diameter": 50,
"intensity": 1000000
},
"planets": [],
"asteroids": [
{
"id": "asteroid-0",
"position": [
242.60734209985543,
-114.56996058926651,
5.575229357062
],
"scale": 2,
"linearVelocity": [
-170.167175139332553,
80.177863609194048,
-0.39450965492725215
],
"angularVelocity": [
-0.834980024785148,
0.9648009938830251,
0.8185653748494373
]
},
{
"id": "asteroid-1",
"position": [
145.90971366777896,
42.273817290099984,
-244.80503221456152
],
"scale": 6,
"linearVelocity": [
-14.737555578618144,
-42.168846343154079,
240.72643991613985
],
"angularVelocity": [
0.575649251710729,
-2.8551046445434349,
-0.9477761112717422
]
},
{
"id": "asteroid-2",
"position": [
195.05992969157123,
-311.0584087077698,
-22.40662780090249
],
"scale": 4,
"linearVelocity": [
-160.81570103491442,
9.660316715266058,
160.9316276535952197
],
"angularVelocity": [
0.8587973467645904,
0.25620436829463733,
-0.7705721105608303
]
},
{
"id": "asteroid-3",
"position": [
-0.9357515100775112,
85.76554222686204,
249.4670613777975
],
"scale": 17.34408913479813,
"linearVelocity": [
0.07109432360434195,
-6.440116659897093,
-18.953420645560346
],
"angularVelocity": [
0.19650221972006143,
0.4226089665809898,
-0.9419176203015098
]
},
{
"id": "asteroid-4",
"position": [
-254.14456477364413,
54.65967750105119,
82.65652287437858
],
"scale": 4,
"linearVelocity": [
22.372081486064396,
-400.723605553550473,
-7.2761676675924445
],
"angularVelocity": [
-0.22039903827783025,
0.03062354927084643,
0.3628209366655213
]
},
{
"id": "asteroid-5",
"position": [
-257.7249224576784,
-112.97325792551102,
-92.25372143357285
],
"scale": 12,
"linearVelocity": [
17.764361846647077,
7.855903788127005,
6.358828139777149
],
"angularVelocity": [
-0.27982741337355455,
0.2465507084870353,
-0.8489416083688623
]
},
{
"id": "asteroid-6",
"position": [
-61.74000302102928,
103.75532261403117,
-224.6843746923246
],
"scale": 14.438006716048399,
"linearVelocity": [
4.573571795825104,
-7.611901885044768,
16.644154013167135
],
"angularVelocity": [
-0.41949593751738457,
-0.5881266007071146,
0.2671577602439994
]
},
{
"id": "asteroid-7",
"position": [
16.846663100767792,
72.36836836065181,
-271.36235273889974
],
"scale": 10,
"linearVelocity": [
220.2776861733199087,
-345.412726361379603,
-20.580688530433683
],
"angularVelocity": [
-0.5793176374486806,
0.8207961833131412,
-0.034658037798875885
]
},
{
"id": "asteroid-8",
"position": [
129.11110725214024,
91.10691458736655,
205.0668479159754
],
"scale": 10,
"linearVelocity": [
-10.330594112594069,
-7.209743461671342,
160.4080567261488
],
"angularVelocity": [
-2.572098306083443,
0.6581860817605101,
-0.7141435682550208
]
},
{
"id": "asteroid-9",
"position": [
-300.953057070289603,
225.21952155696817,
139.05608152400566
],
"scale": 14.151176153817078,
"linearVelocity": [
1.9861965590557589,
-314.387724003424648,
-8.922954201633985
],
"angularVelocity": [
0.7016416714654072,
-4.8069811132136699,
-0.16093262088047533
]
},
{
"id": "asteroid-10",
"position": [
300.953057070289603,
225.21952155696817,
139.05608152400566
],
"scale": 12,
"linearVelocity": [
100.9861965590557589,
-314.387724003424648,
-240.922954201633985
],
"angularVelocity": [
0.7016416714654072,
-4.8069811132136699,
-0.16093262088047533
]
},{
"id": "asteroid-11",
"position": [
300.953057070289603,
-225.21952155696817,
69.05608152400566
],
"scale": 30,
"linearVelocity": [
100.9861965590557589,
-214.387724003424648,
140.922954201633985
],
"angularVelocity": [
0.7016416714654072,
-4.8069811132136699,
-0.16093262088047533
]
}
],
"difficultyConfig": {
"rockCount": 10,
"forceMultiplier": 1,
"rockSizeMin": 8,
"rockSizeMax": 20,
"distanceMin": 225,
"distanceMax": 300
}
}

View File

@ -1,5 +1,5 @@
{ {
"version": "1.0.6", "version": "1.0.11",
"levels": [ "levels": [
{ {
"id": "rookie-training", "id": "rookie-training",
@ -21,21 +21,21 @@
"defaultLocked": false "defaultLocked": false
}, },
{ {
"id": "rescue-mission", "id": "asteroid-mania",
"name": "Rescue Mission", "name": "Asteroid Mania!!!",
"description": "Rescue operation in moderate asteroid field", "description": "Still low stakes, just more asteroids",
"version": "1.0", "version": "1.1",
"levelPath": "rescue-mission.json", "levelPath": "asteroid-mania.json",
"difficulty": "pilot", "difficulty": "pilot",
"estimatedTime": "5-8 minutes", "estimatedTime": "5-8 minutes",
"missionBrief": [ "missionBrief": [
"More asteroids and increased difficulty", "More asteroids and increased difficulty",
"Manage your fuel and ammunition carefully", "Manage your fuel and ammunition carefully",
"Complete the mission and return to base", "Complete the mission and return to base",
"Use your radar to track asteroids", "Watch your hull integrity!",
"Watch your shield strength" "Some of the asteroids are a little more distant"
], ],
"unlockRequirements": ["rookie-training"], "unlockRequirements": [],
"tags": ["medium"], "tags": ["medium"],
"defaultLocked": true "defaultLocked": true
}, },

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0", "version": "1.0",
"difficulty": "pilot", "difficulty": "rookie",
"timestamp": "2025-11-11T23:44:24.810Z", "timestamp": "2025-11-11T23:44:24.810Z",
"metadata": { "metadata": {
"author": "System", "author": "System",

View File

@ -4,10 +4,10 @@
import LevelCard from './LevelCard.svelte'; import LevelCard from './LevelCard.svelte';
import ProgressBar from './ProgressBar.svelte'; import ProgressBar from './ProgressBar.svelte';
// Get default levels in order // Get default levels in order (must match directory.json)
const DEFAULT_LEVEL_ORDER = [ const DEFAULT_LEVEL_ORDER = [
'rookie-training', 'rookie-training',
'rescue-mission', 'asteroid-mania',
'deep-space-patrol', 'deep-space-patrol',
'enemy-territory', 'enemy-territory',
'the-gauntlet', 'the-gauntlet',

View File

@ -1,96 +0,0 @@
/**
* Simple hash-based client-side router
*/
export class Router {
private routes: Map<string, () => void> = new Map();
private currentRoute: string = '';
private started: boolean = false;
constructor() {
// Listen for hash changes
window.addEventListener('hashchange', () => this.handleRoute());
}
/**
* Start the router (call after registering all routes)
*/
public start(): void {
if (!this.started) {
this.started = true;
this.handleRoute();
}
}
/**
* Register a route handler
*/
public on(path: string, handler: () => void | Promise<void>): void {
this.routes.set(path, handler);
}
/**
* Navigate to a route programmatically
*/
public navigate(path: string): void {
window.location.hash = path;
}
/**
* Get current route path (without #)
*/
public getCurrentRoute(): string {
return this.currentRoute;
}
/**
* Handle route changes
*/
private async handleRoute(): Promise<void> {
// Get hash without the #
let hash = window.location.hash.slice(1) || '/';
// Normalize route
if (!hash.startsWith('/')) {
hash = '/' + hash;
}
this.currentRoute = hash;
// Find and execute route handler
const handler = this.routes.get(hash);
if (handler) {
await handler();
} else {
// Default to root if route not found
const defaultHandler = this.routes.get('/');
if (defaultHandler) {
await defaultHandler();
}
}
}
}
// Global router instance
export const router = new Router();
/**
* Helper to show/hide views
*/
export function showView(viewId: string): void {
console.log('[Router] showView() called with viewId:', viewId);
// Hide all views
const views = document.querySelectorAll('[data-view]');
console.log('[Router] Found views:', views.length);
views.forEach(view => {
(view as HTMLElement).style.display = 'none';
});
// Show requested view
const targetView = document.querySelector(`[data-view="${viewId}"]`);
console.log('[Router] Target view found:', !!targetView);
if (targetView) {
(targetView as HTMLElement).style.display = 'block';
console.log('[Router] View display set to block');
}
}

View File

@ -123,11 +123,15 @@ export class RockFactory {
// PhysicsAggregate will automatically compute sphere size from mesh bounding info // PhysicsAggregate will automatically compute sphere size from mesh bounding info
// The mesh scaling is already applied, so Babylon will create correctly sized physics shape // The mesh scaling is already applied, so Babylon will create correctly sized physics shape
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, { const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
mass: 10000, mass: 200,
restitution: .5 friction: 0,
restitution: .8
// Don't pass radius - let Babylon compute from scaled mesh bounds // Don't pass radius - let Babylon compute from scaled mesh bounds
}, DefaultScene.MainScene); }, DefaultScene.MainScene);
const body = agg.body; const body = agg.body;
body.setAngularDamping(0);
// Only apply orbit constraint if enabled for this level and orbit center exists // Only apply orbit constraint if enabled for this level and orbit center exists
if (useOrbitConstraint && this._orbitCenter) { if (useOrbitConstraint && this._orbitCenter) {

View File

@ -36,11 +36,10 @@ export interface LevelRegistryEntry {
} }
const CUSTOM_LEVELS_KEY = 'space-game-custom-levels'; const CUSTOM_LEVELS_KEY = 'space-game-custom-levels';
const CACHE_NAME = 'space-game-levels-v1';
const CACHED_VERSION_KEY = 'space-game-levels-cached-version';
/** /**
* Singleton registry for managing both default and custom levels * Singleton registry for managing both default and custom levels
* Always fetches fresh from network - no caching
*/ */
export class LevelRegistry { export class LevelRegistry {
private static instance: LevelRegistry | null = null; private static instance: LevelRegistry | null = null;
@ -63,121 +62,49 @@ export class LevelRegistry {
* Initialize the registry by loading directory and levels * Initialize the registry by loading directory and levels
*/ */
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
console.log('[LevelRegistry] initialize() called, initialized =', this.initialized);
if (this.initialized) { if (this.initialized) {
console.log('[LevelRegistry] Already initialized, skipping');
return; return;
} }
try { try {
console.log('[LevelRegistry] Loading directory manifest...');
// Load directory manifest
await this.loadDirectory(); await this.loadDirectory();
console.log('[LevelRegistry] Directory loaded, entries:', this.directoryManifest?.levels.length);
console.log('[LevelRegistry] Loading custom levels from localStorage...');
// Load custom levels from localStorage
this.loadCustomLevels(); this.loadCustomLevels();
console.log('[LevelRegistry] Custom levels loaded:', this.customLevels.size);
this.initialized = true; this.initialized = true;
console.log('[LevelRegistry] Initialization complete!'); console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels');
} catch (error) { } catch (error) {
console.error('[LevelRegistry] Failed to initialize level registry:', error); console.error('[LevelRegistry] Failed to initialize:', error);
throw error; throw error;
} }
} }
/** /**
* Load the directory.json manifest * Check if running in development mode (for cache-busting HTTP requests)
*/ */
private async loadDirectory(): Promise<void> { private isDevMode(): boolean {
console.log('[LevelRegistry] ======================================'); return window.location.hostname === 'localhost' ||
console.log('[LevelRegistry] loadDirectory() ENTERED at', Date.now());
console.log('[LevelRegistry] ======================================');
try {
console.log('[LevelRegistry] Attempting to fetch /levels/directory.json');
console.log('[LevelRegistry] window.location.origin:', window.location.origin);
console.log('[LevelRegistry] Full URL will be:', window.location.origin + '/levels/directory.json');
// First, fetch from network to get the latest version
console.log('[LevelRegistry] About to call fetch() - Timestamp:', Date.now());
console.log('[LevelRegistry] Fetching from network to check version...');
// Add cache-busting for development or when debugging
const isDev = window.location.hostname === 'localhost' ||
window.location.hostname.includes('dev.') || window.location.hostname.includes('dev.') ||
window.location.port !== ''; window.location.port !== '';
const cacheBuster = isDev ? `?v=${Date.now()}` : ''; }
console.log('[LevelRegistry] Cache busting:', isDev ? 'ENABLED (dev mode)' : 'DISABLED (production)');
const fetchStartTime = Date.now(); /**
* Load the directory.json manifest (always fresh from network)
*/
private async loadDirectory(): Promise<void> {
try {
// Add cache-busting in dev mode to avoid browser HTTP cache
const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : '';
const response = await fetch(`/levels/directory.json${cacheBuster}`); const response = await fetch(`/levels/directory.json${cacheBuster}`);
const fetchEndTime = Date.now();
console.log('[LevelRegistry] fetch() returned! Time taken:', fetchEndTime - fetchStartTime, 'ms');
console.log('[LevelRegistry] Fetch response status:', response.status, response.ok);
console.log('[LevelRegistry] Fetch response type:', response.type);
console.log('[LevelRegistry] Fetch response headers:', {
contentType: response.headers.get('content-type'),
contentLength: response.headers.get('content-length')
});
if (!response.ok) { if (!response.ok) {
// If network fails, try to use cached version as fallback
console.warn('[LevelRegistry] Network fetch failed, trying cache...');
const cached = await this.getCachedResource('/levels/directory.json');
if (cached) {
console.log('[LevelRegistry] Using cached directory as fallback');
this.directoryManifest = cached;
this.populateDefaultLevelEntries();
return;
}
throw new Error(`Failed to fetch directory: ${response.status}`); throw new Error(`Failed to fetch directory: ${response.status}`);
} }
console.log('[LevelRegistry] About to parse response.json()'); this.directoryManifest = await response.json();
const parseStartTime = Date.now(); console.log('[LevelRegistry] Loaded directory with', this.directoryManifest?.levels?.length || 0, 'levels');
const networkManifest = await response.json();
const parseEndTime = Date.now();
console.log('[LevelRegistry] JSON parsed successfully! Time taken:', parseEndTime - parseStartTime, 'ms');
console.log('[LevelRegistry] Directory JSON parsed:', networkManifest);
console.log('[LevelRegistry] Number of levels in manifest:', networkManifest?.levels?.length || 0);
// Check if version changed
const cachedVersion = localStorage.getItem(CACHED_VERSION_KEY);
const currentVersion = networkManifest.version;
if (cachedVersion && cachedVersion !== currentVersion) {
console.log('[LevelRegistry] Version changed from', cachedVersion, 'to', currentVersion, '- invalidating cache');
await this.invalidateCache();
} else {
console.log('[LevelRegistry] Version unchanged or first load:', currentVersion);
}
// Update cached version
localStorage.setItem(CACHED_VERSION_KEY, currentVersion);
// Store the manifest
this.directoryManifest = networkManifest;
// Cache the directory
await this.cacheResource('/levels/directory.json', this.directoryManifest);
console.log('[LevelRegistry] About to populate default level entries');
this.populateDefaultLevelEntries(); this.populateDefaultLevelEntries();
console.log('[LevelRegistry] Default level entries populated successfully');
console.log('[LevelRegistry] ======================================');
console.log('[LevelRegistry] loadDirectory() COMPLETED at', Date.now());
console.log('[LevelRegistry] ======================================');
} catch (error) { } catch (error) {
console.error('[LevelRegistry] !!!!! EXCEPTION in loadDirectory() !!!!!');
console.error('[LevelRegistry] Failed to load directory:', error); console.error('[LevelRegistry] Failed to load directory:', error);
console.error('[LevelRegistry] Error type:', error?.constructor?.name);
console.error('[LevelRegistry] Error message:', error?.message);
console.error('[LevelRegistry] Error stack:', error?.stack);
throw new Error('Unable to load level directory. Please check your connection.'); throw new Error('Unable to load level directory. Please check your connection.');
} }
} }
@ -187,27 +114,12 @@ export class LevelRegistry {
*/ */
private populateDefaultLevelEntries(): void { private populateDefaultLevelEntries(): void {
if (!this.directoryManifest) { if (!this.directoryManifest) {
console.error('[LevelRegistry] ❌ Cannot populate - directoryManifest is null');
return; return;
} }
console.log('[LevelRegistry] ======================================');
console.log('[LevelRegistry] Populating default level entries...');
console.log('[LevelRegistry] Directory manifest levels:', this.directoryManifest.levels.length);
this.defaultLevels.clear(); this.defaultLevels.clear();
for (const entry of this.directoryManifest.levels) { for (const entry of this.directoryManifest.levels) {
console.log(`[LevelRegistry] Storing level: ${entry.id}`, {
name: entry.name,
levelPath: entry.levelPath,
hasMissionBrief: !!entry.missionBrief,
missionBriefItems: entry.missionBrief?.length || 0,
hasLevelPath: !!entry.levelPath,
estimatedTime: entry.estimatedTime,
difficulty: entry.difficulty
});
this.defaultLevels.set(entry.id, { this.defaultLevels.set(entry.id, {
directoryEntry: entry, directoryEntry: entry,
config: null, // Lazy load config: null, // Lazy load
@ -215,9 +127,7 @@ export class LevelRegistry {
}); });
} }
console.log('[LevelRegistry] Populated entries. Total count:', this.defaultLevels.size);
console.log('[LevelRegistry] Level IDs:', Array.from(this.defaultLevels.keys())); console.log('[LevelRegistry] Level IDs:', Array.from(this.defaultLevels.keys()));
console.log('[LevelRegistry] ======================================');
} }
/** /**
@ -241,7 +151,7 @@ export class LevelRegistry {
name: config.metadata?.description || id, name: config.metadata?.description || id,
description: config.metadata?.description || '', description: config.metadata?.description || '',
version: config.version || '1.0', version: config.version || '1.0',
levelPath: '', // Not applicable for custom levelPath: '',
difficulty: config.difficulty, difficulty: config.difficulty,
missionBrief: [], missionBrief: [],
defaultLocked: false defaultLocked: false
@ -275,91 +185,30 @@ export class LevelRegistry {
} }
/** /**
* Load a default level's config from JSON * Load a default level's config from JSON (always fresh from network)
*/ */
private async loadDefaultLevel(levelId: string): Promise<void> { private async loadDefaultLevel(levelId: string): Promise<void> {
console.log('[LevelRegistry] ======================================');
console.log('[LevelRegistry] loadDefaultLevel() called for:', levelId);
console.log('[LevelRegistry] Timestamp:', Date.now());
console.log('[LevelRegistry] ======================================');
const entry = this.defaultLevels.get(levelId); const entry = this.defaultLevels.get(levelId);
if (!entry || entry.config) { if (!entry || entry.config) {
console.log('[LevelRegistry] Early return - entry:', !!entry, ', config loaded:', !!entry?.config);
return; // Already loaded or doesn't exist
}
try {
const levelPath = `/levels/${entry.directoryEntry.levelPath}`;
console.log('[LevelRegistry] Constructed levelPath:', levelPath);
console.log('[LevelRegistry] Full URL will be:', window.location.origin + levelPath);
// Check if cache busting is enabled (dev mode)
const isDev = window.location.hostname === 'localhost' ||
window.location.hostname.includes('dev.') ||
window.location.port !== '';
// In dev mode, skip cache and always fetch fresh
let cached = null;
if (!isDev) {
console.log('[LevelRegistry] Checking cache for:', levelPath);
cached = await this.getCachedResource(levelPath);
} else {
console.log('[LevelRegistry] Skipping cache check (dev mode)');
}
if (cached) {
console.log('[LevelRegistry] Found in cache! Using cached config');
entry.config = cached;
entry.loadedAt = new Date();
return; return;
} }
console.log('[LevelRegistry] Not in cache, fetching from network');
// Fetch from network with cache-busting in dev mode const levelPath = `/levels/${entry.directoryEntry.levelPath}`;
const cacheBuster = isDev ? `?v=${Date.now()}` : '';
console.log('[LevelRegistry] About to fetch level JSON - Timestamp:', Date.now()); try {
console.log('[LevelRegistry] Cache busting:', isDev ? 'ENABLED' : 'DISABLED'); // Add cache-busting in dev mode
const fetchStartTime = Date.now(); const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : '';
const response = await fetch(`${levelPath}${cacheBuster}`); const response = await fetch(`${levelPath}${cacheBuster}`);
const fetchEndTime = Date.now();
console.log('[LevelRegistry] Level fetch() returned! Time taken:', fetchEndTime - fetchStartTime, 'ms');
console.log('[LevelRegistry] Response status:', response.status, response.ok);
if (!response.ok) { if (!response.ok) {
console.error('[LevelRegistry] Fetch failed with status:', response.status);
throw new Error(`Failed to fetch level: ${response.status}`); throw new Error(`Failed to fetch level: ${response.status}`);
} }
console.log('[LevelRegistry] Parsing level JSON...'); entry.config = await response.json();
const parseStartTime = Date.now();
const config: LevelConfig = await response.json();
const parseEndTime = Date.now();
console.log('[LevelRegistry] Level JSON parsed! Time taken:', parseEndTime - parseStartTime, 'ms');
console.log('[LevelRegistry] Level config loaded:', {
version: config.version,
difficulty: config.difficulty,
asteroidCount: config.asteroids?.length || 0
});
// Cache the level
console.log('[LevelRegistry] Caching level config...');
await this.cacheResource(levelPath, config);
console.log('[LevelRegistry] Level cached successfully');
entry.config = config;
entry.loadedAt = new Date(); entry.loadedAt = new Date();
console.log('[LevelRegistry] Loaded level:', levelId);
console.log('[LevelRegistry] ======================================');
console.log('[LevelRegistry] loadDefaultLevel() COMPLETED for:', levelId);
console.log('[LevelRegistry] ======================================');
} catch (error) { } catch (error) {
console.error('[LevelRegistry] !!!!! EXCEPTION in loadDefaultLevel() !!!!!'); console.error(`[LevelRegistry] Failed to load level ${levelId}:`, error);
console.error(`[LevelRegistry] Failed to load default level ${levelId}:`, error);
console.error('[LevelRegistry] Error type:', error?.constructor?.name);
console.error('[LevelRegistry] Error message:', error?.message);
console.error('[LevelRegistry] Error stack:', error?.stack);
throw error; throw error;
} }
} }
@ -370,12 +219,10 @@ export class LevelRegistry {
public getAllLevels(): Map<string, LevelRegistryEntry> { public getAllLevels(): Map<string, LevelRegistryEntry> {
const all = new Map<string, LevelRegistryEntry>(); const all = new Map<string, LevelRegistryEntry>();
// Add defaults
for (const [id, entry] of this.defaultLevels) { for (const [id, entry] of this.defaultLevels) {
all.set(id, entry); all.set(id, entry);
} }
// Add customs
for (const [id, entry] of this.customLevels) { for (const [id, entry] of this.customLevels) {
all.set(id, entry); all.set(id, entry);
} }
@ -401,7 +248,6 @@ export class LevelRegistry {
* Save a custom level * Save a custom level
*/ */
public saveCustomLevel(levelId: string, config: LevelConfig): void { public saveCustomLevel(levelId: string, config: LevelConfig): void {
// Ensure metadata exists
if (!config.metadata) { if (!config.metadata) {
config.metadata = { config.metadata = {
author: 'Player', author: 'Player',
@ -409,12 +255,10 @@ export class LevelRegistry {
}; };
} }
// Remove 'default' type if present
if (config.metadata.type === 'default') { if (config.metadata.type === 'default') {
delete config.metadata.type; delete config.metadata.type;
} }
// Add/update in memory
this.customLevels.set(levelId, { this.customLevels.set(levelId, {
directoryEntry: { directoryEntry: {
id: levelId, id: levelId,
@ -431,7 +275,6 @@ export class LevelRegistry {
loadedAt: new Date() loadedAt: new Date()
}); });
// Persist to localStorage
this.saveCustomLevelsToStorage(); this.saveCustomLevelsToStorage();
} }
@ -455,10 +298,8 @@ export class LevelRegistry {
return false; return false;
} }
// Deep clone the config
const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config)); const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config));
// Update metadata
clonedConfig.metadata = { clonedConfig.metadata = {
...clonedConfig.metadata, ...clonedConfig.metadata,
type: undefined, type: undefined,
@ -487,72 +328,14 @@ export class LevelRegistry {
} }
/** /**
* Get a resource from cache * Force refresh all default levels from network
*/ */
private async getCachedResource(path: string): Promise<any | null> { public async refreshDefaultLevels(): Promise<void> {
if (!('caches' in window)) { // Clear in-memory configs
return null;
}
try {
const cache = await caches.open(CACHE_NAME);
const response = await cache.match(path);
if (response) {
return await response.json();
}
} catch (error) {
console.warn('Cache read failed:', error);
}
return null;
}
/**
* Cache a resource
*/
private async cacheResource(path: string, data: any): Promise<void> {
if (!('caches' in window)) {
return;
}
try {
const cache = await caches.open(CACHE_NAME);
const response = new Response(JSON.stringify(data), {
headers: {'Content-Type': 'application/json'}
});
await cache.put(path, response);
} catch (error) {
console.warn('Cache write failed:', error);
}
}
/**
* Invalidate the entire cache (called when version changes)
*/
private async invalidateCache(): Promise<void> {
console.log('[LevelRegistry] Invalidating cache...');
if ('caches' in window) {
await caches.delete(CACHE_NAME);
}
// Clear loaded configs
for (const entry of this.defaultLevels.values()) { for (const entry of this.defaultLevels.values()) {
entry.config = null; entry.config = null;
entry.loadedAt = undefined; entry.loadedAt = undefined;
} }
console.log('[LevelRegistry] Cache invalidated');
}
/**
* Force refresh all default levels from network
*/
public async refreshDefaultLevels(): Promise<void> {
// Clear cache
await this.invalidateCache();
// Clear cached version to force re-check
localStorage.removeItem(CACHED_VERSION_KEY);
// Reload directory // Reload directory
await this.loadDirectory(); await this.loadDirectory();
@ -608,36 +391,17 @@ export class LevelRegistry {
} }
/** /**
* Clear all caches and force reload from network * Reset registry state (for testing or force reload)
* Useful for development or when data needs to be refreshed
*/ */
public async clearAllCaches(): Promise<void> { public reset(): void {
console.log('[LevelRegistry] Clearing all caches...');
// Clear Cache API
if ('caches' in window) {
const cacheKeys = await caches.keys();
for (const key of cacheKeys) {
await caches.delete(key);
console.log('[LevelRegistry] Deleted cache:', key);
}
}
// Clear localStorage cache version
localStorage.removeItem(CACHED_VERSION_KEY);
console.log('[LevelRegistry] Cleared localStorage cache version');
// Clear loaded configs
for (const entry of this.defaultLevels.values()) { for (const entry of this.defaultLevels.values()) {
entry.config = null; entry.config = null;
entry.loadedAt = undefined; entry.loadedAt = undefined;
} }
console.log('[LevelRegistry] Cleared loaded configs');
// Reset initialization flag to force reload
this.initialized = false; this.initialized = false;
this.directoryManifest = null; this.directoryManifest = null;
console.log('[LevelRegistry] All caches cleared. Call initialize() to reload.'); console.log('[LevelRegistry] Reset complete. Call initialize() to reload.');
} }
} }

View File

@ -1,416 +0,0 @@
import {LevelConfig} from "../config/levelConfig";
import {ProgressionManager} from "../../game/progression";
import {GameConfig} from "../../core/gameConfig";
import {AuthService} from "../../services/authService";
import debugLog from '../../core/debug';
import {LevelRegistry} from "../storage/levelRegistry";
import {LevelVersionManager} from "../versioning/levelVersionManager";
import {LevelStatsManager} from "../stats/levelStats";
const SELECTED_LEVEL_KEY = 'space-game-selected-level';
// Default level IDs in display order (matches directory.json)
const DEFAULT_LEVEL_ORDER = [
'rookie-training',
'rescue-mission',
'deep-space-patrol',
'enemy-territory',
'the-gauntlet',
'final-challenge'
];
/**
* Populate the level selection screen with levels from registry
* Shows all 6 default levels in a 3x2 carousel with locked/unlocked states
*/
export async function populateLevelSelector(): Promise<boolean> {
console.log('[LevelSelector] populateLevelSelector() called');
const container = document.getElementById('levelCardsContainer');
if (!container) {
console.warn('[LevelSelector] Level cards container not found');
return false;
}
console.log('[LevelSelector] Container found:', container);
const registry = LevelRegistry.getInstance();
const versionManager = LevelVersionManager.getInstance();
const statsManager = LevelStatsManager.getInstance();
// Initialize registry
try {
console.log('[LevelSelector] Initializing registry...');
await registry.initialize();
console.log('[LevelSelector] Registry initialized');
} catch (error) {
console.error('[LevelSelector] Registry initialization error:', error);
container.innerHTML = `
<div class="no-levels-message">
<h2>Failed to Load Levels</h2>
<p>Could not load level directory. Check your connection and try again.</p>
<button onclick="location.reload()" class="btn-primary">Reload</button>
</div>
`;
return false;
}
const gameConfig = GameConfig.getInstance();
const progressionEnabled = gameConfig.progressionEnabled;
const progression = ProgressionManager.getInstance();
// Update version manager with directory
const directory = registry.getDirectory();
if (directory) {
versionManager.updateManifestVersions(directory);
}
const defaultLevels = registry.getDefaultLevels();
const customLevels = registry.getCustomLevels();
console.log('[LevelSelector] Default levels:', defaultLevels.size);
console.log('[LevelSelector] Custom levels:', customLevels.size);
console.log('[LevelSelector] Default level IDs:', Array.from(defaultLevels.keys()));
if (defaultLevels.size === 0 && customLevels.size === 0) {
console.warn('[LevelSelector] No levels found!');
container.innerHTML = `
<div class="no-levels-message">
<h2>No Levels Found</h2>
<p>No levels available. Please check your installation.</p>
<a href="#/editor" class="btn-primary">Create Custom Level</a>
</div>
`;
return false;
}
let html = '';
// Show progression stats only if progression is enabled
if (progressionEnabled) {
const completedCount = progression.getCompletedCount();
const totalCount = progression.getTotalDefaultLevels();
const completionPercent = progression.getCompletionPercentage();
const nextLevel = progression.getNextLevel();
html += `
<div class="progress-bar-container" style="grid-column: 1 / -1;">
<h3 class="progress-bar-title">Progress</h3>
<div class="level-description">
${completedCount} of ${totalCount} default levels completed (${completionPercent.toFixed(0)}%)
</div>
<div class="progress-bar-track">
<div class="progress-fill" style="width: ${completionPercent}%;"></div>
</div>
${nextLevel ? `<div class="progress-percentage">Next: ${nextLevel}</div>` : ''}
</div>
`;
}
// Check if user is authenticated
const authService = AuthService.getInstance();
const isAuthenticated = await authService.isAuthenticated();
const isTutorial = (levelId: string) => levelId === DEFAULT_LEVEL_ORDER[0];
debugLog('[LevelSelector] Authenticated:', isAuthenticated);
debugLog('[LevelSelector] Progression enabled:', progressionEnabled);
debugLog('[LevelSelector] Default levels count:', defaultLevels.size);
// Show all default levels in order (3x2 grid)
if (defaultLevels.size > 0) {
for (const levelId of DEFAULT_LEVEL_ORDER) {
const entry = defaultLevels.get(levelId);
if (!entry) {
// Level doesn't exist - show empty slot
html += `
<div class="level-card level-card-locked">
<div class="level-card-header">
<h2 class="level-card-title">Missing Level</h2>
<div class="level-card-status level-card-status-locked">🔒</div>
</div>
<div class="level-meta">Level not found</div>
<p class="level-card-description">This level has not been created yet.</p>
<button class="level-button" disabled>Locked</button>
</div>
`;
continue;
}
const dirEntry = entry.directoryEntry;
const levelName = dirEntry.name;
const description = dirEntry.description;
const estimatedTime = dirEntry.estimatedTime || '';
const difficulty = dirEntry.difficulty || 'unknown';
// Check for version updates
const hasUpdate = versionManager.hasUpdate(levelId);
// Get stats
const stats = statsManager.getStats(levelId);
const completionRate = stats?.completionRate || 0;
const bestTime = stats?.bestTimeSeconds;
// Check progression
const isCompleted = progressionEnabled && progression.isLevelComplete(levelName);
// Check if level is unlocked
let isUnlocked = false;
const isTut = isTutorial(levelId);
if (isTut) {
isUnlocked = true; // Tutorial always unlocked
} else if (!isAuthenticated) {
isUnlocked = false; // Non-tutorial levels require authentication
} else {
isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName);
}
const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName;
// Determine card state
let cardClasses = 'level-card';
let statusIcons = '';
let buttonText = 'Play Level';
let buttonDisabled = '';
let lockReason = '';
let metaTags = '';
// Version update badge
if (hasUpdate) {
statusIcons += '<div class="level-card-badge level-card-badge-update">UPDATED</div>';
}
if (isCompleted) {
cardClasses += ' level-card-completed';
statusIcons += '<div class="level-card-status level-card-status-complete">✓</div>';
buttonText = 'Replay';
} else if (isCurrentNext && isUnlocked) {
cardClasses += ' level-card-current';
statusIcons += '<div class="level-card-badge">START HERE</div>';
} else if (!isUnlocked) {
cardClasses += ' level-card-locked';
statusIcons += '<div class="level-card-status level-card-status-locked">🔒</div>';
// Determine why it's locked
if (!isAuthenticated && !isTutorial(levelId)) {
buttonText = 'Sign In Required';
lockReason = '<div class="level-lock-reason">Sign in to unlock</div>';
} else if (progressionEnabled) {
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelId);
if (levelIndex > 0) {
const prevId = DEFAULT_LEVEL_ORDER[levelIndex - 1];
const prevEntry = defaultLevels.get(prevId);
const prevName = prevEntry?.directoryEntry.name || 'previous level';
lockReason = `<div class="level-lock-reason">Complete "${prevName}" to unlock</div>`;
}
buttonText = 'Locked';
} else {
buttonText = 'Locked';
}
buttonDisabled = ' disabled';
}
// Show stats if available
if (stats && stats.totalAttempts > 0) {
metaTags = '<div class="level-stats">';
if (bestTime) {
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
}
if (stats.totalCompletions > 0) {
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
}
metaTags += `<span class="stat-badge">${LevelStatsManager.formatCompletionRate(completionRate)}</span>`;
metaTags += '</div>';
}
html += `
<div class="${cardClasses}">
<div class="level-card-header">
<h2 class="level-card-title">${levelName}</h2>
<div class="level-card-badges">${statusIcons}</div>
</div>
<div class="level-meta">
Difficulty: ${difficulty}${estimatedTime ? `${estimatedTime}` : ''}
</div>
<p class="level-card-description">${description}</p>
${metaTags}
${lockReason}
<div class="level-card-actions">
<button class="level-button" data-level-id="${levelId}"${buttonDisabled}>${buttonText}</button>
${entry.isDefault && isUnlocked ? `<button class="level-button-secondary" data-copy-level="${levelId}" title="Copy to custom levels">📋 Copy</button>` : ''}
</div>
</div>
`;
}
}
// Show custom levels section if any exist
if (customLevels.size > 0) {
html += `
<div style="grid-column: 1 / -1; margin-top: var(--space-2xl);">
<h3 class="level-header">Custom Levels</h3>
</div>
`;
for (const [levelId, entry] of customLevels.entries()) {
const config = entry.config;
if (!config) continue;
const description = config.metadata?.description || `${config.asteroids.length} asteroids`;
const author = config.metadata?.author ? ` by ${config.metadata.author}` : '';
const difficulty = config.difficulty || 'custom';
// Get stats
const stats = statsManager.getStats(levelId);
const bestTime = stats?.bestTimeSeconds;
let metaTags = '';
if (stats && stats.totalAttempts > 0) {
metaTags = '<div class="level-stats">';
if (bestTime) {
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
}
if (stats.totalCompletions > 0) {
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
}
metaTags += '</div>';
}
html += `
<div class="level-card">
<div class="level-card-header">
<h2 class="level-card-title">${levelId}</h2>
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
</div>
<div class="level-meta">
${difficulty}${author}
</div>
<p class="level-card-description">${description}</p>
${metaTags}
<div class="level-card-actions">
<button class="level-button" data-level-id="${levelId}">Play Level</button>
<button class="level-button-secondary" data-delete-level="${levelId}" title="Delete level">🗑</button>
</div>
</div>
`;
}
}
console.log('[LevelSelector] Setting container innerHTML, html length:', html.length);
container.innerHTML = html;
console.log('[LevelSelector] Container innerHTML set, now attaching event listeners');
// Attach event listeners to all level buttons
const playButtons = container.querySelectorAll('.level-button:not([disabled])');
playButtons.forEach(button => {
button.addEventListener('click', (e) => {
const target = e.target as HTMLButtonElement;
const levelId = target.getAttribute('data-level-id');
if (levelId) {
selectLevel(levelId);
}
});
});
// Attach copy button listeners
const copyButtons = container.querySelectorAll('[data-copy-level]');
copyButtons.forEach(button => {
button.addEventListener('click', async (e) => {
const target = e.target as HTMLButtonElement;
const levelId = target.getAttribute('data-copy-level');
if (levelId) {
await copyLevelToCustom(levelId);
}
});
});
// Attach delete button listeners
const deleteButtons = container.querySelectorAll('[data-delete-level]');
deleteButtons.forEach(button => {
button.addEventListener('click', (e) => {
const target = e.target as HTMLButtonElement;
const levelId = target.getAttribute('data-delete-level');
if (levelId) {
deleteCustomLevel(levelId);
}
});
});
console.log('[LevelSelector] Event listeners attached, returning true');
// Make the level selector visible by adding 'ready' class
const levelSelectDiv = document.getElementById('levelSelect');
if (levelSelectDiv) {
levelSelectDiv.classList.add('ready');
console.log('[LevelSelector] Added "ready" class to #levelSelect');
}
return true;
}
/**
* Copy a default level to custom levels
*/
async function copyLevelToCustom(levelId: string): Promise<void> {
const registry = LevelRegistry.getInstance();
const customName = prompt(`Enter a name for your copy of this level:`, `${levelId}-copy`);
if (!customName || customName.trim() === '') {
return;
}
const success = await registry.copyDefaultToCustom(levelId, customName);
if (success) {
alert(`Level copied as "${customName}"!`);
await populateLevelSelector(); // Refresh UI
} else {
alert('Failed to copy level. Please try again.');
}
}
/**
* Delete a custom level
*/
function deleteCustomLevel(levelId: string): void {
if (!confirm(`Are you sure you want to delete "${levelId}"?`)) {
return;
}
const registry = LevelRegistry.getInstance();
const success = registry.deleteCustomLevel(levelId);
if (success) {
populateLevelSelector(); // Refresh UI
}
}
/**
* Select a level and dispatch event to start it
*/
export async function selectLevel(levelId: string): Promise<void> {
debugLog(`[LevelSelector] Level selected: ${levelId}`);
const registry = LevelRegistry.getInstance();
const config = await registry.getLevel(levelId);
if (!config) {
console.error(`Level not found: ${levelId}`);
return;
}
// Save selected level
localStorage.setItem(SELECTED_LEVEL_KEY, levelId);
// Dispatch custom event that Main class will listen for
const event = new CustomEvent('levelSelected', {
detail: {levelName: levelId, config}
});
window.dispatchEvent(event);
}
/**
* Get the last selected level ID
*/
export function getSelectedLevel(): string | null {
return localStorage.getItem(SELECTED_LEVEL_KEY);
}

View File

@ -1,262 +0,0 @@
import {LevelDirectory, LevelDirectoryEntry} from "../storage/levelRegistry";
/**
* Tracked version information for a level
*/
export interface LevelVersionInfo {
levelId: string;
loadedVersion: string;
loadedAt: Date;
manifestVersion?: string; // Latest version from directory
}
/**
* Version comparison result
*/
export interface VersionComparison {
levelId: string;
currentVersion: string;
latestVersion: string;
isOutdated: boolean;
changelog?: string;
}
const VERSION_STORAGE_KEY = 'space-game-level-versions';
/**
* Manages level version tracking and update detection
*/
export class LevelVersionManager {
private static instance: LevelVersionManager | null = null;
private versionMap: Map<string, LevelVersionInfo> = new Map();
private constructor() {
this.loadVersions();
}
public static getInstance(): LevelVersionManager {
if (!LevelVersionManager.instance) {
LevelVersionManager.instance = new LevelVersionManager();
}
return LevelVersionManager.instance;
}
/**
* Load version tracking from localStorage
*/
private loadVersions(): void {
const stored = localStorage.getItem(VERSION_STORAGE_KEY);
if (!stored) {
return;
}
try {
const versionsArray: [string, LevelVersionInfo][] = JSON.parse(stored);
for (const [id, info] of versionsArray) {
// Parse date string back to Date object
if (info.loadedAt && typeof info.loadedAt === 'string') {
info.loadedAt = new Date(info.loadedAt);
}
this.versionMap.set(id, info);
}
} catch (error) {
console.error('Failed to load level versions:', error);
}
}
/**
* Save version tracking to localStorage
*/
private saveVersions(): void {
const versionsArray = Array.from(this.versionMap.entries());
localStorage.setItem(VERSION_STORAGE_KEY, JSON.stringify(versionsArray));
}
/**
* Record that a level was loaded with a specific version
*/
public recordLevelLoaded(levelId: string, version: string): void {
const info: LevelVersionInfo = {
levelId,
loadedVersion: version,
loadedAt: new Date()
};
this.versionMap.set(levelId, info);
this.saveVersions();
}
/**
* Update manifest versions from directory
*/
public updateManifestVersions(directory: LevelDirectory): void {
for (const entry of directory.levels) {
const existing = this.versionMap.get(entry.id);
if (existing) {
existing.manifestVersion = entry.version;
} else {
// First time seeing this level
this.versionMap.set(entry.id, {
levelId: entry.id,
loadedVersion: '', // Not yet loaded
loadedAt: new Date(),
manifestVersion: entry.version
});
}
}
this.saveVersions();
}
/**
* Check if a level has an update available
*/
public hasUpdate(levelId: string): boolean {
const info = this.versionMap.get(levelId);
if (!info || !info.manifestVersion || !info.loadedVersion) {
return false;
}
return this.compareVersions(info.loadedVersion, info.manifestVersion) < 0;
}
/**
* Get version comparison for a level
*/
public getVersionComparison(levelId: string): VersionComparison | null {
const info = this.versionMap.get(levelId);
if (!info || !info.manifestVersion) {
return null;
}
const currentVersion = info.loadedVersion || '0.0';
const latestVersion = info.manifestVersion;
const isOutdated = this.compareVersions(currentVersion, latestVersion) < 0;
return {
levelId,
currentVersion,
latestVersion,
isOutdated
};
}
/**
* Get all levels with available updates
*/
public getUpdatableLevels(): VersionComparison[] {
const updatable: VersionComparison[] = [];
for (const [levelId, info] of this.versionMap) {
if (info.manifestVersion && info.loadedVersion) {
const comparison = this.getVersionComparison(levelId);
if (comparison && comparison.isOutdated) {
updatable.push(comparison);
}
}
}
return updatable;
}
/**
* Get version info for a level
*/
public getVersionInfo(levelId: string): LevelVersionInfo | undefined {
return this.versionMap.get(levelId);
}
/**
* Mark a level as updated (user accepted the new version)
*/
public markAsUpdated(levelId: string, newVersion: string): void {
const info = this.versionMap.get(levelId);
if (info) {
info.loadedVersion = newVersion;
info.loadedAt = new Date();
this.saveVersions();
}
}
/**
* Compare two semantic version strings
* Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
private compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const maxLength = Math.max(parts1.length, parts2.length);
for (let i = 0; i < maxLength; i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 < part2) return -1;
if (part1 > part2) return 1;
}
return 0;
}
/**
* Clear all version tracking (for testing/reset)
*/
public clearAll(): void {
this.versionMap.clear();
localStorage.removeItem(VERSION_STORAGE_KEY);
}
/**
* Get summary of version statuses
*/
public getVersionSummary(): {
total: number;
tracked: number;
updatable: number;
upToDate: number;
} {
let tracked = 0;
let updatable = 0;
let upToDate = 0;
for (const info of this.versionMap.values()) {
if (info.loadedVersion) {
tracked++;
if (info.manifestVersion) {
if (this.compareVersions(info.loadedVersion, info.manifestVersion) < 0) {
updatable++;
} else {
upToDate++;
}
}
}
}
return {
total: this.versionMap.size,
tracked,
updatable,
upToDate
};
}
/**
* Build changelog text for version updates
*/
public static buildChangelog(directoryEntry: LevelDirectoryEntry): string {
// In the future, this could fetch from a changelog file or API
// For now, generate a simple message
return `Level updated to version ${directoryEntry.version}. Check for improvements and changes!`;
}
/**
* Check if this is the first time loading any levels
*/
public isFirstRun(): boolean {
return this.versionMap.size === 0;
}
}

View File

@ -23,8 +23,6 @@ import Level from "./levels/level";
import setLoadingMessage from "./utils/setLoadingMessage"; import setLoadingMessage from "./utils/setLoadingMessage";
import {RockFactory} from "./environment/asteroids/rockFactory"; import {RockFactory} from "./environment/asteroids/rockFactory";
import {ControllerDebug} from "./utils/controllerDebug"; import {ControllerDebug} from "./utils/controllerDebug";
import {router, showView} from "./core/router";
import {populateLevelSelector} from "./levels/ui/levelSelector";
import {LevelConfig} from "./levels/config/levelConfig"; import {LevelConfig} from "./levels/config/levelConfig";
import {LegacyMigration} from "./levels/migration/legacyMigration"; import {LegacyMigration} from "./levels/migration/legacyMigration";
import {LevelRegistry} from "./levels/storage/levelRegistry"; import {LevelRegistry} from "./levels/storage/levelRegistry";
@ -747,128 +745,7 @@ export class Main {
} }
} }
// Setup router // Initialize registry and mount Svelte app
router.on('/', async () => {
debugLog('[Router] Home route triggered');
// Always show game view
showView('game');
debugLog('[Router] Game view shown');
// Initialize auth service (but don't block on it)
try {
const authService = AuthService.getInstance();
debugLog('[Router] Initializing auth service...');
await authService.initialize();
debugLog('[Router] Auth service initialized');
// Check if user is authenticated
const isAuthenticated = await authService.isAuthenticated();
const user = authService.getUser();
debugLog('[Router] Auth check - authenticated:', isAuthenticated, 'user:', user);
if (isAuthenticated && user) {
// User is authenticated - update profile display
debugLog('User authenticated:', user?.email || user?.name || 'Unknown');
updateUserProfile(user.name || user.email || 'Player');
} else {
// User not authenticated - show login/signup button
debugLog('User not authenticated, showing login button');
updateUserProfile(null); // This will show login button instead
}
} catch (error) {
// Auth failed, but allow game to continue
debugLog('Auth initialization failed, continuing without auth:', error);
updateUserProfile(null);
}
// Show the app header
const appHeader = document.getElementById('appHeader');
if (appHeader) {
appHeader.style.display = 'block';
}
// Just show the level selector - don't initialize anything yet!
if (!DEBUG_CONTROLLERS) {
debugLog('[Router] Populating level selector (no engine initialization yet)');
await populateLevelSelector();
// Create Main instance lazily only if it doesn't exist
// But don't initialize it yet - that will happen on level selection
if (!(window as any).__mainInstance) {
debugLog('[Router] Creating Main instance (not initialized)');
const main = new Main();
(window as any).__mainInstance = main;
// Initialize demo mode without engine (just for UI purposes)
const demo = new Demo(main);
}
// Discord widget initialization with enhanced error logging
/*if (!(window as any).__discordWidget) {
debugLog('[Router] Initializing Discord widget');
const discord = new DiscordWidget();
// Initialize with your server and channel IDs
discord.initialize({
server: '1112846185913401475', // Replace with your Discord server ID
channel: '1437561367908581406', // Replace with your Discord channel ID
color: '#667eea',
glyph: ['💬', '✖️'],
notifications: true
}).then(() => {
debugLog('[Router] Discord widget ready');
(window as any).__discordWidget = discord;
}).catch(error => {
console.error('[Router] Failed to initialize Discord widget:', error);
console.error('[Router] Error type:', error?.constructor?.name);
console.error('[Router] Error message:', error?.message);
console.error('[Router] Error stack:', error?.stack);
if (error?.response) {
console.error('[Router] GraphQL response error:', error.response);
}
});
}*/
}
debugLog('[Router] Home route handler complete');
});
router.on('/editor', () => {
showView('editor');
// Dynamically import and initialize editor
if (!(window as any).__editorInitialized) {
import('./levels/generation/levelEditor').then(() => {
(window as any).__editorInitialized = true;
});
}
});
router.on('/settings', () => {
showView('settings');
// Dynamically import and initialize settings
if (!(window as any).__settingsInitialized) {
import('./ui/screens/settingsScreen').then((module) => {
module.initializeSettingsScreen();
(window as any).__settingsInitialized = true;
});
}
});
router.on('/controls', () => {
showView('controls');
// Dynamically import and initialize controls screen
if (!(window as any).__controlsInitialized) {
import('./ui/screens/controlsScreen').then((module) => {
const controlsScreen = new module.ControlsScreen();
controlsScreen.initialize();
(window as any).__controlsInitialized = true;
});
}
});
// Initialize registry and start router
// This must happen BEFORE router.start() so levels are available
async function initializeApp() { async function initializeApp() {
console.log('[Main] ========================================'); console.log('[Main] ========================================');
console.log('[Main] initializeApp() STARTED at', new Date().toISOString()); console.log('[Main] initializeApp() STARTED at', new Date().toISOString());
@ -889,8 +766,6 @@ async function initializeApp() {
await LevelRegistry.getInstance().initialize(); await LevelRegistry.getInstance().initialize();
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]'); console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
debugLog('[Main] LevelRegistry initialized after migration'); debugLog('[Main] LevelRegistry initialized after migration');
// NOTE: Old router disabled - now using svelte-routing
// router.start();
// Mount Svelte app // Mount Svelte app
console.log('[Main] Mounting Svelte app [AFTER MIGRATION]'); console.log('[Main] Mounting Svelte app [AFTER MIGRATION]');
@ -917,8 +792,6 @@ async function initializeApp() {
resolve(); resolve();
} catch (error) { } catch (error) {
console.error('[Main] Failed to initialize LevelRegistry after migration:', error); console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
// NOTE: Old router disabled - now using svelte-routing
// router.start(); // Start anyway to show error state
resolve(); resolve();
} }
}); });
@ -941,19 +814,12 @@ async function initializeApp() {
if (isDev) { if (isDev) {
(window as any).__levelRegistry = LevelRegistry.getInstance(); (window as any).__levelRegistry = LevelRegistry.getInstance();
console.log('[Main] LevelRegistry exposed to window.__levelRegistry for debugging'); console.log('[Main] LevelRegistry exposed to window.__levelRegistry for debugging');
console.log('[Main] To clear caches: window.__levelRegistry.clearAllCaches().then(() => location.reload())'); console.log('[Main] To clear caches: window.__levelRegistry.reset(); location.reload()');
} }
// NOTE: Old router disabled - now using svelte-routing
// console.log('[Main] About to call router.start()');
// router.start();
// console.log('[Main] router.start() completed');
} catch (error) { } catch (error) {
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!'); console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
console.error('[Main] Failed to initialize LevelRegistry:', error); console.error('[Main] Failed to initialize LevelRegistry:', error);
console.error('[Main] Error stack:', error?.stack); console.error('[Main] Error stack:', error?.stack);
// NOTE: Old router disabled - now using svelte-routing
// router.start(); // Start anyway to show error state
} }
} }

View File

@ -104,7 +104,8 @@ export class WeaponSystem {
ammoAggregate.body.setCollisionCallbackEnabled(true); ammoAggregate.body.setCollisionCallbackEnabled(true);
// Set projectile velocity (already includes ship velocity) // Set projectile velocity (already includes ship velocity)
ammoAggregate.body.setLinearVelocity(velocityVector); // Clone to capture current direction - prevents curving if source vector updates
ammoAggregate.body.setLinearVelocity(velocityVector.clone());
// Consume ammo // Consume ammo
if (this._shipStatus) { if (this._shipStatus) {