diff --git a/assets/images/zuma/background-01.png b/assets/images/zuma/background-01.png new file mode 100644 index 0000000..006e756 Binary files /dev/null and b/assets/images/zuma/background-01.png differ diff --git a/assets/images/zuma/background-02.png b/assets/images/zuma/background-02.png new file mode 100644 index 0000000..e79ef1a Binary files /dev/null and b/assets/images/zuma/background-02.png differ diff --git a/data/zuma.json b/data/zuma.json index 3700a57..c8bd01d 100644 --- a/data/zuma.json +++ b/data/zuma.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-12T06:00:46.686Z", + "generatedAt": "2026-07-28T04:37:09.319Z", "count": 20, "levels": [ { @@ -9,59 +9,375 @@ "points": [ [ -80, - 300 + 140 ], [ - 240, - 220 + -11, + 140 ], [ - 560, - 300 + 57, + 140 ], [ - 860, - 460 + 126, + 140 ], [ - 1120, - 640 + 195, + 140 ], [ - 1400, - 760 + 263, + 140 ], [ - 1660, - 700 + 332, + 140 ], [ - 1790, - 520 + 401, + 140 ], [ - 1700, - 330 + 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, - 260 + 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": [ - 960, - 920 + 880, + 770 ], "colors": 4, - "quota": 28, - "introBalls": 10, - "pushSpeed": 22, + "quota": 22, + "introBalls": 7, + "pushSpeed": 25, "powerUpRate": 0.07, "seed": 8919, "starScores": [ - 550, - 820, - 1090 + 590, + 880, + 1170 ] }, { @@ -71,63 +387,151 @@ "points": [ [ -80, - 1000 + 130 ], [ - 160, - 900 - ], - [ - 170, - 650 - ], - [ - 300, - 380 - ], - [ - 560, - 190 + 60, + 130 ], [ 960, 130 ], [ - 1360, - 190 + 1191, + 154 ], [ - 1620, - 380 + 1398, + 210 ], [ - 1750, - 650 + 1565, + 294 ], [ - 1700, + 1680, + 399 + ], + [ + 1734, + 514 + ], + [ + 1727, + 632 + ], + [ + 1659, + 742 + ], + [ + 1538, + 836 + ], + [ + 1375, + 908 + ], + [ + 1185, + 951 + ], + [ + 983, + 964 + ], + [ + 785, + 946 + ], + [ + 607, 900 ], [ - 1520, - 990 + 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, - 620 + 580 ], "colors": 4, - "quota": 32, - "introBalls": 10, - "pushSpeed": 24, + "quota": 26, + "introBalls": 8, + "pushSpeed": 32, "powerUpRate": 0.07, "seed": 16838, "starScores": [ - 640, - 960, - 1280 + 710, + 1060, + 1410 ] }, { @@ -137,159 +541,391 @@ "points": [ [ -80, - 180 - ], - [ - 200, - 255 - ], - [ - 540, - 230 - ], - [ - 686, - 259 - ], - [ - 808, - 339 - ], - [ - 882, - 458 - ], - [ - 898, - 594 - ], - [ - 852, - 725 - ], - [ - 752, - 827 - ], - [ - 615, - 883 - ], - [ - 465, - 883 - ], - [ - 328, - 827 - ], - [ - 228, - 725 - ], - [ - 182, - 594 - ], - [ - 198, - 458 - ], - [ - 300, - 210 - ], - [ - 700, - 120 - ], - [ - 1380, - 230 - ], - [ - 1526, - 259 - ], - [ - 1648, - 339 - ], - [ - 1722, - 458 - ], - [ - 1738, - 594 - ], - [ - 1692, - 725 - ], - [ - 1592, - 827 - ], - [ - 1455, - 883 - ], - [ - 1305, - 883 - ], - [ - 1168, - 827 - ], - [ - 1068, - 725 - ], - [ - 1022, - 594 - ], - [ - 1038, - 458 - ], - [ - 1090, - 330 - ], - [ - 1200, - 260 - ], - [ - 1330, 300 ], [ - 1390, - 420 + -10, + 300 ], [ - 1330, - 520 + 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": [ - 540, - 560 + 620, + 600 ], "colors": 4, - "quota": 36, - "introBalls": 10, - "pushSpeed": 26, + "quota": 30, + "introBalls": 9, + "pushSpeed": 35, "powerUpRate": 0.065, "seed": 24757, "starScores": [ - 740, - 1110, - 1480 + 830, + 1240, + 1650 ] }, { @@ -299,99 +935,355 @@ "points": [ [ -80, - 190 + 130 ], [ - 300, - 190 + -11, + 130 ], [ - 800, - 190 + 58, + 130 ], [ - 1300, - 190 + 126, + 130 ], [ - 1560, - 190 + 195, + 130 ], [ - 1720, - 235 + 264, + 130 ], [ - 1785, - 355 + 333, + 130 ], [ - 1720, - 475 + 401, + 130 ], [ - 1560, - 520 + 470, + 130 ], [ - 1100, - 520 + 539, + 130 ], [ - 600, - 520 + 608, + 130 ], [ - 360, - 520 + 676, + 130 ], [ - 200, - 565 + 745, + 130 ], [ - 135, - 685 + 814, + 130 ], [ - 200, - 805 + 882, + 130 ], [ - 360, - 850 + 951, + 130 ], [ - 900, - 850 + 1020, + 130 ], [ - 1400, - 850 + 1089, + 130 ], [ - 1640, - 880 + 1158, + 130 ], [ - 1750, - 960 + 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": [ - 960, - 685 + 1620, + 550 ], "colors": 4, - "quota": 40, - "introBalls": 12, - "pushSpeed": 26, + "quota": 32, + "introBalls": 10, + "pushSpeed": 35, "powerUpRate": 0.065, "seed": 32676, "starScores": [ - 820, - 1230, - 1640 + 880, + 1320, + 1760 ] }, { @@ -401,163 +1293,207 @@ "points": [ [ -80, - 580 + 120 ], [ - 140, - 580 + 58, + 120 ], [ - 215, - 420 + 960, + 120 ], [ - 397, - 290 + 1195, + 143 ], [ - 654, - 207 + 1406, + 201 ], [ - 946, - 182 + 1577, + 287 ], [ - 1228, - 217 + 1694, + 395 ], [ - 1460, - 303 + 1749, + 514 ], [ - 1609, - 426 + 1739, + 635 ], [ - 1656, - 567 + 1667, + 749 ], [ - 1600, - 703 + 1541, + 846 ], [ - 1452, - 815 + 1370, + 919 ], [ - 1239, - 888 + 1171, + 962 ], [ - 995, - 912 + 960, + 973 ], [ - 758, - 887 + 754, + 952 ], [ - 561, - 818 + 571, + 901 ], [ - 433, - 718 + 423, + 826 ], [ - 389, - 602 + 322, + 732 ], [ - 430, - 490 + 276, + 628 ], [ - 546, - 396 + 285, + 523 ], [ - 717, - 335 + 349, + 425 ], [ - 913, - 313 + 459, + 341 ], [ - 1104, - 331 + 607, + 279 ], [ - 1263, - 385 + 779, + 243 ], [ - 1368, - 463 + 960, + 234 ], [ - 1407, - 554 + 1136, + 253 ], [ - 1378, - 642 + 1292, + 297 ], [ - 1289, - 716 + 1417, + 362 ], [ - 1159, - 764 + 1502, + 442 ], [ - 1010, - 782 + 1540, + 530 ], [ - 865, - 769 + 1530, + 619 ], [ - 745, - 729 + 1475, + 702 ], [ - 666, - 671 + 1381, + 771 ], [ - 637, - 605 + 1256, + 823 ], [ - 658, - 541 + 1111, + 853 + ], + [ + 960, + 859 + ], + [ + 814, + 843 + ], + [ + 685, + 806 + ], + [ + 583, + 751 + ], + [ + 515, + 685 + ], + [ + 485, + 612 + ], + [ + 494, + 539 + ], + [ + 541, + 472 + ], + [ + 619, + 416 ], [ 721, - 489 + 375 + ], + [ + 838, + 352 + ], + [ + 960, + 348 ] ], "frog": [ 960, - 580 + 575 ], "colors": 4, - "quota": 46, - "introBalls": 12, - "pushSpeed": 28, + "quota": 38, + "introBalls": 11, + "pushSpeed": 37, "powerUpRate": 0.06, "seed": 40595, "starScores": [ - 970, - 1450, - 1930 + 1060, + 1580, + 2110 ] }, { @@ -567,207 +1503,223 @@ "points": [ [ -80, - 940 + 830 ], [ 60, - 750 + 690 ], [ - 140, - 560 + 130, + 505 ], [ - 147, - 449 + 136, + 416 ], [ - 168, - 346 + 154, + 333 ], [ - 202, - 257 + 183, + 259 ], [ - 249, - 189 + 223, + 199 ], [ - 308, - 145 + 274, + 156 ], [ - 378, - 130 + 334, + 134 ], [ - 458, - 144 + 404, + 132 ], [ - 547, - 186 + 481, + 152 ], [ - 642, - 253 + 565, + 191 ], [ - 743, - 341 + 655, + 249 ], [ - 848, - 443 + 749, + 321 ], [ - 954, - 554 + 846, + 403 ], [ - 1061, + 945, + 491 + ], + [ + 1044, + 581 + ], + [ + 1142, 665 ], [ - 1166, - 769 + 1237, + 741 ], [ - 1267, - 859 + 1328, + 803 ], [ - 1363, - 928 - ], - [ - 1453, - 973 - ], - [ - 1534, - 990 - ], - [ - 1605, - 978 - ], - [ - 1665, - 937 - ], - [ - 1714, - 872 - ], - [ - 1749, - 785 - ], - [ - 1771, - 683 - ], - [ - 1780, - 572 - ], - [ - 1774, - 461 - ], - [ - 1755, - 357 - ], - [ - 1723, - 266 - ], - [ - 1677, - 195 - ], - [ - 1619, - 149 - ], - [ - 1550, - 130 - ], - [ - 1471, - 141 - ], - [ - 1384, - 180 - ], - [ - 1289, - 244 - ], - [ - 1188, - 330 - ], - [ - 1084, - 431 - ], - [ - 978, - 541 - ], - [ - 871, - 653 - ], - [ - 766, - 758 - ], - [ - 664, + 1414, 849 ], [ - 567, - 922 + 1494, + 874 ], [ - 477, - 969 + 1566, + 879 ], [ - 395, - 989 + 1629, + 863 ], [ - 323, - 981 + 1683, + 826 ], [ - 261, - 943 + 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": [ - 550, - 560 + 960, + 990 ], "colors": 4, - "quota": 42, - "introBalls": 12, - "pushSpeed": 28, + "quota": 34, + "introBalls": 10, + "pushSpeed": 37, "powerUpRate": 0.06, "seed": 48514, "starScores": [ - 880, - 1320, - 1760 + 940, + 1410, + 1880 ] }, { @@ -777,59 +1729,375 @@ "points": [ [ -80, - 300 + 140 ], [ - 240, - 220 + -11, + 140 ], [ - 560, - 300 + 57, + 140 ], [ - 860, - 460 + 126, + 140 ], [ - 1120, - 640 + 195, + 140 ], [ - 1400, - 760 + 263, + 140 ], [ - 1660, - 700 + 332, + 140 ], [ - 1790, - 520 + 401, + 140 ], [ - 1700, - 330 + 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, - 260 + 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": [ - 960, - 920 + 880, + 770 ], "colors": 4, - "quota": 30, - "introBalls": 10, - "pushSpeed": 34, + "quota": 26, + "introBalls": 8, + "pushSpeed": 45, "powerUpRate": 0.06, "seed": 56433, "starScores": [ - 680, - 1010, - 1350 + 740, + 1110, + 1480 ] }, { @@ -839,63 +2107,151 @@ "points": [ [ -80, - 1000 + 130 ], [ - 160, - 900 - ], - [ - 170, - 650 - ], - [ - 300, - 380 - ], - [ - 560, - 190 + 60, + 130 ], [ 960, 130 ], [ - 1360, - 190 + 1191, + 154 ], [ - 1620, - 380 + 1398, + 210 ], [ - 1750, - 650 + 1565, + 294 ], [ - 1700, + 1680, + 399 + ], + [ + 1734, + 514 + ], + [ + 1727, + 632 + ], + [ + 1659, + 742 + ], + [ + 1538, + 836 + ], + [ + 1375, + 908 + ], + [ + 1185, + 951 + ], + [ + 983, + 964 + ], + [ + 785, + 946 + ], + [ + 607, 900 ], [ - 1520, - 990 + 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, - 620 + 580 ], "colors": 5, - "quota": 36, - "introBalls": 10, - "pushSpeed": 30, + "quota": 30, + "introBalls": 9, + "pushSpeed": 40, "powerUpRate": 0.055, "seed": 64352, "starScores": [ - 780, - 1160, - 1550 + 840, + 1260, + 1680 ] }, { @@ -905,99 +2261,355 @@ "points": [ [ -80, - 190 + 130 ], [ - 300, - 190 + -11, + 130 ], [ - 800, - 190 + 58, + 130 ], [ - 1300, - 190 + 126, + 130 ], [ - 1560, - 190 + 195, + 130 ], [ - 1720, - 235 + 264, + 130 ], [ - 1785, - 355 + 333, + 130 ], [ - 1720, - 475 + 401, + 130 ], [ - 1560, - 520 + 470, + 130 ], [ - 1100, - 520 + 539, + 130 ], [ - 600, - 520 + 608, + 130 ], [ - 360, - 520 + 676, + 130 ], [ - 200, - 565 + 745, + 130 ], [ - 135, - 685 + 814, + 130 ], [ - 200, - 805 + 882, + 130 ], [ - 360, - 850 + 951, + 130 ], [ - 900, - 850 + 1020, + 130 ], [ - 1400, - 850 + 1089, + 130 ], [ - 1640, - 880 + 1158, + 130 ], [ - 1750, - 960 + 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": [ - 960, - 685 + 1620, + 550 ], "colors": 5, - "quota": 44, - "introBalls": 12, - "pushSpeed": 30, + "quota": 36, + "introBalls": 11, + "pushSpeed": 40, "powerUpRate": 0.055, "seed": 72271, "starScores": [ - 950, - 1420, - 1890 + 1010, + 1520, + 2020 ] }, { @@ -1007,159 +2619,391 @@ "points": [ [ -80, - 180 - ], - [ - 200, - 255 - ], - [ - 540, - 230 - ], - [ - 686, - 259 - ], - [ - 808, - 339 - ], - [ - 882, - 458 - ], - [ - 898, - 594 - ], - [ - 852, - 725 - ], - [ - 752, - 827 - ], - [ - 615, - 883 - ], - [ - 465, - 883 - ], - [ - 328, - 827 - ], - [ - 228, - 725 - ], - [ - 182, - 594 - ], - [ - 198, - 458 - ], - [ - 300, - 210 - ], - [ - 700, - 120 - ], - [ - 1380, - 230 - ], - [ - 1526, - 259 - ], - [ - 1648, - 339 - ], - [ - 1722, - 458 - ], - [ - 1738, - 594 - ], - [ - 1692, - 725 - ], - [ - 1592, - 827 - ], - [ - 1455, - 883 - ], - [ - 1305, - 883 - ], - [ - 1168, - 827 - ], - [ - 1068, - 725 - ], - [ - 1022, - 594 - ], - [ - 1038, - 458 - ], - [ - 1090, - 330 - ], - [ - 1200, - 260 - ], - [ - 1330, 300 ], [ - 1390, - 420 + -10, + 300 ], [ - 1330, - 520 + 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": [ - 540, - 560 + 620, + 600 ], "colors": 5, - "quota": 42, - "introBalls": 12, - "pushSpeed": 32, + "quota": 34, + "introBalls": 10, + "pushSpeed": 43, "powerUpRate": 0.055, "seed": 80190, "starScores": [ - 930, - 1390, - 1850 + 960, + 1440, + 1920 ] }, { @@ -1169,163 +3013,207 @@ "points": [ [ -80, - 580 + 120 ], [ - 140, - 580 + 58, + 120 ], [ - 215, - 420 + 960, + 120 ], [ - 397, - 290 + 1195, + 143 ], [ - 654, - 207 + 1406, + 201 ], [ - 946, - 182 + 1577, + 287 ], [ - 1228, - 217 + 1694, + 395 ], [ - 1460, - 303 + 1749, + 514 ], [ - 1609, - 426 + 1739, + 635 ], [ - 1656, - 567 + 1667, + 749 ], [ - 1600, - 703 + 1541, + 846 ], [ - 1452, - 815 + 1370, + 919 ], [ - 1239, - 888 + 1171, + 962 ], [ - 995, - 912 + 960, + 973 ], [ - 758, - 887 + 754, + 952 ], [ - 561, - 818 + 571, + 901 ], [ - 433, - 718 + 423, + 826 ], [ - 389, - 602 + 322, + 732 ], [ - 430, - 490 + 276, + 628 ], [ - 546, - 396 + 285, + 523 ], [ - 717, - 335 + 349, + 425 ], [ - 913, - 313 + 459, + 341 ], [ - 1104, - 331 + 607, + 279 ], [ - 1263, - 385 + 779, + 243 ], [ - 1368, - 463 + 960, + 234 ], [ - 1407, - 554 + 1136, + 253 ], [ - 1378, - 642 + 1292, + 297 ], [ - 1289, - 716 + 1417, + 362 ], [ - 1159, - 764 + 1502, + 442 ], [ - 1010, - 782 + 1540, + 530 ], [ - 865, - 769 + 1530, + 619 ], [ - 745, - 729 + 1475, + 702 ], [ - 666, - 671 + 1381, + 771 ], [ - 637, - 605 + 1256, + 823 ], [ - 658, - 541 + 1111, + 853 + ], + [ + 960, + 859 + ], + [ + 814, + 843 + ], + [ + 685, + 806 + ], + [ + 583, + 751 + ], + [ + 515, + 685 + ], + [ + 485, + 612 + ], + [ + 494, + 539 + ], + [ + 541, + 472 + ], + [ + 619, + 416 ], [ 721, - 489 + 375 + ], + [ + 838, + 352 + ], + [ + 960, + 348 ] ], "frog": [ 960, - 580 + 575 ], "colors": 5, - "quota": 52, - "introBalls": 14, - "pushSpeed": 32, + "quota": 42, + "introBalls": 12, + "pushSpeed": 43, "powerUpRate": 0.05, "seed": 88109, "starScores": [ - 1150, - 1720, - 2290 + 1190, + 1790, + 2380 ] }, { @@ -1335,207 +3223,223 @@ "points": [ [ -80, - 940 + 830 ], [ 60, - 750 + 690 ], [ - 140, - 560 + 130, + 505 ], [ - 147, - 449 + 136, + 416 ], [ - 168, - 346 + 154, + 333 ], [ - 202, - 257 + 183, + 259 ], [ - 249, - 189 + 223, + 199 ], [ - 308, - 145 + 274, + 156 ], [ - 378, - 130 + 334, + 134 ], [ - 458, - 144 + 404, + 132 ], [ - 547, - 186 + 481, + 152 ], [ - 642, - 253 + 565, + 191 ], [ - 743, - 341 + 655, + 249 ], [ - 848, - 443 + 749, + 321 ], [ - 954, - 554 + 846, + 403 ], [ - 1061, + 945, + 491 + ], + [ + 1044, + 581 + ], + [ + 1142, 665 ], [ - 1166, - 769 + 1237, + 741 ], [ - 1267, - 859 + 1328, + 803 ], [ - 1363, - 928 - ], - [ - 1453, - 973 - ], - [ - 1534, - 990 - ], - [ - 1605, - 978 - ], - [ - 1665, - 937 - ], - [ - 1714, - 872 - ], - [ - 1749, - 785 - ], - [ - 1771, - 683 - ], - [ - 1780, - 572 - ], - [ - 1774, - 461 - ], - [ - 1755, - 357 - ], - [ - 1723, - 266 - ], - [ - 1677, - 195 - ], - [ - 1619, - 149 - ], - [ - 1550, - 130 - ], - [ - 1471, - 141 - ], - [ - 1384, - 180 - ], - [ - 1289, - 244 - ], - [ - 1188, - 330 - ], - [ - 1084, - 431 - ], - [ - 978, - 541 - ], - [ - 871, - 653 - ], - [ - 766, - 758 - ], - [ - 664, + 1414, 849 ], [ - 567, - 922 + 1494, + 874 ], [ - 477, - 969 + 1566, + 879 ], [ - 395, - 989 + 1629, + 863 ], [ - 323, - 981 + 1683, + 826 ], [ - 261, - 943 + 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": [ - 550, - 560 + 960, + 990 ], "colors": 5, - "quota": 46, - "introBalls": 12, - "pushSpeed": 34, + "quota": 38, + "introBalls": 11, + "pushSpeed": 45, "powerUpRate": 0.05, "seed": 96028, "starScores": [ - 1040, - 1550, - 2070 + 1090, + 1630, + 2170 ] }, { @@ -1545,99 +3449,355 @@ "points": [ [ -80, - 190 + 130 ], [ - 300, - 190 + -11, + 130 ], [ - 800, - 190 + 58, + 130 ], [ - 1300, - 190 + 126, + 130 ], [ - 1560, - 190 + 195, + 130 ], [ - 1720, - 235 + 264, + 130 ], [ - 1785, - 355 + 333, + 130 ], [ - 1720, - 475 + 401, + 130 ], [ - 1560, - 520 + 470, + 130 ], [ - 1100, - 520 + 539, + 130 ], [ - 600, - 520 + 608, + 130 ], [ - 360, - 520 + 676, + 130 ], [ - 200, - 565 + 745, + 130 ], [ - 135, - 685 + 814, + 130 ], [ - 200, - 805 + 882, + 130 ], [ - 360, - 850 + 951, + 130 ], [ - 900, - 850 + 1020, + 130 ], [ - 1400, - 850 + 1089, + 130 ], [ - 1640, - 880 + 1158, + 130 ], [ - 1750, - 960 + 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": [ - 960, - 685 + 1620, + 550 ], "colors": 5, - "quota": 50, - "introBalls": 14, - "pushSpeed": 36, + "quota": 34, + "introBalls": 10, + "pushSpeed": 48, "powerUpRate": 0.05, "seed": 103947, "starScores": [ - 1150, - 1730, - 2300 + 980, + 1470, + 1960 ] }, { @@ -1647,163 +3807,207 @@ "points": [ [ -80, - 580 + 120 ], [ - 140, - 580 + 58, + 120 ], [ - 215, - 420 + 960, + 120 ], [ - 397, - 290 + 1195, + 143 ], [ - 654, - 207 + 1406, + 201 ], [ - 946, - 182 + 1577, + 287 ], [ - 1228, - 217 + 1694, + 395 ], [ - 1460, - 303 + 1749, + 514 ], [ - 1609, - 426 + 1739, + 635 ], [ - 1656, - 567 + 1667, + 749 ], [ - 1600, - 703 + 1541, + 846 ], [ - 1452, - 815 + 1370, + 919 ], [ - 1239, - 888 + 1171, + 962 ], [ - 995, - 912 + 960, + 973 ], [ - 758, - 887 + 754, + 952 ], [ - 561, - 818 + 571, + 901 ], [ - 433, - 718 + 423, + 826 ], [ - 389, - 602 + 322, + 732 ], [ - 430, - 490 + 276, + 628 ], [ - 546, - 396 + 285, + 523 ], [ - 717, - 335 + 349, + 425 ], [ - 913, - 313 + 459, + 341 ], [ - 1104, - 331 + 607, + 279 ], [ - 1263, - 385 + 779, + 243 ], [ - 1368, - 463 + 960, + 234 ], [ - 1407, - 554 + 1136, + 253 ], [ - 1378, - 642 + 1292, + 297 ], [ - 1289, - 716 + 1417, + 362 ], [ - 1159, - 764 + 1502, + 442 ], [ - 1010, - 782 + 1540, + 530 ], [ - 865, - 769 + 1530, + 619 ], [ - 745, - 729 + 1475, + 702 ], [ - 666, - 671 + 1381, + 771 ], [ - 637, - 605 + 1256, + 823 ], [ - 658, - 541 + 1111, + 853 + ], + [ + 960, + 859 + ], + [ + 814, + 843 + ], + [ + 685, + 806 + ], + [ + 583, + 751 + ], + [ + 515, + 685 + ], + [ + 485, + 612 + ], + [ + 494, + 539 + ], + [ + 541, + 472 + ], + [ + 619, + 416 ], [ 721, - 489 + 375 + ], + [ + 838, + 352 + ], + [ + 960, + 348 ] ], "frog": [ 960, - 580 + 575 ], "colors": 5, - "quota": 58, - "introBalls": 14, - "pushSpeed": 36, + "quota": 46, + "introBalls": 13, + "pushSpeed": 48, "powerUpRate": 0.05, "seed": 111866, "starScores": [ - 1340, - 2000, - 2670 + 1330, + 1990, + 2650 ] }, { @@ -1813,63 +4017,151 @@ "points": [ [ -80, - 1000 + 130 ], [ - 160, - 900 - ], - [ - 170, - 650 - ], - [ - 300, - 380 - ], - [ - 560, - 190 + 60, + 130 ], [ 960, 130 ], [ - 1360, - 190 + 1191, + 154 ], [ - 1620, - 380 + 1398, + 210 ], [ - 1750, - 650 + 1565, + 294 ], [ - 1700, + 1680, + 399 + ], + [ + 1734, + 514 + ], + [ + 1727, + 632 + ], + [ + 1659, + 742 + ], + [ + 1538, + 836 + ], + [ + 1375, + 908 + ], + [ + 1185, + 951 + ], + [ + 983, + 964 + ], + [ + 785, + 946 + ], + [ + 607, 900 ], [ - 1520, - 990 + 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, - 620 + 580 ], "colors": 6, - "quota": 38, - "introBalls": 10, - "pushSpeed": 38, + "quota": 32, + "introBalls": 9, + "pushSpeed": 51, "powerUpRate": 0.05, "seed": 119785, "starScores": [ - 900, - 1340, - 1790 + 930, + 1400, + 1860 ] }, { @@ -1879,159 +4171,391 @@ "points": [ [ -80, - 180 - ], - [ - 200, - 255 - ], - [ - 540, - 230 - ], - [ - 686, - 259 - ], - [ - 808, - 339 - ], - [ - 882, - 458 - ], - [ - 898, - 594 - ], - [ - 852, - 725 - ], - [ - 752, - 827 - ], - [ - 615, - 883 - ], - [ - 465, - 883 - ], - [ - 328, - 827 - ], - [ - 228, - 725 - ], - [ - 182, - 594 - ], - [ - 198, - 458 - ], - [ - 300, - 210 - ], - [ - 700, - 120 - ], - [ - 1380, - 230 - ], - [ - 1526, - 259 - ], - [ - 1648, - 339 - ], - [ - 1722, - 458 - ], - [ - 1738, - 594 - ], - [ - 1692, - 725 - ], - [ - 1592, - 827 - ], - [ - 1455, - 883 - ], - [ - 1305, - 883 - ], - [ - 1168, - 827 - ], - [ - 1068, - 725 - ], - [ - 1022, - 594 - ], - [ - 1038, - 458 - ], - [ - 1090, - 330 - ], - [ - 1200, - 260 - ], - [ - 1330, 300 ], [ - 1390, - 420 + -10, + 300 ], [ - 1330, - 520 + 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": [ - 540, - 560 + 620, + 600 ], "colors": 6, - "quota": 46, - "introBalls": 12, - "pushSpeed": 40, + "quota": 38, + "introBalls": 11, + "pushSpeed": 53, "powerUpRate": 0.05, "seed": 127704, "starScores": [ - 1110, - 1660, - 2210 + 1120, + 1670, + 2230 ] }, { @@ -2041,99 +4565,355 @@ "points": [ [ -80, - 190 + 130 ], [ - 300, - 190 + -11, + 130 ], [ - 800, - 190 + 58, + 130 ], [ - 1300, - 190 + 126, + 130 ], [ - 1560, - 190 + 195, + 130 ], [ - 1720, - 235 + 264, + 130 ], [ - 1785, - 355 + 333, + 130 ], [ - 1720, - 475 + 401, + 130 ], [ - 1560, - 520 + 470, + 130 ], [ - 1100, - 520 + 539, + 130 ], [ - 600, - 520 + 608, + 130 ], [ - 360, - 520 + 676, + 130 ], [ - 200, - 565 + 745, + 130 ], [ - 135, - 685 + 814, + 130 ], [ - 200, - 805 + 882, + 130 ], [ - 360, - 850 + 951, + 130 ], [ - 900, - 850 + 1020, + 130 ], [ - 1400, - 850 + 1089, + 130 ], [ - 1640, - 880 + 1158, + 130 ], [ - 1750, - 960 + 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": [ - 960, - 685 + 1620, + 550 ], "colors": 6, - "quota": 54, - "introBalls": 14, - "pushSpeed": 42, + "quota": 34, + "introBalls": 10, + "pushSpeed": 50, "powerUpRate": 0.045, "seed": 135623, "starScores": [ - 1330, - 1990, - 2650 + 990, + 1480, + 1970 ] }, { @@ -2143,207 +4923,223 @@ "points": [ [ -80, - 940 + 830 ], [ 60, - 750 + 690 ], [ - 140, - 560 + 130, + 505 ], [ - 147, - 449 + 136, + 416 ], [ - 168, - 346 + 154, + 333 ], [ - 202, - 257 + 183, + 259 ], [ - 249, - 189 + 223, + 199 ], [ - 308, - 145 + 274, + 156 ], [ - 378, - 130 + 334, + 134 ], [ - 458, - 144 + 404, + 132 ], [ - 547, - 186 + 481, + 152 ], [ - 642, - 253 + 565, + 191 ], [ - 743, - 341 + 655, + 249 ], [ - 848, - 443 + 749, + 321 ], [ - 954, - 554 + 846, + 403 ], [ - 1061, + 945, + 491 + ], + [ + 1044, + 581 + ], + [ + 1142, 665 ], [ - 1166, - 769 + 1237, + 741 ], [ - 1267, - 859 + 1328, + 803 ], [ - 1363, - 928 - ], - [ - 1453, - 973 - ], - [ - 1534, - 990 - ], - [ - 1605, - 978 - ], - [ - 1665, - 937 - ], - [ - 1714, - 872 - ], - [ - 1749, - 785 - ], - [ - 1771, - 683 - ], - [ - 1780, - 572 - ], - [ - 1774, - 461 - ], - [ - 1755, - 357 - ], - [ - 1723, - 266 - ], - [ - 1677, - 195 - ], - [ - 1619, - 149 - ], - [ - 1550, - 130 - ], - [ - 1471, - 141 - ], - [ - 1384, - 180 - ], - [ - 1289, - 244 - ], - [ - 1188, - 330 - ], - [ - 1084, - 431 - ], - [ - 978, - 541 - ], - [ - 871, - 653 - ], - [ - 766, - 758 - ], - [ - 664, + 1414, 849 ], [ - 567, - 922 + 1494, + 874 ], [ - 477, - 969 + 1566, + 879 ], [ - 395, - 989 + 1629, + 863 ], [ - 323, - 981 + 1683, + 826 ], [ - 261, - 943 + 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": [ - 550, - 560 + 960, + 990 ], "colors": 6, - "quota": 50, - "introBalls": 12, - "pushSpeed": 44, + "quota": 40, + "introBalls": 11, + "pushSpeed": 50, "powerUpRate": 0.045, "seed": 143542, "starScores": [ - 1250, - 1880, - 2500 + 1160, + 1740, + 2320 ] }, { @@ -2353,163 +5149,207 @@ "points": [ [ -80, - 580 + 120 ], [ - 140, - 580 + 58, + 120 ], [ - 215, - 420 + 960, + 120 ], [ - 397, - 290 + 1195, + 143 ], [ - 654, - 207 + 1406, + 201 ], [ - 946, - 182 + 1577, + 287 ], [ - 1228, - 217 + 1694, + 395 ], [ - 1460, - 303 + 1749, + 514 ], [ - 1609, - 426 + 1739, + 635 ], [ - 1656, - 567 + 1667, + 749 ], [ - 1600, - 703 + 1541, + 846 ], [ - 1452, - 815 + 1370, + 919 ], [ - 1239, - 888 + 1171, + 962 ], [ - 995, - 912 + 960, + 973 ], [ - 758, - 887 + 754, + 952 ], [ - 561, - 818 + 571, + 901 ], [ - 433, - 718 + 423, + 826 ], [ - 389, - 602 + 322, + 732 ], [ - 430, - 490 + 276, + 628 ], [ - 546, - 396 + 285, + 523 ], [ - 717, - 335 + 349, + 425 ], [ - 913, - 313 + 459, + 341 ], [ - 1104, - 331 + 607, + 279 ], [ - 1263, - 385 + 779, + 243 ], [ - 1368, - 463 + 960, + 234 ], [ - 1407, - 554 + 1136, + 253 ], [ - 1378, - 642 + 1292, + 297 ], [ - 1289, - 716 + 1417, + 362 ], [ - 1159, - 764 + 1502, + 442 ], [ - 1010, - 782 + 1540, + 530 ], [ - 865, - 769 + 1530, + 619 ], [ - 745, - 729 + 1475, + 702 ], [ - 666, - 671 + 1381, + 771 ], [ - 637, - 605 + 1256, + 823 ], [ - 658, - 541 + 1111, + 853 + ], + [ + 960, + 859 + ], + [ + 814, + 843 + ], + [ + 685, + 806 + ], + [ + 583, + 751 + ], + [ + 515, + 685 + ], + [ + 485, + 612 + ], + [ + 494, + 539 + ], + [ + 541, + 472 + ], + [ + 619, + 416 ], [ 721, - 489 + 375 + ], + [ + 838, + 352 + ], + [ + 960, + 348 ] ], "frog": [ 960, - 580 + 575 ], "colors": 6, - "quota": 62, + "quota": 50, "introBalls": 14, - "pushSpeed": 46, + "pushSpeed": 61, "powerUpRate": 0.045, "seed": 151461, "starScores": [ - 1580, - 2370, - 3160 + 1510, + 2260, + 3010 ] }, { @@ -2519,163 +5359,207 @@ "points": [ [ -80, - 580 + 120 ], [ - 140, - 580 + 58, + 120 ], [ - 215, - 420 + 960, + 120 ], [ - 397, - 290 + 1195, + 143 ], [ - 654, - 207 + 1406, + 201 ], [ - 946, - 182 + 1577, + 287 ], [ - 1228, - 217 + 1694, + 395 ], [ - 1460, - 303 + 1749, + 514 ], [ - 1609, - 426 + 1739, + 635 ], [ - 1656, - 567 + 1667, + 749 ], [ - 1600, - 703 + 1541, + 846 ], [ - 1452, - 815 + 1370, + 919 ], [ - 1239, - 888 + 1171, + 962 ], [ - 995, - 912 + 960, + 973 ], [ - 758, - 887 + 754, + 952 ], [ - 561, - 818 + 571, + 901 ], [ - 433, - 718 + 423, + 826 ], [ - 389, - 602 + 322, + 732 ], [ - 430, - 490 + 276, + 628 ], [ - 546, - 396 + 285, + 523 ], [ - 717, - 335 + 349, + 425 ], [ - 913, - 313 + 459, + 341 ], [ - 1104, - 331 + 607, + 279 ], [ - 1263, - 385 + 779, + 243 ], [ - 1368, - 463 + 960, + 234 ], [ - 1407, - 554 + 1136, + 253 ], [ - 1378, - 642 + 1292, + 297 ], [ - 1289, - 716 + 1417, + 362 ], [ - 1159, - 764 + 1502, + 442 ], [ - 1010, - 782 + 1540, + 530 ], [ - 865, - 769 + 1530, + 619 ], [ - 745, - 729 + 1475, + 702 ], [ - 666, - 671 + 1381, + 771 ], [ - 637, - 605 + 1256, + 823 ], [ - 658, - 541 + 1111, + 853 + ], + [ + 960, + 859 + ], + [ + 814, + 843 + ], + [ + 685, + 806 + ], + [ + 583, + 751 + ], + [ + 515, + 685 + ], + [ + 485, + 612 + ], + [ + 494, + 539 + ], + [ + 541, + 472 + ], + [ + 619, + 416 ], [ 721, - 489 + 375 + ], + [ + 838, + 352 + ], + [ + 960, + 348 ] ], "frog": [ 960, - 580 + 575 ], "colors": 6, - "quota": 66, - "introBalls": 16, - "pushSpeed": 48, + "quota": 52, + "introBalls": 14, + "pushSpeed": 64, "powerUpRate": 0.045, "seed": 159380, "starScores": [ - 1720, - 2570, - 3430 + 1580, + 2370, + 3160 ] } ] diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 79ad924..9752c88 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -315,6 +315,9 @@ export const MANIFEST = { ], zuma: [ image('zuma-menu-bg', 'assets/images/zuma/background-menu.png'), + // frame 0 = the whole stone frog, frame 1 = the same disc with the mouth + // slot cut out; the ready marble is drawn between them. + sheet('zuma-frog', 'assets/images/zuma/frog.png', 200, 200), ], '2048': [ // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so diff --git a/src/games/zuma/ZumaEditor.js b/src/games/zuma/ZumaEditor.js new file mode 100644 index 0000000..acfb7ee --- /dev/null +++ b/src/games/zuma/ZumaEditor.js @@ -0,0 +1,610 @@ +// 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. +// +// 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, +// fill in the form. The live validation strip runs ZumaLogic.validateLevel, the +// same lint tools/genZuma.js gates on and tools/verifyZuma.js asserts. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { + TUNING, BALL_COLORS, LEVEL_BOUNDS, buildPath, validateLevel, validateLevelParams, +} from './ZumaLogic.js'; + +const FONT = 'm6x11, "Julius Sans One"'; + +// The board shows more than the 1920x1080 canvas because a level's first +// control point is the off-screen spawn lead-in (x is around -80). +const WORLD = { x0: -260, y0: -80, w: 2320, h: 1220 }; +// Tool column on the left, board in the middle, settings strip underneath — +// the board height falls out of the world aspect, so it is sized to leave room +// for the strip rather than the other way round. +const BOARD_X = 360; +const BOARD_Y = 56; +const BOARD_W = 1460; +const BOARD_H = Math.round((BOARD_W * WORLD.h) / WORLD.w); // 768 +const K = BOARD_W / WORLD.w; + +const TOOLS = [ + ['move', 'Move Point'], + ['insert', 'Insert Point'], + ['append', 'Append Point'], + ['delete', 'Delete Point'], + ['frog', 'Move Frog'], +]; + +const HELP = { + move: 'Drag any ◆ control point. The first is the off-screen spawn lead-in, the last is the skull hole.', + insert: 'Click near the path to add a control point on that segment.', + append: 'Click to add a control point at the hole end — the new point becomes the hole.', + delete: 'Click a control point to remove it. Right-click does this from any tool.', + frog: 'Click to move the frog. The dashed ring is the clearance the lint demands.', +}; + +export default class ZumaEditor extends Phaser.Scene { + constructor() { super('ZumaEditor'); } + + init(data) { + this.resume = !!data?.resume; + } + + preload() { + // The editor boots straight out of PreloadScene, so the lazy game art that + // GameRoomScene would normally fetch isn't loaded yet. + if (!this.textures.exists('zuma-frog')) { + this.load.spritesheet('zuma-frog', 'assets/images/zuma/frog.png', + { frameWidth: 200, frameHeight: 200 }); + } + } + + create() { + const raw = this.cache.json.get('zuma'); + this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + this.level = this.resume + ? (this.registry.get('zuma-editor-state') ?? this.defaultLevel()) + : (this.bank[0] ? clone(this.bank[0]) : this.defaultLevel()); + + this.tool = 'move'; + this.selected = -1; + this.dragging = -1; + this.draggingFrog = false; + this.undoStack = []; + this.errs = []; + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0d1a10); + this.add.rectangle(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, BOARD_W + 8, BOARD_H + 8, 0x000000) + .setStrokeStyle(2, COLORS.accent); + this.g = this.add.graphics().setDepth(5); + + this.input.mouse?.disableContextMenu(); + this.buildToolbar(); + this.buildForm(); + this.bindPointer(); + this.bindKeys(); + this.revalidate(); + } + + defaultLevel() { + return { + level: (this.bank?.length ?? 0) + 1, + name: 'New Path', + shape: 'custom', + points: [[-80, 300], [300, 300], [800, 380], [1300, 520], [1600, 760]], + frog: [960, 900], + colors: 4, + quota: 24, + introBalls: 8, + pushSpeed: 30, + powerUpRate: 0.06, + seed: 12345, + starScores: [640, 960, 1280], + }; + } + + // ── Coordinate transforms ────────────────────────────────────────────────── + + toBoard(wx, wy) { + return [BOARD_X + (wx - WORLD.x0) * K, BOARD_Y + (wy - WORLD.y0) * K]; + } + + toWorld(bx, by) { + return [(bx - BOARD_X) / K + WORLD.x0, (by - BOARD_Y) / K + WORLD.y0]; + } + + onBoard(p) { + return p.x >= BOARD_X && p.x <= BOARD_X + BOARD_W && p.y >= BOARD_Y && p.y <= BOARD_Y + BOARD_H; + } + + // ── Toolbar ──────────────────────────────────────────────────────────────── + + buildToolbar() { + const cx = BOARD_X / 2; + const bw = 280; + this.add.text(cx, 34, 'ZUMA EDITOR', { + fontFamily: FONT, fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5); + + this.toolButtons = {}; + let y = 92; + for (const [id, label] of TOOLS) { + this.toolButtons[id] = new Button(this, cx, y, label, () => this.setTool(id), + { width: bw, height: 50, fontSize: 19 }); + y += 58; + } + this.setTool('move'); + + 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; + + const half = bw / 2 - 6; + new Button(this, cx - bw / 4 - 3, y, 'Undo (Z)', () => this.undo(), + { width: half, height: 46, fontSize: 16, variant: 'ghost' }); + new Button(this, cx + bw / 4 + 3, y, 'New Level', () => { + this.pushUndo(); + this.level = this.defaultLevel(); + this.selected = -1; + this.syncForm(); + this.revalidate(); + }, { width: half, height: 46, fontSize: 16, variant: 'ghost' }); + y += 56; + new Button(this, cx, y, 'Exit Editor', () => { window.location.search = ''; }, + { width: bw, height: 46, fontSize: 17, variant: 'ghost' }); + + this.helpText = this.add.text(cx - bw / 2, y + 42, '', { + fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex, wordWrap: { width: bw }, + }); + this.issueText = this.add.text(BOARD_X, BOARD_Y + BOARD_H + 10, '', { + fontFamily: FONT, fontSize: '18px', color: COLORS.dangerHex, wordWrap: { width: BOARD_W }, + }); + this.statText = this.add.text(BOARD_X + BOARD_W, BOARD_Y + BOARD_H + 10, '', { + fontFamily: FONT, fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(1, 0); + } + + setTool(id) { + this.tool = id; + for (const [tid, b] of Object.entries(this.toolButtons)) b.setActive(tid === id); + this.helpText?.setText(HELP[id] ?? ''); + this.draw(); + } + + // ── DOM settings strip ───────────────────────────────────────────────────── + + buildForm() { + const input = 'background:#101a12; color:#f2ead8; border:1px solid #6b5638; border-radius:6px;' + + ' padding:6px 8px; font-size:15px; width:100%; box-sizing:border-box;'; + const cell = 'display:flex; flex-direction:column; gap:4px;'; + const lab = `font-size:12px; color:${COLORS.mutedHex}; letter-spacing:1px;`; + + const field = (id, label, type = 'number', extra = '') => + `
${label}` + + `
`; + + const el = document.createElement('div'); + el.style.cssText = `width:${BOARD_W}px; font-family:"Julius Sans One",sans-serif;`; + el.innerHTML = ` +
+ ${field('zu-level', 'LEVEL', 'number', 'min="1" step="1"')} + ${field('zu-name', 'NAME', 'text')} + ${field('zu-colors', 'COLORS (4-6)', 'number', 'min="4" max="6" step="1"')} + ${field('zu-quota', 'QUOTA', 'number', 'min="20" step="1"')} + ${field('zu-intro', 'INTRO BALLS', 'number', 'min="1" step="1"')} + ${field('zu-push', 'PUSH SPEED', 'number', 'min="10" max="100" step="1"')} + ${field('zu-power', 'POWER-UP RATE', 'number', 'min="0" max="0.2" step="0.005"')} + ${field('zu-seed', 'SEED', 'number', 'step="1"')} + ${field('zu-star1', '★ SCORE', 'number', 'step="10"')} + ${field('zu-star2', '★★ SCORE', 'number', 'step="10"')} + ${field('zu-star3', '★★★ SCORE', 'number', 'step="10"')} +
LOAD FROM BANK +
+
LOAD A .JSON FILE +
+
+
`; + + // 16 cells over 8 columns = 2 rows; add.dom positions by centre. + this.formDom = this.add.dom(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H + 112, el).setDepth(20); + const q = (id) => el.querySelector(id); + this.fields = { + level: q('#zu-level'), name: q('#zu-name'), colors: q('#zu-colors'), + quota: q('#zu-quota'), introBalls: q('#zu-intro'), pushSpeed: q('#zu-push'), + powerUpRate: q('#zu-power'), seed: q('#zu-seed'), + star1: q('#zu-star1'), star2: q('#zu-star2'), star3: q('#zu-star3'), + }; + this.warnEl = q('#zu-warn'); + + for (const [key, elm] of Object.entries(this.fields)) { + elm.addEventListener('change', () => this.readForm(key)); + } + + 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}`); + ev.target.value = ''; + }); + + q('#zu-file').addEventListener('change', (ev) => { + const f = ev.target.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const data = JSON.parse(reader.result); + // accept either a bare level or a whole bank + const def = Array.isArray(data?.levels) ? data.levels[0] : data; + this.loadLevel(def, f.name); + } catch (_) { this.flashWarn(`Could not parse ${f.name}`); } + }; + reader.readAsText(f); + ev.target.value = ''; + }); + + this.syncForm(); + } + + syncForm() { + if (!this.fields) return; + const l = this.level; + 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.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]; + } + + readForm(key) { + const v = this.fields[key].value; + const num = Number(v); + 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; + } + this.revalidate(); + } + + flashWarn(msg) { + if (!this.warnEl) return; + this.warnEl.textContent = msg; + this.time.delayedCall(4000, () => { if (this.warnEl) this.warnEl.textContent = ''; }); + } + + loadLevel(data, label) { + if (!data || !Array.isArray(data.points) || data.points.length < 3 || !Array.isArray(data.frog)) { + 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, + }; + this.selected = -1; + this.syncForm(); + this.revalidate(); + playSound(this, SFX.UI_CHIME); + this.flash(`Loaded ${label}`); + } + + // ── Input ────────────────────────────────────────────────────────────────── + + bindPointer() { + this.input.on('pointerdown', (p) => { + if (!this.onBoard(p)) return; + const [wx, wy] = this.toWorld(p.x, p.y); + if (p.rightButtonDown()) { this.deletePointAt(wx, wy); return; } + this.handleClick(wx, wy); + }); + this.input.on('pointermove', (p) => { + if (this.dragging < 0 && !this.draggingFrog) return; + const [wx, wy] = this.toWorld(p.x, p.y); + 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]; + this.draw(); + }); + this.input.on('pointerup', () => { + if (this.dragging >= 0 || this.draggingFrog) this.revalidate(); + this.dragging = -1; + this.draggingFrog = false; + }); + } + + bindKeys() { + this.input.keyboard.on('keydown-Z', () => this.undo()); + this.input.keyboard.on('keydown-DELETE', () => this.deleteSelected()); + this.input.keyboard.on('keydown-BACKSPACE', () => this.deleteSelected()); + this.input.keyboard.on('keydown-ESC', () => { this.selected = -1; this.draw(); }); + } + + nearestPoint(wx, wy, maxPx = 22) { + const max = maxPx / K; + let best = -1; + let bd = max; + this.level.points.forEach(([x, y], i) => { + const d = Math.hypot(x - wx, y - wy); + if (d < bd) { bd = d; best = i; } + }); + return best; + } + + handleClick(wx, wy) { + const pts = this.level.points; + switch (this.tool) { + case 'move': { + const i = this.nearestPoint(wx, wy); + this.selected = i; + if (i >= 0) { this.pushUndo(); this.dragging = i; } + this.draw(); + break; + } + case 'frog': + this.pushUndo(); + this.level.frog = [Math.round(wx), Math.round(wy)]; + this.draggingFrog = true; + this.revalidate(); + break; + case 'insert': { + this.pushUndo(); + let seg = 0; + let bd = Infinity; + for (let i = 0; i < pts.length - 1; i++) { + const d = distToSeg(wx, wy, pts[i], pts[i + 1]); + if (d < bd) { bd = d; seg = i; } + } + pts.splice(seg + 1, 0, [Math.round(wx), Math.round(wy)]); + this.selected = seg + 1; + this.revalidate(); + break; + } + case 'append': + this.pushUndo(); + pts.push([Math.round(wx), Math.round(wy)]); + this.selected = pts.length - 1; + this.revalidate(); + break; + case 'delete': + this.deletePointAt(wx, wy); + break; + default: + break; + } + } + + deletePointAt(wx, wy) { + const i = this.nearestPoint(wx, wy); + if (i < 0 || this.level.points.length <= 3) return; + this.pushUndo(); + this.level.points.splice(i, 1); + this.selected = -1; + this.revalidate(); + } + + deleteSelected() { + if (this.selected < 0 || this.level.points.length <= 3) return; + this.pushUndo(); + this.level.points.splice(this.selected, 1); + this.selected = -1; + this.revalidate(); + } + + // ── Undo / validation ────────────────────────────────────────────────────── + + pushUndo() { + this.undoStack.push(JSON.stringify(this.level)); + if (this.undoStack.length > 60) this.undoStack.shift(); + } + + undo() { + const snap = this.undoStack.pop(); + if (!snap) return; + this.level = JSON.parse(snap); + this.selected = -1; + this.syncForm(); + this.revalidate(); + } + + revalidate() { + let geo = { errs: ['path invalid'], length: 0, minFrog: 0, minRadius: 0 }; + 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)); + 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` + ); + this.draw(); + } + + // ── Drawing ──────────────────────────────────────────────────────────────── + + draw() { + const g = this.g; + g.clear(); + const lvl = this.level; + + // canvas edge and the legal play area the lint enforces + const [cx0, cy0] = this.toBoard(0, 0); + const [cx1, cy1] = this.toBoard(GAME_WIDTH, GAME_HEIGHT); + g.lineStyle(2, 0x3a4a3a, 0.9); + g.strokeRect(cx0, cy0, cx1 - cx0, cy1 - cy0); + const [bx0, by0] = this.toBoard(LEVEL_BOUNDS.x0, LEVEL_BOUNDS.y0); + const [bx1, by1] = this.toBoard(LEVEL_BOUNDS.x1, LEVEL_BOUNDS.y1); + 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; } + + if (path) { + // the groove, at true scaled widths + const stroke = (w, color) => { + g.lineStyle(w * K, color, 1); + g.beginPath(); + const [sx, sy] = this.toBoard(path.samples[0].x, path.samples[0].y); + g.moveTo(sx, sy); + for (const s of path.samples) { + const [x, y] = this.toBoard(s.x, s.y); + g.lineTo(x, y); + } + g.strokePath(); + }; + stroke(80, 0x241b0e); + stroke(67, 0x4a3a26); + + // Ghost chain: the whole quota packed back from the hole — 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. + 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); + g.fillStyle(BALL_COLORS[i % Math.max(1, lvl.colors)], 0.9); + g.fillCircle(x, y, R); + g.lineStyle(1, 0x000000, 0.4); + g.strokeCircle(x, y, R); + } + + // skull hole at the far end + const end = path.pointAt(path.length); + const [hx, hy] = this.toBoard(end.x, end.y); + g.fillStyle(0x1a120a, 1); g.fillCircle(hx, hy, 61 * K); + g.fillStyle(0x050505, 1); g.fillCircle(hx, hy, 50 * K); + g.lineStyle(2, 0x6b5638, 1); g.strokeCircle(hx, hy, 61 * K); + } + + // frog + the clearance ring the lint enforces + const [fx, fy] = this.toBoard(lvl.frog[0], lvl.frog[1]); + this.frogImg?.destroy(); + if (this.textures.exists('zuma-frog')) { + this.frogImg = this.add.image(fx, fy, 'zuma-frog', 0) + .setScale(TUNING.FROG_SCALE * K).setDepth(6); + } else { + g.fillStyle(0x3f7d3a, 1); + 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); + dashedCircle(g, fx, fy, clear); + + // control points — first is the lead-in, last is the hole + lvl.points.forEach(([px, py], i) => { + const [x, y] = this.toBoard(px, py); + const last = i === lvl.points.length - 1; + const color = i === this.selected ? 0xffd54a : last ? 0xd9403a : i === 0 ? 0x6fd47e : 0x9fd8ff; + g.fillStyle(color, 1); + g.fillPoints([ + new Phaser.Geom.Point(x, y - 8), new Phaser.Geom.Point(x + 8, y), + new Phaser.Geom.Point(x, y + 8), new Phaser.Geom.Point(x - 8, y), + ], true); + if (i === this.selected) { g.lineStyle(2, 0xffffff, 0.9); g.strokeCircle(x, y, 14); } + }); + } + + // ── Test play / export ───────────────────────────────────────────────────── + + testPlay() { + if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; } + this.registry.set('zuma-editor-state', clone(this.level)); + this.scene.start('ZumaGame', { + game: { slug: 'zuma', name: 'Zuma' }, + testLevel: clone(this.level), + returnToEditor: true, + }); + } + + download(name, obj) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 1)}\n`], { type: 'application/json' })); + a.download = name; + a.click(); + 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`); + } + + exportBank() { + 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, + }); + playSound(this, SFX.UI_CHIME); + this.flash('Saved zuma.json — drop it into data/ (genZuma.js will overwrite it)'); + } + + flash(msg) { + this.flashText?.destroy(); + this.flashText = this.add.text(BOARD_X + BOARD_W / 2, BOARD_Y + 26, msg, { + fontFamily: FONT, fontSize: '20px', color: COLORS.goldHex, + backgroundColor: '#000000cc', padding: { x: 12, y: 6 }, + }).setOrigin(0.5).setDepth(30); + this.time.delayedCall(4200, () => this.flashText?.destroy()); + } +} + +const clone = (o) => JSON.parse(JSON.stringify(o)); + +function distToSeg(x, y, a, b) { + const abx = b[0] - a[0]; + const aby = b[1] - a[1]; + const len2 = abx * abx + aby * aby || 1e-6; + const t = Phaser.Math.Clamp(((x - a[0]) * abx + (y - a[1]) * aby) / len2, 0, 1); + return Math.hypot(x - (a[0] + abx * t), y - (a[1] + aby * t)); +} + +function dashedCircle(g, cx, cy, r, dashes = 40) { + for (let i = 0; i < dashes; i += 2) { + const a0 = (i / dashes) * Math.PI * 2; + const a1 = ((i + 1) / dashes) * Math.PI * 2; + g.beginPath(); + g.moveTo(cx + Math.cos(a0) * r, cy + Math.sin(a0) * r); + g.lineTo(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r); + g.strokePath(); + } +} diff --git a/src/games/zuma/ZumaGame.js b/src/games/zuma/ZumaGame.js index 355d32c..95794e2 100644 --- a/src/games/zuma/ZumaGame.js +++ b/src/games/zuma/ZumaGame.js @@ -5,25 +5,49 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { playSound, playScifiWoosh, playScifiExplode, SFX } from '../../ui/Sounds.js'; import { api } from '../../services/api.js'; import { - TUNING, createLevel, step, fireBall, swapBalls, rayHit, + TUNING, BALL_COLORS, createLevel, step, fireBall, swapBalls, rayHit, } from './ZumaLogic.js'; const BG = 0x0d1a10; // deep jungle green const PATH_RIM = 0x241b0e; const PATH_BED = 0x4a3a26; -const D = { path: 0, hole: 2, ball: 10, icon: 11, flight: 12, laser: 13, frog: 14, fx: 20, ui: 30, bg: -2, overlay: 60, overlayUI: 62 }; -// glossy marble palette: red, yellow, blue, green, purple, silver -const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3]; +// The launcher is layered around the marble it holds: frog frame 0 sits under +// the ready marble, frame 1 (the disc with the mouth slot cut out) sits over +// it — and over flights too, so a fired marble slides out from under the lip. +// +// That only works because `this.layer` is a Phaser Layer, not a Container: a +// Container renders its children in insertion order and ignores their depth +// entirely, which would put the frog overlay under every marble (the frog is +// built before any marble exists). Layer depth-sorts, so these numbers mean +// what they say. Do not turn `this.layer` back into a Container. +const D = { + bg: -2, path: 0, hole: 2, + frogBase: 4, frogBall: 5, frogBallGloss: 6, + ball: 10, ballGloss: 11, icon: 12, + flight: 13, flightGloss: 14, + laser: 15, + frogOver: 16, frogNext: 17, frogNextGloss: 18, + fx: 20, ui: 30, overlay: 60, overlayUI: 62, +}; + +// Marbles roll: `zuma-roll` is a greyscale sphere baked once at this many +// rotations about the screen-vertical axis, i.e. a roll cycle toward +x. +// Rolling in any other direction is that same cycle rotated — exact for a +// sphere — so the frame comes from arc-length and the rotation from the path +// tangent. Power of two so the frame index can wrap with a mask. +const ROLL_FRAMES = 32; +const ROLL_COLS = 8; // Render-side feel constants (logic tuning lives in ZumaLogic.TUNING) const TUNE = { INSERT_MS: 120, // squeeze-in tween for a landed shot POP_FX_MS: 320, // particle burst lifetime LASER_ALPHA: 0.55, - PATH_W_RIM: 60, - PATH_W_BED: 50, - GROOVE_STEP: 36, // px between center-groove dots + PATH_W_RIM: 80, + PATH_W_BED: 67, + GROOVE_STEP: 48, // px between center-groove dots + NEXT_SCALE: 0.55, // the on-deck marble shown in the frog's belly }; export default class ZumaGame extends Phaser.Scene { @@ -31,6 +55,10 @@ export default class ZumaGame extends Phaser.Scene { init(data) { this.gameDef = data.game ?? { slug: 'zuma', name: 'Zuma' }; + // Editor test play: a one-off level to run instead of the bank, and no + // progress or history writes for it (see ZumaEditor.testPlay). + this.testLevel = data.testLevel ?? null; + this.returnToEditor = !!data.returnToEditor; this.bank = []; this.levelsCompleted = 0; this.canPersist = true; @@ -56,6 +84,17 @@ export default class ZumaGame extends Phaser.Scene { const raw = this.cache.json.get('zuma'); this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + if (this.testLevel) { + this.bank = [this.testLevel]; + 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); + return; + } + try { const res = await api.get('/puzzles/zuma/progress'); this.levelsCompleted = res?.levelsCompleted ?? 0; @@ -64,7 +103,8 @@ export default class ZumaGame extends Phaser.Scene { this.levelsCompleted = 0; } - this.layer = this.add.container(0, 0); + this.layer = this.add.layer(); + this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS; // px of travel per full roll this.buildTextures(); this.bindInput(); this.showLevelSelect(); @@ -75,32 +115,28 @@ export default class ZumaGame extends Phaser.Scene { buildTextures() { const R = TUNING.BALL_RADIUS; const size = R * 2; - BALL_COLORS.forEach((color, i) => { - const key = `zuma-ball-${i}`; - if (this.textures.exists(key)) return; + + this.buildRollSheet(); + this.buildFrogFallback(); + + // Fixed specular, drawn over the rolling base so the light stays put while + // the marble surface turns under it. Untinted — the base carries the color. + if (!this.textures.exists('zuma-gloss')) { const g = this.add.graphics(); - const dark = Phaser.Display.Color.IntegerToColor(color).darken(35).color; - const light = Phaser.Display.Color.IntegerToColor(color).lighten(25).color; - g.fillStyle(dark, 1); - g.fillCircle(R, R, R); - g.fillStyle(color, 1); - g.fillCircle(R, R, R - 2); - g.fillStyle(light, 0.55); - g.fillCircle(R - R * 0.22, R - R * 0.22, R * 0.62); - g.fillStyle(0xffffff, 0.85); + g.fillStyle(0xffffff, 0.8); g.fillEllipse(R - R * 0.35, R - R * 0.45, R * 0.5, R * 0.32); - g.fillStyle(0xffffff, 0.25); + g.fillStyle(0xffffff, 0.22); g.fillEllipse(R + R * 0.3, R + R * 0.55, R * 0.5, R * 0.2); - g.generateTexture(key, size, size); + g.generateTexture('zuma-gloss', size, size); g.destroy(); - }); + } if (!this.textures.exists('zuma-glow')) { const g = this.add.graphics(); - g.fillStyle(0xffffff, 0.35); g.fillCircle(8, 8, 8); - g.fillStyle(0xffffff, 0.8); g.fillCircle(8, 8, 5); - g.fillStyle(0xffffff, 1); g.fillCircle(8, 8, 2.5); - g.generateTexture('zuma-glow', 16, 16); + g.fillStyle(0xffffff, 0.35); g.fillCircle(11, 11, 11); + g.fillStyle(0xffffff, 0.8); g.fillCircle(11, 11, 7); + g.fillStyle(0xffffff, 1); g.fillCircle(11, 11, 3.5); + g.generateTexture('zuma-glow', 22, 22); g.destroy(); } @@ -108,38 +144,184 @@ export default class ZumaGame extends Phaser.Scene { if (this.textures.exists(key)) return; const g = this.add.graphics(); draw(g); - g.generateTexture(key, 30, 30); + g.generateTexture(key, 40, 40); g.destroy(); }; const GOLD = 0xffd54a; icon('zuma-pw-slow', (g) => { // clock - g.lineStyle(3, GOLD, 1); g.strokeCircle(15, 15, 11); - g.lineBetween(15, 15, 15, 7); g.lineBetween(15, 15, 21, 17); + g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 15); + g.lineBetween(20, 20, 20, 9); g.lineBetween(20, 20, 28, 23); }); icon('zuma-pw-reverse', (g) => { // back arrows g.fillStyle(GOLD, 1); - g.fillTriangle(13, 8, 13, 22, 3, 15); - g.fillTriangle(27, 8, 27, 22, 17, 15); + g.fillTriangle(17, 11, 17, 29, 4, 20); + g.fillTriangle(36, 11, 36, 29, 23, 20); }); icon('zuma-pw-accuracy', (g) => { // crosshair - g.lineStyle(3, GOLD, 1); g.strokeCircle(15, 15, 9); - g.lineBetween(15, 1, 15, 9); g.lineBetween(15, 21, 15, 29); - g.lineBetween(1, 15, 9, 15); g.lineBetween(21, 15, 29, 15); + g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 12); + g.lineBetween(20, 1, 20, 12); g.lineBetween(20, 28, 20, 39); + g.lineBetween(1, 20, 12, 20); g.lineBetween(28, 20, 39, 20); }); icon('zuma-pw-explosion', (g) => { // starburst g.fillStyle(GOLD, 1); for (let k = 0; k < 8; k++) { const a = (k * Math.PI) / 4; g.fillTriangle( - 15 + Math.cos(a) * 14, 15 + Math.sin(a) * 14, - 15 + Math.cos(a + 1.2) * 5, 15 + Math.sin(a + 1.2) * 5, - 15 + Math.cos(a - 1.2) * 5, 15 + Math.sin(a - 1.2) * 5 + 20 + Math.cos(a) * 19, 20 + Math.sin(a) * 19, + 20 + Math.cos(a + 1.2) * 7, 20 + Math.sin(a + 1.2) * 7, + 20 + Math.cos(a - 1.2) * 7, 20 + Math.sin(a - 1.2) * 7 ); } - g.fillCircle(15, 15, 5); + g.fillCircle(20, 20, 7); }); } + // Bake the roll cycle: ROLL_FRAMES orthographic views of one greyscale + // sphere, each rotated a further 2PI/ROLL_FRAMES about the screen-vertical + // axis. That axis is exactly a roll toward +x — a surface point at (0,0,R) + // maps to (R sin0, 0, R cos0). Shading and markings are baked in greyscale so + // setTint() can carry the six marble colors off one sheet. + buildRollSheet() { + if (this.textures.exists('zuma-roll')) return; + const R = TUNING.BALL_RADIUS; + const S = R * 2; + const rows = ROLL_FRAMES / ROLL_COLS; + const W = ROLL_COLS * S; + const tex = this.textures.createCanvas('zuma-roll', W, rows * S); + if (!tex) return; + const ctx = tex.getContext(); + const img = ctx.createImageData(W, rows * S); + const data = img.data; + + // Surface markings: spots on a Fibonacci spiral, so every view of the + // sphere carries a few and the roll is legible from any angle. Without them + // a shaded sphere is rotationally symmetric and rolling is invisible. + const NB = 14; + const SPOT_COS = 0.94; // angular size: cos of the cap radius + const SPOT_SPAN = 1 - SPOT_COS; + const SPOT_DEPTH = 0.55; // how much darker the spot centre is + const GA = Math.PI * (3 - Math.sqrt(5)); + const blobs = []; + for (let i = 0; i < NB; i++) { + const by = 1 - (2 * i + 1) / NB; + const br = Math.sqrt(Math.max(0, 1 - by * by)); + blobs.push([Math.cos(GA * i) * br, by, Math.sin(GA * i) * br]); + } + const LX = -0.42, LY = -0.52, LZ = 0.74; // fixed screen-space light + + for (let f = 0; f < ROLL_FRAMES; f++) { + const th = (f / ROLL_FRAMES) * Math.PI * 2; + const ct = Math.cos(th), stn = Math.sin(th); + const ox = (f % ROLL_COLS) * S; + const oy = Math.floor(f / ROLL_COLS) * S; + for (let py = 0; py < S; py++) { + for (let px = 0; px < S; px++) { + const idx = ((oy + py) * W + ox + px) * 4; + const nx = (px + 0.5 - R) / R; + const ny = (py + 0.5 - R) / R; + const r2 = nx * nx + ny * ny; + if (r2 >= 1) { data[idx + 3] = 0; continue; } + const nz = Math.sqrt(1 - r2); + // object-space point = R_y(-theta) applied to the screen normal + const ox3 = nx * ct - nz * stn; + const oz3 = nx * stn + nz * ct; + let mark = 0; + for (let k = 0; k < NB; k++) { + const b = blobs[k]; + const d = ox3 * b[0] + ny * b[1] + oz3 * b[2]; + if (d > SPOT_COS) { + const u = (d - SPOT_COS) / SPOT_SPAN; + const sm = u * u * (3 - 2 * u); + if (sm > mark) mark = sm; + } + } + const lam = Math.max(0, nx * LX + ny * LY + nz * LZ); + let v = 0.32 + 0.68 * lam; // diffuse + v *= 0.55 + 0.45 * (nz ** 0.45); // rim falloff + v *= 1 - SPOT_DEPTH * mark; // markings + const c = Math.max(0, Math.min(255, Math.round(v * 255))); + data[idx] = c; data[idx + 1] = c; data[idx + 2] = c; + data[idx + 3] = r2 > 0.94 ? Math.round((255 * (1 - r2)) / 0.06) : 255; + } + } + } + ctx.putImageData(img, 0, 0); + tex.refresh(); + for (let f = 0; f < ROLL_FRAMES; f++) { + tex.add(f, 0, (f % ROLL_COLS) * S, Math.floor(f / ROLL_COLS) * S, S, S); + } + } + + // Stand-in for assets/images/zuma/frog.png with the same 200x200 frame + // geometry and the same 43px mouth slot, so every offset still lines up if + // the art fails to load. + buildFrogFallback() { + if (this.textures.exists('zuma-frog')) return; + const S = 200; + const tex = this.textures.createCanvas('zuma-frog', S * 2, S); + if (!tex) return; + const ctx = tex.getContext(); + for (let f = 0; f < 2; f++) { + ctx.save(); + ctx.translate(f * S, 0); + const grad = ctx.createRadialGradient(S * 0.38, S * 0.34, 12, S / 2, S / 2, S / 2); + grad.addColorStop(0, '#8a9487'); + grad.addColorStop(1, '#39423a'); + ctx.beginPath(); + ctx.arc(S / 2, S / 2, S / 2 - 3, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + ctx.lineWidth = 6; + ctx.strokeStyle = '#252c26'; + ctx.stroke(); + ctx.fillStyle = '#3f7d3a'; + ctx.beginPath(); + ctx.ellipse(S / 2, S * 0.56, S * 0.25, S * 0.31, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#1d3d1f'; + for (const ex of [0.38, 0.62]) { + ctx.beginPath(); + ctx.arc(S * ex, S * 0.34, 15, 0, Math.PI * 2); + ctx.fill(); + } + if (f === 1) { + // Cut the mouth slot out of frame 1: a 43px channel from the top edge + // ending in a round seat, matching frog.png. Two separate paths — one + // compound path would connect the rect and the arc with a stray edge. + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillRect(78, 0, 43, 28); + ctx.beginPath(); + ctx.arc(99.5, 28, 21.5, 0, Math.PI * 2); + ctx.fill(); + ctx.globalCompositeOperation = 'source-over'; + } + ctx.restore(); + } + tex.refresh(); + tex.add(0, 0, 0, 0, S, S); + tex.add(1, 0, S, 0, S, S); + } + + // ── Marbles ──────────────────────────────────────────────────────────────── + + // A marble is two sprites: the rolling, tinted base and the fixed highlight. + addMarble(x, y, color, dBase, dGloss, scale = 1) { + const base = this.add.image(x, y, 'zuma-roll', 0) + .setDepth(dBase).setTint(BALL_COLORS[color]).setScale(scale); + const gloss = this.add.image(x, y, 'zuma-gloss').setDepth(dGloss).setScale(scale); + this.layer.add([base, gloss]); + return { base, gloss }; + } + + // Place a marble and spin it to match how far it has travelled in direction + // (tx, ty). The frame is the roll phase; the rotation aims the roll cycle. + rollMarble(m, x, y, travelled, tx, ty) { + m.base.setPosition(x, y); + m.base.setFrame(Math.floor((travelled / this.rollCirc) * ROLL_FRAMES) & (ROLL_FRAMES - 1)); + m.base.rotation = Math.atan2(ty, tx); + m.gloss.setPosition(x, y); + } + bindInput() { this.input.mouse?.disableContextMenu(); this.input.on('pointermove', (p) => { @@ -164,11 +346,21 @@ export default class ZumaGame extends Phaser.Scene { playSound(this, SFX.CARD_PLACE); } + // "Levels" during a normal run, "back to the editor" during a test play. + leaveLevel() { + if (this.returnToEditor) this.scene.start('ZumaEditor', { resume: true }); + else this.showLevelSelect(); + } + clearLayer() { - this.layer.removeAll(true); + // Layer extends List, whose removeAll(true) means "skip the remove + // callback" — NOT Container's "destroy the children". Destroy explicitly; + // each destroy() pulls itself out of the layer, so iterate a copy. + for (const obj of [...this.layer.list]) obj.destroy(); this.ballSprites = new Map(); this.flightSprites = new Map(); - this.frog = null; + this.frogBase = null; + this.frogOver = null; this.frogCurrent = null; this.frogNext = null; this.laserGfx = null; @@ -214,7 +406,7 @@ export default class ZumaGame extends Phaser.Scene { const cx = GAME_WIDTH / 2; if (!this.bank.length) { - const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genZuma.js', { + 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); this.layer.add(msg); @@ -371,7 +563,7 @@ export default class ZumaGame extends Phaser.Scene { let nextDot = 0; for (const s of samples) { if (s.s >= nextDot) { - g.fillCircle(s.x, s.y, 3); + g.fillCircle(s.x, s.y, 4); nextDot += TUNE.GROOVE_STEP; } } @@ -382,40 +574,33 @@ export default class ZumaGame extends Phaser.Scene { const end = this.state.path.pointAt(this.state.path.length); const c = this.add.container(end.x, end.y).setDepth(D.hole); const g = this.add.graphics(); - g.fillStyle(0x1a120a, 1); g.fillCircle(0, 0, 46); - g.fillStyle(0x050505, 1); g.fillCircle(0, 0, 38); - g.lineStyle(4, 0x6b5638, 1); g.strokeCircle(0, 0, 46); + g.fillStyle(0x1a120a, 1); g.fillCircle(0, 0, 61); + g.fillStyle(0x050505, 1); g.fillCircle(0, 0, 50); + g.lineStyle(5, 0x6b5638, 1); g.strokeCircle(0, 0, 61); // skull eyes + nose g.fillStyle(0xb33c2e, 0.9); - g.fillCircle(-13, -8, 7); g.fillCircle(13, -8, 7); - g.fillTriangle(0, 4, -5, 14, 5, 14); + g.fillCircle(-17, -11, 9); g.fillCircle(17, -11, 9); + g.fillTriangle(0, 5, -7, 19, 7, 19); c.add(g); this.layer.add(c); this.tweens.add({ targets: c, scale: 1.08, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } + // Four independently depth-sorted pieces (see D above): base disc, the marble + // seated in the mouth, the slotted overlay, then the on-deck marble on the + // frog's back. Positions and rotation are driven in update(). buildFrog() { const { x, y } = this.state.frog; - const c = this.add.container(x, y).setDepth(D.frog); - const g = this.add.graphics(); - // body drawn facing +x; container.rotation = aim angle - g.fillStyle(0x2c5e2e, 1); g.fillEllipse(-4, 0, 78, 62); - g.fillStyle(0x3f7d3a, 1); g.fillEllipse(-2, 0, 68, 52); - g.fillStyle(0x9ec46a, 0.5); g.fillEllipse(-8, -8, 40, 20); - // eyes - g.fillStyle(0x2c5e2e, 1); g.fillCircle(6, -24, 10); g.fillCircle(6, 24, 10); - g.fillStyle(0xffffff, 1); g.fillCircle(8, -24, 7); g.fillCircle(8, 24, 7); - g.fillStyle(0x101010, 1); g.fillCircle(10, -24, 3.5); g.fillCircle(10, 24, 3.5); - // mouth ring that holds the current marble - g.lineStyle(4, 0x224a24, 1); g.strokeCircle(16, 0, TUNING.BALL_RADIUS * 0.8); - c.add(g); + const SC = TUNING.FROG_SCALE; - this.frogNext = this.add.image(-30, 0, `zuma-ball-${this.state.next}`).setScale(0.55); - this.frogCurrent = this.add.image(16, 0, `zuma-ball-${this.state.current}`).setScale(0.85); - c.add([this.frogNext, this.frogCurrent]); + this.frogBase = this.add.image(x, y, 'zuma-frog', 0).setScale(SC).setDepth(D.frogBase); + this.frogCurrent = this.addMarble(x, y - TUNING.FROG_MUZZLE, this.state.current, + D.frogBall, D.frogBallGloss); + this.frogOver = this.add.image(x, y, 'zuma-frog', 1).setScale(SC).setDepth(D.frogOver); + this.frogNext = this.addMarble(x, y, this.state.next, D.frogNext, D.frogNextGloss, + TUNE.NEXT_SCALE); - this.frog = c; - this.layer.add(c); + this.layer.add([this.frogBase, this.frogOver]); } drawHud() { @@ -431,10 +616,11 @@ export default class ZumaGame extends Phaser.Scene { this.quotaGfx = this.add.graphics().setDepth(D.ui); this.layer.add([title, this.scoreText, this.effectsText, this.quotaGfx]); - const levels = new Button(this, 130, GAME_HEIGHT - 60, 'Levels', () => this.showLevelSelect(), - { width: 180, height: 52, fontSize: 22, variant: 'ghost' }); + const levels = new Button(this, 130, GAME_HEIGHT - 60, + this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), + { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); const restart = new Button(this, 330, GAME_HEIGHT - 60, 'Restart', () => this.playLevel(this.level), - { width: 180, height: 52, fontSize: 22, variant: 'ghost' }); + { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); this.layer.add([levels, restart]); const tip = this.add.text(GAME_WIDTH - 50, GAME_HEIGHT - 56, 'Click to shoot • Right-click / SPACE to swap', { @@ -487,7 +673,7 @@ export default class ZumaGame extends Phaser.Scene { burst(x, y, tint, n = 8, scale = 1) { for (let k = 0; k < n; k++) { const a = (k / n) * Math.PI * 2 + Math.random() * 0.6; - const dist = (40 + Math.random() * 50) * scale; + const dist = (54 + Math.random() * 66) * scale; const p = this.add.image(x, y, 'zuma-glow').setTint(tint).setDepth(D.fx).setScale(1.2 * scale); this.layer.add(p); this.tweens.add({ @@ -510,11 +696,17 @@ export default class ZumaGame extends Phaser.Scene { this.syncChain(); this.syncFlights(); - // frog aim + shooter marbles - if (this.frog) { - this.frog.rotation = this.aimAngle; - this.frogCurrent.setTexture(`zuma-ball-${st.current}`); - this.frogNext.setTexture(`zuma-ball-${st.next}`); + // frog aim + shooter marbles. The art faces up, the aim angle is measured + // from +x, hence the quarter turn. + if (this.frogBase) { + const rot = this.aimAngle + Math.PI / 2; + this.frogBase.rotation = rot; + this.frogOver.rotation = rot; + const mx = st.frog.x + Math.cos(this.aimAngle) * TUNING.FROG_MUZZLE; + const my = st.frog.y + Math.sin(this.aimAngle) * TUNING.FROG_MUZZLE; + this.frogCurrent.base.setPosition(mx, my).setTint(BALL_COLORS[st.current]); + this.frogCurrent.gloss.setPosition(mx, my); + this.frogNext.base.setTint(BALL_COLORS[st.next]); } // laser sight while accuracy is active @@ -522,10 +714,10 @@ export default class ZumaGame extends Phaser.Scene { this.laserGfx.clear(); if (st.elapsedMs < st.effects.accuracyUntil && st.status === 'playing') { const hit = rayHit(st, this.aimAngle); - this.laserGfx.lineStyle(2, 0xff4d4d, TUNE.LASER_ALPHA); + this.laserGfx.lineStyle(3, 0xff4d4d, TUNE.LASER_ALPHA); this.laserGfx.lineBetween(st.frog.x, st.frog.y, hit.x, hit.y); this.laserGfx.fillStyle(0xff4d4d, TUNE.LASER_ALPHA); - this.laserGfx.fillCircle(hit.x, hit.y, 6); + this.laserGfx.fillCircle(hit.x, hit.y, 8); } } @@ -584,8 +776,7 @@ export default class ZumaGame extends Phaser.Scene { makeBallSprite(ball, squeeze = false) { if (this.ballSprites.has(ball.id)) return this.ballSprites.get(ball.id); - const img = this.add.image(ball.x, ball.y, `zuma-ball-${ball.color}`).setDepth(D.ball); - this.layer.add(img); + const marble = this.addMarble(ball.x, ball.y, ball.color, D.ball, D.ballGloss); let icon = null; if (ball.power) { icon = this.add.image(ball.x, ball.y, `zuma-pw-${ball.power}`).setDepth(D.icon); @@ -593,10 +784,13 @@ export default class ZumaGame extends Phaser.Scene { this.tweens.add({ targets: icon, alpha: 0.45, duration: 450, yoyo: true, repeat: -1 }); } if (squeeze) { - img.setScale(0.3); - this.tweens.add({ targets: img, scale: 1, duration: TUNE.INSERT_MS, ease: 'Back.easeOut' }); + marble.base.setScale(0.3); + marble.gloss.setScale(0.3); + this.tweens.add({ + targets: [marble.base, marble.gloss], scale: 1, duration: TUNE.INSERT_MS, ease: 'Back.easeOut', + }); } - const entry = { img, icon }; + const entry = { ...marble, icon }; this.ballSprites.set(ball.id, entry); return entry; } @@ -606,13 +800,14 @@ export default class ZumaGame extends Phaser.Scene { for (const b of this.state.balls) { seen.add(b.id); const spr = this.makeBallSprite(b); - spr.img.setPosition(b.x, b.y); + // 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); } for (const [id, spr] of this.ballSprites) { if (!seen.has(id)) { - spr.img.destroy(); - spr.icon?.destroy(); + this.destroyMarble(spr); this.ballSprites.delete(id); } } @@ -622,22 +817,30 @@ export default class ZumaGame extends Phaser.Scene { const seen = new Set(); for (const f of this.state.flights) { seen.add(f.id); - let img = this.flightSprites.get(f.id); - if (!img) { - img = this.add.image(f.x, f.y, `zuma-ball-${f.color}`).setDepth(D.flight); - this.layer.add(img); - this.flightSprites.set(f.id, img); + let spr = this.flightSprites.get(f.id); + if (!spr) { + spr = this.addMarble(f.x, f.y, f.color, D.flight, D.flightGloss); + spr.ox = f.x; spr.oy = f.y; + this.flightSprites.set(f.id, spr); } - img.setPosition(f.x, f.y); + // flights travel in a straight line, so distance from the muzzle is the + // roll phase and (dx, dy) is the roll direction + this.rollMarble(spr, f.x, f.y, Math.hypot(f.x - spr.ox, f.y - spr.oy), f.dx, f.dy); } - for (const [id, img] of this.flightSprites) { + for (const [id, spr] of this.flightSprites) { if (!seen.has(id)) { - img.destroy(); + this.destroyMarble(spr); this.flightSprites.delete(id); } } } + destroyMarble(spr) { + spr.base.destroy(); + spr.gloss.destroy(); + spr.icon?.destroy(); + } + // ── End states ────────────────────────────────────────────────────────────── onLost() { @@ -645,16 +848,18 @@ export default class ZumaGame extends Phaser.Scene { playSound(this, SFX.CASINO_LOSE); this.laserGfx?.clear(); - api.post('/history/single-player', { - slug: 'zuma', score: this.state.score, opponentScores: [], result: 'loss', - }).catch(() => { /* best effort */ }); + if (!this.testLevel) { + api.post('/history/single-player', { + slug: 'zuma', score: this.state.score, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + } // remaining marbles race into the skull const end = this.state.path.pointAt(this.state.path.length); let i = 0; for (const [, spr] of this.ballSprites) { this.tweens.add({ - targets: [spr.img, ...(spr.icon ? [spr.icon] : [])], + targets: [spr.base, spr.gloss, ...(spr.icon ? [spr.icon] : [])], x: end.x, y: end.y, scale: 0.2, alpha: 0, duration: 600, delay: i * 18, ease: 'Quad.easeIn', }); @@ -677,7 +882,8 @@ export default class ZumaGame extends Phaser.Scene { }).setOrigin(0.5).setDepth(D.overlayUI); const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level), { width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); - const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(), + const levels = new Button(this, cx + 150, cy + 90, + this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), { width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI); this.layer.add([dim, panel, title, msg, retry, levels]); }); @@ -690,15 +896,17 @@ export default class ZumaGame extends Phaser.Scene { const score = this.state.score; const stars = this.medalStars(score, this.levelDef.starScores); - this.saveStars(this.level, stars); - if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; - api.post('/puzzles/zuma/complete', { level: this.level }) - .then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); }) - .catch(() => { /* best effort */ }); - api.post('/history/single-player', { - slug: 'zuma', score, opponentScores: [], result: 'win', - }).catch(() => { /* best effort */ }); + if (!this.testLevel) { + this.saveStars(this.level, stars); + if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; + api.post('/puzzles/zuma/complete', { level: this.level }) + .then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); }) + .catch(() => { /* best effort */ }); + api.post('/history/single-player', { + slug: 'zuma', score, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + } const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const banner = this.add.text(cx, cy - 60, 'ZUMA!', { @@ -749,7 +957,8 @@ export default class ZumaGame extends Phaser.Scene { } const replay = new Button(this, cx - 120, cy + 152, 'Replay', () => this.playLevel(this.level), { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); - const levels = new Button(this, cx + 120, cy + 152, 'Levels', () => this.showLevelSelect(), + const levels = new Button(this, cx + 120, cy + 152, + this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); this.layer.add([replay, levels]); }); diff --git a/src/games/zuma/ZumaLogic.js b/src/games/zuma/ZumaLogic.js index 897d16e..1063a9a 100644 --- a/src/games/zuma/ZumaLogic.js +++ b/src/games/zuma/ZumaLogic.js @@ -10,26 +10,36 @@ // than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball // (b.x, b.y) every tick for flight collision and rendering. +// Marble size is set by the frog art: assets/images/zuma/frog.png is a 200x200 +// disc whose mouth slot is 43px wide, so drawing it at FROG_SCALE seats a +// marble of radius 21.5 * FROG_SCALE. BALL_RADIUS 32 <=> FROG_SCALE 1.488. +// FROG_MUZZLE is where that marble sits in the slot. Measured off the art's +// alpha channel: at art y 39.8 (60.2px forward of the disc centre) frame 1 +// covers a third of the marble, so it reads as held in the mouth rather than +// balanced on the rim. Seated any deeper in the slot and nothing overlaps at +// all — the slot walls are exactly one ball wide. export const TUNING = { - BALL_RADIUS: 24, // px, marble radius - BALL_SPACING: 48, // px along the path between chain neighbors + BALL_RADIUS: 32, // px, marble radius + BALL_SPACING: 64, // px along the path between chain neighbors SHOT_SPEED: 1600, // px/s, fired ball ACCURACY_SHOT_MULT: 1.35, // shot speed multiplier while accuracy is active HIT_PAD: 0.85, // collision distance = BALL_SPACING * HIT_PAD GAP_EPS: 1, // px slack when deciding "contiguous vs gap" - CATCHUP_SPEED: 260, // px/s, rear segment closing a non-matching gap - PULLBACK_SPEED: 320, // px/s, front segment retreating to a matching gap + CATCHUP_SPEED: 347, // px/s, rear segment closing a non-matching gap + PULLBACK_SPEED: 427, // px/s, front segment retreating to a matching gap INTRO_SPEED_MULT: 9, // chain streams in fast before play begins SLOW_MS: 6000, SLOW_MULT: 0.4, REVERSE_MS: 1800, - REVERSE_SPEED: 160, // px/s, whole chain rolls backward + REVERSE_SPEED: 213, // px/s, whole chain rolls backward ACCURACY_MS: 8000, - EXPLOSION_RADIUS: 110, // px, screen-space blast around the popped ball + EXPLOSION_RADIUS: 147, // px, screen-space blast around the popped ball MATCH_MIN: 3, LASTCALL_COUNT: 6, // final spawns only use colors still on the board HOLE_GRACE: 8, // px before path end that counts as "in the hole" - FROG_MUZZLE: 34, // px from frog center where flights spawn + FROG_MUZZLE: 90, // px from frog center to the mouth (60.2 * FROG_SCALE) + FROG_SCALE: 1.488, // frog.png draw scale — 200px art -> 298px disc + FROG_CLEARANCE: 200, // px the frog center must keep off its own path SCORE_BALL: 10, SCORE_CHAIN_BONUS: 100, // extra per chain-reaction pop TIME_PAR_MS_PER_BALL: 1500, // par clear time = quota * this @@ -42,6 +52,10 @@ export const TUNING = { export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion']; +// Marble palette, indexed by ball.color. A level's `colors` field takes the +// first N of these. Lives here so the scene and the editor share one list. +export const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3]; + // ── Seeded RNG (mulberry32, matches genRushHour.js) ───────────────────────── export function makeRng(seed) { let a = seed >>> 0; @@ -107,6 +121,74 @@ export function buildPath(points, step = 4) { }; } +// ── Level geometry lint ────────────────────────────────────────────────────── +// 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. + +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) { + const errs = []; + const path = buildPath(def.points); + const need = def.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)})`); + } + + let minFrog = Infinity; + let minRadius = Infinity; + 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; + } + if (i > 0 && i < path.samples.length - 1 && p.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 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 (outOfBounds) { + errs.push(`sample out of bounds at s=${outOfBounds.s.toFixed(0)} (${outOfBounds.x.toFixed(0)},${outOfBounds.y.toFixed(0)})`); + } + if (minFrog < TUNING.FROG_CLEARANCE) { + errs.push(`frog only ${minFrog.toFixed(0)}px from path (needs ${TUNING.FROG_CLEARANCE})`); + } + const minR = TUNING.BALL_RADIUS * 1.7; + if (minRadius < minR) { + errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} (needs ${minR.toFixed(0)})`); + } + + return { errs, length: path.length, minFrog, minRadius, minRadiusS }; +} + +// Range check on the non-geometric fields, shared with verifyZuma.js. +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 <= 100)) errs.push('pushSpeed must be 10..100'); + 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'); + } + return errs; +} + // ── Level / state construction ─────────────────────────────────────────────── export function createLevel(def, seed) { diff --git a/src/main.js b/src/main.js index 268f9be..97a142f 100644 --- a/src/main.js +++ b/src/main.js @@ -67,6 +67,7 @@ import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js'; import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; import ZumaGame from './games/zuma/ZumaGame.js'; +import ZumaEditor from './games/zuma/ZumaEditor.js'; import BejeweledGame from './games/bejeweled/BejeweledGame.js'; import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js'; import SlotsGame from './games/slots/SlotsGame.js'; @@ -181,6 +182,7 @@ const config = { MahjongGame, JewelQuestGame, ZumaGame, + ZumaEditor, BejeweledGame, MiniMotorwaysGame, SlotsGame, diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index a23baa0..71b7f60 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -236,6 +236,13 @@ export default class PreloadScene extends Phaser.Scene { return; } + // ...and the Zuma path editor: ?zuma-editor=1. + if (params.has('zuma-editor')) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + this.scene.start('ZumaEditor'); + return; + } + // Deep link: index.html?game= jumps straight to the same place a // main-menu click would (GameMenuScene.openGame's branching logic). const deepLinkGame = getGame(params.get('game')); diff --git a/tools/genZuma.js b/tools/genZuma.js index a548a04..d3d88fb 100644 --- a/tools/genZuma.js +++ b/tools/genZuma.js @@ -1,21 +1,24 @@ // Offline generator for Zuma levels. // -// Six hand-designed path shapes (parametric control-point emitters in 1920x1080 -// canvas space) crossed with a hand-written 20-row difficulty table. Each level -// is validated against the same geometry rules verifyZuma.js lints: path long -// enough for its ball quota, samples in bounds past the off-screen lead-in, -// curvature wide enough for the marbles, and the frog clear of the path. -// Writes ordered levels to data/zuma.json. +// Six path shapes in 1920x1080 canvas space crossed with a hand-written 20-row +// difficulty table. Every level is validated against ZumaLogic.validateLevel — +// the same lint verifyZuma.js and the in-game editor run — so this script +// refuses to write a bank the game would consider unplayable. // // Usage: -// node server/scripts/genZuma.js [outFile] +// node tools/genZuma.js [outFile] +// +// 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 +// at near-uniform point spacing (see Pen below). Hand-placed control points at +// irregular spacing make Catmull-Rom overshoot into cusps the lint rejects. // // Deterministic: shapes and the table are static. Re-run after changing either. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { buildPath, TUNING } from '../src/games/zuma/ZumaLogic.js'; +import { validateLevel, TUNING } from '../src/games/zuma/ZumaLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[2] @@ -24,155 +27,181 @@ const OUT_FILE = process.argv[2] const rad = (deg) => (deg * Math.PI) / 180; const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]); +const STEP = 70; // px between emitted control points + +// ── Pen: exact straights and circular arcs at uniform spacing ──────────────── + +class Pen { + constructor(x, y, headingDeg) { + this.x = x; this.y = y; this.h = rad(headingDeg); + this.pts = [[x, y]]; + } + + straight(len) { + const n = Math.max(1, Math.round(len / STEP)); + const dx = Math.cos(this.h), dy = Math.sin(this.h); + for (let i = 1; i <= n; i++) { + this.pts.push([this.x + dx * len * (i / n), this.y + dy * len * (i / n)]); + } + this.x += dx * len; this.y += dy * len; + return this; + } + + // deg > 0 curves clockwise on screen (toward +y), deg < 0 counter-clockwise + turn(radius, deg) { + const sgn = Math.sign(deg); + const cx = this.x + Math.cos(this.h + (sgn * Math.PI) / 2) * radius; + const cy = this.y + Math.sin(this.h + (sgn * Math.PI) / 2) * radius; + const a0 = Math.atan2(this.y - cy, this.x - cx); + const sweep = rad(deg); + const n = Math.max(2, Math.round((Math.abs(sweep) * radius) / STEP)); + for (let i = 1; i <= n; i++) { + const a = a0 + sweep * (i / n); + this.pts.push([cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]); + } + const a1 = a0 + sweep; + this.x = cx + Math.cos(a1) * radius; + this.y = cy + Math.sin(a1) * radius; + this.h += sweep; + return this; + } + + done() { return rp(this.pts); } +} + +// Offset-ellipse coil: `turns` revolutions shrinking from the full radius to +// fEnd of it. It starts at the top of the ellipse, where the tangent is +// horizontal, so the off-screen lead-in joins without a kink. fEnd also sets +// how close the innermost pass comes to the hub, where the frog sits. +function coil(cx, cy, rx, ry, turns, fEnd) { + const points = [[-80, cy - ry], [(cx - rx) / 2, cy - ry]]; + const steps = Math.round(turns * 22); + for (let i = 0; i <= steps; i++) { + const u = i / steps; + const th = -Math.PI / 2 + u * turns * 2 * Math.PI; + const f = 1 - u * (1 - fEnd); + points.push([cx + rx * f * Math.cos(th), cy + ry * f * Math.sin(th)]); + } + return rp(points); +} // ── Shapes: { points, frog } — first point is the off-screen spawn lead-in, // the last is the skull hole ───────────────────────────────────────────── +// Three-lane serpentine. Lane spacing 420 leaves a 210px mid-lane corridor for +// the frog, and the U-turn caps are true half-circles of that same radius. function sCurve() { - return { - points: [ - [-80, 300], [240, 220], [560, 300], [860, 460], [1120, 640], - [1400, 760], [1660, 700], [1790, 520], [1700, 330], [1500, 260], - ], - frog: [960, 920], - }; -} - -function horseshoe() { - return { - points: [ - [-80, 1000], [160, 900], [170, 650], [300, 380], [560, 190], [960, 130], - [1360, 190], [1620, 380], [1750, 650], [1700, 900], [1520, 990], - ], - frog: [960, 620], - }; -} - -function spiral() { - const cx = 960, cy = 580, rx = 820, ry = 430, turns = 2.1, fEnd = 0.36; - const points = [[-80, cy]]; - const steps = Math.round(turns * 16); // a control point every 22.5° - for (let i = 0; i <= steps; i++) { - const u = i / steps; - const th = Math.PI + u * turns * 2 * Math.PI; - const f = 1 - u * (1 - fEnd); - points.push([cx + rx * f * Math.cos(th), cy + ry * f * Math.sin(th)]); - } - return { points: rp(points), frog: [cx, cy] }; + const p = new Pen(-80, 140, 0); + p.straight(1580); // lane 1 + p.turn(210, 180); + p.straight(1240); // lane 2 + p.turn(210, -180); + p.straight(1240); // lane 3 + p.turn(190, -90).straight(220).turn(160, -90); // hook back inward + // Lower corridor, not the upper one: from here the frog has a clear line to + // every lane (measured 100% of a full chain reachable, vs 93% from above). + return { points: p.done(), frog: [880, 770] }; } +// Four tight switchbacks packed into the left two-thirds; the frog watches from +// the right margin, where no lane reaches. Stacked parallel lanes shield each +// other, so only ~57% of a full chain is reachable from anywhere legal — that +// is intrinsic to the shape, and why its levels carry smaller quotas than +// their neighbours rather than larger ones. function zigzag() { - return { - points: [ - [-80, 190], [300, 190], [800, 190], [1300, 190], [1560, 190], - [1720, 235], [1785, 355], [1720, 475], [1560, 520], - [1100, 520], [600, 520], [360, 520], - [200, 565], [135, 685], [200, 805], [360, 850], - [900, 850], [1400, 850], [1640, 880], [1750, 960], - ], - frog: [960, 685], - }; + const p = new Pen(-80, 130, 0); + p.straight(1375); + p.turn(140, 180); + p.straight(1045); + p.turn(140, -180); + p.straight(1045); + p.turn(140, 180); + p.straight(1045); + return { points: p.done(), frog: [1620, 550] }; } +// Wide, shallow coil — a turn and a third, hole well clear of the hub. +function horseshoe() { + return { points: coil(960, 580, 840, 450, 1.35, 0.6), frog: [960, 580] }; +} + +// Deep coil. fEnd is what keeps the innermost pass off the frog at the hub. +function spiral() { + return { points: coil(960, 575, 845, 455, 2.0, 0.5), frog: [960, 575] }; +} + +// Two full loops hanging off one lane, frog in the hub of the first. A 360 +// turn can only rejoin its straight tangentially, so the lane grazes each loop +// at exactly one point — that touch is the shape, not a defect. function doubleLoop() { - const A = { x: 540, y: 560, rx: 360, ry: 330 }; - const B = { x: 1380, y: 560, rx: 360, ry: 330 }; - const points = [[-80, 180], [200, 255]]; - for (let d = -90; d <= 200; d += 24) { - points.push([A.x + A.rx * Math.cos(rad(d)), A.y + A.ry * Math.sin(rad(d))]); - } - points.push([300, 210], [700, 120]); // arc over loop A to loop B's top - for (let d = -90; d <= 200; d += 24) { - points.push([B.x + B.rx * Math.cos(rad(d)), B.y + B.ry * Math.sin(rad(d))]); - } - // hole hook: continue the ring's exit direction, then curl into the center - points.push([1090, 330], [1200, 260], [1330, 300], [1390, 420], [1330, 520]); - return { points: rp(points), frog: [A.x, A.y] }; + const p = new Pen(-80, 300, 0); + p.straight(700).turn(300, 360); + p.straight(780).turn(300, 360); + p.straight(220).turn(230, 180).straight(260); + return { points: p.done(), frog: [620, 600] }; } +// 1:2 Lissajous traced once: enters mid-left, crosses itself at the centre, +// ends in the lower-left lobe. The crossing means the frog has to sit below it. function figureEight() { - // 1:2 Lissajous traced once: enters mid-left, crosses itself at center, - // ends in the lower-left lobe. - const cx = 960, cy = 560, ax = 820, ay = 430; + const cx = 960, cy = 505, ax = 830, ay = 375; const t0 = 1.5 * Math.PI; const t1 = t0 + 2 * Math.PI - 0.55; - // the left tip has a vertical tangent, so the lead-in climbs from below - const points = [[-80, 940], [60, 750]]; - const n = 44; + const points = [[-80, 830], [60, 690]]; + const n = 48; for (let i = 0; i <= n; i++) { const t = t0 + ((t1 - t0) * i) / n; points.push([cx + ax * Math.sin(t), cy + ay * Math.sin(2 * t)]); } - return { points: rp(points), frog: [550, 560] }; + return { points: rp(points), frog: [960, 990] }; } const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight }; // ── Difficulty table ───────────────────────────────────────────────────────── +// Quotas are in marbles, and a marble is BALL_SPACING of path, so they are +// bounded by each shape's length (the lint reports the ceiling). +// +// Difficulty is NOT just quota x pushSpeed: how much of the chain the frog can +// actually shoot varies hugely by shape (spiral/horseshoe ~100%, zigzag ~57%), +// so the zigzag rows carry deliberately small quotas. The aimbot soak in +// verifyZuma.js is the arbiter — every row here is tuned against its clear +// rate over several seeds, not against how the number looks in the column. const TABLE = [ // level, name, shape, colors, quota, intro, push, powerUpRate - [1, 'Riverbend', 'sCurve', 4, 28, 10, 22, 0.07], - [2, 'Temple Gate', 'horseshoe', 4, 32, 10, 24, 0.07], - [3, 'Twin Pools', 'doubleLoop', 4, 36, 10, 26, 0.065], - [4, 'Switchbacks', 'zigzag', 4, 40, 12, 26, 0.065], - [5, 'Serpent Coil', 'spiral', 4, 46, 12, 28, 0.06], - [6, 'Crossroads', 'figureEight', 4, 42, 12, 28, 0.06], - [7, 'Rapids', 'sCurve', 4, 30, 10, 34, 0.06], - [8, 'Sun Court', 'horseshoe', 5, 36, 10, 30, 0.055], - [9, 'Thunder Steps', 'zigzag', 5, 44, 12, 30, 0.055], - [10, 'Twin Serpents', 'doubleLoop', 5, 42, 12, 32, 0.055], - [11, 'Deep Coil', 'spiral', 5, 52, 14, 32, 0.05], - [12, 'Tangled Path', 'figureEight', 5, 46, 12, 34, 0.05], - [13, 'Lightning Run', 'zigzag', 5, 50, 14, 36, 0.05], - [14, 'Whirlpool', 'spiral', 5, 58, 14, 36, 0.05], - [15, 'Obsidian Gate', 'horseshoe', 6, 38, 10, 38, 0.05], - [16, 'Twin Tempests', 'doubleLoop', 6, 46, 12, 40, 0.05], - [17, 'Stormsteps', 'zigzag', 6, 54, 14, 42, 0.045], - [18, 'Maelstrom Cross', 'figureEight', 6, 50, 12, 44, 0.045], - [19, 'Abyss Coil', 'spiral', 6, 62, 14, 46, 0.045], - [20, 'The Final Coil', 'spiral', 6, 66, 16, 48, 0.045], + [1, 'Riverbend', 'sCurve', 4, 22, 7, 25, 0.07], + [2, 'Temple Gate', 'horseshoe', 4, 26, 8, 32, 0.07], + [3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 35, 0.065], + [4, 'Switchbacks', 'zigzag', 4, 32, 10, 35, 0.065], + [5, 'Serpent Coil', 'spiral', 4, 38, 11, 37, 0.06], + [6, 'Crossroads', 'figureEight', 4, 34, 10, 37, 0.06], + [7, 'Rapids', 'sCurve', 4, 26, 8, 45, 0.06], + [8, 'Sun Court', 'horseshoe', 5, 30, 9, 40, 0.055], + [9, 'Thunder Steps', 'zigzag', 5, 36, 11, 40, 0.055], + [10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 43, 0.055], + [11, 'Deep Coil', 'spiral', 5, 42, 12, 43, 0.05], + [12, 'Tangled Path', 'figureEight', 5, 38, 11, 45, 0.05], + [13, 'Lightning Run', 'zigzag', 5, 34, 10, 48, 0.05], + [14, 'Whirlpool', 'spiral', 5, 46, 13, 48, 0.05], + [15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 51, 0.05], + [16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 53, 0.05], + [17, 'Stormsteps', 'zigzag', 6, 34, 10, 50, 0.045], + [18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 50, 0.045], + [19, 'Abyss Coil', 'spiral', 6, 50, 14, 61, 0.045], + [20, 'The Final Coil', 'spiral', 6, 52, 14, 64, 0.045], ]; -// Calibrated against a headless aimbot (accurate shot every 450ms scores -// ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching. +// Calibrated against the headless aimbot in verifyZuma.js, which takes the best +// immediately available shot every 450ms and averages 51 points per quota +// marble. This curve puts two stars comfortably in its reach (18 of 20 levels) +// and three stars just past it (3 of 20) — the gap is the chain and combo play +// the bot never attempts, which is worth SCORE_CHAIN_BONUS a pop. function starScores(quota, pushSpeed) { - const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 10) * 10; + const top = Math.round((quota * (48 + pushSpeed * 0.2)) / 10) * 10; return [Math.round((top * 0.5) / 10) * 10, Math.round((top * 0.75) / 10) * 10, top]; } -// ── Validation (mirrors verifyZuma.js bank lint) ───────────────────────────── - -function validate(level) { - const errs = []; - const p = buildPath(level.points); - if (p.length < level.quota * TUNING.BALL_SPACING * 1.6) { - errs.push(`path ${p.length.toFixed(0)}px too short for quota ${level.quota}`); - } - let minFrog = Infinity, minRadius = Infinity, minRadiusS = 0; - for (let i = 0; i < p.samples.length; i++) { - const s = p.samples[i]; - minFrog = Math.min(minFrog, Math.hypot(s.x - level.frog[0], s.y - level.frog[1])); - if (s.s > 200 && (s.x < 40 || s.x > 1880 || s.y < 40 || s.y > 1040)) { - errs.push(`sample out of bounds at s=${s.s.toFixed(0)} (${s.x.toFixed(0)},${s.y.toFixed(0)})`); - break; - } - if (i > 0 && i < p.samples.length - 1 && s.s > 200) { - const a = p.samples[i - 1], c = p.samples[i + 1]; - 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 = s.s; } - } - } - } - if (minFrog < 140) errs.push(`frog only ${minFrog.toFixed(0)}px from path`); - if (minRadius < TUNING.BALL_RADIUS * 1.7) errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} of ${p.length.toFixed(0)}`); - return { errs, length: p.length, minFrog, minRadius }; -} - // ── Build & write ──────────────────────────────────────────────────────────── const levels = []; @@ -184,12 +213,13 @@ for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRat seed: 1000 + level * 7919, starScores: starScores(quota, pushSpeed), }; - const { errs, length, minFrog, minRadius } = validate(def); + const { errs, length, minFrog, minRadius } = validateLevel(def); + const cap = Math.floor(length / (TUNING.BALL_SPACING * 1.6)); if (errs.length) { bad++; console.error(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} INVALID: ${errs.join('; ')}`); } else { - console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${quota} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`); + console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${String(quota).padStart(2)}/${cap} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`); } levels.push(def); } diff --git a/tools/verifyZuma.js b/tools/verifyZuma.js index 873c624..e70e6d2 100644 --- a/tools/verifyZuma.js +++ b/tools/verifyZuma.js @@ -1,5 +1,5 @@ // Headless verification for Zuma. -// node server/scripts/verifyZuma.js +// node tools/verifyZuma.js // Exits non-zero on any failure. // // 1. Path construction (arc-length parameterization). @@ -11,6 +11,8 @@ // 7. Win/lose state machine, recolor, last-call spawns. // 8. Determinism (seeded replay). // 9. Level bank lint (data/zuma.json geometry + parameters). +// 10. Aimbot soak: every banked level is winnable, and the star curve sits +// above what mechanical play achieves. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -20,6 +22,7 @@ import { TUNING, POWER_KINDS, buildPath, createLevel, step, fireBall, swapBalls, insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent, + validateLevel, validateLevelParams, } from '../src/games/zuma/ZumaLogic.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -37,6 +40,11 @@ const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol; const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]]; const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]]; +// Fixture chains are laid out in multiples of the tuned spacing, never in raw +// pixels — otherwise resizing the marbles silently turns "contiguous" fixtures +// into gapped ones and the insertion tests start asserting nonsense. +const SP = T.BALL_SPACING; + function mkDef(over = {}) { return { level: 1, name: 'Test', shape: 'line', @@ -54,6 +62,65 @@ function mkState(defOver = {}, over = {}) { 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. + +// Walks the shot ray like rayHit does, but reports which ball it lands on. +function firstHit(st, angle) { + const dx = Math.cos(angle), dy = Math.sin(angle); + const stepLen = T.BALL_RADIUS / 2; + const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H); + 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 (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b; + } + 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. +function chooseShot(st) { + let best = null; + for (const target of st.balls) { + 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; + } + if (prev?.color === st.current && next?.color === st.current) score += 8; + if (score > 0 && (!best || score > best.score)) best = { angle, score }; + } + return best; +} + +function aimbotPlay(def, seed) { + const st = createLevel(def, seed ?? def.seed); + const DT = 25; + let sinceShot = 0; + for (let t = 0; t < 400000; t += DT) { + step(st, DT); + if (st.status === 'won' || st.status === 'lost') break; + if (st.status !== 'playing') continue; + sinceShot += DT; + if (sinceShot < 450) continue; + let shot = chooseShot(st); + if (!shot) { swapBalls(st); shot = chooseShot(st); } // try the on-deck colour + if (shot) { fireBall(st, shot.angle); sinceShot = 0; } + } + return { status: st.status, score: st.score }; +} + // spec: [{ color, s, power? }] front-first (descending s) function mkChain(st, spec) { st.balls = spec.map((b) => ({ @@ -114,9 +181,9 @@ console.log('\n— Advance & spawning —'); check('spacing invariant after spawning', spacingOk(st.balls)); const st2 = mkState({}, { status: 'playing', spawned: 10 }); - mkChain(st2, [{ color: 0, s: 696 }, { color: 1, s: 648 }, { color: 2, s: 600 }]); + 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, 796, 1), `got ${st2.balls[0].s.toFixed(1)}`); + 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)); } @@ -125,31 +192,33 @@ console.log('\n— Insertion —'); { const base = () => mkChain( mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 0, s: 600 }, { color: 1, s: 552 }, { color: 0, s: 504 }, { color: 1, s: 456 }, { color: 2, s: 408 }] + [{ color: 0, s: 600 }, { color: 1, s: 600 - SP }, { color: 0, s: 600 - 2 * SP }, + { color: 1, s: 600 - 3 * SP }, { color: 2, s: 600 - 4 * SP }] ); let st = base(); let ev = []; insertBall(st, 3, 2, +1, ev); check('front insert lands in front of hit ball', - st.balls[2].color === 3 && near(st.balls[2].s, 552, 0.01) && near(st.balls[3].s, 504, 0.01)); - check('front insert shoves balls ahead', near(st.balls[0].s, 648, 0.01) && near(st.balls[1].s, 600, 0.01)); + 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); 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); check('behind insert wedges after hit ball', - st.balls[3].color === 3 && near(st.balls[3].s, 504, 0.01) && near(st.balls[2].s, 552, 0.01)); + 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 = base(); ev = []; insertBall(st, 3, 4, -1, ev); check('tail attach adds at rear without shoving', - near(st.balls[5].s, 360, 0.01) && near(st.balls[0].s, 600, 0.01)); + near(st.balls[5].s, 600 - 5 * SP, 0.01) && near(st.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: 852 }, { color: 2, s: 780 }, { color: 3, s: 732 }]); + [{ 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); check('shove-merge emits clank', ev.some((e) => e.type === 'clank')); @@ -167,7 +236,7 @@ console.log('\n— Insertion —'); console.log('\n— Matching —'); { let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 1, s: 504 }, { color: 1, s: 456 }]); + [{ 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); const pop = ev.find((e) => e.type === 'pop'); @@ -176,7 +245,7 @@ console.log('\n— Matching —'); check('shot pop sets combo to 1', pop?.combo === 1); st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 0, s: 696 }, { color: 0, s: 648 }, { color: 0, s: 600 }, { color: 0, s: 552 }]); + [{ 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); const pop5 = ev.find((e) => e.type === 'pop'); @@ -184,7 +253,7 @@ console.log('\n— Matching —'); check('5-pop score', pop5?.score === 5 * T.SCORE_BALL); st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 0, s: 600 }, { color: 0, s: 450 }, { color: 1, s: 402 }]); + [{ 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); @@ -198,7 +267,7 @@ console.log('\n— Pull-back & catch-up —'); { // matching gap edges → front segment retreats, contact pops with chain bonus let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 1, s: 900 }, { color: 1, s: 852 }, { color: 1, s: 600 }, { color: 0, s: 552 }]); + [{ color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 1, s: 900 - 5 * SP }, { color: 0, s: 900 - 6 * SP }]); let popEv = null, clankSeen = false, retreated = false; for (let i = 0; i < 200 && !popEv; i++) { const ev = step(st, 25); @@ -215,14 +284,14 @@ console.log('\n— Pull-back & catch-up —'); // 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: 852 }, { color: 0, s: 600 }, { color: 1, s: 552 }]); + [{ 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 ev = step(st, 25); if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.balls[2].s }; } - check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 804, 1.5), + 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); @@ -236,7 +305,7 @@ console.log('\n— Power-ups —'); { // slow: popped slow ball sets the timer; drive rate drops to SLOW_MULT let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), - [{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 656 }]); + [{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 800 - 3 * SP }]); let ev = []; popRun(st, 0, 0, 'shot', ev); check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS @@ -259,7 +328,7 @@ console.log('\n— Power-ups —'); // 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: 656 }]); + [{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 800 - 3 * SP }]); ev = []; popRun(st, 0, 0, 'shot', ev); check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS); @@ -268,8 +337,9 @@ console.log('\n— Power-ups —'); // explosion: blast radius around the popped ball, nothing beyond st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [ - { color: 1, s: 900 }, { color: 1, s: 852 }, { color: 0, s: 804, power: 'explosion' }, - { color: 2, s: 756 }, { color: 2, s: 708 }, { color: 3, s: 660 }, { color: 3, s: 612 }, + { color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 2 * SP, power: 'explosion' }, + { color: 2, s: 900 - 3 * SP }, { color: 2, s: 900 - 4 * SP }, + { color: 3, s: 900 - 5 * SP }, { color: 3, s: 900 - 6 * SP }, ]); ev = []; popRun(st, 2, 2, 'shot', ev); @@ -320,7 +390,7 @@ console.log('\n— State machine —'); // last-call: final spawns only deal colors still on the board st = mkChain(mkState({}, { status: 'playing', spawned: 5 }), - [{ color: 3, s: 96 }, { color: 3, s: 48 }]); + [{ color: 3, s: 2 * SP }, { color: 3, s: SP }]); for (let i = 0; i < 400 && st.spawned < 10; i++) step(st, 50); check('last-call spawns restrict to present colors', st.spawned === 10 && st.balls.every((b) => b.color === 3)); @@ -359,44 +429,62 @@ console.log('\n— Level bank —'); const levels = bank.levels ?? []; check('bank has 20 levels', levels.length === 20); check('levels numbered 1..N contiguously', levels.every((l, i) => l.level === i + 1)); - let geomOk = true, paramOk = true, clearOk = true, curveOk = true, detail = ''; + // Geometry and parameters both come from ZumaLogic — the same lint + // genZuma.js gates on and the editor shows live, so the three can't drift. + let geomOk = true, paramOk = true, geomDetail = '', paramDetail = ''; for (const l of levels) { - if (!(l.colors >= 4 && l.colors <= 6 && l.quota >= 20 && l.introBalls < l.quota - && l.pushSpeed >= 10 && l.pushSpeed <= 80 - && l.powerUpRate >= 0 && l.powerUpRate <= 0.2 - && Array.isArray(l.starScores) && l.starScores.length === 3 - && l.starScores[0] < l.starScores[1] && l.starScores[1] < l.starScores[2])) { - paramOk = false; detail = `level ${l.level} params`; - } - const path = buildPath(l.points); - if (path.length < l.quota * T.BALL_SPACING * 1.6) { - geomOk = false; detail = `level ${l.level} too short (${path.length.toFixed(0)} for quota ${l.quota})`; - } - let minFrog = Infinity, minRadius = Infinity; - for (let i = 0; i < path.samples.length; i++) { - const p = path.samples[i]; - minFrog = Math.min(minFrog, Math.hypot(p.x - l.frog[0], p.y - l.frog[1])); - if (p.s > 200 && (p.x < 40 || p.x > 1880 || p.y < 40 || p.y > 1040)) { - geomOk = false; detail = `level ${l.level} sample out of bounds at s=${p.s.toFixed(0)}`; - } - if (i > 0 && i < path.samples.length - 1 && p.s > 200) { - 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 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) minRadius = Math.min(minRadius, l1 / theta); - } - } - } - if (minFrog < 140) { clearOk = false; detail = `level ${l.level} frog ${minFrog.toFixed(0)}px from path`; } - if (minRadius < T.BALL_RADIUS * 1.7) { curveOk = false; detail = `level ${l.level} min radius ${minRadius.toFixed(0)}px`; } + const perr = validateLevelParams(l); + if (perr.length) { paramOk = false; paramDetail = `level ${l.level}: ${perr[0]}`; } + const gerr = validateLevel(l).errs; + if (gerr.length) { geomOk = false; geomDetail = `level ${l.level}: ${gerr[0]}`; } } - check('level parameters in range', paramOk, detail); - check('paths long enough and in bounds', geomOk, detail); - check('frog clear of every path sample (≥140px)', clearOk, detail); - check('curvature radius ≥ 1.7 × ball radius', curveOk, detail); + check('level parameters in range', paramOk, paramDetail); + check(`geometry lint (length, bounds, curvature ≥ ${(T.BALL_RADIUS * 1.7).toFixed(0)}px, frog clear ≥ ${T.FROG_CLEARANCE}px)`, + geomOk, geomDetail); + + // ── 10. 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. + // + // Over SEEDS seeds, not one: a single seed makes this check hostage to + // chain luck, so any unrelated tuning nudge (moving the muzzle by 26px, + // say) flips levels between pass and fail for no real reason. + console.log('\n— Aimbot soak —'); + const SEEDS = 8; + const FLOOR = 5; // per-level clears required out of SEEDS + const rows = levels.map((l) => { + const runs = []; + for (let k = 0; k < SEEDS; k++) runs.push(aimbotPlay(l, l.seed + k * 7919)); + return { + level: l.level, + wins: runs.filter((r) => r.status === 'won').length, + best: Math.max(...runs.map((r) => r.score)), + mean: runs.reduce((a, r) => a + r.score, 0) / SEEDS, + }; + }); + + const weak = rows.filter((r) => r.wins < FLOOR); + check(`aimbot clears every level at least ${FLOOR}/${SEEDS} times`, weak.length === 0, + weak.map((r) => `L${r.level} ${r.wins}/${SEEDS}`).join(', ')); + const totalWins = rows.reduce((a, r) => a + r.wins, 0); + const rate = totalWins / (rows.length * SEEDS); + 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; + 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 + // sits relative to ordinary mechanical play. + const threeStar = rows.filter((r, i) => r.mean >= levels[i].starScores[2]).length; + const twoStar = rows.filter((r, i) => r.mean >= levels[i].starScores[1]).length; + const ceiling = rows.filter((r, i) => r.best >= levels[i].starScores[2]).length; + console.log(` .. aimbot medals (mean run): ★★★ on ${threeStar}, ★★+ on ${twoStar} of ${levels.length}` + + ` — ★★★ within reach on a lucky run for ${ceiling}`); + check('three stars stays a stretch for the aimbot', threeStar < levels.length / 2, `${threeStar}/${levels.length}`); + check('three stars is not out of reach either', ceiling >= 3, `only ${ceiling}/${levels.length} even on a best run`); + check('two stars is within the aimbot\'s reach', twoStar >= levels.length / 2, `${twoStar}/${levels.length}`); } }