diff --git a/assets/gamedata/zuma/level-001.json b/assets/gamedata/zuma/level-001.json
new file mode 100644
index 0000000..fb274a3
--- /dev/null
+++ b/assets/gamedata/zuma/level-001.json
@@ -0,0 +1,160 @@
+{
+ "level": 1,
+ "name": "Temple Gate",
+ "frog": [
+ 960,
+ 580
+ ],
+ "colors": 4,
+ "seed": 16838,
+ "starScores": [
+ 820,
+ 1230,
+ 1640
+ ],
+ "paths": [
+ {
+ "shape": "horseshoe",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ 60,
+ 130
+ ],
+ [
+ 960,
+ 130
+ ],
+ [
+ 1191,
+ 154
+ ],
+ [
+ 1398,
+ 210
+ ],
+ [
+ 1565,
+ 294
+ ],
+ [
+ 1680,
+ 399
+ ],
+ [
+ 1734,
+ 514
+ ],
+ [
+ 1727,
+ 632
+ ],
+ [
+ 1659,
+ 742
+ ],
+ [
+ 1538,
+ 836
+ ],
+ [
+ 1375,
+ 908
+ ],
+ [
+ 1185,
+ 951
+ ],
+ [
+ 983,
+ 964
+ ],
+ [
+ 785,
+ 946
+ ],
+ [
+ 607,
+ 900
+ ],
+ [
+ 462,
+ 831
+ ],
+ [
+ 361,
+ 743
+ ],
+ [
+ 311,
+ 646
+ ],
+ [
+ 313,
+ 547
+ ],
+ [
+ 366,
+ 454
+ ],
+ [
+ 464,
+ 374
+ ],
+ [
+ 598,
+ 313
+ ],
+ [
+ 755,
+ 275
+ ],
+ [
+ 923,
+ 263
+ ],
+ [
+ 1087,
+ 276
+ ],
+ [
+ 1235,
+ 312
+ ],
+ [
+ 1356,
+ 368
+ ],
+ [
+ 1441,
+ 438
+ ],
+ [
+ 1485,
+ 517
+ ],
+ [
+ 1485,
+ 598
+ ],
+ [
+ 1445,
+ 673
+ ],
+ [
+ 1368,
+ 739
+ ]
+ ],
+ "tunnels": [],
+ "quota": 26,
+ "introBalls": 8,
+ "pushSpeed": 76,
+ "powerUpRate": 0.07
+ }
+ ],
+ "background": "zuma-background-2"
+}
diff --git a/assets/gamedata/zuma/level-002.json b/assets/gamedata/zuma/level-002.json
new file mode 100644
index 0000000..b5aaeff
--- /dev/null
+++ b/assets/gamedata/zuma/level-002.json
@@ -0,0 +1,75 @@
+{
+ "level": 2,
+ "name": "Temple River",
+ "frog": [
+ 1626,
+ 805
+ ],
+ "colors": 4,
+ "seed": 12345,
+ "starScores": [
+ 640,
+ 960,
+ 1280
+ ],
+ "paths": [
+ {
+ "shape": "custom",
+ "points": [
+ [
+ -80,
+ 300
+ ],
+ [
+ 650,
+ 65
+ ],
+ [
+ 931,
+ 83
+ ],
+ [
+ 993,
+ 327
+ ],
+ [
+ 441,
+ 559
+ ],
+ [
+ 423,
+ 821
+ ],
+ [
+ 767,
+ 838
+ ],
+ [
+ 1292,
+ 130
+ ],
+ [
+ 1566,
+ 73
+ ],
+ [
+ 1624,
+ 288
+ ],
+ [
+ 1431,
+ 544
+ ],
+ [
+ 1247,
+ 907
+ ]
+ ],
+ "tunnels": [],
+ "quota": 24,
+ "introBalls": 8,
+ "pushSpeed": 45,
+ "powerUpRate": 0.06
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-003.json b/assets/gamedata/zuma/level-003.json
new file mode 100644
index 0000000..25e8192
--- /dev/null
+++ b/assets/gamedata/zuma/level-003.json
@@ -0,0 +1,400 @@
+{
+ "level": 3,
+ "name": "Twin Pools",
+ "frog": [
+ 620,
+ 600
+ ],
+ "colors": 4,
+ "background": "zuma-background-1",
+ "seed": 24757,
+ "starScores": [
+ 970,
+ 1460,
+ 1940
+ ],
+ "paths": [
+ {
+ "shape": "doubleLoop",
+ "points": [
+ [
+ -80,
+ 300
+ ],
+ [
+ -10,
+ 300
+ ],
+ [
+ 60,
+ 300
+ ],
+ [
+ 130,
+ 300
+ ],
+ [
+ 200,
+ 300
+ ],
+ [
+ 270,
+ 300
+ ],
+ [
+ 340,
+ 300
+ ],
+ [
+ 410,
+ 300
+ ],
+ [
+ 480,
+ 300
+ ],
+ [
+ 550,
+ 300
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 689,
+ 308
+ ],
+ [
+ 755,
+ 332
+ ],
+ [
+ 813,
+ 370
+ ],
+ [
+ 861,
+ 421
+ ],
+ [
+ 895,
+ 481
+ ],
+ [
+ 915,
+ 548
+ ],
+ [
+ 919,
+ 617
+ ],
+ [
+ 907,
+ 686
+ ],
+ [
+ 880,
+ 750
+ ],
+ [
+ 838,
+ 806
+ ],
+ [
+ 785,
+ 851
+ ],
+ [
+ 723,
+ 882
+ ],
+ [
+ 655,
+ 898
+ ],
+ [
+ 585,
+ 898
+ ],
+ [
+ 517,
+ 882
+ ],
+ [
+ 455,
+ 851
+ ],
+ [
+ 402,
+ 806
+ ],
+ [
+ 360,
+ 750
+ ],
+ [
+ 333,
+ 686
+ ],
+ [
+ 321,
+ 617
+ ],
+ [
+ 325,
+ 548
+ ],
+ [
+ 345,
+ 481
+ ],
+ [
+ 379,
+ 421
+ ],
+ [
+ 427,
+ 370
+ ],
+ [
+ 485,
+ 332
+ ],
+ [
+ 551,
+ 308
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 691,
+ 300
+ ],
+ [
+ 762,
+ 300
+ ],
+ [
+ 833,
+ 300
+ ],
+ [
+ 904,
+ 300
+ ],
+ [
+ 975,
+ 300
+ ],
+ [
+ 1045,
+ 300
+ ],
+ [
+ 1116,
+ 300
+ ],
+ [
+ 1187,
+ 300
+ ],
+ [
+ 1258,
+ 300
+ ],
+ [
+ 1329,
+ 300
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1469,
+ 308
+ ],
+ [
+ 1535,
+ 332
+ ],
+ [
+ 1593,
+ 370
+ ],
+ [
+ 1641,
+ 421
+ ],
+ [
+ 1675,
+ 481
+ ],
+ [
+ 1695,
+ 548
+ ],
+ [
+ 1699,
+ 617
+ ],
+ [
+ 1687,
+ 686
+ ],
+ [
+ 1660,
+ 750
+ ],
+ [
+ 1618,
+ 806
+ ],
+ [
+ 1565,
+ 851
+ ],
+ [
+ 1503,
+ 882
+ ],
+ [
+ 1435,
+ 898
+ ],
+ [
+ 1365,
+ 898
+ ],
+ [
+ 1297,
+ 882
+ ],
+ [
+ 1235,
+ 851
+ ],
+ [
+ 1182,
+ 806
+ ],
+ [
+ 1140,
+ 750
+ ],
+ [
+ 1113,
+ 686
+ ],
+ [
+ 1101,
+ 617
+ ],
+ [
+ 1105,
+ 548
+ ],
+ [
+ 1125,
+ 481
+ ],
+ [
+ 1159,
+ 421
+ ],
+ [
+ 1207,
+ 370
+ ],
+ [
+ 1265,
+ 332
+ ],
+ [
+ 1331,
+ 308
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1473,
+ 300
+ ],
+ [
+ 1547,
+ 300
+ ],
+ [
+ 1620,
+ 300
+ ],
+ [
+ 1691,
+ 311
+ ],
+ [
+ 1755,
+ 344
+ ],
+ [
+ 1806,
+ 395
+ ],
+ [
+ 1839,
+ 459
+ ],
+ [
+ 1850,
+ 530
+ ],
+ [
+ 1839,
+ 601
+ ],
+ [
+ 1806,
+ 665
+ ],
+ [
+ 1755,
+ 716
+ ],
+ [
+ 1691,
+ 749
+ ],
+ [
+ 1620,
+ 760
+ ],
+ [
+ 1555,
+ 760
+ ],
+ [
+ 1490,
+ 760
+ ],
+ [
+ 1425,
+ 760
+ ],
+ [
+ 1360,
+ 760
+ ]
+ ],
+ "tunnels": [],
+ "quota": 30,
+ "introBalls": 9,
+ "pushSpeed": 84,
+ "powerUpRate": 0.065
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-004.json b/assets/gamedata/zuma/level-004.json
new file mode 100644
index 0000000..000babf
--- /dev/null
+++ b/assets/gamedata/zuma/level-004.json
@@ -0,0 +1,364 @@
+{
+ "level": 4,
+ "name": "Switchbacks",
+ "frog": [
+ 1620,
+ 550
+ ],
+ "colors": 4,
+ "background": "zuma-background-2",
+ "seed": 32676,
+ "starScores": [
+ 1040,
+ 1550,
+ 2070
+ ],
+ "paths": [
+ {
+ "shape": "zigzag",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ -11,
+ 130
+ ],
+ [
+ 58,
+ 130
+ ],
+ [
+ 126,
+ 130
+ ],
+ [
+ 195,
+ 130
+ ],
+ [
+ 264,
+ 130
+ ],
+ [
+ 333,
+ 130
+ ],
+ [
+ 401,
+ 130
+ ],
+ [
+ 470,
+ 130
+ ],
+ [
+ 539,
+ 130
+ ],
+ [
+ 608,
+ 130
+ ],
+ [
+ 676,
+ 130
+ ],
+ [
+ 745,
+ 130
+ ],
+ [
+ 814,
+ 130
+ ],
+ [
+ 882,
+ 130
+ ],
+ [
+ 951,
+ 130
+ ],
+ [
+ 1020,
+ 130
+ ],
+ [
+ 1089,
+ 130
+ ],
+ [
+ 1158,
+ 130
+ ],
+ [
+ 1226,
+ 130
+ ],
+ [
+ 1295,
+ 130
+ ],
+ [
+ 1365,
+ 149
+ ],
+ [
+ 1416,
+ 200
+ ],
+ [
+ 1435,
+ 270
+ ],
+ [
+ 1416,
+ 340
+ ],
+ [
+ 1365,
+ 391
+ ],
+ [
+ 1295,
+ 410
+ ],
+ [
+ 1225,
+ 410
+ ],
+ [
+ 1156,
+ 410
+ ],
+ [
+ 1086,
+ 410
+ ],
+ [
+ 1016,
+ 410
+ ],
+ [
+ 947,
+ 410
+ ],
+ [
+ 877,
+ 410
+ ],
+ [
+ 807,
+ 410
+ ],
+ [
+ 738,
+ 410
+ ],
+ [
+ 668,
+ 410
+ ],
+ [
+ 598,
+ 410
+ ],
+ [
+ 529,
+ 410
+ ],
+ [
+ 459,
+ 410
+ ],
+ [
+ 389,
+ 410
+ ],
+ [
+ 320,
+ 410
+ ],
+ [
+ 250,
+ 410
+ ],
+ [
+ 180,
+ 429
+ ],
+ [
+ 129,
+ 480
+ ],
+ [
+ 110,
+ 550
+ ],
+ [
+ 129,
+ 620
+ ],
+ [
+ 180,
+ 671
+ ],
+ [
+ 250,
+ 690
+ ],
+ [
+ 320,
+ 690
+ ],
+ [
+ 389,
+ 690
+ ],
+ [
+ 459,
+ 690
+ ],
+ [
+ 529,
+ 690
+ ],
+ [
+ 598,
+ 690
+ ],
+ [
+ 668,
+ 690
+ ],
+ [
+ 738,
+ 690
+ ],
+ [
+ 807,
+ 690
+ ],
+ [
+ 877,
+ 690
+ ],
+ [
+ 947,
+ 690
+ ],
+ [
+ 1016,
+ 690
+ ],
+ [
+ 1086,
+ 690
+ ],
+ [
+ 1156,
+ 690
+ ],
+ [
+ 1225,
+ 690
+ ],
+ [
+ 1295,
+ 690
+ ],
+ [
+ 1365,
+ 709
+ ],
+ [
+ 1416,
+ 760
+ ],
+ [
+ 1435,
+ 830
+ ],
+ [
+ 1416,
+ 900
+ ],
+ [
+ 1365,
+ 951
+ ],
+ [
+ 1295,
+ 970
+ ],
+ [
+ 1225,
+ 970
+ ],
+ [
+ 1156,
+ 970
+ ],
+ [
+ 1086,
+ 970
+ ],
+ [
+ 1016,
+ 970
+ ],
+ [
+ 947,
+ 970
+ ],
+ [
+ 877,
+ 970
+ ],
+ [
+ 807,
+ 970
+ ],
+ [
+ 738,
+ 970
+ ],
+ [
+ 668,
+ 970
+ ],
+ [
+ 598,
+ 970
+ ],
+ [
+ 529,
+ 970
+ ],
+ [
+ 459,
+ 970
+ ],
+ [
+ 389,
+ 970
+ ],
+ [
+ 320,
+ 970
+ ],
+ [
+ 250,
+ 970
+ ]
+ ],
+ "tunnels": [],
+ "quota": 32,
+ "introBalls": 10,
+ "pushSpeed": 84,
+ "powerUpRate": 0.065
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-005.json b/assets/gamedata/zuma/level-005.json
new file mode 100644
index 0000000..6f6bfcf
--- /dev/null
+++ b/assets/gamedata/zuma/level-005.json
@@ -0,0 +1,221 @@
+{
+ "level": 5,
+ "name": "Serpent Coil",
+ "frog": [
+ 960,
+ 575
+ ],
+ "colors": 4,
+ "background": "zuma-background-1",
+ "seed": 40595,
+ "starScores": [
+ 1250,
+ 1870,
+ 2490
+ ],
+ "paths": [
+ {
+ "shape": "spiral",
+ "points": [
+ [
+ -80,
+ 120
+ ],
+ [
+ 58,
+ 120
+ ],
+ [
+ 960,
+ 120
+ ],
+ [
+ 1195,
+ 143
+ ],
+ [
+ 1406,
+ 201
+ ],
+ [
+ 1577,
+ 287
+ ],
+ [
+ 1694,
+ 395
+ ],
+ [
+ 1749,
+ 514
+ ],
+ [
+ 1739,
+ 635
+ ],
+ [
+ 1667,
+ 749
+ ],
+ [
+ 1541,
+ 846
+ ],
+ [
+ 1370,
+ 919
+ ],
+ [
+ 1171,
+ 962
+ ],
+ [
+ 960,
+ 973
+ ],
+ [
+ 754,
+ 952
+ ],
+ [
+ 571,
+ 901
+ ],
+ [
+ 423,
+ 826
+ ],
+ [
+ 322,
+ 732
+ ],
+ [
+ 276,
+ 628
+ ],
+ [
+ 285,
+ 523
+ ],
+ [
+ 349,
+ 425
+ ],
+ [
+ 459,
+ 341
+ ],
+ [
+ 607,
+ 279
+ ],
+ [
+ 779,
+ 243
+ ],
+ [
+ 960,
+ 234
+ ],
+ [
+ 1136,
+ 253
+ ],
+ [
+ 1292,
+ 297
+ ],
+ [
+ 1417,
+ 362
+ ],
+ [
+ 1502,
+ 442
+ ],
+ [
+ 1540,
+ 530
+ ],
+ [
+ 1530,
+ 619
+ ],
+ [
+ 1475,
+ 702
+ ],
+ [
+ 1381,
+ 771
+ ],
+ [
+ 1256,
+ 823
+ ],
+ [
+ 1111,
+ 853
+ ],
+ [
+ 960,
+ 859
+ ],
+ [
+ 814,
+ 843
+ ],
+ [
+ 685,
+ 806
+ ],
+ [
+ 583,
+ 751
+ ],
+ [
+ 515,
+ 685
+ ],
+ [
+ 485,
+ 612
+ ],
+ [
+ 494,
+ 539
+ ],
+ [
+ 541,
+ 472
+ ],
+ [
+ 619,
+ 416
+ ],
+ [
+ 721,
+ 375
+ ],
+ [
+ 838,
+ 352
+ ],
+ [
+ 960,
+ 348
+ ]
+ ],
+ "tunnels": [
+ [
+ 2633,
+ 3291
+ ]
+ ],
+ "quota": 38,
+ "introBalls": 11,
+ "pushSpeed": 88,
+ "powerUpRate": 0.06
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-006.json b/assets/gamedata/zuma/level-006.json
new file mode 100644
index 0000000..06540fc
--- /dev/null
+++ b/assets/gamedata/zuma/level-006.json
@@ -0,0 +1,232 @@
+{
+ "level": 6,
+ "name": "Crossroads",
+ "frog": [
+ 960,
+ 990
+ ],
+ "colors": 4,
+ "background": "zuma-background-2",
+ "seed": 48514,
+ "starScores": [
+ 1120,
+ 1670,
+ 2230
+ ],
+ "paths": [
+ {
+ "shape": "figureEight",
+ "points": [
+ [
+ -80,
+ 830
+ ],
+ [
+ 60,
+ 690
+ ],
+ [
+ 130,
+ 505
+ ],
+ [
+ 136,
+ 416
+ ],
+ [
+ 154,
+ 333
+ ],
+ [
+ 183,
+ 259
+ ],
+ [
+ 223,
+ 199
+ ],
+ [
+ 274,
+ 156
+ ],
+ [
+ 334,
+ 134
+ ],
+ [
+ 404,
+ 132
+ ],
+ [
+ 481,
+ 152
+ ],
+ [
+ 565,
+ 191
+ ],
+ [
+ 655,
+ 249
+ ],
+ [
+ 749,
+ 321
+ ],
+ [
+ 846,
+ 403
+ ],
+ [
+ 945,
+ 491
+ ],
+ [
+ 1044,
+ 581
+ ],
+ [
+ 1142,
+ 665
+ ],
+ [
+ 1237,
+ 741
+ ],
+ [
+ 1328,
+ 803
+ ],
+ [
+ 1414,
+ 849
+ ],
+ [
+ 1494,
+ 874
+ ],
+ [
+ 1566,
+ 879
+ ],
+ [
+ 1629,
+ 863
+ ],
+ [
+ 1683,
+ 826
+ ],
+ [
+ 1726,
+ 771
+ ],
+ [
+ 1759,
+ 701
+ ],
+ [
+ 1780,
+ 620
+ ],
+ [
+ 1789,
+ 532
+ ],
+ [
+ 1787,
+ 443
+ ],
+ [
+ 1773,
+ 357
+ ],
+ [
+ 1747,
+ 280
+ ],
+ [
+ 1710,
+ 215
+ ],
+ [
+ 1663,
+ 167
+ ],
+ [
+ 1605,
+ 138
+ ],
+ [
+ 1538,
+ 130
+ ],
+ [
+ 1463,
+ 143
+ ],
+ [
+ 1381,
+ 177
+ ],
+ [
+ 1293,
+ 230
+ ],
+ [
+ 1200,
+ 298
+ ],
+ [
+ 1103,
+ 377
+ ],
+ [
+ 1005,
+ 464
+ ],
+ [
+ 906,
+ 554
+ ],
+ [
+ 808,
+ 640
+ ],
+ [
+ 711,
+ 719
+ ],
+ [
+ 619,
+ 786
+ ],
+ [
+ 531,
+ 837
+ ],
+ [
+ 449,
+ 869
+ ],
+ [
+ 375,
+ 880
+ ],
+ [
+ 309,
+ 870
+ ],
+ [
+ 252,
+ 839
+ ]
+ ],
+ "tunnels": [],
+ "quota": 34,
+ "introBalls": 10,
+ "pushSpeed": 88,
+ "powerUpRate": 0.06
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-007.json b/assets/gamedata/zuma/level-007.json
new file mode 100644
index 0000000..991291c
--- /dev/null
+++ b/assets/gamedata/zuma/level-007.json
@@ -0,0 +1,384 @@
+{
+ "level": 7,
+ "name": "Rapids",
+ "frog": [
+ 880,
+ 770
+ ],
+ "colors": 4,
+ "background": "zuma-background-1",
+ "seed": 56433,
+ "starScores": [
+ 910,
+ 1360,
+ 1810
+ ],
+ "paths": [
+ {
+ "shape": "sCurve",
+ "points": [
+ [
+ -80,
+ 140
+ ],
+ [
+ -11,
+ 140
+ ],
+ [
+ 57,
+ 140
+ ],
+ [
+ 126,
+ 140
+ ],
+ [
+ 195,
+ 140
+ ],
+ [
+ 263,
+ 140
+ ],
+ [
+ 332,
+ 140
+ ],
+ [
+ 401,
+ 140
+ ],
+ [
+ 470,
+ 140
+ ],
+ [
+ 538,
+ 140
+ ],
+ [
+ 607,
+ 140
+ ],
+ [
+ 676,
+ 140
+ ],
+ [
+ 744,
+ 140
+ ],
+ [
+ 813,
+ 140
+ ],
+ [
+ 882,
+ 140
+ ],
+ [
+ 950,
+ 140
+ ],
+ [
+ 1019,
+ 140
+ ],
+ [
+ 1088,
+ 140
+ ],
+ [
+ 1157,
+ 140
+ ],
+ [
+ 1225,
+ 140
+ ],
+ [
+ 1294,
+ 140
+ ],
+ [
+ 1363,
+ 140
+ ],
+ [
+ 1431,
+ 140
+ ],
+ [
+ 1500,
+ 140
+ ],
+ [
+ 1572,
+ 153
+ ],
+ [
+ 1635,
+ 189
+ ],
+ [
+ 1682,
+ 245
+ ],
+ [
+ 1707,
+ 314
+ ],
+ [
+ 1707,
+ 386
+ ],
+ [
+ 1682,
+ 455
+ ],
+ [
+ 1635,
+ 511
+ ],
+ [
+ 1572,
+ 547
+ ],
+ [
+ 1500,
+ 560
+ ],
+ [
+ 1431,
+ 560
+ ],
+ [
+ 1362,
+ 560
+ ],
+ [
+ 1293,
+ 560
+ ],
+ [
+ 1224,
+ 560
+ ],
+ [
+ 1156,
+ 560
+ ],
+ [
+ 1087,
+ 560
+ ],
+ [
+ 1018,
+ 560
+ ],
+ [
+ 949,
+ 560
+ ],
+ [
+ 880,
+ 560
+ ],
+ [
+ 811,
+ 560
+ ],
+ [
+ 742,
+ 560
+ ],
+ [
+ 673,
+ 560
+ ],
+ [
+ 604,
+ 560
+ ],
+ [
+ 536,
+ 560
+ ],
+ [
+ 467,
+ 560
+ ],
+ [
+ 398,
+ 560
+ ],
+ [
+ 329,
+ 560
+ ],
+ [
+ 260,
+ 560
+ ],
+ [
+ 188,
+ 573
+ ],
+ [
+ 125,
+ 609
+ ],
+ [
+ 78,
+ 665
+ ],
+ [
+ 53,
+ 734
+ ],
+ [
+ 53,
+ 806
+ ],
+ [
+ 78,
+ 875
+ ],
+ [
+ 125,
+ 931
+ ],
+ [
+ 188,
+ 967
+ ],
+ [
+ 260,
+ 980
+ ],
+ [
+ 329,
+ 980
+ ],
+ [
+ 398,
+ 980
+ ],
+ [
+ 467,
+ 980
+ ],
+ [
+ 536,
+ 980
+ ],
+ [
+ 604,
+ 980
+ ],
+ [
+ 673,
+ 980
+ ],
+ [
+ 742,
+ 980
+ ],
+ [
+ 811,
+ 980
+ ],
+ [
+ 880,
+ 980
+ ],
+ [
+ 949,
+ 980
+ ],
+ [
+ 1018,
+ 980
+ ],
+ [
+ 1087,
+ 980
+ ],
+ [
+ 1156,
+ 980
+ ],
+ [
+ 1224,
+ 980
+ ],
+ [
+ 1293,
+ 980
+ ],
+ [
+ 1362,
+ 980
+ ],
+ [
+ 1431,
+ 980
+ ],
+ [
+ 1500,
+ 980
+ ],
+ [
+ 1573,
+ 966
+ ],
+ [
+ 1634,
+ 924
+ ],
+ [
+ 1676,
+ 863
+ ],
+ [
+ 1690,
+ 790
+ ],
+ [
+ 1690,
+ 717
+ ],
+ [
+ 1690,
+ 643
+ ],
+ [
+ 1690,
+ 570
+ ],
+ [
+ 1678,
+ 509
+ ],
+ [
+ 1643,
+ 457
+ ],
+ [
+ 1591,
+ 422
+ ],
+ [
+ 1530,
+ 410
+ ]
+ ],
+ "tunnels": [],
+ "quota": 26,
+ "introBalls": 8,
+ "pushSpeed": 108,
+ "powerUpRate": 0.06
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-008.json b/assets/gamedata/zuma/level-008.json
new file mode 100644
index 0000000..881b351
--- /dev/null
+++ b/assets/gamedata/zuma/level-008.json
@@ -0,0 +1,165 @@
+{
+ "level": 8,
+ "name": "Sun Court",
+ "frog": [
+ 960,
+ 580
+ ],
+ "colors": 5,
+ "background": "zuma-background-2",
+ "seed": 64352,
+ "starScores": [
+ 1010,
+ 1520,
+ 2020
+ ],
+ "paths": [
+ {
+ "shape": "horseshoe",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ 60,
+ 130
+ ],
+ [
+ 960,
+ 130
+ ],
+ [
+ 1191,
+ 154
+ ],
+ [
+ 1398,
+ 210
+ ],
+ [
+ 1565,
+ 294
+ ],
+ [
+ 1680,
+ 399
+ ],
+ [
+ 1734,
+ 514
+ ],
+ [
+ 1727,
+ 632
+ ],
+ [
+ 1659,
+ 742
+ ],
+ [
+ 1538,
+ 836
+ ],
+ [
+ 1375,
+ 908
+ ],
+ [
+ 1185,
+ 951
+ ],
+ [
+ 983,
+ 964
+ ],
+ [
+ 785,
+ 946
+ ],
+ [
+ 607,
+ 900
+ ],
+ [
+ 462,
+ 831
+ ],
+ [
+ 361,
+ 743
+ ],
+ [
+ 311,
+ 646
+ ],
+ [
+ 313,
+ 547
+ ],
+ [
+ 366,
+ 454
+ ],
+ [
+ 464,
+ 374
+ ],
+ [
+ 598,
+ 313
+ ],
+ [
+ 755,
+ 275
+ ],
+ [
+ 923,
+ 263
+ ],
+ [
+ 1087,
+ 276
+ ],
+ [
+ 1235,
+ 312
+ ],
+ [
+ 1356,
+ 368
+ ],
+ [
+ 1441,
+ 438
+ ],
+ [
+ 1485,
+ 517
+ ],
+ [
+ 1485,
+ 598
+ ],
+ [
+ 1445,
+ 673
+ ],
+ [
+ 1368,
+ 739
+ ]
+ ],
+ "tunnels": [
+ [
+ 1857,
+ 2403
+ ]
+ ],
+ "quota": 30,
+ "introBalls": 9,
+ "pushSpeed": 96,
+ "powerUpRate": 0.055
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-009.json b/assets/gamedata/zuma/level-009.json
new file mode 100644
index 0000000..6a01f5b
--- /dev/null
+++ b/assets/gamedata/zuma/level-009.json
@@ -0,0 +1,364 @@
+{
+ "level": 9,
+ "name": "Thunder Steps",
+ "frog": [
+ 1620,
+ 550
+ ],
+ "colors": 5,
+ "background": "zuma-background-1",
+ "seed": 72271,
+ "starScores": [
+ 1210,
+ 1820,
+ 2420
+ ],
+ "paths": [
+ {
+ "shape": "zigzag",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ -11,
+ 130
+ ],
+ [
+ 58,
+ 130
+ ],
+ [
+ 126,
+ 130
+ ],
+ [
+ 195,
+ 130
+ ],
+ [
+ 264,
+ 130
+ ],
+ [
+ 333,
+ 130
+ ],
+ [
+ 401,
+ 130
+ ],
+ [
+ 470,
+ 130
+ ],
+ [
+ 539,
+ 130
+ ],
+ [
+ 608,
+ 130
+ ],
+ [
+ 676,
+ 130
+ ],
+ [
+ 745,
+ 130
+ ],
+ [
+ 814,
+ 130
+ ],
+ [
+ 882,
+ 130
+ ],
+ [
+ 951,
+ 130
+ ],
+ [
+ 1020,
+ 130
+ ],
+ [
+ 1089,
+ 130
+ ],
+ [
+ 1158,
+ 130
+ ],
+ [
+ 1226,
+ 130
+ ],
+ [
+ 1295,
+ 130
+ ],
+ [
+ 1365,
+ 149
+ ],
+ [
+ 1416,
+ 200
+ ],
+ [
+ 1435,
+ 270
+ ],
+ [
+ 1416,
+ 340
+ ],
+ [
+ 1365,
+ 391
+ ],
+ [
+ 1295,
+ 410
+ ],
+ [
+ 1225,
+ 410
+ ],
+ [
+ 1156,
+ 410
+ ],
+ [
+ 1086,
+ 410
+ ],
+ [
+ 1016,
+ 410
+ ],
+ [
+ 947,
+ 410
+ ],
+ [
+ 877,
+ 410
+ ],
+ [
+ 807,
+ 410
+ ],
+ [
+ 738,
+ 410
+ ],
+ [
+ 668,
+ 410
+ ],
+ [
+ 598,
+ 410
+ ],
+ [
+ 529,
+ 410
+ ],
+ [
+ 459,
+ 410
+ ],
+ [
+ 389,
+ 410
+ ],
+ [
+ 320,
+ 410
+ ],
+ [
+ 250,
+ 410
+ ],
+ [
+ 180,
+ 429
+ ],
+ [
+ 129,
+ 480
+ ],
+ [
+ 110,
+ 550
+ ],
+ [
+ 129,
+ 620
+ ],
+ [
+ 180,
+ 671
+ ],
+ [
+ 250,
+ 690
+ ],
+ [
+ 320,
+ 690
+ ],
+ [
+ 389,
+ 690
+ ],
+ [
+ 459,
+ 690
+ ],
+ [
+ 529,
+ 690
+ ],
+ [
+ 598,
+ 690
+ ],
+ [
+ 668,
+ 690
+ ],
+ [
+ 738,
+ 690
+ ],
+ [
+ 807,
+ 690
+ ],
+ [
+ 877,
+ 690
+ ],
+ [
+ 947,
+ 690
+ ],
+ [
+ 1016,
+ 690
+ ],
+ [
+ 1086,
+ 690
+ ],
+ [
+ 1156,
+ 690
+ ],
+ [
+ 1225,
+ 690
+ ],
+ [
+ 1295,
+ 690
+ ],
+ [
+ 1365,
+ 709
+ ],
+ [
+ 1416,
+ 760
+ ],
+ [
+ 1435,
+ 830
+ ],
+ [
+ 1416,
+ 900
+ ],
+ [
+ 1365,
+ 951
+ ],
+ [
+ 1295,
+ 970
+ ],
+ [
+ 1225,
+ 970
+ ],
+ [
+ 1156,
+ 970
+ ],
+ [
+ 1086,
+ 970
+ ],
+ [
+ 1016,
+ 970
+ ],
+ [
+ 947,
+ 970
+ ],
+ [
+ 877,
+ 970
+ ],
+ [
+ 807,
+ 970
+ ],
+ [
+ 738,
+ 970
+ ],
+ [
+ 668,
+ 970
+ ],
+ [
+ 598,
+ 970
+ ],
+ [
+ 529,
+ 970
+ ],
+ [
+ 459,
+ 970
+ ],
+ [
+ 389,
+ 970
+ ],
+ [
+ 320,
+ 970
+ ],
+ [
+ 250,
+ 970
+ ]
+ ],
+ "tunnels": [],
+ "quota": 36,
+ "introBalls": 11,
+ "pushSpeed": 96,
+ "powerUpRate": 0.055
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-010.json b/assets/gamedata/zuma/level-010.json
new file mode 100644
index 0000000..d9ae499
--- /dev/null
+++ b/assets/gamedata/zuma/level-010.json
@@ -0,0 +1,405 @@
+{
+ "level": 10,
+ "name": "Twin Serpents",
+ "frog": [
+ 620,
+ 600
+ ],
+ "colors": 5,
+ "background": "zuma-background-2",
+ "seed": 80190,
+ "starScores": [
+ 1170,
+ 1760,
+ 2340
+ ],
+ "paths": [
+ {
+ "shape": "doubleLoop",
+ "points": [
+ [
+ -80,
+ 300
+ ],
+ [
+ -10,
+ 300
+ ],
+ [
+ 60,
+ 300
+ ],
+ [
+ 130,
+ 300
+ ],
+ [
+ 200,
+ 300
+ ],
+ [
+ 270,
+ 300
+ ],
+ [
+ 340,
+ 300
+ ],
+ [
+ 410,
+ 300
+ ],
+ [
+ 480,
+ 300
+ ],
+ [
+ 550,
+ 300
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 689,
+ 308
+ ],
+ [
+ 755,
+ 332
+ ],
+ [
+ 813,
+ 370
+ ],
+ [
+ 861,
+ 421
+ ],
+ [
+ 895,
+ 481
+ ],
+ [
+ 915,
+ 548
+ ],
+ [
+ 919,
+ 617
+ ],
+ [
+ 907,
+ 686
+ ],
+ [
+ 880,
+ 750
+ ],
+ [
+ 838,
+ 806
+ ],
+ [
+ 785,
+ 851
+ ],
+ [
+ 723,
+ 882
+ ],
+ [
+ 655,
+ 898
+ ],
+ [
+ 585,
+ 898
+ ],
+ [
+ 517,
+ 882
+ ],
+ [
+ 455,
+ 851
+ ],
+ [
+ 402,
+ 806
+ ],
+ [
+ 360,
+ 750
+ ],
+ [
+ 333,
+ 686
+ ],
+ [
+ 321,
+ 617
+ ],
+ [
+ 325,
+ 548
+ ],
+ [
+ 345,
+ 481
+ ],
+ [
+ 379,
+ 421
+ ],
+ [
+ 427,
+ 370
+ ],
+ [
+ 485,
+ 332
+ ],
+ [
+ 551,
+ 308
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 691,
+ 300
+ ],
+ [
+ 762,
+ 300
+ ],
+ [
+ 833,
+ 300
+ ],
+ [
+ 904,
+ 300
+ ],
+ [
+ 975,
+ 300
+ ],
+ [
+ 1045,
+ 300
+ ],
+ [
+ 1116,
+ 300
+ ],
+ [
+ 1187,
+ 300
+ ],
+ [
+ 1258,
+ 300
+ ],
+ [
+ 1329,
+ 300
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1469,
+ 308
+ ],
+ [
+ 1535,
+ 332
+ ],
+ [
+ 1593,
+ 370
+ ],
+ [
+ 1641,
+ 421
+ ],
+ [
+ 1675,
+ 481
+ ],
+ [
+ 1695,
+ 548
+ ],
+ [
+ 1699,
+ 617
+ ],
+ [
+ 1687,
+ 686
+ ],
+ [
+ 1660,
+ 750
+ ],
+ [
+ 1618,
+ 806
+ ],
+ [
+ 1565,
+ 851
+ ],
+ [
+ 1503,
+ 882
+ ],
+ [
+ 1435,
+ 898
+ ],
+ [
+ 1365,
+ 898
+ ],
+ [
+ 1297,
+ 882
+ ],
+ [
+ 1235,
+ 851
+ ],
+ [
+ 1182,
+ 806
+ ],
+ [
+ 1140,
+ 750
+ ],
+ [
+ 1113,
+ 686
+ ],
+ [
+ 1101,
+ 617
+ ],
+ [
+ 1105,
+ 548
+ ],
+ [
+ 1125,
+ 481
+ ],
+ [
+ 1159,
+ 421
+ ],
+ [
+ 1207,
+ 370
+ ],
+ [
+ 1265,
+ 332
+ ],
+ [
+ 1331,
+ 308
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1473,
+ 300
+ ],
+ [
+ 1547,
+ 300
+ ],
+ [
+ 1620,
+ 300
+ ],
+ [
+ 1691,
+ 311
+ ],
+ [
+ 1755,
+ 344
+ ],
+ [
+ 1806,
+ 395
+ ],
+ [
+ 1839,
+ 459
+ ],
+ [
+ 1850,
+ 530
+ ],
+ [
+ 1839,
+ 601
+ ],
+ [
+ 1806,
+ 665
+ ],
+ [
+ 1755,
+ 716
+ ],
+ [
+ 1691,
+ 749
+ ],
+ [
+ 1620,
+ 760
+ ],
+ [
+ 1555,
+ 760
+ ],
+ [
+ 1490,
+ 760
+ ],
+ [
+ 1425,
+ 760
+ ],
+ [
+ 1360,
+ 760
+ ]
+ ],
+ "tunnels": [
+ [
+ 2581,
+ 3226
+ ]
+ ],
+ "quota": 34,
+ "introBalls": 10,
+ "pushSpeed": 104,
+ "powerUpRate": 0.055
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-011.json b/assets/gamedata/zuma/level-011.json
new file mode 100644
index 0000000..e488849
--- /dev/null
+++ b/assets/gamedata/zuma/level-011.json
@@ -0,0 +1,221 @@
+{
+ "level": 11,
+ "name": "Deep Coil",
+ "frog": [
+ 960,
+ 575
+ ],
+ "colors": 5,
+ "background": "zuma-background-1",
+ "seed": 88109,
+ "starScores": [
+ 1450,
+ 2170,
+ 2890
+ ],
+ "paths": [
+ {
+ "shape": "spiral",
+ "points": [
+ [
+ -80,
+ 120
+ ],
+ [
+ 58,
+ 120
+ ],
+ [
+ 960,
+ 120
+ ],
+ [
+ 1195,
+ 143
+ ],
+ [
+ 1406,
+ 201
+ ],
+ [
+ 1577,
+ 287
+ ],
+ [
+ 1694,
+ 395
+ ],
+ [
+ 1749,
+ 514
+ ],
+ [
+ 1739,
+ 635
+ ],
+ [
+ 1667,
+ 749
+ ],
+ [
+ 1541,
+ 846
+ ],
+ [
+ 1370,
+ 919
+ ],
+ [
+ 1171,
+ 962
+ ],
+ [
+ 960,
+ 973
+ ],
+ [
+ 754,
+ 952
+ ],
+ [
+ 571,
+ 901
+ ],
+ [
+ 423,
+ 826
+ ],
+ [
+ 322,
+ 732
+ ],
+ [
+ 276,
+ 628
+ ],
+ [
+ 285,
+ 523
+ ],
+ [
+ 349,
+ 425
+ ],
+ [
+ 459,
+ 341
+ ],
+ [
+ 607,
+ 279
+ ],
+ [
+ 779,
+ 243
+ ],
+ [
+ 960,
+ 234
+ ],
+ [
+ 1136,
+ 253
+ ],
+ [
+ 1292,
+ 297
+ ],
+ [
+ 1417,
+ 362
+ ],
+ [
+ 1502,
+ 442
+ ],
+ [
+ 1540,
+ 530
+ ],
+ [
+ 1530,
+ 619
+ ],
+ [
+ 1475,
+ 702
+ ],
+ [
+ 1381,
+ 771
+ ],
+ [
+ 1256,
+ 823
+ ],
+ [
+ 1111,
+ 853
+ ],
+ [
+ 960,
+ 859
+ ],
+ [
+ 814,
+ 843
+ ],
+ [
+ 685,
+ 806
+ ],
+ [
+ 583,
+ 751
+ ],
+ [
+ 515,
+ 685
+ ],
+ [
+ 485,
+ 612
+ ],
+ [
+ 494,
+ 539
+ ],
+ [
+ 541,
+ 472
+ ],
+ [
+ 619,
+ 416
+ ],
+ [
+ 721,
+ 375
+ ],
+ [
+ 838,
+ 352
+ ],
+ [
+ 960,
+ 348
+ ]
+ ],
+ "tunnels": [
+ [
+ 2194,
+ 2925
+ ]
+ ],
+ "quota": 42,
+ "introBalls": 12,
+ "pushSpeed": 104,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-012.json b/assets/gamedata/zuma/level-012.json
new file mode 100644
index 0000000..3605a7b
--- /dev/null
+++ b/assets/gamedata/zuma/level-012.json
@@ -0,0 +1,232 @@
+{
+ "level": 12,
+ "name": "Tangled Path",
+ "frog": [
+ 960,
+ 990
+ ],
+ "colors": 5,
+ "background": "zuma-background-2",
+ "seed": 96028,
+ "starScores": [
+ 1320,
+ 1980,
+ 2640
+ ],
+ "paths": [
+ {
+ "shape": "figureEight",
+ "points": [
+ [
+ -80,
+ 830
+ ],
+ [
+ 60,
+ 690
+ ],
+ [
+ 130,
+ 505
+ ],
+ [
+ 136,
+ 416
+ ],
+ [
+ 154,
+ 333
+ ],
+ [
+ 183,
+ 259
+ ],
+ [
+ 223,
+ 199
+ ],
+ [
+ 274,
+ 156
+ ],
+ [
+ 334,
+ 134
+ ],
+ [
+ 404,
+ 132
+ ],
+ [
+ 481,
+ 152
+ ],
+ [
+ 565,
+ 191
+ ],
+ [
+ 655,
+ 249
+ ],
+ [
+ 749,
+ 321
+ ],
+ [
+ 846,
+ 403
+ ],
+ [
+ 945,
+ 491
+ ],
+ [
+ 1044,
+ 581
+ ],
+ [
+ 1142,
+ 665
+ ],
+ [
+ 1237,
+ 741
+ ],
+ [
+ 1328,
+ 803
+ ],
+ [
+ 1414,
+ 849
+ ],
+ [
+ 1494,
+ 874
+ ],
+ [
+ 1566,
+ 879
+ ],
+ [
+ 1629,
+ 863
+ ],
+ [
+ 1683,
+ 826
+ ],
+ [
+ 1726,
+ 771
+ ],
+ [
+ 1759,
+ 701
+ ],
+ [
+ 1780,
+ 620
+ ],
+ [
+ 1789,
+ 532
+ ],
+ [
+ 1787,
+ 443
+ ],
+ [
+ 1773,
+ 357
+ ],
+ [
+ 1747,
+ 280
+ ],
+ [
+ 1710,
+ 215
+ ],
+ [
+ 1663,
+ 167
+ ],
+ [
+ 1605,
+ 138
+ ],
+ [
+ 1538,
+ 130
+ ],
+ [
+ 1463,
+ 143
+ ],
+ [
+ 1381,
+ 177
+ ],
+ [
+ 1293,
+ 230
+ ],
+ [
+ 1200,
+ 298
+ ],
+ [
+ 1103,
+ 377
+ ],
+ [
+ 1005,
+ 464
+ ],
+ [
+ 906,
+ 554
+ ],
+ [
+ 808,
+ 640
+ ],
+ [
+ 711,
+ 719
+ ],
+ [
+ 619,
+ 786
+ ],
+ [
+ 531,
+ 837
+ ],
+ [
+ 449,
+ 869
+ ],
+ [
+ 375,
+ 880
+ ],
+ [
+ 309,
+ 870
+ ],
+ [
+ 252,
+ 839
+ ]
+ ],
+ "tunnels": [],
+ "quota": 38,
+ "introBalls": 11,
+ "pushSpeed": 108,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-013.json b/assets/gamedata/zuma/level-013.json
new file mode 100644
index 0000000..26390c7
--- /dev/null
+++ b/assets/gamedata/zuma/level-013.json
@@ -0,0 +1,364 @@
+{
+ "level": 13,
+ "name": "Lightning Run",
+ "frog": [
+ 1620,
+ 550
+ ],
+ "colors": 5,
+ "background": "zuma-background-1",
+ "seed": 103947,
+ "starScores": [
+ 1210,
+ 1820,
+ 2420
+ ],
+ "paths": [
+ {
+ "shape": "zigzag",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ -11,
+ 130
+ ],
+ [
+ 58,
+ 130
+ ],
+ [
+ 126,
+ 130
+ ],
+ [
+ 195,
+ 130
+ ],
+ [
+ 264,
+ 130
+ ],
+ [
+ 333,
+ 130
+ ],
+ [
+ 401,
+ 130
+ ],
+ [
+ 470,
+ 130
+ ],
+ [
+ 539,
+ 130
+ ],
+ [
+ 608,
+ 130
+ ],
+ [
+ 676,
+ 130
+ ],
+ [
+ 745,
+ 130
+ ],
+ [
+ 814,
+ 130
+ ],
+ [
+ 882,
+ 130
+ ],
+ [
+ 951,
+ 130
+ ],
+ [
+ 1020,
+ 130
+ ],
+ [
+ 1089,
+ 130
+ ],
+ [
+ 1158,
+ 130
+ ],
+ [
+ 1226,
+ 130
+ ],
+ [
+ 1295,
+ 130
+ ],
+ [
+ 1365,
+ 149
+ ],
+ [
+ 1416,
+ 200
+ ],
+ [
+ 1435,
+ 270
+ ],
+ [
+ 1416,
+ 340
+ ],
+ [
+ 1365,
+ 391
+ ],
+ [
+ 1295,
+ 410
+ ],
+ [
+ 1225,
+ 410
+ ],
+ [
+ 1156,
+ 410
+ ],
+ [
+ 1086,
+ 410
+ ],
+ [
+ 1016,
+ 410
+ ],
+ [
+ 947,
+ 410
+ ],
+ [
+ 877,
+ 410
+ ],
+ [
+ 807,
+ 410
+ ],
+ [
+ 738,
+ 410
+ ],
+ [
+ 668,
+ 410
+ ],
+ [
+ 598,
+ 410
+ ],
+ [
+ 529,
+ 410
+ ],
+ [
+ 459,
+ 410
+ ],
+ [
+ 389,
+ 410
+ ],
+ [
+ 320,
+ 410
+ ],
+ [
+ 250,
+ 410
+ ],
+ [
+ 180,
+ 429
+ ],
+ [
+ 129,
+ 480
+ ],
+ [
+ 110,
+ 550
+ ],
+ [
+ 129,
+ 620
+ ],
+ [
+ 180,
+ 671
+ ],
+ [
+ 250,
+ 690
+ ],
+ [
+ 320,
+ 690
+ ],
+ [
+ 389,
+ 690
+ ],
+ [
+ 459,
+ 690
+ ],
+ [
+ 529,
+ 690
+ ],
+ [
+ 598,
+ 690
+ ],
+ [
+ 668,
+ 690
+ ],
+ [
+ 738,
+ 690
+ ],
+ [
+ 807,
+ 690
+ ],
+ [
+ 877,
+ 690
+ ],
+ [
+ 947,
+ 690
+ ],
+ [
+ 1016,
+ 690
+ ],
+ [
+ 1086,
+ 690
+ ],
+ [
+ 1156,
+ 690
+ ],
+ [
+ 1225,
+ 690
+ ],
+ [
+ 1295,
+ 690
+ ],
+ [
+ 1365,
+ 709
+ ],
+ [
+ 1416,
+ 760
+ ],
+ [
+ 1435,
+ 830
+ ],
+ [
+ 1416,
+ 900
+ ],
+ [
+ 1365,
+ 951
+ ],
+ [
+ 1295,
+ 970
+ ],
+ [
+ 1225,
+ 970
+ ],
+ [
+ 1156,
+ 970
+ ],
+ [
+ 1086,
+ 970
+ ],
+ [
+ 1016,
+ 970
+ ],
+ [
+ 947,
+ 970
+ ],
+ [
+ 877,
+ 970
+ ],
+ [
+ 807,
+ 970
+ ],
+ [
+ 738,
+ 970
+ ],
+ [
+ 668,
+ 970
+ ],
+ [
+ 598,
+ 970
+ ],
+ [
+ 529,
+ 970
+ ],
+ [
+ 459,
+ 970
+ ],
+ [
+ 389,
+ 970
+ ],
+ [
+ 320,
+ 970
+ ],
+ [
+ 250,
+ 970
+ ]
+ ],
+ "tunnels": [],
+ "quota": 34,
+ "introBalls": 10,
+ "pushSpeed": 116,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-014.json b/assets/gamedata/zuma/level-014.json
new file mode 100644
index 0000000..6cac870
--- /dev/null
+++ b/assets/gamedata/zuma/level-014.json
@@ -0,0 +1,225 @@
+{
+ "level": 14,
+ "name": "Whirlpool",
+ "frog": [
+ 960,
+ 575
+ ],
+ "colors": 5,
+ "background": "zuma-background-2",
+ "seed": 111866,
+ "starScores": [
+ 1640,
+ 2460,
+ 3280
+ ],
+ "paths": [
+ {
+ "shape": "spiral",
+ "points": [
+ [
+ -80,
+ 120
+ ],
+ [
+ 58,
+ 120
+ ],
+ [
+ 960,
+ 120
+ ],
+ [
+ 1195,
+ 143
+ ],
+ [
+ 1406,
+ 201
+ ],
+ [
+ 1577,
+ 287
+ ],
+ [
+ 1694,
+ 395
+ ],
+ [
+ 1749,
+ 514
+ ],
+ [
+ 1739,
+ 635
+ ],
+ [
+ 1667,
+ 749
+ ],
+ [
+ 1541,
+ 846
+ ],
+ [
+ 1370,
+ 919
+ ],
+ [
+ 1171,
+ 962
+ ],
+ [
+ 960,
+ 973
+ ],
+ [
+ 754,
+ 952
+ ],
+ [
+ 571,
+ 901
+ ],
+ [
+ 423,
+ 826
+ ],
+ [
+ 322,
+ 732
+ ],
+ [
+ 276,
+ 628
+ ],
+ [
+ 285,
+ 523
+ ],
+ [
+ 349,
+ 425
+ ],
+ [
+ 459,
+ 341
+ ],
+ [
+ 607,
+ 279
+ ],
+ [
+ 779,
+ 243
+ ],
+ [
+ 960,
+ 234
+ ],
+ [
+ 1136,
+ 253
+ ],
+ [
+ 1292,
+ 297
+ ],
+ [
+ 1417,
+ 362
+ ],
+ [
+ 1502,
+ 442
+ ],
+ [
+ 1540,
+ 530
+ ],
+ [
+ 1530,
+ 619
+ ],
+ [
+ 1475,
+ 702
+ ],
+ [
+ 1381,
+ 771
+ ],
+ [
+ 1256,
+ 823
+ ],
+ [
+ 1111,
+ 853
+ ],
+ [
+ 960,
+ 859
+ ],
+ [
+ 814,
+ 843
+ ],
+ [
+ 685,
+ 806
+ ],
+ [
+ 583,
+ 751
+ ],
+ [
+ 515,
+ 685
+ ],
+ [
+ 485,
+ 612
+ ],
+ [
+ 494,
+ 539
+ ],
+ [
+ 541,
+ 472
+ ],
+ [
+ 619,
+ 416
+ ],
+ [
+ 721,
+ 375
+ ],
+ [
+ 838,
+ 352
+ ],
+ [
+ 960,
+ 348
+ ]
+ ],
+ "tunnels": [
+ [
+ 1901,
+ 2633
+ ],
+ [
+ 4388,
+ 5119
+ ]
+ ],
+ "quota": 46,
+ "introBalls": 13,
+ "pushSpeed": 116,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-015.json b/assets/gamedata/zuma/level-015.json
new file mode 100644
index 0000000..0e9e920
--- /dev/null
+++ b/assets/gamedata/zuma/level-015.json
@@ -0,0 +1,160 @@
+{
+ "level": 15,
+ "name": "Obsidian Gate",
+ "frog": [
+ 960,
+ 580
+ ],
+ "colors": 6,
+ "background": "zuma-background-1",
+ "seed": 119785,
+ "starScores": [
+ 1170,
+ 1750,
+ 2330
+ ],
+ "paths": [
+ {
+ "shape": "horseshoe",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ 60,
+ 130
+ ],
+ [
+ 960,
+ 130
+ ],
+ [
+ 1191,
+ 154
+ ],
+ [
+ 1398,
+ 210
+ ],
+ [
+ 1565,
+ 294
+ ],
+ [
+ 1680,
+ 399
+ ],
+ [
+ 1734,
+ 514
+ ],
+ [
+ 1727,
+ 632
+ ],
+ [
+ 1659,
+ 742
+ ],
+ [
+ 1538,
+ 836
+ ],
+ [
+ 1375,
+ 908
+ ],
+ [
+ 1185,
+ 951
+ ],
+ [
+ 983,
+ 964
+ ],
+ [
+ 785,
+ 946
+ ],
+ [
+ 607,
+ 900
+ ],
+ [
+ 462,
+ 831
+ ],
+ [
+ 361,
+ 743
+ ],
+ [
+ 311,
+ 646
+ ],
+ [
+ 313,
+ 547
+ ],
+ [
+ 366,
+ 454
+ ],
+ [
+ 464,
+ 374
+ ],
+ [
+ 598,
+ 313
+ ],
+ [
+ 755,
+ 275
+ ],
+ [
+ 923,
+ 263
+ ],
+ [
+ 1087,
+ 276
+ ],
+ [
+ 1235,
+ 312
+ ],
+ [
+ 1356,
+ 368
+ ],
+ [
+ 1441,
+ 438
+ ],
+ [
+ 1485,
+ 517
+ ],
+ [
+ 1485,
+ 598
+ ],
+ [
+ 1445,
+ 673
+ ],
+ [
+ 1368,
+ 739
+ ]
+ ],
+ "tunnels": [],
+ "quota": 32,
+ "introBalls": 9,
+ "pushSpeed": 124,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-016.json b/assets/gamedata/zuma/level-016.json
new file mode 100644
index 0000000..5169efb
--- /dev/null
+++ b/assets/gamedata/zuma/level-016.json
@@ -0,0 +1,405 @@
+{
+ "level": 16,
+ "name": "Twin Tempests",
+ "frog": [
+ 620,
+ 600
+ ],
+ "colors": 6,
+ "background": "zuma-background-2",
+ "seed": 127704,
+ "starScores": [
+ 1400,
+ 2100,
+ 2800
+ ],
+ "paths": [
+ {
+ "shape": "doubleLoop",
+ "points": [
+ [
+ -80,
+ 300
+ ],
+ [
+ -10,
+ 300
+ ],
+ [
+ 60,
+ 300
+ ],
+ [
+ 130,
+ 300
+ ],
+ [
+ 200,
+ 300
+ ],
+ [
+ 270,
+ 300
+ ],
+ [
+ 340,
+ 300
+ ],
+ [
+ 410,
+ 300
+ ],
+ [
+ 480,
+ 300
+ ],
+ [
+ 550,
+ 300
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 689,
+ 308
+ ],
+ [
+ 755,
+ 332
+ ],
+ [
+ 813,
+ 370
+ ],
+ [
+ 861,
+ 421
+ ],
+ [
+ 895,
+ 481
+ ],
+ [
+ 915,
+ 548
+ ],
+ [
+ 919,
+ 617
+ ],
+ [
+ 907,
+ 686
+ ],
+ [
+ 880,
+ 750
+ ],
+ [
+ 838,
+ 806
+ ],
+ [
+ 785,
+ 851
+ ],
+ [
+ 723,
+ 882
+ ],
+ [
+ 655,
+ 898
+ ],
+ [
+ 585,
+ 898
+ ],
+ [
+ 517,
+ 882
+ ],
+ [
+ 455,
+ 851
+ ],
+ [
+ 402,
+ 806
+ ],
+ [
+ 360,
+ 750
+ ],
+ [
+ 333,
+ 686
+ ],
+ [
+ 321,
+ 617
+ ],
+ [
+ 325,
+ 548
+ ],
+ [
+ 345,
+ 481
+ ],
+ [
+ 379,
+ 421
+ ],
+ [
+ 427,
+ 370
+ ],
+ [
+ 485,
+ 332
+ ],
+ [
+ 551,
+ 308
+ ],
+ [
+ 620,
+ 300
+ ],
+ [
+ 691,
+ 300
+ ],
+ [
+ 762,
+ 300
+ ],
+ [
+ 833,
+ 300
+ ],
+ [
+ 904,
+ 300
+ ],
+ [
+ 975,
+ 300
+ ],
+ [
+ 1045,
+ 300
+ ],
+ [
+ 1116,
+ 300
+ ],
+ [
+ 1187,
+ 300
+ ],
+ [
+ 1258,
+ 300
+ ],
+ [
+ 1329,
+ 300
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1469,
+ 308
+ ],
+ [
+ 1535,
+ 332
+ ],
+ [
+ 1593,
+ 370
+ ],
+ [
+ 1641,
+ 421
+ ],
+ [
+ 1675,
+ 481
+ ],
+ [
+ 1695,
+ 548
+ ],
+ [
+ 1699,
+ 617
+ ],
+ [
+ 1687,
+ 686
+ ],
+ [
+ 1660,
+ 750
+ ],
+ [
+ 1618,
+ 806
+ ],
+ [
+ 1565,
+ 851
+ ],
+ [
+ 1503,
+ 882
+ ],
+ [
+ 1435,
+ 898
+ ],
+ [
+ 1365,
+ 898
+ ],
+ [
+ 1297,
+ 882
+ ],
+ [
+ 1235,
+ 851
+ ],
+ [
+ 1182,
+ 806
+ ],
+ [
+ 1140,
+ 750
+ ],
+ [
+ 1113,
+ 686
+ ],
+ [
+ 1101,
+ 617
+ ],
+ [
+ 1105,
+ 548
+ ],
+ [
+ 1125,
+ 481
+ ],
+ [
+ 1159,
+ 421
+ ],
+ [
+ 1207,
+ 370
+ ],
+ [
+ 1265,
+ 332
+ ],
+ [
+ 1331,
+ 308
+ ],
+ [
+ 1400,
+ 300
+ ],
+ [
+ 1473,
+ 300
+ ],
+ [
+ 1547,
+ 300
+ ],
+ [
+ 1620,
+ 300
+ ],
+ [
+ 1691,
+ 311
+ ],
+ [
+ 1755,
+ 344
+ ],
+ [
+ 1806,
+ 395
+ ],
+ [
+ 1839,
+ 459
+ ],
+ [
+ 1850,
+ 530
+ ],
+ [
+ 1839,
+ 601
+ ],
+ [
+ 1806,
+ 665
+ ],
+ [
+ 1755,
+ 716
+ ],
+ [
+ 1691,
+ 749
+ ],
+ [
+ 1620,
+ 760
+ ],
+ [
+ 1555,
+ 760
+ ],
+ [
+ 1490,
+ 760
+ ],
+ [
+ 1425,
+ 760
+ ],
+ [
+ 1360,
+ 760
+ ]
+ ],
+ "tunnels": [
+ [
+ 2452,
+ 2968
+ ]
+ ],
+ "quota": 38,
+ "introBalls": 11,
+ "pushSpeed": 128,
+ "powerUpRate": 0.05
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-017.json b/assets/gamedata/zuma/level-017.json
new file mode 100644
index 0000000..4ab0c76
--- /dev/null
+++ b/assets/gamedata/zuma/level-017.json
@@ -0,0 +1,364 @@
+{
+ "level": 17,
+ "name": "Stormsteps",
+ "frog": [
+ 1620,
+ 550
+ ],
+ "colors": 6,
+ "background": "zuma-background-1",
+ "seed": 135623,
+ "starScores": [
+ 1230,
+ 1840,
+ 2450
+ ],
+ "paths": [
+ {
+ "shape": "zigzag",
+ "points": [
+ [
+ -80,
+ 130
+ ],
+ [
+ -11,
+ 130
+ ],
+ [
+ 58,
+ 130
+ ],
+ [
+ 126,
+ 130
+ ],
+ [
+ 195,
+ 130
+ ],
+ [
+ 264,
+ 130
+ ],
+ [
+ 333,
+ 130
+ ],
+ [
+ 401,
+ 130
+ ],
+ [
+ 470,
+ 130
+ ],
+ [
+ 539,
+ 130
+ ],
+ [
+ 608,
+ 130
+ ],
+ [
+ 676,
+ 130
+ ],
+ [
+ 745,
+ 130
+ ],
+ [
+ 814,
+ 130
+ ],
+ [
+ 882,
+ 130
+ ],
+ [
+ 951,
+ 130
+ ],
+ [
+ 1020,
+ 130
+ ],
+ [
+ 1089,
+ 130
+ ],
+ [
+ 1158,
+ 130
+ ],
+ [
+ 1226,
+ 130
+ ],
+ [
+ 1295,
+ 130
+ ],
+ [
+ 1365,
+ 149
+ ],
+ [
+ 1416,
+ 200
+ ],
+ [
+ 1435,
+ 270
+ ],
+ [
+ 1416,
+ 340
+ ],
+ [
+ 1365,
+ 391
+ ],
+ [
+ 1295,
+ 410
+ ],
+ [
+ 1225,
+ 410
+ ],
+ [
+ 1156,
+ 410
+ ],
+ [
+ 1086,
+ 410
+ ],
+ [
+ 1016,
+ 410
+ ],
+ [
+ 947,
+ 410
+ ],
+ [
+ 877,
+ 410
+ ],
+ [
+ 807,
+ 410
+ ],
+ [
+ 738,
+ 410
+ ],
+ [
+ 668,
+ 410
+ ],
+ [
+ 598,
+ 410
+ ],
+ [
+ 529,
+ 410
+ ],
+ [
+ 459,
+ 410
+ ],
+ [
+ 389,
+ 410
+ ],
+ [
+ 320,
+ 410
+ ],
+ [
+ 250,
+ 410
+ ],
+ [
+ 180,
+ 429
+ ],
+ [
+ 129,
+ 480
+ ],
+ [
+ 110,
+ 550
+ ],
+ [
+ 129,
+ 620
+ ],
+ [
+ 180,
+ 671
+ ],
+ [
+ 250,
+ 690
+ ],
+ [
+ 320,
+ 690
+ ],
+ [
+ 389,
+ 690
+ ],
+ [
+ 459,
+ 690
+ ],
+ [
+ 529,
+ 690
+ ],
+ [
+ 598,
+ 690
+ ],
+ [
+ 668,
+ 690
+ ],
+ [
+ 738,
+ 690
+ ],
+ [
+ 807,
+ 690
+ ],
+ [
+ 877,
+ 690
+ ],
+ [
+ 947,
+ 690
+ ],
+ [
+ 1016,
+ 690
+ ],
+ [
+ 1086,
+ 690
+ ],
+ [
+ 1156,
+ 690
+ ],
+ [
+ 1225,
+ 690
+ ],
+ [
+ 1295,
+ 690
+ ],
+ [
+ 1365,
+ 709
+ ],
+ [
+ 1416,
+ 760
+ ],
+ [
+ 1435,
+ 830
+ ],
+ [
+ 1416,
+ 900
+ ],
+ [
+ 1365,
+ 951
+ ],
+ [
+ 1295,
+ 970
+ ],
+ [
+ 1225,
+ 970
+ ],
+ [
+ 1156,
+ 970
+ ],
+ [
+ 1086,
+ 970
+ ],
+ [
+ 1016,
+ 970
+ ],
+ [
+ 947,
+ 970
+ ],
+ [
+ 877,
+ 970
+ ],
+ [
+ 807,
+ 970
+ ],
+ [
+ 738,
+ 970
+ ],
+ [
+ 668,
+ 970
+ ],
+ [
+ 598,
+ 970
+ ],
+ [
+ 529,
+ 970
+ ],
+ [
+ 459,
+ 970
+ ],
+ [
+ 389,
+ 970
+ ],
+ [
+ 320,
+ 970
+ ],
+ [
+ 250,
+ 970
+ ]
+ ],
+ "tunnels": [],
+ "quota": 34,
+ "introBalls": 10,
+ "pushSpeed": 120,
+ "powerUpRate": 0.045
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-018.json b/assets/gamedata/zuma/level-018.json
new file mode 100644
index 0000000..f1edd94
--- /dev/null
+++ b/assets/gamedata/zuma/level-018.json
@@ -0,0 +1,232 @@
+{
+ "level": 18,
+ "name": "Maelstrom Cross",
+ "frog": [
+ 960,
+ 990
+ ],
+ "colors": 6,
+ "background": "zuma-background-2",
+ "seed": 143542,
+ "starScores": [
+ 1440,
+ 2160,
+ 2880
+ ],
+ "paths": [
+ {
+ "shape": "figureEight",
+ "points": [
+ [
+ -80,
+ 830
+ ],
+ [
+ 60,
+ 690
+ ],
+ [
+ 130,
+ 505
+ ],
+ [
+ 136,
+ 416
+ ],
+ [
+ 154,
+ 333
+ ],
+ [
+ 183,
+ 259
+ ],
+ [
+ 223,
+ 199
+ ],
+ [
+ 274,
+ 156
+ ],
+ [
+ 334,
+ 134
+ ],
+ [
+ 404,
+ 132
+ ],
+ [
+ 481,
+ 152
+ ],
+ [
+ 565,
+ 191
+ ],
+ [
+ 655,
+ 249
+ ],
+ [
+ 749,
+ 321
+ ],
+ [
+ 846,
+ 403
+ ],
+ [
+ 945,
+ 491
+ ],
+ [
+ 1044,
+ 581
+ ],
+ [
+ 1142,
+ 665
+ ],
+ [
+ 1237,
+ 741
+ ],
+ [
+ 1328,
+ 803
+ ],
+ [
+ 1414,
+ 849
+ ],
+ [
+ 1494,
+ 874
+ ],
+ [
+ 1566,
+ 879
+ ],
+ [
+ 1629,
+ 863
+ ],
+ [
+ 1683,
+ 826
+ ],
+ [
+ 1726,
+ 771
+ ],
+ [
+ 1759,
+ 701
+ ],
+ [
+ 1780,
+ 620
+ ],
+ [
+ 1789,
+ 532
+ ],
+ [
+ 1787,
+ 443
+ ],
+ [
+ 1773,
+ 357
+ ],
+ [
+ 1747,
+ 280
+ ],
+ [
+ 1710,
+ 215
+ ],
+ [
+ 1663,
+ 167
+ ],
+ [
+ 1605,
+ 138
+ ],
+ [
+ 1538,
+ 130
+ ],
+ [
+ 1463,
+ 143
+ ],
+ [
+ 1381,
+ 177
+ ],
+ [
+ 1293,
+ 230
+ ],
+ [
+ 1200,
+ 298
+ ],
+ [
+ 1103,
+ 377
+ ],
+ [
+ 1005,
+ 464
+ ],
+ [
+ 906,
+ 554
+ ],
+ [
+ 808,
+ 640
+ ],
+ [
+ 711,
+ 719
+ ],
+ [
+ 619,
+ 786
+ ],
+ [
+ 531,
+ 837
+ ],
+ [
+ 449,
+ 869
+ ],
+ [
+ 375,
+ 880
+ ],
+ [
+ 309,
+ 870
+ ],
+ [
+ 252,
+ 839
+ ]
+ ],
+ "tunnels": [],
+ "quota": 40,
+ "introBalls": 11,
+ "pushSpeed": 120,
+ "powerUpRate": 0.045
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-019.json b/assets/gamedata/zuma/level-019.json
new file mode 100644
index 0000000..ecf5005
--- /dev/null
+++ b/assets/gamedata/zuma/level-019.json
@@ -0,0 +1,216 @@
+{
+ "level": 19,
+ "name": "Abyss Coil",
+ "frog": [
+ 960,
+ 575
+ ],
+ "colors": 6,
+ "background": "zuma-background-1",
+ "seed": 151461,
+ "starScores": [
+ 1940,
+ 2910,
+ 3880
+ ],
+ "paths": [
+ {
+ "shape": "spiral",
+ "points": [
+ [
+ -80,
+ 120
+ ],
+ [
+ 58,
+ 120
+ ],
+ [
+ 960,
+ 120
+ ],
+ [
+ 1195,
+ 143
+ ],
+ [
+ 1406,
+ 201
+ ],
+ [
+ 1577,
+ 287
+ ],
+ [
+ 1694,
+ 395
+ ],
+ [
+ 1749,
+ 514
+ ],
+ [
+ 1739,
+ 635
+ ],
+ [
+ 1667,
+ 749
+ ],
+ [
+ 1541,
+ 846
+ ],
+ [
+ 1370,
+ 919
+ ],
+ [
+ 1171,
+ 962
+ ],
+ [
+ 960,
+ 973
+ ],
+ [
+ 754,
+ 952
+ ],
+ [
+ 571,
+ 901
+ ],
+ [
+ 423,
+ 826
+ ],
+ [
+ 322,
+ 732
+ ],
+ [
+ 276,
+ 628
+ ],
+ [
+ 285,
+ 523
+ ],
+ [
+ 349,
+ 425
+ ],
+ [
+ 459,
+ 341
+ ],
+ [
+ 607,
+ 279
+ ],
+ [
+ 779,
+ 243
+ ],
+ [
+ 960,
+ 234
+ ],
+ [
+ 1136,
+ 253
+ ],
+ [
+ 1292,
+ 297
+ ],
+ [
+ 1417,
+ 362
+ ],
+ [
+ 1502,
+ 442
+ ],
+ [
+ 1540,
+ 530
+ ],
+ [
+ 1530,
+ 619
+ ],
+ [
+ 1475,
+ 702
+ ],
+ [
+ 1381,
+ 771
+ ],
+ [
+ 1256,
+ 823
+ ],
+ [
+ 1111,
+ 853
+ ],
+ [
+ 960,
+ 859
+ ],
+ [
+ 814,
+ 843
+ ],
+ [
+ 685,
+ 806
+ ],
+ [
+ 583,
+ 751
+ ],
+ [
+ 515,
+ 685
+ ],
+ [
+ 485,
+ 612
+ ],
+ [
+ 494,
+ 539
+ ],
+ [
+ 541,
+ 472
+ ],
+ [
+ 619,
+ 416
+ ],
+ [
+ 721,
+ 375
+ ],
+ [
+ 838,
+ 352
+ ],
+ [
+ 960,
+ 348
+ ]
+ ],
+ "tunnels": [],
+ "quota": 50,
+ "introBalls": 14,
+ "pushSpeed": 148,
+ "powerUpRate": 0.045
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/level-020.json b/assets/gamedata/zuma/level-020.json
new file mode 100644
index 0000000..fc579a1
--- /dev/null
+++ b/assets/gamedata/zuma/level-020.json
@@ -0,0 +1,216 @@
+{
+ "level": 20,
+ "name": "The Final Coil",
+ "frog": [
+ 960,
+ 575
+ ],
+ "colors": 6,
+ "background": "zuma-background-2",
+ "seed": 159380,
+ "starScores": [
+ 2040,
+ 3060,
+ 4080
+ ],
+ "paths": [
+ {
+ "shape": "spiral",
+ "points": [
+ [
+ -80,
+ 120
+ ],
+ [
+ 58,
+ 120
+ ],
+ [
+ 960,
+ 120
+ ],
+ [
+ 1195,
+ 143
+ ],
+ [
+ 1406,
+ 201
+ ],
+ [
+ 1577,
+ 287
+ ],
+ [
+ 1694,
+ 395
+ ],
+ [
+ 1749,
+ 514
+ ],
+ [
+ 1739,
+ 635
+ ],
+ [
+ 1667,
+ 749
+ ],
+ [
+ 1541,
+ 846
+ ],
+ [
+ 1370,
+ 919
+ ],
+ [
+ 1171,
+ 962
+ ],
+ [
+ 960,
+ 973
+ ],
+ [
+ 754,
+ 952
+ ],
+ [
+ 571,
+ 901
+ ],
+ [
+ 423,
+ 826
+ ],
+ [
+ 322,
+ 732
+ ],
+ [
+ 276,
+ 628
+ ],
+ [
+ 285,
+ 523
+ ],
+ [
+ 349,
+ 425
+ ],
+ [
+ 459,
+ 341
+ ],
+ [
+ 607,
+ 279
+ ],
+ [
+ 779,
+ 243
+ ],
+ [
+ 960,
+ 234
+ ],
+ [
+ 1136,
+ 253
+ ],
+ [
+ 1292,
+ 297
+ ],
+ [
+ 1417,
+ 362
+ ],
+ [
+ 1502,
+ 442
+ ],
+ [
+ 1540,
+ 530
+ ],
+ [
+ 1530,
+ 619
+ ],
+ [
+ 1475,
+ 702
+ ],
+ [
+ 1381,
+ 771
+ ],
+ [
+ 1256,
+ 823
+ ],
+ [
+ 1111,
+ 853
+ ],
+ [
+ 960,
+ 859
+ ],
+ [
+ 814,
+ 843
+ ],
+ [
+ 685,
+ 806
+ ],
+ [
+ 583,
+ 751
+ ],
+ [
+ 515,
+ 685
+ ],
+ [
+ 485,
+ 612
+ ],
+ [
+ 494,
+ 539
+ ],
+ [
+ 541,
+ 472
+ ],
+ [
+ 619,
+ 416
+ ],
+ [
+ 721,
+ 375
+ ],
+ [
+ 838,
+ 352
+ ],
+ [
+ 960,
+ 348
+ ]
+ ],
+ "tunnels": [],
+ "quota": 52,
+ "introBalls": 14,
+ "pushSpeed": 152,
+ "powerUpRate": 0.045
+ }
+ ]
+}
diff --git a/assets/gamedata/zuma/levels.json b/assets/gamedata/zuma/levels.json
new file mode 100644
index 0000000..975e401
--- /dev/null
+++ b/assets/gamedata/zuma/levels.json
@@ -0,0 +1,105 @@
+{
+ "version": 1,
+ "levels": [
+ {
+ "level": 1,
+ "name": "Riverbend",
+ "file": "level-001.json"
+ },
+ {
+ "level": 2,
+ "name": "Temple Gate",
+ "file": "level-002.json"
+ },
+ {
+ "level": 3,
+ "name": "Twin Pools",
+ "file": "level-003.json"
+ },
+ {
+ "level": 4,
+ "name": "Switchbacks",
+ "file": "level-004.json"
+ },
+ {
+ "level": 5,
+ "name": "Serpent Coil",
+ "file": "level-005.json"
+ },
+ {
+ "level": 6,
+ "name": "Crossroads",
+ "file": "level-006.json"
+ },
+ {
+ "level": 7,
+ "name": "Rapids",
+ "file": "level-007.json"
+ },
+ {
+ "level": 8,
+ "name": "Sun Court",
+ "file": "level-008.json"
+ },
+ {
+ "level": 9,
+ "name": "Thunder Steps",
+ "file": "level-009.json"
+ },
+ {
+ "level": 10,
+ "name": "Twin Serpents",
+ "file": "level-010.json"
+ },
+ {
+ "level": 11,
+ "name": "Deep Coil",
+ "file": "level-011.json"
+ },
+ {
+ "level": 12,
+ "name": "Tangled Path",
+ "file": "level-012.json"
+ },
+ {
+ "level": 13,
+ "name": "Lightning Run",
+ "file": "level-013.json"
+ },
+ {
+ "level": 14,
+ "name": "Whirlpool",
+ "file": "level-014.json"
+ },
+ {
+ "level": 15,
+ "name": "Obsidian Gate",
+ "file": "level-015.json"
+ },
+ {
+ "level": 16,
+ "name": "Twin Tempests",
+ "file": "level-016.json"
+ },
+ {
+ "level": 17,
+ "name": "Stormsteps",
+ "file": "level-017.json"
+ },
+ {
+ "level": 18,
+ "name": "Maelstrom Cross",
+ "file": "level-018.json"
+ },
+ {
+ "level": 19,
+ "name": "Abyss Coil",
+ "file": "level-019.json"
+ },
+ {
+ "level": 20,
+ "name": "The Final Coil",
+ "file": "level-020.json"
+ }
+ ]
+}
diff --git a/data/zuma.json b/data/zuma.json
deleted file mode 100644
index 40a47a1..0000000
--- a/data/zuma.json
+++ /dev/null
@@ -1,5640 +0,0 @@
-{
- "generatedAt": "2026-07-28T23:39:54.596Z",
- "count": 20,
- "levels": [
- {
- "level": 1,
- "name": "Riverbend",
- "shape": "sCurve",
- "points": [
- [
- -80,
- 140
- ],
- [
- -11,
- 140
- ],
- [
- 57,
- 140
- ],
- [
- 126,
- 140
- ],
- [
- 195,
- 140
- ],
- [
- 263,
- 140
- ],
- [
- 332,
- 140
- ],
- [
- 401,
- 140
- ],
- [
- 470,
- 140
- ],
- [
- 538,
- 140
- ],
- [
- 607,
- 140
- ],
- [
- 676,
- 140
- ],
- [
- 744,
- 140
- ],
- [
- 813,
- 140
- ],
- [
- 882,
- 140
- ],
- [
- 950,
- 140
- ],
- [
- 1019,
- 140
- ],
- [
- 1088,
- 140
- ],
- [
- 1157,
- 140
- ],
- [
- 1225,
- 140
- ],
- [
- 1294,
- 140
- ],
- [
- 1363,
- 140
- ],
- [
- 1431,
- 140
- ],
- [
- 1500,
- 140
- ],
- [
- 1572,
- 153
- ],
- [
- 1635,
- 189
- ],
- [
- 1682,
- 245
- ],
- [
- 1707,
- 314
- ],
- [
- 1707,
- 386
- ],
- [
- 1682,
- 455
- ],
- [
- 1635,
- 511
- ],
- [
- 1572,
- 547
- ],
- [
- 1500,
- 560
- ],
- [
- 1431,
- 560
- ],
- [
- 1362,
- 560
- ],
- [
- 1293,
- 560
- ],
- [
- 1224,
- 560
- ],
- [
- 1156,
- 560
- ],
- [
- 1087,
- 560
- ],
- [
- 1018,
- 560
- ],
- [
- 949,
- 560
- ],
- [
- 880,
- 560
- ],
- [
- 811,
- 560
- ],
- [
- 742,
- 560
- ],
- [
- 673,
- 560
- ],
- [
- 604,
- 560
- ],
- [
- 536,
- 560
- ],
- [
- 467,
- 560
- ],
- [
- 398,
- 560
- ],
- [
- 329,
- 560
- ],
- [
- 260,
- 560
- ],
- [
- 188,
- 573
- ],
- [
- 125,
- 609
- ],
- [
- 78,
- 665
- ],
- [
- 53,
- 734
- ],
- [
- 53,
- 806
- ],
- [
- 78,
- 875
- ],
- [
- 125,
- 931
- ],
- [
- 188,
- 967
- ],
- [
- 260,
- 980
- ],
- [
- 329,
- 980
- ],
- [
- 398,
- 980
- ],
- [
- 467,
- 980
- ],
- [
- 536,
- 980
- ],
- [
- 604,
- 980
- ],
- [
- 673,
- 980
- ],
- [
- 742,
- 980
- ],
- [
- 811,
- 980
- ],
- [
- 880,
- 980
- ],
- [
- 949,
- 980
- ],
- [
- 1018,
- 980
- ],
- [
- 1087,
- 980
- ],
- [
- 1156,
- 980
- ],
- [
- 1224,
- 980
- ],
- [
- 1293,
- 980
- ],
- [
- 1362,
- 980
- ],
- [
- 1431,
- 980
- ],
- [
- 1500,
- 980
- ],
- [
- 1573,
- 966
- ],
- [
- 1634,
- 924
- ],
- [
- 1676,
- 863
- ],
- [
- 1690,
- 790
- ],
- [
- 1690,
- 717
- ],
- [
- 1690,
- 643
- ],
- [
- 1690,
- 570
- ],
- [
- 1678,
- 509
- ],
- [
- 1643,
- 457
- ],
- [
- 1591,
- 422
- ],
- [
- 1530,
- 410
- ]
- ],
- "frog": [
- 880,
- 770
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 22,
- "introBalls": 7,
- "pushSpeed": 60,
- "powerUpRate": 0.07,
- "background": "zuma-background-1",
- "seed": 8919,
- "starScores": [
- 660,
- 990,
- 1320
- ]
- },
- {
- "level": 2,
- "name": "Temple Gate",
- "shape": "horseshoe",
- "points": [
- [
- -80,
- 130
- ],
- [
- 60,
- 130
- ],
- [
- 960,
- 130
- ],
- [
- 1191,
- 154
- ],
- [
- 1398,
- 210
- ],
- [
- 1565,
- 294
- ],
- [
- 1680,
- 399
- ],
- [
- 1734,
- 514
- ],
- [
- 1727,
- 632
- ],
- [
- 1659,
- 742
- ],
- [
- 1538,
- 836
- ],
- [
- 1375,
- 908
- ],
- [
- 1185,
- 951
- ],
- [
- 983,
- 964
- ],
- [
- 785,
- 946
- ],
- [
- 607,
- 900
- ],
- [
- 462,
- 831
- ],
- [
- 361,
- 743
- ],
- [
- 311,
- 646
- ],
- [
- 313,
- 547
- ],
- [
- 366,
- 454
- ],
- [
- 464,
- 374
- ],
- [
- 598,
- 313
- ],
- [
- 755,
- 275
- ],
- [
- 923,
- 263
- ],
- [
- 1087,
- 276
- ],
- [
- 1235,
- 312
- ],
- [
- 1356,
- 368
- ],
- [
- 1441,
- 438
- ],
- [
- 1485,
- 517
- ],
- [
- 1485,
- 598
- ],
- [
- 1445,
- 673
- ],
- [
- 1368,
- 739
- ]
- ],
- "frog": [
- 960,
- 580
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 26,
- "introBalls": 8,
- "pushSpeed": 76,
- "powerUpRate": 0.07,
- "background": "zuma-background-2",
- "seed": 16838,
- "starScores": [
- 820,
- 1230,
- 1640
- ]
- },
- {
- "level": 3,
- "name": "Twin Pools",
- "shape": "doubleLoop",
- "points": [
- [
- -80,
- 300
- ],
- [
- -10,
- 300
- ],
- [
- 60,
- 300
- ],
- [
- 130,
- 300
- ],
- [
- 200,
- 300
- ],
- [
- 270,
- 300
- ],
- [
- 340,
- 300
- ],
- [
- 410,
- 300
- ],
- [
- 480,
- 300
- ],
- [
- 550,
- 300
- ],
- [
- 620,
- 300
- ],
- [
- 689,
- 308
- ],
- [
- 755,
- 332
- ],
- [
- 813,
- 370
- ],
- [
- 861,
- 421
- ],
- [
- 895,
- 481
- ],
- [
- 915,
- 548
- ],
- [
- 919,
- 617
- ],
- [
- 907,
- 686
- ],
- [
- 880,
- 750
- ],
- [
- 838,
- 806
- ],
- [
- 785,
- 851
- ],
- [
- 723,
- 882
- ],
- [
- 655,
- 898
- ],
- [
- 585,
- 898
- ],
- [
- 517,
- 882
- ],
- [
- 455,
- 851
- ],
- [
- 402,
- 806
- ],
- [
- 360,
- 750
- ],
- [
- 333,
- 686
- ],
- [
- 321,
- 617
- ],
- [
- 325,
- 548
- ],
- [
- 345,
- 481
- ],
- [
- 379,
- 421
- ],
- [
- 427,
- 370
- ],
- [
- 485,
- 332
- ],
- [
- 551,
- 308
- ],
- [
- 620,
- 300
- ],
- [
- 691,
- 300
- ],
- [
- 762,
- 300
- ],
- [
- 833,
- 300
- ],
- [
- 904,
- 300
- ],
- [
- 975,
- 300
- ],
- [
- 1045,
- 300
- ],
- [
- 1116,
- 300
- ],
- [
- 1187,
- 300
- ],
- [
- 1258,
- 300
- ],
- [
- 1329,
- 300
- ],
- [
- 1400,
- 300
- ],
- [
- 1469,
- 308
- ],
- [
- 1535,
- 332
- ],
- [
- 1593,
- 370
- ],
- [
- 1641,
- 421
- ],
- [
- 1675,
- 481
- ],
- [
- 1695,
- 548
- ],
- [
- 1699,
- 617
- ],
- [
- 1687,
- 686
- ],
- [
- 1660,
- 750
- ],
- [
- 1618,
- 806
- ],
- [
- 1565,
- 851
- ],
- [
- 1503,
- 882
- ],
- [
- 1435,
- 898
- ],
- [
- 1365,
- 898
- ],
- [
- 1297,
- 882
- ],
- [
- 1235,
- 851
- ],
- [
- 1182,
- 806
- ],
- [
- 1140,
- 750
- ],
- [
- 1113,
- 686
- ],
- [
- 1101,
- 617
- ],
- [
- 1105,
- 548
- ],
- [
- 1125,
- 481
- ],
- [
- 1159,
- 421
- ],
- [
- 1207,
- 370
- ],
- [
- 1265,
- 332
- ],
- [
- 1331,
- 308
- ],
- [
- 1400,
- 300
- ],
- [
- 1473,
- 300
- ],
- [
- 1547,
- 300
- ],
- [
- 1620,
- 300
- ],
- [
- 1691,
- 311
- ],
- [
- 1755,
- 344
- ],
- [
- 1806,
- 395
- ],
- [
- 1839,
- 459
- ],
- [
- 1850,
- 530
- ],
- [
- 1839,
- 601
- ],
- [
- 1806,
- 665
- ],
- [
- 1755,
- 716
- ],
- [
- 1691,
- 749
- ],
- [
- 1620,
- 760
- ],
- [
- 1555,
- 760
- ],
- [
- 1490,
- 760
- ],
- [
- 1425,
- 760
- ],
- [
- 1360,
- 760
- ]
- ],
- "frog": [
- 620,
- 600
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 30,
- "introBalls": 9,
- "pushSpeed": 84,
- "powerUpRate": 0.065,
- "background": "zuma-background-1",
- "seed": 24757,
- "starScores": [
- 970,
- 1460,
- 1940
- ]
- },
- {
- "level": 4,
- "name": "Switchbacks",
- "shape": "zigzag",
- "points": [
- [
- -80,
- 130
- ],
- [
- -11,
- 130
- ],
- [
- 58,
- 130
- ],
- [
- 126,
- 130
- ],
- [
- 195,
- 130
- ],
- [
- 264,
- 130
- ],
- [
- 333,
- 130
- ],
- [
- 401,
- 130
- ],
- [
- 470,
- 130
- ],
- [
- 539,
- 130
- ],
- [
- 608,
- 130
- ],
- [
- 676,
- 130
- ],
- [
- 745,
- 130
- ],
- [
- 814,
- 130
- ],
- [
- 882,
- 130
- ],
- [
- 951,
- 130
- ],
- [
- 1020,
- 130
- ],
- [
- 1089,
- 130
- ],
- [
- 1158,
- 130
- ],
- [
- 1226,
- 130
- ],
- [
- 1295,
- 130
- ],
- [
- 1365,
- 149
- ],
- [
- 1416,
- 200
- ],
- [
- 1435,
- 270
- ],
- [
- 1416,
- 340
- ],
- [
- 1365,
- 391
- ],
- [
- 1295,
- 410
- ],
- [
- 1225,
- 410
- ],
- [
- 1156,
- 410
- ],
- [
- 1086,
- 410
- ],
- [
- 1016,
- 410
- ],
- [
- 947,
- 410
- ],
- [
- 877,
- 410
- ],
- [
- 807,
- 410
- ],
- [
- 738,
- 410
- ],
- [
- 668,
- 410
- ],
- [
- 598,
- 410
- ],
- [
- 529,
- 410
- ],
- [
- 459,
- 410
- ],
- [
- 389,
- 410
- ],
- [
- 320,
- 410
- ],
- [
- 250,
- 410
- ],
- [
- 180,
- 429
- ],
- [
- 129,
- 480
- ],
- [
- 110,
- 550
- ],
- [
- 129,
- 620
- ],
- [
- 180,
- 671
- ],
- [
- 250,
- 690
- ],
- [
- 320,
- 690
- ],
- [
- 389,
- 690
- ],
- [
- 459,
- 690
- ],
- [
- 529,
- 690
- ],
- [
- 598,
- 690
- ],
- [
- 668,
- 690
- ],
- [
- 738,
- 690
- ],
- [
- 807,
- 690
- ],
- [
- 877,
- 690
- ],
- [
- 947,
- 690
- ],
- [
- 1016,
- 690
- ],
- [
- 1086,
- 690
- ],
- [
- 1156,
- 690
- ],
- [
- 1225,
- 690
- ],
- [
- 1295,
- 690
- ],
- [
- 1365,
- 709
- ],
- [
- 1416,
- 760
- ],
- [
- 1435,
- 830
- ],
- [
- 1416,
- 900
- ],
- [
- 1365,
- 951
- ],
- [
- 1295,
- 970
- ],
- [
- 1225,
- 970
- ],
- [
- 1156,
- 970
- ],
- [
- 1086,
- 970
- ],
- [
- 1016,
- 970
- ],
- [
- 947,
- 970
- ],
- [
- 877,
- 970
- ],
- [
- 807,
- 970
- ],
- [
- 738,
- 970
- ],
- [
- 668,
- 970
- ],
- [
- 598,
- 970
- ],
- [
- 529,
- 970
- ],
- [
- 459,
- 970
- ],
- [
- 389,
- 970
- ],
- [
- 320,
- 970
- ],
- [
- 250,
- 970
- ]
- ],
- "frog": [
- 1620,
- 550
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 32,
- "introBalls": 10,
- "pushSpeed": 84,
- "powerUpRate": 0.065,
- "background": "zuma-background-2",
- "seed": 32676,
- "starScores": [
- 1040,
- 1550,
- 2070
- ]
- },
- {
- "level": 5,
- "name": "Serpent Coil",
- "shape": "spiral",
- "points": [
- [
- -80,
- 120
- ],
- [
- 58,
- 120
- ],
- [
- 960,
- 120
- ],
- [
- 1195,
- 143
- ],
- [
- 1406,
- 201
- ],
- [
- 1577,
- 287
- ],
- [
- 1694,
- 395
- ],
- [
- 1749,
- 514
- ],
- [
- 1739,
- 635
- ],
- [
- 1667,
- 749
- ],
- [
- 1541,
- 846
- ],
- [
- 1370,
- 919
- ],
- [
- 1171,
- 962
- ],
- [
- 960,
- 973
- ],
- [
- 754,
- 952
- ],
- [
- 571,
- 901
- ],
- [
- 423,
- 826
- ],
- [
- 322,
- 732
- ],
- [
- 276,
- 628
- ],
- [
- 285,
- 523
- ],
- [
- 349,
- 425
- ],
- [
- 459,
- 341
- ],
- [
- 607,
- 279
- ],
- [
- 779,
- 243
- ],
- [
- 960,
- 234
- ],
- [
- 1136,
- 253
- ],
- [
- 1292,
- 297
- ],
- [
- 1417,
- 362
- ],
- [
- 1502,
- 442
- ],
- [
- 1540,
- 530
- ],
- [
- 1530,
- 619
- ],
- [
- 1475,
- 702
- ],
- [
- 1381,
- 771
- ],
- [
- 1256,
- 823
- ],
- [
- 1111,
- 853
- ],
- [
- 960,
- 859
- ],
- [
- 814,
- 843
- ],
- [
- 685,
- 806
- ],
- [
- 583,
- 751
- ],
- [
- 515,
- 685
- ],
- [
- 485,
- 612
- ],
- [
- 494,
- 539
- ],
- [
- 541,
- 472
- ],
- [
- 619,
- 416
- ],
- [
- 721,
- 375
- ],
- [
- 838,
- 352
- ],
- [
- 960,
- 348
- ]
- ],
- "frog": [
- 960,
- 575
- ],
- "tunnels": [
- [
- 2633,
- 3291
- ]
- ],
- "colors": 4,
- "quota": 38,
- "introBalls": 11,
- "pushSpeed": 88,
- "powerUpRate": 0.06,
- "background": "zuma-background-1",
- "seed": 40595,
- "starScores": [
- 1250,
- 1870,
- 2490
- ]
- },
- {
- "level": 6,
- "name": "Crossroads",
- "shape": "figureEight",
- "points": [
- [
- -80,
- 830
- ],
- [
- 60,
- 690
- ],
- [
- 130,
- 505
- ],
- [
- 136,
- 416
- ],
- [
- 154,
- 333
- ],
- [
- 183,
- 259
- ],
- [
- 223,
- 199
- ],
- [
- 274,
- 156
- ],
- [
- 334,
- 134
- ],
- [
- 404,
- 132
- ],
- [
- 481,
- 152
- ],
- [
- 565,
- 191
- ],
- [
- 655,
- 249
- ],
- [
- 749,
- 321
- ],
- [
- 846,
- 403
- ],
- [
- 945,
- 491
- ],
- [
- 1044,
- 581
- ],
- [
- 1142,
- 665
- ],
- [
- 1237,
- 741
- ],
- [
- 1328,
- 803
- ],
- [
- 1414,
- 849
- ],
- [
- 1494,
- 874
- ],
- [
- 1566,
- 879
- ],
- [
- 1629,
- 863
- ],
- [
- 1683,
- 826
- ],
- [
- 1726,
- 771
- ],
- [
- 1759,
- 701
- ],
- [
- 1780,
- 620
- ],
- [
- 1789,
- 532
- ],
- [
- 1787,
- 443
- ],
- [
- 1773,
- 357
- ],
- [
- 1747,
- 280
- ],
- [
- 1710,
- 215
- ],
- [
- 1663,
- 167
- ],
- [
- 1605,
- 138
- ],
- [
- 1538,
- 130
- ],
- [
- 1463,
- 143
- ],
- [
- 1381,
- 177
- ],
- [
- 1293,
- 230
- ],
- [
- 1200,
- 298
- ],
- [
- 1103,
- 377
- ],
- [
- 1005,
- 464
- ],
- [
- 906,
- 554
- ],
- [
- 808,
- 640
- ],
- [
- 711,
- 719
- ],
- [
- 619,
- 786
- ],
- [
- 531,
- 837
- ],
- [
- 449,
- 869
- ],
- [
- 375,
- 880
- ],
- [
- 309,
- 870
- ],
- [
- 252,
- 839
- ]
- ],
- "frog": [
- 960,
- 990
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 34,
- "introBalls": 10,
- "pushSpeed": 88,
- "powerUpRate": 0.06,
- "background": "zuma-background-2",
- "seed": 48514,
- "starScores": [
- 1120,
- 1670,
- 2230
- ]
- },
- {
- "level": 7,
- "name": "Rapids",
- "shape": "sCurve",
- "points": [
- [
- -80,
- 140
- ],
- [
- -11,
- 140
- ],
- [
- 57,
- 140
- ],
- [
- 126,
- 140
- ],
- [
- 195,
- 140
- ],
- [
- 263,
- 140
- ],
- [
- 332,
- 140
- ],
- [
- 401,
- 140
- ],
- [
- 470,
- 140
- ],
- [
- 538,
- 140
- ],
- [
- 607,
- 140
- ],
- [
- 676,
- 140
- ],
- [
- 744,
- 140
- ],
- [
- 813,
- 140
- ],
- [
- 882,
- 140
- ],
- [
- 950,
- 140
- ],
- [
- 1019,
- 140
- ],
- [
- 1088,
- 140
- ],
- [
- 1157,
- 140
- ],
- [
- 1225,
- 140
- ],
- [
- 1294,
- 140
- ],
- [
- 1363,
- 140
- ],
- [
- 1431,
- 140
- ],
- [
- 1500,
- 140
- ],
- [
- 1572,
- 153
- ],
- [
- 1635,
- 189
- ],
- [
- 1682,
- 245
- ],
- [
- 1707,
- 314
- ],
- [
- 1707,
- 386
- ],
- [
- 1682,
- 455
- ],
- [
- 1635,
- 511
- ],
- [
- 1572,
- 547
- ],
- [
- 1500,
- 560
- ],
- [
- 1431,
- 560
- ],
- [
- 1362,
- 560
- ],
- [
- 1293,
- 560
- ],
- [
- 1224,
- 560
- ],
- [
- 1156,
- 560
- ],
- [
- 1087,
- 560
- ],
- [
- 1018,
- 560
- ],
- [
- 949,
- 560
- ],
- [
- 880,
- 560
- ],
- [
- 811,
- 560
- ],
- [
- 742,
- 560
- ],
- [
- 673,
- 560
- ],
- [
- 604,
- 560
- ],
- [
- 536,
- 560
- ],
- [
- 467,
- 560
- ],
- [
- 398,
- 560
- ],
- [
- 329,
- 560
- ],
- [
- 260,
- 560
- ],
- [
- 188,
- 573
- ],
- [
- 125,
- 609
- ],
- [
- 78,
- 665
- ],
- [
- 53,
- 734
- ],
- [
- 53,
- 806
- ],
- [
- 78,
- 875
- ],
- [
- 125,
- 931
- ],
- [
- 188,
- 967
- ],
- [
- 260,
- 980
- ],
- [
- 329,
- 980
- ],
- [
- 398,
- 980
- ],
- [
- 467,
- 980
- ],
- [
- 536,
- 980
- ],
- [
- 604,
- 980
- ],
- [
- 673,
- 980
- ],
- [
- 742,
- 980
- ],
- [
- 811,
- 980
- ],
- [
- 880,
- 980
- ],
- [
- 949,
- 980
- ],
- [
- 1018,
- 980
- ],
- [
- 1087,
- 980
- ],
- [
- 1156,
- 980
- ],
- [
- 1224,
- 980
- ],
- [
- 1293,
- 980
- ],
- [
- 1362,
- 980
- ],
- [
- 1431,
- 980
- ],
- [
- 1500,
- 980
- ],
- [
- 1573,
- 966
- ],
- [
- 1634,
- 924
- ],
- [
- 1676,
- 863
- ],
- [
- 1690,
- 790
- ],
- [
- 1690,
- 717
- ],
- [
- 1690,
- 643
- ],
- [
- 1690,
- 570
- ],
- [
- 1678,
- 509
- ],
- [
- 1643,
- 457
- ],
- [
- 1591,
- 422
- ],
- [
- 1530,
- 410
- ]
- ],
- "frog": [
- 880,
- 770
- ],
- "tunnels": [],
- "colors": 4,
- "quota": 26,
- "introBalls": 8,
- "pushSpeed": 108,
- "powerUpRate": 0.06,
- "background": "zuma-background-1",
- "seed": 56433,
- "starScores": [
- 910,
- 1360,
- 1810
- ]
- },
- {
- "level": 8,
- "name": "Sun Court",
- "shape": "horseshoe",
- "points": [
- [
- -80,
- 130
- ],
- [
- 60,
- 130
- ],
- [
- 960,
- 130
- ],
- [
- 1191,
- 154
- ],
- [
- 1398,
- 210
- ],
- [
- 1565,
- 294
- ],
- [
- 1680,
- 399
- ],
- [
- 1734,
- 514
- ],
- [
- 1727,
- 632
- ],
- [
- 1659,
- 742
- ],
- [
- 1538,
- 836
- ],
- [
- 1375,
- 908
- ],
- [
- 1185,
- 951
- ],
- [
- 983,
- 964
- ],
- [
- 785,
- 946
- ],
- [
- 607,
- 900
- ],
- [
- 462,
- 831
- ],
- [
- 361,
- 743
- ],
- [
- 311,
- 646
- ],
- [
- 313,
- 547
- ],
- [
- 366,
- 454
- ],
- [
- 464,
- 374
- ],
- [
- 598,
- 313
- ],
- [
- 755,
- 275
- ],
- [
- 923,
- 263
- ],
- [
- 1087,
- 276
- ],
- [
- 1235,
- 312
- ],
- [
- 1356,
- 368
- ],
- [
- 1441,
- 438
- ],
- [
- 1485,
- 517
- ],
- [
- 1485,
- 598
- ],
- [
- 1445,
- 673
- ],
- [
- 1368,
- 739
- ]
- ],
- "frog": [
- 960,
- 580
- ],
- "tunnels": [
- [
- 1857,
- 2403
- ]
- ],
- "colors": 5,
- "quota": 30,
- "introBalls": 9,
- "pushSpeed": 96,
- "powerUpRate": 0.055,
- "background": "zuma-background-2",
- "seed": 64352,
- "starScores": [
- 1010,
- 1520,
- 2020
- ]
- },
- {
- "level": 9,
- "name": "Thunder Steps",
- "shape": "zigzag",
- "points": [
- [
- -80,
- 130
- ],
- [
- -11,
- 130
- ],
- [
- 58,
- 130
- ],
- [
- 126,
- 130
- ],
- [
- 195,
- 130
- ],
- [
- 264,
- 130
- ],
- [
- 333,
- 130
- ],
- [
- 401,
- 130
- ],
- [
- 470,
- 130
- ],
- [
- 539,
- 130
- ],
- [
- 608,
- 130
- ],
- [
- 676,
- 130
- ],
- [
- 745,
- 130
- ],
- [
- 814,
- 130
- ],
- [
- 882,
- 130
- ],
- [
- 951,
- 130
- ],
- [
- 1020,
- 130
- ],
- [
- 1089,
- 130
- ],
- [
- 1158,
- 130
- ],
- [
- 1226,
- 130
- ],
- [
- 1295,
- 130
- ],
- [
- 1365,
- 149
- ],
- [
- 1416,
- 200
- ],
- [
- 1435,
- 270
- ],
- [
- 1416,
- 340
- ],
- [
- 1365,
- 391
- ],
- [
- 1295,
- 410
- ],
- [
- 1225,
- 410
- ],
- [
- 1156,
- 410
- ],
- [
- 1086,
- 410
- ],
- [
- 1016,
- 410
- ],
- [
- 947,
- 410
- ],
- [
- 877,
- 410
- ],
- [
- 807,
- 410
- ],
- [
- 738,
- 410
- ],
- [
- 668,
- 410
- ],
- [
- 598,
- 410
- ],
- [
- 529,
- 410
- ],
- [
- 459,
- 410
- ],
- [
- 389,
- 410
- ],
- [
- 320,
- 410
- ],
- [
- 250,
- 410
- ],
- [
- 180,
- 429
- ],
- [
- 129,
- 480
- ],
- [
- 110,
- 550
- ],
- [
- 129,
- 620
- ],
- [
- 180,
- 671
- ],
- [
- 250,
- 690
- ],
- [
- 320,
- 690
- ],
- [
- 389,
- 690
- ],
- [
- 459,
- 690
- ],
- [
- 529,
- 690
- ],
- [
- 598,
- 690
- ],
- [
- 668,
- 690
- ],
- [
- 738,
- 690
- ],
- [
- 807,
- 690
- ],
- [
- 877,
- 690
- ],
- [
- 947,
- 690
- ],
- [
- 1016,
- 690
- ],
- [
- 1086,
- 690
- ],
- [
- 1156,
- 690
- ],
- [
- 1225,
- 690
- ],
- [
- 1295,
- 690
- ],
- [
- 1365,
- 709
- ],
- [
- 1416,
- 760
- ],
- [
- 1435,
- 830
- ],
- [
- 1416,
- 900
- ],
- [
- 1365,
- 951
- ],
- [
- 1295,
- 970
- ],
- [
- 1225,
- 970
- ],
- [
- 1156,
- 970
- ],
- [
- 1086,
- 970
- ],
- [
- 1016,
- 970
- ],
- [
- 947,
- 970
- ],
- [
- 877,
- 970
- ],
- [
- 807,
- 970
- ],
- [
- 738,
- 970
- ],
- [
- 668,
- 970
- ],
- [
- 598,
- 970
- ],
- [
- 529,
- 970
- ],
- [
- 459,
- 970
- ],
- [
- 389,
- 970
- ],
- [
- 320,
- 970
- ],
- [
- 250,
- 970
- ]
- ],
- "frog": [
- 1620,
- 550
- ],
- "tunnels": [],
- "colors": 5,
- "quota": 36,
- "introBalls": 11,
- "pushSpeed": 96,
- "powerUpRate": 0.055,
- "background": "zuma-background-1",
- "seed": 72271,
- "starScores": [
- 1210,
- 1820,
- 2420
- ]
- },
- {
- "level": 10,
- "name": "Twin Serpents",
- "shape": "doubleLoop",
- "points": [
- [
- -80,
- 300
- ],
- [
- -10,
- 300
- ],
- [
- 60,
- 300
- ],
- [
- 130,
- 300
- ],
- [
- 200,
- 300
- ],
- [
- 270,
- 300
- ],
- [
- 340,
- 300
- ],
- [
- 410,
- 300
- ],
- [
- 480,
- 300
- ],
- [
- 550,
- 300
- ],
- [
- 620,
- 300
- ],
- [
- 689,
- 308
- ],
- [
- 755,
- 332
- ],
- [
- 813,
- 370
- ],
- [
- 861,
- 421
- ],
- [
- 895,
- 481
- ],
- [
- 915,
- 548
- ],
- [
- 919,
- 617
- ],
- [
- 907,
- 686
- ],
- [
- 880,
- 750
- ],
- [
- 838,
- 806
- ],
- [
- 785,
- 851
- ],
- [
- 723,
- 882
- ],
- [
- 655,
- 898
- ],
- [
- 585,
- 898
- ],
- [
- 517,
- 882
- ],
- [
- 455,
- 851
- ],
- [
- 402,
- 806
- ],
- [
- 360,
- 750
- ],
- [
- 333,
- 686
- ],
- [
- 321,
- 617
- ],
- [
- 325,
- 548
- ],
- [
- 345,
- 481
- ],
- [
- 379,
- 421
- ],
- [
- 427,
- 370
- ],
- [
- 485,
- 332
- ],
- [
- 551,
- 308
- ],
- [
- 620,
- 300
- ],
- [
- 691,
- 300
- ],
- [
- 762,
- 300
- ],
- [
- 833,
- 300
- ],
- [
- 904,
- 300
- ],
- [
- 975,
- 300
- ],
- [
- 1045,
- 300
- ],
- [
- 1116,
- 300
- ],
- [
- 1187,
- 300
- ],
- [
- 1258,
- 300
- ],
- [
- 1329,
- 300
- ],
- [
- 1400,
- 300
- ],
- [
- 1469,
- 308
- ],
- [
- 1535,
- 332
- ],
- [
- 1593,
- 370
- ],
- [
- 1641,
- 421
- ],
- [
- 1675,
- 481
- ],
- [
- 1695,
- 548
- ],
- [
- 1699,
- 617
- ],
- [
- 1687,
- 686
- ],
- [
- 1660,
- 750
- ],
- [
- 1618,
- 806
- ],
- [
- 1565,
- 851
- ],
- [
- 1503,
- 882
- ],
- [
- 1435,
- 898
- ],
- [
- 1365,
- 898
- ],
- [
- 1297,
- 882
- ],
- [
- 1235,
- 851
- ],
- [
- 1182,
- 806
- ],
- [
- 1140,
- 750
- ],
- [
- 1113,
- 686
- ],
- [
- 1101,
- 617
- ],
- [
- 1105,
- 548
- ],
- [
- 1125,
- 481
- ],
- [
- 1159,
- 421
- ],
- [
- 1207,
- 370
- ],
- [
- 1265,
- 332
- ],
- [
- 1331,
- 308
- ],
- [
- 1400,
- 300
- ],
- [
- 1473,
- 300
- ],
- [
- 1547,
- 300
- ],
- [
- 1620,
- 300
- ],
- [
- 1691,
- 311
- ],
- [
- 1755,
- 344
- ],
- [
- 1806,
- 395
- ],
- [
- 1839,
- 459
- ],
- [
- 1850,
- 530
- ],
- [
- 1839,
- 601
- ],
- [
- 1806,
- 665
- ],
- [
- 1755,
- 716
- ],
- [
- 1691,
- 749
- ],
- [
- 1620,
- 760
- ],
- [
- 1555,
- 760
- ],
- [
- 1490,
- 760
- ],
- [
- 1425,
- 760
- ],
- [
- 1360,
- 760
- ]
- ],
- "frog": [
- 620,
- 600
- ],
- "tunnels": [
- [
- 2581,
- 3226
- ]
- ],
- "colors": 5,
- "quota": 34,
- "introBalls": 10,
- "pushSpeed": 104,
- "powerUpRate": 0.055,
- "background": "zuma-background-2",
- "seed": 80190,
- "starScores": [
- 1170,
- 1760,
- 2340
- ]
- },
- {
- "level": 11,
- "name": "Deep Coil",
- "shape": "spiral",
- "points": [
- [
- -80,
- 120
- ],
- [
- 58,
- 120
- ],
- [
- 960,
- 120
- ],
- [
- 1195,
- 143
- ],
- [
- 1406,
- 201
- ],
- [
- 1577,
- 287
- ],
- [
- 1694,
- 395
- ],
- [
- 1749,
- 514
- ],
- [
- 1739,
- 635
- ],
- [
- 1667,
- 749
- ],
- [
- 1541,
- 846
- ],
- [
- 1370,
- 919
- ],
- [
- 1171,
- 962
- ],
- [
- 960,
- 973
- ],
- [
- 754,
- 952
- ],
- [
- 571,
- 901
- ],
- [
- 423,
- 826
- ],
- [
- 322,
- 732
- ],
- [
- 276,
- 628
- ],
- [
- 285,
- 523
- ],
- [
- 349,
- 425
- ],
- [
- 459,
- 341
- ],
- [
- 607,
- 279
- ],
- [
- 779,
- 243
- ],
- [
- 960,
- 234
- ],
- [
- 1136,
- 253
- ],
- [
- 1292,
- 297
- ],
- [
- 1417,
- 362
- ],
- [
- 1502,
- 442
- ],
- [
- 1540,
- 530
- ],
- [
- 1530,
- 619
- ],
- [
- 1475,
- 702
- ],
- [
- 1381,
- 771
- ],
- [
- 1256,
- 823
- ],
- [
- 1111,
- 853
- ],
- [
- 960,
- 859
- ],
- [
- 814,
- 843
- ],
- [
- 685,
- 806
- ],
- [
- 583,
- 751
- ],
- [
- 515,
- 685
- ],
- [
- 485,
- 612
- ],
- [
- 494,
- 539
- ],
- [
- 541,
- 472
- ],
- [
- 619,
- 416
- ],
- [
- 721,
- 375
- ],
- [
- 838,
- 352
- ],
- [
- 960,
- 348
- ]
- ],
- "frog": [
- 960,
- 575
- ],
- "tunnels": [
- [
- 2194,
- 2925
- ]
- ],
- "colors": 5,
- "quota": 42,
- "introBalls": 12,
- "pushSpeed": 104,
- "powerUpRate": 0.05,
- "background": "zuma-background-1",
- "seed": 88109,
- "starScores": [
- 1450,
- 2170,
- 2890
- ]
- },
- {
- "level": 12,
- "name": "Tangled Path",
- "shape": "figureEight",
- "points": [
- [
- -80,
- 830
- ],
- [
- 60,
- 690
- ],
- [
- 130,
- 505
- ],
- [
- 136,
- 416
- ],
- [
- 154,
- 333
- ],
- [
- 183,
- 259
- ],
- [
- 223,
- 199
- ],
- [
- 274,
- 156
- ],
- [
- 334,
- 134
- ],
- [
- 404,
- 132
- ],
- [
- 481,
- 152
- ],
- [
- 565,
- 191
- ],
- [
- 655,
- 249
- ],
- [
- 749,
- 321
- ],
- [
- 846,
- 403
- ],
- [
- 945,
- 491
- ],
- [
- 1044,
- 581
- ],
- [
- 1142,
- 665
- ],
- [
- 1237,
- 741
- ],
- [
- 1328,
- 803
- ],
- [
- 1414,
- 849
- ],
- [
- 1494,
- 874
- ],
- [
- 1566,
- 879
- ],
- [
- 1629,
- 863
- ],
- [
- 1683,
- 826
- ],
- [
- 1726,
- 771
- ],
- [
- 1759,
- 701
- ],
- [
- 1780,
- 620
- ],
- [
- 1789,
- 532
- ],
- [
- 1787,
- 443
- ],
- [
- 1773,
- 357
- ],
- [
- 1747,
- 280
- ],
- [
- 1710,
- 215
- ],
- [
- 1663,
- 167
- ],
- [
- 1605,
- 138
- ],
- [
- 1538,
- 130
- ],
- [
- 1463,
- 143
- ],
- [
- 1381,
- 177
- ],
- [
- 1293,
- 230
- ],
- [
- 1200,
- 298
- ],
- [
- 1103,
- 377
- ],
- [
- 1005,
- 464
- ],
- [
- 906,
- 554
- ],
- [
- 808,
- 640
- ],
- [
- 711,
- 719
- ],
- [
- 619,
- 786
- ],
- [
- 531,
- 837
- ],
- [
- 449,
- 869
- ],
- [
- 375,
- 880
- ],
- [
- 309,
- 870
- ],
- [
- 252,
- 839
- ]
- ],
- "frog": [
- 960,
- 990
- ],
- "tunnels": [],
- "colors": 5,
- "quota": 38,
- "introBalls": 11,
- "pushSpeed": 108,
- "powerUpRate": 0.05,
- "background": "zuma-background-2",
- "seed": 96028,
- "starScores": [
- 1320,
- 1980,
- 2640
- ]
- },
- {
- "level": 13,
- "name": "Lightning Run",
- "shape": "zigzag",
- "points": [
- [
- -80,
- 130
- ],
- [
- -11,
- 130
- ],
- [
- 58,
- 130
- ],
- [
- 126,
- 130
- ],
- [
- 195,
- 130
- ],
- [
- 264,
- 130
- ],
- [
- 333,
- 130
- ],
- [
- 401,
- 130
- ],
- [
- 470,
- 130
- ],
- [
- 539,
- 130
- ],
- [
- 608,
- 130
- ],
- [
- 676,
- 130
- ],
- [
- 745,
- 130
- ],
- [
- 814,
- 130
- ],
- [
- 882,
- 130
- ],
- [
- 951,
- 130
- ],
- [
- 1020,
- 130
- ],
- [
- 1089,
- 130
- ],
- [
- 1158,
- 130
- ],
- [
- 1226,
- 130
- ],
- [
- 1295,
- 130
- ],
- [
- 1365,
- 149
- ],
- [
- 1416,
- 200
- ],
- [
- 1435,
- 270
- ],
- [
- 1416,
- 340
- ],
- [
- 1365,
- 391
- ],
- [
- 1295,
- 410
- ],
- [
- 1225,
- 410
- ],
- [
- 1156,
- 410
- ],
- [
- 1086,
- 410
- ],
- [
- 1016,
- 410
- ],
- [
- 947,
- 410
- ],
- [
- 877,
- 410
- ],
- [
- 807,
- 410
- ],
- [
- 738,
- 410
- ],
- [
- 668,
- 410
- ],
- [
- 598,
- 410
- ],
- [
- 529,
- 410
- ],
- [
- 459,
- 410
- ],
- [
- 389,
- 410
- ],
- [
- 320,
- 410
- ],
- [
- 250,
- 410
- ],
- [
- 180,
- 429
- ],
- [
- 129,
- 480
- ],
- [
- 110,
- 550
- ],
- [
- 129,
- 620
- ],
- [
- 180,
- 671
- ],
- [
- 250,
- 690
- ],
- [
- 320,
- 690
- ],
- [
- 389,
- 690
- ],
- [
- 459,
- 690
- ],
- [
- 529,
- 690
- ],
- [
- 598,
- 690
- ],
- [
- 668,
- 690
- ],
- [
- 738,
- 690
- ],
- [
- 807,
- 690
- ],
- [
- 877,
- 690
- ],
- [
- 947,
- 690
- ],
- [
- 1016,
- 690
- ],
- [
- 1086,
- 690
- ],
- [
- 1156,
- 690
- ],
- [
- 1225,
- 690
- ],
- [
- 1295,
- 690
- ],
- [
- 1365,
- 709
- ],
- [
- 1416,
- 760
- ],
- [
- 1435,
- 830
- ],
- [
- 1416,
- 900
- ],
- [
- 1365,
- 951
- ],
- [
- 1295,
- 970
- ],
- [
- 1225,
- 970
- ],
- [
- 1156,
- 970
- ],
- [
- 1086,
- 970
- ],
- [
- 1016,
- 970
- ],
- [
- 947,
- 970
- ],
- [
- 877,
- 970
- ],
- [
- 807,
- 970
- ],
- [
- 738,
- 970
- ],
- [
- 668,
- 970
- ],
- [
- 598,
- 970
- ],
- [
- 529,
- 970
- ],
- [
- 459,
- 970
- ],
- [
- 389,
- 970
- ],
- [
- 320,
- 970
- ],
- [
- 250,
- 970
- ]
- ],
- "frog": [
- 1620,
- 550
- ],
- "tunnels": [],
- "colors": 5,
- "quota": 34,
- "introBalls": 10,
- "pushSpeed": 116,
- "powerUpRate": 0.05,
- "background": "zuma-background-1",
- "seed": 103947,
- "starScores": [
- 1210,
- 1820,
- 2420
- ]
- },
- {
- "level": 14,
- "name": "Whirlpool",
- "shape": "spiral",
- "points": [
- [
- -80,
- 120
- ],
- [
- 58,
- 120
- ],
- [
- 960,
- 120
- ],
- [
- 1195,
- 143
- ],
- [
- 1406,
- 201
- ],
- [
- 1577,
- 287
- ],
- [
- 1694,
- 395
- ],
- [
- 1749,
- 514
- ],
- [
- 1739,
- 635
- ],
- [
- 1667,
- 749
- ],
- [
- 1541,
- 846
- ],
- [
- 1370,
- 919
- ],
- [
- 1171,
- 962
- ],
- [
- 960,
- 973
- ],
- [
- 754,
- 952
- ],
- [
- 571,
- 901
- ],
- [
- 423,
- 826
- ],
- [
- 322,
- 732
- ],
- [
- 276,
- 628
- ],
- [
- 285,
- 523
- ],
- [
- 349,
- 425
- ],
- [
- 459,
- 341
- ],
- [
- 607,
- 279
- ],
- [
- 779,
- 243
- ],
- [
- 960,
- 234
- ],
- [
- 1136,
- 253
- ],
- [
- 1292,
- 297
- ],
- [
- 1417,
- 362
- ],
- [
- 1502,
- 442
- ],
- [
- 1540,
- 530
- ],
- [
- 1530,
- 619
- ],
- [
- 1475,
- 702
- ],
- [
- 1381,
- 771
- ],
- [
- 1256,
- 823
- ],
- [
- 1111,
- 853
- ],
- [
- 960,
- 859
- ],
- [
- 814,
- 843
- ],
- [
- 685,
- 806
- ],
- [
- 583,
- 751
- ],
- [
- 515,
- 685
- ],
- [
- 485,
- 612
- ],
- [
- 494,
- 539
- ],
- [
- 541,
- 472
- ],
- [
- 619,
- 416
- ],
- [
- 721,
- 375
- ],
- [
- 838,
- 352
- ],
- [
- 960,
- 348
- ]
- ],
- "frog": [
- 960,
- 575
- ],
- "tunnels": [
- [
- 1901,
- 2633
- ],
- [
- 4388,
- 5119
- ]
- ],
- "colors": 5,
- "quota": 46,
- "introBalls": 13,
- "pushSpeed": 116,
- "powerUpRate": 0.05,
- "background": "zuma-background-2",
- "seed": 111866,
- "starScores": [
- 1640,
- 2460,
- 3280
- ]
- },
- {
- "level": 15,
- "name": "Obsidian Gate",
- "shape": "horseshoe",
- "points": [
- [
- -80,
- 130
- ],
- [
- 60,
- 130
- ],
- [
- 960,
- 130
- ],
- [
- 1191,
- 154
- ],
- [
- 1398,
- 210
- ],
- [
- 1565,
- 294
- ],
- [
- 1680,
- 399
- ],
- [
- 1734,
- 514
- ],
- [
- 1727,
- 632
- ],
- [
- 1659,
- 742
- ],
- [
- 1538,
- 836
- ],
- [
- 1375,
- 908
- ],
- [
- 1185,
- 951
- ],
- [
- 983,
- 964
- ],
- [
- 785,
- 946
- ],
- [
- 607,
- 900
- ],
- [
- 462,
- 831
- ],
- [
- 361,
- 743
- ],
- [
- 311,
- 646
- ],
- [
- 313,
- 547
- ],
- [
- 366,
- 454
- ],
- [
- 464,
- 374
- ],
- [
- 598,
- 313
- ],
- [
- 755,
- 275
- ],
- [
- 923,
- 263
- ],
- [
- 1087,
- 276
- ],
- [
- 1235,
- 312
- ],
- [
- 1356,
- 368
- ],
- [
- 1441,
- 438
- ],
- [
- 1485,
- 517
- ],
- [
- 1485,
- 598
- ],
- [
- 1445,
- 673
- ],
- [
- 1368,
- 739
- ]
- ],
- "frog": [
- 960,
- 580
- ],
- "tunnels": [],
- "colors": 6,
- "quota": 32,
- "introBalls": 9,
- "pushSpeed": 124,
- "powerUpRate": 0.05,
- "background": "zuma-background-1",
- "seed": 119785,
- "starScores": [
- 1170,
- 1750,
- 2330
- ]
- },
- {
- "level": 16,
- "name": "Twin Tempests",
- "shape": "doubleLoop",
- "points": [
- [
- -80,
- 300
- ],
- [
- -10,
- 300
- ],
- [
- 60,
- 300
- ],
- [
- 130,
- 300
- ],
- [
- 200,
- 300
- ],
- [
- 270,
- 300
- ],
- [
- 340,
- 300
- ],
- [
- 410,
- 300
- ],
- [
- 480,
- 300
- ],
- [
- 550,
- 300
- ],
- [
- 620,
- 300
- ],
- [
- 689,
- 308
- ],
- [
- 755,
- 332
- ],
- [
- 813,
- 370
- ],
- [
- 861,
- 421
- ],
- [
- 895,
- 481
- ],
- [
- 915,
- 548
- ],
- [
- 919,
- 617
- ],
- [
- 907,
- 686
- ],
- [
- 880,
- 750
- ],
- [
- 838,
- 806
- ],
- [
- 785,
- 851
- ],
- [
- 723,
- 882
- ],
- [
- 655,
- 898
- ],
- [
- 585,
- 898
- ],
- [
- 517,
- 882
- ],
- [
- 455,
- 851
- ],
- [
- 402,
- 806
- ],
- [
- 360,
- 750
- ],
- [
- 333,
- 686
- ],
- [
- 321,
- 617
- ],
- [
- 325,
- 548
- ],
- [
- 345,
- 481
- ],
- [
- 379,
- 421
- ],
- [
- 427,
- 370
- ],
- [
- 485,
- 332
- ],
- [
- 551,
- 308
- ],
- [
- 620,
- 300
- ],
- [
- 691,
- 300
- ],
- [
- 762,
- 300
- ],
- [
- 833,
- 300
- ],
- [
- 904,
- 300
- ],
- [
- 975,
- 300
- ],
- [
- 1045,
- 300
- ],
- [
- 1116,
- 300
- ],
- [
- 1187,
- 300
- ],
- [
- 1258,
- 300
- ],
- [
- 1329,
- 300
- ],
- [
- 1400,
- 300
- ],
- [
- 1469,
- 308
- ],
- [
- 1535,
- 332
- ],
- [
- 1593,
- 370
- ],
- [
- 1641,
- 421
- ],
- [
- 1675,
- 481
- ],
- [
- 1695,
- 548
- ],
- [
- 1699,
- 617
- ],
- [
- 1687,
- 686
- ],
- [
- 1660,
- 750
- ],
- [
- 1618,
- 806
- ],
- [
- 1565,
- 851
- ],
- [
- 1503,
- 882
- ],
- [
- 1435,
- 898
- ],
- [
- 1365,
- 898
- ],
- [
- 1297,
- 882
- ],
- [
- 1235,
- 851
- ],
- [
- 1182,
- 806
- ],
- [
- 1140,
- 750
- ],
- [
- 1113,
- 686
- ],
- [
- 1101,
- 617
- ],
- [
- 1105,
- 548
- ],
- [
- 1125,
- 481
- ],
- [
- 1159,
- 421
- ],
- [
- 1207,
- 370
- ],
- [
- 1265,
- 332
- ],
- [
- 1331,
- 308
- ],
- [
- 1400,
- 300
- ],
- [
- 1473,
- 300
- ],
- [
- 1547,
- 300
- ],
- [
- 1620,
- 300
- ],
- [
- 1691,
- 311
- ],
- [
- 1755,
- 344
- ],
- [
- 1806,
- 395
- ],
- [
- 1839,
- 459
- ],
- [
- 1850,
- 530
- ],
- [
- 1839,
- 601
- ],
- [
- 1806,
- 665
- ],
- [
- 1755,
- 716
- ],
- [
- 1691,
- 749
- ],
- [
- 1620,
- 760
- ],
- [
- 1555,
- 760
- ],
- [
- 1490,
- 760
- ],
- [
- 1425,
- 760
- ],
- [
- 1360,
- 760
- ]
- ],
- "frog": [
- 620,
- 600
- ],
- "tunnels": [
- [
- 2452,
- 2968
- ]
- ],
- "colors": 6,
- "quota": 38,
- "introBalls": 11,
- "pushSpeed": 128,
- "powerUpRate": 0.05,
- "background": "zuma-background-2",
- "seed": 127704,
- "starScores": [
- 1400,
- 2100,
- 2800
- ]
- },
- {
- "level": 17,
- "name": "Stormsteps",
- "shape": "zigzag",
- "points": [
- [
- -80,
- 130
- ],
- [
- -11,
- 130
- ],
- [
- 58,
- 130
- ],
- [
- 126,
- 130
- ],
- [
- 195,
- 130
- ],
- [
- 264,
- 130
- ],
- [
- 333,
- 130
- ],
- [
- 401,
- 130
- ],
- [
- 470,
- 130
- ],
- [
- 539,
- 130
- ],
- [
- 608,
- 130
- ],
- [
- 676,
- 130
- ],
- [
- 745,
- 130
- ],
- [
- 814,
- 130
- ],
- [
- 882,
- 130
- ],
- [
- 951,
- 130
- ],
- [
- 1020,
- 130
- ],
- [
- 1089,
- 130
- ],
- [
- 1158,
- 130
- ],
- [
- 1226,
- 130
- ],
- [
- 1295,
- 130
- ],
- [
- 1365,
- 149
- ],
- [
- 1416,
- 200
- ],
- [
- 1435,
- 270
- ],
- [
- 1416,
- 340
- ],
- [
- 1365,
- 391
- ],
- [
- 1295,
- 410
- ],
- [
- 1225,
- 410
- ],
- [
- 1156,
- 410
- ],
- [
- 1086,
- 410
- ],
- [
- 1016,
- 410
- ],
- [
- 947,
- 410
- ],
- [
- 877,
- 410
- ],
- [
- 807,
- 410
- ],
- [
- 738,
- 410
- ],
- [
- 668,
- 410
- ],
- [
- 598,
- 410
- ],
- [
- 529,
- 410
- ],
- [
- 459,
- 410
- ],
- [
- 389,
- 410
- ],
- [
- 320,
- 410
- ],
- [
- 250,
- 410
- ],
- [
- 180,
- 429
- ],
- [
- 129,
- 480
- ],
- [
- 110,
- 550
- ],
- [
- 129,
- 620
- ],
- [
- 180,
- 671
- ],
- [
- 250,
- 690
- ],
- [
- 320,
- 690
- ],
- [
- 389,
- 690
- ],
- [
- 459,
- 690
- ],
- [
- 529,
- 690
- ],
- [
- 598,
- 690
- ],
- [
- 668,
- 690
- ],
- [
- 738,
- 690
- ],
- [
- 807,
- 690
- ],
- [
- 877,
- 690
- ],
- [
- 947,
- 690
- ],
- [
- 1016,
- 690
- ],
- [
- 1086,
- 690
- ],
- [
- 1156,
- 690
- ],
- [
- 1225,
- 690
- ],
- [
- 1295,
- 690
- ],
- [
- 1365,
- 709
- ],
- [
- 1416,
- 760
- ],
- [
- 1435,
- 830
- ],
- [
- 1416,
- 900
- ],
- [
- 1365,
- 951
- ],
- [
- 1295,
- 970
- ],
- [
- 1225,
- 970
- ],
- [
- 1156,
- 970
- ],
- [
- 1086,
- 970
- ],
- [
- 1016,
- 970
- ],
- [
- 947,
- 970
- ],
- [
- 877,
- 970
- ],
- [
- 807,
- 970
- ],
- [
- 738,
- 970
- ],
- [
- 668,
- 970
- ],
- [
- 598,
- 970
- ],
- [
- 529,
- 970
- ],
- [
- 459,
- 970
- ],
- [
- 389,
- 970
- ],
- [
- 320,
- 970
- ],
- [
- 250,
- 970
- ]
- ],
- "frog": [
- 1620,
- 550
- ],
- "tunnels": [],
- "colors": 6,
- "quota": 34,
- "introBalls": 10,
- "pushSpeed": 120,
- "powerUpRate": 0.045,
- "background": "zuma-background-1",
- "seed": 135623,
- "starScores": [
- 1230,
- 1840,
- 2450
- ]
- },
- {
- "level": 18,
- "name": "Maelstrom Cross",
- "shape": "figureEight",
- "points": [
- [
- -80,
- 830
- ],
- [
- 60,
- 690
- ],
- [
- 130,
- 505
- ],
- [
- 136,
- 416
- ],
- [
- 154,
- 333
- ],
- [
- 183,
- 259
- ],
- [
- 223,
- 199
- ],
- [
- 274,
- 156
- ],
- [
- 334,
- 134
- ],
- [
- 404,
- 132
- ],
- [
- 481,
- 152
- ],
- [
- 565,
- 191
- ],
- [
- 655,
- 249
- ],
- [
- 749,
- 321
- ],
- [
- 846,
- 403
- ],
- [
- 945,
- 491
- ],
- [
- 1044,
- 581
- ],
- [
- 1142,
- 665
- ],
- [
- 1237,
- 741
- ],
- [
- 1328,
- 803
- ],
- [
- 1414,
- 849
- ],
- [
- 1494,
- 874
- ],
- [
- 1566,
- 879
- ],
- [
- 1629,
- 863
- ],
- [
- 1683,
- 826
- ],
- [
- 1726,
- 771
- ],
- [
- 1759,
- 701
- ],
- [
- 1780,
- 620
- ],
- [
- 1789,
- 532
- ],
- [
- 1787,
- 443
- ],
- [
- 1773,
- 357
- ],
- [
- 1747,
- 280
- ],
- [
- 1710,
- 215
- ],
- [
- 1663,
- 167
- ],
- [
- 1605,
- 138
- ],
- [
- 1538,
- 130
- ],
- [
- 1463,
- 143
- ],
- [
- 1381,
- 177
- ],
- [
- 1293,
- 230
- ],
- [
- 1200,
- 298
- ],
- [
- 1103,
- 377
- ],
- [
- 1005,
- 464
- ],
- [
- 906,
- 554
- ],
- [
- 808,
- 640
- ],
- [
- 711,
- 719
- ],
- [
- 619,
- 786
- ],
- [
- 531,
- 837
- ],
- [
- 449,
- 869
- ],
- [
- 375,
- 880
- ],
- [
- 309,
- 870
- ],
- [
- 252,
- 839
- ]
- ],
- "frog": [
- 960,
- 990
- ],
- "tunnels": [],
- "colors": 6,
- "quota": 40,
- "introBalls": 11,
- "pushSpeed": 120,
- "powerUpRate": 0.045,
- "background": "zuma-background-2",
- "seed": 143542,
- "starScores": [
- 1440,
- 2160,
- 2880
- ]
- },
- {
- "level": 19,
- "name": "Abyss Coil",
- "shape": "spiral",
- "points": [
- [
- -80,
- 120
- ],
- [
- 58,
- 120
- ],
- [
- 960,
- 120
- ],
- [
- 1195,
- 143
- ],
- [
- 1406,
- 201
- ],
- [
- 1577,
- 287
- ],
- [
- 1694,
- 395
- ],
- [
- 1749,
- 514
- ],
- [
- 1739,
- 635
- ],
- [
- 1667,
- 749
- ],
- [
- 1541,
- 846
- ],
- [
- 1370,
- 919
- ],
- [
- 1171,
- 962
- ],
- [
- 960,
- 973
- ],
- [
- 754,
- 952
- ],
- [
- 571,
- 901
- ],
- [
- 423,
- 826
- ],
- [
- 322,
- 732
- ],
- [
- 276,
- 628
- ],
- [
- 285,
- 523
- ],
- [
- 349,
- 425
- ],
- [
- 459,
- 341
- ],
- [
- 607,
- 279
- ],
- [
- 779,
- 243
- ],
- [
- 960,
- 234
- ],
- [
- 1136,
- 253
- ],
- [
- 1292,
- 297
- ],
- [
- 1417,
- 362
- ],
- [
- 1502,
- 442
- ],
- [
- 1540,
- 530
- ],
- [
- 1530,
- 619
- ],
- [
- 1475,
- 702
- ],
- [
- 1381,
- 771
- ],
- [
- 1256,
- 823
- ],
- [
- 1111,
- 853
- ],
- [
- 960,
- 859
- ],
- [
- 814,
- 843
- ],
- [
- 685,
- 806
- ],
- [
- 583,
- 751
- ],
- [
- 515,
- 685
- ],
- [
- 485,
- 612
- ],
- [
- 494,
- 539
- ],
- [
- 541,
- 472
- ],
- [
- 619,
- 416
- ],
- [
- 721,
- 375
- ],
- [
- 838,
- 352
- ],
- [
- 960,
- 348
- ]
- ],
- "frog": [
- 960,
- 575
- ],
- "tunnels": [],
- "colors": 6,
- "quota": 50,
- "introBalls": 14,
- "pushSpeed": 148,
- "powerUpRate": 0.045,
- "background": "zuma-background-1",
- "seed": 151461,
- "starScores": [
- 1940,
- 2910,
- 3880
- ]
- },
- {
- "level": 20,
- "name": "The Final Coil",
- "shape": "spiral",
- "points": [
- [
- -80,
- 120
- ],
- [
- 58,
- 120
- ],
- [
- 960,
- 120
- ],
- [
- 1195,
- 143
- ],
- [
- 1406,
- 201
- ],
- [
- 1577,
- 287
- ],
- [
- 1694,
- 395
- ],
- [
- 1749,
- 514
- ],
- [
- 1739,
- 635
- ],
- [
- 1667,
- 749
- ],
- [
- 1541,
- 846
- ],
- [
- 1370,
- 919
- ],
- [
- 1171,
- 962
- ],
- [
- 960,
- 973
- ],
- [
- 754,
- 952
- ],
- [
- 571,
- 901
- ],
- [
- 423,
- 826
- ],
- [
- 322,
- 732
- ],
- [
- 276,
- 628
- ],
- [
- 285,
- 523
- ],
- [
- 349,
- 425
- ],
- [
- 459,
- 341
- ],
- [
- 607,
- 279
- ],
- [
- 779,
- 243
- ],
- [
- 960,
- 234
- ],
- [
- 1136,
- 253
- ],
- [
- 1292,
- 297
- ],
- [
- 1417,
- 362
- ],
- [
- 1502,
- 442
- ],
- [
- 1540,
- 530
- ],
- [
- 1530,
- 619
- ],
- [
- 1475,
- 702
- ],
- [
- 1381,
- 771
- ],
- [
- 1256,
- 823
- ],
- [
- 1111,
- 853
- ],
- [
- 960,
- 859
- ],
- [
- 814,
- 843
- ],
- [
- 685,
- 806
- ],
- [
- 583,
- 751
- ],
- [
- 515,
- 685
- ],
- [
- 485,
- 612
- ],
- [
- 494,
- 539
- ],
- [
- 541,
- 472
- ],
- [
- 619,
- 416
- ],
- [
- 721,
- 375
- ],
- [
- 838,
- 352
- ],
- [
- 960,
- 348
- ]
- ],
- "frog": [
- 960,
- 575
- ],
- "tunnels": [],
- "colors": 6,
- "quota": 52,
- "introBalls": 14,
- "pushSpeed": 152,
- "powerUpRate": 0.045,
- "background": "zuma-background-2",
- "seed": 159380,
- "starScores": [
- 2040,
- 3060,
- 4080
- ]
- }
- ]
-}
\ No newline at end of file
diff --git a/src/games/zuma/ZumaEditor.js b/src/games/zuma/ZumaEditor.js
index 4c75e49..5735e60 100644
--- a/src/games/zuma/ZumaEditor.js
+++ b/src/games/zuma/ZumaEditor.js
@@ -1,11 +1,12 @@
// Zuma level editor. Secret entrance: index.html?zuma-editor=1 (see
-// PreloadScene). Mirrors the Peggle / Super Kart / Goo Tower editors: load a
-// level (from the banked data/zuma.json or any local .json) → edit → Test Play
-// → ⬇ Export Bank, then hand-drop the file into data/.
-//
-// Unlike those games Zuma has no per-level files — the whole 20-level bank is
-// one JSON preloaded under the cache key 'zuma' — so "export" upserts this
-// level into a copy of that bank and downloads the lot.
+// PreloadScene). Mirrors the Peggle / Super Kart / Goo Tower editors exactly:
+// one file per level (assets/gamedata/zuma/level-NNN.json) plus a lightweight
+// levels.json manifest ({level, name, file}), eager-loaded under the cache
+// key 'zuma-levels' while the per-level payloads are fetched on demand. Load
+// a level (from the manifest dropdown or any local .json) → edit → Test Play
+// → ⬇ Export Level (the level file itself — drop it straight into
+// assets/gamedata/zuma/) and ⬇ Export Manifest (an updated levels.json with
+// this level's entry upserted, same folder).
//
// A level is just a Catmull-Rom control polyline plus a frog position and a
// handful of numbers, so editing is: drag/insert/delete points, drag the frog,
@@ -79,12 +80,15 @@ export default class ZumaEditor extends Phaser.Scene {
}
create() {
- const raw = this.cache.json.get('zuma');
- this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
+ const raw = this.cache.json.get('zuma-levels');
+ this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
+ // Always starts fresh unless resuming an in-progress edit — matching
+ // Peggle/Super Kart/Goo Tower's editors, none of which auto-load the
+ // first banked level either. Pick one from "LOAD FROM BANK" instead.
this.level = this.resume
? (this.registry.get('zuma-editor-state') ?? this.defaultLevel())
- : (this.bank[0] ? clone(this.bank[0]) : this.defaultLevel());
+ : this.defaultLevel();
this.tool = 'move';
this.selected = -1;
@@ -95,6 +99,8 @@ export default class ZumaEditor extends Phaser.Scene {
this.portalImgs = [];
this.undoStack = [];
this.errs = [];
+ this.activePathIdx = 0; // which of this.level.paths the tools below edit
+ this.pathTabButtons = [];
buildPortalTextures(this);
buildPitTextures(this);
@@ -120,22 +126,36 @@ export default class ZumaEditor extends Phaser.Scene {
defaultLevel() {
return {
- level: (this.bank?.length ?? 0) + 1,
+ level: (this.manifest?.length ?? 0) + 1,
name: 'New Path',
- shape: 'custom',
- points: [[-80, 300], [300, 300], [800, 380], [1300, 520], [1600, 760]],
frog: [960, 900],
- tunnels: [],
colors: 4,
+ seed: 12345,
+ starScores: [640, 960, 1280],
+ paths: [this.defaultPath()],
+ };
+ }
+
+ // A second (or third...) path starts shifted down-screen from the first so
+ // it isn't drawn directly on top of it — the author drags it into its real
+ // layout (opposite side of the screen, its own tunnels) from there.
+ defaultPath(offsetY = 0) {
+ return {
+ shape: 'custom',
+ points: [[-80, 300 + offsetY], [300, 300 + offsetY], [800, 380 + offsetY],
+ [1300, 520 + offsetY], [1600, 760 + offsetY]],
+ tunnels: [],
quota: 24,
introBalls: 8,
pushSpeed: 30,
powerUpRate: 0.06,
- seed: 12345,
- starScores: [640, 960, 1280],
};
}
+ activePath() {
+ return this.level.paths[this.activePathIdx];
+ }
+
// ── Coordinate transforms ──────────────────────────────────────────────────
toBoard(wx, wy) {
@@ -159,8 +179,14 @@ export default class ZumaEditor extends Phaser.Scene {
fontFamily: FONT, fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5);
+ this.buildPathTabs();
+ new Button(this, cx - 70, 118, '+ Add Path', () => this.addPath(),
+ { width: 132, height: 34, fontSize: 14, variant: 'ghost' });
+ new Button(this, cx + 70, 118, 'Remove Path', () => this.removePath(),
+ { width: 132, height: 34, fontSize: 14, variant: 'ghost' });
+
this.toolButtons = {};
- let y = 92;
+ let y = 172;
for (const [id, label] of TOOLS) {
this.toolButtons[id] = new Button(this, cx, y, label, () => this.setTool(id),
{ width: bw, height: 50, fontSize: 19 });
@@ -170,10 +196,10 @@ export default class ZumaEditor extends Phaser.Scene {
y += 16;
new Button(this, cx, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 22 });
y += 66;
- new Button(this, cx, y, '⬇ Export Bank', () => this.exportBank(), { width: bw, height: 50, fontSize: 19 });
- y += 58;
new Button(this, cx, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 50, fontSize: 19 });
y += 58;
+ new Button(this, cx, y, '⬇ Export Manifest', () => this.exportManifest(), { width: bw, height: 50, fontSize: 19 });
+ y += 58;
const half = bw / 2 - 6;
new Button(this, cx - bw / 4 - 3, y, 'Undo (Z)', () => this.undo(),
@@ -181,6 +207,8 @@ export default class ZumaEditor extends Phaser.Scene {
new Button(this, cx + bw / 4 + 3, y, 'New Level', () => {
this.pushUndo();
this.level = this.defaultLevel();
+ this.activePathIdx = 0;
+ this.buildPathTabs();
this.selected = -1;
this.pendingEnter = null;
this.syncForm();
@@ -216,6 +244,71 @@ export default class ZumaEditor extends Phaser.Scene {
this.draw();
}
+ // ── Path tabs ────────────────────────────────────────────────────────────
+ // Every point tool (move/insert/append/delete/tunnel) and the per-path form
+ // fields (quota/introBalls/pushSpeed/powerUpRate) act on this.activePath();
+ // switching tabs is the only way to reach a different path's own points.
+
+ // Rebuilt whenever the path count changes (add/remove) — unlike the rest of
+ // buildToolbar(), which only ever runs once.
+ buildPathTabs() {
+ for (const b of this.pathTabButtons) b.destroy();
+ this.pathTabButtons = [];
+
+ const cx = BOARD_X / 2;
+ const bw = 280;
+ const gap = 6;
+ const n = this.level.paths.length;
+ const tabW = Math.min(90, (bw - (n - 1) * gap) / n);
+ const totalW = n * tabW + (n - 1) * gap;
+ let x = cx - totalW / 2 + tabW / 2;
+ const y = 76;
+ for (let i = 0; i < n; i++) {
+ const idx = i;
+ const btn = new Button(this, x, y, `Path ${i + 1}`, () => this.setActivePath(idx),
+ { width: tabW, height: 36, fontSize: 14 });
+ btn.setActive(idx === this.activePathIdx);
+ this.pathTabButtons.push(btn);
+ x += tabW + gap;
+ }
+ }
+
+ setActivePath(idx) {
+ this.activePathIdx = idx;
+ this.pathTabButtons.forEach((b, i) => b.setActive(i === idx));
+ this.selected = -1;
+ this.pendingEnter = null;
+ this.syncForm();
+ this.revalidate();
+ }
+
+ // New paths start offset from path 1 (see defaultPath) rather than stacked
+ // on top of it — the whole point of the tab strip is comparing paths
+ // against each other while editing one at a time.
+ addPath() {
+ this.pushUndo();
+ const n = this.level.paths.length;
+ this.level.paths.push(this.defaultPath(200 * n));
+ this.activePathIdx = this.level.paths.length - 1;
+ this.buildPathTabs();
+ this.selected = -1;
+ this.pendingEnter = null;
+ this.syncForm();
+ this.revalidate();
+ }
+
+ removePath() {
+ if (this.level.paths.length <= 1) return;
+ this.pushUndo();
+ this.level.paths.splice(this.activePathIdx, 1);
+ this.activePathIdx = Math.min(this.activePathIdx, this.level.paths.length - 1);
+ this.buildPathTabs();
+ this.selected = -1;
+ this.pendingEnter = null;
+ this.syncForm();
+ this.revalidate();
+ }
+
// ── DOM settings strip ─────────────────────────────────────────────────────
buildForm() {
@@ -268,12 +361,17 @@ export default class ZumaEditor extends Phaser.Scene {
this.loadSelect = q('#zu-load');
this.loadSelect.innerHTML = ['',
- ...this.bank.map((l) => ``)].join('');
- this.loadSelect.addEventListener('change', (ev) => {
- const n = Number(ev.target.value);
- const def = this.bank.find((l) => l.level === n);
- if (def) this.loadLevel(clone(def), `bank L${n}`);
+ ...this.manifest.map((m) => ``)].join('');
+ this.loadSelect.addEventListener('change', async (ev) => {
+ const file = ev.target.value;
ev.target.value = '';
+ if (!file) return;
+ const entry = this.manifest.find((m) => m.file === file);
+ try {
+ const res = await fetch(`assets/gamedata/zuma/${file}`);
+ const def = await res.json();
+ this.loadLevel(def, entry ? `L${entry.level} · ${entry.name}` : file);
+ } catch (_) { this.flashWarn(`Could not load ${file}`); }
});
q('#zu-file').addEventListener('change', (ev) => {
@@ -298,28 +396,35 @@ export default class ZumaEditor extends Phaser.Scene {
syncForm() {
if (!this.fields) return;
const l = this.level;
+ const p = this.activePath();
this.fields.level.value = l.level;
this.fields.name.value = l.name;
this.fields.colors.value = l.colors;
- this.fields.quota.value = l.quota;
- this.fields.introBalls.value = l.introBalls;
- this.fields.pushSpeed.value = l.pushSpeed;
- this.fields.powerUpRate.value = l.powerUpRate;
+ this.fields.quota.value = p.quota;
+ this.fields.introBalls.value = p.introBalls;
+ this.fields.pushSpeed.value = p.pushSpeed;
+ this.fields.powerUpRate.value = p.powerUpRate;
this.fields.seed.value = l.seed;
this.fields.star1.value = l.starScores[0];
this.fields.star2.value = l.starScores[1];
this.fields.star3.value = l.starScores[2];
}
+ // colors/seed/starScores are level-wide; quota/introBalls/pushSpeed/
+ // powerUpRate belong to whichever path the tab strip has active.
readForm(key) {
const v = this.fields[key].value;
const num = Number(v);
+ const PATH_FIELDS = new Set(['quota', 'introBalls', 'pushSpeed']);
switch (key) {
case 'name': this.level.name = String(v || 'Untitled'); break;
case 'star1': case 'star2': case 'star3':
this.level.starScores[Number(key.slice(4)) - 1] = Math.round(num) || 0; break;
- case 'powerUpRate': this.level.powerUpRate = num; break;
- default: this.level[key] = Math.round(num) || 0; break;
+ case 'powerUpRate': this.activePath().powerUpRate = num; break;
+ default:
+ if (PATH_FIELDS.has(key)) this.activePath()[key] = Math.round(num) || 0;
+ else this.level[key] = Math.round(num) || 0;
+ break;
}
this.revalidate();
}
@@ -331,18 +436,20 @@ export default class ZumaEditor extends Phaser.Scene {
}
loadLevel(data, label) {
- if (!data || !Array.isArray(data.points) || data.points.length < 3 || !Array.isArray(data.frog)) {
+ const migrated = this.migrateLevelShape(data);
+ if (!migrated) {
this.flashWarn(`${label}: not a valid Zuma level`);
return;
}
this.pushUndo();
const d = this.defaultLevel();
this.level = {
- ...d, ...clone(data),
- starScores: Array.isArray(data.starScores) && data.starScores.length === 3
- ? clone(data.starScores) : d.starScores,
- tunnels: Array.isArray(data.tunnels) ? clone(data.tunnels) : [],
+ ...d, ...migrated,
+ starScores: Array.isArray(migrated.starScores) && migrated.starScores.length === 3
+ ? clone(migrated.starScores) : d.starScores,
};
+ this.activePathIdx = 0;
+ this.buildPathTabs();
this.selected = -1;
this.pendingEnter = null;
this.syncForm();
@@ -351,6 +458,23 @@ export default class ZumaEditor extends Phaser.Scene {
this.flash(`Loaded ${label}`);
}
+ // Accepts the current { paths: [...] } schema, or an old flat single-path
+ // export (points/tunnels/quota/introBalls/pushSpeed/powerUpRate at the top
+ // level, from before multi-path existed) — wrapping the latter into a
+ // one-element paths array so previously-exported level files keep loading.
+ // Returns null if `data` isn't recognizable as either shape.
+ migrateLevelShape(data) {
+ if (!data || !Array.isArray(data.frog)) return null;
+ if (Array.isArray(data.paths) && data.paths.length) {
+ return clone(data);
+ }
+ if (Array.isArray(data.points) && data.points.length >= 3) {
+ const { points, tunnels, quota, introBalls, pushSpeed, powerUpRate, shape, ...rest } = clone(data);
+ return { ...rest, paths: [{ shape, points, tunnels: tunnels ?? [], quota, introBalls, pushSpeed, powerUpRate }] };
+ }
+ return null;
+ }
+
// ── Input ──────────────────────────────────────────────────────────────────
bindPointer() {
@@ -371,7 +495,7 @@ export default class ZumaEditor extends Phaser.Scene {
const x = Math.round(Phaser.Math.Clamp(wx, WORLD.x0, WORLD.x0 + WORLD.w));
const y = Math.round(Phaser.Math.Clamp(wy, WORLD.y0, WORLD.y0 + WORLD.h));
if (this.draggingFrog) this.level.frog = [x, y];
- else this.level.points[this.dragging] = [x, y];
+ else this.activePath().points[this.dragging] = [x, y];
this.draw();
});
this.input.on('pointerup', () => {
@@ -389,8 +513,9 @@ export default class ZumaEditor extends Phaser.Scene {
// degenerate ones so a mis-click cannot make an un-undoable mess.
tunnelsOf() {
- if (!Array.isArray(this.level.tunnels)) this.level.tunnels = [];
- return this.level.tunnels;
+ const p = this.activePath();
+ if (!Array.isArray(p.tunnels)) p.tunnels = [];
+ return p.tunnels;
}
// Nearest mouth to a world point, as { idx, end, s }, or null.
@@ -435,8 +560,9 @@ export default class ZumaEditor extends Phaser.Scene {
return;
}
this.pushUndo();
- this.tunnelsOf().push([a, b]);
- this.level.tunnels.sort((p, q) => p[0] - q[0]);
+ const tunnels = this.tunnelsOf();
+ tunnels.push([a, b]);
+ tunnels.sort((p, q) => p[0] - q[0]);
playSound(this, SFX.UI_CHIME);
this.revalidate();
}
@@ -461,7 +587,7 @@ export default class ZumaEditor extends Phaser.Scene {
clearTunnels() {
if (!this.tunnelsOf().length) return;
this.pushUndo();
- this.level.tunnels = [];
+ this.activePath().tunnels = [];
this.pendingEnter = null;
this.revalidate();
}
@@ -481,7 +607,7 @@ export default class ZumaEditor extends Phaser.Scene {
const max = maxPx / K;
let best = -1;
let bd = max;
- this.level.points.forEach(([x, y], i) => {
+ this.activePath().points.forEach(([x, y], i) => {
const d = Math.hypot(x - wx, y - wy);
if (d < bd) { bd = d; best = i; }
});
@@ -489,7 +615,7 @@ export default class ZumaEditor extends Phaser.Scene {
}
handleClick(wx, wy) {
- const pts = this.level.points;
+ const pts = this.activePath().points;
switch (this.tool) {
case 'move': {
const i = this.nearestPoint(wx, wy);
@@ -536,17 +662,17 @@ export default class ZumaEditor extends Phaser.Scene {
deletePointAt(wx, wy) {
const i = this.nearestPoint(wx, wy);
- if (i < 0 || this.level.points.length <= 3) return;
+ if (i < 0 || this.activePath().points.length <= 3) return;
this.pushUndo();
- this.level.points.splice(i, 1);
+ this.activePath().points.splice(i, 1);
this.selected = -1;
this.revalidate();
}
deleteSelected() {
- if (this.selected < 0 || this.level.points.length <= 3) return;
+ if (this.selected < 0 || this.activePath().points.length <= 3) return;
this.pushUndo();
- this.level.points.splice(this.selected, 1);
+ this.activePath().points.splice(this.selected, 1);
this.selected = -1;
this.revalidate();
}
@@ -568,19 +694,28 @@ export default class ZumaEditor extends Phaser.Scene {
}
revalidate() {
- let geo = { errs: ['path invalid'], length: 0, minFrog: 0, minRadius: 0 };
+ let geo = { errs: ['a path is invalid'], paths: [] };
try { geo = validateLevel(this.level); } catch (_) { /* keep the placeholder */ }
this.errs = [...validateLevelParams(this.level), ...geo.errs];
this.geo = geo;
this.issueText?.setText(this.errs.length ? `⚠ ${this.errs.join(' • ')}` : '');
- const cap = Math.floor(geo.length / (TUNING.BALL_SPACING * 1.6));
+
+ const active = geo.paths[this.activePathIdx] ?? { length: 0, minFrog: 0, minRadius: 0, hiddenFrac: 0 };
+ const cap = Math.floor(active.length / (TUNING.BALL_SPACING * 1.6));
const nTun = this.tunnelsOf().length;
- this.statText?.setText(
- `length ${geo.length.toFixed(0)}px • quota ${this.level.quota}/${cap}`
- + ` • min radius ${Number.isFinite(geo.minRadius) ? geo.minRadius.toFixed(0) : '—'}px`
- + ` • frog clear ${geo.minFrog.toFixed(0)}px • ${this.level.points.length} points`
- + ` • ${nTun} tunnel${nTun === 1 ? '' : 's'} (${((geo.hiddenFrac ?? 0) * 100).toFixed(0)}% buried)`
- );
+ const ap = this.activePath();
+ let line = `path ${this.activePathIdx + 1}/${this.level.paths.length}`
+ + ` • length ${active.length.toFixed(0)}px • quota ${ap.quota}/${cap}`
+ + ` • min radius ${Number.isFinite(active.minRadius) ? active.minRadius.toFixed(0) : '—'}px`
+ + ` • frog clear ${active.minFrog.toFixed(0)}px • ${ap.points.length} points`
+ + ` • ${nTun} tunnel${nTun === 1 ? '' : 's'} (${((active.hiddenFrac ?? 0) * 100).toFixed(0)}% buried)`;
+ if (this.level.paths.length > 1) {
+ const others = geo.paths
+ .map((r, i) => (i === this.activePathIdx ? null : `path ${i + 1} ${r.errs.length ? '⚠' : '✓'}`))
+ .filter(Boolean).join(' ');
+ line += ` • ${others}`;
+ }
+ this.statText?.setText(line);
this.draw();
}
@@ -590,8 +725,9 @@ export default class ZumaEditor extends Phaser.Scene {
// board, plus a grab handle on top of it (the handle has to sit above the
// stone, or you cannot see the thing you are about to drag). Real art rather
// than schematic markers because placing a mouth is mostly a question of what
- // stone ends up covering which stretch of path.
- drawMouth(path, s, dir) {
+ // stone ends up covering which stretch of path. `dim` fades a mouth on a
+ // path that isn't the one currently being edited.
+ drawMouth(path, s, dir, dim = 1) {
const p = path.pointAt(s);
const [x, y] = this.toBoard(p.x, p.y);
const ang = Math.atan2(p.ty, p.tx);
@@ -602,17 +738,18 @@ export default class ZumaEditor extends Phaser.Scene {
.setOrigin(PORTAL_ORIGIN_X, 0.5)
.setRotation(rot)
.setScale(K)
- .setDepth(depth);
+ .setDepth(depth)
+ .setAlpha(dim);
if (dir < 0) img.setTint(0xffd9a0); // exits read warmer, as in game
this.portalImgs.push(img);
}
} else {
- this.g.fillStyle(0x0a0704, 0.95);
+ this.g.fillStyle(0x0a0704, 0.95 * dim);
this.g.fillCircle(x, y, 46 * K);
}
const t = this.gTop;
- t.fillStyle(dir > 0 ? 0x9fd8ff : 0xffd54a, 1);
- t.lineStyle(2, 0x000000, 0.85);
+ t.fillStyle(dir > 0 ? 0x9fd8ff : 0xffd54a, dim);
+ t.lineStyle(2, 0x000000, 0.85 * dim);
t.fillCircle(x, y, 7);
t.strokeCircle(x, y, 7);
}
@@ -634,99 +771,21 @@ export default class ZumaEditor extends Phaser.Scene {
g.lineStyle(1, 0x6b5638, 0.7);
g.strokeRect(bx0, by0, bx1 - bx0, by1 - by0);
- let path = null;
- try { path = buildPath(lvl.points); } catch (_) { path = null; }
- this.path = path;
-
for (const img of this.portalImgs) img.destroy();
this.portalImgs = [];
- if (path) {
- const tunnels = normalizeTunnels(lvl.tunnels, path.length);
- const { visible, hidden } = pathSpans(path.length, tunnels);
+ // Every path draws — inactive ones dimmed — so laying a second lane
+ // against the first, or checking a tunnel handoff between them, doesn't
+ // require flipping tabs back and forth. `this.path` keeps meaning "the
+ // path the tunnel/point tools act on," now specifically the active one.
+ this.path = null;
+ lvl.paths.forEach((pd, i) => {
+ const active = i === this.activePathIdx;
+ const built = this.drawOnePathPreview(pd, active);
+ if (active) this.path = built;
+ });
- // Buried runs, on the layer underneath everything, so anything live that
- // crosses them lands on top — the same ordering the game uses.
- this.gUnder.lineStyle(12 * K, 0x000000, 0.45);
- for (const [s0, s1] of hidden) {
- for (let s = s0; s < s1; s += 34) {
- const a = path.pointAt(s);
- const b = path.pointAt(Math.min(s1, s + 17));
- const [ax, ay] = this.toBoard(a.x, a.y);
- const [bx, by] = this.toBoard(b.x, b.y);
- this.gUnder.lineBetween(ax, ay, bx, by);
- }
- }
-
- // the groove, at true scaled widths, broken at every tunnel mouth
- const stroke = (w, color) => {
- g.lineStyle(w * K, color, 1);
- for (const [s0, s1] of visible) {
- const pts = sampleRange(path, s0, s1);
- g.beginPath();
- const [sx, sy] = this.toBoard(pts[0].x, pts[0].y);
- g.moveTo(sx, sy);
- for (const p of pts) {
- const [x, y] = this.toBoard(p.x, p.y);
- g.lineTo(x, y);
- }
- g.strokePath();
- }
- };
- for (const { w, color } of PATH_STYLE.bands) stroke(w, color);
-
- // Ghost chain: the whole quota packed back from the pit — the shape of
- // the moment before you lose. Makes "does this path hold the marbles I
- // asked for, and where does the crunch happen" something you can see.
- // Submerged marbles are drawn hollow: that is the part of the chain the
- // frog will not be able to touch.
- const R = TUNING.BALL_RADIUS * K;
- for (let i = 0; i < lvl.quota; i++) {
- const s = path.length - i * TUNING.BALL_SPACING;
- if (s < 0) break;
- const p = path.pointAt(s);
- const [x, y] = this.toBoard(p.x, p.y);
- const color = BALL_COLORS[i % Math.max(1, lvl.colors)];
- if (isHidden(tunnels, s)) {
- g.lineStyle(2, color, 0.5);
- g.strokeCircle(x, y, R);
- } else {
- g.fillStyle(color, 0.9);
- g.fillCircle(x, y, R);
- g.lineStyle(1, 0x000000, 0.4);
- g.strokeCircle(x, y, R);
- }
- }
-
- // the pit at the far end — the real art, so its footprint over the path
- // is what the author sees
- const end = path.pointAt(path.length);
- const [hx, hy] = this.toBoard(end.x, end.y);
- if (this.textures.exists(PIT_KEY)) {
- this.portalImgs.push(this.add.image(hx, hy, PIT_KEY).setScale(K).setDepth(6));
- } else {
- g.fillStyle(0x0a0704, 1); g.fillCircle(hx, hy, PIT.OUTER * K);
- g.fillStyle(0x050505, 1); g.fillCircle(hx, hy, PIT.MOUTH * K);
- }
-
- // the maws themselves, over the chain, exactly as the game stacks them
- for (const t of tunnels) {
- this.drawMouth(path, t.enter, 1);
- this.drawMouth(path, t.exit, -1);
- }
-
- // a half-placed tunnel: show where the entrance would go
- if (this.pendingEnter !== null) {
- const p = path.pointAt(this.pendingEnter);
- const [x, y] = this.toBoard(p.x, p.y);
- g.lineStyle(3, 0xffd54a, 0.95);
- dashedCircle(g, x, y, 26 * K * 1.6);
- g.fillStyle(0xffd54a, 0.85);
- g.fillCircle(x, y, 5);
- }
- }
-
- // frog + the clearance ring the lint enforces
+ // frog + the clearance ring the lint enforces (must clear EVERY path)
const [fx, fy] = this.toBoard(lvl.frog[0], lvl.frog[1]);
this.frogImg?.destroy();
if (this.textures.exists('zuma-frog')) {
@@ -737,13 +796,15 @@ export default class ZumaEditor extends Phaser.Scene {
g.fillCircle(fx, fy, 100 * TUNING.FROG_SCALE * K);
}
const clear = TUNING.FROG_CLEARANCE * K;
- g.lineStyle(2, this.geo && this.geo.minFrog < TUNING.FROG_CLEARANCE ? 0xd9403a : 0x6fd47e, 0.8);
+ const frogOk = this.geo?.paths?.every((r) => r.minFrog >= TUNING.FROG_CLEARANCE) ?? true;
+ g.lineStyle(2, frogOk ? 0x6fd47e : 0xd9403a, 0.8);
dashedCircle(g, fx, fy, clear);
- // control points — first is the lead-in, last is the pit
- lvl.points.forEach(([px, py], i) => {
+ // control points for the active path only — first is the lead-in, last is the pit
+ const activePts = this.activePath().points;
+ activePts.forEach(([px, py], i) => {
const [x, y] = this.toBoard(px, py);
- const last = i === lvl.points.length - 1;
+ const last = i === activePts.length - 1;
const color = i === this.selected ? 0xffd54a : last ? 0xd9403a : i === 0 ? 0x6fd47e : 0x9fd8ff;
g.fillStyle(color, 1);
g.fillPoints([
@@ -754,6 +815,102 @@ export default class ZumaEditor extends Phaser.Scene {
});
}
+ // One path's polyline, ghost chain, tunnels and pit — dimmed when it isn't
+ // the path currently being edited. Returns the built arc-length curve so
+ // draw() can hand it to nearestMouth/handleTunnelClick/slideMouth via
+ // this.path without rebuilding it.
+ drawOnePathPreview(pd, active) {
+ const g = this.g;
+ const dim = active ? 1 : 0.38;
+ let path = null;
+ try { path = buildPath(pd.points); } catch (_) { return null; }
+
+ const tunnels = normalizeTunnels(pd.tunnels, path.length);
+ const { visible, hidden } = pathSpans(path.length, tunnels);
+
+ // Buried runs, on the layer underneath everything, so anything live that
+ // crosses them lands on top — the same ordering the game uses.
+ this.gUnder.lineStyle(12 * K, 0x000000, 0.45 * dim);
+ for (const [s0, s1] of hidden) {
+ for (let s = s0; s < s1; s += 34) {
+ const a = path.pointAt(s);
+ const b = path.pointAt(Math.min(s1, s + 17));
+ const [ax, ay] = this.toBoard(a.x, a.y);
+ const [bx, by] = this.toBoard(b.x, b.y);
+ this.gUnder.lineBetween(ax, ay, bx, by);
+ }
+ }
+
+ // the groove, at true scaled widths, broken at every tunnel mouth
+ const stroke = (w, color) => {
+ g.lineStyle(w * K, color, dim);
+ for (const [s0, s1] of visible) {
+ const pts = sampleRange(path, s0, s1);
+ g.beginPath();
+ const [sx, sy] = this.toBoard(pts[0].x, pts[0].y);
+ g.moveTo(sx, sy);
+ for (const p of pts) {
+ const [x, y] = this.toBoard(p.x, p.y);
+ g.lineTo(x, y);
+ }
+ g.strokePath();
+ }
+ };
+ for (const { w, color } of PATH_STYLE.bands) stroke(w, color);
+
+ // Ghost chain: the whole quota packed back from the pit — the shape of
+ // the moment before you lose. Makes "does this path hold the marbles I
+ // asked for, and where does the crunch happen" something you can see.
+ // Submerged marbles are drawn hollow: that is the part of the chain the
+ // frog will not be able to touch.
+ const R = TUNING.BALL_RADIUS * K;
+ for (let i = 0; i < pd.quota; i++) {
+ const s = path.length - i * TUNING.BALL_SPACING;
+ if (s < 0) break;
+ const p = path.pointAt(s);
+ const [x, y] = this.toBoard(p.x, p.y);
+ const color = BALL_COLORS[i % Math.max(1, this.level.colors)];
+ if (isHidden(tunnels, s)) {
+ g.lineStyle(2, color, 0.5 * dim);
+ g.strokeCircle(x, y, R);
+ } else {
+ g.fillStyle(color, 0.9 * dim);
+ g.fillCircle(x, y, R);
+ g.lineStyle(1, 0x000000, 0.4 * dim);
+ g.strokeCircle(x, y, R);
+ }
+ }
+
+ // the pit at the far end — the real art, so its footprint over the path
+ // is what the author sees
+ const end = path.pointAt(path.length);
+ const [hx, hy] = this.toBoard(end.x, end.y);
+ if (this.textures.exists(PIT_KEY)) {
+ this.portalImgs.push(this.add.image(hx, hy, PIT_KEY).setScale(K).setDepth(6).setAlpha(dim));
+ } else {
+ g.fillStyle(0x0a0704, dim); g.fillCircle(hx, hy, PIT.OUTER * K);
+ g.fillStyle(0x050505, dim); g.fillCircle(hx, hy, PIT.MOUTH * K);
+ }
+
+ // the maws themselves, over the chain, exactly as the game stacks them
+ for (const t of tunnels) {
+ this.drawMouth(path, t.enter, 1, dim);
+ this.drawMouth(path, t.exit, -1, dim);
+ }
+
+ // a half-placed tunnel: only ever exists on the path being actively edited
+ if (active && this.pendingEnter !== null) {
+ const p = path.pointAt(this.pendingEnter);
+ const [x, y] = this.toBoard(p.x, p.y);
+ g.lineStyle(3, 0xffd54a, 0.95);
+ dashedCircle(g, x, y, 26 * K * 1.6);
+ g.fillStyle(0xffd54a, 0.85);
+ g.fillCircle(x, y, 5);
+ }
+
+ return path;
+ }
+
// ── Test play / export ─────────────────────────────────────────────────────
testPlay() {
@@ -774,25 +931,30 @@ export default class ZumaEditor extends Phaser.Scene {
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
- exportLevel() {
- if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
- this.download(`zuma-level-${this.level.level}.json`, this.level);
- playSound(this, SFX.UI_CHIME);
- this.flash(`Saved zuma-level-${this.level.level}.json`);
+ levelFile() {
+ return `level-${String(this.level.level).padStart(3, '0')}.json`;
}
- exportBank() {
+ manifestEntry() {
+ return { level: this.level.level, name: this.level.name, file: this.levelFile() };
+ }
+
+ exportLevel() {
if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
- const levels = this.bank.filter((l) => l.level !== this.level.level).map(clone);
- levels.push(clone(this.level));
- levels.sort((a, b) => a.level - b.level);
- this.download('zuma.json', {
- generatedAt: new Date().toISOString(),
- count: levels.length,
- levels,
- });
+ const file = this.levelFile();
+ this.download(file, this.level);
playSound(this, SFX.UI_CHIME);
- this.flash('Saved zuma.json — drop it into data/ (genZuma.js will overwrite it)');
+ this.flash(`Saved ${file} — drop it into assets/gamedata/zuma/`);
+ }
+
+ exportManifest() {
+ if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
+ const levels = this.manifest.filter((m) => m.level !== this.level.level).map((m) => ({ ...m }));
+ levels.push(this.manifestEntry());
+ levels.sort((a, b) => a.level - b.level);
+ this.download('levels.json', { version: 1, levels });
+ playSound(this, SFX.UI_CHIME);
+ this.flash('Saved levels.json — drop it into assets/gamedata/zuma/');
}
flash(msg) {
diff --git a/src/games/zuma/ZumaGame.js b/src/games/zuma/ZumaGame.js
index 8b6a2fa..36e16da 100644
--- a/src/games/zuma/ZumaGame.js
+++ b/src/games/zuma/ZumaGame.js
@@ -80,7 +80,8 @@ export default class ZumaGame extends Phaser.Scene {
// progress or history writes for it (see ZumaEditor.testPlay).
this.testLevel = data.testLevel ?? null;
this.returnToEditor = !!data.returnToEditor;
- this.bank = [];
+ this.manifest = []; // lightweight { level, name, file } entries
+ this.levelCache = new Map(); // level number -> fetched full def
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'select';
@@ -93,7 +94,8 @@ export default class ZumaGame extends Phaser.Scene {
this.ballSprites = new Map(); // ball id -> { img, icon }
this.flightSprites = new Map(); // flight id -> img
this.lastClearX = null; // where the last pop/explosion landed —
- this.lastClearY = null; // the victory fireworks start from here
+ this.lastClearY = null; // the victory fireworks start from here,
+ this.lastClearPathIdx = null; // and which path's pit they travel to
}
async create() {
@@ -102,17 +104,17 @@ export default class ZumaGame extends Phaser.Scene {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2);
- const raw = this.cache.json.get('zuma');
- this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
+ const raw = this.cache.json.get('zuma-levels');
+ this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
if (this.testLevel) {
- this.bank = [this.testLevel];
+ this.manifest = [{ level: this.testLevel.level, name: this.testLevel.name }];
this.canPersist = false;
this.layer = this.add.layer();
this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS;
this.buildTextures();
this.bindInput();
- this.playLevel(this.testLevel.level);
+ this.playLevel(this.testLevel.level, this.testLevel);
return;
}
@@ -392,7 +394,7 @@ export default class ZumaGame extends Phaser.Scene {
this.frogCurrent = null;
this.frogNext = null;
this.laserGfx = null;
- this.pitFall = null;
+ this.pitFalls = null;
this.quotaGfx = null;
this.scoreText = null;
this.effectsText = null;
@@ -434,7 +436,7 @@ export default class ZumaGame extends Phaser.Scene {
const cx = GAME_WIDTH / 2;
- if (!this.bank.length) {
+ if (!this.manifest.length) {
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genZuma.js', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
@@ -444,11 +446,11 @@ export default class ZumaGame extends Phaser.Scene {
return;
}
- const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
+ const nextLevel = Math.min(this.levelsCompleted + 1, this.manifest.length);
const bg = this.add.rectangle(cx, 442, 320, 56, 0x000000, 0.55)
.setStrokeStyle(2, 0x6b5638, 0.8)
.setOrigin(0.5);
- const prog = this.add.text(cx, 442, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
+ const prog = this.add.text(cx, 442, `Completed ${this.levelsCompleted} / ${this.manifest.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([bg, prog]);
@@ -460,7 +462,7 @@ export default class ZumaGame extends Phaser.Scene {
const left = cx - gridW / 2 + SIZE / 2;
const top = 550;
- this.bank.forEach((l, i) => {
+ this.manifest.forEach((l, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = left + col * (SIZE + GAP);
@@ -544,14 +546,29 @@ export default class ZumaGame extends Phaser.Scene {
doResetProgress() {
api.post('/puzzles/zuma/reset').catch(() => { /* best effort */ });
this.levelsCompleted = 0;
- try { this.bank.forEach((l) => localStorage.removeItem(`zuma-stars-${l.level}`)); } catch (_) { /* ignore */ }
+ try { this.manifest.forEach((l) => localStorage.removeItem(`zuma-stars-${l.level}`)); } catch (_) { /* ignore */ }
this.showLevelSelect();
}
// ── Play a level ────────────────────────────────────────────────────────────
- playLevel(level) {
- const def = this.bank.find((l) => l.level === level);
+ // Level payloads are fetched on demand (assets/gamedata/zuma/level-NNN.json,
+ // per the manifest's `file` field) rather than loaded up front — memoized
+ // so re-entering an already-played level is free.
+ async fetchLevel(level) {
+ if (this.levelCache.has(level)) return this.levelCache.get(level);
+ const entry = this.manifest.find((m) => m.level === level);
+ if (!entry) return null;
+ const res = await fetch(`assets/gamedata/zuma/${entry.file}`);
+ const json = await res.json();
+ this.levelCache.set(level, json);
+ return json;
+ }
+
+ // `preloadedDef` is set for editor Test Play, which already has the whole
+ // draft level in hand and skips the fetch entirely.
+ async playLevel(level, preloadedDef = null) {
+ const def = preloadedDef ?? await this.fetchLevel(level);
if (!def) return;
this.view = 'play';
this.level = level;
@@ -609,8 +626,12 @@ export default class ZumaGame extends Phaser.Scene {
}
drawPath() {
- const path = this.state.path;
- const tunnels = this.state.tunnels;
+ for (const p of this.state.paths) this.drawOnePath(p);
+ }
+
+ drawOnePath(p) {
+ const path = p.path;
+ const tunnels = p.tunnels;
const { visible, hidden } = pathSpans(path.length, tunnels);
// Buried runs first and BELOW the path layer: a dashed scar showing where
@@ -660,25 +681,27 @@ export default class ZumaGame extends Phaser.Scene {
// exit gets a brighter ember so the two ends read differently at a glance:
// one is swallowing, the other is spitting the chain back out.
drawPortals() {
- for (const t of this.state.tunnels) {
- this.addPortal(t.enter, 1);
- this.addPortal(t.exit, -1);
+ for (const p of this.state.paths) {
+ for (const t of p.tunnels) {
+ this.addPortal(p, t.enter, 1);
+ this.addPortal(p, t.exit, -1);
+ }
}
}
- addPortal(s, dir) {
- const p = this.state.path.pointAt(s);
- const ang = Math.atan2(p.ty, p.tx);
+ addPortal(p, s, dir) {
+ const pt = p.path.pointAt(s);
+ const ang = Math.atan2(pt.ty, pt.tx);
const rot = dir > 0 ? ang : ang + Math.PI;
for (const [key, depth] of [[PORTAL_STONE, D.portalStone], [PORTAL_MOUTH, D.portalMouth]]) {
- const img = this.add.image(p.x, p.y, key)
+ const img = this.add.image(pt.x, pt.y, key)
.setOrigin(PORTAL_ORIGIN_X, 0.5)
.setRotation(rot)
.setDepth(depth);
this.layer.add(img);
}
- const glow = this.add.image(p.x, p.y, 'zuma-glow')
+ const glow = this.add.image(pt.x, pt.y, 'zuma-glow')
.setTint(dir > 0 ? 0xff8a2b : 0xffc45a)
.setBlendMode(Phaser.BlendModes.ADD)
.setScale(dir > 0 ? 2.6 : 3.4)
@@ -695,19 +718,21 @@ export default class ZumaGame extends Phaser.Scene {
// motion is vapour turning slowly in the mouth — the old skull throbbed on a
// scale tween, which a hole cannot do without looking like it is breathing.
drawPit() {
- const end = this.state.path.pointAt(this.state.path.length);
- const pit = this.add.image(end.x, end.y, PIT_KEY).setDepth(D.pit);
- const swirl = this.add.image(end.x, end.y, PIT_SWIRL)
- .setBlendMode(Phaser.BlendModes.ADD)
- .setAlpha(0.5)
- .setDepth(D.pitSwirl);
- this.layer.add([pit, swirl]);
- this.tweens.add({
- targets: swirl, angle: 360, duration: 17000, repeat: -1, ease: 'Linear',
- });
- this.tweens.add({
- targets: swirl, alpha: 0.22, duration: 2600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
- });
+ for (const p of this.state.paths) {
+ const end = p.path.pointAt(p.path.length);
+ const pit = this.add.image(end.x, end.y, PIT_KEY).setDepth(D.pit);
+ const swirl = this.add.image(end.x, end.y, PIT_SWIRL)
+ .setBlendMode(Phaser.BlendModes.ADD)
+ .setAlpha(0.5)
+ .setDepth(D.pitSwirl);
+ this.layer.add([pit, swirl]);
+ this.tweens.add({
+ targets: swirl, angle: 360, duration: 17000, repeat: -1, ease: 'Linear',
+ });
+ this.tweens.add({
+ targets: swirl, alpha: 0.22, duration: 2600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
+ });
+ }
}
// The chain going over the edge. It does NOT cut across to the pit: every
@@ -715,16 +740,10 @@ export default class ZumaGame extends Phaser.Scene {
// stretch in arc-length, still spinning off the tangent, so the chain pours
// over the lip in the order and along the line it was already travelling.
// Driven from update() rather than tweened, because the thing being animated
- // is one shared `advance` along the path, not per-sprite x/y.
+ // is one shared `advance` along the path, not per-sprite x/y. Multi-path
+ // levels run every path's fall in parallel, each into its own pit — losing
+ // on one path does not spare the others.
startPitFall() {
- const path = this.state.path;
- const L = path.length;
- const balls = this.state.balls;
- const rear = balls[balls.length - 1];
- const span = rear ? L - rear.s : 0;
- // Long chains are drained faster rather than made to take longer.
- const speed = Math.max(TUNE.PIT_FALL_SPEED, span / TUNE.PIT_FALL_MAX_S);
-
// Any leftover tween (the insert squeeze, the power-up icon pulse) would
// fight the per-frame writes below.
for (const [, spr] of this.ballSprites) {
@@ -739,28 +758,57 @@ export default class ZumaGame extends Phaser.Scene {
}
this.flightSprites = new Map();
- this.pitFall = {
- advance: 0,
- span,
- speed,
- // Each marble shrinks over the last stretch of ITS OWN run. The front one
- // is already sitting on the pit when the level ends, so a ramp measured
- // from the pit centre would pop it to a dot on the very first frame;
- // measured from where it starts, it drops away from full size like the
- // rest of them, just sooner.
- ramp: new Map(balls.map((b) => [b.id, Math.max(1, Math.min(TUNE.PIT_SWALLOW, L - b.s))])),
- };
- return span ? (span / speed) * 1000 : 0;
+ this.pitFalls = [];
+ let maxMs = 0;
+ for (const p of this.state.paths) {
+ const L = p.path.length;
+ const balls = p.balls;
+ const rear = balls[balls.length - 1];
+ const span = rear ? L - rear.s : 0;
+ if (!span) continue;
+ // Long chains are drained faster rather than made to take longer.
+ const speed = Math.max(TUNE.PIT_FALL_SPEED, span / TUNE.PIT_FALL_MAX_S);
+ this.pitFalls.push({
+ path: p.path,
+ balls,
+ tunnels: p.tunnels,
+ advance: 0,
+ span,
+ speed,
+ // Each marble shrinks over the last stretch of ITS OWN run. The front
+ // one is already sitting on the pit when the level ends, so a ramp
+ // measured from the pit centre would pop it to a dot on the very first
+ // frame; measured from where it starts, it drops away from full size
+ // like the rest of them, just sooner.
+ ramp: new Map(balls.map((b) => [b.id, Math.max(1, Math.min(TUNE.PIT_SWALLOW, L - b.s))])),
+ });
+ maxMs = Math.max(maxMs, (span / speed) * 1000);
+ }
+ return maxMs;
}
stepPitFall(dtMs) {
- const pf = this.pitFall;
- const path = this.state.path;
+ for (const pf of this.pitFalls) this.stepOneFall(pf, dtMs);
+
+ // Once every path's fall has covered its span everything is in, so the
+ // outro ends unconditionally — leaving pitFalls set would keep update()
+ // short-circuited for the rest of the scene's life.
+ if (this.pitFalls.every((pf) => pf.advance >= pf.span)) {
+ for (const [id, spr] of this.ballSprites) {
+ this.destroyMarble(spr);
+ this.ballSprites.delete(id);
+ }
+ this.pitFalls = null;
+ }
+ }
+
+ stepOneFall(pf, dtMs) {
+ const path = pf.path;
const L = path.length;
- const tun = this.state.tunnels;
+ const tun = pf.tunnels;
pf.advance = Math.min(pf.span, pf.advance + pf.speed * (dtMs / 1000));
- for (const b of this.state.balls) {
+ for (const b of pf.balls) {
const spr = this.ballSprites.get(b.id);
if (!spr) continue;
const s = Math.min(L, b.s + pf.advance);
@@ -785,17 +833,6 @@ export default class ZumaGame extends Phaser.Scene {
spr.gloss.setScale(scale).setAlpha(vis * dim);
spr.icon?.setPosition(p.x, p.y).setScale(scale).setAlpha(vis * dim);
}
-
- // Once the chain has covered its span everything is in, so the outro ends
- // unconditionally — leaving pitFall set would keep update() short-circuited
- // for the rest of the scene's life.
- if (pf.advance >= pf.span) {
- for (const [id, spr] of this.ballSprites) {
- this.destroyMarble(spr);
- this.ballSprites.delete(id);
- }
- this.pitFall = null;
- }
}
// Four independently depth-sorted pieces (see D above): base disc, the marble
@@ -862,7 +899,9 @@ export default class ZumaGame extends Phaser.Scene {
g.clear();
g.fillStyle(0x0a120b, 0.9);
g.fillRoundedRect(x - 3, y - 3, W + 6, H + 6, 8);
- const toCome = (st.quota - st.spawned) / st.quota;
+ const totalQuota = st.paths.reduce((a, p) => a + p.quota, 0);
+ const totalSpawned = st.paths.reduce((a, p) => a + p.spawned, 0);
+ const toCome = (totalQuota - totalSpawned) / totalQuota;
g.fillStyle(COLORS.gold, 1);
g.fillRoundedRect(x, y, Math.max(2, W * toCome), H, 6);
g.lineStyle(2, 0x6b5638, 1);
@@ -901,7 +940,7 @@ export default class ZumaGame extends Phaser.Scene {
update(time, delta) {
// The lost-chain outro keeps running after overlayUp is set: the engine is
// frozen ('lost' makes step() a no-op) but the marbles are still falling.
- if (this.pitFall) { this.stepPitFall(delta); return; }
+ if (this.pitFalls) { this.stepPitFall(delta); return; }
if (this.view !== 'play' || this.overlayUp || !this.state) return;
const st = this.state;
const events = step(st, delta);
@@ -954,7 +993,7 @@ export default class ZumaGame extends Phaser.Scene {
case 'inserted': {
playSound(this, 'sfx-zuma-hit');
// squeeze-in: the sprite appears via syncChain, then pops to size
- const ball = this.state.balls.find((b) => b.id === e.id);
+ const ball = this.state.paths[e.pathIdx].balls.find((b) => b.id === e.id);
if (ball) this.makeBallSprite(ball, true);
break;
}
@@ -967,14 +1006,14 @@ export default class ZumaGame extends Phaser.Scene {
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a');
if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36);
else if (e.combo > 1) this.floatText(e.x, e.y - 64, `COMBO x${e.combo}`, '#6fd47e', 34);
- this.lastClearX = e.x; this.lastClearY = e.y;
+ this.lastClearX = e.x; this.lastClearY = e.y; this.lastClearPathIdx = e.pathIdx;
break;
}
case 'explosion':
playScifiExplode(this);
this.burst(e.x, e.y, 0xffa726, 16, 2.2);
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36);
- this.lastClearX = e.x; this.lastClearY = e.y;
+ this.lastClearX = e.x; this.lastClearY = e.y; this.lastClearPathIdx = e.pathIdx;
break;
case 'powerup':
playSound(this, SFX.SCIFI_REVEAL);
@@ -1014,21 +1053,23 @@ export default class ZumaGame extends Phaser.Scene {
syncChain() {
const seen = new Set();
- for (const b of this.state.balls) {
- seen.add(b.id);
- const spr = this.makeBallSprite(b);
- // roll phase from arc-length, roll direction from the path tangent
- const t = this.state.path.pointAt(b.s);
- this.rollMarble(spr, b.x, b.y, b.s, t.tx, t.ty);
- if (spr.icon) spr.icon.setPosition(b.x, b.y);
- // Tunnels: b.vis ramps 1 → 0 just inside a mouth so the marble sinks
- // into the maw instead of blinking out. The power-up icon runs its own
- // pulse tween, so it is toggled rather than alpha-driven.
- const vis = b.vis ?? 1;
- if (vis < 1 || spr.base.alpha < 1) {
- spr.base.setAlpha(vis);
- spr.gloss.setAlpha(vis);
- spr.icon?.setVisible(vis > 0.02);
+ for (const p of this.state.paths) {
+ for (const b of p.balls) {
+ seen.add(b.id);
+ const spr = this.makeBallSprite(b);
+ // roll phase from arc-length, roll direction from the path tangent
+ const t = p.path.pointAt(b.s);
+ this.rollMarble(spr, b.x, b.y, b.s, t.tx, t.ty);
+ if (spr.icon) spr.icon.setPosition(b.x, b.y);
+ // Tunnels: b.vis ramps 1 → 0 just inside a mouth so the marble sinks
+ // into the maw instead of blinking out. The power-up icon runs its own
+ // pulse tween, so it is toggled rather than alpha-driven.
+ const vis = b.vis ?? 1;
+ if (vis < 1 || spr.base.alpha < 1) {
+ spr.base.setAlpha(vis);
+ spr.gloss.setAlpha(vis);
+ spr.icon?.setVisible(vis > 0.02);
+ }
}
}
for (const [id, spr] of this.ballSprites) {
@@ -1128,13 +1169,13 @@ export default class ZumaGame extends Phaser.Scene {
}
// Classic Zuma's clear finale: a chain of explosions runs from wherever the
- // last match landed down to the pit, each cashing in a slice of the time
- // bonus already folded into `score`, and the win jingle lands right as the
- // chain reaches the edge. The soundtrack ducks for the whole sequence —
- // same trick as the level-start fanfare (see MusicPlayer.pause/resume).
+ // last match landed down to that path's own pit, each cashing in a slice of
+ // the time bonus already folded into `score`, and the win jingle lands right
+ // as the chain reaches the edge. The soundtrack ducks for the whole sequence
+ // — same trick as the level-start fanfare (see MusicPlayer.pause/resume).
playVictoryFireworks(timeBonus, onDone) {
this.music?.pause();
- const path = this.state.path;
+ const path = this.state.paths[this.lastClearPathIdx ?? 0].path;
const from = nearestS(path, this.lastClearX ?? this.state.frog.x, this.lastClearY ?? this.state.frog.y).s;
const span = Math.max(0, path.length - from);
const T = TUNE;
@@ -1215,7 +1256,7 @@ export default class ZumaGame extends Phaser.Scene {
this.layer.add(hint);
}
- const hasNext = this.level < this.bank.length;
+ const hasNext = this.level < this.manifest.length;
if (hasNext) {
const next = new Button(this, cx, cy + 80, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI);
diff --git a/src/games/zuma/ZumaLogic.js b/src/games/zuma/ZumaLogic.js
index 42b1136..c81d6e1 100644
--- a/src/games/zuma/ZumaLogic.js
+++ b/src/games/zuma/ZumaLogic.js
@@ -1,11 +1,21 @@
// Zuma — pure game engine (no Phaser, no DOM, no timers).
-// A marble-shooter: a chain of colored balls rolls along a curved path toward a
-// bottomless pit; the player fires balls into the chain, popping runs of 3+.
-// The scene (or a headless script) drives all timing through step(); every
-// transition returns an ordered event list the renderer replays as FX.
+// A marble-shooter: one or more chains of colored balls roll along curved
+// paths toward their own bottomless pits; the player fires balls from one
+// shared frog into whichever chain a shot lands nearest to, popping runs of
+// 3+. The scene (or a headless script) drives all timing through step();
+// every transition returns an ordered event list the renderer replays as FX.
+//
+// Multi-path levels (def.paths.length > 1) are N fully independent chains —
+// each with its own path, tunnels, spawner and quota — sharing exactly one
+// frog, one shot queue, and one score. There is no mechanism to aim at a
+// particular path: physics decides which chain a shot lands on, purely by
+// proximity, which is why levels with more than one path are expected to keep
+// their lanes apart (opposite sides of the screen, or staggered by tunnels)
+// rather than relying on some "switch target" input. Losing on any one path
+// loses the whole level; winning requires clearing every path.
//
// All geometry lives in path-space: each chain ball has an arc-length position
-// `s` along the level's Catmull-Rom path (front of chain = largest s).
+// `s` along its path's Catmull-Rom curve (front of chain = largest s).
// Segments are derived, never stored — a gap exists between neighbors more
// than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball
// (b.x, b.y) every tick for flight collision and rendering.
@@ -53,7 +63,7 @@ export const TUNING = {
export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion'];
// ── Tunnels ──────────────────────────────────────────────────────────────────
-// A tunnel is an arc-length interval [enter, exit] the path runs through
+// A tunnel is an arc-length interval [enter, exit] a path runs through
// underground. Nothing about chain movement changes — balls keep their `s` and
// keep rolling — but while a ball is strictly inside the interval it is
// *submerged*: not drawn, not hit by flights, not seen by the laser sight, not
@@ -245,16 +255,19 @@ export function buildPath(points, step = 4) {
// One implementation shared by genZuma.js (which refuses to write a failing
// bank), verifyZuma.js and the editor's live validation strip, so the three
// can't drift. All thresholds derive from TUNING — they move with ball size.
+// Runs once per path in def.paths; a multi-path level's frog has to clear
+// EVERY path, and each path independently needs enough length for its own
+// quota and enough curve radius throughout.
export const LEVEL_BOUNDS = { x0: 40, y0: 40, x1: 1880, y1: 1040 };
const LEADIN_S = 200; // the off-screen lead-in is exempt from the bounds check
-export function validateLevel(def) {
+function validateOnePath(pd, frog) {
const errs = [];
- const path = buildPath(def.points);
- const need = def.quota * TUNING.BALL_SPACING * 1.6;
+ const path = buildPath(pd.points);
+ const need = pd.quota * TUNING.BALL_SPACING * 1.6;
if (path.length < need) {
- errs.push(`path ${path.length.toFixed(0)}px too short for quota ${def.quota} (needs ${need.toFixed(0)})`);
+ errs.push(`path ${path.length.toFixed(0)}px too short for quota ${pd.quota} (needs ${need.toFixed(0)})`);
}
let minFrog = Infinity;
@@ -262,20 +275,20 @@ export function validateLevel(def) {
let minRadiusS = 0;
let outOfBounds = null;
for (let i = 0; i < path.samples.length; i++) {
- const p = path.samples[i];
- minFrog = Math.min(minFrog, Math.hypot(p.x - def.frog[0], p.y - def.frog[1]));
- if (!outOfBounds && p.s > LEADIN_S && (p.x < LEVEL_BOUNDS.x0 || p.x > LEVEL_BOUNDS.x1
- || p.y < LEVEL_BOUNDS.y0 || p.y > LEVEL_BOUNDS.y1)) {
- outOfBounds = p;
+ const s = path.samples[i];
+ minFrog = Math.min(minFrog, Math.hypot(s.x - frog[0], s.y - frog[1]));
+ if (!outOfBounds && s.s > LEADIN_S && (s.x < LEVEL_BOUNDS.x0 || s.x > LEVEL_BOUNDS.x1
+ || s.y < LEVEL_BOUNDS.y0 || s.y > LEVEL_BOUNDS.y1)) {
+ outOfBounds = s;
}
- if (i > 0 && i < path.samples.length - 1 && p.s > LEADIN_S) {
+ if (i > 0 && i < path.samples.length - 1 && s.s > LEADIN_S) {
const a = path.samples[i - 1], c = path.samples[i + 1];
- const v1x = p.x - a.x, v1y = p.y - a.y, v2x = c.x - p.x, v2y = c.y - p.y;
+ const v1x = s.x - a.x, v1y = s.y - a.y, v2x = c.x - s.x, v2y = c.y - s.y;
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
if (l1 > 0.01 && l2 > 0.01) {
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
const theta = Math.acos(cos);
- if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = p.s; }
+ if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = s.s; }
}
}
}
@@ -294,8 +307,8 @@ export function validateLevel(def) {
// Tunnels. The mouths have to sit on real, on-screen path (not the lead-in,
// not on top of the hole), tunnels may not touch each other, and enough of
// the chain has to stay above ground for the frog to have targets at all.
- const tunnels = normalizeTunnels(def.tunnels, path.length);
- if (Array.isArray(def.tunnels) && def.tunnels.length !== tunnels.length) {
+ const tunnels = normalizeTunnels(pd.tunnels, path.length);
+ if (Array.isArray(pd.tunnels) && pd.tunnels.length !== tunnels.length) {
errs.push('a tunnel has exit <= enter');
}
let hiddenLen = 0;
@@ -325,34 +338,68 @@ export function validateLevel(def) {
return { errs, length: path.length, minFrog, minRadius, minRadiusS, tunnels, hiddenFrac };
}
-// Range check on the non-geometric fields, shared with verifyZuma.js.
+// Returns { errs, paths } where `paths` is one validateOnePath() result per
+// def.paths entry (each error therein prefixed "path N: " once there is more
+// than one), plus path[0]'s stats spread at the top level for callers written
+// before multi-path existed (genZuma.js's console summary) — new callers
+// (the editor) should read `paths[activeIdx]` instead.
+export function validateLevel(def) {
+ const results = (def.paths ?? []).map((pd) => validateOnePath(pd, def.frog));
+ const multi = results.length > 1;
+ const errs = results.flatMap((r, i) => r.errs.map((e) => (multi ? `path ${i + 1}: ${e}` : e)));
+ const first = results[0] ?? { length: 0, minFrog: 0, minRadius: 0, minRadiusS: 0, tunnels: [], hiddenFrac: 0 };
+ return {
+ errs, paths: results,
+ length: first.length, minFrog: first.minFrog, minRadius: first.minRadius,
+ minRadiusS: first.minRadiusS, tunnels: first.tunnels, hiddenFrac: first.hiddenFrac,
+ };
+}
+
+// Range check on the non-geometric fields, shared with verifyZuma.js. Colors
+// and starScores are level-wide; quota/introBalls/pushSpeed/powerUpRate are
+// checked per path.
export function validateLevelParams(def) {
const errs = [];
if (!(def.colors >= 4 && def.colors <= 6)) errs.push('colors must be 4..6');
- if (!(def.quota >= 20)) errs.push('quota must be >= 20');
- if (!(def.introBalls < def.quota)) errs.push('introBalls must be < quota');
- if (!(def.pushSpeed >= 10 && def.pushSpeed <= 400)) errs.push('pushSpeed must be 10..400');
- if (!(def.powerUpRate >= 0 && def.powerUpRate <= 0.2)) errs.push('powerUpRate must be 0..0.2');
if (!(Array.isArray(def.starScores) && def.starScores.length === 3
&& def.starScores[0] < def.starScores[1] && def.starScores[1] < def.starScores[2])) {
errs.push('starScores must be 3 ascending values');
}
+ const paths = def.paths ?? [];
+ if (!paths.length) errs.push('level must have at least one path');
+ const multi = paths.length > 1;
+ paths.forEach((pd, i) => {
+ const tag = (m) => (multi ? `path ${i + 1}: ${m}` : m);
+ if (!(pd.quota >= 20)) errs.push(tag('quota must be >= 20'));
+ if (!(pd.introBalls < pd.quota)) errs.push(tag('introBalls must be < quota'));
+ if (!(pd.pushSpeed >= 10 && pd.pushSpeed <= 400)) errs.push(tag('pushSpeed must be 10..400'));
+ if (!(pd.powerUpRate >= 0 && pd.powerUpRate <= 0.2)) errs.push(tag('powerUpRate must be 0..0.2'));
+ });
return errs;
}
// ── Level / state construction ───────────────────────────────────────────────
export function createLevel(def, seed) {
- const path = buildPath(def.points);
+ const paths = def.paths.map((pd, idx) => {
+ const path = buildPath(pd.points);
+ return {
+ idx,
+ path,
+ tunnels: normalizeTunnels(pd.tunnels, path.length),
+ quota: pd.quota,
+ introBalls: pd.introBalls ?? 8,
+ pushSpeed: pd.pushSpeed,
+ powerUpRate: pd.powerUpRate ?? 0,
+ spawned: 0,
+ balls: [], // front-first: balls[0] has the largest s
+ };
+ });
const state = {
def,
- path,
- tunnels: normalizeTunnels(def.tunnels, path.length),
+ paths,
frog: { x: def.frog[0], y: def.frog[1] },
- quota: def.quota,
- spawned: 0,
- balls: [], // front-first: balls[0] has the largest s
- flights: [], // fired balls in screen space
+ flights: [], // fired balls in screen space — shared by every path
rng: makeRng((seed ?? def.seed ?? 1) >>> 0),
status: 'intro', // 'intro' | 'playing' | 'won' | 'lost'
score: 0,
@@ -372,9 +419,14 @@ function levelColor(state) {
return Math.floor(state.rng() * state.def.colors);
}
+// Union of colors still on the board across every path — this, not any one
+// path's own chain, is what the shooter draws from. It's the reason firing at
+// whichever path a shot happens to land nearest never strands the player with
+// an unmatchable color: as long as SOME path still carries a color, current/
+// next can be recolored to it.
export function colorsPresent(state) {
const set = new Set();
- for (const b of state.balls) set.add(b.color);
+ for (const p of state.paths) for (const b of p.balls) set.add(b.color);
return set;
}
@@ -420,26 +472,27 @@ export function findRun(balls, idx) {
// Screen positions, plus the two tunnel flags the renderer reads: `hidden` is
// the gameplay truth (inert while submerged) and `vis` the cosmetic ramp that
-// sinks a marble into the maw instead of blinking it away.
-function syncPositions(state) {
- const tun = state.tunnels ?? [];
- for (const b of state.balls) {
- const p = state.path.pointAt(b.s);
- b.x = p.x; b.y = p.y;
+// sinks a marble into the maw instead of blinking it away. One path at a time.
+function syncPositions(p) {
+ const tun = p.tunnels ?? [];
+ for (const b of p.balls) {
+ const pt = p.path.pointAt(b.s);
+ b.x = pt.x; b.y = pt.y;
b.hidden = tun.length ? isHidden(tun, b.s) : false;
b.vis = tun.length ? visibilityAt(tun, b.s) : 1;
}
}
// ── Chain movement ────────────────────────────────────────────────────────────
-// Per tick: the rearmost (spawner-fed) segment drives forward; per gap, a
-// matching pair pulls the front side backward, a non-matching pair sends the
-// rear side forward to catch up. Reverse overrides everything backward.
-// Contacts merge implicitly (exact spacing); closed gaps clank + match-check.
+// Per path, per tick: the rearmost (spawner-fed) segment drives forward; per
+// gap, a matching pair pulls the front side backward, a non-matching pair
+// sends the rear side forward to catch up. Reverse overrides everything
+// backward. Contacts merge implicitly (exact spacing); closed gaps clank +
+// match-check. Every path moves independently — no cross-path interaction.
-function moveSegments(state, dtMs, events) {
+function moveSegments(state, p, dtMs, events) {
const T = TUNING;
- const balls = state.balls;
+ const balls = p.balls;
if (!balls.length) return;
const dt = dtMs / 1000;
const now = state.elapsedMs;
@@ -449,8 +502,8 @@ function moveSegments(state, dtMs, events) {
if (now < state.effects.reverseUntil) {
vel.fill(-T.REVERSE_SPEED);
} else {
- let base = state.def.pushSpeed;
- if (state.status === 'intro') base *= T.INTRO_SPEED_MULT;
+ let base = p.pushSpeed;
+ if (p.spawned < p.introBalls) base *= T.INTRO_SPEED_MULT;
if (now < state.effects.slowUntil) base *= T.SLOW_MULT;
vel[segs.length - 1] += base;
for (let g = 0; g < segs.length - 1; g++) {
@@ -494,13 +547,13 @@ function moveSegments(state, dtMs, events) {
const gap = balls[fi].s - balls[fi + 1].s;
if (gap > T.BALL_SPACING + T.GAP_EPS) continue;
if (gap < T.BALL_SPACING) balls[fi + 1].s = balls[fi].s - T.BALL_SPACING;
- const p = state.path.pointAt(balls[fi].s);
- events.push({ type: 'clank', x: p.x, y: p.y });
+ const pt = p.path.pointAt(balls[fi].s);
+ events.push({ type: 'clank', pathIdx: p.idx, x: pt.x, y: pt.y });
if (balls[fi].color === balls[fi + 1].color) {
const run = findRun(balls, fi);
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
state.combo += 1;
- popRun(state, run.lo, run.hi, 'chain', events);
+ popRun(state, p, run.lo, run.hi, 'chain', events);
}
}
}
@@ -508,58 +561,73 @@ function moveSegments(state, dtMs, events) {
// ── Spawning ──────────────────────────────────────────────────────────────────
-function spawnColor(state) {
- if (state.quota - state.spawned <= TUNING.LASTCALL_COUNT) {
+// Last-call colors are drawn from the whole board (every path), same as the
+// shooter — the guarantee is "the player can still find a match somewhere,"
+// not "this path's own chain still has one."
+function spawnColor(state, p) {
+ if (p.quota - p.spawned <= TUNING.LASTCALL_COUNT) {
const present = colorsPresent(state);
if (present.size) return pickPresent(state, present);
}
return levelColor(state);
}
-function spawnBalls(state, events) {
+function spawnBalls(state, p, events) {
const T = TUNING;
- while (state.spawned < state.quota) {
- const rear = state.balls[state.balls.length - 1];
+ while (p.spawned < p.quota) {
+ const rear = p.balls[p.balls.length - 1];
if (rear && rear.s < T.BALL_SPACING) break;
- const color = spawnColor(state);
+ const color = spawnColor(state, p);
let power = null;
- if (state.rng() < (state.def.powerUpRate ?? 0)) {
+ if (state.rng() < p.powerUpRate) {
power = POWER_KINDS[Math.floor(state.rng() * POWER_KINDS.length)];
}
const b = { id: state.nextId++, color, power, s: rear ? rear.s - T.BALL_SPACING : 0, x: 0, y: 0 };
- const p = state.path.pointAt(b.s);
- b.x = p.x; b.y = p.y;
- state.balls.push(b);
- state.spawned++;
- events.push({ type: 'spawn', id: b.id });
- if (state.status === 'intro' && state.spawned >= (state.def.introBalls ?? 8)) {
- state.status = 'playing';
- events.push({ type: 'ready' });
- }
+ const pt = p.path.pointAt(b.s);
+ b.x = pt.x; b.y = pt.y;
+ p.balls.push(b);
+ p.spawned++;
+ events.push({ type: 'spawn', pathIdx: p.idx, id: b.id });
+ }
+}
+
+// The level-wide intro→playing switch: every path fast-feeds independently
+// (see moveSegments) until ITS OWN spawned count clears its own introBalls,
+// but firing/swapping stay locked until ALL paths have — one shared 'ready'
+// moment rather than the frog going live mid-fast-forward on a path that
+// finished its intro early.
+function checkReady(state, events) {
+ if (state.status !== 'intro') return;
+ if (state.paths.every((p) => p.spawned >= p.introBalls)) {
+ state.status = 'playing';
+ events.push({ type: 'ready' });
}
}
// ── Popping, power-ups, scoring ───────────────────────────────────────────────
-export function popRun(state, lo, hi, cause, events) {
+export function popRun(state, p, lo, hi, cause, events) {
const T = TUNING;
- const popped = state.balls.splice(lo, hi - lo + 1);
+ const popped = p.balls.splice(lo, hi - lo + 1);
const mid = popped[Math.floor(popped.length / 2)];
let score = popped.length * T.SCORE_BALL * Math.max(1, state.combo);
if (cause === 'chain') score += T.SCORE_CHAIN_BONUS;
state.score += score;
events.push({
- type: 'pop', ids: popped.map((b) => b.id), color: mid.color,
+ type: 'pop', pathIdx: p.idx, ids: popped.map((b) => b.id), color: mid.color,
score, combo: state.combo, x: mid.x, y: mid.y, cause,
});
const powers = popped.filter((b) => b.power);
- for (const b of powers) applyPower(state, b, events);
+ for (const b of powers) applyPower(state, p, b, events);
recolorShooter(state, events);
}
-function applyPower(state, ball, events) {
+// Explosion blasts stay scoped to the path the popped ball belonged to — a
+// chain reaction cannot hop to a different path, same as it cannot reach
+// through a tunnel.
+function applyPower(state, p, ball, events) {
const T = TUNING;
- events.push({ type: 'powerup', kind: ball.power, x: ball.x, y: ball.y });
+ events.push({ type: 'powerup', pathIdx: p.idx, kind: ball.power, x: ball.x, y: ball.y });
if (ball.power === 'slow') state.effects.slowUntil = state.elapsedMs + T.SLOW_MS;
else if (ball.power === 'reverse') state.effects.reverseUntil = state.elapsedMs + T.REVERSE_MS;
else if (ball.power === 'accuracy') state.effects.accuracyUntil = state.elapsedMs + T.ACCURACY_MS;
@@ -567,34 +635,34 @@ function applyPower(state, ball, events) {
// blast radius around the popped ball; chained power balls trigger too.
// A blast is stopped dead by a tunnel mouth — submerged marbles are out of
// play, so the chain reaction cannot reach through the ground to them.
- const tun = state.tunnels ?? [];
+ const tun = p.tunnels ?? [];
const queue = [ball];
while (queue.length) {
const src = queue.shift();
- const caught = state.balls.filter(
+ const caught = p.balls.filter(
(b) => !(tun.length && isHidden(tun, b.s))
&& Math.hypot(b.x - src.x, b.y - src.y) <= T.EXPLOSION_RADIUS
);
if (!caught.length) continue;
const ids = new Set(caught.map((b) => b.id));
- // splice in place: callers hold references to state.balls across popRun
- for (let i = state.balls.length - 1; i >= 0; i--) {
- if (ids.has(state.balls[i].id)) state.balls.splice(i, 1);
+ // splice in place: callers hold references to p.balls across popRun
+ for (let i = p.balls.length - 1; i >= 0; i--) {
+ if (ids.has(p.balls[i].id)) p.balls.splice(i, 1);
}
const score = caught.length * T.SCORE_BALL * Math.max(1, state.combo);
state.score += score;
- events.push({ type: 'explosion', ids: [...ids], score, x: src.x, y: src.y });
+ events.push({ type: 'explosion', pathIdx: p.idx, ids: [...ids], score, x: src.x, y: src.y });
for (const b of caught) {
if (b.power === 'explosion') queue.push(b);
- else if (b.power) applyPower(state, b, events);
+ else if (b.power) applyPower(state, p, b, events);
}
}
}
}
function recolorShooter(state, events) {
- if (!state.balls.length) return;
const present = colorsPresent(state);
+ if (!present.size) return;
for (const slot of ['current', 'next']) {
if (!present.has(state[slot])) {
state[slot] = pickPresent(state, present);
@@ -630,13 +698,13 @@ export function swapBalls(state) {
state.next = t;
}
-// Wedge a fired ball into the chain at hitIdx. side: +1 in front of the hit
-// ball (higher s), -1 behind. The front portion is shoved toward the hole —
-// shoves can slam segments together (clank + junction match) and can lose the
-// level by pushing the front ball into the pit.
-export function insertBall(state, color, hitIdx, side, events) {
+// Wedge a fired ball into path p's chain at hitIdx. side: +1 in front of the
+// hit ball (higher s), -1 behind. The front portion is shoved toward the
+// hole — shoves can slam segments together (clank + junction match) and can
+// lose the level by pushing the front ball into the pit.
+export function insertBall(state, p, color, hitIdx, side, events) {
const T = TUNING;
- const balls = state.balls;
+ const balls = p.balls;
const hit = balls[hitIdx];
let insertIdx, s, push = true;
@@ -670,20 +738,20 @@ export function insertBall(state, color, hitIdx, side, events) {
balls[i].s = minS;
}
}
- syncPositions(state);
- events.push({ type: 'inserted', id: ball.id, idx: insertIdx, x: ball.x, y: ball.y });
+ syncPositions(p);
+ events.push({ type: 'inserted', pathIdx: p.idx, id: ball.id, idx: insertIdx, x: ball.x, y: ball.y });
// shove-closed gaps
for (const frontId of prevGaps) {
const fi = balls.findIndex((b) => b.id === frontId);
if (fi < 0 || fi + 1 >= balls.length) continue;
if (balls[fi].s - balls[fi + 1].s > T.BALL_SPACING + T.GAP_EPS) continue;
- events.push({ type: 'clank', x: balls[fi].x, y: balls[fi].y });
+ events.push({ type: 'clank', pathIdx: p.idx, x: balls[fi].x, y: balls[fi].y });
if (balls[fi].color === balls[fi + 1].color) {
const run = findRun(balls, fi);
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
state.combo += 1;
- popRun(state, run.lo, run.hi, 'chain', events);
+ popRun(state, p, run.lo, run.hi, 'chain', events);
}
}
}
@@ -694,7 +762,7 @@ export function insertBall(state, color, hitIdx, side, events) {
const run = findRun(balls, idx);
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
state.combo = 1;
- popRun(state, run.lo, run.hi, 'shot', events);
+ popRun(state, p, run.lo, run.hi, 'shot', events);
} else {
state.combo = 0;
}
@@ -702,32 +770,39 @@ export function insertBall(state, color, hitIdx, side, events) {
checkLose(state, events);
}
+// Steps every fired ball, checking every path's chain for the nearest hit —
+// this is the entire "aiming" story for multi-path levels: a shot lands on
+// whichever ball it physically reaches first, regardless of which path that
+// ball is on.
function stepFlights(state, dtMs, events) {
const T = TUNING;
- const tun = state.tunnels ?? [];
for (let f = state.flights.length - 1; f >= 0; f--) {
const fl = state.flights[f];
const dist = fl.speed * (dtMs / 1000);
const steps = Math.max(1, Math.ceil(dist / T.BALL_RADIUS));
const stepLen = dist / steps;
+ let hitPath = null;
let hitIdx = -1;
for (let k = 0; k < steps && hitIdx < 0; k++) {
fl.x += fl.dx * stepLen;
fl.y += fl.dy * stepLen;
let best = Infinity;
- for (let i = 0; i < state.balls.length; i++) {
- const b = state.balls[i];
- if (tun.length && isHidden(tun, b.s)) continue; // underground: shots pass over
- const d = Math.hypot(fl.x - b.x, fl.y - b.y);
- if (d < T.BALL_SPACING * T.HIT_PAD && d < best) { best = d; hitIdx = i; }
+ for (const p of state.paths) {
+ const tun = p.tunnels;
+ for (let i = 0; i < p.balls.length; i++) {
+ const b = p.balls[i];
+ if (tun.length && isHidden(tun, b.s)) continue; // underground: shots pass over
+ const d = Math.hypot(fl.x - b.x, fl.y - b.y);
+ if (d < T.BALL_SPACING * T.HIT_PAD && d < best) { best = d; hitPath = p; hitIdx = i; }
+ }
}
}
if (hitIdx >= 0) {
state.flights.splice(f, 1);
- const b = state.balls[hitIdx];
- const p = state.path.pointAt(b.s);
- const side = ((fl.x - b.x) * p.tx + (fl.y - b.y) * p.ty) >= 0 ? 1 : -1;
- insertBall(state, fl.color, hitIdx, side, events);
+ const b = hitPath.balls[hitIdx];
+ const pt = hitPath.path.pointAt(b.s);
+ const side = ((fl.x - b.x) * pt.tx + (fl.y - b.y) * pt.ty) >= 0 ? 1 : -1;
+ insertBall(state, hitPath, fl.color, hitIdx, side, events);
} else if (fl.x < -T.BOUNDS_PAD || fl.x > T.BOUNDS_W + T.BOUNDS_PAD
|| fl.y < -T.BOUNDS_PAD || fl.y > T.BOUNDS_H + T.BOUNDS_PAD) {
state.flights.splice(f, 1);
@@ -736,19 +811,22 @@ function stepFlights(state, dtMs, events) {
}
}
-// Aiming helper for the laser sight: first chain hit along a ray from the frog.
+// Aiming helper for the laser sight: first chain hit along a ray from the
+// frog, across every path.
export function rayHit(state, angle) {
const T = TUNING;
- const tun = state.tunnels ?? [];
const dx = Math.cos(angle), dy = Math.sin(angle);
const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H);
const stepLen = T.BALL_RADIUS / 2;
let x = state.frog.x + dx * T.FROG_MUZZLE;
let y = state.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
- for (const b of state.balls) {
- if (tun.length && isHidden(tun, b.s)) continue;
- if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { x, y, hit: true };
+ for (const p of state.paths) {
+ const tun = p.tunnels;
+ for (const b of p.balls) {
+ if (tun.length && isHidden(tun, b.s)) continue;
+ if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { x, y, hit: true };
+ }
}
x += dx * stepLen;
y += dy * stepLen;
@@ -758,20 +836,27 @@ export function rayHit(state, angle) {
// ── Win / lose ────────────────────────────────────────────────────────────────
+// Any one path's chain reaching its own pit loses the whole level.
function checkLose(state, events) {
if (state.status === 'won' || state.status === 'lost') return;
- const front = state.balls[0];
- if (front && front.s >= state.path.length - TUNING.HOLE_GRACE) {
- state.status = 'lost';
- events.push({ type: 'lost' });
+ for (const p of state.paths) {
+ const front = p.balls[0];
+ if (front && front.s >= p.path.length - TUNING.HOLE_GRACE) {
+ state.status = 'lost';
+ events.push({ type: 'lost' });
+ return;
+ }
}
}
+// Every path's chain must be gone, plus the shared shot queue empty.
function checkWin(state, events) {
if (state.status !== 'playing') return;
- if (state.spawned >= state.quota && !state.balls.length && !state.flights.length) {
+ const allCleared = state.paths.every((p) => p.spawned >= p.quota && !p.balls.length);
+ if (allCleared && !state.flights.length) {
const T = TUNING;
- const parMs = state.quota * T.TIME_PAR_MS_PER_BALL;
+ const totalQuota = state.paths.reduce((a, p) => a + p.quota, 0);
+ const parMs = totalQuota * T.TIME_PAR_MS_PER_BALL;
const timeBonus = Math.max(0, Math.ceil((parMs - state.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
state.score += timeBonus;
state.status = 'won';
@@ -786,9 +871,10 @@ export function step(state, dtMs) {
if (state.status === 'won' || state.status === 'lost') return events;
const dt = Math.min(dtMs, TUNING.MAX_STEP_MS);
state.elapsedMs += dt;
- moveSegments(state, dt, events);
- spawnBalls(state, events);
- syncPositions(state);
+ for (const p of state.paths) moveSegments(state, p, dt, events);
+ for (const p of state.paths) spawnBalls(state, p, events);
+ checkReady(state, events);
+ for (const p of state.paths) syncPositions(p);
checkLose(state, events);
if (state.status === 'lost') return events;
stepFlights(state, dt, events);
diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js
index dfab32a..400aa14 100644
--- a/src/scenes/PreloadScene.js
+++ b/src/scenes/PreloadScene.js
@@ -60,7 +60,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('slots-artwork', 'data/slots-artwork.json');
this.load.json('blockfighter', 'data/blockfighter.json');
this.load.json('jewelquest', 'data/jewelquest.json');
- this.load.json('zuma', 'data/zuma.json');
+ this.load.json('zuma-levels', 'assets/gamedata/zuma/levels.json');
this.load.json('dotlink', 'data/dotlink.json');
this.load.json('katamino', 'data/katamino.json');
this.load.json('bookwork', 'data/bookwork.json');
diff --git a/tools/genZuma.js b/tools/genZuma.js
index cc533c1..59614c6 100644
--- a/tools/genZuma.js
+++ b/tools/genZuma.js
@@ -6,7 +6,13 @@
// refuses to write a bank the game would consider unplayable.
//
// Usage:
-// node tools/genZuma.js [outFile]
+// node tools/genZuma.js [outDir]
+//
+// Writes one file per level (assets/gamedata/zuma/level-NNN.json) plus a
+// lightweight levels.json manifest, matching Peggle/Super Kart/Goo Tower's
+// layout — the level select screen only ever needs level/name/file, so the
+// manifest stays cheap to eager-load while the full per-level payload (path
+// geometry, tunnels, etc.) is fetched on demand when that level is played.
//
// Geometry note: marbles are BALL_SPACING apart and turns must stay wider than
// BALL_RADIUS * 1.7, so shapes are emitted as exact straights and circular arcs
@@ -21,9 +27,9 @@ import { fileURLToPath } from 'node:url';
import { validateLevel, buildPath, TUNING } from '../src/games/zuma/ZumaLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const OUT_FILE = process.argv[2]
+const OUT_DIR = process.argv[2]
? path.resolve(process.argv[2])
- : path.join(__dirname, '../data/zuma.json');
+ : path.join(__dirname, '../assets/gamedata/zuma');
const rad = (deg) => (deg * Math.PI) / 180;
const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]);
@@ -228,11 +234,14 @@ for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRat
const len = buildPath(points).length;
const tunnels = (tunFrac ?? []).map(([a, b]) => [Math.round(len * a), Math.round(len * b)]);
const def = {
- level, name, shape, points, frog, tunnels, colors, quota, introBalls, pushSpeed, powerUpRate,
+ level, name, frog, colors,
background: BACKGROUNDS[(level - 1) % BACKGROUNDS.length],
seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed),
+ paths: [{ shape, points, tunnels, quota, introBalls, pushSpeed, powerUpRate }],
};
+ // Every table row is still exactly one path — validateLevel/genZuma's own
+ // lint reports below read that one entry's stats (see ZumaLogic.validateLevel).
const { errs, length, minFrog, minRadius, hiddenFrac } = validateLevel(def);
const cap = Math.floor(length / (TUNING.BALL_SPACING * 1.6));
if (errs.length) {
@@ -246,13 +255,18 @@ for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRat
}
if (bad) {
- console.error(`\n${bad} invalid level(s) — not writing ${OUT_FILE}`);
+ console.error(`\n${bad} invalid level(s) — not writing ${OUT_DIR}`);
process.exit(1);
}
-fs.writeFileSync(OUT_FILE, JSON.stringify({
- generatedAt: new Date().toISOString(),
- count: levels.length,
- levels,
-}, null, 1));
-console.log(`\nWrote ${levels.length} levels to ${OUT_FILE}`);
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const manifest = { version: 1, levels: [] };
+for (const def of levels) {
+ const file = `level-${String(def.level).padStart(3, '0')}.json`;
+ fs.writeFileSync(path.join(OUT_DIR, file), `${JSON.stringify(def, null, 1)}\n`);
+ manifest.levels.push({ level: def.level, name: def.name, file });
+}
+fs.writeFileSync(path.join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 1)}\n`);
+
+console.log(`\nWrote ${levels.length} levels + manifest to ${OUT_DIR}`);
diff --git a/tools/verifyZuma.js b/tools/verifyZuma.js
index 42d51c5..f703816 100644
--- a/tools/verifyZuma.js
+++ b/tools/verifyZuma.js
@@ -11,8 +11,10 @@
// 7. Win/lose state machine, recolor, last-call spawns.
// 8. Determinism (seeded replay).
// 9. Tunnels: submerged chain is inert to flights, the laser and explosions.
-// 10. Level bank lint (data/zuma.json geometry + parameters).
-// 11. Aimbot soak: every banked level is winnable, and the star curve sits
+// 10. Multi-path levels: independent spawners, shared shooter color pool,
+// physics-based shot targeting, either-path-loses, every-path-to-win.
+// 11. Level bank lint (assets/gamedata/zuma/ manifest + per-level geometry/parameters).
+// 12. Aimbot soak: every banked level is winnable, and the star curve sits
// above what mechanical play achieves.
import { readFileSync } from 'node:fs';
@@ -48,28 +50,48 @@ const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]];
// into gapped ones and the insertion tests start asserting nonsense.
const SP = T.BALL_SPACING;
+// Every existing single-path test passes flat overrides (points/tunnels/quota/
+// introBalls/pushSpeed/powerUpRate) exactly as before genZuma.js's `paths`
+// array existed — mkDef wraps them into a one-element paths array so none of
+// those call sites need to change.
function mkDef(over = {}) {
+ const { points, tunnels, quota, introBalls, pushSpeed, powerUpRate, shape, paths, ...rest } = over;
return {
- level: 1, name: 'Test', shape: 'line',
- points: STRAIGHT, frog: [800, 900],
- colors: 4, quota: 10, introBalls: 2,
- pushSpeed: 100, powerUpRate: 0, seed: 42,
- starScores: [100, 200, 300],
- ...over,
+ level: 1, name: 'Test',
+ frog: [800, 900], colors: 4, seed: 42, starScores: [100, 200, 300],
+ paths: paths ?? [{
+ shape: shape ?? 'line',
+ points: points ?? STRAIGHT,
+ tunnels,
+ quota: quota ?? 10,
+ introBalls: introBalls ?? 2,
+ pushSpeed: pushSpeed ?? 100,
+ powerUpRate: powerUpRate ?? 0,
+ }],
+ ...rest,
};
}
+// `over` used to be Object.assign'd straight onto state (back when `spawned`
+// and `balls` lived at the top level); both now live on paths[0], so they're
+// special-cased here and every existing call site keeps working unchanged.
function mkState(defOver = {}, over = {}) {
const st = createLevel(mkDef(defOver));
- Object.assign(st, over);
+ const { spawned, balls, ...rest } = over;
+ Object.assign(st, rest);
+ if (spawned !== undefined) st.paths[0].spawned = spawned;
+ if (balls !== undefined) st.paths[0].balls = balls;
return st;
}
// ── Aimbot ───────────────────────────────────────────────────────────────────
// Deliberately mechanical: it never sets up a chain, never banks a shot off a
-// gap, and never holds a colour. Whatever it clears, a player clears.
+// gap, and never holds a colour. Whatever it clears, a player clears. Scans
+// every path for a target — the multi-path story is "physics decides which
+// chain a shot lands on," so the bot has to consider all of them too.
-// Walks the shot ray like rayHit does, but reports which ball it lands on.
+// Walks the shot ray like rayHit does, but reports which ball (and which path)
+// it lands on.
function firstHit(st, angle) {
const dx = Math.cos(angle), dy = Math.sin(angle);
const stepLen = T.BALL_RADIUS / 2;
@@ -77,34 +99,38 @@ function firstHit(st, angle) {
let x = st.frog.x + dx * T.FROG_MUZZLE;
let y = st.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
- for (const b of st.balls) {
- if (isHidden(st.tunnels, b.s)) continue; // submerged: the shot flies over
- if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b;
+ for (const p of st.paths) {
+ for (const b of p.balls) {
+ if (isHidden(p.tunnels, b.s)) continue; // submerged: the shot flies over
+ if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { ball: b, path: p };
+ }
}
x += dx * stepLen; y += dy * stepLen;
}
return null;
}
-// Rank every reachable ball: landing between two of the held colour beats
-// landing beside one, which beats landing on a bare match.
+// Rank every reachable ball across every path: landing between two of the
+// held colour beats landing beside one, which beats landing on a bare match.
function chooseShot(st) {
let best = null;
- for (const target of st.balls) {
- if (isHidden(st.tunnels, target.s)) continue;
- const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x);
- const hit = firstHit(st, angle);
- if (!hit) continue;
- const i = st.balls.indexOf(hit);
- const prev = st.balls[i - 1], next = st.balls[i + 1];
- let score = 0;
- if (hit.color === st.current) {
- score += 5;
- if (prev?.color === st.current) score += 10;
- if (next?.color === st.current) score += 10;
+ for (const p of st.paths) {
+ for (const target of p.balls) {
+ if (isHidden(p.tunnels, target.s)) continue;
+ const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x);
+ const hit = firstHit(st, angle);
+ if (!hit) continue;
+ const i = hit.path.balls.indexOf(hit.ball);
+ const prev = hit.path.balls[i - 1], next = hit.path.balls[i + 1];
+ let score = 0;
+ if (hit.ball.color === st.current) {
+ score += 5;
+ if (prev?.color === st.current) score += 10;
+ if (next?.color === st.current) score += 10;
+ }
+ if (prev?.color === st.current && next?.color === st.current) score += 8;
+ if (score > 0 && (!best || score > best.score)) best = { angle, score };
}
- if (prev?.color === st.current && next?.color === st.current) score += 8;
- if (score > 0 && (!best || score > best.score)) best = { angle, score };
}
return best;
}
@@ -126,14 +152,16 @@ function aimbotPlay(def, seed) {
return { status: st.status, score: st.score };
}
-// spec: [{ color, s, power? }] front-first (descending s)
-function mkChain(st, spec) {
- st.balls = spec.map((b) => ({
+// spec: [{ color, s, power? }] front-first (descending s). Defaults to
+// paths[0] — every pre-multi-path fixture is single-path.
+function mkChain(st, spec, pathIdx = 0) {
+ const p = st.paths[pathIdx];
+ p.balls = spec.map((b) => ({
id: st.nextId++, color: b.color, power: b.power ?? null, s: b.s, x: 0, y: 0,
}));
- for (const b of st.balls) {
- const p = st.path.pointAt(b.s);
- b.x = p.x; b.y = p.y;
+ for (const b of p.balls) {
+ const pt = p.path.pointAt(b.s);
+ b.x = pt.x; b.y = pt.y;
}
return st;
}
@@ -178,18 +206,19 @@ console.log('\n— Advance & spawning —');
let introSpawned = -1;
for (let i = 0; i < 4000 && st.status === 'intro'; i++) {
step(st, 16);
- if (st.status !== 'intro') introSpawned = st.spawned;
+ if (st.status !== 'intro') introSpawned = st.paths[0].spawned;
}
check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`);
- for (let i = 0; i < 4000 && st.spawned < 8; i++) step(st, 16);
- check('spawn stops at quota', st.spawned === 8 && st.balls.length === 8);
- check('spacing invariant after spawning', spacingOk(st.balls));
+ for (let i = 0; i < 4000 && st.paths[0].spawned < 8; i++) step(st, 16);
+ check('spawn stops at quota', st.paths[0].spawned === 8 && st.paths[0].balls.length === 8);
+ check('spacing invariant after spawning', spacingOk(st.paths[0].balls));
const st2 = mkState({}, { status: 'playing', spawned: 10 });
mkChain(st2, [{ color: 0, s: 600 + 2 * SP }, { color: 1, s: 600 + SP }, { color: 2, s: 600 }]);
for (let i = 0; i < 20; i++) step(st2, 50); // 1s at pushSpeed 100
- check('single segment drives at pushSpeed', near(st2.balls[0].s, 600 + 2 * SP + 100, 1), `got ${st2.balls[0].s.toFixed(1)}`);
- check('spacing preserved while driving', spacingOk(st2.balls));
+ check('single segment drives at pushSpeed', near(st2.paths[0].balls[0].s, 600 + 2 * SP + 100, 1),
+ `got ${st2.paths[0].balls[0].s.toFixed(1)}`);
+ check('spacing preserved while driving', spacingOk(st2.paths[0].balls));
}
// ── 3. Insertion ─────────────────────────────────────────────────────────────
@@ -202,37 +231,39 @@ console.log('\n— Insertion —');
);
let st = base(); let ev = [];
- insertBall(st, 3, 2, +1, ev);
+ insertBall(st, st.paths[0], 3, 2, +1, ev);
+ const balls = st.paths[0].balls;
check('front insert lands in front of hit ball',
- st.balls[2].color === 3 && near(st.balls[2].s, 600 - SP, 0.01) && near(st.balls[3].s, 600 - 2 * SP, 0.01));
- check('front insert shoves balls ahead', near(st.balls[0].s, 600 + SP, 0.01) && near(st.balls[1].s, 600, 0.01));
- check('front insert keeps spacing', spacingOk(st.balls) && st.balls.length === 6);
+ balls[2].color === 3 && near(balls[2].s, 600 - SP, 0.01) && near(balls[3].s, 600 - 2 * SP, 0.01));
+ check('front insert shoves balls ahead', near(balls[0].s, 600 + SP, 0.01) && near(balls[1].s, 600, 0.01));
+ check('front insert keeps spacing', spacingOk(balls) && balls.length === 6);
check('non-matching insert resets combo, no pop', st.combo === 0 && !ev.some((e) => e.type === 'pop'));
st = base(); ev = [];
- insertBall(st, 3, 2, -1, ev);
+ insertBall(st, st.paths[0], 3, 2, -1, ev);
check('behind insert wedges after hit ball',
- st.balls[3].color === 3 && near(st.balls[3].s, 600 - 2 * SP, 0.01) && near(st.balls[2].s, 600 - SP, 0.01));
- check('behind insert keeps spacing', spacingOk(st.balls));
+ st.paths[0].balls[3].color === 3 && near(st.paths[0].balls[3].s, 600 - 2 * SP, 0.01)
+ && near(st.paths[0].balls[2].s, 600 - SP, 0.01));
+ check('behind insert keeps spacing', spacingOk(st.paths[0].balls));
st = base(); ev = [];
- insertBall(st, 3, 4, -1, ev);
+ insertBall(st, st.paths[0], 3, 4, -1, ev);
check('tail attach adds at rear without shoving',
- near(st.balls[5].s, 600 - 5 * SP, 0.01) && near(st.balls[0].s, 600, 0.01));
+ near(st.paths[0].balls[5].s, 600 - 5 * SP, 0.01) && near(st.paths[0].balls[0].s, 600, 0.01));
// shove closes a gap → clank, no pop (junction colors differ)
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 2, s: 900 - 2.5 * SP },
{ color: 3, s: 900 - 3.5 * SP }]);
ev = [];
- insertBall(st, 3, 2, +1, ev);
+ insertBall(st, st.paths[0], 3, 2, +1, ev);
check('shove-merge emits clank', ev.some((e) => e.type === 'clank'));
- check('shove-merge joins segments', segmentsOf(st.balls).length === 1 && spacingOk(st.balls));
+ check('shove-merge joins segments', segmentsOf(st.paths[0].balls).length === 1 && spacingOk(st.paths[0].balls));
check('shove-merge without matching junction does not pop', !ev.some((e) => e.type === 'pop'));
// laser-sight ray helper
st = mkChain(mkState({}, { status: 'playing' }), [{ color: 0, s: 600 }]);
- const ball = st.balls[0];
+ const ball = st.paths[0].balls[0];
const ray = rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x));
check('rayHit finds first chain ball', ray.hit && Math.hypot(ray.x - ball.x, ray.y - ball.y) < T.BALL_SPACING);
}
@@ -243,27 +274,27 @@ console.log('\n— Matching —');
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 0, s: 600 - SP }, { color: 1, s: 600 - 2 * SP }, { color: 1, s: 600 - 3 * SP }]);
let ev = [];
- insertBall(st, 0, 1, +1, ev);
+ insertBall(st, st.paths[0], 0, 1, +1, ev);
const pop = ev.find((e) => e.type === 'pop');
- check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.balls.length === 2);
+ check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.paths[0].balls.length === 2);
check('3-pop score = 3 × SCORE_BALL', pop?.score === 3 * T.SCORE_BALL && st.score === 3 * T.SCORE_BALL);
check('shot pop sets combo to 1', pop?.combo === 1);
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 + 2 * SP }, { color: 0, s: 600 + SP }, { color: 0, s: 600 }, { color: 0, s: 600 - SP }]);
ev = [];
- insertBall(st, 0, 1, -1, ev);
+ insertBall(st, st.paths[0], 0, 1, -1, ev);
const pop5 = ev.find((e) => e.type === 'pop');
- check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.balls.length === 0);
+ check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.paths[0].balls.length === 0);
check('5-pop score', pop5?.score === 5 * T.SCORE_BALL);
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 0, s: 600 - 2.5 * SP }, { color: 1, s: 600 - 3.5 * SP }]);
ev = [];
- insertBall(st, 0, 1, +1, ev);
- check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4);
+ insertBall(st, st.paths[0], 0, 1, +1, ev);
+ check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.paths[0].balls.length === 4);
- const run = findRun(st.balls, 1);
+ const run = findRun(st.paths[0].balls, 1);
check('findRun bounded by the gap', run.lo === 1 && run.hi === 2);
}
@@ -276,7 +307,7 @@ console.log('\n— Pull-back & catch-up —');
let popEv = null, clankSeen = false, retreated = false;
for (let i = 0; i < 200 && !popEv; i++) {
const ev = step(st, 25);
- if (st.balls.length && st.balls[0].s < 900 - 1) retreated = true;
+ if (st.paths[0].balls.length && st.paths[0].balls[0].s < 900 - 1) retreated = true;
if (ev.some((e) => e.type === 'clank')) clankSeen = true;
popEv = ev.find((e) => e.type === 'pop') ?? popEv;
}
@@ -285,24 +316,24 @@ console.log('\n— Pull-back & catch-up —');
check('chain pop scores chain bonus', popEv?.cause === 'chain'
&& popEv?.score === 3 * T.SCORE_BALL + T.SCORE_CHAIN_BONUS);
check('chain pop increments combo', popEv?.combo === 1);
- check('survivor remains after chain pop', st.balls.length === 1 && st.balls[0].color === 0);
+ check('survivor remains after chain pop', st.paths[0].balls.length === 1 && st.paths[0].balls[0].color === 0);
// non-matching gap → rear catches up, front stays put, clank without pop
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 5 * SP }, { color: 1, s: 900 - 6 * SP }]);
let clankAt = null;
for (let i = 0; i < 200 && !clankAt; i++) {
- const frontBefore = st.balls[0].s;
+ const frontBefore = st.paths[0].balls[0].s;
const ev = step(st, 25);
- if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.balls[2].s };
+ if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.paths[0].balls[2].s };
}
check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 900 - 2 * SP, 1.5),
clankAt ? `rear front at ${clankAt.rearFront.toFixed(1)}` : 'no clank');
check('non-matching gap: front segment stays put', !!clankAt && near(clankAt.frontBefore, 900, 0.01));
- check('no pop on non-matching junction', st.balls.length === 4);
- const before = st.balls[0].s;
+ check('no pop on non-matching junction', st.paths[0].balls.length === 4);
+ const before = st.paths[0].balls[0].s;
for (let i = 0; i < 8; i++) step(st, 50);
- check('merged chain resumes driving', st.balls[0].s > before + 30);
+ check('merged chain resumes driving', st.paths[0].balls[0].s > before + 30);
}
// ── 6. Power-ups ─────────────────────────────────────────────────────────────
@@ -312,30 +343,30 @@ console.log('\n— Power-ups —');
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 800 - 3 * SP }]);
let ev = [];
- popRun(st, 0, 0, 'shot', ev);
+ popRun(st, st.paths[0], 0, 0, 'shot', ev);
check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS
&& ev.some((e) => e.type === 'powerup' && e.kind === 'slow'));
- const s0 = st.balls[0].s;
+ const s0 = st.paths[0].balls[0].s;
for (let i = 0; i < 20; i++) step(st, 50);
- check('slow halves the drive (SLOW_MULT)', near(st.balls[0].s - s0, 100 * T.SLOW_MULT, 1.5),
- `moved ${(st.balls[0].s - s0).toFixed(1)}`);
+ check('slow halves the drive (SLOW_MULT)', near(st.paths[0].balls[0].s - s0, 100 * T.SLOW_MULT, 1.5),
+ `moved ${(st.paths[0].balls[0].s - s0).toFixed(1)}`);
// reverse: chain rolls backward, then resumes forward when expired
// (elapsedMs increments before the effect check, so 501 covers exactly 10 ticks)
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [{ color: 0, s: 800 }]);
st.effects.reverseUntil = st.elapsedMs + 501;
for (let i = 0; i < 10; i++) step(st, 50);
- check('reverse rolls the chain backward', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2),
- `at ${st.balls[0].s.toFixed(1)}`);
+ check('reverse rolls the chain backward', near(st.paths[0].balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2),
+ `at ${st.paths[0].balls[0].s.toFixed(1)}`);
for (let i = 0; i < 10; i++) step(st, 50);
- check('drive resumes after reverse expires', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2),
- `at ${st.balls[0].s.toFixed(1)}`);
+ check('drive resumes after reverse expires', near(st.paths[0].balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2),
+ `at ${st.paths[0].balls[0].s.toFixed(1)}`);
// accuracy: flag set on pop; fired flights move faster
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 800 - 3 * SP }]);
ev = [];
- popRun(st, 0, 0, 'shot', ev);
+ popRun(st, st.paths[0], 0, 0, 'shot', ev);
check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS);
const flight = fireBall(st, -Math.PI / 2);
check('accuracy speeds up shots', !!flight && near(flight.speed, T.SHOT_SPEED * T.ACCURACY_SHOT_MULT, 0.01));
@@ -347,17 +378,17 @@ console.log('\n— Power-ups —');
{ color: 3, s: 900 - 5 * SP }, { color: 3, s: 900 - 6 * SP },
]);
ev = [];
- popRun(st, 2, 2, 'shot', ev);
+ popRun(st, st.paths[0], 2, 2, 'shot', ev);
const boom = ev.find((e) => e.type === 'explosion');
- check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.balls.length === 2);
- check('explosion spares balls beyond radius', st.balls.every((b) => b.color === 3));
+ check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.paths[0].balls.length === 2);
+ check('explosion spares balls beyond radius', st.paths[0].balls.every((b) => b.color === 3));
}
// ── 7. State machine ─────────────────────────────────────────────────────────
console.log('\n— State machine —');
{
let st = mkState({}, { status: 'playing', spawned: 10 });
- mkChain(st, [{ color: 0, s: st.path.length - 20 }]);
+ mkChain(st, [{ color: 0, s: st.paths[0].path.length - 20 }]);
let lostEv = false;
for (let i = 0; i < 10 && st.status === 'playing'; i++) {
if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true;
@@ -365,7 +396,7 @@ console.log('\n— State machine —');
check('ball reaching the hole loses', st.status === 'lost' && lostEv);
check('terminal state ignores further steps', step(st, 50).length === 0);
- st = mkState({}, { status: 'playing', spawned: 10, balls: [], flights: [] });
+ st = mkState({}, { status: 'playing', spawned: 10, balls: [] });
const ev = step(st, 16);
const won = ev.find((e) => e.type === 'won');
const expectBonus = Math.max(0, Math.ceil((10 * T.TIME_PAR_MS_PER_BALL - st.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
@@ -389,16 +420,16 @@ console.log('\n— State machine —');
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 2, s: 504 }, { color: 2, s: 456 }]);
st.current = 0; st.next = 0;
const ev2 = [];
- popRun(st, 0, 1, 'shot', ev2);
+ popRun(st, st.paths[0], 0, 1, 'shot', ev2);
check('shooter recolors to colors still present', st.current === 2 && st.next === 2
&& ev2.filter((e) => e.type === 'recolor').length === 2);
// last-call: final spawns only deal colors still on the board
st = mkChain(mkState({}, { status: 'playing', spawned: 5 }),
[{ color: 3, s: 2 * SP }, { color: 3, s: SP }]);
- for (let i = 0; i < 400 && st.spawned < 10; i++) step(st, 50);
+ for (let i = 0; i < 400 && st.paths[0].spawned < 10; i++) step(st, 50);
check('last-call spawns restrict to present colors',
- st.spawned === 10 && st.balls.every((b) => b.color === 3));
+ st.paths[0].spawned === 10 && st.paths[0].balls.every((b) => b.color === 3));
}
// ── 8. Determinism ───────────────────────────────────────────────────────────
@@ -414,7 +445,10 @@ console.log('\n— Determinism —');
if (tick === 300) fireBall(st, -Math.PI / 2);
for (const e of step(st, 25)) log.push(e.type + (e.ids ? `:${e.ids.length}` : ''));
}
- return { log: log.join(','), score: st.score, status: st.status, balls: st.balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|') };
+ return {
+ log: log.join(','), score: st.score, status: st.status,
+ balls: st.paths[0].balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|'),
+ };
};
const a = run(), b = run();
check('identical seed + script → identical events', a.log === b.log);
@@ -478,18 +512,18 @@ console.log('\n— Tunnels —');
let st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
- shootAt(st, st.balls[0]);
- check('a shot flies over a submerged ball', runFlights(st) && st.balls.length === 1);
+ shootAt(st, st.paths[0].balls[0]);
+ check('a shot flies over a submerged ball', runFlights(st) && st.paths[0].balls.length === 1);
st = mkState({ pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
- shootAt(st, st.balls[0]);
- check('the same shot lands once the tunnel is gone', !runFlights(st) && st.balls.length === 2);
+ shootAt(st, st.paths[0].balls[0]);
+ check('the same shot lands once the tunnel is gone', !runFlights(st) && st.paths[0].balls.length === 2);
st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
step(st, 16);
- const ball = st.balls[0];
+ const ball = st.paths[0].balls[0];
check('a submerged ball is flagged hidden and invisible', ball.hidden === true && ball.vis === 0);
check('the laser sight ignores submerged chain',
!rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x)).hit);
@@ -502,20 +536,21 @@ console.log('\n— Tunnels —');
]);
step(st, 16);
const ev = [];
- popRun(st, 2, 2, 'shot', ev);
+ popRun(st, st.paths[0], 2, 2, 'shot', ev);
const boom = ev.find((e) => e.type === 'explosion');
check('an explosion cannot reach through a tunnel',
- !!boom && boom.ids.length === 2 && st.balls.length === 2,
+ !!boom && boom.ids.length === 2 && st.paths[0].balls.length === 2,
`caught ${boom?.ids.length}`);
- check('the submerged pair survives the blast', st.balls.every((b) => b.color === 2));
+ check('the submerged pair survives the blast', st.paths[0].balls.every((b) => b.color === 2));
// and the chain itself notices nothing
st = mkState({ tunnels: [[400, 700]] }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 300 }, { color: 1, s: 300 - SP }, { color: 2, s: 300 - 2 * SP }]);
for (let i = 0; i < 40; i++) step(st, 50); // 2s at pushSpeed 100
check('the chain rolls straight on through a tunnel',
- near(st.balls[0].s, 500, 1) && spacingOk(st.balls), `front at ${st.balls[0].s.toFixed(1)}`);
- check('every ball inside is submerged', st.balls.every((b) => b.hidden === (b.s > 400)));
+ near(st.paths[0].balls[0].s, 500, 1) && spacingOk(st.paths[0].balls),
+ `front at ${st.paths[0].balls[0].s.toFixed(1)}`);
+ check('every ball inside is submerged', st.paths[0].balls.every((b) => b.hidden === (b.s > 400)));
// ── lint ──
// A 4000px straight: TUNNEL.MIN_LEN plus the mouth clearances no longer fit
@@ -542,16 +577,106 @@ console.log('\n— Tunnels —');
TUNNEL.MIN_LEN >= 2 * PORTAL_REACH);
}
-// ── 10. Level bank lint ──────────────────────────────────────────────────────
+// ── 10. Multi-path ───────────────────────────────────────────────────────────
+console.log('\n— Multi-path —');
+{
+ // Two independent straight lanes, well apart on screen, frog centred between
+ // them — the "opposite sides" layout the feature is built around.
+ const LANE_A = [[0, 200], [400, 200], [800, 200], [1200, 200], [1600, 200]];
+ const LANE_B = [[0, 800], [400, 800], [800, 800], [1200, 800], [1600, 800]];
+ const twoLanes = (pushSpeed = 100) => ({
+ level: 1, name: 'Two Lanes', frog: [800, 500], colors: 4, seed: 99,
+ starScores: [100, 200, 300],
+ paths: [
+ { shape: 'line', points: LANE_A, tunnels: [], quota: 10, introBalls: 2, pushSpeed, powerUpRate: 0 },
+ { shape: 'line', points: LANE_B, tunnels: [], quota: 10, introBalls: 2, pushSpeed, powerUpRate: 0 },
+ ],
+ });
+ // freeze both spawners at their quota so a hand-built chain fixture isn't
+ // added to mid-test — the same spawned:quota convention the single-path
+ // fixtures use, just applied to both paths.
+ const freeze = (st) => { for (const p of st.paths) p.spawned = p.quota; };
+
+ // independent spawners: both paths reach their own quota on their own clock
+ let st = createLevel(twoLanes(100));
+ for (let i = 0; i < 4000 && (st.paths[0].spawned < 10 || st.paths[1].spawned < 10); i++) step(st, 16);
+ check('each path spawns to its own quota independently',
+ st.paths[0].spawned === 10 && st.paths[1].spawned === 10
+ && st.paths[0].balls.length === 10 && st.paths[1].balls.length === 10);
+
+ // shooter color pool unions across paths: path A all color 0, path B all
+ // color 1 — current/next must be reassignable to whatever is on EITHER lane
+ st = createLevel(twoLanes());
+ freeze(st);
+ mkChain(st, [{ color: 0, s: 300 }, { color: 0, s: 300 - SP }], 0);
+ mkChain(st, [{ color: 1, s: 300 }, { color: 1, s: 300 - SP }], 1);
+ st.status = 'playing';
+ st.current = 2; st.next = 3; // neither color is present anywhere
+ popRun(st, st.paths[0], 0, 0, 'shot', []);
+ check('shooter recolors from the union of both paths\' present colors',
+ (st.current === 0 || st.current === 1) && (st.next === 0 || st.next === 1));
+
+ // a shot lands on whichever path it is physically aimed at — no special
+ // targeting, purely the nearest ball to the ray
+ st = createLevel(twoLanes(0));
+ freeze(st);
+ mkChain(st, [{ color: 0, s: 600 }], 0);
+ mkChain(st, [{ color: 1, s: 600 }], 1);
+ st.status = 'playing';
+ const ballB = st.paths[1].balls[0];
+ st.current = 1;
+ fireBall(st, Math.atan2(ballB.y - st.frog.y, ballB.x - st.frog.x));
+ for (let i = 0; i < 200 && st.flights.length; i++) step(st, 16);
+ check('a shot lands on the path it is aimed at, not always path 0',
+ st.paths[1].balls.length === 2 && st.paths[0].balls.length === 1);
+
+ // either path (not just path 0) reaching its own pit loses the whole level
+ st = createLevel(twoLanes(100));
+ freeze(st);
+ mkChain(st, [{ color: 1, s: st.paths[1].path.length - 20 }], 1);
+ mkChain(st, [{ color: 0, s: 400 }], 0);
+ st.status = 'playing';
+ let lostEv = false;
+ for (let i = 0; i < 10 && st.status === 'playing'; i++) {
+ if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true;
+ }
+ check('either path reaching the hole loses the whole level', st.status === 'lost' && lostEv);
+
+ // winning requires EVERY path cleared, not just one
+ st = createLevel(twoLanes());
+ freeze(st);
+ st.status = 'playing';
+ mkChain(st, [{ color: 0, s: 400 }], 1);
+ let won = step(st, 16).some((e) => e.type === 'won');
+ check('one path cleared but not the other does not win', !won && st.status === 'playing');
+ st.paths[1].balls = [];
+ won = step(st, 16).some((e) => e.type === 'won');
+ check('every path cleared wins', won && st.status === 'won');
+}
+
+// ── 11. Level bank lint ──────────────────────────────────────────────────────
console.log('\n— Level bank —');
{
- let bank = null;
+ const bankDir = join(__dirname, '../assets/gamedata/zuma');
+ let manifest = null;
try {
- bank = JSON.parse(readFileSync(join(__dirname, '../data/zuma.json'), 'utf8'));
+ manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8'));
} catch (_) { /* handled below */ }
- check('bank exists (run genZuma.js)', !!bank);
- if (bank) {
- const levels = bank.levels ?? [];
+ check('manifest exists (run genZuma.js)', !!manifest);
+ let levels = [];
+ let manifestMismatch = '';
+ if (manifest) {
+ levels = (manifest.levels ?? []).map((entry) => {
+ const def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8'));
+ if (!manifestMismatch && (entry.level !== def.level || entry.name !== def.name)) {
+ manifestMismatch = `${entry.file}: manifest says L${entry.level} "${entry.name}", `
+ + `file says L${def.level} "${def.name}"`;
+ }
+ return def;
+ });
+ }
+ check('every manifest entry matches its level file', !manifestMismatch, manifestMismatch);
+ if (manifest) {
check('bank has 20 levels', levels.length === 20);
check('levels numbered 1..N contiguously', levels.every((l, i) => l.level === i + 1));
// Geometry and parameters both come from ZumaLogic — the same lint
@@ -567,11 +692,12 @@ console.log('\n— Level bank —');
check(`geometry lint (length, bounds, curvature ≥ ${(T.BALL_RADIUS * 1.7).toFixed(0)}px, frog clear ≥ ${T.FROG_CLEARANCE}px)`,
geomOk, geomDetail);
- const tunnelled = levels.filter((l) => (l.tunnels?.length ?? 0) > 0);
+ const tunnelCount = (l) => l.paths.reduce((a, p) => a + (p.tunnels?.length ?? 0), 0);
+ const tunnelled = levels.filter((l) => tunnelCount(l) > 0);
console.log(` .. ${tunnelled.length} of ${levels.length} levels carry tunnels`
- + ` (${tunnelled.map((l) => `L${l.level}×${l.tunnels.length}`).join(' ')})`);
+ + ` (${tunnelled.map((l) => `L${l.level}×${tunnelCount(l)}`).join(' ')})`);
- // ── 11. Winnability soak ─────────────────────────────────────────────────
+ // ── 12. Winnability soak ─────────────────────────────────────────────────
// If a bot that only ever takes the best immediately available shot can
// clear a level, a player can. This is what catches a quota the path can
// technically hold but nobody could actually survive.
@@ -582,6 +708,7 @@ console.log('\n— Level bank —');
console.log('\n— Aimbot soak —');
const SEEDS = 8;
const FLOOR = 5; // per-level clears required out of SEEDS
+ const totalQuota = (l) => l.paths.reduce((a, p) => a + p.quota, 0);
const rows = levels.map((l) => {
const runs = [];
for (let k = 0; k < SEEDS; k++) runs.push(aimbotPlay(l, l.seed + k * 7919));
@@ -601,7 +728,7 @@ console.log('\n— Level bank —');
console.log(` .. aimbot clear rate: ${(rate * 100).toFixed(0)}% (worst level ${Math.min(...rows.map((r) => r.wins))}/${SEEDS})`);
check('bank-wide clear rate ≥ 80%', rate >= 0.8, `${(rate * 100).toFixed(0)}%`);
- const perMarble = rows.reduce((a, r, i) => a + r.mean / levels[i].quota, 0) / rows.length;
+ const perMarble = rows.reduce((a, r, i) => a + r.mean / totalQuota(levels[i]), 0) / rows.length;
console.log(` .. mean score per quota marble: ${perMarble.toFixed(1)}`);
// Medals are judged on the bot's MEAN run, not its best: best-of-N is a
// measure of chain luck, and the question here is where the star curve