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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 343 KiB |
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
# Goo Tower (World of Goo) — build plan
|
||||
|
||||
Living plan doc. Survives context clears: **read this file first**, pick the next unchecked
|
||||
item, update the checkboxes and the "Status" line as work lands.
|
||||
|
||||
**Status:** **Waves 0–3 complete (2026-07-26).** All nine goo types and every hazard are
|
||||
live, with a 6-level sandbox tier on top of the 6-level teaching chapter —
|
||||
`node tools/verifyGooTower.js` → **303 checks green**, including a reference player that
|
||||
beats all **12** levels headlessly. **Never opened in a browser** — Brian playtests, and
|
||||
Waves 1–3 are not signed off until he has.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken up front
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Scope | Full campaign — 5 named chapters, ~75 levels. **No** Goo Corporation sandbox tower. |
|
||||
| Level authoring | **Both** — editor for hand-tuned levels, generator for drill/sandbox tiers |
|
||||
| Art | **Procedural, zero art dependency**; optional drop-in sprite hooks later |
|
||||
| Name | **Goo Tower**, slug `gootower`, iconFrame **90** (0–89 all taken) |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role | State |
|
||||
|---|---|---|
|
||||
| `src/games/gootower/GooTowerLogic.js` | Pure sim + rules. Zero imports, Node-testable. | Waves 0–1 done |
|
||||
| `src/games/gootower/GooTowerGame.js` | Phaser scene: level select, board, HUD | Wave 1 done |
|
||||
| `src/games/gootower/tutorial.md` | `hasTutorial: true` | done |
|
||||
| `src/games/gootower/GooTowerEditor.js` | Level editor, `?gootower-editor=1` | Wave 2 done |
|
||||
| `src/games/gootower/GooTowerAuto.js` | Greedy reference player = the winnability gate | done |
|
||||
| `tools/verifyGooTower.js` | Regression harness, **303 checks** | Waves 0–3 done |
|
||||
| `tools/genGooTower.js` | Level bank writer (12 hand-tuned levels) | Wave 3 done |
|
||||
| `assets/gamedata/gootower/levels.json` | Manifest + per-level `level-NNN.json` | 12 levels |
|
||||
|
||||
`GooTowerAuto.js` lives in `src/`, not `tools/`, because the editor's "Test Winnable"
|
||||
button must run exactly the gate the verifier runs.
|
||||
|
||||
Wiring is in place: `gamesRegistry.js` (iconFrame 90), `main.js` import + scene array,
|
||||
`GameRoomScene.js` slugDispatch, `PreloadScene.js` manifest load.
|
||||
|
||||
Run the harness after **every** change to the sim. The physics is the game: a subtly wrong
|
||||
solver produces structures that look plausible and are unbuildable, and there is no way to
|
||||
tell by reading the code.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — the solver — ✅ DONE 2026-07-26
|
||||
|
||||
Landed: Verlet integration with XPBD distance constraints, terrain collision and pushout,
|
||||
ball–ball collision with a uniform-grid broadphase, tension-based strand breaking with a
|
||||
fatigue window, structure flood-fill, seeded RNG, and a 69-check harness.
|
||||
|
||||
- [x] Verlet integrator with global drag and a per-substep travel cap
|
||||
- [x] XPBD distance constraints (compliance + axial damping)
|
||||
- [x] Terrain: point-in-poly, closest-point pushout, friction/restitution, slopes
|
||||
- [x] Ball–ball collision (uniform grid, rebuilt once per substep)
|
||||
- [x] Strand breaking on load, with a shock path and a length backstop
|
||||
- [x] Structure flood-fill; `rooted` components; adrift chunks fall as chunks
|
||||
- [x] Hazards: spikes, fire, falling out of the world
|
||||
- [x] Determinism: identical replay, frame-rate independence, ragged pacing, `cloneState`
|
||||
- [x] `hashState` fingerprint for the generator and verifier
|
||||
|
||||
### Five findings worth not rediscovering
|
||||
|
||||
1. **Plain PBD cannot break strands.** A near-rigid solver yanks every strand back to its
|
||||
rest length each substep, so `length/rest` can never report load and *nothing ever
|
||||
breaks*. XPBD's accumulated multiplier λ **is** the constraint force — that is why the
|
||||
solver is XPBD and not simple relaxation. `tension = -λ/dt²` now reads the true static
|
||||
load (verified: 1.00 / 2.01 / 4.08 ball-weights for 1/2/4-ball chains).
|
||||
2. **Break on force, not on stretch.** At the stiffness a buildable truss needs
|
||||
(k ≈ 8333), a `BREAK_RATIO` threshold would demand ~350 ball-weights and nothing would
|
||||
ever collapse. `BREAK_FORCE` is expressed in ball-weights (25) and is therefore
|
||||
independent of any later stiffness retune. The length ratio survives only as a backstop.
|
||||
3. **Break on a *filtered* load.** A stiff lattice rings at ~15Hz and any impact spikes to
|
||||
~2× static, so judging raw tension snaps strands on the first frame of a level that is
|
||||
merely settling. `LOAD_TAU` (0.12s) is the fatigue window; `SHOCK_FACTOR` (3×) still
|
||||
lets a genuinely violent hit cut through it.
|
||||
4. **`isSettled()` is instantaneous, and that is a trap.** An oscillating structure passes
|
||||
through zero speed at every turning point, so "step until settled" stops at the moment
|
||||
of *maximum stretch* and reports the peak of a decaying oscillation as the static
|
||||
equilibrium. This produced a completely bogus, non-monotonic tuning sweep before it was
|
||||
spotted. `settle()` now requires a sustained quiet window.
|
||||
5. **Constraint damping cannot settle a tower.** It resists a strand *stretching*, but the
|
||||
slowest mode is a pendulum swing that barely stretches anything — it rang for 13s.
|
||||
Global `DRAG` (0.995/substep ≈ 0.30/s) is what actually settles structures.
|
||||
|
||||
Also: `addStrand` takes its rest length from the distance it is **born** at. Clamping to a
|
||||
fixed 62 meant a level author's 87px diagonal started 1.41× pre-stressed and ripped the
|
||||
lattice apart on frame one.
|
||||
|
||||
### Baselines
|
||||
|
||||
```
|
||||
node tools/verifyGooTower.js # 69 passed, 0 failed
|
||||
```
|
||||
|
||||
Perf (1s of sim, full 240Hz substepping):
|
||||
|
||||
| Structure | ms/frame | Budget |
|
||||
|---|---|---|
|
||||
| 30 balls / 69 strands | 0.73 | 16.7 |
|
||||
| 100 balls / 261 strands | 2.18 | 16.7 |
|
||||
| 196 balls / 533 strands | 4.53 | 16.7 |
|
||||
|
||||
### Known gaps, deliberate
|
||||
|
||||
- Rigidity is purely emergent from triangulation — there are **no angular constraints**.
|
||||
That makes `MIN_ANGLE_DEG` in the attachment rule (Wave 1) the most important number in
|
||||
the game: it is the only thing forcing the player to build real trusses.
|
||||
- `GOO_TYPES` carries all nine varieties already, but only mass/buoyancy/stiffness/strength
|
||||
and `spikeProof` are wired. Detachability, terrain-sticking and explosions are Wave 3.
|
||||
- `refreshStructure` runs whenever a ball touches terrain. Fine at current scale (see perf
|
||||
table); revisit if a level ever needs 400+ balls.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — playable core — ✅ DONE 2026-07-26 (pending playtest)
|
||||
|
||||
- [x] `chooseAttachments()` — reach, line of sight, nearest-first, **MIN_ANGLE rejection**
|
||||
- [x] Drag a ball out of the pile; place or spring back; detachable types can be lifted off
|
||||
- [x] Crawling goo: `{onStrand, t, dir}`, deterministic node choice, seeded wander
|
||||
- [x] Pipe: opens on contact; BFS over the strand graph; crawlers path in and are drunk
|
||||
- [x] Win at `collected >= required`; OCD tracked as a separate award
|
||||
- [x] `GooTowerGame.js` — accumulator + events array, procedural rendering, `D` depth map
|
||||
- [x] Wiring (all four files) + `tutorial.md`
|
||||
- [x] Progress via `/puzzles/gootower/{progress,complete,reset}` + `/history/single-player`
|
||||
- [x] Six hand-built levels in `assets/gamedata/gootower/`
|
||||
- [x] Greedy reference player; all six levels beaten headlessly
|
||||
- [ ] **First browser playtest — NOT DONE.** `MIN_ANGLE_DEG`, `ATTACH_R`, `GRAVITY` and
|
||||
`WANDER_SPEED` get tuned by feel here. No amount of Node testing substitutes.
|
||||
|
||||
### Wave 1 findings
|
||||
|
||||
1. **Loose goo has to walk.** The pipe would open and then collect *nothing*, because the
|
||||
heap sat where it was authored and never touched the structure. In the original,
|
||||
unattached goo mills around the tower; `wanderLoose()` reproduces that. Without it a
|
||||
level whose heap is not already against the structure is simply unwinnable, and no
|
||||
amount of building fixes it. Re-targeting is throttled to `WANDER_HZ` because it is
|
||||
O(loose x attached).
|
||||
2. **A settling level is not a winnable level.** The bank lint (levels settle, keep their
|
||||
strands, lose no goo) passed on all six while two of them could not actually be beaten.
|
||||
Only playing them found it — hence `tools/lib/gooAutoPlay.js`.
|
||||
3. Two real level bugs it caught: **L4** handed out exactly enough goo to *reach* the pipe
|
||||
and none to feed it; **L3** asked for a 677px diagonal traverse around an obstacle as the
|
||||
third teaching level.
|
||||
4. The greedy gate is **one-directional**: if the naive builder wins, a human can. A greedy
|
||||
failure is *not* proof a level is impossible — redesign or store a hand-authored
|
||||
reference build, never conclude "unwinnable".
|
||||
|
||||
### Baselines
|
||||
|
||||
```
|
||||
node tools/verifyGooTower.js # 179 passed, 0 failed (~5s, includes the winnability gate)
|
||||
node tools/genGooTower.js # rewrites the 6-level bank
|
||||
```
|
||||
|
||||
## Wave 2 — the editor — ✅ DONE 2026-07-26 (pending playtest)
|
||||
|
||||
Reached at `index.html?gootower-editor=1`. Tools: **terrain** (click vertices, Enter or
|
||||
double-click to close; solid/spike/fire), **ball** (starting structure, auto-strands, shift
|
||||
for pinned), **pile** (loose goo), **pipe**, **erase** (or right-click anywhere). Ctrl-Z
|
||||
undoes. Settings live in a DOM panel on the right; **Settle**, **Test Winnable**, **Test
|
||||
Play**, and Blob-download **⬇ Level** / **⬇ Manifest** sit along the bottom.
|
||||
|
||||
- [x] Terrain polygon drawing, all three kinds
|
||||
- [x] Structure goo with a live attachment preview — it calls the game's own
|
||||
`chooseAttachments`, so the MIN_ANGLE rule is visible while authoring
|
||||
- [x] Pile, pipe, erase, undo
|
||||
- [x] Settle before export; validation blocks a broken export
|
||||
- [x] Test-play round-trip via `registry` + `scene.start('GooTowerGame', { testLevel, returnToEditor })`
|
||||
- [x] Test Winnable runs the same greedy gate the verifier uses
|
||||
- [x] Load from the manifest or from a local file
|
||||
- [ ] **Browser playtest — NOT DONE.**
|
||||
|
||||
### Wave 2 findings
|
||||
|
||||
1. **Settling for export must be physics-only.** `settle()` runs the *whole* sim, agents
|
||||
included — so loose goo walks to the structure and climbs it while settling. Capturing
|
||||
those positions bakes the heap INSIDE the tower, and reloading that level explodes as the
|
||||
collision solver ejects it (measured: 1801px of drift, strands snapped, goo lost). Hence
|
||||
`settlePhysics()`. The editor's Settle button would have quietly corrupted every level
|
||||
with a heap.
|
||||
2. **`grounded` is only true on a simulated state.** It is set by terrain contact during a
|
||||
substep, so validation on a never-stepped state sees `false` everywhere and a floating
|
||||
structure sails straight through. `validate()` settles a throwaway copy first.
|
||||
3. **The hover preview cannot build a state per mouse event.** `drawHoverPreview` runs on
|
||||
every `pointermove`; a version-stamped cache (`touch()` / `draftState()`) keeps it to one
|
||||
rebuild per edit.
|
||||
4. The editor deliberately asks the *real* rules for everything — attachment, legality,
|
||||
winnability — so what it shows can never disagree with what the game does.
|
||||
|
||||
### Editor contract, pinned by the verifier
|
||||
|
||||
`GooTowerEditor.js` imports Phaser and cannot run in Node, so §10 of the harness asserts the
|
||||
assumptions it rests on: structure ball index === ball id (it wires strands by index), pile
|
||||
goo comes after the structure in order, a settled level stays settled when reloaded, and an
|
||||
editor round-trip reproduces a byte-identical level.
|
||||
|
||||
## Wave 3 — remaining goo types and hazards — ✅ DONE 2026-07-26 (pending playtest)
|
||||
|
||||
- [x] **anchor / pokey** — bond to any solid surface within `STICK_DIST` and pin there.
|
||||
`requiredStrands()` drops to 0 when stuck, which is what lets a structure start from
|
||||
a wall instead of only from the ground.
|
||||
- [x] **bomb** — fire lights a fuse (`FUSE_TIME`), then the blast kills goo inside
|
||||
`BLAST_KILL_R`, shears every strand inside `BLAST_R`, and removes terrain the author
|
||||
marked `destructible`. Bombs chain-react; anchors survive.
|
||||
- [x] **balloon** — buoyant, single strand, ~1.3 ball-weights of lift
|
||||
- [x] **block / bit / skull** — stiffness, cheapness and spike immunity
|
||||
- [x] **fans** — axis-aligned force volumes; goo inside weighs less
|
||||
- [x] **gears** — rotating solid terrain that drags whatever touches its rim
|
||||
- [x] `hazardAt()` — "legal but lethal", used by both the reference player and the drag preview
|
||||
- [x] Sandbox tier: levels 7–12, one per mechanic (**throwaway**, replaced in Wave 4)
|
||||
- [x] Editor gained fan/gear tools and a destructible flag
|
||||
- [ ] **Browser playtest — NOT DONE.**
|
||||
|
||||
### Wave 3 findings
|
||||
|
||||
1. **Gears are cheap if you store them as terrain.** A gear is an ordinary `solid` entry
|
||||
carrying a `gear` record; `updateMovers()` respins its polygon each substep and the
|
||||
existing collision path does the rest. The only extra rule is that friction pulls a
|
||||
contacting ball toward the **rim's surface velocity** instead of toward zero — that one
|
||||
line is the whole mechanic. Time-varying terrain costs nothing in determinism.
|
||||
2. **Legal is not survivable.** The reference player happily dropped goo into fire 24 times
|
||||
in a row, because `canPlace` only asks whether a strand can form. `hazardAt()` closes
|
||||
that, and the drag preview now warns the player too (orange, not red — it *is* a legal
|
||||
drop).
|
||||
3. **Greedy will not climb a wall.** Getting over an obstacle means first building *away*
|
||||
from the pipe, which a nearest-to-target search never does. Levels can hand the gate
|
||||
`waypoints` to route it — a hint to the verifier, not gameplay. `autoPlay` also accepts a
|
||||
stored `reference` build for anything waypoints cannot express.
|
||||
4. **A sandbox level must not make its mechanic the only route.** L9's first draft capped
|
||||
the destructible rock with fire, so the climb-over was lethal and the bomb was the sole
|
||||
solution — which greedy cannot find, making the level unwinnable by the gate *and* by any
|
||||
player who did not guess. The fire moved beside the rock; the bomb is now a shortcut.
|
||||
5. **Overlapping goo at level start is violent.** Two balls 6px apart are ejected hard
|
||||
enough to snap their own strand (found when a balloon flew to y=-31120). Both the bank
|
||||
lint and the editor now refuse it — and it immediately caught a real overlap in L2.
|
||||
|
||||
### Baselines
|
||||
|
||||
```
|
||||
node tools/verifyGooTower.js # 303 passed, 0 failed (~15s, includes 12 winnability runs)
|
||||
node tools/genGooTower.js # rewrites the 12-level bank
|
||||
```
|
||||
|
||||
## Wave 4 — the campaign — ⬜ NEXT
|
||||
|
||||
Five named chapters × ~15 levels, **replacing the throwaway sandbox tier**. Hand-authored
|
||||
teaching levels in the editor; `tools/genGooTower.js` fills drill slots. Gates adapted from `genPuddingMonsters.js`:
|
||||
**solvable** (a recorded reference build wins under the deterministic sim), **load-bearing**
|
||||
(strip the featured element and the level must break or get materially harder), **gentle**
|
||||
(no instant-fail opening in a teaching level).
|
||||
|
||||
The uncertain piece: a physics sandbox has no clean BFS solver, so "solvable" means *a
|
||||
stored reference build replays to a win*. That is why levels are hand-authored first and
|
||||
generated second.
|
||||
|
||||
---
|
||||
|
||||
## Sources for original-game behaviour
|
||||
|
||||
Design reconstructed from the shipped game's mechanics: goo balls as point masses joined by
|
||||
springy strands, rigidity from triangulation, loose goo crawling the structure, the pipe
|
||||
drinking a required count, and the OCD (Obsessive Completion Distinction) as a separate
|
||||
award. Level layouts, art, music and the original's physics constants are **not**
|
||||
reproduced — everything here is authored fresh, as Peggle and Jell-o Monsters were.
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||