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": [
|
{
|
||||||
{
|
"version": "1.0.5",
|
||||||
"id": 1,
|
"levels": [
|
||||||
"name": "Recruit",
|
{
|
||||||
"Description": "Simple level to get the hang of things",
|
"id": "rookie-training",
|
||||||
"missionbrief": [
|
"name": "Rookie Training",
|
||||||
"Destroy the asteroids",
|
"description": "Simple level to get the hang of things",
|
||||||
"return to base after they're destroyed to complete the mission",
|
"version": "1.0",
|
||||||
"return to base if you need more fuel, ammo, or hull repairs",
|
"levelPath": "rookie-training.json",
|
||||||
"don't get too far from base, if you run out of fuel, you'll be stranded",
|
"difficulty": "recruit",
|
||||||
"don't run into things, it damages your hull"
|
"estimatedTime": "3-5 minutes",
|
||||||
],
|
"missionBrief": [
|
||||||
"leveldata": "/levels/1.json",
|
"Destroy the asteroids",
|
||||||
"defaultlocked": false
|
"Return to base after they're destroyed to complete the mission",
|
||||||
} ,
|
"Return to base if you need more fuel, ammo, or hull repairs",
|
||||||
{
|
"Don't get too far from base, if you run out of fuel, you'll be stranded",
|
||||||
"id": 2,
|
"Don't run into things, it damages your hull"
|
||||||
"name": "Fuel Management",
|
],
|
||||||
"Description": "Don't run out of fuel",
|
"unlockRequirements": [],
|
||||||
"missionbrief": [
|
"tags": ["tutorial", "easy"],
|
||||||
"Astroids are further away and there a more of them",
|
"defaultLocked": false
|
||||||
"you'll need to keep an eye on your fuel levels",
|
},
|
||||||
"return to base after you've destroyed them all"
|
{
|
||||||
],
|
"id": "rescue-mission",
|
||||||
"leveldata": null,
|
"name": "Rescue Mission",
|
||||||
"defaultlocked": true
|
"description": "Rescue operation in moderate asteroid field",
|
||||||
}
|
"version": "1.0",
|
||||||
]
|
"levelPath": "rescue-mission.json",
|
||||||
|
"difficulty": "pilot",
|
||||||
|
"estimatedTime": "5-8 minutes",
|
||||||
|
"missionBrief": [
|
||||||
|
"More asteroids and increased difficulty",
|
||||||
|
"Manage your fuel and ammunition carefully",
|
||||||
|
"Complete the mission and return to base",
|
||||||
|
"Use your radar to track asteroids",
|
||||||
|
"Watch your shield strength"
|
||||||
|
],
|
||||||
|
"unlockRequirements": ["rookie-training"],
|
||||||
|
"tags": ["medium"],
|
||||||
|
"defaultLocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deep-space-patrol",
|
||||||
|
"name": "Deep Space Patrol",
|
||||||
|
"description": "Extended patrol mission in dangerous territory",
|
||||||
|
"version": "1.0",
|
||||||
|
"levelPath": "deep-space-patrol.json",
|
||||||
|
"difficulty": "captain",
|
||||||
|
"estimatedTime": "8-12 minutes",
|
||||||
|
"missionBrief": [
|
||||||
|
"Large asteroid field requiring careful navigation",
|
||||||
|
"Fuel management is critical at this distance",
|
||||||
|
"Return to base for resupply as needed",
|
||||||
|
"Asteroids are faster and more dangerous",
|
||||||
|
"Plan your route carefully"
|
||||||
|
],
|
||||||
|
"unlockRequirements": ["rescue-mission"],
|
||||||
|
"tags": ["hard"],
|
||||||
|
"defaultLocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "enemy-territory",
|
||||||
|
"name": "Enemy Territory",
|
||||||
|
"description": "Hazardous mission in hostile space",
|
||||||
|
"version": "1.0",
|
||||||
|
"levelPath": "enemy-territory.json",
|
||||||
|
"difficulty": "commander",
|
||||||
|
"estimatedTime": "10-15 minutes",
|
||||||
|
"missionBrief": [
|
||||||
|
"Heavily defended asteroid field",
|
||||||
|
"Maximum asteroid count and speed",
|
||||||
|
"Expert fuel and ammunition management required",
|
||||||
|
"Multiple base trips may be necessary",
|
||||||
|
"Test your skills to the limit"
|
||||||
|
],
|
||||||
|
"unlockRequirements": ["deep-space-patrol"],
|
||||||
|
"tags": ["very-hard", "combat"],
|
||||||
|
"defaultLocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "the-gauntlet",
|
||||||
|
"name": "The Gauntlet",
|
||||||
|
"description": "Survive the ultimate asteroid gauntlet",
|
||||||
|
"version": "1.0",
|
||||||
|
"levelPath": "the-gauntlet.json",
|
||||||
|
"difficulty": "commander",
|
||||||
|
"estimatedTime": "12-18 minutes",
|
||||||
|
"missionBrief": [
|
||||||
|
"Dense asteroid field with extreme hazards",
|
||||||
|
"High-speed rocks from all directions",
|
||||||
|
"This is a test of endurance and skill",
|
||||||
|
"Only the best pilots succeed",
|
||||||
|
"May the stars guide you"
|
||||||
|
],
|
||||||
|
"unlockRequirements": ["enemy-territory"],
|
||||||
|
"tags": ["very-hard", "endurance"],
|
||||||
|
"defaultLocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "final-challenge",
|
||||||
|
"name": "Final Challenge",
|
||||||
|
"description": "The ultimate test for master pilots",
|
||||||
|
"version": "1.0",
|
||||||
|
"levelPath": "final-challenge.json",
|
||||||
|
"difficulty": "commander",
|
||||||
|
"estimatedTime": "15-20 minutes",
|
||||||
|
"missionBrief": [
|
||||||
|
"The pinnacle of difficulty",
|
||||||
|
"Everything you've learned will be tested",
|
||||||
|
"Maximum asteroid count and velocity",
|
||||||
|
"Precision flying and resource management essential",
|
||||||
|
"Complete this to prove your mastery",
|
||||||
|
"Good luck, Commander"
|
||||||
|
],
|
||||||
|
"unlockRequirements": ["the-gauntlet"],
|
||||||
|
"tags": ["extreme", "final-boss"],
|
||||||
|
"defaultLocked": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
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 {
|
.editor-link {
|
||||||
|
display: none;
|
||||||
background: rgba(76, 175, 80, 0.8);
|
background: rgba(76, 175, 80, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +170,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-link {
|
.settings-link {
|
||||||
|
display: none;
|
||||||
background: rgba(33, 150, 243, 0.8);
|
background: rgba(33, 150, 243, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -512,6 +514,11 @@ body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TODO: Re-enable copy button functionality for copying default levels to custom levels */
|
||||||
|
.level-button-secondary {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Test Level Button */
|
/* Test Level Button */
|
||||||
.test-level-button {
|
.test-level-button {
|
||||||
background: var(--gradient-danger);
|
background: var(--gradient-danger);
|
||||||
|
|||||||
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
|
* Helper to show/hide views
|
||||||
*/
|
*/
|
||||||
export function showView(viewId: string): void {
|
export function showView(viewId: string): void {
|
||||||
|
console.log('[Router] showView() called with viewId:', viewId);
|
||||||
|
|
||||||
// Hide all views
|
// Hide all views
|
||||||
const views = document.querySelectorAll('[data-view]');
|
const views = document.querySelectorAll('[data-view]');
|
||||||
|
console.log('[Router] Found views:', views.length);
|
||||||
views.forEach(view => {
|
views.forEach(view => {
|
||||||
(view as HTMLElement).style.display = 'none';
|
(view as HTMLElement).style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show requested view
|
// Show requested view
|
||||||
const targetView = document.querySelector(`[data-view="${viewId}"]`);
|
const targetView = document.querySelector(`[data-view="${viewId}"]`);
|
||||||
|
console.log('[Router] Target view found:', !!targetView);
|
||||||
if (targetView) {
|
if (targetView) {
|
||||||
(targetView as HTMLElement).style.display = 'block';
|
(targetView as HTMLElement).style.display = 'block';
|
||||||
|
console.log('[Router] View display set to block');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,7 +77,8 @@ export class RockFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async createRock(i: number, position: Vector3, scale: number,
|
public static async createRock(i: number, position: Vector3, scale: number,
|
||||||
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>): Promise<Rock> {
|
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>,
|
||||||
|
useOrbitConstraint: boolean = true): Promise<Rock> {
|
||||||
|
|
||||||
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
|
const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh);
|
||||||
debugLog(rock.id);
|
debugLog(rock.id);
|
||||||
@ -100,13 +101,32 @@ export class RockFactory {
|
|||||||
// Don't pass radius - let Babylon compute from scaled mesh bounds
|
// Don't pass radius - let Babylon compute from scaled mesh bounds
|
||||||
}, DefaultScene.MainScene);
|
}, DefaultScene.MainScene);
|
||||||
const body = agg.body;
|
const body = agg.body;
|
||||||
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
|
|
||||||
body.addConstraint(this._orbitCenter.body, constraint);
|
// Only apply orbit constraint if enabled for this level
|
||||||
|
if (useOrbitConstraint) {
|
||||||
|
debugLog(`[RockFactory] Applying orbit constraint for ${rock.name}`);
|
||||||
|
const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene);
|
||||||
|
body.addConstraint(this._orbitCenter.body, constraint);
|
||||||
|
} else {
|
||||||
|
debugLog(`[RockFactory] Orbit constraint disabled for ${rock.name} - asteroid will move freely`);
|
||||||
|
}
|
||||||
|
|
||||||
body.setLinearDamping(0)
|
body.setLinearDamping(0)
|
||||||
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
||||||
body.setCollisionCallbackEnabled(true);
|
body.setCollisionCallbackEnabled(true);
|
||||||
|
|
||||||
|
debugLog(`[RockFactory] Setting velocities for ${rock.name}:`);
|
||||||
|
debugLog(`[RockFactory] Linear velocity input: ${linearVelocitry.toString()}`);
|
||||||
|
debugLog(`[RockFactory] Angular velocity input: ${angularVelocity.toString()}`);
|
||||||
|
|
||||||
body.setLinearVelocity(linearVelocitry);
|
body.setLinearVelocity(linearVelocitry);
|
||||||
body.setAngularVelocity(angularVelocity);
|
body.setAngularVelocity(angularVelocity);
|
||||||
|
|
||||||
|
// Verify velocities were set
|
||||||
|
const setLinear = body.getLinearVelocity();
|
||||||
|
const setAngular = body.getAngularVelocity();
|
||||||
|
debugLog(`[RockFactory] Linear velocity after set: ${setLinear.toString()}`);
|
||||||
|
debugLog(`[RockFactory] Angular velocity after set: ${setAngular.toString()}`);
|
||||||
body.getCollisionObservable().add((eventData) => {
|
body.getCollisionObservable().add((eventData) => {
|
||||||
if (eventData.type == 'COLLISION_STARTED') {
|
if (eventData.type == 'COLLISION_STARTED') {
|
||||||
if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
|
if ( eventData.collidedAgainst.transformNode.id == 'ammo') {
|
||||||
|
|||||||
@ -147,6 +147,9 @@ export interface LevelConfig {
|
|||||||
// Optional: include original difficulty config for reference
|
// Optional: include original difficulty config for reference
|
||||||
difficultyConfig?: DifficultyConfig;
|
difficultyConfig?: DifficultyConfig;
|
||||||
|
|
||||||
|
// Physics configuration
|
||||||
|
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
|
||||||
|
|
||||||
// New fields for full scene serialization
|
// New fields for full scene serialization
|
||||||
materials?: MaterialConfig[];
|
materials?: MaterialConfig[];
|
||||||
sceneHierarchy?: SceneNodeConfig[];
|
sceneHierarchy?: SceneNodeConfig[];
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { FireProceduralTexture } from "@babylonjs/procedural-textures";
|
|||||||
import { createSphereLightmap } from "../../environment/celestial/sphereLightmap";
|
import { createSphereLightmap } from "../../environment/celestial/sphereLightmap";
|
||||||
import debugLog from '../../core/debug';
|
import debugLog from '../../core/debug';
|
||||||
import StarBase from "../../environment/stations/starBase";
|
import StarBase from "../../environment/stations/starBase";
|
||||||
|
import {LevelRegistry} from "../storage/levelRegistry";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deserializes a LevelConfig JSON object and creates all entities in the scene
|
* Deserializes a LevelConfig JSON object and creates all entities in the scene
|
||||||
@ -172,6 +173,16 @@ export class LevelDeserializer {
|
|||||||
for (let i = 0; i < this.config.asteroids.length; i++) {
|
for (let i = 0; i < this.config.asteroids.length; i++) {
|
||||||
const asteroidConfig = this.config.asteroids[i];
|
const asteroidConfig = this.config.asteroids[i];
|
||||||
|
|
||||||
|
debugLog(`[LevelDeserializer] Creating asteroid ${i} (${asteroidConfig.id}):`);
|
||||||
|
debugLog(`[LevelDeserializer] Position: [${asteroidConfig.position.join(', ')}]`);
|
||||||
|
debugLog(`[LevelDeserializer] Scale: ${asteroidConfig.scale}`);
|
||||||
|
debugLog(`[LevelDeserializer] Linear velocity: [${asteroidConfig.linearVelocity.join(', ')}]`);
|
||||||
|
debugLog(`[LevelDeserializer] Angular velocity: [${asteroidConfig.angularVelocity.join(', ')}]`);
|
||||||
|
|
||||||
|
// Use orbit constraints by default (true if not specified)
|
||||||
|
const useOrbitConstraints = this.config.useOrbitConstraints !== false;
|
||||||
|
debugLog(`[LevelDeserializer] Use orbit constraints: ${useOrbitConstraints}`);
|
||||||
|
|
||||||
// Use RockFactory to create the asteroid
|
// Use RockFactory to create the asteroid
|
||||||
const rock = await RockFactory.createRock(
|
const rock = await RockFactory.createRock(
|
||||||
i,
|
i,
|
||||||
@ -179,7 +190,8 @@ export class LevelDeserializer {
|
|||||||
asteroidConfig.scale,
|
asteroidConfig.scale,
|
||||||
this.arrayToVector3(asteroidConfig.linearVelocity),
|
this.arrayToVector3(asteroidConfig.linearVelocity),
|
||||||
this.arrayToVector3(asteroidConfig.angularVelocity),
|
this.arrayToVector3(asteroidConfig.angularVelocity),
|
||||||
scoreObservable
|
scoreObservable,
|
||||||
|
useOrbitConstraints
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the actual mesh from the Rock object
|
// Get the actual mesh from the Rock object
|
||||||
@ -246,4 +258,26 @@ export class LevelDeserializer {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static helper to load from Level Registry by ID
|
||||||
|
* This is the preferred method for loading both default and custom levels
|
||||||
|
*/
|
||||||
|
public static async fromRegistry(levelId: string): Promise<LevelDeserializer> {
|
||||||
|
const registry = LevelRegistry.getInstance();
|
||||||
|
|
||||||
|
// Ensure registry is initialized
|
||||||
|
if (!registry.isInitialized()) {
|
||||||
|
await registry.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get level config from registry (loads if not already loaded)
|
||||||
|
const config = await registry.getLevel(levelId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Level not found in registry: ${levelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LevelDeserializer(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { LevelConfig } from "../config/levelConfig";
|
import {ProgressionManager} from "../../game/progression";
|
||||||
import { ProgressionManager } from "../../game/progression";
|
import {GameConfig} from "../../core/gameConfig";
|
||||||
import { GameConfig } from "../../core/gameConfig";
|
import {AuthService} from "../../services/authService";
|
||||||
import { AuthService } from "../../services/authService";
|
|
||||||
import debugLog from '../../core/debug';
|
import debugLog from '../../core/debug';
|
||||||
|
import {LevelRegistry} from "../storage/levelRegistry";
|
||||||
|
import {LevelVersionManager} from "../versioning/levelVersionManager";
|
||||||
|
import {LevelStatsManager} from "../stats/levelStats";
|
||||||
|
|
||||||
const SELECTED_LEVEL_KEY = 'space-game-selected-level';
|
const SELECTED_LEVEL_KEY = 'space-game-selected-level';
|
||||||
|
|
||||||
// Default level order for the carousel
|
// Default level IDs in display order (matches directory.json)
|
||||||
const DEFAULT_LEVEL_ORDER = [
|
const DEFAULT_LEVEL_ORDER = [
|
||||||
'Rookie Training',
|
'rookie-training',
|
||||||
'Rescue Mission',
|
'rescue-mission',
|
||||||
'Deep Space Patrol',
|
'deep-space-patrol',
|
||||||
'Enemy Territory',
|
'enemy-territory',
|
||||||
'The Gauntlet',
|
'the-gauntlet',
|
||||||
'Final Challenge'
|
'final-challenge'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate the level selection screen with saved levels
|
* Populate the level selection screen with levels from registry
|
||||||
* Shows all 6 default levels in a 3x2 carousel with locked/unlocked states
|
* Shows all 6 default levels in a 3x2 carousel with locked/unlocked states
|
||||||
*/
|
*/
|
||||||
export async function populateLevelSelector(): Promise<boolean> {
|
export async function populateLevelSelector(): Promise<boolean> {
|
||||||
|
console.log('[LevelSelector] populateLevelSelector() called');
|
||||||
const container = document.getElementById('levelCardsContainer');
|
const container = document.getElementById('levelCardsContainer');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
console.warn('Level cards container not found');
|
console.warn('[LevelSelector] Level cards container not found');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
console.log('[LevelSelector] Container found:', container);
|
||||||
|
|
||||||
const savedLevels = getSavedLevels();
|
const registry = LevelRegistry.getInstance();
|
||||||
const gameConfig = GameConfig.getInstance();
|
const versionManager = LevelVersionManager.getInstance();
|
||||||
const progressionEnabled = gameConfig.progressionEnabled;
|
const statsManager = LevelStatsManager.getInstance();
|
||||||
const progression = ProgressionManager.getInstance();
|
|
||||||
|
|
||||||
if (savedLevels.size === 0) {
|
// Initialize registry
|
||||||
|
try {
|
||||||
|
console.log('[LevelSelector] Initializing registry...');
|
||||||
|
await registry.initialize();
|
||||||
|
console.log('[LevelSelector] Registry initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LevelSelector] Registry initialization error:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="no-levels-message">
|
<div class="no-levels-message">
|
||||||
<h2>No Levels Found</h2>
|
<h2>Failed to Load Levels</h2>
|
||||||
<p>Something went wrong - default levels should be auto-generated!</p>
|
<p>Could not load level directory. Check your connection and try again.</p>
|
||||||
<a href="#/editor" class="btn-primary">Go to Level Editor</a>
|
<button onclick="location.reload()" class="btn-primary">Reload</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate default and custom levels
|
const gameConfig = GameConfig.getInstance();
|
||||||
const defaultLevels = new Map<string, LevelConfig>();
|
const progressionEnabled = gameConfig.progressionEnabled;
|
||||||
const customLevels = new Map<string, LevelConfig>();
|
const progression = ProgressionManager.getInstance();
|
||||||
|
|
||||||
for (const [name, config] of savedLevels.entries()) {
|
// Update version manager with directory
|
||||||
if (config.metadata?.type === 'default') {
|
const directory = registry.getDirectory();
|
||||||
defaultLevels.set(name, config);
|
if (directory) {
|
||||||
} else {
|
versionManager.updateManifestVersions(directory);
|
||||||
customLevels.set(name, config);
|
}
|
||||||
}
|
|
||||||
|
const defaultLevels = registry.getDefaultLevels();
|
||||||
|
const customLevels = registry.getCustomLevels();
|
||||||
|
|
||||||
|
console.log('[LevelSelector] Default levels:', defaultLevels.size);
|
||||||
|
console.log('[LevelSelector] Custom levels:', customLevels.size);
|
||||||
|
console.log('[LevelSelector] Default level IDs:', Array.from(defaultLevels.keys()));
|
||||||
|
|
||||||
|
if (defaultLevels.size === 0 && customLevels.size === 0) {
|
||||||
|
console.warn('[LevelSelector] No levels found!');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="no-levels-message">
|
||||||
|
<h2>No Levels Found</h2>
|
||||||
|
<p>No levels available. Please check your installation.</p>
|
||||||
|
<a href="#/editor" class="btn-primary">Create Custom Level</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
@ -79,28 +105,26 @@ export async function populateLevelSelector(): Promise<boolean> {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is authenticated (ASYNC!)
|
// Check if user is authenticated
|
||||||
const authService = AuthService.getInstance();
|
const authService = AuthService.getInstance();
|
||||||
const isAuthenticated = await authService.isAuthenticated();
|
const isAuthenticated = await authService.isAuthenticated();
|
||||||
const isTutorial = (levelName: string) => levelName === DEFAULT_LEVEL_ORDER[0];
|
const isTutorial = (levelId: string) => levelId === DEFAULT_LEVEL_ORDER[0];
|
||||||
|
|
||||||
debugLog('[LevelSelector] Authenticated:', isAuthenticated);
|
debugLog('[LevelSelector] Authenticated:', isAuthenticated);
|
||||||
debugLog('[LevelSelector] Progression enabled:', progressionEnabled);
|
debugLog('[LevelSelector] Progression enabled:', progressionEnabled);
|
||||||
debugLog('[LevelSelector] Tutorial level name:', DEFAULT_LEVEL_ORDER[0]);
|
|
||||||
debugLog('[LevelSelector] Default levels count:', defaultLevels.size);
|
debugLog('[LevelSelector] Default levels count:', defaultLevels.size);
|
||||||
debugLog('[LevelSelector] Default level names:', Array.from(defaultLevels.keys()));
|
|
||||||
|
|
||||||
// Show all 6 default levels in order (3x2 grid)
|
// Show all default levels in order (3x2 grid)
|
||||||
if (defaultLevels.size > 0) {
|
if (defaultLevels.size > 0) {
|
||||||
for (const levelName of DEFAULT_LEVEL_ORDER) {
|
for (const levelId of DEFAULT_LEVEL_ORDER) {
|
||||||
const config = defaultLevels.get(levelName);
|
const entry = defaultLevels.get(levelId);
|
||||||
|
|
||||||
if (!config) {
|
if (!entry) {
|
||||||
// Level doesn't exist - show empty slot
|
// Level doesn't exist - show empty slot
|
||||||
html += `
|
html += `
|
||||||
<div class="level-card level-card-locked">
|
<div class="level-card level-card-locked">
|
||||||
<div class="level-card-header">
|
<div class="level-card-header">
|
||||||
<h2 class="level-card-title">${levelName}</h2>
|
<h2 class="level-card-title">Missing Level</h2>
|
||||||
<div class="level-card-status level-card-status-locked">🔒</div>
|
<div class="level-card-status level-card-status-locked">🔒</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-meta">Level not found</div>
|
<div class="level-meta">Level not found</div>
|
||||||
@ -111,57 +135,72 @@ export async function populateLevelSelector(): Promise<boolean> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
const dirEntry = entry.directoryEntry;
|
||||||
const estimatedTime = config.metadata?.estimatedTime || '';
|
const levelName = dirEntry.name;
|
||||||
|
const description = dirEntry.description;
|
||||||
|
const estimatedTime = dirEntry.estimatedTime || '';
|
||||||
|
const difficulty = dirEntry.difficulty || 'unknown';
|
||||||
|
|
||||||
|
// Check for version updates
|
||||||
|
const hasUpdate = versionManager.hasUpdate(levelId);
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const stats = statsManager.getStats(levelId);
|
||||||
|
const completionRate = stats?.completionRate || 0;
|
||||||
|
const bestTime = stats?.bestTimeSeconds;
|
||||||
|
|
||||||
|
// Check progression
|
||||||
const isCompleted = progressionEnabled && progression.isLevelComplete(levelName);
|
const isCompleted = progressionEnabled && progression.isLevelComplete(levelName);
|
||||||
|
|
||||||
// Check if level is unlocked:
|
// Check if level is unlocked
|
||||||
// - Tutorial is always unlocked
|
|
||||||
// - If authenticated: check progression unlock status
|
|
||||||
// - If not authenticated: only Tutorial is unlocked
|
|
||||||
let isUnlocked = false;
|
let isUnlocked = false;
|
||||||
const isTut = isTutorial(levelName);
|
const isTut = isTutorial(levelId);
|
||||||
|
|
||||||
if (isTut) {
|
if (isTut) {
|
||||||
isUnlocked = true; // Tutorial always unlocked
|
isUnlocked = true; // Tutorial always unlocked
|
||||||
debugLog(`[LevelSelector] ${levelName}: Tutorial - always unlocked`);
|
|
||||||
} else if (!isAuthenticated) {
|
} else if (!isAuthenticated) {
|
||||||
isUnlocked = false; // Non-tutorial levels require authentication
|
isUnlocked = false; // Non-tutorial levels require authentication
|
||||||
debugLog(`[LevelSelector] ${levelName}: Not authenticated - locked`);
|
|
||||||
} else {
|
} else {
|
||||||
isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName);
|
isUnlocked = !progressionEnabled || progression.isLevelUnlocked(levelName);
|
||||||
debugLog(`[LevelSelector] ${levelName}: Authenticated - unlocked:`, isUnlocked);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName;
|
const isCurrentNext = progressionEnabled && progression.getNextLevel() === levelName;
|
||||||
|
|
||||||
// Determine card state
|
// Determine card state
|
||||||
let cardClasses = 'level-card';
|
let cardClasses = 'level-card';
|
||||||
let statusIcon = '';
|
let statusIcons = '';
|
||||||
let buttonText = 'Play Level';
|
let buttonText = 'Play Level';
|
||||||
let buttonDisabled = '';
|
let buttonDisabled = '';
|
||||||
let lockReason = '';
|
let lockReason = '';
|
||||||
|
let metaTags = '';
|
||||||
|
|
||||||
|
// Version update badge
|
||||||
|
if (hasUpdate) {
|
||||||
|
statusIcons += '<div class="level-card-badge level-card-badge-update">UPDATED</div>';
|
||||||
|
}
|
||||||
|
|
||||||
if (isCompleted) {
|
if (isCompleted) {
|
||||||
cardClasses += ' level-card-completed';
|
cardClasses += ' level-card-completed';
|
||||||
statusIcon = '<div class="level-card-status level-card-status-complete">✓</div>';
|
statusIcons += '<div class="level-card-status level-card-status-complete">✓</div>';
|
||||||
buttonText = 'Replay';
|
buttonText = 'Replay';
|
||||||
} else if (isCurrentNext && isUnlocked) {
|
} else if (isCurrentNext && isUnlocked) {
|
||||||
cardClasses += ' level-card-current';
|
cardClasses += ' level-card-current';
|
||||||
statusIcon = '<div class="level-card-badge">START HERE</div>';
|
statusIcons += '<div class="level-card-badge">START HERE</div>';
|
||||||
} else if (!isUnlocked) {
|
} else if (!isUnlocked) {
|
||||||
cardClasses += ' level-card-locked';
|
cardClasses += ' level-card-locked';
|
||||||
statusIcon = '<div class="level-card-status level-card-status-locked">🔒</div>';
|
statusIcons += '<div class="level-card-status level-card-status-locked">🔒</div>';
|
||||||
|
|
||||||
// Determine why it's locked
|
// Determine why it's locked
|
||||||
if (!isAuthenticated && !isTutorial(levelName)) {
|
if (!isAuthenticated && !isTutorial(levelId)) {
|
||||||
buttonText = 'Sign In Required';
|
buttonText = 'Sign In Required';
|
||||||
lockReason = '<div class="level-lock-reason">Sign in to unlock</div>';
|
lockReason = '<div class="level-lock-reason">Sign in to unlock</div>';
|
||||||
} else if (progressionEnabled) {
|
} else if (progressionEnabled) {
|
||||||
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelName);
|
const levelIndex = DEFAULT_LEVEL_ORDER.indexOf(levelId);
|
||||||
if (levelIndex > 0) {
|
if (levelIndex > 0) {
|
||||||
const previousLevel = DEFAULT_LEVEL_ORDER[levelIndex - 1];
|
const prevId = DEFAULT_LEVEL_ORDER[levelIndex - 1];
|
||||||
lockReason = `<div class="level-lock-reason">Complete "${previousLevel}" to unlock</div>`;
|
const prevEntry = defaultLevels.get(prevId);
|
||||||
|
const prevName = prevEntry?.directoryEntry.name || 'previous level';
|
||||||
|
lockReason = `<div class="level-lock-reason">Complete "${prevName}" to unlock</div>`;
|
||||||
}
|
}
|
||||||
buttonText = 'Locked';
|
buttonText = 'Locked';
|
||||||
} else {
|
} else {
|
||||||
@ -170,18 +209,35 @@ export async function populateLevelSelector(): Promise<boolean> {
|
|||||||
buttonDisabled = ' disabled';
|
buttonDisabled = ' disabled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show stats if available
|
||||||
|
if (stats && stats.totalAttempts > 0) {
|
||||||
|
metaTags = '<div class="level-stats">';
|
||||||
|
if (bestTime) {
|
||||||
|
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
|
||||||
|
}
|
||||||
|
if (stats.totalCompletions > 0) {
|
||||||
|
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
|
||||||
|
}
|
||||||
|
metaTags += `<span class="stat-badge">${LevelStatsManager.formatCompletionRate(completionRate)}</span>`;
|
||||||
|
metaTags += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="${cardClasses}">
|
<div class="${cardClasses}">
|
||||||
<div class="level-card-header">
|
<div class="level-card-header">
|
||||||
<h2 class="level-card-title">${levelName}</h2>
|
<h2 class="level-card-title">${levelName}</h2>
|
||||||
${statusIcon}
|
<div class="level-card-badges">${statusIcons}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-meta">
|
<div class="level-meta">
|
||||||
Difficulty: ${config.difficulty}${estimatedTime ? ` • ${estimatedTime}` : ''}
|
Difficulty: ${difficulty}${estimatedTime ? ` • ${estimatedTime}` : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="level-card-description">${description}</p>
|
<p class="level-card-description">${description}</p>
|
||||||
|
${metaTags}
|
||||||
${lockReason}
|
${lockReason}
|
||||||
<button class="level-button" data-level="${levelName}"${buttonDisabled}>${buttonText}</button>
|
<div class="level-card-actions">
|
||||||
|
<button class="level-button" data-level-id="${levelId}"${buttonDisabled}>${buttonText}</button>
|
||||||
|
${entry.isDefault && isUnlocked ? `<button class="level-button-secondary" data-copy-level="${levelId}" title="Copy to custom levels">📋 Copy</button>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -195,68 +251,165 @@ export async function populateLevelSelector(): Promise<boolean> {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
for (const [name, config] of customLevels.entries()) {
|
for (const [levelId, entry] of customLevels.entries()) {
|
||||||
const description = config.metadata?.description || `${config.asteroids.length} asteroids • ${config.planets.length} planets`;
|
const config = entry.config;
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
const description = config.metadata?.description || `${config.asteroids.length} asteroids`;
|
||||||
const author = config.metadata?.author ? ` by ${config.metadata.author}` : '';
|
const author = config.metadata?.author ? ` by ${config.metadata.author}` : '';
|
||||||
|
const difficulty = config.difficulty || 'custom';
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const stats = statsManager.getStats(levelId);
|
||||||
|
const bestTime = stats?.bestTimeSeconds;
|
||||||
|
let metaTags = '';
|
||||||
|
|
||||||
|
if (stats && stats.totalAttempts > 0) {
|
||||||
|
metaTags = '<div class="level-stats">';
|
||||||
|
if (bestTime) {
|
||||||
|
metaTags += `<span class="stat-badge">⏱️ ${LevelStatsManager.formatTime(bestTime)}</span>`;
|
||||||
|
}
|
||||||
|
if (stats.totalCompletions > 0) {
|
||||||
|
metaTags += `<span class="stat-badge">✓ ${stats.totalCompletions}</span>`;
|
||||||
|
}
|
||||||
|
metaTags += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="level-card">
|
<div class="level-card">
|
||||||
<div class="level-card-header">
|
<div class="level-card-header">
|
||||||
<h2 class="level-card-title">${name}</h2>
|
<h2 class="level-card-title">${levelId}</h2>
|
||||||
|
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-meta">
|
<div class="level-meta">
|
||||||
Custom${author} • ${config.difficulty}
|
${difficulty}${author}
|
||||||
</div>
|
</div>
|
||||||
<p class="level-card-description">${description}</p>
|
<p class="level-card-description">${description}</p>
|
||||||
<button class="level-button" data-level="${name}">Play Level</button>
|
${metaTags}
|
||||||
|
<div class="level-card-actions">
|
||||||
|
<button class="level-button" data-level-id="${levelId}">Play Level</button>
|
||||||
|
<button class="level-button-secondary" data-delete-level="${levelId}" title="Delete level">🗑️</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[LevelSelector] Setting container innerHTML, html length:', html.length);
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
console.log('[LevelSelector] Container innerHTML set, now attaching event listeners');
|
||||||
|
|
||||||
// Attach event listeners to all level buttons
|
// Attach event listeners to all level buttons
|
||||||
const buttons = container.querySelectorAll('.level-button:not([disabled])');
|
const playButtons = container.querySelectorAll('.level-button:not([disabled])');
|
||||||
buttons.forEach(button => {
|
playButtons.forEach(button => {
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
const target = e.target as HTMLButtonElement;
|
const target = e.target as HTMLButtonElement;
|
||||||
const levelName = target.getAttribute('data-level');
|
const levelId = target.getAttribute('data-level-id');
|
||||||
if (levelName) {
|
if (levelId) {
|
||||||
selectLevel(levelName);
|
selectLevel(levelId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach copy button listeners
|
||||||
|
const copyButtons = container.querySelectorAll('[data-copy-level]');
|
||||||
|
copyButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
const target = e.target as HTMLButtonElement;
|
||||||
|
const levelId = target.getAttribute('data-copy-level');
|
||||||
|
if (levelId) {
|
||||||
|
await copyLevelToCustom(levelId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach delete button listeners
|
||||||
|
const deleteButtons = container.querySelectorAll('[data-delete-level]');
|
||||||
|
deleteButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLButtonElement;
|
||||||
|
const levelId = target.getAttribute('data-delete-level');
|
||||||
|
if (levelId) {
|
||||||
|
deleteCustomLevel(levelId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[LevelSelector] Event listeners attached, returning true');
|
||||||
|
|
||||||
|
// Make the level selector visible by adding 'ready' class
|
||||||
|
const levelSelectDiv = document.getElementById('levelSelect');
|
||||||
|
if (levelSelectDiv) {
|
||||||
|
levelSelectDiv.classList.add('ready');
|
||||||
|
console.log('[LevelSelector] Added "ready" class to #levelSelect');
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a default level to custom levels
|
||||||
|
*/
|
||||||
|
async function copyLevelToCustom(levelId: string): Promise<void> {
|
||||||
|
const registry = LevelRegistry.getInstance();
|
||||||
|
const customName = prompt(`Enter a name for your copy of this level:`, `${levelId}-copy`);
|
||||||
|
|
||||||
|
if (!customName || customName.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await registry.copyDefaultToCustom(levelId, customName);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
alert(`Level copied as "${customName}"!`);
|
||||||
|
await populateLevelSelector(); // Refresh UI
|
||||||
|
} else {
|
||||||
|
alert('Failed to copy level. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a custom level
|
||||||
|
*/
|
||||||
|
function deleteCustomLevel(levelId: string): void {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${levelId}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = LevelRegistry.getInstance();
|
||||||
|
const success = registry.deleteCustomLevel(levelId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
populateLevelSelector(); // Refresh UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a level and dispatch event to start it
|
* Select a level and dispatch event to start it
|
||||||
*/
|
*/
|
||||||
export function selectLevel(levelName: string): void {
|
export async function selectLevel(levelId: string): Promise<void> {
|
||||||
debugLog(`[LevelSelector] Level selected: ${levelName}`);
|
debugLog(`[LevelSelector] Level selected: ${levelId}`);
|
||||||
|
|
||||||
const savedLevels = getSavedLevels();
|
const registry = LevelRegistry.getInstance();
|
||||||
const config = savedLevels.get(levelName);
|
const config = await registry.getLevel(levelId);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
console.error(`Level not found: ${levelName}`);
|
console.error(`Level not found: ${levelId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save selected level
|
// Save selected level
|
||||||
localStorage.setItem(SELECTED_LEVEL_KEY, levelName);
|
localStorage.setItem(SELECTED_LEVEL_KEY, levelId);
|
||||||
|
|
||||||
// Dispatch custom event that Main class will listen for
|
// Dispatch custom event that Main class will listen for
|
||||||
const event = new CustomEvent('levelSelected', {
|
const event = new CustomEvent('levelSelected', {
|
||||||
detail: { levelName, config }
|
detail: {levelId, config}
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the last selected level name
|
* Get the last selected level ID
|
||||||
*/
|
*/
|
||||||
export function getSelectedLevel(): string | null {
|
export function getSelectedLevel(): string | null {
|
||||||
return localStorage.getItem(SELECTED_LEVEL_KEY);
|
return localStorage.getItem(SELECTED_LEVEL_KEY);
|
||||||
|
|||||||
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 {router, showView} from "./core/router";
|
||||||
import {populateLevelSelector} from "./levels/ui/levelSelector";
|
import {populateLevelSelector} from "./levels/ui/levelSelector";
|
||||||
import {LevelConfig} from "./levels/config/levelConfig";
|
import {LevelConfig} from "./levels/config/levelConfig";
|
||||||
import {generateDefaultLevels} from "./levels/generation/levelEditor";
|
import {LegacyMigration} from "./levels/migration/legacyMigration";
|
||||||
|
import {LevelRegistry} from "./levels/storage/levelRegistry";
|
||||||
import debugLog from './core/debug';
|
import debugLog from './core/debug';
|
||||||
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
import {ReplaySelectionScreen} from "./replay/ReplaySelectionScreen";
|
||||||
import {ReplayManager} from "./replay/ReplayManager";
|
import {ReplayManager} from "./replay/ReplayManager";
|
||||||
@ -674,8 +675,43 @@ router.on('/settings', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate default levels if localStorage is empty
|
// Initialize registry and start router
|
||||||
generateDefaultLevels();
|
// This must happen BEFORE router.start() so levels are available
|
||||||
|
async function initializeApp() {
|
||||||
|
// Check for legacy data migration
|
||||||
|
if (LegacyMigration.needsMigration()) {
|
||||||
|
debugLog('[Main] Legacy data detected - showing migration modal');
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
LegacyMigration.showMigrationModal(async (result) => {
|
||||||
|
debugLog('[Main] Migration completed:', result);
|
||||||
|
// Initialize the new registry system
|
||||||
|
try {
|
||||||
|
await LevelRegistry.getInstance().initialize();
|
||||||
|
debugLog('[Main] LevelRegistry initialized after migration');
|
||||||
|
router.start();
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Main] Failed to initialize LevelRegistry after migration:', error);
|
||||||
|
router.start(); // Start anyway to show error state
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Initialize the new registry system
|
||||||
|
try {
|
||||||
|
await LevelRegistry.getInstance().initialize();
|
||||||
|
debugLog('[Main] LevelRegistry initialized');
|
||||||
|
router.start();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Main] Failed to initialize LevelRegistry:', error);
|
||||||
|
router.start(); // Start anyway to show error state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the app
|
||||||
|
initializeApp();
|
||||||
|
|
||||||
// Suppress non-critical BabylonJS shader loading errors during development
|
// Suppress non-critical BabylonJS shader loading errors during development
|
||||||
// Note: After Vite config fix to pre-bundle shaders, these errors should no longer occur
|
// Note: After Vite config fix to pre-bundle shaders, these errors should no longer occur
|
||||||
@ -694,8 +730,7 @@ window.addEventListener('unhandledrejection', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the router after all routes are registered
|
// DO NOT start router here - it will be started after registry initialization below
|
||||||
router.start();
|
|
||||||
|
|
||||||
if (DEBUG_CONTROLLERS) {
|
if (DEBUG_CONTROLLERS) {
|
||||||
debugLog('🔍 DEBUG MODE: Running minimal controller test');
|
debugLog('🔍 DEBUG MODE: Running minimal controller test');
|
||||||
|
|||||||
@ -165,7 +165,7 @@ export class StatusScreen {
|
|||||||
buttonBar.addControl(this._resumeButton);
|
buttonBar.addControl(this._resumeButton);
|
||||||
|
|
||||||
// Create Next Level button (only shown when game has ended and there's a next level)
|
// Create Next Level button (only shown when game has ended and there's a next level)
|
||||||
this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL");
|
/*this._nextLevelButton = Button.CreateSimpleButton("nextLevelButton", "NEXT LEVEL");
|
||||||
this._nextLevelButton.width = "300px";
|
this._nextLevelButton.width = "300px";
|
||||||
this._nextLevelButton.height = "60px";
|
this._nextLevelButton.height = "60px";
|
||||||
this._nextLevelButton.color = "white";
|
this._nextLevelButton.color = "white";
|
||||||
@ -196,10 +196,10 @@ export class StatusScreen {
|
|||||||
this._onReplayCallback();
|
this._onReplayCallback();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
buttonBar.addControl(this._replayButton);
|
buttonBar.addControl(this._replayButton);*/
|
||||||
|
|
||||||
// Create Exit VR button
|
// Create Exit VR button
|
||||||
this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT VR");
|
this._exitButton = Button.CreateSimpleButton("exitButton", "EXIT");
|
||||||
this._exitButton.width = "300px";
|
this._exitButton.width = "300px";
|
||||||
this._exitButton.height = "60px";
|
this._exitButton.height = "60px";
|
||||||
this._exitButton.color = "white";
|
this._exitButton.color = "white";
|
||||||
|
|||||||
BIN
themes/default/base2.blend
Normal file
BIN
themes/default/base2.blend
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user