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