feat(gootower): add sleeping goo mechanic and redesign levels

feat(zuma): redesign level 7
feat(shift): add 9 new artwork pieces
chore: remove Worms game entirely

- Goo Tower: introduce `asleep` state for pile balls. Asleep balls ignore physics/wandering and cannot be picked up until they gain a clear line of sight to an attached structure. Adds zZzZz visual, editor toggle, auto-play filtering, and logic tests.
- Goo Tower Lvl 9 & Zuma Lvl 7: completely redesigned terrain, ball/pile positions, and pipe placement.
- Shift: register 9 new artwork pieces.
- Worms: remove all source files, assets, verifier, build docs, and game wiring.
This commit is contained in:
Brian Fertig 2026-07-31 23:37:48 -06:00
parent 4c0b04613a
commit 793cb249f9
33 changed files with 754 additions and 5983 deletions

View File

@ -70,30 +70,364 @@
1000
]
]
},
{
"kind": "solid",
"poly": [
[
497,
189
],
[
7,
187
],
[
9,
433
],
[
67,
433
],
[
84,
337
],
[
135,
291
],
[
253,
247
],
[
399,
222
],
[
497,
214
]
]
},
{
"kind": "solid",
"poly": [
[
590,
189
],
[
588,
216
],
[
744,
214
],
[
817,
230
],
[
872,
271
],
[
951,
283
],
[
1012,
260
],
[
1047,
216
],
[
1055,
3
],
[
955,
3
],
[
951,
138
],
[
938,
170
],
[
872,
192
],
[
796,
184
]
]
},
{
"kind": "fire",
"poly": [
[
423,
408
],
[
697,
411
],
[
697,
520
],
[
424,
515
]
]
},
{
"kind": "solid",
"poly": [
[
12,
436
],
[
209,
438
],
[
270,
428
],
[
313,
406
],
[
371,
394
],
[
424,
386
],
[
426,
515
],
[
699,
521
],
[
699,
394
],
[
743,
394
],
[
788,
409
],
[
848,
433
],
[
929,
436
],
[
1037,
433
],
[
1036,
568
],
[
1329,
565
],
[
1327,
424
],
[
1590,
430
],
[
1593,
666
],
[
10,
650
]
]
},
{
"kind": "fire",
"poly": [
[
1039,
444
],
[
1037,
567
],
[
1332,
565
],
[
1330,
431
]
]
},
{
"kind": "fire",
"poly": [
[
9,
660
],
[
9,
844
],
[
1590,
844
],
[
1592,
671
]
]
},
{
"kind": "solid",
"poly": [
[
1040,
433
],
[
1034,
401
],
[
1009,
400
],
[
1007,
435
]
]
},
{
"kind": "solid",
"poly": [
[
705,
316
],
[
700,
391
],
[
740,
394
],
[
739,
352
],
[
744,
332
],
[
791,
310
],
[
809,
310
],
[
807,
294
],
[
704,
290
]
]
},
{
"kind": "solid",
"poly": [
[
1110,
370
],
[
1110,
389
],
[
1275,
387
],
[
1274,
367
]
]
}
],
"balls": [
{
"x": 722,
"y": 836,
"x": 470,
"y": 164,
"type": "common",
"pinned": true
"pinned": false
},
{
"x": 784,
"y": 836,
"x": 399,
"y": 164,
"type": "common",
"pinned": true
"pinned": false
},
{
"x": 753,
"y": 782,
"type": "common"
"x": 434,
"y": 110,
"type": "common",
"pinned": false
}
],
"strands": [
[
2,
1,
0
],
[
@ -101,135 +435,215 @@
1
],
[
0,
1
2,
0
]
],
"pile": [
{
"x": 1013,
"y": 834,
"x": 45,
"y": 159,
"type": "common"
},
{
"x": 1043,
"y": 834,
"x": 100,
"y": 160,
"type": "common"
},
{
"x": 1073,
"y": 834,
"x": 149,
"y": 162,
"type": "common"
},
{
"x": 1103,
"y": 834,
"x": 212,
"y": 164,
"type": "common"
},
{
"x": 1133,
"y": 834,
"x": 273,
"y": 165,
"type": "common"
},
{
"x": 1163,
"y": 834,
"x": 327,
"y": 164,
"type": "common"
},
{
"x": 1193,
"y": 834,
"x": 298,
"y": 124,
"type": "common"
},
{
"x": 1223,
"y": 834,
"x": 239,
"y": 129,
"type": "common"
},
{
"x": 1013,
"y": 804,
"x": 185,
"y": 131,
"type": "common"
},
{
"x": 1043,
"y": 804,
"x": 122,
"y": 126,
"type": "common"
},
{
"x": 1073,
"y": 804,
"x": 75,
"y": 118,
"type": "common"
},
{
"x": 1103,
"y": 804,
"x": 158,
"y": 85,
"type": "common"
},
{
"x": 1133,
"y": 804,
"x": 218,
"y": 90,
"type": "common"
},
{
"x": 1163,
"y": 804,
"x": 806,
"y": 390,
"type": "ivy"
},
{
"x": 850,
"y": 405,
"type": "ivy"
},
{
"x": 888,
"y": 412,
"type": "ivy"
},
{
"x": 927,
"y": 414,
"type": "ivy"
},
{
"x": 969,
"y": 412,
"type": "ivy"
},
{
"x": 990,
"y": 373,
"type": "ivy"
},
{
"x": 958,
"y": 379,
"type": "ivy"
},
{
"x": 900,
"y": 372,
"type": "ivy"
},
{
"x": 864,
"y": 373,
"type": "ivy"
},
{
"x": 832,
"y": 362,
"type": "ivy"
},
{
"x": 862,
"y": 329,
"type": "ivy"
},
{
"x": 908,
"y": 331,
"type": "ivy"
},
{
"x": 944,
"y": 338,
"type": "ivy"
},
{
"x": 979,
"y": 335,
"type": "ivy"
},
{
"x": 261,
"y": 86,
"type": "common"
},
{
"x": 1193,
"y": 804,
"x": 102,
"y": 74,
"type": "common"
},
{
"x": 1223,
"y": 804,
"x": 35,
"y": 77,
"type": "common"
},
{
"x": 1013,
"y": 774,
"x": 187,
"y": 49,
"type": "common"
},
{
"x": 1043,
"y": 774,
"x": 245,
"y": 33,
"type": "common"
},
{
"x": 1073,
"y": 774,
"x": 127,
"y": 39,
"type": "common"
},
{
"x": 1103,
"y": 774,
"x": 59,
"y": 39,
"type": "common"
},
{
"x": 1133,
"y": 774,
"type": "common"
"x": 881,
"y": 296,
"type": "ivy"
},
{
"x": 1163,
"y": 774,
"type": "common"
"x": 932,
"y": 301,
"type": "ivy"
},
{
"x": 1193,
"y": 774,
"type": "common"
"x": 974,
"y": 298,
"type": "ivy"
},
{
"x": 1223,
"y": 774,
"type": "common"
"x": 763,
"y": 379,
"type": "ivy"
},
{
"x": 779,
"y": 337,
"type": "ivy"
},
{
"x": 817,
"y": 329,
"type": "ivy"
}
],
"pipe": {
"x": 753,
"y": 455,
"x": 1474,
"y": 310,
"r": 46
},
"required": 6,

View File

@ -1,384 +1,109 @@
{
"level": 7,
"name": "Rapids",
"frog": [
880,
770
],
"colors": 4,
"background": "zuma-background-2",
"seed": 56433,
"starScores": [
910,
1360,
1810
],
"paths": [
{
"shape": "sCurve",
"points": [
[
-80,
140
],
[
-11,
140
],
[
57,
140
],
[
126,
140
],
[
195,
140
],
[
263,
140
],
[
332,
140
],
[
401,
140
],
[
470,
140
],
[
538,
140
],
[
607,
140
],
[
676,
140
],
[
744,
140
],
[
813,
140
],
[
882,
140
],
[
950,
140
],
[
1019,
140
],
[
1088,
140
],
[
1157,
140
],
[
1225,
140
],
[
1294,
140
],
[
1363,
140
],
[
1431,
140
],
[
1500,
140
],
[
1572,
153
],
[
1635,
189
],
[
1682,
245
],
[
1707,
314
],
[
1707,
386
],
[
1682,
455
],
[
1635,
511
],
[
1572,
547
],
[
1500,
560
],
[
1431,
560
],
[
1362,
560
],
[
1293,
560
],
[
1224,
560
],
[
1156,
560
],
[
1087,
560
],
[
1018,
560
],
[
949,
560
],
[
880,
560
],
[
811,
560
],
[
742,
560
],
[
673,
560
],
[
604,
560
],
[
536,
560
],
[
467,
560
],
[
398,
560
],
[
329,
560
],
[
260,
560
],
[
188,
573
],
[
125,
609
],
[
78,
665
],
[
53,
734
],
[
53,
806
],
[
78,
875
],
[
125,
931
],
[
188,
967
],
[
260,
980
],
[
329,
980
],
[
398,
980
],
[
467,
980
],
[
536,
980
],
[
604,
980
],
[
673,
980
],
[
742,
980
],
[
811,
980
],
[
880,
980
],
[
949,
980
],
[
1018,
980
],
[
1087,
980
],
[
1156,
980
],
[
1224,
980
],
[
1293,
980
],
[
1362,
980
],
[
1431,
980
],
[
1500,
980
],
[
1573,
966
],
[
1634,
924
],
[
1676,
863
],
[
1690,
790
],
[
1690,
717
],
[
1690,
643
],
[
1690,
570
],
[
1678,
509
],
[
1643,
457
],
[
1591,
422
],
[
1530,
410
]
],
"tunnels": [],
"quota": 26,
"introBalls": 8,
"pushSpeed": 108,
"powerUpRate": 0.06
}
]
"level": 7,
"name": "New Path",
"frog": [
958,
559
],
"colors": 4,
"seed": 12345,
"starScores": [
640,
960,
1280
],
"paths": [
{
"shape": "custom",
"points": [
[
-59,
106
],
[
380,
126
],
[
1113,
130
],
[
1646,
102
],
[
1815,
288
],
[
1689,
514
],
[
1247,
327
],
[
956,
251
],
[
591,
288
],
[
347,
294
]
],
"tunnels": [],
"quota": 24,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
},
{
"shape": "custom",
"points": [
[
1923,
907
],
[
1048,
999
],
[
199,
915
],
[
164,
663
],
[
435,
587
],
[
824,
862
],
[
1349,
856
],
[
1704,
702
]
],
"tunnels": [],
"quota": 24,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -143,6 +143,54 @@
"name": "Tiger Warrior",
"key": "tiger-warrior",
"path": "assets/images/shift/tiger-warrior.png"
},
{
"id": "backpacking-goldens",
"name": "Backpacking in the Goldens",
"key": "backpacking-goldens",
"path": "assets/images/shift/backpacking-goldens.png"
},
{
"id": "elven-warrior",
"name": "Elven Warrior",
"key": "elven-warrior",
"path": "assets/images/shift/elven-warrior.png"
},
{
"id": "japanese-treehouses",
"name": "Japanese Treehouses",
"key": "japanese-treehouses",
"path": "assets/images/shift/japanese-treehouses.png"
},
{
"id": "knights-and-goblins",
"name": "Knights and Goblins",
"key": "knights-and-goblins",
"path": "assets/images/shift/knights-and-goblins.png"
},
{
"id": "serving-the-patrons",
"name": "Serving the Patrons",
"key": "serving-the-patrons",
"path": "assets/images/shift/serving-the-patrons.png"
},
{
"id": "surfing-corgi",
"name": "Surfing Corgi",
"key": "surfing-corgi",
"path": "assets/images/shift/surfing-corgi.png"
},
{
"id": "tree-village",
"name": "Tree Village",
"key": "tree-village",
"path": "assets/images/shift/tree-village.png"
},
{
"id": "zen-budha",
"name": "Zen Buddha",
"key": "zen-budha",
"path": "assets/images/shift/zen-budha.png"
}
]
}

View File

