From 58d51503eb30d24283067b95f906f10c57339f9a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 11 Jul 2026 18:39:42 -0600 Subject: [PATCH] Level Adjustments --- assets/gamedata/peggle/level-001.json | 67 +++-- assets/gamedata/peggle/level-002.json | 74 ++---- assets/gamedata/peggle/level-003.json | 86 ++---- assets/gamedata/peggle/level-004.json | 100 ++----- assets/gamedata/peggle/level-005.json | 95 ++----- assets/gamedata/peggle/level-006.json | 67 ++--- assets/gamedata/peggle/level-007.json | 60 ++--- assets/gamedata/peggle/level-008.json | 109 ++------ assets/gamedata/peggle/level-009.json | 128 ++------- assets/gamedata/peggle/level-010.json | 64 +++-- assets/gamedata/peggle/level-011.json | 116 ++++---- assets/gamedata/peggle/level-012.json | 67 ++--- assets/gamedata/peggle/level-013.json | 88 ++---- assets/gamedata/peggle/level-014.json | 60 ++--- assets/gamedata/peggle/level-015.json | 50 ++-- assets/gamedata/peggle/level-016.json | 88 ++---- assets/gamedata/peggle/level-017.json | 39 +-- assets/gamedata/peggle/level-018.json | 67 ++--- assets/gamedata/peggle/level-019.json | 88 ++---- assets/gamedata/peggle/level-020.json | 53 ++-- assets/gamedata/peggle/level-021.json | 109 ++++---- assets/gamedata/peggle/level-022.json | 114 ++------ assets/gamedata/peggle/level-023.json | 36 ++- assets/gamedata/peggle/level-024.json | 48 ++-- assets/gamedata/peggle/level-025.json | 368 ++++---------------------- assets/gamedata/peggle/sprites.md | 13 + src/games/peggle/PeggleEditor.js | 339 ++++++++++++++++++++++-- src/games/peggle/PeggleGame.js | 37 ++- src/games/peggle/PeggleLogic.js | 136 +++++++++- tools/genPeggleLevels.js | 173 +++++++----- tools/verifyPeggle.js | 109 +++++++- 31 files changed, 1424 insertions(+), 1624 deletions(-) diff --git a/assets/gamedata/peggle/level-001.json b/assets/gamedata/peggle/level-001.json index 0b2c18c..9013829 100644 --- a/assets/gamedata/peggle/level-001.json +++ b/assets/gamedata/peggle/level-001.json @@ -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": [], diff --git a/assets/gamedata/peggle/level-002.json b/assets/gamedata/peggle/level-002.json index 0bf72d2..f65763d 100644 --- a/assets/gamedata/peggle/level-002.json +++ b/assets/gamedata/peggle/level-002.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-003.json b/assets/gamedata/peggle/level-003.json index ef02ac0..402be10 100644 --- a/assets/gamedata/peggle/level-003.json +++ b/assets/gamedata/peggle/level-003.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-004.json b/assets/gamedata/peggle/level-004.json index 48efa63..7725cb3 100644 --- a/assets/gamedata/peggle/level-004.json +++ b/assets/gamedata/peggle/level-004.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-005.json b/assets/gamedata/peggle/level-005.json index c8be340..cb1c87f 100644 --- a/assets/gamedata/peggle/level-005.json +++ b/assets/gamedata/peggle/level-005.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-006.json b/assets/gamedata/peggle/level-006.json index b1a9c52..6d66dba 100644 --- a/assets/gamedata/peggle/level-006.json +++ b/assets/gamedata/peggle/level-006.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-007.json b/assets/gamedata/peggle/level-007.json index 260a9f9..f98525e 100644 --- a/assets/gamedata/peggle/level-007.json +++ b/assets/gamedata/peggle/level-007.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-008.json b/assets/gamedata/peggle/level-008.json index 048af83..44d9ae9 100644 --- a/assets/gamedata/peggle/level-008.json +++ b/assets/gamedata/peggle/level-008.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-009.json b/assets/gamedata/peggle/level-009.json index 204753b..5b86c1e 100644 --- a/assets/gamedata/peggle/level-009.json +++ b/assets/gamedata/peggle/level-009.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-010.json b/assets/gamedata/peggle/level-010.json index e03492c..878ee4e 100644 --- a/assets/gamedata/peggle/level-010.json +++ b/assets/gamedata/peggle/level-010.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-011.json b/assets/gamedata/peggle/level-011.json index c901d00..daaf879 100644 --- a/assets/gamedata/peggle/level-011.json +++ b/assets/gamedata/peggle/level-011.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-012.json b/assets/gamedata/peggle/level-012.json index f4f1b51..70f9799 100644 --- a/assets/gamedata/peggle/level-012.json +++ b/assets/gamedata/peggle/level-012.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-013.json b/assets/gamedata/peggle/level-013.json index f38118d..d789ccc 100644 --- a/assets/gamedata/peggle/level-013.json +++ b/assets/gamedata/peggle/level-013.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-014.json b/assets/gamedata/peggle/level-014.json index 6787e20..c8a04b7 100644 --- a/assets/gamedata/peggle/level-014.json +++ b/assets/gamedata/peggle/level-014.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-015.json b/assets/gamedata/peggle/level-015.json index 85b12ac..9f9c4a4 100644 --- a/assets/gamedata/peggle/level-015.json +++ b/assets/gamedata/peggle/level-015.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-016.json b/assets/gamedata/peggle/level-016.json index 832e4ee..73d70fa 100644 --- a/assets/gamedata/peggle/level-016.json +++ b/assets/gamedata/peggle/level-016.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-017.json b/assets/gamedata/peggle/level-017.json index 15aede0..7dc7aa8 100644 --- a/assets/gamedata/peggle/level-017.json +++ b/assets/gamedata/peggle/level-017.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-018.json b/assets/gamedata/peggle/level-018.json index 05aa986..4b0dc8b 100644 --- a/assets/gamedata/peggle/level-018.json +++ b/assets/gamedata/peggle/level-018.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-019.json b/assets/gamedata/peggle/level-019.json index 5255fd6..a2deacd 100644 --- a/assets/gamedata/peggle/level-019.json +++ b/assets/gamedata/peggle/level-019.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-020.json b/assets/gamedata/peggle/level-020.json index 9eef81f..204624f 100644 --- a/assets/gamedata/peggle/level-020.json +++ b/assets/gamedata/peggle/level-020.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-021.json b/assets/gamedata/peggle/level-021.json index c6dcbc4..d56a1a1 100644 --- a/assets/gamedata/peggle/level-021.json +++ b/assets/gamedata/peggle/level-021.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-022.json b/assets/gamedata/peggle/level-022.json index cbb834b..d6e756e 100644 --- a/assets/gamedata/peggle/level-022.json +++ b/assets/gamedata/peggle/level-022.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-023.json b/assets/gamedata/peggle/level-023.json index cdc9ebe..8ee8d74 100644 --- a/assets/gamedata/peggle/level-023.json +++ b/assets/gamedata/peggle/level-023.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-024.json b/assets/gamedata/peggle/level-024.json index 3b3ab28..ae26577 100644 --- a/assets/gamedata/peggle/level-024.json +++ b/assets/gamedata/peggle/level-024.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/level-025.json b/assets/gamedata/peggle/level-025.json index cf1926a..cb611d2 100644 --- a/assets/gamedata/peggle/level-025.json +++ b/assets/gamedata/peggle/level-025.json @@ -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 + } + ] } diff --git a/assets/gamedata/peggle/sprites.md b/assets/gamedata/peggle/sprites.md index 0ee6a6f..04ce971 100644 --- a/assets/gamedata/peggle/sprites.md +++ b/assets/gamedata/peggle/sprites.md @@ -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 9–15 could skin them later if wanted. + ## Not covered by the sheet - **Level editor** (`?peggle-editor=1`) intentionally keeps procedural circles — diff --git a/src/games/peggle/PeggleEditor.js b/src/games/peggle/PeggleEditor.js index 9a8f3e0..a0570e3 100644 --- a/src/games/peggle/PeggleEditor.js +++ b/src/games/peggle/PeggleEditor.js @@ -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', { diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js index d13cfee..2f138e6 100644 --- a/src/games/peggle/PeggleGame.js +++ b/src/games/peggle/PeggleGame.js @@ -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(); diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js index 1a8170f..3c4c21f 100644 --- a/src/games/peggle/PeggleLogic.js +++ b/src/games/peggle/PeggleLogic.js @@ -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; diff --git a/tools/genPeggleLevels.js b/tools/genPeggleLevels.js index 0b28aba..929254f 100644 --- a/tools/genPeggleLevels.js +++ b/tools/genPeggleLevels.js @@ -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], diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js index e0f92bc..eadb18b 100644 --- a/tools/verifyPeggle.js +++ b/tools/verifyPeggle.js @@ -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;