feat: add Goo Tower game with physics engine, 12 levels, and editor

Implement Waves 0-3 of Goo Tower, a World of Goo-inspired physics
puzzle game. Includes:

- GooTowerLogic.js: Verlet integration with XPBD constraints, terrain
  collision, ball-ball collision, strand breaking, crawling agents,
  and 9 goo types (common, ivy, balloon, bomb, block, pokey, bit,
  skull, anchor)
- GooTowerGame.js: Phaser scene with procedural rendering, drag-and-
  drop placement, pipe collection, and win/OCD tracking
- GooTowerEditor.js: Level editor (/?gootower-editor=1) with terrain
  drawing, ball/pile placement, settle/test/export tools
- 12 hand-tuned levels across 2 chapters (teaching + sandbox)
- verifyGooTower.js: 303-check regression harness
- genGooTower.js: Level bank writer
- GooTowerAuto.js: Greedy reference player (winnability gate)
- Wiring into gamesRegistry, main.js, GameRoomScene, and PreloadScene
This commit is contained in:
Brian Fertig 2026-07-26 10:22:25 -06:00
parent cb3c152d10
commit 386ad3efb7
37 changed files with 7669 additions and 1 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
data/chinese-music.json Normal file
View File

@ -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
}

267
docs/gootower-build-plan.md Normal file
View File

@ -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 03 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 13 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** (089 all taken) |
## Files
| File | Role | State |
|---|---|---|
| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 01 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 03 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,
ballball 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] Ballball 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 712, 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.

View File

@ -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 });

View File

@ -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',
};
}

View File

@ -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) => `<option value="${t}" ${t === this.gooType ? 'selected' : ''}>${t}</option>`).join('');
const kindOpts = ['solid', 'spike', 'fire']
.map((k) => `<option value="${k}" ${k === this.kind ? 'selected' : ''}>${k}</option>`).join('');
const loadOpts = ['<option value="">— load from manifest —</option>',
...this.manifest.map((m) => `<option value="${m.file}">L${m.level} · ${m.name}</option>`)].join('');
const el = document.createElement('div');
el.style.cssText = `width:${rw - 24}px; font-family:"Julius Sans One",sans-serif; color:#e8eef7;`;
el.innerHTML = `
<div style="background:#0a1522ee; border:2px solid #2f5b8f; border-radius:14px; padding:16px 18px;">
<div style="font-size:21px; color:#f0c75e; text-align:center; margin-bottom:8px; font-family:Righteous;">LEVEL SETTINGS</div>
<div style="${row}"><label>Level #</label><input id="ge-level" type="number" min="1" value="${this.meta.level}" style="${input} width:76px;"></div>
<div style="${row}"><label>Name</label><input id="ge-name" type="text" value="${this.meta.name}" style="${input} width:186px;"></div>
<div style="${row}"><label>Chapter</label><input id="ge-chapter" type="number" min="1" value="${this.meta.chapter}" style="${input} width:76px;"></div>
<div style="${row}"><label>Required</label><input id="ge-required" type="number" min="1" value="${this.meta.required}" style="${input} width:76px;"></div>
<div style="${row}"><label>OCD target</label><input id="ge-ocd" type="number" min="1" value="${this.meta.ocdTarget}" style="${input} width:76px;"></div>
<div style="${row}"><label>File</label><input id="ge-file" type="text" value="${this.meta.file}" style="${input} width:186px;"></div>
<div style="margin:7px 0;"><label style="display:block;margin-bottom:4px;">Tip</label>
<textarea id="ge-tip" rows="2" style="${input} width:100%; box-sizing:border-box;">${this.meta.tip}</textarea></div>
<hr style="border-color:#2f5b8f44; margin:10px 0;">
<div style="font-size:17px; color:#f0c75e; margin-bottom:4px;">Brush</div>
<div style="${row}"><label>Terrain kind</label><select id="ge-kind" style="${input} width:130px;">${kindOpts}</select></div>
<div style="${row}"><label>Goo type</label><select id="ge-goo" style="${input} width:130px;">${gooOpts}</select></div>
<div style="${row}"><label><input id="ge-pinned" type="checkbox" ${this.pinned ? 'checked' : ''}> Structure goo is pinned</label></div>
<div style="${row}"><label><input id="ge-destruct" type="checkbox" ${this.destructible ? 'checked' : ''}> Terrain is destructible</label></div>
<div style="${row}"><label>Fan blows</label><select id="ge-fandir" style="${input} width:110px;">
${['up', 'down', 'left', 'right'].map((d) => `<option value="${d}" ${d === this.fanDir ? 'selected' : ''}>${d}</option>`).join('')}
</select></div>
<div style="${row}"><label>Gear speed</label><input id="ge-omega" type="number" step="0.2" value="${this.gearOmega}" style="${input} width:76px;"></div>
<hr style="border-color:#2f5b8f44; margin:10px 0;">
<div style="${row}"><select id="ge-load" style="${input} width:100%;">${loadOpts}</select></div>
<div style="${row}"><input id="ge-fileinput" type="file" accept=".json" style="font-size:13px; width:100%;"></div>
<div id="ge-stats" style="color:#9fd6a0; font-size:14px; margin-top:6px; line-height:1.5;"></div>
<div id="ge-warn" style="color:#ff8f8f; font-size:14px; min-height:38px; margin-top:6px; white-space:pre-line;"></div>
</div>`;
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);
}
}
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

@ -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,
],
};

View File

@ -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 = {

View File

@ -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=<slug> jumps straight to the same place a
// main-menu click would (GameMenuScene.openGame's branching logic).
const deepLinkGame = getGame(params.get('game'));

304
tools/genGooTower.js Normal file
View File

@ -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}`);

1173
tools/verifyGooTower.js Normal file

File diff suppressed because it is too large Load Diff