Level Adjustments

This commit is contained in:
Brian Fertig 2026-07-11 18:39:42 -06:00
parent afd3dc9466
commit 58d51503eb
31 changed files with 1424 additions and 1624 deletions

View File

@ -4,7 +4,7 @@
"width": 1200,
"height": 900
},
"orangeCount": 25,
"orangeCount": 15,
"greenCount": 2,
"pegs": [
{
@ -349,41 +349,40 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"curves": [
{
"anchors": [
{
"x": 20,
"y": 530
},
{
"x": 350,
"y": 720
}
],
"bends": [
160
],
"orangeEligible": true
},
{
"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
"anchors": [
{
"x": 1180,
"y": 510
},
{
"x": 890,
"y": 710
}
],
"bends": [
-161
],
"orangeEligible": true
}
],
"walls": [],

View File

@ -168,13 +168,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 705,
"y": 652,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 600,
"y": 680,
@ -182,13 +175,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 495,
"y": 652,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 418,
"y": 575,
@ -258,43 +244,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 380,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 490,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 600,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 710,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 820,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 380,
"y": 600
},
{
"x": 600,
"y": 600
},
{
"x": 820,
"y": 600
}
],
"bends": [
55,
55
],
"orangeEligible": true
}
]
}

View File

@ -210,13 +210,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 822,
"y": 676,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 736,
"y": 704,
@ -224,20 +217,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 646,
"y": 718,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 554,
"y": 718,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 464,
"y": 704,
@ -245,13 +224,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 378,
"y": 676,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 296,
"y": 636,
@ -321,36 +293,36 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 420,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 540,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 660,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 780,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 180,
"y": 640
},
{
"x": 460,
"y": 660
},
{
"x": 740,
"y": 660
},
{
"x": 1020,
"y": 640
}
],
"bends": [
70,
70,
70
],
"orangeEligible": true
}
]
}

View File

@ -77,13 +77,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 699,
"y": 717,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 662,
"y": 660,
@ -140,13 +133,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 300,
"y": 640,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 300,
"y": 710,
@ -182,62 +168,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 160,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 247,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 333,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 420,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 780,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 867,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 953,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1040,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 480,
"y": 340,
@ -268,5 +198,33 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 200,
"y": 690
},
{
"x": 490,
"y": 710
},
{
"x": 780,
"y": 710
},
{
"x": 1050,
"y": 690
}
],
"bends": [
-55,
55,
-55
],
"orangeEligible": true
}
]
}

View File

@ -14,20 +14,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 898,
"y": 326,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 845,
"y": 381,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 782,
"y": 424,
@ -70,20 +56,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 355,
"y": 381,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 302,
"y": 326,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 261,
"y": 263,
@ -321,50 +293,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 200,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 360,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 520,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 680,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 840,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1000,
"y": 250,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 280,
"y": 330
},
{
"x": 600,
"y": 330
},
{
"x": 920,
"y": 330
}
],
"bends": [
50,
50
],
"orangeEligible": true
}
]
}

View File

@ -217,48 +217,6 @@
"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,
@ -366,5 +324,28 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 180,
"y": 300
},
{
"x": 490,
"y": 260
},
{
"x": 800,
"y": 300
}
],
"bends": [
-55,
-45
],
"orangeEligible": true
}
]
}

View File

@ -203,20 +203,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 907,
"y": 704,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 853,
"y": 704,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 809,
"y": 672,
@ -342,29 +328,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 150,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 250,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 350,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 200,
"y": 720
},
{
"x": 620,
"y": 730
},
{
"x": 1040,
"y": 720
}
],
"bends": [
35,
-35
],
"orangeEligible": true
}
]
}

View File

@ -56,20 +56,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 950,
"y": 692,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 890,
"y": 692,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 860,
"y": 640,
@ -196,20 +182,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 310,
"y": 692,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 250,
"y": 692,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 220,
"y": 640,
@ -287,62 +259,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 240,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 345,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 450,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 555,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 660,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 765,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 870,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 975,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 560,
"y": 210,
@ -359,5 +275,28 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 240,
"y": 700
},
{
"x": 600,
"y": 720
},
{
"x": 960,
"y": 700
}
],
"bends": [
-55,
-55
],
"orangeEligible": true
}
]
}

View File

@ -21,27 +21,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 685,
"y": 553,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 618,
"y": 579,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 547,
"y": 570,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 488,
"y": 529,
@ -154,13 +133,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 285,
"y": 640,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 261,
"y": 691,
@ -182,27 +154,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 161,
"y": 612,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 206,
"y": 577,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 261,
"y": 589,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1045,
"y": 640,
@ -231,27 +182,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 921,
"y": 612,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 966,
"y": 577,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1021,
"y": 589,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 160,
"y": 300,
@ -293,36 +223,36 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 430,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 543,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 657,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 770,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 150,
"y": 580
},
{
"x": 450,
"y": 600
},
{
"x": 750,
"y": 600
},
{
"x": 1020,
"y": 580
}
],
"bends": [
55,
-55,
55
],
"orangeEligible": true
}
]
}

View File

@ -217,34 +217,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 140,
"y": 500,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 240,
"y": 500,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 960,
"y": 500,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1060,
"y": 500,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 430,
"y": 780,
@ -275,5 +247,39 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 120,
"y": 330
},
{
"x": 300,
"y": 330
}
],
"bends": [
45
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 900,
"y": 330
},
{
"x": 1080,
"y": 330
}
],
"bends": [
45
],
"orangeEligible": true
}
]
}

View File

@ -238,34 +238,6 @@
"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,
@ -286,50 +258,52 @@
"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": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 240,
"y": 240
},
{
"x": 600,
"y": 205
},
{
"x": 960,
"y": 240
}
],
"bends": [
-30,
-30
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 250,
"y": 720
},
{
"x": 600,
"y": 740
},
{
"x": 950,
"y": 720
}
],
"bends": [
45,
45
],
"orangeEligible": true
}
]
}

View File

@ -77,20 +77,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 726,
"y": 660,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 678,
"y": 692,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 625,
"y": 712,
@ -210,34 +196,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 180,
"y": 660,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 260,
"y": 693,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 340,
"y": 727,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 420,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 760,
"y": 220,
@ -268,5 +226,28 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 150,
"y": 640
},
{
"x": 450,
"y": 690
},
{
"x": 750,
"y": 670
}
],
"bends": [
45,
-40
],
"orangeEligible": true
}
]
}

View File

@ -77,27 +77,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 500,
"y": 247,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 579,
"y": 221,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 662,
"y": 230,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 734,
"y": 271,
@ -237,50 +216,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 160,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 240,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 320,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 880,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 960,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1040,
"y": 300,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 200,
"y": 250
},
{
"x": 500,
"y": 230
},
{
"x": 800,
"y": 250
}
],
"bends": [
-40,
-40
],
"orangeEligible": true
}
]
}

View File

@ -182,13 +182,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 970,
"y": 698,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 918,
"y": 660,
@ -301,34 +294,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 200,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 293,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 387,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 480,
"y": 740,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 650,
"y": 220,
@ -359,5 +324,28 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 180,
"y": 710
},
{
"x": 600,
"y": 740
},
{
"x": 1020,
"y": 710
}
],
"bends": [
45,
45
],
"orangeEligible": true
}
]
}

View File

@ -245,20 +245,6 @@
"r": 16,
"orangeEligible": false
},
{
"x": 300,
"y": 720,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 900,
"y": 720,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 480,
"y": 780,
@ -282,5 +268,39 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 160,
"y": 380
},
{
"x": 160,
"y": 700
}
],
"bends": [
-65
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 1040,
"y": 380
},
{
"x": 1040,
"y": 700
}
],
"bends": [
65
],
"orangeEligible": true
}
]
}

View File

@ -196,13 +196,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 600,
"y": 730,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 520,
"y": 717,
@ -350,34 +343,6 @@
"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,
@ -433,36 +398,31 @@
"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": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 160,
"y": 720
},
{
"x": 600,
"y": 750
},
{
"x": 1040,
"y": 720
}
],
"bends": [
45,
45
],
"orangeEligible": true
}
]
}

View File

@ -210,13 +210,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 503,
"y": 236,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 669,
"y": 304,
@ -224,13 +217,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 697,
"y": 236,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 766,
"y": 401,
@ -261,5 +247,28 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 350,
"y": 250
},
{
"x": 600,
"y": 215
},
{
"x": 850,
"y": 250
}
],
"bends": [
-35,
-35
],
"orangeEligible": true
}
]
}

View File

