Zuma Level Refactor

This commit is contained in:
Brian Fertig 2026-07-28 21:33:36 -06:00
parent eabeef3165
commit dbc1c64d51
28 changed files with 6441 additions and 6141 deletions

View File

@ -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"
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -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 = ['<option value="">— pick a banked level —</option>',
...this.bank.map((l) => `<option value="${l.level}">L${l.level} · ${l.name}</option>`)].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) => `<option value="${m.file}">L${m.level} · ${m.name}</option>`)].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) {

View File

@ -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);

View File

@ -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);

View File

@ -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');

View File

@ -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}`);

View File

@ -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