From d51d026352a06499f7509341d12c6c9a8920e5e3 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 12 Jun 2026 00:15:51 -0600 Subject: [PATCH] feat: add Zuma marble-shooter game with 20 levels Implement a complete Zuma-style puzzle game featuring: - Pure game engine (`ZumaLogic.js`) with Catmull-Rom path animation, segment-based chain physics, match-3 popping, and power-ups (slow, reverse, accuracy, explosion) - Phaser scene (`ZumaGame.js`) with procedural textures, laser sight, and full UI (level select, overlays, scoring) - 20 hand-designed levels across 6 path shapes (s-curve, horseshoe, spiral, zigzag, double-loop, figure-eight) with calibrated difficulty - Level generator (`genZuma.js`) and verification suite (`verifyZuma.js`) for path geometry, parameter ranges, and engine correctness - Platform integration: game registry, scene dispatch, preload asset --- public/assets/images/game-icons.png | Bin 225878 -> 230671 bytes public/assets/images/game-icons.psd | Bin 602789 -> 613017 bytes public/data/zuma.json | 2682 +++++++++++++++++++++++++++ public/src/games/zuma/ZumaGame.js | 753 ++++++++ public/src/games/zuma/ZumaLogic.js | 548 ++++++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- public/src/scenes/PreloadScene.js | 1 + server/games/registry.js | 1 + server/scripts/genZuma.js | 207 +++ server/scripts/verifyZuma.js | 409 ++++ 11 files changed, 4604 insertions(+), 1 deletion(-) create mode 100644 public/data/zuma.json create mode 100644 public/src/games/zuma/ZumaGame.js create mode 100644 public/src/games/zuma/ZumaLogic.js create mode 100644 server/scripts/genZuma.js create mode 100644 server/scripts/verifyZuma.js diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 093b0f2228d15cf2e1f4c17bd5e969f80755a196..5bfb0d4fad6c9b132f306cba9a13b6c39904ef71 100644 GIT binary patch delta 15534 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 Kmyh-m?*9O;A?qms delta 10071 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 diff --git a/public/data/zuma.json b/public/data/zuma.json new file mode 100644 index 0000000..3700a57 --- /dev/null +++ b/public/data/zuma.json @@ -0,0 +1,2682 @@ +{ + "generatedAt": "2026-06-12T06:00:46.686Z", + "count": 20, + "levels": [ + { + "level": 1, + "name": "Riverbend", + "shape": "sCurve", + "points": [ + [ + -80, + 300 + ], + [ + 240, + 220 + ], + [ + 560, + 300 + ], + [ + 860, + 460 + ], + [ + 1120, + 640 + ], + [ + 1400, + 760 + ], + [ + 1660, + 700 + ], + [ + 1790, + 520 + ], + [ + 1700, + 330 + ], + [ + 1500, + 260 + ] + ], + "frog": [ + 960, + 920 + ], + "colors": 4, + "quota": 28, + "introBalls": 10, + "pushSpeed": 22, + "powerUpRate": 0.07, + "seed": 8919, + "starScores": [ + 550, + 820, + 1090 + ] + }, + { + "level": 2, + "name": "Temple Gate", + "shape": "horseshoe", + "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 + ], + "colors": 4, + "quota": 32, + "introBalls": 10, + "pushSpeed": 24, + "powerUpRate": 0.07, + "seed": 16838, + "starScores": [ + 640, + 960, + 1280 + ] + }, + { + "level": 3, + "name": "Twin Pools", + "shape": "doubleLoop", + "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 + ], + [ + 1330, + 520 + ] + ], + "frog": [ + 540, + 560 + ], + "colors": 4, + "quota": 36, + "introBalls": 10, + "pushSpeed": 26, + "powerUpRate": 0.065, + "seed": 24757, + "starScores": [ + 740, + 1110, + 1480 + ] + }, + { + "level": 4, + "name": "Switchbacks", + "shape": "zigzag", + "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 + ], + "colors": 4, + "quota": 40, + "introBalls": 12, + "pushSpeed": 26, + "powerUpRate": 0.065, + "seed": 32676, + "starScores": [ + 820, + 1230, + 1640 + ] + }, + { + "level": 5, + "name": "Serpent Coil", + "shape": "spiral", + "points": [ + [ + -80, + 580 + ], + [ + 140, + 580 + ], + [ + 215, + 420 + ], + [ + 397, + 290 + ], + [ + 654, + 207 + ], + [ + 946, + 182 + ], + [ + 1228, + 217 + ], + [ + 1460, + 303 + ], + [ + 1609, + 426 + ], + [ + 1656, + 567 + ], + [ + 1600, + 703 + ], + [ + 1452, + 815 + ], + [ + 1239, + 888 + ], + [ + 995, + 912 + ], + [ + 758, + 887 + ], + [ + 561, + 818 + ], + [ + 433, + 718 + ], + [ + 389, + 602 + ], + [ + 430, + 490 + ], + [ + 546, + 396 + ], + [ + 717, + 335 + ], + [ + 913, + 313 + ], + [ + 1104, + 331 + ], + [ + 1263, + 385 + ], + [ + 1368, + 463 + ], + [ + 1407, + 554 + ], + [ + 1378, + 642 + ], + [ + 1289, + 716 + ], + [ + 1159, + 764 + ], + [ + 1010, + 782 + ], + [ + 865, + 769 + ], + [ + 745, + 729 + ], + [ + 666, + 671 + ], + [ + 637, + 605 + ], + [ + 658, + 541 + ], + [ + 721, + 489 + ] + ], + "frog": [ + 960, + 580 + ], + "colors": 4, + "quota": 46, + "introBalls": 12, + "pushSpeed": 28, + "powerUpRate": 0.06, + "seed": 40595, + "starScores": [ + 970, + 1450, + 1930 + ] + }, + { + "level": 6, + "name": "Crossroads", + "shape": "figureEight", + "points": [ + [ + -80, + 940 + ], + [ + 60, + 750 + ], + [ + 140, + 560 + ], + [ + 147, + 449 + ], + [ + 168, + 346 + ], + [ + 202, + 257 + ], + [ + 249, + 189 + ], + [ + 308, + 145 + ], + [ + 378, + 130 + ], + [ + 458, + 144 + ], + [ + 547, + 186 + ], + [ + 642, + 253 + ], + [ + 743, + 341 + ], + [ + 848, + 443 + ], + [ + 954, + 554 + ], + [ + 1061, + 665 + ], + [ + 1166, + 769 + ], + [ + 1267, + 859 + ], + [ + 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, + 849 + ], + [ + 567, + 922 + ], + [ + 477, + 969 + ], + [ + 395, + 989 + ], + [ + 323, + 981 + ], + [ + 261, + 943 + ] + ], + "frog": [ + 550, + 560 + ], + "colors": 4, + "quota": 42, + "introBalls": 12, + "pushSpeed": 28, + "powerUpRate": 0.06, + "seed": 48514, + "starScores": [ + 880, + 1320, + 1760 + ] + }, + { + "level": 7, + "name": "Rapids", + "shape": "sCurve", + "points": [ + [ + -80, + 300 + ], + [ + 240, + 220 + ], + [ + 560, + 300 + ], + [ + 860, + 460 + ], + [ + 1120, + 640 + ], + [ + 1400, + 760 + ], + [ + 1660, + 700 + ], + [ + 1790, + 520 + ], + [ + 1700, + 330 + ], + [ + 1500, + 260 + ] + ], + "frog": [ + 960, + 920 + ], + "colors": 4, + "quota": 30, + "introBalls": 10, + "pushSpeed": 34, + "powerUpRate": 0.06, + "seed": 56433, + "starScores": [ + 680, + 1010, + 1350 + ] + }, + { + "level": 8, + "name": "Sun Court", + "shape": "horseshoe", + "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 + ], + "colors": 5, + "quota": 36, + "introBalls": 10, + "pushSpeed": 30, + "powerUpRate": 0.055, + "seed": 64352, + "starScores": [ + 780, + 1160, + 1550 + ] + }, + { + "level": 9, + "name": "Thunder Steps", + "shape": "zigzag", + "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 + ], + "colors": 5, + "quota": 44, + "introBalls": 12, + "pushSpeed": 30, + "powerUpRate": 0.055, + "seed": 72271, + "starScores": [ + 950, + 1420, + 1890 + ] + }, + { + "level": 10, + "name": "Twin Serpents", + "shape": "doubleLoop", + "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 + ], + [ + 1330, + 520 + ] + ], + "frog": [ + 540, + 560 + ], + "colors": 5, + "quota": 42, + "introBalls": 12, + "pushSpeed": 32, + "powerUpRate": 0.055, + "seed": 80190, + "starScores": [ + 930, + 1390, + 1850 + ] + }, + { + "level": 11, + "name": "Deep Coil", + "shape": "spiral", + "points": [ + [ + -80, + 580 + ], + [ + 140, + 580 + ], + [ + 215, + 420 + ], + [ + 397, + 290 + ], + [ + 654, + 207 + ], + [ + 946, + 182 + ], + [ + 1228, + 217 + ], + [ + 1460, + 303 + ], + [ + 1609, + 426 + ], + [ + 1656, + 567 + ], + [ + 1600, + 703 + ], + [ + 1452, + 815 + ], + [ + 1239, + 888 + ], + [ + 995, + 912 + ], + [ + 758, + 887 + ], + [ + 561, + 818 + ], + [ + 433, + 718 + ], + [ + 389, + 602 + ], + [ + 430, + 490 + ], + [ + 546, + 396 + ], + [ + 717, + 335 + ], + [ + 913, + 313 + ], + [ + 1104, + 331 + ], + [ + 1263, + 385 + ], + [ + 1368, + 463 + ], + [ + 1407, + 554 + ], + [ + 1378, + 642 + ], + [ + 1289, + 716 + ], + [ + 1159, + 764 + ], + [ + 1010, + 782 + ], + [ + 865, + 769 + ], + [ + 745, + 729 + ], + [ + 666, + 671 + ], + [ + 637, + 605 + ], + [ + 658, + 541 + ], + [ + 721, + 489 + ] + ], + "frog": [ + 960, + 580 + ], + "colors": 5, + "quota": 52, + "introBalls": 14, + "pushSpeed": 32, + "powerUpRate": 0.05, + "seed": 88109, + "starScores": [ + 1150, + 1720, + 2290 + ] + }, + { + "level": 12, + "name": "Tangled Path", + "shape": "figureEight", + "points": [ + [ + -80, + 940 + ], + [ + 60, + 750 + ], + [ + 140, + 560 + ], + [ + 147, + 449 + ], + [ + 168, + 346 + ], + [ + 202, + 257 + ], + [ + 249, + 189 + ], + [ + 308, + 145 + ], + [ + 378, + 130 + ], + [ + 458, + 144 + ], + [ + 547, + 186 + ], + [ + 642, + 253 + ], + [ + 743, + 341 + ], + [ + 848, + 443 + ], + [ + 954, + 554 + ], + [ + 1061, + 665 + ], + [ + 1166, + 769 + ], + [ + 1267, + 859 + ], + [ + 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, + 849 + ], + [ + 567, + 922 + ], + [ + 477, + 969 + ], + [ + 395, + 989 + ], + [ + 323, + 981 + ], + [ + 261, + 943 + ] + ], + "frog": [ + 550, + 560 + ], + "colors": 5, + "quota": 46, + "introBalls": 12, + "pushSpeed": 34, + "powerUpRate": 0.05, + "seed": 96028, + "starScores": [ + 1040, + 1550, + 2070 + ] + }, + { + "level": 13, + "name": "Lightning Run", + "shape": "zigzag", + "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 + ], + "colors": 5, + "quota": 50, + "introBalls": 14, + "pushSpeed": 36, + "powerUpRate": 0.05, + "seed": 103947, + "starScores": [ + 1150, + 1730, + 2300 + ] + }, + { + "level": 14, + "name": "Whirlpool", + "shape": "spiral", + "points": [ + [ + -80, + 580 + ], + [ + 140, + 580 + ], + [ + 215, + 420 + ], + [ + 397, + 290 + ], + [ + 654, + 207 + ], + [ + 946, + 182 + ], + [ + 1228, + 217 + ], + [ + 1460, + 303 + ], + [ + 1609, + 426 + ], + [ + 1656, + 567 + ], + [ + 1600, + 703 + ], + [ + 1452, + 815 + ], + [ + 1239, + 888 + ], + [ + 995, + 912 + ], + [ + 758, + 887 + ], + [ + 561, + 818 + ], + [ + 433, + 718 + ], + [ + 389, + 602 + ], + [ + 430, + 490 + ], + [ + 546, + 396 + ], + [ + 717, + 335 + ], + [ + 913, + 313 + ], + [ + 1104, + 331 + ], + [ + 1263, + 385 + ], + [ + 1368, + 463 + ], + [ + 1407, + 554 + ], + [ + 1378, + 642 + ], + [ + 1289, + 716 + ], + [ + 1159, + 764 + ], + [ + 1010, + 782 + ], + [ + 865, + 769 + ], + [ + 745, + 729 + ], + [ + 666, + 671 + ], + [ + 637, + 605 + ], + [ + 658, + 541 + ], + [ + 721, + 489 + ] + ], + "frog": [ + 960, + 580 + ], + "colors": 5, + "quota": 58, + "introBalls": 14, + "pushSpeed": 36, + "powerUpRate": 0.05, + "seed": 111866, + "starScores": [ + 1340, + 2000, + 2670 + ] + }, + { + "level": 15, + "name": "Obsidian Gate", + "shape": "horseshoe", + "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 + ], + "colors": 6, + "quota": 38, + "introBalls": 10, + "pushSpeed": 38, + "powerUpRate": 0.05, + "seed": 119785, + "starScores": [ + 900, + 1340, + 1790 + ] + }, + { + "level": 16, + "name": "Twin Tempests", + "shape": "doubleLoop", + "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 + ], + [ + 1330, + 520 + ] + ], + "frog": [ + 540, + 560 + ], + "colors": 6, + "quota": 46, + "introBalls": 12, + "pushSpeed": 40, + "powerUpRate": 0.05, + "seed": 127704, + "starScores": [ + 1110, + 1660, + 2210 + ] + }, + { + "level": 17, + "name": "Stormsteps", + "shape": "zigzag", + "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 + ], + "colors": 6, + "quota": 54, + "introBalls": 14, + "pushSpeed": 42, + "powerUpRate": 0.045, + "seed": 135623, + "starScores": [ + 1330, + 1990, + 2650 + ] + }, + { + "level": 18, + "name": "Maelstrom Cross", + "shape": "figureEight", + "points": [ + [ + -80, + 940 + ], + [ + 60, + 750 + ], + [ + 140, + 560 + ], + [ + 147, + 449 + ], + [ + 168, + 346 + ], + [ + 202, + 257 + ], + [ + 249, + 189 + ], + [ + 308, + 145 + ], + [ + 378, + 130 + ], + [ + 458, + 144 + ], + [ + 547, + 186 + ], + [ + 642, + 253 + ], + [ + 743, + 341 + ], + [ + 848, + 443 + ], + [ + 954, + 554 + ], + [ + 1061, + 665 + ], + [ + 1166, + 769 + ], + [ + 1267, + 859 + ], + [ + 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, + 849 + ], + [ + 567, + 922 + ], + [ + 477, + 969 + ], + [ + 395, + 989 + ], + [ + 323, + 981 + ], + [ + 261, + 943 + ] + ], + "frog": [ + 550, + 560 + ], + "colors": 6, + "quota": 50, + "introBalls": 12, + "pushSpeed": 44, + "powerUpRate": 0.045, + "seed": 143542, + "starScores": [ + 1250, + 1880, + 2500 + ] + }, + { + "level": 19, + "name": "Abyss Coil", + "shape": "spiral", + "points": [ + [ + -80, + 580 + ], + [ + 140, + 580 + ], + [ + 215, + 420 + ], + [ + 397, + 290 + ], + [ + 654, + 207 + ], + [ + 946, + 182 + ], + [ + 1228, + 217 + ], + [ + 1460, + 303 + ], + [ + 1609, + 426 + ], + [ + 1656, + 567 + ], + [ + 1600, + 703 + ], + [ + 1452, + 815 + ], + [ + 1239, + 888 + ], + [ + 995, + 912 + ], + [ + 758, + 887 + ], + [ + 561, + 818 + ], + [ + 433, + 718 + ], + [ + 389, + 602 + ], + [ + 430, + 490 + ], + [ + 546, + 396 + ], + [ + 717, + 335 + ], + [ + 913, + 313 + ], + [ + 1104, + 331 + ], + [ + 1263, + 385 + ], + [ + 1368, + 463 + ], + [ + 1407, + 554 + ], + [ + 1378, + 642 + ], + [ + 1289, + 716 + ], + [ + 1159, + 764 + ], + [ + 1010, + 782 + ], + [ + 865, + 769 + ], + [ + 745, + 729 + ], + [ + 666, + 671 + ], + [ + 637, + 605 + ], + [ + 658, + 541 + ], + [ + 721, + 489 + ] + ], + "frog": [ + 960, + 580 + ], + "colors": 6, + "quota": 62, + "introBalls": 14, + "pushSpeed": 46, + "powerUpRate": 0.045, + "seed": 151461, + "starScores": [ + 1580, + 2370, + 3160 + ] + }, + { + "level": 20, + "name": "The Final Coil", + "shape": "spiral", + "points": [ + [ + -80, + 580 + ], + [ + 140, + 580 + ], + [ + 215, + 420 + ], + [ + 397, + 290 + ], + [ + 654, + 207 + ], + [ + 946, + 182 + ], + [ + 1228, + 217 + ], + [ + 1460, + 303 + ], + [ + 1609, + 426 + ], + [ + 1656, + 567 + ], + [ + 1600, + 703 + ], + [ + 1452, + 815 + ], + [ + 1239, + 888 + ], + [ + 995, + 912 + ], + [ + 758, + 887 + ], + [ + 561, + 818 + ], + [ + 433, + 718 + ], + [ + 389, + 602 + ], + [ + 430, + 490 + ], + [ + 546, + 396 + ], + [ + 717, + 335 + ], + [ + 913, + 313 + ], + [ + 1104, + 331 + ], + [ + 1263, + 385 + ], + [ + 1368, + 463 + ], + [ + 1407, + 554 + ], + [ + 1378, + 642 + ], + [ + 1289, + 716 + ], + [ + 1159, + 764 + ], + [ + 1010, + 782 + ], + [ + 865, + 769 + ], + [ + 745, + 729 + ], + [ + 666, + 671 + ], + [ + 637, + 605 + ], + [ + 658, + 541 + ], + [ + 721, + 489 + ] + ], + "frog": [ + 960, + 580 + ], + "colors": 6, + "quota": 66, + "introBalls": 16, + "pushSpeed": 48, + "powerUpRate": 0.045, + "seed": 159380, + "starScores": [ + 1720, + 2570, + 3430 + ] + } + ] +} \ No newline at end of file diff --git a/public/src/games/zuma/ZumaGame.js b/public/src/games/zuma/ZumaGame.js new file mode 100644 index 0000000..3887ef4 --- /dev/null +++ b/public/src/games/zuma/ZumaGame.js @@ -0,0 +1,753 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +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, +} 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, overlay: 60, overlayUI: 62 }; + +// glossy marble palette: red, yellow, blue, green, purple, silver +const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3]; + +// 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 +}; + +export default class ZumaGame extends Phaser.Scene { + constructor() { super('ZumaGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'zuma', name: 'Zuma' }; + this.bank = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + + this.level = 0; + this.levelDef = null; + this.state = null; + this.overlayUp = false; + this.aimAngle = -Math.PI / 2; + this.ballSprites = new Map(); // ball id -> { img, icon } + this.flightSprites = new Map(); // flight id -> img + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2); + + const raw = this.cache.json.get('zuma'); + this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await api.get('/puzzles/zuma/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.layer = this.add.container(0, 0); + this.buildTextures(); + this.bindInput(); + this.showLevelSelect(); + } + + // ── Generated textures ────────────────────────────────────────────────────── + + 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; + 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.fillEllipse(R - R * 0.35, R - R * 0.45, R * 0.5, R * 0.32); + g.fillStyle(0xffffff, 0.25); + g.fillEllipse(R + R * 0.3, R + R * 0.55, R * 0.5, R * 0.2); + g.generateTexture(key, 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.destroy(); + } + + const icon = (key, draw) => { + if (this.textures.exists(key)) return; + const g = this.add.graphics(); + draw(g); + g.generateTexture(key, 30, 30); + 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); + }); + 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); + }); + 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); + }); + 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 + ); + } + g.fillCircle(15, 15, 5); + }); + } + + bindInput() { + this.input.mouse?.disableContextMenu(); + this.input.on('pointermove', (p) => { + if (this.view !== 'play' || !this.state) return; + this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x); + }); + this.input.on('pointerdown', (p) => { + if (this.view !== 'play' || this.overlayUp || !this.state) return; + this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x); + if (p.rightButtonDown()) { this.doSwap(); return; } + const flight = fireBall(this.state, this.aimAngle); + if (flight) playScifiWoosh(this); + }); + this.input.keyboard.on('keydown-SPACE', (e) => { + e.preventDefault?.(); + if (this.view === 'play' && !this.overlayUp && this.state) this.doSwap(); + }); + } + + doSwap() { + swapBalls(this.state); + playSound(this, SFX.CARD_PLACE); + } + + clearLayer() { + this.layer.removeAll(true); + this.ballSprites = new Map(); + this.flightSprites = new Map(); + this.frog = null; + this.frogCurrent = null; + this.frogNext = null; + this.laserGfx = null; + this.quotaGfx = null; + this.scoreText = null; + this.effectsText = null; + this.readyText = null; + } + + bestStars(level) { + try { return Number(localStorage.getItem(`zuma-stars-${level}`)) || 0; } catch (_) { return 0; } + } + + saveStars(level, stars) { + try { + if (stars > this.bestStars(level)) localStorage.setItem(`zuma-stars-${level}`, String(stars)); + } catch (_) { /* ignore */ } + } + + // Clearing the level is 1 star; the bigger score thresholds add the rest. + medalStars(score, starScores) { + let stars = 0; + for (const t of starScores) if (score >= t) stars++; + return Math.max(1, stars); + } + + // ── Level select ──────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.overlayUp = false; + this.state = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 84, 'ZUMA', { + fontFamily: 'Righteous', fontSize: '72px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 148, 'Shoot marbles into the rolling chain — match 3+ to pop them all before the skull feeds. Right-click or SPACE swaps.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + if (!this.bank.length) { + const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genZuma.js', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', + }).setOrigin(0.5); + this.layer.add(msg); + const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); + this.layer.add(back); + return; + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); + const prog = this.add.text(cx, 192, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add(prog); + + const COLS = 10; + const SIZE = 120; + const GAP = 16; + const gridW = COLS * SIZE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const top = 300; + + this.bank.forEach((l, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = left + col * (SIZE + GAP); + const y = top + row * (SIZE + GAP + 26); + const level = l.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + + const fill = cleared ? 0x1d4023 : playable ? 0x17301c : 0x101a12; + const stroke = cleared ? 0x6fd47e : playable ? COLORS.gold : 0x22351f; + const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - 22, String(level), { + fontFamily: 'Righteous', fontSize: '40px', + color: playable || cleared ? COLORS.textHex : '#4a5e4a', + }).setOrigin(0.5); + this.layer.add([tile, num]); + + if (playable || cleared) { + const earned = this.bestStars(level); + const stars = this.add.text(x, y + 22, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), { + fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#5d7a5d', + }).setOrigin(0.5); + const name = this.add.text(x, y + 48, l.name, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([stars, name]); + } else { + const lock = this.add.text(x, y + 30, 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#4a5e4a', + }).setOrigin(0.5); + this.layer.add(lock); + } + + if (playable) { + tile.setInteractive({ useHandCursor: true }); + tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); + tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); + tile.on('pointerup', () => this.playLevel(level)); + } + }); + + const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), + { width: 300, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 76, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.layer.add([resume, back, reset]); + + if (!this.canPersist) { + const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(note); + } + } + + confirmResetProgress() { + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20); + const title = this.add.text(cx, cy - 92, 'Reset Progress?', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5); + const msg = this.add.text(cx, cy - 14, + 'This clears every cleared level and your star\nmedals, back to Level 1. This cannot be undone.', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5); + const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(), + { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }); + const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), + { variant: 'ghost', width: 250, height: 58, fontSize: 24 }); + this.layer.add([dim, panel, title, msg, yes, no]); + } + + doResetProgress() { + api.post('/puzzles/zuma/reset').catch(() => { /* best effort */ }); + this.levelsCompleted = 0; + try { this.bank.forEach((l) => localStorage.removeItem(`zuma-stars-${l.level}`)); } catch (_) { /* ignore */ } + this.showLevelSelect(); + } + + // ── Play a level ──────────────────────────────────────────────────────────── + + playLevel(level) { + const def = this.bank.find((l) => l.level === level); + if (!def) return; + this.view = 'play'; + this.level = level; + this.levelDef = def; + this.state = createLevel(def, def.seed); + this.overlayUp = false; + this.aimAngle = -Math.PI / 2; + + this.clearLayer(); + this.drawPath(); + this.drawHole(); + this.buildFrog(); + this.laserGfx = this.add.graphics().setDepth(D.laser); + this.layer.add(this.laserGfx); + this.drawHud(); + + this.readyText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'GET READY…', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.fx); + this.layer.add(this.readyText); + } + + drawPath() { + const samples = this.state.path.samples; + const g = this.add.graphics().setDepth(D.path); + g.lineStyle(TUNE.PATH_W_RIM, PATH_RIM, 1); + g.beginPath(); + g.moveTo(samples[0].x, samples[0].y); + for (const s of samples) g.lineTo(s.x, s.y); + g.strokePath(); + g.lineStyle(TUNE.PATH_W_BED, PATH_BED, 1); + g.beginPath(); + g.moveTo(samples[0].x, samples[0].y); + for (const s of samples) g.lineTo(s.x, s.y); + g.strokePath(); + // dotted center groove + g.fillStyle(0x6b5638, 0.55); + let nextDot = 0; + for (const s of samples) { + if (s.s >= nextDot) { + g.fillCircle(s.x, s.y, 3); + nextDot += TUNE.GROOVE_STEP; + } + } + this.layer.add(g); + } + + drawHole() { + 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); + // 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); + c.add(g); + this.layer.add(c); + this.tweens.add({ targets: c, scale: 1.08, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + + 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); + + 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.frog = c; + this.layer.add(c); + } + + drawHud() { + const title = this.add.text(40, 50, `ZUMA — Level ${this.level}: ${this.levelDef.name}`, { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.scoreText = this.add.text(GAME_WIDTH - 50, 50, '', { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.effectsText = this.add.text(GAME_WIDTH - 50, 92, '', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffd54a', + }).setOrigin(1, 0.5).setDepth(D.ui); + 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 restart = new Button(this, 330, GAME_HEIGHT - 60, 'Restart', () => this.playLevel(this.level), + { width: 180, height: 52, fontSize: 22, variant: 'ghost' }); + this.layer.add([levels, restart]); + + const tip = this.add.text(GAME_WIDTH - 50, GAME_HEIGHT - 56, 'Click to shoot • Right-click / SPACE to swap', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.layer.add(tip); + } + + updateHud() { + const st = this.state; + if (this.scoreText) this.scoreText.setText(`Score: ${st.score}`); + + if (this.effectsText) { + const now = st.elapsedMs; + const parts = []; + if (now < st.effects.slowUntil) parts.push(`SLOW ${Math.ceil((st.effects.slowUntil - now) / 1000)}s`); + if (now < st.effects.reverseUntil) parts.push('REVERSE'); + if (now < st.effects.accuracyUntil) parts.push(`LASER ${Math.ceil((st.effects.accuracyUntil - now) / 1000)}s`); + this.effectsText.setText(parts.join(' ')); + } + + // quota bar: gold drains as the spawner empties, green = balls left on path + const g = this.quotaGfx; + if (!g) return; + const W = 460, H = 18; + const x = GAME_WIDTH / 2 - W / 2, y = 42; + g.clear(); + g.fillStyle(0x0a120b, 0.9); + g.fillRoundedRect(x - 3, y - 3, W + 6, H + 6, 8); + const toCome = (st.quota - st.spawned) / st.quota; + g.fillStyle(COLORS.gold, 1); + g.fillRoundedRect(x, y, Math.max(2, W * toCome), H, 6); + g.lineStyle(2, 0x6b5638, 1); + g.strokeRoundedRect(x - 3, y - 3, W + 6, H + 6, 8); + } + + // ── FX helpers ────────────────────────────────────────────────────────────── + + floatText(x, y, str, color, size = 30) { + const t = this.add.text(x, y, str, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, + }).setOrigin(0.5).setDepth(D.fx); + this.layer.add(t); + this.tweens.add({ + targets: t, y: y - 70, alpha: 0, duration: 900, ease: 'Quad.easeOut', + onComplete: () => t.destroy(), + }); + } + + 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 p = this.add.image(x, y, 'zuma-glow').setTint(tint).setDepth(D.fx).setScale(1.2 * scale); + this.layer.add(p); + this.tweens.add({ + targets: p, x: x + Math.cos(a) * dist, y: y + Math.sin(a) * dist, + alpha: 0, scale: 0.2, duration: TUNE.POP_FX_MS, ease: 'Quad.easeOut', + onComplete: () => p.destroy(), + }); + } + } + + // ── Frame loop ────────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.view !== 'play' || this.overlayUp || !this.state) return; + const st = this.state; + const events = step(st, delta); + + for (const e of events) this.handleEvent(e); + + 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}`); + } + + // laser sight while accuracy is active + if (this.laserGfx) { + 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.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.updateHud(); + } + + handleEvent(e) { + switch (e.type) { + case 'ready': + if (this.readyText) { + this.readyText.setText('FIRE!'); + playSound(this, SFX.SCIFI_REVEAL); + this.tweens.add({ + targets: this.readyText, alpha: 0, scale: 1.6, duration: 600, ease: 'Quad.easeOut', + onComplete: () => { this.readyText?.destroy(); this.readyText = null; }, + }); + } + break; + case 'inserted': { + playSound(this, SFX.PIECE_CLICK); + // squeeze-in: the sprite appears via syncChain, then pops to size + const ball = this.state.balls.find((b) => b.id === e.id); + if (ball) this.makeBallSprite(ball, true); + break; + } + case 'clank': + playSound(this, SFX.PIECE_CLICK); + break; + case 'pop': { + playSound(this, SFX.MASTERMIND_MATCH); + this.burst(e.x, e.y, BALL_COLORS[e.color], 10); + this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a'); + if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36); + else if (e.combo > 1) this.floatText(e.x, e.y - 64, `COMBO x${e.combo}`, '#6fd47e', 34); + break; + } + case 'explosion': + playScifiExplode(this); + this.burst(e.x, e.y, 0xffa726, 16, 2.2); + this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36); + break; + case 'powerup': + playSound(this, SFX.SCIFI_REVEAL); + this.floatText(e.x, e.y - 44, e.kind.toUpperCase(), '#ffd54a', 32); + break; + case 'lost': + this.onLost(); + break; + case 'won': + this.onWon(e.timeBonus); + break; + default: + break; + } + } + + 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); + let icon = null; + if (ball.power) { + icon = this.add.image(ball.x, ball.y, `zuma-pw-${ball.power}`).setDepth(D.icon); + this.layer.add(icon); + 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' }); + } + const entry = { img, icon }; + this.ballSprites.set(ball.id, entry); + return entry; + } + + syncChain() { + const seen = new Set(); + for (const b of this.state.balls) { + seen.add(b.id); + const spr = this.makeBallSprite(b); + spr.img.setPosition(b.x, b.y); + 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.ballSprites.delete(id); + } + } + } + + syncFlights() { + 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); + } + img.setPosition(f.x, f.y); + } + for (const [id, img] of this.flightSprites) { + if (!seen.has(id)) { + img.destroy(); + this.flightSprites.delete(id); + } + } + } + + // ── End states ────────────────────────────────────────────────────────────── + + onLost() { + this.overlayUp = true; + 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 */ }); + + // 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] : [])], + x: end.x, y: end.y, scale: 0.2, alpha: 0, + duration: 600, delay: i * 18, ease: 'Quad.easeIn', + }); + i++; + } + + this.time.delayedCall(Math.min(1400, 650 + i * 18), () => { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20); + const title = this.add.text(cx, cy - 90, 'The Skull Feeds!', { + fontFamily: 'Righteous', fontSize: '58px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, `The chain reached the hole. Score: ${this.state.score}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).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(), + { width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI); + this.layer.add([dim, panel, title, msg, retry, levels]); + }); + } + + onWon(timeBonus) { + this.overlayUp = true; + this.laserGfx?.clear(); + playSound(this, SFX.VICTORY_SHORT); + + 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 */ }); + + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const banner = this.add.text(cx, cy - 60, 'ZUMA!', { + fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex, + }).setOrigin(0.5).setScale(0.2).setDepth(D.fx); + this.layer.add(banner); + this.tweens.add({ targets: banner, scale: 1, duration: 450, ease: 'Back.easeOut' }); + + this.time.delayedCall(1100, () => { + banner.destroy(); + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20); + panel.lineStyle(3, COLORS.gold, 1); + panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20); + const title = this.add.text(cx, cy - 140, 'Path Cleared!', { + fontFamily: 'Righteous', fontSize: '60px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const starRow = this.add.text(cx, cy - 66, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { + fontFamily: 'serif', fontSize: '56px', color: '#ffd54a', + }).setOrigin(0.5).setDepth(D.overlayUI); + const stat = this.add.text(cx, cy - 6, + `Score: ${score} • Time bonus: +${timeBonus}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([dim, panel, title, starRow, stat]); + + if (stars < 3) { + const [, s2, s3] = this.levelDef.starScores; + const hint = this.add.text(cx, cy + 32, + `Bigger combos and chains earn more stars (★★ at ${s2}, ★★★ at ${s3}).`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(hint); + } + + const hasNext = this.level < this.bank.length; + if (hasNext) { + const next = new Button(this, cx, cy + 80, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1), + { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI); + this.layer.add(next); + } else { + const done = this.add.text(cx, cy + 72, 'You cleared every path. The skull goes hungry!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(done); + } + 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(), + { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + this.layer.add([replay, levels]); + }); + } +} diff --git a/public/src/games/zuma/ZumaLogic.js b/public/src/games/zuma/ZumaLogic.js new file mode 100644 index 0000000..897d16e --- /dev/null +++ b/public/src/games/zuma/ZumaLogic.js @@ -0,0 +1,548 @@ +// Zuma — pure game engine (no Phaser, no DOM, no timers). +// A marble-shooter: a chain of colored balls rolls along a curved path toward a +// skull hole; the player fires balls into the chain, popping runs of 3+. +// The scene (or a headless script) drives all timing through step(); every +// transition returns an ordered event list the renderer replays as FX. +// +// All geometry lives in path-space: each chain ball has an arc-length position +// `s` along the level's Catmull-Rom path (front of chain = largest s). +// Segments are derived, never stored — a gap exists between neighbors more +// than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball +// (b.x, b.y) every tick for flight collision and rendering. + +export const TUNING = { + BALL_RADIUS: 24, // px, marble radius + BALL_SPACING: 48, // 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 + 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 + ACCURACY_MS: 8000, + EXPLOSION_RADIUS: 110, // 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 + SCORE_BALL: 10, + SCORE_CHAIN_BONUS: 100, // extra per chain-reaction pop + TIME_PAR_MS_PER_BALL: 1500, // par clear time = quota * this + TIME_BONUS_PER_SEC: 25, // per second under par + MAX_STEP_MS: 50, // dt clamp so background tabs can't teleport + BOUNDS_PAD: 80, // flights are discarded this far off the canvas + BOUNDS_W: 1920, + BOUNDS_H: 1080, +}; + +export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion']; + +// ── Seeded RNG (mulberry32, matches genRushHour.js) ───────────────────────── +export function makeRng(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ── Path: Catmull-Rom through control points, arc-length parameterized ────── + +function crPoint(p0, p1, p2, p3, t) { + const t2 = t * t, t3 = t2 * t; + return { + x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3), + y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3), + }; +} + +// buildPath(points, step) -> { length, samples: [{x,y,s}], pointAt(s) } +// pointAt returns { x, y, tx, ty } with a unit tangent; s is clamped to [0, length]. +export function buildPath(points, step = 4) { + const pts = points.map(([x, y]) => ({ x, y })); + const P = [pts[0], ...pts, pts[pts.length - 1]]; // phantom endpoints + const samples = []; + let s = 0; + let prev = null; + for (let i = 0; i < pts.length - 1; i++) { + const chord = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + const n = Math.max(8, Math.ceil((chord * 1.5) / step)); + for (let k = (i === 0 ? 0 : 1); k <= n; k++) { + const pt = crPoint(P[i], P[i + 1], P[i + 2], P[i + 3], k / n); + if (prev) s += Math.hypot(pt.x - prev.x, pt.y - prev.y); + samples.push({ x: pt.x, y: pt.y, s }); + prev = pt; + } + } + const length = s; + return { + length, + samples, + pointAt(q) { + const qq = Math.max(0, Math.min(length, q)); + let lo = 0, hi = samples.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (samples[mid].s < qq) lo = mid + 1; else hi = mid; + } + const j = Math.max(1, lo); + const a = samples[j - 1], b = samples[j]; + const span = b.s - a.s || 1; + const f = (qq - a.s) / span; + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + return { x: a.x + dx * f, y: a.y + dy * f, tx: dx / len, ty: dy / len }; + }, + }; +} + +// ── Level / state construction ─────────────────────────────────────────────── + +export function createLevel(def, seed) { + const state = { + def, + path: buildPath(def.points), + frog: { x: def.frog[0], y: def.frog[1] }, + quota: def.quota, + spawned: 0, + balls: [], // front-first: balls[0] has the largest s + flights: [], // fired balls in screen space + rng: makeRng((seed ?? def.seed ?? 1) >>> 0), + status: 'intro', // 'intro' | 'playing' | 'won' | 'lost' + score: 0, + elapsedMs: 0, + combo: 0, // pops chained from the current shot + effects: { slowUntil: 0, reverseUntil: 0, accuracyUntil: 0 }, + nextId: 1, + current: 0, + next: 0, + }; + state.current = levelColor(state); + state.next = levelColor(state); + return state; +} + +function levelColor(state) { + return Math.floor(state.rng() * state.def.colors); +} + +export function colorsPresent(state) { + const set = new Set(); + for (const b of state.balls) set.add(b.color); + return set; +} + +function pickPresent(state, present) { + const list = [...present].sort((a, b) => a - b); + return list[Math.floor(state.rng() * list.length)]; +} + +// Shooter only deals colors still on the board (any level color when empty). +function shooterColor(state) { + const present = colorsPresent(state); + return present.size ? pickPresent(state, present) : levelColor(state); +} + +// ── Segments (derived from spacing, never stored) ──────────────────────────── + +export function segmentsOf(balls) { + const T = TUNING; + const segs = []; + if (!balls.length) return segs; + let start = 0; + for (let i = 0; i < balls.length - 1; i++) { + if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) { + segs.push({ start, end: i }); + start = i + 1; + } + } + segs.push({ start, end: balls.length - 1 }); + return segs; +} + +// Contiguous same-color run containing idx (never crosses a gap). +export function findRun(balls, idx) { + const T = TUNING; + const c = balls[idx].color; + let lo = idx, hi = idx; + while (lo > 0 && balls[lo - 1].color === c + && balls[lo - 1].s - balls[lo].s <= T.BALL_SPACING + T.GAP_EPS) lo--; + while (hi < balls.length - 1 && balls[hi + 1].color === c + && balls[hi].s - balls[hi + 1].s <= T.BALL_SPACING + T.GAP_EPS) hi++; + return { lo, hi }; +} + +function syncPositions(state) { + for (const b of state.balls) { + const p = state.path.pointAt(b.s); + b.x = p.x; b.y = p.y; + } +} + +// ── Chain movement ──────────────────────────────────────────────────────────── +// Per tick: the rearmost (spawner-fed) segment drives forward; per gap, a +// matching pair pulls the front side backward, a non-matching pair sends the +// rear side forward to catch up. Reverse overrides everything backward. +// Contacts merge implicitly (exact spacing); closed gaps clank + match-check. + +function moveSegments(state, dtMs, events) { + const T = TUNING; + const balls = state.balls; + if (!balls.length) return; + const dt = dtMs / 1000; + const now = state.elapsedMs; + const segs = segmentsOf(balls); + const vel = new Array(segs.length).fill(0); + + if (now < state.effects.reverseUntil) { + vel.fill(-T.REVERSE_SPEED); + } else { + let base = state.def.pushSpeed; + if (state.status === 'intro') base *= T.INTRO_SPEED_MULT; + if (now < state.effects.slowUntil) base *= T.SLOW_MULT; + vel[segs.length - 1] += base; + for (let g = 0; g < segs.length - 1; g++) { + const frontEdge = balls[segs[g].end]; // rear ball of front segment + const rearEdge = balls[segs[g + 1].start]; // front ball of rear segment + if (frontEdge.color === rearEdge.color) vel[g] -= T.PULLBACK_SPEED; + else vel[g + 1] = Math.max(vel[g + 1], T.CATCHUP_SPEED); + } + } + + // remember which pairs were gaps so we can clank when they close + const gapPairs = []; + for (let g = 0; g < segs.length - 1; g++) { + gapPairs.push([balls[segs[g].end].id, balls[segs[g + 1].start].id]); + } + + // apply movement front→rear: forward motion clamps against the (already + // moved) segment ahead; backward motion against the unmoved one behind. + for (let k = 0; k < segs.length; k++) { + let ds = vel[k] * dt; + if (ds === 0) continue; + if (ds > 0 && k > 0) { + const maxFront = balls[segs[k - 1].end].s - T.BALL_SPACING; + ds = Math.min(ds, maxFront - balls[segs[k].start].s); + if (ds < 0) ds = 0; + } + if (ds < 0) { + const floor = k < segs.length - 1 + ? balls[segs[k + 1].start].s + T.BALL_SPACING // segment behind + : 0; // path start + ds = Math.max(ds, floor - balls[segs[k].end].s); + if (ds > 0) ds = 0; + } + for (let i = segs[k].start; i <= segs[k].end; i++) balls[i].s += ds; + } + + // closed gaps: snap exact, clank, and match-check matching junctions + for (const [frontId, rearId] of gapPairs) { + const fi = balls.findIndex((b) => b.id === frontId); + if (fi < 0 || fi + 1 >= balls.length || balls[fi + 1].id !== rearId) continue; + const gap = balls[fi].s - balls[fi + 1].s; + if (gap > T.BALL_SPACING + T.GAP_EPS) continue; + if (gap < T.BALL_SPACING) balls[fi + 1].s = balls[fi].s - T.BALL_SPACING; + const p = state.path.pointAt(balls[fi].s); + events.push({ type: 'clank', x: p.x, y: p.y }); + if (balls[fi].color === balls[fi + 1].color) { + const run = findRun(balls, fi); + if (run.hi - run.lo + 1 >= T.MATCH_MIN) { + state.combo += 1; + popRun(state, run.lo, run.hi, 'chain', events); + } + } + } +} + +// ── Spawning ────────────────────────────────────────────────────────────────── + +function spawnColor(state) { + if (state.quota - state.spawned <= TUNING.LASTCALL_COUNT) { + const present = colorsPresent(state); + if (present.size) return pickPresent(state, present); + } + return levelColor(state); +} + +function spawnBalls(state, events) { + const T = TUNING; + while (state.spawned < state.quota) { + const rear = state.balls[state.balls.length - 1]; + if (rear && rear.s < T.BALL_SPACING) break; + const color = spawnColor(state); + let power = null; + if (state.rng() < (state.def.powerUpRate ?? 0)) { + power = POWER_KINDS[Math.floor(state.rng() * POWER_KINDS.length)]; + } + const b = { id: state.nextId++, color, power, s: rear ? rear.s - T.BALL_SPACING : 0, x: 0, y: 0 }; + const p = state.path.pointAt(b.s); + b.x = p.x; b.y = p.y; + state.balls.push(b); + state.spawned++; + events.push({ type: 'spawn', id: b.id }); + if (state.status === 'intro' && state.spawned >= (state.def.introBalls ?? 8)) { + state.status = 'playing'; + events.push({ type: 'ready' }); + } + } +} + +// ── Popping, power-ups, scoring ─────────────────────────────────────────────── + +export function popRun(state, lo, hi, cause, events) { + const T = TUNING; + const popped = state.balls.splice(lo, hi - lo + 1); + const mid = popped[Math.floor(popped.length / 2)]; + let score = popped.length * T.SCORE_BALL * Math.max(1, state.combo); + if (cause === 'chain') score += T.SCORE_CHAIN_BONUS; + state.score += score; + events.push({ + type: 'pop', ids: popped.map((b) => b.id), color: mid.color, + score, combo: state.combo, x: mid.x, y: mid.y, cause, + }); + const powers = popped.filter((b) => b.power); + for (const b of powers) applyPower(state, b, events); + recolorShooter(state, events); +} + +function applyPower(state, ball, events) { + const T = TUNING; + events.push({ type: 'powerup', kind: ball.power, x: ball.x, y: ball.y }); + if (ball.power === 'slow') state.effects.slowUntil = state.elapsedMs + T.SLOW_MS; + else if (ball.power === 'reverse') state.effects.reverseUntil = state.elapsedMs + T.REVERSE_MS; + else if (ball.power === 'accuracy') state.effects.accuracyUntil = state.elapsedMs + T.ACCURACY_MS; + else if (ball.power === 'explosion') { + // blast radius around the popped ball; chained power balls trigger too + const queue = [ball]; + while (queue.length) { + const src = queue.shift(); + const caught = state.balls.filter( + (b) => Math.hypot(b.x - src.x, b.y - src.y) <= T.EXPLOSION_RADIUS + ); + if (!caught.length) continue; + const ids = new Set(caught.map((b) => b.id)); + // splice in place: callers hold references to state.balls across popRun + for (let i = state.balls.length - 1; i >= 0; i--) { + if (ids.has(state.balls[i].id)) state.balls.splice(i, 1); + } + const score = caught.length * T.SCORE_BALL * Math.max(1, state.combo); + state.score += score; + events.push({ type: 'explosion', ids: [...ids], score, x: src.x, y: src.y }); + for (const b of caught) { + if (b.power === 'explosion') queue.push(b); + else if (b.power) applyPower(state, b, events); + } + } + } +} + +function recolorShooter(state, events) { + if (!state.balls.length) return; + const present = colorsPresent(state); + for (const slot of ['current', 'next']) { + if (!present.has(state[slot])) { + state[slot] = pickPresent(state, present); + events.push({ type: 'recolor', slot, color: state[slot] }); + } + } +} + +// ── Firing & insertion ──────────────────────────────────────────────────────── + +// Returns the flight object (renderer needs id + color), or null if rejected. +export function fireBall(state, angle) { + if (state.status !== 'playing') return null; + const T = TUNING; + const dx = Math.cos(angle), dy = Math.sin(angle); + const speed = T.SHOT_SPEED + * (state.elapsedMs < state.effects.accuracyUntil ? T.ACCURACY_SHOT_MULT : 1); + const flight = { + id: state.nextId++, color: state.current, + x: state.frog.x + dx * T.FROG_MUZZLE, y: state.frog.y + dy * T.FROG_MUZZLE, + dx, dy, speed, + }; + state.flights.push(flight); + state.current = state.next; + state.next = shooterColor(state); + return flight; +} + +export function swapBalls(state) { + if (state.status !== 'playing') return; + const t = state.current; + state.current = state.next; + state.next = t; +} + +// Wedge a fired ball into the chain at hitIdx. side: +1 in front of the hit +// ball (higher s), -1 behind. The front portion is shoved toward the hole — +// shoves can slam segments together (clank + junction match) and can lose the +// level by pushing the front ball into the skull. +export function insertBall(state, color, hitIdx, side, events) { + const T = TUNING; + const balls = state.balls; + const hit = balls[hitIdx]; + let insertIdx, s, push = true; + + if (side >= 0) { + insertIdx = hitIdx; + s = hit.s + T.BALL_SPACING; + } else { + insertIdx = hitIdx + 1; + const behind = balls[hitIdx + 1]; + if (!behind || hit.s - T.BALL_SPACING - behind.s >= T.BALL_SPACING - T.GAP_EPS) { + s = hit.s - T.BALL_SPACING; // tail attach: nothing moves + push = false; + } else { + s = hit.s; // wedge: hit ball and everything ahead shift + } + } + + // pairs that were gaps before the shove (to clank/match if the shove closes them) + const prevGaps = []; + for (let i = 0; i < balls.length - 1; i++) { + if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) prevGaps.push(balls[i].id); + } + + const ball = { id: state.nextId++, color, power: null, s, x: 0, y: 0 }; + balls.splice(insertIdx, 0, ball); + + if (push) { + for (let i = insertIdx - 1; i >= 0; i--) { + const minS = balls[i + 1].s + T.BALL_SPACING; + if (balls[i].s >= minS - 1e-7) break; + balls[i].s = minS; + } + } + syncPositions(state); + events.push({ type: 'inserted', id: ball.id, idx: insertIdx, x: ball.x, y: ball.y }); + + // shove-closed gaps + for (const frontId of prevGaps) { + const fi = balls.findIndex((b) => b.id === frontId); + if (fi < 0 || fi + 1 >= balls.length) continue; + if (balls[fi].s - balls[fi + 1].s > T.BALL_SPACING + T.GAP_EPS) continue; + events.push({ type: 'clank', x: balls[fi].x, y: balls[fi].y }); + if (balls[fi].color === balls[fi + 1].color) { + const run = findRun(balls, fi); + if (run.hi - run.lo + 1 >= T.MATCH_MIN) { + state.combo += 1; + popRun(state, run.lo, run.hi, 'chain', events); + } + } + } + + // match at the inserted ball (it may already be gone via a junction pop) + const idx = balls.indexOf(ball); + if (idx >= 0) { + const run = findRun(balls, idx); + if (run.hi - run.lo + 1 >= T.MATCH_MIN) { + state.combo = 1; + popRun(state, run.lo, run.hi, 'shot', events); + } else { + state.combo = 0; + } + } + checkLose(state, events); +} + +function stepFlights(state, dtMs, events) { + const T = TUNING; + for (let f = state.flights.length - 1; f >= 0; f--) { + const fl = state.flights[f]; + const dist = fl.speed * (dtMs / 1000); + const steps = Math.max(1, Math.ceil(dist / T.BALL_RADIUS)); + const stepLen = dist / steps; + let hitIdx = -1; + for (let k = 0; k < steps && hitIdx < 0; k++) { + fl.x += fl.dx * stepLen; + fl.y += fl.dy * stepLen; + let best = Infinity; + for (let i = 0; i < state.balls.length; i++) { + const b = state.balls[i]; + const d = Math.hypot(fl.x - b.x, fl.y - b.y); + if (d < T.BALL_SPACING * T.HIT_PAD && d < best) { best = d; hitIdx = i; } + } + } + if (hitIdx >= 0) { + state.flights.splice(f, 1); + const b = state.balls[hitIdx]; + const p = state.path.pointAt(b.s); + const side = ((fl.x - b.x) * p.tx + (fl.y - b.y) * p.ty) >= 0 ? 1 : -1; + insertBall(state, fl.color, hitIdx, side, events); + } else if (fl.x < -T.BOUNDS_PAD || fl.x > T.BOUNDS_W + T.BOUNDS_PAD + || fl.y < -T.BOUNDS_PAD || fl.y > T.BOUNDS_H + T.BOUNDS_PAD) { + state.flights.splice(f, 1); + events.push({ type: 'missed', id: fl.id }); + } + } +} + +// Aiming helper for the laser sight: first chain hit along a ray from the frog. +export function rayHit(state, angle) { + const T = TUNING; + const dx = Math.cos(angle), dy = Math.sin(angle); + const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H); + const stepLen = T.BALL_RADIUS / 2; + let x = state.frog.x + dx * T.FROG_MUZZLE; + let y = state.frog.y + dy * T.FROG_MUZZLE; + for (let d = 0; d < max; d += stepLen) { + for (const b of state.balls) { + if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { x, y, hit: true }; + } + x += dx * stepLen; + y += dy * stepLen; + } + return { x, y, hit: false }; +} + +// ── Win / lose ──────────────────────────────────────────────────────────────── + +function checkLose(state, events) { + if (state.status === 'won' || state.status === 'lost') return; + const front = state.balls[0]; + if (front && front.s >= state.path.length - TUNING.HOLE_GRACE) { + state.status = 'lost'; + events.push({ type: 'lost' }); + } +} + +function checkWin(state, events) { + if (state.status !== 'playing') return; + if (state.spawned >= state.quota && !state.balls.length && !state.flights.length) { + const T = TUNING; + const parMs = state.quota * T.TIME_PAR_MS_PER_BALL; + const timeBonus = Math.max(0, Math.ceil((parMs - state.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC; + state.score += timeBonus; + state.status = 'won'; + events.push({ type: 'won', timeBonus }); + } +} + +// ── Frame orchestrator ──────────────────────────────────────────────────────── + +export function step(state, dtMs) { + const events = []; + if (state.status === 'won' || state.status === 'lost') return events; + const dt = Math.min(dtMs, TUNING.MAX_STEP_MS); + state.elapsedMs += dt; + moveSegments(state, dt, events); + spawnBalls(state, events); + syncPositions(state); + checkLose(state, events); + if (state.status === 'lost') return events; + stepFlights(state, dt, events); + checkWin(state, events); + return events; +} diff --git a/public/src/main.js b/public/src/main.js index 886ae0e..e623f67 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -69,6 +69,7 @@ import BlockFighterGame from './games/blockfighter/BlockFighterGame.js'; 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'; const config = { type: Phaser.AUTO, @@ -151,6 +152,7 @@ const config = { MahjongMatchGame, MahjongGame, JewelQuestGame, + ZumaGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 34e2b38..5276e38 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index bd2ce05..f297d1a 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -65,6 +65,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('shift-artwork', '/data/shift-artwork.json'); this.load.json('blockfighter', '/data/blockfighter.json'); this.load.json('jewelquest', '/data/jewelquest.json'); + this.load.json('zuma', '/data/zuma.json'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); diff --git a/server/games/registry.js b/server/games/registry.js index d1888fc..7de12d0 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -84,3 +84,4 @@ registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: ' registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 57 }); registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 }); registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 }); +registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 }); diff --git a/server/scripts/genZuma.js b/server/scripts/genZuma.js new file mode 100644 index 0000000..350a2fa --- /dev/null +++ b/server/scripts/genZuma.js @@ -0,0 +1,207 @@ +// 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 public/data/zuma.json. +// +// Usage: +// node server/scripts/genZuma.js [outFile] +// +// 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 '../../public/src/games/zuma/ZumaLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT_FILE = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join(__dirname, '../../public/data/zuma.json'); + +const rad = (deg) => (deg * Math.PI) / 180; +const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]); + +// ── Shapes: { points, frog } — first point is the off-screen spawn lead-in, +// the last is the skull hole ───────────────────────────────────────────── + +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] }; +} + +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], + }; +} + +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] }; +} + +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 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; + 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] }; +} + +const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight }; + +// ── Difficulty table ───────────────────────────────────────────────────────── + +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], +]; + +// Calibrated against a headless aimbot (accurate shot every 450ms scores +// ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching. +function starScores(quota, pushSpeed) { + const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 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 = []; +let bad = 0; +for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate] of TABLE) { + const { points, frog } = SHAPES[shape](); + const def = { + level, name, shape, points, frog, colors, quota, introBalls, pushSpeed, powerUpRate, + seed: 1000 + level * 7919, + starScores: starScores(quota, pushSpeed), + }; + const { errs, length, minFrog, minRadius } = validate(def); + 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)}`); + } + levels.push(def); +} + +if (bad) { + console.error(`\n${bad} invalid level(s) — not writing ${OUT_FILE}`); + process.exit(1); +} + +fs.writeFileSync(OUT_FILE, JSON.stringify({ + generatedAt: new Date().toISOString(), + count: levels.length, + levels, +}, null, 1)); +console.log(`\nWrote ${levels.length} levels to ${OUT_FILE}`); diff --git a/server/scripts/verifyZuma.js b/server/scripts/verifyZuma.js new file mode 100644 index 0000000..8bce293 --- /dev/null +++ b/server/scripts/verifyZuma.js @@ -0,0 +1,409 @@ +// Headless verification for Zuma. +// node server/scripts/verifyZuma.js +// Exits non-zero on any failure. +// +// 1. Path construction (arc-length parameterization). +// 2. Chain advance, spawning, intro transition, spacing invariant. +// 3. Insertion (front/behind/tail wedges, shove-merge clank). +// 4. Match detection and scoring. +// 5. Pull-back chains and catch-up clanks. +// 6. Power-ups (slow, reverse, accuracy, explosion). +// 7. Win/lose state machine, recolor, last-call spawns. +// 8. Determinism (seeded replay). +// 9. Level bank lint (public/data/zuma.json geometry + parameters). + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + TUNING, POWER_KINDS, + buildPath, createLevel, step, fireBall, swapBalls, + insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent, +} from '../../public/src/games/zuma/ZumaLogic.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const T = TUNING; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} +const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]]; +const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]]; + +function mkDef(over = {}) { + return { + level: 1, name: 'Test', shape: 'line', + points: STRAIGHT, frog: [800, 900], + colors: 4, quota: 10, introBalls: 2, + pushSpeed: 100, powerUpRate: 0, seed: 42, + starScores: [100, 200, 300], + ...over, + }; +} + +function mkState(defOver = {}, over = {}) { + const st = createLevel(mkDef(defOver)); + Object.assign(st, over); + return st; +} + +// spec: [{ color, s, power? }] front-first (descending s) +function mkChain(st, spec) { + st.balls = spec.map((b) => ({ + id: st.nextId++, color: b.color, power: b.power ?? null, s: b.s, x: 0, y: 0, + })); + for (const b of st.balls) { + const p = st.path.pointAt(b.s); + b.x = p.x; b.y = p.y; + } + return st; +} + +function spacingOk(balls) { + for (let i = 0; i < balls.length - 1; i++) { + const d = balls[i].s - balls[i + 1].s; + if (d < T.BALL_SPACING - 0.01) return false; // overlap + if (d > T.BALL_SPACING + 0.01 && d <= T.BALL_SPACING + T.GAP_EPS) return false; // not snapped + } + return true; +} + +// ── 1. Path ────────────────────────────────────────────────────────────────── +console.log('\n— Path —'); +{ + const p = buildPath(STRAIGHT); + check('straight path length ≈ 1600', near(p.length, 1600, 3), `got ${p.length.toFixed(1)}`); + const a = p.pointAt(0), b = p.pointAt(p.length); + check('pointAt(0) at first control point', near(a.x, 0, 1) && near(a.y, 500, 1)); + check('pointAt(length) at last control point', near(b.x, 1600, 1) && near(b.y, 500, 1)); + + const c = buildPath(CURVY); + let maxErr = 0, maxTanErr = 0; + const stepS = 37; + for (let s = 0; s + stepS <= c.length; s += stepS) { + const u = c.pointAt(s), v = c.pointAt(s + stepS); + maxErr = Math.max(maxErr, Math.abs(Math.hypot(v.x - u.x, v.y - u.y) - stepS)); + maxTanErr = Math.max(maxTanErr, Math.abs(Math.hypot(u.tx, u.ty) - 1)); + } + check('curved path constant-speed (equal s → equal distance)', maxErr < stepS * 0.05, `max err ${maxErr.toFixed(2)}px`); + check('tangents unit length', maxTanErr < 1e-6); + let mono = true; + for (let i = 1; i < c.samples.length; i++) if (c.samples[i].s < c.samples[i - 1].s) mono = false; + check('sample arc lengths monotonic', mono); +} + +// ── 2. Advance & spawning ──────────────────────────────────────────────────── +console.log('\n— Advance & spawning —'); +{ + const st = mkState({ quota: 8, introBalls: 3 }); + let introSpawned = -1; + for (let i = 0; i < 4000 && st.status === 'intro'; i++) { + step(st, 16); + if (st.status !== 'intro') introSpawned = st.spawned; + } + check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`); + for (let i = 0; i < 4000 && st.spawned < 8; i++) step(st, 16); + check('spawn stops at quota', st.spawned === 8 && st.balls.length === 8); + check('spacing invariant after spawning', spacingOk(st.balls)); + + const st2 = mkState({}, { status: 'playing', spawned: 10 }); + mkChain(st2, [{ color: 0, s: 696 }, { color: 1, s: 648 }, { 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('spacing preserved while driving', spacingOk(st2.balls)); +} + +// ── 3. Insertion ───────────────────────────────────────────────────────────── +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 }] + ); + + 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)); + 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)); + 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)); + + // 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 }]); + ev = []; + insertBall(st, 3, 2, +1, ev); + check('shove-merge emits clank', ev.some((e) => e.type === 'clank')); + check('shove-merge joins segments', segmentsOf(st.balls).length === 1 && spacingOk(st.balls)); + check('shove-merge without matching junction does not pop', !ev.some((e) => e.type === 'pop')); + + // laser-sight ray helper + st = mkChain(mkState({}, { status: 'playing' }), [{ color: 0, s: 600 }]); + const ball = st.balls[0]; + const ray = rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x)); + check('rayHit finds first chain ball', ray.hit && Math.hypot(ray.x - ball.x, ray.y - ball.y) < T.BALL_SPACING); +} + +// ── 4. Matching ────────────────────────────────────────────────────────────── +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 }]); + let ev = []; + insertBall(st, 0, 1, +1, ev); + const pop = ev.find((e) => e.type === 'pop'); + check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.balls.length === 2); + check('3-pop score = 3 × SCORE_BALL', pop?.score === 3 * T.SCORE_BALL && st.score === 3 * T.SCORE_BALL); + check('shot pop sets combo to 1', pop?.combo === 1); + + st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), + [{ color: 0, s: 696 }, { color: 0, s: 648 }, { color: 0, s: 600 }, { color: 0, s: 552 }]); + ev = []; + insertBall(st, 0, 1, -1, ev); + const pop5 = ev.find((e) => e.type === 'pop'); + check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.balls.length === 0); + check('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 }]); + ev = []; + insertBall(st, 0, 1, +1, ev); + check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4); + + const run = findRun(st.balls, 1); + check('findRun bounded by the gap', run.lo === 1 && run.hi === 2); +} + +// ── 5. Pull-back & catch-up ────────────────────────────────────────────────── +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 }]); + let popEv = null, clankSeen = false, retreated = false; + for (let i = 0; i < 200 && !popEv; i++) { + const ev = step(st, 25); + if (st.balls.length && st.balls[0].s < 900 - 1) retreated = true; + if (ev.some((e) => e.type === 'clank')) clankSeen = true; + popEv = ev.find((e) => e.type === 'pop') ?? popEv; + } + check('matching gap pulls front segment backward', retreated); + check('pull-back contact clanks and pops', clankSeen && !!popEv && popEv.ids.length === 3); + check('chain pop scores chain bonus', popEv?.cause === 'chain' + && popEv?.score === 3 * T.SCORE_BALL + T.SCORE_CHAIN_BONUS); + check('chain pop increments combo', popEv?.combo === 1); + check('survivor remains after chain pop', st.balls.length === 1 && st.balls[0].color === 0); + + // 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 }]); + 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), + clankAt ? `rear front at ${clankAt.rearFront.toFixed(1)}` : 'no clank'); + check('non-matching gap: front segment stays put', !!clankAt && near(clankAt.frontBefore, 900, 0.01)); + check('no pop on non-matching junction', st.balls.length === 4); + const before = st.balls[0].s; + for (let i = 0; i < 8; i++) step(st, 50); + check('merged chain resumes driving', st.balls[0].s > before + 30); +} + +// ── 6. Power-ups ───────────────────────────────────────────────────────────── +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 }]); + let ev = []; + popRun(st, 0, 0, 'shot', ev); + check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS + && ev.some((e) => e.type === 'powerup' && e.kind === 'slow')); + const s0 = st.balls[0].s; + for (let i = 0; i < 20; i++) step(st, 50); + check('slow halves the drive (SLOW_MULT)', near(st.balls[0].s - s0, 100 * T.SLOW_MULT, 1.5), + `moved ${(st.balls[0].s - s0).toFixed(1)}`); + + // reverse: chain rolls backward, then resumes forward when expired + // (elapsedMs increments before the effect check, so 501 covers exactly 10 ticks) + st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [{ color: 0, s: 800 }]); + st.effects.reverseUntil = st.elapsedMs + 501; + for (let i = 0; i < 10; i++) step(st, 50); + check('reverse rolls the chain backward', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2), + `at ${st.balls[0].s.toFixed(1)}`); + for (let i = 0; i < 10; i++) step(st, 50); + check('drive resumes after reverse expires', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2), + `at ${st.balls[0].s.toFixed(1)}`); + + // 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 }]); + ev = []; + popRun(st, 0, 0, 'shot', ev); + check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS); + const flight = fireBall(st, -Math.PI / 2); + check('accuracy speeds up shots', !!flight && near(flight.speed, T.SHOT_SPEED * T.ACCURACY_SHOT_MULT, 0.01)); + + // 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 }, + ]); + ev = []; + popRun(st, 2, 2, 'shot', ev); + const boom = ev.find((e) => e.type === 'explosion'); + check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.balls.length === 2); + check('explosion spares balls beyond radius', st.balls.every((b) => b.color === 3)); +} + +// ── 7. State machine ───────────────────────────────────────────────────────── +console.log('\n— State machine —'); +{ + let st = mkState({}, { status: 'playing', spawned: 10 }); + mkChain(st, [{ color: 0, s: st.path.length - 20 }]); + let lostEv = false; + for (let i = 0; i < 10 && st.status === 'playing'; i++) { + if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true; + } + check('ball reaching the hole loses', st.status === 'lost' && lostEv); + check('terminal state ignores further steps', step(st, 50).length === 0); + + st = mkState({}, { status: 'playing', spawned: 10, balls: [], flights: [] }); + const ev = step(st, 16); + const won = ev.find((e) => e.type === 'won'); + const expectBonus = Math.max(0, Math.ceil((10 * T.TIME_PAR_MS_PER_BALL - st.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC; + check('cleared board after quota wins', st.status === 'won' && !!won); + check('win time bonus math', won?.timeBonus === expectBonus && st.score === expectBonus, + `bonus ${won?.timeBonus} expected ${expectBonus}`); + + st = mkState(); // status 'intro' + check('firing rejected during intro', fireBall(st, 0) === null); + st.status = 'won'; + check('firing rejected after game over', fireBall(st, 0) === null); + const c0 = st.current, n0 = st.next; + swapBalls(st); + check('swap rejected after game over', st.current === c0 && st.next === n0); + st.status = 'playing'; + swapBalls(st); + check('swap exchanges current and next', st.current === n0 && st.next === c0); + + // recolor: shooter colors must exist on the board after a pop + st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), + [{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 2, s: 504 }, { color: 2, s: 456 }]); + st.current = 0; st.next = 0; + const ev2 = []; + popRun(st, 0, 1, 'shot', ev2); + check('shooter recolors to colors still present', st.current === 2 && st.next === 2 + && ev2.filter((e) => e.type === 'recolor').length === 2); + + // last-call: final spawns only deal colors still on the board + st = mkChain(mkState({}, { status: 'playing', spawned: 5 }), + [{ color: 3, s: 96 }, { color: 3, s: 48 }]); + 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)); +} + +// ── 8. Determinism ─────────────────────────────────────────────────────────── +console.log('\n— Determinism —'); +{ + const run = () => { + const st = createLevel(mkDef({ quota: 20, powerUpRate: 0.1, seed: 777 })); + const log = []; + for (let tick = 0; tick < 600 && st.status !== 'lost' && st.status !== 'won'; tick++) { + if (tick === 80) fireBall(st, -Math.PI / 2 + 0.3); + if (tick === 160) swapBalls(st); + if (tick === 200) fireBall(st, -Math.PI / 2 - 0.2); + if (tick === 300) fireBall(st, -Math.PI / 2); + for (const e of step(st, 25)) log.push(e.type + (e.ids ? `:${e.ids.length}` : '')); + } + return { log: log.join(','), score: st.score, status: st.status, balls: st.balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|') }; + }; + const a = run(), b = run(); + check('identical seed + script → identical events', a.log === b.log); + check('identical final score and chain', a.score === b.score && a.balls === b.balls && a.status === b.status); + check('scripted run produced activity', a.log.includes('pop') || a.log.includes('inserted')); +} + +// ── 9. Level bank lint ─────────────────────────────────────────────────────── +console.log('\n— Level bank —'); +{ + let bank = null; + try { + bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/zuma.json'), 'utf8')); + } catch (_) { /* handled below */ } + check('bank exists (run genZuma.js)', !!bank); + if (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 = ''; + 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`; } + } + 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); + } +} + +// ── Result ─────────────────────────────────────────────────────────────────── +console.log(''); +if (failures) { + console.error(`${failures} failure(s)`); + process.exit(1); +} +console.log('All Zuma checks passed.');