@ -1,59 +0,0 @@
{
"_readme": [
"Drop-in art for Worms. Every slot starts null, which means 'not painted' —",
"the game renders that slot procedurally from the theme palette in",
"src/games/worms/WormsThemes.js and looks complete either way. Fill in a",
"'path' (relative to the repo root, e.g. assets/images/worms/bg-forest.png)",
"and that slot upgrades on the next load. No code change is needed.",
"",
"Per theme:",
" backdrop a single image, 1920x1080 or wider, parallaxed behind the world",
" fill a SEAMLESS 256x256 tile for the inside of the terrain",
" surface a SEAMLESS 256x48 strip for the top crust of the terrain",
" objects a spritesheet of background scenery, 160x160 cells, 8 per sheet",
"",
"Shared:",
" worms a spritesheet of worm frames, 64x64 cells (see sprites.md)",
" icons weapon icons for the weapon panel, 64x64 cells, in PANEL_ORDER"
],
"themes": {
"forest": { "backdrop": { "key": "worms-bg-forest", "path": null },
"fill": { "key": "worms-fill-forest", "path": null },
"surface": { "key": "worms-surface-forest", "path": null },
"objects": { "key": "worms-objects-forest", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"desert": { "backdrop": { "key": "worms-bg-desert", "path": null },
"fill": { "key": "worms-fill-desert", "path": null },
"surface": { "key": "worms-surface-desert", "path": null },
"objects": { "key": "worms-objects-desert", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"farm": { "backdrop": { "key": "worms-bg-farm", "path": null },
"fill": { "key": "worms-fill-farm", "path": null },
"surface": { "key": "worms-surface-farm", "path": null },
"objects": { "key": "worms-objects-farm", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"hell": { "backdrop": { "key": "worms-bg-hell", "path": null },
"fill": { "key": "worms-fill-hell", "path": null },
"surface": { "key": "worms-surface-hell", "path": null },
"objects": { "key": "worms-objects-hell", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"arctic": { "backdrop": { "key": "worms-bg-arctic", "path": null },
"fill": { "key": "worms-fill-arctic", "path": null },
"surface": { "key": "worms-surface-arctic", "path": null },
"objects": { "key": "worms-objects-arctic", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"jungle": { "backdrop": { "key": "worms-bg-jungle", "path": null },
"fill": { "key": "worms-fill-jungle", "path": null },
"surface": { "key": "worms-surface-jungle", "path": null },
"objects": { "key": "worms-objects-jungle", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"construction": { "backdrop": { "key": "worms-bg-construction", "path": null },
"fill": { "key": "worms-fill-construction", "path": null },
"surface": { "key": "worms-surface-construction", "path": null },
"objects": { "key": "worms-objects-construction", "path": null, "frameWidth": 160, "frameHeight": 160 } },
"space": { "backdrop": { "key": "worms-bg-space", "path": null },
"fill": { "key": "worms-fill-space", "path": null },
"surface": { "key": "worms-surface-space", "path": null },
"objects": { "key": "worms-objects-space", "path": null, "frameWidth": 160, "frameHeight": 160 } }
},
"shared": {
"worms": { "key": "worms-sprites", "path": null, "frameWidth": 64, "frameHeight": 64 },
"icons": { "key": "worms-icons", "path": null, "frameWidth": 64, "frameHeight": 64 }
}
}

View File

@ -1,212 +0,0 @@
# Worms (Worms Armageddon) — 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 01 done (2026-07-31). `node tools/verifyWorms.js` → **207 checks green**
(~4 min; `--quick` ≈ 30 s). The game is wired into the menu and a full match is playable
with 9 of the 16 weapons. **Never opened in a browser** — Brian playtests, and nothing here
is signed off until he has.
---
## Decisions taken up front
| Question | Answer |
|---|---|
| Slug / name / category | `worms` / "Worms" / `arcade-console-pc` ("Video Games") |
| iconFrame | **92** — freed by the Angry Birds removal; 091 were contiguous with no gaps |
| Physics | Bespoke deterministic solver. Matter.js ships inside the CDN Phaser build but is not importable in bare Node, which would forfeit the verifier and the AI's shot replay |
| Weapons | 16 core: Bazooka, Grenade, Cluster, Shotgun, Uzi, Fire Punch, Dynamite, Mine, Air Strike, Homing Missile, Holy Hand Grenade, Blowtorch, Girder, Teleport, Ninja Rope, Skip Go (+ Surrender) |
| Single-player | 20-match campaign ladder fronted by the shared opponent roster, plus free Quick Match |
| 2 player | Hotseat round-robin, one shared control set (turns never overlap) |
| Themes | 8 — Forest, Desert, Farm, Hell, Arctic, Jungle, Construction, Space |
| Art | Terrain shape procedural; worms procedural with a drop-in hook. Brian supplies theme backdrops, terrain fill/surface textures and background-object sheets |
| Map shapes | Island (open sky) and Cavern (indestructible frame) |
| Look | Clean late-90s PC. **No CRT overlay, no m6x11 pixel font** |
| Rules | 45 s turns, 5 s retreat, fall damage, wind, sudden death + rising water, crates, mines, oil drums |
---
## Files
| File | Role | State |
|---|---|---|
| `src/games/worms/WormsTerrain.js` | Mask terrain: generation, destruction, queries, spawn lint | ✅ Wave 0 |
| `src/games/worms/WormsPhysics.js` | Worm locomotion, projectiles, rope, blast geometry | ✅ Wave 0 |
| `tools/verifyWorms.js` | Headless verifier, sections 19 | ✅ Wave 0 (152 checks) |
| `src/games/worms/WormsWeapons.js` | The 16-weapon table | ✅ Wave 1 |
| `src/games/worms/WormsLogic.js` | Match rules, turn order, event list | ✅ Wave 1 |
| `src/games/worms/WormsThemes.js` | 8 theme palettes + art slot names | ✅ Wave 1 |
| `src/games/worms/WormsRender.js` | Procedural worms, terrain painting, FX | ✅ Wave 1 |
| `src/games/worms/WormsGame.js` | Phaser scene | ✅ Wave 1 |
| `src/games/worms/tutorial.md` | Player-facing rules and controls | ✅ Wave 1 |
| `data/worms-artwork.json` | Drop-in art slots, all `path: null` | ✅ Wave 1 |
| `src/games/worms/WormsAI.js` | Skill 15 opponent | ⬜ Wave 3 |
| `data/worms-campaign.json` | 20 ladder rungs | ⬜ Wave 3 |
| `src/games/worms/sprites.md` | Art spec for Brian | ⬜ Wave 4 |
**Wiring touchpoints** — all done in Wave 1: `src/data/gamesRegistry.js` (iconFrame 92),
`src/main.js` (import + scene array), `src/scenes/GameRoomScene.js:26` (`slugDispatch`),
`src/data/assetManifest.js` (`wormsArt` resolver), `src/scenes/PreloadScene.js`
(`worms-artwork` JSON). The `?worms-maplab=1` entrance is still Wave 4.
**Deviation from the original plan:** the weapon table lives in `WormsWeapons.js`, not
`data/worms-rules.json`. The verifier, the AI and the scene must agree on those numbers
exactly, and a plain ESM import gives that in both the browser and bare Node with no fetch
and no async boot ordering. Theme palettes are in `WormsThemes.js` for the same reason;
only the *art paths* are data (`data/worms-artwork.json`).
---
## Wave 0 — terrain + physics core, headless — ✅ DONE 2026-07-31
Landed: `WormsTerrain.js`, `WormsPhysics.js`, `tools/verifyWorms.js` sections 19.
- [x] Seeded RNG + integer-hash value noise, no `Math.random` anywhere in the sim
- [x] Island generation — heightfield crust, coastline envelope, noise caves, arches, speck prune
- [x] Cavern generation — ceiling/floor band, interior pillars and ledges, largest-void filter, ROCK frame
- [x] `carveCircle` / `addCircle` / `addGirder`, ROCK immunity, order independence
- [x] `raycast`, `normalAt`, `surfaceY`, `firstSolidBelow`, `hashMask`
- [x] Worm locomotion — step-up walking, jump, backflip, ballistic flight, fall damage
- [x] Projectiles — swept collision, bounce, fuse, `maxAge`, wind, homing
- [x] Ninja rope — raycast anchor, pendulum, corner push/pop wrapping
- [x] Blast geometry — linear falloff, impulse direction
- [x] Fairness lint + `generateValidMap` retry loop
### Wave 0 findings
1. **A plain noise threshold cannot generate a cavern.** The base octave decides huge regions
at once, so thresholding fBm gives *one* usable chamber and welds two thirds of the map
shut — measured 70 % solid with the right-hand 60 % of the map unreachable. The fix is to
carve the void as a band between a wavy ceiling and a wavy floor, which is connected across
the full width **by construction**, and get the interest from what is put *back*: pillars,
stalactites and floating ledges from a second noise field. Verifier pins this: "cavern void
spans the full width" requires >98 % of columns to contain air.
2. **Only the ROOT rope anchor is attached to terrain.** Every later anchor is a corner the
rope merely bends around, and by construction it sits in AIR (it is the last *free* point
before the raycast contact). Validating those the same way as the root made the rope
release itself the instant it wrapped anything — 14 of 35 swings dropped mid-flight. Bends
are geometry, not attachments.
3. **Sub-pixel residue from a landing rides along forever.** The mask is only sampled at
integers, so a worm that settles at y=290.625 stands a visible pixel higher than one at
y=291 and reports a different standing height for the rest of the match — it made a
correct 6px step-climb look 1.4px wrong. `snapToGround` now rounds to integers on landing.
4. **`resolveOverlap` has to be a per-frame safety net, not just a landing fixup.** Backing
off 1.5px along the surface normal after a bounce is not enough in a concave corner, and
terrain can be built or destroyed around an airborne worm. Without the net, a randomised
walk had a worm inside terrain on 14 of 14 400 frames. The invariant worth having is
absolute: **a worm is never inside terrain at the end of a step.**
5. **A 6px drop is a step, not a fall — and that is load-bearing.** `MAX_STEP` (10px) governs
both climbing up and walking down. Terrain features below it are invisible to movement;
features above it are walls. This is what makes terrain shape tactical rather than
incidental, and it means "walked off a ledge" tests need a drop taller than `MAX_STEP`.
6. **Explosion falloff divides by the radius.** A zero-radius blast (which a weapon table
typo will produce sooner or later) put NaN into worm velocities and from there into the
whole sim. Guarded at the source.
### Wave 0 baselines
| Measurement | Result |
|---|---|
| Map generation, medium island / cavern | 93 ms / 199 ms |
| Map generation, large island / cavern | 148 ms / 266 ms |
| `generateValidMap` retries | 1.01.2 mean, ≤8 worst |
| `carveCircle` r=30 / r=75 / r=120 | 0.011 / 0.033 / 0.080 ms |
| 8 worms × 120 substeps (1 s of sim) | 6.0 ms |
| 500 AI shot replays | 40 ms |
| Bazooka range, full power, flat ground | ~1030 px (matches `2v²sin θ cos θ / g` to 2 %) |
The AI budget risk in the original plan is **resolved**: at 0.08 ms per shot replay the AI can
evaluate a few thousand candidates inside a 200 ms turn, so no coarse-to-fine sweep is needed.
Terrain repaint is a non-issue on the logic side; the CanvasTexture upload still wants
measuring in the browser during Wave 1.
---
## Wave 1 — rules engine + one playable match — ✅ DONE 2026-07-31
Landed: the 16-weapon table, the match rules engine, the 8 theme palettes, the renderer,
the scene, the tutorial, and all five wiring touchpoints. Verifier sections 1012 added.
- [x] `WormsWeapons.js` — 16 weapons + surrender, crate table, charge curve
- [x] `WormsLogic.js` — teams, turn rotation, timer, retreat, wind, crates, mines, barrels,
sudden death + rising water, drowning, corpse blasts, chain reactions, win check
- [x] `WormsThemes.js` — 8 palettes with named art slots
- [x] `WormsRender.js` — terrain RenderTexture, procedural worms, water, explosions
- [x] `WormsGame.js` — mode select, quick-match setup, camera, HUD, weapon panel,
keyboard + slingshot mouse aim, 2-player hotseat
- [x] Wiring: registry (iconFrame 92), `main.js`, `slugDispatch`, manifest resolver, preload
- [x] Verifier §10 weapon table, §11 match rules, §12 scripted match soak
**9 of 16 weapons are live** (bazooka, grenade, cluster, shotgun, uzi, fire punch, dynamite,
mine, holy hand grenade, plus skip/surrender). The `target` and `utility` kinds — air strike,
homing missile, blowtorch, girder, teleport, ninja rope — deliberately **refuse to fire**
rather than silently eating the ammo and the turn; the verifier pins that. The rope already
works in physics and is reachable from the scene, it just has no ammo accounting yet.
### Wave 1 findings
1. **`pendingDeath` must not block the settle phase.** "Is the world quiet?" and "is anything
waiting to be resolved?" are different questions. Treating a worm at 0 HP as *still
moving* stranded every kill until the 14-second settle timeout fired, turning a two-second
turn into a sixteen-second one. Deaths are resolved by the phase that comes *after* settle.
2. **A body has to be seated by its own radius.** Barrels were being placed 8 px above the
surface with a 13 px radius, so they started half-buried — and a half-buried body is never
"grounded" by the contact test, so it fell through its own first frame.
3. **A two-shot weapon costs one ammo for the pair.** Spending per trigger pull halves the
shotgun. The fix is to spend only on the pull that *starts* the pair, which needs the
decision made before the switch, not inside the multi-shot bookkeeping.
4. **Phaser Containers swallow `scrollFactor` the same way they swallow depth.** Parenting
the parallax layers to one backdrop container made every layer move at the container's
rate — the backdrop looked painted onto the terrain. `makeBackdrop` returns loose root
objects with explicit depths instead. (Same family as the container-depth gotcha already
burned into the repo.)
5. **"Leave match" leaves `this.state` set.** Tearing down the graphics while the state
object survives means the next `update()` draws into destroyed objects. A single
`matchLive` flag set by `teardownMatch`/`buildWorld` guards every path — rematch, menu,
scene shutdown — instead of null-checking six fields.
6. **Craters must be smaller than the damage radius.** Otherwise every hit that hurts also
opens a hole big enough to fall through, and matches devolve into everyone drowning.
Pinned by the verifier across the whole weapon table.
### Wave 1 baselines
| Measurement | Result |
|---|---|
| Scripted match, 4v4 | terminates 30/30, 0 NaN, 0 draws, mean 50 turns |
| Wall time per scripted match | ~5.7 s (~2.5 s with `retreatSeconds: 0`) |
| Whole-match determinism | same seed → same `hashState` |
The soak cost matters for Wave 3: 400 AI matches at ~5.7 s is 38 minutes. The soak config
sets `retreatSeconds: 0` (the retreat wait is dead time headlessly), which is why §12 runs
24 matches by default and `--games=400` is the long run.
## Wave 2 — full arsenal and rule extras — ⬜ NEXT
- [ ] `target` kind: air strike, homing missile, girder, teleport (click-to-place flow)
- [ ] `utility` kind: blowtorch, ninja rope ammo + turn integration
- [ ] Verifier §13: a fixture per weapon pinning damage, blast and behaviour
## Wave 3 — AI and campaign ladder — ⬜
## Wave 4 — presentation, themes, 2-player — ⬜
## Wave 5 — retune after Brian's playtest — ⬜
---
## Sources for original-game behaviour
Worms Armageddon (Team17, 1999): 45 s default turn with 5 s retreat, 100 HP worms, wind
re-rolled each turn and affecting only unpowered arcing weapons, fall damage from the peak of
the fall, sudden death dropping every worm to 1 HP with the water rising per turn, crates
parachuting in between turns, and the ninja rope wrapping terrain corners rather than passing
through them.

View File

@ -47,31 +47,6 @@ function imagesFrom(scene, jsonKey) {
.map((a) => ({ type: 'image', key: a.key, path: a.path }));
}
// Worms art is two levels deep (theme → slot) and mixes plain images with a
// spritesheet, so it gets its own resolver rather than bending sheetsFrom.
// Slots whose `path` is still null render procedurally from the theme palette
// in src/games/worms/WormsThemes.js, so an empty result here is the normal,
// fully playable state — not a missing asset.
function wormsArt(scene) {
const art = scene.cache.json.get('worms-artwork');
if (!art) return [];
const out = [];
const add = (slot, asSheet) => {
if (!slot || !slot.key || !slot.path) return;
out.push(asSheet
? { type: 'spritesheet', key: slot.key, path: slot.path, frameWidth: slot.frameWidth, frameHeight: slot.frameHeight }
: { type: 'image', key: slot.key, path: slot.path });
};
for (const theme of Object.values(art.themes ?? {})) {
add(theme.backdrop, false);
add(theme.fill, false);
add(theme.surface, false);
add(theme.objects, true);
}
for (const slot of Object.values(art.shared ?? {})) add(slot, true);
return out;
}
// Drop-in per-game soundtrack tracks declared in a cached `<name>-music.json`
// (see services/soundtrack.js). Audio bytes lazy-load only when the owning
// game is entered — never part of the shared default-soundtrack preload.
@ -86,12 +61,6 @@ const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', ke
const image = (key, path) => ({ type: 'image', key, path });
export const MANIFEST = {
// All of Worms' art is optional drop-in theme art; the game is fully
// playable with none of it (see data/worms-artwork.json).
worms: [
image('worms-bg-menu', 'assets/images/worms/background-menu.png'),
(scene) => wormsArt(scene),
],
gootower: [
image('gootower-bg', 'assets/images/gootower/background-1.png'),
image('gootower-pipe', 'assets/images/gootower/pipe.png'),

View File

@ -118,5 +118,4 @@ registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-co
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 });
registerGame({ slug: 'worms', name: 'Worms', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 92 });
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });

View File

@ -53,7 +53,7 @@ export function autoPlay(level, { maxBalls = 60, maxSeconds = 60, seed } = {}) {
// 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
const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held && !b.asleep
&& (!step.type || b.type === step.type));
if (!ball) break;
beginDrag(st, ball, null);
@ -75,8 +75,8 @@ export function autoPlay(level, { maxBalls = 60, maxSeconds = 60, seed } = {}) {
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);
const ball = st.balls.find((b) => !b.dead && !b.attached && !b.held && !b.asleep && !b.walking)
|| st.balls.find((b) => !b.dead && !b.attached && !b.held && !b.asleep);
if (!ball) break;
while (ti < targets.length - 1

View File

@ -63,6 +63,7 @@ export default class GooTowerEditor extends Phaser.Scene {
this.kind = 'solid';
this.gooType = 'common';
this.pinned = false;
this.asleep = false;
this.pendingPoly = null;
this.manifest = [];
this.undoStack = [];
@ -126,7 +127,7 @@ export default class GooTowerEditor extends Phaser.Scene {
...(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 })),
pile: this.pileItems.map((p) => ({ x: p.x, y: p.y, type: p.type, ...(p.asleep ? { asleep: true } : {}) })),
pipe: { x: this.pipe.x, y: this.pipe.y, r: this.pipe.r },
required: this.meta.required,
ocdTarget: this.meta.ocdTarget,
@ -224,6 +225,7 @@ export default class GooTowerEditor extends Phaser.Scene {
<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-asleep" type="checkbox" ${this.asleep ? 'checked' : ''}> Pile goo starts asleep</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('')}
@ -253,6 +255,7 @@ export default class GooTowerEditor extends Phaser.Scene {
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-asleep').addEventListener('change', (ev) => { this.asleep = 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; });
@ -283,9 +286,10 @@ export default class GooTowerEditor extends Phaser.Scene {
refreshStats() {
if (!this.statsEl) return;
const asleep = this.pileItems.filter((p) => p.asleep).length;
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}`;
+ `pile ${this.pileItems.length}${asleep ? ` (${asleep} asleep)` : ''} · needs ${this.meta.required}, OCD ${this.meta.ocdTarget}`;
}
// ── Input ─────────────────────────────────────────────────────────────────
@ -347,7 +351,7 @@ export default class GooTowerEditor extends Phaser.Scene {
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 });
this.pileItems.push({ x: Math.round(x), y: Math.round(y), type: this.gooType, asleep: this.asleep });
} else if (this.tool === 'fan') {
if (!this.pendingFan) {
this.pendingFan = { x: Math.round(x), y: Math.round(y) };
@ -624,7 +628,7 @@ export default class GooTowerEditor extends Phaser.Scene {
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.pileItems = (def.pile ?? []).map((p) => ({ x: p.x, y: p.y, type: p.type || 'common', asleep: !!p.asleep }));
this.pipe = def.pipe ? { ...def.pipe, r: def.pipe.r || TUNING.PIPE_R } : this.pipe;
this.pendingPoly = null;
this.touch();
@ -728,6 +732,20 @@ export default class GooTowerEditor extends Phaser.Scene {
g.lineStyle(1, 0xffffff, 0.35);
g.strokeCircle(this.sx(b.x), this.sy(b.y), r + 1);
}
// Static stand-in for the in-game flashing zZzZz — the editor doesn't
// animate its preview, so one hollow Z is enough to mark it at a glance.
if (!structural && b.asleep) {
const zx = this.sx(b.x) + r * 0.7;
const zy = this.sy(b.y) - r * 1.1;
const s = 8;
g.lineStyle(2, 0xdcefff, 0.9);
g.beginPath();
g.moveTo(zx - s / 2, zy - s / 2);
g.lineTo(zx + s / 2, zy - s / 2);
g.lineTo(zx - s / 2, zy + s / 2);
g.lineTo(zx + s / 2, zy + s / 2);
g.strokePath();
}
}
// Live attachment preview for the ball tool — the same call the game makes,

View File

@ -1338,10 +1338,12 @@ export default class GooTowerGame extends Phaser.Scene {
const offLen = Math.hypot(dx, dy);
if (offLen > maxOff) { dx = (dx / offLen) * maxOff; dy = (dy / offLen) * maxOff; }
// Asleep goo (see drawSleepZs) always reads as eyes-shut rather than
// blinking on the normal desynced cycle.
const blinkPeriod = 2.6 + (b.id % 7) * 0.45;
const blinkDur = 0.14;
const bt = (this.state.clock + seed * 3.1) % blinkPeriod;
const closeAmt = bt < blinkDur ? Math.sin((bt / blinkDur) * Math.PI) : 0;
const closeAmt = b.asleep ? 1 : (bt < blinkDur ? Math.sin((bt / blinkDur) * Math.PI) : 0);
const eyeScaleY = Math.max(0.12, 1 - closeAmt);
const spread = eyes === 2 ? b.r * 0.38 : 0;
@ -1353,6 +1355,31 @@ export default class GooTowerGame extends Phaser.Scene {
g.fillStyle(0x101018, 1);
g.fillEllipse(ex + dx, ey + dy * eyeScaleY, pr * 2, pr * 2 * eyeScaleY);
}
if (b.asleep) this.drawSleepZs(g, x, y, b.r, seed);
}
// A cascade of three hollow Z's climbing up and to the right of an asleep
// ball's head, faded in/out together on a per-ball-desynced cycle (same
// seed as the blink above) so a pile of sleepers doesn't flash in unison.
drawSleepZs(g, x, y, r, seed) {
const period = 2.2;
const t = (this.state.clock + seed * 1.9) % period;
const alpha = Math.max(0, Math.sin((t / period) * Math.PI * 2)) ** 0.6;
if (alpha < 0.03) return;
g.lineStyle(2.4, 0xdcefff, alpha * 0.95);
const sizes = [7, 10, 13];
for (let i = 0; i < 3; i += 1) {
const s = sizes[i];
const zx = x + r * 0.55 + i * (r * 0.42);
const zy = y - r * 1.15 - i * (r * 0.5);
g.beginPath();
g.moveTo(zx - s / 2, zy - s / 2);
g.lineTo(zx + s / 2, zy - s / 2);
g.lineTo(zx - s / 2, zy + s / 2);
g.lineTo(zx + s / 2, zy + s / 2);
g.strokePath();
}
}
// A tapering blob stretching from (x, y) toward unit direction (dirX, dirY),

View File

@ -290,7 +290,7 @@ export function createState(level, seed = 0x60071e) {
// Loose goo waiting to be dragged. Given positions so it can pile up and be
// simulated like everything else.
for (const p of level.pile || []) {
const b = addBall(state, p.x, p.y, p.type || 'common', { free: true });
const b = addBall(state, p.x, p.y, p.type || 'common', { free: true, asleep: !!p.asleep });
state.pile.push(b.id);
}
refreshStructure(state);
@ -314,6 +314,11 @@ export function addBall(state, x, y, type = 'common', opts = {}) {
fuse: null, // seconds until detonation once lit
exploded: false,
held: false, // under the player's cursor; physics leaves it alone
// Authored-asleep pile goo: physics (gravity, terrain, ball collision)
// still applies, but it is excluded from wanderLoose/recruitWalkers and
// cannot be picked up, so it just sits there, until tryWake finds a
// straight, terrain-unobstructed line to some attached ball.
asleep: !!opts.asleep,
grounded: false,
wasDetached: false, // true when an attached ball was detached mid-drag
detachTime: 0, // simulation clock time when detached (0 = not recently detached)
@ -1015,7 +1020,7 @@ export function pickBallAt(state, x, y, reach = 42) {
}
export function beginDrag(state, ball, events = null) {
if (!ball || ball.dead) return false;
if (!ball || ball.dead || ball.asleep) return false;
// Cooldown check: if recently detached, block pickup until cooldown expires
if (ball.detachTime > 0) {
const elapsed = state.clock - ball.detachTime;
@ -1161,7 +1166,7 @@ function recruitWalkers(state, events) {
const grid = state.grid;
if (!grid) return;
for (const b of state.balls) {
if (b.dead || b.attached || b.walking || b.held) continue;
if (b.dead || b.attached || b.walking || b.held || b.asleep) continue;
const cx = Math.floor(b.x / cell);
const cy = Math.floor(b.y / cell);
let target = null;
@ -1250,6 +1255,19 @@ function advanceWalkers(state, dt, events) {
}
}
// An asleep ball wakes the moment a straight line to some attached ball is
// no longer crossed by solid terrain -- reusing the same lineBlocked test
// that stops a dropped ball attaching through a wall. It does not have to be
// the NEAREST attached ball: any unobstructed one is a "clear path". Once
// awake it is just an ordinary loose ball and falls back to wanderLoose /
// recruitWalkers like everything else.
function tryWake(state, b) {
for (const a of state.balls) {
if (!a.attached || a.dead) continue;
if (!lineBlocked(state, b.x, b.y, a.x, a.y)) { b.asleep = false; return; }
}
}
// Loose goo resting on terrain walks toward the nearest part of the structure.
// Re-targeting is throttled to WANDER_HZ because it is O(loose x attached) and
// would otherwise run 240 times a second.
@ -1261,6 +1279,10 @@ function wanderLoose(state, dt) {
for (const b of state.balls) {
if (b.dead || b.attached || b.walking || b.held) continue;
if (b.asleep) {
if (retarget) tryWake(state, b);
continue;
}
if (retarget) {
b.wanderDir = 0;
let bestD = Infinity;
@ -1416,6 +1438,7 @@ export function hashState(state) {
mix(b.x); mix(b.y); mix(b.px); mix(b.py);
mix(b.dead ? 1 : 0);
mix(b.attached ? 1 : 0);
mix(b.asleep ? 1 : 0);
mix(b.walking ? b.onStrand + b.t : -1);
}
for (const s of state.strands) mix(s.broken ? 1 : 0);

View File

@ -1,804 +0,0 @@
// Worms — the Phaser scene.
//
// Holds no rules. It drives WormsLogic with input, replays the event list the
// simulation returns as camera moves, sound and particles, and draws the world.
// Anything that decides an outcome belongs in WormsLogic, so the verifier can
// reach it.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { addFullscreenButton } from '../../ui/FullscreenButton.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { SFX, playSound, playSoundEx } from '../../ui/Sounds.js';
import { MAP_SIZES } from './WormsTerrain.js';
import { fireRope, releaseRope } from './WormsPhysics.js';
import { THEMES, getTheme } from './WormsThemes.js';
import { getWeapon } from './WormsWeapons.js';
import {
createMatch, stepMatch, activeWorm, canAct, availableWeapons, livingWorms,
teamHealth, setMove, setRopeInput, setAim, jump, selectWeapon, setFuse,
beginCharge, fire,
} from './WormsLogic.js';
import {
makeTerrainSurface, makeBackdrop, drawWorms, drawWormLabels, drawObjects,
drawRope, drawWater, spawnExplosion, spawnDamageNumber, spawnTracer,
} from './WormsRender.js';
const MODES = [
{ id: 'ai', label: '1 PLAYER vs COMPUTER' },
{ id: 'hotseat', label: '2 PLAYER HOTSEAT' },
];
const TEAM_COLORS = [0xe05a4a, 0x4a8fe0, 0x5ac86a, 0xe0c04a];
const TEAM_NAMES = ['Red', 'Blue', 'Green', 'Gold'];
const FONT = '"Julius Sans One"';
export default class WormsGame extends Phaser.Scene {
constructor() { super('WormsGame'); }
init(data) {
this.gameDef = data?.game ?? { slug: 'worms', name: 'Worms' };
this.mode = 'ai';
this.state = null;
this.surface = null;
this.music = null;
this.screenObjs = [];
this.worldObjs = [];
this.hudObjs = [];
this.setup = {
theme: 'forest',
shape: 'island',
size: 'medium',
wormsPerTeam: 4,
difficulty: 3,
teams: 2,
};
this.charging = false;
this.aimHold = 0;
this.dragAim = null;
this.followTarget = null;
this.cameraFree = false;
this.matchLive = false;
this.aiTurn = -1;
this.aiActAt = Infinity;
this.quitArmedAt = 0;
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bg);
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
this.events.once('shutdown', () => this.teardownMatch());
this.showModeSelect();
}
// -------------------------------------------------------------------------
// Screens
// -------------------------------------------------------------------------
clearScreen() {
for (const o of this.screenObjs) o.destroy();
this.screenObjs = [];
}
track(o) { this.screenObjs.push(o); return o; }
title(text, y = 120, size = 72) {
return this.track(this.add.text(GAME_WIDTH / 2, y, text, {
fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.textHex,
}).setOrigin(0.5).setScrollFactor(0).setDepth(100));
}
showModeSelect() {
this.clearScreen();
this.cameras.main.setScroll(0, 0).setZoom(1).setBounds(0, 0, GAME_WIDTH, GAME_HEIGHT);
// Background image (if loaded).
if (this.textures.exists('worms-bg-menu')) {
this.track(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'worms-bg-menu')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1));
}
MODES.forEach((m, i) => {
this.track(new Button(this, GAME_WIDTH / 2, 800 + i * 96, m.label, () => {
this.mode = m.id;
this.showSetup();
}, { width: 560, height: 72 }));
});
this.track(new Button(this, GAME_WIDTH / 2, 1020, 'Leave', () => {
this.scene.start('GameMenu');
}, { width: 260, height: 60 }));
this.track(addFullscreenButton(this));
}
showSetup() {
this.clearScreen();
this.title(this.mode === 'hotseat' ? '2 PLAYER SETUP' : 'QUICK MATCH', 90, 60);
const rows = [
{ key: 'theme', label: 'Theme', values: THEMES.map((t) => t.id), display: (v) => getTheme(v).name },
{ key: 'shape', label: 'Landscape', values: ['island', 'cavern'], display: (v) => v === 'island' ? 'Island' : 'Cavern' },
{ key: 'size', label: 'Map size', values: ['small', 'medium', 'large'], display: (v) => MAP_SIZES[v].name },
{ key: 'wormsPerTeam', label: 'Worms per team', values: [2, 3, 4, 5, 6], display: (v) => `${v}` },
{ key: 'teams', label: 'Teams', values: this.mode === 'hotseat' ? [2] : [2, 3, 4], display: (v) => `${v}` },
];
if (this.mode === 'ai') {
rows.push({ key: 'difficulty', label: 'Difficulty', values: [1, 2, 3, 4, 5], display: (v) => '★'.repeat(v) });
}
const startY = 210, rowH = 76;
rows.forEach((row, i) => {
const y = startY + i * rowH;
this.track(this.add.text(GAME_WIDTH / 2 - 340, y, row.label, {
fontFamily: FONT, fontSize: '30px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(100));
const valueText = this.track(this.add.text(GAME_WIDTH / 2 + 150, y, row.display(this.setup[row.key]), {
fontFamily: FONT, fontSize: '30px', color: COLORS.accentHex,
}).setOrigin(0.5, 0.5).setDepth(100));
// A value carried over from the other mode may not be offered here
// (hotseat is always 2 teams), so snap it back before it can be shown.
if (!row.values.includes(this.setup[row.key])) {
this.setup[row.key] = row.values[0];
valueText.setText(row.display(this.setup[row.key]));
}
const cycle = (dir) => {
const idx = row.values.indexOf(this.setup[row.key]);
const next = (idx + dir + row.values.length) % row.values.length;
this.setup[row.key] = row.values[next];
valueText.setText(row.display(this.setup[row.key]));
};
this.track(new Button(this, GAME_WIDTH / 2 + 40, y, '', () => cycle(-1), { width: 56, height: 52 }));
this.track(new Button(this, GAME_WIDTH / 2 + 260, y, '', () => cycle(1), { width: 56, height: 52 }));
});
this.track(new Button(this, GAME_WIDTH / 2 - 170, 780, 'Back', () => this.showModeSelect(), { width: 240, height: 64 }));
this.track(new Button(this, GAME_WIDTH / 2 + 170, 780, 'Start match', () => this.startMatch(), { width: 300, height: 64 }));
}
// -------------------------------------------------------------------------
// Match setup
// -------------------------------------------------------------------------
startMatch() {
this.clearScreen();
const note = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Sculpting the landscape…', {
fontFamily: FONT, fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setScrollFactor(0).setDepth(200);
// Generation is ~100-270ms of straight-line work, so let the browser paint
// the notice before we block on it.
this.time.delayedCall(30, () => {
const teamCount = this.setup.teams;
const teams = [];
for (let i = 0; i < teamCount; i++) {
const human = this.mode === 'hotseat' ? true : i === 0;
teams.push({
id: `t${i}`,
name: this.mode === 'hotseat' ? `Player ${i + 1}` : (i === 0 ? 'You' : TEAM_NAMES[i]),
color: TEAM_COLORS[i],
controller: human ? 'human' : 'ai',
skill: this.setup.difficulty,
});
}
const seed = (Math.floor(Math.random() * 0x7fffffff)) >>> 0;
this.state = createMatch({
seed,
mapSeed: seed,
shape: this.setup.shape,
size: this.setup.size,
theme: this.setup.theme,
wormsPerTeam: this.setup.wormsPerTeam,
teams,
});
note.destroy();
this.buildWorld();
this.bindInput();
this.snapCameraToActive(true);
});
}
teardownMatch() {
// Guards update(): the graphics below are about to be destroyed, and
// "Menu" leaves this.state set so a stray frame would draw into them.
this.matchLive = false;
if (this.weaponPanel) { this.weaponPanel.destroy(); this.weaponPanel = null; }
if (this.surface) { this.surface.destroy(); this.surface = null; }
for (const o of this.worldObjs) o.destroy();
for (const o of this.hudObjs) o.destroy();
this.worldObjs = [];
this.hudObjs = [];
}
buildWorld() {
this.teardownMatch();
const t = this.state.terrain;
const theme = this.setup.theme;
this.cameras.main.setBounds(-200, -400, t.w + 400, t.h + 700);
this.cameras.main.setBackgroundColor(getTheme(theme).sky[0]);
const push = (o, depth) => { o.setDepth(depth); this.worldObjs.push(o); return o; };
// makeBackdrop returns loose root objects, not a container, so each
// parallax layer keeps its own scrollFactor.
for (const o of makeBackdrop(this, t, theme, 0)) this.worldObjs.push(o);
this.surface = makeTerrainSurface(this, t, theme, 10);
this.gObjects = push(this.add.graphics(), 12);
this.gRope = push(this.add.graphics(), 13);
this.gWorms = push(this.add.graphics(), 14);
this.gAim = push(this.add.graphics(), 15);
this.gLabels = push(this.add.graphics(), 16);
this.fxLayer = push(this.add.container(0, 0), 20);
this.gWater = push(this.add.graphics(), 25);
this.buildHud();
this.matchLive = true;
}
// -------------------------------------------------------------------------
// HUD
// -------------------------------------------------------------------------
buildHud() {
const hud = (o) => { o.setScrollFactor(0).setDepth(60); this.hudObjs.push(o); return o; };
this.hudPanel = hud(this.add.graphics());
this.hudTimer = hud(this.add.text(GAME_WIDTH / 2, 40, '45', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.textHex,
stroke: '#000000', strokeThickness: 6,
}).setOrigin(0.5, 0));
this.hudTurn = hud(this.add.text(GAME_WIDTH / 2, 116, '', {
fontFamily: FONT, fontSize: '28px', color: COLORS.accentHex,
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5, 0));
this.hudWeapon = hud(this.add.text(40, GAME_HEIGHT - 96, '', {
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0, 0));
this.hudTip = hud(this.add.text(40, GAME_HEIGHT - 54, '', {
fontFamily: FONT, fontSize: '22px', color: COLORS.mutedHex,
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0));
this.hudTeams = this.state.teams.map((team, i) => hud(this.add.text(40, 34 + i * 34, '', {
fontFamily: FONT, fontSize: '26px',
color: `#${team.color.toString(16).padStart(6, '0')}`,
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0, 0)));
this.hudWind = hud(this.add.graphics());
this.hudWindText = hud(this.add.text(GAME_WIDTH - 40, 34, 'WIND', {
fontFamily: FONT, fontSize: '22px', color: COLORS.mutedHex,
stroke: '#000000', strokeThickness: 3,
}).setOrigin(1, 0));
this.hudPower = hud(this.add.graphics());
this.hudBanner = hud(this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, '', {
fontFamily: 'Righteous', fontSize: '78px', color: COLORS.textHex,
stroke: '#000000', strokeThickness: 8, align: 'center',
}).setOrigin(0.5).setAlpha(0));
this.weaponPanel = null;
}
refreshHud() {
const st = this.state;
if (!st) return;
const t = Math.max(0, st.phase === 'retreat' ? st.retreatTime : st.turnTime);
this.hudTimer.setText(st.phase === 'settle' || st.phase === 'resolve' ? '…' : `${Math.ceil(t)}`);
this.hudTimer.setColor(st.phase === 'retreat' ? '#ffd24a' : (t <= 5 ? '#ff6a5a' : COLORS.textHex));
const team = st.teams[st.activeTeam];
const worm = activeWorm(st);
this.hudTurn.setText(worm && team
? `${team.name}${worm.name}${st.suddenDeath ? ' · SUDDEN DEATH' : ''}`
: '');
for (let i = 0; i < this.hudTeams.length; i++) {
const tm = st.teams[i];
const hp = teamHealth(st, i);
const alive = livingWorms(st, i).length;
this.hudTeams[i].setText(`${tm.name} ${hp} (${alive})`);
this.hudTeams[i].setAlpha(st.activeTeam === i ? 1 : 0.65);
}
const w = getWeapon(st.selectedWeapon);
const ammo = team ? team.ammo[st.selectedWeapon] : 0;
this.hudWeapon.setText(w ? `${w.name}${ammo === Infinity ? '' : ` ×${ammo}`}${w.fuseOptions ? ` fuse ${st.fuse}s` : ''}` : '');
this.hudTip.setText(w?.tip ?? '');
// Wind arrow.
const g = this.hudWind;
g.clear();
const wx = GAME_WIDTH - 220, wy = 78;
g.fillStyle(0x000000, 0.35);
g.fillRoundedRect(wx - 10, wy - 14, 190, 28, 6);
const mag = Math.abs(st.wind);
const dir = Math.sign(st.wind) || 1;
g.fillStyle(mag > 0.6 ? 0xff6a5a : mag > 0.3 ? 0xffd24a : 0x8fd8ff, 1);
const barW = 84 * mag;
if (dir > 0) g.fillRect(wx + 86, wy - 7, barW, 14);
else g.fillRect(wx + 86 - barW, wy - 7, barW, 14);
g.lineStyle(2, 0xffffff, 0.7);
g.lineBetween(wx + 86, wy - 12, wx + 86, wy + 12);
// Power bar while charging.
const p = this.hudPower;
p.clear();
if (st.charging || st.power > 0) {
const pw = 420, ph = 20, px = GAME_WIDTH / 2 - pw / 2, py = GAME_HEIGHT - 130;
p.fillStyle(0x000000, 0.55);
p.fillRoundedRect(px - 3, py - 3, pw + 6, ph + 6, 4);
p.fillStyle(0xffd24a, 1);
p.fillRect(px, py, pw * st.power, ph);
p.lineStyle(2, 0xffffff, 0.8);
p.strokeRoundedRect(px - 3, py - 3, pw + 6, ph + 6, 4);
}
}
banner(text, ms = 1600) {
this.hudBanner.setText(text).setAlpha(1).setScale(0.8);
this.tweens.add({ targets: this.hudBanner, scale: 1, duration: 220, ease: 'Back.easeOut' });
this.tweens.add({ targets: this.hudBanner, alpha: 0, delay: ms, duration: 380 });
}
toggleWeaponPanel(force = null) {
if (this.weaponPanel) {
this.weaponPanel.destroy();
this.weaponPanel = null;
if (force !== true) return;
}
if (force === false) return;
const st = this.state;
const list = availableWeapons(st);
const cols = 4;
const cw = 250, ch = 74;
const rows = Math.ceil(list.length / cols);
const pw = cols * cw + 40, ph = rows * ch + 80;
const px = GAME_WIDTH / 2 - pw / 2, py = GAME_HEIGHT / 2 - ph / 2;
const panel = this.add.container(0, 0).setScrollFactor(0).setDepth(80);
const bg = this.add.graphics();
bg.fillStyle(0x11100c, 0.94);
bg.fillRoundedRect(px, py, pw, ph, 10);
bg.lineStyle(3, COLORS.accent, 1);
bg.strokeRoundedRect(px, py, pw, ph, 10);
panel.add(bg);
panel.add(this.add.text(GAME_WIDTH / 2, py + 22, 'WEAPONS', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
}).setOrigin(0.5, 0));
list.forEach((entry, i) => {
const cx = px + 20 + (i % cols) * cw;
const cy = py + 62 + Math.floor(i / cols) * ch;
const selected = entry.id === st.selectedWeapon;
const box = this.add.graphics();
box.fillStyle(selected ? 0x3a3320 : 0x1e1a12, 1);
box.fillRoundedRect(cx, cy, cw - 10, ch - 10, 6);
box.lineStyle(2, selected ? COLORS.accent : 0x4a4438, 1);
box.strokeRoundedRect(cx, cy, cw - 10, ch - 10, 6);
panel.add(box);
const label = this.add.text(cx + 12, cy + 12,
`${entry.weapon.name}\n${entry.ammo === Infinity ? '∞' : `×${entry.ammo}`}`, {
fontFamily: FONT, fontSize: '21px', color: COLORS.textHex, lineSpacing: 2,
});
panel.add(label);
const hit = this.add.rectangle(cx + (cw - 10) / 2, cy + (ch - 10) / 2, cw - 10, ch - 10, 0xffffff, 0.001)
.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => {
selectWeapon(st, entry.id);
this.toggleWeaponPanel(false);
});
panel.add(hit);
});
this.weaponPanel = panel;
}
// -------------------------------------------------------------------------
// Input
// -------------------------------------------------------------------------
bindInput() {
if (this.inputBound) return;
this.inputBound = true;
this.keys = this.input.keyboard.addKeys({
left: 'LEFT', right: 'RIGHT', up: 'UP', down: 'DOWN',
fire: 'SPACE', jump: 'ENTER', flip: 'BACKSPACE',
panel: 'TAB', skip: 'K', zoomIn: 'PLUS', zoomOut: 'MINUS',
one: 'ONE', two: 'TWO', three: 'THREE', four: 'FOUR', five: 'FIVE',
six: 'SIX', seven: 'SEVEN', eight: 'EIGHT', nine: 'NINE',
});
this.input.keyboard.on('keydown-TAB', (e) => { e.preventDefault(); this.toggleWeaponPanel(); });
this.input.keyboard.on('keydown-ENTER', () => jump(this.state, 'forward'));
this.input.keyboard.on('keydown-BACKSPACE', (e) => { e.preventDefault(); jump(this.state, 'backflip'); });
this.input.keyboard.on('keydown-K', () => this.doSkip());
this.input.keyboard.on('keydown-F', () => this.cycleFuse());
this.input.keyboard.on('keydown-ESC', () => this.confirmQuit());
// Number keys cycle the weapons in that panel slot, like the original.
for (let n = 1; n <= 9; n++) {
this.input.keyboard.on(`keydown-${['ONE','TWO','THREE','FOUR','FIVE','SIX','SEVEN','EIGHT','NINE'][n - 1]}`,
() => this.cycleSlot(n));
}
this.input.on('wheel', (p, o, dx, dy) => {
const cam = this.cameras.main;
const before = cam.getWorldPoint(p.x, p.y);
const z = Phaser.Math.Clamp(cam.zoom * (dy > 0 ? 0.9 : 1.1), 0.45, 2.4);
cam.setZoom(z);
const after = cam.getWorldPoint(p.x, p.y);
cam.scrollX += before.x - after.x;
cam.scrollY += before.y - after.y;
this.cameraFree = true;
});
this.input.on('pointerdown', (p) => {
if (p.rightButtonDown()) { this.panFrom = { x: p.x, y: p.y, sx: this.cameras.main.scrollX, sy: this.cameras.main.scrollY }; return; }
if (this.weaponPanel) return;
if (!canAct(this.state)) return;
const worm = activeWorm(this.state);
if (!worm) return;
// Slingshot aim: drag away from the worm to set angle and power.
const wp = this.cameras.main.getWorldPoint(p.x, p.y);
this.dragAim = { x: wp.x, y: wp.y, worm };
});
this.input.on('pointermove', (p) => {
if (this.panFrom && p.rightButtonDown()) {
const cam = this.cameras.main;
cam.setScroll(this.panFrom.sx - (p.x - this.panFrom.x) / cam.zoom,
this.panFrom.sy - (p.y - this.panFrom.y) / cam.zoom);
this.cameraFree = true;
return;
}
if (this.dragAim) {
const wp = this.cameras.main.getWorldPoint(p.x, p.y);
this.dragAim.x = wp.x;
this.dragAim.y = wp.y;
}
});
this.input.on('pointerup', (p) => {
if (this.panFrom) { this.panFrom = null; return; }
if (!this.dragAim) return;
const { worm } = this.dragAim;
const dx = this.dragAim.x - worm.x, dy = this.dragAim.y - worm.y;
const dist = Math.hypot(dx, dy);
const drag = this.dragAim;
this.dragAim = null;
if (dist < 24) return; // a click, not a drag
const angle = Math.atan2(dy, dx);
const power = Phaser.Math.Clamp(dist / 260, 0.1, 1);
setAim(this.state, angle);
this.fireSelected({ angle, power });
drag;
});
}
cycleSlot(slot) {
const st = this.state;
if (!canAct(st)) return;
const inSlot = availableWeapons(st).filter((e) => e.weapon.slot === slot);
if (!inSlot.length) return;
const idx = inSlot.findIndex((e) => e.id === st.selectedWeapon);
const next = inSlot[(idx + 1) % inSlot.length];
selectWeapon(st, next.id);
playSound(this, SFX.PIECE_CLICK);
}
// Grenade-family fuse. The number keys already cycle weapon slots, so the
// original's "press 1-5 to set the fuse" would collide.
cycleFuse() {
const st = this.state;
const w = getWeapon(st.selectedWeapon);
if (!w || !w.fuseOptions || !canAct(st)) return;
const i = w.fuseOptions.indexOf(st.fuse);
setFuse(st, w.fuseOptions[(i + 1) % w.fuseOptions.length]);
playSound(this, SFX.PIECE_CLICK);
}
doSkip() {
const st = this.state;
if (!canAct(st) || st.hasFired) return;
selectWeapon(st, 'skip');
fire(st, {});
}
fireSelected(opts) {
const st = this.state;
const weapon = getWeapon(st.selectedWeapon);
if (!weapon) return;
// The rope is not a weapon in the firing sense — it changes the worm's
// state and hands control straight back.
if (weapon.id === 'ninjarope') {
const worm = activeWorm(st);
if (!worm) return;
if (worm.rope) { releaseRope(worm); return; }
if (fireRope(st.terrain, worm, opts.angle ?? st.aim)) {
playSound(this, SFX.WOOSH);
}
return;
}
if (fire(st, opts)) {
if (weapon.kind === 'charge') playSound(this, SFX.SCIFI_LAUNCH);
this.followProjectile();
}
}
confirmQuit() {
if (!this.matchLive) return;
const now = this.time.now;
if (this.quitArmedAt && now - this.quitArmedAt < 1400) {
this.quitArmedAt = 0;
this.teardownMatch();
this.showModeSelect();
return;
}
this.quitArmedAt = now;
this.banner('Press ESC again to leave', 1200);
}
// -------------------------------------------------------------------------
// Camera
// -------------------------------------------------------------------------
snapCameraToActive(instant = false) {
const w = activeWorm(this.state);
if (!w) return;
this.followTarget = w;
this.cameraFree = false;
if (instant) this.cameras.main.centerOn(w.x, w.y - 60);
}
followProjectile() {
this.cameraFree = false;
this.followTarget = null; // update() picks the newest projectile
}
updateCamera(delta) {
if (this.cameraFree) return;
const st = this.state;
let target = null;
if (st.projectiles.length) target = st.projectiles[st.projectiles.length - 1];
else if (this.followTarget && !this.followTarget.dead) target = this.followTarget;
else target = activeWorm(st);
if (!target) return;
const cam = this.cameras.main;
const k = 1 - Math.pow(0.001, delta / 1000); // frame-rate independent lerp
const cx = cam.scrollX + cam.width / 2 / cam.zoom;
const cy = cam.scrollY + cam.height / 2 / cam.zoom;
cam.centerOn(cx + (target.x - cx) * k, cy + (target.y - 50 - cy) * k);
}
// -------------------------------------------------------------------------
// Frame
// -------------------------------------------------------------------------
update(time, delta) {
const st = this.state;
if (!st || !this.matchLive) return;
this.readHeldKeys(delta);
this.driveAI(time);
const events = stepMatch(st, Math.min(delta, 50) / 1000);
this.handleEvents(events);
this.updateCamera(delta);
this.drawFrame(time);
this.refreshHud();
}
// ---------------------------------------------------------------------
// PLACEHOLDER opponent — replaced wholesale by WormsAI.js in Wave 3.
//
// It aims straight at a random enemy with a fixed lead and fires. It does
// not read the wind, does not check line of fire, and does not reposition,
// so it is beatable by anyone paying attention. It exists so 1-player mode
// is not a 45-second wait per enemy turn while the real AI is being built.
// ---------------------------------------------------------------------
driveAI(time) {
const st = this.state;
const team = st.teams[st.activeTeam];
if (!team || team.controller !== 'ai') { this.aiTurn = -1; return; }
if (st.phase !== 'play' || !canAct(st) || st.hasFired) return;
if (this.aiTurn !== st.turnCount) {
this.aiTurn = st.turnCount;
this.aiActAt = time + 900 + Math.random() * 700;
return;
}
if (time < this.aiActAt) return;
this.aiActAt = Infinity;
const me = activeWorm(st);
const foes = st.worms.filter((w) => !w.dead && w.team !== me.team);
if (!foes.length) { this.doSkip(); return; }
const target = foes[Math.floor(Math.random() * foes.length)];
const dx = target.x - me.x, dy = target.y - me.y;
const dist = Math.hypot(dx, dy);
const angle = Math.atan2(dy - Math.min(200, dist * 0.45), dx);
const power = Phaser.Math.Clamp(dist / 900, 0.35, 1);
selectWeapon(st, 'bazooka');
setAim(st, angle);
if (!fire(st, { angle, power })) this.doSkip();
this.followProjectile();
}
readHeldKeys(delta) {
const st = this.state;
if (!canAct(st) || this.weaponPanel) { setMove(st, 0); setRopeInput(st, 0); return; }
const k = this.keys;
const worm = activeWorm(st);
setMove(st, (k.left.isDown ? -1 : 0) + (k.right.isDown ? 1 : 0));
setRopeInput(st, worm && worm.rope ? ((k.up.isDown ? -1 : 0) + (k.down.isDown ? 1 : 0)) : 0);
// Aiming only when not roped — up/down reels the rope instead.
if (worm && !worm.rope) {
const rate = 1.5 * (delta / 1000);
if (k.up.isDown) setAim(st, st.aim - rate * (Math.cos(st.aim) >= 0 ? 1 : -1));
if (k.down.isDown) setAim(st, st.aim + rate * (Math.cos(st.aim) >= 0 ? 1 : -1));
}
// Hold to charge, release to fire.
const firing = k.fire.isDown;
if (firing && !this.charging && !st.hasFired) {
const weapon = getWeapon(st.selectedWeapon);
if (weapon && weapon.kind === 'charge') { beginCharge(st); this.charging = true; }
else { this.fireSelected({ angle: st.aim, power: 1 }); this.charging = true; }
} else if (!firing && this.charging) {
this.charging = false;
const weapon = getWeapon(st.selectedWeapon);
if (weapon && weapon.kind === 'charge' && st.charging) {
this.fireSelected({ angle: st.aim, power: st.power });
}
}
}
drawFrame(time) {
const st = this.state;
drawObjects(this.gObjects, st, this.setup.theme);
drawRope(this.gRope, activeWorm(st));
drawWorms(this.gWorms, st, {
activeId: st.activeWorm, time, aim: st.aim, teams: st.teams,
});
drawWormLabels(this.gLabels, st, st.teams);
drawWater(this.gWater, st.terrain, st.waterY, this.setup.theme, time);
this.drawAimHelper();
}
drawAimHelper() {
const st = this.state;
const g = this.gAim;
g.clear();
if (!canAct(st)) return;
const worm = activeWorm(st);
if (!worm || worm.rope) return;
const weapon = getWeapon(st.selectedWeapon);
if (!weapon || weapon.kind === 'meta' || weapon.kind === 'place') return;
const angle = this.dragAim
? Math.atan2(this.dragAim.y - worm.y, this.dragAim.x - worm.x)
: st.aim;
const power = this.dragAim
? Phaser.Math.Clamp(Math.hypot(this.dragAim.x - worm.x, this.dragAim.y - worm.y) / 260, 0.1, 1)
: Math.max(0.25, st.power);
// A short dotted crosshair line — not a full trajectory preview, which
// would remove the whole skill of the game.
const len = 42 + power * 46;
for (let d = 20; d < len; d += 8) {
const x = worm.x + Math.cos(angle) * d;
const y = worm.y + Math.sin(angle) * d;
g.fillStyle(0xffffff, 0.55);
g.fillCircle(x, y, 1.8);
}
g.fillStyle(0xffd24a, 0.9);
g.fillCircle(worm.x + Math.cos(angle) * len, worm.y + Math.sin(angle) * len, 3.5);
}
// -------------------------------------------------------------------------
// Event replay
// -------------------------------------------------------------------------
handleEvents(events) {
if (!events || !events.length) return;
const st = this.state;
for (const e of events) {
switch (e.type) {
case 'explosion': {
if (e.crater > 0) this.surface.crater(e.x, e.y, e.crater);
spawnExplosion(this, this.fxLayer, e.x, e.y, e.radius || 40, this.setup.theme);
const shake = Math.min(0.02, (e.radius || 40) / 5000);
this.cameras.main.shake(220, shake);
playSoundEx(this, SFX.EIGHTBIT_EXPLODE, { volume: 0.5, rate: 1.4 - Math.min(0.6, (e.radius || 40) / 200) });
break;
}
case 'hitscan':
spawnTracer(this, this.fxLayer, e.x0, e.y0, e.x1, e.y1);
break;
case 'damage': {
const w = st.worms[e.wormId];
if (w) spawnDamageNumber(this, this.fxLayer, w.x, w.y, e.amount, '#ff8a7a');
break;
}
case 'wormDied': {
this.banner(e.cause === 'drowned' ? 'Glug glug!' : 'Ooof!', 900);
playSound(this, e.cause === 'drowned' ? SFX.WATER_SPLASH : SFX.EIGHTBIT_EXPLODE_2);
break;
}
case 'turnStart':
this.snapCameraToActive();
this.toggleWeaponPanel(false);
this.charging = false;
if (this.mode === 'hotseat') this.banner(`${st.teams[e.team].name}'s turn`, 900);
break;
case 'suddenDeath':
this.banner('SUDDEN DEATH', 2200);
playSound(this, SFX.SCIFI_RISER);
break;
case 'crateDropped':
playSound(this, SFX.UI_CHIME);
break;
case 'crateCollected':
this.banner(e.kind === 'health' ? `+${e.amount} health`
: e.kind === 'trap' ? 'It was a trap!'
: `${getWeapon(e.weapon)?.name ?? 'Weapon'} ×${e.amount}`, 1100);
break;
case 'waterRose':
playSoundEx(this, SFX.WATER_RAISE, { volume: 0.4 });
break;
case 'matchOver':
this.showResult(e.winner);
break;
default:
break;
}
}
}
showResult(winner) {
const st = this.state;
const name = winner == null ? null : st.teams[winner].name;
this.banner(winner == null ? 'DRAW' : `${name} WINS`, 4000);
playSound(this, winner === 0 || this.mode === 'hotseat' ? SFX.VICTORY_SHORT : SFX.EIGHTBIT_EXPLODE_2);
this.time.delayedCall(2200, () => {
if (!this.matchLive) return;
this.track(new Button(this, GAME_WIDTH / 2 - 170, GAME_HEIGHT - 110, 'Rematch', () => {
this.clearScreen(); this.startMatch();
}, { width: 260, height: 64 }));
this.track(new Button(this, GAME_WIDTH / 2 + 170, GAME_HEIGHT - 110, 'Menu', () => {
this.teardownMatch(); this.showModeSelect();
}, { width: 260, height: 64 }));
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,623 +0,0 @@
// Worms — particle-vs-bitmask physics.
//
// Imports WormsTerrain only; no Phaser, so the verifier and the AI run it in
// bare Node. Everything here is deterministic: fixed substeps, no Math.random,
// no wall-clock reads.
//
// Three kinds of moving thing, in rising order of fiddliness:
// 1. Projectiles — points, swept against the mask, bounce or detonate.
// 2. Worms — non-rotating circles that WALK (a step-up search, not a
// velocity integration) while grounded and fly ballistically otherwise.
// 3. The ninja rope — a polyline of anchors that wraps terrain corners.
//
// The walk/fly split matters: Worms locomotion is not physical. A worm climbs
// a 10px step instantly and is stopped dead by an 11px one, which is what makes
// terrain shape tactical rather than incidental.
import { isSolid, isRock, raycast, normalAt, cellAt, AIR } from './WormsTerrain.js';
export const PHYS = {
SUBSTEP: 1 / 120,
MAX_SUBSTEPS: 8, // dt clamp, so a stalled tab can't fast-forward a turn
GRAVITY: 700, // px/s^2 — tuned so a full-power bazooka carries ~1000px
TERMINAL_V: 1400,
WIND_ACCEL: 110, // px/s^2 at |wind| == 1, on windAffected projectiles only
// --- worms ---
WORM_R: 8, // collision radius
WALK_SPEED: 46, // px/s
MAX_STEP: 10, // climbable step height; 11px stops a worm dead
JUMP_VX: 135,
JUMP_VY: -300,
BACKFLIP_VX: -95,
BACKFLIP_VY: -430,
WORM_RESTITUTION: 0.32, // bounce when blasted into terrain
WORM_FRICTION: 0.55,
WORM_REST_SPEED: 26, // below this on contact, a flying worm settles
FALL_SAFE: 55, // px of drop that costs nothing
FALL_DAMAGE_PER_PX: 0.20,
FALL_DAMAGE_MAX: 50,
// --- projectiles ---
PROJ_STEP_MAX: 6, // max px advanced per collision probe
BOUNCE_MIN_SPEED: 45, // below this a bouncing projectile comes to rest
SURFACE_PROBE_R: 6, // radius of the normal-estimate disc
// --- ninja rope ---
ROPE_MAX_LEN: 520,
ROPE_REEL_SPEED: 150, // px/s of length change on up/down
ROPE_MIN_LEN: 26,
ROPE_SWING_ACCEL: 260, // px/s^2 of left/right input while roped
ROPE_DAMPING: 0.06, // per second, so a swing decays instead of ringing
ROPE_CORNER_EPS: 1.5,
};
// ---------------------------------------------------------------------------
// Circle-vs-mask
// ---------------------------------------------------------------------------
// Disc offsets are precomputed per radius; a worm test is then a flat loop over
// ~200 offsets with no arithmetic. Checking the FULL disc, not just its
// perimeter, is deliberate — a girder is 8px thick and would otherwise slip
// between the perimeter samples.
const discCache = new Map();
function discOffsets(r) {
let d = discCache.get(r);
if (d) return d;
const xs = [], ys = [];
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy <= r * r) { xs.push(dx); ys.push(dy); }
}
}
d = { xs: Int16Array.from(xs), ys: Int16Array.from(ys), n: xs.length };
discCache.set(r, d);
return d;
}
export function circleHits(t, cx, cy, r = PHYS.WORM_R) {
const d = discOffsets(r | 0);
const x = Math.round(cx), y = Math.round(cy);
for (let i = 0; i < d.n; i++) {
if (cellAt(t, x + d.xs[i], y + d.ys[i]) !== AIR) return true;
}
return false;
}
// Nudge a circle out of terrain it has ended up inside (an explosion can hurl a
// worm into a wall, and a Girder can be built around one). Spirals outward and
// takes the first free spot, so the displacement is always minimal.
export function resolveOverlap(t, body, r = PHYS.WORM_R, maxPush = 40) {
if (!circleHits(t, body.x, body.y, r)) return false;
for (let d = 1; d <= maxPush; d++) {
for (let a = 0; a < 16; a++) {
const ang = (a / 16) * Math.PI * 2;
const nx = body.x + Math.cos(ang) * d;
const ny = body.y + Math.sin(ang) * d;
if (!circleHits(t, nx, ny, r)) { body.x = nx; body.y = ny; return true; }
}
}
return false;
}
// ---------------------------------------------------------------------------
// Worms
// ---------------------------------------------------------------------------
export function makeWorm(x, y, extra = {}) {
return {
x, y,
vx: 0, vy: 0,
facing: 1,
grounded: false,
airborne: true,
peakY: y, // highest point reached this flight, for fall damage
walkAcc: 0,
rope: null,
...extra,
};
}
export function groundedAt(t, x, y, r = PHYS.WORM_R) {
// Solid immediately under the disc but not intersecting it.
return !circleHits(t, x, y, r) && circleHits(t, x, y + 2, r);
}
// One 1px walk attempt. Returns 'moved' | 'blocked' | 'fell'.
function walkPixel(t, worm, dir) {
const r = PHYS.WORM_R;
// Step up: take the lowest free position at or above the current height.
let nx = worm.x + dir, ny = -1;
for (let up = 0; up <= PHYS.MAX_STEP; up++) {
if (!circleHits(t, nx, worm.y - up, r)) { ny = worm.y - up; break; }
}
if (ny < 0) return 'blocked';
// Step down: settle onto whatever is below the new position.
let dropped = 0;
while (dropped < PHYS.MAX_STEP && !circleHits(t, nx, ny + 1, r)) { ny += 1; dropped++; }
worm.x = nx;
worm.y = ny;
if (dropped >= PHYS.MAX_STEP && !circleHits(t, nx, ny + 1, r)) {
// Walked off a ledge — hand over to the ballistic path with a little
// forward carry, the way stepping off a cliff looks in the original.
worm.airborne = true;
worm.grounded = false;
worm.vx = dir * 40;
worm.vy = 0;
worm.peakY = worm.y;
return 'fell';
}
return 'moved';
}
// Drive a grounded worm horizontally. `dir` is -1, 0 or +1.
export function walkWorm(t, worm, dir, dt) {
if (dir !== 0) worm.facing = dir;
if (dir === 0) { worm.walkAcc = 0; return 'idle'; }
worm.walkAcc += PHYS.WALK_SPEED * dt;
let result = 'idle';
while (worm.walkAcc >= 1) {
worm.walkAcc -= 1;
result = walkPixel(t, worm, dir);
if (result !== 'moved') break;
}
return result;
}
export function jumpWorm(worm, kind = 'forward') {
if (!worm.grounded) return false;
if (kind === 'backflip') {
worm.vx = PHYS.BACKFLIP_VX * worm.facing;
worm.vy = PHYS.BACKFLIP_VY;
} else {
worm.vx = PHYS.JUMP_VX * worm.facing;
worm.vy = PHYS.JUMP_VY;
}
worm.grounded = false;
worm.airborne = true;
worm.peakY = worm.y;
return true;
}
// Ballistic worm integration for one substep. Returns an event string when
// something worth reacting to happened.
function flyWorm(t, worm, dt, events) {
const r = PHYS.WORM_R;
worm.vy += PHYS.GRAVITY * dt;
if (worm.vy > PHYS.TERMINAL_V) worm.vy = PHYS.TERMINAL_V;
const dx = worm.vx * dt, dy = worm.vy * dt;
const dist = Math.hypot(dx, dy);
const steps = Math.max(1, Math.ceil(dist));
const sx = dx / steps, sy = dy / steps;
for (let i = 0; i < steps; i++) {
const nx = worm.x + sx, ny = worm.y + sy;
if (!circleHits(t, nx, ny, r)) {
worm.x = nx; worm.y = ny;
if (worm.y < worm.peakY) worm.peakY = worm.y;
continue;
}
// Contact. Reflect about the surface normal.
const n = normalAt(t, Math.round(nx), Math.round(ny), PHYS.SURFACE_PROBE_R)
?? { x: 0, y: -1 };
const speed = Math.hypot(worm.vx, worm.vy);
const fall = worm.y - worm.peakY;
if (speed < PHYS.WORM_REST_SPEED || (n.y < -0.5 && Math.abs(worm.vx) < 60)) {
// Settle. Land on top of whatever we hit.
worm.vx = 0; worm.vy = 0;
worm.airborne = false;
worm.grounded = true;
resolveOverlap(t, worm, r);
snapToGround(t, worm);
if (fall > PHYS.FALL_SAFE) {
events.push({ type: 'wormLanded', worm, fall, damage: fallDamage(fall) });
}
return;
}
const dot = worm.vx * n.x + worm.vy * n.y;
let rvx = worm.vx - (1 + PHYS.WORM_RESTITUTION) * dot * n.x;
let rvy = worm.vy - (1 + PHYS.WORM_RESTITUTION) * dot * n.y;
rvx *= PHYS.WORM_FRICTION;
rvy *= PHYS.WORM_FRICTION;
worm.vx = rvx; worm.vy = rvy;
// Back off along the normal so we don't re-collide on the same pixel.
worm.x += n.x * 1.5;
worm.y += n.y * 1.5;
if (fall > PHYS.FALL_SAFE) {
events.push({ type: 'wormLanded', worm, fall, damage: fallDamage(fall) });
}
worm.peakY = worm.y;
return;
}
}
// Landing snaps to integer pixels. Sub-pixel residue left over from the
// ballistic path otherwise rides along through every subsequent walk step —
// the mask is only sampled at integers, so a worm resting at y=290.625 sits a
// visible pixel higher than one resting at y=291 and reports a different
// standing height for the rest of the match.
function snapToGround(t, worm) {
const r = PHYS.WORM_R;
worm.x = Math.round(worm.x);
worm.y = Math.round(worm.y);
if (circleHits(t, worm.x, worm.y, r)) resolveOverlap(t, worm, r, 16);
let n = 0;
while (n < PHYS.MAX_STEP && !circleHits(t, worm.x, worm.y + 1, r)) { worm.y += 1; n++; }
}
export function fallDamage(fallPx) {
if (fallPx <= PHYS.FALL_SAFE) return 0;
const d = Math.round((fallPx - PHYS.FALL_SAFE) * PHYS.FALL_DAMAGE_PER_PX);
return Math.max(0, Math.min(PHYS.FALL_DAMAGE_MAX, d));
}
// Advance one worm by one substep. `input` is { move: -1|0|1, } and only
// applies to the worm whose turn it is.
export function stepWorm(t, worm, dt, events, input = null) {
if (worm.dead) return;
if (worm.rope) { stepRope(t, worm, dt, input, events); return; }
if (worm.airborne) {
flyWorm(t, worm, dt, events);
// Safety net. A glancing bounce backs off along the normal, which is not
// always enough to clear a concave corner, and terrain can be built or
// blown up around a worm mid-flight. One extra disc test per frame buys
// the invariant that a worm is NEVER inside terrain at the end of a step.
if (circleHits(t, worm.x, worm.y, PHYS.WORM_R)) resolveOverlap(t, worm, PHYS.WORM_R, 24);
return;
}
// Grounded: keep it honest — terrain under the worm may have been blown away.
if (!circleHits(t, worm.x, worm.y + 2, PHYS.WORM_R)) {
worm.airborne = true;
worm.grounded = false;
worm.vx = 0;
worm.vy = 0;
worm.peakY = worm.y;
return;
}
worm.grounded = true;
if (input && input.move) walkWorm(t, worm, input.move, dt);
}
export function settleWorm(t, worm, maxSeconds = 6) {
const events = [];
const steps = Math.ceil(maxSeconds / PHYS.SUBSTEP);
for (let i = 0; i < steps; i++) {
stepWorm(t, worm, PHYS.SUBSTEP, events);
if (!worm.airborne && !worm.rope) break;
}
return events;
}
// ---------------------------------------------------------------------------
// Projectiles
// ---------------------------------------------------------------------------
export function makeProjectile(x, y, vx, vy, spec = {}) {
return {
x, y, vx, vy,
born: 0,
age: 0,
bounces: 0,
resting: false,
spec, // the WormsWeapons entry driving it
...(spec.projectileExtra ?? {}),
};
}
// Integrate one projectile for one substep.
// Returns null, or a terminating event: { type: 'detonate'|'expire', x, y, nx, ny }.
export function stepProjectile(t, p, dt, opts = {}) {
const { wind = 0, homingTarget = null } = opts;
const spec = p.spec ?? {};
p.age += dt;
if (p.resting) {
// A grenade at rest still counts down its fuse.
if (spec.fuse != null && p.age >= spec.fuse) return { type: 'detonate', x: p.x, y: p.y, nx: 0, ny: -1 };
return null;
}
if (spec.homing && homingTarget && p.age > (spec.homingDelay ?? 0.35)) {
const dx = homingTarget.x - p.x, dy = homingTarget.y - p.y;
const d = Math.hypot(dx, dy) || 1;
const speed = Math.hypot(p.vx, p.vy) || 1;
const turn = (spec.homingTurn ?? 4.0) * dt;
p.vx += (dx / d) * speed * turn;
p.vy += (dy / d) * speed * turn;
const ns = Math.hypot(p.vx, p.vy) || 1;
p.vx = (p.vx / ns) * speed;
p.vy = (p.vy / ns) * speed;
}
if (spec.gravity !== false) p.vy += PHYS.GRAVITY * dt;
if (spec.windAffected) p.vx += wind * PHYS.WIND_ACCEL * dt;
if (spec.drag) {
const k = Math.max(0, 1 - spec.drag * dt);
p.vx *= k; p.vy *= k;
}
if (p.vy > PHYS.TERMINAL_V) p.vy = PHYS.TERMINAL_V;
// Swept move, chunked so no probe skips more than PROJ_STEP_MAX px.
let remaining = Math.hypot(p.vx * dt, p.vy * dt);
const totalDist = remaining;
if (totalDist < 1e-6) return checkExpiry(p, spec);
const ux = (p.vx * dt) / totalDist, uy = (p.vy * dt) / totalDist;
while (remaining > 1e-6) {
const seg = Math.min(remaining, PHYS.PROJ_STEP_MAX);
const tx = p.x + ux * seg, ty = p.y + uy * seg;
const hit = raycast(t, p.x, p.y, tx, ty);
if (!hit.hit) {
p.x = tx; p.y = ty;
remaining -= seg;
continue;
}
// Contact.
p.x = hit.x; p.y = hit.y;
const n = normalAt(t, Math.round(hit.hitX), Math.round(hit.hitY), PHYS.SURFACE_PROBE_R)
?? { x: 0, y: -1 };
if (!spec.bounce) return { type: 'detonate', x: p.x, y: p.y, nx: n.x, ny: n.y };
const speed = Math.hypot(p.vx, p.vy);
p.bounces++;
if (spec.maxBounces != null && p.bounces > spec.maxBounces) {
return { type: 'detonate', x: p.x, y: p.y, nx: n.x, ny: n.y };
}
if (speed < PHYS.BOUNCE_MIN_SPEED) {
p.vx = 0; p.vy = 0; p.resting = true;
p.x += n.x * 1.2; p.y += n.y * 1.2;
return checkExpiry(p, spec);
}
const dot = p.vx * n.x + p.vy * n.y;
const e = spec.restitution ?? 0.45;
const f = spec.tangentFriction ?? 0.82;
let rvx = p.vx - (1 + e) * dot * n.x;
let rvy = p.vy - (1 + e) * dot * n.y;
// Tangential friction: scrub the component along the surface.
const tanx = -n.y, tany = n.x;
const tanDot = rvx * tanx + rvy * tany;
rvx += tanx * tanDot * (f - 1);
rvy += tany * tanDot * (f - 1);
p.vx = rvx; p.vy = rvy;
p.x += n.x * 1.5; p.y += n.y * 1.5;
return { type: 'bounce', x: p.x, y: p.y, nx: n.x, ny: n.y, speed };
}
return checkExpiry(p, spec);
}
function checkExpiry(p, spec) {
if (spec.fuse != null && p.age >= spec.fuse) {
return { type: 'detonate', x: p.x, y: p.y, nx: 0, ny: -1 };
}
if (spec.maxAge != null && p.age >= spec.maxAge) {
return { type: 'expire', x: p.x, y: p.y, nx: 0, ny: -1 };
}
return null;
}
// Where would this shot land, ignoring worms? The AI's whole scoring loop is
// built on this, so it is a plain loop with a step cap and no allocation
// beyond the returned point.
export function simulateShot(t, x, y, vx, vy, spec, opts = {}) {
const p = makeProjectile(x, y, vx, vy, spec);
const maxSteps = Math.ceil((opts.maxSeconds ?? 12) / PHYS.SUBSTEP);
const trace = opts.trace ? [] : null;
for (let i = 0; i < maxSteps; i++) {
const ev = stepProjectile(t, p, PHYS.SUBSTEP, opts);
if (trace && (i % 4 === 0)) trace.push({ x: p.x, y: p.y });
if (p.y > t.h + 400 || p.x < -600 || p.x > t.w + 600) {
return { outcome: 'lost', x: p.x, y: p.y, time: p.age, trace };
}
if (ev && (ev.type === 'detonate' || ev.type === 'expire')) {
return { outcome: ev.type, x: ev.x, y: ev.y, time: p.age, trace };
}
}
return { outcome: 'timeout', x: p.x, y: p.y, time: p.age, trace };
}
// ---------------------------------------------------------------------------
// Explosions
// ---------------------------------------------------------------------------
// Geometry only — how hard each body is hit and from where. Damage numbers and
// who is allowed to be hurt are WormsLogic's business.
export function blastEffect(cx, cy, radius, power, bodies) {
const out = [];
for (const b of bodies) {
if (b.dead) continue;
const dx = b.x - cx, dy = b.y - cy;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
// Linear falloff. A zero-radius blast would divide by zero, so treat it as
// a point hit at full strength rather than letting NaN into the sim.
const f = radius <= 0 ? 1 : 1 - d / radius;
const inv = d < 1e-3 ? 0 : 1 / d;
// Straight up on a dead-centre hit, so a worm standing on a mine launches.
const ux = d < 1e-3 ? 0 : dx * inv;
const uy = d < 1e-3 ? -1 : dy * inv;
out.push({ body: b, dist: d, falloff: f, ux, uy, impulse: power * f });
}
return out;
}
export function applyImpulse(worm, ux, uy, impulse) {
worm.vx += ux * impulse;
worm.vy += uy * impulse;
worm.airborne = true;
worm.grounded = false;
worm.rope = null;
worm.peakY = worm.y;
}
// ---------------------------------------------------------------------------
// Ninja rope
// ---------------------------------------------------------------------------
//
// The rope is a polyline: anchors[0] is the pin in the terrain, the last anchor
// is whatever corner the rope currently bends around, and the worm swings on
// the final segment. Corners are pushed when the taut segment clips terrain and
// popped when the bend straightens out again — that push/pop pair is the whole
// trick to wrapping, and getting it wrong shows up as a rope that either cuts
// through rock or locks solid.
export function fireRope(t, worm, angleRad, maxLen = PHYS.ROPE_MAX_LEN) {
const dx = Math.cos(angleRad), dy = Math.sin(angleRad);
const hit = raycast(t, worm.x, worm.y, worm.x + dx * maxLen, worm.y + dy * maxLen);
if (!hit.hit) return false;
// The root anchor must land on an actually-solid pixel: rounding the
// sub-pixel contact point can put it in the air, and then the "was my anchor
// blown up?" check fires on the very next step.
const anchor = nearestSolidPixel(t, Math.round(hit.hitX), Math.round(hit.hitY));
if (!anchor) return false;
worm.rope = {
anchors: [anchor],
length: Math.max(PHYS.ROPE_MIN_LEN, Math.hypot(worm.x - anchor.x, worm.y - anchor.y)),
};
worm.airborne = true;
worm.grounded = false;
return true;
}
function nearestSolidPixel(t, x, y, r = 3) {
if (isSolid(t, x, y)) return { x, y };
for (let d = 1; d <= r; d++) {
for (let dy = -d; dy <= d; dy++) {
for (let dx = -d; dx <= d; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== d) continue;
if (isSolid(t, x + dx, y + dy)) return { x: x + dx, y: y + dy };
}
}
}
return null;
}
export function releaseRope(worm) {
if (!worm.rope) return false;
worm.rope = null;
worm.airborne = true;
worm.grounded = false;
worm.peakY = worm.y;
return true;
}
function ropeTip(rope) { return rope.anchors[rope.anchors.length - 1]; }
// Total length used by the fixed bends, so the free segment is length - that.
function bendLength(rope) {
let sum = 0;
for (let i = 1; i < rope.anchors.length; i++) {
const a = rope.anchors[i - 1], b = rope.anchors[i];
sum += Math.hypot(b.x - a.x, b.y - a.y);
}
return sum;
}
function stepRope(t, worm, dt, input, events) {
const rope = worm.rope;
const tip = ropeTip(rope);
// Reel in/out.
if (input && input.rope) {
rope.length += input.rope * PHYS.ROPE_REEL_SPEED * dt;
}
const freeMax = Math.max(PHYS.ROPE_MIN_LEN, rope.length - bendLength(rope));
// Only the ROOT anchor is an attachment to terrain; every later anchor is a
// corner the rope merely bends around and sits in AIR by construction, so
// validating those would drop the rope the instant it wrapped anything.
const root = rope.anchors[0];
if (!isSolid(t, root.x, root.y)) {
releaseRope(worm);
events.push({ type: 'ropeLost', worm });
return;
}
// Gravity + swing input, then the length constraint.
worm.vy += PHYS.GRAVITY * dt;
if (input && input.move) worm.vx += input.move * PHYS.ROPE_SWING_ACCEL * dt;
const damp = Math.max(0, 1 - PHYS.ROPE_DAMPING * dt * 60 * dt);
worm.vx *= damp; worm.vy *= damp;
let nx = worm.x + worm.vx * dt;
let ny = worm.y + worm.vy * dt;
// Constrain to the circle around the current tip.
let dx = nx - tip.x, dy = ny - tip.y;
let d = Math.hypot(dx, dy);
if (d > freeMax && d > 1e-6) {
const k = freeMax / d;
nx = tip.x + dx * k;
ny = tip.y + dy * k;
// Kill the radial component; a rope cannot push.
const ux = dx / d, uy = dy / d;
const radial = worm.vx * ux + worm.vy * uy;
if (radial > 0) { worm.vx -= radial * ux; worm.vy -= radial * uy; }
}
// Terrain in the way of the worm itself: slide along it rather than stopping,
// which is what makes climbing a cliff on the rope feel right.
if (circleHits(t, nx, ny, PHYS.WORM_R)) {
const n = normalAt(t, Math.round(nx), Math.round(ny), PHYS.SURFACE_PROBE_R);
if (n) {
const dot = worm.vx * n.x + worm.vy * n.y;
if (dot < 0) { worm.vx -= dot * n.x; worm.vy -= dot * n.y; }
nx = worm.x + n.x * 1.2;
ny = worm.y + n.y * 1.2;
} else {
nx = worm.x; ny = worm.y;
}
}
worm.x = nx; worm.y = ny;
// Reeling in against a wall can still bury the worm a pixel or two deep.
resolveOverlap(t, worm, PHYS.WORM_R, 12);
worm.peakY = Math.min(worm.peakY, worm.y);
updateRopeWrap(t, worm);
}
// Push a new bend where the taut segment has cut into terrain; pop the last one
// when the rope has swung back past it.
function updateRopeWrap(t, worm) {
const rope = worm.rope;
if (!rope) return;
const tip = ropeTip(rope);
// POP first: if the segment from the previous anchor straight to the worm is
// clear, this bend is no longer doing anything.
if (rope.anchors.length > 1) {
const prev = rope.anchors[rope.anchors.length - 2];
const clear = !raycast(t, prev.x, prev.y, worm.x, worm.y).hit;
if (clear) { rope.anchors.pop(); return; }
}
// PUSH: the straight line from the tip to the worm is blocked, so the rope is
// lying over a corner. Pin the corner one pixel back from the contact.
const hit = raycast(t, tip.x, tip.y, worm.x, worm.y);
if (hit.hit) {
const cx = Math.round(hit.x), cy = Math.round(hit.y);
if (Math.hypot(cx - tip.x, cy - tip.y) > PHYS.ROPE_CORNER_EPS && rope.anchors.length < 24) {
rope.anchors.push({ x: cx, y: cy });
}
}
}
// Total rope path length, for drawing and for the reel-in limit.
export function ropePoints(worm) {
if (!worm.rope) return null;
return [...worm.rope.anchors, { x: worm.x, y: worm.y }];
}

View File

@ -1,542 +0,0 @@
// Worms — presentation helpers.
//
// Everything here is Phaser-side and holds no game state. The two jobs that
// matter are painting the terrain mask into a texture, and drawing worms
// procedurally so the game is playable before any art exists.
//
// Terrain lives in a RenderTexture, not a CanvasTexture: craters are erased on
// the GPU with a brush, so a blast costs one draw call instead of re-uploading
// a 10 MB canvas every time something explodes.
import * as Phaser from 'phaser';
import { AIR, ROCK } from './WormsTerrain.js';
import { getTheme, artKeys } from './WormsThemes.js';
const CRUST = 11; // px of surface band painted along the top of terrain
const BRUSH_R = 128; // radius of the source crater brush, scaled per blast
// Pixel classes, computed once per map.
const C_AIR = 0, C_FILL = 1, C_CRUST = 2, C_ROCK = 3;
// One pass over the mask, row-major so it stays cache-friendly, tracking how
// far each column is below the last air pixel. That distance is what makes a
// crust band possible without an O(w*h*depth) probe per pixel.
function classifyTerrain(terrain) {
const { w, h, mask } = terrain;
const cls = new Uint8Array(w * h);
const since = new Int16Array(w).fill(9999);
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
const v = mask[row + x];
if (v === AIR) { since[x] = 0; cls[row + x] = C_AIR; continue; }
if (since[x] < 9999) since[x] += 1;
cls[row + x] = v === ROCK ? C_ROCK : (since[x] <= CRUST ? C_CRUST : C_FILL);
}
}
return cls;
}
const rgb = (hex) => ({ r: (hex >> 16) & 0xff, g: (hex >> 8) & 0xff, b: hex & 0xff });
// Cheap deterministic per-pixel grain, so dirt does not read as a flat slab.
function grainAt(x, y) {
let n = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0;
n = Math.imul(n ^ (n >>> 13), 1274126177) >>> 0;
return ((n >>> 16) & 0xff) / 255;
}
// Paint the whole terrain into a CanvasTexture. This is the one expensive
// operation per map (~100 ms at 2560x1000) and happens behind the loading bar.
export function paintTerrainBase(scene, terrain, themeId) {
const theme = getTheme(themeId);
const keys = artKeys(themeId);
const key = 'worms-terrain-base';
if (scene.textures.exists(key)) scene.textures.remove(key);
const { w, h } = terrain;
const tex = scene.textures.createCanvas(key, w, h);
const ctx = tex.getContext();
const cls = classifyTerrain(terrain);
const hasFillArt = scene.textures.exists(keys.fill);
const hasSurfaceArt = scene.textures.exists(keys.surface);
if (hasFillArt) {
// Tile the supplied dirt texture across the whole rect, then knock out the
// air with an alpha pass. Cheaper and better looking than per-pixel work.
const src = scene.textures.get(keys.fill).getSourceImage();
const pat = ctx.createPattern(src, 'repeat');
ctx.fillStyle = pat;
ctx.fillRect(0, 0, w, h);
if (hasSurfaceArt) {
const ssrc = scene.textures.get(keys.surface).getSourceImage();
const spat = ctx.createPattern(ssrc, 'repeat-x');
// Draw the crust strip only where the class map says crust.
const strip = document.createElement('canvas');
strip.width = w; strip.height = h;
const sctx = strip.getContext('2d');
sctx.fillStyle = spat;
sctx.fillRect(0, 0, w, h);
const sdata = sctx.getImageData(0, 0, w, h);
for (let i = 0, p = 0; i < cls.length; i++, p += 4) {
if (cls[i] !== C_CRUST) sdata.data[p + 3] = 0;
}
sctx.putImageData(sdata, 0, 0);
ctx.drawImage(strip, 0, 0);
}
const img = ctx.getImageData(0, 0, w, h);
const d = img.data;
for (let i = 0, p = 0; i < cls.length; i++, p += 4) {
if (cls[i] === C_AIR) d[p + 3] = 0;
else if (cls[i] === C_ROCK) {
const c = rgb(theme.rock);
d[p] = c.r; d[p + 1] = c.g; d[p + 2] = c.b; d[p + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
} else {
const img = ctx.createImageData(w, h);
const d = img.data;
const cFill = rgb(theme.fill), cLight = rgb(theme.fillLight), cDark = rgb(theme.fillDark);
const cCrust = rgb(theme.surface), cCrustD = rgb(theme.surfaceDark);
const cRock = rgb(theme.rock);
const g = theme.grain ?? 0.14;
for (let y = 0, i = 0, p = 0; y < h; y++) {
// Horizontal strata, so dirt reads as layered rather than as noise.
const strata = Math.sin(y * 0.09) * 0.5 + Math.sin(y * 0.021) * 0.5;
for (let x = 0; x < w; x++, i++, p += 4) {
const k = cls[i];
if (k === C_AIR) { d[p + 3] = 0; continue; }
d[p + 3] = 255;
if (k === C_ROCK) {
const n = (grainAt(x, y) - 0.5) * 0.25;
d[p] = cRock.r * (1 + n); d[p + 1] = cRock.g * (1 + n); d[p + 2] = cRock.b * (1 + n);
continue;
}
const n = (grainAt(x, y) - 0.5) * g + strata * g * 0.35;
if (k === C_CRUST) {
const t = n > 0 ? n : 0, u = n < 0 ? -n : 0;
d[p] = cCrust.r + (255 - cCrust.r) * t * 0.5 - (cCrust.r - cCrustD.r) * u * 2;
d[p + 1] = cCrust.g + (255 - cCrust.g) * t * 0.5 - (cCrust.g - cCrustD.g) * u * 2;
d[p + 2] = cCrust.b + (255 - cCrust.b) * t * 0.5 - (cCrust.b - cCrustD.b) * u * 2;
} else {
const t = n > 0 ? n : 0, u = n < 0 ? -n : 0;
d[p] = cFill.r + (cLight.r - cFill.r) * t * 3 - (cFill.r - cDark.r) * u * 3;
d[p + 1] = cFill.g + (cLight.g - cFill.g) * t * 3 - (cFill.g - cDark.g) * u * 3;
d[p + 2] = cFill.b + (cLight.b - cFill.b) * t * 3 - (cFill.b - cDark.b) * u * 3;
}
}
}
ctx.putImageData(img, 0, 0);
}
tex.refresh();
return key;
}
// A soft-edged disc used as the eraser, and a scorched ring stamped around each
// crater so a fresh hole reads as burnt rather than as a clean cut.
export function makeBrushes(scene, themeId) {
const theme = getTheme(themeId);
const eraseKey = 'worms-brush-erase';
const scorchKey = 'worms-brush-scorch';
if (!scene.textures.exists(eraseKey)) {
const tex = scene.textures.createCanvas(eraseKey, BRUSH_R * 2, BRUSH_R * 2);
const ctx = tex.getContext();
// Hard core with a 2px feather: any softer and the visible hole drifts out
// of step with the collision mask, which is a hard-edged circle.
const grad = ctx.createRadialGradient(BRUSH_R, BRUSH_R, 0, BRUSH_R, BRUSH_R, BRUSH_R);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.97, 'rgba(255,255,255,1)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, BRUSH_R * 2, BRUSH_R * 2);
tex.refresh();
}
if (scene.textures.exists(scorchKey)) scene.textures.remove(scorchKey);
const stex = scene.textures.createCanvas(scorchKey, BRUSH_R * 2, BRUSH_R * 2);
const sctx = stex.getContext();
const c = rgb(theme.scorch);
const sgrad = sctx.createRadialGradient(BRUSH_R, BRUSH_R, BRUSH_R * 0.62,
BRUSH_R, BRUSH_R, BRUSH_R);
sgrad.addColorStop(0, `rgba(${c.r},${c.g},${c.b},0)`);
sgrad.addColorStop(0.78, `rgba(${c.r},${c.g},${c.b},0.85)`);
sgrad.addColorStop(1, `rgba(${c.r},${c.g},${c.b},0)`);
sctx.fillStyle = sgrad;
sctx.fillRect(0, 0, BRUSH_R * 2, BRUSH_R * 2);
stex.refresh();
return { eraseKey, scorchKey, brushRadius: BRUSH_R };
}
// The live terrain surface. Draw the painted base once, then erase craters and
// draw girders straight into it.
export function makeTerrainSurface(scene, terrain, themeId, depth = 10) {
const baseKey = paintTerrainBase(scene, terrain, themeId);
const brushes = makeBrushes(scene, themeId);
const rt = scene.add.renderTexture(0, 0, terrain.w, terrain.h).setOrigin(0, 0).setDepth(depth);
rt.draw(baseKey, 0, 0);
// Reusable stamps. Created once and re-scaled per blast.
const eraser = scene.make.image({ key: brushes.eraseKey, add: false }).setOrigin(0.5);
const scorch = scene.make.image({ key: brushes.scorchKey, add: false }).setOrigin(0.5);
return {
rt, baseKey, brushes,
crater(x, y, r) {
if (r <= 0) return;
const s = (r * 1.02) / brushes.brushRadius;
eraser.setScale(s);
rt.erase(eraser, x, y);
scorch.setScale((r + 9) / brushes.brushRadius).setAlpha(0.9);
rt.draw(scorch, x, y);
},
girder(x, y, len, thick, angle, color) {
const g = scene.make.graphics({ add: false });
g.fillStyle(color ?? 0x9a8b6a, 1);
g.fillRect(-len / 2, -thick / 2, len, thick);
g.lineStyle(2, 0x5d523d, 1);
g.strokeRect(-len / 2, -thick / 2, len, thick);
const tmp = 'worms-girder-stamp';
if (scene.textures.exists(tmp)) scene.textures.remove(tmp);
g.generateTexture(tmp, len, thick);
const img = scene.make.image({ key: tmp, add: false }).setOrigin(0.5).setRotation(angle);
rt.draw(img, x, y);
g.destroy();
},
repaint() {
rt.clear();
rt.draw(paintTerrainBase(scene, terrain, themeId), 0, 0);
},
destroy() {
eraser.destroy(); scorch.destroy(); rt.destroy();
if (scene.textures.exists(baseKey)) scene.textures.remove(baseKey);
},
};
}
// ---------------------------------------------------------------------------
// Backdrop
// ---------------------------------------------------------------------------
// A parallax sky. Uses the supplied backdrop if it exists, otherwise a
// procedural gradient with theme-appropriate scenery silhouettes.
//
// Returns a flat ARRAY of root game objects rather than a container on purpose:
// a Phaser Container's children ignore their own scrollFactor (the container's
// governs), so parallax layers parented to one all move together — which is
// exactly the bug that makes a backdrop look painted onto the terrain.
export function makeBackdrop(scene, terrain, themeId, depth = 0) {
const theme = getTheme(themeId);
const keys = artKeys(themeId);
const out = [];
if (scene.textures.exists(keys.backdrop)) {
const img = scene.add.image(0, 0, keys.backdrop).setOrigin(0, 0).setDepth(depth);
img.setDisplaySize(terrain.w * 1.15, terrain.h * 1.15);
img.setScrollFactor(0.4);
out.push(img);
} else {
const key = `worms-sky-${themeId}`;
if (!scene.textures.exists(key)) {
const tex = scene.textures.createCanvas(key, 8, 256);
const ctx = tex.getContext();
const grad = ctx.createLinearGradient(0, 0, 0, 256);
const top = theme.sky[0], bot = theme.sky[1];
grad.addColorStop(0, `#${top.toString(16).padStart(6, '0')}`);
grad.addColorStop(1, `#${bot.toString(16).padStart(6, '0')}`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 8, 256);
tex.refresh();
}
const sky = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(depth);
sky.setDisplaySize(terrain.w * 1.4, terrain.h * 1.4);
sky.setScrollFactor(0.15);
out.push(sky);
// Two rows of rolling hill silhouettes, drawn from the theme palette.
for (const [i, alpha, scale] of [[0, 0.22, 0.55], [1, 0.34, 0.34]]) {
const g = scene.add.graphics().setDepth(depth + 1 + i);
g.fillStyle(theme.fillDark, alpha);
const baseY = terrain.h * (0.62 + i * 0.10);
g.beginPath();
g.moveTo(0, terrain.h);
for (let x = 0; x <= terrain.w; x += 40) {
const y = baseY - Math.sin(x * 0.0018 + i * 2.1) * terrain.h * 0.09 * (1 - i * 0.4)
- Math.sin(x * 0.0005 + i) * terrain.h * 0.06;
g.lineTo(x, y);
}
g.lineTo(terrain.w, terrain.h);
g.closePath();
g.fillPath();
g.setScrollFactor(scale);
out.push(g);
}
}
return out;
}
// ---------------------------------------------------------------------------
// Worms
// ---------------------------------------------------------------------------
const WORM_R = 8;
// One Graphics object redrawn every frame for all worms. With at most a couple
// of dozen worms this is far cheaper than keeping per-worm display lists in
// sync with a simulation that can kill or launch any of them at any moment.
export function drawWorms(g, state, { activeId = null, time = 0, aim = 0, teams = [] } = {}) {
g.clear();
for (const w of state.worms) {
if (w.dead) continue;
const team = teams[w.team] ?? { color: 0xffffff };
const isActive = w.id === activeId;
// Squash and stretch from vertical speed, so a falling worm reads as
// falling without needing a single frame of animation.
const speed = Math.min(1, Math.abs(w.vy) / 700);
const sx = 1 - speed * 0.18, sy = 1 + speed * 0.22;
const rw = WORM_R * sx, rh = WORM_R * sy;
const face = w.facing >= 0 ? 1 : -1;
// Shadow.
g.fillStyle(0x000000, 0.18);
g.fillEllipse(w.x, w.y + rh + 2, rw * 2.1, 5);
// Body: a capsule with a slightly narrower head end.
g.fillStyle(0x000000, 0.35);
g.fillEllipse(w.x, w.y + 1, rw * 2.15, rh * 2.15);
g.fillStyle(team.color, 1);
g.fillEllipse(w.x, w.y, rw * 2, rh * 2);
g.fillEllipse(w.x + face * rw * 0.35, w.y - rh * 0.45, rw * 1.55, rh * 1.35);
// Belly highlight.
g.fillStyle(0xffffff, 0.16);
g.fillEllipse(w.x - face * rw * 0.2, w.y - rh * 0.25, rw * 1.1, rh * 0.9);
// Eyes, tracking the aim when this worm is up.
const look = isActive ? aim : 0;
const lx = Math.cos(look) * 1.5 * (face < 0 ? -1 : 1);
const ly = Math.sin(look) * 1.5;
for (const s of [-1, 1]) {
const ex = w.x + face * rw * 0.5 + s * rw * 0.30;
const ey = w.y - rh * 0.62;
g.fillStyle(0xffffff, 1);
g.fillCircle(ex, ey, 2.7);
g.fillStyle(0x101010, 1);
g.fillCircle(ex + lx, ey + ly, 1.5);
}
// The active worm gets a bobbing arrow so it is never ambiguous whose turn
// it is — the single most important readability cue in the game.
if (isActive) {
const bob = Math.sin(time * 0.005) * 3;
const ay = w.y - rh - 26 + bob;
g.fillStyle(team.color, 1);
g.fillTriangle(w.x, ay + 11, w.x - 8, ay - 3, w.x + 8, ay - 3);
g.lineStyle(2, 0x000000, 0.5);
g.strokeTriangle(w.x, ay + 11, w.x - 8, ay - 3, w.x + 8, ay - 3);
}
}
}
// Name tags and health bars go in their own pass so they never end up behind
// another worm's body.
export function drawWormLabels(g, state, teams) {
g.clear();
for (const w of state.worms) {
if (w.dead) continue;
const team = teams[w.team] ?? { color: 0xffffff };
const frac = Math.max(0, w.health / (w.maxHealth || 100));
const bw = 34, bh = 5;
const bx = w.x - bw / 2, by = w.y - 30;
g.fillStyle(0x000000, 0.55);
g.fillRect(bx - 1, by - 1, bw + 2, bh + 2);
g.fillStyle(frac > 0.5 ? 0x4ad24a : frac > 0.25 ? 0xe0c040 : 0xe04a4a, 1);
g.fillRect(bx, by, bw * frac, bh);
g.lineStyle(1, team.color, 0.9);
g.strokeRect(bx - 1, by - 1, bw + 2, bh + 2);
}
}
// ---------------------------------------------------------------------------
// World furniture
// ---------------------------------------------------------------------------
export function drawObjects(g, state, themeId) {
const theme = getTheme(themeId);
g.clear();
for (const b of state.barrels) {
if (b.dead) continue;
g.fillStyle(0x000000, 0.25); g.fillEllipse(b.x, b.y + 13, 24, 6);
g.fillStyle(0xb5462a, 1);
g.fillRoundedRect(b.x - 10, b.y - 13, 20, 26, 4);
g.lineStyle(2, 0x6e2716, 1);
g.strokeRoundedRect(b.x - 10, b.y - 13, 20, 26, 4);
g.lineStyle(2, 0xe0e0e0, 0.55);
g.lineBetween(b.x - 10, b.y - 5, b.x + 10, b.y - 5);
g.lineBetween(b.x - 10, b.y + 5, b.x + 10, b.y + 5);
}
for (const m of state.mines) {
if (m.dead) continue;
const hot = m.triggered;
g.fillStyle(0x000000, 0.25); g.fillEllipse(m.x, m.y + 7, 14, 4);
g.fillStyle(hot ? 0xe04a3a : 0x3a3a44, 1);
g.fillCircle(m.x, m.y, 6);
g.lineStyle(1.5, 0x15151c, 1);
g.strokeCircle(m.x, m.y, 6);
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
g.lineBetween(m.x + Math.cos(a) * 5, m.y + Math.sin(a) * 5,
m.x + Math.cos(a) * 9, m.y + Math.sin(a) * 9);
}
if (m.armed && !hot) { g.fillStyle(0x50ff70, 1); g.fillCircle(m.x + 3, m.y - 3, 1.6); }
}
for (const c of state.crates) {
if (c.dead) continue;
if (c.parachute) {
g.fillStyle(0xf0f0f0, 0.92);
g.fillEllipse(c.x, c.y - 26, 44, 26);
g.lineStyle(1, 0x99a0aa, 1);
g.lineBetween(c.x - 20, c.y - 24, c.x - 6, c.y - 10);
g.lineBetween(c.x + 20, c.y - 24, c.x + 6, c.y - 10);
}
const col = c.kind === 'health' ? 0xd83b3b : c.kind === 'trap' ? 0x8a6a3a : 0x9a7a3a;
g.fillStyle(0x000000, 0.25); g.fillEllipse(c.x, c.y + 12, 22, 5);
g.fillStyle(col, 1);
g.fillRoundedRect(c.x - 11, c.y - 11, 22, 22, 3);
g.lineStyle(2, 0x2a1c10, 1);
g.strokeRoundedRect(c.x - 11, c.y - 11, 22, 22, 3);
if (c.kind === 'health') {
g.fillStyle(0xffffff, 1);
g.fillRect(c.x - 6, c.y - 2, 12, 4);
g.fillRect(c.x - 2, c.y - 6, 4, 12);
} else {
g.lineStyle(2, 0xf0e0b0, 0.8);
g.lineBetween(c.x - 7, c.y - 7, c.x + 7, c.y + 7);
g.lineBetween(c.x + 7, c.y - 7, c.x - 7, c.y + 7);
}
}
for (const p of state.projectiles) {
const spin = p.age * 12;
g.fillStyle(0x000000, 0.3);
g.fillCircle(p.x + 1, p.y + 1, 5);
g.fillStyle(p.weaponId === 'hhg' ? 0xe8d070 : 0x3a3a44, 1);
g.fillCircle(p.x, p.y, 4.5);
g.lineStyle(2, theme.scorch, 0.9);
g.lineBetween(p.x, p.y, p.x - Math.cos(spin) * 7, p.y - Math.sin(spin) * 7);
}
}
// Rope, drawn as the polyline the physics actually uses so what you see is
// exactly what you are swinging on.
export function drawRope(g, worm) {
g.clear();
if (!worm || !worm.rope) return;
const pts = [...worm.rope.anchors, { x: worm.x, y: worm.y }];
g.lineStyle(2, 0x2b2b30, 1);
g.beginPath();
g.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y);
g.strokePath();
g.fillStyle(0x8a8a92, 1);
g.fillCircle(pts[0].x, pts[0].y, 3);
}
export function drawWater(g, terrain, waterY, themeId, time) {
const theme = getTheme(themeId);
g.clear();
const h = terrain.h + 400 - waterY;
g.fillStyle(theme.water, 0.72);
g.fillRect(-200, waterY, terrain.w + 400, h);
// Two offset sine crests, which reads as moving water for the cost of a
// handful of line segments.
for (const [amp, freq, speed, alpha] of [[5, 0.010, 0.0011, 0.55], [3, 0.017, -0.0017, 0.35]]) {
g.lineStyle(3, theme.waterFoam, alpha);
g.beginPath();
for (let x = -200; x <= terrain.w + 200; x += 16) {
const y = waterY + Math.sin(x * freq + time * speed) * amp;
if (x === -200) g.moveTo(x, y); else g.lineTo(x, y);
}
g.strokePath();
}
}
// ---------------------------------------------------------------------------
// Effects
// ---------------------------------------------------------------------------
export function spawnExplosion(scene, layer, x, y, radius, themeId) {
const theme = getTheme(themeId);
const flash = scene.add.circle(x, y, radius * 0.75, 0xfff0c0, 0.95);
layer.add(flash);
scene.tweens.add({
targets: flash, scale: 1.7, alpha: 0, duration: 260,
ease: 'Quad.easeOut', onComplete: () => flash.destroy(),
});
const ring = scene.add.circle(x, y, radius * 0.4);
ring.setStrokeStyle(4, 0xffb050, 0.9);
layer.add(ring);
scene.tweens.add({
targets: ring, scale: 2.6, alpha: 0, duration: 380,
ease: 'Cubic.easeOut', onComplete: () => ring.destroy(),
});
const n = Math.min(26, 8 + Math.round(radius / 5));
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2 + Math.random() * 0.4;
const sp = radius * (1.4 + Math.random() * 2.0);
const size = 2 + Math.random() * 4;
const bit = scene.add.circle(x, y, size, i % 3 === 0 ? 0xffc060 : theme.fillDark, 1);
layer.add(bit);
scene.tweens.add({
targets: bit,
x: x + Math.cos(a) * sp,
y: y + Math.sin(a) * sp + radius * 0.8,
alpha: 0, scale: 0.3,
duration: 420 + Math.random() * 320,
ease: 'Quad.easeOut',
onComplete: () => bit.destroy(),
});
}
const smoke = scene.add.circle(x, y, radius * 0.6, theme.scorch, 0.55);
layer.add(smoke);
scene.tweens.add({
targets: smoke, scale: 1.9, alpha: 0, y: y - radius * 0.7,
duration: 900, ease: 'Sine.easeOut', onComplete: () => smoke.destroy(),
});
}
export function spawnDamageNumber(scene, layer, x, y, amount, color = '#ffffff') {
const txt = scene.add.text(x, y - 34, `${amount}`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color,
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5);
layer.add(txt);
scene.tweens.add({
targets: txt, y: y - 78, alpha: 0, duration: 900,
ease: 'Quad.easeOut', onComplete: () => txt.destroy(),
});
}
export function spawnTracer(scene, layer, x0, y0, x1, y1) {
const g = scene.add.graphics();
g.lineStyle(2, 0xfff0b0, 0.9);
g.lineBetween(x0, y0, x1, y1);
layer.add(g);
scene.tweens.add({
targets: g, alpha: 0, duration: 170, onComplete: () => g.destroy(),
});
}

View File

@ -1,736 +0,0 @@
// Worms — procedural, fully destructible terrain.
//
// Zero imports, Node-testable. This module owns the AUTHORITATIVE terrain
// representation: a Uint8Array mask, one byte per world pixel. Physics, the AI
// and the verifier all read this; the Phaser scene keeps a CanvasTexture in
// lockstep purely for display and never reads pixels back.
//
// Why a bitmask and not polygons: Worms terrain is carved by every explosion,
// leaves floating chunks, and is walked over pixel by pixel. Polygon booleans
// would degrade after a few dozen blasts; a mask is O(r^2) per blast forever.
//
// Generation is seeded and byte-identical for a seed (mulberry32 + an integer
// hash noise, no Math.random anywhere) because the campaign pins seeds and the
// verifier replays recorded turns.
export const AIR = 0; // empty space
export const SOLID = 1; // destructible dirt
export const ROCK = 2; // indestructible border (cavern maps only)
// Out-of-bounds always reads AIR. Cavern maps bake a ROCK border into the mask
// instead of relying on an OOB rule, so there is exactly one convention.
export const MAP_SIZES = {
small: { id: 'small', name: 'Small', w: 1920, h: 900 },
medium: { id: 'medium', name: 'Medium', w: 2560, h: 1000 },
large: { id: 'large', name: 'Large', w: 3200, h: 1100 },
};
export const MAP_SHAPES = ['island', 'cavern'];
export const TERRAIN_TUNING = {
// Water sits this far up from the bottom of the world at match start.
WATER_DEPTH: 90,
// --- island ---
ISL_SURFACE_SCALE: 340, // px per noise cell for the top surface
ISL_SURFACE_OCTAVES: 5,
ISL_TOP_MIN: 0.20, // surface stays between these fractions of height
ISL_TOP_MAX: 0.62,
ISL_COAST_FRAC: 0.16, // fraction of width the land tapers into water over
ISL_CAVE_SCALE: 170,
ISL_CAVE_OCTAVES: 4,
ISL_CAVE_THRESHOLD: 0.60, // higher = fewer caves
ISL_CAVE_MARGIN: 34, // no caves within this many px of the surface
ISL_SPECK_MIN: 260, // solid components smaller than this are dropped
// --- cavern ---
CAV_BORDER: 26, // ROCK frame thickness
CAV_CEIL_MIN: 0.10, // ceiling wanders between these fractions of height
CAV_CEIL_MAX: 0.34,
CAV_FLOOR_MIN: 0.60, // floor wanders between these
CAV_FLOOR_MAX: 0.88,
CAV_SURFACE_SCALE: 300,
CAV_SURFACE_OCTAVES: 4,
CAV_BLOB_SCALE: 150, // pillars, stalactites and ledges inside the void
CAV_BLOB_OCTAVES: 4,
CAV_BLOB_THRESHOLD: 0.615,
CAV_POCKET_SCALE: 190, // extra pockets eaten out of the floor and ceiling
CAV_POCKET_OCTAVES: 3,
CAV_POCKET_THRESHOLD: 0.60,
CAV_SPECK_MIN: 200,
CAV_OPEN_MIN: 0.32, // reject a cavern outside this open-space band
CAV_OPEN_MAX: 0.68,
// Density fields are evaluated on a coarse lattice and bilinearly upsampled.
// Every feature we care about is >= 50px, so this is invisible and ~16x
// cheaper than per-pixel fBm.
FIELD_STEP: 4,
// --- spawn placement ---
SPAWN_HEADROOM: 44, // clear air needed above a spawn
SPAWN_FOOTING: 10, // solid depth needed beneath it
SPAWN_FLAT_SPAN: 9, // half-width of the flatness probe
SPAWN_FLAT_TOLERANCE: 16, // max surface height swing across that span
SPAWN_MIN_SEP: 130, // min px between any two spawned worms
};
// ---------------------------------------------------------------------------
// Seeded randomness
// ---------------------------------------------------------------------------
export function mulberry32(seed) {
let a = seed >>> 0;
return function rng() {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Integer lattice hash — no permutation table, so it costs nothing to have as
// many independent noise channels as we like (just vary `seed`).
function hash2(x, y, seed) {
let h = (seed ^ Math.imul(x, 374761393) ^ Math.imul(y, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function value2(x, y, seed) {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = smoothstep(x - ix), fy = smoothstep(y - iy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
const top = a + (b - a) * fx;
const bot = c + (d - c) * fx;
return top + (bot - top) * fy;
}
// Fractional Brownian motion in [0,1].
function fbm2(x, y, seed, octaves, lacunarity = 2, gain = 0.5) {
let sum = 0, amp = 1, norm = 0, fx = x, fy = y;
for (let o = 0; o < octaves; o++) {
sum += amp * value2(fx, fy, seed + o * 1013);
norm += amp;
amp *= gain;
fx *= lacunarity;
fy *= lacunarity;
}
return sum / norm;
}
// 1-D slice of the same noise, for surface height curves.
function fbm1(x, seed, octaves) { return fbm2(x, 0.5, seed, octaves); }
// ---------------------------------------------------------------------------
// Terrain object
// ---------------------------------------------------------------------------
export function createTerrain(w, h, meta = {}) {
return {
w, h,
mask: new Uint8Array(w * h),
waterY: h - TERRAIN_TUNING.WATER_DEPTH,
shape: meta.shape ?? 'island',
theme: meta.theme ?? 'forest',
seed: meta.seed ?? 0,
size: meta.size ?? 'medium',
};
}
export function inBounds(t, x, y) { return x >= 0 && y >= 0 && x < t.w && y < t.h; }
export function cellAt(t, x, y) {
if (x < 0 || y < 0 || x >= t.w || y >= t.h) return AIR;
return t.mask[y * t.w + x];
}
export function isSolid(t, x, y) { return cellAt(t, x | 0, y | 0) !== AIR; }
// True where a worm cannot stand and an explosion cannot dig.
export function isRock(t, x, y) { return cellAt(t, x | 0, y | 0) === ROCK; }
// ---------------------------------------------------------------------------
// Destruction and construction
// ---------------------------------------------------------------------------
// Carve a circular hole. ROCK survives. Returns the dirty rect (for the
// renderer) plus how many pixels actually went, which the crater FX scales off.
export function carveCircle(t, cx, cy, r) {
cx |= 0; cy |= 0; r |= 0;
const x0 = Math.max(0, cx - r), x1 = Math.min(t.w - 1, cx + r);
const y0 = Math.max(0, cy - r), y1 = Math.min(t.h - 1, cy + r);
const r2 = r * r;
let removed = 0;
for (let y = y0; y <= y1; y++) {
const dy = y - cy, row = y * t.w;
const span = Math.floor(Math.sqrt(Math.max(0, r2 - dy * dy)));
const sx = Math.max(x0, cx - span), ex = Math.min(x1, cx + span);
for (let x = sx; x <= ex; x++) {
if (t.mask[row + x] === SOLID) { t.mask[row + x] = AIR; removed++; }
}
}
return { x0, y0, x1, y1, removed };
}
export function addCircle(t, cx, cy, r, value = SOLID) {
cx |= 0; cy |= 0; r |= 0;
const r2 = r * r;
const y0 = Math.max(0, cy - r), y1 = Math.min(t.h - 1, cy + r);
for (let y = y0; y <= y1; y++) {
const dy = y - cy, row = y * t.w;
const span = Math.floor(Math.sqrt(Math.max(0, r2 - dy * dy)));
const sx = Math.max(0, cx - span), ex = Math.min(t.w - 1, cx + span);
for (let x = sx; x <= ex; x++) {
if (t.mask[row + x] !== ROCK) t.mask[row + x] = value;
}
}
}
// The Girder weapon: an oriented solid slab. Placed over air only — girders
// never overwrite existing terrain, matching the original.
export function addGirder(t, cx, cy, len, thick, angleRad) {
const ca = Math.cos(angleRad), sa = Math.sin(angleRad);
const hl = len / 2, ht = thick / 2;
const reach = Math.ceil(Math.hypot(hl, ht)) + 1;
const x0 = Math.max(0, (cx - reach) | 0), x1 = Math.min(t.w - 1, (cx + reach) | 0);
const y0 = Math.max(0, (cy - reach) | 0), y1 = Math.min(t.h - 1, (cy + reach) | 0);
let placed = 0;
for (let y = y0; y <= y1; y++) {
const row = y * t.w;
for (let x = x0; x <= x1; x++) {
const dx = x - cx, dy = y - cy;
const u = dx * ca + dy * sa; // along the girder
const v = -dx * sa + dy * ca; // across it
if (Math.abs(u) <= hl && Math.abs(v) <= ht && t.mask[row + x] === AIR) {
t.mask[row + x] = SOLID;
placed++;
}
}
}
return { x0, y0, x1, y1, placed };
}
// ---------------------------------------------------------------------------
// Queries used by physics
// ---------------------------------------------------------------------------
// Outward surface normal at a contact point, from the solidity gradient over a
// disc of radius `r`. Grenades bounce about this; a worm uses it to decide
// which way a slope runs. Returns a unit vector, or null in open air / deep
// inside solid rock where the gradient is degenerate.
export function normalAt(t, x, y, r = 6) {
x |= 0; y |= 0;
let gx = 0, gy = 0;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const d2 = dx * dx + dy * dy;
if (d2 === 0 || d2 > r * r) continue;
if (cellAt(t, x + dx, y + dy) !== AIR) {
const inv = 1 / Math.sqrt(d2);
gx -= dx * inv;
gy -= dy * inv;
}
}
}
const len = Math.hypot(gx, gy);
if (len < 1e-6) return null;
return { x: gx / len, y: gy / len };
}
// Walk a segment one pixel at a time and report the first solid pixel. This is
// the swept test that stops fast projectiles tunnelling thin walls — a rocket
// at 900 px/s moves 7.5px per 120Hz substep, and a girder is 8px thick.
export function raycast(t, x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0;
const dist = Math.hypot(dx, dy);
const steps = Math.max(1, Math.ceil(dist));
const sx = dx / steps, sy = dy / steps;
let px = x0, py = y0;
for (let i = 1; i <= steps; i++) {
const nx = x0 + sx * i, ny = y0 + sy * i;
if (isSolid(t, nx, ny)) {
return { hit: true, x: px, y: py, hitX: nx, hitY: ny, t: i / steps };
}
px = nx; py = ny;
}
return { hit: false, x: x1, y: y1, hitX: x1, hitY: y1, t: 1 };
}
// First solid pixel at or below (x, y). Returns -1 if the column is clear all
// the way down, which on an island map means "over the water".
export function firstSolidBelow(t, x, y) {
x |= 0;
if (x < 0 || x >= t.w) return -1;
for (let yy = Math.max(0, y | 0); yy < t.h; yy++) {
if (t.mask[yy * t.w + x] !== AIR) return yy;
}
return -1;
}
// Highest solid pixel in a column — the outdoor surface.
export function surfaceY(t, x) {
x |= 0;
if (x < 0 || x >= t.w) return -1;
for (let y = 0; y < t.h; y++) {
if (t.mask[y * t.w + x] !== AIR) return y;
}
return -1;
}
export function solidCount(t) {
let n = 0;
for (let i = 0; i < t.mask.length; i++) if (t.mask[i] !== AIR) n++;
return n;
}
// FNV-1a over the mask. Determinism tests compare these.
export function hashMask(t) {
let h = 0x811c9dc5;
const m = t.mask;
for (let i = 0; i < m.length; i++) {
h ^= m[i];
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// ---------------------------------------------------------------------------
// Connected components
// ---------------------------------------------------------------------------
// 4-connected labelling over pixels satisfying `solidPred`. Scanline fill keeps
// the stack tiny even on a 3.5M-pixel map.
function labelComponents(w, h, mask, wantSolid) {
const labels = new Int32Array(w * h).fill(-1);
const sizes = [];
const stack = [];
const ok = (i) => (wantSolid ? mask[i] !== AIR : mask[i] === AIR);
for (let sy = 0; sy < h; sy++) {
for (let sxi = 0; sxi < w; sxi++) {
const start = sy * w + sxi;
if (labels[start] !== -1 || !ok(start)) continue;
const id = sizes.length;
let count = 0;
stack.push(sxi, sy);
while (stack.length) {
const y = stack.pop(), x = stack.pop();
const row = y * w;
if (labels[row + x] !== -1 || !ok(row + x)) continue;
let xl = x;
while (xl > 0 && labels[row + xl - 1] === -1 && ok(row + xl - 1)) xl--;
let xr = x;
while (xr < w - 1 && labels[row + xr + 1] === -1 && ok(row + xr + 1)) xr++;
for (let i = xl; i <= xr; i++) { labels[row + i] = id; count++; }
for (const ny of [y - 1, y + 1]) {
if (ny < 0 || ny >= h) continue;
const nrow = ny * w;
let i = xl;
while (i <= xr) {
while (i <= xr && (labels[nrow + i] !== -1 || !ok(nrow + i))) i++;
if (i > xr) break;
stack.push(i, ny);
while (i <= xr && labels[nrow + i] === -1 && ok(nrow + i)) i++;
}
}
}
sizes.push(count);
}
}
return { labels, sizes };
}
// ---------------------------------------------------------------------------
// Density fields
// ---------------------------------------------------------------------------
// Evaluate `fn(x, y)` on a lattice of FIELD_STEP and return a bilinear sampler
// over full-resolution coordinates.
function coarseField(w, h, step, fn) {
const cw = Math.ceil(w / step) + 1;
const ch = Math.ceil(h / step) + 1;
const data = new Float32Array(cw * ch);
for (let j = 0; j < ch; j++) {
for (let i = 0; i < cw; i++) data[j * cw + i] = fn(i * step, j * step);
}
return function sample(x, y) {
const gx = x / step, gy = y / step;
const i = Math.min(cw - 2, Math.max(0, Math.floor(gx)));
const j = Math.min(ch - 2, Math.max(0, Math.floor(gy)));
const fx = gx - i, fy = gy - j;
const a = data[j * cw + i], b = data[j * cw + i + 1];
const c = data[(j + 1) * cw + i], d = data[(j + 1) * cw + i + 1];
const top = a + (b - a) * fx;
const bot = c + (d - c) * fx;
return top + (bot - top) * fy;
};
}
// ---------------------------------------------------------------------------
// Island generation
// ---------------------------------------------------------------------------
//
// A heightfield surface filled down to the bottom of the world, minus a noise
// cave system and minus the coastline. Caves are held back from the surface so
// the top crust never reads as swiss cheese; below that they cut arches,
// tunnels and the overhangs that make rope play worthwhile.
function generateIsland(t, rng) {
const T = TERRAIN_TUNING;
const { w, h } = t;
const seedA = (rng() * 0x7fffffff) | 0;
const seedB = (rng() * 0x7fffffff) | 0;
const seedC = (rng() * 0x7fffffff) | 0;
const topMin = h * T.ISL_TOP_MIN;
const topMax = h * T.ISL_TOP_MAX;
// Coastline envelope: 1 inland, 0 at the very edges, so the landmass sinks
// below the waterline before it reaches the world border.
const coast = Math.max(1, Math.floor(w * T.ISL_COAST_FRAC));
const envelope = (x) => {
const d = Math.min(x, w - 1 - x);
if (d >= coast) return 1;
return smoothstep(d / coast);
};
// Surface height per column.
const top = new Int32Array(w);
for (let x = 0; x < w; x++) {
const n = fbm1(x / T.ISL_SURFACE_SCALE, seedA, T.ISL_SURFACE_OCTAVES);
const base = topMin + (topMax - topMin) * n;
// Sink to well below the water line at the coasts.
const e = envelope(x);
top[x] = Math.round(base * e + (h + 60) * (1 - e));
}
// Fill from the surface to the bottom of the world.
for (let x = 0; x < w; x++) {
const y0 = Math.max(0, top[x]);
for (let y = y0; y < h; y++) t.mask[y * w + x] = SOLID;
}
// Cave field.
const caves = coarseField(w, h, T.FIELD_STEP, (x, y) =>
fbm2(x / T.ISL_CAVE_SCALE, y / T.ISL_CAVE_SCALE, seedB, T.ISL_CAVE_OCTAVES));
for (let x = 0; x < w; x++) {
const guard = top[x] + T.ISL_CAVE_MARGIN;
for (let y = guard; y < h; y++) {
const i = y * w + x;
if (t.mask[i] === AIR) continue;
// Caves fade out near the very bottom so the island keeps a floor.
const depthFade = y > h - 80 ? (h - y) / 80 : 1;
if (caves(x, y) > T.ISL_CAVE_THRESHOLD + (1 - depthFade) * 0.4) {
t.mask[i] = AIR;
}
}
}
// A couple of big bites out of the sides, so no two islands read alike.
const bites = 2 + Math.floor(rng() * 3);
for (let b = 0; b < bites; b++) {
const bx = Math.floor(rng() * w);
const byBase = surfaceY(t, bx);
if (byBase < 0) continue;
const by = byBase + 40 + Math.floor(rng() * (h * 0.4));
const r = 50 + Math.floor(rng() * 110);
carveCircle(t, bx, by, r);
}
// One or two arches: a random walk of overlapping circles through the body.
const arches = 1 + Math.floor(rng() * 2);
for (let a = 0; a < arches; a++) {
let ax = w * (0.15 + rng() * 0.7);
const startY = surfaceY(t, Math.floor(ax));
if (startY < 0) continue;
let ay = startY + 70 + rng() * 120;
let dir = rng() < 0.5 ? -1 : 1;
const r = 18 + rng() * 14;
const steps = 40 + Math.floor(rng() * 60);
for (let s = 0; s < steps; s++) {
carveCircle(t, ax | 0, ay | 0, r | 0);
ax += dir * (6 + rng() * 6);
ay += (rng() - 0.5) * 9;
if (ax < r || ax > w - r) dir = -dir;
ay = Math.max(startY + 50, Math.min(h - 60, ay));
}
}
// Drop specks. Floating chunks are authentic Worms and stay, but single-digit
// pixel crumbs just look like dirt on the screen.
pruneSpecks(t, T.ISL_SPECK_MIN);
seedC; // reserved channel, keeps seed consumption stable across tweaks
}
function pruneSpecks(t, minSize) {
const { labels, sizes } = labelComponents(t.w, t.h, t.mask, true);
let dropped = 0;
for (let i = 0; i < t.mask.length; i++) {
const id = labels[i];
if (id >= 0 && sizes[id] < minSize && t.mask[i] === SOLID) { t.mask[i] = AIR; dropped++; }
}
return dropped;
}
// ---------------------------------------------------------------------------
// Cavern generation
// ---------------------------------------------------------------------------
//
// The inverse problem: start solid and carve. Everything is walled in by an
// indestructible ROCK frame, so projectiles ricochet off the ceiling and no
// worm can ever be knocked out of the world — the whole point of the shape.
//
// A plain noise threshold does NOT work here: the base octave decides huge
// regions at once, so you get one usable chamber and two thirds of the map
// welded shut. Instead the void is carved as a band between a wavy ceiling and
// a wavy floor — connected across the full width by construction — and the
// interest comes from what gets put BACK: pillars, stalactites and floating
// ledges from a second noise field.
function generateCavern(t, rng) {
const T = TERRAIN_TUNING;
const { w, h } = t;
const seedCeil = (rng() * 0x7fffffff) | 0;
const seedFloor = (rng() * 0x7fffffff) | 0;
const seedBlob = (rng() * 0x7fffffff) | 0;
const seedPock = (rng() * 0x7fffffff) | 0;
const b = T.CAV_BORDER;
t.mask.fill(SOLID);
// Ceiling and floor curves.
const ceilY = new Int32Array(w);
const floorY = new Int32Array(w);
for (let x = 0; x < w; x++) {
const nc = fbm1(x / T.CAV_SURFACE_SCALE, seedCeil, T.CAV_SURFACE_OCTAVES);
const nf = fbm1(x / T.CAV_SURFACE_SCALE, seedFloor, T.CAV_SURFACE_OCTAVES);
ceilY[x] = Math.round(h * (T.CAV_CEIL_MIN + (T.CAV_CEIL_MAX - T.CAV_CEIL_MIN) * nc));
floorY[x] = Math.round(h * (T.CAV_FLOOR_MIN + (T.CAV_FLOOR_MAX - T.CAV_FLOOR_MIN) * nf));
// Never let the two curves cross or pinch the cave shut.
if (floorY[x] - ceilY[x] < 200) floorY[x] = ceilY[x] + 200;
}
for (let x = 0; x < w; x++) {
for (let y = ceilY[x]; y < floorY[x]; y++) t.mask[y * w + x] = AIR;
}
// Put material back inside the void: stalactites hanging off the ceiling,
// mounds rising off the floor, pillars, and slabs floating in mid-air. All
// one noise field, so they read as one rock formation rather than props.
const blobs = coarseField(w, h, T.FIELD_STEP, (x, y) =>
fbm2(x / T.CAV_BLOB_SCALE, y / T.CAV_BLOB_SCALE, seedBlob, T.CAV_BLOB_OCTAVES));
for (let x = 0; x < w; x++) {
for (let y = ceilY[x]; y < floorY[x]; y++) {
if (blobs(x, y) > T.CAV_BLOB_THRESHOLD) t.mask[y * w + x] = SOLID;
}
}
// ...and eat pockets back out of the floor and ceiling, so the rock body has
// side caves and the floor has pits worth rope-swinging over.
const pockets = coarseField(w, h, T.FIELD_STEP, (x, y) =>
fbm2(x / T.CAV_POCKET_SCALE, y / T.CAV_POCKET_SCALE, seedPock, T.CAV_POCKET_OCTAVES));
for (let x = 0; x < w; x++) {
for (let y = b; y < h - b; y++) {
const i = y * w + x;
if (t.mask[i] !== SOLID) continue;
// Only within reach of the main void — no sealed bubbles deep in the rock.
const near = y > ceilY[x] - 160 && y < floorY[x] + 160;
if (near && pockets(x, y) > T.CAV_POCKET_THRESHOLD) t.mask[i] = AIR;
}
}
// Keep only the largest air pocket; seal the rest. A cavern where half the
// worms spawn in an unreachable bubble is not a level.
const { labels, sizes } = labelComponents(w, h, t.mask, false);
let best = -1, bestN = -1;
for (let i = 0; i < sizes.length; i++) if (sizes[i] > bestN) { bestN = sizes[i]; best = i; }
if (best >= 0) {
for (let i = 0; i < t.mask.length; i++) {
if (t.mask[i] === AIR && labels[i] !== best) t.mask[i] = SOLID;
}
}
pruneSpecks(t, T.CAV_SPECK_MIN);
// Indestructible frame, drawn last so nothing carved it.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (x < b || y < b || x >= w - b || y >= h - b) t.mask[y * w + x] = ROCK;
}
}
}
// ---------------------------------------------------------------------------
// Spawn points
// ---------------------------------------------------------------------------
// Every column that offers flat-enough footing with headroom above it, sampled
// every few pixels. On a cavern map this finds ledges inside the chamber as
// well as the floor.
export function findFootholds(t, stride = 6) {
const T = TERRAIN_TUNING;
const out = [];
for (let x = T.SPAWN_FLAT_SPAN; x < t.w - T.SPAWN_FLAT_SPAN; x += stride) {
// Walk the column top-down looking for air->solid transitions (ledges).
let y = 0;
while (y < t.h) {
// find next solid
while (y < t.h && t.mask[y * t.w + x] === AIR) y++;
if (y >= t.h) break;
const surf = y;
// measure solid depth
let depth = 0;
while (y < t.h && t.mask[y * t.w + x] !== AIR) { depth++; y++; }
if (t.mask[surf * t.w + x] === ROCK) continue;
if (depth < T.SPAWN_FOOTING) continue;
if (surf >= t.waterY - 12) continue; // in or under the water
if (!hasHeadroom(t, x, surf, T.SPAWN_HEADROOM)) continue;
if (!isFlatEnough(t, x, surf)) continue;
out.push({ x, y: surf });
}
}
return out;
}
function hasHeadroom(t, x, surfY, need) {
const top = Math.max(0, surfY - need);
for (let y = top; y < surfY; y++) if (t.mask[y * t.w + x] !== AIR) return false;
return surfY - top >= need * 0.75;
}
function isFlatEnough(t, x, surfY) {
const T = TERRAIN_TUNING;
let lo = surfY, hi = surfY;
for (let d = -T.SPAWN_FLAT_SPAN; d <= T.SPAWN_FLAT_SPAN; d++) {
const yy = nearestSurfaceNear(t, x + d, surfY);
if (yy < 0) return false;
if (yy < lo) lo = yy;
if (yy > hi) hi = yy;
}
return hi - lo <= T.SPAWN_FLAT_TOLERANCE;
}
// The surface of the ledge nearest `refY` in column x — not the topmost
// surface, which on a cavern map is the ceiling.
function nearestSurfaceNear(t, x, refY, window = 30) {
if (x < 0 || x >= t.w) return -1;
for (let d = 0; d <= window; d++) {
for (const y of (d === 0 ? [refY] : [refY - d, refY + d])) {
if (y <= 0 || y >= t.h) continue;
if (t.mask[y * t.w + x] !== AIR && t.mask[(y - 1) * t.w + x] === AIR) return y;
}
}
return -1;
}
// Pick `count` spawn points, spread out, drawn from the available footholds.
// Returns null if the map cannot seat them all with the required separation.
export function pickSpawns(t, rng, count, minSep = TERRAIN_TUNING.SPAWN_MIN_SEP, pool = null) {
pool = pool ?? findFootholds(t);
if (pool.length < count) return null;
// Shuffle deterministically, then greedily accept anything far enough from
// what we already took. Greedy on a shuffled pool spreads worms out without
// the clumping a pure random draw gives.
const shuffled = pool.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
for (let relax = 0; relax < 4; relax++) {
const sep = minSep * (1 - relax * 0.2);
const taken = [];
for (const p of shuffled) {
if (taken.every((q) => Math.hypot(q.x - p.x, q.y - p.y) >= sep)) {
taken.push(p);
if (taken.length === count) return taken;
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// Generation entry point + fairness lint
// ---------------------------------------------------------------------------
export function generateMap({ seed, shape = 'island', size = 'medium', theme = 'forest' }) {
const dims = MAP_SIZES[size] ?? MAP_SIZES.medium;
const t = createTerrain(dims.w, dims.h, { shape, theme, seed, size });
const rng = mulberry32(seed >>> 0);
if (shape === 'cavern') generateCavern(t, rng);
else generateIsland(t, rng);
return t;
}
// Everything a map must satisfy before a match is allowed to start on it.
// The generator retries with the next seed until this passes, and the verifier
// asserts it over thousands of seeds.
export function lintMap(t, { worms = 8 } = {}) {
const T = TERRAIN_TUNING;
const reasons = [];
const solid = solidCount(t);
const frac = solid / (t.w * t.h);
if (t.shape === 'cavern') {
const openFrac = 1 - frac;
if (openFrac < T.CAV_OPEN_MIN) reasons.push(`cavern too cramped (${openFrac.toFixed(2)} open)`);
if (openFrac > T.CAV_OPEN_MAX) reasons.push(`cavern too empty (${openFrac.toFixed(2)} open)`);
// The frame must be intact all the way round or worms leak out of the world.
for (let x = 0; x < t.w; x++) {
if (t.mask[x] !== ROCK || t.mask[(t.h - 1) * t.w + x] !== ROCK) {
reasons.push('cavern frame breached'); break;
}
}
} else {
if (frac < 0.12) reasons.push(`island too sparse (${frac.toFixed(2)} solid)`);
if (frac > 0.70) reasons.push(`island too solid (${frac.toFixed(2)} solid)`);
// Air strikes need open sky over a decent share of the surface.
let openSky = 0, columns = 0;
for (let x = 0; x < t.w; x += 8) {
const s = surfaceY(t, x);
columns++;
if (s > 60) openSky++;
}
if (openSky / columns < 0.5) reasons.push('not enough open sky for air strikes');
}
// Twice the footholds we need, so spawn placement has real choice.
const pool = findFootholds(t);
if (pool.length < worms * 2) reasons.push(`only ${pool.length} footholds for ${worms} worms`);
// And they must actually seat with separation.
const spawns = pickSpawns(t, mulberry32(0xa11ce ^ (t.seed >>> 0)), worms, T.SPAWN_MIN_SEP, pool);
if (!spawns) reasons.push(`cannot seat ${worms} worms ${T.SPAWN_MIN_SEP}px apart`);
// Spread: worms bunched into one third of the map is a firing-squad start.
if (spawns) {
const xs = spawns.map((s) => s.x);
const span = Math.max(...xs) - Math.min(...xs);
if (span < t.w * 0.45) reasons.push(`spawns span only ${(span / t.w).toFixed(2)} of the map`);
}
return { ok: reasons.length === 0, reasons, spawns, solidFraction: frac, footholds: pool.length };
}
// Generate until a map passes lint. Deterministic: the same starting seed
// always lands on the same accepted map.
export function generateValidMap(opts, { worms = 8, maxTries = 40 } = {}) {
let seed = opts.seed >>> 0;
let last = null;
for (let i = 0; i < maxTries; i++) {
const t = generateMap({ ...opts, seed });
const lint = lintMap(t, { worms });
if (lint.ok) return { terrain: t, lint, tries: i + 1, seed };
last = { terrain: t, lint, tries: i + 1, seed };
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
}
return last;
}

View File

@ -1,115 +0,0 @@
// Worms — level themes.
//
// Zero imports. Each theme carries a full procedural palette so the game is
// playable and legible with no art at all, plus named art slots that Brian can
// fill in later. The slots are resolved at runtime with `textures.exists()`
// (never a flag), so dropping a PNG in is the only step needed to upgrade one.
//
// Art slots per theme, all optional:
// worms-bg-<id> 1920x1080 backdrop, parallaxed
// worms-fill-<id> seamless tile for the inside of the terrain (256x256)
// worms-surface-<id> seamless strip for the top crust (256x48)
// worms-objects-<id> spritesheet of background scenery, 160x160 cells
//
// Paths live in data/worms-artwork.json; anything still null renders
// procedurally from the palette below.
export const THEMES = [
{
id: 'forest', name: 'Forest',
sky: [0x6fb7e8, 0xbfe4f5],
fill: 0x6b4a2a, fillDark: 0x4a3119, fillLight: 0x83603a,
surface: 0x4f9d3a, surfaceDark: 0x37732a,
rock: 0x54514c,
water: 0x2f6fb0, waterFoam: 0x9fd0f0,
scorch: 0x2a1c10,
grain: 0.16, objects: ['tree', 'bush', 'rock', 'stump'],
},
{
id: 'desert', name: 'Desert',
sky: [0xe8b878, 0xf7e2bb],
fill: 0xc79a5c, fillDark: 0x9c7440, fillLight: 0xdcb478,
surface: 0xe6c98d, surfaceDark: 0xc0a066,
rock: 0x8a7358,
water: 0x3a8fa8, waterFoam: 0xbfe8ef,
scorch: 0x5c3f22,
grain: 0.10, objects: ['cactus', 'skull', 'dune', 'rock'],
},
{
id: 'farm', name: 'Farm',
sky: [0x86c4ea, 0xd8eef8],
fill: 0x7a5233, fillDark: 0x543720, fillLight: 0x96683f,
surface: 0x76b93f, surfaceDark: 0x53902c,
rock: 0x6a655c,
water: 0x3a76ab, waterFoam: 0xa8d6ef,
scorch: 0x33220f,
grain: 0.14, objects: ['haybale', 'fence', 'barn', 'scarecrow'],
},
{
id: 'hell', name: 'Hell',
sky: [0x3a0d0d, 0x8c2416],
fill: 0x5a2320, fillDark: 0x3a1412, fillLight: 0x7b3226,
surface: 0x9c3b1e, surfaceDark: 0x6d2712,
rock: 0x4a3230,
water: 0xd4491c, waterFoam: 0xffb057,
scorch: 0x1b0705,
grain: 0.20, objects: ['pillar', 'skullpile', 'spike', 'brazier'],
},
{
id: 'arctic', name: 'Arctic',
sky: [0x9dc6e0, 0xe8f4fb],
fill: 0x8fa9bd, fillDark: 0x6b8296, fillLight: 0xaec5d6,
surface: 0xf2fbff, surfaceDark: 0xcfe4f2,
rock: 0x6e7d8a,
water: 0x2f6d9e, waterFoam: 0xd8f0ff,
scorch: 0x44525e,
grain: 0.08, objects: ['iceberg', 'pine', 'igloo', 'floe'],
},
{
id: 'jungle', name: 'Jungle',
sky: [0x77bfa8, 0xd3ecdf],
fill: 0x53412a, fillDark: 0x352a19, fillLight: 0x6d5637,
surface: 0x2f8f4a, surfaceDark: 0x1f6a35,
rock: 0x4b5347,
water: 0x2b7f77, waterFoam: 0xa6e2d5,
scorch: 0x1a1409,
grain: 0.18, objects: ['palm', 'vine', 'idol', 'fern'],
},
{
id: 'construction', name: 'Construction',
sky: [0x8f9aa6, 0xd6dde3],
fill: 0x7d7a72, fillDark: 0x585650, fillLight: 0x99968c,
surface: 0xb8a24e, surfaceDark: 0x8d7b39,
rock: 0x5f5d59,
water: 0x4a6f86, waterFoam: 0xc2dbe8,
scorch: 0x2c2b28,
grain: 0.12, objects: ['girder', 'crane', 'pipe', 'barrelstack'],
},
{
id: 'space', name: 'Space',
sky: [0x080a1c, 0x1d2352],
fill: 0x5a4f6e, fillDark: 0x3a3149, fillLight: 0x776a8d,
surface: 0x9a86c4, surfaceDark: 0x6f5f94,
rock: 0x413a52,
water: 0x2b3f8c, waterFoam: 0x8fa6ff,
scorch: 0x14101c,
grain: 0.15, objects: ['satellite', 'crater', 'monolith', 'antenna'],
},
];
export const THEMES_BY_ID = Object.fromEntries(THEMES.map((t) => [t.id, t]));
export function getTheme(id) { return THEMES_BY_ID[id] ?? THEMES[0]; }
export const THEME_IDS = THEMES.map((t) => t.id);
// Art slot keys for a theme. `assetManifest.js` resolves these against
// data/worms-artwork.json and skips anything still unpainted.
export function artKeys(themeId) {
return {
backdrop: `worms-bg-${themeId}`,
fill: `worms-fill-${themeId}`,
surface: `worms-surface-${themeId}`,
objects: `worms-objects-${themeId}`,
};
}

View File

@ -1,214 +0,0 @@
// Worms — the weapon table.
//
// Zero imports. The canonical numbers live here rather than in data/*.json on
// purpose: the verifier, the AI and the scene must all agree on them exactly,
// and a plain ESM import gives that for free in both the browser and bare Node
// with no fetch and no async boot ordering.
//
// `kind` decides which input flow the weapon needs, and it is the only switch
// the scene and the AI branch on:
//
// charge hold to build power, fires a projectile along the aim angle
// instant fires the moment you press, at the aim angle (hitscan)
// melee hits whatever is directly in front of the worm
// place drops at the worm's feet
// target pick a point on the map first, then it happens
// utility changes the worm's state rather than damaging anything
// meta not a weapon at all (skip, surrender)
//
// Damage numbers are the Worms Armageddon defaults. `blastRadius` is the damage
// radius; `crater` is how much terrain goes, and is deliberately a little
// smaller so a hit that hurts does not always open a hole you can fall through.
export const WEAPON_KINDS = ['charge', 'instant', 'melee', 'place', 'target', 'utility', 'meta'];
// Shared projectile flight profiles.
const ARC = { windAffected: true, gravity: true };
const BOUNCY = {
...ARC, bounce: true, restitution: 0.42, tangentFriction: 0.80,
};
export const WEAPONS = [
{
id: 'bazooka', name: 'Bazooka', slot: 1, kind: 'charge',
ammo: Infinity, damage: 45, blastRadius: 55, blastPower: 300, crater: 44,
speedMin: 180, speedMax: 850, projectile: { ...ARC, maxAge: 12 },
sfx: 'worms-launch', crateWeight: 0, endsTurn: true,
tip: 'The workhorse. Watch the wind.',
},
{
id: 'grenade', name: 'Grenade', slot: 2, kind: 'charge',
ammo: Infinity, damage: 45, blastRadius: 55, blastPower: 300, crater: 44,
speedMin: 150, speedMax: 700, fuseOptions: [1, 2, 3, 4, 5], defaultFuse: 3,
projectile: { ...BOUNCY, windAffected: false, maxAge: 12 },
sfx: 'worms-launch', crateWeight: 0, endsTurn: true,
tip: 'Bounces. Set the fuse with 1-5.',
},
{
id: 'cluster', name: 'Cluster Bomb', slot: 2, kind: 'charge',
ammo: 5, damage: 25, blastRadius: 38, blastPower: 210, crater: 30,
speedMin: 150, speedMax: 700, fuseOptions: [1, 2, 3, 4, 5], defaultFuse: 3,
projectile: { ...BOUNCY, windAffected: false, maxAge: 12 },
cluster: { count: 5, damage: 25, blastRadius: 34, blastPower: 190, crater: 26, speed: 260, fuse: 1.6 },
sfx: 'worms-launch', crateWeight: 10, endsTurn: true,
tip: 'Splits into five bomblets. Good over flat ground.',
},
{
id: 'shotgun', name: 'Shotgun', slot: 3, kind: 'instant',
ammo: 3, damage: 25, blastRadius: 28, blastPower: 150, crater: 22,
shots: 2, range: 1400,
sfx: 'worms-shotgun', crateWeight: 10, endsTurn: true, endsTurnAfterShots: true,
tip: 'Two shots per turn. Fires straight, ignores wind.',
},
{
id: 'uzi', name: 'Uzi', slot: 3, kind: 'instant',
ammo: 3, damage: 5, blastRadius: 12, blastPower: 60, crater: 7,
burst: 12, burstInterval: 0.06, spread: 0.05, range: 1400,
sfx: 'worms-uzi', crateWeight: 10, endsTurn: true,
tip: 'Twelve rounds of chip damage and a shove.',
},
{
id: 'firepunch', name: 'Fire Punch', slot: 4, kind: 'melee',
ammo: 3, damage: 30, blastRadius: 34, blastPower: 400, crater: 0,
reach: 26, launchUp: 340,
selfLaunch: { vx: 40, vy: -300 },
sfx: 'worms-punch', crateWeight: 8, endsTurn: true,
tip: 'Uppercut. Sends them up, not away — line up a drop.',
},
{
id: 'dynamite', name: 'Dynamite', slot: 5, kind: 'place',
ammo: 2, damage: 75, blastRadius: 90, blastPower: 480, crater: 72,
fuse: 5,
sfx: 'worms-fuse', crateWeight: 8, endsTurn: true,
tip: 'Five second fuse. Plant it and run.',
},
{
id: 'mine', name: 'Land Mine', slot: 5, kind: 'place',
ammo: 2, damage: 50, blastRadius: 60, blastPower: 380, crater: 48,
armDelay: 1.0, triggerRadius: 34, fuse: 2.0,
sfx: 'worms-mine', crateWeight: 8, endsTurn: true,
tip: 'Arms after a second, then waits.',
},
{
id: 'airstrike', name: 'Air Strike', slot: 6, kind: 'target',
ammo: 2, damage: 30, blastRadius: 44, blastPower: 250, crater: 36,
bombs: 5, bombSpacing: 46, dropSpeed: 260, planeHeight: 40,
projectile: { ...ARC, maxAge: 12 },
sfx: 'worms-plane', crateWeight: 8, endsTurn: true, needsOpenSky: true,
tip: 'Click a target. Needs open sky above it.',
},
{
id: 'homing', name: 'Homing Missile', slot: 6, kind: 'target',
ammo: 2, damage: 45, blastRadius: 55, blastPower: 300, crater: 44,
speedMin: 200, speedMax: 700,
projectile: { ...ARC, maxAge: 14, homing: true, homingDelay: 0.55, homingTurn: 3.4 },
aimAfterTarget: true,
sfx: 'worms-launch', crateWeight: 6, endsTurn: true,
tip: 'Mark a target, then fire. It will come around.',
},
{
id: 'hhg', name: 'Holy Hand Grenade', slot: 7, kind: 'charge',
ammo: 1, damage: 90, blastRadius: 120, blastPower: 700, crater: 95,
speedMin: 150, speedMax: 620, fuse: 5,
projectile: { ...BOUNCY, windAffected: false, restitution: 0.30, maxAge: 12 },
sfx: 'worms-hallelujah', crateWeight: 3, endsTurn: true, super: true,
tip: 'And the Lord did grin.',
},
{
id: 'blowtorch', name: 'Blowtorch', slot: 8, kind: 'utility',
ammo: 2, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
burnRadius: 13, burnSpeed: 150, fuel: 2.6,
sfx: 'worms-torch', crateWeight: 6, endsTurn: false,
tip: 'Dig a tunnel. Hold the direction you want to go.',
},
{
id: 'girder', name: 'Girder', slot: 8, kind: 'target',
ammo: 2, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
length: 130, thickness: 9, angles: [0, Math.PI / 6, Math.PI / 3, Math.PI / 2,
-Math.PI / 3, -Math.PI / 6],
sfx: 'worms-girder', crateWeight: 6, endsTurn: false, buildsTerrain: true,
tip: 'Build a platform. Rotate with the mouse wheel.',
},
{
id: 'teleport', name: 'Teleport', slot: 9, kind: 'target',
ammo: 2, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
sfx: 'worms-teleport', crateWeight: 6, endsTurn: false,
tip: 'Click anywhere you can fit. Best escape in the game.',
},
{
id: 'ninjarope', name: 'Ninja Rope', slot: 9, kind: 'utility',
ammo: 5, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
sfx: 'worms-rope', crateWeight: 8, endsTurn: false, movement: true,
tip: 'Aim, fire, swing. Reel with up and down, let go with the fire key.',
},
{
id: 'skip', name: 'Skip Go', slot: 0, kind: 'meta',
ammo: Infinity, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
sfx: null, crateWeight: 0, endsTurn: true,
tip: 'Pass the turn.',
},
{
id: 'surrender', name: 'Surrender', slot: 0, kind: 'meta',
ammo: Infinity, damage: 0, blastRadius: 0, blastPower: 0, crater: 0,
sfx: null, crateWeight: 0, endsTurn: true, surrender: true,
tip: 'Retire the whole team.',
},
];
export const WEAPONS_BY_ID = Object.fromEntries(WEAPONS.map((w) => [w.id, w]));
export function getWeapon(id) { return WEAPONS_BY_ID[id] ?? null; }
// The order weapons appear in the panel: by slot, then by table order.
export const PANEL_ORDER = WEAPONS
.filter((w) => w.id !== 'surrender')
.slice()
.sort((a, b) => (a.slot - b.slot) || WEAPONS.indexOf(a) - WEAPONS.indexOf(b))
.map((w) => w.id);
// Starting ammo for a fresh team. Infinity stays infinite; everything else is
// the table value, so a match config can scale it in one place.
export function startingAmmo(scale = 1) {
const out = {};
for (const w of WEAPONS) {
out[w.id] = w.ammo === Infinity ? Infinity : Math.max(0, Math.round(w.ammo * scale));
}
return out;
}
// Weapons a crate can contain, weighted. Anything with crateWeight 0 (the
// infinite-ammo staples and the meta entries) never drops.
export const CRATE_TABLE = WEAPONS.filter((w) => (w.crateWeight ?? 0) > 0);
export function rollCrateWeapon(rng) {
const total = CRATE_TABLE.reduce((s, w) => s + w.crateWeight, 0);
let r = rng() * total;
for (const w of CRATE_TABLE) {
r -= w.crateWeight;
if (r <= 0) return w.id;
}
return CRATE_TABLE[CRATE_TABLE.length - 1].id;
}
// How much ammo a crate of this weapon grants.
export function crateAmmoFor(id) {
const w = getWeapon(id);
if (!w) return 1;
return w.super ? 1 : (w.ammo === Infinity ? 1 : Math.max(1, Math.min(3, w.ammo)));
}
// Charge weapons map a 0..1 hold into a launch speed.
export function launchSpeed(weapon, power) {
const lo = weapon.speedMin ?? 200;
const hi = weapon.speedMax ?? 800;
return lo + (hi - lo) * Math.max(0, Math.min(1, power));
}
// The flight profile handed to WormsPhysics, with the per-shot fuse folded in.
export function projectileSpec(weapon, { fuse = null } = {}) {
const base = weapon.projectile ?? {};
const spec = { ...base };
const f = fuse ?? weapon.fuse ?? null;
if (f != null) spec.fuse = f;
return spec;
}

View File

@ -1,97 +0,0 @@
# Worms
Two teams of worms take turns lobbing explosives at each other across a
crumbling landscape. Last team standing wins. Everything you shoot at is
destructible, including the ground you are standing on.
---
## The turn
You get **45 seconds** to move and take one shot. The moment you fire, a
**5 second retreat timer** starts — use it to get somewhere safer, because the
next worm to move will be aiming at wherever you stop.
Only one of your worms plays per turn, and it rotates through the team
automatically. You do not get to pick.
---
## Controls
| Key | Action |
|---|---|
| **← →** | Walk |
| **↑ ↓** | Aim up and down |
| **Space** (hold) | Charge the shot — release to fire |
| **Enter** | Jump forward |
| **Backspace** | Backflip (higher, backwards) |
| **1 9** | Weapon slots — press again to cycle within a slot |
| **F** | Change the fuse on grenades and cluster bombs |
| **Tab** | Open the weapon panel |
| **K** | Skip your go |
| **Esc** | Leave the match (press twice) |
**Mouse:** drag away from your worm to aim like a slingshot — the direction sets
the angle, the distance sets the power. Release to fire. Right-drag pans the
camera and the wheel zooms.
While on the **ninja rope**, ↑ and ↓ reel the rope in and out instead of aiming,
← and → swing you, and the fire key lets go.
---
## Reading the screen
- The **arrow** bobs over the worm whose turn it is.
- The **wind gauge** at the top right shows strength and direction. Wind pushes
the bazooka and the air strike; it does **not** touch grenades, the shotgun or
the uzi.
- Each worm's **health bar** sits above it. Team totals are top left.
---
## Weapons worth knowing
- **Bazooka** — the workhorse. Arcs, and the wind matters.
- **Grenade** — bounces, and detonates on its **fuse**, not on impact. Press
**F** to change the fuse after selecting it.
- **Cluster Bomb** — splits into five bomblets. Devastating over flat ground,
wasted in a pit.
- **Fire Punch** — sends a worm straight **up**, not away. Line one up next to a
drop, or over water.
- **Dynamite** — huge, five second fuse, planted at your feet. Plant and run.
- **Holy Hand Grenade** — one per match. It ends arguments.
- **Ninja Rope** — the skill weapon. Fire it at terrain, swing, and reach places
nobody expects you to reach.
---
## Things that will kill you
- **Water.** Anything that touches it drowns, no matter how much health it has.
Knocking a worm into the sea is usually cheaper than shooting it to death.
- **Falling.** Long drops hurt, and being blasted upward then landing counts.
- **Mines and oil drums.** Scattered around the map. Mines arm after a second
and chase nothing — they just wait. Drums chain-detonate.
- **Your own blast.** Standing next to your own explosion is a classic.
- **Sudden Death.** After 15 rounds every worm drops to 1 health and the water
starts rising every turn. Nothing survives a long game.
---
## Crates
Crates parachute in between turns:
- **Wooden crate** — a random weapon, often one you cannot get any other way.
- **Red cross crate** — health.
- Occasionally one is **booby-trapped**. Grabbing crates in the open, in front
of an enemy worm, is how people lose winning positions.
---
## Two-player
Both players share the same controls and take turns, so there is nothing to
learn twice. A banner announces whose go it is.

View File

@ -103,7 +103,6 @@ import BloxorzGame from './games/bloxorz/BloxorzGame.js';
import GooTowerGame from './games/gootower/GooTowerGame.js';
import GooTowerEditor from './games/gootower/GooTowerEditor.js';
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
import WormsGame from './games/worms/WormsGame.js';
const config = {
type: Phaser.AUTO,
@ -219,7 +218,6 @@ const config = {
BloxorzGame,
GooTowerGame,
ExcitebikeGame,
WormsGame,
GooTowerEditor,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', worms: 'WormsGame' };
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', excitebike: 'ExcitebikeGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -76,7 +76,6 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('superkart-artwork', 'data/superkart-artwork.json');
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.json('star-control-ships', 'data/star-control-ships.json');
this.load.json('worms-artwork', 'data/worms-artwork.json');
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');

View File

@ -892,6 +892,45 @@ section('4g. Balloon, block, bit');
Object.keys(GOO_TYPES).every((k) => gooType(k) === GOO_TYPES[k]));
}
// ── 4h. Sleeping goo ─────────────────────────────────────────────────────────
section('4h. Sleeping goo');
{
// One pinned structure ball on the ground and one loose ball resting
// nearby, with or without a wall between them blocking straight-line sight
// (the same lineBlocked test section 1 exercises directly).
const buildSleeper = (walled) => {
const terrain = [ground()];
if (walled) {
terrain.push({ kind: 'solid', poly: [[520, GROUND_Y - 200], [540, GROUND_Y - 200], [540, GROUND_Y], [520, GROUND_Y]] });
}
return createState(baseLevel({
terrain,
balls: [{ x: 400, y: GROUND_Y - 14, type: 'common', pinned: true }],
pile: [{ x: 600, y: GROUND_Y - 14, type: 'common', asleep: true }],
}));
};
const walled = buildSleeper(true);
const wSleeper = walled.balls[1];
check('a pile ball authored asleep starts asleep', wSleeper.asleep);
const startX = wSleeper.x, startY = wSleeper.y;
for (let i = 0; i < 60 * 3; i += 1) stepSim(walled, 1 / 60);
check('a sleeper walled off from the structure stays asleep', wSleeper.asleep);
const drift = Math.hypot(wSleeper.x - startX, wSleeper.y - startY);
check('an asleep ball does not wander off on its own', drift < 3, `moved ${drift.toFixed(2)}px`);
check('beginDrag refuses to pick up a sleeping ball', !beginDrag(walled, wSleeper, null));
check('a refused pickup never sets held', !wSleeper.held);
const clear = buildSleeper(false);
const cSleeper = clear.balls[1];
for (let i = 0; i < 60 * 3; i += 1) stepSim(clear, 1 / 60);
check('a sleeper with a clear line to the structure wakes on its own', !cSleeper.asleep);
check('once awake it can be dragged like ordinary loose goo', beginDrag(clear, cSleeper, null));
// Same three seconds either side -- the wall is what gates waking, not time.
check('the wake condition is the wall, not the clock', wSleeper.asleep && !cSleeper.asleep);
}
// ── 5. Stability ────────────────────────────────────────────────────────────
section('5. Stability');
{

File diff suppressed because it is too large Load Diff