**feat: add Worms game with full physics, weapons, and procedural terrain**
Add Worms (inspired by Worms Armageddon) as a new arcade game with: - **Procedural terrain** — seeded Island and Cavern generation, full destruction via `carveCircle`, girder placement, ROCK immunity - **Bespoke physics** — worm locomotion (walk, jump, backflip, ballistic flight, fall damage), projectile simulation with wind/bounce/homing, ninja rope with corner wrapping, explosion geometry with linear falloff - **16 weapons** — Bazooka, Grenade, Cluster, Shotgun, Uzi, Fire Punch, Dynamite, Mine, Air Strike, Homing Missile, Holy Hand Grenade, Blowtorch, Girder, Teleport, Ninja Rope, Skip Go (+ Surrender) - **Match rules engine** — 45s turns, retreat phase, sudden death with rising water, crates, mines, oil drums, chain reactions, deterministic hash-based state - **8 themes** — Forest, Desert, Farm, Hell, Arctic, Jungle, Construction, Space with full procedural palettes and drop-in art hooks (`data/worms-artwork.json`) - **Phaser scene** — mode select, quick match setup, camera, HUD, weapon panel, slingshot aim, 2-player hotseat - **Headless verifier** (`tools/verifyWorms.js`) — 207 checks across terrain, physics, weapons, and match logic; deterministic through the sim - **Wiring** — registry (iconFrame 92), main.js scene registration, slug dispatch, asset manifest resolver, preload Also updates `level-008.json` with new solid/spike geometry, additional balls and strands, and repositioned pipe.
This commit is contained in:
parent
94e2152918
commit
2268c74918
|
|
@ -70,6 +70,161 @@
|
|||
1000
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "solid",
|
||||
"poly": [
|
||||
[
|
||||
1593,
|
||||
617
|
||||
],
|
||||
[
|
||||
884,
|
||||
562
|
||||
],
|
||||
[
|
||||
884,
|
||||
542
|
||||
],
|
||||
[
|
||||
1314,
|
||||
546
|
||||
],
|
||||
[
|
||||
1332,
|
||||
534
|
||||
],
|
||||
[
|
||||
1332,
|
||||
387
|
||||
],
|
||||
[
|
||||
1318,
|
||||
368
|
||||
],
|
||||
[
|
||||
1102,
|
||||
370
|
||||
],
|
||||
[
|
||||
1099,
|
||||
356
|
||||
],
|
||||
[
|
||||
1592,
|
||||
359
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "spike",
|
||||
"poly": [
|
||||
[
|
||||
899,
|
||||
362
|
||||
],
|
||||
[
|
||||
902,
|
||||
378
|
||||
],
|
||||
[
|
||||
708,
|
||||
381
|
||||
],
|
||||
[
|
||||
667,
|
||||
408
|
||||
],
|
||||
[
|
||||
628,
|
||||
471
|
||||
],
|
||||
[
|
||||
604,
|
||||
540
|
||||
],
|
||||
[
|
||||
602,
|
||||
824
|
||||
],
|
||||
[
|
||||
596,
|
||||
833
|
||||
],
|
||||
[
|
||||
473,
|
||||
833
|
||||
],
|
||||
[
|
||||
456,
|
||||
800
|
||||
],
|
||||
[
|
||||
458,
|
||||
726
|
||||
],
|
||||
[
|
||||
465,
|
||||
581
|
||||
],
|
||||
[
|
||||
454,
|
||||
435
|
||||
],
|
||||
[
|
||||
464,
|
||||
340
|
||||
],
|
||||
[
|
||||
445,
|
||||
280
|
||||
],
|
||||
[
|
||||
465,
|
||||
231
|
||||
],
|
||||
[
|
||||
521,
|
||||
227
|
||||
],
|
||||
[
|
||||
579,
|
||||
241
|
||||
],
|
||||
[
|
||||
637,
|
||||
304
|
||||
],
|
||||
[
|
||||
708,
|
||||
361
|
||||
],
|
||||
[
|
||||
900,
|
||||
362
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "spike",
|
||||
"poly": [
|
||||
[
|
||||
787,
|
||||
238
|
||||
],
|
||||
[
|
||||
1185,
|
||||
241
|
||||
],
|
||||
[
|
||||
1185,
|
||||
179
|
||||
],
|
||||
[
|
||||
784,
|
||||
187
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"balls": [
|
||||
|
|
@ -88,7 +243,8 @@
|
|||
{
|
||||
"x": 839,
|
||||
"y": 782,
|
||||
"type": "common"
|
||||
"type": "common",
|
||||
"pinned": false
|
||||
}
|
||||
],
|
||||
"strands": [
|
||||
|
|
@ -225,11 +381,81 @@
|
|||
"x": 1309,
|
||||
"y": 774,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1099,
|
||||
"y": 735,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1135,
|
||||
"y": 732,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1171,
|
||||
"y": 732,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1203,
|
||||
"y": 729,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1242,
|
||||
"y": 734,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1289,
|
||||
"y": 734,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1330,
|
||||
"y": 734,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1322,
|
||||
"y": 694,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1281,
|
||||
"y": 696,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1234,
|
||||
"y": 696,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1199,
|
||||
"y": 691,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1160,
|
||||
"y": 694,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1125,
|
||||
"y": 688,
|
||||
"type": "common"
|
||||
},
|
||||
{
|
||||
"x": 1097,
|
||||
"y": 688,
|
||||
"type": "common"
|
||||
}
|
||||
],
|
||||
"pipe": {
|
||||
"x": 839,
|
||||
"y": 505,
|
||||
"x": 1469,
|
||||
"y": 71,
|
||||
"r": 46
|
||||
},
|
||||
"required": 5,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"_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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
# 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 0–1 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; 0–91 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 1–9 | ✅ 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 1–5 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 1–9.
|
||||
|
||||
- [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.0–1.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 10–12 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.
|
||||
|
|
@ -47,6 +47,31 @@ 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.
|
||||
|
|
@ -61,6 +86,11 @@ 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: [
|
||||
(scene) => wormsArt(scene),
|
||||
],
|
||||
gootower: [
|
||||
image('gootower-bg', 'assets/images/gootower/background-1.png'),
|
||||
image('gootower-pipe', 'assets/images/gootower/pipe.png'),
|
||||
|
|
|
|||
|
|
@ -118,4 +118,5 @@ 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 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,802 @@
|
|||
// 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);
|
||||
this.title('WORMS', 150, 96);
|
||||
this.track(this.add.text(GAME_WIDTH / 2, 232, 'Total annihilation, one turn at a time.', {
|
||||
fontFamily: FONT, fontSize: '30px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setScrollFactor(0).setDepth(100));
|
||||
|
||||
MODES.forEach((m, i) => {
|
||||
this.track(new Button(this, GAME_WIDTH / 2, 400 + i * 96, m.label, () => {
|
||||
this.mode = m.id;
|
||||
this.showSetup();
|
||||
}, { width: 560, height: 72 }));
|
||||
});
|
||||
|
||||
this.track(new Button(this, GAME_WIDTH / 2, 720, '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
|
|
@ -0,0 +1,623 @@
|
|||
// 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 }];
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
// 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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,736 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
// 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}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# 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.
|
||||
|
|
@ -103,6 +103,7 @@ 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,
|
||||
|
|
@ -218,6 +219,7 @@ const config = {
|
|||
BloxorzGame,
|
||||
GooTowerGame,
|
||||
ExcitebikeGame,
|
||||
WormsGame,
|
||||
GooTowerEditor,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ 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');
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export const SFX = {
|
|||
SQUISH: 'sfx-squish',
|
||||
SQUASH: 'sfx-squash',
|
||||
WOOSH: 'sfx-woosh',
|
||||
// Loaded in PreloadScene since the Zuma work; named here so games stop
|
||||
// reaching for the raw cache keys.
|
||||
WATER_SPLASH: 'sfx-water-splash',
|
||||
WATER_SINK: 'sfx-water-sink',
|
||||
WATER_RAISE: 'sfx-water-raise',
|
||||
WATER_DRY: 'sfx-water-dry',
|
||||
JELLO_MONSTERS: 'sfx-jello-monsters',
|
||||
COUNTDOWN_TICK: 'sfx-countdown-tick',
|
||||
COUNTDOWN_GO: 'sfx-countdown-go',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue