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",
"levels": [
{ {
"id": 1, "id": "rookie-training",
"name": "Recruit", "name": "Rookie Training",
"Description": "Simple level to get the hang of things", "description": "Simple level to get the hang of things",
"missionbrief": [ "version": "1.0",
"levelPath": "rookie-training.json",
"difficulty": "recruit",
"estimatedTime": "3-5 minutes",
"missionBrief": [
"Destroy the asteroids", "Destroy the asteroids",
"return to base after they're destroyed to complete the mission", "Return to base after they're destroyed to complete the mission",
"return to base if you need more fuel, ammo, or hull repairs", "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", "Don't get too far from base, if you run out of fuel, you'll be stranded",
"don't run into things, it damages your hull" "Don't run into things, it damages your hull"
], ],
"leveldata": "/levels/1.json", "unlockRequirements": [],
"defaultlocked": false "tags": ["tutorial", "easy"],
} , "defaultLocked": false
},
{ {
"id": 2, "id": "rescue-mission",
"name": "Fuel Management", "name": "Rescue Mission",
"Description": "Don't run out of fuel", "description": "Rescue operation in moderate asteroid field",
"missionbrief": [ "version": "1.0",
"Astroids are further away and there a more of them", "levelPath": "rescue-mission.json",
"you'll need to keep an eye on your fuel levels", "difficulty": "pilot",
"return to base after you've destroyed them all" "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"
], ],
"leveldata": null, "unlockRequirements": ["rookie-training"],
"defaultlocked": true "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;
// 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); const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
body.addConstraint(this._orbitCenter.body, constraint); 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.