Implement hybrid level storage system with JSON-based defaults and configurable orbit constraints
All checks were successful
Build / build (push) Successful in 1m34s
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:
parent
500830779d
commit
244a25fff5
441
public/levels/deep-space-patrol.json
Normal file
441
public/levels/deep-space-patrol.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,120 @@
|
||||
{"missions": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Recruit",
|
||||
"Description": "Simple level to get the hang of things",
|
||||
"missionbrief": [
|
||||
"version": "1.0.5",
|
||||
"levels": [
|
||||
{
|
||||
"id": "rookie-training",
|
||||
"name": "Rookie Training",
|
||||
"description": "Simple level to get the hang of things",
|
||||
"version": "1.0",
|
||||
"levelPath": "rookie-training.json",
|
||||
"difficulty": "recruit",
|
||||
"estimatedTime": "3-5 minutes",
|
||||
"missionBrief": [
|
||||
"Destroy the asteroids",
|
||||
"return to base after they're destroyed to complete the mission",
|
||||
"return to base if you need more fuel, ammo, or hull repairs",
|
||||
"don't get too far from base, if you run out of fuel, you'll be stranded",
|
||||
"don't run into things, it damages your hull"
|
||||
"Return to base after they're destroyed to complete the mission",
|
||||
"Return to base if you need more fuel, ammo, or hull repairs",
|
||||
"Don't get too far from base, if you run out of fuel, you'll be stranded",
|
||||
"Don't run into things, it damages your hull"
|
||||
],
|
||||
"leveldata": "/levels/1.json",
|
||||
"defaultlocked": false
|
||||
"unlockRequirements": [],
|
||||
"tags": ["tutorial", "easy"],
|
||||
"defaultLocked": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fuel Management",
|
||||
"Description": "Don't run out of fuel",
|
||||
"missionbrief": [
|
||||
"Astroids are further away and there a more of them",
|
||||
"you'll need to keep an eye on your fuel levels",
|
||||
"return to base after you've destroyed them all"
|
||||
"id": "rescue-mission",
|
||||
"name": "Rescue Mission",
|
||||
"description": "Rescue operation in moderate asteroid field",
|
||||
"version": "1.0",
|
||||
"levelPath": "rescue-mission.json",
|
||||
"difficulty": "pilot",
|
||||
"estimatedTime": "5-8 minutes",
|
||||
"missionBrief": [
|
||||
"More asteroids and increased difficulty",
|
||||
"Manage your fuel and ammunition carefully",
|
||||
"Complete the mission and return to base",
|
||||
"Use your radar to track asteroids",
|
||||
"Watch your shield strength"
|
||||
],
|
||||
"leveldata": null,
|
||||
"defaultlocked": true
|
||||
"unlockRequirements": ["rookie-training"],
|
||||
"tags": ["medium"],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
"id": "deep-space-patrol",
|
||||
"name": "Deep Space Patrol",
|
||||
"description": "Extended patrol mission in dangerous territory",
|
||||
"version": "1.0",
|
||||
"levelPath": "deep-space-patrol.json",
|
||||
"difficulty": "captain",
|
||||
"estimatedTime": "8-12 minutes",
|
||||
"missionBrief": [
|
||||
"Large asteroid field requiring careful navigation",
|
||||
"Fuel management is critical at this distance",
|
||||
"Return to base for resupply as needed",
|
||||
"Asteroids are faster and more dangerous",
|
||||
"Plan your route carefully"
|
||||
],
|
||||
"unlockRequirements": ["rescue-mission"],
|
||||
"tags": ["hard"],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
"id": "enemy-territory",
|
||||
"name": "Enemy Territory",
|
||||
"description": "Hazardous mission in hostile space",
|
||||
"version": "1.0",
|
||||
"levelPath": "enemy-territory.json",
|
||||
"difficulty": "commander",
|
||||
"estimatedTime": "10-15 minutes",
|
||||
"missionBrief": [
|
||||
"Heavily defended asteroid field",
|
||||
"Maximum asteroid count and speed",
|
||||
"Expert fuel and ammunition management required",
|
||||
"Multiple base trips may be necessary",
|
||||
"Test your skills to the limit"
|
||||
],
|
||||
"unlockRequirements": ["deep-space-patrol"],
|
||||
"tags": ["very-hard", "combat"],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
"id": "the-gauntlet",
|
||||
"name": "The Gauntlet",
|
||||
"description": "Survive the ultimate asteroid gauntlet",
|
||||
"version": "1.0",
|
||||
"levelPath": "the-gauntlet.json",
|
||||
"difficulty": "commander",
|
||||
"estimatedTime": "12-18 minutes",
|
||||
"missionBrief": [
|
||||
"Dense asteroid field with extreme hazards",
|
||||
"High-speed rocks from all directions",
|
||||
"This is a test of endurance and skill",
|
||||
"Only the best pilots succeed",
|
||||
"May the stars guide you"
|
||||
],
|
||||
"unlockRequirements": ["enemy-territory"],
|
||||
"tags": ["very-hard", "endurance"],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
"id": "final-challenge",
|
||||
"name": "Final Challenge",
|
||||
"description": "The ultimate test for master pilots",
|
||||
"version": "1.0",
|
||||
"levelPath": "final-challenge.json",
|
||||
"difficulty": "commander",
|
||||
"estimatedTime": "15-20 minutes",
|
||||
"missionBrief": [
|
||||
"The pinnacle of difficulty",
|
||||
"Everything you've learned will be tested",
|
||||
"Maximum asteroid count and velocity",
|
||||
"Precision flying and resource management essential",
|
||||
"Complete this to prove your mastery",
|
||||
"Good luck, Commander"
|
||||
],
|
||||
"unlockRequirements": ["the-gauntlet"],
|
||||
"tags": ["extreme", "final-boss"],
|
||||
"defaultLocked": true
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
1011
public/levels/enemy-territory.json
Normal file
1011
public/levels/enemy-territory.json
Normal file
File diff suppressed because it is too large
Load Diff
1011
public/levels/final-challenge.json
Normal file
1011
public/levels/final-challenge.json
Normal file
File diff suppressed because it is too large
Load Diff
251
public/levels/rescue-mission.json
Normal file
251
public/levels/rescue-mission.json
Normal 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
|
||||
}
|
||||
}
|
||||
157
public/levels/rookie-training.json
Normal file
157
public/levels/rookie-training.json
Normal 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
|
||||
}
|
||||
1011
public/levels/the-gauntlet.json
Normal file
1011
public/levels/the-gauntlet.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -160,6 +160,7 @@ body {
|
||||
}
|
||||
|
||||
.editor-link {
|
||||
display: none;
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
}
|
||||
|
||||
@ -169,6 +170,7 @@ body {
|
||||
}
|
||||
|
||||
.settings-link {
|
||||
display: none;
|
||||
background: rgba(33, 150, 243, 0.8);
|
||||
}
|
||||
|
||||
@ -512,6 +514,11 @@ body {
|
||||
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 {
|
||||
background: var(--gradient-danger);
|
||||
|
||||
215
scripts/generateDefaultLevels.cjs
Normal file
215
scripts/generateDefaultLevels.cjs
Normal 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!`);
|
||||
@ -77,15 +77,20 @@ export const router = new Router();
|
||||
* Helper to show/hide views
|
||||
*/
|
||||
export function showView(viewId: string): void {
|
||||
console.log('[Router] showView() called with viewId:', viewId);
|
||||
|
||||
// Hide all views
|
||||
const views = document.querySelectorAll('[data-view]');
|
||||
console.log('[Router] Found views:', views.length);
|
||||
views.forEach(view => {
|
||||
(view as HTMLElement).style.display = 'none';
|
||||
});
|
||||
|
||||
// Show requested view
|
||||
const targetView = document.querySelector(`[data-view="${viewId}"]`);
|
||||
console.log('[Router] Target view found:', !!targetView);
|
||||
if (targetView) {
|
||||
(targetView as HTMLElement).style.display = 'block';
|
||||
console.log('[Router] View display set to block');
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,7 +77,8 @@ export class RockFactory {
|
||||
}
|
||||
|
||||
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);
|
||||
debugLog(rock.id);
|
||||
@ -100,13 +101,32 @@ export class RockFactory {
|
||||
// Don't pass radius - let Babylon compute from scaled mesh bounds
|
||||
}, DefaultScene.MainScene);
|
||||
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);
|
||||
body.addConstraint(this._orbitCenter.body, constraint);
|
||||
} else {
|
||||
debugLog(`[RockFactory] Orbit constraint disabled for ${rock.name} - asteroid will move freely`);
|
||||
}
|
||||
|
||||
body.setLinearDamping(0)
|
||||
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
||||
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.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) => {
|
||||
if (eventData.type == 'COLLISION_STARTED') {
|
||||
if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
|
||||
|
||||
@ -147,6 +147,9 @@ export interface LevelConfig {
|
||||
// Optional: include original difficulty config for reference
|
||||
difficultyConfig?: DifficultyConfig;
|
||||
|
||||
// Physics configuration
|
||||
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
|
||||
|
||||
// New fields for full scene serialization
|
||||
materials?: MaterialConfig[];
|
||||
sceneHierarchy?: SceneNodeConfig[];
|
||||
|
||||
@ -21,6 +21,7 @@ import { FireProceduralTexture } from "@babylonjs/procedural-textures";
|
||||
import { createSphereLightmap } from "../../environment/celestial/sphereLightmap";
|
||||
import debugLog from '../../core/debug';
|
||||
import StarBase from "../../environment/stations/starBase";
|
||||
import {LevelRegistry} from "../storage/levelRegistry";
|
||||
|
||||
/**
|
||||
* 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++) {
|
||||
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
|
||||
const rock = await RockFactory.createRock(
|
||||
i,
|
||||
@ -179,7 +190,8 @@ export class LevelDeserializer {
|
||||
asteroidConfig.scale,
|
||||
this.arrayToVector3(asteroidConfig.linearVelocity),
|
||||
this.arrayToVector3(asteroidConfig.angularVelocity),
|
||||
scoreObservable
|
||||
scoreObservable,
|
||||
useOrbitConstraints
|
||||
);
|
||||
|
||||
// Get the actual mesh from the Rock object
|
||||
@ -246,4 +258,26 @@ export class LevelDeserializer {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
330
src/levels/migration/legacyMigration.ts
Normal file
330
src/levels/migration/legacyMigration.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
381
src/levels/stats/levelStats.ts
Normal file
381
src/levels/stats/levelStats.ts
Normal 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)}%`;
|
||||
}
|
||||
}
|
||||
241
src/levels/storage/ILevelStorageProvider.ts
Normal file
241
src/levels/storage/ILevelStorageProvider.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
501
src/levels/storage/levelRegistry.ts
Normal file
501
src/levels/storage/levelRegistry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,59 +1,85 @@
|
||||
import { getSavedLevels } from "../generation/levelEditor";
|
||||
import {LevelConfig} from "../config/levelConfig";
|
||||
import {ProgressionManager} from "../../game/progression";
|
||||
import {GameConfig} from "../../core/gameConfig";
|
||||
import {AuthService} from "../../services/authService";
|
||||
import debugLog from '../../core/debug';
|
||||
import {LevelRegistry} from "../storage/levelRegistry";
|
||||
import {LevelVersionManager} from "../versioning/levelVersionManager";
|
||||
import {LevelStatsManager} from "../stats/levelStats";
|
||||
|
||||
const SELECTED_LEVEL_KEY = 'space-game-selected-level';
|
||||
|
||||
// Default level order for the carousel
|
||||
// Default level IDs in display order (matches directory.json)
|
||||
const DEFAULT_LEVEL_ORDER = [
|
||||
'Rookie Training',
|
||||
'Rescue Mission',
|
||||
'Deep Space Patrol',
|
||||
'Enemy Territory',
|
||||
'The Gauntlet',
|
||||
'Final Challenge'
|
||||
'rookie-training',
|
||||
'rescue-mission',
|
||||
'deep-space-patrol',
|
||||
'enemy-territory',
|
||||
'the-gauntlet',
|
||||
'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
|
||||
*/
|
||||
export async function populateLevelSelector(): Promise<boolean> {
|
||||
console.log('[LevelSelector] populateLevelSelector() called');
|
||||
const container = document.getElementById('levelCardsContainer');
|
||||
if (!container) {
|
||||
console.warn('Level cards container not found');
|
||||
console.warn('[LevelSelector] Level cards container not found');
|
||||
return false;
|
||||
}
|
||||
console.log('[LevelSelector] Container found:', container);
|
||||
|
||||
const savedLevels = getSavedLevels();
|
||||
const gameConfig = GameConfig.getInstance();
|
||||
const progressionEnabled = gameConfig.progressionEnabled;
|
||||
const progression = ProgressionManager.getInstance();
|
||||
const registry = LevelRegistry.getInstance();
|
||||
const versionManager = LevelVersionManager.getInstance();
|
||||
const statsManager = LevelStatsManager.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 = `
|
||||
<div class="no-levels-message">
|
||||
<h2>No Levels Found</h2>
|
||||
<p>Something went wrong - default levels should be auto-generated!</p>
|
||||
<a href="#/editor" class="btn-primary">Go to Level Editor</a>
|
||||
<h2>Failed to Load Levels</h2>
|
||||
<p>Could not load level directory. Check your connection and try again.</p>
|
||||
<button onclick="location.reload()" class="btn-primary">Reload</button>
|
||||
</div>
|
||||
`;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Separate default and custom levels
|
||||
const defaultLevels = new Map<string, LevelConfig>();
|
||||
const customLevels = new Map<string, LevelConfig>();
|
||||
const gameConfig = GameConfig.getInstance();
|
||||
const progressionEnabled = gameConfig.progressionEnabled;
|
||||
const progression = ProgressionManager.getInstance();
|
||||
|
||||
for (const [name, config] of savedLevels.entries()) {
|
||||
if (config.metadata?.type === 'default') {
|
||||
defaultLevels.set(name, config);
|
||||
} else {
|
||||
customLevels.set(name, config);
|
||||
// Update version manager with directory
|
||||
const directory = registry.getDirectory();
|
||||
if (directory) {
|
||||
versionManager.updateManifestVersions(directory);
|
||||
}
|
||||
|
||||
const defaultLevels = registry.getDefaultLevels();
|
||||
const customLevels = registry.getCustomLevels();
|
||||
|
||||
console.log('[LevelSelector] Default levels:', defaultLevels.size);
|
||||
console.log('[LevelSelector] Custom levels:', customLevels.size);
|
||||
console.log('[LevelSelector] Default level IDs:', Array.from(defaultLevels.keys()));
|
||||
|
||||
if (defaultLevels.size === 0 && customLevels.size === 0) {
|
||||
console.warn('[LevelSelector] No levels found!');
|
||||
container.innerHTML = `
|
||||
<div class="no-levels-message">
|
||||
<h2>No Levels Found</h2>
|
||||
<p>No levels available. Please check your installation.</p>
|
||||
<a href="#/editor" class="btn-primary">Create Custom Level</a>
|
||||
</div>
|
||||
`;
|
||||
return false;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
@ -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 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] Progression enabled:', progressionEnabled);
|
||||
debugLog('[LevelSelector] Tutorial level name:', DEFAULT_LEVEL_ORDER[0]);
|
||||
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) {
|
||||
for (const levelName of DEFAULT_LEVEL_ORDER) {
|
||||
const config = defaultLevels.get(levelName);
|
||||
for (const levelId of DEFAULT_LEVEL_ORDER) {
|
||||
const entry = defaultLevels.get(levelId);
|
||||
|
||||
if (!config) {
|
||||
if (!entry) {
|
||||
// Level doesn't exist - show empty slot
|
||||
html += `
|
||||
<div class="level-card level-card-locked">
|
||||
<div class="level-card-header">
|
||||
<h2 class="level-card-title">${levelName}</h2>
|
||||
<h2 class="level-card-title">Missing Level</h2>
|
||||
<div class="level-card-status level-card-status-locked">🔒</div>
|
||||
</div>
|
||||
<div class="level-meta">Level not found</div>
|
||||
@ -111,57 +135,72 @@ export async function populateLevelSelector(): Promise<boolean> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
||||
const estimatedTime = config.metadata?.estimatedTime || '';
|
||||
const dirEntry = entry.directoryEntry;
|
||||
const levelName = dirEntry.name;
|
||||
const description = dirEntry.description;
|
||||
const estimatedTime = dirEntry.estimatedTime || '';
|
||||
const difficulty = dirEntry.difficulty || 'unknown';
|
||||
|
||||
// Check for version updates
|
||||
const hasUpdate = versionManager.hasUpdate(levelId);
|
||||
|
||||
// Get stats
|
||||
const stats = statsManager.getStats(levelId);
|
||||
const completionRate = stats?.completionRate || 0;
|
||||
const bestTime = stats?.bestTimeSeconds;
|
||||
|
||||
// Check progression
|
||||
const isCompleted = progressionEnabled && progression.isLevelComplete(levelName);
|
||||
|
||||
// Check if level is unlocked:
|
||||
// - Tutorial is always unlocked
|
||||
// - If authenticated: check progression unlock status
|
||||
// - If not authenticated: only Tutorial is unlocked
|
||||
// Check if level is unlocked
|
||||
let isUnlocked = false;
|
||||
const isTut = isTutorial(levelName);
|
||||
const isTut = isTutorial(levelId);
|
||||
|
||||
if (isTut) {
|
||||
isUnlocked = true; // Tutorial always unlocked
|
||||
debugLog(`[LevelSelector] ${levelName}: Tutorial - always unlocked`);
|
||||
} else if (!isAuthenticated) {
|
||||
isUnlocked = false; // Non-tutorial levels require authentication
|
||||
debugLog(`[LevelSelector] ${levelName}: Not authenticated - locked`);
|
||||
} else {
|
||||
isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName);
|
||||
debugLog(`[LevelSelector] ${levelName}: Authenticated - unlocked:`, isUnlocked);
|
||||
}
|
||||
|
||||
const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName;
|
||||
|
||||
// Determine card state
|
||||
let cardClasses = 'level-card';
|
||||
let statusIcon = '';
|
||||
let statusIcons = '';
|
||||
let buttonText = 'Play Level';
|
||||
let buttonDisabled = '';
|
||||
let lockReason = '';
|
||||
let metaTags = '';
|
||||
|
||||
// Version update badge
|
||||
if (hasUpdate) {
|
||||
statusIcons += '<div class="level-card-badge level-card-badge-update">UPDATED</div>';
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
cardClasses += ' level-card-completed';
|
||||
statusIcon = '<div class="level-card-status level-card-status-complete">✓</div>';
|
||||
statusIcons += '<div class="level-card-status level-card-status-complete">✓</div>';
|
||||
buttonText = 'Replay';
|
||||
} else if (isCurrentNext && isUnlocked) {
|
||||
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) {
|
||||
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
|
||||
if (!isAuthenticated && !isTutorial(levelName)) {
|
||||
if (!isAuthenticated && !isTutorial(levelId)) {
|
||||
buttonText = 'Sign In Required';
|
||||
lockReason = '<div class="level-lock-reason">Sign in to unlock</div>';
|
||||
} else if (progressionEnabled) {
|
||||
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelName);
|
||||
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelId);
|
||||
if (levelIndex > 0) {
|
||||
const previousLevel = DEFAULT_LEVEL_ORDER[levelIndex - 1];
|
||||
lockReason = `<div class="level-lock-reason">Complete "${previousLevel}" to unlock</div>`;
|
||||
const prevId = DEFAULT_LEVEL_ORDER[levelIndex - 1];
|
||||
const prevEntry = defaultLevels.get(prevId);
|
||||
const prevName = prevEntry?.directoryEntry.name || 'previous level';
|
||||
lockReason = `<div class="level-lock-reason">Complete "${prevName}" to unlock</div>`;
|
||||
}
|
||||
buttonText = 'Locked';
|
||||
} else {
|
||||
@ -170,18 +209,35 @@ export async function populateLevelSelector(): Promise<boolean> {
|
||||
buttonDisabled = ' disabled';
|
||||
}
|
||||
|
||||
// Show stats if available
|
||||
if (stats && stats.totalAttempts > 0) {
|
||||
metaTags = '<div class="level-stats">';
|
||||
if (bestTime) {
|
||||
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
|
||||
}
|
||||
if (stats.totalCompletions > 0) {
|
||||
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
|
||||
}
|
||||
metaTags += `<span class="stat-badge">${LevelStatsManager.formatCompletionRate(completionRate)}</span>`;
|
||||
metaTags += '</div>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="${cardClasses}">
|
||||
<div class="level-card-header">
|
||||
<h2 class="level-card-title">${levelName}</h2>
|
||||
${statusIcon}
|
||||
<div class="level-card-badges">${statusIcons}</div>
|
||||
</div>
|
||||
<div class="level-meta">
|
||||
Difficulty: ${config.difficulty}${estimatedTime ? ` • ${estimatedTime}` : ''}
|
||||
Difficulty: ${difficulty}${estimatedTime ? ` • ${estimatedTime}` : ''}
|
||||
</div>
|
||||
<p class="level-card-description">${description}</p>
|
||||
${metaTags}
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
@ -195,68 +251,165 @@ export async function populateLevelSelector(): Promise<boolean> {
|
||||
</div>
|
||||
`;
|
||||
|
||||
for (const [name, config] of customLevels.entries()) {
|
||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
||||
for (const [levelId, entry] of customLevels.entries()) {
|
||||
const config = entry.config;
|
||||
if (!config) continue;
|
||||
|
||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids`;
|
||||
const author = config.metadata?.author ? ` by ${config.metadata.author}` : '';
|
||||
const difficulty = config.difficulty || 'custom';
|
||||
|
||||
// Get stats
|
||||
const stats = statsManager.getStats(levelId);
|
||||
const bestTime = stats?.bestTimeSeconds;
|
||||
let metaTags = '';
|
||||
|
||||
if (stats && stats.totalAttempts > 0) {
|
||||
metaTags = '<div class="level-stats">';
|
||||
if (bestTime) {
|
||||
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
|
||||
}
|
||||
if (stats.totalCompletions > 0) {
|
||||
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
|
||||
}
|
||||
metaTags += '</div>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="level-card">
|
||||
<div class="level-card-header">
|
||||
<h2 class="level-card-title">${name}</h2>
|
||||
<h2 class="level-card-title">${levelId}</h2>
|
||||
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
|
||||
</div>
|
||||
<div class="level-meta">
|
||||
Custom${author} • ${config.difficulty}
|
||||
${difficulty}${author}
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[LevelSelector] Setting container innerHTML, html length:', html.length);
|
||||
container.innerHTML = html;
|
||||
console.log('[LevelSelector] Container innerHTML set, now attaching event listeners');
|
||||
|
||||
// Attach event listeners to all level buttons
|
||||
const buttons = container.querySelectorAll('.level-button:not([disabled])');
|
||||
buttons.forEach(button => {
|
||||
const playButtons = container.querySelectorAll('.level-button:not([disabled])');
|
||||
playButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLButtonElement;
|
||||
const levelName = target.getAttribute('data-level');
|
||||
if (levelName) {
|
||||
selectLevel(levelName);
|
||||
const levelId = target.getAttribute('data-level-id');
|
||||
if (levelId) {
|
||||
selectLevel(levelId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attach copy button listeners
|
||||
const copyButtons = container.querySelectorAll('[data-copy-level]');
|
||||
copyButtons.forEach(button => {
|
||||
button.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLButtonElement;
|
||||
const levelId = target.getAttribute('data-copy-level');
|
||||
if (levelId) {
|
||||
await copyLevelToCustom(levelId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attach delete button listeners
|
||||
const deleteButtons = container.querySelectorAll('[data-delete-level]');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLButtonElement;
|
||||
const levelId = target.getAttribute('data-delete-level');
|
||||
if (levelId) {
|
||||
deleteCustomLevel(levelId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[LevelSelector] Event listeners attached, returning true');
|
||||
|
||||
// Make the level selector visible by adding 'ready' class
|
||||
const levelSelectDiv = document.getElementById('levelSelect');
|
||||
if (levelSelectDiv) {
|
||||
levelSelectDiv.classList.add('ready');
|
||||
console.log('[LevelSelector] Added "ready" class to #levelSelect');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a default level to custom levels
|
||||
*/
|
||||
async function copyLevelToCustom(levelId: string): Promise<void> {
|
||||
const registry = LevelRegistry.getInstance();
|
||||
const customName = prompt(`Enter a name for your copy of this level:`, `${levelId}-copy`);
|
||||
|
||||
if (!customName || customName.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await registry.copyDefaultToCustom(levelId, customName);
|
||||
|
||||
if (success) {
|
||||
alert(`Level copied as "${customName}"!`);
|
||||
await populateLevelSelector(); // Refresh UI
|
||||
} else {
|
||||
alert('Failed to copy level. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom level
|
||||
*/
|
||||
function deleteCustomLevel(levelId: string): void {
|
||||
if (!confirm(`Are you sure you want to delete "${levelId}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = LevelRegistry.getInstance();
|
||||
const success = registry.deleteCustomLevel(levelId);
|
||||
|
||||
if (success) {
|
||||
populateLevelSelector(); // Refresh UI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a level and dispatch event to start it
|
||||
*/
|
||||
export function selectLevel(levelName: string): void {
|
||||
debugLog(`[LevelSelector] Level selected: ${levelName}`);
|
||||
export async function selectLevel(levelId: string): Promise<void> {
|
||||
debugLog(`[LevelSelector] Level selected: ${levelId}`);
|
||||
|
||||
const savedLevels = getSavedLevels();
|
||||
const config = savedLevels.get(levelName);
|
||||
const registry = LevelRegistry.getInstance();
|
||||
const config = await registry.getLevel(levelId);
|
||||
|
||||
if (!config) {
|
||||
console.error(`Level not found: ${levelName}`);
|
||||
console.error(`Level not found: ${levelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save selected level
|
||||
localStorage.setItem(SELECTED_LEVEL_KEY, levelName);
|
||||
localStorage.setItem(SELECTED_LEVEL_KEY, levelId);
|
||||
|
||||
// Dispatch custom event that Main class will listen for
|
||||
const event = new CustomEvent('levelSelected', {
|
||||
detail: { levelName, config }
|
||||
detail: {levelId, config}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last selected level name
|
||||
* Get the last selected level ID
|
||||
*/
|
||||
export function getSelectedLevel(): string | null {
|
||||
return localStorage.getItem(SELECTED_LEVEL_KEY);
|
||||
|
||||
262
src/levels/versioning/levelVersionManager.ts
Normal file
262
src/levels/versioning/levelVersionManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/main.ts
45
src/main.ts
@ -25,7 +25,8 @@ import {ControllerDebug} from "./utils/controllerDebug";
|
||||
import {router, showView} from "./core/router";
|
||||
import {populateLevelSelector} from "./levels/ui/levelSelector";
|
||||
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 {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
||||
import {ReplayManager} from "./replay/ReplayManager";
|
||||
@ -674,8 +675,43 @@ router.on('/settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate default levels if localStorage is empty
|
||||
generateDefaultLevels();
|
||||
// Initialize registry and start router
|
||||
// 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
|
||||
// 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
|
||||
router.start();
|
||||
// DO NOT start router here - it will be started after registry initialization below
|
||||
|
||||
if (DEBUG_CONTROLLERS) {
|
||||
debugLog('🔍 DEBUG MODE: Running minimal controller test');
|
||||
|
||||
@ -165,7 +165,7 @@ export class StatusScreen {
|
||||
buttonBar.addControl(this._resumeButton);
|
||||
|
||||
// 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.height = "60px";
|
||||
this._nextLevelButton.color = "white";
|
||||
@ -196,10 +196,10 @@ export class StatusScreen {
|
||||
this._onReplayCallback();
|
||||
}
|
||||
});
|
||||
buttonBar.addControl(this._replayButton);
|
||||
buttonBar.addControl(this._replayButton);*/
|
||||
|
||||
// Create Exit VR button
|
||||
this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT VR");
|
||||
this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT");
|
||||
this._exitButton.width = "300px";
|
||||
this._exitButton.height = "60px";
|
||||
this._exitButton.color = "white";
|
||||
|
||||
BIN
themes/default/base2.blend
Normal file
BIN
themes/default/base2.blend
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user