diff --git a/assets/gamedata/peggle/level-001.json b/assets/gamedata/peggle/level-001.json new file mode 100644 index 0000000..0b2c18c --- /dev/null +++ b/assets/gamedata/peggle/level-001.json @@ -0,0 +1,391 @@ +{ + "version": 1, + "board": { + "width": 1200, + "height": 900 + }, + "orangeCount": 25, + "greenCount": 2, + "pegs": [ + { + "x": 974, + "y": 311, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 937, + "y": 371, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 890, + "y": 424, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 834, + "y": 469, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 772, + "y": 503, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 705, + "y": 527, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 635, + "y": 538, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 565, + "y": 538, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 495, + "y": 527, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 428, + "y": 503, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 366, + "y": 469, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 310, + "y": 424, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 263, + "y": 371, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 226, + "y": 311, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1053, + "y": 389, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 996, + "y": 456, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 929, + "y": 513, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 854, + "y": 559, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 773, + "y": 593, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 688, + "y": 613, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 512, + "y": 613, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 427, + "y": 593, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 346, + "y": 559, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 271, + "y": 513, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 204, + "y": 456, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 147, + "y": 389, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1095, + "y": 505, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1019, + "y": 570, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 935, + "y": 624, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 845, + "y": 666, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 749, + "y": 694, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 650, + "y": 708, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 550, + "y": 708, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 451, + "y": 694, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 355, + "y": 666, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 265, + "y": 624, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 181, + "y": 570, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 105, + "y": 505, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 160, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 258, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 356, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 453, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 551, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 649, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 747, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 844, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 942, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 1040, + "y": 770, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 320, + "y": 300, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 460, + "y": 300, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 600, + "y": 300, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 740, + "y": 300, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 880, + "y": 300, + "shape": "circle", + "r": 16, + "orangeEligible": false + } + ], + "walls": [], + "moving": [] +} diff --git a/assets/gamedata/peggle/level-002.json b/assets/gamedata/peggle/level-002.json new file mode 100644 index 0000000..b1a9c52 --- /dev/null +++ b/assets/gamedata/peggle/level-002.json @@ -0,0 +1,370 @@ +{ + "version": 1, + "board": { + "width": 1200, + "height": 900 + }, + "orangeCount": 25, + "greenCount": 2, + "pegs": [ + { + "x": 405, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 383, + "y": 491, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 326, + "y": 524, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 263, + "y": 512, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 221, + "y": 462, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 221, + "y": 398, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 262, + "y": 348, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 326, + "y": 336, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 383, + "y": 369, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 985, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 963, + "y": 491, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 906, + "y": 524, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 843, + "y": 512, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 801, + "y": 462, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 801, + "y": 398, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 843, + "y": 348, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 906, + "y": 336, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 963, + "y": 369, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 560, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 640, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 560, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 640, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 515, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 685, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 770, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 220, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 372, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 524, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 676, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 828, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 980, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 300, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 450, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 600, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 750, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 900, + "y": 620, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 300, + "y": 710, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 450, + "y": 710, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 600, + "y": 710, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 750, + "y": 710, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 900, + "y": 710, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 140, + "y": 320, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 180, + "y": 440, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 220, + "y": 560, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 1060, + "y": 320, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 980, + "y": 560, + "shape": "circle", + "r": 16, + "orangeEligible": false + } + ], + "walls": [], + "moving": [] +} diff --git a/assets/gamedata/peggle/level-003.json b/assets/gamedata/peggle/level-003.json new file mode 100644 index 0000000..c901d00 --- /dev/null +++ b/assets/gamedata/peggle/level-003.json @@ -0,0 +1,335 @@ +{ + "version": 1, + "board": { + "width": 1200, + "height": 900 + }, + "orangeCount": 25, + "greenCount": 2, + "pegs": [ + { + "x": 600, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 619, + "y": 309, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 639, + "y": 369, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 658, + "y": 428, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 677, + "y": 488, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 697, + "y": 547, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 716, + "y": 607, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 735, + "y": 666, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 685, + "y": 629, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 634, + "y": 593, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 584, + "y": 556, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 533, + "y": 519, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 482, + "y": 482, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 432, + "y": 446, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 381, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 444, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 506, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 569, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 694, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 756, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 819, + "y": 409, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 768, + "y": 446, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 718, + "y": 482, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 566, + "y": 593, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 515, + "y": 629, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 465, + "y": 666, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 484, + "y": 607, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 503, + "y": 547, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 561, + "y": 369, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 581, + "y": 309, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 972, + "y": 577, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 942, + "y": 638, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 901, + "y": 693, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 852, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 796, + "y": 779, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 404, + "y": 779, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 348, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 299, + "y": 693, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 258, + "y": 638, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 228, + "y": 577, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 350, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 460, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 740, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 850, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 960, + "y": 240, + "shape": "circle", + "r": 16, + "orangeEligible": false + } + ], + "walls": [], + "moving": [] +} diff --git a/assets/gamedata/peggle/level-004.json b/assets/gamedata/peggle/level-004.json new file mode 100644 index 0000000..832e4ee --- /dev/null +++ b/assets/gamedata/peggle/level-004.json @@ -0,0 +1,468 @@ +{ + "version": 1, + "board": { + "width": 1200, + "height": 900 + }, + "orangeCount": 25, + "greenCount": 2, + "pegs": [ + { + "x": 690, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 664, + "y": 534, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 560, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 536, + "y": 534, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 510, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 536, + "y": 406, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 380, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 664, + "y": 406, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 775, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 758, + "y": 546, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 709, + "y": 607, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 639, + "y": 641, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 561, + "y": 641, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 491, + "y": 607, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 442, + "y": 546, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 425, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 442, + "y": 394, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 491, + "y": 333, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 561, + "y": 299, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 639, + "y": 299, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 709, + "y": 333, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 758, + "y": 394, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 860, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 847, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 810, + "y": 623, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 753, + "y": 680, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 680, + "y": 717, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 520, + "y": 717, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 447, + "y": 680, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 390, + "y": 623, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 353, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 340, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 353, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 390, + "y": 317, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 447, + "y": 260, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 520, + "y": 223, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 680, + "y": 223, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 753, + "y": 260, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 810, + "y": 317, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 847, + "y": 390, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 829, + "y": 212, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 886, + "y": 278, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 926, + "y": 356, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 944, + "y": 442, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 940, + "y": 529, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 914, + "y": 613, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 868, + "y": 687, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 805, + "y": 748, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 728, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 472, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 395, + "y": 748, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 332, + "y": 687, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 286, + "y": 613, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 260, + "y": 529, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 256, + "y": 442, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 274, + "y": 356, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 314, + "y": 278, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 371, + "y": 212, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 600, + "y": 470, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 160, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 280, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 920, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 1040, + "y": 740, + "shape": "circle", + "r": 16, + "orangeEligible": false + } + ], + "walls": [], + "moving": [] +} diff --git a/assets/gamedata/peggle/level-005.json b/assets/gamedata/peggle/level-005.json new file mode 100644 index 0000000..cf1926a --- /dev/null +++ b/assets/gamedata/peggle/level-005.json @@ -0,0 +1,629 @@ +{ + "version": 1, + "board": { + "width": 1200, + "height": 900 + }, + "orangeCount": 25, + "greenCount": 2, + "pegs": [ + { + "x": 180, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 300, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 420, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 540, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 660, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 780, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 900, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1020, + "y": 250, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 180, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 300, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 420, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 540, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 660, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 780, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 900, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1020, + "y": 370, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 180, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 300, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 420, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 540, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 660, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 780, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 900, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1020, + "y": 490, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 180, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 300, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 420, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 540, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 660, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 780, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 900, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1020, + "y": 610, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 180, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 300, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 420, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 540, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 660, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 780, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 900, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1020, + "y": 730, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 360, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 840, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 960, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1080, + "y": 310, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 360, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 840, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 960, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1080, + "y": 430, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 360, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 840, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 960, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1080, + "y": 550, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 360, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 840, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 960, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1080, + "y": 670, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 240, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 360, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 480, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 600, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 720, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 840, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 960, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 1080, + "y": 790, + "shape": "circle", + "r": 16, + "orangeEligible": true + }, + { + "x": 160, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 286, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 411, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 537, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 663, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 789, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 914, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + }, + { + "x": 1040, + "y": 210, + "shape": "circle", + "r": 16, + "orangeEligible": false + } + ], + "walls": [], + "moving": [] +} diff --git a/assets/gamedata/peggle/levels.json b/assets/gamedata/peggle/levels.json new file mode 100644 index 0000000..784a970 --- /dev/null +++ b/assets/gamedata/peggle/levels.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "levels": [ + { + "level": 1, + "name": "Tea Time", + "masterId": "ethel", + "powerId": "superguide", + "file": "level-001.json", + "background": "parlor" + }, + { + "level": 2, + "name": "Fetch!", + "masterId": "kona", + "powerId": "multiball", + "file": "level-002.json", + "background": "park" + }, + { + "level": 3, + "name": "Hocus Pocus", + "masterId": "zanthor", + "powerId": "spaceblast", + "file": "level-003.json", + "background": "arcana" + }, + { + "level": 4, + "name": "X-Ray Vision", + "masterId": "fireball", + "powerId": "fireball", + "file": "level-004.json", + "background": "lab" + }, + { + "level": 5, + "name": "Checkmate", + "masterId": "victor", + "powerId": "zenball", + "file": "level-005.json", + "background": "study" + } + ] +} diff --git a/assets/gamedata/peggle/sprites.md b/assets/gamedata/peggle/sprites.md new file mode 100644 index 0000000..9ffc728 --- /dev/null +++ b/assets/gamedata/peggle/sprites.md @@ -0,0 +1,89 @@ +# Peggle Sprite Sheet Spec + +Drop-in art for the pegs and ball. Physics and geometry are unchanged — these +frames are pure display skins drawn at each peg's/ball's existing position and +size. + +## What to create + +**One PNG:** `assets/images/peggle-sprites.png` + +- **Frame size:** 64 × 64 px per cell +- **Grid:** 8 columns × 2 rows → the PNG is exactly **512 × 128 px** +- **Background:** fully transparent (32-bit PNG with alpha) + +## Frame map + +Frames are numbered left→right, top→bottom (row 0 = frames 0–7, +row 1 = frames 8–15). + +| Frame | Content | Notes | +|-------|--------------------|-------| +| 0 | Blue peg | The common filler peg | +| 1 | Blue peg — LIT | Hit state: brighter / glowing version of frame 0 | +| 2 | Orange peg | The objective peg | +| 3 | Orange peg — LIT | | +| 4 | Green peg | The power peg | +| 5 | Green peg — LIT | | +| 6 | Purple peg | The bonus (500-pt) peg | +| 7 | Purple peg — LIT | | +| 8 | Ball | The silver launch ball | +| 9–15 | *(reserved)* | Leave empty/transparent — future use (e.g. fireball ball, brick pegs) | + +## Drawing guidance + +- **Pegs (frames 0–7):** draw a circle that **fills the full 64 px cell** + (i.e. 64 px diameter, touching the cell edges). In game a peg is 32 px + across, so the art renders at 2× resolution and is scaled down — keep + detail readable at 50%. +- **LIT variants (odd frames):** same silhouette as the unlit frame, but + clearly "switched on" — brighter core, inner glow, or highlight ring. The + game swaps unlit↔lit frames instantly on hit, and lit pegs fade out 1.5 s + later, so the two states should read at a glance. +- **Ball (frame 8):** draw a circle filling the full 64 px cell. In game the + ball is 26 px across. It's seen against a dark blue board — a light, + specular look (chrome/silver) reads best. +- Do **not** bake in drop shadows that extend outside the circle — pegs sit + close together and overhang would overlap neighbours. +- Keep each sprite centered in its cell; the game positions frames by cell + center. + +## How it activates + +1. Save the PNG as `assets/images/peggle-sprites.png`. That's it — no code or + config changes. The sheet is registered in `src/data/assetManifest.js` and + lazy-loads the first time Peggle is entered. +2. Until the file exists (or if it fails to load) the game silently falls back + to the current procedural circles — you'll just see a `[assets] failed to + load peggle-sprites` console warning, which is harmless. +3. To revert to procedural art, delete the PNG. + +## Board backgrounds (per master) + +One optional PNG per friend/master, shown behind the peg field on their levels. + +- **Path & naming:** `assets/images/peggle/.png`, where `` + is the opponent's key from `levels.json` — e.g. `ethel.png`, `kona.png`, + `zanthor.png`, `fireball.png`, `victor.png`. +- **Dimensions:** exactly **1200 × 900 px** (4:3). The board window is + 1200×900 logical pixels rendered 1:1 on the 1920×1080 canvas, so this is + native resolution. If you want extra sharpness on large/4K displays, paint + at **2400 × 1800** (same 4:3 ratio) — any other aspect ratio will be + stretched to fit. +- **Style:** the game lays a dark scrim (38% opacity, near-black blue) over + the image so pegs, the ball, and the aim guide stay readable — so paint at + full vibrancy and expect it to read darker in game. Avoid fine high-contrast + detail in the central play area; big soft shapes work best behind ~50 pegs. +- **Loading:** lazy-loaded when Peggle is first entered (listed from + `levels.json` masterIds automatically — a new master's background needs no + code change). Missing files fall back to the procedural starfield with a + harmless console warning. + +## Not covered by the sheet + +- **Level editor** (`?peggle-editor=1`) intentionally keeps procedural circles — + its colors encode editing state (gold = orange-eligible, blue = normal, + red = invalid), not gameplay colors. +- Bucket, launcher, background, and particles remain procedural for now. If + you want art for those later, the reserved frames 9–15 (or a second sheet) + can take them. diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 0297f90..db0f51e 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index f655772..ee059c1 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/images/peggle-sprites.png b/assets/images/peggle-sprites.png new file mode 100644 index 0000000..c9ee86d Binary files /dev/null and b/assets/images/peggle-sprites.png differ diff --git a/assets/images/peggle-sprites.psd b/assets/images/peggle-sprites.psd new file mode 100644 index 0000000..225383e Binary files /dev/null and b/assets/images/peggle-sprites.psd differ diff --git a/assets/images/peggle/ethel.png b/assets/images/peggle/ethel.png new file mode 100644 index 0000000..9ce2f22 Binary files /dev/null and b/assets/images/peggle/ethel.png differ diff --git a/assets/images/peggle/fireball.png b/assets/images/peggle/fireball.png new file mode 100644 index 0000000..c500d21 Binary files /dev/null and b/assets/images/peggle/fireball.png differ diff --git a/assets/images/peggle/kona.png b/assets/images/peggle/kona.png new file mode 100644 index 0000000..7e8c125 Binary files /dev/null and b/assets/images/peggle/kona.png differ diff --git a/assets/images/peggle/victor.png b/assets/images/peggle/victor.png new file mode 100644 index 0000000..25ee853 Binary files /dev/null and b/assets/images/peggle/victor.png differ diff --git a/assets/images/peggle/zanthor.png b/assets/images/peggle/zanthor.png new file mode 100644 index 0000000..087076c Binary files /dev/null and b/assets/images/peggle/zanthor.png differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index d1819d9..252030a 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -127,6 +127,19 @@ export const MANIFEST = { swdbg: (scene) => sheetsFrom(scene, 'swdbg-artwork', ['cardSheet', 'baseSheet']), balatro: (scene) => sheetsFrom(scene, 'balatro-artwork', ['jokerSheet', 'tarotSheet', 'planetSheet', 'spectralSheet', 'voucherSheet', 'packSheet', 'deckBackSheet', 'bossIconSheet']), + // Optional drop-in art — spec in assets/gamedata/peggle/sprites.md. Missing + // files 404 harmlessly and the game renders procedurally: circles for + // pegs/ball, gradient starfield for the board background. + peggle: [ + sheet('peggle-sprites', 'assets/images/peggle-sprites.png', 64, 64), + // One board background per master, named by opponent id + // (assets/images/peggle/.png, 1200×900). + (scene) => { + const levels = scene.cache.json.get('peggle-levels')?.levels ?? []; + const ids = [...new Set(levels.map((l) => l.masterId).filter(Boolean))]; + return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`)); + }, + ], }; // Returns the full (normalized) descriptor list for a slug — including assets diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 281fc8a..7b41d49 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -107,3 +107,4 @@ registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77, defaultPlayfield: 'fantasy' }); registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' }); registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 }); +registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 }); diff --git a/src/games/peggle/PeggleEditor.js b/src/games/peggle/PeggleEditor.js new file mode 100644 index 0000000..9a8f3e0 --- /dev/null +++ b/src/games/peggle/PeggleEditor.js @@ -0,0 +1,559 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { TUNING, POWERS } from './PeggleLogic.js'; + +// Secret level editor — reached only via index.html?peggle-editor=1. +// Board occupies the same logical 1200×900 space, at the same screen offset, +// as PeggleGame, so authored coordinates are exactly play coordinates. + +const BOARD_X = 360; +const BOARD_Y = 90; +const BOARD_W = TUNING.BOARD_W; +const BOARD_H = TUNING.BOARD_H; +const MIN_Y = 140; // launcher clearance +const MAX_Y = 820; // bucket lane clearance +const GAP = 6; // min rim-to-rim clearance + +const D = { felt: -2, board: -1, zone: 0, grid: 1, peg: 5, marker: 8, ui: 30 }; + +export default class PeggleEditor extends Phaser.Scene { + constructor() { super('PeggleEditor'); } + + init(data) { + this.resume = !!data?.resume; + this.pegs = []; // { x, y, r, orangeEligible } + this.meta = { + level: 1, name: 'New Level', masterId: 'ethel', powerId: 'superguide', + orangeCount: 25, greenCount: 2, background: '', file: 'level-001.json', + }; + this.mode = 'place'; // place | line | arc + this.snap = true; + this.pending = null; // first click of a stamper + this.dragIdx = -1; + this.hoverIdx = -1; + this.undoStack = []; + this.pegImages = []; + this.stampCount = 8; + this.stampSpan = 180; + } + + create() { + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0d1b2e).setDepth(D.felt); + this.input.mouse?.disableContextMenu(); + + // Shared peg texture (PeggleGame also makes this; the editor can boot first + // via the secret URL, so make sure it exists before any peg image renders). + if (!this.textures.exists('peggle-peg')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0xffffff, 1); + g.fillCircle(16, 16, 16); + g.fillStyle(0xf2f7ff, 1); + g.fillCircle(13, 12, 7); + g.generateTexture('peggle-peg', 32, 32); + g.destroy(); + } + + this.manifest = (this.cache.json.get('peggle-levels')?.levels ?? []).slice().sort((a, b) => a.level - b.level); + this.roster = []; + fetch('data/opponents.json') + .then((r) => r.json()) + .then((d) => { this.roster = d.opponents ?? d; this.buildForm(); }) + .catch(() => this.buildForm()); + + if (this.resume) { + const saved = this.registry.get('peggle-editor-state'); + if (saved) { + this.pegs = saved.pegs.map((p) => ({ ...p })); + this.meta = { ...saved.meta }; + } + } + + this.drawChrome(); + this.pegLayer = this.add.container(0, 0).setDepth(D.peg); + this.markerG = this.add.graphics().setDepth(D.marker); + this.buildToolbar(); + this.bindPointer(); + this.bindKeys(); + this.renderPegs(); + } + + // ── Chrome ────────────────────────────────────────────────────────────────── + + drawChrome() { + const g = this.add.graphics().setDepth(D.board); + g.fillStyle(0x0a1522, 1); + g.fillRoundedRect(BOARD_X - 18, BOARD_Y - 18, BOARD_W + 36, BOARD_H + 36, 16); + g.fillStyle(0x122a45, 1); + g.fillRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H); + g.lineStyle(3, 0x2f5b8f, 1); + g.strokeRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H); + this.add.text(BOARD_X + BOARD_W / 2, BOARD_Y - 46, 'PEGGLE LEVEL EDITOR', { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, + }).setOrigin(0.5); + + // Forbidden lanes (launcher strip on top, bucket lane on bottom). + const zone = this.add.graphics().setDepth(D.zone); + zone.fillStyle(0xe03131, 0.10); + zone.fillRect(BOARD_X, BOARD_Y, BOARD_W, MIN_Y); + zone.fillRect(BOARD_X, BOARD_Y + MAX_Y, BOARD_W, BOARD_H - MAX_Y); + zone.lineStyle(1, 0xe03131, 0.4); + zone.lineBetween(BOARD_X, BOARD_Y + MIN_Y, BOARD_X + BOARD_W, BOARD_Y + MIN_Y); + zone.lineBetween(BOARD_X, BOARD_Y + MAX_Y, BOARD_X + BOARD_W, BOARD_Y + MAX_Y); + + // Grid + const grid = this.add.graphics().setDepth(D.grid); + grid.lineStyle(1, 0x21466a, 0.35); + for (let x = 0; x <= BOARD_W; x += 50) grid.lineBetween(BOARD_X + x, BOARD_Y, BOARD_X + x, BOARD_Y + BOARD_H); + for (let y = 0; y <= BOARD_H; y += 50) grid.lineBetween(BOARD_X, BOARD_Y + y, BOARD_X + BOARD_W, BOARD_Y + y); + + this.statusText = this.add.text(BOARD_X, BOARD_Y + BOARD_H + 24, '', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, + }).setDepth(D.ui); + } + + // ── Toolbar (left column) ─────────────────────────────────────────────────── + + buildToolbar() { + const cx = BOARD_X / 2; + let y = 140; + const step = 66; + + this.add.text(cx, y - 60, 'TOOLS', { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + + this.modeBtns = {}; + for (const [mode, label] of [['place', 'Place / Move'], ['line', 'Line Stamp'], ['arc', 'Arc Stamp']]) { + this.modeBtns[mode] = new Button(this, cx, y, label, () => this.setMode(mode), + { width: 260, height: 54, fontSize: 20, variant: mode === this.mode ? 'solid' : 'ghost' }); + y += step; + } + + y += 10; + this.snapBtn = new Button(this, cx, y, 'Snap: ON', () => { + this.snap = !this.snap; + this.snapBtn.text.setText(`Snap: ${this.snap ? 'ON' : 'OFF'}`); + }, { width: 260, height: 54, fontSize: 20, variant: 'ghost' }); + y += step; + + new Button(this, cx, y, 'Undo (Ctrl+Z)', () => this.undo(), + { width: 260, height: 54, fontSize: 20, variant: 'ghost' }); + y += step; + new Button(this, cx, y, 'Clear Board', () => { this.pushUndo(); this.pegs = []; this.renderPegs(); }, + { width: 260, height: 54, fontSize: 20, variant: 'ghost', textColor: COLORS.dangerHex }); + y += step + 20; + + new Button(this, cx, y, '▶ Test Play', () => this.testPlay(), + { width: 260, height: 60, fontSize: 24 }); + y += step + 14; + new Button(this, cx, y, '⬇ Export Level', () => this.exportLevel(), + { width: 260, height: 54, fontSize: 20 }); + y += step; + new Button(this, cx, y, '⬇ Export Manifest', () => this.exportManifest(), + { width: 260, height: 54, fontSize: 20, variant: 'ghost' }); + y += step + 14; + + this.add.text(cx, y + 4, + 'Left-click: place / drag\nRight-click: delete\nE: toggle orange-eligible\nEsc: cancel stamp', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5, 0).setDepth(D.ui); + + new Button(this, cx, GAME_HEIGHT - 60, 'Exit Editor', () => { window.location.search = ''; }, + { width: 260, height: 54, fontSize: 20, variant: 'ghost' }); + } + + setMode(mode) { + this.mode = mode; + this.pending = null; + this.markerG.clear(); + for (const [m, btn] of Object.entries(this.modeBtns)) { + btn.options.variant = m === mode ? 'solid' : 'ghost'; + btn._drawBg(btn.options.bg, m === mode ? 1 : 0.35); + } + } + + // ── DOM form (right column) ───────────────────────────────────────────────── + + buildForm() { + if (this.formDom) this.formDom.destroy(); + const rx = BOARD_X + BOARD_W + 24; + const rw = GAME_WIDTH - rx - 24; + + const masterOpts = (this.roster.length ? this.roster : [{ id: this.meta.masterId, name: this.meta.masterId }]) + .map((o) => ``).join(''); + const powerOpts = Object.values(POWERS) + .map((p) => ``).join(''); + const loadOpts = ['', + ...this.manifest.map((m) => ``)].join(''); + + const el = document.createElement('div'); + el.style.cssText = `width:${rw - 24}px; font-family:"Julius Sans One",sans-serif; color:#e8eef7;`; + const row = 'display:flex; align-items:center; justify-content:space-between; margin:7px 0;'; + const input = 'background:#0d2036; color:#e8eef7; border:1px solid #2f5b8f; border-radius:6px; padding:7px 9px; font-size:15px;'; + el.innerHTML = ` +
+
LEVEL SETTINGS
+
+
+
+
+
+
+
+
+
Stamp tools
+
+
+
+
+
Load
+
+
+
+
`; + + this.formDom = this.add.dom(rx + rw / 2, BOARD_Y + 385, el).setDepth(D.ui); + + const q = (id) => el.querySelector(id); + const bind = (id, key, num = false) => { + q(id).addEventListener('input', (ev) => { + this.meta[key] = num ? Math.max(0, parseInt(ev.target.value, 10) || 0) : ev.target.value; + this.renderPegs(); + }); + }; + bind('#pe-level', 'level', true); + bind('#pe-name', 'name'); + bind('#pe-master', 'masterId'); + bind('#pe-power', 'powerId'); + bind('#pe-orange', 'orangeCount', true); + bind('#pe-green', 'greenCount', true); + bind('#pe-file', 'file'); + q('#pe-count').addEventListener('input', (ev) => { this.stampCount = Math.max(2, parseInt(ev.target.value, 10) || 2); }); + q('#pe-span').addEventListener('input', (ev) => { this.stampSpan = Math.max(10, parseInt(ev.target.value, 10) || 10); }); + this.eligibleCheckbox = q('#pe-eligible'); + this.warnEl = q('#pe-warn'); + + q('#pe-load').addEventListener('change', (ev) => { + const file = ev.target.value; + if (file) this.loadFromManifest(file); + ev.target.value = ''; + }); + q('#pe-fileinput').addEventListener('change', (ev) => { + const f = ev.target.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => { + try { this.loadLayout(JSON.parse(reader.result), f.name); } catch (_) { this.flashWarn(`Could not parse ${f.name}`); } + }; + reader.readAsText(f); + ev.target.value = ''; + }); + + this.renderPegs(); + } + + flashWarn(msg) { + if (this.warnEl) this.warnEl.textContent = msg; + } + + async loadFromManifest(file) { + try { + const res = await fetch(`assets/gamedata/peggle/${file}`); + const layout = await res.json(); + const entry = this.manifest.find((m) => m.file === file); + this.loadLayout(layout, file, entry); + } catch (_) { + this.flashWarn(`Could not load ${file}`); + } + } + + loadLayout(layout, fileName, entry = null) { + this.pushUndo(); + this.pegs = (layout.pegs ?? []).map((p) => ({ + x: p.x, y: p.y, r: p.r ?? TUNING.PEG_R, orangeEligible: !!p.orangeEligible, + })); + this.meta.orangeCount = layout.orangeCount ?? 25; + this.meta.greenCount = layout.greenCount ?? 2; + if (fileName) this.meta.file = fileName; + if (entry) { + this.meta.level = entry.level; + this.meta.name = entry.name; + this.meta.masterId = entry.masterId; + this.meta.powerId = entry.powerId; + this.meta.background = entry.background ?? ''; + } + this.buildForm(); // re-sync inputs (also re-renders pegs) + } + + // ── Pointer editing ───────────────────────────────────────────────────────── + + toBoard(pointer) { + let x = pointer.x - BOARD_X; + let y = pointer.y - BOARD_Y; + if (this.snap) { x = Math.round(x / 10) * 10; y = Math.round(y / 10) * 10; } + return { x, y }; + } + + inBoard(pointer) { + const x = pointer.x - BOARD_X; + const y = pointer.y - BOARD_Y; + return x >= 0 && x <= BOARD_W && y >= 0 && y <= BOARD_H; + } + + pegAt(pointer) { + const x = pointer.x - BOARD_X; + const y = pointer.y - BOARD_Y; + for (let i = this.pegs.length - 1; i >= 0; i--) { + const p = this.pegs[i]; + if ((p.x - x) ** 2 + (p.y - y) ** 2 <= (p.r + 6) ** 2) return i; + } + return -1; + } + + bindPointer() { + this.input.on('pointerdown', (pointer) => { + if (!this.inBoard(pointer)) return; + const idx = this.pegAt(pointer); + + if (pointer.rightButtonDown()) { + if (idx >= 0) { this.pushUndo(); this.pegs.splice(idx, 1); this.renderPegs(); } + return; + } + + if (this.mode === 'place') { + if (idx >= 0) { + this.pushUndo(); + this.dragIdx = idx; + this.hoverIdx = idx; + } else { + const { x, y } = this.toBoard(pointer); + this.pushUndo(); + this.pegs.push({ x, y, r: TUNING.PEG_R, orangeEligible: this.eligibleCheckbox?.checked ?? true }); + this.hoverIdx = this.pegs.length - 1; + this.renderPegs(); + } + return; + } + + // Stamp tools: two clicks. + const { x, y } = this.toBoard(pointer); + if (!this.pending) { + this.pending = { x, y }; + this.drawMarker(x, y); + } else { + this.pushUndo(); + if (this.mode === 'line') this.stampLine(this.pending, { x, y }); + else this.stampArc(this.pending, { x, y }); + this.pending = null; + this.markerG.clear(); + this.renderPegs(); + } + }); + + this.input.on('pointermove', (pointer) => { + if (this.dragIdx >= 0 && pointer.isDown) { + const { x, y } = this.toBoard(pointer); + this.pegs[this.dragIdx].x = x; + this.pegs[this.dragIdx].y = y; + this.renderPegs(false); + } else if (this.inBoard(pointer)) { + this.hoverIdx = this.pegAt(pointer); + } + }); + + this.input.on('pointerup', () => { + if (this.dragIdx >= 0) { this.dragIdx = -1; this.renderPegs(); } + }); + } + + bindKeys() { + this.input.keyboard?.on('keydown-E', () => { + if (this.hoverIdx >= 0 && this.pegs[this.hoverIdx]) { + this.pushUndo(); + this.pegs[this.hoverIdx].orangeEligible = !this.pegs[this.hoverIdx].orangeEligible; + this.renderPegs(); + } + }); + this.input.keyboard?.on('keydown-DELETE', () => { + if (this.hoverIdx >= 0 && this.pegs[this.hoverIdx]) { + this.pushUndo(); + this.pegs.splice(this.hoverIdx, 1); + this.hoverIdx = -1; + this.renderPegs(); + } + }); + this.input.keyboard?.on('keydown-ESC', () => { this.pending = null; this.markerG.clear(); }); + this.input.keyboard?.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); }); + } + + drawMarker(x, y) { + this.markerG.clear(); + this.markerG.lineStyle(2, 0xf0c75e, 1); + this.markerG.strokeCircle(BOARD_X + x, BOARD_Y + y, 10); + this.markerG.lineBetween(BOARD_X + x - 16, BOARD_Y + y, BOARD_X + x + 16, BOARD_Y + y); + this.markerG.lineBetween(BOARD_X + x, BOARD_Y + y - 16, BOARD_X + x, BOARD_Y + y + 16); + } + + stampLine(a, b) { + const n = this.stampCount; + const eligible = this.eligibleCheckbox?.checked ?? true; + for (let i = 0; i < n; i++) { + const t = n === 1 ? 0 : i / (n - 1); + this.pegs.push({ + x: Math.round(a.x + (b.x - a.x) * t), + y: Math.round(a.y + (b.y - a.y) * t), + r: TUNING.PEG_R, orangeEligible: eligible, + }); + } + } + + stampArc(center, rim) { + const n = this.stampCount; + const eligible = this.eligibleCheckbox?.checked ?? true; + const radius = Math.hypot(rim.x - center.x, rim.y - center.y); + const start = Math.atan2(rim.y - center.y, rim.x - center.x); + const span = (this.stampSpan * Math.PI) / 180; + for (let i = 0; i < n; i++) { + const t = n === 1 ? 0 : i / (n - 1); + const a = start - span / 2 + span * t; + this.pegs.push({ + x: Math.round(center.x + radius * Math.cos(a)), + y: Math.round(center.y + radius * Math.sin(a)), + r: TUNING.PEG_R, orangeEligible: eligible, + }); + } + } + + // ── Undo ──────────────────────────────────────────────────────────────────── + + pushUndo() { + this.undoStack.push(JSON.stringify(this.pegs)); + if (this.undoStack.length > 60) this.undoStack.shift(); + } + + undo() { + const prev = this.undoStack.pop(); + if (prev == null) return; + this.pegs = JSON.parse(prev); + this.dragIdx = -1; + this.hoverIdx = -1; + this.renderPegs(); + } + + // ── Validation + render ───────────────────────────────────────────────────── + + validate() { + const bad = new Set(); + for (let i = 0; i < this.pegs.length; i++) { + const p = this.pegs[i]; + if (p.x < p.r || p.x > BOARD_W - p.r || p.y < MIN_Y || p.y > MAX_Y) bad.add(i); + for (let j = i + 1; j < this.pegs.length; j++) { + const q = this.pegs[j]; + const min = p.r + q.r + GAP; + if ((p.x - q.x) ** 2 + (p.y - q.y) ** 2 < min * min) { bad.add(i); bad.add(j); } + } + } + const eligible = this.pegs.filter((p) => p.orangeEligible).length; + const problems = []; + if (bad.size) problems.push(`${bad.size} peg(s) overlap or sit out of bounds (shown red).`); + if (eligible < this.meta.orangeCount) problems.push(`Only ${eligible} orange-eligible pegs — need ≥ ${this.meta.orangeCount}.`); + if (this.pegs.length < this.meta.orangeCount + this.meta.greenCount + 1) { + problems.push(`Need ≥ ${this.meta.orangeCount + this.meta.greenCount + 1} pegs total for orange+green+purple.`); + } + return { bad, eligible, problems, valid: problems.length === 0 }; + } + + renderPegs(fullValidate = true) { + while (this.pegImages.length > this.pegs.length) this.pegImages.pop().destroy(); + while (this.pegImages.length < this.pegs.length) { + const img = this.add.image(0, 0, 'peggle-peg'); + this.pegLayer.add(img); + this.pegImages.push(img); + } + + const v = fullValidate ? this.validate() : { bad: new Set(), eligible: this.pegs.filter((p) => p.orangeEligible).length, problems: [], valid: true }; + this.lastValidation = fullValidate ? v : this.lastValidation; + + for (let i = 0; i < this.pegs.length; i++) { + const p = this.pegs[i]; + const img = this.pegImages[i]; + img.setPosition(BOARD_X + p.x, BOARD_Y + p.y).setDisplaySize(p.r * 2, p.r * 2); + if (v.bad.has(i)) img.setTint(0xff4d4d); + else img.setTint(p.orangeEligible ? 0xffb347 : 0x4d8dee); + } + + if (this.statusText) { + const val = this.lastValidation ?? v; + this.statusText.setText( + `${this.pegs.length} pegs · ${val.eligible} orange-eligible (need ${this.meta.orangeCount}) · ` + + `${val.valid ? '✓ valid' : '✗ fix issues to export'}`, + ); + this.statusText.setColor(val.valid ? '#9be7b4' : '#ff8f8f'); + } + this.flashWarn((this.lastValidation ?? v).problems.join('\n')); + } + + // ── Export / test ─────────────────────────────────────────────────────────── + + layoutJson() { + return { + version: 1, + board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H }, + orangeCount: this.meta.orangeCount, + greenCount: this.meta.greenCount, + pegs: this.pegs.map((p) => ({ + x: p.x, y: p.y, shape: 'circle', r: p.r, orangeEligible: p.orangeEligible, + })), + walls: [], + moving: [], + }; + } + + manifestEntry() { + return { + level: this.meta.level, + name: this.meta.name, + masterId: this.meta.masterId, + powerId: this.meta.powerId, + file: this.meta.file, + background: this.meta.background || undefined, + }; + } + + download(name, obj) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); + } + + exportLevel() { + const v = this.validate(); + if (!v.valid) { this.flashWarn(`Export blocked:\n${v.problems.join('\n')}`); return; } + this.download(this.meta.file, this.layoutJson()); + this.flashWarn(`Saved ${this.meta.file} — drop it into assets/gamedata/peggle/`); + } + + exportManifest() { + const levels = this.manifest.filter((m) => m.level !== this.meta.level).map((m) => ({ ...m })); + levels.push(this.manifestEntry()); + levels.sort((a, b) => a.level - b.level); + this.download('levels.json', { version: 1, levels }); + this.flashWarn('Saved levels.json — drop it into assets/gamedata/peggle/'); + } + + testPlay() { + const v = this.validate(); + if (!v.valid) { this.flashWarn(`Test blocked:\n${v.problems.join('\n')}`); return; } + this.registry.set('peggle-editor-state', { + pegs: this.pegs.map((p) => ({ ...p })), + meta: { ...this.meta }, + }); + this.scene.start('PeggleGame', { + testLevel: { entry: this.manifestEntry(), layout: this.layoutJson() }, + returnToEditor: true, + }); + } +} diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js new file mode 100644 index 0000000..c8193b2 --- /dev/null +++ b/src/games/peggle/PeggleGame.js @@ -0,0 +1,1124 @@ +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, SFX } from '../../ui/Sounds.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js'; +import { api } from '../../services/api.js'; +import { + TUNING, SCORING, POWERS, + createRound, launchBall, stepSim, simulatePreview, bucketX, multiplierFor, clampAim, +} from './PeggleLogic.js'; + +// Screen placement of the 1200×900 logical board (canvas is 1920×1080). +const BOARD_X = 360; +const BOARD_Y = 90; +const BOARD_W = TUNING.BOARD_W; +const BOARD_H = TUNING.BOARD_H; + +const SIM_STEP = 1 / 60; +const FEVER_TIMESCALE = 0.3; + +const PEG_TINT = { blue: 0x4d8dee, orange: 0xff8a1e, green: 0x3ed164, purple: 0xc05df0 }; +const PEG_LIT = { blue: 0x9cc4ff, orange: 0xffc266, green: 0x8df2a8, purple: 0xe3a8ff }; + +// Optional drop-in spritesheet (see assets/gamedata/peggle/sprites.md). When +// assets/images/peggle-sprites.png exists it replaces the procedural circles; +// physics/geometry are untouched — frames are just display skins. +const SPRITE_SHEET = 'peggle-sprites'; +const SPRITE_FRAME = { blue: 0, orange: 2, green: 4, purple: 6, ball: 8 }; // color+1 = lit variant + +const FELT = 0x0d1b2e; +const BOARD_BG = 0x122a45; + +const D = { felt: -2, board: -1, peg: 5, bucket: 8, ball: 10, fx: 12, preview: 14, launcher: 16, ui: 30, banner: 40, overlay: 60, overlayUI: 62 }; + +export default class PeggleGame extends Phaser.Scene { + constructor() { super('PeggleGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'peggle', name: 'Peggle' }; + this.testLevel = data.testLevel ?? null; // editor test-play + this.returnToEditor = !!data.returnToEditor; + this.manifest = []; + this.levelCache = new Map(); // file -> layout json + this.roster = []; + this.levelsCompleted = 0; + this.canPersist = !this.testLevel; + this.view = 'select'; + + // per-level play state + this.entry = null; // manifest entry + this.state = null; // PeggleLogic round state + this.accum = 0; + this.aimAngle = 0; + this.overlayUp = false; + this.shotHits = 0; // for the rising plink pitch + this.pegImages = new Map(); + this.ballImages = []; + this.portrait = null; + this.tube = null; + this.reservoirBalls = []; + + // end-of-level celebration + this.celebrating = false; + this.celebrationFx = null; + this.countTween = null; + this.countTimer = null; + this.countText = null; + this.countTotal = 0; + this.countDone = false; + this.skipHandler = null; + } + + 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, FELT).setDepth(D.felt); + this.buildTextures(); + this.useSprites = this.textures.exists(SPRITE_SHEET); + + this.manifest = (this.cache.json.get('peggle-levels')?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await fetch('data/opponents.json'); + const data = await res.json(); + this.roster = data.opponents ?? data; + } catch (_) { this.roster = []; } + + if (!this.testLevel) { + try { + const res = await api.get('/puzzles/peggle/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + } + + this.layer = this.add.container(0, 0); + this.bindInput(); + + if (this.testLevel) { + this.startLevel(this.testLevel.entry, this.testLevel.layout); + } else { + this.showLevelSelect(); + } + } + + buildTextures() { + if (!this.textures.exists('peggle-peg')) { + const g = this.make.graphics({ add: false }); + // White peg with a highlight; tinted per color at render time. + g.fillStyle(0xffffff, 1); + g.fillCircle(16, 16, 16); + g.fillStyle(0xf2f7ff, 1); + g.fillCircle(13, 12, 7); + g.generateTexture('peggle-peg', 32, 32); + g.destroy(); + } + if (!this.textures.exists('peggle-ball')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0xd8dee8, 1); + g.fillCircle(13, 13, 13); + g.fillStyle(0xffffff, 0.9); + g.fillCircle(9, 9, 5); + g.lineStyle(2, 0x8b95a5, 1); + g.strokeCircle(13, 13, 12); + g.generateTexture('peggle-ball', 26, 26); + g.destroy(); + } + if (!this.textures.exists('peggle-spark')) { + const g = this.make.graphics({ add: false }); + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('peggle-spark', 8, 8); + g.destroy(); + } + } + + masterFor(entry) { + return this.roster.find((o) => o.id === entry.masterId) + ?? { id: entry.masterId, spriteIndex: 0, name: entry.masterId, bio: '', speech: {} }; + } + + say(kind) { + const clips = this.master?.speech?.[kind]; + if (clips?.length) enqueueSpeech(clips[Math.floor(Math.random() * clips.length)]); + } + + clearLayer() { + // Undo any fever zoom/pan before rebuilding the view. + this.cameras.main.setZoom(1); + this.cameras.main.centerOn(GAME_WIDTH / 2, GAME_HEIGHT / 2); + this.feverZoomed = false; + this.portrait?.destroy(); + this.portrait = null; + this.pegImages.clear(); + this.ballImages = []; + this.previewG = null; + this.launcherG = null; + this.bucketC = null; + this.feverBuckets = null; + this.hud = null; + this.tube = null; + this.reservoirBalls = []; + // Destroyed with the layer below; drop the stale references so + // updatePowerHud() / the celebration don't poke dead objects before the + // next level rebuilds them. + this.powerStatus = null; + this.celebrating = false; + this.countDone = false; + this.countTween?.remove(); + this.countTween = null; + this.countTimer?.remove(); + this.countTimer = null; + this.celebrationFx = null; + this.countText = null; + if (this.skipHandler) { this.input.off('pointerup', this.skipHandler); this.skipHandler = null; } + this.layer.removeAll(true); + } + + // ── Level select ──────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.state = null; + this.overlayUp = false; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 90, 'PEGGLE', { + fontFamily: 'Righteous', fontSize: '72px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 152, 'Clear all 25 orange pegs. A friend joins every level — hit their green pegs to fire their power!', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + if (!this.manifest.length) { + const msg = this.add.text(cx, 480, 'No levels found.\nRun: node tools/genPeggleLevels.js', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', + }).setOrigin(0.5); + const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); + this.layer.add([msg, back]); + return; + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.manifest.length); + const prog = this.add.text(cx, 208, `Completed ${this.levelsCompleted} / ${this.manifest.length}`, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add(prog); + + const COLS = Math.min(5, this.manifest.length); + const SIZE = 240; + const GAP = 26; + const gridW = COLS * SIZE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const top = 420; + + this.manifest.forEach((entry, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = left + col * (SIZE + GAP); + const y = top + row * (SIZE + GAP + 60); + const level = entry.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + const master = this.masterFor(entry); + const power = POWERS[entry.powerId]; + + const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b; + const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; + const tile = this.add.rectangle(x, y, SIZE, SIZE + 46, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + + const face = this.add.image(x, y - 46, 'opponents', master.spriteIndex ?? 0) + .setDisplaySize(120, 120); + if (!playable && !cleared) face.setTint(0x334455).setAlpha(0.6); + + const num = this.add.text(x - SIZE / 2 + 16, y - SIZE / 2 - 6, String(level), { + fontFamily: 'Righteous', fontSize: '30px', + color: playable || cleared ? COLORS.goldHex : '#54606b', + }).setOrigin(0, 0); + const name = this.add.text(x, y + 36, entry.name, { + fontFamily: 'Righteous', fontSize: '26px', + color: playable || cleared ? COLORS.textHex : '#54606b', + }).setOrigin(0.5); + const info = this.add.text(x, y + 74, + cleared ? '✓ cleared' : playable ? `${master.name} · ${power?.name ?? ''}` : 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '17px', + color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', + }).setOrigin(0.5); + this.layer.add([tile, face, num, name, info]); + + 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(entry)); + } + }); + + const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, + () => this.playLevel(this.manifest[nextLevel - 1]), { width: 280, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.layer.add([resume, back, reset]); + } + + 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).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + 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).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, + 'This clears every level you have cleared and\nstarts you back at Level 1. This cannot be undone.', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5).setDepth(D.overlayUI); + const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => { + api.post('/puzzles/peggle/reset').catch(() => { /* best effort */ }); + this.levelsCompleted = 0; + this.showLevelSelect(); + }, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI); + const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), + { variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); + this.layer.add([dim, panel, title, msg, yes, no]); + } + + // ── Level start ───────────────────────────────────────────────────────────── + + async playLevel(entry) { + if (!entry) return; + let layout = this.levelCache.get(entry.file); + if (!layout) { + try { + const res = await fetch(`assets/gamedata/peggle/${entry.file}`); + layout = await res.json(); + this.levelCache.set(entry.file, layout); + } catch (_) { + return; + } + } + this.startLevel(entry, layout); + } + + startLevel(entry, layout) { + this.view = 'play'; + this.entry = entry; + this.master = this.masterFor(entry); + this.state = createRound({ ...layout, powerId: entry.powerId }); + this.accum = 0; + this.aimAngle = 0; + this.overlayUp = false; + this.shotHits = 0; + this.feverZoomed = false; + + this.clearLayer(); + this.drawBoardChrome(); + this.renderPegs(); + this.drawBucket(); + this.drawLauncher(); + this.drawHud(); + this.drawMasterPanel(); + this.say('intro'); + } + + // ── Board rendering ───────────────────────────────────────────────────────── + + sx(x) { return BOARD_X + x; } + sy(y) { return BOARD_Y + y; } + + drawBoardChrome() { + // Children of this.layer render in insertion order: frame, then + // background (image or procedural), then scrim, then border. + const g = this.add.graphics().setDepth(D.board); + g.fillStyle(0x0a1522, 1); + g.fillRoundedRect(BOARD_X - 18, BOARD_Y - 18, BOARD_W + 36, BOARD_H + 36, 16); + + // Per-master board background (assets/images/peggle/.png, + // 1200×900) when painted; procedural gradient starfield otherwise. + const bgKey = `peggle-bg-${this.entry?.masterId}`; + if (this.entry?.masterId && this.textures.exists(bgKey)) { + this.layer.add(g); + const img = this.add.image(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, bgKey) + .setDisplaySize(BOARD_W, BOARD_H) + .setDepth(D.board); + this.layer.add(img); + // Dark scrim keeps pegs, ball, and the aim guide readable over any art. + const scrim = this.add.rectangle(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, + BOARD_W, BOARD_H, 0x0a1522, 0.38).setDepth(D.board); + this.layer.add(scrim); + } else { + g.fillGradientStyle(BOARD_BG, BOARD_BG, 0x0e2036, 0x0e2036, 1); + g.fillRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H); + // Subtle star field so ball flight reads against the background. + g.fillStyle(0xffffff, 0.06); + let sx = 137; + for (let i = 0; i < 90; i++) { + sx = (sx * 16807) % 2147483647; + const px = sx % BOARD_W; + const py = (Math.floor(sx / BOARD_W) % (BOARD_H - 60)) + 30; + g.fillCircle(BOARD_X + px, BOARD_Y + py, (sx % 3) === 0 ? 2 : 1); + } + this.layer.add(g); + } + + const border = this.add.graphics().setDepth(D.board); + border.lineStyle(3, 0x2f5b8f, 1); + border.strokeRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H); + this.layer.add(border); + } + + tintFor(peg) { + return peg.lit ? PEG_LIT[peg.color] : PEG_TINT[peg.color]; + } + + // Skins a peg image from its logical state — spritesheet frame when the + // sheet is present, tinted procedural circle otherwise. + applyPegVisual(img, peg) { + if (this.useSprites) { + img.setTexture(SPRITE_SHEET, SPRITE_FRAME[peg.color] + (peg.lit ? 1 : 0)); + img.clearTint(); + } else { + img.setTexture('peggle-peg'); + img.setTint(this.tintFor(peg)); + } + img.setDisplaySize(peg.r * 2, peg.r * 2); + } + + makeBallImage() { + const img = this.useSprites + ? this.add.image(0, 0, SPRITE_SHEET, SPRITE_FRAME.ball).setDisplaySize(TUNING.BALL_R * 2, TUNING.BALL_R * 2) + : this.add.image(0, 0, 'peggle-ball'); + return img.setDepth(D.ball); + } + + renderPegs() { + this.pegImages.forEach((img) => img.destroy()); + this.pegImages.clear(); + for (const peg of this.state.pegs) { + if (peg.removed) continue; + const img = this.add.image(this.sx(peg.x), this.sy(peg.y), 'peggle-peg').setDepth(D.peg); + this.applyPegVisual(img, peg); + this.layer.add(img); + this.pegImages.set(peg.id, img); + } + } + + refreshPegTints() { + for (const peg of this.state.pegs) { + const img = this.pegImages.get(peg.id); + if (img && !peg.removed) this.applyPegVisual(img, peg); + } + } + + drawBucket() { + const c = this.add.container(0, this.sy(TUNING.BUCKET_Y)).setDepth(D.bucket); + const g = this.add.graphics(); + const half = TUNING.BUCKET_W / 2; + const rimW = TUNING.BUCKET_RIM_W; + const rimH = TUNING.BUCKET_RIM_H; + g.fillStyle(0xc9a44a, 1); + g.fillRect(-half - rimW, 0, rimW, rimH); + g.fillRect(half, 0, rimW, rimH); + g.fillStyle(0x8a6f2f, 1); + g.fillRect(-half - rimW, rimH, TUNING.BUCKET_W + rimW * 2, 12); + g.fillStyle(0x5c4a1f, 1); + g.fillRect(-half, 6, TUNING.BUCKET_W, rimH - 6); + c.add(g); + const label = this.add.text(0, 22, 'FREE BALL', { + fontFamily: 'Righteous', fontSize: '13px', color: '#ffe9b0', + }).setOrigin(0.5); + c.add(label); + this.layer.add(c); + this.bucketC = c; + } + + drawLauncher() { + this.launcherG = this.add.graphics().setDepth(D.launcher); + this.previewG = this.add.graphics().setDepth(D.preview); + this.layer.add([this.launcherG, this.previewG]); + this.renderLauncher(); + } + + renderLauncher() { + const g = this.launcherG; + if (!g) return; + g.clear(); + const lx = this.sx(TUNING.LAUNCH_X); + const ly = this.sy(TUNING.LAUNCH_Y); + const a = this.aimAngle; + // Barrel + g.fillStyle(0x9aa7b8, 1); + // Canvas rotation is mirrored vs. our aim convention (angle from straight + // down, positive = right), so rotate by -a. + g.save(); + g.translateCanvas(lx, ly); + g.rotateCanvas(-a); + g.fillRoundedRect(-12, -6, 24, 52, 8); + g.restore(); + // Housing + g.fillStyle(0x3a4b61, 1); + g.fillCircle(lx, ly, 26); + g.lineStyle(3, 0x6d84a3, 1); + g.strokeCircle(lx, ly, 26); + g.fillStyle(0xd8dee8, 1); + g.fillCircle(lx, ly, 9); + } + + renderPreview() { + const g = this.previewG; + if (!g) return; + g.clear(); + if (!this.state || this.state.phase !== 'aim' || this.overlayUp) return; + const maxPegHits = this.state.superGuideShots > 0 ? 3 : 1; + const { points } = simulatePreview(this.state, this.aimAngle, { maxPegHits }); + const superGuide = this.state.superGuideShots > 0; + g.fillStyle(superGuide ? 0x7df29b : 0xffffff, 0.75); + for (let i = 4; i < points.length; i += 3) { + const p = points[i]; + const fade = Math.max(0.15, 1 - i / points.length); + g.fillStyle(superGuide ? 0x7df29b : 0xffffff, 0.75 * fade); + g.fillCircle(this.sx(p.x), this.sy(p.y), 4); + } + } + + // ── HUD ───────────────────────────────────────────────────────────────────── + + drawHud() { + const rx = BOARD_X + BOARD_W + 24; + const rw = GAME_WIDTH - rx - 24; + const cx = rx + rw / 2; + + const panel = this.add.graphics().setDepth(D.ui); + panel.fillStyle(0x0a1522, 0.92); + panel.fillRoundedRect(rx, BOARD_Y, rw, BOARD_H, 16); + panel.lineStyle(2, 0x2f5b8f, 1); + panel.strokeRoundedRect(rx, BOARD_Y, rw, BOARD_H, 16); + this.layer.add(panel); + + const title = this.add.text(cx, BOARD_Y + 44, `Level ${this.entry.level}`, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + const name = this.add.text(cx, BOARD_Y + 82, this.entry.name, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add([title, name]); + + this.hud = {}; + const mk = (y, label, size = 40) => { + const l = this.add.text(cx, y, label, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + const v = this.add.text(cx, y + 34, '', { + fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add([l, v]); + return v; + }; + this.hud.score = mk(BOARD_Y + 140, 'SCORE', 42); + this.hud.balls = mk(BOARD_Y + 250, 'BALLS LEFT', 42); + this.hud.orange = mk(BOARD_Y + 360, 'ORANGE PEGS', 36); + this.hud.mult = mk(BOARD_Y + 464, 'MULTIPLIER', 36); + this.hud.mult.setColor(COLORS.goldHex); + + this.buildReservoir(cx); + + const levelsBtn = new Button(this, cx, BOARD_Y + BOARD_H - 50, + this.returnToEditor ? 'Back to Editor' : 'Levels', + () => this.exitLevel(), { variant: 'ghost', width: rw - 60, height: 54, fontSize: 22 }); + levelsBtn.setDepth(D.ui); + this.layer.add(levelsBtn); + + this.updateHud(); + } + + // ── Ball reservoir (Peggle-style tube of waiting balls) ───────────────────── + // Shows ballsLeft − 1 while aiming (one ball sits in the cannon). The bottom + // ball drops out when the cannon reloads after a shot; free balls fall in + // from the top; the stack settles with a gravity bounce. + + buildReservoir(cx) { + const size = 24; // displayed ball diameter in the tube + const tubeW = 46; + const top = BOARD_Y + 545; + const height = 270; + this.tube = { cx, top, bottom: top + height, size }; + + const label = this.add.text(cx, top - 18, 'RESERVE', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + const tg = this.add.graphics().setDepth(D.ui); + tg.fillStyle(0x0d2036, 1); + tg.fillRoundedRect(cx - tubeW / 2, top, tubeW, height, 10); + tg.lineStyle(2, 0x2f5b8f, 1); + tg.strokeRoundedRect(cx - tubeW / 2, top, tubeW, height, 10); + this.layer.add([label, tg]); + + this.reservoirBalls = []; // index 0 = bottom of the stack + const n = Math.max(0, this.state.ballsLeft - 1); + for (let i = 0; i < n; i++) { + const img = this.makeReservoirBall(); + img.setPosition(cx, this.reservoirSlotY(i)); + this.reservoirBalls.push(img); + } + } + + reservoirSlotY(i) { + return this.tube.bottom - this.tube.size / 2 - 4 - i * this.tube.size; + } + + makeReservoirBall() { + const img = this.makeBallImage() + .setDisplaySize(this.tube.size, this.tube.size) + .setDepth(D.ui + 1); + this.layer.add(img); + return img; + } + + // Free ball earned: a new ball drops into the tube from above. + addReservoirBall() { + if (!this.tube) return; + const i = this.reservoirBalls.length; + const img = this.makeReservoirBall(); + img.setPosition(this.tube.cx, this.tube.top - 26); + this.reservoirBalls.push(img); + this.tweens.add({ + targets: img, y: this.reservoirSlotY(i), duration: 520, ease: 'Bounce.easeOut', + }); + } + + // Cannon reloads: the bottom ball exits the tube and the rest fall down. + loadCannonBall() { + if (!this.tube || !this.reservoirBalls.length) return; + const img = this.reservoirBalls.shift(); + this.tweens.add({ + targets: img, y: this.tube.bottom + 30, alpha: 0, duration: 240, ease: 'Quad.easeIn', + onComplete: () => img.destroy(), + }); + this.reservoirBalls.forEach((b, i) => { + this.tweens.add({ + targets: b, y: this.reservoirSlotY(i), duration: 420, ease: 'Bounce.easeOut', + }); + }); + } + + exitLevel() { + if (this.returnToEditor) { + this.scene.start('PeggleEditor', { resume: true }); + } else { + this.showLevelSelect(); + } + } + + updateHud() { + if (!this.hud || !this.state) return; + const st = this.state; + this.hud.score.setText(st.score.toLocaleString()); + this.hud.balls.setText(String(st.ballsLeft)); + this.hud.orange.setText(`${st.orangeTotal - st.orangeCleared} left`); + this.hud.mult.setText(`×${multiplierFor(st.orangeCleared)}`); + this.updatePowerHud(); + } + + drawMasterPanel() { + const lw = BOARD_X - 48; + const lx = 24; + const cx = lx + lw / 2; + + const panel = this.add.graphics().setDepth(D.ui); + panel.fillStyle(0x0a1522, 0.92); + panel.fillRoundedRect(lx, BOARD_Y, lw, BOARD_H, 16); + panel.lineStyle(2, 0x2f5b8f, 1); + panel.strokeRoundedRect(lx, BOARD_Y, lw, BOARD_H, 16); + this.layer.add(panel); + + this.portrait = createOpponentPortrait(this, this.master, cx, BOARD_Y + 160, 110, D.ui + 1, { playIntro: true }); + + const name = this.add.text(cx, BOARD_Y + 300, this.master.name, { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(name); + + const power = POWERS[this.entry.powerId]; + const pTitle = this.add.text(cx, BOARD_Y + 356, `POWER · ${power?.name ?? '—'}`, { + fontFamily: 'Righteous', fontSize: '22px', color: '#3ed164', + }).setOrigin(0.5).setDepth(D.ui); + const pDesc = this.add.text(cx, BOARD_Y + 420, power?.desc ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + align: 'center', wordWrap: { width: lw - 50 }, lineSpacing: 6, + }).setOrigin(0.5, 0).setDepth(D.ui); + this.layer.add([pTitle, pDesc]); + + this.powerStatus = this.add.text(cx, BOARD_Y + 560, '', { + fontFamily: 'Righteous', fontSize: '24px', color: '#8df2a8', align: 'center', + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(this.powerStatus); + + const hint = this.add.text(cx, BOARD_Y + BOARD_H - 70, + 'Hit a green peg\nto trigger the power!', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#3ed164', align: 'center', lineSpacing: 4, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(hint); + } + + updatePowerHud() { + if (!this.powerStatus || !this.state) return; + const st = this.state; + let txt = ''; + if (st.superGuideShots > 0) txt = `Super Guide: ${st.superGuideShots} shots`; + else if (st.fireballNext > 0) txt = `Fireball ready ×${st.fireballNext}`; + else if (st.zenNext > 0) txt = `Zen Ball ready ×${st.zenNext}`; + this.powerStatus.setText(txt); + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + bindInput() { + this.input.on('pointermove', (pointer) => { + if (this.view !== 'play' || !this.state || this.overlayUp) return; + this.setAimFromPointer(pointer); + }); + this.input.on('pointerup', (pointer) => { + if (this.view !== 'play' || !this.state || this.overlayUp) return; + if (this.state.phase !== 'aim' || this.state.ballsLeft <= 0) return; + const bx = pointer.x - BOARD_X; + const by = pointer.y - BOARD_Y; + if (bx < 0 || bx > BOARD_W || by < 0 || by > BOARD_H) return; + this.setAimFromPointer(pointer); + this.fire(); + }); + } + + setAimFromPointer(pointer) { + const dx = (pointer.x - BOARD_X) - TUNING.LAUNCH_X; + const dy = (pointer.y - BOARD_Y) - TUNING.LAUNCH_Y; + if (dy <= 4) return; // don't aim above the launcher + this.aimAngle = clampAim(Math.atan2(dx, dy)); + this.renderLauncher(); + this.renderPreview(); + } + + fire() { + const events = launchBall(this.state, this.aimAngle); + if (!events.length) return; + this.shotHits = 0; + playSound(this, SFX.SCIFI_LAUNCH); + this.previewG?.clear(); + this.processEvents(events); + this.updateHud(); + } + + // ── Sim loop ──────────────────────────────────────────────────────────────── + + update(_time, delta) { + if (this.view !== 'play' || !this.state) return; + const st = this.state; + if (st.phase === 'aim') { + // Keep the round clock ticking between shots so the free-ball bucket + // patrols while the player aims (stepSim only advances time in 'aim'). + stepSim(st, delta / 1000); + if (this.bucketC && !this.feverBuckets) this.bucketC.x = this.sx(bucketX(st)); + return; + } + if (st.phase !== 'flight' && st.phase !== 'fever') return; + + const scale = st.phase === 'fever' ? FEVER_TIMESCALE : 1; + this.accum = Math.min(this.accum + (delta / 1000) * scale, 0.12); + while (this.accum >= SIM_STEP) { + this.accum -= SIM_STEP; + const events = stepSim(st, SIM_STEP); + if (events.length) this.processEvents(events); + if (st.phase !== 'flight' && st.phase !== 'fever') break; + } + this.renderDynamic(); + } + + renderDynamic() { + const st = this.state; + // Balls + while (this.ballImages.length < st.balls.length) { + const img = this.makeBallImage(); + this.layer.add(img); + this.ballImages.push(img); + } + while (this.ballImages.length > st.balls.length) this.ballImages.pop().destroy(); + for (let i = 0; i < st.balls.length; i++) { + this.ballImages[i].setPosition(this.sx(st.balls[i].x), this.sy(st.balls[i].y)); + } + // Bucket + if (this.bucketC && !this.feverBuckets) this.bucketC.x = this.sx(bucketX(st)); + // Fever camera follows the falling ball. + if (st.phase === 'fever' && st.balls.length && this.feverZoomed) { + const b = st.balls[0]; + this.cameras.main.pan(this.sx(b.x), Math.min(this.sy(b.y) + 120, GAME_HEIGHT - 250), 180, 'Sine.easeOut', true); + } + } + + // ── Event presentation ────────────────────────────────────────────────────── + + processEvents(events) { + for (const e of events) { + switch (e.type) { + case 'pegHit': this.onPegHit(e); break; + case 'longShot': this.banner('LONG SHOT! +25,000', '#ffd166'); playSound(this, SFX.FIREWORK); break; + case 'freeBall': + this.banner('FREE BALL!', '#8df2a8'); + playSound(this, SFX.UI_CHIME); + this.addReservoirBall(); + break; + case 'bucketCatch': this.portrait?.playEmotion('happy'); break; + case 'ballLost': + if (this.state.shotScore === 0) { this.portrait?.playEmotion('upset'); this.say('upset'); } + break; + case 'powerCharged': + playSound(this, SFX.EIGHTBIT_ACTIVATE); + this.portrait?.playEmotion('happy'); + this.say('happy'); + break; + case 'powerFired': this.onPowerFired(e); break; + case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); break; + case 'spaceBlast': this.onSpaceBlast(e); break; + case 'zenAdjust': this.banner('ZEN BALL', '#b6e3ff'); break; + case 'purpleMoved': this.refreshPegTints(); break; + case 'pegsCleared': this.onPegsCleared(e); break; + case 'shotEnd': + this.shotHits = 0; + this.renderPreview(); + // Back to aiming: the next ball leaves the tube for the cannon. + if (this.state.phase === 'aim') this.loadCannonBall(); + break; + case 'feverStart': this.onFeverStart(); break; + case 'feverResolve': this.onFeverResolve(e); break; + case 'win': + // After a fever finish the celebration sequence owns the timing and + // calls showResult itself; only the direct (non-fever) path here. + if (!this.celebrating) this.time.delayedCall(1100, () => this.showResult(true)); + break; + case 'lose': this.time.delayedCall(600, () => this.showResult(false)); break; + default: break; + } + } + this.updateHud(); + } + + onPegHit(e) { + const peg = this.state.pegs[e.pegId]; + const img = this.pegImages.get(e.pegId); + if (img) { + this.applyPegVisual(img, peg); // peg.lit is already set + this.tweens.add({ targets: img, scale: img.scale * 1.25, duration: 90, yoyo: true }); + } + // Rising plink, the classic Peggle feel. + const rate = Math.min(1 + this.shotHits * 0.07, 2.2); + this.shotHits++; + try { this.sound.play(SFX.SCIFI_PLINK, { rate, volume: 0.7 }); } catch (_) { /* audio locked */ } + + this.floatText(this.sx(peg.x), this.sy(peg.y) - 22, `+${e.points.toLocaleString()}`, + peg.color === 'orange' ? '#ffc266' : peg.color === 'green' ? '#8df2a8' : peg.color === 'purple' ? '#e3a8ff' : '#cfe3ff'); + } + + onPowerFired(e) { + if (e.powerId === 'fireball') this.banner('FIREBALL!', '#ff9d5c'); + if (e.powerId === 'spaceblast') playSound(this, SFX.SCIFI_EXPLODE); + } + + onSpaceBlast(e) { + const x = this.sx(e.x); + const y = this.sy(e.y); + const ring = this.add.circle(x, y, 20, 0x3ed164, 0.5).setDepth(D.fx); + this.layer.add(ring); + this.tweens.add({ + targets: ring, radius: SCORING.SPACE_BLAST_RADIUS, alpha: 0, duration: 420, ease: 'Cubic.easeOut', + onUpdate: () => ring.setRadius(ring.radius), + onComplete: () => ring.destroy(), + }); + this.banner('SPACE BLAST!', '#8df2a8'); + } + + onPegsCleared(e) { + for (const id of e.pegIds) { + const img = this.pegImages.get(id); + if (!img) continue; + this.pegImages.delete(id); + this.tweens.add({ + targets: img, alpha: 0, scale: img.scale * 0.3, duration: 320, ease: 'Cubic.easeIn', + onComplete: () => img.destroy(), + }); + } + } + + onFeverStart() { + playSound(this, SFX.SCIFI_RISER); + this.banner('EXTREME FEVER!', '#ffd166', 44); + this.portrait?.playEmotion('happy'); + this.say('happy'); + + // Swap the moving bucket for the 5 fever buckets. + if (this.bucketC) { this.bucketC.destroy(); this.bucketC = null; } + const g = this.add.graphics().setDepth(D.bucket); + const n = SCORING.FEVER_BUCKETS.length; + const bw = BOARD_W / n; + this.feverBuckets = g; + for (let i = 0; i < n; i++) { + const x = BOARD_X + i * bw; + g.fillStyle(i === Math.floor(n / 2) ? 0xc9a44a : 0x2f5b8f, 0.9); + g.fillRect(x + 4, BOARD_Y + BOARD_H - 46, bw - 8, 46); + const label = this.add.text(x + bw / 2, BOARD_Y + BOARD_H - 23, + `${SCORING.FEVER_BUCKETS[i] / 1000}K`, { + fontFamily: 'Righteous', fontSize: '22px', color: '#ffffff', + }).setOrigin(0.5).setDepth(D.bucket + 1); + this.layer.add(label); + this._feverLabels = this._feverLabels ?? []; + this._feverLabels.push(label); + } + this.layer.add(g); + + this.feverZoomed = true; + this.cameras.main.zoomTo(1.28, 700, 'Sine.easeInOut'); + } + + onFeverResolve(e) { + // Zoom back to full screen at normal speed, then hand off to the + // celebration once the camera has settled. + this.feverZoomed = false; + this.celebrating = true; + this.countDone = false; + // Force-restart: a fever follow-pan may still be running, and Phaser + // ignores a non-forced pan while one is active. + this.cameras.main.pan(GAME_WIDTH / 2, GAME_HEIGHT / 2, 500, 'Sine.easeInOut', true); + this.cameras.main.zoomTo(1, 500, 'Sine.easeInOut', true); + for (const id of e.pegIds) { + const img = this.pegImages.get(id); + if (!img) continue; + this.pegImages.delete(id); + this.tweens.add({ + targets: img, alpha: 0, y: img.y - 30, duration: 500, delay: Math.random() * 250, + onComplete: () => img.destroy(), + }); + } + this.time.delayedCall(550, () => { + // Guarantee the camera is exactly home (centered, zoom 1) before the + // celebration starts, whatever state the pan/zoom effects ended in. + const cam = this.cameras.main; + cam.panEffect?.reset(); + cam.zoomEffect?.reset(); + cam.setZoom(1); + cam.centerOn(GAME_WIDTH / 2, GAME_HEIGHT / 2); + this.startFeverCelebration(e); + }); + } + + // ── End-of-level celebration ──────────────────────────────────────────────── + + startFeverCelebration(e) { + if (!this.celebrating || !this.state) return; + const n = SCORING.FEVER_BUCKETS.length; + const bw = BOARD_W / n; + const bx = BOARD_X + (e.bucketIndex + 0.5) * bw; + const by = BOARD_Y + BOARD_H - 46; + + playSound(this, SFX.FIREWORK); + + // Pulsing glow on the winning bucket. + const glow = this.add.rectangle(bx, BOARD_Y + BOARD_H - 23, bw - 8, 46, 0xffe9b0, 0.55).setDepth(D.bucket + 2); + this.layer.add(glow); + this.tweens.add({ targets: glow, alpha: 0.08, duration: 240, yoyo: true, repeat: 9 }); + + // Geyser of lights gushing out of the scoring bucket. + this.celebrationFx = this.add.particles(bx, by, 'peggle-spark', { + angle: { min: 250, max: 290 }, + speed: { min: 600, max: 950 }, + gravityY: 900, + lifespan: { min: 1200, max: 1800 }, + scale: { start: 1.6, end: 0 }, + quantity: 3, + frequency: 18, + blendMode: 'ADD', + tint: [0xffd166, 0xffffff, 0xff8a1e, 0x3ed164, 0x4d8dee, 0xe3a8ff], + }).setDepth(D.fx); + this.layer.add(this.celebrationFx); + this.time.delayedCall(1500, () => { if (this.celebrating) playSound(this, SFX.FIREWORK); }); + + // Big score roll-up from zero to the level total. + const cx = BOARD_X + BOARD_W / 2; + const cy = BOARD_Y + 330; + const label = this.add.text(cx, cy - 84, 'TOTAL SCORE', { + fontFamily: 'Righteous', fontSize: '34px', color: '#ffffff', + stroke: '#000000', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.banner); + this.countText = this.add.text(cx, cy, '0', { + fontFamily: 'Righteous', fontSize: '104px', color: COLORS.goldHex, + stroke: '#000000', strokeThickness: 8, + }).setOrigin(0.5).setDepth(D.banner); + this.layer.add([label, this.countText]); + + this.countTotal = this.state.score; + const counter = { v: 0 }; + this.countTween = this.tweens.add({ + targets: counter, v: this.countTotal, duration: 2800, ease: 'Cubic.easeOut', + onUpdate: () => this.countText?.setText(Math.floor(counter.v).toLocaleString()), + onComplete: () => this.endCountUp(), + }); + + // Scoring tick that rises in pitch as the counter climbs. + this.countTimer = this.time.addEvent({ + delay: 90, + loop: true, + callback: () => { + const p = this.countTotal > 0 ? counter.v / this.countTotal : 1; + try { this.sound.play(SFX.SCIFI_PLINK, { rate: 0.9 + 1.3 * p, volume: 0.6 }); } catch (_) { /* audio locked */ } + }, + }); + + // Click to skip straight to the result. + this.skipHandler = () => this.skipCelebration(); + this.input.on('pointerup', this.skipHandler); + } + + endCountUp() { + if (this.countDone) return; + this.countDone = true; + this.countTimer?.remove(); + this.countTimer = null; + this.countText?.setText(this.countTotal.toLocaleString()); + playSound(this, SFX.VICTORY_SHORT); + if (this.countText) { + this.tweens.add({ targets: this.countText, scale: 1.18, duration: 160, yoyo: true }); + } + this.time.delayedCall(700, () => this.finishCelebration()); + } + + skipCelebration() { + if (!this.celebrating) return; + if (!this.countDone) { + this.countTween?.remove(); + this.countTween = null; + this.countDone = true; + this.countTimer?.remove(); + this.countTimer = null; + this.countText?.setText(this.countTotal.toLocaleString()); + playSound(this, SFX.VICTORY_SHORT); + } + this.finishCelebration(); + } + + finishCelebration() { + if (!this.celebrating) return; + this.celebrating = false; + if (this.skipHandler) { this.input.off('pointerup', this.skipHandler); this.skipHandler = null; } + this.celebrationFx?.stop(); + this.showResult(true); + } + + // ── FX helpers ────────────────────────────────────────────────────────────── + + floatText(x, y, str, color) { + const t = this.add.text(x, y, str, { + fontFamily: 'Righteous', fontSize: '20px', color, + }).setOrigin(0.5).setDepth(D.fx); + this.layer.add(t); + this.tweens.add({ + targets: t, y: y - 46, alpha: 0, duration: 800, ease: 'Cubic.easeOut', + onComplete: () => t.destroy(), + }); + } + + banner(str, color, size = 34) { + const t = this.add.text(BOARD_X + BOARD_W / 2, BOARD_Y + 200, str, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, + stroke: '#000000', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.banner).setScale(0.6).setAlpha(0); + this.layer.add(t); + this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' }); + this.tweens.add({ + targets: t, alpha: 0, y: t.y - 30, delay: 1050, duration: 350, + onComplete: () => t.destroy(), + }); + } + + // ── Result ────────────────────────────────────────────────────────────────── + + showResult(won) { + if (this.overlayUp) return; + this.overlayUp = true; + this.previewG?.clear(); + + if (won && this.canPersist && !this.testLevel) { + if (this.entry.level > this.levelsCompleted) this.levelsCompleted = this.entry.level; + api.post('/puzzles/peggle/complete', { level: this.entry.level }) + .then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); }) + .catch(() => { /* best effort */ }); + api.post('/history/single-player', { + slug: 'peggle', score: this.state.score, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + } else if (!won && this.canPersist && !this.testLevel) { + api.post('/history/single-player', { + slug: 'peggle', score: this.state.score, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + } + + if (won) { playSound(this, SFX.VICTORY_SHORT); this.portrait?.playEmotion('happy'); this.say('happy'); } + else { playSound(this, SFX.CASINO_LOSE); this.portrait?.playEmotion('upset'); this.say('upset'); } + + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH * 2, GAME_HEIGHT * 2, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20); + panel.lineStyle(3, won ? COLORS.gold : COLORS.danger, 1); + panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20); + const title = this.add.text(cx, cy - 140, won ? 'LEVEL CLEARED!' : 'OUT OF BALLS', { + fontFamily: 'Righteous', fontSize: '56px', color: won ? COLORS.goldHex : COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const stat = this.add.text(cx, cy - 60, + won + ? `${this.entry.name} cleared!\nFinal score: ${this.state.score.toLocaleString()}` + : `${this.state.orangeTotal - this.state.orangeCleared} orange pegs remained.\nScore: ${this.state.score.toLocaleString()}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([dim, panel, title, stat]); + + const btns = []; + if (this.returnToEditor) { + btns.push(new Button(this, cx, cy + 60, 'Back to Editor', () => this.exitLevel(), + { width: 320, height: 60, fontSize: 24 })); + } else if (won) { + const idx = this.manifest.findIndex((m) => m.level === this.entry.level); + const next = this.manifest[idx + 1]; + if (next) { + btns.push(new Button(this, cx, cy + 60, `Next Level (${next.level})`, () => this.playLevel(next), + { width: 340, height: 60, fontSize: 26 })); + } else { + btns.push(this.add.text(cx, cy + 55, 'You cleared every level. Bravo!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5)); + } + } else { + btns.push(new Button(this, cx, cy + 60, 'Try Again', () => this.playLevel(this.entry), + { width: 320, height: 60, fontSize: 26 })); + } + if (!this.returnToEditor) { + btns.push(new Button(this, cx - 115, cy + 145, 'Replay', () => this.playLevel(this.entry), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' })); + btns.push(new Button(this, cx + 115, cy + 145, 'Levels', () => this.showLevelSelect(), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' })); + } + for (const b of btns) { b.setDepth?.(D.overlayUI); this.layer.add(b); } + } +} diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js new file mode 100644 index 0000000..aa1439e --- /dev/null +++ b/src/games/peggle/PeggleLogic.js @@ -0,0 +1,643 @@ +// Peggle — pure deterministic simulation + rules. Zero imports, Node-testable. +// +// All physics runs in a fixed logical board space (default 1200×900) with a +// fixed substep so per-step travel never exceeds the ball radius at max speed +// (no tunneling by construction). Every random draw comes from the round's +// seeded RNG, so the same seed + same aim angle replays identically — that is +// what makes the aim preview, Super Guide, and Zen Ball exact rather than +// approximations. + +export const TUNING = { + BOARD_W: 1200, + BOARD_H: 900, + GRAVITY: 1100, // px/s² + BALL_R: 13, + PEG_R: 16, + PEG_RESTITUTION: 0.88, + WALL_RESTITUTION: 0.82, + LAUNCH_SPEED: 1000, + MAX_SPEED: 1900, + SUBSTEP_DT: 1 / 240, + LAUNCH_X: 600, + LAUNCH_Y: 40, + MAX_AIM_DEG: 80, // from straight down + BUCKET_Y: 860, // top of the bucket opening + BUCKET_W: 130, // opening width + BUCKET_RIM_W: 14, + BUCKET_RIM_H: 34, + BUCKET_MARGIN: 90, // travel inset from each side wall + BUCKET_PERIOD: 6.5, // seconds for one full left→right→left cycle... (one-way = half) + STUCK_SPEED: 40, + STUCK_TIME: 1.5, + PEG_FADE_S: 1.5, // lit pegs clear out (and stop colliding) after this + MAX_FLIGHT_S: 45, + BALLS_PER_LEVEL: 10, +}; + +export const SCORING = { + PEG_BASE: { blue: 10, orange: 100, green: 10, purple: 500 }, + // Multiplier by orange pegs cleared BEFORE the current hit. + MULT_LADDER: [[22, 10], [20, 5], [15, 3], [10, 2], [0, 1]], + FREE_BALL_THRESHOLDS: [25000, 75000, 125000], + LONG_SHOT_BONUS: 25000, + LONG_SHOT_DIST: 500, + FEVER_BUCKETS: [10000, 50000, 100000, 50000, 10000], + SPACE_BLAST_RADIUS: 150, +}; + +export const POWERS = { + superguide: { + id: 'superguide', name: 'Super Guide', trigger: 'charge', + desc: 'Your next 3 shots show an extended trajectory through extra bounces.', + }, + multiball: { + id: 'multiball', name: 'Multiball', trigger: 'instant', + desc: 'A second ball is fetched and joins the action immediately.', + }, + spaceblast: { + id: 'spaceblast', name: 'Space Blast', trigger: 'instant', + desc: 'The green peg explodes, lighting every peg nearby.', + }, + fireball: { + id: 'fireball', name: 'Fireball', trigger: 'nextBall', + desc: 'Your next ball blazes straight through pegs, lighting all it touches.', + }, + zenball: { + id: 'zenball', name: 'Zen Ball', trigger: 'nextBall', + desc: 'Your next shot is calmly nudged to a better-scoring angle.', + }, +}; + +// ── RNG ────────────────────────────────────────────────────────────────────── + +export function mulberry32(seed) { + let s = seed >>> 0; + return { + next() { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }, + get state() { return s >>> 0; }, + set state(v) { s = v >>> 0; }, + }; +} + +// ── Round construction ─────────────────────────────────────────────────────── + +export function multiplierFor(orangeCleared) { + for (const [min, mult] of SCORING.MULT_LADDER) { + if (orangeCleared >= min) return mult; + } + return 1; +} + +function pickN(rng, indices, n) { + // Fisher-Yates partial shuffle over a copy; deterministic given rng state. + const pool = indices.slice(); + const out = []; + for (let i = 0; i < n && pool.length; i++) { + const j = Math.floor(rng.next() * pool.length); + out.push(pool[j]); + pool[j] = pool[pool.length - 1]; + pool.pop(); + } + return out; +} + +export function createRound(levelDef, opts = {}) { + const seed = opts.seed ?? Math.floor(Math.random() * 0xffffffff); + const rng = mulberry32(seed); + const board = levelDef.board ?? { width: TUNING.BOARD_W, height: TUNING.BOARD_H }; + const orangeCount = levelDef.orangeCount ?? 25; + const greenCount = levelDef.greenCount ?? 2; + + const pegs = levelDef.pegs.map((p, i) => ({ + id: i, + x: p.x, y: p.y, + r: p.r ?? TUNING.PEG_R, + shape: p.shape ?? 'circle', + color: 'blue', + orangeEligible: !!p.orangeEligible, + lit: false, + removed: false, + })); + + const eligible = pegs.filter((p) => p.orangeEligible).map((p) => p.id); + for (const id of pickN(rng, eligible, Math.min(orangeCount, eligible.length))) { + pegs[id].color = 'orange'; + } + const blues = pegs.filter((p) => p.color === 'blue').map((p) => p.id); + for (const id of pickN(rng, blues, Math.min(greenCount, blues.length))) { + pegs[id].color = 'green'; + } + + const state = { + seed, + rng, + board, + pegs, + powerId: levelDef.powerId ?? opts.powerId ?? null, + orangeTotal: pegs.filter((p) => p.color === 'orange').length, + orangeCleared: 0, + ballsLeft: TUNING.BALLS_PER_LEVEL, + score: 0, + shotScore: 0, + freeBallsGiven: 0, // count of FREE_BALL_THRESHOLDS already awarded + balls: [], + phase: 'aim', // aim | flight | fever | won | lost + time: 0, // round clock (drives the bucket) + clock: 0, // sim clock, advanced per substep (drives peg fades) + flightTime: 0, + purpleId: null, + lastPegHit: null, // { x, y } within the current shot + wallBouncesSincePeg: 0, + // power bookkeeping + superGuideShots: 0, + fireballNext: 0, + zenNext: 0, + feverResolved: false, + }; + assignPurple(state); + return state; +} + +function assignPurple(state) { + const prev = state.purpleId; + if (prev != null && state.pegs[prev] && state.pegs[prev].color === 'purple' + && !state.pegs[prev].removed && !state.pegs[prev].lit) { + state.pegs[prev].color = 'blue'; + } + state.purpleId = null; + const blues = state.pegs.filter((p) => p.color === 'blue' && !p.removed && !p.lit).map((p) => p.id); + if (!blues.length) return null; + const id = blues[Math.floor(state.rng.next() * blues.length)]; + state.pegs[id].color = 'purple'; + state.purpleId = id; + return id; +} + +// ── Cloning (previews / zen must never disturb the live round) ─────────────── + +export function cloneState(state) { + const c = { + ...state, + rng: mulberry32(0), + board: { ...state.board }, + pegs: state.pegs.map((p) => ({ ...p })), + balls: state.balls.map((b) => ({ ...b })), + lastPegHit: state.lastPegHit ? { ...state.lastPegHit } : null, + }; + c.rng.state = state.rng.state; + return c; +} + +// ── Aiming ─────────────────────────────────────────────────────────────────── + +export function clampAim(angle) { + const max = (TUNING.MAX_AIM_DEG * Math.PI) / 180; + return Math.max(-max, Math.min(max, angle)); +} + +// angle: radians from straight down (0 = straight down, + = right) +export function aimToVelocity(angle) { + const a = clampAim(angle); + return { vx: TUNING.LAUNCH_SPEED * Math.sin(a), vy: TUNING.LAUNCH_SPEED * Math.cos(a) }; +} + +// ── Bucket (kinematic: position is a pure function of the round clock) ────── + +export function bucketX(state) { + const T = TUNING; + const minX = T.BUCKET_MARGIN + T.BUCKET_W / 2; + const maxX = state.board.width - T.BUCKET_MARGIN - T.BUCKET_W / 2; + const span = maxX - minX; + const half = T.BUCKET_PERIOD / 2; + const t = state.time % T.BUCKET_PERIOD; + const frac = t < half ? t / half : (T.BUCKET_PERIOD - t) / half; // triangle wave 0..1..0 + return minX + span * frac; +} + +// ── Launch ─────────────────────────────────────────────────────────────────── + +export function launchBall(state, angle, opts = {}) { + const events = []; + if (state.phase !== 'aim' || state.ballsLeft <= 0) return events; + + let a = clampAim(angle); + if (!opts.skipZen && state.zenNext > 0) { + state.zenNext--; + const best = zenBallOptimize(state, a); + if (best.angle !== a) events.push({ type: 'zenAdjust', from: a, to: best.angle }); + a = best.angle; + events.push({ type: 'powerFired', powerId: 'zenball' }); + } + + state.ballsLeft--; + state.phase = 'flight'; + state.shotScore = 0; + state.flightTime = 0; + state.lastPegHit = null; + state.wallBouncesSincePeg = 0; + if (state.superGuideShots > 0) state.superGuideShots--; + + const ball = { x: TUNING.LAUNCH_X, y: TUNING.LAUNCH_Y, ...aimToVelocity(a), stuckFor: 0, fireball: false }; + if (state.fireballNext > 0) { + state.fireballNext--; + ball.fireball = true; + events.push({ type: 'powerFired', powerId: 'fireball' }); + } + state.balls.push(ball); + events.push({ type: 'launch', angle: a }); + return events; +} + +// ── Peg / geometry helpers ─────────────────────────────────────────────────── + +function collideBallPeg(ball, peg) { + // Returns contact normal if overlapping, else null. Circles only in v1. + const dx = ball.x - peg.x; + const dy = ball.y - peg.y; + const rr = TUNING.BALL_R + peg.r; + const d2 = dx * dx + dy * dy; + if (d2 >= rr * rr) return null; + const d = Math.sqrt(d2) || 0.0001; + return { nx: dx / d, ny: dy / d, depth: rr - d }; +} + +function reflect(ball, nx, ny, restitution) { + const dot = ball.vx * nx + ball.vy * ny; + if (dot >= 0) return false; // already separating + ball.vx -= (1 + restitution) * dot * nx; + ball.vy -= (1 + restitution) * dot * ny; + return true; +} + +function clampSpeed(ball) { + const s2 = ball.vx * ball.vx + ball.vy * ball.vy; + const max = TUNING.MAX_SPEED; + if (s2 > max * max) { + const s = Math.sqrt(s2); + ball.vx = (ball.vx / s) * max; + ball.vy = (ball.vy / s) * max; + } +} + +// ── Scoring on peg hits ────────────────────────────────────────────────────── + +function scorePeg(state, peg, events, { fromBlast = false, ball = null } = {}) { + if (peg.lit || peg.removed) return; + peg.lit = true; + peg.litAt = state.clock; + const multBefore = multiplierFor(state.orangeCleared); + const base = SCORING.PEG_BASE[peg.color] ?? 10; + const points = base * multBefore; + state.score += points; + state.shotScore += points; + events.push({ type: 'pegHit', pegId: peg.id, color: peg.color, points, mult: multBefore, fromBlast }); + + if (peg.color === 'orange') { + // Long Shot: hit an orange far from the previous peg contact with a wall + // bounce in between. + if (!fromBlast && state.lastPegHit && state.wallBouncesSincePeg > 0) { + const dx = peg.x - state.lastPegHit.x; + const dy = peg.y - state.lastPegHit.y; + if (dx * dx + dy * dy >= SCORING.LONG_SHOT_DIST * SCORING.LONG_SHOT_DIST) { + state.score += SCORING.LONG_SHOT_BONUS; + state.shotScore += SCORING.LONG_SHOT_BONUS; + events.push({ type: 'longShot', points: SCORING.LONG_SHOT_BONUS }); + } + } + state.orangeCleared++; + if (state.orangeCleared >= state.orangeTotal && state.phase === 'flight') { + state.phase = 'fever'; + events.push({ type: 'feverStart' }); + } + } else if (peg.color === 'green') { + events.push({ type: 'powerCharged', powerId: state.powerId }); + applyPower(state, state.powerId, { peg, ball, events }); + } + + if (!fromBlast) { + state.lastPegHit = { x: peg.x, y: peg.y }; + state.wallBouncesSincePeg = 0; + } + checkFreeBallThresholds(state, events); +} + +function checkFreeBallThresholds(state, events) { + const T = SCORING.FREE_BALL_THRESHOLDS; + while (state.freeBallsGiven < T.length && state.score >= T[state.freeBallsGiven]) { + state.freeBallsGiven++; + state.ballsLeft++; + events.push({ type: 'freeBall', reason: 'threshold', threshold: T[state.freeBallsGiven - 1] }); + } +} + +// ── Powers ─────────────────────────────────────────────────────────────────── + +export function applyPower(state, powerId, ctx = {}) { + const events = ctx.events ?? []; + switch (powerId) { + case 'superguide': + state.superGuideShots += 3; + break; + case 'multiball': { + const src = ctx.ball ?? state.balls[0]; + if (src) { + state.balls.push({ x: src.x, y: src.y, vx: -src.vx, vy: src.vy, stuckFor: 0, fireball: false }); + events.push({ type: 'multiball' }); + events.push({ type: 'powerFired', powerId: 'multiball' }); + } + break; + } + case 'spaceblast': { + const peg = ctx.peg; + if (peg) { + const R = SCORING.SPACE_BLAST_RADIUS; + const hit = []; + for (const q of state.pegs) { + if (q.lit || q.removed || q.id === peg.id) continue; + const dx = q.x - peg.x; + const dy = q.y - peg.y; + if (dx * dx + dy * dy <= R * R) hit.push(q); + } + events.push({ type: 'spaceBlast', x: peg.x, y: peg.y, pegIds: hit.map((q) => q.id) }); + events.push({ type: 'powerFired', powerId: 'spaceblast' }); + for (const q of hit) scorePeg(state, q, events, { fromBlast: true }); + } + break; + } + case 'fireball': + state.fireballNext++; + break; + case 'zenball': + state.zenNext++; + break; + default: + break; + } + return events; +} + +// ── Simulation step ────────────────────────────────────────────────────────── + +export function stepSim(state, dt) { + const events = []; + state.time += dt; + if (state.phase !== 'flight' && state.phase !== 'fever') return events; + + state.flightTime += dt; + const n = Math.max(1, Math.ceil(dt / TUNING.SUBSTEP_DT)); + const h = dt / n; + for (let i = 0; i < n; i++) substep(state, h, events); + + if (state.flightTime > TUNING.MAX_FLIGHT_S && state.balls.length) { + // Failsafe: something degenerate happened; resolve the shot. + for (const _ of state.balls) events.push({ type: 'ballLost', failsafe: true }); + state.balls.length = 0; + } + + if (!state.balls.length && (state.phase === 'flight' || state.phase === 'fever')) { + if (state.phase === 'fever' && !state.feverResolved) { + // Fever ball vanished without crossing the bottom (failsafe path). + resolveFever(state, state.board.width / 2, events); + } else if (state.phase === 'flight') { + endShot(state, events); + } + } + return events; +} + +function substep(state, h, events) { + const T = TUNING; + const W = state.board.width; + const bx = bucketX(state); + state.clock += h; + + // Lit pegs clear out after PEG_FADE_S and stop colliding. + let faded = null; + for (const peg of state.pegs) { + if (peg.lit && !peg.removed && state.clock - peg.litAt >= T.PEG_FADE_S) { + peg.removed = true; + (faded ??= []).push(peg.id); + } + } + if (faded) events.push({ type: 'pegsCleared', pegIds: faded, faded: true }); + + for (let bi = state.balls.length - 1; bi >= 0; bi--) { + const ball = state.balls[bi]; + // resolveFever (triggered by another ball's bottom exit) empties the + // array mid-loop; any remaining indices are gone. + if (!ball) continue; + ball.vy += T.GRAVITY * h; + clampSpeed(ball); + ball.x += ball.vx * h; + ball.y += ball.vy * h; + + // Pegs + for (const peg of state.pegs) { + if (peg.removed) continue; + const c = collideBallPeg(ball, peg); + if (!c) continue; + if (ball.fireball) { + scorePeg(state, peg, events, { ball }); + continue; // burn straight through + } + // Push out and reflect. + ball.x += c.nx * c.depth; + ball.y += c.ny * c.depth; + if (reflect(ball, c.nx, c.ny, T.PEG_RESTITUTION)) { + scorePeg(state, peg, events, { ball }); + } + } + + // Walls (left/right/top). Bottom is open. + if (ball.x < T.BALL_R) { + ball.x = T.BALL_R; + if (reflect(ball, 1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + } else if (ball.x > W - T.BALL_R) { + ball.x = W - T.BALL_R; + if (reflect(ball, -1, 0, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + } + if (ball.y < T.BALL_R) { + ball.y = T.BALL_R; + if (reflect(ball, 0, 1, T.WALL_RESTITUTION)) { state.wallBouncesSincePeg++; events.push({ type: 'wallHit' }); } + } + + if (state.phase === 'flight') { + // Bucket rims (two vertical posts) + catch opening. + const openHalf = T.BUCKET_W / 2; + for (const side of [-1, 1]) { + const rimX = bx + side * (openHalf + T.BUCKET_RIM_W / 2); + const dx = ball.x - rimX; + const dy = ball.y - (T.BUCKET_Y + T.BUCKET_RIM_H / 2); + const halfW = T.BUCKET_RIM_W / 2 + T.BALL_R; + const halfH = T.BUCKET_RIM_H / 2 + T.BALL_R; + if (Math.abs(dx) < halfW && Math.abs(dy) < halfH) { + // Resolve along the shallower axis. + if (halfW - Math.abs(dx) < halfH - Math.abs(dy)) { + const nx = dx < 0 ? -1 : 1; + ball.x = rimX + nx * halfW; + reflect(ball, nx, 0, T.WALL_RESTITUTION); + } else { + const ny = dy < 0 ? -1 : 1; + ball.y = T.BUCKET_Y + T.BUCKET_RIM_H / 2 + ny * halfH; + reflect(ball, 0, ny, T.WALL_RESTITUTION); + } + } + } + if (ball.vy > 0 && ball.y > T.BUCKET_Y && ball.y < T.BUCKET_Y + T.BUCKET_RIM_H + && Math.abs(ball.x - bx) < openHalf - T.BALL_R * 0.4) { + state.ballsLeft++; + events.push({ type: 'bucketCatch' }); + events.push({ type: 'freeBall', reason: 'bucket' }); + state.balls.splice(bi, 1); + continue; + } + } + + // Bottom exit + if (ball.y > state.board.height + T.BALL_R * 3) { + if (state.phase === 'fever' && !state.feverResolved) { + resolveFever(state, ball.x, events); + } else { + events.push({ type: 'ballLost' }); + } + state.balls.splice(bi, 1); + continue; + } + + // Stuck detection → deterministic nudge. + const speed = Math.hypot(ball.vx, ball.vy); + if (speed < T.STUCK_SPEED) { + ball.stuckFor += h; + if (ball.stuckFor > T.STUCK_TIME) { + ball.stuckFor = 0; + ball.vx += (state.rng.next() - 0.5) * 260; + ball.vy -= 140 + state.rng.next() * 120; + events.push({ type: 'nudge' }); + } + } else { + ball.stuckFor = 0; + } + } +} + +function endShot(state, events) { + const cleared = []; + for (const peg of state.pegs) { + if (peg.lit && !peg.removed) { + peg.removed = true; + peg.lit = false; + cleared.push(peg.id); + } + } + if (cleared.length) events.push({ type: 'pegsCleared', pegIds: cleared }); + events.push({ type: 'shotEnd', shotScore: state.shotScore }); + + if (state.orangeCleared >= state.orangeTotal) { + // Should have gone through fever, but guard the direct path. + state.phase = 'won'; + events.push({ type: 'win', score: state.score }); + } else if (state.ballsLeft <= 0) { + state.phase = 'lost'; + events.push({ type: 'lose', score: state.score }); + } else { + state.phase = 'aim'; + const moved = assignPurple(state); + if (moved != null) events.push({ type: 'purpleMoved', pegId: moved }); + } +} + +export function resolveFever(state, finalX, events = []) { + if (state.feverResolved) return events; + state.feverResolved = true; + + // Score every remaining (unlit, unremoved) peg at the final multiplier. + const mult = multiplierFor(state.orangeCleared); + let bonus = 0; + const swept = []; + for (const peg of state.pegs) { + if (peg.removed) continue; + if (!peg.lit) { + bonus += (SCORING.PEG_BASE[peg.color] ?? 10) * mult; + swept.push(peg.id); + } + peg.lit = false; + peg.removed = true; + } + state.score += bonus; + + // 5 bonus buckets across the board bottom. + const n = SCORING.FEVER_BUCKETS.length; + const idx = Math.max(0, Math.min(n - 1, Math.floor((finalX / state.board.width) * n))); + const bucketPoints = SCORING.FEVER_BUCKETS[idx]; + state.score += bucketPoints; + + state.balls.length = 0; + state.phase = 'won'; + events.push({ type: 'feverResolve', bucketIndex: idx, bucketPoints, sweepPoints: bonus, pegIds: swept }); + events.push({ type: 'win', score: state.score }); + return events; +} + +// ── Preview & Zen Ball (dry-runs on cloned state) ──────────────────────────── + +// Traces the flight path for the aim guide. Stops after `maxPegHits` peg +// contacts (1 = normal guide, higher = Super Guide) or when the ball resolves. +export function simulatePreview(state, angle, opts = {}) { + const maxPegHits = opts.maxPegHits ?? 1; + const maxTime = opts.maxTime ?? 8; + const sim = cloneState(state); + sim.phase = 'aim'; + sim.zenNext = 0; + sim.fireballNext = 0; + sim.balls = []; + launchBall(sim, angle, { skipZen: true }); + + const points = []; + let pegHits = 0; + let t = 0; + const dt = 1 / 120; + while (sim.balls.length && t < maxTime && pegHits <= maxPegHits) { + const evs = stepSim(sim, dt); + for (const e of evs) { + if (e.type === 'pegHit') pegHits++; + } + if (sim.balls.length) points.push({ x: sim.balls[0].x, y: sim.balls[0].y }); + if (pegHits >= maxPegHits && points.length > 2) break; + t += dt; + } + return { points, pegHits }; +} + +// Runs a complete shot on a clone and reports the score it would gain. +export function scoreShot(state, angle, opts = {}) { + const sim = cloneState(state); + sim.zenNext = 0; + const before = sim.score; + launchBall(sim, angle, { skipZen: true }); + let t = 0; + const dt = 1 / 60; + const maxTime = opts.maxTime ?? TUNING.MAX_FLIGHT_S + 1; + while ((sim.phase === 'flight' || sim.phase === 'fever') && t < maxTime) { + stepSim(sim, dt); + t += dt; + } + return sim.score - before; +} + +// Samples angles around the aim and returns the best-scoring one. +export function zenBallOptimize(state, angle, opts = {}) { + const spread = ((opts.spreadDeg ?? 6) * Math.PI) / 180; + const samples = opts.samples ?? 13; + let best = { angle: clampAim(angle), score: -1 }; + for (let i = 0; i < samples; i++) { + const a = clampAim(angle - spread + (2 * spread * i) / (samples - 1)); + const s = scoreShot(state, a); + if (s > best.score) best = { angle: a, score: s }; + } + return best; +} diff --git a/src/main.js b/src/main.js index 125e7bc..73d8253 100644 --- a/src/main.js +++ b/src/main.js @@ -87,6 +87,8 @@ import JumbleGame from './games/jumble/JumbleGame.js'; import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js'; import SWDBGGame from './games/swdbg/SWDBGGame.js'; import BalatroGame from './games/balatro/BalatroGame.js'; +import PeggleGame from './games/peggle/PeggleGame.js'; +import PeggleEditor from './games/peggle/PeggleEditor.js'; const config = { type: Phaser.AUTO, @@ -187,6 +189,8 @@ const config = { DungeonBossGame, SWDBGGame, BalatroGame, + PeggleGame, + PeggleEditor, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index df25d50..f5b5db8 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,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', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame' }; + 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', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 8169709..9c558b7 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -57,6 +57,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.json('balatro-artwork', 'data/balatro-artwork.json'); + this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json'); this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3'); @@ -168,6 +169,14 @@ export default class PreloadScene extends Phaser.Scene { try { await Promise.race([Promise.all([document.fonts.load('48px YummyCupcakes'), document.fonts.load('48px m6x11')]), new Promise(r => setTimeout(r, 1500))]); } catch { /* ignore */ } await auth.refresh(); + + // Secret level-editor entrance: index.html?peggle-editor=1 boots straight + // into the Peggle editor instead of the landing page. + const params = new URLSearchParams(window.location.search); + if (params.has('peggle-editor')) { + this.scene.start('PeggleEditor'); + return; + } this.scene.start('Landing'); } } diff --git a/tools/genPeggleLevels.js b/tools/genPeggleLevels.js new file mode 100644 index 0000000..ace13c3 --- /dev/null +++ b/tools/genPeggleLevels.js @@ -0,0 +1,198 @@ +// Generates the first five Peggle stub layouts + the level manifest. +// node tools/genPeggleLevels.js +// Writes assets/gamedata/peggle/level-001..005.json and levels.json. +// +// Layouts are starting points meant to be hand-tuned later in the editor +// (index.html?peggle-editor=1); pegs violating spacing are dropped, so shapes +// may thin slightly where strokes intersect. + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { TUNING } from '../src/games/peggle/PeggleLogic.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle'); + +const W = TUNING.BOARD_W; // 1200 +const MIN_Y = 190; // clear of the launcher +const MAX_Y = 790; // clear of the bucket lane +const GAP = 6; // required clearance between peg rims +const R = TUNING.PEG_R; + +// ── Shape helpers ──────────────────────────────────────────────────────────── + +function arc(cx, cy, radius, a0, a1, n, opts = {}) { + const pegs = []; + for (let i = 0; i < n; i++) { + const a = a0 + ((a1 - a0) * i) / Math.max(1, n - 1); + pegs.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), ...opts }); + } + return pegs; +} + +function line(x0, y0, x1, y1, n, opts = {}) { + const pegs = []; + for (let i = 0; i < n; i++) { + const t = n === 1 ? 0 : i / (n - 1); + pegs.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, ...opts }); + } + return pegs; +} + +function grid(x, y, cols, rows, dx, dy, opts = {}) { + const pegs = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + pegs.push({ x: x + c * dx, y: y + r * dy, ...opts }); + } + } + return pegs; +} + +function circle(cx, cy, radius, n, opts = {}) { + return arc(cx, cy, radius, 0, Math.PI * 2 * (1 - 1 / n), n, opts); +} + +// Drop pegs out of bounds or too close to an earlier peg. +function settle(pegs) { + const out = []; + for (const p of pegs) { + const x = Math.round(p.x); + const y = Math.round(p.y); + if (x < R + GAP || x > W - R - GAP || y < MIN_Y || y > MAX_Y) continue; + const minD = 2 * R + GAP; + if (out.some((q) => (q.x - x) ** 2 + (q.y - y) ** 2 < minD * minD)) continue; + out.push({ x, y, shape: 'circle', r: R, orangeEligible: !!p.orangeEligible }); + } + return out; +} + +function levelFile(pegs, over = {}) { + return { + version: 1, + board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H }, + orangeCount: 25, + greenCount: 2, + pegs, + walls: [], + moving: [], + ...over, + }; +} + +const el = { orangeEligible: true }; + +// ── Level 1 — Tea Time (Ethel): gentle nested arcs + bottom row ───────────── + +function level1() { + const pegs = [ + ...arc(600, 120, 420, Math.PI * 0.15, Math.PI * 0.85, 14, el), + ...arc(600, 60, 560, Math.PI * 0.20, Math.PI * 0.80, 13, el), + ...arc(600, 10, 700, Math.PI * 0.25, Math.PI * 0.75, 12, el), + ...line(160, 770, 1040, 770, 10), + ...line(320, 300, 880, 300, 5), + ]; + return levelFile(settle(pegs)); +} + +// ── Level 2 — Fetch! (Kona): a dog bone + scattered field ─────────────────── + +function level2() { + const pegs = [ + // bone: two knuckle circles joined by a shaft + ...circle(310, 430, 95, 9, el), + ...circle(890, 430, 95, 9, el), + ...line(400, 390, 800, 390, 6, el), + ...line(400, 470, 800, 470, 6, el), + ...line(430, 430, 770, 430, 5, el), + // scattered field above and below + ...grid(220, 240, 6, 1, 152, 0), + ...grid(300, 620, 5, 2, 150, 90), + ...line(140, 320, 220, 560, 3), + ...line(1060, 320, 980, 560, 3), + ]; + return levelFile(settle(pegs)); +} + +// ── Level 3 — Hocus Pocus (Zanthor): five-point star inside an arc ────────── + +function level3() { + const cx = 600; + const cy = 480; + const outer = 230; + // star vertices (point up) + const verts = []; + for (let i = 0; i < 5; i++) { + const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; + verts.push([cx + outer * Math.cos(a), cy + outer * Math.sin(a)]); + } + const order = [0, 2, 4, 1, 3, 0]; // pentagram stroke + const pegs = []; + for (let i = 0; i < 5; i++) { + const [x0, y0] = verts[order[i]]; + const [x1, y1] = verts[order[i + 1]]; + pegs.push(...line(x0, y0, x1, y1, 8, el)); + } + pegs.push(...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el)); + pegs.push(...line(240, 240, 460, 240, 3)); + pegs.push(...line(740, 240, 960, 240, 3)); + return levelFile(settle(pegs)); +} + +// ── Level 4 — X-Ray Vision (Fireball): concentric rings, orange core ──────── + +function level4() { + const cx = 600; + const cy = 470; + const pegs = [ + ...circle(cx, cy, 90, 8, el), + ...circle(cx, cy, 175, 14, el), + ...circle(cx, cy, 260, 20, el), + ...arc(cx, cy, 345, Math.PI * -0.35, Math.PI * 1.35, 22), + { x: cx, y: cy, ...el }, + ...line(160, 740, 400, 740, 3), + ...line(800, 740, 1040, 740, 3), + ]; + return levelFile(settle(pegs)); +} + +// ── Level 5 — Checkmate (Victor): diagonal checkerboard lattice ───────────── + +function level5() { + const pegs = [ + ...grid(180, 250, 8, 5, 120, 120, el), + ...grid(240, 310, 8, 5, 120, 120, el), + ...line(160, 210, 1040, 210, 8), + ]; + return levelFile(settle(pegs)); +} + +// ── Manifest + write ───────────────────────────────────────────────────────── + +const LEVELS = [ + { level: 1, name: 'Tea Time', masterId: 'ethel', powerId: 'superguide', file: 'level-001.json', background: 'parlor', build: level1 }, + { level: 2, name: 'Fetch!', masterId: 'kona', powerId: 'multiball', file: 'level-002.json', background: 'park', build: level2 }, + { level: 3, name: 'Hocus Pocus', masterId: 'zanthor', powerId: 'spaceblast', file: 'level-003.json', background: 'arcana', build: level3 }, + { level: 4, name: 'X-Ray Vision', masterId: 'fireball', powerId: 'fireball', file: 'level-004.json', background: 'lab', build: level4 }, + { level: 5, name: 'Checkmate', masterId: 'victor', powerId: 'zenball', file: 'level-005.json', background: 'study', build: level5 }, +]; + +mkdirSync(OUT_DIR, { recursive: true }); +for (const def of LEVELS) { + const data = def.build(); + const eligible = data.pegs.filter((p) => p.orangeEligible).length; + if (eligible < data.orangeCount) { + throw new Error(`${def.file}: only ${eligible} orange-eligible pegs (< ${data.orangeCount})`); + } + writeFileSync(join(OUT_DIR, def.file), `${JSON.stringify(data, null, 2)}\n`); + console.log(`${def.file}: ${data.pegs.length} pegs (${eligible} orange-eligible)`); +} + +const manifest = { + version: 1, + levels: LEVELS.map(({ build, ...entry }) => entry), +}; +writeFileSync(join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`levels.json: ${manifest.levels.length} levels`); diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js new file mode 100644 index 0000000..c64e3ba --- /dev/null +++ b/tools/verifyPeggle.js @@ -0,0 +1,545 @@ +// Headless verification for Peggle. +// node tools/verifyPeggle.js +// Exits non-zero on any failure. +// +// 1. Physics invariants (bounce restitution, wall reflection, anti-tunnel bound, +// bucket catch direction, stuck nudge). +// 2. Rules (peg color assignment, multiplier ladder, free-ball thresholds, +// long shot, fever + bonus buckets, win/lose, purple re-roll). +// 3. Powers (superguide, multiball, spaceblast, fireball, zenball). +// 4. Determinism (seeded replay, frame-rate independence of the substep). +// 5. Data lint (levels.json manifest ↔ level files ↔ opponents.json ↔ POWERS). +// +// Rendering, input, camera fever zoom, portrait overlays, and the editor's +// Blob export are browser-only and must be smoke-tested manually. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + TUNING, SCORING, POWERS, + mulberry32, multiplierFor, createRound, cloneState, + clampAim, aimToVelocity, bucketX, + launchBall, stepSim, resolveFever, + simulatePreview, scoreShot, zenBallOptimize, applyPower, +} from '../src/games/peggle/PeggleLogic.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle'); +const T = TUNING; + +let failures = 0; +let passes = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} +const near = (a, b, tol) => Math.abs(a - b) <= tol; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function mkLevel(pegs, over = {}) { + return { + board: { width: T.BOARD_W, height: T.BOARD_H }, + orangeCount: 0, + greenCount: 0, + powerId: over.powerId ?? null, + pegs, + ...over, + }; +} + +function mkState(pegs, over = {}, stateOver = {}) { + const st = createRound(mkLevel(pegs, over), { seed: over.seed ?? 7 }); + Object.assign(st, stateOver); + return st; +} + +// Put a single ball in flight manually (bypasses launch bookkeeping). +function inject(st, ball) { + st.phase = 'flight'; + st.balls.push({ stuckFor: 0, fireball: false, ...ball }); +} + +function run(st, seconds, dt = 1 / 60) { + const log = []; + for (let t = 0; t < seconds && (st.phase === 'flight' || st.phase === 'fever'); t += dt) { + log.push(...stepSim(st, dt)); + } + return log; +} + +// ── 1. Physics ─────────────────────────────────────────────────────────────── + +console.log('― physics'); + +check('anti-tunnel: MAX_SPEED × SUBSTEP_DT < BALL_R', + T.MAX_SPEED * T.SUBSTEP_DT < T.BALL_R, + `${T.MAX_SPEED * T.SUBSTEP_DT} !< ${T.BALL_R}`); + +{ + // Ball dropped straight onto a lone peg bounces back upward. + const st = mkState([{ x: 600, y: 500 }]); + inject(st, { x: 600, y: 430, vx: 0, vy: 200 }); + let hit = null; + let vyAfter = null; + let vyBefore = null; + for (let i = 0; i < 600 && !hit; i++) { + vyBefore = st.balls[0]?.vy; + const evs = stepSim(st, 1 / 240); + hit = evs.find((e) => e.type === 'pegHit') ?? null; + if (hit) vyAfter = st.balls[0].vy; + } + check('peg bounce: hit registered', !!hit); + check('peg bounce: ball reflects upward', vyAfter != null && vyAfter < 0, `vy after ${vyAfter}`); + check('peg bounce: restitution ratio ≈ PEG_RESTITUTION', + vyAfter != null && near(Math.abs(vyAfter / vyBefore), T.PEG_RESTITUTION, 0.06), + `|${vyAfter}/${vyBefore}| = ${Math.abs(vyAfter / vyBefore)}`); + check('peg lit but still present until shot ends', st.pegs[0].lit && !st.pegs[0].removed); +} + +{ + // A lit peg clears out (and stops colliding) PEG_FADE_S after the hit. + const st = mkState([{ x: 600, y: 500 }]); + inject(st, { x: 600, y: 430, vx: 0, vy: 200 }); + const log = run(st, T.PEG_FADE_S + 2); + const faded = log.find((e) => e.type === 'pegsCleared' && e.faded); + check('lit peg fades mid-flight after PEG_FADE_S', !!faded && faded.pegIds.includes(0)); + check('faded peg no longer collides', st.pegs[0].removed); +} + +{ + // Wall reflection preserves |vx|·restitution; ball never escapes the walls. + const st = mkState([]); + inject(st, { x: 100, y: 300, vx: -900, vy: 0 }); + let wallHit = false; + let vxAfter = null; + for (let i = 0; i < 200 && !wallHit; i++) { + const evs = stepSim(st, 1 / 240); + if (evs.some((e) => e.type === 'wallHit')) { wallHit = true; vxAfter = st.balls[0].vx; } + } + check('wall bounce: reflects rightward', wallHit && vxAfter > 0, `vx ${vxAfter}`); + check('wall bounce: restitution ratio ≈ WALL_RESTITUTION', + vxAfter != null && near(vxAfter / 900, T.WALL_RESTITUTION, 0.05), `${vxAfter / 900}`); +} + +{ + // Ball inside the bucket opening while descending → catch + free ball. + const st = mkState([]); + st.time = 0; + const bx = bucketX(st); + const ballsBefore = st.ballsLeft; + inject(st, { x: bx, y: T.BUCKET_Y - 30, vx: 0, vy: 300 }); + const log = run(st, 1); + check('bucket catch while descending', log.some((e) => e.type === 'bucketCatch')); + check('bucket catch grants a free ball', st.ballsLeft === ballsBefore + 1); + + // Same spot moving upward → no catch. + const st2 = mkState([]); + st2.time = 0; + inject(st2, { x: bucketX(st2), y: T.BUCKET_Y - 30, vx: 0, vy: -1200 }); + let caught = false; + for (let i = 0; i < 30; i++) { + if (stepSim(st2, 1 / 240).some((e) => e.type === 'bucketCatch')) caught = true; + } + check('no bucket catch while ascending', !caught); +} + +{ + // Bucket kinematics: pure function of the clock, periodic, within travel. + const st = mkState([]); + st.time = 1.234; + const a = bucketX(st); + st.time = 1.234 + T.BUCKET_PERIOD; + const b = bucketX(st); + check('bucket periodic', near(a, b, 0.001)); + let ok = true; + for (let t = 0; t < T.BUCKET_PERIOD; t += 0.05) { + st.time = t; + const x = bucketX(st); + if (x < T.BUCKET_MARGIN || x > st.board.width - T.BUCKET_MARGIN) ok = false; + } + check('bucket stays within travel bounds', ok); +} + +{ + // A ball resting on a peg gets nudged free. + const st = mkState([{ x: 600, y: 500 }]); + inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 }); + const log = run(st, 6); + check('stuck ball receives a nudge', log.some((e) => e.type === 'nudge')); +} + +{ + // Failsafe: flight never exceeds MAX_FLIGHT_S. + const st = mkState([{ x: 600, y: 500 }]); + inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 }); + const log = run(st, T.MAX_FLIGHT_S + 10); + check('shot always resolves (failsafe or exit)', st.balls.length === 0 && st.phase !== 'flight' || st.phase === 'aim', + `phase ${st.phase}, balls ${st.balls.length}`); +} + +// ── 2. Rules ───────────────────────────────────────────────────────────────── + +console.log('― rules'); + +{ + // Color assignment on a real generated level. + const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8')); + const st = createRound(lvl, { seed: 123 }); + const count = (c) => st.pegs.filter((p) => p.color === c).length; + check('createRound assigns exactly orangeCount orange', count('orange') === lvl.orangeCount, `${count('orange')}`); + check('createRound assigns exactly greenCount green', count('green') === lvl.greenCount, `${count('green')}`); + check('createRound assigns exactly one purple', count('purple') === 1); + check('orange pegs only on eligible slots', + st.pegs.every((p) => p.color !== 'orange' || lvl.pegs[p.id].orangeEligible)); + check('purple/green never on the same peg', st.pegs[st.purpleId].color === 'purple'); + check('10 balls to start', st.ballsLeft === T.BALLS_PER_LEVEL); +} + +check('multiplier ladder ×1 →', multiplierFor(0) === 1 && multiplierFor(9) === 1); +check('multiplier ladder ×2 @10', multiplierFor(10) === 2 && multiplierFor(14) === 2); +check('multiplier ladder ×3 @15', multiplierFor(15) === 3 && multiplierFor(19) === 3); +check('multiplier ladder ×5 @20', multiplierFor(20) === 5 && multiplierFor(21) === 5); +check('multiplier ladder ×10 @22', multiplierFor(22) === 10 && multiplierFor(25) === 10); + +{ + // Peg values scale with the multiplier in effect before the hit. + const st = mkState([{ x: 600, y: 500, orangeEligible: true }], { orangeCount: 1 }, { orangeCleared: 0 }); + st.orangeTotal = 5; // avoid fever for this test + st.orangeCleared = 22; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 2); + const hit = log.find((e) => e.type === 'pegHit'); + check('orange peg at ×10 scores 1000', hit && hit.points === SCORING.PEG_BASE.orange * 10, JSON.stringify(hit)); +} + +{ + // Free-ball thresholds fire exactly once each. + const st = mkState([{ x: 600, y: 500 }], {}, { score: 24995 }); + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 2); + const fb = log.filter((e) => e.type === 'freeBall' && e.reason === 'threshold'); + check('crossing 25k grants one free ball', fb.length === 1 && fb[0].threshold === 25000, JSON.stringify(fb)); + check('threshold not re-awarded', st.freeBallsGiven === 1); + + const st2 = mkState([{ x: 600, y: 500 }], {}, { score: 130000, freeBallsGiven: 0 }); + inject(st2, { x: 600, y: 450, vx: 0, vy: 200 }); + const log2 = run(st2, 2); + const fb2 = log2.filter((e) => e.type === 'freeBall' && e.reason === 'threshold'); + check('multiple owed thresholds granted together', fb2.length === 3 && st2.freeBallsGiven === 3); +} + +{ + // Long Shot: orange hit ≥ LONG_SHOT_DIST from previous peg contact with a + // wall bounce in between. + const st = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 }); + st.orangeTotal = 5; + inject(st, { x: 900, y: 450, vx: 0, vy: 200 }); + st.lastPegHit = { x: 200, y: 300 }; + st.wallBouncesSincePeg = 1; + const log = run(st, 2); + check('long shot bonus awarded', log.some((e) => e.type === 'longShot')); + + const st2 = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 }); + st2.orangeTotal = 5; + inject(st2, { x: 900, y: 450, vx: 0, vy: 200 }); + st2.lastPegHit = { x: 200, y: 300 }; + st2.wallBouncesSincePeg = 0; + const log2 = run(st2, 2); + check('no long shot without a wall bounce', !log2.some((e) => e.type === 'longShot')); +} + +{ + // Fever: lighting the last orange starts fever; bottom exit resolves it into + // a bonus bucket; every leftover peg is swept. + const st = mkState([ + { x: 600, y: 500, orangeEligible: true }, + { x: 200, y: 700 }, { x: 1000, y: 700 }, + ], { orangeCount: 1 }); + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 10); + check('fever starts on last orange', log.some((e) => e.type === 'feverStart')); + const res = log.find((e) => e.type === 'feverResolve'); + check('fever resolves with a bucket', !!res && SCORING.FEVER_BUCKETS.includes(res.bucketPoints)); + check('fever sweeps leftover pegs', !!res && res.pegIds.length === 2, res && JSON.stringify(res.pegIds)); + check('fever ends in a win', st.phase === 'won' && log.some((e) => e.type === 'win')); +} + +{ + // Fever bucket mapping across the bottom fifths. + const n = SCORING.FEVER_BUCKETS.length; + let ok = true; + for (let i = 0; i < n; i++) { + const st = mkState([]); + st.phase = 'fever'; + const x = ((i + 0.5) / n) * st.board.width; + const evs = resolveFever(st, x); + const r = evs.find((e) => e.type === 'feverResolve'); + if (!r || r.bucketIndex !== i || r.bucketPoints !== SCORING.FEVER_BUCKETS[i]) ok = false; + } + check('fever bucket x → index mapping', ok); +} + +{ + // Lose: last ball drained with orange remaining. + const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 }); + st.ballsLeft = 1; + const evs = launchBall(st, 0); // straight down the middle, missing the peg + const log = run(st, 10); + check('losing shot ends the round', st.phase === 'lost' && log.some((e) => e.type === 'lose'), `phase ${st.phase}`); +} + +{ + // Purple re-rolls between shots; lit pegs removed at shot end. An orange peg + // far off the drop path keeps the round alive (orangeTotal 0 means instant win). + const st = mkState( + [{ x: 600, y: 500 }, { x: 300, y: 400 }, { x: 900, y: 400 }, { x: 450, y: 640 }, + { x: 100, y: 780, orangeEligible: true }], + { orangeCount: 1 }, { seed: 5 }); + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 10); + const clearedEv = log.find((e) => e.type === 'pegsCleared'); + check('lit pegs removed at shot end', !!clearedEv && clearedEv.pegIds.includes(0) && st.pegs[0].removed); + check('back to aim with a purple somewhere', + st.phase === 'aim' && st.purpleId != null && st.pegs[st.purpleId].color === 'purple'); +} + +// ── 3. Powers ──────────────────────────────────────────────────────────────── + +console.log('― powers'); + +{ + const st = mkState([]); + applyPower(st, 'superguide'); + check('superguide charges 3 shots', st.superGuideShots === 3); + launchBall(st, 0); + check('superguide decrements per launch', st.superGuideShots === 2); +} + +{ + const st = mkState([]); + inject(st, { x: 500, y: 400, vx: 320, vy: -100 }); + const evs = applyPower(st, 'multiball', { ball: st.balls[0] }); + check('multiball spawns a second ball', st.balls.length === 2 && evs.some((e) => e.type === 'multiball')); + check('multiball mirrors vx', st.balls[1].vx === -320 && st.balls[1].vy === -100); +} + +{ + const pegs = [ + { x: 600, y: 500 }, // green (via forced color below) + { x: 600, y: 560 }, // within 150 + { x: 700, y: 450 }, // within 150 + { x: 1100, y: 200 }, // far away + ]; + const st = mkState(pegs, { powerId: 'spaceblast' }); + st.pegs[0].color = 'green'; + inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); + const log = run(st, 2); + const blast = log.find((e) => e.type === 'spaceBlast'); + check('green peg triggers space blast', !!blast); + check('space blast lights only nearby pegs', + !!blast && blast.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, + blast && JSON.stringify(blast.pegIds)); + check('blast pegs are scored', log.filter((e) => e.type === 'pegHit' && e.fromBlast).length === 2); +} + +{ + // Fireball burns through a column; a normal ball reflects off the top peg. + const pegs = [{ x: 600, y: 400 }, { x: 600, y: 480 }, { x: 600, y: 560 }]; + const stNorm = mkState(pegs); + launchBall(stNorm, 0); + run(stNorm, 12); + const normHits = stNorm.pegs.filter((p) => p.removed).length; + + const stFire = mkState(pegs); + applyPower(stFire, 'fireball'); + check('fireball charges next ball', stFire.fireballNext === 1); + launchBall(stFire, 0); + check('fireball flag applied on launch', stFire.balls[0].fireball === true && stFire.fireballNext === 0); + run(stFire, 12); + check('fireball lights the whole column', stFire.pegs.every((p) => p.removed), + `${stFire.pegs.filter((p) => p.removed).length}/3 vs normal ${normHits}`); +} + +{ + // Zen ball: substituted angle never scores worse than the raw aim. + const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8')); + const st = createRound(lvl, { seed: 99 }); + const rawAngle = 0.35; + const rawScore = scoreShot(st, rawAngle); + const best = zenBallOptimize(st, rawAngle, { samples: 9 }); + check('zen ball never scores worse', best.score >= rawScore, `${best.score} < ${rawScore}`); + + applyPower(st, 'zenball'); + const evs = launchBall(st, rawAngle); + check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); +} + +// ── 4. Determinism ─────────────────────────────────────────────────────────── + +console.log('― determinism'); + +{ + const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-002.json'), 'utf8')); + const logOf = (dt) => { + const st = createRound(lvl, { seed: 4242 }); + const log = [...launchBall(st, 0.42)]; + for (let t = 0; t < 30 && st.phase === 'flight'; t += dt) log.push(...stepSim(st, dt)); + return { log: log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|'), score: st.score }; + }; + const a = logOf(1 / 60); + const b = logOf(1 / 60); + check('seeded replay identical', a.log === b.log && a.score === b.score); + const c = logOf(1 / 120); + check('frame-rate independent (1/60 vs 1/120)', a.log === c.log && a.score === c.score, + `\n a: ${a.log}\n c: ${c.log}`); +} + +{ + // Preview is a dry run: it must not disturb the live state. + const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-003.json'), 'utf8')); + const st = createRound(lvl, { seed: 31337 }); + const snap = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase }); + const prev = simulatePreview(st, 0.2, { maxPegHits: 3 }); + const snap2 = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase }); + check('preview leaves live state untouched', snap === snap2); + check('preview produces a path', prev.points.length > 5); + + // Preview trajectory agrees with the real shot up to the first peg contact. + const st2 = cloneState(st); + const log = [...launchBall(st2, 0.2)]; + for (let t = 0; t < 20 && st2.phase === 'flight' && !log.some((e) => e.type === 'pegHit'); t += 1 / 120) { + log.push(...stepSim(st2, 1 / 120)); + } + const firstReal = log.find((e) => e.type === 'pegHit'); + const stP = cloneState(st); + const logP = [...launchBall(stP, 0.2)]; + for (let t = 0; t < 20 && stP.phase === 'flight' && !logP.some((e) => e.type === 'pegHit'); t += 1 / 120) { + logP.push(...stepSim(stP, 1 / 120)); + } + const firstPrev = logP.find((e) => e.type === 'pegHit'); + check('preview and live shot hit the same first peg', + firstReal && firstPrev && firstReal.pegId === firstPrev.pegId); +} + +{ + const r1 = mulberry32(555); + const r2 = mulberry32(555); + const seq1 = [r1.next(), r1.next(), r1.next()]; + const seq2 = [r2.next(), r2.next(), r2.next()]; + check('mulberry32 deterministic', seq1.join() === seq2.join()); + const r3 = mulberry32(0); + r3.state = r1.state; + check('rng state clone resumes identically', r3.next() === r2.next()); +} + +check('aim clamps to ±MAX_AIM_DEG', clampAim(3) === (T.MAX_AIM_DEG * Math.PI) / 180 && clampAim(-3) === -(T.MAX_AIM_DEG * Math.PI) / 180); +{ + const v = aimToVelocity(0); + check('straight-down launch', near(v.vx, 0, 0.001) && near(v.vy, T.LAUNCH_SPEED, 0.001)); +} + +// ── 5. Data lint ───────────────────────────────────────────────────────────── + +console.log('― data lint'); + +{ + const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8')); + const opponents = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'opponents.json'), 'utf8')); + const roster = new Set((opponents.opponents ?? opponents).map((o) => o.id)); + + check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length >= 5); + check('manifest levels sequential from 1', + manifest.levels.every((l, i) => l.level === i + 1)); + check('manifest masters exist in opponents.json', + manifest.levels.every((l) => roster.has(l.masterId)), + manifest.levels.filter((l) => !roster.has(l.masterId)).map((l) => l.masterId).join(',')); + check('manifest powers exist in POWERS', + manifest.levels.every((l) => POWERS[l.powerId])); + check('manifest names/files present', + manifest.levels.every((l) => l.name && /^level-\d{3}\.json$/.test(l.file))); + + for (const entry of manifest.levels) { + const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8')); + const tag = entry.file; + check(`${tag}: board is ${T.BOARD_W}×${T.BOARD_H}`, + lvl.board.width === T.BOARD_W && lvl.board.height === T.BOARD_H); + check(`${tag}: enough orange-eligible pegs`, + lvl.pegs.filter((p) => p.orangeEligible).length >= lvl.orangeCount); + check(`${tag}: enough pegs for orange+green+purple`, + lvl.pegs.length >= lvl.orangeCount + lvl.greenCount + 1); + check(`${tag}: pegs in bounds and clear of launcher/bucket lanes`, + lvl.pegs.every((p) => p.x >= p.r && p.x <= T.BOARD_W - p.r && p.y >= 140 && p.y <= 820)); + let overlap = false; + for (let i = 0; i < lvl.pegs.length && !overlap; i++) { + for (let j = i + 1; j < lvl.pegs.length; j++) { + const a = lvl.pegs[i]; + const b = lvl.pegs[j]; + const min = a.r + b.r + 6; + if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < min * min) { overlap = true; break; } + } + } + check(`${tag}: no overlapping pegs`, !overlap); + + // Every level must be playable: a straight-ish shot hits at least one peg. + const st = createRound(lvl, { seed: 1 }); + let anyHit = false; + for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) { + if (scoreShot(st, ang) > 0) { anyHit = true; break; } + } + check(`${tag}: pegs are reachable`, anyHit); + } +} + +// ── Soak: full rounds at varied aims stay well-formed ──────────────────────── + +console.log('― soak'); + +{ + const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8')); + let wins = 0; + let games = 0; + let invariantOk = true; + for (const entry of manifest.levels) { + const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8')); + for (let g = 0; g < 6; g++) { + games++; + const st = createRound({ ...lvl, powerId: entry.powerId }, { seed: g * 977 + entry.level }); + const rng = mulberry32(g * 31 + 7); + let guard = 0; + // Generous guard: 10+ balls × up to MAX_FLIGHT_S each at 60 steps/s. + while (st.phase !== 'won' && st.phase !== 'lost' && guard++ < 60 * 60 * 15) { + if (st.phase === 'aim') { + // Aim at a random remaining unlit peg, then (like a player reading + // the aim guide) try a few nearby angles and take the best-scoring. + const targets = st.pegs.filter((p) => !p.removed && (p.color === 'orange' || rng.next() < 0.3)); + const tgt = targets[Math.floor(rng.next() * targets.length)] ?? { x: 600, y: 600 }; + const base = Math.atan2(tgt.x - T.LAUNCH_X, tgt.y - T.LAUNCH_Y); + let angle = base; + let bestScore = -1; + for (const off of [-0.22, -0.1, 0, 0.1, 0.22]) { + const s = scoreShot(st, base + off, { maxTime: 25 }); + if (s > bestScore) { bestScore = s; angle = base + off; } + } + launchBall(st, angle); + } + stepSim(st, 1 / 60); + const cleared = st.pegs.filter((p) => p.color === 'orange' && (p.removed || p.lit)).length; + if (cleared !== st.orangeCleared) invariantOk = false; + if (st.score < 0 || st.ballsLeft < 0) invariantOk = false; + } + if (st.phase === 'won') wins++; + if (st.phase !== 'won' && st.phase !== 'lost') invariantOk = false; + } + } + check('soak: every game terminates cleanly', invariantOk); + check('soak: naive aim-at-orange wins sometimes', wins > 0, `${wins}/${games}`); + console.log(` soak result: ${wins}/${games} naive-aim wins`); +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +console.log(`\n${passes} passed, ${failures} failed`); +process.exit(failures ? 1 : 0);