Peggle Modifications
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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/<masterId>.png`, where `<masterId>`
|
||||||
|
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.
|
||||||
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -127,6 +127,19 @@ export const MANIFEST = {
|
||||||
swdbg: (scene) => sheetsFrom(scene, 'swdbg-artwork', ['cardSheet', 'baseSheet']),
|
swdbg: (scene) => sheetsFrom(scene, 'swdbg-artwork', ['cardSheet', 'baseSheet']),
|
||||||
balatro: (scene) => sheetsFrom(scene, 'balatro-artwork',
|
balatro: (scene) => sheetsFrom(scene, 'balatro-artwork',
|
||||||
['jokerSheet', 'tarotSheet', 'planetSheet', 'spectralSheet', 'voucherSheet', 'packSheet', 'deckBackSheet', 'bossIconSheet']),
|
['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/<masterId>.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
|
// Returns the full (normalized) descriptor list for a slug — including assets
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
|
|
@ -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) => `<option value="${o.id}" ${o.id === this.meta.masterId ? 'selected' : ''}>${o.name}</option>`).join('');
|
||||||
|
const powerOpts = Object.values(POWERS)
|
||||||
|
.map((p) => `<option value="${p.id}" ${p.id === this.meta.powerId ? 'selected' : ''}>${p.name}</option>`).join('');
|
||||||
|
const loadOpts = ['<option value="">— load from manifest —</option>',
|
||||||
|
...this.manifest.map((m) => `<option value="${m.file}">L${m.level} · ${m.name}</option>`)].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 = `
|
||||||
|
<div style="background:#0a1522ee; border:2px solid #2f5b8f; border-radius:14px; padding:18px 20px;">
|
||||||
|
<div style="font-size:22px; color:#f0c75e; text-align:center; margin-bottom:10px; font-family:Righteous;">LEVEL SETTINGS</div>
|
||||||
|
<div style="${row}"><label>Level #</label><input id="pe-level" type="number" min="1" value="${this.meta.level}" style="${input} width:80px;"></div>
|
||||||
|
<div style="${row}"><label>Name</label><input id="pe-name" type="text" value="${this.meta.name}" style="${input} width:200px;"></div>
|
||||||
|
<div style="${row}"><label>Master</label><select id="pe-master" style="${input} width:200px;">${masterOpts}</select></div>
|
||||||
|
<div style="${row}"><label>Power</label><select id="pe-power" style="${input} width:200px;">${powerOpts}</select></div>
|
||||||
|
<div style="${row}"><label>Orange pegs</label><input id="pe-orange" type="number" min="1" value="${this.meta.orangeCount}" style="${input} width:80px;"></div>
|
||||||
|
<div style="${row}"><label>Green pegs</label><input id="pe-green" type="number" min="0" value="${this.meta.greenCount}" style="${input} width:80px;"></div>
|
||||||
|
<div style="${row}"><label>File name</label><input id="pe-file" type="text" value="${this.meta.file}" style="${input} width:200px;"></div>
|
||||||
|
<hr style="border-color:#2f5b8f44; margin:12px 0;">
|
||||||
|
<div style="font-size:18px; color:#f0c75e; margin-bottom:6px;">Stamp tools</div>
|
||||||
|
<div style="${row}"><label>Peg count</label><input id="pe-count" type="number" min="2" max="40" value="${this.stampCount}" style="${input} width:80px;"></div>
|
||||||
|
<div style="${row}"><label>Arc span (°)</label><input id="pe-span" type="number" min="10" max="360" value="${this.stampSpan}" style="${input} width:80px;"></div>
|
||||||
|
<div style="${row}"><label><input id="pe-eligible" type="checkbox" checked> New pegs orange-eligible</label></div>
|
||||||
|
<hr style="border-color:#2f5b8f44; margin:12px 0;">
|
||||||
|
<div style="font-size:18px; color:#f0c75e; margin-bottom:6px;">Load</div>
|
||||||
|
<div style="${row}"><select id="pe-load" style="${input} width:100%;">${loadOpts}</select></div>
|
||||||
|
<div style="${row}"><input id="pe-fileinput" type="file" accept=".json" style="font-size:14px; width:100%;"></div>
|
||||||
|
<div id="pe-warn" style="color:#ff8f8f; font-size:15px; min-height:40px; margin-top:8px;"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,8 @@ import JumbleGame from './games/jumble/JumbleGame.js';
|
||||||
import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js';
|
import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js';
|
||||||
import SWDBGGame from './games/swdbg/SWDBGGame.js';
|
import SWDBGGame from './games/swdbg/SWDBGGame.js';
|
||||||
import BalatroGame from './games/balatro/BalatroGame.js';
|
import BalatroGame from './games/balatro/BalatroGame.js';
|
||||||
|
import PeggleGame from './games/peggle/PeggleGame.js';
|
||||||
|
import PeggleEditor from './games/peggle/PeggleEditor.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -187,6 +189,8 @@ const config = {
|
||||||
DungeonBossGame,
|
DungeonBossGame,
|
||||||
SWDBGGame,
|
SWDBGGame,
|
||||||
BalatroGame,
|
BalatroGame,
|
||||||
|
PeggleGame,
|
||||||
|
PeggleEditor,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
||||||
this.load.json('jumble', 'data/jumble.json');
|
this.load.json('jumble', 'data/jumble.json');
|
||||||
this.load.json('balatro-artwork', 'data/balatro-artwork.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-splash', 'assets/fx/water-splash.mp3');
|
||||||
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.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 */ }
|
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();
|
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');
|
this.scene.start('Landing');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
@ -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);
|
||||||