@ -265,50 +265,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 200,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 320,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 440,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 760,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 880,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1000,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 200,
"y": 680
},
{
"x": 600,
"y": 720
},
{
"x": 1000,
"y": 680
}
],
"bends": [
50,
50
],
"orangeEligible": true
}
]
}

View File

@ -63,13 +63,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 350,
"y": 230,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 375,
"y": 312,
@ -119,13 +112,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 560,
"y": 230,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 585,
"y": 312,
@ -175,13 +161,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 770,
"y": 230,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 795,
"y": 312,
@ -231,13 +210,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 980,
"y": 230,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1005,
"y": 312,
@ -286,43 +258,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 180,
"y": 200,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 516,
"y": 200,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 684,
"y": 200,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 852,
"y": 200,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1020,
"y": 200,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 180,
"y": 230
},
{
"x": 600,
"y": 200
},
{
"x": 1020,
"y": 230
}
],
"bends": [
-25,
-25
],
"orangeEligible": true
}
]
}

View File

@ -203,13 +203,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 770,
"y": 764,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 470,
"y": 695,
@ -217,13 +210,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 430,
"y": 764,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 340,
"y": 470,
@ -314,22 +300,31 @@
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 543,
"y": 780,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 657,
"y": 780,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 300,
"y": 720
},
{
"x": 600,
"y": 750
},
{
"x": 900,
"y": 720
}
],
"bends": [
35,
35
],
"orangeEligible": true
}
]
}

View File

@ -237,71 +237,52 @@
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 745,
"y": 675,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 895,
"y": 675,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1045,
"y": 675,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 160,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 336,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 512,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 688,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 864,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1040,
"y": 210,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 160,
"y": 240
},
{
"x": 600,
"y": 205
},
{
"x": 1040,
"y": 240
}
],
"bends": [
-30,
-30
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 160,
"y": 700
},
{
"x": 600,
"y": 730
},
{
"x": 1040,
"y": 700
}
],
"bends": [
45,
-45
],
"orangeEligible": true
}
]
}

View File

@ -133,20 +133,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 475,
"y": 700,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 570,
"y": 700,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 660,
"y": 480,
@ -196,84 +182,42 @@
"r": 16,
"orangeEligible": true
},
{
"x": 940,
"y": 700,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1035,
"y": 700,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 1130,
"y": 700,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 160,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 260,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 360,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 460,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 740,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 840,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 940,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
},
{
"x": 1040,
"y": 760,
"shape": "circle",
"r": 16,
"orangeEligible": false
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 160,
"y": 710
},
{
"x": 450,
"y": 730
},
{
"x": 740,
"y": 730
},
{
"x": 1030,
"y": 710
}
],
"bends": [
40,
-40,
40
],
"orangeEligible": true
}
]
}

View File

@ -317,5 +317,39 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 170,
"y": 300
},
{
"x": 170,
"y": 620
}
],
"bends": [
-60
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 1030,
"y": 300
},
{
"x": 1030,
"y": 620
}
],
"bends": [
60
],
"orangeEligible": true
}
]
}

View File

@ -273,34 +273,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 580,
"y": 400,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 680,
"y": 400,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 580,
"y": 530,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 680,
"y": 530,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 830,
"y": 400,
@ -331,5 +303,23 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 220,
"y": 760
},
{
"x": 980,
"y": 760
}
],
"bends": [
25
],
"orangeEligible": true
}
]
}

View File

@ -63,20 +63,6 @@
"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,
@ -105,20 +91,6 @@
"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,
@ -175,13 +147,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 180,
"y": 610,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 300,
"y": 610,
@ -224,13 +189,6 @@
"r": 16,
"orangeEligible": true
},
{
"x": 1020,
"y": 610,
"shape": "circle",
"r": 16,
"orangeEligible": true
},
{
"x": 180,
"y": 730,
@ -287,286 +245,6 @@
"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,
@ -625,5 +303,49 @@
}
],
"walls": [],
"moving": []
"moving": [],
"curves": [
{
"anchors": [
{
"x": 160,
"y": 350
},
{
"x": 600,
"y": 410
},
{
"x": 1040,
"y": 350
}
],
"bends": [
50,
50
],
"orangeEligible": true
},
{
"anchors": [
{
"x": 160,
"y": 610
},
{
"x": 600,
"y": 550
},
{
"x": 1040,
"y": 610
}
],
"bends": [
-50,
-50
],
"orangeEligible": true
}
]
}

View File

