Implement hybrid level storage system with JSON-based defaults and configurable orbit constraints
All checks were successful
Build / build (push) Successful in 1m34s

Major changes:
- Add LevelRegistry for managing default (JSON) and custom (localStorage) levels
- Default levels now load from /public/levels/*.json files
- Add 6 default level JSON files (rookie-training through final-challenge)
- Implement version-based automatic cache invalidation
- Add LevelVersionManager for tracking level updates
- Add LevelStatsManager for performance tracking (completion rate, best time, etc.)
- Add legacy migration tool for existing localStorage data
- Update level selector UI with stats display and version badges
- Add configurable orbit constraints per level (useOrbitConstraints flag)
- Hide copy button in level selector UI (TODO: re-enable later)
- Add extensive debug logging for velocity troubleshooting
- Add cloud sync infrastructure interfaces (future-ready)

Technical improvements:
- Hybrid storage: immutable defaults from JSON, editable custom levels in localStorage
- Automatic cache refresh when directory.json version changes
- Cache API for offline support
- Fresh start migration approach with export option
- Level loading now initializes before router starts

Physics configuration:
- Add useOrbitConstraints flag to LevelConfig
- Rookietraining.json uses constraints (velocities will create orbital motion)
- Debug logging added to verify velocity application

🤖 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-11 18:40:01 -06:00
parent 500830779d
commit 244a25fff5
22 changed files with 6280 additions and 121 deletions

View File

@ -0,0 +1,441 @@
{
"version": "1.0",
"difficulty": "captain",
"timestamp": "2025-11-11T23:44:24.810Z",
"metadata": {
"author": "System",
"description": "Patrol a dangerous sector with heavy asteroid activity. Watch your fuel!",
"estimatedTime": "8-12 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": [
166.3103777512652,
225.37995981448225,
-23.260546252581893
],
"scale": 15.57675246095847,
"linearVelocity": [
-15.247760404104447,
-20.571728072500164,
2.1325863179651163
],
"angularVelocity": [
0.3175968011295134,
0.41914092045057316,
0.3490716681663488
]
},
{
"id": "asteroid-1",
"position": [
-249.03794410207183,
-44.17921063467387,
62.65614175181777
],
"scale": 17.559290714405755,
"linearVelocity": [
28.21843468423197,
5.1192464223731795,
-7.099553644182771
],
"angularVelocity": [
-0.7119913444012966,
0.7778624030643284,
0.692030459502786
]
},
{
"id": "asteroid-2",
"position": [
-75.13363399135183,
316.85667650092637,
-310.96487929135486
],
"scale": 31.622342902375273,
"linearVelocity": [
4.725067636041931,
-19.86386230079167,
19.556222812999994
],
"angularVelocity": [
0.5519570317343065,
0.6149078850079825,
-0.891202682633375
]
},
{
"id": "asteroid-3",
"position": [
37.69090411493977,
258.7939790070069,
-296.8568452906189
],
"scale": 26.5394417569339,
"linearVelocity": [
-2.5867033769995693,
-17.692240919295507,
20.373101209167153
],
"angularVelocity": [
-0.7785494324348137,
-0.20192027466159823,
-0.9607478385112791
]
},
{
"id": "asteroid-4",
"position": [
-221.43907625779156,
174.13268502069522,
-28.083248212763042
],
"scale": 25.9507959425748,
"linearVelocity": [
24.966800076249847,
-19.5203538897708,
3.166328434298123
],
"angularVelocity": [
0.12619252306439144,
-0.3248913262817572,
0.81227377216443
]
},
{
"id": "asteroid-5",
"position": [
-259.9477876700893,
-254.62227443891425,
11.496494773295924
],
"scale": 22.751714697755023,
"linearVelocity": [
12.927816475558101,
12.712698502381889,
-0.5717477954842545
],
"angularVelocity": [
-0.35481099529697646,
-0.6316024099485169,
-0.8870507592544725
]
},
{
"id": "asteroid-6",
"position": [
161.9658398928775,
-27.636039784069954,
264.68815118855474
],
"scale": 31.487052543025985,
"linearVelocity": [
-14.659086384593829,
2.5917698521181003,
-23.956202590729646
],
"angularVelocity": [
0.3478963338932801,
0.02204602907153763,
0.10920873627447891
]
},
{
"id": "asteroid-7",
"position": [
-284.20649039890117,
-272.4658003612411,
-164.57014594288472
],
"scale": 38.73582873730346,
"linearVelocity": [
22.435184300258108,
21.58731710282488,
12.99112328281743
],
"angularVelocity": [
0.06533995393293157,
-0.8892051337798605,
-0.3186200100290164
]
},
{
"id": "asteroid-8",
"position": [
-161.48900437690028,
-244.51884642800135,
-63.50169475507713
],
"scale": 9.793580529897,
"linearVelocity": [
19.05949281156754,
28.97698643108488,
7.494690424135613
],
"angularVelocity": [
0.026878678094339747,
0.6390878354060479,
-0.4576976077246311
]
},
{
"id": "asteroid-9",
"position": [
-179.37511378444728,
275.18039672598127,
127.15174115989014
],
"scale": 12.201546220635752,
"linearVelocity": [
11.535052657633953,
-17.631683945442504,
-8.17673086775957
],
"angularVelocity": [
-0.9276174339399885,
-0.7940475172876842,
-0.519891218823211
]
},
{
"id": "asteroid-10",
"position": [
-161.85346730070253,
-351.9583610917342,
-211.00495094996367
],
"scale": 28.520265023931636,
"linearVelocity": [
10.316203045109077,
22.496831116545703,
13.449016285075643
],
"angularVelocity": [
-0.12498943826987396,
0.19710933568597477,
0.6942780482525945
]
},
{
"id": "asteroid-11",
"position": [
238.15124667512623,
-336.0404556200413,
-137.62425812333427
],
"scale": 36.57155193341867,
"linearVelocity": [
-13.074585061842177,
18.503636524303484,
7.555618937662253
],
"angularVelocity": [
0.8666046519504111,
0.17700461662165567,
0.7935034314479288
]
},
{
"id": "asteroid-12",
"position": [
196.63832555316407,
-52.5552884713219,
360.704951820807
],
"scale": 37.89225310776497,
"linearVelocity": [
-14.659857963374991,
3.9926749781282407,
-26.89141776153598
],
"angularVelocity": [
-0.9987270161689143,
-0.1748693297130952,
0.8516444011722366
]
},
{
"id": "asteroid-13",
"position": [
-83.92561949303223,
-253.3043114432265,
-1.616443680779213
],
"scale": 32.821087460334866,
"linearVelocity": [
10.323698427408104,
31.281997511469118,
0.19883889074705718
],
"angularVelocity": [
0.632854416028195,
0.029291754806906045,
0.40775129810052313
]
},
{
"id": "asteroid-14",
"position": [
84.70991492450895,
-283.3171370603572,
-5.226290049567284
],
"scale": 24.384062404096834,
"linearVelocity": [
-9.840583709194428,
33.02856093875565,
0.6071278051350835
],
"angularVelocity": [
0.9698449632732298,
0.15375578935014778,
-0.3922269025131073
]
},
{
"id": "asteroid-15",
"position": [
-260.77067504513934,
201.3472149603708,
140.6455294494111
],
"scale": 7.723773988992447,
"linearVelocity": [
20.882499051701014,
-16.0437922158873,
-11.262885041981486
],
"angularVelocity": [
0.3799403198861504,
-0.4285539548267927,
0.4547601376586794
]
},
{
"id": "asteroid-16",
"position": [
88.29502639093482,
39.73694881770429,
-429.03785854398495
],
"scale": 9.258837908114785,
"linearVelocity": [
-4.590007145936655,
-2.0137359843781644,
22.303485452000903
],
"angularVelocity": [
-0.6590987578049781,
0.866939902569952,
-0.3034435245285141
]
},
{
"id": "asteroid-17",
"position": [
211.63076567269354,
-86.40698323508542,
-182.38745904183477
],
"scale": 10.822556168662619,
"linearVelocity": [
-15.182311988861233,
6.270544290959188,
13.084408106860023
],
"angularVelocity": [
-0.916175355667403,
0.16026156077498221,
0.7296019227737922
]
},
{
"id": "asteroid-18",
"position": [
109.29137029559803,
-225.1245616977874,
37.07836905330716
],
"scale": 6.601394757786682,
"linearVelocity": [
-9.672107524407286,
20.01165388205001,
-3.2813750193018394
],
"angularVelocity": [
-0.6708090024867568,
-0.17052400155852965,
-0.7102991593042125
]
},
{
"id": "asteroid-19",
"position": [
102.93248991332028,
101.20067906285311,
-277.6954524087843
],
"scale": 5.224189195445002,
"linearVelocity": [
-7.252212644577497,
-7.059740149167784,
19.565313857622314
],
"angularVelocity": [
-0.43523205758445815,
-0.7478682237907837,
-0.6354712781926715
]
}
],
"difficultyConfig": {
"rockCount": 20,
"forceMultiplier": 1.2,
"rockSizeMin": 5,
"rockSizeMax": 40,
"distanceMin": 230,
"distanceMax": 450
}
}

View File

@ -1,30 +1,120 @@
{"missions": [ {
{ "version": "1.0.5",
"id": 1, "levels": [
"name": "Recruit", {
"Description": "Simple level to get the hang of things", "id": "rookie-training",
"missionbrief": [ "name": "Rookie Training",
"Destroy the asteroids", "description": "Simple level to get the hang of things",
"return to base after they're destroyed to complete the mission", "version": "1.0",
"return to base if you need more fuel, ammo, or hull repairs", "levelPath": "rookie-training.json",
"don't get too far from base, if you run out of fuel, you'll be stranded", "difficulty": "recruit",
"don't run into things, it damages your hull" "estimatedTime": "3-5 minutes",
], "missionBrief": [
"leveldata": "/levels/1.json", "Destroy the asteroids",
"defaultlocked": false "Return to base after they're destroyed to complete the mission",
} , "Return to base if you need more fuel, ammo, or hull repairs",
{ "Don't get too far from base, if you run out of fuel, you'll be stranded",
"id": 2, "Don't run into things, it damages your hull"
"name": "Fuel Management", ],
"Description": "Don't run out of fuel", "unlockRequirements": [],
"missionbrief": [ "tags": ["tutorial", "easy"],
"Astroids are further away and there a more of them", "defaultLocked": false
"you'll need to keep an eye on your fuel levels", },
"return to base after you've destroyed them all" {
], "id": "rescue-mission",
"leveldata": null, "name": "Rescue Mission",
"defaultlocked": true "description": "Rescue operation in moderate asteroid field",
} "version": "1.0",
] "levelPath": "rescue-mission.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"
],
"unlockRequirements": ["rookie-training"],
"tags": ["medium"],
"defaultLocked": true
},
{
"id": "deep-space-patrol",
"name": "Deep Space Patrol",
"description": "Extended patrol mission in dangerous territory",
"version": "1.0",
"levelPath": "deep-space-patrol.json",
"difficulty": "captain",
"estimatedTime": "8-12 minutes",
"missionBrief": [
"Large asteroid field requiring careful navigation",
"Fuel management is critical at this distance",
"Return to base for resupply as needed",
"Asteroids are faster and more dangerous",
"Plan your route carefully"
],
"unlockRequirements": ["rescue-mission"],
"tags": ["hard"],
"defaultLocked": true
},
{
"id": "enemy-territory",
"name": "Enemy Territory",
"description": "Hazardous mission in hostile space",
"version": "1.0",
"levelPath": "enemy-territory.json",
"difficulty": "commander",
"estimatedTime": "10-15 minutes",
"missionBrief": [
"Heavily defended asteroid field",
"Maximum asteroid count and speed",
"Expert fuel and ammunition management required",
"Multiple base trips may be necessary",
"Test your skills to the limit"
],
"unlockRequirements": ["deep-space-patrol"],
"tags": ["very-hard", "combat"],
"defaultLocked": true
},
{
"id": "the-gauntlet",
"name": "The Gauntlet",
"description": "Survive the ultimate asteroid gauntlet",
"version": "1.0",
"levelPath": "the-gauntlet.json",
"difficulty": "commander",
"estimatedTime": "12-18 minutes",
"missionBrief": [
"Dense asteroid field with extreme hazards",
"High-speed rocks from all directions",
"This is a test of endurance and skill",
"Only the best pilots succeed",
"May the stars guide you"
],
"unlockRequirements": ["enemy-territory"],
"tags": ["very-hard", "endurance"],
"defaultLocked": true
},
{
"id": "final-challenge",
"name": "Final Challenge",
"description": "The ultimate test for master pilots",
"version": "1.0",
"levelPath": "final-challenge.json",
"difficulty": "commander",
"estimatedTime": "15-20 minutes",
"missionBrief": [
"The pinnacle of difficulty",
"Everything you've learned will be tested",
"Maximum asteroid count and velocity",
"Precision flying and resource management essential",
"Complete this to prove your mastery",
"Good luck, Commander"
],
"unlockRequirements": ["the-gauntlet"],
"tags": ["extreme", "final-boss"],
"defaultLocked": true
}
]
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,251 @@
{
"version": "1.0",
"difficulty": "pilot",
"timestamp": "2025-11-11T23:44:24.810Z",
"metadata": {
"author": "System",
"description": "Clear a path through moderate asteroid density to reach the stranded station.",
"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": 15.53109319217301,
"linearVelocity": [
-17.167175139332553,
8.177863609194048,
-0.39450965492725215
],
"angularVelocity": [
-0.834980024785148,
0.9648009938830251,
0.8185653748494373
]
},
{
"id": "asteroid-1",
"position": [
145.90971366777896,
42.273817290099984,
-244.80503221456152
],
"scale": 17.57678371034564,
"linearVelocity": [
-14.737555578618144,
-4.168846343154079,
24.72643991613985
],
"angularVelocity": [
0.575649251710729,
-0.8551046445434349,
-0.9477761112717422
]
},
{
"id": "asteroid-2",
"position": [
195.05992969157123,
-111.0584087077698,
-22.40662780090249
],
"scale": 14.234090261353138,
"linearVelocity": [
-16.81570103491442,
9.660316715266058,
1.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": 14.980803819380306,
"linearVelocity": [
22.372081486064396,
-4.723605553550473,
-7.2761676675924445
],
"angularVelocity": [
-0.22039903827783025,
0.03062354927084643,
0.3628209366655213
]
},
{
"id": "asteroid-5",
"position": [
-257.7249224576784,
-112.97325792551102,
-92.25372143357285
],
"scale": 17.10484995348801,
"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": 18.93457175982751,
"linearVelocity": [
-1.2776861733199087,
-5.412726361379603,
-20.580688530433683
],
"angularVelocity": [
-0.5793176374486806,
0.8207961833131412,
-0.034658037798875885
]
},
{
"id": "asteroid-8",
"position": [
129.11110725214024,
91.10691458736655,
205.0668479159754
],
"scale": 15.43421226033438,
"linearVelocity": [
-10.330594112594069,
-7.209743461671342,
-16.4080567261488
],
"angularVelocity": [
-0.572098306083443,
0.6581860817605101,
-0.7141435682550208
]
},
{
"id": "asteroid-9",
"position": [
-30.953057070289603,
225.21952155696817,
139.05608152400566
],
"scale": 14.151176153817078,
"linearVelocity": [
1.9861965590557589,
-14.387724003424648,
-8.922954201633985
],
"angularVelocity": [
0.7016416714654072,
-0.8069811132136699,
-0.16093262088047533
]
}
],
"difficultyConfig": {
"rockCount": 10,
"forceMultiplier": 1,
"rockSizeMin": 8,
"rockSizeMax": 20,
"distanceMin": 225,
"distanceMax": 300
}
}

View File

@ -0,0 +1,157 @@
{
"version": "1.0",
"difficulty": "recruit",
"timestamp": "2025-11-11T23:44:24.807Z",
"metadata": {
"author": "System",
"description": "Learn the basics of ship control and asteroid destruction in a calm sector of space.",
"estimatedTime": "3-5 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": [
-58.36428561508043,
-34.06637347343719,
-338.05090628315398
],
"scale": 10.625768191199857,
"linearVelocity": [
40.109444660495561,
20.469032555006988,
50.76122675780117
],
"angularVelocity": [
-0.9227568265038757,
0.8864368307279991,
0.2263811569998082
]
},
{
"id": "asteroid-1",
"position": [
-190.9219320499405,
-113.5286869174331,
109.66679494883233
],
"scale": 13.813320127774826,
"linearVelocity": [
50.192029331119596,
6.113911187593468,
-5.854367692424185
],
"angularVelocity": [
-0.13959509905525813,
-0.43731874553360095,
-0.7521225869488948
]
},
{
"id": "asteroid-2",
"position": [
-83.56705788144077,
64.21712690769468,
308.30902558008037
],
"scale": 11.275762178690638,
"linearVelocity": [
5.1248287379998665,
-3.8768500043399237,
-52.774747702523037
],
"angularVelocity": [
0.2651717231493369,
-0.7099071240513899,
0.1663263008032705
]
},
{
"id": "asteroid-3",
"position": [
217.1757715338133,
-61.659461955067925,
-44.39701192104106
],
"scale": 10.559754454640935,
"linearVelocity": [
-13.51279993073768,
3.8987073336340097,
2.7624073135533793
],
"angularVelocity": [
-0.11401525507789279,
-0.8495070152885558,
-0.14027260599293268
]
},
{
"id": "asteroid-4",
"position": [
-146.69138484095544,
-213.7144044815136,
-152.58815494515483
],
"scale": 14.914115364458254,
"linearVelocity": [
30.82941775132167,
-90.089462061108069,
12.30494231618762
],
"angularVelocity": [
-0.5051583421856027,
-0.2763521676136351,
-0.9366828390177142
]
}
],
"difficultyConfig": {
"rockCount": 5,
"forceMultiplier": 0.8,
"rockSizeMin": 10,
"rockSizeMax": 15,
"distanceMin": 220,
"distanceMax": 250
},
"useOrbitConstraints": true
}

File diff suppressed because it is too large Load Diff

View File

@ -160,6 +160,7 @@ body {
} }
.editor-link { .editor-link {
display: none;
background: rgba(76, 175, 80, 0.8); background: rgba(76, 175, 80, 0.8);
} }
@ -169,6 +170,7 @@ body {
} }
.settings-link { .settings-link {
display: none;
background: rgba(33, 150, 243, 0.8); background: rgba(33, 150, 243, 0.8);
} }
@ -512,6 +514,11 @@ body {
outline-offset: 2px; outline-offset: 2px;
} }
/* TODO: Re-enable copy button functionality for copying default levels to custom levels */
.level-button-secondary {
display: none !important;
}
/* Test Level Button */ /* Test Level Button */
.test-level-button { .test-level-button {
background: var(--gradient-danger); background: var(--gradient-danger);

View File

@ -0,0 +1,215 @@
#!/usr/bin/env node
/**
* Script to generate default level JSON files
* Run with: node scripts/generateDefaultLevels.js
*/
const fs = require('fs');
const path = require('path');
// Helper function to generate random asteroid data
function generateAsteroid(id, config, shipPos = [0, 1, 0]) {
const { distanceMin, distanceMax, rockSizeMin, rockSizeMax, forceMultiplier } = config;
// Random spherical distribution
const theta = Math.random() * Math.PI * 2; // Azimuth angle
const phi = Math.acos(2 * Math.random() - 1); // Polar angle
const distance = distanceMin + Math.random() * (distanceMax - distanceMin);
const position = [
shipPos[0] + distance * Math.sin(phi) * Math.cos(theta),
shipPos[1] + distance * Math.sin(phi) * Math.sin(theta),
shipPos[2] + distance * Math.cos(phi)
];
const scale = rockSizeMin + Math.random() * (rockSizeMax - rockSizeMin);
// Random velocity toward ship
const speedMin = 15 * forceMultiplier;
const speedMax = 30 * forceMultiplier;
const speed = speedMin + Math.random() * (speedMax - speedMin);
const dirToShip = [
shipPos[0] - position[0],
shipPos[1] - position[1],
shipPos[2] - position[2]
];
const length = Math.sqrt(dirToShip[0]**2 + dirToShip[1]**2 + dirToShip[2]**2);
const normalized = dirToShip.map(v => v / length);
const linearVelocity = normalized.map(v => v * speed);
const angularVelocity = [
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
];
return {
id: `asteroid-${id}`,
position,
scale,
linearVelocity,
angularVelocity
};
}
// Level configurations matching LevelGenerator difficulty configs
const levels = [
{
filename: 'rookie-training.json',
difficulty: 'recruit',
difficultyConfig: {
rockCount: 5,
forceMultiplier: 0.8,
rockSizeMin: 10,
rockSizeMax: 15,
distanceMin: 220,
distanceMax: 250
},
metadata: {
author: 'System',
description: 'Learn the basics of ship control and asteroid destruction in a calm sector of space.',
estimatedTime: '3-5 minutes',
type: 'default'
}
},
{
filename: 'rescue-mission.json',
difficulty: 'pilot',
difficultyConfig: {
rockCount: 10,
forceMultiplier: 1.0,
rockSizeMin: 8,
rockSizeMax: 20,
distanceMin: 225,
distanceMax: 300
},
metadata: {
author: 'System',
description: 'Clear a path through moderate asteroid density to reach the stranded station.',
estimatedTime: '5-8 minutes',
type: 'default'
}
},
{
filename: 'deep-space-patrol.json',
difficulty: 'captain',
difficultyConfig: {
rockCount: 20,
forceMultiplier: 1.2,
rockSizeMin: 5,
rockSizeMax: 40,
distanceMin: 230,
distanceMax: 450
},
metadata: {
author: 'System',
description: 'Patrol a dangerous sector with heavy asteroid activity. Watch your fuel!',
estimatedTime: '8-12 minutes',
type: 'default'
}
},
{
filename: 'enemy-territory.json',
difficulty: 'commander',
difficultyConfig: {
rockCount: 50,
forceMultiplier: 1.3,
rockSizeMin: 2,
rockSizeMax: 8,
distanceMin: 90,
distanceMax: 280
},
metadata: {
author: 'System',
description: 'Navigate through hostile space with high-speed asteroids and limited resources.',
estimatedTime: '10-15 minutes',
type: 'default'
}
},
{
filename: 'the-gauntlet.json',
difficulty: 'commander',
difficultyConfig: {
rockCount: 50,
forceMultiplier: 1.3,
rockSizeMin: 2,
rockSizeMax: 8,
distanceMin: 90,
distanceMax: 280
},
metadata: {
author: 'System',
description: 'Face maximum asteroid density in this ultimate test of piloting skill.',
estimatedTime: '12-18 minutes',
type: 'default'
}
},
{
filename: 'final-challenge.json',
difficulty: 'commander',
difficultyConfig: {
rockCount: 50,
forceMultiplier: 1.3,
rockSizeMin: 2,
rockSizeMax: 8,
distanceMin: 90,
distanceMax: 280
},
metadata: {
author: 'System',
description: 'The ultimate challenge - survive the most chaotic asteroid field in known space.',
estimatedTime: '15-20 minutes',
type: 'default'
}
}
];
// Output directory
const outputDir = path.join(__dirname, '../public/levels');
// Ensure directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Generate each level
for (const level of levels) {
const asteroids = [];
for (let i = 0; i < level.difficultyConfig.rockCount; i++) {
asteroids.push(generateAsteroid(i, level.difficultyConfig));
}
const levelConfig = {
version: '1.0',
difficulty: level.difficulty,
timestamp: new Date().toISOString(),
metadata: level.metadata,
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,
difficultyConfig: level.difficultyConfig
};
const outputPath = path.join(outputDir, level.filename);
fs.writeFileSync(outputPath, JSON.stringify(levelConfig, null, 2));
console.log(`Generated: ${level.filename} (${level.difficultyConfig.rockCount} asteroids)`);
}
console.log(`\nSuccessfully generated ${levels.length} default level files!`);

View File

@ -77,15 +77,20 @@ export const router = new Router();
* Helper to show/hide views * Helper to show/hide views
*/ */
export function showView(viewId: string): void { export function showView(viewId: string): void {
console.log('[Router] showView() called with viewId:', viewId);
// Hide all views // Hide all views
const views = document.querySelectorAll('[data-view]'); const views = document.querySelectorAll('[data-view]');
console.log('[Router] Found views:', views.length);
views.forEach(view => { views.forEach(view => {
(view as HTMLElement).style.display = 'none'; (view as HTMLElement).style.display = 'none';
}); });
// Show requested view // Show requested view
const targetView = document.querySelector(`[data-view="${viewId}"]`); const targetView = document.querySelector(`[data-view="${viewId}"]`);
console.log('[Router] Target view found:', !!targetView);
if (targetView) { if (targetView) {
(targetView as HTMLElement).style.display = 'block'; (targetView as HTMLElement).style.display = 'block';
console.log('[Router] View display set to block');
} }
} }

View File

@ -77,7 +77,8 @@ export class RockFactory {
} }
public static async createRock(i: number, position: Vector3, scale: number, public static async createRock(i: number, position: Vector3, scale: number,
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>): Promise<Rock> { linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
useOrbitConstraint: boolean = true): Promise<Rock> {
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh); const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
debugLog(rock.id); debugLog(rock.id);
@ -100,13 +101,32 @@ export class RockFactory {
// 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;
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
body.addConstraint(this._orbitCenter.body, constraint); // Only apply orbit constraint if enabled for this level
if (useOrbitConstraint) {
debugLog(`[RockFactory] Applying orbit constraint for ${rock.name}`);
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
body.addConstraint(this._orbitCenter.body, constraint);
} else {
debugLog(`[RockFactory] Orbit constraint disabled for ${rock.name} - asteroid will move freely`);
}
body.setLinearDamping(0) body.setLinearDamping(0)
body.setMotionType(PhysicsMotionType.DYNAMIC); body.setMotionType(PhysicsMotionType.DYNAMIC);
body.setCollisionCallbackEnabled(true); body.setCollisionCallbackEnabled(true);
debugLog(`[RockFactory] Setting velocities for ${rock.name}:`);
debugLog(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`);
debugLog(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`);
body.setLinearVelocity(linearVelocitry); body.setLinearVelocity(linearVelocitry);
body.setAngularVelocity(angularVelocity); body.setAngularVelocity(angularVelocity);
// Verify velocities were set
const setLinear = body.getLinearVelocity();
const setAngular = body.getAngularVelocity();
debugLog(`[RockFactory] Linear velocity after set: ${setLinear.toString()}`);
debugLog(`[RockFactory] Angular velocity after set: ${setAngular.toString()}`);
body.getCollisionObservable().add((eventData) => { body.getCollisionObservable().add((eventData) => {
if (eventData.type == 'COLLISION_STARTED') { if (eventData.type == 'COLLISION_STARTED') {
if ( eventData.collidedAgainst.transformNode.id == 'ammo') { if ( eventData.collidedAgainst.transformNode.id == 'ammo') {

View File

@ -147,6 +147,9 @@ export interface LevelConfig {
// Optional: include original difficulty config for reference // Optional: include original difficulty config for reference
difficultyConfig?: DifficultyConfig; difficultyConfig?: DifficultyConfig;
// Physics configuration
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
// New fields for full scene serialization // New fields for full scene serialization
materials?: MaterialConfig[]; materials?: MaterialConfig[];
sceneHierarchy?: SceneNodeConfig[]; sceneHierarchy?: SceneNodeConfig[];

View File

@ -21,6 +21,7 @@ import { FireProceduralTexture } from "@babylonjs/procedural-textures";
import { createSphereLightmap } from "../../environment/celestial/sphereLightmap"; import { createSphereLightmap } from "../../environment/celestial/sphereLightmap";
import debugLog from '../../core/debug'; import debugLog from '../../core/debug';
import StarBase from "../../environment/stations/starBase"; import StarBase from "../../environment/stations/starBase";
import {LevelRegistry} from "../storage/levelRegistry";
/** /**
* Deserializes a LevelConfig JSON object and creates all entities in the scene * Deserializes a LevelConfig JSON object and creates all entities in the scene
@ -172,6 +173,16 @@ export class LevelDeserializer {
for (let i = 0; i < this.config.asteroids.length; i++) { for (let i = 0; i < this.config.asteroids.length; i++) {
const asteroidConfig = this.config.asteroids[i]; const asteroidConfig = this.config.asteroids[i];
debugLog(`[LevelDeserializer] Creating asteroid ${i} (${asteroidConfig.id}):`);
debugLog(`[LevelDeserializer] Position: [${asteroidConfig.position.join(', ')}]`);
debugLog(`[LevelDeserializer] Scale: ${asteroidConfig.scale}`);
debugLog(`[LevelDeserializer] Linear velocity: [${asteroidConfig.linearVelocity.join(', ')}]`);
debugLog(`[LevelDeserializer] Angular velocity: [${asteroidConfig.angularVelocity.join(', ')}]`);
// Use orbit constraints by default (true if not specified)
const useOrbitConstraints = this.config.useOrbitConstraints !== false;
debugLog(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`);
// Use RockFactory to create the asteroid // Use RockFactory to create the asteroid
const rock = await RockFactory.createRock( const rock = await RockFactory.createRock(
i, i,
@ -179,7 +190,8 @@ export class LevelDeserializer {
asteroidConfig.scale, asteroidConfig.scale,
this.arrayToVector3(asteroidConfig.linearVelocity), this.arrayToVector3(asteroidConfig.linearVelocity),
this.arrayToVector3(asteroidConfig.angularVelocity), this.arrayToVector3(asteroidConfig.angularVelocity),
scoreObservable scoreObservable,
useOrbitConstraints
); );
// Get the actual mesh from the Rock object // Get the actual mesh from the Rock object
@ -246,4 +258,26 @@ export class LevelDeserializer {
reader.readAsText(file); reader.readAsText(file);
}); });
} }
/**
* Static helper to load from Level Registry by ID
* This is the preferred method for loading both default and custom levels
*/
public static async fromRegistry(levelId: string): Promise<LevelDeserializer> {
const registry = LevelRegistry.getInstance();
// Ensure registry is initialized
if (!registry.isInitialized()) {
await registry.initialize();
}
// Get level config from registry (loads if not already loaded)
const config = await registry.getLevel(levelId);
if (!config) {
throw new Error(`Level not found in registry: ${levelId}`);
}
return new LevelDeserializer(config);
}
} }

View File

@ -0,0 +1,330 @@
import {LevelConfig} from "../config/levelConfig";
const LEGACY_STORAGE_KEY = 'space-game-levels';
const ARCHIVE_STORAGE_KEY = 'space-game-levels-archive';
const CUSTOM_LEVELS_KEY = 'space-game-custom-levels';
const MIGRATION_STATUS_KEY = 'space-game-migration-status';
/**
* Migration status information
*/
export interface MigrationStatus {
migrated: boolean;
migratedAt?: Date;
version: string;
customLevelsMigrated: number;
defaultLevelsRemoved: number;
}
/**
* Result of migration operation
*/
export interface MigrationResult {
success: boolean;
customLevelsMigrated: number;
defaultLevelsFound: number;
error?: string;
legacyDataArchived: boolean;
}
/**
* Handles migration from legacy localStorage format to new hybrid system
*/
export class LegacyMigration {
private static readonly MIGRATION_VERSION = '2.0';
/**
* Check if migration is needed
*/
public static needsMigration(): boolean {
// Check if migration was already completed
const status = this.getMigrationStatus();
if (status && status.migrated) {
return false;
}
// Check if legacy data exists
const legacyData = localStorage.getItem(LEGACY_STORAGE_KEY);
return legacyData !== null && legacyData.length > 0;
}
/**
* Get current migration status
*/
public static getMigrationStatus(): MigrationStatus | null {
const stored = localStorage.getItem(MIGRATION_STATUS_KEY);
if (!stored) {
return null;
}
try {
const status: MigrationStatus = JSON.parse(stored);
if (status.migratedAt && typeof status.migratedAt === 'string') {
status.migratedAt = new Date(status.migratedAt);
}
return status;
} catch (error) {
console.error('Failed to parse migration status:', error);
return null;
}
}
/**
* Perform the migration
*/
public static migrate(): MigrationResult {
const result: MigrationResult = {
success: false,
customLevelsMigrated: 0,
defaultLevelsFound: 0,
legacyDataArchived: false
};
try {
// Load legacy data
const legacyData = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!legacyData) {
result.error = 'No legacy data found';
return result;
}
const legacyLevels: [string, LevelConfig][] = JSON.parse(legacyData);
// Separate custom from default levels
const customLevels: [string, LevelConfig][] = [];
for (const [name, config] of legacyLevels) {
if (config.metadata?.type === 'default') {
result.defaultLevelsFound++;
// Skip default levels - they'll be loaded from JSON files now
} else {
customLevels.push([name, config]);
result.customLevelsMigrated++;
}
}
// Save custom levels to new storage location
if (customLevels.length > 0) {
localStorage.setItem(CUSTOM_LEVELS_KEY, JSON.stringify(customLevels));
}
// Archive legacy data (don't delete immediately)
this.archiveLegacyData(legacyData);
result.legacyDataArchived = true;
// Clear legacy storage key
localStorage.removeItem(LEGACY_STORAGE_KEY);
// Record migration status
const status: MigrationStatus = {
migrated: true,
migratedAt: new Date(),
version: this.MIGRATION_VERSION,
customLevelsMigrated: result.customLevelsMigrated,
defaultLevelsRemoved: result.defaultLevelsFound
};
localStorage.setItem(MIGRATION_STATUS_KEY, JSON.stringify(status));
result.success = true;
console.log('Migration completed:', result);
} catch (error) {
result.error = error instanceof Error ? error.message : 'Unknown error';
console.error('Migration failed:', error);
}
return result;
}
/**
* Archive legacy data for potential recovery
*/
private static archiveLegacyData(legacyData: string): void {
const archive = {
data: legacyData,
archivedAt: new Date().toISOString(),
migrationVersion: this.MIGRATION_VERSION
};
localStorage.setItem(ARCHIVE_STORAGE_KEY, JSON.stringify(archive));
}
/**
* Get archived legacy data (for export/recovery)
*/
public static getArchivedData(): string | null {
const stored = localStorage.getItem(ARCHIVE_STORAGE_KEY);
if (!stored) {
return null;
}
try {
const archive = JSON.parse(stored);
return archive.data || null;
} catch (error) {
console.error('Failed to parse archived data:', error);
return null;
}
}
/**
* Export legacy data as JSON file
*/
public static exportLegacyData(): string | null {
const archivedData = this.getArchivedData();
if (!archivedData) {
return null;
}
try {
const levels: [string, LevelConfig][] = JSON.parse(archivedData);
const exportData = {
exportedAt: new Date().toISOString(),
migrationVersion: this.MIGRATION_VERSION,
levels: Object.fromEntries(levels)
};
return JSON.stringify(exportData, null, 2);
} catch (error) {
console.error('Failed to export legacy data:', error);
return null;
}
}
/**
* Download legacy data as JSON file
*/
public static downloadLegacyData(): void {
const jsonString = this.exportLegacyData();
if (!jsonString) {
console.warn('No legacy data to download');
return;
}
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `legacy-levels-backup-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Clear archived data (after user confirms backup)
*/
public static clearArchive(): void {
localStorage.removeItem(ARCHIVE_STORAGE_KEY);
}
/**
* Reset migration status (for testing/debugging)
*/
public static resetMigration(): void {
localStorage.removeItem(MIGRATION_STATUS_KEY);
console.log('Migration status reset');
}
/**
* Full reset - clear all migration data (dangerous!)
*/
public static fullReset(): void {
localStorage.removeItem(LEGACY_STORAGE_KEY);
localStorage.removeItem(ARCHIVE_STORAGE_KEY);
localStorage.removeItem(CUSTOM_LEVELS_KEY);
localStorage.removeItem(MIGRATION_STATUS_KEY);
console.log('Full migration reset completed');
}
/**
* Create and show migration modal UI
*/
public static showMigrationModal(onComplete: (result: MigrationResult) => void): void {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: 'Courier New', monospace;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #1a1a1a;
border: 2px solid #00ff00;
padding: 30px;
max-width: 600px;
color: #00ff00;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
`;
content.innerHTML = `
<h2 style="margin-top: 0; color: #00ff00;">Level System Updated</h2>
<p>The level storage system has been upgraded!</p>
<p><strong>Changes:</strong></p>
<ul>
<li>Default levels now load from game files (always available)</li>
<li>Your custom levels remain in browser storage</li>
<li>Version tracking and update notifications enabled</li>
<li>Level statistics and performance tracking added</li>
</ul>
<p><strong>Your data will be migrated automatically.</strong></p>
<p>A backup of your old level data will be saved.</p>
<div style="margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end;">
<button id="export-backup" style="padding: 10px 20px; background: #004400; color: #00ff00; border: 1px solid #00ff00; cursor: pointer;">
Export Backup
</button>
<button id="migrate-now" style="padding: 10px 20px; background: #006600; color: #00ff00; border: 1px solid #00ff00; cursor: pointer; font-weight: bold;">
Migrate Now
</button>
</div>
<p id="migration-status" style="margin-top: 15px; font-size: 0.9em; color: #ffff00; display: none;"></p>
`;
modal.appendChild(content);
document.body.appendChild(modal);
const exportBtn = content.querySelector('#export-backup') as HTMLButtonElement;
const migrateBtn = content.querySelector('#migrate-now') as HTMLButtonElement;
const statusText = content.querySelector('#migration-status') as HTMLParagraphElement;
exportBtn.addEventListener('click', () => {
this.downloadLegacyData();
statusText.textContent = 'Backup downloaded! You can now proceed with migration.';
statusText.style.display = 'block';
});
migrateBtn.addEventListener('click', () => {
statusText.textContent = 'Migrating...';
statusText.style.display = 'block';
statusText.style.color = '#ffff00';
// Give UI time to update
setTimeout(() => {
const result = this.migrate();
if (result.success) {
statusText.textContent = `Migration complete! ${result.customLevelsMigrated} custom levels migrated.`;
statusText.style.color = '#00ff00';
setTimeout(() => {
document.body.removeChild(modal);
onComplete(result);
}, 2000);
} else {
statusText.textContent = `Migration failed: ${result.error}`;
statusText.style.color = '#ff0000';
}
}, 100);
});
}
}

View File

@ -0,0 +1,381 @@
/**
* Completion record for a single play-through
*/
export interface LevelCompletion {
timestamp: Date;
completionTimeSeconds: number;
score?: number;
survived: boolean; // false if player died/quit
}
/**
* Aggregated statistics for a level
*/
export interface LevelStatistics {
levelId: string;
firstPlayed?: Date;
lastPlayed?: Date;
completions: LevelCompletion[];
totalAttempts: number; // Including incomplete attempts
totalCompletions: number; // Only successful completions
bestTimeSeconds?: number;
averageTimeSeconds?: number;
bestScore?: number;
averageScore?: number;
completionRate: number; // percentage (0-100)
difficultyRating?: number; // 1-5 stars, user-submitted
}
const STATS_STORAGE_KEY = 'space-game-level-stats';
/**
* Manages level performance statistics and ratings
*/
export class LevelStatsManager {
private static instance: LevelStatsManager | null = null;
private statsMap: Map<string, LevelStatistics> = new Map();
private constructor() {
this.loadStats();
}
public static getInstance(): LevelStatsManager {
if (!LevelStatsManager.instance) {
LevelStatsManager.instance = new LevelStatsManager();
}
return LevelStatsManager.instance;
}
/**
* Load stats from localStorage
*/
private loadStats(): void {
const stored = localStorage.getItem(STATS_STORAGE_KEY);
if (!stored) {
return;
}
try {
const statsArray: [string, LevelStatistics][] = JSON.parse(stored);
for (const [id, stats] of statsArray) {
// Parse date strings back to Date objects
if (stats.firstPlayed && typeof stats.firstPlayed === 'string') {
stats.firstPlayed = new Date(stats.firstPlayed);
}
if (stats.lastPlayed && typeof stats.lastPlayed === 'string') {
stats.lastPlayed = new Date(stats.lastPlayed);
}
// Parse completion timestamps
stats.completions = stats.completions.map(c => ({
...c,
timestamp: typeof c.timestamp === 'string' ? new Date(c.timestamp) : c.timestamp
}));
this.statsMap.set(id, stats);
}
} catch (error) {
console.error('Failed to load level stats:', error);
}
}
/**
* Save stats to localStorage
*/
private saveStats(): void {
const statsArray = Array.from(this.statsMap.entries());
localStorage.setItem(STATS_STORAGE_KEY, JSON.stringify(statsArray));
}
/**
* Get statistics for a level
*/
public getStats(levelId: string): LevelStatistics | undefined {
return this.statsMap.get(levelId);
}
/**
* Initialize stats for a level if not exists
*/
private ensureStatsExist(levelId: string): LevelStatistics {
let stats = this.statsMap.get(levelId);
if (!stats) {
stats = {
levelId,
completions: [],
totalAttempts: 0,
totalCompletions: 0,
completionRate: 0
};
this.statsMap.set(levelId, stats);
}
return stats;
}
/**
* Record that a level was started (attempt)
*/
public recordAttempt(levelId: string): void {
const stats = this.ensureStatsExist(levelId);
stats.totalAttempts++;
const now = new Date();
if (!stats.firstPlayed) {
stats.firstPlayed = now;
}
stats.lastPlayed = now;
this.recalculateStats(stats);
this.saveStats();
}
/**
* Record a level completion
*/
public recordCompletion(
levelId: string,
completionTimeSeconds: number,
score?: number,
survived: boolean = true
): void {
const stats = this.ensureStatsExist(levelId);
const completion: LevelCompletion = {
timestamp: new Date(),
completionTimeSeconds,
score,
survived
};
stats.completions.push(completion);
if (survived) {
stats.totalCompletions++;
}
const now = new Date();
if (!stats.firstPlayed) {
stats.firstPlayed = now;
}
stats.lastPlayed = now;
this.recalculateStats(stats);
this.saveStats();
}
/**
* Set difficulty rating for a level (1-5 stars)
*/
public setDifficultyRating(levelId: string, rating: number): void {
if (rating < 1 || rating > 5) {
console.warn('Rating must be between 1 and 5');
return;
}
const stats = this.ensureStatsExist(levelId);
stats.difficultyRating = rating;
this.saveStats();
}
/**
* Recalculate aggregated statistics
*/
private recalculateStats(stats: LevelStatistics): void {
const successfulCompletions = stats.completions.filter(c => c.survived);
// Completion rate
stats.completionRate = stats.totalAttempts > 0
? (stats.totalCompletions / stats.totalAttempts) * 100
: 0;
// Time statistics
if (successfulCompletions.length > 0) {
const times = successfulCompletions.map(c => c.completionTimeSeconds);
stats.bestTimeSeconds = Math.min(...times);
stats.averageTimeSeconds = times.reduce((a, b) => a + b, 0) / times.length;
} else {
stats.bestTimeSeconds = undefined;
stats.averageTimeSeconds = undefined;
}
// Score statistics
const completionsWithScore = successfulCompletions.filter(c => c.score !== undefined);
if (completionsWithScore.length > 0) {
const scores = completionsWithScore.map(c => c.score!);
stats.bestScore = Math.max(...scores);
stats.averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
} else {
stats.bestScore = undefined;
stats.averageScore = undefined;
}
}
/**
* Get all stats
*/
public getAllStats(): Map<string, LevelStatistics> {
return new Map(this.statsMap);
}
/**
* Get stats for multiple levels
*/
public getStatsForLevels(levelIds: string[]): Map<string, LevelStatistics> {
const result = new Map<string, LevelStatistics>();
for (const id of levelIds) {
const stats = this.statsMap.get(id);
if (stats) {
result.set(id, stats);
}
}
return result;
}
/**
* Get top N fastest completions for a level
*/
public getTopCompletions(levelId: string, limit: number = 10): LevelCompletion[] {
const stats = this.statsMap.get(levelId);
if (!stats) {
return [];
}
return stats.completions
.filter(c => c.survived)
.sort((a, b) => a.completionTimeSeconds - b.completionTimeSeconds)
.slice(0, limit);
}
/**
* Get recent completions for a level
*/
public getRecentCompletions(levelId: string, limit: number = 10): LevelCompletion[] {
const stats = this.statsMap.get(levelId);
if (!stats) {
return [];
}
return [...stats.completions]
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit);
}
/**
* Delete stats for a level
*/
public deleteStats(levelId: string): boolean {
const deleted = this.statsMap.delete(levelId);
if (deleted) {
this.saveStats();
}
return deleted;
}
/**
* Clear all stats (for testing/reset)
*/
public clearAll(): void {
this.statsMap.clear();
localStorage.removeItem(STATS_STORAGE_KEY);
}
/**
* Export stats as JSON
*/
public exportStats(): string {
const statsArray = Array.from(this.statsMap.entries());
return JSON.stringify(statsArray, null, 2);
}
/**
* Import stats from JSON
*/
public importStats(jsonString: string): number {
try {
const statsArray: [string, LevelStatistics][] = JSON.parse(jsonString);
let importCount = 0;
for (const [id, stats] of statsArray) {
// Parse dates
if (stats.firstPlayed && typeof stats.firstPlayed === 'string') {
stats.firstPlayed = new Date(stats.firstPlayed);
}
if (stats.lastPlayed && typeof stats.lastPlayed === 'string') {
stats.lastPlayed = new Date(stats.lastPlayed);
}
stats.completions = stats.completions.map(c => ({
...c,
timestamp: typeof c.timestamp === 'string' ? new Date(c.timestamp) : c.timestamp
}));
this.statsMap.set(id, stats);
importCount++;
}
this.saveStats();
return importCount;
} catch (error) {
console.error('Failed to import stats:', error);
throw new Error('Invalid stats JSON format');
}
}
/**
* Get summary statistics across all levels
*/
public getGlobalSummary(): {
totalLevelsPlayed: number;
totalAttempts: number;
totalCompletions: number;
averageCompletionRate: number;
totalPlayTimeSeconds: number;
} {
let totalLevelsPlayed = 0;
let totalAttempts = 0;
let totalCompletions = 0;
let totalPlayTimeSeconds = 0;
let totalCompletionRates = 0;
for (const stats of this.statsMap.values()) {
if (stats.totalAttempts > 0) {
totalLevelsPlayed++;
totalAttempts += stats.totalAttempts;
totalCompletions += stats.totalCompletions;
totalCompletionRates += stats.completionRate;
// Sum all completion times
for (const completion of stats.completions) {
if (completion.survived) {
totalPlayTimeSeconds += completion.completionTimeSeconds;
}
}
}
}
return {
totalLevelsPlayed,
totalAttempts,
totalCompletions,
averageCompletionRate: totalLevelsPlayed > 0 ? totalCompletionRates / totalLevelsPlayed : 0,
totalPlayTimeSeconds
};
}
/**
* Format time in MM:SS format
*/
public static formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Format completion rate as percentage
*/
public static formatCompletionRate(rate: number): string {
return `${rate.toFixed(1)}%`;
}
}

View File

@ -0,0 +1,241 @@
import {LevelConfig} from "../config/levelConfig";
/**
* Sync status for a level
*/
export enum SyncStatus {
NotSynced = 'not_synced',
Syncing = 'syncing',
Synced = 'synced',
Conflict = 'conflict',
Error = 'error'
}
/**
* Metadata for synced levels
*/
export interface SyncMetadata {
lastSyncedAt?: Date;
syncStatus: SyncStatus;
cloudVersion?: string;
localVersion?: string;
syncError?: string;
}
/**
* Interface for level storage providers (localStorage, cloud, etc.)
*/
export interface ILevelStorageProvider {
/**
* Get a level by ID
*/
getLevel(levelId: string): Promise<LevelConfig | null>;
/**
* Save a level
*/
saveLevel(levelId: string, config: LevelConfig): Promise<void>;
/**
* Delete a level
*/
deleteLevel(levelId: string): Promise<boolean>;
/**
* List all level IDs
*/
listLevels(): Promise<string[]>;
/**
* Check if provider is available/connected
*/
isAvailable(): Promise<boolean>;
/**
* Get sync metadata for a level (if supported)
*/
getSyncMetadata?(levelId: string): Promise<SyncMetadata | null>;
}
/**
* LocalStorage implementation of level storage provider
*/
export class LocalStorageProvider implements ILevelStorageProvider {
private storageKey: string;
constructor(storageKey: string = 'space-game-custom-levels') {
this.storageKey = storageKey;
}
async getLevel(levelId: string): Promise<LevelConfig | null> {
const stored = localStorage.getItem(this.storageKey);
if (!stored) {
return null;
}
try {
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
const found = levelsArray.find(([id]) => id === levelId);
return found ? found[1] : null;
} catch (error) {
console.error('Failed to get level from localStorage:', error);
return null;
}
}
async saveLevel(levelId: string, config: LevelConfig): Promise<void> {
const stored = localStorage.getItem(this.storageKey);
let levelsArray: [string, LevelConfig][] = [];
if (stored) {
try {
levelsArray = JSON.parse(stored);
} catch (error) {
console.error('Failed to parse localStorage data:', error);
}
}
// Update or add level
const existingIndex = levelsArray.findIndex(([id]) => id === levelId);
if (existingIndex >= 0) {
levelsArray[existingIndex] = [levelId, config];
} else {
levelsArray.push([levelId, config]);
}
localStorage.setItem(this.storageKey, JSON.stringify(levelsArray));
}
async deleteLevel(levelId: string): Promise<boolean> {
const stored = localStorage.getItem(this.storageKey);
if (!stored) {
return false;
}
try {
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
const newArray = levelsArray.filter(([id]) => id !== levelId);
if (newArray.length === levelsArray.length) {
return false; // Level not found
}
localStorage.setItem(this.storageKey, JSON.stringify(newArray));
return true;
} catch (error) {
console.error('Failed to delete level from localStorage:', error);
return false;
}
}
async listLevels(): Promise<string[]> {
const stored = localStorage.getItem(this.storageKey);
if (!stored) {
return [];
}
try {
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
return levelsArray.map(([id]) => id);
} catch (error) {
console.error('Failed to list levels from localStorage:', error);
return [];
}
}
async isAvailable(): Promise<boolean> {
try {
const testKey = '_storage_test_';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
}
/**
* Cloud storage provider (stub for future implementation)
*
* Future implementation could use:
* - Firebase Firestore
* - AWS S3 + DynamoDB
* - Custom backend API
* - IPFS for decentralized storage
*/
export class CloudStorageProvider implements ILevelStorageProvider {
private apiEndpoint: string;
private authToken?: string;
constructor(apiEndpoint: string, authToken?: string) {
this.apiEndpoint = apiEndpoint;
this.authToken = authToken;
}
async getLevel(_levelId: string): Promise<LevelConfig | null> {
// TODO: Implement cloud fetch
throw new Error('Cloud storage not yet implemented');
}
async saveLevel(_levelId: string, _config: LevelConfig): Promise<void> {
// TODO: Implement cloud save
throw new Error('Cloud storage not yet implemented');
}
async deleteLevel(_levelId: string): Promise<boolean> {
// TODO: Implement cloud delete
throw new Error('Cloud storage not yet implemented');
}
async listLevels(): Promise<string[]> {
// TODO: Implement cloud list
throw new Error('Cloud storage not yet implemented');
}
async isAvailable(): Promise<boolean> {
// TODO: Implement cloud connectivity check
return false;
}
async getSyncMetadata(_levelId: string): Promise<SyncMetadata | null> {
// TODO: Implement sync metadata fetch
throw new Error('Cloud storage not yet implemented');
}
/**
* Authenticate with cloud service
*/
async authenticate(token: string): Promise<boolean> {
this.authToken = token;
// TODO: Implement authentication
return false;
}
/**
* Sync local level to cloud
*/
async syncToCloud(_levelId: string, _config: LevelConfig): Promise<SyncMetadata> {
// TODO: Implement sync to cloud
throw new Error('Cloud storage not yet implemented');
}
/**
* Sync cloud level to local
*/
async syncFromCloud(_levelId: string): Promise<LevelConfig> {
// TODO: Implement sync from cloud
throw new Error('Cloud storage not yet implemented');
}
/**
* Resolve sync conflicts
*/
async resolveConflict(
_levelId: string,
_strategy: 'use_local' | 'use_cloud' | 'merge'
): Promise<LevelConfig> {
// TODO: Implement conflict resolution
throw new Error('Cloud storage not yet implemented');
}
}

View File

@ -0,0 +1,501 @@
import {LevelConfig} from "../config/levelConfig";
/**
* Level directory entry from directory.json manifest
*/
export interface LevelDirectoryEntry {
id: string;
name: string;
description: string;
version: string;
levelPath: string;
missionBrief?: string[];
estimatedTime?: string;
difficulty?: string;
unlockRequirements?: string[];
tags?: string[];
defaultLocked?: boolean;
}
/**
* Directory manifest structure
*/
export interface LevelDirectory {
version: string;
levels: LevelDirectoryEntry[];
}
/**
* Registry entry combining directory info with loaded config
*/
export interface LevelRegistryEntry {
directoryEntry: LevelDirectoryEntry;
config: LevelConfig | null; // null if not yet loaded
isDefault: boolean;
loadedAt?: Date;
}
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
*/
export class LevelRegistry {
private static instance: LevelRegistry | null = null;
private defaultLevels: Map<string, LevelRegistryEntry> = new Map();
private customLevels: Map<string, LevelRegistryEntry> = new Map();
private directoryManifest: LevelDirectory | null = null;
private initialized: boolean = false;
private constructor() {}
public static getInstance(): LevelRegistry {
if (!LevelRegistry.instance) {
LevelRegistry.instance = new LevelRegistry();
}
return LevelRegistry.instance;
}
/**
* 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!');
} catch (error) {
console.error('[LevelRegistry] Failed to initialize level registry:', error);
throw error;
}
}
/**
* Load the directory.json manifest
*/
private async loadDirectory(): Promise<void> {
try {
console.log('[LevelRegistry] Attempting to fetch /levels/directory.json');
// First, fetch from network to get the latest version
console.log('[LevelRegistry] Fetching from network to check version...');
const response = await fetch('/levels/directory.json');
console.log('[LevelRegistry] Fetch response status:', response.status, 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}`);
}
const networkManifest = await response.json();
console.log('[LevelRegistry] Directory JSON parsed:', networkManifest);
// 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);
this.populateDefaultLevelEntries();
} catch (error) {
console.error('[LevelRegistry] Failed to load directory:', error);
throw new Error('Unable to load level directory. Please check your connection.');
}
}
/**
* Populate default level registry entries from directory
*/
private populateDefaultLevelEntries(): void {
if (!this.directoryManifest) {
return;
}
this.defaultLevels.clear();
for (const entry of this.directoryManifest.levels) {
this.defaultLevels.set(entry.id, {
directoryEntry: entry,
config: null, // Lazy load
isDefault: true
});
}
}
/**
* Load custom levels from localStorage
*/
private loadCustomLevels(): void {
this.customLevels.clear();
const stored = localStorage.getItem(CUSTOM_LEVELS_KEY);
if (!stored) {
return;
}
try {
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
for (const [id, config] of levelsArray) {
this.customLevels.set(id, {
directoryEntry: {
id,
name: config.metadata?.description || id,
description: config.metadata?.description || '',
version: config.version || '1.0',
levelPath: '', // Not applicable for custom
difficulty: config.difficulty,
missionBrief: [],
defaultLocked: false
},
config,
isDefault: false,
loadedAt: new Date()
});
}
} catch (error) {
console.error('Failed to load custom levels from localStorage:', error);
}
}
/**
* Get a level config by ID (loads if not yet loaded)
*/
public async getLevel(levelId: string): Promise<LevelConfig | null> {
// Check default levels first
const defaultEntry = this.defaultLevels.get(levelId);
if (defaultEntry) {
if (!defaultEntry.config) {
await this.loadDefaultLevel(levelId);
}
return defaultEntry.config;
}
// Check custom levels
const customEntry = this.customLevels.get(levelId);
return customEntry?.config || null;
}
/**
* Load a default level's config from JSON
*/
private async loadDefaultLevel(levelId: string): Promise<void> {
const entry = this.defaultLevels.get(levelId);
if (!entry || entry.config) {
return; // Already loaded or doesn't exist
}
try {
const levelPath = `/levels/${entry.directoryEntry.levelPath}`;
// Try cache first
const cached = await this.getCachedResource(levelPath);
if (cached) {
entry.config = cached;
entry.loadedAt = new Date();
return;
}
// Fetch from network
const response = await fetch(levelPath);
if (!response.ok) {
throw new Error(`Failed to fetch level: ${response.status}`);
}
const config: LevelConfig = await response.json();
// Cache the level
await this.cacheResource(levelPath, config);
entry.config = config;
entry.loadedAt = new Date();
} catch (error) {
console.error(`Failed to load default level ${levelId}:`, error);
throw error;
}
}
/**
* Get all level registry entries (default + custom)
*/
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);
}
return all;
}
/**
* Get only default levels
*/
public getDefaultLevels(): Map<string, LevelRegistryEntry> {
return new Map(this.defaultLevels);
}
/**
* Get only custom levels
*/
public getCustomLevels(): Map<string, LevelRegistryEntry> {
return new Map(this.customLevels);
}
/**
* Save a custom level
*/
public saveCustomLevel(levelId: string, config: LevelConfig): void {
// Ensure metadata exists
if (!config.metadata) {
config.metadata = {
author: 'Player',
description: levelId
};
}
// 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,
name: config.metadata.description || levelId,
description: config.metadata.description || '',
version: config.version || '1.0',
levelPath: '',
difficulty: config.difficulty,
missionBrief: [],
defaultLocked: false
},
config,
isDefault: false,
loadedAt: new Date()
});
// Persist to localStorage
this.saveCustomLevelsToStorage();
}
/**
* Delete a custom level
*/
public deleteCustomLevel(levelId: string): boolean {
const deleted = this.customLevels.delete(levelId);
if (deleted) {
this.saveCustomLevelsToStorage();
}
return deleted;
}
/**
* Copy a default level to custom levels with a new ID
*/
public async copyDefaultToCustom(defaultLevelId: string, newCustomId: string): Promise<boolean> {
const config = await this.getLevel(defaultLevelId);
if (!config) {
return false;
}
// Deep clone the config
const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config));
// Update metadata
clonedConfig.metadata = {
...clonedConfig.metadata,
type: undefined,
author: 'Player',
description: `Copy of ${defaultLevelId}`,
originalDefault: defaultLevelId
};
this.saveCustomLevel(newCustomId, clonedConfig);
return true;
}
/**
* Persist custom levels to localStorage
*/
private saveCustomLevelsToStorage(): void {
const levelsArray: [string, LevelConfig][] = [];
for (const [id, entry] of this.customLevels) {
if (entry.config) {
levelsArray.push([id, entry.config]);
}
}
localStorage.setItem(CUSTOM_LEVELS_KEY, JSON.stringify(levelsArray));
}
/**
* Get a resource from cache
*/
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
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();
}
/**
* Export custom levels as JSON for backup/sharing
*/
public exportCustomLevels(): string {
const levelsArray: [string, LevelConfig][] = [];
for (const [id, entry] of this.customLevels) {
if (entry.config) {
levelsArray.push([id, entry.config]);
}
}
return JSON.stringify(levelsArray, null, 2);
}
/**
* Import custom levels from JSON
*/
public importCustomLevels(jsonString: string): number {
try {
const levelsArray: [string, LevelConfig][] = JSON.parse(jsonString);
let importCount = 0;
for (const [id, config] of levelsArray) {
this.saveCustomLevel(id, config);
importCount++;
}
return importCount;
} catch (error) {
console.error('Failed to import custom levels:', error);
throw new Error('Invalid custom levels JSON format');
}
}
/**
* Get directory manifest
*/
public getDirectory(): LevelDirectory | null {
return this.directoryManifest;
}
/**
* Check if registry is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
}

View File

@ -1,59 +1,85 @@
import { getSavedLevels } from "../generation/levelEditor"; import {LevelConfig} from "../config/levelConfig";
import { LevelConfig } from "../config/levelConfig"; import {ProgressionManager} from "../../game/progression";
import { ProgressionManager } from "../../game/progression"; import {GameConfig} from "../../core/gameConfig";
import { GameConfig } from "../../core/gameConfig"; import {AuthService} from "../../services/authService";
import { AuthService } from "../../services/authService";
import debugLog from '../../core/debug'; 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'; const SELECTED_LEVEL_KEY = 'space-game-selected-level';
// Default level order for the carousel // Default level IDs in display order (matches directory.json)
const DEFAULT_LEVEL_ORDER = [ const DEFAULT_LEVEL_ORDER = [
'Rookie Training', 'rookie-training',
'Rescue Mission', 'rescue-mission',
'Deep Space Patrol', 'deep-space-patrol',
'Enemy Territory', 'enemy-territory',
'The Gauntlet', 'the-gauntlet',
'Final Challenge' 'final-challenge'
]; ];
/** /**
* Populate the level selection screen with saved levels * Populate the level selection screen with levels from registry
* Shows all 6 default levels in a 3x2 carousel with locked/unlocked states * Shows all 6 default levels in a 3x2 carousel with locked/unlocked states
*/ */
export async function populateLevelSelector(): Promise<boolean> { export async function populateLevelSelector(): Promise<boolean> {
console.log('[LevelSelector] populateLevelSelector() called');
const container = document.getElementById('levelCardsContainer'); const container = document.getElementById('levelCardsContainer');
if (!container) { if (!container) {
console.warn('Level cards container not found'); console.warn('[LevelSelector] Level cards container not found');
return false; return false;
} }
console.log('[LevelSelector] Container found:', container);
const savedLevels = getSavedLevels(); const registry = LevelRegistry.getInstance();
const gameConfig = GameConfig.getInstance(); const versionManager = LevelVersionManager.getInstance();
const progressionEnabled = gameConfig.progressionEnabled; const statsManager = LevelStatsManager.getInstance();
const progression = ProgressionManager.getInstance();
if (savedLevels.size === 0) { // 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 = ` container.innerHTML = `
<div class="no-levels-message"> <div class="no-levels-message">
<h2>No Levels Found</h2> <h2>Failed to Load Levels</h2>
<p>Something went wrong - default levels should be auto-generated!</p> <p>Could not load level directory. Check your connection and try again.</p>
<a href="#/editor" class="btn-primary">Go to Level Editor</a> <button onclick="location.reload()" class="btn-primary">Reload</button>
</div> </div>
`; `;
return false; return false;
} }
// Separate default and custom levels const gameConfig = GameConfig.getInstance();
const defaultLevels = new Map<string, LevelConfig>(); const progressionEnabled = gameConfig.progressionEnabled;
const customLevels = new Map<string, LevelConfig>(); const progression = ProgressionManager.getInstance();
for (const [name, config] of savedLevels.entries()) { // Update version manager with directory
if (config.metadata?.type === 'default') { const directory = registry.getDirectory();
defaultLevels.set(name, config); if (directory) {
} else { versionManager.updateManifestVersions(directory);
customLevels.set(name, config); }
}
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 = ''; let html = '';
@ -79,28 +105,26 @@ export async function populateLevelSelector(): Promise<boolean> {
`; `;
} }
// Check if user is authenticated (ASYNC!) // Check if user is authenticated
const authService = AuthService.getInstance(); const authService = AuthService.getInstance();
const isAuthenticated = await authService.isAuthenticated(); const isAuthenticated = await authService.isAuthenticated();
const isTutorial = (levelName: string) => levelName === DEFAULT_LEVEL_ORDER[0]; const isTutorial = (levelId: string) => levelId === DEFAULT_LEVEL_ORDER[0];
debugLog('[LevelSelector] Authenticated:', isAuthenticated); debugLog('[LevelSelector] Authenticated:', isAuthenticated);
debugLog('[LevelSelector] Progression enabled:', progressionEnabled); debugLog('[LevelSelector] Progression enabled:', progressionEnabled);
debugLog('[LevelSelector] Tutorial level name:', DEFAULT_LEVEL_ORDER[0]);
debugLog('[LevelSelector] Default levels count:', defaultLevels.size); debugLog('[LevelSelector] Default levels count:', defaultLevels.size);
debugLog('[LevelSelector] Default level names:', Array.from(defaultLevels.keys()));
// Show all 6 default levels in order (3x2 grid) // Show all default levels in order (3x2 grid)
if (defaultLevels.size > 0) { if (defaultLevels.size > 0) {
for (const levelName of DEFAULT_LEVEL_ORDER) { for (const levelId of DEFAULT_LEVEL_ORDER) {
const config = defaultLevels.get(levelName); const entry = defaultLevels.get(levelId);
if (!config) { if (!entry) {
// Level doesn't exist - show empty slot // Level doesn't exist - show empty slot
html += ` html += `
<div class="level-card level-card-locked"> <div class="level-card level-card-locked">
<div class="level-card-header"> <div class="level-card-header">
<h2 class="level-card-title">${levelName}</h2> <h2 class="level-card-title">Missing Level</h2>
<div class="level-card-status level-card-status-locked">🔒</div> <div class="level-card-status level-card-status-locked">🔒</div>
</div> </div>
<div class="level-meta">Level not found</div> <div class="level-meta">Level not found</div>
@ -111,57 +135,72 @@ export async function populateLevelSelector(): Promise<boolean> {
continue; continue;
} }
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`; const dirEntry = entry.directoryEntry;
const estimatedTime = config.metadata?.estimatedTime || ''; 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); const isCompleted = progressionEnabled && progression.isLevelComplete(levelName);
// Check if level is unlocked: // Check if level is unlocked
// - Tutorial is always unlocked
// - If authenticated: check progression unlock status
// - If not authenticated: only Tutorial is unlocked
let isUnlocked = false; let isUnlocked = false;
const isTut = isTutorial(levelName); const isTut = isTutorial(levelId);
if (isTut) { if (isTut) {
isUnlocked = true; // Tutorial always unlocked isUnlocked = true; // Tutorial always unlocked
debugLog(`[LevelSelector] ${levelName}: Tutorial - always unlocked`);
} else if (!isAuthenticated) { } else if (!isAuthenticated) {
isUnlocked = false; // Non-tutorial levels require authentication isUnlocked = false; // Non-tutorial levels require authentication
debugLog(`[LevelSelector] ${levelName}: Not authenticated - locked`);
} else { } else {
isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName); isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName);
debugLog(`[LevelSelector] ${levelName}: Authenticated - unlocked:`, isUnlocked);
} }
const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName; const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName;
// Determine card state // Determine card state
let cardClasses = 'level-card'; let cardClasses = 'level-card';
let statusIcon = ''; let statusIcons = '';
let buttonText = 'Play Level'; let buttonText = 'Play Level';
let buttonDisabled = ''; let buttonDisabled = '';
let lockReason = ''; let lockReason = '';
let metaTags = '';
// Version update badge
if (hasUpdate) {
statusIcons += '<div class="level-card-badge level-card-badge-update">UPDATED</div>';
}
if (isCompleted) { if (isCompleted) {
cardClasses += ' level-card-completed'; cardClasses += ' level-card-completed';
statusIcon = '<div class="level-card-status level-card-status-complete">✓</div>'; statusIcons += '<div class="level-card-status level-card-status-complete">✓</div>';
buttonText = 'Replay'; buttonText = 'Replay';
} else if (isCurrentNext && isUnlocked) { } else if (isCurrentNext && isUnlocked) {
cardClasses += ' level-card-current'; cardClasses += ' level-card-current';
statusIcon = '<div class="level-card-badge">START HERE</div>'; statusIcons += '<div class="level-card-badge">START HERE</div>';
} else if (!isUnlocked) { } else if (!isUnlocked) {
cardClasses += ' level-card-locked'; cardClasses += ' level-card-locked';
statusIcon = '<div class="level-card-status level-card-status-locked">🔒</div>'; statusIcons += '<div class="level-card-status level-card-status-locked">🔒</div>';
// Determine why it's locked // Determine why it's locked
if (!isAuthenticated && !isTutorial(levelName)) { if (!isAuthenticated && !isTutorial(levelId)) {
buttonText = 'Sign In Required'; buttonText = 'Sign In Required';
lockReason = '<div class="level-lock-reason">Sign in to unlock</div>'; lockReason = '<div class="level-lock-reason">Sign in to unlock</div>';
} else if (progressionEnabled) { } else if (progressionEnabled) {
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelName); const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelId);
if (levelIndex > 0) { if (levelIndex > 0) {
const previousLevel = DEFAULT_LEVEL_ORDER[levelIndex - 1]; const prevId = DEFAULT_LEVEL_ORDER[levelIndex - 1];
lockReason = `<div class="level-lock-reason">Complete "${previousLevel}" to unlock</div>`; 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'; buttonText = 'Locked';
} else { } else {
@ -170,18 +209,35 @@ export async function populateLevelSelector(): Promise<boolean> {
buttonDisabled = ' disabled'; 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 += ` html += `
<div class="${cardClasses}"> <div class="${cardClasses}">
<div class="level-card-header"> <div class="level-card-header">
<h2 class="level-card-title">${levelName}</h2> <h2 class="level-card-title">${levelName}</h2>
${statusIcon} <div class="level-card-badges">${statusIcons}</div>
</div> </div>
<div class="level-meta"> <div class="level-meta">
Difficulty: ${config.difficulty}${estimatedTime ? `${estimatedTime}` : ''} Difficulty: ${difficulty}${estimatedTime ? `${estimatedTime}` : ''}
</div> </div>
<p class="level-card-description">${description}</p> <p class="level-card-description">${description}</p>
${metaTags}
${lockReason} ${lockReason}
<button class="level-button" data-level="${levelName}"${buttonDisabled}>${buttonText}</button> <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> </div>
`; `;
} }
@ -195,68 +251,165 @@ export async function populateLevelSelector(): Promise<boolean> {
</div> </div>
`; `;
for (const [name, config] of customLevels.entries()) { for (const [levelId, entry] of customLevels.entries()) {
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`; 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 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 += ` html += `
<div class="level-card"> <div class="level-card">
<div class="level-card-header"> <div class="level-card-header">
<h2 class="level-card-title">${name}</h2> <h2 class="level-card-title">${levelId}</h2>
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
</div> </div>
<div class="level-meta"> <div class="level-meta">
Custom${author} ${config.difficulty} ${difficulty}${author}
</div> </div>
<p class="level-card-description">${description}</p> <p class="level-card-description">${description}</p>
<button class="level-button" data-level="${name}">Play Level</button> ${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> </div>
`; `;
} }
} }
console.log('[LevelSelector] Setting container innerHTML, html length:', html.length);
container.innerHTML = html; container.innerHTML = html;
console.log('[LevelSelector] Container innerHTML set, now attaching event listeners');
// Attach event listeners to all level buttons // Attach event listeners to all level buttons
const buttons = container.querySelectorAll('.level-button:not([disabled])'); const playButtons = container.querySelectorAll('.level-button:not([disabled])');
buttons.forEach(button => { playButtons.forEach(button => {
button.addEventListener('click', (e) => { button.addEventListener('click', (e) => {
const target = e.target as HTMLButtonElement; const target = e.target as HTMLButtonElement;
const levelName = target.getAttribute('data-level'); const levelId = target.getAttribute('data-level-id');
if (levelName) { if (levelId) {
selectLevel(levelName); 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; 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 * Select a level and dispatch event to start it
*/ */
export function selectLevel(levelName: string): void { export async function selectLevel(levelId: string): Promise<void> {
debugLog(`[LevelSelector] Level selected: ${levelName}`); debugLog(`[LevelSelector] Level selected: ${levelId}`);
const savedLevels = getSavedLevels(); const registry = LevelRegistry.getInstance();
const config = savedLevels.get(levelName); const config = await registry.getLevel(levelId);
if (!config) { if (!config) {
console.error(`Level not found: ${levelName}`); console.error(`Level not found: ${levelId}`);
return; return;
} }
// Save selected level // Save selected level
localStorage.setItem(SELECTED_LEVEL_KEY, levelName); localStorage.setItem(SELECTED_LEVEL_KEY, levelId);
// Dispatch custom event that Main class will listen for // Dispatch custom event that Main class will listen for
const event = new CustomEvent('levelSelected', { const event = new CustomEvent('levelSelected', {
detail: { levelName, config } detail: {levelId, config}
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
} }
/** /**
* Get the last selected level name * Get the last selected level ID
*/ */
export function getSelectedLevel(): string | null { export function getSelectedLevel(): string | null {
return localStorage.getItem(SELECTED_LEVEL_KEY); return localStorage.getItem(SELECTED_LEVEL_KEY);

View File

@ -0,0 +1,262 @@
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

@ -25,7 +25,8 @@ import {ControllerDebug} from "./utils/controllerDebug";
import {router, showView} from "./core/router"; import {router, showView} from "./core/router";
import {populateLevelSelector} from "./levels/ui/levelSelector"; import {populateLevelSelector} from "./levels/ui/levelSelector";
import {LevelConfig} from "./levels/config/levelConfig"; import {LevelConfig} from "./levels/config/levelConfig";
import {generateDefaultLevels} from "./levels/generation/levelEditor"; import {LegacyMigration} from "./levels/migration/legacyMigration";
import {LevelRegistry} from "./levels/storage/levelRegistry";
import debugLog from './core/debug'; import debugLog from './core/debug';
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen"; import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
import {ReplayManager} from "./replay/ReplayManager"; import {ReplayManager} from "./replay/ReplayManager";
@ -674,8 +675,43 @@ router.on('/settings', () => {
} }
}); });
// Generate default levels if localStorage is empty // Initialize registry and start router
generateDefaultLevels(); // This must happen BEFORE router.start() so levels are available
async function initializeApp() {
// Check for legacy data migration
if (LegacyMigration.needsMigration()) {
debugLog('[Main] Legacy data detected - showing migration modal');
return new Promise<void>((resolve) => {
LegacyMigration.showMigrationModal(async (result) => {
debugLog('[Main] Migration completed:', result);
// Initialize the new registry system
try {
await LevelRegistry.getInstance().initialize();
debugLog('[Main] LevelRegistry initialized after migration');
router.start();
resolve();
} catch (error) {
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
router.start(); // Start anyway to show error state
resolve();
}
});
});
} else {
// Initialize the new registry system
try {
await LevelRegistry.getInstance().initialize();
debugLog('[Main] LevelRegistry initialized');
router.start();
} catch (error) {
console.error('[Main] Failed to initialize LevelRegistry:', error);
router.start(); // Start anyway to show error state
}
}
}
// Start the app
initializeApp();
// Suppress non-critical BabylonJS shader loading errors during development // Suppress non-critical BabylonJS shader loading errors during development
// Note: After Vite config fix to pre-bundle shaders, these errors should no longer occur // Note: After Vite config fix to pre-bundle shaders, these errors should no longer occur
@ -694,8 +730,7 @@ window.addEventListener('unhandledrejection', (event) => {
} }
}); });
// Start the router after all routes are registered // DO NOT start router here - it will be started after registry initialization below
router.start();
if (DEBUG_CONTROLLERS) { if (DEBUG_CONTROLLERS) {
debugLog('🔍 DEBUG MODE: Running minimal controller test'); debugLog('🔍 DEBUG MODE: Running minimal controller test');

View File

@ -165,7 +165,7 @@ export class StatusScreen {
buttonBar.addControl(this._resumeButton); buttonBar.addControl(this._resumeButton);
// Create Next Level button (only shown when game has ended and there's a next level) // Create Next Level button (only shown when game has ended and there's a next level)
this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL"); /*this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL");
this._nextLevelButton.width = "300px"; this._nextLevelButton.width = "300px";
this._nextLevelButton.height = "60px"; this._nextLevelButton.height = "60px";
this._nextLevelButton.color = "white"; this._nextLevelButton.color = "white";
@ -196,10 +196,10 @@ export class StatusScreen {
this._onReplayCallback(); this._onReplayCallback();
} }
}); });
buttonBar.addControl(this._replayButton); buttonBar.addControl(this._replayButton);*/
// Create Exit VR button // Create Exit VR button
this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT VR"); this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT");
this._exitButton.width = "300px"; this._exitButton.width = "300px";
this._exitButton.height = "60px"; this._exitButton.height = "60px";
this._exitButton.color = "white"; this._exitButton.color = "white";

BIN
themes/default/base2.blend Normal file

Binary file not shown.