Simplify level system and add asteroid-mania level
All checks were successful
Build / build (push) Successful in 1m29s
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:
parent
3a5cf3e074
commit
3ff1ffeb45
357
CLOUD_STORAGE_PLAN.md
Normal file
357
CLOUD_STORAGE_PLAN.md
Normal 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)
|
||||
289
public/levels/asteroid-mania.json
Normal file
289
public/levels/asteroid-mania.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.11",
|
||||
"levels": [
|
||||
{
|
||||
"id": "rookie-training",
|
||||
@ -21,21 +21,21 @@
|
||||
"defaultLocked": false
|
||||
},
|
||||
{
|
||||
"id": "rescue-mission",
|
||||
"name": "Rescue Mission",
|
||||
"description": "Rescue operation in moderate asteroid field",
|
||||
"version": "1.0",
|
||||
"levelPath": "rescue-mission.json",
|
||||
"id": "asteroid-mania",
|
||||
"name": "Asteroid Mania!!!",
|
||||
"description": "Still low stakes, just more asteroids",
|
||||
"version": "1.1",
|
||||
"levelPath": "asteroid-mania.json",
|
||||
"difficulty": "pilot",
|
||||
"estimatedTime": "5-8 minutes",
|
||||
"missionBrief": [
|
||||
"More asteroids and increased difficulty",
|
||||
"Manage your fuel and ammunition carefully",
|
||||
"Complete the mission and return to base",
|
||||
"Use your radar to track asteroids",
|
||||
"Watch your shield strength"
|
||||
"Watch your hull integrity!",
|
||||
"Some of the asteroids are a little more distant"
|
||||
],
|
||||
"unlockRequirements": ["rookie-training"],
|
||||
"unlockRequirements": [],
|
||||
"tags": ["medium"],
|
||||
"defaultLocked": true
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"difficulty": "pilot",
|
||||
"difficulty": "rookie",
|
||||
"timestamp": "2025-11-11T23:44:24.810Z",
|
||||
"metadata": {
|
||||
"author": "System",
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
import LevelCard from './LevelCard.svelte';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
|
||||
// Get default levels in order
|
||||
// Get default levels in order (must match directory.json)
|
||||
const DEFAULT_LEVEL_ORDER = [
|
||||
'rookie-training',
|
||||
'rescue-mission',
|
||||
'asteroid-mania',
|
||||
'deep-space-patrol',
|
||||
'enemy-territory',
|
||||
'the-gauntlet',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -123,11 +123,15 @@ export class RockFactory {
|
||||
// PhysicsAggregate will automatically compute sphere size from mesh bounding info
|
||||
// The mesh scaling is already applied, so Babylon will create correctly sized physics shape
|
||||
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
|
||||
mass: 10000,
|
||||
restitution: .5
|
||||
mass: 200,
|
||||
friction: 0,
|
||||
restitution: .8
|
||||
// Don't pass radius - let Babylon compute from scaled mesh bounds
|
||||
}, DefaultScene.MainScene);
|
||||
const body = agg.body;
|
||||
body.setAngularDamping(0);
|
||||
|
||||
|
||||
|
||||
// Only apply orbit constraint if enabled for this level and orbit center exists
|
||||
if (useOrbitConstraint && this._orbitCenter) {
|
||||
|
||||
@ -36,11 +36,10 @@ export interface LevelRegistryEntry {
|
||||
}
|
||||
|
||||
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
|
||||
* Always fetches fresh from network - no caching
|
||||
*/
|
||||
export class LevelRegistry {
|
||||
private static instance: LevelRegistry | null = null;
|
||||
@ -63,121 +62,49 @@ export class LevelRegistry {
|
||||
* Initialize the registry by loading directory and levels
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
console.log('[LevelRegistry] initialize() called, initialized =', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
console.log('[LevelRegistry] Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[LevelRegistry] Loading directory manifest...');
|
||||
// Load directory manifest
|
||||
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();
|
||||
console.log('[LevelRegistry] Custom levels loaded:', this.customLevels.size);
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[LevelRegistry] Initialization complete!');
|
||||
console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels');
|
||||
} catch (error) {
|
||||
console.error('[LevelRegistry] Failed to initialize level registry:', error);
|
||||
console.error('[LevelRegistry] Failed to initialize:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the directory.json manifest
|
||||
* Check if running in development mode (for cache-busting HTTP requests)
|
||||
*/
|
||||
private isDevMode(): boolean {
|
||||
return window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.includes('dev.') ||
|
||||
window.location.port !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the directory.json manifest (always fresh from network)
|
||||
*/
|
||||
private async loadDirectory(): Promise<void> {
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
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.port !== '';
|
||||
const cacheBuster = isDev ? `?v=${Date.now()}` : '';
|
||||
console.log('[LevelRegistry] Cache busting:', isDev ? 'ENABLED (dev mode)' : 'DISABLED (production)');
|
||||
|
||||
const fetchStartTime = Date.now();
|
||||
// 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 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 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}`);
|
||||
}
|
||||
|
||||
console.log('[LevelRegistry] About to parse response.json()');
|
||||
const parseStartTime = Date.now();
|
||||
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);
|
||||
this.directoryManifest = await response.json();
|
||||
console.log('[LevelRegistry] Loaded directory with', this.directoryManifest?.levels?.length || 0, 'levels');
|
||||
|
||||
// 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();
|
||||
console.log('[LevelRegistry] Default level entries populated successfully');
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
console.log('[LevelRegistry] loadDirectory() COMPLETED at', Date.now());
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
} catch (error) {
|
||||
console.error('[LevelRegistry] !!!!! EXCEPTION in loadDirectory() !!!!!');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@ -187,27 +114,12 @@ export class LevelRegistry {
|
||||
*/
|
||||
private populateDefaultLevelEntries(): void {
|
||||
if (!this.directoryManifest) {
|
||||
console.error('[LevelRegistry] ❌ Cannot populate - directoryManifest is null');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
console.log('[LevelRegistry] Populating default level entries...');
|
||||
console.log('[LevelRegistry] Directory manifest levels:', this.directoryManifest.levels.length);
|
||||
|
||||
this.defaultLevels.clear();
|
||||
|
||||
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, {
|
||||
directoryEntry: entry,
|
||||
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] ======================================');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -241,7 +151,7 @@ export class LevelRegistry {
|
||||
name: config.metadata?.description || id,
|
||||
description: config.metadata?.description || '',
|
||||
version: config.version || '1.0',
|
||||
levelPath: '', // Not applicable for custom
|
||||
levelPath: '',
|
||||
difficulty: config.difficulty,
|
||||
missionBrief: [],
|
||||
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> {
|
||||
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);
|
||||
if (!entry || entry.config) {
|
||||
console.log('[LevelRegistry] Early return - entry:', !!entry, ', config loaded:', !!entry?.config);
|
||||
return; // Already loaded or doesn't exist
|
||||
return;
|
||||
}
|
||||
|
||||
const levelPath = `/levels/${entry.directoryEntry.levelPath}`;
|
||||
|
||||
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;
|
||||
}
|
||||
console.log('[LevelRegistry] Not in cache, fetching from network');
|
||||
|
||||
// Fetch from network with cache-busting in dev mode
|
||||
const cacheBuster = isDev ? `?v=${Date.now()}` : '';
|
||||
console.log('[LevelRegistry] About to fetch level JSON - Timestamp:', Date.now());
|
||||
console.log('[LevelRegistry] Cache busting:', isDev ? 'ENABLED' : 'DISABLED');
|
||||
const fetchStartTime = Date.now();
|
||||
// Add cache-busting in dev mode
|
||||
const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : '';
|
||||
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) {
|
||||
console.error('[LevelRegistry] Fetch failed with status:', response.status);
|
||||
throw new Error(`Failed to fetch level: ${response.status}`);
|
||||
}
|
||||
|
||||
console.log('[LevelRegistry] Parsing level 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.config = await response.json();
|
||||
entry.loadedAt = new Date();
|
||||
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
console.log('[LevelRegistry] loadDefaultLevel() COMPLETED for:', levelId);
|
||||
console.log('[LevelRegistry] ======================================');
|
||||
console.log('[LevelRegistry] Loaded level:', levelId);
|
||||
} catch (error) {
|
||||
console.error('[LevelRegistry] !!!!! EXCEPTION in loadDefaultLevel() !!!!!');
|
||||
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);
|
||||
console.error(`[LevelRegistry] Failed to load level ${levelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -370,12 +219,10 @@ export class LevelRegistry {
|
||||
public getAllLevels(): Map<string, LevelRegistryEntry> {
|
||||
const all = new Map<string, LevelRegistryEntry>();
|
||||
|
||||
// Add defaults
|
||||
for (const [id, entry] of this.defaultLevels) {
|
||||
all.set(id, entry);
|
||||
}
|
||||
|
||||
// Add customs
|
||||
for (const [id, entry] of this.customLevels) {
|
||||
all.set(id, entry);
|
||||
}
|
||||
@ -401,7 +248,6 @@ export class LevelRegistry {
|
||||
* Save a custom level
|
||||
*/
|
||||
public saveCustomLevel(levelId: string, config: LevelConfig): void {
|
||||
// Ensure metadata exists
|
||||
if (!config.metadata) {
|
||||
config.metadata = {
|
||||
author: 'Player',
|
||||
@ -409,12 +255,10 @@ export class LevelRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
// Remove 'default' type if present
|
||||
if (config.metadata.type === 'default') {
|
||||
delete config.metadata.type;
|
||||
}
|
||||
|
||||
// Add/update in memory
|
||||
this.customLevels.set(levelId, {
|
||||
directoryEntry: {
|
||||
id: levelId,
|
||||
@ -431,7 +275,6 @@ export class LevelRegistry {
|
||||
loadedAt: new Date()
|
||||
});
|
||||
|
||||
// Persist to localStorage
|
||||
this.saveCustomLevelsToStorage();
|
||||
}
|
||||
|
||||
@ -455,10 +298,8 @@ export class LevelRegistry {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deep clone the config
|
||||
const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config));
|
||||
|
||||
// Update metadata
|
||||
clonedConfig.metadata = {
|
||||
...clonedConfig.metadata,
|
||||
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> {
|
||||
if (!('caches' in window)) {
|
||||
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
|
||||
public async refreshDefaultLevels(): Promise<void> {
|
||||
// Clear in-memory configs
|
||||
for (const entry of this.defaultLevels.values()) {
|
||||
entry.config = null;
|
||||
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
|
||||
await this.loadDirectory();
|
||||
@ -608,36 +391,17 @@ export class LevelRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches and force reload from network
|
||||
* Useful for development or when data needs to be refreshed
|
||||
* Reset registry state (for testing or force reload)
|
||||
*/
|
||||
public async clearAllCaches(): Promise<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
|
||||
public reset(): void {
|
||||
for (const entry of this.defaultLevels.values()) {
|
||||
entry.config = null;
|
||||
entry.loadedAt = undefined;
|
||||
}
|
||||
console.log('[LevelRegistry] Cleared loaded configs');
|
||||
|
||||
// Reset initialization flag to force reload
|
||||
this.initialized = false;
|
||||
this.directoryManifest = null;
|
||||
|
||||
console.log('[LevelRegistry] All caches cleared. Call initialize() to reload.');
|
||||
console.log('[LevelRegistry] Reset complete. Call initialize() to reload.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
138
src/main.ts
138
src/main.ts
@ -23,8 +23,6 @@ import Level from "./levels/level";
|
||||
import setLoadingMessage from "./utils/setLoadingMessage";
|
||||
import {RockFactory} from "./environment/asteroids/rockFactory";
|
||||
import {ControllerDebug} from "./utils/controllerDebug";
|
||||
import {router, showView} from "./core/router";
|
||||
import {populateLevelSelector} from "./levels/ui/levelSelector";
|
||||
import {LevelConfig} from "./levels/config/levelConfig";
|
||||
import {LegacyMigration} from "./levels/migration/legacyMigration";
|
||||
import {LevelRegistry} from "./levels/storage/levelRegistry";
|
||||
@ -747,128 +745,7 @@ export class Main {
|
||||
}
|
||||
}
|
||||
|
||||
// Setup router
|
||||
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
|
||||
// Initialize registry and mount Svelte app
|
||||
async function initializeApp() {
|
||||
console.log('[Main] ========================================');
|
||||
console.log('[Main] initializeApp() STARTED at', new Date().toISOString());
|
||||
@ -889,8 +766,6 @@ async function initializeApp() {
|
||||
await LevelRegistry.getInstance().initialize();
|
||||
console.log('[Main] LevelRegistry.initialize() completed successfully [AFTER MIGRATION]');
|
||||
debugLog('[Main] LevelRegistry initialized after migration');
|
||||
// NOTE: Old router disabled - now using svelte-routing
|
||||
// router.start();
|
||||
|
||||
// Mount Svelte app
|
||||
console.log('[Main] Mounting Svelte app [AFTER MIGRATION]');
|
||||
@ -917,8 +792,6 @@ async function initializeApp() {
|
||||
resolve();
|
||||
} catch (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();
|
||||
}
|
||||
});
|
||||
@ -941,19 +814,12 @@ async function initializeApp() {
|
||||
if (isDev) {
|
||||
(window as any).__levelRegistry = LevelRegistry.getInstance();
|
||||
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) {
|
||||
console.error('[Main] !!!!! EXCEPTION in LevelRegistry initialization !!!!!');
|
||||
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
||||
console.error('[Main] Error stack:', error?.stack);
|
||||
// NOTE: Old router disabled - now using svelte-routing
|
||||
// router.start(); // Start anyway to show error state
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -104,7 +104,8 @@ export class WeaponSystem {
|
||||
ammoAggregate.body.setCollisionCallbackEnabled(true);
|
||||
|
||||
// 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
|
||||
if (this._shipStatus) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user