@ -98,6 +98,19 @@ level of each friend's block in `levels.json`).
- The intro shows on every fresh entry to that level (level select or "Next
Level") but is skipped on Replay / Try Again.
## Brick curves
Wavy lines of peg-like brick sections (the classic Peggle curved walls).
Authored in the editor with the **Brick Curve** tool; stored in the level file
as a `curves` array (`anchors` + per-segment `bends` + `orangeEligible`) and
tessellated into 36×22 brick sections at load. Each brick behaves exactly
like a peg — same color pool (blue/orange/green/purple), scoring, fade, and
fever sweep.
Bricks are **procedurally drawn** (not in the sprite sheet): color fill with a
dark outer stroke and a light inner inset stroke, using the same palette as
the pegs. The reserved sheet frames 915 could skin them later if wanted.
## Not covered by the sheet
- **Level editor** (`?peggle-editor=1`) intentionally keeps procedural circles —

View File

@ -1,7 +1,7 @@
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';
import { TUNING, POWERS, tessellateCurves } 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,
@ -27,7 +27,7 @@ export default class PeggleEditor extends Phaser.Scene {
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.mode = 'place'; // place | line | arc | curve
this.snap = true;
this.pending = null; // first click of a stamper
this.dragIdx = -1;
@ -36,6 +36,14 @@ export default class PeggleEditor extends Phaser.Scene {
this.pegImages = [];
this.stampCount = 8;
this.stampSpan = 180;
// Brick curves: { anchors:[{x,y}], bends:[px per segment], orangeEligible }
this.curves = [];
this.selCurve = -1; // selected curve index
this.drawingCurve = null; // anchors being placed by the Curve tool
this.dragAnchor = null; // { curve, idx }
this.dragBend = null; // { curve, seg }
this.lastClick = { t: -9999, x: 0, y: 0 };
}
create() {
@ -65,12 +73,17 @@ export default class PeggleEditor extends Phaser.Scene {
const saved = this.registry.get('peggle-editor-state');
if (saved) {
this.pegs = saved.pegs.map((p) => ({ ...p }));
this.curves = (saved.curves ?? []).map((c) => ({
anchors: c.anchors.map((a) => ({ ...a })), bends: [...c.bends], orangeEligible: !!c.orangeEligible,
}));
this.meta = { ...saved.meta };
}
}
this.drawChrome();
this.brickG = this.add.graphics().setDepth(D.peg - 1);
this.pegLayer = this.add.container(0, 0).setDepth(D.peg);
this.handleG = this.add.graphics().setDepth(D.marker + 1);
this.markerG = this.add.graphics().setDepth(D.marker);
this.buildToolbar();
this.bindPointer();
@ -124,7 +137,7 @@ export default class PeggleEditor extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.ui);
this.modeBtns = {};
for (const [mode, label] of [['place', 'Place / Move'], ['line', 'Line Stamp'], ['arc', 'Arc Stamp']]) {
for (const [mode, label] of [['place', 'Place / Move'], ['line', 'Line Stamp'], ['arc', 'Arc Stamp'], ['curve', 'Brick Curve']]) {
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;
@ -140,7 +153,14 @@ export default class PeggleEditor extends Phaser.Scene {
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(); },
new Button(this, cx, y, 'Clear Board', () => {
this.pushUndo();
this.pegs = [];
this.curves = [];
this.selCurve = -1;
this.drawingCurve = null;
this.renderPegs();
},
{ width: 260, height: 54, fontSize: 20, variant: 'ghost', textColor: COLORS.dangerHex });
y += step + 20;
@ -155,8 +175,8 @@ export default class PeggleEditor extends Phaser.Scene {
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,
'Left-click: place / drag · Right-click: delete\nE: toggle orange-eligible · Esc: cancel\n\nBrick Curve: click anchors, dbl-click/Enter\nto finish. Click bricks to select: drag ◆\nanchors, drag ● to bend, dbl-click ● to\nadd an anchor, Del removes the curve.', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, align: 'center', lineSpacing: 5,
}).setOrigin(0.5, 0).setDepth(D.ui);
new Button(this, cx, GAME_HEIGHT - 60, 'Exit Editor', () => { window.location.search = ''; },
@ -273,6 +293,13 @@ export default class PeggleEditor extends Phaser.Scene {
this.pegs = (layout.pegs ?? []).map((p) => ({
x: p.x, y: p.y, r: p.r ?? TUNING.PEG_R, orangeEligible: !!p.orangeEligible,
}));
this.curves = (layout.curves ?? []).map((c) => ({
anchors: (c.anchors ?? []).map((a) => ({ x: a.x, y: a.y })),
bends: [...(c.bends ?? [])],
orangeEligible: !!c.orangeEligible,
}));
this.selCurve = -1;
this.drawingCurve = null;
this.meta.orangeCount = layout.orangeCount ?? 25;
this.meta.greenCount = layout.greenCount ?? 2;
if (fileName) this.meta.file = fileName;
@ -311,9 +338,133 @@ export default class PeggleEditor extends Phaser.Scene {
return -1;
}
// ── Curve geometry helpers ──────────────────────────────────────────────────
// Chord-perpendicular unit vector and the bezier midpoint of a segment.
segmentInfo(curve, seg) {
const p0 = curve.anchors[seg];
const p1 = curve.anchors[seg + 1];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const len = Math.hypot(dx, dy) || 1;
const perp = { x: -dy / len, y: dx / len };
const bend = curve.bends[seg] ?? 0;
const cp = { x: (p0.x + p1.x) / 2 + perp.x * bend, y: (p0.y + p1.y) / 2 + perp.y * bend };
// Quadratic bezier at t=0.5:
const mid = { x: 0.25 * p0.x + 0.5 * cp.x + 0.25 * p1.x, y: 0.25 * p0.y + 0.5 * cp.y + 0.25 * p1.y };
return { p0, p1, perp, bend, mid };
}
curveBricks() {
return tessellateCurves(this.curves);
}
curveAt(pointer) {
const x = pointer.x - BOARD_X;
const y = pointer.y - BOARD_Y;
const bricks = this.curveBricksByCurve ?? [];
for (let ci = 0; ci < bricks.length; ci++) {
for (const b of bricks[ci]) {
if ((b.x - x) ** 2 + (b.y - y) ** 2 <= 22 * 22) return ci;
}
}
return -1;
}
// Hit-test the selected curve's handles. Returns {kind:'anchor'|'bend', idx}.
handleAt(pointer) {
if (this.selCurve < 0) return null;
const curve = this.curves[this.selCurve];
if (!curve) return null;
const x = pointer.x - BOARD_X;
const y = pointer.y - BOARD_Y;
for (let i = 0; i < curve.anchors.length; i++) {
const a = curve.anchors[i];
if ((a.x - x) ** 2 + (a.y - y) ** 2 <= 14 * 14) return { kind: 'anchor', idx: i };
}
for (let s = 0; s < curve.anchors.length - 1; s++) {
const { mid } = this.segmentInfo(curve, s);
if ((mid.x - x) ** 2 + (mid.y - y) ** 2 <= 12 * 12) return { kind: 'bend', idx: s };
}
return null;
}
isDoubleClick(pointer) {
const now = this.time.now;
const dbl = now - this.lastClick.t < 330
&& Math.abs(pointer.x - this.lastClick.x) < 14
&& Math.abs(pointer.y - this.lastClick.y) < 14;
this.lastClick = { t: now, x: pointer.x, y: pointer.y };
return dbl;
}
finishCurve() {
if (this.drawingCurve && this.drawingCurve.length >= 2) {
this.pushUndo();
this.curves.push({
anchors: this.drawingCurve,
bends: new Array(this.drawingCurve.length - 1).fill(0),
orangeEligible: this.eligibleCheckbox?.checked ?? true,
});
this.selCurve = this.curves.length - 1;
}
this.drawingCurve = null;
this.renderPegs();
}
deleteAnchor(curve, idx) {
this.pushUndo();
curve.anchors.splice(idx, 1);
curve.bends.splice(idx === 0 ? 0 : idx - 1, 1);
if (curve.anchors.length < 2) {
this.curves.splice(this.selCurve, 1);
this.selCurve = -1;
}
this.renderPegs();
}
insertAnchor(curve, seg) {
this.pushUndo();
const { mid, bend } = this.segmentInfo(curve, seg);
curve.anchors.splice(seg + 1, 0, { x: Math.round(mid.x), y: Math.round(mid.y) });
// Each half of a quadratic keeps ~1/4 of the original sagitta.
curve.bends.splice(seg, 1, bend / 4, bend / 4);
this.renderPegs();
}
// ── Pointer / keys ──────────────────────────────────────────────────────────
bindPointer() {
this.input.on('pointerdown', (pointer) => {
if (!this.inBoard(pointer)) return;
const dbl = this.isDoubleClick(pointer);
// Handles of the selected curve take priority over everything.
const handle = this.handleAt(pointer);
if (handle) {
const curve = this.curves[this.selCurve];
if (handle.kind === 'anchor') {
if (pointer.rightButtonDown()) { this.deleteAnchor(curve, handle.idx); return; }
this.pushUndo();
this.dragAnchor = { curve, idx: handle.idx };
} else {
if (dbl) { this.insertAnchor(curve, handle.idx); return; }
this.pushUndo();
this.dragBend = { curve, seg: handle.idx };
}
return;
}
if (this.mode === 'curve') {
if (pointer.rightButtonDown()) { this.drawingCurve = null; this.renderPegs(); return; }
if (dbl) { this.finishCurve(); return; }
const { x, y } = this.toBoard(pointer);
if (!this.drawingCurve) this.drawingCurve = [];
this.drawingCurve.push({ x, y });
this.renderPegs();
return;
}
const idx = this.pegAt(pointer);
if (pointer.rightButtonDown()) {
@ -322,11 +473,19 @@ export default class PeggleEditor extends Phaser.Scene {
}
if (this.mode === 'place') {
// A click on a curve's bricks selects that curve.
const ci = this.curveAt(pointer);
if (ci >= 0 && idx < 0) {
this.selCurve = ci;
this.renderPegs();
return;
}
if (idx >= 0) {
this.pushUndo();
this.dragIdx = idx;
this.hoverIdx = idx;
} else {
if (this.selCurve >= 0) { this.selCurve = -1; this.renderPegs(); return; }
const { x, y } = this.toBoard(pointer);
this.pushUndo();
this.pegs.push({ x, y, r: TUNING.PEG_R, orangeEligible: this.eligibleCheckbox?.checked ?? true });
@ -352,7 +511,22 @@ export default class PeggleEditor extends Phaser.Scene {
});
this.input.on('pointermove', (pointer) => {
if (this.dragIdx >= 0 && pointer.isDown) {
if (this.dragAnchor && pointer.isDown) {
const { x, y } = this.toBoard(pointer);
this.dragAnchor.curve.anchors[this.dragAnchor.idx] = { x, y };
this.renderPegs(false);
} else if (this.dragBend && pointer.isDown) {
const { curve, seg } = this.dragBend;
const { p0, p1, perp } = this.segmentInfo(curve, seg);
const bx = pointer.x - BOARD_X;
const by = pointer.y - BOARD_Y;
const midX = (p0.x + p1.x) / 2;
const midY = (p0.y + p1.y) / 2;
// Signed distance of the pointer from the chord midpoint, along perp.
// The handle sits at half the control offset, so bend = 2× that.
curve.bends[seg] = Math.round(2 * ((bx - midX) * perp.x + (by - midY) * perp.y));
this.renderPegs(false);
} else if (this.dragIdx >= 0 && pointer.isDown) {
const { x, y } = this.toBoard(pointer);
this.pegs[this.dragIdx].x = x;
this.pegs[this.dragIdx].y = y;
@ -363,12 +537,23 @@ export default class PeggleEditor extends Phaser.Scene {
});
this.input.on('pointerup', () => {
if (this.dragIdx >= 0) { this.dragIdx = -1; this.renderPegs(); }
if (this.dragIdx >= 0 || this.dragAnchor || this.dragBend) {
this.dragIdx = -1;
this.dragAnchor = null;
this.dragBend = null;
this.renderPegs();
}
});
}
bindKeys() {
this.input.keyboard?.on('keydown-E', () => {
if (this.selCurve >= 0 && this.curves[this.selCurve]) {
this.pushUndo();
this.curves[this.selCurve].orangeEligible = !this.curves[this.selCurve].orangeEligible;
this.renderPegs();
return;
}
if (this.hoverIdx >= 0 && this.pegs[this.hoverIdx]) {
this.pushUndo();
this.pegs[this.hoverIdx].orangeEligible = !this.pegs[this.hoverIdx].orangeEligible;
@ -376,6 +561,13 @@ export default class PeggleEditor extends Phaser.Scene {
}
});
this.input.keyboard?.on('keydown-DELETE', () => {
if (this.selCurve >= 0 && this.curves[this.selCurve]) {
this.pushUndo();
this.curves.splice(this.selCurve, 1);
this.selCurve = -1;
this.renderPegs();
return;
}
if (this.hoverIdx >= 0 && this.pegs[this.hoverIdx]) {
this.pushUndo();
this.pegs.splice(this.hoverIdx, 1);
@ -383,7 +575,13 @@ export default class PeggleEditor extends Phaser.Scene {
this.renderPegs();
}
});
this.input.keyboard?.on('keydown-ESC', () => { this.pending = null; this.markerG.clear(); });
this.input.keyboard?.on('keydown-ENTER', () => this.finishCurve());
this.input.keyboard?.on('keydown-ESC', () => {
this.pending = null;
this.markerG.clear();
if (this.drawingCurve) { this.drawingCurve = null; this.renderPegs(); }
else if (this.selCurve >= 0) { this.selCurve = -1; this.renderPegs(); }
});
this.input.keyboard?.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); });
}
@ -428,16 +626,21 @@ export default class PeggleEditor extends Phaser.Scene {
// ── Undo ────────────────────────────────────────────────────────────────────
pushUndo() {
this.undoStack.push(JSON.stringify(this.pegs));
this.undoStack.push(JSON.stringify({ pegs: this.pegs, curves: this.curves }));
if (this.undoStack.length > 60) this.undoStack.shift();
}
undo() {
const prev = this.undoStack.pop();
if (prev == null) return;
this.pegs = JSON.parse(prev);
const snap = JSON.parse(prev);
this.pegs = snap.pegs ?? [];
this.curves = snap.curves ?? [];
this.dragIdx = -1;
this.hoverIdx = -1;
this.selCurve = -1;
this.dragAnchor = null;
this.dragBend = null;
this.renderPegs();
}
@ -454,14 +657,36 @@ export default class PeggleEditor extends Phaser.Scene {
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;
// Bricks: tessellate per curve so adjacency within a curve is exempt.
this.curveBricksByCurve = this.curves.map((c) => tessellateCurves([c]));
const allBricks = this.curveBricksByCurve.flatMap((list, ci) => list.map((b, bi) => ({ ...b, ci, bi })));
const badBricks = new Set();
const boundR = Math.hypot(TUNING.BRICK_LEN / 2, TUNING.BRICK_WID / 2);
for (const b of allBricks) {
if (b.x < boundR || b.x > BOARD_W - boundR || b.y < MIN_Y || b.y > MAX_Y) badBricks.add(`${b.ci}:${b.bi}`);
for (const p of this.pegs) {
const min = p.r + TUNING.BRICK_WID / 2 + GAP;
if ((p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min) badBricks.add(`${b.ci}:${b.bi}`);
}
for (const o of allBricks) {
if (o.ci === b.ci && Math.abs(o.bi - b.bi) <= 1) continue; // self/adjacent
const min = TUNING.BRICK_LEN * 0.8;
if ((o.x - b.x) ** 2 + (o.y - b.y) ** 2 < min * min) badBricks.add(`${b.ci}:${b.bi}`);
}
}
const eligible = this.pegs.filter((p) => p.orangeEligible).length
+ allBricks.filter((b) => b.orangeEligible).length;
const totalPegs = this.pegs.length + allBricks.length;
const problems = [];
if (bad.size) problems.push(`${bad.size} peg(s) overlap or sit out of bounds (shown red).`);
if (badBricks.size) problems.push(`${badBricks.size} brick(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) {
if (totalPegs < 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 };
return { bad, badBricks, eligible, bricks: allBricks.length, problems, valid: problems.length === 0 };
}
renderPegs(fullValidate = true) {
@ -472,26 +697,88 @@ export default class PeggleEditor extends Phaser.Scene {
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;
const v = fullValidate ? this.validate() : (this.lastValidation ?? this.validate());
this.lastValidation = v;
if (!fullValidate) this.curveBricksByCurve = this.curves.map((c) => tessellateCurves([c]));
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);
if (fullValidate && v.bad.has(i)) img.setTint(0xff4d4d);
else img.setTint(p.orangeEligible ? 0xffb347 : 0x4d8dee);
}
this.renderCurves(fullValidate ? v : null);
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.pegs.length} pegs + ${v.bricks ?? 0} bricks · ${v.eligible} orange-eligible (need ${this.meta.orangeCount}) · ` +
`${v.valid ? '✓ valid' : '✗ fix issues to export'}`,
);
this.statusText.setColor(val.valid ? '#9be7b4' : '#ff8f8f');
this.statusText.setColor(v.valid ? '#9be7b4' : '#ff8f8f');
}
this.flashWarn(v.problems.join('\n'));
}
// Bricks + (for the selected curve) anchor and bend handles.
renderCurves(validation) {
const g = this.brickG;
const h = this.handleG;
if (!g || !h) return;
g.clear();
h.clear();
const L = TUNING.BRICK_LEN;
const W = TUNING.BRICK_WID;
(this.curveBricksByCurve ?? []).forEach((bricks, ci) => {
const curve = this.curves[ci];
const selected = ci === this.selCurve;
bricks.forEach((b, bi) => {
const invalid = validation?.badBricks?.has(`${ci}:${bi}`);
const fill = invalid ? 0xff4d4d : curve.orangeEligible ? 0xffb347 : 0x4d8dee;
g.save();
g.translateCanvas(BOARD_X + b.x, BOARD_Y + b.y);
g.rotateCanvas(b.angle);
g.fillStyle(fill, 1);
g.fillRoundedRect(-L / 2, -W / 2, L, W, 7);
g.lineStyle(selected ? 3 : 2, selected ? 0xffffff : 0x0a1522, selected ? 1 : 0.8);
g.strokeRoundedRect(-L / 2, -W / 2, L, W, 7);
g.restore();
});
});
// Drawing-in-progress preview polyline.
if (this.drawingCurve?.length) {
h.lineStyle(2, 0xf0c75e, 0.9);
for (let i = 0; i < this.drawingCurve.length - 1; i++) {
const a = this.drawingCurve[i];
const b = this.drawingCurve[i + 1];
h.lineBetween(BOARD_X + a.x, BOARD_Y + a.y, BOARD_X + b.x, BOARD_Y + b.y);
}
for (const a of this.drawingCurve) {
h.fillStyle(0xf0c75e, 1);
h.fillRect(BOARD_X + a.x - 6, BOARD_Y + a.y - 6, 12, 12);
}
}
// Handles for the selected curve.
const sel = this.curves[this.selCurve];
if (sel) {
for (let s = 0; s < sel.anchors.length - 1; s++) {
const { mid } = this.segmentInfo(sel, s);
h.fillStyle(0xffffff, 1);
h.fillCircle(BOARD_X + mid.x, BOARD_Y + mid.y, 8);
h.lineStyle(2, 0x0a1522, 1);
h.strokeCircle(BOARD_X + mid.x, BOARD_Y + mid.y, 8);
}
for (const a of sel.anchors) {
h.fillStyle(0xf0c75e, 1);
h.fillRect(BOARD_X + a.x - 7, BOARD_Y + a.y - 7, 14, 14);
h.lineStyle(2, 0x0a1522, 1);
h.strokeRect(BOARD_X + a.x - 7, BOARD_Y + a.y - 7, 14, 14);
}
}
this.flashWarn((this.lastValidation ?? v).problems.join('\n'));
}
// ── Export / test ───────────────────────────────────────────────────────────
@ -505,6 +792,11 @@ export default class PeggleEditor extends Phaser.Scene {
pegs: this.pegs.map((p) => ({
x: p.x, y: p.y, shape: 'circle', r: p.r, orangeEligible: p.orangeEligible,
})),
curves: this.curves.map((c) => ({
anchors: c.anchors.map((a) => ({ x: a.x, y: a.y })),
bends: [...c.bends],
orangeEligible: !!c.orangeEligible,
})),
walls: [],
moving: [],
};
@ -549,6 +841,9 @@ export default class PeggleEditor extends Phaser.Scene {
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 })),
curves: this.curves.map((c) => ({
anchors: c.anchors.map((a) => ({ ...a })), bends: [...c.bends], orangeEligible: !!c.orangeEligible,
})),
meta: { ...this.meta },
});
this.scene.start('PeggleGame', {

View File

@ -32,6 +32,13 @@ const SPRITE_FRAME = { blue: 0, orange: 2, green: 4, purple: 6, ball: 8 }; // co
const FELT = 0x0d1b2e;
const BOARD_BG = 0x122a45;
function shade(color, f) {
const r = Math.min(255, Math.round(((color >> 16) & 0xff) * f));
const g = Math.min(255, Math.round(((color >> 8) & 0xff) * f));
const b = Math.min(255, Math.round((color & 0xff) * f));
return (r << 16) | (g << 8) | b;
}
const D = { felt: -2, board: -1, peg: 5, bucket: 8, ball: 10, fx: 12, preview: 14, launcher: 16, ui: 30, banner: 40, overlay: 60, overlayUI: 62 };
export default class PeggleGame extends Phaser.Scene {
@ -142,6 +149,26 @@ export default class PeggleGame extends Phaser.Scene {
g.generateTexture('peggle-spark', 8, 8);
g.destroy();
}
// Brick-curve sections: pre-styled per color (dark outer stroke, color
// fill, light inner stroke — the classic Peggle brick look). Pre-tinting
// keeps the stroke contrast that a runtime tint would flatten.
const L = TUNING.BRICK_LEN;
const W = TUNING.BRICK_WID;
for (const [color, base] of Object.entries(PEG_TINT)) {
for (const [suffix, tone] of [['', base], ['-lit', PEG_LIT[color]]]) {
const key = `peggle-brick-${color}${suffix}`;
if (this.textures.exists(key)) continue;
const g = this.make.graphics({ add: false });
g.fillStyle(tone, 1);
g.fillRoundedRect(2, 2, L, W, 8);
g.lineStyle(3, shade(tone, 0.45), 1);
g.strokeRoundedRect(2, 2, L, W, 8);
g.lineStyle(2, shade(tone, 1.45), 0.95);
g.strokeRoundedRect(6.5, 6.5, L - 9, W - 9, 5);
g.generateTexture(key, L + 4, W + 4);
g.destroy();
}
}
}
masterFor(entry) {
@ -521,8 +548,16 @@ export default class PeggleGame extends Phaser.Scene {
}
// Skins a peg image from its logical state — spritesheet frame when the
// sheet is present, tinted procedural circle otherwise.
// sheet is present, tinted procedural circle otherwise. Brick-curve
// sections use their own pre-styled textures, rotated to the curve tangent.
applyPegVisual(img, peg) {
if (peg.shape === 'brick') {
img.setTexture(`peggle-brick-${peg.color}${peg.lit ? '-lit' : ''}`);
img.clearTint();
img.setDisplaySize(TUNING.BRICK_LEN + 4, TUNING.BRICK_WID + 4);
img.setRotation(peg.angle);
return;
}
if (this.useSprites) {
img.setTexture(SPRITE_SHEET, SPRITE_FRAME[peg.color] + (peg.lit ? 1 : 0));
img.clearTint();

View File

@ -30,6 +30,8 @@ export const TUNING = {
STUCK_SPEED: 40,
STUCK_TIME: 1.5,
PEG_FADE_S: 1.5, // lit pegs clear out (and stop colliding) after this
BRICK_LEN: 36, // brick-curve section size (fixed → even spacing)
BRICK_WID: 22,
MAX_FLIGHT_S: 45,
BALLS_PER_LEVEL: 10,
};
@ -145,6 +147,65 @@ export function mulberry32(seed) {
};
}
// ── Brick curves ─────────────────────────────────────────────────────────────
// A curve is { anchors: [{x,y}...], bends: [px per segment], orangeEligible }.
// Each segment is a quadratic bezier whose control point is the chord midpoint
// displaced perpendicular by the bend. Curves tessellate into fixed-size brick
// "pegs" that flow through the exact same color/score/fade pipeline as circles.
function bezierPoint(p0, cp, p1, t) {
const u = 1 - t;
return {
x: u * u * p0.x + 2 * u * t * cp.x + t * t * p1.x,
y: u * u * p0.y + 2 * u * t * cp.y + t * t * p1.y,
};
}
// Deterministic, pure: curves → brick defs {x, y, shape:'brick', angle, orangeEligible}.
export function tessellateCurves(curves = []) {
const bricks = [];
const LEN = TUNING.BRICK_LEN;
for (const curve of curves) {
const anchors = curve.anchors ?? [];
const bends = curve.bends ?? [];
for (let s = 0; s < anchors.length - 1; s++) {
const p0 = anchors[s];
const p1 = anchors[s + 1];
const bend = bends[s] ?? 0;
const chordX = p1.x - p0.x;
const chordY = p1.y - p0.y;
const chordLen = Math.hypot(chordX, chordY) || 1;
const cp = {
x: (p0.x + p1.x) / 2 + (-chordY / chordLen) * bend,
y: (p0.y + p1.y) / 2 + (chordX / chordLen) * bend,
};
// Arc-length table over a fine sampling of the bezier.
const SAMPLES = 96;
const pts = [];
const cum = [0];
for (let i = 0; i <= SAMPLES; i++) {
pts.push(bezierPoint(p0, cp, p1, i / SAMPLES));
if (i > 0) cum.push(cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y));
}
const total = cum[SAMPLES];
const count = Math.max(1, Math.floor(total / LEN));
const pad = (total - count * LEN) / 2;
for (let b = 0; b < count; b++) {
const target = pad + LEN * (b + 0.5);
let i = 1;
while (i < SAMPLES && cum[i] < target) i++;
const span = cum[i] - cum[i - 1] || 1;
const f = (target - cum[i - 1]) / span;
const x = pts[i - 1].x + (pts[i].x - pts[i - 1].x) * f;
const y = pts[i - 1].y + (pts[i].y - pts[i - 1].y) * f;
const angle = Math.atan2(pts[i].y - pts[i - 1].y, pts[i].x - pts[i - 1].x);
bricks.push({ x, y, shape: 'brick', angle, orangeEligible: !!curve.orangeEligible });
}
}
}
return bricks;
}
// ── Round construction ───────────────────────────────────────────────────────
export function multiplierFor(orangeCleared) {
@ -174,16 +235,35 @@ export function createRound(levelDef, opts = {}) {
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 halfLen = TUNING.BRICK_LEN / 2;
const halfWid = TUNING.BRICK_WID / 2;
const pegs = [
...(levelDef.pegs ?? []),
...tessellateCurves(levelDef.curves ?? []),
].map((p, i) => (p.shape === 'brick'
? {
id: i,
x: p.x, y: p.y,
shape: 'brick',
angle: p.angle,
halfLen, halfWid,
r: halfWid,
boundR: Math.hypot(halfLen, halfWid),
color: 'blue',
orangeEligible: !!p.orangeEligible,
lit: false,
removed: false,
}
: {
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))) {
@ -353,10 +433,42 @@ export function launchBall(state, angle, opts = {}) {
// ── Peg / geometry helpers ───────────────────────────────────────────────────
function collideBallPeg(ball, peg) {
// Returns contact normal if overlapping, else null. Circles only in v1.
// Returns contact normal if overlapping, else null.
const dx = ball.x - peg.x;
const dy = ball.y - peg.y;
const rr = (ball.r ?? TUNING.BALL_R) + peg.r;
const br = ball.r ?? TUNING.BALL_R;
if (peg.shape === 'brick') {
// Circle vs oriented rect: work in the brick's local space.
const bound = peg.boundR + br;
if (dx * dx + dy * dy > bound * bound) return null;
const ca = Math.cos(peg.angle);
const sa = Math.sin(peg.angle);
const lx = dx * ca + dy * sa;
const ly = -dx * sa + dy * ca;
const cx = Math.max(-peg.halfLen, Math.min(peg.halfLen, lx));
const cy = Math.max(-peg.halfWid, Math.min(peg.halfWid, ly));
let nxl = lx - cx;
let nyl = ly - cy;
const d2 = nxl * nxl + nyl * nyl;
let depth;
if (d2 >= br * br) return null;
if (d2 < 0.000001) {
// Ball center inside the brick — push out along the shallower axis.
const pushX = peg.halfLen - Math.abs(lx);
const pushY = peg.halfWid - Math.abs(ly);
if (pushY <= pushX) { nxl = 0; nyl = ly >= 0 ? 1 : -1; depth = pushY + br; }
else { nxl = lx >= 0 ? 1 : -1; nyl = 0; depth = pushX + br; }
} else {
const d = Math.sqrt(d2);
nxl /= d;
nyl /= d;
depth = br - d;
}
return { nx: nxl * ca - nyl * sa, ny: nxl * sa + nyl * ca, depth };
}
const rr = br + peg.r;
const d2 = dx * dx + dy * dy;
if (d2 >= rr * rr) return null;
const d = Math.sqrt(d2) || 0.0001;

View File

@ -14,7 +14,7 @@ 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';
import { TUNING, tessellateCurves } from '../src/games/peggle/PeggleLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle');
@ -91,66 +91,86 @@ function settle(pegs) {
}
function levelFile(pegs, over = {}) {
const curves = over.curves ?? [];
let placed = pegs;
if (curves.length) {
// Cull circle pegs that would collide with the brick curves.
const bricks = tessellateCurves(curves);
placed = pegs.filter((p) => {
const min = p.r + TUNING.BRICK_WID / 2 + GAP + 4;
return !bricks.some((b) => (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min);
});
}
return {
version: 1,
board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H },
orangeCount: 25,
greenCount: 2,
pegs,
pegs: placed,
walls: [],
moving: [],
...over,
curves,
};
}
// Brick-curve shorthand: anchors + per-segment bends, orange-eligible.
const wave = (anchors, bends, orangeEligible = true) => ({ anchors, bends, orangeEligible });
const el = { orangeEligible: true };
// ── Ethel (1-5): parlor arcs & chandeliers ───────────────────────────────────
function ethelLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Tea Time — gentle nested arcs + bottom row
case 0: // Tea Time — nested arcs, a steam-wisp brick curve up top
pegs.push(
...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),
);
curves.push(wave([{ x: 350, y: 300 }, { x: 600, y: 270 }, { x: 850, y: 300 }], [-45, -45]));
break;
case 1: // Fine China — a saucer stack: opposing arc pairs
case 1: // Fine China — arc pairs with a brick saucer bowl
pegs.push(
...arc(600, 40, 500, Math.PI * 0.22, Math.PI * 0.78, 12, el),
...arc(600, 940, 500, -Math.PI * 0.76, -Math.PI * 0.24, 12, el),
...arc(600, 470, 210, 0, Math.PI * 2 * (1 - 1 / 12), 12, el),
...line(150, 480, 330, 480, 3),
...line(870, 480, 1050, 480, 3),
...line(380, 740, 820, 740, 5),
);
curves.push(wave([{ x: 380, y: 600 }, { x: 600, y: 600 }, { x: 820, y: 600 }], [55, 55]));
break;
case 2: // Doily Drop — lace fan of alternating arcs with side tassels
case 2: // Doily Drop — lace fan with hanging brick swags
pegs.push(
...arc(600, 130, 350, Math.PI * 0.1, Math.PI * 0.9, 12, el),
...arc(600, 130, 470, Math.PI * 0.14, Math.PI * 0.86, 13, el),
...arc(600, 130, 590, Math.PI * 0.18, Math.PI * 0.82, 14, el),
...line(180, 260, 180, 560, 4),
...line(1020, 260, 1020, 560, 4),
...line(420, 760, 780, 760, 4),
);
curves.push(wave(
[{ x: 180, y: 640 }, { x: 460, y: 660 }, { x: 740, y: 660 }, { x: 1020, y: 640 }],
[70, 70, 70],
));
break;
case 3: // Lace & Grace — S-curve of two big arcs + hanging drops
case 3: // Lace & Grace — S arcs with a ribbon wave along the bottom
pegs.push(
...arc(320, 300, 240, -Math.PI * 0.5, Math.PI * 0.5, 12, el),
...arc(880, 560, 240, Math.PI * 0.5, Math.PI * 1.5, 12, el),
...line(300, 640, 300, 780, 3, el),
...line(600, 620, 600, 780, 3, el),
...line(160, 210, 420, 210, 4),
...line(780, 210, 1040, 210, 4),
...grid(480, 340, 3, 2, 120, 100, el),
);
curves.push(wave(
[{ x: 200, y: 690 }, { x: 490, y: 710 }, { x: 780, y: 710 }, { x: 1050, y: 690 }],
[-55, 55, -55],
));
break;
default: // Grandma's Attic — chandelier: arcs + hanging strands
default: // Grandma's Attic — chandelier strands under a cobweb swag
pegs.push(
...arc(600, 90, 380, Math.PI * 0.15, Math.PI * 0.85, 12, el),
...arc(600, 90, 520, Math.PI * 0.2, Math.PI * 0.8, 13, el),
@ -159,30 +179,31 @@ function ethelLevel(v) {
...line(600, 500, 600, 780, 5, el),
...line(760, 470, 760, 770, 5, el),
...line(920, 420, 920, 720, 5, el),
...line(200, 250, 1000, 250, 6),
);
curves.push(wave([{ x: 280, y: 330 }, { x: 600, y: 330 }, { x: 920, y: 330 }], [50, 50]));
break;
}
return levelFile(settle(pegs));
return levelFile(settle(pegs), { curves });
}
// ── Kona (6-10): bones, paws & toys ──────────────────────────────────────────
function konaLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Fetch! — the dog bone
case 0: // Fetch! — the dog bone under a thrown-ball arc
pegs.push(
...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),
...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),
);
curves.push(wave([{ x: 180, y: 300 }, { x: 490, y: 260 }, { x: 800, y: 300 }], [-55, -45]));
break;
case 1: { // Paw Prints — big pad + four toes, twice
const paw = (cx, cy, s) => [
@ -196,11 +217,11 @@ function konaLevel(v) {
...paw(390, 520, 1),
...paw(880, 620, 0.8),
...line(700, 240, 1050, 240, 4),
...line(150, 740, 350, 740, 3),
);
curves.push(wave([{ x: 200, y: 720 }, { x: 620, y: 730 }, { x: 1040, y: 720 }], [35, -35]));
break;
}
case 2: // Buried Bones — two crossed bones + dirt scatter
case 2: // Buried Bones — crossed bones over brick dirt mounds
pegs.push(
...circle(280, 300, 60, 6, el),
...circle(920, 640, 60, 6, el),
@ -208,11 +229,11 @@ function konaLevel(v) {
...circle(920, 300, 60, 6, el),
...circle(280, 640, 60, 6, el),
...line(850, 340, 350, 600, 8, el),
...grid(240, 760, 8, 1, 105, 0),
...line(560, 210, 640, 210, 2),
);
curves.push(wave([{ x: 240, y: 700 }, { x: 600, y: 720 }, { x: 960, y: 700 }], [-55, -55]));
break;
case 3: // Squeaky Toy — ball on a rope with small toys
case 3: // Squeaky Toy — bouncing-path brick wave under the ball
pegs.push(
...circle(600, 430, 150, 13, el),
...circle(600, 430, 70, 7, el),
@ -221,10 +242,13 @@ function konaLevel(v) {
...circle(980, 640, 65, 7, el),
...line(160, 300, 360, 300, 3),
...line(840, 300, 1040, 300, 3),
...line(430, 740, 770, 740, 4),
);
curves.push(wave(
[{ x: 150, y: 580 }, { x: 450, y: 600 }, { x: 750, y: 600 }, { x: 1020, y: 580 }],
[55, -55, 55],
));
break;
default: // Best in Show — kennel house + trophy rows
default: // Best in Show — kennel house with bunting swags
pegs.push(
...line(600, 200, 320, 380, 5, el),
...line(600, 200, 880, 380, 5, el),
@ -232,58 +256,62 @@ function konaLevel(v) {
...line(880, 380, 880, 700, 5, el),
...line(320, 700, 880, 700, 8, el),
...grid(460, 460, 3, 3, 140, 110, el),
...line(140, 500, 240, 500, 2),
...line(960, 500, 1060, 500, 2),
...line(430, 780, 770, 780, 4),
);
curves.push(
wave([{ x: 120, y: 330 }, { x: 300, y: 330 }], [45]),
wave([{ x: 900, y: 330 }, { x: 1080, y: 330 }], [45]),
);
break;
}
return levelFile(settle(pegs));
return levelFile(settle(pegs), { curves });
}
// ── Zanthor (11-15): stars, moons & sigils ───────────────────────────────────
function zanthorLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Hocus Pocus — pentagram inside an arc
case 0: // Hocus Pocus — pentagram between two wand-swish curves
pegs.push(
...pentagram(600, 480, 230, 8, el),
...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el),
...line(240, 240, 460, 240, 3),
...line(740, 240, 960, 240, 3),
);
curves.push(
wave([{ x: 240, y: 240 }, { x: 600, y: 205 }, { x: 960, y: 240 }], [-30, -30]),
wave([{ x: 250, y: 720 }, { x: 600, y: 740 }, { x: 950, y: 720 }], [45, 45]),
);
break;
case 1: // Crescent Moon — offset arcs + scattered stars
case 1: // Crescent Moon — moon with a comet-tail brick curve
pegs.push(
...arc(560, 460, 260, -Math.PI * 0.42, Math.PI * 0.42, 13, el),
...arc(660, 460, 200, -Math.PI * 0.40, Math.PI * 0.40, 10, el),
...pentagram(280, 300, 80, 3, el),
...pentagram(920, 620, 80, 3, el),
...line(180, 660, 420, 760, 4),
...line(760, 220, 1000, 220, 4),
);
curves.push(wave([{ x: 150, y: 640 }, { x: 450, y: 690 }, { x: 750, y: 670 }], [45, -40]));
break;
case 2: // Crystal Ball — orb on a stand with facet lattice
case 2: // Crystal Ball — orb under a curl of mystic mist
pegs.push(
...circle(600, 420, 200, 15, el),
...grid(490, 340, 3, 3, 110, 90, el),
...line(420, 680, 780, 680, 5, el),
...line(470, 760, 730, 760, 4, el),
...line(160, 300, 320, 300, 3),
...line(880, 300, 1040, 300, 3),
);
curves.push(wave([{ x: 200, y: 250 }, { x: 500, y: 230 }, { x: 800, y: 250 }], [-40, -40]));
break;
case 3: // Twin Stars — two pentagrams joined by a beam
case 3: // Twin Stars — pentagrams over a shooting-star trail
pegs.push(
...pentagram(330, 400, 170, 6, el),
...pentagram(870, 560, 170, 6, el),
...line(430, 470, 750, 500, 4, el),
...line(200, 740, 480, 740, 4),
...line(650, 220, 1000, 220, 4),
);
curves.push(wave([{ x: 180, y: 710 }, { x: 600, y: 740 }, { x: 1020, y: 710 }], [45, 45]));
break;
default: // Grand Sigil — double circle, cross & diagonals
default: // Grand Sigil — circles & cross flanked by runic rails
pegs.push(
...circle(600, 470, 260, 18, el),
...circle(600, 470, 130, 10, el),
@ -293,19 +321,22 @@ function zanthorLevel(v) {
...line(740, 470, 850, 470, 2, el),
...line(300, 240, 420, 330, 2),
...line(900, 240, 780, 330, 2),
...line(300, 720, 420, 620, 2),
...line(900, 720, 780, 620, 2),
...line(480, 780, 720, 780, 3),
);
curves.push(
wave([{ x: 160, y: 380 }, { x: 160, y: 700 }], [-65]),
wave([{ x: 1040, y: 380 }, { x: 1040, y: 700 }], [65]),
);
break;
}
return levelFile(settle(pegs));
return levelFile(settle(pegs), { curves });
}
// ── Fireball (16-20): rings, rays & bursts ───────────────────────────────────
function fireballLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // X-Ray Vision — concentric rings, orange core
pegs.push(
@ -314,11 +345,10 @@ function fireballLevel(v) {
...circle(600, 470, 260, 20, el),
...arc(600, 470, 345, Math.PI * -0.35, Math.PI * 1.35, 22),
{ x: 600, y: 470, ...el },
...line(160, 740, 400, 740, 3),
...line(800, 740, 1040, 740, 3),
);
curves.push(wave([{ x: 160, y: 720 }, { x: 600, y: 750 }, { x: 1040, y: 720 }], [45, 45]));
break;
case 1: { // Solar Flare — core + radial rays
case 1: { // Solar Flare — core + rays under a corona curve
pegs.push(...circle(600, 470, 110, 9, el), { x: 600, y: 470, ...el });
for (let i = 0; i < 8; i++) {
const a = (i * Math.PI * 2) / 8 + Math.PI / 8;
@ -327,28 +357,28 @@ function fireballLevel(v) {
600 + 400 * Math.cos(a), 470 + 400 * Math.sin(a), 4, el,
));
}
curves.push(wave([{ x: 350, y: 250 }, { x: 600, y: 215 }, { x: 850, y: 250 }], [-35, -35]));
break;
}
case 2: // Atomic Age — three overlapping orbit rings + nucleus
case 2: // Atomic Age — orbit rings over an electron-path curve
pegs.push(
...circle(600, 470, 60, 6, el),
...circle(440, 470, 190, 13, el),
...circle(760, 470, 190, 13, el),
...circle(600, 320, 190, 13, el),
...line(200, 760, 440, 760, 3),
...line(760, 760, 1000, 760, 3),
);
curves.push(wave([{ x: 200, y: 680 }, { x: 600, y: 720 }, { x: 1000, y: 680 }], [50, 50]));
break;
case 3: { // Meteor Shower — diagonal streaks with cores
case 3: { // Meteor Shower — streaks under an atmosphere curve
for (let i = 0; i < 5; i++) {
const x0 = 140 + i * 210;
pegs.push(...line(x0, 230, x0 + 150, 720, 7, el));
pegs.push({ x: x0 + 160, y: 760, ...el });
}
pegs.push(...line(180, 200, 1020, 200, 6));
curves.push(wave([{ x: 180, y: 230 }, { x: 600, y: 200 }, { x: 1020, y: 230 }], [-25, -25]));
break;
}
default: // Supernova — rings + rays + corner shields
default: // Supernova — rings + rays over a shockwave curve
pegs.push(
...circle(600, 470, 90, 8, el),
...circle(600, 470, 210, 16, el),
@ -363,26 +393,30 @@ function fireballLevel(v) {
pegs.push(
...arc(140, 210, 150, 0, Math.PI * 0.5, 4),
...arc(1060, 210, 150, Math.PI * 0.5, Math.PI, 4),
...line(430, 780, 770, 780, 4),
);
curves.push(wave([{ x: 300, y: 720 }, { x: 600, y: 750 }, { x: 900, y: 720 }], [35, 35]));
break;
}
return levelFile(settle(pegs));
return levelFile(settle(pegs), { curves });
}
// ── Victor (21-25): boards & lattices ────────────────────────────────────────
function victorLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Opening Move — light checker rows
case 0: // Opening Move — checker rows between two brick waves
pegs.push(
...grid(220, 300, 6, 3, 150, 150, el),
...grid(295, 375, 6, 3, 150, 150, el),
...line(160, 210, 1040, 210, 6),
);
curves.push(
wave([{ x: 160, y: 240 }, { x: 600, y: 205 }, { x: 1040, y: 240 }], [-30, -30]),
wave([{ x: 160, y: 700 }, { x: 600, y: 730 }, { x: 1040, y: 700 }], [45, -45]),
);
break;
case 1: { // Knight's Tour — L-shaped clusters
case 1: { // Knight's Tour — L clusters over a zigzag rail
const L = (x, y) => [
...line(x, y, x, y + 180, 3, el),
...line(x, y + 180, x + 190, y + 180, 3, el),
@ -390,12 +424,14 @@ function victorLevel(v) {
pegs.push(
...L(240, 260), ...L(520, 220), ...L(800, 260),
...L(380, 520), ...L(660, 480), ...L(940, 520),
...line(160, 760, 460, 760, 4),
...line(740, 760, 1040, 760, 4),
);
curves.push(wave(
[{ x: 160, y: 710 }, { x: 450, y: 730 }, { x: 740, y: 730 }, { x: 1030, y: 710 }],
[40, -40, 40],
));
break;
}
case 2: // Double Gambit — chevrons
case 2: // Double Gambit — chevrons between curved side rails
for (let i = 0; i < 4; i++) {
const y = 260 + i * 140;
pegs.push(
@ -403,8 +439,12 @@ function victorLevel(v) {
...line(600, y + 90, 960, y, 6, el),
);
}
curves.push(
wave([{ x: 170, y: 300 }, { x: 170, y: 620 }], [-60]),
wave([{ x: 1030, y: 300 }, { x: 1030, y: 620 }], [60]),
);
break;
case 3: // Fortress — walls, towers & courtyard
case 3: // Fortress — walls & towers ringed by a brick moat
pegs.push(
...line(220, 260, 980, 260, 9, el),
...line(220, 700, 980, 700, 9, el),
@ -413,19 +453,22 @@ function victorLevel(v) {
...line(725, 330, 725, 630, 4, el),
...line(980, 330, 980, 630, 4, el),
...grid(330, 400, 2, 2, 100, 130, el),
...grid(580, 400, 2, 2, 100, 130, el),
...grid(830, 400, 2, 2, 100, 130, el),
);
curves.push(wave([{ x: 220, y: 760 }, { x: 980, y: 760 }], [25]));
break;
default: // Checkmate — the dense diagonal lattice
default: // Checkmate — lattice woven with two long waves
pegs.push(
...grid(180, 250, 8, 5, 120, 120, el),
...grid(240, 310, 8, 5, 120, 120, el),
...line(160, 210, 1040, 210, 8),
);
curves.push(
wave([{ x: 160, y: 350 }, { x: 600, y: 410 }, { x: 1040, y: 350 }], [50, 50]),
wave([{ x: 160, y: 610 }, { x: 600, y: 550 }, { x: 1040, y: 610 }], [-50, -50]),
);
break;
}
return levelFile(settle(pegs));
return levelFile(settle(pegs), { curves });
}
// ── Manifest + write ─────────────────────────────────────────────────────────
@ -461,12 +504,14 @@ for (const block of BLOCKS) {
levelNo++;
const file = `level-${String(levelNo).padStart(3, '0')}.json`;
const data = block.build(v);
const eligible = data.pegs.filter((p) => p.orangeEligible).length;
const bricks = tessellateCurves(data.curves ?? []);
const eligible = data.pegs.filter((p) => p.orangeEligible).length
+ bricks.filter((b) => b.orangeEligible).length;
if (eligible < data.orangeCount) {
throw new Error(`${file} (${block.names[v]}): only ${eligible} orange-eligible pegs (< ${data.orangeCount})`);
throw new Error(`${file} (${block.names[v]}): only ${eligible} orange-eligible pegs+bricks (< ${data.orangeCount})`);
}
writeFileSync(join(OUT_DIR, file), `${JSON.stringify(data, null, 2)}\n`);
console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs (${eligible} orange-eligible)`);
console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs + ${bricks.length} bricks (${eligible} orange-eligible)`);
manifestLevels.push({
level: levelNo,
name: block.names[v],

View File

@ -20,7 +20,7 @@ import { dirname, join } from 'node:path';
import {
TUNING, SCORING, POWERS,
mulberry32, multiplierFor, createRound, cloneState,
clampAim, aimToVelocity, bucketX, bucketWidth,
clampAim, aimToVelocity, bucketX, bucketWidth, tessellateCurves,
launchBall, stepSim, resolveFever,
simulatePreview, scoreShot, zenBallOptimize, applyPower,
} from '../src/games/peggle/PeggleLogic.js';
@ -192,8 +192,10 @@ console.log('― rules');
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);
// Peg ids run over circles then tessellated bricks, in order.
const defs = [...lvl.pegs, ...tessellateCurves(lvl.curves ?? [])];
check('orange pegs only on eligible slots',
st.pegs.every((p) => p.color !== 'orange' || lvl.pegs[p.id].orangeEligible));
st.pegs.every((p) => p.color !== 'orange' || defs[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);
}
@ -377,6 +379,90 @@ console.log('― powers');
check('zen launch consumes the charge and fires', st.zenNext === 0 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball'));
}
// ── 3a. Brick curves ─────────────────────────────────────────────────────────
console.log('― brick curves');
{
// Straight segment: evenly spaced bricks along the chord, angle ≈ 0.
const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 560, y: 400 }], bends: [0], orangeEligible: true }]);
check('straight curve brick count', bricks.length === Math.floor(360 / T.BRICK_LEN), `${bricks.length}`);
check('straight curve bricks level and flat',
bricks.every((b) => near(b.y, 400, 0.5) && near(b.angle, 0, 0.01)));
const gaps = bricks.slice(1).map((b, i) => b.x - bricks[i].x);
check('straight curve spacing = BRICK_LEN', gaps.every((g) => near(g, T.BRICK_LEN, 0.5)), gaps.join(','));
check('curve bricks inherit eligibility', bricks.every((b) => b.orangeEligible));
}
{
// Bent segment: the middle brick bows toward the bend side (+perp is +y
// for a left→right chord), ends stay near the anchors.
const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 800, y: 400 }], bends: [100], orangeEligible: false }]);
const mid = bricks[Math.floor(bricks.length / 2)];
check('bent curve bows at the middle', mid.y > 435 && mid.y < 465, `${mid.y}`);
check('bent curve stays anchored at the ends', bricks[0].y < 420 && bricks[bricks.length - 1].y < 420);
check('bent curve bricks rotate along the tangent',
bricks[0].angle > 0.05 && bricks[bricks.length - 1].angle < -0.05,
`${bricks[0].angle} .. ${bricks[bricks.length - 1].angle}`);
}
{
// Ball bounces off a horizontal brick face like a peg (lights + restitution).
const lvl = mkLevel([], {
curves: [{ anchors: [{ x: 450, y: 500 }, { x: 750, y: 500 }], bends: [0], orangeEligible: true }],
});
const st = createRound(lvl, { seed: 7 });
check('bricks join the peg list', st.pegs.length >= 8 && st.pegs.every((p) => p.shape === 'brick'));
st.orangeTotal = 99; // keep the round alive
inject(st, { x: 600, y: 430, vx: 0, vy: 200 });
let vyBefore = null;
let hit = null;
for (let i = 0; i < 600 && !hit; i++) {
vyBefore = st.balls[0]?.vy;
hit = stepSim(st, 1 / 240).find((e) => e.type === 'pegHit') ?? null;
}
check('brick hit registered and lit', !!hit && st.pegs[hit.pegId].lit);
check('brick bounce reflects upward with peg restitution',
st.balls[0].vy < 0 && near(Math.abs(st.balls[0].vy / vyBefore), T.PEG_RESTITUTION, 0.06),
`${Math.abs(st.balls[0]?.vy / vyBefore)}`);
}
{
// A 45° brick wall deflects a vertical drop sideways.
const lvl = mkLevel([], {
curves: [{ anchors: [{ x: 480, y: 380 }, { x: 720, y: 620 }], bends: [0], orangeEligible: false }],
});
const st = createRound(lvl, { seed: 7 });
st.orangeTotal = 99;
inject(st, { x: 600, y: 420, vx: 0, vy: 300 });
let hit = false;
for (let i = 0; i < 600 && !hit; i++) {
hit = stepSim(st, 1 / 240).some((e) => e.type === 'pegHit');
}
check('45° brick deflects the ball sideways', hit && Math.abs(st.balls[0].vx) > 60, `vx ${st.balls[0]?.vx}`);
}
{
// Round integration: brick curves join orange assignment and the fever sweep.
const lvl = mkLevel([], {
orangeCount: 5,
curves: [{ anchors: [{ x: 300, y: 500 }, { x: 900, y: 500 }], bends: [-60], orangeEligible: true }],
});
const st = createRound(lvl, { seed: 11 });
const orange = st.pegs.filter((p) => p.color === 'orange').length;
check('bricks receive orange assignment', orange === 5, `${orange}`);
check('bricks host the purple bonus', st.purpleId != null && st.pegs[st.purpleId].color === 'purple');
// Determinism replay over a level with curves.
const logOf = () => {
const s = createRound(lvl, { seed: 999 });
const log = [...launchBall(s, 0.3)];
for (let t = 0; t < 25 && s.phase === 'flight'; t += 1 / 60) log.push(...stepSim(s, 1 / 60));
return log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|');
};
check('curved levels replay deterministically', logOf() === logOf());
}
// ── 3b. New powers (for the next wave of friends) ────────────────────────────
console.log('― powers (new wave)');
@ -702,10 +788,25 @@ console.log('― data lint');
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);
const bricks = tessellateCurves(lvl.curves ?? []);
check(`${tag}: enough orange-eligible pegs`,
lvl.pegs.filter((p) => p.orangeEligible).length >= lvl.orangeCount);
lvl.pegs.filter((p) => p.orangeEligible).length
+ bricks.filter((b) => b.orangeEligible).length >= lvl.orangeCount);
check(`${tag}: enough pegs for orange+green+purple`,
lvl.pegs.length >= lvl.orangeCount + lvl.greenCount + 1);
lvl.pegs.length + bricks.length >= lvl.orangeCount + lvl.greenCount + 1);
if (lvl.curves?.length) {
check(`${tag}: curve schema valid`,
lvl.curves.every((c) => (c.anchors?.length ?? 0) >= 2 && (c.bends?.length ?? -1) === c.anchors.length - 1));
const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2);
check(`${tag}: bricks in bounds and clear of launcher/bucket lanes`,
bricks.every((b) => b.x >= boundR && b.x <= T.BOARD_W - boundR && b.y >= 140 && b.y <= 820),
bricks.filter((b) => !(b.y >= 140 && b.y <= 820)).map((b) => `${Math.round(b.x)},${Math.round(b.y)}`).join(' '));
const clash = lvl.pegs.some((p) => bricks.some((b) => {
const min = (p.r ?? T.PEG_R) + T.BRICK_WID / 2;
return (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min;
}));
check(`${tag}: bricks clear of circle pegs`, !clash);
}
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;