diff --git a/assets/gamedata/gootower/level-001.json b/assets/gamedata/gootower/level-001.json new file mode 100644 index 0000000..bbf786b --- /dev/null +++ b/assets/gamedata/gootower/level-001.json @@ -0,0 +1,155 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 1, + "name": "Ground Floor", + "chapter": 1, + "tip": "Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + } + ], + "balls": [ + { + "x": 740, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 802, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 771, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 771, + "y": 600, + "r": 46 + }, + "pile": [ + { + "x": 980, + "y": 834, + "type": "common" + }, + { + "x": 1010, + "y": 834, + "type": "common" + }, + { + "x": 1040, + "y": 834, + "type": "common" + }, + { + "x": 1070, + "y": 834, + "type": "common" + }, + { + "x": 1100, + "y": 834, + "type": "common" + }, + { + "x": 1130, + "y": 834, + "type": "common" + }, + { + "x": 1160, + "y": 834, + "type": "common" + }, + { + "x": 1190, + "y": 834, + "type": "common" + }, + { + "x": 980, + "y": 804, + "type": "common" + }, + { + "x": 1010, + "y": 804, + "type": "common" + }, + { + "x": 1040, + "y": 804, + "type": "common" + }, + { + "x": 1070, + "y": 804, + "type": "common" + }, + { + "x": 1100, + "y": 804, + "type": "common" + }, + { + "x": 1130, + "y": 804, + "type": "common" + }, + { + "x": 1160, + "y": 804, + "type": "common" + }, + { + "x": 1190, + "y": 804, + "type": "common" + } + ], + "required": 4, + "ocdTarget": 9 +} diff --git a/assets/gamedata/gootower/level-002.json b/assets/gamedata/gootower/level-002.json new file mode 100644 index 0000000..a46e71e --- /dev/null +++ b/assets/gamedata/gootower/level-002.json @@ -0,0 +1,238 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 2, + "name": "Mind the Gap", + "chapter": 1, + "tip": "Spikes below. Build ACROSS — triangles carry weight, long chains do not.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 700 + ], + [ + 600, + 700 + ], + [ + 600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 860, + 700 + ], + [ + 1600, + 700 + ], + [ + 1600, + 1000 + ], + [ + 860, + 1000 + ] + ] + }, + { + "kind": "spike", + "poly": [ + [ + 600, + 930 + ], + [ + 860, + 930 + ], + [ + 860, + 1000 + ], + [ + 600, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 0, + 620 + ], + [ + 40, + 620 + ], + [ + 40, + 700 + ], + [ + 0, + 700 + ] + ] + } + ], + "balls": [ + { + "x": 399, + "y": 686, + "type": "common", + "pinned": true + }, + { + "x": 461, + "y": 686, + "type": "common", + "pinned": true + }, + { + "x": 430, + "y": 632, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 980, + "y": 640, + "r": 46 + }, + "pile": [ + { + "x": 150, + "y": 684, + "type": "common" + }, + { + "x": 180, + "y": 684, + "type": "common" + }, + { + "x": 210, + "y": 684, + "type": "common" + }, + { + "x": 240, + "y": 684, + "type": "common" + }, + { + "x": 270, + "y": 684, + "type": "common" + }, + { + "x": 300, + "y": 684, + "type": "common" + }, + { + "x": 330, + "y": 684, + "type": "common" + }, + { + "x": 150, + "y": 654, + "type": "common" + }, + { + "x": 180, + "y": 654, + "type": "common" + }, + { + "x": 210, + "y": 654, + "type": "common" + }, + { + "x": 240, + "y": 654, + "type": "common" + }, + { + "x": 270, + "y": 654, + "type": "common" + }, + { + "x": 300, + "y": 654, + "type": "common" + }, + { + "x": 330, + "y": 654, + "type": "common" + }, + { + "x": 150, + "y": 624, + "type": "common" + }, + { + "x": 180, + "y": 624, + "type": "common" + }, + { + "x": 210, + "y": 624, + "type": "common" + }, + { + "x": 240, + "y": 624, + "type": "common" + }, + { + "x": 270, + "y": 624, + "type": "common" + }, + { + "x": 300, + "y": 624, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-003.json b/assets/gamedata/gootower/level-003.json new file mode 100644 index 0000000..5ce61f2 --- /dev/null +++ b/assets/gamedata/gootower/level-003.json @@ -0,0 +1,216 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 3, + "name": "Headroom", + "chapter": 1, + "tip": "You cannot build through the slab. Come out from under it first.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 300, + 540 + ], + [ + 820, + 540 + ], + [ + 820, + 610 + ], + [ + 300, + 610 + ] + ] + } + ], + "balls": [ + { + "x": 529, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 591, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 560, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 980, + "y": 470, + "r": 46 + }, + "pile": [ + { + "x": 1020, + "y": 834, + "type": "common" + }, + { + "x": 1050, + "y": 834, + "type": "common" + }, + { + "x": 1080, + "y": 834, + "type": "common" + }, + { + "x": 1110, + "y": 834, + "type": "common" + }, + { + "x": 1140, + "y": 834, + "type": "common" + }, + { + "x": 1170, + "y": 834, + "type": "common" + }, + { + "x": 1200, + "y": 834, + "type": "common" + }, + { + "x": 1230, + "y": 834, + "type": "common" + }, + { + "x": 1020, + "y": 804, + "type": "common" + }, + { + "x": 1050, + "y": 804, + "type": "common" + }, + { + "x": 1080, + "y": 804, + "type": "common" + }, + { + "x": 1110, + "y": 804, + "type": "common" + }, + { + "x": 1140, + "y": 804, + "type": "common" + }, + { + "x": 1170, + "y": 804, + "type": "common" + }, + { + "x": 1200, + "y": 804, + "type": "common" + }, + { + "x": 1230, + "y": 804, + "type": "common" + }, + { + "x": 1020, + "y": 774, + "type": "common" + }, + { + "x": 1050, + "y": 774, + "type": "common" + }, + { + "x": 1080, + "y": 774, + "type": "common" + }, + { + "x": 1110, + "y": 774, + "type": "common" + }, + { + "x": 1140, + "y": 774, + "type": "common" + }, + { + "x": 1170, + "y": 774, + "type": "common" + }, + { + "x": 1200, + "y": 774, + "type": "common" + }, + { + "x": 1230, + "y": 774, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-004.json b/assets/gamedata/gootower/level-004.json new file mode 100644 index 0000000..82d29a6 --- /dev/null +++ b/assets/gamedata/gootower/level-004.json @@ -0,0 +1,175 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 4, + "name": "Second Thoughts", + "chapter": 1, + "tip": "Green ivy goo can be pulled back off and used again. Grey goo is there for good.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + } + ], + "balls": [ + { + "x": 300, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 362, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 331, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 660, + "y": 560, + "r": 46 + }, + "pile": [ + { + "x": 540, + "y": 834, + "type": "ivy" + }, + { + "x": 570, + "y": 834, + "type": "ivy" + }, + { + "x": 600, + "y": 834, + "type": "ivy" + }, + { + "x": 630, + "y": 834, + "type": "ivy" + }, + { + "x": 660, + "y": 834, + "type": "ivy" + }, + { + "x": 690, + "y": 834, + "type": "ivy" + }, + { + "x": 540, + "y": 804, + "type": "ivy" + }, + { + "x": 570, + "y": 804, + "type": "ivy" + }, + { + "x": 600, + "y": 804, + "type": "ivy" + }, + { + "x": 630, + "y": 804, + "type": "ivy" + }, + { + "x": 660, + "y": 804, + "type": "ivy" + }, + { + "x": 690, + "y": 804, + "type": "ivy" + }, + { + "x": 860, + "y": 834, + "type": "common" + }, + { + "x": 890, + "y": 834, + "type": "common" + }, + { + "x": 920, + "y": 834, + "type": "common" + }, + { + "x": 950, + "y": 834, + "type": "common" + }, + { + "x": 980, + "y": 834, + "type": "common" + }, + { + "x": 1010, + "y": 834, + "type": "common" + }, + { + "x": 1040, + "y": 834, + "type": "common" + }, + { + "x": 1070, + "y": 834, + "type": "common" + } + ], + "required": 4, + "ocdTarget": 9 +} diff --git a/assets/gamedata/gootower/level-005.json b/assets/gamedata/gootower/level-005.json new file mode 100644 index 0000000..858f4f1 --- /dev/null +++ b/assets/gamedata/gootower/level-005.json @@ -0,0 +1,186 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 5, + "name": "Overhead", + "chapter": 1, + "tip": "The ceiling bites. Stop short of it.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "spike", + "poly": [ + [ + 400, + 180 + ], + [ + 1200, + 180 + ], + [ + 1200, + 240 + ], + [ + 400, + 240 + ] + ] + } + ], + "balls": [ + { + "x": 760, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 822, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 791, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 791, + "y": 340, + "r": 46 + }, + "pile": [ + { + "x": 1000, + "y": 834, + "type": "common" + }, + { + "x": 1030, + "y": 834, + "type": "common" + }, + { + "x": 1060, + "y": 834, + "type": "common" + }, + { + "x": 1090, + "y": 834, + "type": "common" + }, + { + "x": 1120, + "y": 834, + "type": "common" + }, + { + "x": 1150, + "y": 834, + "type": "common" + }, + { + "x": 1180, + "y": 834, + "type": "common" + }, + { + "x": 1210, + "y": 834, + "type": "common" + }, + { + "x": 1240, + "y": 834, + "type": "common" + }, + { + "x": 1000, + "y": 804, + "type": "common" + }, + { + "x": 1030, + "y": 804, + "type": "common" + }, + { + "x": 1060, + "y": 804, + "type": "common" + }, + { + "x": 1090, + "y": 804, + "type": "common" + }, + { + "x": 1120, + "y": 804, + "type": "common" + }, + { + "x": 1150, + "y": 804, + "type": "common" + }, + { + "x": 1180, + "y": 804, + "type": "common" + }, + { + "x": 1210, + "y": 804, + "type": "common" + }, + { + "x": 1240, + "y": 804, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 11 +} diff --git a/assets/gamedata/gootower/level-006.json b/assets/gamedata/gootower/level-006.json new file mode 100644 index 0000000..e906cf7 --- /dev/null +++ b/assets/gamedata/gootower/level-006.json @@ -0,0 +1,205 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 6, + "name": "Tall Order", + "chapter": 1, + "tip": "A tower stands on compression and falls on sway. Brace every level.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + } + ], + "balls": [ + { + "x": 760, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 822, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 791, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 791, + "y": 180, + "r": 46 + }, + "pile": [ + { + "x": 1020, + "y": 834, + "type": "common" + }, + { + "x": 1050, + "y": 834, + "type": "common" + }, + { + "x": 1080, + "y": 834, + "type": "common" + }, + { + "x": 1110, + "y": 834, + "type": "common" + }, + { + "x": 1140, + "y": 834, + "type": "common" + }, + { + "x": 1170, + "y": 834, + "type": "common" + }, + { + "x": 1200, + "y": 834, + "type": "common" + }, + { + "x": 1230, + "y": 834, + "type": "common" + }, + { + "x": 1260, + "y": 834, + "type": "common" + }, + { + "x": 1020, + "y": 804, + "type": "common" + }, + { + "x": 1050, + "y": 804, + "type": "common" + }, + { + "x": 1080, + "y": 804, + "type": "common" + }, + { + "x": 1110, + "y": 804, + "type": "common" + }, + { + "x": 1140, + "y": 804, + "type": "common" + }, + { + "x": 1170, + "y": 804, + "type": "common" + }, + { + "x": 1200, + "y": 804, + "type": "common" + }, + { + "x": 1230, + "y": 804, + "type": "common" + }, + { + "x": 1260, + "y": 804, + "type": "common" + }, + { + "x": 1020, + "y": 774, + "type": "common" + }, + { + "x": 1050, + "y": 774, + "type": "common" + }, + { + "x": 1080, + "y": 774, + "type": "common" + }, + { + "x": 1110, + "y": 774, + "type": "common" + }, + { + "x": 1140, + "y": 774, + "type": "common" + }, + { + "x": 1170, + "y": 774, + "type": "common" + }, + { + "x": 1200, + "y": 774, + "type": "common" + }, + { + "x": 1230, + "y": 774, + "type": "common" + } + ], + "required": 6, + "ocdTarget": 14 +} diff --git a/assets/gamedata/gootower/level-007.json b/assets/gamedata/gootower/level-007.json new file mode 100644 index 0000000..4891a6f --- /dev/null +++ b/assets/gamedata/gootower/level-007.json @@ -0,0 +1,248 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 7, + "name": "Anchors Aweigh", + "chapter": 2, + "sandbox": true, + "tip": "Brown anchor goo sticks straight to rock and needs no friends. Start a span from a wall.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 400, + 300 + ], + [ + 460, + 300 + ], + [ + 460, + 850 + ], + [ + 400, + 850 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 1120, + 300 + ], + [ + 1180, + 300 + ], + [ + 1180, + 850 + ], + [ + 1120, + 850 + ] + ] + } + ], + "balls": [ + { + "x": 749, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 811, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 780, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 780, + "y": 400, + "r": 46 + }, + "pile": [ + { + "x": 860, + "y": 834, + "type": "anchor" + }, + { + "x": 890, + "y": 834, + "type": "anchor" + }, + { + "x": 920, + "y": 834, + "type": "anchor" + }, + { + "x": 950, + "y": 834, + "type": "anchor" + }, + { + "x": 980, + "y": 834, + "type": "anchor" + }, + { + "x": 1010, + "y": 834, + "type": "anchor" + }, + { + "x": 860, + "y": 804, + "type": "common" + }, + { + "x": 890, + "y": 804, + "type": "common" + }, + { + "x": 920, + "y": 804, + "type": "common" + }, + { + "x": 950, + "y": 804, + "type": "common" + }, + { + "x": 980, + "y": 804, + "type": "common" + }, + { + "x": 1010, + "y": 804, + "type": "common" + }, + { + "x": 860, + "y": 774, + "type": "common" + }, + { + "x": 890, + "y": 774, + "type": "common" + }, + { + "x": 920, + "y": 774, + "type": "common" + }, + { + "x": 950, + "y": 774, + "type": "common" + }, + { + "x": 980, + "y": 774, + "type": "common" + }, + { + "x": 1010, + "y": 774, + "type": "common" + }, + { + "x": 860, + "y": 744, + "type": "common" + }, + { + "x": 890, + "y": 744, + "type": "common" + }, + { + "x": 920, + "y": 744, + "type": "common" + }, + { + "x": 950, + "y": 744, + "type": "common" + }, + { + "x": 980, + "y": 744, + "type": "common" + }, + { + "x": 1010, + "y": 744, + "type": "common" + }, + { + "x": 860, + "y": 714, + "type": "common" + }, + { + "x": 890, + "y": 714, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-008.json b/assets/gamedata/gootower/level-008.json new file mode 100644 index 0000000..1522a59 --- /dev/null +++ b/assets/gamedata/gootower/level-008.json @@ -0,0 +1,258 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 8, + "name": "Sticking Point", + "chapter": 2, + "sandbox": true, + "tip": "Pokey goo grips rock. There is no ground here worth building from.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 240, + 200 + ], + [ + 320, + 200 + ], + [ + 320, + 760 + ], + [ + 240, + 760 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 1120, + 200 + ], + [ + 1200, + 200 + ], + [ + 1200, + 760 + ], + [ + 1120, + 760 + ] + ] + } + ], + "balls": [ + { + "x": 340, + "y": 560, + "type": "pokey", + "pinned": true + }, + { + "x": 340, + "y": 498, + "type": "pokey", + "pinned": true + }, + { + "x": 398, + "y": 530, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 1080, + "y": 430, + "r": 46 + }, + "pile": [ + { + "x": 620, + "y": 834, + "type": "pokey" + }, + { + "x": 650, + "y": 834, + "type": "pokey" + }, + { + "x": 680, + "y": 834, + "type": "pokey" + }, + { + "x": 710, + "y": 834, + "type": "pokey" + }, + { + "x": 740, + "y": 834, + "type": "pokey" + }, + { + "x": 770, + "y": 834, + "type": "pokey" + }, + { + "x": 620, + "y": 804, + "type": "common" + }, + { + "x": 650, + "y": 804, + "type": "common" + }, + { + "x": 680, + "y": 804, + "type": "common" + }, + { + "x": 710, + "y": 804, + "type": "common" + }, + { + "x": 740, + "y": 804, + "type": "common" + }, + { + "x": 770, + "y": 804, + "type": "common" + }, + { + "x": 800, + "y": 804, + "type": "common" + }, + { + "x": 830, + "y": 804, + "type": "common" + }, + { + "x": 620, + "y": 774, + "type": "common" + }, + { + "x": 650, + "y": 774, + "type": "common" + }, + { + "x": 680, + "y": 774, + "type": "common" + }, + { + "x": 710, + "y": 774, + "type": "common" + }, + { + "x": 740, + "y": 774, + "type": "common" + }, + { + "x": 770, + "y": 774, + "type": "common" + }, + { + "x": 800, + "y": 774, + "type": "common" + }, + { + "x": 830, + "y": 774, + "type": "common" + }, + { + "x": 620, + "y": 744, + "type": "common" + }, + { + "x": 650, + "y": 744, + "type": "common" + }, + { + "x": 680, + "y": 744, + "type": "common" + }, + { + "x": 710, + "y": 744, + "type": "common" + }, + { + "x": 740, + "y": 744, + "type": "common" + }, + { + "x": 770, + "y": 744, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-009.json b/assets/gamedata/gootower/level-009.json new file mode 100644 index 0000000..b9e7025 --- /dev/null +++ b/assets/gamedata/gootower/level-009.json @@ -0,0 +1,265 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 9, + "name": "Demolition", + "chapter": 2, + "sandbox": true, + "tip": "Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 700, + 660 + ], + [ + 780, + 660 + ], + [ + 780, + 850 + ], + [ + 700, + 850 + ] + ], + "destructible": true + }, + { + "kind": "fire", + "poly": [ + [ + 600, + 806 + ], + [ + 680, + 806 + ], + [ + 680, + 850 + ], + [ + 600, + 850 + ] + ] + } + ], + "balls": [ + { + "x": 369, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 431, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 400, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 980, + "y": 700, + "r": 46 + }, + "waypoints": [ + { + "x": 740, + "y": 590 + } + ], + "pile": [ + { + "x": 160, + "y": 834, + "type": "bomb" + }, + { + "x": 190, + "y": 834, + "type": "bomb" + }, + { + "x": 220, + "y": 834, + "type": "bomb" + }, + { + "x": 250, + "y": 834, + "type": "bomb" + }, + { + "x": 160, + "y": 804, + "type": "common" + }, + { + "x": 190, + "y": 804, + "type": "common" + }, + { + "x": 220, + "y": 804, + "type": "common" + }, + { + "x": 250, + "y": 804, + "type": "common" + }, + { + "x": 280, + "y": 804, + "type": "common" + }, + { + "x": 310, + "y": 804, + "type": "common" + }, + { + "x": 340, + "y": 804, + "type": "common" + }, + { + "x": 160, + "y": 774, + "type": "common" + }, + { + "x": 190, + "y": 774, + "type": "common" + }, + { + "x": 220, + "y": 774, + "type": "common" + }, + { + "x": 250, + "y": 774, + "type": "common" + }, + { + "x": 280, + "y": 774, + "type": "common" + }, + { + "x": 310, + "y": 774, + "type": "common" + }, + { + "x": 340, + "y": 774, + "type": "common" + }, + { + "x": 160, + "y": 744, + "type": "common" + }, + { + "x": 190, + "y": 744, + "type": "common" + }, + { + "x": 220, + "y": 744, + "type": "common" + }, + { + "x": 250, + "y": 744, + "type": "common" + }, + { + "x": 280, + "y": 744, + "type": "common" + }, + { + "x": 310, + "y": 744, + "type": "common" + }, + { + "x": 340, + "y": 744, + "type": "common" + }, + { + "x": 160, + "y": 714, + "type": "common" + }, + { + "x": 190, + "y": 714, + "type": "common" + }, + { + "x": 220, + "y": 714, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-010.json b/assets/gamedata/gootower/level-010.json new file mode 100644 index 0000000..c33d8a0 --- /dev/null +++ b/assets/gamedata/gootower/level-010.json @@ -0,0 +1,280 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 10, + "name": "Updraft", + "chapter": 2, + "sandbox": true, + "tip": "The updraft takes weight off everything inside it. Spans reach further in the wind.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 700 + ], + [ + 600, + 700 + ], + [ + 600, + 1000 + ], + [ + 0, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 940, + 700 + ], + [ + 1600, + 700 + ], + [ + 1600, + 1000 + ], + [ + 940, + 1000 + ] + ] + }, + { + "kind": "spike", + "poly": [ + [ + 600, + 930 + ], + [ + 940, + 930 + ], + [ + 940, + 1000 + ], + [ + 600, + 1000 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 0, + 620 + ], + [ + 40, + 620 + ], + [ + 40, + 700 + ], + [ + 0, + 700 + ] + ] + } + ], + "fans": [ + { + "x": 600, + "y": 300, + "w": 340, + "h": 630, + "dx": 0, + "dy": -1, + "force": 1150 + } + ], + "balls": [ + { + "x": 329, + "y": 686, + "type": "common", + "pinned": true + }, + { + "x": 391, + "y": 686, + "type": "common", + "pinned": true + }, + { + "x": 360, + "y": 632, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 1060, + "y": 630, + "r": 46 + }, + "pile": [ + { + "x": 120, + "y": 684, + "type": "common" + }, + { + "x": 150, + "y": 684, + "type": "common" + }, + { + "x": 180, + "y": 684, + "type": "common" + }, + { + "x": 210, + "y": 684, + "type": "common" + }, + { + "x": 240, + "y": 684, + "type": "common" + }, + { + "x": 270, + "y": 684, + "type": "common" + }, + { + "x": 120, + "y": 654, + "type": "common" + }, + { + "x": 150, + "y": 654, + "type": "common" + }, + { + "x": 180, + "y": 654, + "type": "common" + }, + { + "x": 210, + "y": 654, + "type": "common" + }, + { + "x": 240, + "y": 654, + "type": "common" + }, + { + "x": 270, + "y": 654, + "type": "common" + }, + { + "x": 120, + "y": 624, + "type": "common" + }, + { + "x": 150, + "y": 624, + "type": "common" + }, + { + "x": 180, + "y": 624, + "type": "common" + }, + { + "x": 210, + "y": 624, + "type": "common" + }, + { + "x": 240, + "y": 624, + "type": "common" + }, + { + "x": 270, + "y": 624, + "type": "common" + }, + { + "x": 120, + "y": 594, + "type": "common" + }, + { + "x": 150, + "y": 594, + "type": "common" + }, + { + "x": 180, + "y": 594, + "type": "common" + }, + { + "x": 210, + "y": 594, + "type": "common" + }, + { + "x": 240, + "y": 594, + "type": "common" + }, + { + "x": 270, + "y": 594, + "type": "common" + }, + { + "x": 120, + "y": 564, + "type": "common" + }, + { + "x": 150, + "y": 564, + "type": "common" + } + ], + "required": 4, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/level-011.json b/assets/gamedata/gootower/level-011.json new file mode 100644 index 0000000..a7ad9e0 --- /dev/null +++ b/assets/gamedata/gootower/level-011.json @@ -0,0 +1,225 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 11, + "name": "Cogs", + "chapter": 2, + "sandbox": true, + "tip": "The cog is solid and it is turning. Anything it touches gets dragged along.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + } + ], + "gears": [ + { + "x": 800, + "y": 470, + "r": 110, + "teeth": 9, + "omega": 1.4 + } + ], + "balls": [ + { + "x": 269, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 331, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 300, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 1240, + "y": 560, + "r": 46 + }, + "pile": [ + { + "x": 1000, + "y": 834, + "type": "common" + }, + { + "x": 1030, + "y": 834, + "type": "common" + }, + { + "x": 1060, + "y": 834, + "type": "common" + }, + { + "x": 1090, + "y": 834, + "type": "common" + }, + { + "x": 1120, + "y": 834, + "type": "common" + }, + { + "x": 1150, + "y": 834, + "type": "common" + }, + { + "x": 1180, + "y": 834, + "type": "common" + }, + { + "x": 1210, + "y": 834, + "type": "common" + }, + { + "x": 1240, + "y": 834, + "type": "common" + }, + { + "x": 1000, + "y": 804, + "type": "common" + }, + { + "x": 1030, + "y": 804, + "type": "common" + }, + { + "x": 1060, + "y": 804, + "type": "common" + }, + { + "x": 1090, + "y": 804, + "type": "common" + }, + { + "x": 1120, + "y": 804, + "type": "common" + }, + { + "x": 1150, + "y": 804, + "type": "common" + }, + { + "x": 1180, + "y": 804, + "type": "common" + }, + { + "x": 1210, + "y": 804, + "type": "common" + }, + { + "x": 1240, + "y": 804, + "type": "common" + }, + { + "x": 1000, + "y": 774, + "type": "common" + }, + { + "x": 1030, + "y": 774, + "type": "common" + }, + { + "x": 1060, + "y": 774, + "type": "common" + }, + { + "x": 1090, + "y": 774, + "type": "common" + }, + { + "x": 1120, + "y": 774, + "type": "common" + }, + { + "x": 1150, + "y": 774, + "type": "common" + }, + { + "x": 1180, + "y": 774, + "type": "common" + }, + { + "x": 1210, + "y": 774, + "type": "common" + }, + { + "x": 1240, + "y": 774, + "type": "common" + }, + { + "x": 1000, + "y": 744, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 13 +} diff --git a/assets/gamedata/gootower/level-012.json b/assets/gamedata/gootower/level-012.json new file mode 100644 index 0000000..e878e51 --- /dev/null +++ b/assets/gamedata/gootower/level-012.json @@ -0,0 +1,266 @@ +{ + "world": { + "w": 1600, + "h": 1000 + }, + "level": 12, + "name": "Lighter Than Air", + "chapter": 2, + "sandbox": true, + "tip": "Balloons pull up, blocks hold rigid, bits are cheap and weak. Mix them.", + "terrain": [ + { + "kind": "solid", + "poly": [ + [ + 0, + 850 + ], + [ + 1600, + 850 + ], + [ + 1600, + 1000 + ], + [ + 0, + 1000 + ] + ] + } + ], + "balls": [ + { + "x": 369, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 431, + "y": 836, + "type": "common", + "pinned": true + }, + { + "x": 400, + "y": 782, + "type": "common" + } + ], + "strands": [ + [ + 2, + 0 + ], + [ + 2, + 1 + ], + [ + 0, + 1 + ] + ], + "pipe": { + "x": 400, + "y": 300, + "r": 46 + }, + "pile": [ + { + "x": 700, + "y": 834, + "type": "balloon" + }, + { + "x": 730, + "y": 834, + "type": "balloon" + }, + { + "x": 760, + "y": 834, + "type": "balloon" + }, + { + "x": 790, + "y": 834, + "type": "balloon" + }, + { + "x": 820, + "y": 834, + "type": "balloon" + }, + { + "x": 850, + "y": 834, + "type": "balloon" + }, + { + "x": 700, + "y": 800, + "type": "block" + }, + { + "x": 730, + "y": 800, + "type": "block" + }, + { + "x": 760, + "y": 800, + "type": "block" + }, + { + "x": 790, + "y": 800, + "type": "block" + }, + { + "x": 820, + "y": 800, + "type": "block" + }, + { + "x": 850, + "y": 800, + "type": "block" + }, + { + "x": 880, + "y": 800, + "type": "block" + }, + { + "x": 910, + "y": 800, + "type": "block" + }, + { + "x": 700, + "y": 766, + "type": "bit" + }, + { + "x": 730, + "y": 766, + "type": "bit" + }, + { + "x": 760, + "y": 766, + "type": "bit" + }, + { + "x": 790, + "y": 766, + "type": "bit" + }, + { + "x": 820, + "y": 766, + "type": "bit" + }, + { + "x": 850, + "y": 766, + "type": "bit" + }, + { + "x": 700, + "y": 734, + "type": "common" + }, + { + "x": 730, + "y": 734, + "type": "common" + }, + { + "x": 760, + "y": 734, + "type": "common" + }, + { + "x": 790, + "y": 734, + "type": "common" + }, + { + "x": 820, + "y": 734, + "type": "common" + }, + { + "x": 850, + "y": 734, + "type": "common" + }, + { + "x": 880, + "y": 734, + "type": "common" + }, + { + "x": 910, + "y": 734, + "type": "common" + }, + { + "x": 940, + "y": 734, + "type": "common" + }, + { + "x": 700, + "y": 704, + "type": "common" + }, + { + "x": 730, + "y": 704, + "type": "common" + }, + { + "x": 760, + "y": 704, + "type": "common" + }, + { + "x": 790, + "y": 704, + "type": "common" + }, + { + "x": 820, + "y": 704, + "type": "common" + }, + { + "x": 850, + "y": 704, + "type": "common" + }, + { + "x": 880, + "y": 704, + "type": "common" + }, + { + "x": 910, + "y": 704, + "type": "common" + }, + { + "x": 940, + "y": 704, + "type": "common" + } + ], + "required": 5, + "ocdTarget": 12 +} diff --git a/assets/gamedata/gootower/levels.json b/assets/gamedata/gootower/levels.json new file mode 100644 index 0000000..7da1e78 --- /dev/null +++ b/assets/gamedata/gootower/levels.json @@ -0,0 +1,124 @@ +{ + "version": 1, + "generatedAt": "1970-01-01T00:00:00.000Z", + "levels": [ + { + "level": 1, + "name": "Ground Floor", + "chapter": 1, + "file": "level-001.json", + "required": 4, + "ocdTarget": 9 + }, + { + "level": 2, + "name": "Mind the Gap", + "chapter": 1, + "file": "level-002.json", + "required": 5, + "ocdTarget": 12 + }, + { + "level": 3, + "name": "Headroom", + "chapter": 1, + "file": "level-003.json", + "required": 5, + "ocdTarget": 12 + }, + { + "level": 4, + "name": "Second Thoughts", + "chapter": 1, + "file": "level-004.json", + "required": 4, + "ocdTarget": 9 + }, + { + "level": 5, + "name": "Overhead", + "chapter": 1, + "file": "level-005.json", + "required": 5, + "ocdTarget": 11 + }, + { + "level": 6, + "name": "Tall Order", + "chapter": 1, + "file": "level-006.json", + "required": 6, + "ocdTarget": 14 + }, + { + "level": 7, + "name": "Anchors Aweigh", + "chapter": 2, + "file": "level-007.json", + "required": 5, + "ocdTarget": 12, + "sandbox": true + }, + { + "level": 8, + "name": "Sticking Point", + "chapter": 2, + "file": "level-008.json", + "required": 5, + "ocdTarget": 12, + "sandbox": true + }, + { + "level": 9, + "name": "Demolition", + "chapter": 2, + "file": "level-009.json", + "required": 5, + "ocdTarget": 12, + "sandbox": true + }, + { + "level": 10, + "name": "Updraft", + "chapter": 2, + "file": "level-010.json", + "required": 4, + "ocdTarget": 12, + "sandbox": true + }, + { + "level": 11, + "name": "Cogs", + "chapter": 2, + "file": "level-011.json", + "required": 5, + "ocdTarget": 13, + "sandbox": true + }, + { + "level": 12, + "name": "Lighter Than Air", + "chapter": 2, + "file": "level-012.json", + "required": 5, + "ocdTarget": 12, + "sandbox": true + } + ], + "chapters": [ + { + "id": 1, + "name": "The Goo Filled Hills", + "blurb": "Learn to build.", + "from": 1, + "to": 6 + }, + { + "id": 2, + "name": "Sandbox", + "blurb": "One level per mechanic — throwaway, replaced in Wave 4.", + "from": 7, + "to": 12 + } + ] +} diff --git a/assets/images/background-jello.png b/assets/images/background-jello.png index 43db9f4..6d6a5d9 100644 Binary files a/assets/images/background-jello.png and b/assets/images/background-jello.png differ diff --git a/assets/images/background-mahjongmatch.png b/assets/images/background-mahjongmatch.png index bd375e4..4b02b6e 100644 Binary files a/assets/images/background-mahjongmatch.png and b/assets/images/background-mahjongmatch.png differ diff --git a/assets/images/background-mm-01.png b/assets/images/background-mm-01.png index 35825ce..2ec179b 100644 Binary files a/assets/images/background-mm-01.png 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 index 44b929e..befd6da 100644 Binary files a/assets/images/background-mm-02.png 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 index ea4b61b..173ed13 100644 Binary files a/assets/images/background-mm-03.png and b/assets/images/background-mm-03.png differ diff --git a/assets/images/bloxorz/background-menu.png b/assets/images/bloxorz/background-menu.png index cf16b3c..e39e700 100644 Binary files a/assets/images/bloxorz/background-menu.png and b/assets/images/bloxorz/background-menu.png differ diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 6ceac24..91c1062 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 6af4c01..350bb7f 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/music/chinese-track01.mp3 b/assets/music/chinese-track01.mp3 new file mode 100644 index 0000000..6e29c25 Binary files /dev/null and b/assets/music/chinese-track01.mp3 differ diff --git a/assets/music/chinese-track02.mp3 b/assets/music/chinese-track02.mp3 new file mode 100644 index 0000000..156b068 Binary files /dev/null and b/assets/music/chinese-track02.mp3 differ diff --git a/assets/music/chinese-track03.mp3 b/assets/music/chinese-track03.mp3 new file mode 100644 index 0000000..ae448ed Binary files /dev/null and b/assets/music/chinese-track03.mp3 differ diff --git a/data/chinese-music.json b/data/chinese-music.json new file mode 100644 index 0000000..1c412e6 --- /dev/null +++ b/data/chinese-music.json @@ -0,0 +1,20 @@ +{ + "tracks": [ + { + "file": "chinese-track01.mp3", + "artist": "Kage", + "title": "Spring Blossoms" + }, + { + "file": "chinese-track02.mp3", + "artist": "Aiko", + "title": "Autumn Breeze" + }, + { + "file": "chinese-track03.mp3", + "artist": "Steve", + "title": "I studied your Earth Music" + } + ], + "volume": 0.4 +} diff --git a/docs/gootower-build-plan.md b/docs/gootower-build-plan.md new file mode 100644 index 0000000..3dd110f --- /dev/null +++ b/docs/gootower-build-plan.md @@ -0,0 +1,267 @@ +# Goo Tower (World of Goo) — build 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:** **Waves 0–3 complete (2026-07-26).** All nine goo types and every hazard are +live, with a 6-level sandbox tier on top of the 6-level teaching chapter — +`node tools/verifyGooTower.js` → **303 checks green**, including a reference player that +beats all **12** levels headlessly. **Never opened in a browser** — Brian playtests, and +Waves 1–3 are not signed off until he has. + +--- + +## Decisions taken up front + +| Question | Answer | +|---|---| +| Scope | Full campaign — 5 named chapters, ~75 levels. **No** Goo Corporation sandbox tower. | +| Level authoring | **Both** — editor for hand-tuned levels, generator for drill/sandbox tiers | +| Art | **Procedural, zero art dependency**; optional drop-in sprite hooks later | +| Name | **Goo Tower**, slug `gootower`, iconFrame **90** (0–89 all taken) | + +## Files + +| File | Role | State | +|---|---|---| +| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 0–1 done | +| `src/games/gootower/GooTowerGame.js` | Phaser scene: level select, board, HUD | Wave 1 done | +| `src/games/gootower/tutorial.md` | `hasTutorial: true` | done | +| `src/games/gootower/GooTowerEditor.js` | Level editor, `?gootower-editor=1` | Wave 2 done | +| `src/games/gootower/GooTowerAuto.js` | Greedy reference player = the winnability gate | done | +| `tools/verifyGooTower.js` | Regression harness, **303 checks** | Waves 0–3 done | +| `tools/genGooTower.js` | Level bank writer (12 hand-tuned levels) | Wave 3 done | +| `assets/gamedata/gootower/levels.json` | Manifest + per-level `level-NNN.json` | 12 levels | + +`GooTowerAuto.js` lives in `src/`, not `tools/`, because the editor's "Test Winnable" +button must run exactly the gate the verifier runs. + +Wiring is in place: `gamesRegistry.js` (iconFrame 90), `main.js` import + scene array, +`GameRoomScene.js` slugDispatch, `PreloadScene.js` manifest load. + +Run the harness after **every** change to the sim. The physics is the game: a subtly wrong +solver produces structures that look plausible and are unbuildable, and there is no way to +tell by reading the code. + +--- + +## Wave 0 — the solver — ✅ DONE 2026-07-26 + +Landed: Verlet integration with XPBD distance constraints, terrain collision and pushout, +ball–ball collision with a uniform-grid broadphase, tension-based strand breaking with a +fatigue window, structure flood-fill, seeded RNG, and a 69-check harness. + +- [x] Verlet integrator with global drag and a per-substep travel cap +- [x] XPBD distance constraints (compliance + axial damping) +- [x] Terrain: point-in-poly, closest-point pushout, friction/restitution, slopes +- [x] Ball–ball collision (uniform grid, rebuilt once per substep) +- [x] Strand breaking on load, with a shock path and a length backstop +- [x] Structure flood-fill; `rooted` components; adrift chunks fall as chunks +- [x] Hazards: spikes, fire, falling out of the world +- [x] Determinism: identical replay, frame-rate independence, ragged pacing, `cloneState` +- [x] `hashState` fingerprint for the generator and verifier + +### Five findings worth not rediscovering + +1. **Plain PBD cannot break strands.** A near-rigid solver yanks every strand back to its + rest length each substep, so `length/rest` can never report load and *nothing ever + breaks*. XPBD's accumulated multiplier λ **is** the constraint force — that is why the + solver is XPBD and not simple relaxation. `tension = -λ/dt²` now reads the true static + load (verified: 1.00 / 2.01 / 4.08 ball-weights for 1/2/4-ball chains). +2. **Break on force, not on stretch.** At the stiffness a buildable truss needs + (k ≈ 8333), a `BREAK_RATIO` threshold would demand ~350 ball-weights and nothing would + ever collapse. `BREAK_FORCE` is expressed in ball-weights (25) and is therefore + independent of any later stiffness retune. The length ratio survives only as a backstop. +3. **Break on a *filtered* load.** A stiff lattice rings at ~15Hz and any impact spikes to + ~2× static, so judging raw tension snaps strands on the first frame of a level that is + merely settling. `LOAD_TAU` (0.12s) is the fatigue window; `SHOCK_FACTOR` (3×) still + lets a genuinely violent hit cut through it. +4. **`isSettled()` is instantaneous, and that is a trap.** An oscillating structure passes + through zero speed at every turning point, so "step until settled" stops at the moment + of *maximum stretch* and reports the peak of a decaying oscillation as the static + equilibrium. This produced a completely bogus, non-monotonic tuning sweep before it was + spotted. `settle()` now requires a sustained quiet window. +5. **Constraint damping cannot settle a tower.** It resists a strand *stretching*, but the + slowest mode is a pendulum swing that barely stretches anything — it rang for 13s. + Global `DRAG` (0.995/substep ≈ 0.30/s) is what actually settles structures. + +Also: `addStrand` takes its rest length from the distance it is **born** at. Clamping to a +fixed 62 meant a level author's 87px diagonal started 1.41× pre-stressed and ripped the +lattice apart on frame one. + +### Baselines + +``` +node tools/verifyGooTower.js # 69 passed, 0 failed +``` + +Perf (1s of sim, full 240Hz substepping): + +| Structure | ms/frame | Budget | +|---|---|---| +| 30 balls / 69 strands | 0.73 | 16.7 | +| 100 balls / 261 strands | 2.18 | 16.7 | +| 196 balls / 533 strands | 4.53 | 16.7 | + +### Known gaps, deliberate + +- Rigidity is purely emergent from triangulation — there are **no angular constraints**. + That makes `MIN_ANGLE_DEG` in the attachment rule (Wave 1) the most important number in + the game: it is the only thing forcing the player to build real trusses. +- `GOO_TYPES` carries all nine varieties already, but only mass/buoyancy/stiffness/strength + and `spikeProof` are wired. Detachability, terrain-sticking and explosions are Wave 3. +- `refreshStructure` runs whenever a ball touches terrain. Fine at current scale (see perf + table); revisit if a level ever needs 400+ balls. + +--- + +## Wave 1 — playable core — ✅ DONE 2026-07-26 (pending playtest) + +- [x] `chooseAttachments()` — reach, line of sight, nearest-first, **MIN_ANGLE rejection** +- [x] Drag a ball out of the pile; place or spring back; detachable types can be lifted off +- [x] Crawling goo: `{onStrand, t, dir}`, deterministic node choice, seeded wander +- [x] Pipe: opens on contact; BFS over the strand graph; crawlers path in and are drunk +- [x] Win at `collected >= required`; OCD tracked as a separate award +- [x] `GooTowerGame.js` — accumulator + events array, procedural rendering, `D` depth map +- [x] Wiring (all four files) + `tutorial.md` +- [x] Progress via `/puzzles/gootower/{progress,complete,reset}` + `/history/single-player` +- [x] Six hand-built levels in `assets/gamedata/gootower/` +- [x] Greedy reference player; all six levels beaten headlessly +- [ ] **First browser playtest — NOT DONE.** `MIN_ANGLE_DEG`, `ATTACH_R`, `GRAVITY` and + `WANDER_SPEED` get tuned by feel here. No amount of Node testing substitutes. + +### Wave 1 findings + +1. **Loose goo has to walk.** The pipe would open and then collect *nothing*, because the + heap sat where it was authored and never touched the structure. In the original, + unattached goo mills around the tower; `wanderLoose()` reproduces that. Without it a + level whose heap is not already against the structure is simply unwinnable, and no + amount of building fixes it. Re-targeting is throttled to `WANDER_HZ` because it is + O(loose x attached). +2. **A settling level is not a winnable level.** The bank lint (levels settle, keep their + strands, lose no goo) passed on all six while two of them could not actually be beaten. + Only playing them found it — hence `tools/lib/gooAutoPlay.js`. +3. Two real level bugs it caught: **L4** handed out exactly enough goo to *reach* the pipe + and none to feed it; **L3** asked for a 677px diagonal traverse around an obstacle as the + third teaching level. +4. The greedy gate is **one-directional**: if the naive builder wins, a human can. A greedy + failure is *not* proof a level is impossible — redesign or store a hand-authored + reference build, never conclude "unwinnable". + +### Baselines + +``` +node tools/verifyGooTower.js # 179 passed, 0 failed (~5s, includes the winnability gate) +node tools/genGooTower.js # rewrites the 6-level bank +``` + +## Wave 2 — the editor — ✅ DONE 2026-07-26 (pending playtest) + +Reached at `index.html?gootower-editor=1`. Tools: **terrain** (click vertices, Enter or +double-click to close; solid/spike/fire), **ball** (starting structure, auto-strands, shift +for pinned), **pile** (loose goo), **pipe**, **erase** (or right-click anywhere). Ctrl-Z +undoes. Settings live in a DOM panel on the right; **Settle**, **Test Winnable**, **Test +Play**, and Blob-download **⬇ Level** / **⬇ Manifest** sit along the bottom. + +- [x] Terrain polygon drawing, all three kinds +- [x] Structure goo with a live attachment preview — it calls the game's own + `chooseAttachments`, so the MIN_ANGLE rule is visible while authoring +- [x] Pile, pipe, erase, undo +- [x] Settle before export; validation blocks a broken export +- [x] Test-play round-trip via `registry` + `scene.start('GooTowerGame', { testLevel, returnToEditor })` +- [x] Test Winnable runs the same greedy gate the verifier uses +- [x] Load from the manifest or from a local file +- [ ] **Browser playtest — NOT DONE.** + +### Wave 2 findings + +1. **Settling for export must be physics-only.** `settle()` runs the *whole* sim, agents + included — so loose goo walks to the structure and climbs it while settling. Capturing + those positions bakes the heap INSIDE the tower, and reloading that level explodes as the + collision solver ejects it (measured: 1801px of drift, strands snapped, goo lost). Hence + `settlePhysics()`. The editor's Settle button would have quietly corrupted every level + with a heap. +2. **`grounded` is only true on a simulated state.** It is set by terrain contact during a + substep, so validation on a never-stepped state sees `false` everywhere and a floating + structure sails straight through. `validate()` settles a throwaway copy first. +3. **The hover preview cannot build a state per mouse event.** `drawHoverPreview` runs on + every `pointermove`; a version-stamped cache (`touch()` / `draftState()`) keeps it to one + rebuild per edit. +4. The editor deliberately asks the *real* rules for everything — attachment, legality, + winnability — so what it shows can never disagree with what the game does. + +### Editor contract, pinned by the verifier + +`GooTowerEditor.js` imports Phaser and cannot run in Node, so §10 of the harness asserts the +assumptions it rests on: structure ball index === ball id (it wires strands by index), pile +goo comes after the structure in order, a settled level stays settled when reloaded, and an +editor round-trip reproduces a byte-identical level. + +## Wave 3 — remaining goo types and hazards — ✅ DONE 2026-07-26 (pending playtest) + +- [x] **anchor / pokey** — bond to any solid surface within `STICK_DIST` and pin there. + `requiredStrands()` drops to 0 when stuck, which is what lets a structure start from + a wall instead of only from the ground. +- [x] **bomb** — fire lights a fuse (`FUSE_TIME`), then the blast kills goo inside + `BLAST_KILL_R`, shears every strand inside `BLAST_R`, and removes terrain the author + marked `destructible`. Bombs chain-react; anchors survive. +- [x] **balloon** — buoyant, single strand, ~1.3 ball-weights of lift +- [x] **block / bit / skull** — stiffness, cheapness and spike immunity +- [x] **fans** — axis-aligned force volumes; goo inside weighs less +- [x] **gears** — rotating solid terrain that drags whatever touches its rim +- [x] `hazardAt()` — "legal but lethal", used by both the reference player and the drag preview +- [x] Sandbox tier: levels 7–12, one per mechanic (**throwaway**, replaced in Wave 4) +- [x] Editor gained fan/gear tools and a destructible flag +- [ ] **Browser playtest — NOT DONE.** + +### Wave 3 findings + +1. **Gears are cheap if you store them as terrain.** A gear is an ordinary `solid` entry + carrying a `gear` record; `updateMovers()` respins its polygon each substep and the + existing collision path does the rest. The only extra rule is that friction pulls a + contacting ball toward the **rim's surface velocity** instead of toward zero — that one + line is the whole mechanic. Time-varying terrain costs nothing in determinism. +2. **Legal is not survivable.** The reference player happily dropped goo into fire 24 times + in a row, because `canPlace` only asks whether a strand can form. `hazardAt()` closes + that, and the drag preview now warns the player too (orange, not red — it *is* a legal + drop). +3. **Greedy will not climb a wall.** Getting over an obstacle means first building *away* + from the pipe, which a nearest-to-target search never does. Levels can hand the gate + `waypoints` to route it — a hint to the verifier, not gameplay. `autoPlay` also accepts a + stored `reference` build for anything waypoints cannot express. +4. **A sandbox level must not make its mechanic the only route.** L9's first draft capped + the destructible rock with fire, so the climb-over was lethal and the bomb was the sole + solution — which greedy cannot find, making the level unwinnable by the gate *and* by any + player who did not guess. The fire moved beside the rock; the bomb is now a shortcut. +5. **Overlapping goo at level start is violent.** Two balls 6px apart are ejected hard + enough to snap their own strand (found when a balloon flew to y=-31120). Both the bank + lint and the editor now refuse it — and it immediately caught a real overlap in L2. + +### Baselines + +``` +node tools/verifyGooTower.js # 303 passed, 0 failed (~15s, includes 12 winnability runs) +node tools/genGooTower.js # rewrites the 12-level bank +``` + +## Wave 4 — the campaign — ⬜ NEXT + +Five named chapters × ~15 levels, **replacing the throwaway sandbox tier**. Hand-authored +teaching levels in the editor; `tools/genGooTower.js` fills drill slots. Gates adapted from `genPuddingMonsters.js`: +**solvable** (a recorded reference build wins under the deterministic sim), **load-bearing** +(strip the featured element and the level must break or get materially harder), **gentle** +(no instant-fail opening in a teaching level). + +The uncertain piece: a physics sandbox has no clean BFS solver, so "solvable" means *a +stored reference build replays to a win*. That is why levels are hand-authored first and +generated second. + +--- + +## Sources for original-game behaviour + +Design reconstructed from the shipped game's mechanics: goo balls as point masses joined by +springy strands, rigidity from triangulation, loose goo crawling the structure, the pipe +drinking a required count, and the OCD (Obsessive Completion Distinction) as a separate +award. Level layouts, art, music and the original's physics constants are **not** +reproduced — everything here is authored fresh, as Peggle and Jell-o Monsters were. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 4baac30..ad65c0b 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -117,3 +117,4 @@ registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-cons registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 }); registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 }); registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 }); +registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 }); diff --git a/src/games/gootower/GooTowerAuto.js b/src/games/gootower/GooTowerAuto.js new file mode 100644 index 0000000..29bf6cb --- /dev/null +++ b/src/games/gootower/GooTowerAuto.js @@ -0,0 +1,106 @@ +// Goo Tower — headless reference player. +// +// A physics sandbox has no clean BFS solver the way a grid puzzle does, so +// "is this level winnable?" is answered by actually playing it: place goo with +// a deliberately NAIVE greedy strategy and see whether the pipe opens and +// drinks its fill. If this dumb builder can win, a human certainly can. +// +// The converse does not hold -- a greedy failure does not prove a level is +// impossible -- so treat a failure as "redesign or hand-author a reference +// build", never as proof of impossibility. +// +// Lives in src/ (not tools/) because the level editor calls it too: its +// "Test Winnable" button must run exactly the gate the verifier runs. + +import { + TUNING, createState, stepSim, canPlace, beginDrag, endDrag, isWon, + hazardAt, +} from './GooTowerLogic.js'; + +const SETTLE_FRAMES = 24; + +// Greedy: of every legal drop position on a ring around each attached ball, +// take the one nearest the current target. +function bestDrop(st, type, target) { + let best = null; + let bestD = Infinity; + for (const anchor of st.balls) { + if (!anchor.attached || anchor.dead) continue; + for (let a = 0; a < 24; a += 1) { + const ang = (a / 24) * Math.PI * 2; + for (const rad of [TUNING.STRAND_REST, TUNING.STRAND_REST * 1.3]) { + const x = anchor.x + Math.cos(ang) * rad; + const y = anchor.y + Math.sin(ang) * rad; + if (!canPlace(st, x, y, type)) continue; + // Legal is not the same as survivable: dropping goo onto spikes or + // into fire is a valid placement that kills it instantly, and a + // greedy builder will do it over and over. + if (hazardAt(st, x, y, type)) continue; + const d = Math.hypot(x - target.x, y - target.y); + if (d < bestD) { bestD = d; best = { x, y }; } + } + } + } + return best; +} + +export function autoPlay(level, { maxBalls = 60, maxSeconds = 60, seed } = {}) { + const st = seed == null ? createState(level) : createState(level, seed); + let placed = 0; + + // A stored reference build beats guessing. Some mechanics -- lighting a bomb + // to blow open a wall, say -- a greedy builder will never discover, so the + // level author records the intended solution and the gate replays THAT. + if (Array.isArray(level.reference) && level.reference.length) { + for (const step of level.reference) { + const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held + && (!step.type || b.type === step.type)); + if (!ball) break; + beginDrag(st, ball, null); + endDrag(st, ball, step.x, step.y, null); + placed += 1; + for (let k = 0; k < SETTLE_FRAMES; k += 1) stepSim(st, 1 / 60); + } + for (let k = 0; k < 60 * maxSeconds && !isWon(st); k += 1) stepSim(st, 1 / 60); + return { state: st, won: isWon(st), placed, pipeOpen: !!st.pipe?.open, collected: st.collected, mode: 'reference' }; + } + + // Pure "always build toward the pipe" walks straight into a local minimum + // whenever clearing an obstacle means first building AWAY from the pipe. A + // level can hand the reference player waypoints to route around that; they + // are a hint to the gate, not gameplay. + const targets = [...(level.waypoints || []).map((w) => ({ x: w.x, y: w.y }))]; + if (st.pipe) targets.push({ x: st.pipe.x, y: st.pipe.y }); + let ti = 0; + + for (let i = 0; i < maxBalls; i += 1) { + if (!st.pipe || st.pipe.open) break; + const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held && !b.walking) + || st.balls.find((b) => !b.dead && !b.attached && !b.held); + if (!ball) break; + + while (ti < targets.length - 1 + && st.balls.some((b) => b.attached && !b.dead + && Math.hypot(b.x - targets[ti].x, b.y - targets[ti].y) < 72)) { + ti += 1; + } + const drop = bestDrop(st, ball.type, targets[ti]); + if (!drop) break; + beginDrag(st, ball, null); + endDrag(st, ball, drop.x, drop.y, null); + placed += 1; + for (let k = 0; k < SETTLE_FRAMES; k += 1) stepSim(st, 1 / 60); + } + + // Let the crawlers do their work. + for (let k = 0; k < 60 * maxSeconds && !isWon(st); k += 1) stepSim(st, 1 / 60); + + return { + state: st, + won: isWon(st), + placed, + pipeOpen: !!st.pipe?.open, + collected: st.collected, + mode: 'greedy', + }; +} diff --git a/src/games/gootower/GooTowerEditor.js b/src/games/gootower/GooTowerEditor.js new file mode 100644 index 0000000..9b1fc27 --- /dev/null +++ b/src/games/gootower/GooTowerEditor.js @@ -0,0 +1,773 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { + TUNING, GOO_TYPES, gooType, createState, settlePhysics, + chooseAttachments, insideSolid, pointInPoly, isRooted, +} from './GooTowerLogic.js'; +import { autoPlay } from './GooTowerAuto.js'; + +// Secret level editor — reached only via index.html?gootower-editor=1. +// +// A static site cannot write files, so the editor exports a Blob download of +// the level plus an upserted manifest and the author drops both into +// assets/gamedata/gootower/. Same contract as PeggleEditor. +// +// The world is 1600x1000, which is wider than the play view can spare once a +// settings column is on screen, so the editor draws it at SCALE in the left +// column. Authored coordinates are still world coordinates — every conversion +// goes through toWorld/toScreen. + +const WORLD_W = 1600; +const WORLD_H = 1000; +const SCALE = 0.78; +const EX = 24; +const EY = 96; + +const PANEL_X = EX + WORLD_W * SCALE + 22; + +const D = { bg: -2, world: -1, grid: 0, terrain: 2, pipe: 4, strand: 6, ball: 8, marker: 12, ui: 30 }; + +const KIND_COLOR = { solid: 0x2f2a3d, spike: 0x7d3550, fire: 0xd4642a }; +const GOO_COLOR = { + common: 0x4a4258, ivy: 0x63c132, balloon: 0xffd7e6, bomb: 0xc8392b, + block: 0x8a6a4a, pokey: 0xb07d18, bit: 0x9ecbff, skull: 0xe8e2ff, anchor: 0x5a4a3a, +}; + +const TOOLS = ['terrain', 'ball', 'pile', 'pipe', 'fan', 'gear', 'erase']; + +export default class GooTowerEditor extends Phaser.Scene { + constructor() { super('GooTowerEditor'); } + + init(data) { + this.resume = !!data?.resume; + + this.meta = { + level: 1, name: 'New Level', chapter: 1, + required: 4, ocdTarget: 9, tip: '', + file: 'level-001.json', + }; + this.terrain = []; // { kind, poly: [[x,y]...] } + this.balls = []; // { x, y, type, pinned } (the starting structure) + this.strands = []; // [i, j] into balls + this.pileItems = []; // { x, y, type } + this.fans = []; // { x, y, w, h, dx, dy, force } + this.gears = []; // { x, y, r, teeth, omega } + this.pipe = { x: 800, y: 520, r: TUNING.PIPE_R }; + this.pendingFan = null; // first corner of a fan rectangle + this.destructible = false; + this.fanDir = 'up'; + this.gearOmega = 1.4; + + this.tool = 'terrain'; + this.kind = 'solid'; + this.gooType = 'common'; + this.pinned = false; + this.pendingPoly = null; + this.manifest = []; + this.undoStack = []; + this.lastClick = { t: -9999, x: 0, y: 0 }; + } + + async create() { + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0d1219).setDepth(D.bg); + this.input.mouse?.disableContextMenu(); + + if (this.resume) { + const saved = this.registry.get('gootower-editor-state'); + if (saved) Object.assign(this, structuredClone(saved)); + } + + try { + const raw = this.cache.json.get('gootower-levels'); + this.manifest = raw?.levels ?? []; + } catch (_) { this.manifest = []; } + + this.worldG = this.add.graphics().setDepth(D.world); + this.gridG = this.add.graphics().setDepth(D.grid); + this.mainG = this.add.graphics().setDepth(D.terrain); + this.markerG = this.add.graphics().setDepth(D.marker); + + this.buildToolbar(); + this.buildForm(); + this.bindInput(); + this.redraw(); + } + + // ── Coordinate mapping ──────────────────────────────────────────────────── + + toWorld(px, py) { return { x: (px - EX) / SCALE, y: (py - EY) / SCALE }; } + sx(x) { return EX + x * SCALE; } + sy(y) { return EY + y * SCALE; } + + inWorld(px, py) { + return px >= EX && px <= EX + WORLD_W * SCALE && py >= EY && py <= EY + WORLD_H * SCALE; + } + + // ── Draft -> live state ─────────────────────────────────────────────────── + // + // Everything the editor needs to know about legality it asks the REAL rules + // for, by building the level exactly as the game would. That is why the + // attachment preview here can never disagree with what happens in play. + + draftLevel() { + return { + world: { w: WORLD_W, h: WORLD_H }, + level: this.meta.level, + name: this.meta.name, + chapter: this.meta.chapter, + tip: this.meta.tip || undefined, + terrain: this.terrain.map((t) => ({ + kind: t.kind, + poly: t.poly.map((p) => [p[0], p[1]]), + ...(t.destructible ? { destructible: true } : {}), + })), + ...(this.fans.length ? { fans: this.fans.map((f) => ({ ...f })) } : {}), + ...(this.gears.length ? { gears: this.gears.map((g) => ({ ...g })) } : {}), + balls: this.balls.map((b) => ({ x: b.x, y: b.y, type: b.type, pinned: !!b.pinned })), + strands: this.strands.map((s) => [s[0], s[1]]), + pile: this.pileItems.map((p) => ({ x: p.x, y: p.y, type: p.type })), + pipe: { x: this.pipe.x, y: this.pipe.y, r: this.pipe.r }, + required: this.meta.required, + ocdTarget: this.meta.ocdTarget, + }; + } + + // A fresh, mutable state. Use this for anything that simulates. + freshState() { return createState(this.draftLevel()); } + + // Cached read-only state for hover previews. drawHoverPreview() runs on every + // pointermove, and building a whole sim state per mouse event allocates the + // entire level 100+ times a second. Invalidated by touch() on every edit. + draftState() { + if (this._dsVersion !== this.version) { + this._ds = createState(this.draftLevel()); + this._dsVersion = this.version; + } + return this._ds; + } + + // Call after ANY mutation of terrain/balls/strands/pile/pipe. + touch() { + this.version = (this.version || 0) + 1; + this.refreshStats(); + } + + // ── Toolbar ─────────────────────────────────────────────────────────────── + + buildToolbar() { + this.toolBtns = {}; + let x = EX + 70; + for (const t of TOOLS) { + const b = new Button(this, x, 44, t[0].toUpperCase() + t.slice(1), () => this.setTool(t), + { width: 130, height: 44, fontSize: 18, variant: t === this.tool ? 'solid' : 'ghost' }); + b.setDepth(D.ui); + this.toolBtns[t] = b; + x += 142; + } + + const py = GAME_HEIGHT - 46; + let bx = EX + 90; + const mk = (label, fn, opts = {}) => { + new Button(this, bx, py, label, fn, { width: 168, height: 46, fontSize: 17, ...opts }).setDepth(D.ui); + bx += 180; + }; + mk('Settle', () => this.settleDraft()); + mk('Test Winnable', () => this.testWinnable(), { variant: 'ghost' }); + mk('Test Play', () => this.testPlay(), { variant: 'ghost' }); + mk('⬇ Level', () => this.exportLevel()); + mk('⬇ Manifest', () => this.exportManifest(), { variant: 'ghost' }); + mk('Undo', () => this.undo(), { variant: 'ghost' }); + mk('Exit', () => { window.location.search = ''; }, { variant: 'ghost' }); + } + + setTool(t) { + this.tool = t; + this.pendingPoly = null; + this.pendingFan = null; + for (const [k, b] of Object.entries(this.toolBtns)) { + b.options.variant = k === t ? 'solid' : 'ghost'; + b._drawBg(b.options.bg, k === t ? 1 : 0.35); + } + this.redraw(); + } + + // ── Settings form ───────────────────────────────────────────────────────── + + buildForm() { + if (this.formDom) this.formDom.destroy(); + const rw = GAME_WIDTH - PANEL_X - 24; + const row = 'display:flex; align-items:center; justify-content:space-between; margin:7px 0;'; + const input = 'background:#0d2036; color:#e8eef7; border:1px solid #2f5b8f; border-radius:6px; padding:6px 8px; font-size:15px;'; + const gooOpts = Object.keys(GOO_TYPES) + .map((t) => ``).join(''); + const kindOpts = ['solid', 'spike', 'fire'] + .map((k) => ``).join(''); + const loadOpts = ['', + ...this.manifest.map((m) => ``)].join(''); + + const el = document.createElement('div'); + el.style.cssText = `width:${rw - 24}px; font-family:"Julius Sans One",sans-serif; color:#e8eef7;`; + el.innerHTML = ` +
+
LEVEL SETTINGS
+
+
+
+
+
+
+
+
+
+
Brush
+
+
+
+
+
+
+
+
+
+
+
+
`; + + this.formDom = this.add.dom(PANEL_X + rw / 2, EY + 330, el).setDepth(D.ui); + + const q = (id) => el.querySelector(id); + const bindNum = (id, key, min = 0) => q(id).addEventListener('input', (ev) => { + this.meta[key] = Math.max(min, parseInt(ev.target.value, 10) || min); + this.refreshStats(); + }); + bindNum('#ge-level', 'level', 1); + bindNum('#ge-chapter', 'chapter', 1); + bindNum('#ge-required', 'required', 1); + bindNum('#ge-ocd', 'ocdTarget', 1); + q('#ge-name').addEventListener('input', (ev) => { this.meta.name = ev.target.value; }); + q('#ge-file').addEventListener('input', (ev) => { this.meta.file = ev.target.value; }); + q('#ge-tip').addEventListener('input', (ev) => { this.meta.tip = ev.target.value; }); + q('#ge-kind').addEventListener('change', (ev) => { this.kind = ev.target.value; }); + q('#ge-goo').addEventListener('change', (ev) => { this.gooType = ev.target.value; }); + q('#ge-pinned').addEventListener('change', (ev) => { this.pinned = ev.target.checked; }); + q('#ge-destruct').addEventListener('change', (ev) => { this.destructible = ev.target.checked; }); + q('#ge-fandir').addEventListener('change', (ev) => { this.fanDir = ev.target.value; }); + q('#ge-omega').addEventListener('input', (ev) => { this.gearOmega = parseFloat(ev.target.value) || 0; }); + + q('#ge-load').addEventListener('change', (ev) => { + const file = ev.target.value; + ev.target.value = ''; + if (file) this.loadFromManifest(file); + }); + q('#ge-fileinput').addEventListener('change', (ev) => { + const f = ev.target.files?.[0]; + ev.target.value = ''; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => { + try { this.loadLevel(JSON.parse(reader.result), f.name); } + catch (_) { this.warn(`Could not parse ${f.name}`); } + }; + reader.readAsText(f); + }); + + this.warnEl = q('#ge-warn'); + this.statsEl = q('#ge-stats'); + this.refreshStats(); + } + + warn(msg) { if (this.warnEl) this.warnEl.textContent = msg; } + + refreshStats() { + if (!this.statsEl) return; + this.statsEl.textContent = + `terrain ${this.terrain.length} · structure ${this.balls.length} (${this.strands.length} strands)\n` + + `pile ${this.pileItems.length} · needs ${this.meta.required}, OCD ${this.meta.ocdTarget}`; + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindInput() { + this.input.on('pointerdown', (p) => { + if (!this.inWorld(p.x, p.y)) return; + const w = this.toWorld(p.x, p.y); + const dbl = (this.time.now - this.lastClick.t < 320) + && Math.hypot(p.x - this.lastClick.x, p.y - this.lastClick.y) < 12; + this.lastClick = { t: this.time.now, x: p.x, y: p.y }; + + if (p.rightButtonDown()) { this.eraseAt(w.x, w.y); return; } + if (dbl && this.tool === 'terrain') { this.closePoly(); return; } + this.applyTool(w.x, w.y); + }); + this.input.on('pointermove', (p) => { + this.hover = this.inWorld(p.x, p.y) ? this.toWorld(p.x, p.y) : null; + this.redraw(); + }); + this.input.keyboard?.on('keydown-ENTER', () => { if (this.tool === 'terrain') this.closePoly(); }); + this.input.keyboard?.on('keydown-ESC', () => { + this.pendingPoly = null; + this.pendingFan = null; + this.redraw(); + }); + this.input.keyboard?.on('keydown-Z', (e) => { if (e.ctrlKey || e.metaKey) this.undo(); }); + } + + // Every mutation is preceded by pushUndo(), so bumping the cache version + // here guarantees no edit can leave a stale preview state behind. + pushUndo() { + this.version = (this.version || 0) + 1; + this.undoStack.push(JSON.stringify({ + terrain: this.terrain, balls: this.balls, strands: this.strands, + pileItems: this.pileItems, pipe: this.pipe, fans: this.fans, gears: this.gears, + })); + if (this.undoStack.length > 60) this.undoStack.shift(); + } + + undo() { + const s = this.undoStack.pop(); + if (!s) return; + const o = JSON.parse(s); + Object.assign(this, o); + this.pendingPoly = null; + this.touch(); + this.redraw(); + } + + applyTool(x, y) { + if (this.tool === 'terrain') { + if (!this.pendingPoly) { this.pushUndo(); this.pendingPoly = { kind: this.kind, poly: [], destructible: this.destructible }; } + this.pendingPoly.poly.push([Math.round(x), Math.round(y)]); + } else if (this.tool === 'pipe') { + this.pushUndo(); + this.pipe = { x: Math.round(x), y: Math.round(y), r: TUNING.PIPE_R }; + } else if (this.tool === 'ball') { + this.placeStructureBall(x, y); + } else if (this.tool === 'pile') { + this.pushUndo(); + this.pileItems.push({ x: Math.round(x), y: Math.round(y), type: this.gooType }); + } else if (this.tool === 'fan') { + if (!this.pendingFan) { + this.pendingFan = { x: Math.round(x), y: Math.round(y) }; + } else { + const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; + const [dx, dy] = DIRS[this.fanDir] || DIRS.up; + const a = this.pendingFan; + this.pushUndo(); + this.fans.push({ + x: Math.round(Math.min(a.x, x)), y: Math.round(Math.min(a.y, y)), + w: Math.round(Math.abs(x - a.x)), h: Math.round(Math.abs(y - a.y)), + dx, dy, force: 1500, + }); + this.pendingFan = null; + } + } else if (this.tool === 'gear') { + this.pushUndo(); + this.gears.push({ + x: Math.round(x), y: Math.round(y), r: 100, teeth: 9, omega: this.gearOmega, + }); + } else if (this.tool === 'erase') { + this.eraseAt(x, y); + } + this.redraw(); + this.refreshStats(); + } + + // Structure goo auto-connects using the GAME's own attachment rule, so what + // the editor shows is what the level will actually start with. + placeStructureBall(x, y) { + const st = this.draftState(); + if (insideSolid(st, x, y)) { this.warn('Cannot place goo inside solid terrain.'); return; } + const targets = chooseAttachments(st, x, y, this.gooType); + + this.pushUndo(); + const idx = this.balls.length; + this.balls.push({ x: Math.round(x), y: Math.round(y), type: this.gooType, pinned: this.pinned }); + // chooseAttachments returns ball ids, which for a freshly built draft state + // are exactly the indices of this.balls (createState adds structure balls + // first, in order, before the pile). + for (const id of targets) { + if (id >= 0 && id < idx) this.strands.push([idx, id]); + } + this.warn(targets.length ? '' : 'Placed with no strands — it will start unattached.'); + } + + closePoly() { + if (!this.pendingPoly) return; + if (this.pendingPoly.poly.length < 3) { this.warn('A terrain polygon needs at least 3 points.'); return; } + this.terrain.push(this.pendingPoly); + this.pendingPoly = null; + this.warn(''); + this.touch(); + this.redraw(); + } + + eraseAt(x, y) { + // Nearest-first: pile goo, then structure goo, then a terrain polygon + // containing the point. + const near = (arr, r) => { + let best = -1; + let bd = r; + arr.forEach((o, i) => { + const d = Math.hypot(o.x - x, o.y - y); + if (d < bd) { bd = d; best = i; } + }); + return best; + }; + + let i = near(this.pileItems, 24); + if (i >= 0) { this.pushUndo(); this.pileItems.splice(i, 1); this.redraw(); this.refreshStats(); return; } + + i = near(this.balls, 24); + if (i >= 0) { + this.pushUndo(); + this.balls.splice(i, 1); + // Drop strands touching it and renumber the ones above. + this.strands = this.strands + .filter((s) => s[0] !== i && s[1] !== i) + .map((s) => [s[0] > i ? s[0] - 1 : s[0], s[1] > i ? s[1] - 1 : s[1]]); + this.redraw(); + this.refreshStats(); + return; + } + + for (let k = this.gears.length - 1; k >= 0; k -= 1) { + const g = this.gears[k]; + if (Math.hypot(g.x - x, g.y - y) < g.r) { + this.pushUndo(); this.gears.splice(k, 1); this.touch(); this.redraw(); return; + } + } + for (let k = this.fans.length - 1; k >= 0; k -= 1) { + const f = this.fans[k]; + if (x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h) { + this.pushUndo(); this.fans.splice(k, 1); this.touch(); this.redraw(); return; + } + } + for (let k = this.terrain.length - 1; k >= 0; k -= 1) { + if (pointInPoly(x, y, this.terrain[k].poly)) { + this.pushUndo(); + this.terrain.splice(k, 1); + this.redraw(); + this.refreshStats(); + return; + } + } + } + + // ── Settling ────────────────────────────────────────────────────────────── + // + // Levels must be exported at rest. An unsettled level spends its first + // second absorbing its own startup transient -- which peaks near 2x static + // load and can snap strands before the player has touched anything. + + settleDraft() { + const st = this.freshState(); + settlePhysics(st, 14); + this.pushUndo(); + this.balls.forEach((b, i) => { + const sb = st.balls[i]; + if (!sb) return; + b.x = Math.round(sb.x); + b.y = Math.round(sb.y); + }); + const offset = this.balls.length; + this.pileItems.forEach((p, i) => { + const sb = st.balls[offset + i]; + if (!sb) return; + p.x = Math.round(sb.x); + p.y = Math.round(sb.y); + }); + const lost = st.balls.filter((b) => b.dead).length; + const broke = st.strands.filter((s) => s.broken).length; + this.touch(); + this.warn(lost || broke + ? `Settled, but ${lost} goo died and ${broke} strands snapped — fix before exporting.` + : 'Settled. Positions are now at rest.'); + this.redraw(); + } + + testWinnable() { + const v = this.validate(); + if (!v.valid) { this.warn(`Cannot test:\n${v.problems.join('\n')}`); return; } + const r = autoPlay(this.draftLevel()); + this.warn(r.won + ? `Winnable: greedy placed ${r.placed} goo, collected ${r.collected}/${this.meta.required}.` + : `Greedy could NOT win (placed ${r.placed}, pipe ${r.pipeOpen ? 'open' : 'shut'}, ` + + `collected ${r.collected}/${this.meta.required}).\nThat is not proof it is impossible — but check it.`); + } + + testPlay() { + const v = this.validate(); + if (!v.valid) { this.warn(`Cannot test:\n${v.problems.join('\n')}`); return; } + this.registry.set('gootower-editor-state', { + meta: this.meta, terrain: this.terrain, balls: this.balls, + strands: this.strands, pileItems: this.pileItems, pipe: this.pipe, + fans: this.fans, gears: this.gears, + tool: this.tool, kind: this.kind, gooType: this.gooType, pinned: this.pinned, + }); + this.scene.start('GooTowerGame', { testLevel: this.draftLevel(), returnToEditor: true }); + } + + // ── Validation ──────────────────────────────────────────────────────────── + + validate() { + const problems = []; + if (!this.meta.name.trim()) problems.push('• Name is empty.'); + if (this.meta.level < 1) problems.push('• Level number must be at least 1.'); + if (!this.pipe) problems.push('• No pipe placed.'); + if (this.meta.ocdTarget < this.meta.required) problems.push('• OCD target is below the requirement.'); + if (this.pileItems.length < this.meta.ocdTarget) { + problems.push(`• Only ${this.pileItems.length} loose goo for an OCD target of ${this.meta.ocdTarget}.`); + } + if (!this.balls.length) problems.push('• No starting structure.'); + + for (const b of [...this.balls, ...this.pileItems]) { + if (insideSolid(this.draftState(), b.x, b.y)) { + problems.push('• Some goo starts inside solid terrain.'); + break; + } + } + + // Rootedness needs a SIMULATED state: `grounded` is set by terrain contact + // during a substep, so on a never-stepped state it is false everywhere and + // a floating structure would sail through this check. + if (this.balls.length) { + const sim = this.freshState(); + settlePhysics(sim, 6); + const anyRooted = sim.balls.some((b, i) => i < this.balls.length && !b.dead && isRooted(sim, b.id)); + if (!anyRooted) problems.push('• The starting structure is not anchored or resting on anything.'); + const broke = sim.strands.filter((s) => s.broken).length; + if (broke) problems.push(`• ${broke} strand(s) snap on their own — press Settle and fix it.`); + const lost = sim.balls.filter((b) => b.dead).length; + if (lost) problems.push(`• ${lost} goo die(s) before the player touches anything.`); + } + for (const t of this.terrain) { + if (t.poly.length < 3) { problems.push('• A terrain polygon has fewer than 3 points.'); break; } + } + + // Overlapping goo is ejected violently by the collision solver and can snap + // its own strands before the player has touched anything. + const all = [...this.balls, ...this.pileItems]; + outer: + for (let i = 0; i < all.length; i += 1) { + for (let j = i + 1; j < all.length; j += 1) { + const ra = gooType(all[i].type).r; + const rb = gooType(all[j].type).r; + if (Math.hypot(all[j].x - all[i].x, all[j].y - all[i].y) < (ra + rb) * 0.6) { + problems.push('• Some goo starts overlapping — it will be flung apart on the first frame.'); + break outer; + } + } + } + return { valid: problems.length === 0, problems }; + } + + // ── Export / load ───────────────────────────────────────────────────────── + + download(name, obj) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); + } + + manifestEntry() { + return { + level: this.meta.level, name: this.meta.name, chapter: this.meta.chapter, + file: this.meta.file, required: this.meta.required, ocdTarget: this.meta.ocdTarget, + }; + } + + exportLevel() { + const v = this.validate(); + if (!v.valid) { this.warn(`Export blocked:\n${v.problems.join('\n')}`); return; } + this.download(this.meta.file, this.draftLevel()); + this.warn(`Saved ${this.meta.file} — drop it into assets/gamedata/gootower/`); + } + + exportManifest() { + const levels = this.manifest.filter((m) => m.level !== this.meta.level).map((m) => ({ ...m })); + levels.push(this.manifestEntry()); + levels.sort((a, b) => a.level - b.level); + const chapters = [{ id: 1, name: 'The Goo Filled Hills', blurb: 'Learn to build.', from: 1, to: levels.length }]; + this.download('levels.json', { version: 1, levels, chapters }); + this.warn('Saved levels.json — drop it into assets/gamedata/gootower/'); + } + + async loadFromManifest(file) { + try { + const res = await fetch(`assets/gamedata/gootower/${file}`); + this.loadLevel(await res.json(), file); + } catch (_) { this.warn(`Could not load ${file}`); } + } + + loadLevel(def, file) { + this.pushUndo(); + this.meta = { + level: def.level ?? 1, + name: def.name ?? 'Untitled', + chapter: def.chapter ?? 1, + required: def.required ?? 4, + ocdTarget: def.ocdTarget ?? def.required ?? 4, + tip: def.tip ?? '', + file: file || `level-${String(def.level ?? 1).padStart(3, '0')}.json`, + }; + this.terrain = (def.terrain ?? []).map((t) => ({ + kind: t.kind || 'solid', + poly: t.poly.map((p) => [p[0], p[1]]), + destructible: !!t.destructible, + })); + this.fans = (def.fans ?? []).map((f) => ({ ...f })); + this.gears = (def.gears ?? []).map((g) => ({ ...g })); + this.balls = (def.balls ?? []).map((b) => ({ x: b.x, y: b.y, type: b.type || 'common', pinned: !!b.pinned })); + this.strands = (def.strands ?? []).map((s) => [s[0], s[1]]); + this.pileItems = (def.pile ?? []).map((p) => ({ x: p.x, y: p.y, type: p.type || 'common' })); + this.pipe = def.pipe ? { ...def.pipe, r: def.pipe.r || TUNING.PIPE_R } : this.pipe; + this.pendingPoly = null; + this.touch(); + this.buildForm(); + this.redraw(); + this.warn(`Loaded ${this.meta.name}`); + } + + // ── Drawing ─────────────────────────────────────────────────────────────── + + redraw() { + const g = this.mainG; + g.clear(); + + // World frame + a light grid on STRAND_REST, so spacing reads at a glance. + this.worldG.clear(); + this.worldG.fillStyle(0x151d28, 1); + this.worldG.fillRect(EX, EY, WORLD_W * SCALE, WORLD_H * SCALE); + this.worldG.lineStyle(2, 0x2f5b8f, 1); + this.worldG.strokeRect(EX, EY, WORLD_W * SCALE, WORLD_H * SCALE); + + this.gridG.clear(); + this.gridG.lineStyle(1, 0xffffff, 0.05); + for (let x = 0; x <= WORLD_W; x += TUNING.STRAND_REST) { + this.gridG.lineBetween(this.sx(x), EY, this.sx(x), EY + WORLD_H * SCALE); + } + for (let y = 0; y <= WORLD_H; y += TUNING.STRAND_REST) { + this.gridG.lineBetween(EX, this.sy(y), EX + WORLD_W * SCALE, this.sy(y)); + } + + for (const t of this.terrain) this.drawPoly(g, t, 1); + if (this.pendingPoly) this.drawPoly(g, this.pendingPoly, 0.45, true); + + // Pipe. + const px = this.sx(this.pipe.x); + const py = this.sy(this.pipe.y); + g.fillStyle(0x3d5a6c, 1); + g.fillRoundedRect(px - this.pipe.r * 0.72 * SCALE, py - this.pipe.r * 1.5 * SCALE, + this.pipe.r * 1.44 * SCALE, this.pipe.r * 1.5 * SCALE, 6); + g.lineStyle(2, 0x8fd0e8, 0.9); + g.strokeCircle(px, py, this.pipe.r * SCALE); + + // Structure strands, then goo. + g.lineStyle(4 * SCALE, 0x1c1626, 1); + for (const [i, j] of this.strands) { + const a = this.balls[i]; + const b = this.balls[j]; + if (!a || !b) continue; + g.lineBetween(this.sx(a.x), this.sy(a.y), this.sx(b.x), this.sy(b.y)); + } + for (const f of this.fans) { + g.fillStyle(0x8fd0e8, 0.10); + g.fillRect(this.sx(f.x), this.sy(f.y), f.w * SCALE, f.h * SCALE); + g.lineStyle(2, 0x8fd0e8, 0.5); + g.strokeRect(this.sx(f.x), this.sy(f.y), f.w * SCALE, f.h * SCALE); + const cx = this.sx(f.x + f.w / 2); + const cy = this.sy(f.y + f.h / 2); + g.lineBetween(cx - f.dx * 20, cy - f.dy * 20, cx + f.dx * 20, cy + f.dy * 20); + } + for (const gr of this.gears) { + g.fillStyle(0x4a4260, 1); + g.fillCircle(this.sx(gr.x), this.sy(gr.y), gr.r * SCALE); + g.lineStyle(2, 0x8a7fb0, 1); + g.strokeCircle(this.sx(gr.x), this.sy(gr.y), gr.r * SCALE); + g.lineBetween(this.sx(gr.x), this.sy(gr.y), + this.sx(gr.x) + gr.r * SCALE * (gr.omega >= 0 ? 0.7 : -0.7), this.sy(gr.y)); + } + for (const b of this.balls) this.drawGoo(g, b, true); + for (const p of this.pileItems) this.drawGoo(g, p, false); + + this.drawHoverPreview(); + } + + drawPoly(g, t, alpha, pending = false) { + /* eslint-disable no-param-reassign */ + const pts = t.poly.map(([x, y]) => ({ x: this.sx(x), y: this.sy(y) })); + const color = KIND_COLOR[t.kind] ?? KIND_COLOR.solid; + if (t.destructible) alpha *= 0.75; + if (pts.length >= 3) { + g.fillStyle(color, alpha); + g.fillPoints(pts, true); + } + g.lineStyle(2, t.kind === 'spike' ? 0xd46a8c : 0x4a4260, 1); + if (pts.length >= 2) g.strokePoints(pts, !pending); + if (pending) { + g.fillStyle(0xf0c75e, 1); + for (const p of pts) g.fillCircle(p.x, p.y, 4); + } + } + + drawGoo(g, b, structural) { + const r = gooType(b.type).r * SCALE; + const c = GOO_COLOR[b.type] ?? GOO_COLOR.common; + g.fillStyle(c, 1); + g.fillCircle(this.sx(b.x), this.sy(b.y), r); + if (structural && b.pinned) { + g.lineStyle(2, 0xf0c75e, 1); + g.strokeCircle(this.sx(b.x), this.sy(b.y), r + 3); + } + if (!structural) { + g.lineStyle(1, 0xffffff, 0.35); + g.strokeCircle(this.sx(b.x), this.sy(b.y), r + 1); + } + } + + // Live attachment preview for the ball tool — the same call the game makes, + // so the MIN_ANGLE rule is visible while authoring instead of a surprise. + drawHoverPreview() { + const m = this.markerG; + m.clear(); + if (!this.hover) return; + const { x, y } = this.hover; + + if (this.tool === 'ball') { + const st = this.draftState(); + const targets = chooseAttachments(st, x, y, this.gooType); + const need = gooType(this.gooType).minStrands; + const ok = targets.length >= need && !insideSolid(st, x, y); + const color = ok ? 0x7fe38a : 0xff6b6b; + m.lineStyle(2, color, 0.85); + for (const id of targets) { + const t = this.balls[id]; + if (!t) continue; + m.lineBetween(this.sx(x), this.sy(y), this.sx(t.x), this.sy(t.y)); + } + m.strokeCircle(this.sx(x), this.sy(y), gooType(this.gooType).r * SCALE + 4); + m.lineStyle(1, color, 0.25); + m.strokeCircle(this.sx(x), this.sy(y), TUNING.ATTACH_R * SCALE); + } else if (this.tool === 'terrain' && this.pendingPoly?.poly.length) { + const last = this.pendingPoly.poly[this.pendingPoly.poly.length - 1]; + m.lineStyle(2, 0xf0c75e, 0.6); + m.lineBetween(this.sx(last[0]), this.sy(last[1]), this.sx(x), this.sy(y)); + } else if (this.tool === 'pile') { + m.lineStyle(2, 0x9ecbff, 0.7); + m.strokeCircle(this.sx(x), this.sy(y), gooType(this.gooType).r * SCALE + 3); + } else if (this.tool === 'fan' && this.pendingFan) { + const a = this.pendingFan; + m.lineStyle(2, 0x8fd0e8, 0.8); + m.strokeRect(this.sx(Math.min(a.x, x)), this.sy(Math.min(a.y, y)), + Math.abs(x - a.x) * SCALE, Math.abs(y - a.y) * SCALE); + } else if (this.tool === 'gear') { + m.lineStyle(2, 0x8a7fb0, 0.7); + m.strokeCircle(this.sx(x), this.sy(y), 100 * SCALE); + } + } +} diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js new file mode 100644 index 0000000..20b5567 --- /dev/null +++ b/src/games/gootower/GooTowerGame.js @@ -0,0 +1,729 @@ +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 { Tooltip } from '../../ui/Tooltip.js'; +import { api } from '../../services/api.js'; +import { + TUNING, gooType, createState, stepSim, + pickBallAt, beginDrag, dragTo, endDrag, chooseAttachments, canPlace, + strandStress, isWon, hasOCD, hazardAt, requiredStrands, +} from './GooTowerLogic.js'; + +const BG = 0x121a24; +const SKY_TOP = 0x1b2a3a; +const SKY_BOT = 0x0d1219; +const DIRT = 0x2f2a3d; +const DIRT_RIM = 0x4a4260; +const SPIKE = 0x7d3550; +const SPIKE_RIM = 0xd46a8c; +const FIRE = 0xd4642a; +const STRAND = 0x1c1626; +const STRAND_HOT = 0xff5a4a; +const PIPE_BODY = 0x3d5a6c; +const PIPE_RIM = 0x8fd0e8; +const GHOST_OK = 0x7fe38a; +const GHOST_NO = 0xff6b6b; + +// One colour per goo type. Everything is drawn procedurally; there is no art +// dependency (see docs/gootower-build-plan.md). +const GOO_COLOR = { + common: 0x4a4258, + ivy: 0x63c132, + balloon: 0xffd7e6, + bomb: 0xc8392b, + block: 0x8a6a4a, + pokey: 0xb07d18, + bit: 0x9ecbff, + skull: 0xe8e2ff, + anchor: 0x5a4a3a, +}; + +// Phaser Containers render children in insertion order and IGNORE child depth, +// so every layer here is a root-level object with its own setDepth. +const D = { + sky: -10, terrain: 0, pipe: 4, strand: 8, ball: 10, ghost: 14, + hud: 30, overlay: 60, overlayUI: 62, +}; + +// World-space -> screen-space. The world is 1600x1000 on a 1920x1080 canvas, +// so it fits outright and there is no camera to drive. +const OX = 160; +const OY = 70; + +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); +}; +const lerpColor = (a, b, t) => { + const ch = (s) => { + const va = (a >> s) & 0xff; + const vb = (b >> s) & 0xff; + return Math.round(va + (vb - va) * t) & 0xff; + }; + return (ch(16) << 16) | (ch(8) << 8) | ch(0); +}; + +export default class GooTowerGame extends Phaser.Scene { + constructor() { super('GooTowerGame'); } + + init(data) { + this.gameDef = data?.game ?? { slug: 'gootower', name: 'Goo Tower' }; + this.testLevel = data?.testLevel ?? null; + this.returnToEditor = !!data?.returnToEditor; + + this.manifest = []; + this.chapters = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.levelCache = new Map(); + + this.view = 'select'; + this.level = 0; + this.levelDef = null; + this.state = null; + this.accum = 0; + this.held = null; + this.boardObjs = []; + this.overlayUp = false; + this.finished = false; + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.drawSky(); + + const raw = this.cache.json.get('gootower-levels'); + this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + this.chapters = raw?.chapters?.length + ? raw.chapters + : (this.manifest.length ? [{ id: 1, name: 'Levels', blurb: '', from: 1, to: this.manifest.length }] : []); + + try { + const res = await api.get('/puzzles/gootower/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.bindInput(); + + if (this.testLevel) this.startLevel(this.testLevel.level ?? 1, this.testLevel); + else this.showSelect(); + } + + drawSky() { + const g = this.add.graphics().setDepth(D.sky); + g.fillGradientStyle(SKY_TOP, SKY_TOP, SKY_BOT, SKY_BOT, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + + clearBoard() { + for (const o of this.boardObjs) o.destroy(); + this.boardObjs = []; + if (this.tooltip) { this.tooltip.destroy?.(); this.tooltip = null; } + // These were tracked, so they are already destroyed -- but the fields still + // point at dead objects, and drawGhost()/drawDynamic() would happily call + // methods on them on the next level. + this.dyn = null; + this.ghost = null; + this.terrainG = null; + this.needText = null; + this.hudText = null; + } + + track(obj) { this.boardObjs.push(obj); return obj; } + + // ── Level select ────────────────────────────────────────────────────────── + + // Leaving a level goes back wherever the player came from: the level list + // normally, or the editor when this is an editor test-play. + exitLevel() { + if (this.returnToEditor) this.scene.start('GooTowerEditor', { resume: true }); + else this.showSelect(); + } + + showSelect() { + this.view = 'select'; + this.state = null; + this.held = null; + this.clearBoard(); + + const cx = GAME_WIDTH / 2; + this.track(this.add.text(cx, 70, 'Goo Tower', { + fontFamily: 'Righteous', fontSize: '58px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.hud)); + + this.tooltip = new Tooltip(this); + + let y = 190; + for (const ch of this.chapters) { + this.track(this.add.text(140, y, ch.name, { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + }).setDepth(D.hud)); + if (ch.blurb) { + this.track(this.add.text(140, y + 36, ch.blurb, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#9aa7b4', + }).setDepth(D.hud)); + } + + const levels = this.manifest.filter((m) => m.level >= ch.from && m.level <= ch.to); + let x = 150; + for (const m of levels) { + const unlocked = m.level <= this.levelsCompleted + 1; + const cleared = m.level <= this.levelsCompleted; + this.makeLevelTile(x, y + 110, m, unlocked, cleared); + x += 175; + } + y += 260; + } + + this.track(new Button(this, cx, GAME_HEIGHT - 70, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 200 }).setDepth(D.hud)); + if (this.levelsCompleted > 0) { + this.track(new Button(this, 210, GAME_HEIGHT - 70, 'Reset Progress', + () => this.resetProgress(), { variant: 'ghost', width: 260, fontSize: 20 }).setDepth(D.hud)); + } + } + + makeLevelTile(x, y, m, unlocked, cleared) { + const w = 150; + const h = 120; + const g = this.add.graphics().setDepth(D.hud); + const base = cleared ? 0x2b4a3a : unlocked ? 0x2a3547 : 0x1b2029; + g.fillStyle(base, 1); + g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 10); + g.lineStyle(2, cleared ? 0x63c132 : unlocked ? 0x5b7fa8 : 0x2a3038, 1); + g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 10); + this.track(g); + + this.track(this.add.text(x, y - 30, String(m.level), { + fontFamily: 'Righteous', fontSize: '34px', + color: unlocked ? COLORS.textHex : '#4a5560', + }).setOrigin(0.5).setDepth(D.hud)); + this.track(this.add.text(x, y + 12, unlocked ? m.name : 'Locked', { + fontFamily: '"Julius Sans One"', fontSize: '15px', + color: unlocked ? '#b9c6d2' : '#4a5560', + wordWrap: { width: w - 16 }, align: 'center', + }).setOrigin(0.5).setDepth(D.hud)); + + const best = this.bestFor(m.level); + if (best.count > 0) { + this.track(this.add.text(x, y + 44, best.ocd ? `★ ${best.count}` : `${best.count}`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', + color: best.ocd ? '#ffd166' : '#7f8c99', + }).setOrigin(0.5).setDepth(D.hud)); + } + + if (!unlocked) return; + const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }) + .setDepth(D.hud + 1); + hit.on('pointerup', () => this.startLevel(m.level)); + if (this.tooltip) { + this.tooltip.attachTo(hit, () => ({ + title: m.name, + lines: [`Collect ${m.required} to finish`, `OCD target: ${m.ocdTarget}`], + })); + } + this.track(hit); + } + + bestFor(level) { + try { + return { + count: Number(localStorage.getItem(`gt-best-${level}`) || 0), + ocd: localStorage.getItem(`gt-ocd-${level}`) === '1', + }; + } catch (_) { return { count: 0, ocd: false }; } + } + + recordBest(level, count, ocd) { + try { + if (count > Number(localStorage.getItem(`gt-best-${level}`) || 0)) { + localStorage.setItem(`gt-best-${level}`, String(count)); + } + if (ocd) localStorage.setItem(`gt-ocd-${level}`, '1'); + } catch (_) { /* private browsing */ } + } + + async resetProgress() { + try { await api.post('/puzzles/gootower/reset'); } catch (_) { /* offline */ } + for (const m of this.manifest) { + try { + localStorage.removeItem(`gt-best-${m.level}`); + localStorage.removeItem(`gt-ocd-${m.level}`); + } catch (_) { /* ignore */ } + } + this.levelsCompleted = 0; + this.showSelect(); + } + + // ── Loading a level ─────────────────────────────────────────────────────── + + async fetchLevel(level) { + if (this.levelCache.has(level)) return this.levelCache.get(level); + const entry = this.manifest.find((m) => m.level === level); + if (!entry) return null; + const res = await fetch(`assets/gamedata/gootower/${entry.file}`); + const json = await res.json(); + this.levelCache.set(level, json); + return json; + } + + async startLevel(level, preloaded = null) { + const def = preloaded || await this.fetchLevel(level); + if (!def) { this.showSelect(); return; } + + this.clearBoard(); + this.view = 'play'; + this.level = level; + this.levelDef = def; + this.state = createState(def, 0x60071e + level); + this.accum = 0; + this.held = null; + this.overlayUp = false; + this.finished = false; + + this.terrainG = this.track(this.add.graphics().setDepth(D.terrain)); + this.drawTerrain(); + this.dyn = this.track(this.add.graphics().setDepth(D.strand)); + this.ghost = this.track(this.add.graphics().setDepth(D.ghost)); + this.buildHud(); + } + + // Redrawn on demand, not just once: a blast can delete destructible terrain. + drawTerrain() { + const g = this.terrainG; + if (!g) return; + g.clear(); + for (const t of this.state.terrain) { + if (t.gear) continue; // gears rotate; they are drawn per-frame instead + const pts = t.poly.map(([x, y]) => ({ x: x + OX, y: y + OY })); + if (t.kind === 'solid') { + g.fillStyle(DIRT, 1); + g.fillPoints(pts, true); + g.lineStyle(4, DIRT_RIM, 1); + g.strokePoints(pts, true); + } else if (t.kind === 'spike') { + g.fillStyle(SPIKE, 1); + g.fillPoints(pts, true); + this.drawTeeth(g, t); + } else if (t.kind === 'fire') { + g.fillStyle(FIRE, 0.75); + g.fillPoints(pts, true); + } + if (t.destructible) { + // Cross-hatch marks rock a blast can open. + g.lineStyle(1, 0xd9a066, 0.5); + const bb = t.bounds; + for (let x = bb.minX; x < bb.maxX + (bb.maxY - bb.minY); x += 16) { + g.lineBetween(x + OX, bb.minY + OY, + x - (bb.maxY - bb.minY) + OX, bb.maxY + OY); + } + } + } + this.drawFans(g); + } + + drawFans(g) { + for (const f of this.state.fans) { + g.fillStyle(0x8fd0e8, 0.07); + g.fillRect(f.x + OX, f.y + OY, f.w, f.h); + g.lineStyle(1, 0x8fd0e8, 0.28); + g.strokeRect(f.x + OX, f.y + OY, f.w, f.h); + // Chevrons pointing the way the wind blows. + const step = 56; + for (let x = f.x + step / 2; x < f.x + f.w; x += step) { + for (let y = f.y + step / 2; y < f.y + f.h; y += step) { + const cx = x + OX; + const cy = y + OY; + const ax = f.dx * 12; + const ay = f.dy * 12; + g.lineStyle(2, 0x8fd0e8, 0.32); + g.lineBetween(cx - ax, cy - ay, cx + ax, cy + ay); + g.lineBetween(cx + ax, cy + ay, cx + ax - ay * 0.5 - ax * 0.5, cy + ay + ax * 0.5 - ay * 0.5); + g.lineBetween(cx + ax, cy + ay, cx + ax + ay * 0.5 - ax * 0.5, cy + ay - ax * 0.5 - ay * 0.5); + } + } + } + } + + drawGears(g) { + for (const t of this.state.terrain) { + if (!t.gear) continue; + const pts = t.poly.map(([x, y]) => ({ x: x + OX, y: y + OY })); + g.fillStyle(0x4a4260, 1); + g.fillPoints(pts, true); + g.lineStyle(3, 0x8a7fb0, 1); + g.strokePoints(pts, true); + const cx = t.gear.cx + OX; + const cy = t.gear.cy + OY; + g.fillStyle(0x2a2438, 1); + g.fillCircle(cx, cy, t.gear.r * 0.3); + // A spoke, so the rotation is legible. + g.lineStyle(4, 0x8a7fb0, 0.9); + g.lineBetween(cx, cy, + cx + Math.cos(t.gear.angle) * t.gear.r * 0.62, + cy + Math.sin(t.gear.angle) * t.gear.r * 0.62); + } + } + + // Spikes read as spikes because of the teeth, not the fill colour. Teeth are + // stamped along whichever edge faces away from the polygon's interior. + drawTeeth(g, t) { + const poly = t.poly; + const cy = poly.reduce((s, p) => s + p[1], 0) / poly.length; + g.fillStyle(SPIKE_RIM, 1); + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [ax, ay] = poly[j]; + const [bx, by] = poly[i]; + const mid = (ay + by) / 2; + if (Math.abs(ay - by) > 4) continue; // only horizontal edges + const up = mid < cy ? -1 : 1; // point away from the middle + const len = Math.abs(bx - ax); + const n = Math.max(2, Math.round(len / 26)); + const step = (bx - ax) / n; + for (let k = 0; k < n; k += 1) { + const x0 = ax + step * k; + const x1 = x0 + step; + g.fillTriangle( + x0 + OX, mid + OY, + x1 + OX, mid + OY, + (x0 + x1) / 2 + OX, mid + OY + up * 20, + ); + } + } + } + + buildHud() { + const st = this.state; + this.hudText = this.track(this.add.text(40, 26, '', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, + }).setDepth(D.hud)); + this.tipText = this.track(this.add.text(GAME_WIDTH / 2, 30, this.levelDef.tip || '', { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: '#9aa7b4', + wordWrap: { width: 760 }, align: 'center', + }).setOrigin(0.5, 0).setDepth(D.hud)); + + this.track(new Button(this, GAME_WIDTH - 130, 44, + this.returnToEditor ? 'Editor' : 'Levels', () => this.exitLevel(), + { variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud)); + this.track(new Button(this, GAME_WIDTH - 310, 44, 'Retry', () => this.startLevel(this.level), + { variant: 'ghost', width: 160, height: 48, fontSize: 20 }).setDepth(D.hud)); + if (st) this.refreshHud(); + } + + refreshHud() { + const st = this.state; + if (!this.hudText || !st) return; + this.hudText.setText( + `Collected ${st.collected} / ${st.required} OCD ${st.ocdTarget} Goo left ${st.pile.length}`, + ); + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindInput() { + this.input.on('pointerdown', (p) => this.onDown(p)); + this.input.on('pointermove', (p) => this.onMove(p)); + this.input.on('pointerup', (p) => this.onUp(p)); + this.input.keyboard?.on('keydown-R', () => { if (this.view === 'play') this.startLevel(this.level); }); + this.input.keyboard?.on('keydown-ESC', () => { if (this.view === 'play') this.exitLevel(); }); + } + + toWorld(p) { return { x: p.x - OX, y: p.y - OY }; } + + onDown(p) { + if (this.view !== 'play' || this.overlayUp || !this.state) return; + const w = this.toWorld(p); + const ball = pickBallAt(this.state, w.x, w.y, 46); + if (!ball) return; + if (beginDrag(this.state, ball, null)) { + this.held = ball; + playSound(this, SFX.UI_PICK); + } + } + + onMove(p) { + if (!this.held || !this.state) return; + const w = this.toWorld(p); + dragTo(this.state, this.held, w.x, w.y); + } + + onUp(p) { + if (!this.held || !this.state) return; + const w = this.toWorld(p); + const ball = this.held; + this.held = null; + const events = []; + const stuck = endDrag(this.state, ball, w.x, w.y, events); + playSound(this, stuck ? SFX.UI_PLACE : SFX.SQUISH); + this.handleEvents(events); + } + + // ── Frame ───────────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.view !== 'play' || !this.state) return; + + // Fixed-timestep accumulator; the sim substeps internally at 240Hz. + const dt = Math.min(delta / 1000, 0.05); + const events = stepSim(this.state, dt); + this.handleEvents(events); + + this.drawDynamic(); + this.refreshHud(); + + if (!this.finished && isWon(this.state)) this.finishLevel(); + } + + handleEvents(events) { + for (const e of events) { + if (e.type === 'strandBreak') playSound(this, SFX.SQUASH); + else if (e.type === 'collect') playSound(this, SFX.UI_CHIME); + else if (e.type === 'ballDie') playSound(this, SFX.SQUISH); + else if (e.type === 'pipeOpen') playSound(this, SFX.GEM_DROP); + else if (e.type === 'stick') playSound(this, SFX.PIECE_CLICK); + else if (e.type === 'ignite') playSound(this, SFX.WOOSH); + else if (e.type === 'explode') { + playSound(this, SFX.SQUASH); + this.cameras.main.shake(180, 0.006); + } + // A blast can delete destructible terrain, so the static layer is stale. + else if (e.type === 'terrainDestroyed') this.drawTerrain(); + } + } + + drawDynamic() { + const st = this.state; + const g = this.dyn; + if (!g || !st) return; + g.clear(); + + this.drawGears(g); + + // Strands first, under the balls. + for (const s of st.strands) { + if (s.broken) continue; + const a = st.balls[s.a]; + const b = st.balls[s.b]; + if (!a || !b || a.dead || b.dead) continue; + const stress = strandStress(s); + const color = lerpColor(STRAND, STRAND_HOT, stress); + const width = 7 - 3 * stress; // strands thin as they are pulled taut + g.lineStyle(width, color, 1); + g.lineBetween(a.x + OX, a.y + OY, b.x + OX, b.y + OY); + } + + // Balls. + for (const b of st.balls) { + if (b.dead) continue; + this.drawBall(g, b); + } + + this.drawPipe(g); + this.drawGhost(); + } + + drawBall(g, b) { + const base = GOO_COLOR[b.type] ?? GOO_COLOR.common; + const x = b.x + OX; + const y = b.y + OY; + + // Squash along the direction of travel, so goo reads as soft. + const vx = b.x - b.px; + const vy = b.y - b.py; + const sp = Math.min(1, Math.hypot(vx, vy) / 6); + + g.fillStyle(shade(base, 0.55), 1); + g.fillCircle(x, y + 2, b.r); + g.fillStyle(base, 1); + g.fillCircle(x, y, b.r); + g.fillStyle(shade(base, 1.45), 0.5); + g.fillCircle(x - b.r * 0.3, y - b.r * 0.35, b.r * 0.32); + + if (b.held) { + g.lineStyle(3, 0xffffff, 0.9); + g.strokeCircle(x, y, b.r + 5); + } + + // A lit fuse flashes faster as it burns down. + if (b.fuse != null && !b.exploded) { + const t = 1 - Math.max(0, b.fuse) / TUNING.FUSE_TIME; + const flash = 0.45 + 0.55 * Math.abs(Math.sin(this.state.clock * (8 + t * 26))); + g.fillStyle(0xffd166, flash); + g.fillCircle(x, y, b.r + 4 + t * 6); + g.lineStyle(2, 0xff5a4a, flash); + g.strokeCircle(x, y, b.r + 7 + t * 8); + } + + // Pinned goo (anchors, wall-stuck pokey) gets a collar so the player can + // see what is immovable. + if (b.pinned && b.attached) { + g.lineStyle(3, 0xd9a066, 0.85); + g.strokeCircle(x, y, b.r + 3); + } + + // Eyes. Pupils lean into the direction of motion; loose goo has two eyes, + // structural goo one, which is a cheap way to read the pile at a glance. + const eyes = b.attached ? 1 : 2; + const er = Math.max(2.6, b.r * 0.30); + const pr = er * 0.52; + const dx = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; + const dy = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0; + const spread = eyes === 2 ? b.r * 0.38 : 0; + for (let i = 0; i < eyes; i += 1) { + const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0); + const ey = y - b.r * 0.12; + g.fillStyle(0xffffff, 1); + g.fillCircle(ex, ey, er); + g.fillStyle(0x101018, 1); + g.fillCircle(ex + dx, ey + dy, pr); + } + } + + drawPipe(g) { + const p = this.state.pipe; + if (!p) return; + const x = p.x + OX; + const y = p.y + OY; + const open = p.open; + + g.fillStyle(PIPE_BODY, 1); + g.fillRoundedRect(x - p.r * 0.72, y - p.r * 1.5, p.r * 1.44, p.r * 1.5, 8); + g.fillStyle(open ? PIPE_RIM : shade(PIPE_RIM, 0.45), 1); + g.fillEllipse(x, y - p.r * 0.35, p.r * 1.7, p.r * 0.62); + g.fillStyle(0x0a0f14, 1); + g.fillEllipse(x, y - p.r * 0.35, p.r * 1.24, p.r * 0.4); + + if (open) { + const pulse = 0.35 + 0.25 * Math.sin(this.state.clock * 6); + g.lineStyle(3, PIPE_RIM, pulse); + g.strokeCircle(x, y - p.r * 0.35, p.r * (1.0 + 0.12 * Math.sin(this.state.clock * 4))); + } + } + + // Live feedback while dragging: exactly which strands would form, and + // whether the drop is legal at all. Without this the MIN_ANGLE rule is + // invisible and the player just feels rejected at random. + drawGhost() { + const g = this.ghost; + g.clear(); + const b = this.held; + if (!b) return; + + const hazard = hazardAt(this.state, b.x, b.y, b.type); + const ok = canPlace(this.state, b.x, b.y, b.type); + const targets = chooseAttachments(this.state, b.x, b.y, b.type); + // Legal but lethal is still worth a warning -- dropping goo onto spikes is + // a perfectly valid placement that kills it on contact. + const color = !ok ? GHOST_NO : hazard ? 0xffb347 : GHOST_OK; + + for (const id of targets) { + const t = this.state.balls[id]; + if (!t) continue; + g.lineStyle(3, color, 0.8); + g.lineBetween(b.x + OX, b.y + OY, t.x + OX, t.y + OY); + } + g.lineStyle(2, color, 0.85); + g.strokeCircle(b.x + OX, b.y + OY, b.r + 8); + + if (!ok || hazard) { + const need = requiredStrands(this.state, b.x, b.y, b.type); + g.lineStyle(1, color, 0.35); + g.strokeCircle(b.x + OX, b.y + OY, TUNING.ATTACH_R); + if (!this.needText) { + this.needText = this.track(this.add.text(0, 0, '', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ff9d9d', + }).setOrigin(0.5).setDepth(D.ghost)); + } + const label = !ok + ? (need === 0 ? 'no hold' : `needs ${need}`) + : (hazard === 'fire' && gooType(b.type).explodes ? 'will light!' : `${hazard}!`); + this.needText.setPosition(b.x + OX, b.y + OY - b.r - 26) + .setColor(ok ? '#ffb347' : '#ff9d9d') + .setText(label) + .setVisible(true); + } else if (this.needText) { + this.needText.setVisible(false); + } + } + + // ── Finish ──────────────────────────────────────────────────────────────── + + async finishLevel() { + this.finished = true; + const st = this.state; + const ocd = hasOCD(st); + if (!this.returnToEditor) this.recordBest(this.level, st.collected, ocd); + playSound(this, SFX.VICTORY_SHORT); + + // An editor test-play is not a real run: it must not advance saved + // progress or write a match record. + if (this.canPersist && !this.returnToEditor) { + try { await api.post('/puzzles/gootower/complete', { level: this.level }); } catch (_) { /* offline */ } + try { + await api.post('/history/single-player', { + game: 'gootower', score: st.collected, won: true, + }); + } catch (_) { /* offline */ } + } + if (!this.returnToEditor && this.level > this.levelsCompleted) this.levelsCompleted = this.level; + + // The overlay offers "Keep Playing" rather than ending the run outright, + // because OCD is a separate, higher target and hitting `required` should + // not shut the door on chasing it. + this.showWinOverlay(); + } + + showWinOverlay() { + if (this.overlayUp) return; + this.overlayUp = true; + const st = this.state; + const cx = GAME_WIDTH / 2; + const ocd = hasOCD(st); + + this.track(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62) + .setDepth(D.overlay).setInteractive()); + this.track(this.add.text(cx, 330, 'Level Complete', { + fontFamily: 'Righteous', fontSize: '58px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI)); + this.track(this.add.text(cx, 410, `${st.collected} goo collected (needed ${st.required})`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: '#b9c6d2', + }).setOrigin(0.5).setDepth(D.overlayUI)); + this.track(this.add.text(cx, 456, ocd ? `★ OCD met — ${st.ocdTarget}` : `OCD target: ${st.ocdTarget}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: ocd ? '#ffd166' : '#7f8c99', + }).setOrigin(0.5).setDepth(D.overlayUI)); + + const next = this.returnToEditor ? null : this.manifest.find((m) => m.level === this.level + 1); + if (next) { + this.track(new Button(this, cx - 180, 570, 'Next Level', () => this.startLevel(next.level), + { width: 250 }).setDepth(D.overlayUI)); + } + this.track(new Button(this, cx + (next ? 180 : 0), 570, + this.returnToEditor ? 'Back to Editor' : 'Levels', () => this.exitLevel(), + { width: 250, variant: 'ghost' }).setDepth(D.overlayUI)); + this.track(new Button(this, cx, 660, 'Keep Playing', () => { + this.overlayUp = false; + this.dismissOverlay(); + }, { width: 250, variant: 'ghost' }).setDepth(D.overlayUI)); + } + + // "Keep Playing" tears the overlay widgets down but must leave the board + // itself alone, so the tracked list is filtered rather than cleared. + dismissOverlay() { + const keep = []; + for (const o of this.boardObjs) { + if (o.depth >= D.overlay) o.destroy(); + else keep.push(o); + } + this.boardObjs = keep; + } +} diff --git a/src/games/gootower/GooTowerLogic.js b/src/games/gootower/GooTowerLogic.js new file mode 100644 index 0000000..c45aa14 --- /dev/null +++ b/src/games/gootower/GooTowerLogic.js @@ -0,0 +1,1374 @@ +// Goo Tower — pure soft-body structure simulation + rules. Zero imports, Node-testable. +// Shared by the client scene, the level editor, the offline generator and the verifier. +// +// ── WHY VERLET + XPBD ─────────────────────────────────────────────────────── +// The whole game is a lattice of point masses joined by springy strands. A +// force-based spring integrator either explodes at the stiffness a buildable +// tower needs, or demands a punishing timestep. Verlet integration with XPBD +// distance constraints is unconditionally stable at any stiffness (corrections +// are positional, never forces) and exactly reproducible given a fixed +// iteration order. +// +// XPBD specifically, rather than plain position-based relaxation, for two +// reasons that turned out to be load-bearing: +// * stiffness is 1/COMPLIANCE regardless of RELAX_ITERS, so the iteration +// count is a quality/perf knob and not a secret physics knob; +// * the accumulated multiplier lambda IS the constraint force, so a strand +// knows how hard it is being pulled. A near-rigid solver yanks every +// strand back to its rest length every substep, which means length can +// never report load and NOTHING WOULD EVER BREAK. +// +// Reproducibility is load-bearing, exactly as it is in PeggleLogic: the level +// generator and tools/verifyGooTower.js replay recorded builds headlessly and +// assert they still win. If the sim were not deterministic, a level bank could +// not be validated at all. +// +// ── ONE SUBSTEP ───────────────────────────────────────────────────────────── +// 1. INTEGRATE x' = x + (x - xPrev) * drag + a * dt^2 (unpinned balls) +// 2. RELAX x N strand distance constraints, then terrain pushout, +// iterated in a fixed order (ascending id) +// 3. STRESS strand length / rest > BREAK_RATIO -> snap +// 4. STRUCTURE flood-fill components; a component holding no anchor and +// touching no ground is adrift and simply falls (it does NOT +// disintegrate -- broken chunks stay chunks, like the original) +// 5. AGENTS crawling goo advances; hazards resolve; the pipe drinks +// +// ── RIGIDITY IS EMERGENT ──────────────────────────────────────────────────── +// There are no angular constraints anywhere in this file. A structure is stiff +// only because it is TRIANGULATED, which is why the attachment rule's +// MIN_ANGLE test (see chooseAttachments) is the single most important number +// in the game: it is what stops the player building a floppy chain of slivers +// and forces real trusses. Tune it before tuning anything else. +// +// ── UNITS ─────────────────────────────────────────────────────────────────── +// Pixels and seconds, in a level's own logical world space (level.world). The +// scene maps that to the 1920x1080 canvas with a camera; nothing in here knows +// about the screen. + +export const TUNING = { + SUBSTEP_DT: 1 / 240, + RELAX_ITERS: 8, // Gauss-Seidel passes per substep + GRAVITY: 1500, // px/s^2 + // Global velocity retention per substep (0.995^240 ~= 0.30/s). Constraint + // damping alone cannot settle a structure: it resists a strand STRETCHING, + // but a tower's slowest mode is a pendulum swing that barely stretches + // anything, so it rings for 13s without this. + DRAG: 0.995, + MAX_SPEED: 2600, // px/s, clamped per substep to stop blow-ups + + BALL_R: 14, + STRAND_REST: 62, // natural strand length when freshly attached + STRAND_MIN: 30, // attachment refuses candidates closer than this + // XPBD compliance: px of stretch per unit of tension (i.e. 1/k). Tuned so a + // 4-bay cantilever truss settles at ~40px of tip sag -- visibly goo-like, + // but it holds itself up. + COMPLIANCE: 0.00012, + DAMPING: 0.0004, // constraint damping (axial only; see DRAG) + + // Strands break on TENSION, not on stretch. At the stiffness a buildable + // truss needs, a length-ratio threshold would demand ~350 ball-weights and + // nothing would ever collapse -- but collapse is the whole drama of the + // game. XPBD hands us the real constraint force for free, so the threshold + // is expressed in ball-weights (1 weight = mass 1 x GRAVITY) and is + // therefore independent of how stiff we later decide the goo should be. + // ~25 ball-weights for a plain common strand. Calibrated so a well- + // proportioned 3-bay cantilever survives its own settling transient (which + // peaks near 2x static) while an 8-bay one tears its root off. + BREAK_FORCE: 25 * 1500, + // Breaking watches a low-pass-filtered load, not raw tension. A stiff + // lattice rings at ~15Hz, and any impact drives an instantaneous spike of + // roughly 2x the static load -- judging on the raw value would snap strands + // on the first frame of a level that is merely settling. LOAD_TAU is the + // fatigue window; SHOCK_FACTOR still lets a genuinely violent hit cut + // straight through it. + LOAD_TAU: 0.12, + SHOCK_FACTOR: 3, + BREAK_RATIO: 1.85, // backstop: a strand stretched this far is gone regardless + SLACK_RATIO: 0.55, // below this a strand goes limp and stops pushing + + ATTACH_R: 108, // how far a dropped ball reaches for candidates + MIN_ANGLE_DEG: 24, // reject a candidate this close to an existing strand + + FRICTION: 0.35, // tangential loss on terrain contact + RESTITUTION: 0.08, // normal bounce on terrain contact + BALL_PUSH: 0.5, // how hard overlapping balls shove apart, per pass + GRID_CELL: 34, // broadphase cell size; must exceed 2 * max ball radius + + // Terrain-sticking goo (anchor, pokey) bonds to a solid surface within this + // distance of its rim, and is pinned there. It is what lets a player start a + // structure off a wall instead of only off the ground. + STICK_DIST: 14, + + // A bomb touching fire detonates. The blast kills goo close in and shears + // strands over a wider radius, so an explosion collapses a structure rather + // than merely punching a hole in it. Terrain only goes if it is marked + // `destructible` -- the level author decides what can be blown open. + BLAST_R: 130, + BLAST_KILL_R: 74, + FUSE_TIME: 0.55, // seconds between ignition and detonation + + CRAWL_SPEED: 150, // px/s along a strand + // Loose goo sitting on the ground ambles toward the structure, the way + // unattached goo mills around the tower in the original. This is not + // decoration: without it a heap dropped away from the structure can never be + // reached by the pipe, and the level is unwinnable no matter what is built. + WANDER_SPEED: 95, // px/s + WANDER_HZ: 20, // how often the target is re-picked (perf) + PIPE_R: 46, + SUCK_SPEED: 420, + + SETTLE_SPEED: 4, // below this a ball counts as at rest + MAX_SUBSTEPS: 600, // guard against a runaway accumulator +}; + +// Goo varieties. +// buoyancy scales gravity (1 = normal, 0 = weightless, negative = rises) +// stiff divides TUNING.COMPLIANCE for strands this type owns +// strength multiplies TUNING.BREAK_FORCE for strands this type owns +// A strand takes the WEAKEST of its two endpoints for both, so one flimsy +// ball cannot be used to hang a tower. +export const GOO_TYPES = { + common: { minStrands: 2, maxStrands: 2, mass: 1.0, buoyancy: 1, stiff: 1.0, strength: 1.0, detachable: false, spikeProof: false, sticksToTerrain: false, r: 14 }, + ivy: { minStrands: 2, maxStrands: 3, mass: 1.0, buoyancy: 1, stiff: 1.0, strength: 0.9, detachable: true, spikeProof: false, sticksToTerrain: false, r: 14 }, + balloon: { minStrands: 1, maxStrands: 1, mass: 0.6, buoyancy: -2.2, stiff: 0.6, strength: 0.7, detachable: true, spikeProof: false, sticksToTerrain: false, r: 16 }, + bomb: { minStrands: 2, maxStrands: 2, mass: 1.2, buoyancy: 1, stiff: 1.0, strength: 1.0, detachable: false, spikeProof: true, sticksToTerrain: false, r: 15, explodes: true }, + block: { minStrands: 2, maxStrands: 4, mass: 1.6, buoyancy: 1, stiff: 2.4, strength: 1.8, detachable: false, spikeProof: false, sticksToTerrain: false, r: 15 }, + pokey: { minStrands: 2, maxStrands: 2, mass: 1.0, buoyancy: 1, stiff: 1.2, strength: 1.2, detachable: false, spikeProof: false, sticksToTerrain: true, r: 14 }, + bit: { minStrands: 1, maxStrands: 1, mass: 0.4, buoyancy: 1, stiff: 0.8, strength: 0.6, detachable: true, spikeProof: false, sticksToTerrain: false, r: 9 }, + skull: { minStrands: 2, maxStrands: 2, mass: 1.1, buoyancy: 1, stiff: 1.1, strength: 1.1, detachable: false, spikeProof: true, sticksToTerrain: false, r: 14 }, + anchor: { minStrands: 0, maxStrands: 4, mass: 0, buoyancy: 0, stiff: 3.0, strength: 3.0, detachable: false, spikeProof: true, sticksToTerrain: true, r: 15 }, +}; + +export function gooType(name) { + return GOO_TYPES[name] || GOO_TYPES.common; +} + +// ── Seeded RNG ────────────────────────────────────────────────────────────── +// The only stochastic element in the sim is the crawling wander. Everything +// else is fully determined by the level and the player's inputs. +export function mulberry32(seed) { + let a = seed >>> 0; + return function rng() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ── Geometry ──────────────────────────────────────────────────────────────── + +export function pointInPoly(px, py, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; +} + +export function closestPointOnSegment(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-12) return { x: ax, y: ay, t: 0 }; + let t = ((px - ax) * dx + (py - ay) * dy) / len2; + if (t < 0) t = 0; else if (t > 1) t = 1; + return { x: ax + dx * t, y: ay + dy * t, t }; +} + +// Nearest point anywhere on a polygon's boundary. +export function closestPointOnPoly(px, py, poly) { + let best = null, bestD2 = Infinity; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const cp = closestPointOnSegment(px, py, poly[j][0], poly[j][1], poly[i][0], poly[i][1]); + const dx = px - cp.x, dy = py - cp.y; + const d2 = dx * dx + dy * dy; + if (d2 < bestD2) { bestD2 = d2; best = cp; } + } + return { x: best.x, y: best.y, d2: bestD2 }; +} + +export function segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy) { + const d1 = cross(cx, cy, dx, dy, ax, ay); + const d2 = cross(cx, cy, dx, dy, bx, by); + const d3 = cross(ax, ay, bx, by, cx, cy); + const d4 = cross(ax, ay, bx, by, dx, dy); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && + ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); +} + +function cross(ax, ay, bx, by, px, py) { + return (bx - ax) * (py - ay) - (by - ay) * (px - ax); +} + +function polyBounds(poly) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const [x, y] of poly) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + return { minX, minY, maxX, maxY }; +} + +// True when the straight line a->b is broken by solid terrain. This is what +// stops a dropped ball attaching through a wall. +export function lineBlocked(state, ax, ay, bx, by) { + for (const t of state.terrain) { + if (t.kind !== 'solid') continue; + const poly = t.poly; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + if (segmentsIntersect(ax, ay, bx, by, poly[j][0], poly[j][1], poly[i][0], poly[i][1])) { + return true; + } + } + } + return false; +} + +// ── State ─────────────────────────────────────────────────────────────────── + +export function createState(level, seed = 0x60071e) { + const world = level.world || { w: 1600, h: 1000 }; + const state = { + world, + terrain: (level.terrain || []).map((t) => ({ + kind: t.kind || 'solid', + poly: t.poly.map((p) => [p[0], p[1]]), + bounds: polyBounds(t.poly), + destructible: !!t.destructible, + })), + // Fans are axis-aligned force volumes: { x, y, w, h, dx, dy, force }. + fans: (level.fans || []).map((f) => ({ + x: f.x, y: f.y, w: f.w, h: f.h, + dx: f.dx ?? 0, dy: f.dy ?? -1, force: f.force ?? 2200, + })), + balls: [], + strands: [], + pipe: level.pipe ? { x: level.pipe.x, y: level.pipe.y, r: level.pipe.r || TUNING.PIPE_R, open: false } : null, + pile: [], + required: level.required || 1, + ocdTarget: level.ocdTarget || level.required || 1, + collected: 0, + clock: 0, + acc: 0, + nextBallId: 0, + nextStrandId: 0, + seed: seed >>> 0, + rng: mulberry32(seed), + dirty: true, // strand graph changed -> components need recomputing + pipeDist: new Map(), + pipeDistDirty: true, + grid: null, + wanderClock: 0, + agentsOff: false, // settlePhysics() flips this + }; + + // Gears join the terrain list as ordinary solids carrying a `gear` record, so + // the existing collision path handles them and only their polygon has to be + // respun each substep. + for (const g of level.gears || []) { + const gear = { + cx: g.x, cy: g.y, r: g.r ?? 90, teeth: g.teeth ?? 8, + omega: g.omega ?? 1.2, angle: g.angle ?? 0, + }; + const poly = buildGearPoly(gear); + state.terrain.push({ kind: 'solid', poly, bounds: polyBounds(poly), destructible: false, gear }); + } + + for (const b of level.balls || []) { + addBall(state, b.x, b.y, b.type || 'common', { pinned: !!b.pinned || (b.type === 'anchor') }); + } + for (const s of level.strands || []) { + addStrand(state, s[0], s[1]); + } + // Loose goo waiting to be dragged. Given positions so it can pile up and be + // simulated like everything else. + for (const p of level.pile || []) { + const b = addBall(state, p.x, p.y, p.type || 'common', { free: true }); + state.pile.push(b.id); + } + refreshStructure(state); + return state; +} + +export function addBall(state, x, y, type = 'common', opts = {}) { + const def = gooType(type); + const ball = { + id: state.nextBallId++, + x, y, + px: x, py: y, // Verlet previous position == starts at rest + type, + r: def.r, + invMass: opts.pinned || def.mass === 0 ? 0 : 1 / def.mass, + pinned: !!opts.pinned, + attached: !opts.free, // part of the structure (vs loose in the pile) + strands: [], + dead: false, + collected: false, // drunk by the pipe (dead, but a success not a death) + fuse: null, // seconds until detonation once lit + exploded: false, + held: false, // under the player's cursor; physics leaves it alone + grounded: false, + // crawling + walking: false, + onStrand: -1, + t: 0, + dir: 1, + wanderDir: 0, + }; + state.balls.push(ball); + state.dirty = true; + return ball; +} + +export function addStrand(state, aId, bId, restOverride = null) { + const a = ballById(state, aId); + const b = ballById(state, bId); + if (!a || !b || a === b) return null; + if (a.strands.some((sid) => strandLinks(state, sid, aId, bId))) return null; + const dist = Math.hypot(b.x - a.x, b.y - a.y); + // Rest length is the distance the strand is BORN at, clamped to the reachable + // band. Anything else means a level author's own geometry starts pre-stressed + // — a diagonal drawn across a 62px grid is 87px long, and forcing it to a + // 62px rest would rip the lattice apart the instant the sim starts. + const strand = { + id: state.nextStrandId++, + a: aId, + b: bId, + rest: restOverride != null + ? restOverride + : Math.max(TUNING.STRAND_MIN, Math.min(dist, TUNING.ATTACH_R)), + stiff: Math.min(gooType(a.type).stiff, gooType(b.type).stiff), + strength: Math.min(gooType(a.type).strength, gooType(b.type).strength), + lambda: 0, // XPBD multiplier, reset every substep + tension: 0, // instantaneous constraint force, derived from lambda + load: 0, // low-pass-filtered tension; what actually breaks strands + broken: false, + }; + state.strands.push(strand); + a.strands.push(strand.id); + b.strands.push(strand.id); + state.dirty = true; + return strand; +} + +function strandLinks(state, sid, aId, bId) { + const s = strandById(state, sid); + if (!s || s.broken) return false; + return (s.a === aId && s.b === bId) || (s.a === bId && s.b === aId); +} + +export function ballById(state, id) { + return state.balls[id] && state.balls[id].id === id + ? state.balls[id] + : state.balls.find((b) => b.id === id) || null; +} + +export function strandById(state, id) { + return state.strands[id] && state.strands[id].id === id + ? state.strands[id] + : state.strands.find((s) => s.id === id) || null; +} + +export function breakStrand(state, strand, events) { + if (strand.broken) return; + strand.broken = true; + state.dirty = true; + if (events) events.push({ type: 'strandBreak', id: strand.id, a: strand.a, b: strand.b }); +} + +export function liveStrands(state) { + return state.strands.filter((s) => !s.broken); +} + +// ── Structure ─────────────────────────────────────────────────────────────── +// Flood-fill the live strand graph. A component is `rooted` when it contains a +// pinned anchor or a ball resting on terrain. Adrift components are still +// simulated as coupled lattices -- a snapped-off chunk falls as a chunk. + +export function refreshStructure(state) { + const comp = new Map(); + let next = 0; + const adj = new Map(); + for (const b of state.balls) adj.set(b.id, []); + for (const s of state.strands) { + if (s.broken) continue; + if (adj.has(s.a)) adj.get(s.a).push(s.b); + if (adj.has(s.b)) adj.get(s.b).push(s.a); + } + for (const b of state.balls) { + if (comp.has(b.id) || !b.attached || b.dead) continue; + const id = next++; + const stack = [b.id]; + comp.set(b.id, id); + while (stack.length) { + const cur = stack.pop(); + for (const nb of adj.get(cur) || []) { + if (!comp.has(nb)) { comp.set(nb, id); stack.push(nb); } + } + } + } + const rooted = new Set(); + for (const b of state.balls) { + if (!comp.has(b.id)) continue; + if (b.pinned || b.grounded) rooted.add(comp.get(b.id)); + } + state.components = comp; + state.rootedComponents = rooted; + state.componentCount = next; + state.dirty = false; + return state; +} + +export function isRooted(state, ballId) { + if (state.dirty) refreshStructure(state); + const c = state.components.get(ballId); + return c != null && state.rootedComponents.has(c); +} + +// ── Simulation ────────────────────────────────────────────────────────────── + +export function stepSim(state, dt) { + const events = []; + state.acc += dt; + const sub = TUNING.SUBSTEP_DT; + const eps = sub * 1e-6; + let guard = 0; + while (state.acc >= sub - eps && guard < TUNING.MAX_SUBSTEPS) { + state.acc -= sub; + substep(state, sub, events); + guard += 1; + } + return events; +} + +export function substep(state, dt, events) { + updateMovers(state, dt); + integrate(state, dt); + for (const s of state.strands) s.lambda = 0; // XPBD accumulates per substep + buildGrid(state); + for (let i = 0; i < TUNING.RELAX_ITERS; i += 1) { + const last = i === TUNING.RELAX_ITERS - 1; + relaxStrands(state, dt); + resolveBallCollisions(state); + // Positional pushout runs every pass, but the velocity response (friction + // and restitution) and the lethal hazards run ONCE per substep. Applying + // friction on all eight passes compounds it to (1-f)^8 and glues + // everything to the terrain. + resolveTerrain(state, events, last); + } + const idt2 = 1 / (dt * dt); + const k = 1 - Math.exp(-dt / TUNING.LOAD_TAU); + for (const s of state.strands) { + s.tension = -s.lambda * idt2; + s.load += (s.tension - s.load) * k; + } + applyStress(state, events); + updateFuses(state, dt, events); + if (state.dirty) refreshStructure(state); + updateAgents(state, dt, events); + if (state.dirty) refreshStructure(state); + state.clock += dt; +} + +function integrate(state, dt) { + const dt2 = dt * dt; + const maxStep = TUNING.MAX_SPEED * dt; + for (const b of state.balls) { + if (b.dead || b.invMass === 0 || b.walking || b.held) continue; + const def = gooType(b.type); + const fan = state.fans.length ? fanAccel(state, b) : null; + const ax = fan ? fan.ax : 0; + const ay = TUNING.GRAVITY * def.buoyancy + (fan ? fan.ay : 0); + + let vx = (b.x - b.px) * TUNING.DRAG; + let vy = (b.y - b.py) * TUNING.DRAG; + // Clamp per-substep travel so a pathological configuration cannot launch a + // ball through the world in one frame. + const sp = Math.hypot(vx, vy); + if (sp > maxStep) { const k = maxStep / sp; vx *= k; vy *= k; } + + b.px = b.x; + b.py = b.y; + b.x += vx + ax * dt2; + b.y += vy + ay * dt2; + b.grounded = false; + } +} + +// XPBD (extended position-based dynamics) distance constraint. +// +// Plain PBD would make stiffness depend on RELAX_ITERS -- more iterations, a +// stiffer world -- so tuning iterations for performance would silently retune +// the physics. XPBD fixes stiffness at 1/COMPLIANCE regardless of iteration +// count, and the accumulated multiplier lambda IS the constraint force, which +// is what lets a strand know how hard it is being pulled. Without that, a +// near-rigid solver snaps every strand back to rest each substep and nothing +// ever breaks under load. +function relaxStrands(state, dt) { + const idt2 = 1 / (dt * dt); + const strands = state.strands; + for (let i = 0; i < strands.length; i += 1) { + const s = strands[i]; + if (s.broken) continue; + const a = state.balls[s.a]; + const b = state.balls[s.b]; + if (!a || !b || a.dead || b.dead) continue; + const wsum = a.invMass + b.invMass; + if (wsum === 0) continue; + + const dx = a.x - b.x; + const dy = a.y - b.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) continue; + // A strand crushed below SLACK_RATIO goes limp rather than strutting its + // endpoints apart -- goo strands pull, they do not push. + if (len < s.rest * TUNING.SLACK_RATIO) continue; + + const nx = dx / len; + const ny = dy / len; + const C = len - s.rest; + + const alpha = (TUNING.COMPLIANCE / s.stiff) * idt2; + const beta = TUNING.DAMPING * s.stiff; + const gamma = (alpha * beta) / dt; + + // Relative displacement over this substep, projected on the strand axis -- + // the damping term. + const rvx = (a.x - a.px) - (b.x - b.px); + const rvy = (a.y - a.py) - (b.y - b.py); + const rn = rvx * nx + rvy * ny; + + const dLambda = (-C - alpha * s.lambda - gamma * rn) / ((1 + gamma) * wsum + alpha); + s.lambda += dLambda; + + a.x += nx * dLambda * a.invMass; + a.y += ny * dLambda * a.invMass; + b.x -= nx * dLambda * b.invMass; + b.y -= ny * dLambda * b.invMass; + } +} + +// ── Movers: fans and gears ────────────────────────────────────────────────── +// +// Fans are force volumes: goo inside gets pushed, nothing collides. +// Gears are ROTATING SOLID TERRAIN. They are stored as ordinary terrain +// entries carrying a `gear` record, so the existing collision path handles +// them for free; all this has to do is spin the polygon and recompute bounds. +// Contact then drags goo along at the rim's surface speed. + +function buildGearPoly(gear) { + const { cx, cy, r, teeth, angle } = gear; + const poly = []; + const n = Math.max(3, teeth) * 2; + for (let i = 0; i < n; i += 1) { + const a = angle + (i / n) * Math.PI * 2; + const rad = i % 2 === 0 ? r : r * 0.74; // alternate tip / root + poly.push([cx + Math.cos(a) * rad, cy + Math.sin(a) * rad]); + } + return poly; +} + +function updateMovers(state, dt) { + for (const t of state.terrain) { + if (!t.gear) continue; + t.gear.angle += t.gear.omega * dt; + t.poly = buildGearPoly(t.gear); + t.bounds = polyBounds(t.poly); + } +} + +// Fan force, applied during integration alongside gravity. +function fanAccel(state, b) { + let ax = 0; + let ay = 0; + for (const f of state.fans) { + if (b.x < f.x || b.x > f.x + f.w || b.y < f.y || b.y > f.y + f.h) continue; + ax += f.dx * f.force; + ay += f.dy * f.force; + } + return { ax, ay }; +} + +// Uniform-grid broadphase, rebuilt once per substep. Positions move by well +// under a cell inside the relaxation loop, so one build per substep is enough. +function buildGrid(state) { + const cell = TUNING.GRID_CELL; + const grid = new Map(); + for (const b of state.balls) { + if (b.dead || b.walking || b.held) continue; + const key = `${Math.floor(b.x / cell)},${Math.floor(b.y / cell)}`; + let bucket = grid.get(key); + if (!bucket) { bucket = []; grid.set(key, bucket); } + bucket.push(b); + } + state.grid = grid; +} + +// Goo balls are solid: loose goo must PILE on the ground rather than collapse +// into a single point, and a tower must not swallow the ball you drop on it. +// Structural balls sit ~62px apart against a 28px contact diameter, so this +// almost never fires for attached goo -- it is mostly about the pile. +function resolveBallCollisions(state) { + const cell = TUNING.GRID_CELL; + const grid = state.grid; + if (!grid) return; + for (const a of state.balls) { + if (a.dead || a.walking || a.held) continue; + const cx = Math.floor(a.x / cell); + const cy = Math.floor(a.y / cell); + for (let ox = -1; ox <= 1; ox += 1) { + for (let oy = -1; oy <= 1; oy += 1) { + const bucket = grid.get(`${cx + ox},${cy + oy}`); + if (!bucket) continue; + for (const b of bucket) { + if (b.id <= a.id || b.dead || b.walking || b.held) continue; + const wsum = a.invMass + b.invMass; + if (wsum === 0) continue; + const dx = b.x - a.x; + const dy = b.y - a.y; + const minD = a.r + b.r; + const d2 = dx * dx + dy * dy; + if (d2 >= minD * minD) continue; + let d = Math.sqrt(d2); + let nx, ny; + if (d < 1e-9) { + // Exactly coincident: separate along a stable, arbitrary axis + // rather than dividing by zero. + nx = 0; ny = -1; d = 0; + } else { + nx = dx / d; ny = dy / d; + } + const push = (minD - d) * TUNING.BALL_PUSH; + const ka = a.invMass / wsum; + const kb = b.invMass / wsum; + a.x -= nx * push * ka; + a.y -= ny * push * ka; + b.x += nx * push * kb; + b.y += ny * push * kb; + } + } + } + } +} + +function resolveTerrain(state, events, finalPass = true) { + const checkHazards = finalPass; + const applyResponse = finalPass; + for (const b of state.balls) { + if (b.dead || b.invMass === 0 || b.walking || b.held) continue; + for (const t of state.terrain) { + const bb = t.bounds; + if (b.x + b.r < bb.minX || b.x - b.r > bb.maxX || + b.y + b.r < bb.minY || b.y - b.r > bb.maxY) continue; + + const inside = pointInPoly(b.x, b.y, t.poly); + const cp = closestPointOnPoly(b.x, b.y, t.poly); + const d = Math.sqrt(cp.d2); + if (!inside && d >= b.r) continue; + + if (t.kind === 'spike') { + if (checkHazards && !gooType(b.type).spikeProof) { + killBall(state, b, 'spike', events); + break; + } + continue; + } + if (t.kind === 'fire') { + if (checkHazards) { + // A bomb does not merely survive fire -- fire is how you set one off. + if (gooType(b.type).explodes) { ignite(state, b, events); continue; } + if (!gooType(b.type).spikeProof) { killBall(state, b, 'fire', events); break; } + } + continue; + } + if (t.kind !== 'solid') continue; + + let nx, ny; + if (d > 1e-9) { nx = (b.x - cp.x) / d; ny = (b.y - cp.y) / d; } + else { nx = 0; ny = -1; } + if (inside) { nx = -nx; ny = -ny; } + + b.x = cp.x + nx * b.r; + b.y = cp.y + ny * b.r; + if (!b.grounded) { b.grounded = true; state.dirty = true; } + + // Velocity response runs once per substep (see substep()): split into + // normal / tangential, bounce the normal part, shed the tangential part + // as friction. + if (applyResponse) { + let vx = b.x - b.px; + let vy = b.y - b.py; + const vn = vx * nx + vy * ny; + const tx = vx - vn * nx; + const ty = vy - vn * ny; + const nvn = vn < 0 ? -vn * TUNING.RESTITUTION : vn; + + // A spinning gear drags whatever touches it. Friction pulls the ball's + // tangential motion toward the RIM's surface velocity rather than + // toward zero -- that difference is the whole mechanic. + let stx = 0; + let sty = 0; + if (t.gear) { + const sdt = TUNING.SUBSTEP_DT; + const svx = -t.gear.omega * (cp.y - t.gear.cy) * sdt; + const svy = t.gear.omega * (cp.x - t.gear.cx) * sdt; + const svn = svx * nx + svy * ny; + stx = svx - svn * nx; + sty = svy - svn * ny; + } + vx = tx + (stx - tx) * TUNING.FRICTION + nx * nvn; + vy = ty + (sty - ty) * TUNING.FRICTION + ny * nvn; + b.px = b.x - vx; + b.py = b.y - vy; + } + } + + // Falling out of the world is fatal. + if (checkHazards && !b.dead && b.y - b.r > state.world.h + 200) killBall(state, b, 'fell', events); + } +} + +function applyStress(state, events) { + for (const s of state.strands) { + if (s.broken) continue; + const a = state.balls[s.a]; + const b = state.balls[s.b]; + if (!a || !b) continue; + if (a.dead || b.dead) { breakStrand(state, s, events); continue; } + const cap = TUNING.BREAK_FORCE * s.strength; + if (s.load > cap || s.tension > cap * TUNING.SHOCK_FACTOR) { + breakStrand(state, s, events); + continue; + } + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len > s.rest * TUNING.BREAK_RATIO) breakStrand(state, s, events); + } +} + +// ── Explosions ────────────────────────────────────────────────────────────── + +// Light the fuse. Detonation is deferred by FUSE_TIME so the player sees it +// coming and a chain reaction reads as a chain rather than one frame of noise. +export function ignite(state, ball, events) { + if (!ball || ball.dead || ball.fuse != null) return; + if (!gooType(ball.type).explodes) return; + ball.fuse = TUNING.FUSE_TIME; + if (events) events.push({ type: 'ignite', ball: ball.id }); +} + +export function detonate(state, ball, events) { + if (!ball || ball.exploded) return; + ball.exploded = true; + const bx = ball.x; + const by = ball.y; + if (events) events.push({ type: 'explode', ball: ball.id, x: bx, y: by }); + + // Close in: goo dies. Anchors are bolted to the world and survive. + for (const b of state.balls) { + if (b.dead || b === ball) continue; + const d = Math.hypot(b.x - bx, b.y - by); + if (d > TUNING.BLAST_KILL_R) continue; + if (b.type === 'anchor') continue; + if (gooType(b.type).explodes) ignite(state, b, events); // chain reaction + else killBall(state, b, 'blast', events); + } + + // Wider: strands shear. This is what makes a bomb collapse a structure + // instead of just punching a hole through it. + for (const s of state.strands) { + if (s.broken) continue; + const a = state.balls[s.a]; + const c = state.balls[s.b]; + if (!a || !c) continue; + const mx = (a.x + c.x) / 2; + const my = (a.y + c.y) / 2; + if (Math.hypot(mx - bx, my - by) <= TUNING.BLAST_R) breakStrand(state, s, events); + } + + // Terrain only goes if the author said it could. + const kept = []; + for (const t of state.terrain) { + const reaches = pointInPoly(bx, by, t.poly) + || Math.sqrt(closestPointOnPoly(bx, by, t.poly).d2) <= TUNING.BLAST_R; + if (t.destructible && reaches) { + if (events) events.push({ type: 'terrainDestroyed', kind: t.kind }); + continue; + } + kept.push(t); + } + state.terrain = kept; + + killBall(state, ball, 'blast', events); + state.dirty = true; +} + +function updateFuses(state, dt, events) { + for (const b of state.balls) { + if (b.dead || b.fuse == null || b.exploded) continue; + b.fuse -= dt; + if (b.fuse <= 0) detonate(state, b, events); + } +} + +export function killBall(state, ball, cause, events) { + if (ball.dead) return; + ball.dead = true; + ball.attached = false; + for (const sid of ball.strands) { + const s = strandById(state, sid); + if (s) breakStrand(state, s, events); + } + ball.strands = []; + state.dirty = true; + if (events) events.push({ type: 'ballDie', ball: ball.id, cause }); +} + +// ── Attachment ────────────────────────────────────────────────────────────── +// +// THE most important rule in the game. Rigidity is emergent from triangulation +// (there are no angular constraints), so what the player is allowed to connect +// to is the only thing standing between a proper truss and a floppy chain of +// slivers. Four gates, in order: +// +// reach within ATTACH_R, and no closer than STRAND_MIN +// line of sight the strand must not pass through solid terrain +// nearest-first take candidates by distance, up to the type's maxStrands +// MIN_ANGLE reject a candidate lying within MIN_ANGLE_DEG of a strand +// already chosen -- this is what forces triangles +// +// Below minStrands the placement is refused outright and the ball goes back to +// the pile. + +export function insideSolid(state, x, y) { + for (const t of state.terrain) { + if (t.kind !== 'solid') continue; + const bb = t.bounds; + if (x < bb.minX || x > bb.maxX || y < bb.minY || y > bb.maxY) continue; + if (pointInPoly(x, y, t.poly)) return true; + } + return false; +} + +export function chooseAttachments(state, x, y, type = 'common') { + const def = gooType(type); + if (def.maxStrands <= 0) return []; + + const cands = []; + for (const b of state.balls) { + if (!b.attached || b.dead || b.walking || b.held) continue; + const dx = b.x - x; + const dy = b.y - y; + const d = Math.hypot(dx, dy); + if (d > TUNING.ATTACH_R || d < TUNING.STRAND_MIN) continue; + if (lineBlocked(state, x, y, b.x, b.y)) continue; + cands.push({ id: b.id, d, ang: Math.atan2(dy, dx) }); + } + // Ties broken by id so the choice is deterministic -- the generator and the + // verifier replay recorded builds and must get the same structure back. + cands.sort((p, q) => (p.d - q.d) || (p.id - q.id)); + + const minAng = (TUNING.MIN_ANGLE_DEG * Math.PI) / 180; + const chosen = []; + for (const c of cands) { + if (chosen.length >= def.maxStrands) break; + let ok = true; + for (const prev of chosen) { + let da = Math.abs(c.ang - prev.ang); + if (da > Math.PI) da = Math.PI * 2 - da; + if (da < minAng) { ok = false; break; } + } + if (ok) chosen.push(c); + } + return chosen.map((c) => c.id); +} + +// How close a point is to any solid surface. Terrain-sticking goo bonds when +// this is within STICK_DIST of its rim. +export function distanceToSolid(state, x, y) { + let best = Infinity; + for (const t of state.terrain) { + if (t.kind !== 'solid') continue; + const d = Math.sqrt(closestPointOnPoly(x, y, t.poly).d2); + if (d < best) best = d; + } + return best; +} + +export function canStickToTerrain(state, x, y, type) { + if (!gooType(type).sticksToTerrain) return false; + return distanceToSolid(state, x, y) <= gooType(type).r + TUNING.STICK_DIST; +} + +// Terrain-sticking goo bonded to a surface needs no strands at all: the +// surface IS its anchor. That is the whole point of anchor and pokey goo -- +// they let a structure start from a wall instead of only from the ground. +export function requiredStrands(state, x, y, type) { + if (canStickToTerrain(state, x, y, type)) return 0; + return gooType(type).minStrands; +} + +// Would goo of this type dropped here die on contact? Spikes and fire are +// lethal terrain, and a placement that lands on them is legal but suicidal -- +// the reference player must not walk into it, and the drag preview warns. +export function hazardAt(state, x, y, type = 'common') { + const def = gooType(type); + const r = def.r + 2; + for (const t of state.terrain) { + if (t.kind !== 'spike' && t.kind !== 'fire') continue; + const bb = t.bounds; + if (x + r < bb.minX || x - r > bb.maxX || y + r < bb.minY || y - r > bb.maxY) continue; + if (!pointInPoly(x, y, t.poly) && Math.sqrt(closestPointOnPoly(x, y, t.poly).d2) > r) continue; + if (t.kind === 'spike' && def.spikeProof) continue; + if (t.kind === 'fire' && def.explodes) return 'fire'; // lights it, not lethal per se + if (t.kind === 'fire' && def.spikeProof) continue; + return t.kind; + } + return null; +} + +export function canPlace(state, x, y, type = 'common') { + if (insideSolid(state, x, y)) return false; + if (x < 0 || y < 0 || x > state.world.w || y > state.world.h) return false; + const need = requiredStrands(state, x, y, type); + if (need === 0) { + // An anchor still has to have something to anchor TO. + if (gooType(type).sticksToTerrain) return canStickToTerrain(state, x, y, type); + return true; + } + return chooseAttachments(state, x, y, type).length >= need; +} + +export function placeBall(state, ball, x, y, events = null) { + if (!ball || ball.dead) return false; + const targets = chooseAttachments(state, x, y, ball.type); + const need = requiredStrands(state, x, y, ball.type); + const stuck = canStickToTerrain(state, x, y, ball.type); + if (insideSolid(state, x, y) || targets.length < need || (need === 0 && !stuck && gooType(ball.type).sticksToTerrain)) { + ball.held = false; + return false; + } + ball.x = x; ball.y = y; ball.px = x; ball.py = y; + ball.held = false; + ball.walking = false; + ball.onStrand = -1; + ball.attached = true; + // Bonded to terrain: it becomes immovable, which is what makes it an anchor + // point the rest of the structure can hang from. + if (stuck) { + ball.pinned = true; + ball.invMass = 0; + if (events) events.push({ type: 'stick', ball: ball.id }); + } + const i = state.pile.indexOf(ball.id); + if (i >= 0) state.pile.splice(i, 1); + for (const t of targets) addStrand(state, ball.id, t); + state.dirty = true; + if (events) events.push({ type: 'place', ball: ball.id, strands: targets.length }); + return true; +} + +// The nearest ball the player is allowed to pick up: anything loose, plus +// attached goo of a detachable type. +export function pickBallAt(state, x, y, reach = 42) { + let best = null; + let bestD = Infinity; + for (const b of state.balls) { + if (b.dead) continue; + if (b.attached && !gooType(b.type).detachable) continue; + const d = Math.hypot(b.x - x, b.y - y); + if (d > reach + b.r || d >= bestD) continue; + bestD = d; + best = b; + } + return best; +} + +export function beginDrag(state, ball, events = null) { + if (!ball || ball.dead) return false; + if (ball.attached) { + if (!gooType(ball.type).detachable) return false; + for (const sid of ball.strands.slice()) { + const s = strandById(state, sid); + if (s) breakStrand(state, s, events); + } + ball.strands = []; + ball.attached = false; + if (!state.pile.includes(ball.id)) state.pile.push(ball.id); + } + ball.held = true; + ball.walking = false; + ball.onStrand = -1; + state.dirty = true; + if (events) events.push({ type: 'pickup', ball: ball.id }); + return true; +} + +export function dragTo(state, ball, x, y) { + if (!ball || !ball.held) return; + ball.x = x; ball.y = y; + ball.px = x; ball.py = y; // dropped goo starts from rest, never flung +} + +// Returns true when the ball stuck to the structure; false means it was +// released into the world as loose goo again. +export function endDrag(state, ball, x, y, events = null) { + if (!ball) return false; + if (placeBall(state, ball, x, y, events)) return true; + ball.held = false; + ball.x = x; ball.y = y; ball.px = x; ball.py = y; + if (!ball.attached && !state.pile.includes(ball.id)) state.pile.push(ball.id); + if (events) events.push({ type: 'placeFailed', ball: ball.id }); + return false; +} + +// ── The pipe ──────────────────────────────────────────────────────────────── + +function updatePipe(state, events) { + const p = state.pipe; + if (!p) return; + const was = p.open; + p.open = false; + for (const b of state.balls) { + if (!b.attached || b.dead) continue; + if (Math.hypot(b.x - p.x, b.y - p.y) <= p.r + b.r) { p.open = true; break; } + } + if (p.open !== was) { + state.pipeDistDirty = true; + if (events) events.push({ type: p.open ? 'pipeOpen' : 'pipeClose' }); + } +} + +// Hop count from the pipe, over the live strand graph. Crawlers walk downhill +// on this; without it they wander and the level never ends. +function refreshPipeDist(state) { + state.pipeDistDirty = false; + const dist = new Map(); + const p = state.pipe; + if (!p || !p.open) { state.pipeDist = dist; return; } + + const adj = new Map(); + for (const s of state.strands) { + if (s.broken) continue; + if (!adj.has(s.a)) adj.set(s.a, []); + if (!adj.has(s.b)) adj.set(s.b, []); + adj.get(s.a).push(s.b); + adj.get(s.b).push(s.a); + } + const queue = []; + for (const b of state.balls) { + if (!b.attached || b.dead) continue; + if (Math.hypot(b.x - p.x, b.y - p.y) <= p.r + b.r) { dist.set(b.id, 0); queue.push(b.id); } + } + for (let i = 0; i < queue.length; i += 1) { + const cur = queue[i]; + const d = dist.get(cur); + for (const nb of adj.get(cur) || []) { + if (!dist.has(nb)) { dist.set(nb, d + 1); queue.push(nb); } + } + } + state.pipeDist = dist; +} + +function collectBall(state, ball, events) { + ball.collected = true; + ball.dead = true; + ball.walking = false; + ball.held = false; + const i = state.pile.indexOf(ball.id); + if (i >= 0) state.pile.splice(i, 1); + state.collected += 1; + state.dirty = true; + if (events) events.push({ type: 'collect', ball: ball.id, total: state.collected }); +} + +// ── Crawling goo ──────────────────────────────────────────────────────────── +// +// Loose goo that touches the structure climbs onto it and crawls along the +// strands. A crawler rides a strand as a parameter t in [0,1]; at a node it +// picks the next strand -- downhill toward the pipe when the pipe is open, +// otherwise a seeded wander that prefers not to double straight back. + +function dropWalker(state, ball) { + ball.walking = false; + ball.onStrand = -1; + ball.px = ball.x; + ball.py = ball.y; +} + +function strandEndpoints(state, s) { + return [state.balls[s.a], state.balls[s.b]]; +} + +function recruitWalkers(state, events) { + const cell = TUNING.GRID_CELL; + const grid = state.grid; + if (!grid) return; + for (const b of state.balls) { + if (b.dead || b.attached || b.walking || b.held) continue; + const cx = Math.floor(b.x / cell); + const cy = Math.floor(b.y / cell); + let target = null; + let bestD = Infinity; + for (let ox = -1; ox <= 1 && !target; ox += 1) { + for (let oy = -1; oy <= 1; oy += 1) { + for (const other of grid.get(`${cx + ox},${cy + oy}`) || []) { + if (!other.attached || other.dead) continue; + const d = Math.hypot(other.x - b.x, other.y - b.y); + if (d > b.r + other.r + 4 || d >= bestD) continue; + const live = other.strands.map((id) => strandById(state, id)).filter((s) => s && !s.broken); + if (!live.length) continue; + bestD = d; + target = { node: other, strand: live[0] }; + } + } + } + if (!target) continue; + b.walking = true; + b.onStrand = target.strand.id; + b.dir = target.strand.a === target.node.id ? 1 : -1; + b.t = target.strand.a === target.node.id ? 0 : 1; + if (events) events.push({ type: 'climb', ball: b.id }); + } +} + +function advanceWalkers(state, dt, events) { + const p = state.pipe; + for (const b of state.balls) { + if (!b.walking || b.dead) continue; + let s = strandById(state, b.onStrand); + if (!s || s.broken) { dropWalker(state, b); continue; } + + let [a, c] = strandEndpoints(state, s); + if (!a || !c || a.dead || c.dead) { dropWalker(state, b); continue; } + + const len = Math.hypot(c.x - a.x, c.y - a.y) || 1; + b.t += ((TUNING.CRAWL_SPEED * dt) / len) * b.dir; + + if (b.t < 0 || b.t > 1) { + const nodeId = b.t < 0 ? s.a : s.b; + const node = state.balls[nodeId]; + if (!node || node.dead) { dropWalker(state, b); continue; } + + // Arrived at the pipe? + if (p && p.open && Math.hypot(node.x - p.x, node.y - p.y) <= p.r + node.r) { + b.x = node.x; b.y = node.y; + collectBall(state, b, events); + continue; + } + + const opts = node.strands + .map((id) => strandById(state, id)) + .filter((o) => o && !o.broken); + if (!opts.length) { dropWalker(state, b); continue; } + + let next = null; + if (p && p.open && state.pipeDist) { + const here = state.pipeDist.get(nodeId); + let bestD = here == null ? Infinity : here; + for (const o of opts) { + const far = o.a === nodeId ? o.b : o.a; + const d = state.pipeDist.get(far); + if (d != null && d < bestD) { bestD = d; next = o; } + } + } + if (!next) { + // Wander, but prefer not to double straight back the way we came. + const fwd = opts.filter((o) => o.id !== s.id); + const pool = fwd.length ? fwd : opts; + next = pool[Math.floor(state.rng() * pool.length) % pool.length]; + } + + s = next; + b.onStrand = s.id; + b.dir = s.a === nodeId ? 1 : -1; + b.t = s.a === nodeId ? 0 : 1; + [a, c] = strandEndpoints(state, s); + if (!a || !c) { dropWalker(state, b); continue; } + } + + b.x = a.x + (c.x - a.x) * b.t; + b.y = a.y + (c.y - a.y) * b.t; + b.px = b.x; + b.py = b.y; + } +} + +// Loose goo resting on terrain walks toward the nearest part of the structure. +// Re-targeting is throttled to WANDER_HZ because it is O(loose x attached) and +// would otherwise run 240 times a second. +function wanderLoose(state, dt) { + state.wanderClock += dt; + const period = 1 / TUNING.WANDER_HZ; + const retarget = state.wanderClock >= period; + if (retarget) state.wanderClock -= period; + + for (const b of state.balls) { + if (b.dead || b.attached || b.walking || b.held) continue; + if (retarget) { + b.wanderDir = 0; + let bestD = Infinity; + let best = null; + for (const a of state.balls) { + if (!a.attached || a.dead) continue; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < bestD) { bestD = d; best = a; } + } + // Close enough to climb (recruitWalkers takes over), or nothing to aim at. + if (best && bestD > b.r + best.r + 8) { + b.wanderDir = Math.sign(best.x - b.x); + } + } + if (!b.grounded || !b.wanderDir) continue; + const vy = b.y - b.py; + b.px = b.x - b.wanderDir * TUNING.WANDER_SPEED * dt; + b.py = b.y - vy; + } +} + +function updateAgents(state, dt, events) { + if (state.agentsOff) return; + updatePipe(state, events); + if (state.pipeDistDirty || state.dirty) refreshPipeDist(state); + wanderLoose(state, dt); + recruitWalkers(state, events); + advanceWalkers(state, dt, events); +} + +// ── Win conditions ────────────────────────────────────────────────────────── + +export function isWon(state) { + return state.collected >= state.required; +} + +export function hasOCD(state) { + return state.collected >= state.ocdTarget; +} + +// Balls the player could still get into the pipe: loose goo plus crawlers. +export function availableGoo(state) { + let n = 0; + for (const b of state.balls) { + if (b.dead || b.attached) continue; + n += 1; + } + return n; +} + +// ── Introspection helpers (used heavily by the verifier) ──────────────────── + +// 0 = slack, 1 = about to snap. The renderer reddens a strand by this. +export function strandStress(strand) { + const cap = TUNING.BREAK_FORCE * strand.strength; + if (cap <= 0) return 0; + const v = strand.load / cap; + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +export function strandLength(state, strand) { + const a = state.balls[strand.a]; + const b = state.balls[strand.b]; + return Math.hypot(b.x - a.x, b.y - a.y); +} + +export function ballSpeed(state, ball) { + return Math.hypot(ball.x - ball.px, ball.y - ball.py) / TUNING.SUBSTEP_DT; +} + +export function isSettled(state) { + for (const b of state.balls) { + if (b.dead || b.invMass === 0) continue; + if (ballSpeed(state, b) > TUNING.SETTLE_SPEED) return false; + } + return true; +} + +// Run until the structure is genuinely quiet. +// +// isSettled() is instantaneous, and an oscillating structure passes through +// zero speed at every turning point of its swing -- so a naive +// "step until isSettled" stops at the moment of MAXIMUM stretch and reports +// the peak of a decaying oscillation as if it were the static equilibrium. +// Require the state to stay quiet for a sustained window instead. +export function settle(state, maxSeconds = 8, quietSeconds = 0.25) { + const step = 1 / 60; + let t = 0; + let quiet = 0; + while (t < maxSeconds) { + stepSim(state, step); + t += step; + quiet = isSettled(state) ? quiet + step : 0; + if (quiet >= quietSeconds) break; + } + return t; +} + +// Settle with the agents switched off: no wandering, no crawling, no pipe. +// +// This is what "put the level at rest" means, and it is what the editor's +// Settle button must use before exporting. Settling with agents ON does +// something quite different and quite wrong: loose goo walks to the structure +// and climbs it, so the positions you capture have the heap baked INSIDE the +// tower, and reloading that level explodes as the collision solver ejects it. +export function settlePhysics(state, maxSeconds = 12) { + const was = state.agentsOff; + state.agentsOff = true; + const t = settle(state, maxSeconds); + state.agentsOff = was; + return t; +} + +export function cloneState(state) { + const copy = { + ...state, + terrain: state.terrain.map((t) => ({ + kind: t.kind, + poly: t.poly.map((p) => [p[0], p[1]]), + bounds: { ...t.bounds }, + destructible: t.destructible, + gear: t.gear ? { ...t.gear } : undefined, + })), + fans: state.fans.map((f) => ({ ...f })), + balls: state.balls.map((b) => ({ ...b, strands: b.strands.slice() })), + strands: state.strands.map((s) => ({ ...s })), + pipe: state.pipe ? { ...state.pipe } : null, + pile: state.pile.slice(), + components: null, + rootedComponents: null, + dirty: true, + pipeDist: new Map(), + pipeDistDirty: true, + grid: null, + rng: mulberry32(state.seed), + }; + refreshStructure(copy); + return copy; +} + +// Bit-exact fingerprint of every simulated position, for determinism tests. +export function hashState(state) { + const buf = new ArrayBuffer(8); + const f64 = new Float64Array(buf); + const u32 = new Uint32Array(buf); + let h = 0x811c9dc5; + const mix = (v) => { + f64[0] = v; + h ^= u32[0]; h = Math.imul(h, 0x01000193); + h ^= u32[1]; h = Math.imul(h, 0x01000193); + }; + for (const b of state.balls) { + mix(b.x); mix(b.y); mix(b.px); mix(b.py); + mix(b.dead ? 1 : 0); + mix(b.attached ? 1 : 0); + mix(b.walking ? b.onStrand + b.t : -1); + } + for (const s of state.strands) mix(s.broken ? 1 : 0); + mix(state.collected); + return h >>> 0; +} diff --git a/src/games/gootower/tutorial.md b/src/games/gootower/tutorial.md new file mode 100644 index 0000000..be4fdbd --- /dev/null +++ b/src/games/gootower/tutorial.md @@ -0,0 +1,68 @@ +# Goo Tower + +Build a structure out of goo balls, reach the pipe, and get enough goo into it. + +## Building + +**Drag a goo ball** from the heap and drop it near your structure. It grabs the nearest +balls it can reach and hangs on by stretchy strands. + +A ball needs **two** friends within reach to hold on — a single strand isn't enough. While +you're dragging, green lines show exactly which strands would form. Red means the drop +won't take. + +Two balls **almost in line** with each other don't count as separate anchors. That's +deliberate: it's what forces you to build **triangles**, and triangles are the only reason +a structure stands up at all. + +## Physics is doing the work + +Nothing here is on rails. Strands stretch under load and **redden as they strain** — when +one is glowing, it's about to go. Towers sway. Long unbraced arms sag and tear off at the +root. + +- **Compression is cheap, tension is expensive.** A tower standing on the ground mostly + pushes down and holds fine. The same structure stuck out sideways pulls its own root + apart. +- **Brace every level.** An unbraced column will lean, and once it leans it keeps leaning. +- **Weight is cumulative.** The strands at the bottom carry everything above them. + +## The kinds of goo + +| Goo | What it does | +|---|---| +| **Grey** | The standard. Two strands, permanent once placed. | +| **Green (ivy)** | Can be pulled back off and used again. | +| **Pink (balloon)** | Rises. One strand. Worth about one goo of lift — tie them to sagging spans. | +| **Red (bomb)** | Detonates in fire. Kills goo close in, shears strands wider out, and opens **cross-hatched rock**. | +| **Brown (block)** | Much stiffer. Holds a span rigid where grey goo would droop. | +| **Amber (pokey)** and **anchor** | Grip bare rock. They need **no** friends at all — place one against a wall and build out from there. | +| **Blue (bit)** | Small, light, cheap, and weak. One strand. | +| **Pale (skull)** | Walks on spikes unharmed. | + +Goo with a **gold collar** is pinned and will never move. + +## Hazards + +- **Spikes** and **fire** kill on contact. While dragging, an orange ring and a + warning label mean the drop is *legal but fatal*. +- **Fans** blow. Inside the wind everything weighs less, so spans reach further. +- **Cogs** are solid and turning. Whatever touches the rim gets dragged along with it. +- Anything that falls off the world is gone. + +## The pipe + +The pipe opens once any part of your structure touches it. Loose goo then **crawls up the +strands by itself** and gets drunk. You don't have to carry it there. + +You can pick loose goo back off the structure at any time, and **green ivy goo** can be +pulled off after it's been placed and used somewhere else. Plain grey goo is permanent +once it's down. + +## Finishing + +- **Collect** the required number of goo to complete the level. +- **OCD** is a separate, higher target for collecting far more than you needed. Reaching + the required count doesn't end the level — hit *Keep Playing* and go for it. + +Press **R** to retry, **Esc** for the level list. diff --git a/src/main.js b/src/main.js index a07adaa..268f9be 100644 --- a/src/main.js +++ b/src/main.js @@ -99,6 +99,8 @@ import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js'; import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js'; import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js'; import BloxorzGame from './games/bloxorz/BloxorzGame.js'; +import GooTowerGame from './games/gootower/GooTowerGame.js'; +import GooTowerEditor from './games/gootower/GooTowerEditor.js'; const config = { type: Phaser.AUTO, @@ -211,6 +213,8 @@ const config = { TetrisAttackGame, TotalAnnihilationGame, BloxorzGame, + GooTowerGame, + GooTowerEditor, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index cba8f79..3324e42 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 40131d7..ff88159 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -53,6 +53,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('rushhour', 'data/rushhour.json'); this.load.json('bloxorz', 'data/bloxorz.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); + this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json'); this.load.json('shift-artwork', 'data/shift-artwork.json'); this.load.json('slots-artwork', 'data/slots-artwork.json'); this.load.json('blockfighter', 'data/blockfighter.json'); @@ -227,6 +228,13 @@ export default class PreloadScene extends Phaser.Scene { return; } + // ...and the Goo Tower level editor: ?gootower-editor=1. + if (params.has('gootower-editor')) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + this.scene.start('GooTowerEditor'); + return; + } + // Deep link: index.html?game= jumps straight to the same place a // main-menu click would (GameMenuScene.openGame's branching logic). const deepLinkGame = getGame(params.get('game')); diff --git a/tools/genGooTower.js b/tools/genGooTower.js new file mode 100644 index 0000000..43fe3d2 --- /dev/null +++ b/tools/genGooTower.js @@ -0,0 +1,304 @@ +// Goo Tower — level bank writer. +// +// Wave 1: the six hand-designed teaching levels below are authored here and +// emitted verbatim. They are HAND-TUNED, not generated -- the script exists so +// the repetitive parts (goo piles, spike strips, the manifest) are constructed +// rather than typed, and so the bank is byte-reproducible. +// +// Wave 4 will add generated drill/sandbox tiers alongside these; when it does, +// levels 1-6 must be carried over untouched, exactly as genPeggleLevels.js +// freezes Peggle's hand-tuned 1-25. +// +// Usage: node tools/genGooTower.js [outDir] + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = process.argv[2] || join(__dirname, '..', 'assets', 'gamedata', 'gootower'); + +const W = 1600; +const H = 1000; +const GROUND = 850; + +const rect = (x1, y1, x2, y2, kind = 'solid') => ({ kind, poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] }); +const ground = (top = GROUND) => rect(0, top, W, H); + +// A loose heap of goo resting on a surface. Laid out in rows so the balls start +// apart -- dumping them on one spot just makes the collision solver do work. +function pile(type, n, x, surfaceY, { perRow = 8, gap = 30 } = {}) { + const out = []; + for (let i = 0; i < n; i += 1) { + const row = Math.floor(i / perRow); + const col = i % perRow; + out.push({ x: x + col * gap, y: surfaceY - 16 - row * 30, type }); + } + return out; +} + +// A small planted tripod: two pinned feet and a free apex, already triangulated +// so the player has something legal to attach to on frame one. +function footing(x, y, type = 'common') { + return { + balls: [ + { x: x - 31, y, type, pinned: true }, + { x: x + 31, y, type, pinned: true }, + { x, y: y - 54, type }, + ], + strands: [[2, 0], [2, 1], [0, 1]], + }; +} + +const LEVELS = []; + +// ── 1 ─────────────────────────────────────────────────────────────────────── +{ + const f = footing(771, GROUND - 14); + LEVELS.push({ + level: 1, name: 'Ground Floor', chapter: 1, + tip: 'Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.', + terrain: [ground()], + ...f, + pipe: { x: 771, y: 600, r: 46 }, + pile: pile('common', 16, 980, GROUND), + required: 4, ocdTarget: 9, + }); +} + +// ── 2 ─────────────────────────────────────────────────────────────────────── +{ + const f = footing(430, 686); + LEVELS.push({ + level: 2, name: 'Mind the Gap', chapter: 1, + tip: 'Spikes below. Build ACROSS — triangles carry weight, long chains do not.', + terrain: [ + rect(0, 700, 600, H), + rect(860, 700, W, H), + rect(600, 930, 860, H, 'spike'), + // Lip on the outer rim. Without it the heap spreads left, rolls off the + // edge of the world and the level quietly loses goo before the player + // has touched anything. + rect(0, 620, 40, 700), + ], + ...f, + pipe: { x: 980, y: 640, r: 46 }, + // perRow 7 keeps the heap clear of the footing at x=430; at 10 the last + // column landed 9px from a foot and the two were ejected apart on frame one. + pile: pile('common', 20, 150, 700, { perRow: 7 }), + required: 5, ocdTarget: 12, + }); +} + +// ── 3 ─────────────────────────────────────────────────────────────────────── +{ + // The footing sits UNDER the overhang, so the only way up is to walk the + // structure out sideways first. Keep the run short -- this is level 3, and + // an earlier draft asked for a 677px diagonal that nothing could build. + const f = footing(560, GROUND - 14); + LEVELS.push({ + level: 3, name: 'Headroom', chapter: 1, + tip: 'You cannot build through the slab. Come out from under it first.', + terrain: [ground(), rect(300, 540, 820, 610)], + ...f, + pipe: { x: 980, y: 470, r: 46 }, + pile: pile('common', 24, 1020, GROUND, { perRow: 8 }), + required: 5, ocdTarget: 12, + }); +} + +// ── 4 ─────────────────────────────────────────────────────────────────────── +{ + const f = footing(331, GROUND - 14, 'common'); + LEVELS.push({ + level: 4, name: 'Second Thoughts', chapter: 1, + tip: 'Green ivy goo can be pulled back off and used again. Grey goo is there for good.', + terrain: [ground()], + ...f, + // The heap must comfortably outlast the climb. An earlier draft gave + // exactly enough goo to REACH the pipe and nothing to feed it, so the + // level opened the pipe and was then unwinnable. + pipe: { x: 660, y: 560, r: 46 }, + pile: [ + ...pile('ivy', 12, 540, GROUND, { perRow: 6 }), + ...pile('common', 8, 860, GROUND, { perRow: 8 }), + ], + required: 4, ocdTarget: 9, + }); +} + +// ── 5 ─────────────────────────────────────────────────────────────────────── +{ + const f = footing(791, GROUND - 14); + LEVELS.push({ + level: 5, name: 'Overhead', chapter: 1, + tip: 'The ceiling bites. Stop short of it.', + terrain: [ground(), rect(400, 180, 1200, 240, 'spike')], + ...f, + pipe: { x: 791, y: 340, r: 46 }, + pile: pile('common', 18, 1000, GROUND, { perRow: 9 }), + required: 5, ocdTarget: 11, + }); +} + +// ── 6 ─────────────────────────────────────────────────────────────────────── +{ + const f = footing(791, GROUND - 14); + LEVELS.push({ + level: 6, name: 'Tall Order', chapter: 1, + tip: 'A tower stands on compression and falls on sway. Brace every level.', + terrain: [ground()], + ...f, + pipe: { x: 791, y: 180, r: 46 }, + pile: pile('common', 26, 1020, GROUND, { perRow: 9 }), + required: 6, ocdTarget: 14, + }); +} + +// ── Sandbox tier (7-12) ───────────────────────────────────────────────────── +// One level per Wave 3 mechanic, so each can be played and judged on its own. +// This is THROWAWAY content, explicitly not the final curriculum -- Wave 4 +// replaces it with a designed 5-chapter ramp. + +// ── 7 · anchors ───────────────────────────────────────────────────────────── +{ + const f = footing(780, GROUND - 14); + LEVELS.push({ + level: 7, name: 'Anchors Aweigh', chapter: 2, sandbox: true, + tip: 'Brown anchor goo sticks straight to rock and needs no friends. Start a span from a wall.', + // Pillars must not stand BETWEEN the heap and the structure: loose goo + // walks along the ground, and a 550px wall is a wall. + terrain: [ground(), rect(400, 300, 460, GROUND), rect(1120, 300, 1180, GROUND)], + ...f, + pipe: { x: 780, y: 400, r: 46 }, + pile: [ + ...pile('anchor', 6, 860, GROUND, { perRow: 6 }), + ...pile('common', 20, 860, GROUND - 30, { perRow: 6 }), + ], + required: 5, ocdTarget: 12, + }); +} + +// ── 8 · pokey (wall starts) ───────────────────────────────────────────────── +{ + LEVELS.push({ + level: 8, name: 'Sticking Point', chapter: 2, sandbox: true, + tip: 'Pokey goo grips rock. There is no ground here worth building from.', + terrain: [ground(), rect(240, 200, 320, 760), rect(1120, 200, 1200, 760)], + balls: [ + { x: 340, y: 560, type: 'pokey', pinned: true }, + { x: 340, y: 498, type: 'pokey', pinned: true }, + { x: 398, y: 530, type: 'common' }, + ], + strands: [[2, 0], [2, 1], [0, 1]], + pipe: { x: 1080, y: 430, r: 46 }, + pile: [ + ...pile('pokey', 6, 620, GROUND, { perRow: 6 }), + ...pile('common', 22, 620, GROUND - 30, { perRow: 8 }), + ], + required: 5, ocdTarget: 12, + }); +} + +// ── 9 · bombs, fire and destructible rock ─────────────────────────────────── +{ + const f = footing(400, GROUND - 14); + LEVELS.push({ + level: 9, name: 'Demolition', chapter: 2, sandbox: true, + tip: 'Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.', + terrain: [ + ground(), + // Soft rock in the way -- blow it, or climb over. The fire sits BESIDE + // the rock at ground level, not on the climbing route: an earlier draft + // had it capping the rock, which made the bomb the only survivable way + // through and left the level unwinnable by ordinary building. + { ...rect(700, 660, 780, GROUND), destructible: true }, + rect(600, 806, 680, GROUND, 'fire'), + ], + ...f, + pipe: { x: 980, y: 700, r: 46 }, + // Greedy will not climb a wall unprompted -- that means building away from + // the pipe first -- so route it over the top. + waypoints: [{ x: 740, y: 590 }], + pile: [ + ...pile('bomb', 4, 160, GROUND, { perRow: 4 }), + ...pile('common', 24, 160, GROUND - 30, { perRow: 7 }), + ], + required: 5, ocdTarget: 12, + }); +} + +// ── 10 · fans ─────────────────────────────────────────────────────────────── +{ + const f = footing(360, 686); + LEVELS.push({ + level: 10, name: 'Updraft', chapter: 2, sandbox: true, + tip: 'The updraft takes weight off everything inside it. Spans reach further in the wind.', + terrain: [ + rect(0, 700, 600, H), + rect(940, 700, W, H), + rect(600, 930, 940, H, 'spike'), + rect(0, 620, 40, 700), + ], + fans: [{ x: 600, y: 300, w: 340, h: 630, dx: 0, dy: -1, force: 1150 }], + ...f, + pipe: { x: 1060, y: 630, r: 46 }, + pile: pile('common', 26, 120, 700, { perRow: 6 }), + required: 4, ocdTarget: 12, + }); +} + +// ── 11 · gears ────────────────────────────────────────────────────────────── +{ + const f = footing(300, GROUND - 14); + LEVELS.push({ + level: 11, name: 'Cogs', chapter: 2, sandbox: true, + tip: 'The cog is solid and it is turning. Anything it touches gets dragged along.', + terrain: [ground()], + gears: [{ x: 800, y: 470, r: 110, teeth: 9, omega: 1.4 }], + ...f, + pipe: { x: 1240, y: 560, r: 46 }, + pile: pile('common', 28, 1000, GROUND, { perRow: 9 }), + required: 5, ocdTarget: 13, + }); +} + +// ── 12 · balloons, blocks and bits ────────────────────────────────────────── +{ + const f = footing(400, GROUND - 14); + LEVELS.push({ + level: 12, name: 'Lighter Than Air', chapter: 2, sandbox: true, + tip: 'Balloons pull up, blocks hold rigid, bits are cheap and weak. Mix them.', + terrain: [ground()], + ...f, + pipe: { x: 400, y: 300, r: 46 }, + pile: [ + ...pile('balloon', 6, 700, GROUND, { perRow: 6 }), + ...pile('block', 8, 700, GROUND - 34, { perRow: 8 }), + ...pile('bit', 6, 700, GROUND - 68, { perRow: 6 }), + ...pile('common', 18, 700, GROUND - 100, { perRow: 9 }), + ], + required: 5, ocdTarget: 12, + }); +} + +mkdirSync(outDir, { recursive: true }); + +const manifest = { version: 1, generatedAt: new Date(0).toISOString(), levels: [] }; +for (const lv of LEVELS) { + const file = `level-${String(lv.level).padStart(3, '0')}.json`; + const body = { world: { w: W, h: H }, ...lv }; + writeFileSync(join(outDir, file), `${JSON.stringify(body, null, 2)}\n`); + manifest.levels.push({ + level: lv.level, name: lv.name, chapter: lv.chapter, file, + required: lv.required, ocdTarget: lv.ocdTarget, + sandbox: lv.sandbox || undefined, + }); +} +manifest.chapters = [ + { id: 1, name: 'The Goo Filled Hills', blurb: 'Learn to build.', from: 1, to: 6 }, + { id: 2, name: 'Sandbox', blurb: 'One level per mechanic — throwaway, replaced in Wave 4.', from: 7, to: LEVELS.length }, +]; +writeFileSync(join(outDir, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`[gen] wrote ${LEVELS.length} levels + manifest to ${outDir}`); diff --git a/tools/verifyGooTower.js b/tools/verifyGooTower.js new file mode 100644 index 0000000..b689149 --- /dev/null +++ b/tools/verifyGooTower.js @@ -0,0 +1,1173 @@ +// Goo Tower — regression harness. Run after every change to the sim. +// +// The physics is the game. A subtly wrong solver produces a structure that +// looks plausible and is unbuildable, and there is no way to tell by reading +// the code — so everything the sim promises gets asserted here. +// +// 1. Geometry primitives (point-in-poly, closest point, segment crossing, +// line-of-sight blocking) +// 2. Solver invariants (chain hangs at rest length, truss beats chain, +// strands snap past BREAK_RATIO, orphaned chunks stop being rooted) +// 3. Terrain (balls rest ON the ground, never inside it; slopes; friction) +// 4. Hazards (spikes kill, spikeProof survives, falling out of the world) +// 5. Stability (no NaN, no launch, stiff lattices stay bounded) +// 6. Determinism (same seed replays bit-identically; frame-rate independent) +// +// Usage: node tools/verifyGooTower.js + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + TUNING, GOO_TYPES, gooType, mulberry32, + pointInPoly, closestPointOnPoly, segmentsIntersect, lineBlocked, + createState, addBall, addStrand, breakStrand, strandById, ballById, + stepSim, settle, settlePhysics, isSettled, refreshStructure, isRooted, + strandLength, strandStress, ballSpeed, cloneState, hashState, liveStrands, + chooseAttachments, canPlace, placeBall, pickBallAt, beginDrag, dragTo, endDrag, + insideSolid, isWon, hasOCD, availableGoo, + distanceToSolid, canStickToTerrain, requiredStrands, ignite, detonate, +} from '../src/games/gootower/GooTowerLogic.js'; +import { autoPlay } from '../src/games/gootower/GooTowerAuto.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +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}` : ''}`); } +} +function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); } + +const GROUND_Y = 800; +const ground = () => ({ kind: 'solid', poly: [[0, GROUND_Y], [1600, GROUND_Y], [1600, 1000], [0, 1000]] }); +const baseLevel = (over = {}) => ({ + world: { w: 1600, h: 1000 }, + terrain: [ground()], + balls: [], strands: [], pile: [], + required: 1, ocdTarget: 1, + ...over, +}); + +// ── 1. Geometry ───────────────────────────────────────────────────────────── +section('1. Geometry primitives'); +{ + const sq = [[0, 0], [100, 0], [100, 100], [0, 100]]; + check('pointInPoly centre', pointInPoly(50, 50, sq)); + check('pointInPoly outside', !pointInPoly(150, 50, sq)); + check('pointInPoly outside (above)', !pointInPoly(50, -10, sq)); + + const cp = closestPointOnPoly(50, 20, sq); + check('closestPointOnPoly picks nearest edge', Math.abs(cp.y - 0) < 1e-9 && Math.abs(cp.x - 50) < 1e-9, + `got ${cp.x},${cp.y}`); + const cp2 = closestPointOnPoly(-30, 50, sq); + check('closestPointOnPoly from outside', Math.abs(cp2.x) < 1e-9 && Math.abs(cp2.d2 - 900) < 1e-6, + `got ${cp2.x},${cp2.y} d2=${cp2.d2}`); + + check('segmentsIntersect crossing', segmentsIntersect(0, 0, 10, 10, 0, 10, 10, 0)); + check('segmentsIntersect parallel', !segmentsIntersect(0, 0, 10, 0, 0, 5, 10, 5)); + check('segmentsIntersect disjoint', !segmentsIntersect(0, 0, 1, 1, 5, 5, 6, 6)); +} +{ + const st = createState(baseLevel({ + terrain: [ground(), { kind: 'solid', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }], + })); + check('lineBlocked through a wall', lineBlocked(st, 300, 450, 500, 450)); + check('lineBlocked clear path', !lineBlocked(st, 300, 200, 500, 200)); + check('lineBlocked ignores non-solid', !lineBlocked( + createState(baseLevel({ terrain: [{ kind: 'spike', poly: [[400, 300], [420, 300], [420, 600], [400, 600]] }] })), + 300, 450, 500, 450)); +} + +// ── 2. Solver invariants ──────────────────────────────────────────────────── +section('2. Solver invariants'); +{ + // A chain hung from a pinned node must settle near its rest length, not + // stretch away to nothing and not collapse to zero. + const N = 6; + const balls = [{ x: 400, y: 200, type: 'common', pinned: true }]; + for (let i = 1; i <= N; i += 1) balls.push({ x: 400, y: 200 + i * TUNING.STRAND_REST, type: 'common' }); + const strands = []; + for (let i = 0; i < N; i += 1) strands.push([i, i + 1]); + const st = createState(baseLevel({ balls, strands })); + settle(st, 12); + + check('hanging chain settles', isSettled(st)); + let allInBand = true; + let worst = 0; + for (const s of liveStrands(st)) { + const ratio = strandLength(st, s) / s.rest; + worst = Math.max(worst, ratio); + if (ratio < 0.9 || ratio > TUNING.BREAK_RATIO) allInBand = false; + } + check('chain strands hang between 0.9x and break ratio', allInBand, `worst ratio ${worst.toFixed(3)}`); + check('chain does not snap under its own weight', liveStrands(st).length === N, + `${liveStrands(st).length}/${N} survived`); + check('pinned node never moves', st.balls[0].x === 400 && st.balls[0].y === 200); + check('chain hangs downward', st.balls[N].y > st.balls[0].y + N * TUNING.STRAND_REST * 0.8); +} +{ + // THE core claim of the whole design: rigidity is emergent from + // triangulation. A triangulated cantilever must sag dramatically less than + // an untriangulated chain of the same span and mass. + // + // Proportions matter and are load-bearing in the literal sense: chord force + // in a cantilever goes as W*L/(2*d), so a shallow truss tears its own root + // strands off. This one is 3 bays at full depth, which common goo can + // actually carry -- the over-long case is asserted separately below. + const SPAN = 3; + const DX = TUNING.STRAND_REST; + + const chainBalls = [{ x: 300, y: 300, type: 'common', pinned: true }]; + const chainStrands = []; + for (let i = 1; i <= SPAN; i += 1) { + chainBalls.push({ x: 300 + i * DX, y: 300, type: 'common' }); + chainStrands.push([i - 1, i]); + } + const chain = createState(baseLevel({ balls: chainBalls, strands: chainStrands })); + settle(chain, 12); + const chainSag = chain.balls[SPAN].y - 300; + + // Same span, two rows, fully triangulated. + const H = 62; + const trussBalls = []; + const idx = (row, col) => row * (SPAN + 1) + col; + for (let row = 0; row < 2; row += 1) { + for (let col = 0; col <= SPAN; col += 1) { + trussBalls.push({ x: 300 + col * DX, y: 300 - row * H, type: 'common', pinned: col === 0 }); + } + } + const trussStrands = []; + for (let col = 0; col < SPAN; col += 1) { + trussStrands.push([idx(0, col), idx(0, col + 1)]); // bottom chord + trussStrands.push([idx(1, col), idx(1, col + 1)]); // top chord + trussStrands.push([idx(0, col), idx(1, col + 1)]); // diagonal + trussStrands.push([idx(1, col), idx(0, col + 1)]); // counter-diagonal + } + for (let col = 0; col <= SPAN; col += 1) trussStrands.push([idx(0, col), idx(1, col)]); + const truss = createState(baseLevel({ balls: trussBalls, strands: trussStrands })); + settle(truss, 12); + const trussSag = truss.balls[idx(0, SPAN)].y - 300; + + check('triangulated truss sags far less than a bare chain', + trussSag < chainSag * 0.5, + `truss ${trussSag.toFixed(1)}px vs chain ${chainSag.toFixed(1)}px`); + check('truss holds itself up', trussSag < DX, + `sag ${trussSag.toFixed(1)}px`); + check('truss keeps all its strands', liveStrands(truss).length === trussStrands.length, + `${liveStrands(truss).length}/${trussStrands.length}`); +} +{ + // ...and the converse, which is just as important for the game to have any + // drama in it: overreach and the structure tears itself apart. A cantilever + // long enough to drive root chord force past the strand rating MUST fail. + const SPAN = 8, DX = TUNING.STRAND_REST, H = 54; + const balls = []; + const idx = (r, c) => r * (SPAN + 1) + c; + for (let r = 0; r < 2; r += 1) + for (let c = 0; c <= SPAN; c += 1) + balls.push({ x: 200 + c * DX, y: 300 - r * H, type: 'common', pinned: c === 0 }); + const strands = []; + for (let c = 0; c < SPAN; c += 1) { + strands.push([idx(0, c), idx(0, c + 1)]); + strands.push([idx(1, c), idx(1, c + 1)]); + strands.push([idx(0, c), idx(1, c + 1)]); + strands.push([idx(1, c), idx(0, c + 1)]); + } + for (let c = 0; c <= SPAN; c += 1) strands.push([idx(0, c), idx(1, c)]); + const st = createState(baseLevel({ balls, strands })); + settle(st, 14); + check('an over-long cantilever collapses', liveStrands(st).length < strands.length, + `${liveStrands(st).length}/${strands.length} survived`); +} +{ + // Tension must be the real, physical constraint force -- the whole break + // model is calibrated in ball-weights, so if this drifts, every level's + // difficulty drifts with it. + const weight = 1500; // GOO_TYPES.common.mass * TUNING.GRAVITY + const readings = []; + for (const n of [1, 2, 4]) { + const balls = [{ x: 400, y: 100, type: 'common', pinned: true }]; + const strands = []; + for (let i = 1; i <= n; i += 1) { + balls.push({ x: 400, y: 100 + i * TUNING.STRAND_REST, type: 'common' }); + strands.push([i - 1, i]); + } + const st = createState(baseLevel({ terrain: [], balls, strands })); + settle(st, 25); + readings.push(st.strands[0].tension / weight); + } + check('strand tension reads true static load in ball-weights', + readings.every((v, i) => Math.abs(v - (i === 0 ? 1 : i === 1 ? 2 : 4)) < 0.2), + readings.map((v) => v.toFixed(2)).join(', ')); + check('strandStress is 0..1 and rises with load', + (() => { + const st = createState(baseLevel({ + terrain: [], + balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }], + strands: [[0, 1]], + })); + settle(st, 20); + const s = strandStress(st.strands[0]); + return s > 0 && s < 1; + })()); +} +{ + // A single-frame numerical spike must not snap a strand; sustained overload + // must. This is the LOAD_TAU fatigue window. + const mk = () => createState(baseLevel({ + terrain: [], + balls: [{ x: 400, y: 100, type: 'common', pinned: true }, { x: 400, y: 162, type: 'common' }], + strands: [[0, 1]], + })); + // Drive these through real positions, not by poking `tension`: substep() + // recomputes tension from lambda before applyStress ever reads it, so an + // injected value is overwritten and the test would prove nothing. + const k = 1 / TUNING.COMPLIANCE; // strand stiffness + const cap = TUNING.BREAK_FORCE; // common strand, strength 1 + const stretchFor = (force) => force / k; // Hooke + const restLen = mk().strands[0].rest; + + // Modest overload for a single substep: over cap, under SHOCK_FACTOR * cap. + const spike = mk(); + spike.balls[1].y = spike.balls[0].y + restLen + stretchFor(cap * 1.6); + spike.balls[1].py = spike.balls[1].y; + stepSim(spike, TUNING.SUBSTEP_DT); + check('a one-frame spike below SHOCK_FACTOR does not snap a strand', + !spike.strands[0].broken, `stress ${strandStress(spike.strands[0]).toFixed(2)}`); + + // Same strand, same single substep, but genuinely violent. + const shock = mk(); + const violent = stretchFor(cap * (TUNING.SHOCK_FACTOR + 1)); + check('the shock test stays under the length backstop', violent < restLen * (TUNING.BREAK_RATIO - 1), + `${violent.toFixed(1)}px vs ${(restLen * (TUNING.BREAK_RATIO - 1)).toFixed(1)}px`); + shock.balls[1].y = shock.balls[0].y + restLen + violent; + shock.balls[1].py = shock.balls[1].y; + stepSim(shock, TUNING.SUBSTEP_DT); + check('a violent shock cuts straight through the fatigue window', shock.strands[0].broken); + + // And sustained-but-moderate overload eventually fatigues through. + const fatigue = mk(); + fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6); + for (let i = 0; i < 200 && !fatigue.strands[0].broken; i += 1) { + fatigue.balls[1].y = fatigue.balls[0].y + restLen + stretchFor(cap * 1.6); + fatigue.balls[1].py = fatigue.balls[1].y; + stepSim(fatigue, TUNING.SUBSTEP_DT); + } + check('sustained overload fatigues a strand through', fatigue.strands[0].broken); +} +{ + // A strand stretched past BREAK_RATIO must snap. + const st = createState(baseLevel({ + balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 260, type: 'common' }], + strands: [[0, 1]], + })); + const s = st.strands[0]; + check('strand starts intact', !s.broken); + st.balls[1].y = 200 + s.rest * (TUNING.BREAK_RATIO + 0.3); + st.balls[1].py = st.balls[1].y; + stepSim(st, 1 / 60); + check('an extreme stretch snaps a strand', s.broken, `len/rest = ${(strandLength(st, s) / s.rest).toFixed(2)}`); +} +{ + // Slack strands pull but never push. + const st = createState(baseLevel({ + balls: [{ x: 400, y: 200, type: 'common', pinned: true }, { x: 400, y: 210, type: 'common', pinned: true }], + strands: [[0, 1]], + })); + const s = st.strands[0]; + check('short strand is treated as slack', strandLength(st, s) < s.rest * TUNING.SLACK_RATIO, + `len ${strandLength(st, s).toFixed(1)} rest ${s.rest.toFixed(1)}`); +} +{ + // Cut a structure and the orphaned half must stop counting as rooted. + const st = createState(baseLevel({ + balls: [ + { x: 400, y: 200, type: 'common', pinned: true }, + { x: 400, y: 262, type: 'common' }, + { x: 400, y: 324, type: 'common' }, + ], + strands: [[0, 1], [1, 2]], + })); + refreshStructure(st); + check('whole structure is rooted via its anchor', isRooted(st, 2)); + breakStrand(st, st.strands[0], null); + refreshStructure(st); + check('orphaned chunk is no longer rooted', !isRooted(st, 2)); + check('orphaned chunk stays one component (chunks fall as chunks)', + st.components.get(1) === st.components.get(2)); + const before = st.balls[2].y; + settle(st, 4); + check('orphaned chunk falls', st.balls[2].y > before + 50, `moved ${(st.balls[2].y - before).toFixed(1)}px`); +} + +// ── 3. Terrain ────────────────────────────────────────────────────────────── +section('3. Terrain collision'); +{ + const st = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] })); + settle(st, 8); + const b = st.balls[0]; + check('ball rests on the ground surface', Math.abs(b.y - (GROUND_Y - b.r)) < 2.5, + `y=${b.y.toFixed(2)} expected ${(GROUND_Y - b.r).toFixed(2)}`); + check('ball is never inside solid terrain', !pointInPoly(b.x, b.y, st.terrain[0].poly)); + check('resting ball is marked grounded', b.grounded); + check('resting ball counts as settled', isSettled(st)); +} +{ + // Dropped into the middle of a solid block, a ball must be ejected, not stuck. + const st = createState(baseLevel({ + terrain: [ground(), { kind: 'solid', poly: [[300, 400], [700, 400], [700, 600], [300, 600]] }], + balls: [{ x: 500, y: 500, type: 'common' }], + })); + stepSim(st, 1 / 60); + const b = st.balls[0]; + check('ball ejected from inside a block', !pointInPoly(b.x, b.y, st.terrain[1].poly), + `at ${b.x.toFixed(1)},${b.y.toFixed(1)}`); +} +{ + // A ball on a slope slides downhill rather than sticking or sinking. + const st = createState(baseLevel({ + terrain: [{ kind: 'solid', poly: [[0, 400], [1600, 800], [1600, 1000], [0, 1000]] }], + balls: [{ x: 400, y: 300, type: 'common' }], + })); + const x0 = st.balls[0].x; + settle(st, 10); + check('ball slides down a slope', st.balls[0].x > x0 + 10, `moved ${(st.balls[0].x - x0).toFixed(1)}px`); + check('ball stays on top of the slope', !pointInPoly(st.balls[0].x, st.balls[0].y, st.terrain[0].poly)); +} + +section('3b. Ball-ball collision'); +{ + // Loose goo must pile, not merge into a single point. + const pile = []; + for (let i = 0; i < 12; i += 1) pile.push({ x: 400 + (i % 3) * 4, y: 300 - i * 6, type: 'common' }); + const st = createState(baseLevel({ pile })); + settle(st, 14); + + let minGap = Infinity; + for (let i = 0; i < st.balls.length; i += 1) { + for (let j = i + 1; j < st.balls.length; j += 1) { + const a = st.balls[i], b = st.balls[j]; + minGap = Math.min(minGap, Math.hypot(b.x - a.x, b.y - a.y) - (a.r + b.r)); + } + } + check('loose goo piles instead of collapsing to a point', minGap > -2.5, + `closest overlap ${minGap.toFixed(2)}px`); + check('nothing in the pile is inside the terrain', + st.balls.every((b) => !pointInPoly(b.x, b.y, st.terrain[0].poly))); + // On an open floor, spreading into a single layer is the CORRECT outcome -- + // round balls on flat ground roll apart. Stacking only has to happen when + // something contains them, so test that in a pit. + const spread = Math.max(...st.balls.map((b) => b.x)) - Math.min(...st.balls.map((b) => b.x)); + check('goo on an open floor spreads out', spread > 100, `spread ${spread.toFixed(0)}px`); +} +{ + const pit = [ + ground(), + { kind: 'solid', poly: [[330, 600], [350, 600], [350, GROUND_Y], [330, GROUND_Y]] }, + { kind: 'solid', poly: [[470, 600], [490, 600], [490, GROUND_Y], [470, GROUND_Y]] }, + ]; + const pile = []; + for (let i = 0; i < 12; i += 1) pile.push({ x: 370 + (i % 3) * 30, y: 560 - i * 8, type: 'common' }); + const st = createState(baseLevel({ terrain: pit, pile })); + settle(st, 16); + const highest = Math.min(...st.balls.map((b) => b.y)); + check('contained goo stacks up', highest < GROUND_Y - 3 * 14, + `highest ball at y=${highest.toFixed(0)}, floor ${GROUND_Y}`); + check('stacked goo does not interpenetrate', (() => { + for (let i = 0; i < st.balls.length; i += 1) { + for (let j = i + 1; j < st.balls.length; j += 1) { + const a = st.balls[i], b = st.balls[j]; + if (Math.hypot(b.x - a.x, b.y - a.y) < a.r + b.r - 2.5) return false; + } + } + return true; + })()); + + // Two balls dropped at exactly the same spot must still separate. + const twin = createState(baseLevel({ pile: [{ x: 500, y: 300 }, { x: 500, y: 300 }] })); + settle(twin, 10); + const gap = Math.hypot(twin.balls[1].x - twin.balls[0].x, twin.balls[1].y - twin.balls[0].y); + check('coincident balls separate rather than dividing by zero', + Number.isFinite(gap) && gap > twin.balls[0].r, `gap ${gap.toFixed(2)}px`); +} + +// ── 4. Hazards ────────────────────────────────────────────────────────────── +section('4. Hazards'); +{ + const spikes = { kind: 'spike', poly: [[300, 600], [700, 600], [700, 660], [300, 660]] }; + const st = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'common' }] })); + let died = null; + for (let i = 0; i < 240 && !died; i += 1) { + for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = e; + } + check('spikes kill common goo', !!died && died.cause === 'spike', died ? died.cause : 'survived'); + + const st2 = createState(baseLevel({ terrain: [ground(), spikes], balls: [{ x: 400, y: 300, type: 'skull' }] })); + let died2 = false; + for (let i = 0; i < 240; i += 1) { + for (const e of stepSim(st2, 1 / 60)) if (e.type === 'ballDie') died2 = true; + } + check('skull goo is spike-proof', !died2); + check('spike-proof flag matches the type table', GOO_TYPES.skull.spikeProof && !GOO_TYPES.common.spikeProof); +} +{ + // Killing a ball must take its strands with it. + const st = createState(baseLevel({ + balls: [ + { x: 400, y: 200, type: 'common', pinned: true }, + { x: 400, y: 262, type: 'common' }, + { x: 400, y: 324, type: 'common' }, + ], + strands: [[0, 1], [1, 2]], + })); + const spikes = { kind: 'spike', poly: [[350, 330], [450, 330], [450, 380], [350, 380]] }; + st.terrain.push({ ...spikes, bounds: { minX: 350, minY: 330, maxX: 450, maxY: 380 } }); + let died = false; + for (let i = 0; i < 240 && !died; i += 1) { + for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') died = true; + } + check('a dying ball drops its strands', died && liveStrands(st).length <= 1, + `${liveStrands(st).length} strands left`); +} +{ + const st = createState(baseLevel({ terrain: [], balls: [{ x: 400, y: 300, type: 'common' }] })); + let cause = null; + for (let i = 0; i < 600 && !cause; i += 1) { + for (const e of stepSim(st, 1 / 60)) if (e.type === 'ballDie') cause = e.cause; + } + check('falling out of the world is fatal', cause === 'fell', cause || 'survived'); +} + +// ── 4b. Attachment rules ──────────────────────────────────────────────────── +section('4b. Attachment'); +{ + // Two anchors 62px apart; a ball dropped above their midpoint should reach + // both and form a triangle. + const mk = () => createState(baseLevel({ + balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }], + pile: [{ x: 800, y: 700, type: 'common' }], + })); + + const st = mk(); + const picks = chooseAttachments(st, 431, 350, 'common'); + check('a ball between two anchors attaches to both', picks.length === 2, `got ${picks.length}`); + check('canPlace agrees', canPlace(st, 431, 350, 'common')); + + check('out of reach attaches to nothing', + chooseAttachments(st, 431, 100, 'common').length === 0); + check('canPlace refuses out of reach', !canPlace(st, 431, 100, 'common')); + + // MIN_ANGLE: the rule that forces triangles. Sitting directly in line with + // two anchors, the far one is within MIN_ANGLE of the near one and must be + // rejected -- leaving one strand, which is below common goo's minStrands. + const inline = chooseAttachments(st, 369, 400, 'common'); + check('MIN_ANGLE rejects a collinear second candidate', inline.length === 1, + `got ${inline.length}`); + check('a placement below minStrands is refused', !canPlace(st, 369, 400, 'common')); + + // Line of sight. + const walled = createState(baseLevel({ + terrain: [ground(), { kind: 'solid', poly: [[430, 200], [440, 200], [440, 600], [430, 600]] }], + balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 470, y: 400, type: 'common', pinned: true }], + })); + const seen = chooseAttachments(walled, 400, 350, 'common'); + check('a wall blocks attachment through it', !seen.includes(1), `got [${seen}]`); + check('cannot place inside solid terrain', !canPlace(walled, 435, 400, 'common')); + + // Type limits. + const many = createState(baseLevel({ + balls: [ + { x: 400, y: 400, type: 'common', pinned: true }, + { x: 462, y: 400, type: 'common', pinned: true }, + { x: 431, y: 458, type: 'common', pinned: true }, + ], + })); + check('common goo takes at most 2 strands', + chooseAttachments(many, 431, 400, 'common').length <= 2); + check('balloon goo takes exactly 1 strand', + chooseAttachments(many, 431, 400, 'balloon').length <= 1); + check('attachment is deterministic', + JSON.stringify(chooseAttachments(many, 431, 400, 'common')) === + JSON.stringify(chooseAttachments(many, 431, 400, 'common'))); +} +{ + // Full drag round-trip. + const st = createState(baseLevel({ + balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common', pinned: true }], + pile: [{ x: 800, y: 700, type: 'common' }], + })); + const ball = st.balls[2]; + check('pickBallAt finds the loose ball', pickBallAt(st, 800, 700) === ball); + check('pickBallAt ignores non-detachable attached goo', pickBallAt(st, 400, 400) === null); + + const ev = []; + check('beginDrag takes the ball', beginDrag(st, ball, ev) && ball.held); + dragTo(st, ball, 431, 350); + check('endDrag sticks it to the structure', endDrag(st, ball, 431, 350, ev)); + check('the placed ball is attached', ball.attached && !ball.held); + check('it gained two strands', ball.strands.length === 2, `${ball.strands.length}`); + check('it left the pile', !st.pile.includes(ball.id)); + check('a place event was emitted', ev.some((e) => e.type === 'place')); + + // A failed placement returns the ball to the pile rather than eating it. + const st2 = createState(baseLevel({ + balls: [{ x: 400, y: 400, type: 'common', pinned: true }], + pile: [{ x: 800, y: 700, type: 'common' }], + })); + const b2 = st2.balls[1]; + const ev2 = []; + beginDrag(st2, b2, ev2); + check('a placement with too few strands fails', !endDrag(st2, b2, 400, 340, ev2)); + check('the failed ball returns to the pile', st2.pile.includes(b2.id) && !b2.attached); + check('a placeFailed event was emitted', ev2.some((e) => e.type === 'placeFailed')); + + // Detachable goo can be lifted back off; common goo cannot. + const st3 = createState(baseLevel({ + balls: [ + { x: 400, y: 400, type: 'common', pinned: true }, + { x: 462, y: 400, type: 'common', pinned: true }, + { x: 431, y: 350, type: 'ivy' }, + ], + strands: [[2, 0], [2, 1]], + })); + check('ivy goo is detachable', beginDrag(st3, st3.balls[2])); + check('detaching drops its strands', liveStrands(st3).length === 0); + const st4 = createState(baseLevel({ + balls: [{ x: 400, y: 400, type: 'common', pinned: true }, { x: 462, y: 400, type: 'common' }], + strands: [[0, 1]], + })); + check('common goo is not detachable', !beginDrag(st4, st4.balls[1])); +} + +// ── 4c. Crawling and the pipe ─────────────────────────────────────────────── +section('4c. Crawlers and the pipe'); +{ + // A structure that reaches the pipe, with loose goo at its foot. + const build = (pileN) => { + const balls = [ + { x: 400, y: 700, type: 'common', pinned: true }, + { x: 462, y: 700, type: 'common', pinned: true }, + { x: 431, y: 645, type: 'common' }, + { x: 431, y: 590, type: 'common' }, + ]; + const strands = [[2, 0], [2, 1], [3, 2], [3, 0], [3, 1]]; + const pile = []; + for (let i = 0; i < pileN; i += 1) pile.push({ x: 420 + i * 6, y: 690 - i * 4, type: 'common' }); + return createState(baseLevel({ + terrain: [ground()], + balls, strands, pile, + pipe: { x: 431, y: 570, r: 46 }, + required: 2, ocdTarget: 3, + })); + }; + + const st = build(0); + for (let i = 0; i < 30; i += 1) stepSim(st, 1 / 60); + check('the pipe opens when the structure reaches it', st.pipe.open); + + const closed = createState(baseLevel({ + balls: [{ x: 400, y: 700, type: 'common', pinned: true }], + pipe: { x: 431, y: 200, r: 46 }, + required: 1, + })); + for (let i = 0; i < 30; i += 1) stepSim(closed, 1 / 60); + check('a pipe out of reach stays shut', !closed.pipe.open); + + // Loose goo climbs the structure and gets drunk. + const run = build(4); + let collected = 0; + let climbed = false; + for (let i = 0; i < 60 * 25; i += 1) { + for (const e of stepSim(run, 1 / 60)) { + if (e.type === 'collect') collected += 1; + if (e.type === 'climb') climbed = true; + } + if (collected >= 4) break; + } + check('loose goo climbs onto the structure', climbed); + check('crawlers reach the pipe and are collected', collected > 0, `${collected} collected`); + check('state.collected agrees with the events', run.collected === collected); + check('the level is won once required is met', isWon(run), `${run.collected}/${run.required}`); + check('OCD tracks separately from the win', + hasOCD(run) === (run.collected >= run.ocdTarget)); + check('collected goo leaves the pile', run.pile.length + run.collected >= 4); + + // Cutting the structure strands a crawler rather than teleporting it. + const cut = build(3); + for (let i = 0; i < 60 * 4; i += 1) stepSim(cut, 1 / 60); + const walker = cut.balls.find((b) => b.walking); + if (walker) { + const s = strandById(cut, walker.onStrand); + breakStrand(cut, s, null); + stepSim(cut, 1 / 60); + check('a crawler whose strand breaks falls off', !walker.walking); + } else { + check('a crawler whose strand breaks falls off', true, 'no crawler active; skipped'); + } +} + +// ── 4d. Terrain-sticking goo (anchor, pokey) ──────────────────────────────── +section('4d. Anchors and wall-sticking goo'); +{ + const wall = { kind: 'solid', poly: [[600, 200], [640, 200], [640, 700], [600, 700]] }; + const st = createState(baseLevel({ terrain: [ground(), wall], pile: [{ x: 900, y: 700, type: 'anchor' }] })); + + check('distanceToSolid measures the nearest surface', + Math.abs(distanceToSolid(st, 660, 400) - 20) < 1e-6, `${distanceToSolid(st, 660, 400)}`); + check('anchor goo sticks beside a wall', canStickToTerrain(st, 660, 400, 'anchor')); + check('anchor goo does not stick in mid-air', !canStickToTerrain(st, 900, 400, 'anchor')); + check('common goo never sticks to terrain', !canStickToTerrain(st, 660, 400, 'common')); + + check('a stuck anchor needs no strands', requiredStrands(st, 660, 400, 'anchor') === 0); + check('an unstuck anchor still needs none by type', gooType('anchor').minStrands === 0); + check('pokey beside a wall needs no strands', requiredStrands(st, 660, 400, 'pokey') === 0); + check('pokey in mid-air needs its full quota', + requiredStrands(st, 900, 400, 'pokey') === GOO_TYPES.pokey.minStrands); + check('common goo beside a wall still needs two', + requiredStrands(st, 660, 400, 'common') === 2); + + check('an anchor can be placed on a wall', canPlace(st, 660, 400, 'anchor')); + check('an anchor cannot be placed in mid-air', !canPlace(st, 900, 400, 'anchor')); + + // Placing one pins it, which is what makes it an anchor point. + const ball = st.balls.find((b) => b.type === 'anchor'); + const ev = []; + beginDrag(st, ball, ev); + check('placing an anchor on a wall succeeds', endDrag(st, ball, 660, 400, ev)); + check('a stuck anchor is pinned', ball.pinned && ball.invMass === 0); + check('a stick event was emitted', ev.some((e) => e.type === 'stick')); + settle(st, 4); + check('a stuck anchor never moves', Math.abs(ball.x - 660) < 1e-9 && Math.abs(ball.y - 400) < 1e-9); + check('a stuck anchor roots the structure', isRooted(st, ball.id)); +} + +// ── 4e. Bombs ─────────────────────────────────────────────────────────────── +section('4e. Bombs and fire'); +{ + const fire = { kind: 'fire', poly: [[380, 500], [520, 500], [520, 560], [380, 560]] }; + const mk = (over = {}) => createState(baseLevel({ + terrain: [ground(), fire], + balls: [ + { x: 400, y: 300, type: 'common', pinned: true }, + { x: 462, y: 300, type: 'common', pinned: true }, + { x: 431, y: 356, type: 'common' }, + ], + strands: [[2, 0], [2, 1], [0, 1]], + pile: [{ x: 450, y: 450, type: 'bomb' }], + ...over, + })); + + const st = mk(); + const bomb = st.balls.find((b) => b.type === 'bomb'); + let lit = false; + let blew = false; + for (let i = 0; i < 60 * 8 && !blew; i += 1) { + for (const e of stepSim(st, 1 / 60)) { + if (e.type === 'ignite') lit = true; + if (e.type === 'explode') blew = true; + } + } + check('fire lights a bomb rather than killing it', lit); + check('a lit bomb detonates', blew); + check('the fuse is not instant', TUNING.FUSE_TIME > 0); + check('the bomb is consumed by its own blast', bomb.dead); + + // Blast radius is finite: goo well clear of it survives. + const far = createState(baseLevel({ + terrain: [ground(), fire], + balls: [{ x: 1400, y: 300, type: 'common', pinned: true }], + pile: [{ x: 450, y: 450, type: 'bomb' }], + })); + for (let i = 0; i < 60 * 8; i += 1) stepSim(far, 1 / 60); + check('goo outside the blast radius survives', !far.balls[0].dead); + + // Blast effects, driven directly so the bomb is where we put it. + const blast = createState(baseLevel({ + terrain: [ + ground(), + { kind: 'solid', poly: [[470, 300], [560, 300], [560, 340], [470, 340]], destructible: true }, + { kind: 'solid', poly: [[1300, 300], [1390, 300], [1390, 340], [1300, 340]], destructible: true }, + ], + balls: [ + { x: 400, y: 300, type: 'common', pinned: true }, + { x: 462, y: 300, type: 'common', pinned: true }, + { x: 431, y: 356, type: 'common' }, + { x: 1500, y: 300, type: 'common', pinned: true }, + ], + strands: [[2, 0], [2, 1], [0, 1]], + pile: [{ x: 431, y: 400, type: 'bomb' }], + })); + const charge = blast.balls.find((b) => b.type === 'bomb'); + const terrainBefore = blast.terrain.length; + const evb = []; + detonate(blast, charge, evb); + + check('a blast shears strands within BLAST_R', liveStrands(blast).length < 3, + `${liveStrands(blast).length}/3 left`); + check('a blast kills goo within BLAST_KILL_R', blast.balls[2].dead); + check('a blast spares goo well outside it', !blast.balls[3].dead); + check('a blast destroys destructible terrain in range', blast.terrain.length < terrainBefore, + `${terrainBefore} -> ${blast.terrain.length}`); + check('an explode event carries the blast position', + evb.some((e) => e.type === 'explode' && e.x === 431)); + + const soft = blast; + check('destructible terrain out of range survives', + soft.terrain.some((t) => t.destructible), 'all destructible terrain went'); + check('indestructible terrain always survives', + soft.terrain.some((t) => t.kind === 'solid' && !t.destructible)); + + // Chain reaction. + const chain = createState(baseLevel({ + terrain: [ground(), fire], + pile: [ + { x: 450, y: 450, type: 'bomb' }, + { x: 450, y: 390, type: 'bomb' }, + { x: 450, y: 330, type: 'bomb' }, + ], + })); + let blasts = 0; + for (let i = 0; i < 60 * 12; i += 1) { + for (const e of stepSim(chain, 1 / 60)) if (e.type === 'explode') blasts += 1; + } + check('bombs chain-react', blasts >= 2, `${blasts} explosions`); + + // Anchors are bolted to the world. + const anchored = createState(baseLevel({ + terrain: [ground(), fire], + balls: [{ x: 460, y: 430, type: 'anchor', pinned: true }], + pile: [{ x: 450, y: 450, type: 'bomb' }], + })); + for (let i = 0; i < 60 * 8; i += 1) stepSim(anchored, 1 / 60); + check('anchors survive a blast', !anchored.balls[0].dead); +} + +// ── 4f. Fans and gears ────────────────────────────────────────────────────── +section('4f. Fans and gears'); +{ + // A fan strong enough to beat gravity lifts goo inside its volume. + const st = createState(baseLevel({ + fans: [{ x: 300, y: 200, w: 300, h: 600, dx: 0, dy: -1, force: 2600 }], + pile: [{ x: 450, y: 700, type: 'common' }, { x: 1200, y: 700, type: 'common' }], + })); + const inFan = st.balls[0]; + const outside = st.balls[1]; + const y0 = inFan.y; + const y1 = outside.y; + for (let i = 0; i < 90; i += 1) stepSim(st, 1 / 60); + check('a fan lifts goo inside its volume', inFan.y < y0 - 30, + `moved ${(inFan.y - y0).toFixed(1)}px`); + check('goo outside the fan is unaffected', outside.y >= y1 - 2, + `moved ${(outside.y - y1).toFixed(1)}px`); + check('a sideways fan pushes sideways', (() => { + const s2 = createState(baseLevel({ + fans: [{ x: 300, y: 600, w: 600, h: 260, dx: 1, dy: 0, force: 2600 }], + pile: [{ x: 400, y: 800, type: 'common' }], + })); + const x0 = s2.balls[0].x; + for (let i = 0; i < 90; i += 1) stepSim(s2, 1 / 60); + return s2.balls[0].x > x0 + 30; + })()); +} +{ + // A gear is solid, spins, and drags what touches it. + const mkGear = (omega) => createState(baseLevel({ + terrain: [ground()], + gears: [{ x: 500, y: 600, r: 90, teeth: 8, omega }], + pile: [{ x: 500, y: 460, type: 'common' }], + })); + + const spun = mkGear(3); + const gearT = spun.terrain.find((t) => t.gear); + check('a gear joins the terrain as a solid', !!gearT && gearT.kind === 'solid'); + const a0 = gearT.gear.angle; + for (let i = 0; i < 60; i += 1) stepSim(spun, 1 / 60); + check('a gear rotates', Math.abs(gearT.gear.angle - a0) > 1, + `angle moved ${(gearT.gear.angle - a0).toFixed(2)}rad`); + check('a spinning gear stays solid (goo never sinks into it)', + !pointInPoly(spun.balls[0].x, spun.balls[0].y, gearT.poly)); + + // Positive omega drags the top of the wheel to the right (screen y is down). + const right = mkGear(4); + const left = mkGear(-4); + const rx = right.balls[0].x; + const lx = left.balls[0].x; + for (let i = 0; i < 150; i += 1) { stepSim(right, 1 / 60); stepSim(left, 1 / 60); } + check('a gear drags goo along its rim', + right.balls[0].x > rx + 5 && left.balls[0].x < lx - 5, + `cw ${(right.balls[0].x - rx).toFixed(1)}px, ccw ${(left.balls[0].x - lx).toFixed(1)}px`); + + // Time-varying terrain must not cost determinism. + const d1 = mkGear(3); + const d2 = mkGear(3); + for (let i = 0; i < 200; i += 1) { stepSim(d1, 1 / 60); stepSim(d2, 1 / 60); } + check('gears stay deterministic', hashState(d1) === hashState(d2)); +} + +// ── 4g. The remaining goo types ───────────────────────────────────────────── +section('4g. Balloon, block, bit'); +{ + // A balloon tied to a hanging structure must visibly hold it up. + const build = (tip) => { + const balls = [ + { x: 400, y: 200, type: 'common', pinned: true }, + { x: 462, y: 200, type: 'common', pinned: true }, + { x: 431, y: 256, type: 'common' }, + { x: 431, y: 318, type: 'common' }, + ]; + const strands = [[2, 0], [2, 1], [3, 2]]; + if (tip) { + // Clear of every other ball: goo that starts overlapping is ejected hard + // by the collision solver and snaps its own strand. + balls.push({ x: 493, y: 256, type: 'balloon' }); + strands.push([4, 3]); + } + return createState(baseLevel({ terrain: [], balls, strands })); + }; + const plain = build(false); + const lifted = build(true); + settle(plain, 12); + settle(lifted, 12); + + // Position is a weak way to measure lift: the structure is held by strands + // stiff enough that ~1.3 ball-weights of buoyancy only moves it a few px. + // The load it takes OFF the supporting strand is the real signal. + const plainLoad = plain.strands[2].tension; + const liftedLoad = lifted.strands[2].tension; + check('a balloon takes load off the strand holding the structure', + liftedLoad < plainLoad * 0.7, + `${liftedLoad.toFixed(0)} vs ${plainLoad.toFixed(0)}`); + check('a balloon lifts the structure it is tied to', + lifted.balls[3].y < plain.balls[3].y - 2, + `${lifted.balls[3].y.toFixed(2)} vs ${plain.balls[3].y.toFixed(2)}`); + check('one balloon is worth about one goo of lift', + Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) > 1 + && Math.abs(GOO_TYPES.balloon.mass * GOO_TYPES.balloon.buoyancy) < 3, + `${(GOO_TYPES.balloon.mass * -GOO_TYPES.balloon.buoyancy).toFixed(2)} ball-weights`); + check('balloon goo takes a single strand', GOO_TYPES.balloon.maxStrands === 1); + + // Block goo is stiffer, so the same span sags less. + const span = (type) => { + const balls = [{ x: 300, y: 300, type, pinned: true }]; + const strands = []; + for (let i = 1; i <= 3; i += 1) { + balls.push({ x: 300 + i * TUNING.STRAND_REST, y: 300, type }); + strands.push([i - 1, i]); + } + const s = createState(baseLevel({ terrain: [], balls, strands })); + settle(s, 12); + return s.balls[3].y - 300; + }; + const blockSag = span('block'); + const commonSag = span('common'); + check('block goo is stiffer than common goo', blockSag < commonSag, + `block ${blockSag.toFixed(1)}px vs common ${commonSag.toFixed(1)}px`); + + check('bit goo is small and light', + GOO_TYPES.bit.r < GOO_TYPES.common.r && GOO_TYPES.bit.mass < GOO_TYPES.common.mass); + check('bit goo hangs off a single strand', GOO_TYPES.bit.minStrands === 1); + check('every goo type is reachable from gooType()', + Object.keys(GOO_TYPES).every((k) => gooType(k) === GOO_TYPES[k])); +} + +// ── 5. Stability ──────────────────────────────────────────────────────────── +section('5. Stability'); +{ + // A dense, stiff, over-constrained lattice is the worst case for a naive + // spring integrator. It must stay finite and bounded. + const COLS = 8, ROWS = 5, DX = TUNING.STRAND_REST; + const balls = [], strands = []; + const at = (r, c) => r * COLS + c; + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + balls.push({ x: 300 + c * DX, y: 300 + r * DX, type: 'block', pinned: r === 0 }); + } + } + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + if (c + 1 < COLS) strands.push([at(r, c), at(r, c + 1)]); + if (r + 1 < ROWS) strands.push([at(r, c), at(r + 1, c)]); + if (r + 1 < ROWS && c + 1 < COLS) strands.push([at(r, c), at(r + 1, c + 1)]); + if (r + 1 < ROWS && c > 0) strands.push([at(r, c), at(r + 1, c - 1)]); + } + } + const st = createState(baseLevel({ balls, strands })); + settle(st, 12); + + let finite = true, bounded = true, maxSpeed = 0; + for (const b of st.balls) { + if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) finite = false; + if (Math.abs(b.x) > 1e5 || Math.abs(b.y) > 1e5) bounded = false; + maxSpeed = Math.max(maxSpeed, ballSpeed(st, b)); + } + check('stiff lattice stays finite (no NaN)', finite); + check('stiff lattice stays bounded (no explosion)', bounded); + check('stiff lattice comes to rest', maxSpeed < TUNING.SETTLE_SPEED, `max speed ${maxSpeed.toFixed(2)}`); + check('stiff lattice keeps most of its strands', liveStrands(st).length > strands.length * 0.9, + `${liveStrands(st).length}/${strands.length}`); + check('per-substep travel is capped', maxSpeed <= TUNING.MAX_SPEED + 1e-6); +} +{ + // Buoyant goo must rise, not sink. + const st = createState(baseLevel({ balls: [{ x: 400, y: 500, type: 'balloon' }] })); + const y0 = st.balls[0].y; + for (let i = 0; i < 60; i += 1) stepSim(st, 1 / 60); + check('balloon goo rises', st.balls[0].y < y0 - 20, `moved ${(st.balls[0].y - y0).toFixed(1)}px`); + check('common goo falls', (() => { + const s2 = createState(baseLevel({ balls: [{ x: 400, y: 300, type: 'common' }] })); + const start = s2.balls[0].y; + for (let i = 0; i < 20; i += 1) stepSim(s2, 1 / 60); + return s2.balls[0].y > start + 5; + })()); +} + +// ── 6. Determinism ────────────────────────────────────────────────────────── +section('6. Determinism'); +{ + const mk = () => createState(baseLevel({ + balls: [ + { x: 400, y: 200, type: 'common', pinned: true }, + { x: 462, y: 200, type: 'common' }, + { x: 431, y: 254, type: 'common' }, + { x: 493, y: 254, type: 'common' }, + ], + strands: [[0, 1], [0, 2], [1, 2], [1, 3], [2, 3]], + }), 12345); + + const a = mk(), b = mk(); + for (let i = 0; i < 300; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); } + check('identical states replay bit-identically', hashState(a) === hashState(b), + `${hashState(a)} vs ${hashState(b)}`); + + // Frame-rate independence: one 1/60 step must equal four 1/240 steps. + const c = mk(), d = mk(); + for (let i = 0; i < 300; i += 1) { + stepSim(c, 1 / 60); + for (let k = 0; k < 4; k += 1) stepSim(d, TUNING.SUBSTEP_DT); + } + check('1/60 step == 4x substep', hashState(c) === hashState(d), + `${hashState(c)} vs ${hashState(d)}`); + + // A ragged frame budget must not change the outcome either. + const e = mk(), f = mk(); + const raggedRng = mulberry32(999); + let tE = 0, tF = 0; + const TOTAL = 5; + while (tE < TOTAL) { const dt = TUNING.SUBSTEP_DT * 4; stepSim(e, dt); tE += dt; } + while (tF < TOTAL) { + const n = 1 + Math.floor(raggedRng() * 6); + const dt = TUNING.SUBSTEP_DT * n; + if (tF + dt > tE) break; + stepSim(f, dt); tF += dt; + } + while (tF < tE - 1e-9) { stepSim(f, TUNING.SUBSTEP_DT); tF += TUNING.SUBSTEP_DT; } + check('ragged frame pacing reaches the same state', hashState(e) === hashState(f), + `${hashState(e)} vs ${hashState(f)}`); + + const g = mk(); + const h = cloneState(g); + for (let i = 0; i < 120; i += 1) { stepSim(g, 1 / 60); stepSim(h, 1 / 60); } + check('cloneState produces an independent, identical sim', hashState(g) === hashState(h)); + const g2 = cloneState(g); + for (let i = 0; i < 30; i += 1) stepSim(g2, 1 / 60); + check('mutating a clone does not touch the original', hashState(g) !== hashState(g2)); +} +{ + const rng = mulberry32(42); + const first = [rng(), rng(), rng()]; + const rng2 = mulberry32(42); + const second = [rng2(), rng2(), rng2()]; + check('seeded rng is reproducible', first.every((v, i) => v === second[i])); + check('rng stays in [0,1)', first.every((v) => v >= 0 && v < 1)); +} + +// ── 7. Type table sanity ──────────────────────────────────────────────────── +section('7. Goo type table'); +{ + let ok = true, why = ''; + for (const [name, t] of Object.entries(GOO_TYPES)) { + if (t.minStrands > t.maxStrands) { ok = false; why = `${name} min>max`; } + if (t.r <= 0) { ok = false; why = `${name} bad radius`; } + if (t.mass < 0) { ok = false; why = `${name} negative mass`; } + } + check('every goo type is coherent', ok, why); + check('gooType falls back to common', gooType('nonexistent') === GOO_TYPES.common); + check('anchor is massless and pinned in practice', GOO_TYPES.anchor.mass === 0); + check('balloon is the only single-strand lifter', GOO_TYPES.balloon.buoyancy < 0); +} + +// ── 8. The shipped level bank ─────────────────────────────────────────────── +section('8. Level bank'); +{ + const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower'); + let manifest = null; + try { + manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8')); + } catch (e) { + check('levels.json loads', false, e.message); + } + + if (manifest) { + check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length > 0); + check('manifest levels are numbered 1..N with no gaps', + manifest.levels.every((m, i) => m.level === i + 1), + manifest.levels.map((m) => m.level).join(',')); + check('every manifest entry names a chapter that exists', + manifest.levels.every((m) => (manifest.chapters || []).some((c) => c.id === m.chapter))); + + for (const entry of manifest.levels) { + const tag = `L${entry.level} ${entry.name}`; + let def = null; + try { + def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8')); + } catch (e) { + check(`${tag}: file loads`, false, e.message); + continue; + } + + check(`${tag}: manifest matches the level file`, + def.level === entry.level && def.required === entry.required && def.ocdTarget === entry.ocdTarget); + check(`${tag}: has a pipe`, !!def.pipe); + check(`${tag}: OCD target is at least the requirement`, def.ocdTarget >= def.required); + + const st = createState(def); + const pileSize = st.pile.length; + check(`${tag}: enough goo exists to meet OCD`, pileSize >= def.ocdTarget, + `${pileSize} loose vs OCD ${def.ocdTarget}`); + check(`${tag}: the starting structure is rooted`, + st.balls.some((b) => b.attached && isRooted(st, b.id))); + check(`${tag}: nothing starts inside solid terrain`, + st.balls.every((b) => !insideSolid(st, b.x, b.y))); + // Overlapping goo is ejected violently by the collision solver and can + // snap its own strands before the player touches anything. + check(`${tag}: no goo starts overlapping`, (() => { + for (let i = 0; i < st.balls.length; i += 1) { + for (let j = i + 1; j < st.balls.length; j += 1) { + const a = st.balls[i]; + const b = st.balls[j]; + if (Math.hypot(b.x - a.x, b.y - a.y) < (a.r + b.r) * 0.6) return false; + } + } + return true; + })()); + check(`${tag}: the pipe does not start open`, !st.pipe.open); + + // The level must be quiet before the player touches it. A bank level that + // spends its first seconds collapsing, shedding strands or losing goo off + // the rim is a bug in the level, not in the physics. + settle(st, 10); + check(`${tag}: settles without breaking a strand`, + liveStrands(st).length === st.strands.length, + `${liveStrands(st).length}/${st.strands.length}`); + check(`${tag}: settles without losing goo`, + st.balls.every((b) => !b.dead), + `${st.balls.filter((b) => b.dead).length} lost`); + check(`${tag}: still has OCD-many goo after settling`, + st.pile.length >= def.ocdTarget, `${st.pile.length} left`); + } + } +} + +// ── 9. Winnability ────────────────────────────────────────────────────────── +// The gate that matters. Everything above proves the physics is sane; this +// proves the levels can actually be beaten. It caught two real level bugs on +// the first run (a level that handed out exactly enough goo to reach the pipe +// and none to feed it, and a level asking for a 677px traverse at level 3). +section('9. Winnability (greedy reference player)'); +{ + const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower'); + let manifest = null; + try { + manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8')); + } catch (_) { manifest = null; } + + if (manifest) { + for (const entry of manifest.levels) { + const def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8')); + const r = autoPlay(def); + check(`L${entry.level} ${entry.name}: a naive builder can win it`, + r.won, + `placed ${r.placed}, pipe ${r.pipeOpen ? 'open' : 'SHUT'}, collected ${r.collected}/${def.required}`); + } + } +} + +// ── 10. Editor contract ───────────────────────────────────────────────────── +// GooTowerEditor.js imports Phaser, so it cannot run here. What CAN be pinned +// down are the assumptions it makes about this module — the ones that would +// break it silently. +section('10. Editor contract'); +{ + // placeStructureBall() maps the ball ids returned by chooseAttachments + // straight onto indices of its own `balls` array. That is only valid because + // createState adds structure balls first, in order, before the pile. If that + // order ever changes, the editor would wire strands to the wrong goo. + const st = createState(baseLevel({ + balls: [ + { x: 400, y: 400, type: 'common', pinned: true }, + { x: 462, y: 400, type: 'common', pinned: true }, + { x: 431, y: 350, type: 'ivy' }, + ], + pile: [{ x: 900, y: 700, type: 'common' }, { x: 940, y: 700, type: 'common' }], + })); + check('structure ball index === ball id', + st.balls.slice(0, 3).every((b, i) => b.id === i)); + check('pile goo comes after the structure, in order', + st.pile.length === 2 && st.pile[0] === 3 && st.pile[1] === 4, `pile=[${st.pile}]`); + check('structure goo is attached, pile goo is not', + st.balls.slice(0, 3).every((b) => b.attached) && st.balls.slice(3).every((b) => !b.attached)); +} +{ + // The editor's Settle button writes settled positions back into the level. + // That is only worth doing if a settled level STAYS settled when reloaded -- + // otherwise every export would drift. + const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower'); + const def = JSON.parse(readFileSync(join(bankDir, 'level-001.json'), 'utf8')); + + const first = createState(def); + settlePhysics(first, 14); + // Replicate the editor's write-back: structure balls, then pile, in order. + const settled = JSON.parse(JSON.stringify(def)); + settled.balls.forEach((b, i) => { b.x = Math.round(first.balls[i].x); b.y = Math.round(first.balls[i].y); }); + const offset = settled.balls.length; + settled.pile.forEach((p, i) => { p.x = Math.round(first.balls[offset + i].x); p.y = Math.round(first.balls[offset + i].y); }); + + const second = createState(settled); + const before = second.balls.map((b) => ({ x: b.x, y: b.y })); + settlePhysics(second, 14); + let maxDrift = 0; + second.balls.forEach((b, i) => { + maxDrift = Math.max(maxDrift, Math.hypot(b.x - before[i].x, b.y - before[i].y)); + }); + check('a settled level stays settled when reloaded', maxDrift < 12, + `max drift ${maxDrift.toFixed(1)}px`); + check('settling never breaks a strand on a good level', + liveStrands(second).length === second.strands.length); + check('settling never loses goo on a good level', second.balls.every((b) => !b.dead)); +} +{ + // The editor exports exactly the shape createState consumes. Round-trip a + // shipped level through that shape and it must still behave identically. + const bankDir = join(__dirname, '..', 'assets', 'gamedata', 'gootower'); + const def = JSON.parse(readFileSync(join(bankDir, 'level-002.json'), 'utf8')); + const roundTripped = { + world: { w: 1600, h: 1000 }, + level: def.level, name: def.name, chapter: def.chapter, tip: def.tip, + terrain: def.terrain.map((t) => ({ kind: t.kind, poly: t.poly.map((p) => [p[0], p[1]]) })), + balls: def.balls.map((b) => ({ x: b.x, y: b.y, type: b.type, pinned: !!b.pinned })), + strands: def.strands.map((s) => [s[0], s[1]]), + pile: def.pile.map((p) => ({ x: p.x, y: p.y, type: p.type })), + pipe: { x: def.pipe.x, y: def.pipe.y, r: def.pipe.r }, + required: def.required, ocdTarget: def.ocdTarget, + }; + const a = createState(def); + const b = createState(roundTripped); + for (let i = 0; i < 120; i += 1) { stepSim(a, 1 / 60); stepSim(b, 1 / 60); } + check('an editor round-trip produces an identical level', hashState(a) === hashState(b), + `${hashState(a)} vs ${hashState(b)}`); +} + +console.log(`\n[verify] ${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);