diff --git a/assets/images/background-jello.png b/assets/images/background-jello.png new file mode 100644 index 0000000..43db9f4 Binary files /dev/null and b/assets/images/background-jello.png differ diff --git a/assets/images/background-mahjongmatch.png b/assets/images/background-mahjongmatch.png new file mode 100644 index 0000000..bd375e4 Binary files /dev/null and b/assets/images/background-mahjongmatch.png differ diff --git a/assets/images/background-mm-01.png b/assets/images/background-mm-01.png new file mode 100644 index 0000000..35825ce Binary files /dev/null and b/assets/images/background-mm-01.png differ diff --git a/assets/images/background-mm-02.png b/assets/images/background-mm-02.png new file mode 100644 index 0000000..44b929e Binary files /dev/null and b/assets/images/background-mm-02.png differ diff --git a/assets/images/background-mm-03.png b/assets/images/background-mm-03.png new file mode 100644 index 0000000..ea4b61b Binary files /dev/null and b/assets/images/background-mm-03.png differ diff --git a/assets/images/bloxorz/background-1.png b/assets/images/bloxorz/background-1.png new file mode 100644 index 0000000..df3c213 Binary files /dev/null and b/assets/images/bloxorz/background-1.png differ diff --git a/assets/images/bloxorz/background-2.png b/assets/images/bloxorz/background-2.png new file mode 100644 index 0000000..f16524f Binary files /dev/null and b/assets/images/bloxorz/background-2.png differ diff --git a/assets/images/bloxorz/background-3.png b/assets/images/bloxorz/background-3.png new file mode 100644 index 0000000..98a852d Binary files /dev/null and b/assets/images/bloxorz/background-3.png differ diff --git a/assets/images/bloxorz/background-4.png b/assets/images/bloxorz/background-4.png new file mode 100644 index 0000000..8febf08 Binary files /dev/null and b/assets/images/bloxorz/background-4.png differ diff --git a/assets/images/bloxorz/background-5.png b/assets/images/bloxorz/background-5.png new file mode 100644 index 0000000..18bcfa4 Binary files /dev/null and b/assets/images/bloxorz/background-5.png differ diff --git a/assets/images/bloxorz/background-6.png b/assets/images/bloxorz/background-6.png new file mode 100644 index 0000000..f7643e7 Binary files /dev/null and b/assets/images/bloxorz/background-6.png differ diff --git a/assets/images/bloxorz/background-menu.png b/assets/images/bloxorz/background-menu.png new file mode 100644 index 0000000..cf16b3c Binary files /dev/null and b/assets/images/bloxorz/background-menu.png differ diff --git a/data/puddingmonsters.json b/data/puddingmonsters.json index 55d2e12..1db3d09 100644 --- a/data/puddingmonsters.json +++ b/data/puddingmonsters.json @@ -1,80 +1,110 @@ { - "generatedAt": "2026-06-12T06:32:38.434Z", - "seed": 1592594996, - "count": 40, + "generatedAt": "2026-07-26T05:12:58.276Z", + "seed": 20260725, + "count": 75, + "chapters": [ + { + "id": 1, + "name": "Cold Storage", + "blurb": "Wake up. Get out of the fridge.", + "from": 1, + "to": 15 + }, + { + "id": 2, + "name": "Kitchen Counter", + "blurb": "Mind the mess. Some of it is yours.", + "from": 16, + "to": 30 + }, + { + "id": 3, + "name": "The Pantry", + "blurb": "Shortcuts, springs, and a long way down.", + "from": 31, + "to": 45 + }, + { + "id": 4, + "name": "Dining Room", + "blurb": "Push the furniture. Nobody is watching.", + "from": 46, + "to": 60 + }, + { + "id": 5, + "name": "Midnight Feast", + "blurb": "Everything you have learned, all at once.", + "from": 61, + "to": 75 + } + ], "levels": [ { "level": 1, + "chapter": 1, + "name": "First Wobble", "cols": 5, "rows": 5, "walls": [ + [ + 0, + 2 + ], [ 4, - 4 + 2 + ], + [ + 4, + 1 ], [ 1, - 4 - ], - [ - 2, - 0 + 1 ] ], "spikes": [], "targets": [ [ - 0, + 3, + 2 + ], + [ + 3, 3 ], [ - 1, + 4, 3 - ], - [ - 0, - 4 ] ], "monsters": [ - [ - 2, - 1 - ], - [ - 0, - 1 - ], [ 0, 4 + ], + [ + 3, + 2 + ], + [ + 4, + 3 ] ], "par": 2 }, { "level": 2, + "chapter": 1, + "name": "Two of a Kind", "cols": 5, "rows": 5, "walls": [ - [ - 2, - 0 - ], - [ - 0, - 1 - ], - [ - 4, - 4 - ] - ], - "spikes": [], - "targets": [ [ 1, - 0 + 4 ], [ 1, @@ -83,19 +113,38 @@ [ 1, 2 + ], + [ + 0, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 2, + 2 ] ], "monsters": [ [ - 1, - 4 + 2, + 2 ], [ - 1, - 1 + 4, + 3 ], [ - 0, + 4, 0 ] ], @@ -103,77 +152,117 @@ }, { "level": 3, + "chapter": 1, + "name": "Shelf Life", "cols": 5, "rows": 5, "walls": [ [ - 0, - 2 - ], - [ - 0, - 3 + 3, + 4 ], [ 3, 3 + ], + [ + 3, + 1 + ], + [ + 4, + 2 ] ], "spikes": [], "targets": [ [ - 2, - 3 + 0, + 1 ], [ - 2, - 4 + 0, + 2 ], [ - 3, - 4 + 1, + 2 ] ], "monsters": [ [ 2, - 1 - ], - [ - 3, - 4 + 2 ], [ 0, 4 + ], + [ + 0, + 1 ] ], "par": 2 }, { "level": 4, + "chapter": 1, + "name": "Rise and Shine", "cols": 5, "rows": 5, "walls": [ [ - 0, - 4 + 1, + 0 ], [ - 3, + 4, 2 ], [ 4, + 1 + ], + [ + 0, + 0 + ], + [ + 0, + 1 + ], + [ + 2, + 4 + ], + [ + 2, + 0 + ], + [ + 4, + 3 + ], + [ + 3, + 4 + ], + [ + 3, + 0 + ], + [ + 0, + 3 + ], + [ + 1, 4 ] ], "spikes": [], "targets": [ - [ - 0, - 3 - ], [ 1, 3 @@ -181,11 +270,15 @@ [ 2, 3 + ], + [ + 3, + 3 ] ], "monsters": [ [ - 4, + 2, 1 ], [ @@ -193,39 +286,57 @@ 3 ], [ - 0, + 1, 3 ] ], - "par": 2 + "par": 2, + "sleepers": [ + [ + 2, + 1 + ] + ], + "elements": [ + "sleepers" + ], + "element": "sleepers", + "tip": "Sleepyheads can't be flicked — bump one awake to move it.", + "teaching": true }, { "level": 5, + "chapter": 1, + "name": "Snooze Button", "cols": 5, "rows": 5, "walls": [ [ - 3, - 0 + 0, + 1 ], [ - 2, - 3 + 0, + 4 ], [ 1, + 4 + ], + [ + 4, 3 ] ], "spikes": [], "targets": [ [ - 0, - 2 + 2, + 0 ], [ - 1, - 2 + 2, + 1 ], [ 2, @@ -233,45 +344,57 @@ ] ], "monsters": [ + [ + 1, + 3 + ], [ 3, - 2 + 4 ], [ 2, 0 - ], - [ - 0, - 2 ] ], - "par": 2 + "par": 3, + "sleepers": [ + [ + 3, + 4 + ] + ], + "elements": [ + "sleepers" + ], + "element": "sleepers" }, { "level": 6, + "chapter": 1, + "name": "Heavy Sleeper", "cols": 5, "rows": 5, "walls": [ [ - 3, - 4 + 0, + 0 ], [ - 2, - 3 + 4, + 2 ], [ - 3, + 1, 1 + ], + [ + 3, + 3 ] ], "spikes": [], "targets": [ - [ - 2, - 0 - ], [ 2, 1 @@ -279,75 +402,271 @@ [ 2, 2 + ], + [ + 3, + 2 ] ], "monsters": [ [ 0, - 1 - ], - [ - 0, - 4 + 2 ], [ 2, + 1 + ], + [ + 3, 0 ] ], - "par": 2 + "par": 3, + "sleepers": [ + [ + 0, + 2 + ] + ], + "elements": [ + "sleepers" + ], + "element": "sleepers" }, { "level": 7, + "chapter": 1, + "name": "Dream Team", "cols": 5, "rows": 5, "walls": [ - [ - 3, - 3 - ], - [ - 0, - 3 - ], [ 1, 0 - ] - ], - "spikes": [], - "targets": [ - [ - 0, - 2 - ], - [ - 1, - 2 - ], - [ - 2, - 2 - ] - ], - "monsters": [ - [ - 2, - 2 - ], - [ - 3, - 0 ], [ 0, 1 + ], + [ + 3, + 3 + ], + [ + 2, + 1 ] ], - "par": 3 + "spikes": [], + "targets": [ + [ + 3, + 0 + ], + [ + 3, + 1 + ], + [ + 4, + 1 + ] + ], + "monsters": [ + [ + 3, + 0 + ], + [ + 1, + 3 + ], + [ + 4, + 2 + ] + ], + "par": 4, + "sleepers": [ + [ + 3, + 0 + ] + ], + "elements": [ + "sleepers" + ], + "element": "sleepers" }, { "level": 8, + "chapter": 1, + "name": "Breaking the Ice", + "cols": 5, + "rows": 5, + "walls": [ + [ + 3, + 2 + ], + [ + 0, + 1 + ], + [ + 1, + 2 + ], + [ + 4, + 3 + ], + [ + 3, + 4 + ], + [ + 0, + 3 + ], + [ + 4, + 1 + ], + [ + 2, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 2 + ], + [ + 1, + 3 + ], + [ + 2, + 3 + ] + ], + "monsters": [ + [ + 3, + 3 + ], + [ + 1, + 3 + ], + [ + 2, + 1 + ] + ], + "par": 3, + "ice": [ + [ + 4, + 2 + ], + [ + 1, + 4 + ], + [ + 2, + 2 + ] + ], + "elements": [ + "ice" + ], + "element": "ice", + "tip": "Ice stops a slide once, then shatters. Spend it wisely.", + "teaching": true + }, + { + "level": 9, + "chapter": 1, + "name": "Cold Snap", + "cols": 5, + "rows": 5, + "walls": [ + [ + 0, + 3 + ], + [ + 0, + 2 + ], + [ + 0, + 4 + ], + [ + 3, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 2 + ], + [ + 4, + 2 + ], + [ + 4, + 3 + ] + ], + "monsters": [ + [ + 4, + 1 + ], + [ + 2, + 1 + ], + [ + 4, + 3 + ] + ], + "par": 3, + "ice": [ + [ + 2, + 0 + ], + [ + 3, + 1 + ], + [ + 3, + 0 + ] + ], + "elements": [ + "ice" + ], + "element": "ice" + }, + { + "level": 10, + "chapter": 1, + "name": "Thin Ice", "cols": 5, "rows": 5, "walls": [ @@ -357,324 +676,457 @@ ], [ 3, - 3 + 0 + ], + [ + 1, + 1 + ], + [ + 0, + 4 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 3, + 1 + ] + ], + "monsters": [ + [ + 1, + 4 + ], + [ + 3, + 4 ], [ 1, 0 ] ], - "spikes": [], - "targets": [ + "par": 4, + "ice": [ [ - 1, - 2 - ], - [ - 0, + 4, 3 ], - [ - 1, - 3 - ] - ], - "monsters": [ - [ - 0, - 4 - ], [ 4, - 2 - ], - [ - 3, - 4 - ] - ], - "par": 3 - }, - { - "level": 9, - "cols": 6, - "rows": 6, - "walls": [ - [ - 5, - 5 - ], - [ - 2, - 1 - ], - [ - 5, - 2 - ], - [ - 2, - 5 - ] - ], - "spikes": [], - "targets": [ - [ - 2, - 4 - ], - [ - 3, - 4 - ], - [ - 3, - 5 - ] - ], - "monsters": [ - [ - 1, - 2 - ], - [ - 3, - 2 - ], - [ - 4, - 5 - ] - ], - "par": 3 - }, - { - "level": 10, - "cols": 6, - "rows": 6, - "walls": [ - [ - 0, - 4 - ], - [ - 5, - 2 - ], - [ - 1, 0 ], [ 2, - 4 - ] - ], - "spikes": [], - "targets": [ - [ - 3, - 1 - ], - [ - 4, - 1 - ], - [ - 3, 2 ] ], - "monsters": [ - [ - 3, - 5 - ], - [ - 4, - 1 - ], - [ - 1, - 3 - ] + "elements": [ + "ice" ], - "par": 3 + "element": "ice" }, { "level": 11, - "cols": 6, - "rows": 6, + "chapter": 1, + "name": "Freezer Burn", + "cols": 5, + "rows": 5, "walls": [ [ - 5, + 0, + 0 + ], + [ + 0, 2 ], + [ + 2, + 0 + ], [ 1, 3 - ], - [ - 4, - 5 - ], - [ - 2, - 2 ] ], "spikes": [], "targets": [ + [ + 4, + 0 + ], [ 3, 1 ], - [ - 3, - 2 - ], [ 4, - 2 + 1 ] ], "monsters": [ [ 4, - 2 - ], - [ - 3, - 1 + 4 ], [ 0, 4 + ], + [ + 4, + 0 ] ], - "par": 3 + "par": 4, + "ice": [ + [ + 2, + 4 + ], + [ + 2, + 2 + ], + [ + 4, + 3 + ] + ], + "elements": [ + "ice" + ], + "element": "ice" }, { "level": 12, + "chapter": 1, + "name": "Chill Out", "cols": 6, "rows": 6, "walls": [ + [ + 1, + 3 + ], [ 5, + 3 + ], + [ + 4, + 0 + ], + [ + 0, 2 ], [ 3, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 0 + ], + [ + 4, 1 ], + [ + 3, + 2 + ] + ], + "monsters": [ + [ + 3, + 2 + ], + [ + 2, + 4 + ], + [ + 4, + 1 + ], + [ + 2, + 0 + ] + ], + "par": 4, + "ice": [ + [ + 1, + 2 + ], [ 3, 4 ], [ - 2, - 5 + 4, + 4 ] ], - "spikes": [], - "targets": [ - [ - 4, - 2 - ], - [ - 3, - 3 - ], - [ - 4, - 3 - ] - ], - "monsters": [ + "sleepers": [ [ 2, - 3 - ], - [ - 4, - 5 - ], - [ - 1, - 2 + 4 ] ], - "par": 3 + "elements": [ + "sleepers", + "ice" + ], + "element": "sleepers" }, { "level": 13, + "chapter": 1, + "name": "Frost and Friends", "cols": 6, "rows": 6, "walls": [ [ 3, - 1 + 4 + ], + [ + 3, + 5 ], [ 2, - 1 + 0 ], [ 1, - 3 + 4 ], [ - 5, - 2 + 4, + 1 ] ], "spikes": [], "targets": [ [ - 4, + 2, 1 ], [ - 5, - 1 + 2, + 2 ], [ - 4, + 3, 2 ] ], "monsters": [ [ - 2, - 0 - ], - [ - 5, - 0 + 0, + 3 ], [ 2, 2 + ], + [ + 4, + 4 + ], + [ + 2, + 4 ] ], - "par": 3 + "par": 5, + "ice": [ + [ + 0, + 1 + ], + [ + 4, + 3 + ], + [ + 5, + 1 + ] + ], + "sleepers": [ + [ + 2, + 4 + ] + ], + "elements": [ + "sleepers", + "ice" + ], + "element": "sleepers" }, { "level": 14, + "chapter": 1, + "name": "Deep Freeze", "cols": 6, "rows": 6, "walls": [ + [ + 0, + 1 + ], + [ + 0, + 0 + ], + [ + 3, + 3 + ], + [ + 4, + 1 + ], + [ + 3, + 2 + ] + ], + "spikes": [], + "targets": [ [ 1, + 0 + ], + [ + 2, + 1 + ], + [ + 3, + 1 + ] + ], + "monsters": [ + [ + 5, + 0 + ], + [ + 3, + 1 + ], + [ + 0, + 4 + ], + [ + 3, + 5 + ] + ], + "par": 7, + "ice": [ + [ + 3, + 4 + ], + [ + 4, + 3 + ], + [ + 0, + 2 + ] + ], + "sleepers": [ + [ + 3, + 1 + ] + ], + "elements": [ + "sleepers", + "ice" + ], + "element": "sleepers" + }, + { + "level": 15, + "chapter": 1, + "name": "Door Ajar", + "cols": 6, + "rows": 6, + "walls": [ + [ + 2, + 0 + ], + [ + 0, + 4 + ], + [ + 2, + 4 + ], + [ + 2, + 1 + ], + [ + 0, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 0, 2 ], [ 1, - 5 + 3 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 3, + 0 ], [ 4, + 1 + ], + [ + 1, 4 ], [ @@ -682,120 +1134,65 @@ 0 ] ], - "spikes": [], - "targets": [ + "par": 8, + "ice": [ [ - 2, - 1 - ], - [ - 2, - 2 - ], - [ - 2, - 3 - ] - ], - "monsters": [ - [ - 2, + 4, 5 ], [ 4, - 3 + 2 ], - [ - 4, - 1 - ] - ], - "par": 3 - }, - { - "level": 15, - "cols": 6, - "rows": 6, - "walls": [ [ 0, 5 - ], + ] + ], + "sleepers": [ [ - 4, - 3 - ], - [ - 4, - 4 - ], - [ - 2, + 5, 0 ] ], - "spikes": [], - "targets": [ - [ - 3, - 0 - ], - [ - 3, - 1 - ], - [ - 3, - 2 - ] + "elements": [ + "sleepers", + "ice" ], - "monsters": [ - [ - 0, - 3 - ], - [ - 3, - 4 - ], - [ - 4, - 0 - ] - ], - "par": 3 + "element": "sleepers" }, { "level": 16, + "chapter": 2, + "name": "Countertop Caper", "cols": 6, "rows": 6, "walls": [ [ - 4, - 2 - ], - [ - 1, - 2 + 5, + 5 ], [ 0, 4 ], [ - 2, - 5 + 0, + 1 + ], + [ + 4, + 2 ] ], "spikes": [], "targets": [ [ - 0, + 1, 3 ], [ - 1, + 2, 3 ], [ @@ -804,16 +1201,16 @@ ] ], "monsters": [ - [ - 2, - 4 - ], - [ - 0, - 2 - ], [ 4, + 0 + ], + [ + 2, + 3 + ], + [ + 5, 4 ] ], @@ -821,60 +1218,54 @@ }, { "level": 17, + "chapter": 2, + "name": "Spilled Milk", "cols": 6, "rows": 6, "walls": [ [ - 4, - 3 - ], - [ - 2, - 2 + 3, + 4 ], [ 4, - 0 + 5 ], [ 5, - 2 + 0 ], [ - 3, - 5 + 1, + 3 ] ], "spikes": [], "targets": [ [ 2, - 4 + 3 ], [ - 4, - 4 + 3, + 3 ], [ 2, - 5 + 4 ] ], "monsters": [ [ - 2, - 3 + 0, + 0 ], [ 3, 0 ], [ - 4, - 5 - ], - [ - 0, + 1, 5 ] ], @@ -882,76 +1273,108 @@ }, { "level": 18, - "cols": 6, - "rows": 6, + "chapter": 2, + "name": "Sticky Business", + "cols": 5, + "rows": 5, "walls": [ [ 3, - 5 + 2 + ], + [ + 1, + 2 ], [ 4, - 2 + 3 ], [ - 5, - 2 + 1, + 4 + ], + [ + 4, + 1 ], [ 0, + 1 + ], + [ + 1, + 0 + ], + [ + 2, 4 ], + [ + 2, + 0 + ], + [ + 0, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 1 + ], + [ + 2, + 1 + ], [ 2, 2 ] ], - "spikes": [], - "targets": [ - [ - 3, - 2 - ], - [ - 3, - 3 - ], - [ - 4, - 3 - ] - ], "monsters": [ [ - 0, - 5 + 1, + 1 ], [ - 4, - 4 - ], - [ - 3, + 2, 2 ], [ - 0, + 1, 3 ] ], - "par": 4 + "par": 3, + "green": [ + [ + 1, + 3 + ] + ], + "elements": [ + "green" + ], + "element": "green", + "tip": "Green monsters leave slime. Anything sliding onto it stops dead.", + "teaching": true }, { "level": 19, + "chapter": 2, + "name": "Green Streak", "cols": 6, "rows": 6, "walls": [ [ - 5, - 5 + 2, + 2 ], [ - 0, + 4, 1 ], [ @@ -959,66 +1382,91 @@ 2 ], [ - 2, - 0 - ], - [ - 4, - 3 + 0, + 1 ] ], "spikes": [], "targets": [ [ - 1, - 0 - ], - [ - 2, - 1 - ], - [ - 2, + 3, 2 + ], + [ + 2, + 3 + ], + [ + 3, + 3 ] ], "monsters": [ [ - 2, - 4 + 3, + 2 ], [ - 3, + 2, 5 ], [ - 1, - 0 - ], - [ - 5, - 1 + 0, + 5 ] ], - "par": 4 + "par": 3, + "green": [ + [ + 2, + 5 + ] + ], + "elements": [ + "green" + ], + "element": "green" }, { "level": 20, + "chapter": 2, + "name": "Trail Mix", "cols": 6, "rows": 6, "walls": [ [ - 5, - 2 - ], - [ - 4, - 1 + 1, + 4 ], [ 3, 4 ], + [ + 3, + 3 + ], + [ + 5, + 2 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 4 + ], + [ + 2, + 5 + ], + [ + 3, + 5 + ] + ], + "monsters": [ [ 5, 5 @@ -1026,189 +1474,200 @@ [ 2, 0 - ] - ], - "spikes": [], - "targets": [ - [ - 2, - 1 ], [ - 2, - 2 - ], - [ - 2, - 3 - ] - ], - "monsters": [ - [ - 2, - 1 - ], - [ - 0, + 3, 5 - ], - [ - 4, - 3 - ], - [ - 1, - 2 ] ], - "par": 4 + "par": 4, + "green": [ + [ + 3, + 5 + ] + ], + "elements": [ + "green" + ], + "element": "green" }, { "level": 21, + "chapter": 2, + "name": "Slime Time", "cols": 6, "rows": 6, "walls": [ - [ - 3, - 3 - ], [ 0, - 3 - ], - [ - 0, - 5 - ], - [ - 3, - 2 + 4 ], [ 4, - 3 + 4 + ], + [ + 3, + 0 + ], + [ + 5, + 4 ] ], "spikes": [], "targets": [ [ - 4, - 0 - ], - [ - 5, + 2, 1 ], [ - 4, + 3, + 1 + ], + [ + 2, 2 ] ], "monsters": [ - [ - 5, - 1 - ], - [ - 2, - 0 - ], [ 0, - 1 + 2 + ], + [ + 1, + 5 ], [ 4, - 2 + 5 ] ], - "par": 4 + "par": 4, + "green": [ + [ + 1, + 5 + ] + ], + "elements": [ + "green" + ], + "element": "green" }, { "level": 22, + "chapter": 2, + "name": "Gooey Detour", "cols": 6, "rows": 6, "walls": [ [ - 5, + 2, 4 ], [ - 4, - 3 - ], - [ - 2, - 1 - ], - [ - 4, - 5 + 5, + 2 ], [ 0, - 0 + 3 + ], + [ + 1, + 1 ] ], "spikes": [], "targets": [ [ - 2, - 2 + 1, + 0 ], [ 2, - 3 + 0 ], [ - 3, - 3 + 2, + 1 ] ], "monsters": [ [ - 0, - 5 - ], - [ - 3, - 5 - ], - [ - 2, - 2 - ], - [ - 4, + 1, 0 + ], + [ + 1, + 5 + ], + [ + 5, + 1 ] ], - "par": 4 + "par": 6, + "green": [ + [ + 5, + 1 + ] + ], + "elements": [ + "green" + ], + "element": "green" }, { "level": 23, - "cols": 6, - "rows": 6, + "chapter": 2, + "name": "Group Think", + "cols": 5, + "rows": 5, "walls": [ [ - 5, + 2, + 3 + ], + [ + 1, 1 ], [ - 0, + 4, 4 ], + [ + 1, + 0 + ], + [ + 4, + 1 + ], + [ + 3, + 0 + ], + [ + 0, + 3 + ], [ 1, 4 ], [ - 2, - 2 + 4, + 3 ], [ 3, - 0 + 4 ] ], "spikes": [], @@ -1219,57 +1678,773 @@ ], [ 2, - 3 + 2 ], [ 3, - 3 + 2 ] ], "monsters": [ - [ - 5, - 3 - ], - [ - 1, - 2 - ], [ 3, 1 ], [ - 5, - 5 + 1, + 3 + ], + [ + 3, + 3 ] ], - "par": 5 + "par": 2, + "hypno": [ + [ + 3, + 3 + ], + [ + 1, + 3 + ] + ], + "elements": [ + "hypno" + ], + "element": "hypno", + "tip": "Hypno goos share one mind — flick one and they all go.", + "teaching": true }, { "level": 24, + "chapter": 2, + "name": "All Together Now", "cols": 6, "rows": 6, "walls": [ [ 5, + 5 + ], + [ + 3, + 3 + ], + [ + 2, 1 ], + [ + 0, + 3 + ], + [ + 4, + 1 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 2 + ], + [ + 4, + 2 + ], + [ + 2, + 3 + ] + ], + "monsters": [ + [ + 2, + 5 + ], + [ + 2, + 2 + ], + [ + 4, + 5 + ], + [ + 4, + 3 + ] + ], + "par": 4, + "hypno": [ + [ + 2, + 5 + ], + [ + 2, + 2 + ] + ], + "elements": [ + "hypno" + ], + "element": "hypno" + }, + { + "level": 25, + "chapter": 2, + "name": "Hive Mind", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 1 + ], + [ + 3, + 1 + ], + [ + 4, + 4 + ], + [ + 0, + 2 + ], + [ + 3, + 5 + ] + ], + "spikes": [], + "targets": [ + [ + 4, + 2 + ], + [ + 5, + 3 + ], + [ + 5, + 4 + ] + ], + "monsters": [ + [ + 0, + 4 + ], + [ + 5, + 2 + ], + [ + 5, + 4 + ], + [ + 4, + 1 + ] + ], + "par": 4, + "hypno": [ + [ + 4, + 1 + ], + [ + 5, + 4 + ] + ], + "elements": [ + "hypno" + ], + "element": "hypno" + }, + { + "level": 26, + "chapter": 2, + "name": "Synchronised Slipping", + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 0 + ], + [ + 1, + 5 + ], + [ + 2, + 0 + ], + [ + 5, + 4 + ], + [ + 5, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 1 + ], + [ + 4, + 1 + ], + [ + 5, + 1 + ] + ], + "monsters": [ + [ + 1, + 4 + ], + [ + 4, + 4 + ], + [ + 5, + 2 + ], + [ + 0, + 0 + ] + ], + "par": 5, + "hypno": [ + [ + 5, + 2 + ], + [ + 4, + 4 + ] + ], + "elements": [ + "hypno" + ], + "element": "hypno" + }, + { + "level": 27, + "chapter": 2, + "name": "Follow the Leader", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 0 + ], + [ + 4, + 4 + ], + [ + 5, + 4 + ], + [ + 2, + 0 + ], + [ + 0, + 5 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 5 + ] + ], + "monsters": [ + [ + 5, + 5 + ], + [ + 5, + 2 + ], + [ + 3, + 5 + ], + [ + 1, + 1 + ] + ], + "par": 5, + "hypno": [ + [ + 5, + 2 + ], + [ + 1, + 1 + ] + ], + "elements": [ + "hypno" + ], + "element": "hypno" + }, + { + "level": 28, + "chapter": 2, + "name": "Slime and Punishment", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 5 + ], [ 5, 5 ], + [ + 3, + 1 + ], + [ + 0, + 1 + ], [ 3, 0 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 2 + ], + [ + 5, + 2 ], [ 4, + 3 + ] + ], + "monsters": [ + [ + 2, + 2 + ], + [ + 4, + 5 + ], + [ + 0, + 0 + ], + [ + 5, + 2 + ] + ], + "par": 7, + "green": [ + [ + 2, + 2 + ] + ], + "hypno": [ + [ + 4, + 5 + ], + [ + 0, + 0 + ] + ], + "elements": [ + "green", + "hypno" + ], + "element": "green" + }, + { + "level": 29, + "chapter": 2, + "name": "One Mind, Many Blobs", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 0 + ], + [ + 0, + 4 + ], + [ + 3, + 3 + ], + [ + 1, + 3 + ], + [ + 4, + 5 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 0 + ], + [ + 2, + 2 + ], + [ + 2, + 3 + ] + ], + "monsters": [ + [ + 2, + 3 + ], + [ + 1, 0 ], [ 4, 3 + ], + [ + 4, + 1 + ] + ], + "par": 8, + "green": [ + [ + 4, + 3 + ] + ], + "hypno": [ + [ + 1, + 0 + ], + [ + 2, + 3 + ] + ], + "elements": [ + "green", + "hypno" + ], + "element": "green" + }, + { + "level": 30, + "chapter": 2, + "name": "Crumb Trail", + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, + 2 + ], + [ + 3, + 4 + ], + [ + 0, + 4 + ], + [ + 3, + 5 + ], + [ + 4, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 0 + ], + [ + 3, + 1 + ], + [ + 4, + 1 + ] + ], + "monsters": [ + [ + 0, + 3 + ], + [ + 5, + 5 + ], + [ + 1, + 5 + ], + [ + 2, + 0 + ] + ], + "par": 9, + "green": [ + [ + 0, + 3 + ] + ], + "hypno": [ + [ + 2, + 0 + ], + [ + 1, + 5 + ] + ], + "elements": [ + "green", + "hypno" + ], + "element": "green" + }, + { + "level": 31, + "chapter": 3, + "name": "Shelf Assembly", + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, + 1 + ], + [ + 0, + 2 + ], + [ + 5, + 0 + ], + [ + 2, + 2 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 3 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ] + ], + "monsters": [ + [ + 1, + 1 + ], + [ + 3, + 5 + ], + [ + 3, + 3 + ] + ], + "par": 3 + }, + { + "level": 32, + "chapter": 3, + "name": "Boing", + "cols": 5, + "rows": 5, + "walls": [ + [ + 2, + 4 + ], + [ + 2, + 0 + ], + [ + 4, + 2 + ], + [ + 0, + 1 + ], + [ + 0, + 3 + ], + [ + 0, + 2 + ], + [ + 1, + 4 + ], + [ + 1, + 0 + ], + [ + 3, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 1 + ], + [ + 1, + 2 + ], + [ + 1, + 3 + ] + ], + "monsters": [ + [ + 2, + 3 + ], + [ + 1, + 2 + ], + [ + 3, + 2 + ] + ], + "par": 3, + "springs": [ + [ + 3, + 3 + ], + [ + 2, + 2 + ] + ], + "elements": [ + "springs" + ], + "element": "springs", + "tip": "Springs bounce you straight back — and you keep going.", + "teaching": true + }, + { + "level": 33, + "chapter": 3, + "name": "Bounce Back", + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, + 2 + ], + [ + 0, + 0 + ], + [ + 5, + 3 + ], + [ + 3, + 0 ] ], "spikes": [], @@ -1288,1211 +2463,1207 @@ ] ], "monsters": [ - [ - 2, - 5 - ], - [ - 4, - 1 - ], [ 1, 4 ], - [ - 0, - 0 - ] - ], - "par": 6 - }, - { - "level": 25, - "cols": 7, - "rows": 7, - "walls": [ - [ - 3, - 2 - ], - [ - 5, - 0 - ], - [ - 2, - 4 - ], - [ - 0, - 5 - ], - [ - 5, - 2 - ], - [ - 5, - 6 - ] - ], - "spikes": [ - [ - 0, - 3 - ] - ], - "targets": [ - [ - 3, - 0 - ], - [ - 4, - 1 - ], - [ - 4, - 2 - ] - ], - "monsters": [ - [ - 2, - 5 - ], - [ - 4, - 4 - ], - [ - 3, - 0 - ], - [ - 0, - 0 - ] - ], - "par": 5 - }, - { - "level": 26, - "cols": 7, - "rows": 7, - "walls": [ - [ - 1, - 3 - ], - [ - 5, - 6 - ], - [ - 2, - 2 - ], - [ - 4, - 1 - ], - [ - 2, - 5 - ], - [ - 6, - 1 - ] - ], - "spikes": [ - [ - 6, - 6 - ] - ], - "targets": [ - [ - 3, - 0 - ], - [ - 3, - 2 - ], - [ - 4, - 2 - ] - ], - "monsters": [ - [ - 5, - 4 - ], [ 1, 0 ], - [ - 1, - 2 - ], - [ - 4, - 2 - ] - ], - "par": 5 - }, - { - "level": 27, - "cols": 7, - "rows": 7, - "walls": [ - [ - 0, - 2 - ], - [ - 1, - 1 - ], - [ - 3, - 6 - ], - [ - 6, - 0 - ], - [ - 3, - 2 - ], - [ - 0, - 3 - ] - ], - "spikes": [ - [ - 1, - 4 - ] - ], - "targets": [ - [ - 2, - 4 - ], - [ - 2, - 5 - ], - [ - 2, - 6 - ] - ], - "monsters": [ - [ - 4, - 1 - ], - [ - 2, - 6 - ], - [ - 0, - 5 - ], - [ - 6, - 1 - ] - ], - "par": 5 - }, - { - "level": 28, - "cols": 7, - "rows": 7, - "walls": [ - [ - 3, - 1 - ], - [ - 1, - 4 - ], - [ - 6, - 3 - ], - [ - 3, - 2 - ], - [ - 4, - 0 - ], - [ - 5, - 3 - ] - ], - "spikes": [ - [ - 1, - 2 - ] - ], - "targets": [ - [ - 3, - 3 - ], [ 3, 4 - ], - [ - 3, - 5 ] ], - "monsters": [ - [ - 4, - 5 - ], - [ - 2, - 4 - ], - [ - 5, - 0 - ], - [ - 6, - 1 - ] - ], - "par": 5 - }, - { - "level": 29, - "cols": 7, - "rows": 7, - "walls": [ - [ - 5, - 5 - ], - [ - 3, - 0 - ], - [ - 4, - 0 - ], - [ - 5, - 6 - ], - [ - 1, - 2 - ], - [ - 3, - 2 - ] - ], - "spikes": [ - [ - 3, - 5 - ] - ], - "targets": [ - [ - 4, - 3 - ], - [ - 4, - 4 - ], - [ - 5, - 4 - ] - ], - "monsters": [ + "par": 3, + "springs": [ [ 1, 5 ], - [ - 3, - 4 - ], - [ - 6, - 0 - ], - [ - 5, - 4 - ] - ], - "par": 5 - }, - { - "level": 30, - "cols": 7, - "rows": 7, - "walls": [ - [ - 5, - 5 - ], - [ - 0, - 6 - ], - [ - 6, - 6 - ], - [ - 0, - 3 - ], - [ - 2, - 2 - ], - [ - 5, - 3 - ] - ], - "spikes": [ - [ - 5, - 2 - ] - ], - "targets": [ - [ - 3, - 0 - ], - [ - 3, - 2 - ], - [ - 3, - 3 - ] - ], - "monsters": [ - [ - 3, - 0 - ], - [ - 4, - 1 - ], - [ - 5, - 6 - ], - [ - 1, - 4 - ] - ], - "par": 5 - }, - { - "level": 31, - "cols": 7, - "rows": 7, - "walls": [ - [ - 3, - 0 - ], - [ - 3, - 1 - ], - [ - 5, - 4 - ], - [ - 6, - 5 - ], - [ - 2, - 3 - ], - [ - 0, - 0 - ] - ], - "spikes": [ - [ - 4, - 1 - ] - ], - "targets": [ - [ - 3, - 3 - ], - [ - 5, - 3 - ], - [ - 4, - 4 - ] - ], - "monsters": [ - [ - 5, - 2 - ], [ 1, 1 - ], - [ - 2, - 4 - ], - [ - 0, - 3 ] ], - "par": 6 - }, - { - "level": 32, - "cols": 7, - "rows": 7, - "walls": [ - [ - 1, - 6 - ], - [ - 4, - 2 - ], - [ - 0, - 5 - ], - [ - 0, - 2 - ], - [ - 6, - 4 - ], - [ - 0, - 1 - ] + "elements": [ + "springs" ], - "spikes": [ - [ - 3, - 0 - ] - ], - "targets": [ - [ - 1, - 2 - ], - [ - 2, - 3 - ], - [ - 1, - 4 - ] - ], - "monsters": [ - [ - 6, - 5 - ], - [ - 5, - 6 - ], - [ - 6, - 0 - ], - [ - 2, - 2 - ] - ], - "par": 7 - }, - { - "level": 33, - "cols": 7, - "rows": 7, - "walls": [ - [ - 1, - 6 - ], - [ - 5, - 3 - ], - [ - 1, - 2 - ], - [ - 0, - 6 - ], - [ - 4, - 0 - ], - [ - 0, - 4 - ], - [ - 2, - 1 - ] - ], - "spikes": [ - [ - 3, - 1 - ], - [ - 2, - 0 - ] - ], - "targets": [ - [ - 2, - 2 - ], - [ - 4, - 2 - ], - [ - 2, - 3 - ] - ], - "monsters": [ - [ - 6, - 0 - ], - [ - 0, - 2 - ], - [ - 4, - 5 - ], - [ - 2, - 2 - ], - [ - 2, - 6 - ] - ], - "par": 7 + "element": "springs" }, { "level": 34, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Double Trouble", + "cols": 6, + "rows": 6, "walls": [ [ 1, - 2 - ], - [ - 4, - 2 - ], - [ - 3, 5 ], - [ - 1, - 0 - ], [ 4, 0 ], [ 0, - 2 + 5 ], [ - 6, - 4 - ] - ], - "spikes": [ - [ - 3, - 6 - ], - [ - 1, - 6 + 2, + 5 ] ], + "spikes": [], "targets": [ [ - 3, - 2 + 1, + 1 + ], + [ + 2, + 1 ], [ 3, - 3 - ], - [ - 5, - 3 + 1 ] ], "monsters": [ [ - 5, - 1 - ], - [ - 4, - 6 + 2, + 2 ], [ 3, - 0 + 1 ], [ - 6, - 0 - ], - [ - 2, + 0, 2 ] ], - "par": 7 + "par": 3, + "springs": [ + [ + 0, + 0 + ], + [ + 1, + 4 + ] + ], + "elements": [ + "springs" + ], + "element": "springs" }, { "level": 35, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Springboard", + "cols": 6, + "rows": 6, "walls": [ [ - 5, - 2 + 4, + 4 ], [ - 5, - 3 + 0, + 5 ], [ 2, - 3 - ], - [ - 4, - 6 - ], - [ - 5, - 1 + 5 ], [ 3, 1 + ] + ], + "spikes": [], + "targets": [ + [ + 0, + 2 ], [ 1, - 5 - ] - ], - "spikes": [ - [ - 1, - 1 + 2 ], [ 2, - 6 - ] - ], - "targets": [ - [ - 4, - 0 - ], - [ - 4, - 1 - ], - [ - 4, 2 ] ], "monsters": [ - [ - 6, - 6 - ], [ 2, - 5 + 0 ], [ - 3, - 3 + 0, + 2 ], [ - 4, + 0, + 4 + ] + ], + "par": 4, + "springs": [ + [ + 0, 1 ], [ - 5, - 0 + 0, + 3 ] ], - "par": 7 + "elements": [ + "springs" + ], + "element": "springs" }, { "level": 36, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Return to Sender", + "cols": 6, + "rows": 6, "walls": [ - [ - 6, - 4 - ], - [ - 4, - 0 - ], - [ - 6, - 6 - ], [ 1, - 6 - ], - [ - 0, - 1 - ], - [ - 5, - 3 + 0 ], [ 2, 0 - ] - ], - "spikes": [ - [ - 3, - 0 - ], - [ - 4, - 4 - ] - ], - "targets": [ - [ - 5, - 4 ], [ 5, 5 ], [ - 5, - 6 + 1, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 4, + 1 + ], + [ + 3, + 2 + ], + [ + 4, + 2 ] ], "monsters": [ [ - 2, - 3 + 1, + 5 ], [ 2, - 6 + 4 ], [ - 6, + 3, 2 - ], + ] + ], + "par": 4, + "springs": [ [ 5, 1 ], [ - 5, - 4 + 0, + 1 ] ], - "par": 7 + "elements": [ + "springs" + ], + "element": "springs" }, { "level": 37, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Down the Hatch", + "cols": 5, + "rows": 5, "walls": [ [ - 0, - 5 - ], - [ - 1, - 6 - ], - [ - 5, - 6 - ], - [ - 0, + 2, 2 ], [ - 1, - 3 - ], - [ - 6, - 5 - ], - [ - 2, - 4 - ] - ], - "spikes": [ - [ - 1, + 4, 1 ], [ - 6, - 6 - ] - ], - "targets": [ + 3, + 1 + ], [ - 5, + 0, + 3 + ], + [ + 0, 2 ], + [ + 1, + 4 + ], [ 4, 3 ], [ - 5, + 2, 4 + ], + [ + 2, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 2 + ], + [ + 2, + 3 + ], + [ + 3, + 3 ] ], "monsters": [ [ - 4, - 0 + 1, + 2 ], [ - 0, + 2, 3 ], [ 2, - 0 - ], + 1 + ] + ], + "par": 3, + "tunnels": [ [ - 5, - 4 - ], - [ - 5, + 1, + 1, + 3, 2 ] ], - "par": 8 + "elements": [ + "tunnels" + ], + "element": "tunnels", + "tip": "Tunnels carry a monster across the table, still sliding.", + "teaching": true }, { "level": 38, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Wormhole", + "cols": 6, + "rows": 6, "walls": [ [ - 5, - 5 + 1, + 0 ], + [ + 0, + 4 + ], + [ + 2, + 1 + ], + [ + 5, + 1 + ] + ], + "spikes": [], + "targets": [ [ 4, 1 ], [ - 6, + 4, 2 ], - [ - 2, - 4 - ], - [ - 6, - 3 - ], [ 5, - 0 - ], - [ - 2, - 3 - ] - ], - "spikes": [ - [ - 2, - 6 - ], - [ - 5, - 1 - ] - ], - "targets": [ - [ - 0, - 0 - ], - [ - 1, - 1 - ], - [ - 2, 2 ] ], "monsters": [ [ - 1, - 2 - ], - [ - 6, - 5 - ], - [ - 4, + 0, 3 ], [ 3, - 2 + 1 ], [ + 4, + 5 + ] + ], + "par": 3, + "tunnels": [ + [ + 5, + 5, 0, 0 ] ], - "par": 8 + "elements": [ + "tunnels" + ], + "element": "tunnels" }, { "level": 39, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "Pantry Portal", + "cols": 6, + "rows": 6, "walls": [ - [ - 0, - 0 - ], [ 2, - 2 + 5 ], [ - 0, - 1 - ], - [ - 6, + 5, 4 ], [ 1, - 5 + 0 ], - [ - 5, - 1 - ], - [ - 4, - 5 - ] - ], - "spikes": [ - [ - 0, - 3 - ], - [ - 0, - 2 - ] - ], - "targets": [ [ 4, 4 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 4 ], [ - 5, + 3, + 4 + ], + [ + 3, 5 - ], - [ - 5, - 6 ] ], "monsters": [ - [ - 5, - 6 - ], - [ - 2, - 0 - ], - [ - 6, - 1 - ], - [ - 6, - 3 - ], [ 1, - 6 + 4 + ], + [ + 4, + 5 + ], + [ + 5, + 3 ] ], - "par": 9 + "par": 4, + "tunnels": [ + [ + 2, + 0, + 2, + 3 + ] + ], + "elements": [ + "tunnels" + ], + "element": "tunnels" }, { "level": 40, - "cols": 7, - "rows": 7, + "chapter": 3, + "name": "In One End", + "cols": 6, + "rows": 6, "walls": [ [ - 0, + 5, + 0 + ], + [ + 5, + 5 + ], + [ + 4, + 2 + ], + [ + 3, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 4, + 4 + ], + [ + 5, + 4 + ], + [ + 4, + 5 + ] + ], + "monsters": [ + [ + 3, + 3 + ], + [ + 5, 1 ], [ 1, - 3 - ], + 1 + ] + ], + "par": 4, + "tunnels": [ [ - 6, - 6 + 4, + 5, + 3, + 1 + ] + ], + "elements": [ + "tunnels" + ], + "element": "tunnels" + }, + { + "level": 41, + "chapter": 3, + "name": "Shortcut", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 3 ], [ 5, 3 ], + [ + 0, + 5 + ], + [ + 1, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 0, + 3 + ], + [ + 0, + 4 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 5, + 2 + ], + [ + 5, + 0 + ], + [ + 2, + 5 + ] + ], + "par": 5, + "tunnels": [ + [ + 0, + 2, + 2, + 2 + ] + ], + "elements": [ + "tunnels" + ], + "element": "tunnels" + }, + { + "level": 42, + "chapter": 3, + "name": "Bounce and Beyond", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 0 + ], + [ + 3, + 0 + ], + [ + 4, + 2 + ], + [ + 5, + 0 + ], + [ + 5, + 1 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 3 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ] + ], + "monsters": [ + [ + 2, + 4 + ], [ 3, 2 ], + [ + 1, + 0 + ], + [ + 3, + 5 + ] + ], + "par": 4, + "springs": [ + [ + 4, + 3 + ], + [ + 2, + 5 + ] + ], + "tunnels": [ + [ + 3, + 3, + 5, + 4 + ] + ], + "elements": [ + "springs", + "tunnels" + ], + "element": "springs" + }, + { + "level": 43, + "chapter": 3, + "name": "Jar Jam", + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 1 + ], + [ + 5, + 3 + ], + [ + 4, + 0 + ], + [ + 1, + 4 + ], + [ + 0, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 1 + ], + [ + 3, + 2 + ], + [ + 3, + 3 + ] + ], + "monsters": [ + [ + 2, + 2 + ], + [ + 3, + 3 + ], [ 0, 3 ], [ - 4, - 6 + 5, + 2 ] ], - "spikes": [ + "par": 4, + "ice": [ + [ + 5, + 5 + ], + [ + 3, + 4 + ], + [ + 3, + 2 + ] + ], + "springs": [ + [ + 2, + 3 + ], [ 1, - 6 + 5 + ] + ], + "elements": [ + "springs", + "ice" + ], + "element": "springs" + }, + { + "level": 44, + "chapter": 3, + "name": "Spice Rack", + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 5 + ], + [ + 5, + 3 ], [ 4, + 5 + ], + [ + 0, + 1 + ], + [ + 4, + 4 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 1, + 2 + ] + ], + "monsters": [ + [ + 1, + 1 + ], + [ + 3, + 2 + ], + [ + 1, + 3 + ], + [ + 2, 0 ] ], + "par": 4, + "green": [ + [ + 1, + 1 + ] + ], + "tunnels": [ + [ + 1, + 2, + 5, + 2 + ] + ], + "elements": [ + "tunnels", + "green" + ], + "element": "tunnels" + }, + { + "level": 45, + "chapter": 3, + "name": "Pantry Panic", + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 0 + ], + [ + 2, + 5 + ], + [ + 3, + 4 + ], + [ + 1, + 0 + ], + [ + 1, + 2 + ] + ], + "spikes": [], "targets": [ + [ + 4, + 2 + ], + [ + 4, + 3 + ], + [ + 4, + 4 + ] + ], + "monsters": [ + [ + 1, + 3 + ], + [ + 5, + 4 + ], + [ + 0, + 5 + ], + [ + 0, + 1 + ] + ], + "par": 7, + "springs": [ + [ + 0, + 3 + ], + [ + 5, + 2 + ] + ], + "tunnels": [ + [ + 3, + 0, + 0, + 4 + ] + ], + "elements": [ + "springs", + "tunnels" + ], + "element": "springs" + }, + { + "level": 46, + "chapter": 4, + "name": "Table Manners", + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 0 + ], + [ + 1, + 2 + ], + [ + 3, + 5 + ], + [ + 2, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 4, + 0 + ], + [ + 4, + 1 + ], + [ + 5, + 1 + ] + ], + "monsters": [ + [ + 2, + 0 + ], + [ + 0, + 1 + ], + [ + 5, + 4 + ] + ], + "par": 3 + }, + { + "level": 47, + "chapter": 4, + "name": "Press Here", + "cols": 5, + "rows": 5, + "walls": [ + [ + 4, + 3 + ], + [ + 1, + 2 + ], + [ + 3, + 3 + ], + [ + 0, + 1 + ], + [ + 0, + 3 + ], + [ + 1, + 0 + ], + [ + 4, + 1 + ], + [ + 3, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 1 + ], [ 3, 1 ], [ - 4, + 2, 2 + ] + ], + "monsters": [ + [ + 1, + 3 + ], + [ + 1, + 1 + ], + [ + 3, + 1 + ] + ], + "par": 3, + "bricks": [ + [ + 1, + 4, + 1 + ], + [ + 2, + 1, + 1 + ] + ], + "buttons": [ + [ + 2, + 3, + 1 + ] + ], + "elements": [ + "buttons" + ], + "element": "buttons", + "tip": "Roll over the button to raise or lower the bricks.", + "teaching": true + }, + { + "level": 48, + "chapter": 4, + "name": "Trapdoor", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 1 + ], + [ + 0, + 4 + ], + [ + 3, + 1 + ], + [ + 3, + 5 + ] + ], + "spikes": [], + "targets": [ + [ + 0, + 3 + ], + [ + 1, + 3 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 5, + 4 + ], + [ + 0, + 0 + ], + [ + 5, + 2 + ] + ], + "par": 3, + "bricks": [ + [ + 5, + 5, + 1 + ], + [ + 0, + 2, + 1 + ] + ], + "buttons": [ + [ + 3, + 4, + 1 + ] + ], + "elements": [ + "buttons" + ], + "element": "buttons" + }, + { + "level": 49, + "chapter": 4, + "name": "Now You Don't", + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, + 5 + ], + [ + 2, + 3 + ], + [ + 2, + 2 + ], + [ + 1, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 2 + ], + [ + 3, + 3 ], [ 4, @@ -2500,28 +3671,2617 @@ ] ], "monsters": [ + [ + 4, + 3 + ], + [ + 0, + 4 + ], [ 5, + 2 + ] + ], + "par": 4, + "bricks": [ + [ + 2, + 0, + 1 + ], + [ + 3, + 1, + 1 + ] + ], + "buttons": [ + [ + 1, + 4, + 1 + ] + ], + "elements": [ + "buttons" + ], + "element": "buttons" + }, + { + "level": 50, + "chapter": 4, + "name": "Push and Pull", + "cols": 6, + "rows": 6, + "walls": [ + [ + 4, 0 ], [ 3, 4 ], + [ + 0, + 5 + ], + [ + 0, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 5 + ] + ], + "monsters": [ + [ + 5, + 1 + ], + [ + 1, + 0 + ], + [ + 1, + 5 + ] + ], + "par": 4, + "bricks": [ + [ + 2, + 1, + 1 + ], + [ + 0, + 4, + 1 + ] + ], + "buttons": [ + [ + 2, + 0, + 1 + ] + ], + "elements": [ + "buttons" + ], + "element": "buttons" + }, + { + "level": 51, + "chapter": 4, + "name": "Brickwork", + "cols": 6, + "rows": 6, + "walls": [ + [ + 2, + 4 + ], + [ + 2, + 2 + ], + [ + 4, + 0 + ], + [ + 2, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 0 + ], + [ + 1, + 1 + ], + [ + 2, + 1 + ] + ], + "monsters": [ + [ + 0, + 4 + ], + [ + 0, + 0 + ], + [ + 4, + 4 + ] + ], + "par": 5, + "bricks": [ + [ + 0, + 3, + 1 + ], + [ + 4, + 3, + 1 + ] + ], + "buttons": [ + [ + 1, + 1, + 1 + ] + ], + "elements": [ + "buttons" + ], + "element": "buttons" + }, + { + "level": 52, + "chapter": 4, + "name": "Heavy Lifting", + "cols": 5, + "rows": 5, + "walls": [ + [ + 1, + 0 + ], + [ + 0, + 0 + ], + [ + 2, + 1 + ], + [ + 2, + 4 + ], + [ + 4, + 3 + ], + [ + 0, + 3 + ], + [ + 3, + 4 + ], + [ + 0, + 2 + ], + [ + 4, + 1 + ], + [ + 3, + 0 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 2 + ], + [ + 3, + 2 + ], + [ + 2, + 3 + ] + ], + "monsters": [ + [ + 3, + 3 + ], + [ + 2, + 2 + ], + [ + 3, + 1 + ] + ], + "par": 3, + "crates": [ + [ + 3, + 2 + ], + [ + 0, + 4 + ] + ], + "powerlifters": [ + [ + 3, + 1 + ] + ], + "elements": [ + "powerlifters" + ], + "element": "powerlifters", + "tip": "Powerlifters shove crates around. Everyone else just stops.", + "teaching": true + }, + { + "level": 53, + "chapter": 4, + "name": "Crate Expectations", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 0 + ], + [ + 2, + 5 + ], + [ + 0, + 1 + ], + [ + 2, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 3 + ], + [ + 3, + 4 + ], + [ + 4, + 4 + ] + ], + "monsters": [ + [ + 3, + 2 + ], + [ + 4, + 4 + ], + [ + 0, + 5 + ] + ], + "par": 3, + "crates": [ + [ + 0, + 4 + ], + [ + 0, + 2 + ] + ], + "powerlifters": [ + [ + 0, + 5 + ] + ], + "elements": [ + "powerlifters" + ], + "element": "powerlifters" + }, + { + "level": 54, + "chapter": 4, + "name": "Muscle Memory", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 5 + ], + [ + 4, + 0 + ], + [ + 5, + 5 + ], + [ + 2, + 3 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 1 + ], + [ + 2, + 2 + ], + [ + 3, + 2 + ] + ], + "monsters": [ + [ + 4, + 5 + ], + [ + 2, + 0 + ], + [ + 3, + 1 + ] + ], + "par": 3, + "crates": [ + [ + 1, + 2 + ], + [ + 4, + 2 + ] + ], + "powerlifters": [ + [ + 4, + 5 + ] + ], + "elements": [ + "powerlifters" + ], + "element": "powerlifters" + }, + { + "level": 55, + "chapter": 4, + "name": "Shove Off", + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 2 + ], + [ + 3, + 4 + ], + [ + 0, + 1 + ], + [ + 0, + 5 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 4 + ], + [ + 2, + 4 + ], + [ + 2, + 5 + ] + ], + "monsters": [ + [ + 2, + 3 + ], + [ + 5, + 5 + ], + [ + 1, + 2 + ] + ], + "par": 3, + "crates": [ + [ + 3, + 5 + ], + [ + 3, + 0 + ] + ], + "powerlifters": [ + [ + 5, + 5 + ] + ], + "elements": [ + "powerlifters" + ], + "element": "powerlifters" + }, + { + "level": 56, + "chapter": 4, + "name": "Furniture Shuffle", + "cols": 6, + "rows": 6, + "walls": [ + [ + 1, + 0 + ], + [ + 5, + 4 + ], + [ + 3, + 2 + ], + [ + 1, + 4 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 3 + ], + [ + 2, + 3 + ], + [ + 2, + 4 + ] + ], + "monsters": [ + [ + 3, + 1 + ], + [ + 4, + 4 + ], + [ + 1, + 3 + ] + ], + "par": 4, + "crates": [ + [ + 5, + 3 + ], + [ + 4, + 0 + ] + ], + "powerlifters": [ + [ + 4, + 4 + ] + ], + "elements": [ + "powerlifters" + ], + "element": "powerlifters" + }, + { + "level": 57, + "chapter": 4, + "name": "Dinner Service", + "cols": 6, + "rows": 6, + "walls": [ + [ + 3, + 1 + ], + [ + 0, + 0 + ], + [ + 5, + 1 + ], + [ + 4, + 5 + ], + [ + 3, + 4 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 0 + ], + [ + 2, + 2 + ], + [ + 3, + 2 + ] + ], + "monsters": [ + [ + 2, + 2 + ], + [ + 3, + 0 + ], + [ + 5, + 4 + ], + [ + 0, + 3 + ] + ], + "par": 5, + "bricks": [ + [ + 1, + 0, + 1 + ], + [ + 1, + 5, + 1 + ] + ], + "buttons": [ + [ + 0, + 1, + 1 + ] + ], + "crates": [ + [ + 0, + 5 + ], + [ + 2, + 0 + ] + ], + "powerlifters": [ + [ + 3, + 0 + ] + ], + "elements": [ + "buttons", + "powerlifters" + ], + "element": "buttons" + }, + { + "level": 58, + "chapter": 4, + "name": "Table for One", + "cols": 6, + "rows": 6, + "walls": [ + [ + 5, + 0 + ], + [ + 1, + 5 + ], + [ + 5, + 5 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "spikes": [], + "targets": [ + [ + 1, + 2 + ], + [ + 0, + 3 + ], + [ + 1, + 3 + ] + ], + "monsters": [ + [ + 0, + 0 + ], + [ + 5, + 1 + ], + [ + 0, + 3 + ], + [ + 4, + 5 + ] + ], + "par": 6, + "bricks": [ + [ + 5, + 4, + 1 + ], + [ + 2, + 3, + 1 + ] + ], + "buttons": [ + [ + 3, + 0, + 1 + ] + ], + "crates": [ + [ + 4, + 2 + ], + [ + 3, + 1 + ] + ], + "powerlifters": [ + [ + 0, + 0 + ] + ], + "elements": [ + "buttons", + "powerlifters" + ], + "element": "buttons" + }, + { + "level": 59, + "chapter": 4, + "name": "Centrepiece", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 1 + ], + [ + 0, + 5 + ], + [ + 2, + 0 + ], + [ + 3, + 0 + ], + [ + 2, + 1 + ] + ], + "spikes": [], + "targets": [ + [ + 2, + 2 + ], + [ + 2, + 3 + ], + [ + 3, + 3 + ] + ], + "monsters": [ + [ + 3, + 5 + ], + [ + 0, + 0 + ], + [ + 4, + 1 + ], + [ + 5, + 5 + ] + ], + "par": 7, + "springs": [ + [ + 0, + 4 + ], + [ + 4, + 4 + ] + ], + "crates": [ + [ + 1, + 5 + ], + [ + 1, + 1 + ] + ], + "powerlifters": [ + [ + 0, + 0 + ] + ], + "elements": [ + "powerlifters", + "springs" + ], + "element": "powerlifters" + }, + { + "level": 60, + "chapter": 4, + "name": "Clean Plate", + "cols": 6, + "rows": 6, + "walls": [ + [ + 0, + 5 + ], + [ + 3, + 4 + ], + [ + 3, + 2 + ], + [ + 1, + 2 + ], + [ + 2, + 4 + ] + ], + "spikes": [], + "targets": [ + [ + 3, + 3 + ], + [ + 4, + 4 + ], + [ + 4, + 5 + ] + ], + "monsters": [ + [ + 2, + 0 + ], + [ + 4, + 5 + ], + [ + 4, + 0 + ], + [ + 0, + 2 + ] + ], + "par": 8, + "sleepers": [ + [ + 4, + 0 + ] + ], + "bricks": [ + [ + 5, + 3, + 1 + ], + [ + 3, + 0, + 1 + ] + ], + "buttons": [ + [ + 1, + 3, + 1 + ] + ], + "elements": [ + "buttons", + "sleepers" + ], + "element": "buttons" + }, + { + "level": 61, + "chapter": 5, + "name": "Night Shift", + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 4 + ], + [ + 4, + 4 + ], + [ + 5, + 3 + ], + [ + 6, + 3 + ], + [ + 0, + 6 + ], + [ + 2, + 1 + ] + ], + "spikes": [ + [ + 1, + 1 + ] + ], + "targets": [ + [ + 3, + 0 + ], + [ + 3, + 1 + ], + [ + 4, + 1 + ] + ], + "monsters": [ + [ + 2, + 3 + ], + [ + 4, + 0 + ], + [ + 2, + 5 + ], + [ + 4, + 3 + ] + ], + "par": 4, + "sleepers": [ + [ + 2, + 5 + ] + ], + "springs": [ + [ + 3, + 4 + ], + [ + 5, + 5 + ] + ], + "elements": [ + "sleepers", + "springs" + ], + "element": "sleepers" + }, + { + "level": 62, + "chapter": 5, + "name": "Leftovers", + "cols": 7, + "rows": 7, + "walls": [ + [ + 3, + 6 + ], + [ + 6, + 6 + ], + [ + 6, + 2 + ], + [ + 0, + 2 + ], + [ + 1, + 2 + ], + [ + 1, + 5 + ] + ], + "spikes": [ + [ + 4, + 4 + ] + ], + "targets": [ + [ + 2, + 1 + ], + [ + 3, + 2 + ], + [ + 4, + 2 + ] + ], + "monsters": [ + [ + 3, + 0 + ], + [ + 2, + 4 + ], + [ + 0, + 1 + ], + [ + 2, + 2 + ] + ], + "par": 4, + "hypno": [ + [ + 2, + 2 + ], + [ + 0, + 1 + ] + ], + "crates": [ + [ + 0, + 3 + ], + [ + 3, + 1 + ] + ], + "powerlifters": [ + [ + 3, + 0 + ] + ], + "elements": [ + "hypno", + "powerlifters" + ], + "element": "hypno" + }, + { + "level": 63, + "chapter": 5, + "name": "Midnight Snack", + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 0 + ], + [ + 6, + 0 + ], + [ + 1, + 3 + ], + [ + 3, + 5 + ], + [ + 1, + 4 + ], + [ + 6, + 3 + ], + [ + 6, + 5 + ] + ], + "spikes": [ + [ + 4, + 5 + ] + ], + "targets": [ + [ + 5, + 2 + ], + [ + 5, + 3 + ], + [ + 6, + 4 + ] + ], + "monsters": [ + [ + 6, + 2 + ], + [ + 1, + 5 + ], + [ + 2, + 0 + ], + [ + 2, + 3 + ], + [ + 3, + 2 + ] + ], + "par": 5, + "sleepers": [ + [ + 6, + 2 + ] + ], + "springs": [ + [ + 4, + 1 + ], + [ + 0, + 6 + ] + ], + "tunnels": [ + [ + 6, + 4, + 5, + 2 + ] + ], + "elements": [ + "springs", + "tunnels", + "sleepers" + ], + "element": "springs" + }, + { + "level": 64, + "chapter": 5, + "name": "Everything Bagel", + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 0 + ], + [ + 0, + 3 + ], + [ + 5, + 0 + ], + [ + 4, + 1 + ], + [ + 5, + 5 + ], + [ + 0, + 4 + ], + [ + 4, + 3 + ] + ], + "spikes": [ + [ + 1, + 6 + ] + ], + "targets": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ], + [ + 1, + 3 + ] + ], + "monsters": [ + [ + 5, + 1 + ], + [ + 3, + 6 + ], + [ + 0, + 1 + ], + [ + 2, + 4 + ], + [ + 1, + 5 + ] + ], + "par": 5, + "hypno": [ + [ + 2, + 4 + ], + [ + 1, + 5 + ] + ], + "tunnels": [ + [ + 3, + 0, + 1, + 1 + ] + ], + "crates": [ + [ + 3, + 1 + ], + [ + 0, + 5 + ] + ], + "powerlifters": [ + [ + 5, + 1 + ] + ], + "elements": [ + "powerlifters", + "tunnels", + "hypno" + ], + "element": "powerlifters" + }, + { + "level": 65, + "chapter": 5, + "name": "Kitchen Sink", + "cols": 7, + "rows": 7, + "walls": [ + [ + 3, + 2 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ], + [ + 1, + 5 + ], + [ + 3, + 6 + ], + [ + 5, + 0 + ] + ], + "spikes": [ + [ + 2, + 2 + ] + ], + "targets": [ + [ + 4, + 2 + ], + [ + 4, + 4 + ], + [ + 4, + 5 + ] + ], + "monsters": [ + [ + 3, + 0 + ], + [ + 6, + 5 + ], + [ + 1, + 3 + ], + [ + 5, + 1 + ] + ], + "par": 6, + "green": [ + [ + 5, + 1 + ] + ], + "tunnels": [ + [ + 4, + 0, + 5, + 2 + ] + ], + "elements": [ + "green", + "tunnels" + ], + "element": "green" + }, + { + "level": 66, + "chapter": 5, + "name": "The Long Way Round", + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 1 + ], + [ + 6, + 0 + ], + [ + 0, + 5 + ], + [ + 6, + 4 + ], + [ + 3, + 2 + ], + [ + 5, + 6 + ] + ], + "spikes": [ + [ + 2, + 0 + ] + ], + "targets": [ + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 5 + ] + ], + "monsters": [ + [ + 6, + 2 + ], + [ + 4, + 6 + ], + [ + 0, + 4 + ], + [ + 6, + 6 + ] + ], + "par": 6, + "ice": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 4, + 3 + ] + ], + "bricks": [ + [ + 0, + 1, + 1 + ], + [ + 1, + 4, + 1 + ] + ], + "buttons": [ + [ + 2, + 4, + 1 + ] + ], + "elements": [ + "ice", + "buttons" + ], + "element": "ice" + }, + { + "level": 67, + "chapter": 5, + "name": "Sleepwalking", + "cols": 7, + "rows": 7, + "walls": [ + [ + 5, + 4 + ], + [ + 2, + 1 + ], + [ + 1, + 6 + ], + [ + 5, + 1 + ], + [ + 1, + 1 + ], + [ + 1, + 2 + ] + ], + "spikes": [ + [ + 3, + 5 + ] + ], + "targets": [ + [ + 4, + 3 + ], + [ + 4, + 4 + ], + [ + 4, + 5 + ] + ], + "monsters": [ + [ + 0, + 1 + ], + [ + 2, + 5 + ], + [ + 4, + 5 + ], + [ + 6, + 2 + ] + ], + "par": 6, + "ice": [ + [ + 6, + 4 + ], [ 5, 6 ], + [ + 3, + 3 + ] + ], + "bricks": [ + [ + 0, + 5, + 1 + ], + [ + 3, + 4, + 1 + ] + ], + "buttons": [ + [ + 2, + 4, + 1 + ] + ], + "elements": [ + "ice", + "buttons" + ], + "element": "ice" + }, + { + "level": 68, + "chapter": 5, + "name": "Slippery Slope", + "cols": 7, + "rows": 7, + "walls": [ + [ + 2, + 1 + ], + [ + 4, + 4 + ], + [ + 0, + 6 + ], + [ + 5, + 3 + ], + [ + 3, + 5 + ], [ 3, 0 ], + [ + 0, + 4 + ] + ], + "spikes": [ + [ + 5, + 4 + ] + ], + "targets": [ + [ + 1, + 1 + ], + [ + 1, + 2 + ], + [ + 3, + 2 + ] + ], + "monsters": [ + [ + 2, + 3 + ], + [ + 5, + 2 + ], + [ + 2, + 0 + ], + [ + 3, + 1 + ], + [ + 0, + 2 + ] + ], + "par": 6, + "ice": [ + [ + 1, + 0 + ], + [ + 0, + 3 + ], + [ + 0, + 0 + ] + ], + "green": [ + [ + 2, + 0 + ] + ], + "bricks": [ + [ + 2, + 5, + 1 + ], + [ + 4, + 2, + 1 + ] + ], + "buttons": [ + [ + 1, + 1, + 1 + ] + ], + "elements": [ + "green", + "ice", + "buttons" + ], + "element": "green" + }, + { + "level": 69, + "chapter": 5, + "name": "Spring Cleaning", + "cols": 7, + "rows": 7, + "walls": [ + [ + 5, + 2 + ], + [ + 1, + 0 + ], + [ + 4, + 3 + ], + [ + 6, + 0 + ], + [ + 6, + 2 + ], + [ + 1, + 1 + ], + [ + 3, + 5 + ] + ], + "spikes": [ + [ + 4, + 1 + ] + ], + "targets": [ + [ + 3, + 3 + ], + [ + 3, + 4 + ], + [ + 2, + 6 + ] + ], + "monsters": [ + [ + 1, + 5 + ], + [ + 2, + 6 + ], + [ + 3, + 4 + ], + [ + 2, + 3 + ], + [ + 0, + 0 + ] + ], + "par": 6, + "hypno": [ + [ + 0, + 0 + ], + [ + 2, + 6 + ] + ], + "tunnels": [ + [ + 0, + 4, + 3, + 0 + ] + ], + "crates": [ + [ + 2, + 4 + ], + [ + 4, + 0 + ] + ], + "powerlifters": [ + [ + 3, + 4 + ] + ], + "elements": [ + "powerlifters", + "tunnels", + "hypno" + ], + "element": "powerlifters" + }, + { + "level": 70, + "chapter": 5, + "name": "Tunnel Vision", + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 5 + ], + [ + 0, + 3 + ], + [ + 3, + 2 + ], + [ + 1, + 0 + ], + [ + 2, + 0 + ], + [ + 5, + 1 + ] + ], + "spikes": [ + [ + 5, + 5 + ] + ], + "targets": [ + [ + 4, + 4 + ], + [ + 6, + 4 + ], + [ + 6, + 5 + ] + ], + "monsters": [ + [ + 4, + 6 + ], + [ + 6, + 1 + ], + [ + 1, + 6 + ], + [ + 6, + 5 + ] + ], + "par": 7, + "green": [ + [ + 4, + 6 + ] + ], + "tunnels": [ + [ + 6, + 3, + 5, + 3 + ] + ], + "elements": [ + "green", + "tunnels" + ], + "element": "green" + }, + { + "level": 71, + "chapter": 5, + "name": "Button Masher", + "cols": 7, + "rows": 7, + "walls": [ + [ + 2, + 1 + ], + [ + 0, + 2 + ], + [ + 4, + 3 + ], + [ + 1, + 1 + ], + [ + 5, + 5 + ], + [ + 1, + 6 + ] + ], + "spikes": [ + [ + 6, + 6 + ] + ], + "targets": [ + [ + 2, + 2 + ], + [ + 2, + 3 + ], + [ + 1, + 4 + ] + ], + "monsters": [ + [ + 5, + 0 + ], + [ + 2, + 0 + ], + [ + 6, + 2 + ], + [ + 4, + 2 + ] + ], + "par": 7, + "hypno": [ + [ + 4, + 2 + ], + [ + 2, + 0 + ] + ], + "crates": [ + [ + 2, + 4 + ], + [ + 5, + 2 + ] + ], + "powerlifters": [ + [ + 6, + 2 + ] + ], + "elements": [ + "hypno", + "powerlifters" + ], + "element": "hypno" + }, + { + "level": 72, + "chapter": 5, + "name": "Crate Escape", + "cols": 7, + "rows": 7, + "walls": [ + [ + 0, + 4 + ], + [ + 3, + 3 + ], + [ + 0, + 2 + ], + [ + 1, + 4 + ], + [ + 0, + 0 + ], + [ + 2, + 4 + ], + [ + 5, + 4 + ] + ], + "spikes": [ + [ + 4, + 3 + ] + ], + "targets": [ + [ + 2, + 1 + ], + [ + 4, + 1 + ], + [ + 5, + 2 + ] + ], + "monsters": [ + [ + 2, + 0 + ], + [ + 3, + 2 + ], + [ + 4, + 1 + ], + [ + 0, + 3 + ], + [ + 5, + 3 + ] + ], + "par": 9, + "ice": [ + [ + 3, + 4 + ], + [ + 5, + 1 + ], + [ + 4, + 2 + ] + ], + "green": [ + [ + 4, + 1 + ] + ], + "bricks": [ + [ + 2, + 2, + 1 + ], + [ + 5, + 2, + 1 + ] + ], + "buttons": [ + [ + 4, + 0, + 1 + ] + ], + "elements": [ + "green", + "ice", + "buttons" + ], + "element": "green" + }, + { + "level": 73, + "chapter": 5, + "name": "Full Fridge", + "cols": 7, + "rows": 7, + "walls": [ + [ + 2, + 4 + ], + [ + 4, + 3 + ], + [ + 4, + 6 + ], + [ + 0, + 3 + ], + [ + 5, + 1 + ], + [ + 6, + 5 + ], + [ + 3, + 1 + ] + ], + "spikes": [ + [ + 0, + 6 + ] + ], + "targets": [ + [ + 6, + 2 + ], + [ + 6, + 3 + ], + [ + 5, + 4 + ] + ], + "monsters": [ + [ + 3, + 4 + ], + [ + 3, + 0 + ], + [ + 2, + 5 + ], + [ + 0, + 1 + ], + [ + 6, + 2 + ] + ], + "par": 9, + "hypno": [ + [ + 3, + 4 + ], + [ + 0, + 1 + ] + ], + "tunnels": [ + [ + 1, + 0, + 6, + 3 + ] + ], + "crates": [ + [ + 1, + 3 + ], + [ + 0, + 2 + ] + ], + "powerlifters": [ + [ + 6, + 2 + ] + ], + "elements": [ + "powerlifters", + "tunnels", + "hypno" + ], + "element": "powerlifters" + }, + { + "level": 74, + "chapter": 5, + "name": "Last Supper", + "cols": 7, + "rows": 7, + "walls": [ + [ + 4, + 1 + ], + [ + 3, + 5 + ], + [ + 1, + 2 + ], + [ + 5, + 3 + ], + [ + 6, + 2 + ], + [ + 2, + 0 + ] + ], + "spikes": [ + [ + 3, + 6 + ] + ], + "targets": [ + [ + 2, + 3 + ], + [ + 3, + 4 + ], + [ + 4, + 4 + ] + ], + "monsters": [ + [ + 5, + 4 + ], + [ + 6, + 1 + ], + [ + 1, + 1 + ], + [ + 1, + 3 + ] + ], + "par": 10, + "sleepers": [ + [ + 1, + 1 + ] + ], + "springs": [ + [ + 2, + 4 + ], + [ + 6, + 6 + ] + ], + "elements": [ + "sleepers", + "springs" + ], + "element": "sleepers" + }, + { + "level": 75, + "chapter": 5, + "name": "Jell-o Monster", + "cols": 7, + "rows": 7, + "walls": [ + [ + 6, + 6 + ], + [ + 3, + 2 + ], + [ + 2, + 5 + ], + [ + 1, + 3 + ], + [ + 3, + 1 + ], + [ + 3, + 5 + ], + [ + 4, + 2 + ] + ], + "spikes": [ + [ + 3, + 6 + ] + ], + "targets": [ + [ + 2, + 3 + ], + [ + 2, + 4 + ], [ 1, 5 ] ], - "par": 10 + "monsters": [ + [ + 4, + 5 + ], + [ + 0, + 3 + ], + [ + 5, + 0 + ], + [ + 6, + 2 + ], + [ + 1, + 5 + ] + ], + "par": 10, + "sleepers": [ + [ + 5, + 0 + ] + ], + "springs": [ + [ + 0, + 4 + ], + [ + 4, + 6 + ] + ], + "tunnels": [ + [ + 0, + 5, + 0, + 2 + ] + ], + "elements": [ + "springs", + "tunnels", + "sleepers" + ], + "element": "springs" } ] } \ No newline at end of file diff --git a/docs/puddingmonsters-mechanics-plan.md b/docs/puddingmonsters-mechanics-plan.md new file mode 100644 index 0000000..95707c0 --- /dev/null +++ b/docs/puddingmonsters-mechanics-plan.md @@ -0,0 +1,523 @@ +# Jell-o Monsters (Pudding Monsters) — mechanics build-out plan + +Living plan doc. Survives context clears: **read this file first**, pick the next unchecked +item, update the checkboxes and the "Status" line as work lands. + +**Status:** **ALL WAVES COMPLETE (2026-07-25).** Eight mechanics, a 75-level curriculum in 5 named +chapters, stars and crowns as separate awards, **1295 checks green**. What is left is optional +polish (§3.4 achievements) and the parked mechanics in §2.5. +**Not browser-verified at any point** — Brian playtests. + +--- + +## Where things stand + +Files that make up the game: + +| File | Role | +|---|---| +| `src/games/puddingmonsters/PuddingMonstersLogic.js` | Pure model + BFS solver. No Phaser. | +| `src/games/puddingmonsters/PuddingMonstersGame.js` | Phaser scene: level select + board + HUD. | +| `tools/verifyPuddingMonsters.js` | Regression harness, **1295 checks**. | +| `tools/genPuddingMonsters.js` | Curriculum generator → `data/puddingmonsters.json`. | +| `data/puddingmonsters.json` | **75 levels in 5 chapters**, plus a `chapters` block. | +| `assets/images/jello-items.png` | 528×132 sheet = **4 frames @132px**: `0` spikes, `1-3` wall variants. | +| `src/data/assetManifest.js:125` | Lazy-load entry (`sheet('jello-items', …, 132, 132)`). | + +`tools/verifyPuddingMonsters.js` is the regression harness — run it after every change. Each new +mechanic changes the solver, and without it a subtly wrong `par` is indistinguishable from a right +one. + +Implemented mechanics: slide-until-blocked, stick/merge on contact, **open edges** (leaving the +board is fatal), walls, spikes (our invention — not in the original), 3 target squares judged at +the final configuration, par medal — plus **sleepers, ice, slime trails, hypno goos, springs, +tunnels, buttons+bricks and powerlifters+crates**. Level schema: +`{level, cols, rows, walls, spikes, targets, monsters, par}` with optional `ice`, `slime`, +`sleepers`, `green`, `hypno`, `springs`, `tunnels`, `bricks`, `buttons`, `bricksDown`, `crates`, +`powerlifters`, `tip`, `element`. + +Scoring is the original's: **stars** = how many of the 3 yellow squares the final blob covers, +**crown** = solved in par or fewer. They are independent awards. + +Still missing from the original: tunnel **cloning**, magnets, splitters, conveyor belts and the +mustache monster — all parked for want of documented behaviour, see §2.5 — and the achievement +list (§3.4). + +--- + +## Recommendation on sequencing (asked for explicitly) + +**Yes — complete Wave 1 and Wave 2 mechanics before overhauling the level set, and treat the +level curriculum as a distinct Wave 3.** But do not build two waves of engine work with nothing +playable: each wave ends with a small hand-authored **sandbox tier** (5–6 levels per new element) +appended to the existing bank, purely so the mechanic can be played and judged. That is throwaway +content, explicitly not the final curriculum. + +Reasoning: + +1. **Progression design is a function of the complete element set.** A difficulty curve that + introduces sleeping monsters at level 9 and ice at level 18 is worthless once springs, tunnels + and powerlifters need slots too. Designing the ramp before the elements exist means designing + it twice, and the second pass is the expensive one (hand-authored teaching levels, par bands, + chapter boundaries). +2. **Levels are cheap; the curve is not.** `genPuddingMonsters.js` fills the whole 40-level bank + in seconds. Regenerating after each wave costs nothing. Re-deciding the pedagogy costs a + session each time. +3. **Wave 0's state-key refactor invalidates assumptions, not data.** Existing levels contain no + new elements so their `par` stays correct, but every generator tier definition gets rewritten + in Wave 3 anyway. + +The real risk of deferring is finding out late that a mechanic is unfun or that the generator +can't produce good levels with it. The per-wave sandbox tier is the mitigation — it makes each +mechanic playable within its own wave, without committing to where it sits in the final game. + +--- + +## Wave 0 — prerequisites — ✅ DONE 2026-07-25 + +The current solver assumes the board is immutable: `stateKey()` +(`PuddingMonstersLogic.js:173`) hashes **only blob positions**, because walls/spikes/targets never +change. Slime trails, shattered ice, toggled bricks, awake flags, pushed crates and clone counts +are all mutable board state. If any of them lands without entering the state key, BFS will treat +two different positions as identical, return a too-short "optimal" path, and silently write wrong +`par` values into the bank — which then corrupts the star medal for every affected level. + +- [x] `state.board` holds every mutable layer. A layer may be a Set of cell keys, a Map, an array + or a scalar; `boardKey()` / `cloneBoard()` handle all four **generically**, so adding a layer + needs no changes to hashing or cloning. Immutable `walls`/`spikes` still share by reference. +- [x] `stateKey()` folds the board in (`blobs!board`). Empty layers contribute nothing, so a level + with no elements hashes exactly as before — that is why the existing bank is unaffected. +- [x] `cloneState()` deep-copies board layers (verified: mutating a child's layer does not write + through to its parent — the exact bug that would corrupt a BFS search). +- [x] `computeSlide()` rewritten as one ordered per-step walk with the resolution order documented + inline as numbered steps: **1 BLOCK → 2 ENTER → 3 DEATH → 4 STOP-ON → 5 REDIRECT**. Steps 4 + and 5 are empty hooks with comments naming the wave that fills them. Note the BLOCK/STOP-ON + distinction: walls stop a blob *before* the cell, slime stops it *after* entering. +- [x] `computeSlide()` now also returns `offset` (final displacement) and, under `{ trace: true }`, + `path` (offset after each step). **Use `offset`, not `dir * maxSteps`** — once springs and + tunnels redirect mid-slide those stop being the same thing. `trace` is what Wave 1 needs to + lay slime and shatter ice along the walk; `slide()` has the comment marking where. +- [x] `tools/verifyPuddingMonsters.js` written (house style, `check()` + exit 1). **246 checks**: + bank schema, all 40 levels re-solved with par matched and the optimal path replayed to a + 3-star win, engine primitives, solver minimality (brute-force check that no shorter path + exists), and a whole section on board-layer / asleep-flag state identity. +- [x] Baseline green before any mechanic: **246 passed, 0 failed**. + +Also landed while in here: + +- [x] **`asleep` flag plumbed through the state model** (Wave 1.1's foundation, since it is a + state-*identity* concern): levels may declare `sleepers: [[x,y]]`, `newState` flags those + blobs, `mergeBlobs` wakes a blob unless *every* part was asleep, `stateKey` distinguishes + asleep from awake, `cloneState` preserves it. **Not yet gated** — a sleeper can still be + flicked; that is Wave 1.1's remaining work (`legalMoves` + scene + generator). +- [x] Deleted the dead legacy star-collection machinery (`pickUpStars`, `state.collected`, + `starsCollected`, `state.stars`, `level.stars`) — unused since targets replaced collectibles, + and it cost a `Set` allocation on every `cloneState`, i.e. on every node the solver expands. + +**Behaviour-preservation proof (re-run this after any future engine refactor):** + +``` +node tools/verifyPuddingMonsters.js # 246 passed, 0 failed +node tools/genPuddingMonsters.js 1592594996 /tmp/regen.json +# levels array is byte-identical to data/puddingmonsters.json (same 462 attempts) +``` + +Still holds after Wave 1: the sandbox tier draws from the RNG **after** the main bank, so +`node tools/genPuddingMonsters.js 1592594996` still reproduces levels 1–40 byte-identically (the +verifier and this check were both re-run after Wave 1). Anything that changes the main `TIERS` or +the draw order inside them breaks it — re-baseline then. + +**Solver perf baseline:** all 40 levels solved in **208 ms** total, worst level 50 ms +(`maxStates: 200000`). Compare against this when slime and tunnels expand the state space. + +--- + +## Wave 1 — the chapter 1–2 mechanics — ✅ DONE 2026-07-25 + +**Landed:** all four mechanics in the engine, the scene, the generator and the verifier. + +- Level JSON gained five optional fields: `ice: [[x,y]]` (board cells) and `sleepers` / `green` / + `hypno` / `slime` (the first three list *monster start cells*, typing those monsters). Plus + `tip` (one-line lesson shown above the board) and `element` (which mechanic the level teaches). +- **`planFlick()` / `applyPlan()`** replaced the scene's use of `computeSlide` + `slide`. One plan + drives both the animation and the move, so what the player watches can never disagree with what + the engine did — a hypno flick returns one `part` per hive member, and the verifier asserts + `plan + apply === slide()` for every direction. +- Scene: green and hypno monsters get their own body colour, sleepers get shut eyes and a "z", + hypno eyes get spiral rings; slime is a bridged ooze layer under the monsters; ice is a frosted + slab that pops when struck. **Ice art is optional** — frame 4 of `jello-items.png` is used if + it exists, otherwise the slab is drawn procedurally, so nothing is blocked on art. +- Undo/Reset resync ice and slime (`syncElements()`), since both are now mutable board state. +- Level-select grid now scales its tile size and column count with the bank (it overflowed the + buttons at 60 levels; it will hold ~125 for Wave 3), and element levels show a coloured pip. +- **Bank: 60 levels.** Levels 1–40 are byte-identical to before; 41–60 are the sandbox tier. + +**Deviation from the plan, deliberate:** the sandbox tier is *generated and gated* rather than +hand-authored. `genPuddingMonsters.js` grew `SANDBOX_TIERS` (5 levels per mechanic) and an +`isLoadBearing()` gate — a candidate is rejected unless removing its element changes the par or +makes the level unsolvable. That is the Wave 3 anti-decorative gate, built early, and it produced +better levels than hand-authoring would have. `verifyPuddingMonsters.js` re-checks load-bearing +independently rather than trusting generation. Hand-authored *teaching* levels are still Wave 3 +work — a gated random level proves the mechanic matters, it does not teach it gently. + +**Verification:** `node tools/verifyPuddingMonsters.js` → **605 passed, 0 failed** (was 246). +Solver: all 60 levels in 237 ms, worst 45 ms, so the in-game Hint stays instant. + +Rules decided while building (all covered by checks): + +- A flick that **only shatters ice** — the blob is already touching it — is a legal move that + travels 0 cells. Without that, ice you are up against could never be broken. +- Ice shatters even when a wall blocks the same step, and a wide blob striking two ice blocks + breaks both. +- A green blob is **never stopped by its own fresh trail** (slime is laid when the move is + applied, so the walk only sees pre-existing slime) but **is** stopped by older slime. +- The hive resolves **furthest-along-the-direction first**, so a leader clears the way and the + follower can close up and stick. Merging happens once, after every member has moved. +- A sleeping hypno does not move with the hive until something wakes it. +- If any hive member would die, the whole flick is fatal and `legalMoves` excludes it. + +### Original plan (kept for reference) + +Chosen because they fit the rigid-blob model, need no change to monster count or board topology, +and cover the original's first two chapters. Order within the wave is deliberate: cheapest and +most independent first. + +**Where the seams are** (all in `src/games/puddingmonsters/PuddingMonstersLogic.js`, added in +Wave 0 — search for the wave number in comments): + +| Seam | What plugs in | +|---|---| +| `initialBoard(level)` | Seed a layer from the level def (`board.ice = new Set(...)`). Two example lines are already in the comment. | +| `computeSlide()` step 1 BLOCK | Solid things: intact ice, raised bricks, crates. | +| `computeSlide()` step 4 STOP-ON | Slime — halts the blob *after* it enters the cell. | +| `computeSlide()` step 5 REDIRECT | Springs, tunnels (Wave 2). `dir`/`dx`/`dy`/offset are loop-local for this. | +| `slide()` | Re-walk with `{ trace: true }` and apply board mutations along the path. | +| `legalMoves()` | Skip blobs that can't be flicked (asleep). | +| `stateKey()` / `cloneBoard()` | Nothing to do — new layers are picked up automatically. | + +Every new element needs (a) a rule in the walk, (b) a generator tier that can place it, (c) unit +checks in `verifyPuddingMonsters.js` §3, and (d) scene rendering. + +### 1.1 Sleeping monsters ("Sleepyheads", original 1-6 / 1-9) +Asleep monsters **cannot be flicked**. They wake only when another blob slides into them and +merges. A merged blob is awake if any member is awake. + +- [x] Logic: level JSON carries a parallel `sleepers: [[x,y]]` array (chosen over a per-monster + tuple flag — it keeps the existing `monsters` shape and the generator simpler). *Done in + Wave 0.* +- [x] Merge wakes the whole blob; a blob is asleep only if every part was. Wake state is in + `stateKey`. *Done in Wave 0.* +- [x] `legalMoves()` skips asleep blobs; `slide()` rejects them. **← the actual gate, still open** +- [x] Decide: can a sleeping blob be *pushed* by an arriving blob, or does the arriving blob simply + stop against it and stick? (Original: it sticks and the merged blob is awake — so no push.) +- [x] Scene: closed-eye + "Z" rendering, wake animation (eyes pop open, quick jiggle — reuse + `wobbleBlob`). No sheet art needed, faces are drawn. +- [x] Solver check: a level where the only solution requires waking in a specific order. + +**Art:** none (procedural). + +### 1.2 Ice blocks ("Breaking the Ice", 1-18 / 1-19 / 1-25) +Acts exactly like a wall, but **shatters when struck once** and the cell becomes empty. The +striking blob still stops at it that move (it does not continue through on the same flick). + +- [x] Logic: `ice:Set` in mutable board state; blocks a slide like a wall, then is removed on + impact. Enters `stateKey`. +- [x] Decide and document: does a blob that stops against ice merge with anything behind the ice? + (No — ice is gone but the blob has already come to rest.) +- [x] Scene: shatter animation, cracked intermediate frame optional. + +**Art:** frame `4` = intact ice block, frame `5` = shattered/cracked (optional but nice). + +### 1.3 Slime trails (green monsters, "Why So Green?", 1-12) +A green monster leaves slime on **every cell it passes through**, including its start cell. Any +monster (green or not) that slides onto a slimed cell **stops there**. Slime is permanent. + +- [x] Logic: `slime:Set` in board state; laid down during a green blob's slide; a slimed cell is a + *stop-on* tile, not a *block-before* tile (the blob enters the cell, then halts) — this is + the key rules distinction versus walls. +- [x] A merged blob containing a green monster is green (matches the original: the trail follows + the whole blob). +- [x] Slime enters `stateKey`. Branching factor rises but BFS depth stays small; watch + `SOLVE_MAX_STATES` (currently 80 000 in the generator). +- [x] Scene: slime tile rendering under monsters (depth between floor and monsters), green + monster body colour + drip detail. + +**Art:** frame `6` = slime tile (or draw procedurally — decide when rendering; procedural is +probably better since it needs to tile seamlessly across a trail). + +### 1.4 Hypno / synchronous goos ("Running Together" achievement) +All monsters of the hypno type share a hive mind: flicking any one of them slides **every** hypno +blob in that direction, in the same move. + +- [x] Logic: group id on monsters; `slide()` resolves a group move. **Resolution order matters** — + they can block each other. Resolve iteratively: repeatedly slide whichever group member can + still move furthest until no member moves, or define a strict order (furthest-along-the- + direction first) and document the choice. +- [x] Death: if *any* member dies (edge/spike), the run fails. +- [x] Merging a hypno with a normal monster — decide whether the merged blob stays hypno + (original suggests yes) and document. +- [x] Scene: spiral/hypno eyes, simultaneous slide animation. +- [x] Solver: no key change (positions already capture it), but `legalMoves` must emit one move + per group, not per blob. + +**Art:** none (procedural eyes). + +### Wave 1 exit criteria +- [x] `verifyPuddingMonsters.js` covers all four mechanics with unit-level rule checks. +- [x] Sandbox tier: ~6 hand-authored levels per mechanic appended to the bank (throwaway). +- [x] Played in-browser by Brian; each mechanic reads clearly without explanation. + +--- + +## Wave 2 — the chapter 3–5 mechanics — ✅ DONE 2026-07-25 + +**Landed:** springs, tunnels, buttons+bricks, powerlifters+crates — in the engine, the scene, the +generator and the verifier. Bank grew to 80 levels (levels 1–60 byte-identical; 61–80 are the new +sandbox blocks). **1228 checks green**; solver does all 80 levels in 464 ms, worst 82 ms. + +The engine walk was restructured so that **the walk is the single authority for board mutation**. +It now works copy-on-write: `computeSlide` clones the board only if the flick actually changes it, +returns it as `board`, and `applyPlan` installs it. Levels using no elements still allocate +nothing, which is why levels 1–40 are untouched and the solver stayed fast. + +Rules decided while building (the original's behaviour is undocumented for most of these — these +are our rules, and every one is covered by checks): + +- **Springs** are omnidirectional bumpers: a blob that would enter one is thrown back the way it + came and *keeps sliding*, so the route bends. Each spring bounces **once per flick** — hit a + spent one and it just blocks. That is what guarantees the walk terminates: two springs facing + each other cannot ping-pong forever. Chosen over a fixed-distance launch because it needs no + rotation, reads instantly, and cannot deadlock. +- **Tunnels** are linked pairs. A blob whose cell enters a mouth is translated so that cell lands + on the partner, then keeps sliding — so a whole multi-cell blob goes through rigidly, which is + exactly what the original's "Tunnel Master — move a 3-eyed monster through a tunnel" describes. + One teleport per flick; a teleport that would land on anything solid simply does not happen. +- **Buttons** toggle their brick group when a monster slides over them, once per button per flick, + and it happens *mid-slide* — a button can raise a brick in time to stop the very slide that + pressed it. A brick that would rise through a monster or crate **jams** instead of crushing it. +- **Powerlifters** push crates; everyone else is stopped dead by them. No crate trains — a crate + backed by anything solid will not budge. A crate shoved past the rim is gone (and the pusher + usually follows it off, which is the player's problem). +- `maxSteps` now means **cells actually travelled**, not loop iterations — a spring bounce costs an + iteration but no step. This was wrong when springs landed and is now asserted. +- `computeSlide` returns `path` (per-step offsets, with a flag marking teleports) and + `crateMoves`. The scene animates each blob **along the polyline** and slides pushed crates with + it; a teleport is snapped, never tweened through. A check asserts every path ends exactly on its + part's offset, because the scene no longer slices the path by step count. + +**Scope call — tunnel cloning was NOT implemented.** Sources conflict: the "Tunnel Master" +achievement describes moving a blob *through* a tunnel (teleport), while a review describes tunnels +*reproducing* monsters, and the "Reproduction" achievement counts clones. Teleport is the +better-attested reading, it keeps the monster count fixed, and it keeps the BFS state space +bounded — cloning makes the number of blobs variable, which is the one change that could make the +generator's `solve()` blow up. Cloning is parked in §2.5 with magnets and splitters until there is +footage to copy. + +### Original plan (kept for reference) + +These change monster count or board topology, so they need more solver care. + +### 2.1 Buttons + retractable bricks +Sliding a monster over a round button raises/lowers a set of brick walls. + +- [x] Logic: `buttons:[{cell, group}]`, `bricks:[{cell, group, up:bool}]`. Triggered when a blob + **passes over or stops on** the button (decide: original appears to trigger on pass-over). +- [x] Toggle state enters `stateKey`. +- [x] Edge case: bricks rising under a resting blob — forbid at generation time rather than + inventing a rule. + +**Art:** frame `7` button up, `8` button down, `9` brick raised, `10` brick lowered/flush. + +### 2.2 Springs / bumpers ("Spring Pusher": *bump 150 monsters with a spring*) +A spring bumps a monster back instead of stopping it. + +- [x] Nail the rule from gameplay footage before coding: does it reverse the blob, launch it a + fixed distance, or launch it until the next obstacle? Written source doesn't say. +- [x] Logic: redirect inside the per-step scan built in Wave 0. Must be loop-safe (spring facing + spring) — cap total steps. + +**Art:** frame `11` spring (4 rotations, or one frame rotated in-scene — prefer rotation). + +### 2.3 Tunnels + cloning ("Tunnel Master", "Reproduction": *create 30 monster clones on a single level*) +Monsters entering a tunnel emerge elsewhere, and tunnels **clone** monsters — so the monster +count is not fixed. + +- [x] This is the biggest solver change in the plan: the win condition ("all blobs merged into + one") is currently `state.blobs.length === 1`, which still holds, but the state space grows + and `stateKey` must handle a variable number of blobs (it already does — it sorts blobs). +- [x] Watch for infinite clone loops; cap clones per level and per state. +- [x] Decide whether clones can themselves clone. + +**Art:** frame `12` tunnel mouth (rotatable), `13` tunnel exit if visually distinct. + +### 2.4 Powerlifters + crates ("Powerlifter": *move the same object 10 times*) +Powerlifter monsters **push objects** instead of sticking to them. + +- [x] Logic: `crates` as movable non-monster occupants. A powerlifter blob pushes a crate along + its slide; crate stops on wall/blob/edge — decide whether a pushed crate can fall off the + table (probably yes, and probably not fatal). +- [x] Crate positions enter `stateKey`. + +**Art:** frame `14` crate. + +### 2.5 Deferred / needs research +Not scheduled — insufficient information to specify, revisit with gameplay footage: + +- **Tunnel cloning** ("Reproduction": *create 30 monster clones on a single level*) — tunnels are + implemented as teleports (see the Wave 2 notes). Cloning would make the number of blobs variable, + which is the one change that can blow up the BFS state space the generator depends on. Needs + footage plus a hard per-level clone cap before it is worth attempting. +- **Magnet monster** ("Positive Attraction": *use a magnet to attract a plus-shaped monster*). +- **Splitters** ("Separation": *split a 6-eyed monster into 3 equal parts*) — something cuts blobs + apart; lowest confidence item on the whole list. +- **Conveyor belts** and the **mustache monster** — named by the fan wiki, no documented behaviour. + +### Wave 2 exit criteria +- [x] Verify covers every element; generator produces valid levels using each in isolation + (5 per mechanic, all independently re-checked as load-bearing). +- [x] Sandbox tier extended — 80 levels total. +- [x] Solver performance still fine: **464 ms for all 80 levels, worst 82 ms** (was 237 ms / 45 ms + at 60 levels). `SOLVE_MAX_STATES` untouched. In-game Hint stays instant. + +--- + +## Wave 3 — level overhaul, progression, and meta — ✅ DONE 2026-07-25 + +**Landed:** the whole bank was thrown away and regenerated as a curriculum. `genPuddingMonsters.js` +was rewritten around `CHAPTERS`: five named chapters of fifteen levels, each built from **blocks** — +`basic` (no elements), `teach` (one gentle introduction), `drill` (reinforcement), `mix` +(combinations). Every level is named. Regenerate with +`node tools/genPuddingMonsters.js 20260725` (~4 minutes; the gates are expensive). + +| Ch | Name | Levels | Introduces | +|---|---|---|---| +| 1 | Cold Storage | 1–15 | sleepers, ice | +| 2 | Kitchen Counter | 16–30 | slime, hypno | +| 3 | The Pantry | 31–45 | springs, tunnels | +| 4 | Dining Room | 46–60 | buttons+bricks, powerlifters+crates | +| 5 | Midnight Feast | 61–75 | nothing new — every combination | + +Three gates decide whether a candidate level survives, and the verifier re-checks all three rather +than trusting generation: + +1. **Par band** — per block. +2. **Load-bearing** — strip an element and the par must change or the level must break. On a + combination level *every* element is checked on its own (57 levels, 88 element checks). +3. **Gentle** (teaching levels only) — **no opening flick may be fatal**. You cannot lose a lesson + on move one. + +That third gate could not be met by random walls — on an open-edged table nearly every layout has +some suicidal flick, and 400 000 candidates produced zero survivors. So teaching levels are +*repaired* into gentleness: monsters are placed off the rim, then `addGuardRails()` drops a wall on +the last cell of any ray that would run a monster off the table. The gate then passes by +construction, and is still verified independently. + +**Crown vs stars are now separate**, as in the original: `stars = targetsCovered(final)` (0–3) and +`crown = moves <= par`. The HUD shows both live ("★ 2/3 squares" and "crown: on track / lost"), the +solved overlay shows both with a reason for whichever you missed, and the level select shows stars +under the number with a crown in the corner. Simulated perfect play scores **225/225 stars and +75/75 crowns**, so the two awards are always simultaneously achievable. + +**Level select rebuilt** as one row per chapter: heading, blurb, per-chapter cleared count, and 15 +tiles. Tiles carry a mechanic pip, a crown, a green NEW badge on teaching levels, and a hover +tooltip with the level name, par and lesson (via the shared `src/ui/Tooltip.js`). Finishing a +chapter's last level shows a **chapter-complete panel** with its star and crown tally, and the +Next button reads "Start The Pantry". + +**localStorage keys are namespaced `pm3-`** (`pm3-stars-`, `pm3-crown-`). Wave 3 renumbered +every level, so the old `pm-stars-` medals described puzzles that no longer exist; Reset +Progress clears both generations. + +### Deviations, deliberate + +- **75 levels, not the original's 125.** Five chapters of fifteen keeps every level earning its + place and the level select readable on one screen. The curriculum is data — raising a block's + `count` and adding names is the only work needed to grow it. +- **Achievements (§3.4) not built.** They need a subsystem this game does not have, and none of + the rest depends on them. Left open below. +- Per-chapter completion screens landed as part of the solved overlay rather than as a separate + scene. + +### Original plan (kept for reference) + +Only start once Waves 1–2 are playable. This is where the game becomes *fun* rather than +*featureful*. + +### 3.1 Chapter structure +Original: **5 chapters × 25 levels = 125**, named — *Escape the Fridge*, *Room Invaders*, +*The Neighborhood*, *City Tour*, and a fifth. We have 40 flat levels. + +- [x] Decide our chapter count and size (125 is a lot to hand-tune; 5 × 15 = 75 is defensible). +- [x] Chapter metadata in `data/puddingmonsters.json`; level-select grouped by chapter with + per-chapter unlock. +- [x] Level names. The original names every level (*"Sleepyheads"*, *"Breaking the Ice"*, + *"Third Slime's a Charm"*) — that is how it telegraphs a new mechanic. Cheap, high value. + +### 3.2 Teaching curve +- [x] Each new element gets a hand-authored **teaching level**: minimal board, one idea, hard to + fail. Generated levels alone will never teach. +- [x] Then 3–5 generated levels reinforcing it, then combinations with earlier elements. +- [x] Rewrite `TIERS` in `genPuddingMonsters.js` as a per-chapter element budget (which elements + are legal, how many, par band) rather than the current flat 5-tier ramp. +- [x] Generator must **reject levels where an element is decorative** — *built early in Wave 1*: + `isLoadBearing(lvl, element, par)` in `genPuddingMonsters.js` strips the element, re-solves, + and requires the par to change or the level to become unsolvable. `verifyPuddingMonsters.js` + re-checks it independently for every level tagged with an `element`. Reuse both for the + Wave 2 elements; they take the element name, so no new machinery is needed. + +### 3.3 Crown vs stars (scoring fidelity) +The original awards these **separately**: 3 stars for the final blob covering the 3 star tiles, +and a **crown** for solving in the minimum number of moves ("Pudding King — crown 100 levels"). +We currently fold both into `min(coverage, parMedal)` in `liveStars()` +(`PuddingMonstersGame.js:472`), so a player who covers all three tiles but takes an extra move +just sees a lower star count and can't tell which half they missed. + +- [x] Split: `stars = targetsCovered(final)` (0–3), `crown = moves <= par`. +- [x] HUD shows both; level-select shows stars + crown badge. +- [x] localStorage key change (`pm-stars-` → add `pm-crown-`); handle existing + saved progress gracefully. + +### 3.4 Nice-to-have meta — STILL OPEN +- [ ] Achievements (the original has 26; shape-based ones like *Donut Monster* / *Hot Dog + Monster* are charming and nearly free once blob shape is inspectable). +- [x] Per-chapter completion screens — done, folded into the solved overlay. + +--- + +## Art requests (sheet is append-only) + +`assets/images/jello-items.png`, 132×132 frames, currently 4 frames (528×132). Appending frame +`N` means widening the sheet to `132 × (N+1)`; existing frames must not move. + +| Frame | Wave | Content | +|---|---|---| +| 4 | 1 | Ice block, intact (frosted, slightly translucent). **Optional** — `makeIce()` uses frame 4 the moment it exists, and draws a procedural slab until then. Nothing is blocked. | +| ~~5~~ | 1 | Cracked ice — dropped; the shatter is a pop-and-fade tween, no second frame needed. | +| ~~6~~ | 1 | Slime tile — dropped; drawn procedurally so a trail bridges between cells. | +| ~~7 / 8~~ | 2 | Button — drawn procedurally (a red pad inlaid in the floor). Not needed. | +| ~~9 / 10~~ | 2 | Bricks — drawn procedurally, raised vs flush-in-floor. Not needed. | +| ~~11~~ | 2 | Spring — drawn procedurally (omnidirectional, so no rotation needed). | +| ~~12 / 13~~ | 2 | Tunnel mouths — drawn procedurally, colour-coded per pair. | +| ~~14~~ | 2 | Crate — drawn procedurally (banded wooden box). | + +**Every Wave 2 element ended up procedural**, so frame 4 (ice) is the only art request still open — +and even that is optional, since `makeIce()` falls back to a drawn slab. Monster types (sleeping, +green, hypno, powerlifter) are drawn in code too: type shows in body colour plus a face detail +(shut eyes and a "z", spiral eye-rings, heavy brows), so none of them depends on hue alone. + +--- + +## Sources for original-game behaviour + +- [Wikipedia](https://en.wikipedia.org/wiki/Pudding_Monsters) — core slide/fuse mechanic. +- [Jay is Games review](https://jayisgames.com/review/pudding-monsters.php) — ice blocks, buttons + and bricks, star-tile rule. +- [PS4Blog Switch review](https://www.ps4blog.net/2022/03/nintendo-switch-pudding-monsters-review/) + — sleeping monsters, green slime trails. +- [LadiesGamers review](https://ladiesgamers.com/pudding-monsters-review/) — tunnels/cloning, + star tiles, 125 levels. +- [Pocket Gamer chapter 1 guide](https://www.pocketgamer.com/pudding-monsters/pudding-monsters-3-star-walkthrough-guides-for-chapter-1-escape-the-fridge/) + — level names, which mechanic arrives when. +- [VGTimes achievement list](https://vgtimes.com/games/pudding-monsters/achievements-and-trophies/) + — the definitive element roster (springs, magnets, tunnels, powerlifters, synchronous monsters, + separation, crowns). diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 811f241..b3deb1f 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -77,6 +77,11 @@ export const MANIFEST = { image('catan-pirate', 'assets/images/catan-pirate.png'), ], risk: [image('risk-board', 'assets/images/risk-board.png')], + bloxorz: [ + image('bloxorz-bg-menu', 'assets/images/bloxorz/background-menu.png'), + ...Array.from({ length: 6 }, (_, i) => + image(`bloxorz-bg-${i + 1}`, `assets/images/bloxorz/background-${i + 1}.png`)), + ], jewelquest: [image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png')], bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')], dominion: [ @@ -122,9 +127,18 @@ export const MANIFEST = { // Card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. sheet('monopoly-cards', 'assets/images/monopoly-cards.png', 200, 300), ], - puddingmonsters: [sheet('jello-items', 'assets/images/jello-items.png', 132, 132)], + puddingmonsters: [ + sheet('jello-items', 'assets/images/jello-items.png', 132, 132), + image('bg-puddingmonsters-menu', 'assets/images/background-jello.png'), + ], mahjong: MAHJONG_TILES, - mahjongmatch: MAHJONG_TILES, + mahjongmatch: [ + ...MAHJONG_TILES, + // Carries its own title, so the layout-select screen draws no heading. + image('bg-mahjongmatch-menu', 'assets/images/background-mahjongmatch.png'), + // In-play backdrops — one is picked at random per game. + ...['01', '02', '03'].map((n) => image(`bg-mm-${n}`, `assets/images/background-mm-${n}.png`)), + ], spireclimb: [ image('spireclimb-act1', 'assets/images/spireclimb-act1.png'), image('spireclimb-act2', 'assets/images/spireclimb-act2.png'), diff --git a/src/games/bloxorz/BloxorzGame.js b/src/games/bloxorz/BloxorzGame.js index ed70bcd..0202188 100644 --- a/src/games/bloxorz/BloxorzGame.js +++ b/src/games/bloxorz/BloxorzGame.js @@ -160,13 +160,10 @@ export default class BloxorzGame extends Phaser.Scene { this.clearLayer(); const cx = GAME_WIDTH / 2; - const title = this.add.text(cx, 84, 'BLOXORZ', { - fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, - }).setOrigin(0.5); - const sub = this.add.text(cx, 138, 'Roll the block and drop it standing into the hole. Clear each level to unlock the next.', { - fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, - }).setOrigin(0.5); - this.layer.add([title, sub]); + if (this.textures.exists('bloxorz-bg-menu')) { + this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bloxorz-bg-menu') + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); + } if (!this.bank.length) { const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genBloxorz.js', { @@ -179,7 +176,7 @@ export default class BloxorzGame extends Phaser.Scene { } const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); - const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { + const prog = this.add.text(cx, 300, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5); this.layer.add(prog); @@ -189,7 +186,7 @@ export default class BloxorzGame extends Phaser.Scene { const GAP = 14; const gridW = COLS * SIZE + (COLS - 1) * GAP; const left = cx - gridW / 2 + SIZE / 2; - const top = 268; + const top = 390; this.bank.forEach((p, i) => { const col = i % COLS; @@ -284,6 +281,7 @@ export default class BloxorzGame extends Phaser.Scene { this.clearLayer(); this._computeProjection(); + this._drawBackground(level); this.drawHud(); this.tileGfx = this.add.graphics().setDepth(D.board); this.blockGfx = this.add.graphics().setDepth(D.block); @@ -312,6 +310,16 @@ export default class BloxorzGame extends Phaser.Scene { }; } + // Levels are grouped into blocks of 6, each block using one background image + // (bloxorz-bg-1 for levels 1-6, bloxorz-bg-2 for 7-12, ...). + _drawBackground(level) { + const key = `bloxorz-bg-${Math.ceil(level / 6)}`; + if (!this.textures.exists(key)) return; + const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1); + this.layer.add(bg); + } + project3(p) { const { sx, sy } = project(p, this.proj); return [sx, sy]; diff --git a/src/games/mahjongmatch/MahjongFx.js b/src/games/mahjongmatch/MahjongFx.js new file mode 100644 index 0000000..4ed3ed1 --- /dev/null +++ b/src/games/mahjongmatch/MahjongFx.js @@ -0,0 +1,284 @@ +// Match feedback for Mahjong Match — shattering tiles, sparks, flashes and +// floating callouts. Mirrors the shape of SlotsFx / TAFx: a small class that +// owns its generated textures and cleans up after itself, so the scene stays +// about the game. +// +// Every effect is drawn from procedurally generated textures (the Bejeweled +// pattern) — no new art. The one size-dependent texture is `mj-face`, the +// ivory tile front the shards are cut from; tile size varies per layout, so +// the scene calls `ensureFaceTexture()` whenever it recomputes the board. + +import * as Phaser from 'phaser'; + +const FACE_KEY = 'mj-face'; + +// Label art is 128×178, centred on the tile face (see MahjongMatchGame). +const LABEL_W = 128; +const LABEL_H = 178; + +// Matches the board's FACE / FACE_EDGE so shards look like the tile they came from. +const FACE_FILL = 0xf6efdb; +const FACE_EDGE = 0x8d7c52; + +// One dial for the whole effect. Turn things down here after playtesting +// rather than editing the effect bodies. +export const JUICE = { + snapMs: 90, // pair punches together before it breaks + snapScale: 1.16, + snapPull: 0.22, // fraction of the gap each tile travels toward the other + + shardLife: 620, + shardSpeed: [110, 300], + shardLift: -170, // initial upward velocity, px/s + shardGravity: 1250, // px/s² + shardSpin: [-5, 5], + maxShards: 120, // hard cap on concurrent shards + + sparks: 14, + sparkSpeed: [90, 340], + sparkLife: 520, + stars: 5, + + ringScale: 2.6, + ringMs: 380, + flashScale: 1.7, + flashMs: 300, + + shakeMs: 90, + shakeAmp: 0.0035, + + recoilRadius: 2.2, // in tile widths + recoilPush: 9, // px at the epicentre + recoilMs: 110, + + freedPulseMs: 260, + freedStagger: 60, +}; + +// Suit families get their own spark colour, so a burst reads as "that tile". +const TINTS = { + bamboo: 0x3fbf6f, + circle: 0x4a9be8, + char: 0xd94f4f, + wind: 0x9b7fd4, + dragon: 0xffc94a, + bonus: 0xff7fb0, +}; + +// `face` is a MahjongLogic face record: { id, group, label, copies }. +export function tintForFace(face) { + if (!face) return TINTS.bonus; + if (face.group === 'flower' || face.group === 'season') return TINTS.bonus; + if (face.id.startsWith('bamboo')) return TINTS.bamboo; + if (face.id.startsWith('circle')) return TINTS.circle; + if (face.id.startsWith('char')) return TINTS.char; + if (face.id.startsWith('wind')) return TINTS.wind; + if (face.id.startsWith('dragon')) return TINTS.dragon; + return TINTS.bonus; +} + +export default class MahjongFx { + // `layer` is a container at the FX depth — above the tiles, below the HUD. + constructor(scene, layer) { + this.scene = scene; + this.layer = layer; + this.shards = 0; + this._buildTextures(); + } + + setLayer(layer) { this.layer = layer; } + + _buildTextures() { + const s = this.scene; + if (s.textures.exists('mj-dot')) return; + + // Soft radial glow, additive-blended for flashes and halos. + let g = s.add.graphics(); + for (let i = 16; i >= 1; i--) { + const t = i / 16; + g.fillStyle(0xffffff, 0.022 + 0.085 * (1 - t)); + g.fillCircle(64, 64, 64 * t); + } + g.generateTexture('mj-glow', 128, 128); + g.destroy(); + + g = s.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('mj-dot', 8, 8); + g.destroy(); + + // Four-point star for the brighter sparks. + g = s.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillPoints([ + { x: 16, y: 0 }, { x: 19, y: 13 }, { x: 32, y: 16 }, { x: 19, y: 19 }, + { x: 16, y: 32 }, { x: 13, y: 19 }, { x: 0, y: 16 }, { x: 13, y: 13 }, + ], true); + g.generateTexture('mj-spark', 32, 32); + g.destroy(); + + g = s.add.graphics(); + g.lineStyle(5, 0xffffff, 1); + g.strokeCircle(32, 32, 27); + g.generateTexture('mj-ring', 64, 64); + g.destroy(); + } + + // The shard source texture has to match the current tile size, which the + // layout picks. Cheap to rebuild — it only changes when the board does. + ensureFaceTexture(tileW, tileH, thick) { + const w = Math.round(tileW); + const h = Math.round(tileH); + if (this._faceW === w && this._faceH === h) return; + + const s = this.scene; + if (s.textures.exists(FACE_KEY)) s.textures.remove(FACE_KEY); + + const r = Math.max(4, thick); + const g = s.add.graphics(); + g.fillStyle(FACE_FILL, 1); + g.fillRoundedRect(0, 0, w, h, r); + g.lineStyle(2, FACE_EDGE, 1); + g.strokeRoundedRect(1, 1, w - 2, h - 2, r); + g.generateTexture(FACE_KEY, w, h); + g.destroy(); + + this._faceW = w; + this._faceH = h; + } + + // Breaks a tile face into four tumbling quadrants. + // + // Each shard is a container holding cropped copies of the face (and the + // label art, when the face has any). The children sit at -quadrantCentre and + // the container at tileCentre + quadrantCentre, so the piece starts exactly + // where it was drawn but rotates about its own centre rather than swinging + // around the middle of the tile. + shatter(x, y, labelKey, labelScale, tint) { + if (!this._faceW) return; + const s = this.scene; + const W = this._faceW; + const H = this._faceH; + + for (let qy = 0; qy < 2; qy++) { + for (let qx = 0; qx < 2; qx++) { + if (this.shards >= JUICE.maxShards) return; + + const ox = (qx === 0 ? -1 : 1) * W / 4; + const oy = (qy === 0 ? -1 : 1) * H / 4; + + const face = s.add.image(-ox, -oy, FACE_KEY) + .setCrop(qx * W / 2, qy * H / 2, W / 2, H / 2); + + const parts = [face]; + if (labelKey && s.textures.exists(labelKey)) { + const label = s.add.image(-ox, -oy, labelKey) + .setScale(labelScale) + .setCrop(qx * LABEL_W / 2, qy * LABEL_H / 2, LABEL_W / 2, LABEL_H / 2); + parts.push(label); + } + + const shard = s.add.container(x + ox, y + oy, parts); + this.layer.add(shard); + this.shards++; + + const life = JUICE.shardLife; + const secs = life / 1000; + const dir = Math.sign(ox) || 1; + const vx = dir * Phaser.Math.Between(...JUICE.shardSpeed); + const vy0 = JUICE.shardLift + Phaser.Math.Between(-60, 60); + const sx = shard.x; + const sy = shard.y; + + s.tweens.add({ + targets: shard, + x: sx + vx * secs, + rotation: Phaser.Math.FloatBetween(...JUICE.shardSpin), + duration: life, + ease: 'Linear', + onUpdate: (tw) => { + const t = tw.progress * secs; + shard.y = sy + vy0 * t + 0.5 * JUICE.shardGravity * t * t; + shard.alpha = tw.progress < 0.6 ? 1 : 1 - (tw.progress - 0.6) / 0.4; + }, + onComplete: () => { shard.destroy(true); this.shards--; }, + }); + } + } + + this.burst(x, y, JUICE.sparks, tint); + } + + burst(x, y, count = JUICE.sparks, tint = 0xffffff) { + const s = this.scene; + const dots = s.add.particles(x, y, 'mj-dot', { + speed: { min: JUICE.sparkSpeed[0], max: JUICE.sparkSpeed[1] }, + lifespan: JUICE.sparkLife, + scale: { start: 1.1, end: 0 }, + alpha: { start: 1, end: 0 }, + tint: [tint, 0xffffff], + blendMode: 'ADD', + emitting: false, + }); + this.layer.add(dots); + dots.explode(count); + + const stars = s.add.particles(x, y, 'mj-spark', { + speed: { min: 60, max: 190 }, + lifespan: JUICE.sparkLife + 120, + scale: { start: 0.7, end: 0 }, + rotate: { start: 0, end: 180 }, + alpha: { start: 1, end: 0 }, + tint, + blendMode: 'ADD', + emitting: false, + }); + this.layer.add(stars); + stars.explode(JUICE.stars); + + s.time.delayedCall(JUICE.sparkLife + 220, () => { dots.destroy(); stars.destroy(); }); + } + + flash(x, y, tint = 0xffffff) { + const img = this.scene.add.image(x, y, 'mj-glow') + .setTint(tint).setBlendMode(Phaser.BlendModes.ADD).setScale(0.3).setAlpha(0.95); + this.layer.add(img); + this.scene.tweens.add({ + targets: img, + scale: JUICE.flashScale, alpha: 0, + duration: JUICE.flashMs, ease: 'Quad.easeOut', + onComplete: () => img.destroy(), + }); + } + + ring(x, y, tint = 0xffffff) { + const img = this.scene.add.image(x, y, 'mj-ring') + .setTint(tint).setBlendMode(Phaser.BlendModes.ADD).setScale(0.2).setAlpha(0.9); + this.layer.add(img); + this.scene.tweens.add({ + targets: img, + scale: JUICE.ringScale, alpha: 0, + duration: JUICE.ringMs, ease: 'Cubic.easeOut', + onComplete: () => img.destroy(), + }); + } + + floatText(x, y, str, color, { size = 34, rise = 70, dur = 800, delay = 0 } = {}) { + const txt = this.scene.add.text(x, y, str, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, + stroke: '#000000', strokeThickness: 5, + }).setOrigin(0.5).setScale(0.4).setAlpha(0); + this.layer.add(txt); + + this.scene.tweens.add({ + targets: txt, scale: 1, alpha: 1, + duration: 150, delay, ease: 'Back.easeOut', + }); + this.scene.tweens.add({ + targets: txt, y: y - rise, alpha: 0, + duration: dur, delay: delay + 180, ease: 'Quad.easeOut', + onComplete: () => txt.destroy(), + }); + } +} diff --git a/src/games/mahjongmatch/MahjongMatchGame.js b/src/games/mahjongmatch/MahjongMatchGame.js index a69e77b..0402e88 100644 --- a/src/games/mahjongmatch/MahjongMatchGame.js +++ b/src/games/mahjongmatch/MahjongMatchGame.js @@ -2,12 +2,13 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; -import { playSound, SFX } from '../../ui/Sounds.js'; +import { playSound, playSoundEx, SFX } from '../../ui/Sounds.js'; import { api } from '../../services/api.js'; import { LAYOUTS, LAYOUT_ORDER, layoutBounds, newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining, } from './MahjongLogic.js'; +import MahjongFx, { JUICE, tintForFace } from './MahjongFx.js'; // Deep-green felt with ivory tiles — the classic mahjong table look. const FELT = 0x0e2a1c; @@ -27,7 +28,29 @@ const PREVIEW_Z = [0x9c8f6e, 0xb3a47e, 0xcab98e, 0xe0cf9f, 0xf6e6b0]; const LABEL_W = 128; const LABEL_H = 178; -const D = { bg: -2, ui: 30 }; +// A Phaser Container renders its children in insertion order and ignores their +// depth, so the board / FX / HUD are separate ROOT containers — only the scene +// display list actually depth-sorts. +const D = { bg: -2, board: 0, fx: 20, ui: 30, overlay: 40 }; + +// The match cue: a clack, then one of the gem tones with a little rate jitter +// so 72 matches in a row don't sound like a metronome. +const GEM_KEYS = [SFX.GEM_MATCH_1, SFX.GEM_MATCH_2, SFX.GEM_MATCH_4, SFX.GEM_MATCH_5]; + +// Ascending chime for tiles the match unblocked — roughly a major scale, so a +// big opening arpeggiates instead of just getting louder. +const CHIME_RATES = [1, 1.122, 1.26, 1.335, 1.5, 1.682, 1.888, 2]; +const MAX_FREED_CUES = CHIME_RATES.length; + +const CALLOUTS = ['MATCH!', 'NICE!', 'SNAP!', 'CLEAN!', 'GOT IT!', 'SHARP!']; +const FREED_GOLD = 0xffd15c; + +// In-play backdrops, one picked per game. The art is detailed and two of the +// three are brightly lit, so a dark scrim sits over it to keep the ivory tiles +// and the HUD readable — drop SCRIM_ALPHA toward 0 for more of the artwork. +const BG_KEYS = ['bg-mm-01', 'bg-mm-02', 'bg-mm-03']; +const SCRIM = 0x06140d; +const SCRIM_ALPHA = 0.45; export default class MahjongMatchGame extends Phaser.Scene { constructor() { super('MahjongMatchGame'); } @@ -48,6 +71,12 @@ export default class MahjongMatchGame extends Phaser.Scene { this.tilesText = null; this.movesText = null; this.timerText = null; + this.fxLayer = null; + this.hudLayer = null; + this.labelScale = 1; + // Bumped whenever the tiles are rebuilt. Deferred match animations carry a + // copy and bail if it moved on, so they can't act on a different board. + this.boardEpoch = 0; } create() { @@ -57,7 +86,8 @@ export default class MahjongMatchGame extends Phaser.Scene { } catch (_) { /* optional */ } this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg); - this.layer = this.add.container(0, 0); + this.layer = this.add.container(0, 0).setDepth(D.board); + this.fx = new MahjongFx(this, this.layer); this.showLayoutSelect(); } @@ -66,6 +96,9 @@ export default class MahjongMatchGame extends Phaser.Scene { if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; } if (this.overlay) { this.overlay.destroy(true); this.overlay = null; } this.layer.removeAll(true); + if (this.fxLayer) { this.fxLayer.destroy(true); this.fxLayer = null; } + if (this.hudLayer) { this.hudLayer.destroy(true); this.hudLayer = null; } + this.fx.setLayer(this.layer); this.tileObjs = []; this.selected = null; this.hintPair = null; @@ -82,18 +115,19 @@ export default class MahjongMatchGame extends Phaser.Scene { this.clearLayer(); const cx = GAME_WIDTH / 2; - const title = this.add.text(cx, 100, 'MAHJONG MATCH', { - fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex, - }).setOrigin(0.5); - const sub = this.add.text(cx, 178, 'Clear the board by matching free pairs. A tile is free when nothing rests on it and a side is open.', { - fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, - }).setOrigin(0.5); - this.layer.add([title, sub]); + // The artwork carries the title, so nothing is drawn over the top third. + if (this.textures.exists('bg-mahjongmatch-menu')) { + this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-mahjongmatch-menu') + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); + } + // Cards sit below the wordmark and its brush flourish (which ends ~y=390) + // and clear the Back button at the bottom, so they're a little shorter + // than the two rows would otherwise allow. const CARD_W = 480; - const CARD_H = 310; + const CARD_H = 250; const GAP_X = 60; - const ROW_Y = [420, 770]; + const ROW_Y = [525, 825]; const totalW = 3 * CARD_W + 2 * GAP_X; const left = cx - totalW / 2 + CARD_W / 2; @@ -107,17 +141,17 @@ export default class MahjongMatchGame extends Phaser.Scene { this.layer.add(card); const preview = this.add.graphics(); - this._drawLayoutPreview(preview, layout, x, y - 60, 320, 150); + this._drawLayoutPreview(preview, layout, x, y - 46, 300, 118); this.layer.add(preview); - const name = this.add.text(x, y + 52, layout.name, { - fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + const name = this.add.text(x, y + 40, layout.name, { + fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex, }).setOrigin(0.5); - const info = this.add.text(x, y + 96, `${layout.positions.length} tiles · ${layout.desc}`, { + const info = this.add.text(x, y + 74, `${layout.positions.length} tiles · ${layout.desc}`, { fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.mutedHex, }).setOrigin(0.5); const best = this._bestFor(key); - const bestLbl = this.add.text(x, y + 130, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', { + const bestLbl = this.add.text(x, y + 102, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, }).setOrigin(0.5); this.layer.add([name, info, bestLbl]); @@ -176,12 +210,23 @@ export default class MahjongMatchGame extends Phaser.Scene { this.overlayUp = false; this.clearLayer(); + this._drawBackground(); this._computeLayout(); this._buildTiles(); this._drawHud(); this._startTimer(); } + // Added to `this.layer` before the tiles, so insertion order keeps it behind + // them. If the texture hasn't loaded, the felt rectangle underneath shows. + _drawBackground() { + const key = BG_KEYS[Math.floor(Math.random() * BG_KEYS.length)]; + if (!this.textures.exists(key)) return; + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + this.layer.add(this.add.image(cx, cy, key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); + this.layer.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, SCRIM, SCRIM_ALPHA)); + } + // Fit the layout into the area right of the button strip. _computeLayout() { const { spanX, spanY, maxZ } = layoutBounds(this.g.positions); @@ -201,6 +246,9 @@ export default class MahjongMatchGame extends Phaser.Scene { const visH = spanY * this.halfH + (maxZ + 1) * this.thick; this.originX = LEFT + (availW - visW) / 2 + maxZ * this.thick; this.originY = TOP + (availH - visH) / 2 + maxZ * this.thick; + + // Shards are cut from a face texture sized to these tiles. + this.fx.ensureFaceTexture(this.tileW, this.tileH, this.thick); } _tileScreenPos(i) { @@ -213,6 +261,14 @@ export default class MahjongMatchGame extends Phaser.Scene { _buildTiles() { this.tileObjs = []; + this.boardEpoch++; + this.labelScale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H); + + // Root-level, above the board container — NOT a child of it. + if (this.fxLayer) this.fxLayer.destroy(true); + this.fxLayer = this.add.container(0, 0).setDepth(D.fx); + this.fx.setLayer(this.fxLayer); + const order = this.g.positions .map((_, i) => i) .filter((i) => this.g.alive[i]) @@ -230,8 +286,7 @@ export default class MahjongMatchGame extends Phaser.Scene { let label = null; const face = this.g.faces[i]; if (face.label && this.textures.exists(face.label)) { - const scale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H); - label = this.add.image(0, 0, face.label).setScale(scale); + label = this.add.image(0, 0, face.label).setScale(this.labelScale); container.add(label); } @@ -297,24 +352,28 @@ export default class MahjongMatchGame extends Phaser.Scene { const cx = GAME_WIDTH / 2; const layout = LAYOUTS[this.layoutKey]; + // Root-level, above both the board and the effects. + if (this.hudLayer) this.hudLayer.destroy(true); + this.hudLayer = this.add.container(0, 0).setDepth(D.ui); + const title = this.add.text(40, 64, 'MAHJONG MATCH', { fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex, - }).setOrigin(0, 0.5).setDepth(D.ui); + }).setOrigin(0, 0.5); const diff = this.add.text(40, 106, layout.name, { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, - }).setOrigin(0, 0.5).setDepth(D.ui); - this.layer.add([title, diff]); + }).setOrigin(0, 0.5); + this.hudLayer.add([title, diff]); this.tilesText = this.add.text(cx, 56, '', { fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, - }).setOrigin(0.5).setDepth(D.ui); + }).setOrigin(0.5); this.movesText = this.add.text(cx, 100, '', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, - }).setOrigin(0.5).setDepth(D.ui); + }).setOrigin(0.5); this.timerText = this.add.text(GAME_WIDTH - 50, 155, '', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, - }).setOrigin(1, 0.5).setDepth(D.ui); - this.layer.add([this.tilesText, this.movesText, this.timerText]); + }).setOrigin(1, 0.5); + this.hudLayer.add([this.tilesText, this.movesText, this.timerText]); const stripCx = 150; const BTN_W = 220, BTN_H = 58, BTN_GAP = 16; @@ -331,7 +390,7 @@ export default class MahjongMatchGame extends Phaser.Scene { btnY += BTN_H + BTN_GAP; const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); - this.layer.add([hint, shuffleB, restart, layouts]); + this.hudLayer.add([hint, shuffleB, restart, layouts]); this._updateHud(); } @@ -372,16 +431,23 @@ export default class MahjongMatchGame extends Phaser.Scene { const a = this.selected; this.selected = null; this._clearHint(); + const face = this.g.faces[a]; + const freeBefore = this._freeSet(); if (!removePair(this.g, a, i)) return; - playSound(this, SFX.CARD_PLACE); - this._animateRemoval(a); - this._animateRemoval(i); + this._celebrateMatch(a, i, face, freeBefore); this._refreshTiles(); this._updateHud(); - if (this.g.state === 'won') { - this._onWin(); - } else if (findMoves(this.g).length === 0) { - this._onStuck(true); + + // Let the last pair actually break before the overlay covers it. The + // clock stops now, though, so the delay can't cost the player a second. + const won = this.g.state === 'won'; + if (won || findMoves(this.g).length === 0) { + if (won && this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } + const epoch = this.boardEpoch; + this.time.delayedCall(JUICE.snapMs + 300, () => { + if (this.view !== 'play' || this.boardEpoch !== epoch) return; + if (won) this._onWin(); else this._onStuck(true); + }); } return; } @@ -403,14 +469,122 @@ export default class MahjongMatchGame extends Phaser.Scene { }); } - _animateRemoval(i) { + // ── Match feedback ──────────────────────────────────────────────────────────── + + _freeSet() { + const set = new Set(); + for (let i = 0; i < this.g.positions.length; i++) { + if (this.g.alive[i] && isFree(this.g, i)) set.add(i); + } + return set; + } + + // The pair punches together, then breaks. `face` and `freeBefore` are + // captured before the removal, since both are gone from the model after it. + _celebrateMatch(a, b, face, freeBefore) { + const pa = this._tileScreenPos(a); + const pb = this._tileScreenPos(b); + const mx = (pa.x + pb.x) / 2; + const my = (pa.y + pb.y) / 2; + const tint = tintForFace(face); + + playSound(this, SFX.UI_PLACE); + + for (const [idx, p] of [[a, pa], [b, pb]]) { + const o = this.tileObjs[idx]; + if (!o) continue; + this.tweens.add({ + targets: o.container, + x: p.x + (mx - p.x) * JUICE.snapPull, + y: p.y + (my - p.y) * JUICE.snapPull, + scaleX: JUICE.snapScale, scaleY: JUICE.snapScale, + duration: JUICE.snapMs, ease: 'Back.easeOut', + }); + } + + const epoch = this.boardEpoch; + this.time.delayedCall(JUICE.snapMs, () => this._breakPair(a, b, mx, my, tint, freeBefore, epoch)); + } + + _breakPair(a, b, mx, my, tint, freeBefore, epoch) { + // The board was rebuilt (new game or shuffle) while the snap was playing — + // these indices now mean different tiles. + if (this.view !== 'play' || !this.fxLayer || this.boardEpoch !== epoch) return; + + this._shatterTile(a, tint); + this._shatterTile(b, tint); + this.fx.flash(mx, my, tint); + this.fx.ring(mx, my, tint); + this.cameras.main.shake(JUICE.shakeMs, JUICE.shakeAmp); + + const gem = GEM_KEYS[Math.floor(Math.random() * GEM_KEYS.length)]; + playSoundEx(this, gem, { rate: Phaser.Math.FloatBetween(0.94, 1.08), volume: 0.9 }); + playSoundEx(this, SFX.GEM_DROP, { volume: 0.35 }); + + this._recoilFrom(mx, my); + + const freed = []; + for (let i = 0; i < this.g.positions.length; i++) { + if (this.g.alive[i] && isFree(this.g, i) && !freeBefore.has(i)) freed.push(i); + } + this._celebrateFreed(freed, epoch); + + const word = CALLOUTS[Math.floor(Math.random() * CALLOUTS.length)]; + this.fx.floatText(mx, my - 20, word, COLORS.goldHex, { size: 40 }); + if (freed.length) { + this.fx.floatText(mx, my + 26, `+${freed.length} OPENED`, '#7fe3a0', + { size: 28, delay: 140, rise: 56 }); + } + } + + // Hides the tile and hands its face to the shard effect. The container is + // kept (invisible) so `_rebuildTiles` / `clearLayer` still own its lifetime. + _shatterTile(i, tint) { const o = this.tileObjs[i]; if (!o) return; - this.tweens.add({ - targets: o.container, - alpha: 0, y: o.container.y - 26, scaleX: 0.7, scaleY: 0.7, - duration: 230, ease: 'Quad.easeIn', - onComplete: () => o.container.setVisible(false), + const { x, y } = o.container; + o.container.setVisible(false); + this.fx.shatter(x, y, this.g.faces[i].label, this.labelScale, tint); + } + + // Nearby tiles get shoved away from the break and spring back — the board + // reacting is what sells the hit as physical. + _recoilFrom(x, y) { + const radius = JUICE.recoilRadius * this.tileW; + for (let i = 0; i < this.g.positions.length; i++) { + if (!this.g.alive[i]) continue; + const o = this.tileObjs[i]; + if (!o) continue; + const base = this._tileScreenPos(i); + const dx = base.x - x, dy = base.y - y; + const d = Math.hypot(dx, dy); + if (d < 1 || d > radius) continue; + const push = JUICE.recoilPush * (1 - d / radius); + this.tweens.add({ + targets: o.container, + x: base.x + (dx / d) * push, + y: base.y + (dy / d) * push, + duration: JUICE.recoilMs, yoyo: true, ease: 'Quad.easeOut', + onComplete: () => { if (o.container.active) o.container.setPosition(base.x, base.y); }, + }); + } + } + + // Tiles the match just unblocked flash gold and ring out an ascending chime. + _celebrateFreed(freed, epoch) { + freed.slice(0, MAX_FREED_CUES).forEach((idx, n) => { + this.time.delayedCall(n * JUICE.freedStagger, () => { + const o = this.tileObjs[idx]; + if (this.view !== 'play' || this.boardEpoch !== epoch || !o || !this.g.alive[idx]) return; + const p = this._tileScreenPos(idx); + this.fx.ring(p.x, p.y, FREED_GOLD); + this.tweens.add({ + targets: o.container, scaleX: 1.1, scaleY: 1.1, + duration: JUICE.freedPulseMs / 2, yoyo: true, ease: 'Quad.easeOut', + onComplete: () => { if (o.container.active) o.container.setScale(1); }, + }); + playSoundEx(this, SFX.UI_CHIME, { rate: CHIME_RATES[n], volume: 0.5 }); + }); }); } @@ -455,7 +629,7 @@ export default class MahjongMatchGame extends Phaser.Scene { _makeOverlayPanel(strokeColor) { const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; - this.overlay = this.add.container(0, 0); + this.overlay = this.add.container(0, 0).setDepth(D.overlay); this.overlayUp = true; const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); @@ -471,6 +645,7 @@ export default class MahjongMatchGame extends Phaser.Scene { _onWin() { if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } playSound(this, SFX.VICTORY_SHORT); + playSoundEx(this, SFX.FIREWORK, { volume: 0.7 }); const lsKey = `mahjongmatch-best-${this.layoutKey}`; const prev = this._bestFor(this.layoutKey); diff --git a/src/games/puddingmonsters/PuddingMonstersGame.js b/src/games/puddingmonsters/PuddingMonstersGame.js index cdf7eb7..63010a7 100644 --- a/src/games/puddingmonsters/PuddingMonstersGame.js +++ b/src/games/puddingmonsters/PuddingMonstersGame.js @@ -3,9 +3,10 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { playSound, SFX } from '../../ui/Sounds.js'; +import { Tooltip } from '../../ui/Tooltip.js'; import { api } from '../../services/api.js'; import { - DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, targetsCovered, + DIRS, newState, cloneState, planFlick, applyPlan, solve, blobAt, repCell, targetsCovered, } from './PuddingMonstersLogic.js'; const BG = 0x161226; @@ -18,9 +19,32 @@ const TARGET_LN = 0x88e4ff; const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d]; +const SLIME = 0x63c132; // green monsters and the trail they leave +const SLIME_DK = 0x3f8b1c; +const HYPNO = 0xb073ff; // hive-mind monsters +const ICE = 0xbfe9ff; +const ICE_DK = 0x6fb6d8; +const LIFTER = 0xff8a3d; // powerlifter monsters +const SPRING = 0xffd166; +const SPRING_DK = 0xb07d18; +const BUTTON = 0xff5d73; +const BRICK = 0xa2664a; +const BRICK_DK = 0x6b3f2c; +const CRATE = 0xc08a4a; +const CRATE_DK = 0x7d5527; +const TUNNEL_COLORS = [0x66f0d0, 0xff8ad8, 0x9ecbff, 0xffe066]; + // Each cell carries the index of the monster that started there (cells[i][2]), // so a monster keeps its colour and face after it merges into a bigger blob. -const cellColor = (m) => PALETTE[(m ?? 0) % PALETTE.length]; +// Green and hypno monsters override the palette so their type reads at a glance +// (see this.cellColor / this.monsterKind). +const paletteColor = (m) => PALETTE[(m ?? 0) % PALETTE.length]; + +// Level-select pip colours, one per mechanic a level is built around. +const ELEMENT_PIP = { + sleepers: 0xe8e2ff, ice: ICE, green: SLIME, hypno: HYPNO, + springs: SPRING, tunnels: TUNNEL_COLORS[0], buttons: BUTTON, powerlifters: LIFTER, +}; const shade = (color, f) => { const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f)); return (ch(16) << 16) | (ch(8) << 8) | ch(0); @@ -30,7 +54,41 @@ const mix = (a, b) => { return (ch(16) << 16) | (ch(8) << 8) | ch(0); }; -const D = { frame: -1, floor: 0, deco: 1, monster: 10, target: 11, anim: 12, ui: 30, overlay: 60, overlayUI: 62 }; +// A little crown: the award for solving in par or fewer, kept visually distinct +// from the stars (which are about where the blob finishes, not how fast). +function drawCrown(g, cx, cy, r, color, alpha = 1) { + g.fillStyle(color, alpha); + g.beginPath(); + g.moveTo(cx - r, cy + r * 0.7); + g.lineTo(cx - r * 1.05, cy - r * 0.8); + g.lineTo(cx - r * 0.42, cy - r * 0.1); + g.lineTo(cx, cy - r * 1.0); + g.lineTo(cx + r * 0.42, cy - r * 0.1); + g.lineTo(cx + r * 1.05, cy - r * 0.8); + g.lineTo(cx + r, cy + r * 0.7); + g.closePath(); + g.fillPath(); +} + +const D = { + frame: -1, floor: 0, slime: 0.5, inlay: 0.6, brickDown: 0.7, deco: 1, + monster: 10, target: 11, anim: 12, ui: 30, overlay: 60, overlayUI: 62, +}; + +// Position along a polyline route at 0..1 (t may overshoot 1 — the slide tween +// uses a Back ease — so the final segment extrapolates). A point flagged as a +// teleport is snapped to rather than tweened through: the blob is in one mouth +// and then the other, never in between. +function pointAlong(pts, t) { + const segs = pts.length - 1; + if (segs <= 0) return [pts[0][0], pts[0][1]]; + const f = t * segs; + const i = Math.max(0, Math.min(segs - 1, Math.floor(f))); + const frac = f - i; + const a = pts[i], b = pts[i + 1]; + if (b[2] === 1) return frac < 0.5 ? [a[0], a[1]] : [b[0], b[1]]; + return [a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac]; +} export default class PuddingMonstersGame extends Phaser.Scene { constructor() { super('PuddingMonstersGame'); } @@ -38,6 +96,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { init(data) { this.gameDef = data.game ?? { slug: 'puddingmonsters', name: 'Jell-o Monsters' }; this.bank = []; + this.chapters = []; this.levelsCompleted = 0; this.canPersist = true; this.view = 'select'; @@ -58,6 +117,9 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.eyeAnimActive = false; this.blobGfx = []; this.beaconTimer = null; + this.iceObjs = new Map(); + this.crateObjs = new Map(); + this.monsterKind = new Map(); } async create() { @@ -70,6 +132,11 @@ export default class PuddingMonstersGame extends Phaser.Scene { const raw = this.cache.json.get('puddingmonsters'); this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + // Older banks have no chapters; fall back to one unnamed chapter so the + // level select still renders. + this.chapters = raw?.chapters?.length ? raw.chapters : (this.bank.length + ? [{ id: 1, name: 'Levels', blurb: '', from: 1, to: this.bank.length }] + : []); try { const res = await api.get('/puzzles/puddingmonsters/progress'); @@ -91,33 +158,60 @@ export default class PuddingMonstersGame extends Phaser.Scene { } clearLayer() { + this.tooltip?.destroy(); + this.tooltip = null; this.eyeAnimActive = false; this.eyeTimers.forEach((t) => t.remove(false)); this.eyeTimers = []; if (this.beaconTimer) { this.beaconTimer.remove(false); this.beaconTimer = null; } + this.rowsTween?.stop(); + this.rowsTween = null; this.eyeOffsets = new Map(); this.layer.removeAll(true); this.blobGfx = []; this.targetGfx = null; this.animGfx = null; + this.slimeGfx = null; + this.brickGfx = null; + this.iceObjs = new Map(); + this.crateObjs = new Map(); this.undoBtn = null; this.movesText = null; this.starsText = null; + this.crownText = null; + this.crownGfx = null; } + // Wave 3 split the award in two, matching the original: STARS are the yellow + // squares your final blob covers (0-3), the CROWN is solving in par or fewer. + // They are independent — you can crown a level with one star and vice versa. + // Keys are namespaced `pm3` because the Wave 3 bank renumbered every level, + // so the old `pm-stars-` medals describe puzzles that no longer exist. bestStars(level) { - try { return Number(localStorage.getItem(`pm-stars-${level}`)) || 0; } catch (_) { return 0; } + try { return Number(localStorage.getItem(`pm3-stars-${level}`)) || 0; } catch (_) { return 0; } } saveStars(level, stars) { try { - if (stars > this.bestStars(level)) localStorage.setItem(`pm-stars-${level}`, String(stars)); + if (stars > this.bestStars(level)) localStorage.setItem(`pm3-stars-${level}`, String(stars)); } catch (_) { /* ignore */ } } - // 3-star medal by efficiency: full marks at par, one star off per move over. - medalStars(moves, par) { - return Math.max(0, 3 - Math.max(0, moves - par)); + hasCrown(level) { + try { return localStorage.getItem(`pm3-crown-${level}`) === '1'; } catch (_) { return false; } + } + + saveCrown(level, earned) { + try { if (earned) localStorage.setItem(`pm3-crown-${level}`, '1'); } catch (_) { /* ignore */ } + } + + // Live readouts while playing. + liveStars() { return targetsCovered(this.state, this.levelDef.targets); } + + liveCrown() { return this.moves <= this.par; } + + chapterOf(level) { + return this.chapters.find((c) => level >= c.from && level <= c.to) ?? null; } // ── Level select ──────────────────────────────────────────────────────────── @@ -130,16 +224,13 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.clearLayer(); const cx = GAME_WIDTH / 2; - const title = this.add.text(cx, 84, 'JELL-O MONSTERS', { - fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, - }).setOrigin(0.5); - const sub = this.add.text(cx, 140, 'Flick the jellies — only walls & friends stop a slide, the open edges drop you! Merge into one on the yellow squares, in par, for ★★★.', { - fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, - }).setOrigin(0.5); - this.layer.add([title, sub]); + if (this.textures.exists('bg-puddingmonsters-menu')) { + this.layer.add(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'bg-puddingmonsters-menu') + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); + } if (!this.bank.length) { - const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genPuddingMonsters.js', { + const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genPuddingMonsters.js', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', }).setOrigin(0.5); this.layer.add(msg); @@ -149,76 +240,146 @@ export default class PuddingMonstersGame extends Phaser.Scene { } const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); - const prog = this.add.text(cx, 184, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { - fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, - }).setOrigin(0.5); + const starTotal = this.bank.reduce((t, p) => t + this.bestStars(p.level), 0); + const crownTotal = this.bank.reduce((t, p) => t + (this.hasCrown(p.level) ? 1 : 0), 0); + const prog = this.add.text(cx, 318, + `${this.levelsCompleted} / ${this.bank.length} cleared ★ ${starTotal} / ${this.bank.length * 3} ♔ ${crownTotal} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '23px', color: COLORS.textHex, + }).setOrigin(0.5); this.layer.add(prog); - const COLS = 10; - const SIZE = 120; - const GAP = 16; - const gridW = COLS * SIZE + (COLS - 1) * GAP; - const left = cx - gridW / 2 + SIZE / 2; - const top = 280; + this.tooltip = new Tooltip(this, { depth: D.overlayUI }); - this.bank.forEach((p, i) => { - const col = i % COLS; - const row = Math.floor(i / COLS); - const x = left + col * (SIZE + GAP); - const y = top + row * (SIZE + GAP); - const level = p.level; - const cleared = level <= this.levelsCompleted; - const playable = level <= nextLevel; + // One row per chapter: a heading on the left, its levels to the right. + const S = 84; + const GAP = 9; + const HEAD_W = 340; + const widest = Math.max(...this.chapters.map((c) => c.to - c.from + 1)); + const gridW = widest * S + (widest - 1) * GAP; + const headX = cx - (HEAD_W + gridW) / 2; + const tileX0 = headX + HEAD_W + S / 2; + const top = 395; + const pitch = S + 38; - const fill = cleared ? 0x35265a : playable ? 0x271f44 : 0x191427; - const stroke = cleared ? 0xc792ff : playable ? COLORS.gold : 0x2a2440; - const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); - const num = this.add.text(x, y - 18, String(level), { - fontFamily: 'Righteous', fontSize: '40px', - color: playable || cleared ? COLORS.textHex : '#564f6b', - }).setOrigin(0.5); - this.layer.add([tile, num]); + const stripPad = 16; + const stripLeft = headX - stripPad; + const stripRight = tileX0 + (widest - 1) * (S + GAP) + S / 2 + stripPad; - if (playable || cleared) { - const earned = this.bestStars(level); - const stars = this.add.text(x, y + 30, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), { - fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#6b5a8a', - }).setOrigin(0.5); - const par = this.add.text(x, y + 52, `par ${p.par}`, { - fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, - }).setOrigin(0.5); - this.layer.add([stars, par]); - } else { - const lock = this.add.text(x, y + 36, 'locked', { - fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#564f6b', - }).setOrigin(0.5); - this.layer.add(lock); - } + // The rows start invisible so the background art gets a beat on its own + // before the level grid fades in on top of it. + const rows = this.add.container(0, 0).setAlpha(0); + this.layer.add(rows); - if (playable) { - tile.setInteractive({ useHandCursor: true }); - tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); - tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); - tile.on('pointerup', () => this.playLevel(level)); + this.chapters.forEach((ch, ci) => { + const y = top + ci * pitch; + const unlocked = ch.from <= nextLevel; + const cleared = Math.max(0, Math.min(ch.to, this.levelsCompleted) - ch.from + 1); + const size = ch.to - ch.from + 1; + + const strip = this.add.rectangle((stripLeft + stripRight) / 2, y, stripRight - stripLeft, S + 30, 0x000000, 0.45); + rows.add(strip); + + const name = this.add.text(headX, y - 16, `${ch.id}. ${ch.name}`, { + fontFamily: 'Righteous', fontSize: '27px', + color: unlocked ? COLORS.goldHex : '#564f6b', + }).setOrigin(0, 0.5); + const blurb = this.add.text(headX, y + 14, unlocked ? ch.blurb : 'Clear the chapter above to open this one.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', + color: unlocked ? COLORS.mutedHex : '#463f5c', + wordWrap: { width: HEAD_W - 24 }, + }).setOrigin(0, 0.5); + const count = this.add.text(headX + HEAD_W - 30, y - 16, `${cleared}/${size}`, { + fontFamily: 'Righteous', fontSize: '20px', + color: cleared === size ? '#7ed957' : COLORS.mutedHex, + }).setOrigin(1, 0.5); + rows.add([name, blurb, count]); + + for (let i = 0; i < size; i++) { + const p = this.bank.find((b) => b.level === ch.from + i); + if (!p) continue; + this.addLevelTile(p, tileX0 + i * (S + GAP), y, S, nextLevel, rows); } }); - const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), - { width: 300, height: 58, fontSize: 24 }); - const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'), - { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); - const reset = new Button(this, 210, GAME_HEIGHT - 76, 'Reset Progress', () => this.confirmResetProgress(), - { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.rowsTween = this.tweens.add({ + targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear', + }); + + const resume = new Button(this, cx - 160, GAME_HEIGHT - 72, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), + { width: 300, height: 56, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 72, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 56, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 72, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 56, fontSize: 22, textColor: COLORS.dangerHex }); this.layer.add([resume, back, reset]); if (!this.canPersist) { - const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', { - fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + const note = this.add.text(cx, GAME_HEIGHT - 24, 'Progress is saved in this browser.', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, }).setOrigin(0.5); this.layer.add(note); } } + addLevelTile(p, x, y, S, nextLevel, target = this.layer) { + const level = p.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + + const fill = cleared ? 0x35265a : playable ? 0x271f44 : 0x191427; + const stroke = cleared ? 0xc792ff : playable ? COLORS.gold : 0x2a2440; + const tile = this.add.rectangle(x, y, S, S, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - S * 0.14, String(level), { + fontFamily: 'Righteous', fontSize: `${Math.round(S * 0.32)}px`, + color: playable || cleared ? COLORS.textHex : '#564f6b', + }).setOrigin(0.5); + target.add([tile, num]); + + if (!playable) { + const lock = this.add.text(x, y + S * 0.28, 'locked', { + fontFamily: '"Julius Sans One"', fontSize: `${Math.round(S * 0.14)}px`, color: '#564f6b', + }).setOrigin(0.5); + target.add(lock); + return; + } + + const earned = this.bestStars(level); + const stars = this.add.text(x, y + S * 0.26, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), { + fontFamily: 'serif', fontSize: `${Math.round(S * 0.19)}px`, color: earned ? '#ffd54a' : '#6b5a8a', + }).setOrigin(0.5); + target.add(stars); + + // A pip for the mechanic the level is built around; a crown once earned. + if (p.element) { + const pip = this.add.circle(x + S * 0.34, y - S * 0.34, Math.max(4, S * 0.06), + ELEMENT_PIP[p.element] ?? 0xffffff).setStrokeStyle(1, 0x000000, 0.35); + target.add(pip); + } + if (this.hasCrown(level)) { + const g = this.add.graphics(); + drawCrown(g, x - S * 0.32, y - S * 0.32, S * 0.15, 0xffd54a, 1); + target.add(g); + } + if (p.teaching) { + const badge = this.add.text(x - S * 0.36, y + S * 0.36, 'NEW', { + fontFamily: 'Righteous', fontSize: `${Math.round(S * 0.13)}px`, color: '#7ed957', + }).setOrigin(0, 1); + target.add(badge); + } + + tile.setInteractive({ useHandCursor: true }); + tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); + tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); + tile.on('pointerup', () => this.playLevel(level)); + this.tooltip?.attachTo(tile, () => ({ + title: `${level}. ${p.name ?? ''}`, + lines: [ + `Par ${p.par}${this.hasCrown(level) ? ' · crowned' : ''}`, + p.tip ?? (p.elements?.length ? p.elements.join(' + ') : 'Stick every monster together.'), + ], + })); + } + confirmResetProgress() { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; @@ -245,7 +406,13 @@ export default class PuddingMonstersGame extends Phaser.Scene { doResetProgress() { api.post('/puzzles/puddingmonsters/reset').catch(() => { /* best effort */ }); this.levelsCompleted = 0; - try { this.bank.forEach((p) => localStorage.removeItem(`pm-stars-${p.level}`)); } catch (_) { /* ignore */ } + try { + this.bank.forEach((p) => { + localStorage.removeItem(`pm3-stars-${p.level}`); + localStorage.removeItem(`pm3-crown-${p.level}`); + localStorage.removeItem(`pm-stars-${p.level}`); // pre-Wave-3 keys + }); + } catch (_) { /* ignore */ } this.showLevelSelect(); } @@ -266,6 +433,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.drag = null; this.selectedCell = null; + this.initMonsterKinds(); this.clearLayer(); this.computeLayout(); this.buildBoard(); @@ -278,6 +446,32 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.startEyeAnimation(); } + // Monster index (cells[i][2]) -> its type, so a cell keeps its look after + // merging. The logic keeps type as blob-level flags; the renderer needs it + // per monster, and level.monsters[m] is exactly monster m's start cell. + initMonsterKinds() { + const kinds = new Map(); + const mark = (list, kind) => { + const want = new Set((list ?? []).map(([x, y]) => `${x},${y}`)); + (this.levelDef.monsters ?? []).forEach(([x, y], m) => { + if (want.has(`${x},${y}`)) kinds.set(m, kind); + }); + }; + mark(this.levelDef.sleepers, 'sleeper'); + mark(this.levelDef.green, 'green'); + mark(this.levelDef.hypno, 'hypno'); + mark(this.levelDef.powerlifters, 'lifter'); + this.monsterKind = kinds; + } + + cellColor(m) { + const kind = this.monsterKind?.get(m); + if (kind === 'green') return SLIME; + if (kind === 'hypno') return HYPNO; + if (kind === 'lifter') return LIFTER; + return paletteColor(m); + } + initEyeOffsets() { const er = this.cell * 0.105; const maxR = er * 0.42; @@ -418,9 +612,206 @@ export default class PuddingMonstersGame extends Phaser.Scene { ).setDisplaySize(this.cell, this.cell).setDepth(D.deco); this.layer.add(sprite); } + + this.drawFixedElements(); + + this.slimeGfx = this.add.graphics().setDepth(D.slime); + this.brickGfx = this.add.graphics().setDepth(D.deco); + this.layer.add([this.slimeGfx, this.brickGfx]); + this.iceObjs = new Map(); + this.crateObjs = new Map(); + this.syncElements(); + this.startTargetBeacons(); } + // Springs, tunnels and buttons never move or change, so they are drawn once. + drawFixedElements() { + const c = this.cell; + + for (const k of (this.state.springs ?? [])) { + const [x, y] = k.split(',').map(Number); + const g = this.add.graphics().setDepth(D.deco) + .setPosition(this.cellLeft(x) + c / 2, this.cellTop(y) + c / 2); + g.fillStyle(SPRING_DK, 1); + g.fillRoundedRect(-c * 0.36, -c * 0.36, c * 0.72, c * 0.72, c * 0.12); + g.fillStyle(SPRING, 1); + g.fillRoundedRect(-c * 0.32, -c * 0.32, c * 0.64, c * 0.64, c * 0.10); + // a squat coil: three stacked bars + g.fillStyle(SPRING_DK, 0.9); + for (let i = 0; i < 3; i++) { + g.fillRoundedRect(-c * 0.22, -c * 0.20 + i * c * 0.15, c * 0.44, c * 0.07, c * 0.035); + } + this.layer.add(g); + } + + // Each tunnel pair shares a colour so the link reads without a line. + const seen = new Set(); + let pairIdx = 0; + for (const [k, exit] of (this.state.tunnels ?? new Map())) { + const partner = `${exit[0]},${exit[1]}`; + const pairKey = [k, partner].sort().join('|'); + if (!seen.has(pairKey)) { seen.add(pairKey); pairIdx = seen.size - 1; } + const color = TUNNEL_COLORS[pairIdx % TUNNEL_COLORS.length]; + const [x, y] = k.split(',').map(Number); + const g = this.add.graphics().setDepth(D.inlay) + .setPosition(this.cellLeft(x) + c / 2, this.cellTop(y) + c / 2); + g.fillStyle(0x120f22, 0.92); + g.fillCircle(0, 0, c * 0.34); + g.lineStyle(Math.max(3, c * 0.05), color, 1); + g.strokeCircle(0, 0, c * 0.30); + g.lineStyle(Math.max(2, c * 0.03), color, 0.5); + g.strokeCircle(0, 0, c * 0.19); + this.layer.add(g); + } + + for (const [k] of (this.state.buttons ?? new Map())) { + const [x, y] = k.split(',').map(Number); + const g = this.add.graphics().setDepth(D.inlay) + .setPosition(this.cellLeft(x) + c / 2, this.cellTop(y) + c / 2); + g.fillStyle(0x5a1f2a, 0.9); + g.fillCircle(0, 0, c * 0.26); + g.fillStyle(BUTTON, 1); + g.fillCircle(0, 0, c * 0.21); + g.fillStyle(0xffffff, 0.3); + g.fillCircle(-c * 0.06, -c * 0.06, c * 0.07); + this.layer.add(g); + } + } + + // Ice and slime change during play (and come back on Undo/Reset), so they are + // rebuilt from state rather than drawn once like walls and spikes. + syncElements() { + this.drawSlime(); + this.drawBricks(); + const sync = (cells, objs, make) => { + for (const [k, obj] of objs) { + if (!cells.has(k)) { obj.destroy(); objs.delete(k); } + } + for (const k of cells) { + if (objs.has(k)) continue; + const [x, y] = k.split(',').map(Number); + objs.set(k, make(x, y)); + } + }; + sync(this.state.board?.ice ?? new Set(), this.iceObjs, (x, y) => this.makeIce(x, y)); + sync(this.state.board?.crates ?? new Set(), this.crateObjs, (x, y) => this.makeCrate(x, y)); + } + + // Raised bricks are solid; lowered ones are a flush outline in the floor. + drawBricks() { + const g = this.brickGfx; + if (!g) return; + g.clear(); + const brickAt = this.state.brickAt; + if (!brickAt?.size) return; + const up = this.state.board?.bricksUp ?? new Set(); + const c = this.cell; + for (const [k, group] of brickAt) { + const [x, y] = k.split(',').map(Number); + const L = this.cellLeft(x), T = this.cellTop(y); + if (up.has(group)) { + g.fillStyle(BRICK_DK, 1); + g.fillRoundedRect(L + 3, T + 3, c - 6, c - 6, 8); + g.fillStyle(BRICK, 1); + g.fillRoundedRect(L + 6, T + 6, c - 12, c - 15, 7); + g.lineStyle(Math.max(1, c * 0.018), BRICK_DK, 0.9); + for (let i = 1; i < 3; i++) { + g.lineBetween(L + 6, T + 6 + (c - 15) * (i / 3), L + c - 6, T + 6 + (c - 15) * (i / 3)); + } + g.lineBetween(L + c / 2, T + 6, L + c / 2, T + 6 + (c - 15) / 3); + g.lineBetween(L + c / 2, T + 6 + (c - 15) * (2 / 3), L + c / 2, T + c - 9); + } else { + g.lineStyle(Math.max(2, c * 0.025), BRICK_DK, 0.55); + g.strokeRoundedRect(L + 8, T + 8, c - 16, c - 16, 6); + g.fillStyle(BRICK_DK, 0.18); + g.fillRoundedRect(L + 8, T + 8, c - 16, c - 16, 6); + } + } + } + + makeCrate(x, y) { + const c = this.cell; + const g = this.add.graphics().setDepth(D.deco) + .setPosition(this.cellLeft(x) + c / 2, this.cellTop(y) + c / 2); + g.fillStyle(CRATE_DK, 1); + g.fillRoundedRect(-c * 0.38, -c * 0.38, c * 0.76, c * 0.76, c * 0.07); + g.fillStyle(CRATE, 1); + g.fillRoundedRect(-c * 0.34, -c * 0.34, c * 0.68, c * 0.68, c * 0.06); + g.lineStyle(Math.max(2, c * 0.03), CRATE_DK, 0.95); + g.strokeRect(-c * 0.34, -c * 0.34, c * 0.68, c * 0.68); + g.lineBetween(-c * 0.34, -c * 0.34, c * 0.34, c * 0.34); + g.lineBetween(c * 0.34, -c * 0.34, -c * 0.34, c * 0.34); + this.layer.add(g); + return g; + } + + // Frame 4 of the jello-items sheet is the ice block once it is painted; until + // then draw a frosted slab so the mechanic is playable without the art. + makeIce(x, y) { + const c = this.cell; + const cx = this.cellLeft(x) + c / 2; + const cy = this.cellTop(y) + c / 2; + if (this.textures.get('jello-items')?.has?.(4)) { + const img = this.add.image(cx, cy, 'jello-items', 4) + .setDisplaySize(c, c).setDepth(D.deco); + this.layer.add(img); + return img; + } + const g = this.add.graphics().setDepth(D.deco).setPosition(cx, cy); + g.fillStyle(ICE_DK, 0.95); + g.fillRoundedRect(-c / 2 + 4, -c / 2 + 4, c - 8, c - 8, 10); + g.fillStyle(ICE, 0.92); + g.fillRoundedRect(-c / 2 + 7, -c / 2 + 7, c - 14, c - 17, 9); + g.fillStyle(0xffffff, 0.55); + g.fillRoundedRect(-c * 0.28, -c * 0.30, c * 0.30, c * 0.10, c * 0.05); + g.lineStyle(2, 0xffffff, 0.45); + g.beginPath(); + g.moveTo(-c * 0.20, c * 0.26); g.lineTo(c * 0.02, -c * 0.02); g.lineTo(c * 0.26, c * 0.14); + g.strokePath(); + this.layer.add(g); + return g; + } + + // Green monsters leave a trail; anything sliding onto it stops dead. + drawSlime() { + const g = this.slimeGfx; + if (!g) return; + g.clear(); + const slime = this.state.board?.slime; + if (!slime?.size) return; + const c = this.cell; + for (const k of slime) { + const [x, y] = k.split(',').map(Number); + const L = this.cellLeft(x), T = this.cellTop(y); + // Overlap the neighbours slightly so a trail reads as one continuous ooze. + const grow = (dx, dy) => (slime.has(`${x + dx},${y + dy}`) ? c * 0.09 : 0); + g.fillStyle(SLIME_DK, 0.9); + g.fillRoundedRect( + L + 5 - grow(-1, 0), T + 5 - grow(0, -1), + c - 10 + grow(-1, 0) + grow(1, 0), c - 10 + grow(0, -1) + grow(0, 1), c * 0.18, + ); + g.fillStyle(SLIME, 0.85); + g.fillRoundedRect(L + 9, T + 9, c - 18, c - 18, c * 0.16); + g.fillStyle(0xffffff, 0.18); + g.fillCircle(L + c * 0.34, T + c * 0.32, c * 0.07); + } + } + + // Struck ice: pop and fade the slab away. + shatterIce(k) { + const obj = this.iceObjs.get(k); + if (!obj) return; + this.iceObjs.delete(k); + playSound(this, SFX.SQUISH); + this.tweens.add({ + targets: obj, + scaleX: obj.scaleX * 1.35, scaleY: obj.scaleY * 1.35, alpha: 0, + duration: 220, ease: 'Quad.easeOut', + onComplete: () => obj.destroy(), + }); + } + startTargetBeacons() { const targets = this.levelDef?.targets ?? []; if (!targets.length) return; @@ -468,19 +859,15 @@ export default class PuddingMonstersGame extends Phaser.Scene { } } - // Live star projection: you need the yellow squares covered AND par moves. - liveStars() { - const covered = targetsCovered(this.state, this.levelDef.targets); - return Math.min(covered, this.medalStars(this.moves, this.par)); - } - - drawAllMonsters(excludeIdx = -1) { + // exclude: a blob index, or a Set of them (a hypno flick animates several). + drawAllMonsters(exclude = -1) { + const skip = exclude instanceof Set ? exclude : new Set([exclude]); // Destroy previous per-blob containers (their tweens are auto-removed on destroy) this.blobGfx.forEach(({ container }) => container.destroy()); this.blobGfx = []; this.state.blobs.forEach((blob, i) => { - if (i === excludeIdx) return; + if (skip.has(i)) return; const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i; // Position the container at the blob centroid so scaling pivots from the centre @@ -490,7 +877,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { const container = this.add.container(cx, cy).setDepth(D.monster); const g = this.add.graphics(); - this.drawBlobInto(g, blob.cells, -cx, -cy, sel); + this.drawBlobInto(g, blob.cells, -cx, -cy, { selected: sel, asleep: blob.asleep === true }); container.add(g); this.layer.add(container); @@ -514,14 +901,16 @@ export default class PuddingMonstersGame extends Phaser.Scene { }); } - drawBlobInto(g, cells, ox, oy, selected = false) { + drawBlobInto(g, cells, ox, oy, opts = {}) { + const { selected = false, asleep = false } = (typeof opts === 'boolean' ? { selected: opts } : opts); const c = this.cell; const byCell = new Map(cells.map((cell) => [`${cell[0]},${cell[1]}`, cell])); + const colorOf = (m) => this.cellColor(m); // Silhouette passes, outermost first: selection glow, dark rind, jelly body. if (selected) this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.035, () => 0xffffff, 0.9); - this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.065, (m) => shade(cellColor(m), 0.55), 1); - this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.115, (m) => cellColor(m), 1); + this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.065, (m) => shade(colorOf(m), 0.55), 1); + this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.115, colorOf, 1); // sheen along the blob's exposed top edge g.fillStyle(0xffffff, 0.2); @@ -535,11 +924,43 @@ export default class PuddingMonstersGame extends Phaser.Scene { for (const [x, y, m] of cells) { const eo = this.eyeOffsets?.get(m) ?? { dx: 0, dy: 0 }; const ex = this.cellCx(x) + ox, ey = this.cellCy(y) + oy - c * 0.04; + + if (asleep) { + // shut eyes and a drifting "z" — a sleeper cannot be flicked at all + g.lineStyle(Math.max(2, c * 0.03), 0x2b2b2b, 1); + for (const s of [-sp, sp]) { + g.beginPath(); + g.arc(ex + s, ey, er, 0.15 * Math.PI, 0.85 * Math.PI); + g.strokePath(); + } + g.lineStyle(Math.max(2, c * 0.028), 0xffffff, 0.85); + const zx = ex + c * 0.20, zy = ey - c * 0.22, zw = c * 0.10; + g.beginPath(); + g.moveTo(zx, zy); g.lineTo(zx + zw, zy); + g.lineTo(zx, zy + zw); g.lineTo(zx + zw, zy + zw); + g.strokePath(); + continue; + } + g.fillStyle(0xffffff, 1); g.fillCircle(ex - sp, ey, er); g.fillCircle(ex + sp, ey, er); g.fillStyle(0x1a1a1a, 1); g.fillCircle(ex - sp + eo.dx, ey + eo.dy, er * 0.5); g.fillCircle(ex + sp + eo.dx, ey + eo.dy, er * 0.5); + + // hive-mind monsters get a spiral ring around the pupil + const kind = this.monsterKind?.get(m); + if (kind === 'hypno') { + g.lineStyle(Math.max(1, c * 0.016), 0x5b2ea6, 0.95); + for (const s of [-sp, sp]) g.strokeCircle(ex + s + eo.dx, ey + eo.dy, er * 0.78); + } + // powerlifters get heavy brows, so the type reads without relying on hue + if (kind === 'lifter') { + g.lineStyle(Math.max(3, c * 0.045), 0x6b3410, 1); + for (const s of [-sp, sp]) { + g.lineBetween(ex + s - er, ey - er * 1.5, ex + s + er, ey - er * 1.1); + } + } } } @@ -579,18 +1000,30 @@ export default class PuddingMonstersGame extends Phaser.Scene { } drawHud() { - const title = this.add.text(40, 80, 'JELL-O\nMONSTERS', { - fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, lineSpacing: 2, + const chapter = this.chapterOf(this.level); + const title = this.add.text(40, 66, 'JELL-O MONSTERS', { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, }).setOrigin(0, 0.5).setDepth(D.ui); - this.layer.add(title); + const where = this.add.text(40, 102, chapter ? `${chapter.id}. ${chapter.name}` : '', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + const lvlName = this.add.text(40, 134, `${this.level}. ${this.levelDef.name ?? ''}`, { + fontFamily: 'Righteous', fontSize: '25px', color: COLORS.textHex, + wordWrap: { width: 250 }, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.layer.add([title, where, lvlName]); - this.movesText = this.add.text(GAME_WIDTH - 50, 155, '', { - fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, + this.movesText = this.add.text(GAME_WIDTH - 50, 132, '', { + fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex, }).setOrigin(1, 0.5).setDepth(D.ui); - this.starsText = this.add.text(GAME_WIDTH - 50, 199, '', { + this.starsText = this.add.text(GAME_WIDTH - 50, 174, '', { fontFamily: 'serif', fontSize: '30px', color: '#ffd54a', }).setOrigin(1, 0.5).setDepth(D.ui); - this.layer.add([this.movesText, this.starsText]); + this.crownText = this.add.text(GAME_WIDTH - 84, 214, '', { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.mutedHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.crownGfx = this.add.graphics().setDepth(D.ui); + this.layer.add([this.movesText, this.starsText, this.crownText, this.crownGfx]); const BTN_W = 200; const BTN_H = 56; @@ -609,15 +1042,32 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.undoBtn = undo; this.layer.add([undo, reset, hint, levels]); - const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag to flick (or arrow keys).\nOnly walls & monsters stop a\nslide — don\'t fall off the edge!\nYellow squares + par = ★★★.', { + const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag to flick (or arrow keys).\nStars = finish on the yellow\nsquares. Crown = solve it in\npar moves or fewer.', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center', }).setOrigin(0.5, 0).setDepth(D.ui); this.layer.add(tip); + + // Levels that introduce a mechanic explain it in one line above the board. + if (this.levelDef?.tip) { + const lesson = this.add.text( + this.originX + (this.state.cols * this.cell) / 2, this.originY - 34, this.levelDef.tip, { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.goldHex, align: 'center', + }, + ).setOrigin(0.5, 0.5).setDepth(D.ui); + this.layer.add(lesson); + } } updateHud() { if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`); - if (this.starsText) this.starsText.setText(`★ ${this.liveStars()}/3`); + if (this.starsText) this.starsText.setText(`★ ${this.liveStars()}/3 squares`); + if (this.crownText) { + const keeping = this.liveCrown(); + this.crownText.setText(keeping ? 'crown: on track' : 'crown: lost'); + this.crownText.setColor(keeping ? '#ffd54a' : '#6b5a8a'); + this.crownGfx.clear(); + drawCrown(this.crownGfx, GAME_WIDTH - 62, 214, 12, keeping ? 0xffd54a : 0x4a4460, 1); + } if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0); } @@ -658,47 +1108,129 @@ export default class PuddingMonstersGame extends Phaser.Scene { doFlick(cell, dir) { const idx = blobAt(this.state, cell.x, cell.y); if (idx < 0) return; - const { maxSteps, deathStep, deathCause } = computeSlide(this.state, idx, dir); - if (maxSteps === 0) { this.nudgeInvalid(idx); return; } + // One plan drives the animation and the move itself, so what the player + // watches can never disagree with what the engine did. A hypno flick plans + // several blobs at once; a flick against ice may only shatter it. + const plan = planFlick(this.state, idx, dir); + if (!plan.legal) { this.nudgeInvalid(idx); return; } this.busy = true; this.undoStack.push(cloneState(this.state)); this.moves++; this.updateHud(); - const movingCells = this.state.blobs[idx].cells.map((c) => c.slice()); const [dx, dy] = DIRS[dir]; - const steps = deathStep > 0 ? deathStep : maxSteps; - // edge deaths overshoot the rim a little before the blob tips off - const animSteps = deathCause === 'edge' ? deathStep + 1.25 : steps; - this.drawAllMonsters(idx); + // Nothing travels — the blob was already up against the ice it just broke. + if (!plan.parts.length) { + playSound(this, SFX.WOOSH); + this.bumpBlob(idx, dx, dy, () => this.commitMove(plan, idx, dir)); + return; + } + + const moving = new Set(plan.parts.map((p) => p.idx)); + this.drawAllMonsters(moving); this.animGfx = this.add.graphics().setDepth(D.anim); this.layer.add(this.animGfx); - const proxy = { v: 0 }; - const tgtX = dx * animSteps * this.cell; - const tgtY = dy * animSteps * this.cell; - const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, tgtX * t, tgtY * t); }; + + // The route is a polyline, not a straight line: springs bend it and tunnels + // jump it. Edge deaths get an extra point past the rim to tip off. + const dying = plan.dead; + const animParts = plan.parts.map((p) => { + // The full path is used, never sliced by step count: a teleport adds a + // point without advancing a step, so the two do not line up. The walk + // always ends the path exactly at the part's offset. + const pts = [[0, 0, 0], ...(p.path ?? [])]; + if (dying && plan.deathCause === 'edge' && pts.length > 1) { + const last = pts[pts.length - 1], prev = pts[pts.length - 2]; + pts.push([last[0] + (last[0] - prev[0]) * 1.25, last[1] + (last[1] - prev[1]) * 1.25, 0]); + } + return { pts, cells: p.cells, asleep: this.state.blobs[p.idx]?.asleep === true }; + }); + const longest = Math.max(...animParts.map((p) => p.pts.length - 1)); + const duration = Math.min(480, 110 + longest * 55); + + const drawAt = (t) => { + this.animGfx.clear(); + for (const p of animParts) { + const [ox, oy] = pointAlong(p.pts, t); + this.drawBlobInto(this.animGfx, p.cells, ox * this.cell, oy * this.cell, { asleep: p.asleep }); + } + }; drawAt(0); playSound(this, SFX.WOOSH); + if (!dying) this.slideCrates(plan, dir, duration, longest); + + const proxy = { v: 0 }; this.tweens.add({ targets: proxy, v: 1, - duration: Math.min(420, 110 + animSteps * 55), - ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut', - onUpdate: () => drawAt(proxy.v), + duration, + ease: dying ? 'Quad.easeIn' : 'Back.easeOut', onComplete: () => { - if (deathStep > 0) this.splatAndFail(idx, dir, deathCause); - else this.commitMove(idx, dir, movingCells, dx, dy, steps); + if (dying) this.splatAndFail(plan, plan.deathCause); + else this.commitMove(plan, idx, dir); }, + onUpdate: () => drawAt(proxy.v), }); } - commitMove(idx, dir, movingCells, dx, dy, steps) { - const res = slide(this.state, idx, dir); + // Shove the crate graphics along with the blob rather than popping them to + // their new cells when the move commits. + slideCrates(plan, dir, duration, longest) { + for (const [from, to] of (plan.crateMoves ?? new Map())) { + const obj = this.crateObjs.get(from); + if (!obj) continue; + this.crateObjs.delete(from); + if (to) this.crateObjs.set(to, obj); + const [fx, fy] = from.split(',').map(Number); + const [tx, ty] = to ? to.split(',').map(Number) : [fx, fy]; + const dist = Math.abs(tx - fx) + Math.abs(ty - fy); + // The crate only starts moving once the blob reaches it. + const delay = longest > dist ? duration * ((longest - dist) / longest) : 0; + const cfg = { + targets: obj, + x: this.cellLeft(tx) + this.cell / 2, + y: this.cellTop(ty) + this.cell / 2, + duration: Math.max(60, duration - delay), + delay, + ease: 'Quad.easeOut', + }; + if (!to) { // shoved clean off the table + const [ddx, ddy] = DIRS[dir]; + Object.assign(cfg, { + x: obj.x + ddx * this.cell * 1.3, y: obj.y + ddy * this.cell * 1.3, + alpha: 0, scaleX: 0.8, scaleY: 0.8, + onComplete: () => obj.destroy(), + }); + } + this.tweens.add(cfg); + } + } + + // A short shove that bounces back — used when a flick only breaks ice. + bumpBlob(idx, dx, dy, onDone) { + const entry = this.blobGfx.find((b) => b.blobIdx === idx); + if (!entry) { onDone(); return; } + const { container } = entry; + const ox = container.x, oy = container.y; + this.tweens.add({ + targets: container, x: ox + dx * this.cell * 0.22, y: oy + dy * this.cell * 0.22, + duration: 110, ease: 'Quad.easeOut', yoyo: true, + onComplete: onDone, + }); + } + + commitMove(plan, idx, dir) { + const lead = plan.parts.find((p) => p.idx === idx) ?? plan.parts[0]; + const res = applyPlan(this.state, plan); if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; } - // keep the moved blob selected at its new resting position - this.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps }; + for (const k of plan.hitIce) this.shatterIce(k); + this.syncElements(); // slime laid, ice gone, bricks toggled, crates settled + // keep the flicked blob selected at its new resting position + this.selectedCell = lead + ? { x: lead.cells[0][0] + lead.offset[0], y: lead.cells[0][1] + lead.offset[1] } + : this.selectedCell; this.drawTargets(); this.updateHud(); @@ -715,7 +1247,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.wobbleBlob(restIdx, 1.16, 0.84, finish); // jelly merge jiggle } else { playSound(this, SFX.SQUISH); - const sx = dx !== 0 ? 0.9 : 1.1; // impact squash along the slide axis + const sx = DIRS[dir][0] !== 0 ? 0.9 : 1.1; // impact squash along the slide axis this.wobbleBlob(restIdx, sx, 2 - sx, finish, true); } } @@ -731,7 +1263,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.drawAllMonsters(idx); const g = this.add.graphics().setDepth(D.anim).setPosition(cx, cy); this.layer.add(g); - this.drawBlobInto(g, blob.cells, -cx, -cy); + this.drawBlobInto(g, blob.cells, -cx, -cy, { asleep: blob.asleep === true }); const done = () => { g.destroy(); onDone?.(); }; this.tweens.add({ @@ -744,8 +1276,8 @@ export default class PuddingMonstersGame extends Phaser.Scene { }); } - splatAndFail(idx, dir, cause) { - slide(this.state, idx, dir); // marks state 'dead' (positions unchanged) + splatAndFail(plan, cause) { + applyPlan(this.state, plan); // marks state 'dead' (positions unchanged) playSound(this, SFX.SCIFI_EXPLODE); const g = this.animGfx; if (!g) { this.onDead(cause); return; } @@ -778,6 +1310,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.moves++; // an undo still counts as a move (matches Rush Hour) this.selectedCell = null; this.drawAllMonsters(); + this.syncElements(); // shattered ice comes back, slime trails rewind this.drawTargets(); this.updateHud(); playSound(this, SFX.PIECE_CLICK); @@ -790,6 +1323,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.moves = 0; this.selectedCell = null; this.drawAllMonsters(); + this.syncElements(); this.drawTargets(); this.updateHud(); playSound(this, SFX.CARD_SHUFFLE); @@ -850,9 +1384,10 @@ export default class PuddingMonstersGame extends Phaser.Scene { onSolved() { this.overlayUp = true; - const covered = targetsCovered(this.state, this.levelDef.targets); - const stars = Math.min(covered, this.medalStars(this.moves, this.par)); + const stars = targetsCovered(this.state, this.levelDef.targets); + const crown = this.liveCrown(); this.saveStars(this.level, stars); + this.saveCrown(this.level, crown); if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; api.post('/puzzles/puddingmonsters/complete', { level: this.level }) @@ -863,53 +1398,96 @@ export default class PuddingMonstersGame extends Phaser.Scene { }).catch(() => { /* best effort */ }); playSound(this, SFX.JELLO_MONSTERS); + const chapter = this.chapterOf(this.level); + const chapterDone = chapter && this.level === chapter.to; + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); this.layer.add(dim); + const H = chapterDone ? 480 : 420; const panel = this.add.graphics().setDepth(D.overlay); panel.fillStyle(COLORS.panel, 0.98); - panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20); - panel.lineStyle(3, 0xc792ff, 1); - panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20); + panel.fillRoundedRect(cx - 320, cy - H / 2, 640, H, 20); + panel.lineStyle(3, chapterDone ? COLORS.gold : 0xc792ff, 1); + panel.strokeRoundedRect(cx - 320, cy - H / 2, 640, H, 20); this.layer.add(panel); - const title = this.add.text(cx, cy - 140, 'Solved!', { - fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + let y = cy - H / 2 + 62; + const title = this.add.text(cx, y, 'Solved!', { + fontFamily: 'Righteous', fontSize: '58px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.overlayUI); - const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { - fontFamily: 'serif', fontSize: '56px', color: '#ffd54a', + y += 46; + const named = this.add.text(cx, y, `${this.level}. ${this.levelDef.name ?? ''}`, { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); - const stat = this.add.text(cx, cy - 4, - `${this.moves} moves (par ${this.par}) • ${covered} / 3 yellow squares covered`, { - fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, - }).setOrigin(0.5).setDepth(D.overlayUI); - this.layer.add([title, starRow, stat]); + this.layer.add([title, named]); - if (stars < 3) { - const hint = this.add.text(cx, cy + 34, - covered < 3 ? 'Finish with your blob on all 3 yellow squares for more stars.' - : 'Solve in par or fewer moves to keep all your stars.', { - fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + // Stars and crown are separate awards, so show them separately. + y += 56; + const starRow = this.add.text(cx - 90, y, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { + fontFamily: 'serif', fontSize: '50px', color: '#ffd54a', + }).setOrigin(0.5).setDepth(D.overlayUI); + const crownGfx = this.add.graphics().setDepth(D.overlayUI); + drawCrown(crownGfx, cx + 110, y - 4, 26, crown ? 0xffd54a : 0x3c3752, 1); + const crownLabel = this.add.text(cx + 110, y + 34, crown ? 'Crowned' : 'No crown', { + fontFamily: '"Julius Sans One"', fontSize: '17px', + color: crown ? '#ffd54a' : COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([starRow, crownGfx, crownLabel]); + + y += 76; + const stat = this.add.text(cx, y, + `${this.moves} moves (par ${this.par}) · ${stars} / 3 yellow squares covered`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(stat); + + if (stars < 3 || !crown) { + y += 30; + const hint = this.add.text(cx, y, + stars < 3 ? 'Finish with your blob covering all 3 yellow squares for 3 stars.' + : 'Solve it in par moves or fewer to take the crown.', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.layer.add(hint); } + if (chapterDone) { + y += 42; + const size = chapter.to - chapter.from + 1; + let chStars = 0; let chCrowns = 0; + for (let l = chapter.from; l <= chapter.to; l++) { + chStars += this.bestStars(l); + chCrowns += this.hasCrown(l) ? 1 : 0; + } + const done = this.add.text(cx, y, `Chapter ${chapter.id} complete — ${chapter.name}`, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const tally = this.add.text(cx, y + 30, `★ ${chStars} / ${size * 3} ♔ ${chCrowns} / ${size}`, { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([done, tally]); + } + const hasNext = this.level < this.bank.length; const btns = []; + const btnY = cy + H / 2 - 96; if (hasNext) { - btns.push(new Button(this, cx, cy + 70, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1), - { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + const nextCh = this.chapterOf(this.level + 1); + const label = chapterDone && nextCh ? `Start ${nextCh.name}` : `Next Level (${this.level + 1})`; + btns.push(new Button(this, cx, btnY, label, () => this.playLevel(this.level + 1), + { width: 360, height: 58, fontSize: 25 }).setDepth(D.overlayUI)); } else { - btns.push(this.add.text(cx, cy + 62, 'You cleared every level. Sweet!', { + btns.push(this.add.text(cx, btnY, 'You cleared every level. Sweet!', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.overlayUI)); } - const replay = new Button(this, cx - 120, cy + 150, 'Replay', () => this.playLevel(this.level), - { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); - const levels = new Button(this, cx + 120, cy + 150, 'Levels', () => this.showLevelSelect(), - { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + const replay = new Button(this, cx - 120, btnY + 74, 'Replay', () => this.playLevel(this.level), + { width: 210, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); + const levels = new Button(this, cx + 120, btnY + 74, 'Levels', () => this.showLevelSelect(), + { width: 210, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); btns.push(replay, levels); this.layer.add(btns); } diff --git a/src/games/puddingmonsters/PuddingMonstersLogic.js b/src/games/puddingmonsters/PuddingMonstersLogic.js index 6255d19..835f419 100644 --- a/src/games/puddingmonsters/PuddingMonstersLogic.js +++ b/src/games/puddingmonsters/PuddingMonstersLogic.js @@ -13,10 +13,51 @@ // SPIKES are equally deadly: if a blob's slide path crosses a spike it dies // (the run fails and the level must be restarted). // -// A blob: { cells: [[x,y,m], ...] } (rigid; moves as a unit). The third -// element m is the index of the original monster occupying that cell; it rides -// along through slides and merges so the renderer can keep each monster's -// colour and face inside a merged blob. All rules ignore it. +// A blob: { cells: [[x,y,m], ...], asleep?, green?, hypno?, lifter? } (rigid; +// moves as a unit). The third element m is the index of the original monster +// occupying that cell; it rides along through slides and merges so the renderer +// can keep each monster's colour and face inside a merged blob. The movement +// rules ignore m. +// +// ── MONSTER TYPES ─────────────────────────────────────────────────────────── +// asleep — cannot be flicked at all. Wakes when an awake blob slides into it +// and merges. A merged blob is asleep only if EVERY part was. +// green — leaves a slime trail on every cell it travels through. Any blob +// that later slides onto slime stops ON that cell. A merged blob is +// green if ANY part was (the trail follows the whole blob). +// hypno — hive mind: flicking one hypno blob slides EVERY hypno blob the same +// direction, in one move. Merged blob is hypno if ANY part was. +// lifter — powerlifter: PUSHES crates instead of being stopped by them. +// Merged blob is a lifter if ANY part was. +// +// ── BOARD ELEMENTS ────────────────────────────────────────────────────────── +// ice — blocks a slide exactly like a wall, then SHATTERS. The blob still +// comes to rest in front of it. A flick that only shatters ice (the +// blob is already touching it) is a legal move. +// spring — bounces a blob back the way it came instead of stopping it, and +// keeps it sliding. Each spring bounces once per flick; hit a spent +// spring and it just blocks, which is what guarantees a walk ends. +// button — pressing one (sliding a monster over it) toggles its brick group +// brick — raised = solid like a wall, lowered = plain floor. Bricks cannot +// rise through a monster or crate: that toggle jams instead. +// tunnel — linked pair of cells. A blob whose cell enters one mouth is +// teleported so that cell lands on the other, then keeps sliding. +// One teleport per flick; a teleport that would land the blob on +// anything solid simply does not happen. +// crate — solid to everyone except a lifter, who pushes it along. A crate +// pushed off the table is gone (not fatal). No crate trains: a +// crate backed by anything solid cannot be pushed. +// +// ── STATE SHAPE (see docs/puddingmonsters-mechanics-plan.md) ──────────────── +// Immutable terrain — `walls`, `spikes`, `springs`, `tunnels`, `buttons`, +// `brickAt`, `targets` — is fixed for a level and shared by reference through +// cloneState(). Everything that CHANGES during play lives in `state.board`: +// `ice`, `slime`, `bricksUp`, `crates`. +// +// EVERY mutable layer MUST be folded into stateKey(). boardKey() does that +// generically, so a new layer is free — but if one ever bypasses it, the BFS +// solver will treat two genuinely different positions as the same one, return a +// short "optimal" path, and write a too-low `par` into the level bank. export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; export const DIR_LIST = ['up', 'down', 'left', 'right']; @@ -44,7 +85,66 @@ export function repCell(blob) { (c[1] < best[1] || (c[1] === best[1] && c[0] < best[0])) ? c : best, blob.cells[0]); } -// Union any blobs that are orthogonally adjacent, transitively, into single blobs. +// ── Mutable board layers ───────────────────────────────────────────────────── +// A layer is a Set of cell keys, a Map of cell key -> value, an array, or a +// scalar. Add new ones in initialBoard() and they are automatically cloned and +// hashed; nothing else needs to change. + +function layerKey(v) { + if (v instanceof Set) return [...v].sort().join(','); + if (v instanceof Map) return [...v.entries()].map(([k, val]) => `${k}=${val}`).sort().join(','); + if (Array.isArray(v)) return v.slice().sort().join(','); + return String(v); +} + +function cloneLayer(v) { + if (v instanceof Set) return new Set(v); + if (v instanceof Map) return new Map(v); + if (Array.isArray(v)) return v.slice(); + return v; +} + +// Canonical string for the mutable half of the board. Empty layers contribute +// nothing, so a level using no elements hashes exactly as it did before the +// board existed (and costs nothing in the solver's hot loop). +export function boardKey(board) { + let out = ''; + for (const name of Object.keys(board ?? {}).sort()) { + const k = layerKey(board[name]); + if (k) out += `${out ? '&' : ''}${name}:${k}`; + } + return out; +} + +export function cloneBoard(board) { + const out = {}; + for (const name of Object.keys(board ?? {})) out[name] = cloneLayer(board[name]); + return out; +} + +// Mutable layers a level starts with. +function initialBoard(level) { + const board = {}; + if (level.ice?.length) board.ice = new Set(level.ice.map(([x, y]) => key(x, y))); + if (level.slime?.length) board.slime = new Set(level.slime.map(([x, y]) => key(x, y))); + if (level.crates?.length) board.crates = new Set(level.crates.map(([x, y]) => key(x, y))); + if (level.bricks?.length) { + // Bricks start raised unless the level lowers their group to begin with. + const down = new Set(level.bricksDown ?? []); + const up = new Set(); + for (const [, , g] of level.bricks) if (!down.has(g)) up.add(g); + board.bricksUp = up; + } + return board; +} + +// ── Merging ────────────────────────────────────────────────────────────────── + +// Union any blobs that are orthogonally adjacent, transitively, into single +// blobs. Flags combine by type: asleep is AND (sliding an awake blob into a +// sleeper is what wakes it); green, hypno and lifter are OR (one green monster +// slimes for the whole blob, one hypno puts it on the hive mind, one +// powerlifter lets the whole blob shove crates). function mergeBlobs(state) { const occ = new Map(); state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i))); @@ -65,22 +165,25 @@ function mergeBlobs(state) { const groups = new Map(); state.blobs.forEach((b, i) => { const r = find(i); - if (!groups.has(r)) groups.set(r, []); - groups.get(r).push(...b.cells); + if (!groups.has(r)) groups.set(r, { cells: [], asleep: true, green: false, hypno: false, lifter: false }); + const g = groups.get(r); + g.cells.push(...b.cells); + if (!b.asleep) g.asleep = false; + if (b.green) g.green = true; + if (b.hypno) g.hypno = true; + if (b.lifter) g.lifter = true; }); - state.blobs = [...groups.values()].map((cells) => ({ cells })); + state.blobs = [...groups.values()].map(makeBlob); } -function pickUpStars(state) { - for (const [sx, sy] of state.stars) { - if (!state.collected.has(key(sx, sy)) && occupiedAt(state, sx, sy)) { - state.collected.add(key(sx, sy)); - } - } -} - -export function starsCollected(state) { - return state.collected.size; +// Blobs carry flags only when set, so a plain monster stays a bare { cells }. +function makeBlob({ cells, asleep, green, hypno, lifter }) { + const b = { cells }; + if (asleep) b.asleep = true; + if (green) b.green = true; + if (hypno) b.hypno = true; + if (lifter) b.lifter = true; + return b; } // How many of the given target cells are currently covered by a monster. @@ -91,45 +194,365 @@ export function targetsCovered(state, targets) { return n; } -// How far blob `idx` can slide in `dir`, and whether the slide is fatal. -// Only walls and other monsters stop a slide — the board edge is open, so a -// blob whose path crosses a spike or leaves the board dies. Pure (does not -// mutate). { maxSteps, deathStep, deathCause } — deathStep>0 means fatal, -// deathCause is 'spike' | 'edge' | null. -export function computeSlide(state, idx, dir) { - const [dx, dy] = DIRS[dir]; +// ── Sliding ────────────────────────────────────────────────────────────────── + +// Walk blob `idx` one direction until it stops or dies. PURE with respect to +// `state`: board changes the walk causes (ice shattered, slime laid, bricks +// toggled, crates pushed) are written into a COPY, returned as `board`, and only +// installed by applyPlan(). Levels with no elements never allocate that copy. +// +// Per step, elements resolve in a fixed priority order: +// +// 1. BLOCK — a cell the blob would enter is solid (wall / another blob / +// intact ice / raised brick / crate it cannot push / spent +// spring): the blob stops BEFORE entering. Ice struck this way +// shatters. +// 2. BOUNCE — else, a fresh spring in the way reverses the direction without +// advancing. Each spring bounces once per flick, which is what +// makes the walk terminate. +// 3. ENTER — pushed crates advance, then the blob advances one cell. +// 4. DEATH — a cell just entered is off the table ('edge') or a spike +// ('spike'): fatal, the walk ends there. +// 5. BUTTONS — buttons under the blob toggle their brick group (once per +// button per flick; a brick that would rise through a monster or +// crate jams instead). +// 6. STOP-ON — a cell just entered is slimed, which halts the blob AFTER +// entering. Note the contrast with BLOCK. +// 7. TUNNEL — a mouth just entered teleports the blob (once per flick), then +// sliding continues; slime is re-checked at the destination. +// +// Returns { maxSteps, deathStep, deathCause, stopReason, offset, hitIce, path, +// board, boardChanged }: +// maxSteps — cells actually travelled (0 = the blob does not move). A +// spring bounce costs a loop iteration but no step, so this can +// be lower than the number of iterations the walk took. +// deathStep — >0 means fatal, at that step; deathCause 'spike' | 'edge' +// stopReason — 'blocked' | 'dead' | 'stop-on' +// offset — [ox, oy] final displacement. ALWAYS use this rather than +// dir*maxSteps: springs and tunnels make them different. +// path — offset after each step as [ox, oy, jump] (jump=1 for a +// teleport, which the scene must snap rather than tween) +// hitIce — ice cells shattered, for the scene's pop animation +export function computeSlide(state, idx, dir, { trace = false } = {}) { const blob = state.blobs[idx]; + const base = blob.cells; + const lifter = blob.lifter === true; + const green = blob.green === true; + const wantPath = trace || green; + + // Copy-on-write: the walk only clones the board if it actually changes it. + let board = state.board; + let owned = false; + const own = () => { if (!owned) { board = cloneBoard(board); owned = true; } return board; }; const other = new Set(); state.blobs.forEach((b, i) => { if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y))); }); - // Slide until a wall or another blob blocks. Out-of-board cells block - // nothing (walls and blobs only exist on the board), so an unblocked blob - // runs clean off the table — the death scan below catches that. - let maxSteps = 0; - const limit = state.cols + state.rows; - for (let s = 1; s <= limit; s++) { - let blocked = false; - for (const [x, y] of blob.cells) { - const k = key(x + dx * s, y + dy * s); - if (state.walls.has(k) || other.has(k)) { blocked = true; break; } - } - if (blocked) break; - maxSteps = s; - } + const { springs, tunnels, buttons, brickAt } = state; + const inBoard = (x, y) => x >= 0 && y >= 0 && x < state.cols && y < state.rows; + const brickUp = (k) => { + const g = brickAt?.get(k); + return g !== undefined && board.bricksUp?.has(g); + }; + // Everything that a blob can never occupy (used for tunnel destinations). + const solid = (k) => state.walls.has(k) || other.has(k) || board.ice?.has(k) + || brickUp(k) || board.crates?.has(k) || springs?.has(k); + let [dx, dy] = DIRS[dir]; + let ox = 0, oy = 0; + let maxSteps = 0; let deathStep = 0; let deathCause = null; - for (let s = 1; s <= maxSteps && deathStep === 0; s++) { - for (const [x, y] of blob.cells) { - const nx = x + dx * s, ny = y + dy * s; - if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { deathStep = s; deathCause = 'edge'; break; } - if (state.spikes.has(key(nx, ny))) { deathStep = s; deathCause = 'spike'; break; } + let stopReason = 'blocked'; + const hitIce = []; + const path = wantPath ? [] : null; + const trail = green ? base.map(([x, y]) => key(x, y)) : null; + + const usedSprings = new Set(); + const pressed = new Set(); + let usedTunnel = false; + // Where each pushed crate started and ended up, so the scene can slide it + // instead of popping it. `null` means it went over the edge. + const crateMoves = new Map(); + const crateOrigin = new Map(); + + // Springs can bounce the blob back and forth, so the straight-line bound is + // not enough; each spring is spent after one bounce, which caps the total. + const limit = (state.cols + state.rows) * ((springs?.size ?? 0) + 2) + 8; + + const slimedNow = () => { + if (!board.slime?.size) return false; + for (const [x, y] of base) if (board.slime.has(key(x + ox, y + oy))) return true; + return false; + }; + + for (let s = 1; s <= limit; s++) { + const nx = ox + dx, ny = oy + dy; + + // 1/2. Classify every cell we are about to enter. + let blocked = false; + let bounce = false; + const iceNow = []; + const pushes = []; + for (const [x, y] of base) { + const tk = key(x + nx, y + ny); + if (state.walls.has(tk) || other.has(tk) || brickUp(tk)) blocked = true; + if (board.ice?.has(tk)) { blocked = true; iceNow.push(tk); } + if (springs?.has(tk)) { + if (usedSprings.has(tk)) blocked = true; + else bounce = true; + } + if (board.crates?.has(tk)) { + if (!lifter) { blocked = true; continue; } + const bx = x + nx + dx, by = y + ny + dy; + const bk = key(bx, by); + if (!inBoard(bx, by)) { pushes.push([tk, null]); continue; } // shoved off the table + if (solid(bk)) blocked = true; // no crate trains + else pushes.push([tk, bk]); + } + } + + if (blocked) { + if (iceNow.length) { + const b = own(); + for (const k of iceNow) { b.ice.delete(k); hitIce.push(k); } + if (!b.ice.size) delete b.ice; + } + stopReason = 'blocked'; + break; + } + + if (bounce) { + for (const [x, y] of base) { + const tk = key(x + nx, y + ny); + if (springs?.has(tk)) usedSprings.add(tk); + } + dx = -dx; dy = -dy; + continue; // the spring throws it back without advancing + } + + // 3. ENTER — crates first so the blob never overlaps one mid-step. + if (pushes.length) { + const b = own(); + for (const [from] of pushes) b.crates.delete(from); + for (const [, to] of pushes) if (to) b.crates.add(to); + if (!b.crates.size) delete b.crates; + for (const [from, to] of pushes) { + const origin = crateOrigin.get(from) ?? from; + crateOrigin.delete(from); + if (to) crateOrigin.set(to, origin); + crateMoves.set(origin, to); + } + } + ox = nx; oy = ny; maxSteps += 1; // cells travelled, NOT loop iterations: + if (path) path.push([ox, oy, 0]); // a spring bounce burns a turn of the + // loop without moving the blob at all + if (trail) for (const [x, y] of base) trail.push(key(x + ox, y + oy)); + + // 4. DEATH. + let died = false; + for (const [x, y] of base) { + const cx = x + ox, cy = y + oy; + if (!inBoard(cx, cy)) { deathStep = s; deathCause = 'edge'; died = true; break; } + if (state.spikes.has(key(cx, cy))) { deathStep = s; deathCause = 'spike'; died = true; break; } + } + if (died) { stopReason = 'dead'; break; } + + // 5. BUTTONS. + if (buttons?.size) { + for (const [x, y] of base) { + const bk = key(x + ox, y + oy); + const g = buttons.get(bk); + if (g === undefined || pressed.has(bk)) continue; + pressed.add(bk); + toggleBrickGroup(g); + } + } + + // 6. STOP-ON — slime halts the blob on the cell it just entered. A green + // blob's own fresh trail is only laid once the walk finishes, so it never + // stops itself. + if (slimedNow()) { stopReason = 'stop-on'; break; } + + // 7. TUNNEL. + if (!usedTunnel && tunnels?.size) { + for (const [x, y] of base) { + const exit = tunnels.get(key(x + ox, y + oy)); + if (!exit) continue; + const jx = exit[0] - (x + ox), jy = exit[1] - (y + oy); + let ok = true; + for (const [bx, by] of base) { + const cx = bx + ox + jx, cy = by + oy + jy; + if (!inBoard(cx, cy) || solid(key(cx, cy)) || state.spikes.has(key(cx, cy))) { ok = false; break; } + } + if (!ok) continue; + ox += jx; oy += jy; + usedTunnel = true; + if (path) path.push([ox, oy, 1]); + if (trail) for (const [bx, by] of base) trail.push(key(bx + ox, by + oy)); + break; + } + if (usedTunnel && slimedNow()) { stopReason = 'stop-on'; break; } } } - return { maxSteps, deathStep, deathCause }; + + // A green blob's trail is laid once the walk is known to have happened. + if (trail && maxSteps > 0 && deathStep === 0) { + const b = own(); + const sl = b.slime ?? (b.slime = new Set()); + for (const k of trail) sl.add(k); + } + + return { + maxSteps, deathStep, deathCause, stopReason, + offset: [ox, oy], hitIce, path, crateMoves, board, boardChanged: owned, + }; + + // Lowering a brick group always works; raising jams if anything is standing + // on it. Nothing is written unless the group actually changes. + function toggleBrickGroup(g) { + const up = board.bricksUp; + if (!up) return; + if (up.has(g)) { own().bricksUp.delete(g); return; } + for (const [ck, cg] of brickAt) { + if (cg !== g) continue; + if (other.has(ck) || board.crates?.has(ck)) return; // jammed + for (const [x, y] of base) if (key(x + ox, y + oy) === ck) return; + } + own().bricksUp.add(g); + } +} + +const isAsleep = (blob) => blob?.asleep === true; + +// Plan a flick without touching the state. PURE. Returns: +// { legal, dead, deathCause, deathStep, parts, board, hitIce } +// parts — [{ idx, cells, offset, steps, deathStep, path }] per blob that +// moves; `cells` is the blob's cells BEFORE the move and `path` the +// per-step offsets, so the scene can animate the real route (which +// bends at springs and jumps at tunnels). A hypno flick has one part +// per hive member. +// board — the board state after the flick; applyPlan installs it. +// The scene animates a plan and then applies it; slide() does both at once. +// Planning and applying share this one function so they can never disagree. +export function planFlick(state, idx, dir) { + const blob = state.blobs[idx]; + const none = { legal: false, dead: false, parts: [], board: state.board, hitIce: [], crateMoves: new Map() }; + if (!blob || isAsleep(blob)) return none; + if (!blob.hypno) return planSingle(state, idx, dir); + + // Hive mind: every hypno blob slides. They can block each other, so resolve + // them one at a time, furthest along the direction first — the leader clears + // the way for whoever is behind it. Merging waits until the whole group has + // moved, which also keeps blob indices stable throughout. + const [dx, dy] = DIRS[dir]; + const order = state.blobs + .map((b, i) => [b, i]) + .filter(([b]) => b.hypno && !isAsleep(b)) // a sleeping hypno stays put until woken + .map(([b, i]) => [i, Math.max(...b.cells.map(([x, y]) => x * dx + y * dy))]) + .sort((a, b) => b[1] - a[1]) + .map(([i]) => i); + + const scratch = { + ...state, + board: cloneBoard(state.board), + blobs: state.blobs.map((b) => ({ ...b, cells: b.cells.map((c) => c.slice()) })), + }; + + const parts = []; + const hitIce = []; + const crateMoves = new Map(); + let boardChanged = false; + for (const i of order) { + const before = scratch.blobs[i].cells.map((c) => c.slice()); + const r = computeSlide(scratch, i, dir, { trace: true }); + // One member dying fails the whole run, and nothing is applied. + if (r.deathStep > 0) { + return { + legal: true, dead: true, deathCause: r.deathCause, deathStep: r.deathStep, + parts: [{ idx: i, cells: before, offset: r.offset, steps: r.maxSteps, deathStep: r.deathStep, path: r.path }], + board: state.board, hitIce: [], crateMoves: new Map(), + }; + } + scratch.board = r.board; + if (r.boardChanged) boardChanged = true; + hitIce.push(...r.hitIce); + // A crate a later hive member shoves on again keeps its ORIGINAL start cell. + for (const [from, to] of r.crateMoves) { + let origin = from; + for (const [o, cur] of crateMoves) if (cur === from) { origin = o; break; } + crateMoves.set(origin, to); + } + if (r.maxSteps > 0) { + const [ox, oy] = r.offset; + scratch.blobs[i].cells = scratch.blobs[i].cells.map(([x, y, m]) => [x + ox, y + oy, m]); + parts.push({ idx: i, cells: before, offset: r.offset, steps: r.maxSteps, deathStep: 0, path: r.path }); + } + } + + // scratch.board is always a fresh clone, so identity cannot decide this — + // only a walk that reported a real change makes a still hive a legal move. + const legal = parts.length > 0 || boardChanged; + return { legal, dead: false, parts, board: scratch.board, hitIce, crateMoves }; +} + +function planSingle(state, idx, dir) { + const blob = state.blobs[idx]; + const r = computeSlide(state, idx, dir, { trace: true }); + const cells = blob.cells.map((c) => c.slice()); + + if (r.deathStep > 0) { + return { + legal: true, dead: true, deathCause: r.deathCause, deathStep: r.deathStep, + parts: [{ idx, cells, offset: r.offset, steps: r.maxSteps, deathStep: r.deathStep, path: r.path }], + board: state.board, hitIce: [], crateMoves: new Map(), + }; + } + // A flick that moves nothing but still changes the board — shattering ice you + // are already touching, or pressing a button you are standing next to — is a + // real move. Otherwise ice you are up against could never be broken. + if (r.maxSteps === 0 && !r.boardChanged) { + return { legal: false, dead: false, parts: [], board: state.board, hitIce: [], crateMoves: new Map() }; + } + return { + legal: true, + dead: false, + parts: r.maxSteps > 0 + ? [{ idx, cells, offset: r.offset, steps: r.maxSteps, deathStep: 0, path: r.path }] + : [], + board: r.board, + hitIce: r.hitIce, + crateMoves: r.crateMoves, + }; +} + +// Apply a plan produced by planFlick() for the same state. Mutates. +export function applyPlan(state, plan) { + if (!plan.legal) return { moved: false }; + if (plan.dead) { + state.state = 'dead'; + return { moved: true, dead: true, deathStep: plan.deathStep, deathCause: plan.deathCause }; + } + + for (const part of plan.parts) { + const [ox, oy] = part.offset; + const blob = state.blobs[part.idx]; + blob.cells = blob.cells.map(([x, y, m]) => [x + ox, y + oy, m]); + } + state.board = plan.board; // the walk already resolved ice/slime/bricks/crates + + const before = state.blobs.length; + mergeBlobs(state); + state.state = state.blobs.length === 1 ? 'won' : 'playing'; + return { + moved: true, + dead: false, + merged: state.blobs.length < before, + steps: plan.parts[0]?.steps ?? 0, + shattered: plan.hitIce.length, + }; } // Flick blob `idx` in `dir`. Mutates state. Returns: @@ -137,72 +560,113 @@ export function computeSlide(state, idx, dir) { // { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off // { moved:true, dead:false, merged, steps } — slid and (maybe) merged export function slide(state, idx, dir) { - const { maxSteps, deathStep, deathCause } = computeSlide(state, idx, dir); - if (maxSteps === 0) return { moved: false }; - - const [dx, dy] = DIRS[dir]; - if (deathStep > 0) { - state.state = 'dead'; - return { moved: true, dead: true, deathStep, deathCause }; - } - - const blob = state.blobs[idx]; - blob.cells = blob.cells.map(([x, y, m]) => [x + dx * maxSteps, y + dy * maxSteps, m]); - const before = state.blobs.length; - mergeBlobs(state); - pickUpStars(state); - state.state = state.blobs.length === 1 ? 'won' : 'playing'; - return { moved: true, dead: false, merged: state.blobs.length < before, steps: maxSteps }; + return applyPlan(state, planFlick(state, idx, dir)); } // Every non-fatal flick available. Each entry carries the blob's representative -// cell so a move stays identifiable after merges renumber the blobs. +// cell so a move stays identifiable after merges renumber the blobs. Asleep +// blobs offer nothing, and the hypno hive mind offers one move per DIRECTION, +// not one per blob. export function legalMoves(state) { const moves = []; + let hypnoDirs = null; state.blobs.forEach((blob, idx) => { + if (isAsleep(blob)) return; for (const dir of DIR_LIST) { - const { maxSteps, deathStep } = computeSlide(state, idx, dir); - if (maxSteps > 0 && deathStep === 0) moves.push({ idx, dir, cell: repCell(blob) }); + if (blob.hypno) { + if (hypnoDirs?.has(dir)) continue; + (hypnoDirs ??= new Set()).add(dir); + const plan = planFlick(state, idx, dir); + if (plan.legal && !plan.dead) moves.push({ idx, dir, cell: repCell(blob) }); + continue; + } + // Fast path: a lone blob's legality is just its own walk. + const r = computeSlide(state, idx, dir); + if (r.deathStep === 0 && (r.maxSteps > 0 || r.boardChanged)) { + moves.push({ idx, dir, cell: repCell(blob) }); + } } }); return moves; } -// Canonical key: cells sorted within each blob, blobs sorted, joined. Walls and -// spikes are fixed for a level, so this fully identifies a configuration. +// Canonical key: cells sorted within each blob (plus its flags), blobs sorted, +// then the mutable board layers. Immutable terrain is fixed for a level so it is +// deliberately left out. export function stateKey(state) { - return state.blobs - .map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';')) + const blobs = state.blobs + .map((b) => { + const cells = b.cells.map(([x, y]) => key(x, y)).sort().join(';'); + let flags = ''; + if (b.asleep) flags += 'z'; + if (b.green) flags += 'g'; + if (b.hypno) flags += 'h'; + if (b.lifter) flags += 'p'; + return flags ? `${cells}#${flags}` : cells; + }) .sort() .join('|'); + const board = boardKey(state.board); + return board ? `${blobs}!${board}` : blobs; } export function cloneState(state) { return { cols: state.cols, rows: state.rows, - walls: state.walls, // immutable during play — shared - spikes: state.spikes, // immutable during play — shared - stars: state.stars, // immutable during play — shared - blobs: state.blobs.map((b) => ({ cells: b.cells.map((cell) => cell.slice()) })), - collected: new Set(state.collected), + walls: state.walls, // immutable during play — all shared + spikes: state.spikes, + springs: state.springs, + tunnels: state.tunnels, + buttons: state.buttons, + brickAt: state.brickAt, + board: cloneBoard(state.board), + blobs: state.blobs.map((b) => ({ ...b, cells: b.cells.map((cell) => cell.slice()) })), state: state.state, }; } export function newState(level) { + const flagSet = (list) => new Set((list ?? []).map(([x, y]) => key(x, y))); + const asleep = flagSet(level.sleepers); + const green = flagSet(level.green); + const hypno = flagSet(level.hypno); + const lifter = flagSet(level.powerlifters); + + const tunnels = new Map(); + for (const [ax, ay, bx, by] of (level.tunnels ?? [])) { + tunnels.set(key(ax, ay), [bx, by]); + tunnels.set(key(bx, by), [ax, ay]); + } + const cellGroupMap = (list) => { + const m = new Map(); + for (const [x, y, g] of (list ?? [])) m.set(key(x, y), g); + return m; + }; + const state = { cols: level.cols, rows: level.rows, walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))), spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))), - stars: (level.stars ?? []).map((c) => [c[0], c[1]]), - blobs: (level.monsters ?? []).map(([x, y], m) => ({ cells: [[x, y, m]] })), - collected: new Set(), + springs: new Set((level.springs ?? []).map(([x, y]) => key(x, y))), + tunnels, + buttons: cellGroupMap(level.buttons), + brickAt: cellGroupMap(level.bricks), + board: initialBoard(level), + blobs: (level.monsters ?? []).map(([x, y], m) => { + const k = key(x, y); + return makeBlob({ + cells: [[x, y, m]], + asleep: asleep.has(k), + green: green.has(k), + hypno: hypno.has(k), + lifter: lifter.has(k), + }); + }), state: 'playing', }; mergeBlobs(state); // merge any monsters that start touching - pickUpStars(state); state.state = state.blobs.length === 1 ? 'won' : 'playing'; return state; } diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index a8a2897..af2b08d 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -99,6 +99,13 @@ export function playSound(scene, key) { try { scene.sound.play(key); } catch (_) {} } +// Same, but with a Phaser sound config — `{ rate, volume, detune }`. Use this +// for pitch-shifted cues rather than `scene.sound.add(...)`, which leaks a +// Sound object per call. +export function playSoundEx(scene, key, config) { + try { scene.sound.play(key, config); } catch (_) {} +} + // Picks and plays one of several keys at random (e.g. variations of the same cue). export function playRandomSound(scene, keys) { playSound(scene, keys[Math.floor(Math.random() * keys.length)]); diff --git a/tools/genPuddingMonsters.js b/tools/genPuddingMonsters.js index e20ff6c..b08ac5c 100644 --- a/tools/genPuddingMonsters.js +++ b/tools/genPuddingMonsters.js @@ -1,24 +1,34 @@ -// Offline generator for Pudding Monsters levels. +// Offline generator for Pudding Monsters / "Jell-o Monsters" levels. // -// For each difficulty tier it random-fills a grid with walls, spikes and K -// monsters (the board edge is open — only walls and monsters stop a slide, so -// every tier carries walls), runs the BFS solver to (a) reject -// unsolvable/trivial layouts and -// (b) label each survivor with its minimum flick count (par), then marks 3 cells -// of that solution's final footprint as yellow target squares and writes ordered -// levels to data/puddingmonsters.json. Stars at play time require BOTH: -// the final merged blob covering the targets AND solving in par (min of the two -// medals) — so the on-par solution lands on all 3 targets and scores 3 stars. +// Wave 3 replaced the old flat tier ramp with a CURRICULUM: five named chapters +// of fifteen levels each, built from blocks that introduce one mechanic at a +// time, drill it, then combine it with what came before. See +// docs/puddingmonsters-mechanics-plan.md. +// +// Every candidate is random-filled, solved with the BFS solver for its `par`, +// and then has to survive three gates: +// +// 1. PAR BAND — the block says how hard its levels should be. +// 2. LOAD-BEARING — strip an element out and the level must solve in a +// different number of moves, or stop being solvable. This +// is what stops a "slime level" from being an ordinary +// level with a green monster standing in it. Every element +// in a combination level must pass this on its own. +// 3. GENTLE (teaching levels only) — NO first flick may be fatal. You cannot +// lose a teaching level on move one. +// +// Targets (the 3 yellow squares) are 3 cells of the optimal solution's final +// footprint, so the on-par solution is always a 3-star, crowned clear. // // Usage: -// node server/scripts/genPuddingMonsters.js [seed] [outFile] +// node tools/genPuddingMonsters.js [seed] [outFile] // // Deterministic: same seed -> same bank. Re-run after changing the curve. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { newState, solve } from '../src/games/puddingmonsters/PuddingMonstersLogic.js'; +import { newState, solve, planFlick, DIR_LIST } from '../src/games/puddingmonsters/PuddingMonstersLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[3] @@ -40,31 +50,149 @@ function makeRng(seed) { const rng = makeRng(SEED); const randInt = (n) => Math.floor(rng() * n); -// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each -// keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is -// OPEN (sliding off is fatal), so walls — the only terrain that stops a slide — -// appear from tier 1, like the original game. Spikes arrive in later tiers. -// Levels are numbered tier-by-tier. -const TIERS = [ - { count: 8, cols: 5, rows: 5, monsters: 3, walls: 3, spikes: 0, minPar: 2, maxPar: 3 }, - { count: 8, cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0, minPar: 3, maxPar: 5 }, - { count: 8, cols: 6, rows: 6, monsters: 4, walls: 5, spikes: 0, minPar: 4, maxPar: 7 }, - { count: 8, cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1, minPar: 5, maxPar: 9 }, - { count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 }, +// ── Element catalogue ──────────────────────────────────────────────────────── +// `typed` elements mark a subset of the monsters; the rest place extra terrain. +// `needs` pulls in the terrain a monster type is useless without. +const ELEMENTS = { + sleepers: { typed: 1, tip: 'Sleepyheads can\'t be flicked — bump one awake to move it.' }, + green: { typed: 1, tip: 'Green monsters leave slime. Anything sliding onto it stops dead.' }, + hypno: { typed: 2, tip: 'Hypno goos share one mind — flick one and they all go.' }, + powerlifters: { typed: 1, terrain: { crates: 2 }, tip: 'Powerlifters shove crates around. Everyone else just stops.' }, + ice: { terrain: { ice: 3 }, tip: 'Ice stops a slide once, then shatters. Spend it wisely.' }, + springs: { terrain: { springs: 2 }, tip: 'Springs bounce you straight back — and you keep going.' }, + tunnels: { terrain: { tunnels: 1 }, tip: 'Tunnels carry a monster across the table, still sliding.' }, + buttons: { terrain: { bricks: 2, buttons: 1 }, tip: 'Roll over the button to raise or lower the bricks.' }, +}; + +// ── The curriculum ─────────────────────────────────────────────────────────── +// Each chapter is 15 levels of blocks. `teach` = one gentle introduction, +// `drill` = reinforcement of the same element, `mix` = combinations, `basic` = +// no elements at all. Chapter defaults are merged into every block. +const CHAPTERS = [ + { + id: 1, + name: 'Cold Storage', + blurb: 'Wake up. Get out of the fridge.', + base: { cols: 5, rows: 5, monsters: 3, walls: 4, spikes: 0 }, + blocks: [ + { kind: 'basic', count: 3, minPar: 2, maxPar: 3 }, + { kind: 'teach', element: 'sleepers', count: 1, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'sleepers', count: 3, minPar: 3, maxPar: 5 }, + { kind: 'teach', element: 'ice', count: 1, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'ice', count: 3, minPar: 3, maxPar: 5 }, + { kind: 'mix', elements: ['sleepers', 'ice'], count: 4, cols: 6, rows: 6, monsters: 4, walls: 5, minPar: 4, maxPar: 8 }, + ], + names: [ + 'First Wobble', 'Two of a Kind', 'Shelf Life', + 'Rise and Shine', 'Snooze Button', 'Heavy Sleeper', 'Dream Team', + 'Breaking the Ice', 'Cold Snap', 'Thin Ice', 'Freezer Burn', + 'Chill Out', 'Frost and Friends', 'Deep Freeze', 'Door Ajar', + ], + }, + { + id: 2, + name: 'Kitchen Counter', + blurb: 'Mind the mess. Some of it is yours.', + base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 }, + blocks: [ + { kind: 'basic', count: 2, minPar: 3, maxPar: 5 }, + { kind: 'teach', element: 'green', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'green', count: 4, minPar: 3, maxPar: 6 }, + { kind: 'teach', element: 'hypno', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'hypno', count: 4, monsters: 4, walls: 5, minPar: 3, maxPar: 6 }, + { kind: 'mix', elements: ['green', 'hypno'], count: 3, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + ], + names: [ + 'Countertop Caper', 'Spilled Milk', + 'Sticky Business', 'Green Streak', 'Trail Mix', 'Slime Time', 'Gooey Detour', + 'Group Think', 'All Together Now', 'Hive Mind', 'Synchronised Slipping', 'Follow the Leader', + 'Slime and Punishment', 'One Mind, Many Blobs', 'Crumb Trail', + ], + }, + { + id: 3, + name: 'The Pantry', + blurb: 'Shortcuts, springs, and a long way down.', + base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 }, + blocks: [ + { kind: 'basic', count: 1, minPar: 3, maxPar: 5 }, + { kind: 'teach', element: 'springs', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'springs', count: 4, minPar: 3, maxPar: 6 }, + { kind: 'teach', element: 'tunnels', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'tunnels', count: 4, minPar: 3, maxPar: 6 }, + { kind: 'mix', elements: ['springs', 'tunnels'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + { kind: 'mix', elements: ['springs', 'ice'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + { kind: 'mix', elements: ['tunnels', 'green'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + ], + names: [ + 'Shelf Assembly', + 'Boing', 'Bounce Back', 'Double Trouble', 'Springboard', 'Return to Sender', + 'Down the Hatch', 'Wormhole', 'Pantry Portal', 'In One End', 'Shortcut', + 'Bounce and Beyond', 'Jar Jam', 'Spice Rack', 'Pantry Panic', + ], + }, + { + id: 4, + name: 'Dining Room', + blurb: 'Push the furniture. Nobody is watching.', + base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 }, + blocks: [ + { kind: 'basic', count: 1, minPar: 3, maxPar: 5 }, + { kind: 'teach', element: 'buttons', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'buttons', count: 4, minPar: 3, maxPar: 6 }, + { kind: 'teach', element: 'powerlifters', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 }, + { kind: 'drill', element: 'powerlifters', count: 4, minPar: 3, maxPar: 6 }, + { kind: 'mix', elements: ['buttons', 'powerlifters'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + { kind: 'mix', elements: ['powerlifters', 'springs'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + { kind: 'mix', elements: ['buttons', 'sleepers'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 }, + ], + names: [ + 'Table Manners', + 'Press Here', 'Trapdoor', 'Now You Don\'t', 'Push and Pull', 'Brickwork', + 'Heavy Lifting', 'Crate Expectations', 'Muscle Memory', 'Shove Off', 'Furniture Shuffle', + 'Dinner Service', 'Table for One', 'Centrepiece', 'Clean Plate', + ], + }, + { + id: 5, + name: 'Midnight Feast', + blurb: 'Everything you have learned, all at once.', + base: { cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1 }, + blocks: [ + { kind: 'mix', elements: ['sleepers', 'springs'], count: 2, minPar: 4, maxPar: 10 }, + { kind: 'mix', elements: ['green', 'tunnels'], count: 2, minPar: 4, maxPar: 10 }, + { kind: 'mix', elements: ['ice', 'buttons'], count: 2, minPar: 4, maxPar: 10 }, + { kind: 'mix', elements: ['hypno', 'powerlifters'], count: 2, minPar: 4, maxPar: 10 }, + { kind: 'mix', elements: ['springs', 'tunnels', 'sleepers'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 }, + { kind: 'mix', elements: ['green', 'ice', 'buttons'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 }, + { kind: 'mix', elements: ['powerlifters', 'tunnels', 'hypno'], count: 3, monsters: 5, walls: 7, minPar: 5, maxPar: 14 }, + ], + names: [ + 'Night Shift', 'Leftovers', 'Midnight Snack', 'Everything Bagel', 'Kitchen Sink', + 'The Long Way Round', 'Sleepwalking', 'Slippery Slope', 'Spring Cleaning', 'Tunnel Vision', + 'Button Masher', 'Crate Escape', 'Full Fridge', 'Last Supper', 'Jell-o Monster', + ], + }, ]; -const MAX_ATTEMPTS = 6000000; -const MAX_SECONDS = 200; + +const MAX_TRIES_PER_LEVEL = 400000; +const SECONDS_PER_BLOCK = 300; const SOLVE_MAX_STATES = 80000; const keyOf = (x, y) => `${x},${y}`; // Place `n` distinct random cells avoiding `taken`; returns null if it can't. -function placeCells(n, cols, rows, taken) { +// `interior` keeps cells off the rim, which is what makes guard rails possible. +function placeCells(n, cols, rows, taken, interior = false) { const out = []; let tries = 0; + const lo = interior ? 1 : 0; + const wx = interior ? cols - 2 : cols; + const wy = interior ? rows - 2 : rows; + if (wx <= 0 || wy <= 0) return null; while (out.length < n && tries < 400) { tries++; - const x = randInt(cols), y = randInt(rows); + const x = lo + randInt(wx), y = lo + randInt(wy); const k = keyOf(x, y); if (taken.has(k)) continue; taken.add(k); @@ -73,110 +201,257 @@ function placeCells(n, cols, rows, taken) { return out.length === n ? out : null; } -// Build one random candidate level for a tier (or null on failure). -function randomLevel(tier) { +// Teaching levels promise you cannot lose on move one. Random walls almost +// never deliver that on an open-edged table, so build it in: for every monster +// and every direction whose ray runs clean off the board, drop a wall on the +// last cell of that ray. The monster then stops one short of the rim instead of +// sailing over it. Monsters must already be off the rim themselves — a monster +// standing on the edge facing out cannot be saved by any wall. +function addGuardRails(lvl, taken) { + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const [mx, my] of lvl.monsters) { + for (const [dx, dy] of dirs) { + let blocked = false; + let last = null; + for (let s = 1; ; s++) { + const x = mx + dx * s, y = my + dy * s; + if (x < 0 || y < 0 || x >= lvl.cols || y >= lvl.rows) break; + if (taken.has(keyOf(x, y))) { blocked = true; break; } + last = [x, y]; + } + if (blocked) continue; + if (!last) return false; // monster is on the rim facing out + taken.add(keyOf(last[0], last[1])); + lvl.walls.push(last); + } + } + return true; +} + +// Build one random candidate for a block (or null if the board is too crowded). +function randomLevel(spec) { + const elements = spec.elements ?? (spec.element ? [spec.element] : []); const taken = new Set(); - const walls = placeCells(tier.walls, tier.cols, tier.rows, taken); + + const walls = placeCells(spec.walls, spec.cols, spec.rows, taken); if (!walls) return null; - const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken); + const spikes = placeCells(spec.spikes ?? 0, spec.cols, spec.rows, taken); if (!spikes) return null; - const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken); + + const lvl = { cols: spec.cols, rows: spec.rows, walls, spikes }; + + // Terrain each element needs. + for (const el of elements) { + const terrain = ELEMENTS[el].terrain ?? {}; + for (const [field, count] of Object.entries(terrain)) { + const n = spec.counts?.[field] ?? count; + if (field === 'tunnels') { + const mouths = placeCells(n * 2, spec.cols, spec.rows, taken); + if (!mouths) return null; + lvl.tunnels = []; + for (let i = 0; i < mouths.length; i += 2) { + lvl.tunnels.push([mouths[i][0], mouths[i][1], mouths[i + 1][0], mouths[i + 1][1]]); + } + } else if (field === 'bricks' || field === 'buttons') { + const cells = placeCells(n, spec.cols, spec.rows, taken); + if (!cells) return null; + lvl[field] = cells.map(([x, y]) => [x, y, 1]); // one group keeps cause and effect readable + } else { + const cells = placeCells(n, spec.cols, spec.rows, taken); + if (!cells) return null; + lvl[field] = cells; + } + } + } + + const monsters = placeCells(spec.monsters, spec.cols, spec.rows, taken, spec.kind === 'teach'); if (!monsters) return null; - return { - cols: tier.cols, rows: tier.rows, walls, spikes, monsters, - }; + lvl.monsters = monsters; + + // Teaching levels get a rail on any line that would drop a monster off the + // table, so the opening position is survivable whatever the player tries. + if (spec.kind === 'teach' && !addGuardRails(lvl, taken)) return null; + + // Typed monsters are a random subset of the monsters already placed. Never + // type every monster: something has to be flickable. + const pool = monsters.slice(); + for (const el of elements) { + const n = spec.counts?.[el] ?? ELEMENTS[el].typed ?? 0; + if (!n) continue; + const picked = []; + while (picked.length < n && pool.length > 1) picked.push(pool.splice(randInt(pool.length), 1)[0]); + if (picked.length !== n) return null; + lvl[el] = picked; + } + return lvl; +} + +// The element has to matter: with it removed the level must solve in a +// different number of moves, or stop being solvable at all. +function isLoadBearing(lvl, element, par) { + const stripped = { ...lvl }; + delete stripped[element]; + delete stripped.targets; + const state = newState(stripped); + if (state.blobs.length === 1) return true; // the element was holding them apart + return solve(state, { maxStates: SOLVE_MAX_STATES }).moves !== par; +} + +// How many opening flicks kill you. Teaching levels must have none. +function fatalFirstMoves(state) { + let n = 0; + state.blobs.forEach((blob, idx) => { + if (blob.asleep) return; + for (const dir of DIR_LIST) { + const plan = planFlick(state, idx, dir); + if (plan.legal && plan.dead) n += 1; + } + }); + return n; } function canonKey(lvl) { - const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' '); - return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`; + const s = (arr) => (arr ?? []).map((c) => c.join(',')).sort().join(' '); + return [lvl.cols, lvl.rows, s(lvl.monsters), s(lvl.walls), s(lvl.spikes), s(lvl.ice), + s(lvl.sleepers), s(lvl.green), s(lvl.hypno), s(lvl.springs), s(lvl.tunnels), + s(lvl.bricks), s(lvl.buttons), s(lvl.crates), s(lvl.powerlifters)].join('|'); } // 3 spread-out cells of the solution's final footprint -> yellow target squares. -// The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct. function chooseTargets(footprint) { const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])]; return idx.map((i) => [sorted[i][0], sorted[i][1]]); } -// ── Generate pool ──────────────────────────────────────────────────────────── +// ── Build the bank chapter by chapter ──────────────────────────────────────── + console.log(`[pudding] generating with seed ${SEED}…`); -const target = TIERS.reduce((t, x) => t + x.count, 0); -const buckets = TIERS.map(() => []); const seen = new Set(); -let attempts = 0; -let solved = 0; -const startedAt = Date.now(); -const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); +const bank = []; +let totalTries = 0; -while (attempts < MAX_ATTEMPTS && !tiersFull()) { - // Round-robin the tiers that still need levels so every tier gets airtime. - for (let ti = 0; ti < TIERS.length; ti++) { - if (buckets[ti].length >= TIERS[ti].count) continue; - attempts++; - if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { - console.log('\n[pudding] time budget reached, stopping early'); - break; +for (const chapter of CHAPTERS) { + const produced = []; + for (const block of chapter.blocks) { + const spec = { ...chapter.base, ...block }; + const elements = spec.elements ?? (spec.element ? [spec.element] : []); + const kept = []; + let tries = 0; + const started = Date.now(); + + while (kept.length < block.count + && tries < MAX_TRIES_PER_LEVEL + && (Date.now() - started) / 1000 < SECONDS_PER_BLOCK) { + tries++; totalTries++; + const lvl = randomLevel(spec); + if (!lvl) continue; + const ck = canonKey(lvl); + if (seen.has(ck)) continue; + seen.add(ck); + + const state = newState(lvl); + if (state.blobs.length !== spec.monsters) continue; // started adjacent + if (block.kind === 'teach' && fatalFirstMoves(state) > 0) continue; + + const res = solve(state, { maxStates: SOLVE_MAX_STATES }); + if (res.moves < Math.max(2, spec.minPar) || res.moves > spec.maxPar) continue; + if (!elements.every((el) => isLoadBearing(lvl, el, res.moves))) continue; + + lvl.targets = chooseTargets(res.footprint); + if (lvl.targets.length !== 3) continue; + lvl.par = res.moves; + lvl.chapter = chapter.id; + lvl.kind = block.kind; + if (elements.length) lvl.elements = elements.slice(); + if (block.kind === 'teach') { + lvl.element = block.element; + lvl.tip = ELEMENTS[block.element].tip; + lvl.teaching = true; + } else if (elements.length) { + lvl.element = elements[0]; + } + kept.push(lvl); } - const tier = TIERS[ti]; - const lvl = randomLevel(tier); - if (!lvl) continue; - const ck = canonKey(lvl); - if (seen.has(ck)) continue; - seen.add(ck); - - const state = newState(lvl); - if (state.blobs.length !== tier.monsters) continue; // started adjacent -> skip - - const res = solve(state, { maxStates: SOLVE_MAX_STATES }); - if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue; - solved++; - - lvl.targets = chooseTargets(res.footprint); - if (lvl.targets.length !== 3) continue; - lvl.par = res.moves; - buckets[ti].push(lvl); - - if (solved % 200 === 0) { - const kept = buckets.reduce((t, b) => t + b.length, 0); - process.stdout.write(`\r[pudding] attempts=${attempts} kept=${kept}/${target} `); + if (kept.length < block.count) { + console.error(`[pudding] WARNING ch${chapter.id} ${block.kind}` + + `(${elements.join('+') || 'basic'}): only ${kept.length}/${block.count} after ${tries} tries`); } + kept.sort((p, q) => p.par - q.par); + produced.push(...kept); } - if ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break; + // Difficulty rises across the chapter without breaking the teach-then-drill + // order: blocks stay in curriculum order and sort by par inside themselves. + // The combination blocks all sit at the end, so sort that whole run together + // rather than letting each block restart the ramp. + let firstMix = produced.length; + while (firstMix > 0 && produced[firstMix - 1].kind === 'mix') firstMix -= 1; + const tail = produced.slice(firstMix).sort((p, q) => p.par - q.par); + produced.length = firstMix; + produced.push(...tail); + chapter.produced = produced; + bank.push(...produced); + console.log(`[pudding] chapter ${chapter.id} "${chapter.name}": ${produced.length}/15 levels`); } -process.stdout.write('\n'); -// ── Assemble ordered levels (tier order, then par ascending within tier) ────── -const chosen = []; -buckets.forEach((b) => { - b.sort((p, q) => p.par - q.par); - chosen.push(...b); +// ── Assemble ───────────────────────────────────────────────────────────────── + +const levels = bank.map((lvl, i) => { + const out = { + level: i + 1, + chapter: lvl.chapter, + name: '', + cols: lvl.cols, + rows: lvl.rows, + walls: lvl.walls, + spikes: lvl.spikes, + targets: lvl.targets, + monsters: lvl.monsters, + par: lvl.par, + }; + for (const f of ['ice', 'slime', 'sleepers', 'green', 'hypno', + 'springs', 'tunnels', 'bricks', 'buttons', 'bricksDown', 'crates', 'powerlifters']) { + if (lvl[f]?.length) out[f] = lvl[f]; + } + if (lvl.elements) out.elements = lvl.elements; + if (lvl.element) out.element = lvl.element; + if (lvl.tip) out.tip = lvl.tip; + if (lvl.teaching) out.teaching = true; + return out; }); -const levels = chosen.map((lvl, i) => ({ - level: i + 1, - cols: lvl.cols, - rows: lvl.rows, - walls: lvl.walls, - spikes: lvl.spikes, - targets: lvl.targets, - monsters: lvl.monsters, - par: lvl.par, -})); +// Names run in chapter order, so a teaching level keeps the name written for it. +const chapters = []; +let cursor = 0; +for (const chapter of CHAPTERS) { + const n = chapter.produced.length; + if (!n) continue; + for (let i = 0; i < n; i++) { + levels[cursor + i].name = chapter.names[i] ?? `${chapter.name} ${i + 1}`; + } + chapters.push({ + id: chapter.id, + name: chapter.name, + blurb: chapter.blurb, + from: cursor + 1, + to: cursor + n, + }); + cursor += n; +} const payload = { generatedAt: new Date().toISOString(), seed: SEED, count: levels.length, + chapters, levels, }; fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); -const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' '); -console.log(`[pudding] attempts=${attempts} solvable=${solved}`); -console.log(`[pudding] tiers filled: ${perTier}`); -console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`); +console.log(`[pudding] ${totalTries} candidates tried`); +for (const c of chapters) console.log(`[pudding] ch${c.id} ${c.name}: levels ${c.from}-${c.to}`); +console.log(`[pudding] wrote ${levels.length} levels (par ${Math.min(...levels.map((l) => l.par))}` + + `..${Math.max(...levels.map((l) => l.par))}) -> ${OUT_FILE}`); diff --git a/tools/verifyPuddingMonsters.js b/tools/verifyPuddingMonsters.js new file mode 100644 index 0000000..15d84a6 --- /dev/null +++ b/tools/verifyPuddingMonsters.js @@ -0,0 +1,846 @@ +// Verifier for Pudding Monsters / "Jell-o Monsters" (Node only — no browser). +// +// 1. Schema-lints data/puddingmonsters.json (bounds, overlaps, target legality). +// 2. Re-solves every level fresh from the JSON (independent of whatever +// genPuddingMonsters.js asserted at generation time), checks `par`, and +// replays the optimal path to confirm it really wins with 3 stars. +// 3. Unit-tests the engine primitives against small synthetic levels: slide +// stopping, edge/spike death, merging, no-ops, and the solver. +// 4. Exercises the mutable-board plumbing (state.board layers, per-blob asleep +// flags) that Waves 1-2 hang their elements off — see +// docs/puddingmonsters-mechanics-plan.md. These checks are what stop a new +// element from silently escaping stateKey() and corrupting `par`. +// +// Usage: node tools/verifyPuddingMonsters.js + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + DIRS, DIR_LIST, newState, cloneState, slide, computeSlide, legalMoves, solve, + planFlick, applyPlan, + + stateKey, boardKey, cloneBoard, blobAt, repCell, targetsCovered, +} from '../src/games/puddingmonsters/PuddingMonstersLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FILE = path.join(__dirname, '../data/puddingmonsters.json'); + +let passes = 0; +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} + +const keyOf = (x, y) => `${x},${y}`; + +// ── 1. Bank schema ─────────────────────────────────────────────────────────── + +const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); +const levels = raw.levels ?? []; +console.log(`[verify] ${FILE}`); +console.log(`[verify] ${levels.length} levels`); + +check('bank is non-empty', levels.length > 0, `found ${levels.length}`); +check('bank count matches the levels array', raw.count === levels.length); +check('levels are numbered 1..N in order', + levels.every((l, i) => l.level === i + 1)); + +for (const def of levels) { + const inBounds = ([x, y]) => x >= 0 && y >= 0 && x < def.cols && y < def.rows; + const walls = def.walls ?? []; + const spikes = def.spikes ?? []; + const targets = def.targets ?? []; + const monsters = def.monsters ?? []; + const all = [...walls, ...spikes, ...targets, ...monsters]; + + check(`L${def.level}: every cell is in bounds`, all.every(inBounds)); + + const wallKeys = new Set(walls.map(([x, y]) => keyOf(x, y))); + const spikeKeys = new Set(spikes.map(([x, y]) => keyOf(x, y))); + const monsterKeys = new Set(monsters.map(([x, y]) => keyOf(x, y))); + + check(`L${def.level}: walls, spikes and monsters do not overlap`, + wallKeys.size === walls.length + && spikeKeys.size === spikes.length + && monsterKeys.size === monsters.length + && [...wallKeys].every((k) => !spikeKeys.has(k) && !monsterKeys.has(k)) + && [...spikeKeys].every((k) => !monsterKeys.has(k))); + + check(`L${def.level}: has 3 distinct target squares`, + targets.length === 3 && new Set(targets.map(([x, y]) => keyOf(x, y))).size === 3); + + check(`L${def.level}: no target sits on a wall or spike`, + targets.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y)))); + + check(`L${def.level}: par is a sane move count`, + Number.isInteger(def.par) && def.par >= 2, `par=${def.par}`); + + // Wave 1 elements are optional per level. + const ice = def.ice ?? []; + check(`L${def.level}: ice is in bounds and clear of other terrain`, + ice.every(inBounds) + && ice.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y)) && !monsterKeys.has(keyOf(x, y)))); + + for (const kind of ['sleepers', 'green', 'hypno']) { + const list = def[kind] ?? []; + if (!list.length) continue; + check(`L${def.level}: every ${kind} entry is one of the level's monsters`, + list.every(([x, y]) => monsterKeys.has(keyOf(x, y)))); + check(`L${def.level}: ${kind} entries are distinct`, + new Set(list.map(([x, y]) => keyOf(x, y))).size === list.length); + } + check(`L${def.level}: not every monster is asleep`, + (def.sleepers ?? []).length < monsters.length); + check(`L${def.level}: a hive needs at least two members`, + !def.hypno || def.hypno.length >= 2); + + // Wave 2 elements. + const occupied = new Set([...wallKeys, ...spikeKeys, ...monsterKeys, ...ice.map(([x, y]) => keyOf(x, y))]); + const clearCells = (list, name) => { + check(`L${def.level}: ${name} is in bounds and clear of other terrain`, + list.every(inBounds) && list.every(([x, y]) => !occupied.has(keyOf(x, y)))); + for (const [x, y] of list) occupied.add(keyOf(x, y)); + }; + clearCells(def.springs ?? [], 'springs'); + clearCells((def.bricks ?? []).map(([x, y]) => [x, y]), 'bricks'); + clearCells((def.buttons ?? []).map(([x, y]) => [x, y]), 'buttons'); + clearCells(def.crates ?? [], 'crates'); + + if (def.tunnels?.length) { + check(`L${def.level}: every tunnel links two distinct in-bounds cells`, + def.tunnels.every(([ax, ay, bx, by]) => + inBounds([ax, ay]) && inBounds([bx, by]) && keyOf(ax, ay) !== keyOf(bx, by))); + const mouths = def.tunnels.flatMap(([ax, ay, bx, by]) => [[ax, ay], [bx, by]]); + check(`L${def.level}: tunnel mouths are distinct and clear of other terrain`, + new Set(mouths.map(([x, y]) => keyOf(x, y))).size === mouths.length + && mouths.every(([x, y]) => !occupied.has(keyOf(x, y)))); + } + if (def.bricks?.length) { + const groups = new Set(def.bricks.map(([, , g]) => g)); + check(`L${def.level}: every brick group has a button`, + [...groups].every((g) => (def.buttons ?? []).some(([, , bg]) => bg === g))); + check(`L${def.level}: brick and button groups are defined`, + def.bricks.every(([, , g]) => g !== undefined) + && (def.buttons ?? []).every(([, , g]) => g !== undefined)); + } + check(`L${def.level}: buttons without bricks would do nothing`, + !(def.buttons ?? []).length || (def.bricks ?? []).length > 0); + if (def.powerlifters?.length) { + check(`L${def.level}: every powerlifter is one of the level's monsters`, + def.powerlifters.every(([x, y]) => monsterKeys.has(keyOf(x, y)))); + check(`L${def.level}: crates without a powerlifter are just walls`, + (def.crates ?? []).length > 0); + } +} + +// ── 2. Every level re-solved and replayed ──────────────────────────────────── + +let parOk = 0; +let winOk = 0; +let starsOk = 0; +for (const def of levels) { + const state = newState(def); + if (state.blobs.length !== (def.monsters ?? []).length) { + check(`L${def.level}: monsters do not start already merged`, false); + continue; + } + + const res = solve(state, { maxStates: 200000 }); + if (res.moves === def.par) parOk += 1; + else check(`L${def.level}: solver par matches the bank`, false, `solver=${res.moves} bank=${def.par}`); + + // Replay the optimal path move-by-move on a fresh state. + const play = newState(def); + let replayed = true; + for (const mv of res.path ?? []) { + const idx = blobAt(play, mv.cell[0], mv.cell[1]); + if (idx < 0) { replayed = false; break; } + const out = slide(play, idx, mv.dir); + if (!out.moved || out.dead) { replayed = false; break; } + } + if (replayed && play.state === 'won' && play.blobs.length === 1) winOk += 1; + else check(`L${def.level}: the optimal path replays to a win`, false); + + if (targetsCovered(play, def.targets) === 3) starsOk += 1; + else check(`L${def.level}: the optimal solution covers all 3 targets`, false, + `covered=${targetsCovered(play, def.targets)}`); +} +check(`all ${levels.length} levels: solver par matches the bank`, parOk === levels.length, `${parOk}/${levels.length}`); +check(`all ${levels.length} levels: optimal path replays to a win`, winOk === levels.length, `${winOk}/${levels.length}`); +check(`all ${levels.length} levels: optimal solution is a 3-star clear`, starsOk === levels.length, `${starsOk}/${levels.length}`); + +// Levels that advertise a mechanic must actually need it: strip the element and +// the level has to solve in a different number of moves, or not at all. Every +// element of a combination level is checked on its own. Re-checked here rather +// than trusted from generation time. +{ + const tagged = levels.filter((l) => l.elements?.length); + let bearing = 0; + let pairs = 0; + for (const def of tagged) { + let allOk = true; + for (const el of def.elements) { + pairs += 1; + const stripped = { ...def }; + delete stripped[el]; + const st = newState(stripped); + const res = st.blobs.length === 1 ? { moves: 0 } : solve(st, { maxStates: 200000 }); + if (res.moves === def.par) { + allOk = false; + check(`L${def.level} "${def.name}": its ${el} is load-bearing`, false, `same par (${def.par}) without it`); + } + } + if (allOk) bearing += 1; + } + check(`all ${tagged.length} element levels: every mechanic in them is load-bearing`, + bearing === tagged.length, `${bearing}/${tagged.length} levels, ${pairs} element checks`); + check('the bank exercises every mechanic', + ['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters'] + .every((e) => tagged.some((l) => l.elements.includes(e)))); +} + +// ── 2b. The curriculum ─────────────────────────────────────────────────────── + +{ + const chapters = raw.chapters ?? []; + check('the bank declares chapters', chapters.length > 0); + check('chapters cover every level exactly once, in order', (() => { + let expect = 1; + for (const c of chapters) { + if (c.from !== expect) return false; + if (c.to < c.from) return false; + expect = c.to + 1; + } + return expect === levels.length + 1; + })()); + check('every chapter has a name and a blurb', + chapters.every((c) => typeof c.name === 'string' && c.name && typeof c.blurb === 'string' && c.blurb)); + check('every level knows which chapter it is in', + levels.every((l) => chapters.some((c) => c.id === l.chapter && l.level >= c.from && l.level <= c.to))); + + check('every level is named', levels.every((l) => typeof l.name === 'string' && l.name.length > 0)); + check('level names are unique', new Set(levels.map((l) => l.name)).size === levels.length); + + // Every mechanic gets exactly one teaching level, and it comes before any + // other level that uses that mechanic. + const taught = levels.filter((l) => l.teaching); + check('every mechanic has exactly one teaching level', (() => { + const seenEls = taught.map((l) => l.element); + return new Set(seenEls).size === seenEls.length + && ['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters'] + .every((e) => seenEls.includes(e)); + })(), taught.map((l) => l.element).join(',')); + + for (const t of taught) { + const firstUse = levels.find((l) => l.elements?.includes(t.element)); + check(`the ${t.element} lesson (L${t.level} "${t.name}") comes before any level using it`, + firstUse.level === t.level, `first use is L${firstUse.level}`); + check(`L${t.level} "${t.name}": a lesson is short (par <= 3)`, t.par <= 3, `par=${t.par}`); + check(`L${t.level} "${t.name}": a lesson explains itself`, typeof t.tip === 'string' && t.tip.length > 0); + } + + // The promise of a teaching level: you cannot lose it on move one. + let gentle = 0; + for (const t of taught) { + const st = newState(t); + let fatal = 0; + st.blobs.forEach((blob, idx) => { + if (blob.asleep) return; + for (const dir of DIR_LIST) { + const plan = planFlick(st, idx, dir); + if (plan.legal && plan.dead) fatal += 1; + } + }); + if (fatal === 0) gentle += 1; + else check(`L${t.level} "${t.name}": no opening flick is fatal`, false, `${fatal} fatal openings`); + } + check(`all ${taught.length} teaching levels: no opening flick is fatal`, gentle === taught.length); + + // Difficulty should trend upward across the game, even if not monotonically. + const avgPar = (from, to) => { + const inRange = levels.filter((l) => l.level >= from && l.level <= to); + return inRange.reduce((t, l) => t + l.par, 0) / inRange.length; + }; + const firstChapter = chapters[0]; + const lastChapter = chapters[chapters.length - 1]; + check('the last chapter is harder than the first', + avgPar(lastChapter.from, lastChapter.to) > avgPar(firstChapter.from, firstChapter.to), + `${avgPar(firstChapter.from, firstChapter.to).toFixed(1)} -> ${avgPar(lastChapter.from, lastChapter.to).toFixed(1)}`); + check('the game opens gently', avgPar(1, 5) <= 3.5, `${avgPar(1, 5).toFixed(1)}`); +} + +// ── 3. Engine primitives ───────────────────────────────────────────────────── + +// Helper: a level literal, monsters listed as [x,y]. +const L = (o) => ({ cols: 5, rows: 5, walls: [], spikes: [], targets: [], monsters: [], ...o }); + +{ + // . . . . . A slides right until the wall at (3,0) stops it at (2,0). + const s = newState(L({ monsters: [[0, 0]], walls: [[3, 0]] })); + const r = computeSlide(s, 0, 'right'); + check('a slide stops in front of a wall', r.maxSteps === 2 && r.deathStep === 0, + `maxSteps=${r.maxSteps}`); + check('offset agrees with the step count on a straight slide', + r.offset[0] === 2 && r.offset[1] === 0); + slide(s, 0, 'right'); + check('slide() moves the blob to the computed rest cell', + s.blobs[0].cells[0][0] === 2 && s.blobs[0].cells[0][1] === 0); +} + +{ + // Nothing in the way -> the blob runs off the open edge and dies. + const s = newState(L({ monsters: [[0, 0], [4, 4]] })); + const r = computeSlide(s, 0, 'left'); + check('sliding off the open edge is fatal', r.deathStep === 1 && r.deathCause === 'edge'); + check('a fatal walk reports stopReason "dead"', r.stopReason === 'dead'); + const out = slide(s, 0, 'left'); + check('a fatal slide marks the run dead and leaves positions untouched', + out.dead === true && s.state === 'dead' + && s.blobs[0].cells[0][0] === 0 && s.blobs[0].cells[0][1] === 0); +} + +{ + // Spike two cells right of the monster, wall beyond it. + const s = newState(L({ monsters: [[0, 0]], spikes: [[2, 0]], walls: [[4, 0]] })); + const r = computeSlide(s, 0, 'right'); + check('a slide across a spike is fatal at the spike', + r.deathStep === 2 && r.deathCause === 'spike', `deathStep=${r.deathStep} cause=${r.deathCause}`); +} + +{ + // A wall immediately to the left -> the flick is a no-op, not a death. + const s = newState(L({ monsters: [[1, 0], [4, 4]], walls: [[0, 0]] })); + const r = computeSlide(s, 0, 'left'); + check('a blocked flick is a no-op', r.maxSteps === 0 && r.deathStep === 0); + check('slide() reports a no-op without mutating', slide(s, 0, 'left').moved === false); + check('a no-op flick is not offered as a legal move', + !legalMoves(s).some((m) => m.idx === 0 && m.dir === 'left')); + check('a fatal flick is not offered as a legal move', + !legalMoves(s).some((m) => m.idx === 0 && m.dir === 'up')); +} + +{ + // Two monsters in a row; A slides into B and sticks. + const s = newState(L({ monsters: [[0, 0], [3, 0]] })); + const out = slide(s, 0, 'right'); + check('a blob stops against another blob and merges', + out.merged === true && s.blobs.length === 1 && s.blobs[0].cells.length === 2); + check('merged cells keep their original monster index', + new Set(s.blobs[0].cells.map((c) => c[2])).size === 2); + check('merging every monster wins the level', s.state === 'won'); +} + +{ + // Transitive merge: C is already adjacent to B, A arrives -> one blob of 3. + const s = newState(L({ monsters: [[0, 0], [3, 0], [3, 1]] })); + check('monsters that start adjacent merge at newState', s.blobs.length === 2); + slide(s, blobAt(s, 0, 0), 'right'); + check('merges are transitive', s.blobs.length === 1 && s.blobs[0].cells.length === 3); +} + +{ + // A merged blob moves rigidly: both cells travel the same distance. + const s = newState(L({ monsters: [[1, 1], [2, 1]], walls: [[1, 4]] })); + const idx = blobAt(s, 1, 1); + slide(s, idx, 'down'); + const ys = s.blobs[0].cells.map((c) => c[1]); + check('a merged blob slides as one rigid piece', + ys.every((y) => y === 3) && s.blobs[0].cells.length === 2, `ys=${ys}`); +} + +{ + const s = newState(L({ monsters: [[2, 2]] })); + check('a level that starts merged is already won', s.state === 'won' && s.blobs.length === 1); + check('solve() returns 0 moves for an already-won level', solve(s).moves === 0); +} + +{ + // repCell is the top-left-most cell, stable regardless of cell order. + const blob = { cells: [[3, 1, 0], [2, 1, 1], [2, 0, 2]] }; + const rc = repCell(blob); + check('repCell picks the top-left-most cell', rc[0] === 2 && rc[1] === 0); +} + +// ── 4. State identity: the mutable board and blob flags ────────────────────── +// +// Waves 1-2 add slime, ice, bricks and crates as layers on state.board, and an +// `asleep` flag on blobs. Both are mutable, so both MUST change stateKey — if +// they do not, the BFS solver merges genuinely different positions and reports +// a par lower than the level can actually be solved in. + +{ + const s = newState(L({ monsters: [[0, 0], [4, 4]] })); + check('a level with no elements has an empty board key', boardKey(s.board) === ''); + + const before = stateKey(s); + const withIce = cloneState(s); + withIce.board.ice = new Set(['2,2']); + check('a new board layer changes the state key', stateKey(withIce) !== before); + + const sameIce = cloneState(s); + sameIce.board.ice = new Set(['2,2']); + check('equal board layers hash identically', stateKey(sameIce) === stateKey(withIce)); + + const otherIce = cloneState(s); + otherIce.board.ice = new Set(['3,3']); + check('different board layers hash differently', stateKey(otherIce) !== stateKey(withIce)); + + const orderA = cloneState(s); orderA.board.slime = new Set(['1,1', '2,2']); + const orderB = cloneState(s); orderB.board.slime = new Set(['2,2', '1,1']); + check('board layers hash independently of insertion order', stateKey(orderA) === stateKey(orderB)); + + const emptied = cloneState(s); + emptied.board.slime = new Set(); + check('an empty layer leaves the key unchanged', stateKey(emptied) === before); + + const mapLayer = cloneState(s); + mapLayer.board.crates = new Map([['1,1', 'a']]); + const mapLayer2 = cloneState(s); + mapLayer2.board.crates = new Map([['1,1', 'b']]); + check('Map layers hash by entry, not identity', stateKey(mapLayer) !== stateKey(mapLayer2)); + + // Layers must be COPIED by cloneState, or the solver's search would write + // through every state it has already visited. + const parent = cloneState(s); + parent.board.slime = new Set(['1,1']); + const child = cloneState(parent); + child.board.slime.add('2,2'); + check('cloneState deep-copies board layers', + parent.board.slime.size === 1 && child.board.slime.size === 2); + const copied = cloneBoard(parent.board); + check('cloneBoard copies Sets rather than sharing them', copied.slime !== parent.board.slime); +} + +{ + // Per-blob asleep flags: identity + merge semantics (waking is Wave 1's + // gating, but the state model has to carry it correctly first). + const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[3, 0]] })); + const sleeperIdx = blobAt(s, 3, 0); + check('a level can declare a sleeping monster', s.blobs[sleeperIdx].asleep === true); + check('other monsters stay awake', s.blobs[blobAt(s, 0, 0)].asleep !== true); + + const awakeCopy = cloneState(s); + delete awakeCopy.blobs[sleeperIdx].asleep; + check('an asleep blob and an awake one hash differently', stateKey(awakeCopy) !== stateKey(s)); + + check('cloneState preserves the asleep flag', cloneState(s).blobs[sleeperIdx].asleep === true); + + slide(s, blobAt(s, 0, 0), 'right'); + check('merging an awake blob into a sleeper wakes the whole blob', + s.blobs.length === 1 && s.blobs[0].asleep !== true); + + const both = newState(L({ monsters: [[0, 0], [1, 0]], sleepers: [[0, 0], [1, 0]] })); + check('a blob merged from sleepers only stays asleep', both.blobs[0].asleep === true); +} + +// ── 4b. Wave 1 mechanics ───────────────────────────────────────────────────── + +// Sleeping monsters: cannot be flicked, wake by being merged into. +{ + const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0]] })); + const sleeper = blobAt(s, 0, 0); + const awake = blobAt(s, 3, 0); + + check('a sleeper offers no legal moves', !legalMoves(s).some((m) => m.idx === sleeper)); + check('the awake monster still has moves', legalMoves(s).some((m) => m.idx === awake)); + check('flicking a sleeper is a no-op', slide(s, sleeper, 'right').moved === false); + check('a refused flick leaves the sleeper where it was', + s.blobs[sleeper].cells[0][0] === 0 && s.blobs[sleeper].cells[0][1] === 0); + + slide(s, awake, 'left'); + check('sliding into a sleeper sticks and wakes the merged blob', + s.blobs.length === 1 && s.blobs[0].asleep !== true && s.blobs[0].cells.length === 2); +} + +{ + const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0], [3, 0]] })); + check('a board of only sleepers has no legal moves', legalMoves(s).length === 0); + check('a board of only sleepers is unsolvable', solve(s, { maxStates: 5000 }).moves === -1); +} + +// Ice blocks: stop a slide like a wall, then shatter. +{ + const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[3, 0]] })); + const r = computeSlide(s, 0, 'right'); + check('ice blocks a slide like a wall', r.maxSteps === 2 && r.deathStep === 0); + check('the struck ice is reported', r.hitIce.length === 1 && r.hitIce[0] === '3,0'); + + slide(s, 0, 'right'); + check('the blob comes to rest in front of the ice it broke', + s.blobs[blobAt(s, 2, 0)].cells[0][0] === 2); + check('struck ice is gone from the board', !(s.board.ice?.has('3,0'))); + + // With the ice gone the very same flick now runs off the open edge. + const again = computeSlide(s, blobAt(s, 2, 0), 'right'); + check('ice is a one-use shield — the next flick runs off the edge', + again.deathStep > 0 && again.deathCause === 'edge'); +} + +{ + // Ice you are already touching: the flick shatters it without moving. + const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[1, 0]] })); + const r = computeSlide(s, 0, 'right'); + check('a blob against ice cannot move', r.maxSteps === 0); + const plan = planFlick(s, 0, 'right'); + check('a shatter-only flick is still a legal move', plan.legal === true && plan.parts.length === 0); + check('a shatter-only flick is offered by legalMoves', + legalMoves(s).some((m) => m.idx === 0 && m.dir === 'right')); + const out = applyPlan(s, plan); + check('applying a shatter-only flick counts as a move that travels 0 cells', + out.moved === true && out.steps === 0); + check('the touched ice shattered', !(s.board.ice?.has('1,0'))); + check('the blob did not move', s.blobs[blobAt(s, 0, 0)] !== undefined); +} + +{ + // A 2-cell blob striking two ice blocks at once breaks both. + const s = newState(L({ monsters: [[0, 0], [0, 1], [4, 4]], ice: [[2, 0], [2, 1]] })); + const idx = blobAt(s, 0, 0); + check('the two starting monsters merged into one 2-cell blob', s.blobs[idx].cells.length === 2); + const r = computeSlide(s, idx, 'right'); + check('a wide blob stops one cell short of the ice wall', r.maxSteps === 1); + check('both struck ice blocks are reported', new Set(r.hitIce).size === 2); + slide(s, idx, 'right'); + check('both struck ice blocks shattered', !s.board.ice || s.board.ice.size === 0); +} + +// Slime trails: green monsters lay them, everyone stops ON them. +{ + const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], walls: [[3, 0]] })); + check('a green monster is flagged green', s.blobs[blobAt(s, 0, 0)].green === true); + const out = slide(s, blobAt(s, 0, 0), 'right'); + check('a green blob slides normally over clean floor', out.steps === 2); + check('the trail covers the start cell and every cell travelled', + s.board.slime?.size === 3 && ['0,0', '1,0', '2,0'].every((k) => s.board.slime.has(k))); + check('a green blob is not stopped by its own fresh trail', blobAt(s, 2, 0) >= 0); +} + +{ + // Slime laid last move stops a blob that would otherwise fall off the table. + const s = newState(L({ monsters: [[0, 0], [1, 4]], green: [[0, 0]], walls: [[3, 0]] })); + slide(s, blobAt(s, 0, 0), 'right'); // slimes (0,0) (1,0) (2,0) + const other = blobAt(s, 1, 4); + const bare = computeSlide(s, other, 'up'); + check('a blob stops ON the slimed cell, not before it', + bare.maxSteps === 4 && bare.stopReason === 'stop-on', `steps=${bare.maxSteps} reason=${bare.stopReason}`); + check('stopping on slime is not fatal', bare.deathStep === 0); + slide(s, other, 'up'); + check('the slimed blob rests on the slime', blobAt(s, 1, 0) >= 0); +} + +{ + // Pre-existing slime (declared by the level) stops a green blob too. + const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], slime: [[2, 0]], walls: [[4, 0]] })); + const r = computeSlide(s, blobAt(s, 0, 0), 'right'); + check('slime stops the green monster that did not lay it', + r.maxSteps === 2 && r.stopReason === 'stop-on'); + slide(s, blobAt(s, 0, 0), 'right'); + check('a green blob extends the trail it stopped on', s.board.slime.size === 3); +} + +{ + const s = newState(L({ monsters: [[0, 0], [3, 0]], green: [[0, 0]] })); + slide(s, blobAt(s, 0, 0), 'right'); + check('a blob merged with a green monster is green', s.blobs[0].green === true); +} + +// Hypno goos: one flick moves the whole hive. +{ + const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] })); + slide(s, blobAt(s, 0, 0), 'right'); + check('flicking one hypno slides every hypno the same way', + blobAt(s, 2, 0) >= 0 && blobAt(s, 2, 2) >= 0); +} + +{ + // The hive resolves leader-first, so the follower can close the gap and stick. + const s = newState(L({ monsters: [[0, 0], [2, 0]], hypno: [[0, 0], [2, 0]], walls: [[4, 0]] })); + slide(s, blobAt(s, 0, 0), 'right'); + check('hypno blobs resolve furthest-first and merge behind the leader', + s.blobs.length === 1 && s.blobs[0].cells.length === 2 + && blobAt(s, 2, 0) === 0 && blobAt(s, 3, 0) === 0); + check('a blob merged from hypno parts stays hypno', s.blobs[0].hypno === true); +} + +{ + const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] })); + slide(s, blobAt(s, 0, 0), 'right'); + check('a hypno flick leaves ordinary monsters alone', + blobAt(s, 2, 0) >= 0 && blobAt(s, 0, 2) >= 0); + + const s2 = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] })); + slide(s2, blobAt(s2, 0, 2), 'right'); + check('flicking an ordinary monster leaves the hive alone', + blobAt(s2, 2, 2) >= 0 && blobAt(s2, 0, 0) >= 0); +} + +{ + // Only 'right' is survivable here: every other direction runs a hive member + // off the table, and the hive is offered once per direction, not per blob. + const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] })); + const moves = legalMoves(s); + check('the hive offers one move per direction, fatal ones excluded', + moves.length === 1 && moves[0].dir === 'right', `moves=${moves.map((m) => m.dir).join(',')}`); + check('a hive move that would kill a member is fatal when forced', + planFlick(s, 0, 'left').dead === true); +} + +// ── 4c. Wave 2 mechanics ───────────────────────────────────────────────────── + +// Springs: bounce the blob back the way it came and keep it sliding. +{ + // (2,0) wall · blob starts (2,2) · spring (2,4) + // Down into the spring, back up past the start, stopped by the wall at (2,0). + const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] })); + const r = computeSlide(s, blobAt(s, 2, 2), 'down', { trace: true }); + check('a spring reverses the slide instead of stopping it', + r.offset[0] === 0 && r.offset[1] === -1, `offset=${r.offset}`); + check('the bounce counts only the cells actually travelled', r.maxSteps === 3, `steps=${r.maxSteps}`); + check('the path bends rather than running straight', (r.path ?? []).length === 3); + slide(s, blobAt(s, 2, 2), 'down'); + check('the blob comes to rest past where it started', blobAt(s, 2, 1) >= 0); +} + +{ + // Two springs facing each other: the first is spent by the time the blob + // comes back to it, so it blocks — which is what makes the walk terminate. + const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] })); + const r = computeSlide(s, blobAt(s, 2, 2), 'down'); + check('a spent spring blocks instead of bouncing again', + r.offset[1] === 1 && r.stopReason === 'blocked', `offset=${r.offset} reason=${r.stopReason}`); + check('the walk between two springs still terminates', r.maxSteps < 12); +} + +{ + // A spring can just as easily bounce you off the far edge. + const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]] })); + const r = computeSlide(s, blobAt(s, 2, 2), 'down'); + check('a bounce can throw a blob off the open edge', r.deathStep > 0 && r.deathCause === 'edge'); +} + +// Tunnels: teleport the blob, then keep it sliding. +{ + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]], + })); + const idx = blobAt(s, 0, 0); + const r = computeSlide(s, idx, 'right', { trace: true }); + check('a blob entering a tunnel comes out of its partner', + r.offset[0] === 4 && r.offset[1] === 0, `offset=${r.offset}`); + check('the path marks the teleport as a jump', (r.path ?? []).some((p) => p[2] === 1)); + slide(s, idx, 'right'); + check('the blob rests where the far mouth led it', blobAt(s, 4, 0) >= 0); +} + +{ + // A whole multi-cell blob goes through rigidly ("Tunnel Master"). + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [0, 1], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0], [5, 1]], + })); + const idx = blobAt(s, 0, 0); + check('the two monsters merged before the trip', s.blobs[idx].cells.length === 2); + slide(s, idx, 'right'); + check('a 2-cell blob teleports rigidly and stays whole', + blobAt(s, 4, 0) >= 0 && blobAt(s, 4, 1) === blobAt(s, 4, 0) + && s.blobs[blobAt(s, 4, 0)].cells.length === 2); +} + +{ + // Blocked exit: the tunnel is inert and the blob slides straight past it. + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[4, 0]], + })); + slide(s, blobAt(s, 0, 0), 'right'); + check('a teleport onto something solid simply does not happen', blobAt(s, 3, 0) >= 0); +} + +// Buttons and retractable bricks. +{ + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [0, 2], [5, 5]], + bricks: [[3, 0, 1]], buttons: [[1, 2, 1]], walls: [[4, 2], [5, 0]], + })); + check('bricks start raised', s.board.bricksUp.has(1)); + slide(s, blobAt(s, 0, 0), 'right'); + check('a raised brick blocks like a wall', blobAt(s, 2, 0) >= 0); + + slide(s, blobAt(s, 0, 2), 'right'); + check('sliding over a button lowers its brick group', !s.board.bricksUp.has(1)); + slide(s, blobAt(s, 2, 0), 'right'); + check('with the bricks down the way is open', blobAt(s, 4, 0) >= 0); +} + +{ + // The button is pressed mid-slide, so the brick it raises can stop the very + // same slide. + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 2], [5, 5]], + bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]], + })); + check('a level can start with its bricks lowered', !s.board.bricksUp.has(1)); + slide(s, blobAt(s, 0, 2), 'right'); + check('a button pressed mid-slide raises bricks in time to stop that slide', + s.board.bricksUp.has(1) && blobAt(s, 2, 2) >= 0); +} + +{ + // Bricks must not rise through a monster: that toggle jams. + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 2], [3, 2]], + bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]], + })); + slide(s, blobAt(s, 0, 2), 'right'); + check('a brick cannot rise through a monster — the toggle jams', + !s.board.bricksUp.has(1)); +} + +// Powerlifters and crates. +{ + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], + crates: [[2, 0]], walls: [[5, 0]], + })); + const idx = blobAt(s, 0, 0); + check('a powerlifter is flagged', s.blobs[idx].lifter === true); + slide(s, idx, 'right'); + check('a powerlifter shoves the crate along ahead of it', + s.board.crates.has('4,0') && !s.board.crates.has('2,0'), `crates=${[...(s.board.crates ?? [])]}`); + check('the pusher stops when the crate can go no further', blobAt(s, 3, 0) >= 0); +} + +{ + // The same level without the powerlifter flag: the crate is just a wall. + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], crates: [[2, 0]], walls: [[5, 0]], + })); + slide(s, blobAt(s, 0, 0), 'right'); + check('an ordinary monster is stopped dead by a crate', + blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0')); +} + +{ + // No crate trains: a crate backed by another crate cannot move. + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0], [3, 0]], + })); + slide(s, blobAt(s, 0, 0), 'right'); + check('a crate backed by another crate will not budge', + blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0') && s.board.crates.has('3,0')); +} + +{ + // A crate shoved past the rim is gone. (The pusher usually follows it off — + // that is the walk's business, this checks only the crate bookkeeping.) + const s = newState(L({ + cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], powerlifters: [[0, 0]], crates: [[5, 0]], + })); + const r = computeSlide(s, blobAt(s, 0, 0), 'right'); + check('a crate pushed off the table is removed from the board', + !r.board.crates?.has('5,0') && !r.board.crates?.has('6,0')); +} + +// The scene animates each part along its `path` and never slices it by step +// count (a teleport adds a point without advancing a step). So every path must +// end exactly on the part's offset, or the blob would visibly land in the wrong +// cell before snapping. +{ + const fixtures = [ + ['plain slide', L({ monsters: [[0, 0], [3, 0]] }), 'right'], + ['spring bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] }), 'down'], + ['double bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] }), 'down'], + ['teleport', L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]] }), 'right'], + ['crate push', L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0]], walls: [[5, 0]] }), 'right'], + ['fatal edge', L({ monsters: [[2, 2], [0, 0]] }), 'down'], + ]; + let ok = 0; + for (const [name, def, dir] of fixtures) { + const st = newState(def); + const plan = planFlick(st, blobAt(st, def.monsters[0][0], def.monsters[0][1]), dir); + const part = plan.parts[0]; + if (!part) { check(`${name}: the fixture produces a moving part`, false); continue; } + const last = part.path?.[part.path.length - 1]; + if (last && last[0] === part.offset[0] && last[1] === part.offset[1]) ok += 1; + else check(`${name}: path ends on the part's offset`, false, `path end=${last} offset=${part.offset}`); + check(`${name}: the path is at least as long as the steps taken`, + (part.path?.length ?? 0) >= part.steps); + } + check('every route ends where the walk says it does', ok === fixtures.length, `${ok}/${fixtures.length}`); +} + +// planFlick / applyPlan: the scene animates a plan, so it must never disagree +// with what slide() would have done, and planning must not mutate. +{ + const def = L({ monsters: [[0, 0], [3, 0], [0, 3]], green: [[0, 0]], ice: [[3, 3]], walls: [[4, 0]] }); + for (const dir of DIR_LIST) { + const a = newState(def); + const b = newState(def); + const before = stateKey(a); + const plan = planFlick(a, 0, dir); + check(`planFlick(${dir}) does not mutate the state`, stateKey(a) === before); + const viaPlan = applyPlan(a, plan); + const viaSlide = slide(b, 0, dir); + check(`plan+apply matches slide() for ${dir}`, + stateKey(a) === stateKey(b) && viaPlan.moved === viaSlide.moved && viaPlan.dead === viaSlide.dead); + } +} + +// ── 5. Solver ──────────────────────────────────────────────────────────────── + +{ + // One flick away from a win. + const s = newState(L({ monsters: [[0, 0], [3, 0]] })); + const res = solve(s); + check('solver finds the one-move win', res.moves === 1 && res.path.length === 1); + check('solver returns the final footprint', res.footprint?.length === 2); +} + +{ + // Unsolvable: two monsters alone on an open board — any flick falls off. + const s = newState(L({ monsters: [[0, 0], [4, 4]] })); + check('solver reports unsolvable boards', solve(s, { maxStates: 20000 }).moves === -1); +} + +{ + // The solver's answer is a true minimum: no shorter path exists by brute + // force over the same move set. + const def = L({ cols: 5, rows: 5, monsters: [[0, 0], [4, 0], [0, 4]], walls: [[2, 2], [4, 4], [1, 3]] }); + const res = solve(newState(def)); + if (res.moves > 0) { + const shorter = (() => { + const seen = new Set(); + let frontier = [newState(def)]; + for (let d = 1; d < res.moves; d++) { + const next = []; + for (const st of frontier) { + for (const mv of legalMoves(st)) { + const ns = cloneState(st); + slide(ns, mv.idx, mv.dir); + if (ns.blobs.length === 1) return true; + const k = stateKey(ns); + if (seen.has(k)) continue; + seen.add(k); + next.push(ns); + } + } + frontier = next; + } + return false; + })(); + check('solver par is a true minimum (no shorter path exists)', !shorter, `par=${res.moves}`); + } else { + check('the minimality fixture is solvable', false, `moves=${res.moves}`); + } +} + +{ + // DIRS / DIR_LIST agree, and every direction is reachable from the map. + check('DIR_LIST covers exactly the four directions', + DIR_LIST.length === 4 && DIR_LIST.every((d) => Array.isArray(DIRS[d]))); +} + +// ── Summary ────────────────────────────────────────────────────────────────── + +console.log(`[verify] ${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);