diff --git a/assets/gamedata/gootower/level-008.json b/assets/gamedata/gootower/level-008.json index 82acdbb..5c94b45 100644 --- a/assets/gamedata/gootower/level-008.json +++ b/assets/gamedata/gootower/level-008.json @@ -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, diff --git a/data/worms-artwork.json b/data/worms-artwork.json new file mode 100644 index 0000000..065f656 --- /dev/null +++ b/data/worms-artwork.json @@ -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 } + } +} diff --git a/docs/worms-build-plan.md b/docs/worms-build-plan.md new file mode 100644 index 0000000..897feb2 --- /dev/null +++ b/docs/worms-build-plan.md @@ -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. diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 22c3080..44bac2c 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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 `-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'), diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 0904458..3552028 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/worms/WormsGame.js b/src/games/worms/WormsGame.js new file mode 100644 index 0000000..8f9fd51 --- /dev/null +++ b/src/games/worms/WormsGame.js @@ -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 })); + }); + } +} diff --git a/src/games/worms/WormsLogic.js b/src/games/worms/WormsLogic.js new file mode 100644 index 0000000..8f9fc17 --- /dev/null +++ b/src/games/worms/WormsLogic.js @@ -0,0 +1,1015 @@ +// Worms — match rules. +// +// Imports WormsTerrain / WormsPhysics / WormsWeapons only; no Phaser, so the +// verifier and the AI drive a whole match headlessly. `stepMatch()` returns an +// EVENT LIST and never touches presentation — the scene replays those events as +// sound, camera moves and particles. That split is what makes a 400-match AI +// soak possible at all. +// +// The turn is a small state machine: +// +// play ──fire──▶ retreat ──weapon resolves──▶ settle ──▶ resolve ──▶ play +// │ │ +// └──────────── timer expires / skip ──────────────────────┘ +// +// `settle` is where most of the fiddliness lives: a turn is not over when the +// rocket lands, it is over when every worm, crate, barrel and mine has stopped +// moving and every chain reaction has finished. + +import { + generateValidMap, carveCircle, addGirder, isSolid, surfaceY, + pickSpawns, mulberry32, firstSolidBelow, +} from './WormsTerrain.js'; + +import { + PHYS, makeWorm, makeProjectile, stepWorm, stepProjectile, circleHits, + blastEffect, applyImpulse, resolveOverlap, jumpWorm, +} from './WormsPhysics.js'; + +import { + getWeapon, startingAmmo, launchSpeed, projectileSpec, rollCrateWeapon, + crateAmmoFor, PANEL_ORDER, +} from './WormsWeapons.js'; + +export const RULES = { + HEALTH: 100, + WORMS_PER_TEAM: 4, + TURN_SECONDS: 45, + RETREAT_SECONDS: 5, + SETTLE_TIMEOUT: 14, // hard cap on the settle phase, so nothing hangs + TURN_HARD_CAP: 120, // hard cap on a whole turn + + WIND_MAX: 1.0, + + SUDDEN_DEATH_ROUND: 15, + SUDDEN_DEATH_HEALTH: 1, + WATER_RISE_PER_TURN: 7, + DROWN_MARGIN: 4, // px of submersion before a worm drowns + + CRATE_CHANCE: 0.72, // per turn, once the first round is done + CRATE_HEALTH_SHARE: 0.30, // of crates that drop, this many are health + CRATE_HEALTH_AMOUNT: 25, + CRATE_TRAP_SHARE: 0.08, // booby-trapped crates + CRATE_TRAP: { damage: 45, radius: 60, power: 320, crater: 46 }, + CRATE_R: 11, + CRATE_PICKUP_R: 22, + + MINE_COUNT: [4, 11], + MINE_R: 6, + MINE_ARM: 1.0, + MINE_TRIGGER_R: 34, + MINE_FUSE: 1.6, + MINE_BLAST: { damage: 50, radius: 60, power: 380, crater: 48 }, + + BARREL_COUNT: [2, 7], + BARREL_R: 13, + BARREL_HEALTH: 45, + BARREL_BLAST: { damage: 60, radius: 72, power: 420, crater: 56 }, + + CORPSE_BLAST: { damage: 30, radius: 52, power: 260, crater: 40 }, + + FALL_DAMAGE: true, + MAX_ROUNDS: 60, // draw guard, well past any real match +}; + +const PHASES = ['play', 'retreat', 'settle', 'resolve', 'over']; + +// --------------------------------------------------------------------------- +// Match construction +// --------------------------------------------------------------------------- + +const DEFAULT_WORM_NAMES = [ + 'Spadge', 'Boggy', 'Clint', 'Rambo', 'Cecil', 'Nobby', 'Gus', 'Wormstrong', +]; + +export function createMatch(config = {}) { + const cfg = { + seed: 1, + mapSeed: null, + shape: 'island', + size: 'medium', + theme: 'forest', + wormsPerTeam: RULES.WORMS_PER_TEAM, + health: RULES.HEALTH, + turnSeconds: RULES.TURN_SECONDS, + retreatSeconds: RULES.RETREAT_SECONDS, + suddenDeathRound: RULES.SUDDEN_DEATH_ROUND, + crates: true, + mines: true, + barrels: true, + ammoScale: 1, + teams: [ + { id: 'a', name: 'Red', color: 0xe05a4a, controller: 'human' }, + { id: 'b', name: 'Blue', color: 0x4a8fe0, controller: 'ai', skill: 3 }, + ], + ...config, + }; + + const rng = mulberry32(cfg.seed >>> 0); + const mapSeed = (cfg.mapSeed ?? cfg.seed) >>> 0; + const totalWorms = cfg.teams.length * cfg.wormsPerTeam; + + const gen = generateValidMap( + { seed: mapSeed, shape: cfg.shape, size: cfg.size, theme: cfg.theme }, + { worms: totalWorms }, + ); + const terrain = gen.terrain; + + const spawnRng = mulberry32((mapSeed ^ 0x5eed) >>> 0); + const spawns = pickSpawns(terrain, spawnRng, totalWorms) + ?? pickSpawns(terrain, spawnRng, totalWorms, 40); + if (!spawns) throw new Error('worms: map could not seat the requested worms'); + + // Deal spawns round-robin so no team gets a contiguous block of the map. + const teams = cfg.teams.map((t, i) => ({ + index: i, + id: t.id ?? `t${i}`, + name: t.name ?? `Team ${i + 1}`, + color: t.color ?? 0xffffff, + controller: t.controller ?? 'ai', + skill: t.skill ?? 3, + opponentId: t.opponentId ?? null, + ammo: startingAmmo(cfg.ammoScale), + nextWorm: 0, + retired: false, + })); + + const worms = []; + for (let k = 0; k < totalWorms; k++) { + const teamIndex = k % teams.length; + const nth = Math.floor(k / teams.length); + const s = spawns[k]; + const names = cfg.teams[teamIndex]?.wormNames ?? DEFAULT_WORM_NAMES; + const w = makeWorm(s.x, s.y - PHYS.WORM_R - 1, { + id: k, + team: teamIndex, + name: names[nth % names.length], + health: cfg.health, + maxHealth: cfg.health, + dead: false, + pendingDeath: false, + deathCause: null, + }); + resolveOverlap(terrain, w, PHYS.WORM_R, 30); + worms.push(w); + } + + const state = { + config: cfg, + rng, + terrain, + mapSeed, + mapTries: gen.tries, + teams, + worms, + projectiles: [], + crates: [], + mines: [], + barrels: [], + corpses: [], // queued corpse explosions + round: 0, + turnCount: 0, + wind: 0, + waterY: terrain.waterY, + suddenDeath: false, + phase: 'play', + turnTime: cfg.turnSeconds, + retreatTime: 0, + settleTime: 0, + turnElapsed: 0, + activeTeam: -1, + activeWorm: null, + aim: -Math.PI / 4, + power: 0, + charging: false, + selectedWeapon: 'bazooka', + fuse: 3, + hasFired: false, + shotsLeft: 0, + input: { move: 0, rope: 0 }, + winner: undefined, + over: false, + accumulator: 0, + stepCount: 0, + }; + + scatterMines(state); + scatterBarrels(state); + beginTurn(state, []); + return state; +} + +function randRange(rng, [lo, hi]) { return lo + Math.floor(rng() * (hi - lo + 1)); } + +function scatterMines(state) { + if (!state.config.mines) return; + const n = randRange(state.rng, RULES.MINE_COUNT); + placeOnSurface(state, n, RULES.MINE_R, (x, y) => { + state.mines.push({ + id: state.mines.length, x, y, vx: 0, vy: 0, + armed: false, armTimer: 0, triggered: false, fuse: 0, dead: false, airborne: false, + }); + }); +} + +function scatterBarrels(state) { + if (!state.config.barrels) return; + const n = randRange(state.rng, RULES.BARREL_COUNT); + placeOnSurface(state, n, RULES.BARREL_R, (x, y) => { + state.barrels.push({ + id: state.barrels.length, x, y, vx: 0, vy: 0, + health: RULES.BARREL_HEALTH, dead: false, airborne: false, + }); + }); +} + +// Drop `n` objects onto surface pixels well away from every worm, so the match +// never opens with a worm already standing on a mine. The object is seated by +// its own radius: placing a 13px barrel 8px above the surface buries half of +// it, and a half-buried body is never "grounded" so it falls through its own +// first frame. +function placeOnSurface(state, n, radius, emit) { + const t = state.terrain; + let placed = 0, guard = 0; + while (placed < n && guard++ < n * 200) { + const x = Math.floor(20 + state.rng() * (t.w - 40)); + const y = surfaceY(t, x); + if (y < 0 || y >= state.waterY - 10) continue; + const py = y - radius - 1; + if (circleHits(t, x, py, radius)) continue; + const tooClose = state.worms.some((w) => Math.hypot(w.x - x, w.y - py) < 90) + || state.mines.some((m) => Math.hypot(m.x - x, m.y - py) < 40) + || state.barrels.some((b) => Math.hypot(b.x - x, b.y - py) < 40); + if (tooClose) continue; + emit(x, py); + placed++; + } +} + +// --------------------------------------------------------------------------- +// Turn flow +// --------------------------------------------------------------------------- + +export function livingWorms(state, teamIndex = null) { + return state.worms.filter((w) => !w.dead && (teamIndex == null || w.team === teamIndex)); +} + +export function livingTeams(state) { + return state.teams.filter((t) => !t.retired && livingWorms(state, t.index).length > 0); +} + +function beginTurn(state, events) { + if (state.over) return; + + const alive = livingTeams(state); + if (alive.length <= 1) { finishMatch(state, events); return; } + + // Next team in order, skipping the wiped out. + let next = state.activeTeam; + for (let i = 0; i < state.teams.length; i++) { + next = (next + 1) % state.teams.length; + const t = state.teams[next]; + if (!t.retired && livingWorms(state, next).length > 0) break; + } + // A completed lap of the table is a new round. + if (next <= state.activeTeam || state.activeTeam < 0) { + state.round += 1; + if (!state.suddenDeath && state.config.suddenDeathRound > 0 + && state.round > state.config.suddenDeathRound) { + enterSuddenDeath(state, events); + } + } + state.activeTeam = next; + + const team = state.teams[next]; + const pool = livingWorms(state, next); + // Round-robin within the team, which is what Worms does — you do not get to + // pick which of your worms plays. + let pick = null; + for (let i = 0; i < pool.length + 1; i++) { + const idx = (team.nextWorm + i) % Math.max(1, pool.length); + if (pool[idx] && !pool[idx].dead) { pick = pool[idx]; team.nextWorm = (idx + 1) % pool.length; break; } + } + if (!pick) { finishMatch(state, events); return; } + + state.activeWorm = pick.id; + state.turnCount += 1; + state.phase = 'play'; + state.turnTime = state.config.turnSeconds; + state.retreatTime = 0; + state.settleTime = 0; + state.turnElapsed = 0; + state.hasFired = false; + state.charging = false; + state.power = 0; + state.shotsLeft = 0; + state.input = { move: 0, rope: 0 }; + state.wind = Math.round((state.rng() * 2 - 1) * RULES.WIND_MAX * 100) / 100; + state.aim = pick.facing >= 0 ? -Math.PI / 4 : -Math.PI + Math.PI / 4; + + // If the selected weapon ran out, fall back to the bazooka. + if (!hasAmmo(state, state.selectedWeapon)) state.selectedWeapon = 'bazooka'; + + events.push({ + type: 'turnStart', team: next, wormId: pick.id, round: state.round, + wind: state.wind, suddenDeath: state.suddenDeath, + }); +} + +function enterSuddenDeath(state, events) { + state.suddenDeath = true; + for (const w of state.worms) { + if (!w.dead && w.health > RULES.SUDDEN_DEATH_HEALTH) w.health = RULES.SUDDEN_DEATH_HEALTH; + } + events.push({ type: 'suddenDeath', round: state.round }); +} + +function finishMatch(state, events) { + if (state.over) return; + const alive = livingTeams(state); + state.over = true; + state.phase = 'over'; + state.winner = alive.length === 1 ? alive[0].index : null; + events.push({ type: 'matchOver', winner: state.winner }); +} + +export function activeWorm(state) { + if (state.activeWorm == null) return null; + const w = state.worms[state.activeWorm]; + return w && !w.dead ? w : null; +} + +function hasAmmo(state, weaponId) { + const team = state.teams[state.activeTeam]; + if (!team) return false; + const n = team.ammo[weaponId]; + return n === Infinity || n > 0; +} + +function spendAmmo(state, weaponId) { + const team = state.teams[state.activeTeam]; + if (!team) return; + if (team.ammo[weaponId] !== Infinity) team.ammo[weaponId] -= 1; +} + +// --------------------------------------------------------------------------- +// Commands — everything the player (or the AI) can ask for +// --------------------------------------------------------------------------- + +export function canAct(state) { + return !state.over && (state.phase === 'play' || state.phase === 'retreat') && !!activeWorm(state); +} + +export function setMove(state, dir) { + if (!canAct(state)) return false; + state.input.move = Math.sign(dir | 0); + return true; +} + +export function setRopeInput(state, dir) { + if (!canAct(state)) return false; + state.input.rope = Math.sign(dir | 0); + return true; +} + +export function setAim(state, angle) { + if (!canAct(state)) return false; + state.aim = angle; + return true; +} + +export function nudgeAim(state, delta) { + if (!canAct(state)) return false; + const w = activeWorm(state); + // Aim is stored as a world angle; nudging is relative to the worm's facing so + // up is always up regardless of which way it looks. + state.aim += delta * (w && w.facing < 0 ? -1 : 1); + return true; +} + +export function jump(state, kind = 'forward') { + if (!canAct(state)) return false; + const w = activeWorm(state); + if (!w || w.rope) return false; + return jumpWorm(w, kind); +} + +export function selectWeapon(state, id) { + if (!canAct(state) || state.hasFired) return false; + if (!getWeapon(id) || !hasAmmo(state, id)) return false; + state.selectedWeapon = id; + const w = getWeapon(id); + if (w.fuseOptions && !w.fuseOptions.includes(state.fuse)) state.fuse = w.defaultFuse ?? 3; + return true; +} + +export function setFuse(state, seconds) { + const w = getWeapon(state.selectedWeapon); + if (!w || !w.fuseOptions || !w.fuseOptions.includes(seconds)) return false; + state.fuse = seconds; + return true; +} + +export function beginCharge(state) { + if (!canAct(state) || state.hasFired) return false; + const w = getWeapon(state.selectedWeapon); + if (!w || w.kind !== 'charge') return false; + state.charging = true; + state.power = 0; + return true; +} + +// Charge rate: a full sweep takes about 1.4s, matching the original's feel. +export const CHARGE_RATE = 1 / 1.4; + +// The one entry point for using the selected weapon. +// `opts`: { power, angle, target: {x,y} } +export function fire(state, opts = {}) { + if (!canAct(state) || state.hasFired) return false; + const weapon = getWeapon(state.selectedWeapon); + const worm = activeWorm(state); + if (!weapon || !worm) return false; + if (!hasAmmo(state, weapon.id)) return false; + + const angle = opts.angle ?? state.aim; + const power = Math.max(0, Math.min(1, opts.power ?? state.power ?? 1)); + const events = state.pendingEvents ?? (state.pendingEvents = []); + + // A multi-shot weapon (the two-barrelled shotgun) costs one ammo for the + // whole pair, so only the trigger pull that STARTS the pair spends anything. + const startsMultiShot = (weapon.shots ?? 1) > 1 && state.shotsLeft <= 0; + const spendThisShot = (weapon.shots ?? 1) <= 1 || startsMultiShot; + + switch (weapon.kind) { + case 'meta': + if (weapon.surrender) { + state.teams[state.activeTeam].retired = true; + for (const w of state.worms) if (w.team === state.activeTeam) killWorm(state, w, 'surrender', events); + events.push({ type: 'surrender', team: state.activeTeam }); + } + state.charging = false; + endTurn(state); + return true; + + case 'charge': { + const speed = launchSpeed(weapon, power); + const spec = projectileSpec(weapon, { fuse: weapon.fuseOptions ? state.fuse : weapon.fuse }); + const muzzle = PHYS.WORM_R + 6; + const px = worm.x + Math.cos(angle) * muzzle; + const py = worm.y + Math.sin(angle) * muzzle; + const p = makeProjectile(px, py, Math.cos(angle) * speed, Math.sin(angle) * speed, spec); + p.weaponId = weapon.id; + p.ownerId = worm.id; + p.ownerTeam = worm.team; + state.projectiles.push(p); + events.push({ type: 'fired', weapon: weapon.id, wormId: worm.id, x: px, y: py, power, angle }); + break; + } + + case 'instant': { + const shots = weapon.burst ?? 1; + // Hitscan resolves immediately; the scene animates it from the event. + for (let i = 0; i < shots; i++) { + const spread = weapon.spread ? (state.rng() * 2 - 1) * weapon.spread : 0; + hitscan(state, worm, angle + spread, weapon, events); + } + if (startsMultiShot) state.shotsLeft = weapon.shots; + if ((weapon.shots ?? 1) > 1) state.shotsLeft -= 1; + break; + } + + case 'melee': { + meleeHit(state, worm, angle, weapon, events); + break; + } + + case 'place': { + const px = worm.x + worm.facing * (PHYS.WORM_R + 4); + const py = worm.y; + state.projectiles.push(Object.assign( + makeProjectile(px, py, 0, 0, { fuse: weapon.fuse, gravity: true, bounce: true, restitution: 0.2, tangentFriction: 0.5 }), + { weaponId: weapon.id, ownerId: worm.id, ownerTeam: worm.team, placed: true }, + )); + events.push({ type: 'placed', weapon: weapon.id, wormId: worm.id, x: px, y: py }); + break; + } + + default: + // target / utility kinds arrive in Wave 2. Refuse loudly rather than + // silently eating the ammo and the turn. + return false; + } + + if (spendThisShot) spendAmmo(state, weapon.id); + state.charging = false; + state.power = 0; + + // Multi-shot weapons keep the turn until their shots are used up. + const keepTurn = (weapon.shots ?? 1) > 1 && state.shotsLeft > 0; + if (!keepTurn) { + state.hasFired = true; + if (weapon.endsTurn) { + state.phase = 'retreat'; + state.retreatTime = state.config.retreatSeconds; + } + } + return true; +} + +export function endTurn(state) { + if (state.over) return false; + if (state.phase === 'settle' || state.phase === 'resolve') return false; + state.phase = 'settle'; + state.settleTime = 0; + state.input = { move: 0, rope: 0 }; + return true; +} + +// --------------------------------------------------------------------------- +// Weapon resolution +// --------------------------------------------------------------------------- + +function hitscan(state, worm, angle, weapon, events) { + const t = state.terrain; + const range = weapon.range ?? 1400; + const ox = worm.x + Math.cos(angle) * (PHYS.WORM_R + 2); + const oy = worm.y + Math.sin(angle) * (PHYS.WORM_R + 2); + const dx = Math.cos(angle), dy = Math.sin(angle); + + // March until we hit terrain or a worm, whichever comes first. + let hitWorm = null, hx = ox + dx * range, hy = oy + dy * range; + for (let d = 0; d < range; d += 2) { + const x = ox + dx * d, y = oy + dy * d; + if (isSolid(t, x, y)) { hx = x; hy = y; break; } + const target = state.worms.find((w) => !w.dead && w.id !== worm.id + && Math.hypot(w.x - x, w.y - y) <= PHYS.WORM_R); + if (target) { hitWorm = target; hx = x; hy = y; break; } + if (x < 0 || y < 0 || x > t.w || y > t.h) { hx = x; hy = y; break; } + } + + events.push({ type: 'hitscan', weapon: weapon.id, x0: ox, y0: oy, x1: hx, y1: hy, hitWorm: hitWorm?.id ?? null }); + detonate(state, hx, hy, { + damage: weapon.damage, radius: weapon.blastRadius, power: weapon.blastPower, crater: weapon.crater, + weaponId: weapon.id, ownerId: worm.id, silent: true, + }, events); +} + +function meleeHit(state, worm, angle, weapon, events) { + const reach = weapon.reach ?? 26; + const tx = worm.x + worm.facing * reach; + const ty = worm.y; + const targets = state.worms.filter((w) => !w.dead && w.id !== worm.id + && Math.hypot(w.x - tx, w.y - ty) <= weapon.blastRadius); + events.push({ type: 'melee', weapon: weapon.id, wormId: worm.id, x: tx, y: ty, hits: targets.length }); + for (const target of targets) { + damageWorm(state, target, weapon.damage, weapon.id, events); + // Fire punch sends them straight up, which is the whole point of it. + applyImpulse(target, worm.facing * 0.25, -1, weapon.launchUp ?? weapon.blastPower); + } + if (weapon.selfLaunch) { + worm.vx += weapon.selfLaunch.vx * worm.facing; + worm.vy += weapon.selfLaunch.vy; + worm.airborne = true; worm.grounded = false; worm.peakY = worm.y; + } +} + +// The one place terrain is destroyed and damage is dealt. Everything — +// projectiles, mines, barrels, corpses, booby-trapped crates — comes through +// here, so chain reactions are automatic. +export function detonate(state, x, y, opts, events) { + const { damage = 0, radius = 0, power = 0, crater = 0, weaponId = null, ownerId = null } = opts; + + if (crater > 0) carveCircle(state.terrain, x, y, crater); + events.push({ type: 'explosion', x, y, radius, crater, weapon: weaponId }); + + // Worms. + for (const hit of blastEffect(x, y, radius, power, state.worms)) { + const w = hit.body; + if (damage > 0) { + const dmg = Math.max(1, Math.round(damage * hit.falloff)); + damageWorm(state, w, dmg, weaponId, events, ownerId); + } + applyImpulse(w, hit.ux, hit.uy, hit.impulse); + } + + // Barrels take damage and chain. + for (const hit of blastEffect(x, y, radius, power, state.barrels)) { + const b = hit.body; + if (b.dead || b.queued) continue; + b.health -= Math.max(1, Math.round((damage || 25) * hit.falloff)); + if (b.health <= 0) { + b.queued = true; + state.chain = state.chain ?? []; + state.chain.push({ kind: 'barrel', obj: b, delay: 0.12 + state.chain.length * 0.06 }); + } else { + applyImpulse(b, hit.ux, hit.uy, hit.impulse * 0.5); + b.airborne = true; + } + } + + // Mines are set off by any nearby blast. + for (const hit of blastEffect(x, y, radius, power, state.mines)) { + const m = hit.body; + if (m.dead || m.triggered) continue; + m.triggered = true; + m.armed = true; + m.fuse = 0.25 + state.rng() * 0.2; + applyImpulse(m, hit.ux, hit.uy, hit.impulse * 0.7); + m.airborne = true; + } + + // Crates in the blast are destroyed; a trapped one goes off too. + for (const hit of blastEffect(x, y, radius, power, state.crates)) { + const c = hit.body; + if (c.dead) continue; + c.dead = true; + events.push({ type: 'crateDestroyed', x: c.x, y: c.y }); + if (c.kind === 'trap') { + state.chain = state.chain ?? []; + state.chain.push({ kind: 'trapCrate', obj: c, delay: 0.05 }); + } + } +} + +function damageWorm(state, worm, amount, weaponId, events, ownerId = null) { + if (worm.dead || amount <= 0) return; + const before = worm.health; + worm.health = Math.max(0, worm.health - amount); + events.push({ + type: 'damage', wormId: worm.id, team: worm.team, + amount: before - worm.health, health: worm.health, weapon: weaponId, ownerId, + }); + if (worm.health <= 0 && !worm.pendingDeath) { + worm.pendingDeath = true; + worm.deathCause = 'blast'; + } +} + +function killWorm(state, worm, cause, events) { + if (worm.dead) return; + worm.dead = true; + worm.health = 0; + worm.rope = null; + events.push({ type: 'wormDied', wormId: worm.id, team: worm.team, cause, x: worm.x, y: worm.y }); +} + +// --------------------------------------------------------------------------- +// Simulation +// --------------------------------------------------------------------------- + +export function stepMatch(state, dt) { + const events = state.pendingEvents ?? []; + state.pendingEvents = null; + if (state.over) { state.pendingEvents = null; return events; } + + // Fixed substeps. Clamp so a stalled frame cannot fast-forward the world. + state.accumulator += Math.min(dt, PHYS.SUBSTEP * PHYS.MAX_SUBSTEPS); + let guard = 0; + while (state.accumulator >= PHYS.SUBSTEP && guard++ < PHYS.MAX_SUBSTEPS * 4) { + state.accumulator -= PHYS.SUBSTEP; + substep(state, PHYS.SUBSTEP, events); + state.stepCount++; + if (state.over) break; + } + return events; +} + +function substep(state, dt, events) { + const t = state.terrain; + state.turnElapsed += dt; + + // --- timers + if (state.phase === 'play') { + state.turnTime -= dt; + if (state.turnTime <= 0) { state.turnTime = 0; endTurn(state); } + } else if (state.phase === 'retreat') { + state.retreatTime -= dt; + if (state.retreatTime <= 0) { state.retreatTime = 0; endTurn(state); } + } else if (state.phase === 'settle') { + state.settleTime += dt; + } + if (state.turnElapsed > RULES.TURN_HARD_CAP && state.phase !== 'resolve' && state.phase !== 'over') { + endTurn(state); + } + + // --- charging + if (state.charging) { + state.power = Math.min(1, state.power + CHARGE_RATE * dt); + } + + // --- projectiles + stepProjectiles(state, dt, events); + + // --- chain reactions queued by detonate() + if (state.chain && state.chain.length) { + const still = []; + for (const c of state.chain) { + c.delay -= dt; + if (c.delay > 0) { still.push(c); continue; } + if (c.kind === 'barrel') { + c.obj.dead = true; + detonate(state, c.obj.x, c.obj.y, { ...RULES.BARREL_BLAST, weaponId: 'barrel' }, events); + } else if (c.kind === 'mine') { + c.obj.dead = true; + detonate(state, c.obj.x, c.obj.y, { ...RULES.MINE_BLAST, weaponId: 'mine' }, events); + } else if (c.kind === 'trapCrate') { + detonate(state, c.obj.x, c.obj.y, { ...RULES.CRATE_TRAP, weaponId: 'crate' }, events); + } else if (c.kind === 'corpse') { + detonate(state, c.obj.x, c.obj.y, { ...RULES.CORPSE_BLAST, weaponId: 'corpse' }, events); + } + } + state.chain = still; + } + + // --- worms + const active = activeWorm(state); + const canDrive = (state.phase === 'play' || state.phase === 'retreat'); + for (const w of state.worms) { + if (w.dead) continue; + const input = (canDrive && active && w.id === active.id) ? state.input : null; + stepWorm(t, w, dt, events, input); + } + + // --- loose objects + stepLoose(state, state.mines, dt, PHYS.WORM_R - 2); + stepLoose(state, state.barrels, dt, RULES.BARREL_R); + stepLoose(state, state.crates, dt, RULES.CRATE_R, true); + + // --- mines arming and triggering + for (const m of state.mines) { + if (m.dead) continue; + if (m.triggered) { + m.fuse -= dt; + if (m.fuse <= 0) { + m.dead = true; + detonate(state, m.x, m.y, { ...RULES.MINE_BLAST, weaponId: 'mine' }, events); + } + continue; + } + if (!m.armed) { + m.armTimer += dt; + if (m.armTimer >= RULES.MINE_ARM) m.armed = true; + continue; + } + const near = state.worms.find((w) => !w.dead && Math.hypot(w.x - m.x, w.y - m.y) <= RULES.MINE_TRIGGER_R); + if (near) { + m.triggered = true; + m.fuse = RULES.MINE_FUSE; + events.push({ type: 'mineTriggered', x: m.x, y: m.y }); + } + } + + // --- crate pickup + for (const c of state.crates) { + if (c.dead) continue; + const taker = state.worms.find((w) => !w.dead && Math.hypot(w.x - c.x, w.y - c.y) <= RULES.CRATE_PICKUP_R); + if (!taker) continue; + c.dead = true; + collectCrate(state, c, taker, events); + } + + // --- drowning and falling out of the world + for (const w of state.worms) { + if (w.dead) continue; + if (w.y - PHYS.WORM_R > state.waterY + RULES.DROWN_MARGIN) { + killWorm(state, w, 'drowned', events); + } else if (w.y > t.h + 200 || w.x < -300 || w.x > t.w + 300) { + killWorm(state, w, 'lost', events); + } + } + for (const list of [state.mines, state.barrels, state.crates]) { + for (const o of list) { + if (!o.dead && o.y - 10 > state.waterY + RULES.DROWN_MARGIN) o.dead = true; + } + } + + // --- phase advance + if (state.phase === 'settle') { + if (worldIsQuiet(state) || state.settleTime > RULES.SETTLE_TIMEOUT) { + state.phase = 'resolve'; + } + } + if (state.phase === 'resolve') resolveTurn(state, events); +} + +function stepProjectiles(state, dt, events) { + if (!state.projectiles.length) return; + const keep = []; + for (const p of state.projectiles) { + const homingTarget = p.spec?.homing ? p.homingTarget ?? null : null; + const ev = stepProjectile(state.terrain, p, dt, { wind: state.wind, homingTarget }); + + // A projectile that touches a worm detonates on it (except the one that + // fired it, for the first moment of flight). + if (!ev && !p.placed) { + const hit = state.worms.find((w) => !w.dead + && !(w.id === p.ownerId && p.age < 0.12) + && Math.hypot(w.x - p.x, w.y - p.y) <= PHYS.WORM_R + 2); + if (hit) { detonateProjectile(state, p, p.x, p.y, events); continue; } + } + + if (ev && ev.type === 'bounce') { + events.push({ type: 'projectileBounce', x: ev.x, y: ev.y, speed: ev.speed, weapon: p.weaponId }); + keep.push(p); + continue; + } + if (ev && (ev.type === 'detonate' || ev.type === 'expire')) { + detonateProjectile(state, p, ev.x, ev.y, events); + continue; + } + if (p.y > state.terrain.h + 500 || p.x < -800 || p.x > state.terrain.w + 800) continue; + keep.push(p); + } + state.projectiles = keep; +} + +function detonateProjectile(state, p, x, y, events) { + const weapon = getWeapon(p.weaponId); + if (!weapon) return; + + // Cluster bombs split instead of exploding. + if (weapon.cluster && !p.isCluster) { + const c = weapon.cluster; + events.push({ type: 'clusterSplit', x, y, count: c.count, weapon: weapon.id }); + for (let i = 0; i < c.count; i++) { + const a = -Math.PI / 2 + (i - (c.count - 1) / 2) * 0.42; + const jitter = (state.rng() * 2 - 1) * 0.12; + const sp = c.speed * (0.75 + state.rng() * 0.5); + const child = makeProjectile(x, y, Math.cos(a + jitter) * sp, Math.sin(a + jitter) * sp, { + gravity: true, bounce: true, restitution: 0.35, tangentFriction: 0.8, fuse: c.fuse, maxAge: 8, + }); + child.weaponId = weapon.id; + child.ownerId = p.ownerId; + child.ownerTeam = p.ownerTeam; + child.isCluster = true; + state.projectiles.push(child); + } + return; + } + + const c = p.isCluster ? weapon.cluster : weapon; + detonate(state, x, y, { + damage: c.damage, radius: c.blastRadius ?? c.radius, power: c.blastPower ?? c.power, + crater: c.crater, weaponId: weapon.id, ownerId: p.ownerId, + }, events); +} + +// Gravity and ground contact for mines, barrels and crates. They are circles +// like worms but with no locomotion, so this is a cut-down flyWorm. +function stepLoose(state, list, dt, radius, isCrate = false) { + const t = state.terrain; + for (const o of list) { + if (o.dead) continue; + const grounded = circleHits(t, o.x, o.y + 2, radius) && !circleHits(t, o.x, o.y, radius); + if (grounded && Math.abs(o.vx) < 6 && Math.abs(o.vy) < 6) { o.vx = 0; o.vy = 0; o.airborne = false; continue; } + + o.airborne = true; + o.vy += PHYS.GRAVITY * (isCrate && o.parachute ? 0.12 : 1) * dt; + if (isCrate && o.parachute && o.vy > 90) o.vy = 90; + + const dx = o.vx * dt, dy = o.vy * dt; + const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy))); + for (let i = 0; i < steps; i++) { + const nx = o.x + dx / steps, ny = o.y + dy / steps; + if (!circleHits(t, nx, ny, radius)) { o.x = nx; o.y = ny; continue; } + o.vx *= 0.4; o.vy *= -0.25; + if (Math.abs(o.vy) < 30) { o.vx = 0; o.vy = 0; o.airborne = false; o.parachute = false; } + resolveOverlap(t, o, radius, 20); + break; + } + if (isCrate && o.parachute && !o.airborne) o.parachute = false; + } +} + +// Nothing is moving, nothing is in flight, nothing is about to go off. +function worldIsQuiet(state) { + if (state.projectiles.length) return false; + if (state.chain && state.chain.length) return false; + // Note: pendingDeath deliberately does NOT block quiet. A worm at 0 HP is + // waiting for the resolve phase, which is what comes after settle — treating + // it as "still moving" strands the turn until SETTLE_TIMEOUT fires. + for (const w of state.worms) { + if (w.dead) continue; + if (w.airborne || w.rope) return false; + if (Math.abs(w.vx) > 1 || Math.abs(w.vy) > 1) return false; + } + for (const m of state.mines) if (!m.dead && (m.triggered || m.airborne)) return false; + for (const b of state.barrels) if (!b.dead && b.airborne) return false; + for (const c of state.crates) if (!c.dead && c.airborne) return false; + return true; +} + +// End-of-turn bookkeeping: blow up the dead, rise the water, drop a crate, +// check for a winner, then start the next turn. +function resolveTurn(state, events) { + // Corpses explode one at a time; each can kill another worm, so loop until + // the dust settles rather than doing a single pass. + const dying = state.worms.filter((w) => !w.dead && w.pendingDeath); + if (dying.length) { + for (const w of dying) { + killWorm(state, w, w.deathCause ?? 'blast', events); + state.chain = state.chain ?? []; + state.chain.push({ kind: 'corpse', obj: { x: w.x, y: w.y }, delay: 0.15 + state.chain.length * 0.1 }); + } + state.phase = 'settle'; + state.settleTime = 0; + return; + } + + if (livingTeams(state).length <= 1) { finishMatch(state, events); return; } + if (state.round >= RULES.MAX_ROUNDS) { finishMatch(state, events); return; } + + if (state.suddenDeath) { + state.waterY -= RULES.WATER_RISE_PER_TURN; + events.push({ type: 'waterRose', waterY: state.waterY }); + } + + // Reap spent objects so the lists (and hashState) do not grow forever. + state.mines = state.mines.filter((m) => !m.dead); + state.barrels = state.barrels.filter((b) => !b.dead); + state.crates = state.crates.filter((c) => !c.dead); + + maybeDropCrate(state, events); + beginTurn(state, events); +} + +function maybeDropCrate(state, events) { + if (!state.config.crates) return; + if (state.turnCount < 2) return; + if (state.rng() > RULES.CRATE_CHANCE) return; + + const t = state.terrain; + for (let attempt = 0; attempt < 60; attempt++) { + const x = Math.floor(30 + state.rng() * (t.w - 60)); + const top = surfaceY(t, x); + if (top < 60 || top >= state.waterY - 20) continue; + // Needs a clear drop from the sky. + if (firstSolidBelow(t, x, 20) !== top) continue; + + const roll = state.rng(); + const kind = roll < RULES.CRATE_TRAP_SHARE ? 'trap' + : roll < RULES.CRATE_TRAP_SHARE + RULES.CRATE_HEALTH_SHARE ? 'health' : 'weapon'; + const crate = { + id: state.crates.length, x, y: 30, vx: 0, vy: 0, + kind, dead: false, airborne: true, parachute: true, + weapon: kind === 'weapon' ? rollCrateWeapon(state.rng) : null, + amount: kind === 'health' ? RULES.CRATE_HEALTH_AMOUNT : 0, + }; + state.crates.push(crate); + events.push({ type: 'crateDropped', x, kind, weapon: crate.weapon }); + return; + } +} + +function collectCrate(state, crate, worm, events) { + if (crate.kind === 'health') { + worm.health = Math.min(worm.maxHealth, worm.health + crate.amount); + events.push({ type: 'crateCollected', kind: 'health', wormId: worm.id, amount: crate.amount, health: worm.health }); + } else if (crate.kind === 'trap') { + events.push({ type: 'crateCollected', kind: 'trap', wormId: worm.id }); + state.chain = state.chain ?? []; + state.chain.push({ kind: 'trapCrate', obj: crate, delay: 0.05 }); + } else { + const team = state.teams[worm.team]; + const add = crateAmmoFor(crate.weapon); + if (team.ammo[crate.weapon] !== Infinity) team.ammo[crate.weapon] = (team.ammo[crate.weapon] ?? 0) + add; + events.push({ type: 'crateCollected', kind: 'weapon', wormId: worm.id, weapon: crate.weapon, amount: add }); + } +} + +// --------------------------------------------------------------------------- +// Inspection helpers — used by the HUD, the AI and the verifier +// --------------------------------------------------------------------------- + +export function teamHealth(state, teamIndex) { + return livingWorms(state, teamIndex).reduce((s, w) => s + w.health, 0); +} + +export function availableWeapons(state) { + const team = state.teams[state.activeTeam]; + if (!team) return []; + return PANEL_ORDER + .map((id) => ({ id, weapon: getWeapon(id), ammo: team.ammo[id] ?? 0 })) + .filter((e) => e.weapon && (e.ammo === Infinity || e.ammo > 0)); +} + +// FNV-1a over everything that can affect the future. Determinism tests compare +// these; anything not folded in here is by definition not part of the sim. +export function hashState(state) { + let h = 0x811c9dc5; + const mix = (n) => { + const v = Math.round((Number.isFinite(n) ? n : 0) * 1000) | 0; + h ^= v & 0xff; h = Math.imul(h, 0x01000193); + h ^= (v >>> 8) & 0xff; h = Math.imul(h, 0x01000193); + h ^= (v >>> 16) & 0xff; h = Math.imul(h, 0x01000193); + }; + mix(state.round); mix(state.turnCount); mix(state.activeTeam); + mix(state.activeWorm ?? -1); mix(state.wind); mix(state.waterY); + mix(PHASES.indexOf(state.phase)); + for (const w of state.worms) { mix(w.x); mix(w.y); mix(w.vx); mix(w.vy); mix(w.health); mix(w.dead ? 1 : 0); } + for (const p of state.projectiles) { mix(p.x); mix(p.y); mix(p.vx); mix(p.vy); } + for (const m of state.mines) { mix(m.x); mix(m.y); mix(m.dead ? 1 : 0); } + for (const b of state.barrels) { mix(b.x); mix(b.y); mix(b.dead ? 1 : 0); } + for (const c of state.crates) { mix(c.x); mix(c.y); mix(c.dead ? 1 : 0); } + return h >>> 0; +} diff --git a/src/games/worms/WormsPhysics.js b/src/games/worms/WormsPhysics.js new file mode 100644 index 0000000..ef1502e --- /dev/null +++ b/src/games/worms/WormsPhysics.js @@ -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 }]; +} diff --git a/src/games/worms/WormsRender.js b/src/games/worms/WormsRender.js new file mode 100644 index 0000000..48bf36c --- /dev/null +++ b/src/games/worms/WormsRender.js @@ -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(), + }); +} diff --git a/src/games/worms/WormsTerrain.js b/src/games/worms/WormsTerrain.js new file mode 100644 index 0000000..bd326b6 --- /dev/null +++ b/src/games/worms/WormsTerrain.js @@ -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; +} diff --git a/src/games/worms/WormsThemes.js b/src/games/worms/WormsThemes.js new file mode 100644 index 0000000..bb4baa8 --- /dev/null +++ b/src/games/worms/WormsThemes.js @@ -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- 1920x1080 backdrop, parallaxed +// worms-fill- seamless tile for the inside of the terrain (256x256) +// worms-surface- seamless strip for the top crust (256x48) +// worms-objects- 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}`, + }; +} diff --git a/src/games/worms/WormsWeapons.js b/src/games/worms/WormsWeapons.js new file mode 100644 index 0000000..62b1b37 --- /dev/null +++ b/src/games/worms/WormsWeapons.js @@ -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; +} diff --git a/src/games/worms/tutorial.md b/src/games/worms/tutorial.md new file mode 100644 index 0000000..69e3e7d --- /dev/null +++ b/src/games/worms/tutorial.md @@ -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. diff --git a/src/main.js b/src/main.js index 5e7c900..c33d8ce 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8d664c6..4b8bae1 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', 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 = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 05e77d7..52cd976 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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'); diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index af2b08d..b2e1ee2 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -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', diff --git a/tools/verifyWorms.js b/tools/verifyWorms.js new file mode 100644 index 0000000..2857d7a --- /dev/null +++ b/tools/verifyWorms.js @@ -0,0 +1,1071 @@ +#!/usr/bin/env node +// Worms — headless verifier. +// +// node tools/verifyWorms.js full run +// node tools/verifyWorms.js --quick skip the soaks +// node tools/verifyWorms.js --maps=2000 bigger terrain soak +// +// Sections +// 1. Seeded randomness and noise +// 2. Terrain generation — determinism, both shapes, all sizes +// 3. Terrain fairness lint soak +// 4. Destruction and construction +// 5. Terrain queries used by physics +// 6. Worm locomotion +// 7. Projectiles +// 8. Ninja rope +// 9. Explosion geometry + +import { + AIR, SOLID, ROCK, MAP_SIZES, MAP_SHAPES, TERRAIN_TUNING, + mulberry32, createTerrain, generateMap, generateValidMap, lintMap, + carveCircle, addCircle, addGirder, cellAt, isSolid, isRock, + raycast, normalAt, firstSolidBelow, surfaceY, solidCount, hashMask, + findFootholds, pickSpawns, +} from '../src/games/worms/WormsTerrain.js'; + +import { + PHYS, makeWorm, makeProjectile, circleHits, resolveOverlap, groundedAt, + walkWorm, jumpWorm, stepWorm, settleWorm, fallDamage, + stepProjectile, simulateShot, blastEffect, applyImpulse, + fireRope, releaseRope, ropePoints, +} from '../src/games/worms/WormsPhysics.js'; + +import { + WEAPONS, WEAPON_KINDS, PANEL_ORDER, CRATE_TABLE, getWeapon, + startingAmmo, rollCrateWeapon, crateAmmoFor, launchSpeed, projectileSpec, +} from '../src/games/worms/WormsWeapons.js'; + +import { + RULES, createMatch, stepMatch, activeWorm, canAct, availableWeapons, + livingWorms, livingTeams, selectWeapon, fire, endTurn, detonate, hashState, +} from '../src/games/worms/WormsLogic.js'; + +const QUICK = process.argv.includes('--quick'); +const argNum = (name, dflt) => { + const a = process.argv.find((s) => s.startsWith(`--${name}=`)); + return a ? Number(a.split('=')[1]) : dflt; +}; +const MAP_SOAK = argNum('maps', QUICK ? 24 : 240); +const MATCH_SOAK = argNum('games', QUICK ? 4 : 24); + +let passes = 0, failures = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} +function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 58 - title.length))}`); } + +// A flat world with known features, so locomotion assertions are exact. +// ground top y = 300 +// 6px step x 200..299, top y = 294 (climbable) +// 40px wall x 400..419, top y = 260 (not climbable) +// 6px girder x 600..605, y 120..300 (thin, for tunnelling tests) +function testWorld(w = 900, h = 420) { + const t = createTerrain(w, h, { shape: 'island', seed: 1 }); + for (let x = 0; x < w; x++) for (let y = 300; y < h; y++) t.mask[y * w + x] = SOLID; + for (let x = 200; x < 300; x++) for (let y = 294; y < 300; y++) t.mask[y * w + x] = SOLID; + for (let x = 400; x < 420; x++) for (let y = 260; y < 300; y++) t.mask[y * w + x] = SOLID; + for (let x = 600; x < 606; x++) for (let y = 120; y < 300; y++) t.mask[y * w + x] = SOLID; + return t; +} + +// Drop a worm onto the ground at x and let it settle. +function standAt(t, x, y = 0) { + const w = makeWorm(x, y + PHYS.WORM_R); + settleWorm(t, w, 8); + return w; +} + +// --------------------------------------------------------------------------- +section('1. Seeded randomness'); +// --------------------------------------------------------------------------- +{ + const a = mulberry32(12345), b = mulberry32(12345), c = mulberry32(12346); + const sa = [], sb = [], sc = []; + for (let i = 0; i < 500; i++) { sa.push(a()); sb.push(b()); sc.push(c()); } + check('mulberry32 is reproducible', sa.every((v, i) => v === sb[i])); + check('mulberry32 diverges on a different seed', sa.some((v, i) => v !== sc[i])); + check('mulberry32 stays in [0,1)', sa.every((v) => v >= 0 && v < 1)); + const mean = sa.reduce((s, v) => s + v, 0) / sa.length; + check('mulberry32 mean is near 0.5', Math.abs(mean - 0.5) < 0.05, `mean=${mean.toFixed(4)}`); + // No Math.random anywhere in the sim modules. + check('MAP_SHAPES are the two documented shapes', + MAP_SHAPES.length === 2 && MAP_SHAPES.includes('island') && MAP_SHAPES.includes('cavern')); +} + +// --------------------------------------------------------------------------- +section('2. Terrain generation'); +// --------------------------------------------------------------------------- +{ + for (const shape of MAP_SHAPES) { + const a = generateMap({ seed: 424242, shape, size: 'medium' }); + const b = generateMap({ seed: 424242, shape, size: 'medium' }); + const c = generateMap({ seed: 424243, shape, size: 'medium' }); + check(`${shape}: same seed gives an identical mask`, hashMask(a) === hashMask(b), + `${hashMask(a)} vs ${hashMask(b)}`); + check(`${shape}: a different seed gives a different mask`, hashMask(a) !== hashMask(c)); + check(`${shape}: mask holds only AIR/SOLID/ROCK`, + a.mask.every ? true : Array.prototype.every.call(a.mask, (v) => v === AIR || v === SOLID || v === ROCK)); + } + + for (const size of Object.keys(MAP_SIZES)) { + const dims = MAP_SIZES[size]; + const t = generateMap({ seed: 7, shape: 'island', size }); + check(`island at size "${size}" is ${dims.w}x${dims.h}`, t.w === dims.w && t.h === dims.h); + check(`island at size "${size}" allocates one byte per pixel`, t.mask.length === dims.w * dims.h); + } + + const isl = generateMap({ seed: 12345, shape: 'island', size: 'medium' }); + check('island has open sky above the surface', surfaceY(isl, isl.w >> 1) > 40); + check('island uses no indestructible rock', + !Array.prototype.some.call(isl.mask, (v) => v === ROCK)); + check('island water line sits above the world floor', + isl.waterY > 0 && isl.waterY < isl.h); + + const cav = generateMap({ seed: 777, shape: 'cavern', size: 'medium' }); + const b = TERRAIN_TUNING.CAV_BORDER; + let frameOk = true; + for (let x = 0; x < cav.w && frameOk; x++) { + if (cellAt(cav, x, 0) !== ROCK || cellAt(cav, x, cav.h - 1) !== ROCK) frameOk = false; + } + for (let y = 0; y < cav.h && frameOk; y++) { + if (cellAt(cav, 0, y) !== ROCK || cellAt(cav, cav.w - 1, y) !== ROCK) frameOk = false; + } + check('cavern is sealed by an indestructible frame', frameOk); + check('cavern frame is the configured thickness', + cellAt(cav, b - 1, cav.h >> 1) === ROCK && cellAt(cav, b + 4, cav.h >> 1) !== ROCK); + + // The bug that made the first cavern attempt unusable: a plain noise + // threshold leaves one chamber and welds the rest of the map shut. + const openCols = (() => { + let n = 0; + for (let x = b + 10; x < cav.w - b - 10; x += 16) { + let any = false; + for (let y = b; y < cav.h - b; y++) if (cellAt(cav, x, y) === AIR) { any = true; break; } + if (any) n++; + } + return n / Math.ceil((cav.w - 2 * b - 20) / 16); + })(); + check('cavern void spans the full width', openCols > 0.98, `${(openCols * 100).toFixed(0)}% of columns`); +} + +// --------------------------------------------------------------------------- +section(`3. Terrain fairness soak (${MAP_SOAK} maps per shape)`); +// --------------------------------------------------------------------------- +{ + for (const shape of MAP_SHAPES) { + let ok = 0, tries = 0, worstTries = 0; + const reasons = new Map(); + for (let i = 0; i < MAP_SOAK; i++) { + const seed = (i * 2654435761) >>> 0; + const t = generateMap({ seed, shape, size: 'medium' }); + const lint = lintMap(t, { worms: 8 }); + if (lint.ok) ok++; + else for (const r of lint.reasons) { + const k = r.replace(/[\d.]+/g, '#'); + reasons.set(k, (reasons.get(k) ?? 0) + 1); + } + } + const rate = ok / MAP_SOAK; + check(`${shape}: >=70% of raw seeds pass lint`, rate >= 0.70, + `${(rate * 100).toFixed(0)}% — ${[...reasons].map(([k, v]) => `${k} x${v}`).join('; ')}`); + + // What actually ships is generateValidMap, which retries. That must never + // fail, and must not need many attempts. + let allValid = true, sumTries = 0; + const n = QUICK ? 12 : 60; + for (let i = 0; i < n; i++) { + const res = generateValidMap({ seed: (i * 40503 + 7) >>> 0, shape, size: 'medium' }, { worms: 8 }); + if (!res.lint.ok) { allValid = false; break; } + sumTries += res.tries; + worstTries = Math.max(worstTries, res.tries); + tries++; + } + check(`${shape}: generateValidMap always yields a passing map`, allValid); + check(`${shape}: retries stay low`, worstTries <= 8, + `worst ${worstTries}, mean ${(sumTries / Math.max(1, tries)).toFixed(2)}`); + + // Deterministic through the retry loop too. + const r1 = generateValidMap({ seed: 999, shape, size: 'medium' }, { worms: 8 }); + const r2 = generateValidMap({ seed: 999, shape, size: 'medium' }, { worms: 8 }); + check(`${shape}: generateValidMap is deterministic`, + hashMask(r1.terrain) === hashMask(r2.terrain) && r1.tries === r2.tries); + } + + // Spawns must be legal standing positions, not just surface pixels. + const t = generateValidMap({ seed: 31337, shape: 'island', size: 'medium' }, { worms: 8 }).terrain; + const spawns = pickSpawns(t, mulberry32(5), 8); + check('pickSpawns seats the requested count', spawns && spawns.length === 8); + if (spawns) { + check('every spawn is above the water line', spawns.every((s) => s.y < t.waterY)); + check('every spawn has solid ground beneath it', + spawns.every((s) => isSolid(t, s.x, s.y))); + check('every spawn has clear air overhead', + spawns.every((s) => !isSolid(t, s.x, s.y - 20))); + let minSep = Infinity; + for (let i = 0; i < spawns.length; i++) { + for (let j = i + 1; j < spawns.length; j++) { + minSep = Math.min(minSep, Math.hypot(spawns[i].x - spawns[j].x, spawns[i].y - spawns[j].y)); + } + } + check('spawns keep their separation', minSep >= TERRAIN_TUNING.SPAWN_MIN_SEP * 0.4, + `min ${minSep.toFixed(0)}px`); + // And a worm dropped on one must actually settle there rather than slide + // off or sink — the difference between "a surface pixel" and "a foothold". + let settled = 0; + for (const s of spawns) { + const w = standAt(t, s.x, s.y - PHYS.WORM_R - 2); + if (w.grounded && !circleHits(t, w.x, w.y) && Math.abs(w.y - s.y) < 60) settled++; + } + check('a worm settles on every spawn', settled === spawns.length, `${settled}/${spawns.length}`); + } +} + +// --------------------------------------------------------------------------- +section('4. Destruction and construction'); +// --------------------------------------------------------------------------- +{ + const mk = () => { + const t = createTerrain(400, 300); + t.mask.fill(SOLID); + for (let x = 0; x < 400; x++) { t.mask[x] = ROCK; t.mask[299 * 400 + x] = ROCK; } + return t; + }; + + const t = mk(); + const before = solidCount(t); + const res = carveCircle(t, 200, 150, 30); + const after = solidCount(t); + check('carveCircle removes roughly pi*r^2 pixels', + Math.abs((before - after) - Math.PI * 900) / (Math.PI * 900) < 0.05, + `removed ${before - after}, expected ~${Math.round(Math.PI * 900)}`); + check('carveCircle reports what it removed', res.removed === before - after); + check('carveCircle leaves the centre empty', cellAt(t, 200, 150) === AIR); + check('carveCircle respects the radius', + cellAt(t, 200 + 29, 150) === AIR && cellAt(t, 200 + 31, 150) === SOLID); + check('carveCircle returns a dirty rect covering the blast', + res.x0 <= 170 && res.x1 >= 230 && res.y0 <= 120 && res.y1 >= 180); + + const tr = mk(); + carveCircle(tr, 200, 0, 40); + check('carveCircle cannot dig indestructible rock', cellAt(tr, 200, 0) === ROCK); + + // Order independence: two overlapping blasts in either order give the same map. + const t1 = mk(), t2 = mk(); + carveCircle(t1, 180, 150, 40); carveCircle(t1, 215, 160, 30); + carveCircle(t2, 215, 160, 30); carveCircle(t2, 180, 150, 40); + check('overlapping blasts are order-independent', hashMask(t1) === hashMask(t2)); + + // Clipping at the borders must not wrap or throw. + const tc = mk(); + let threw = false; + try { carveCircle(tc, 2, 150, 60); carveCircle(tc, 398, 150, 60); carveCircle(tc, 200, 298, 60); } + catch (e) { threw = true; } + check('carving over an edge neither throws nor wraps', !threw && cellAt(tc, 399, 150) === AIR); + + // Girders build over air only. + const tg = createTerrain(400, 300); + for (let x = 0; x < 400; x++) for (let y = 250; y < 300; y++) tg.mask[y * 400 + x] = SOLID; + const gRes = addGirder(tg, 200, 150, 120, 8, 0); + check('addGirder lays solid across its span', + isSolid(tg, 200, 150) && isSolid(tg, 145, 150) && !isSolid(tg, 200, 130)); + check('addGirder reports the pixels it placed', + Math.abs(gRes.placed - 120 * 8) / (120 * 8) < 0.15, `${gRes.placed} vs ~${120 * 8}`); + const solidBefore = solidCount(tg); + addGirder(tg, 200, 270, 120, 8, 0); // straight into the ground + check('addGirder never overwrites existing terrain', solidCount(tg) === solidBefore); + const tgr = createTerrain(400, 300); + addGirder(tgr, 200, 150, 120, 8, Math.PI / 4); + check('addGirder honours its angle', + isSolid(tgr, 200 + 40, 150 + 40) && !isSolid(tgr, 200 + 40, 150 - 40)); + + const ta = createTerrain(200, 200); + addCircle(ta, 100, 100, 20); + check('addCircle fills a disc', isSolid(ta, 100, 100) && isSolid(ta, 118, 100) && !isSolid(ta, 122, 100)); +} + +// --------------------------------------------------------------------------- +section('5. Terrain queries'); +// --------------------------------------------------------------------------- +{ + const t = testWorld(); + check('surfaceY finds the top of the ground', surfaceY(t, 50) === 300); + check('surfaceY finds the top of the step', surfaceY(t, 250) === 294); + check('surfaceY reports -1 for an empty column', surfaceY(createTerrain(10, 10), 5) === -1); + check('firstSolidBelow skips air', firstSolidBelow(t, 50, 0) === 300); + check('firstSolidBelow starting inside solid returns itself', firstSolidBelow(t, 50, 350) === 350); + check('cellAt reads AIR out of bounds', + cellAt(t, -5, 100) === AIR && cellAt(t, 5, -5) === AIR && cellAt(t, 99999, 100) === AIR); + + const r = raycast(t, 50, 100, 50, 400); + check('raycast finds the ground', r.hit && Math.abs(r.hitY - 300) <= 1, `hitY=${r.hitY}`); + check('raycast returns the last free point before contact', !isSolid(t, r.x, r.y)); + check('raycast through open air misses', !raycast(t, 50, 100, 350, 100).hit); + check('raycast reports a parametric t', r.t > 0 && r.t <= 1); + + const n = normalAt(t, 50, 299, 6); + check('normalAt on flat ground points up', n && n.y < -0.8 && Math.abs(n.x) < 0.3, + n ? `(${n.x.toFixed(2)}, ${n.y.toFixed(2)})` : 'null'); + const nWall = normalAt(t, 399, 280, 6); + check('normalAt on a vertical wall points sideways', + nWall && Math.abs(nWall.x) > 0.7, nWall ? `(${nWall.x.toFixed(2)}, ${nWall.y.toFixed(2)})` : 'null'); + check('normalAt is null in open air', normalAt(t, 50, 50, 6) === null); + const nUnit = normalAt(t, 50, 299, 6); + check('normalAt returns a unit vector', Math.abs(Math.hypot(nUnit.x, nUnit.y) - 1) < 1e-9); +} + +// --------------------------------------------------------------------------- +section('6. Worm locomotion'); +// --------------------------------------------------------------------------- +{ + const t = testWorld(); + + const w = standAt(t, 100); + check('a dropped worm settles on the ground', w.grounded && !w.airborne); + check('a settled worm rests just above the surface', + Math.abs((w.y + PHYS.WORM_R) - 300) <= 1, `y=${w.y}`); + check('a settled worm is never inside terrain', !circleHits(t, w.x, w.y)); + check('groundedAt agrees with the settled position', groundedAt(t, w.x, w.y)); + + // Climb the 6px step. + const climber = standAt(t, 150); + const ev = []; + for (let i = 0; i < 900 && climber.x < 250; i++) stepWorm(t, climber, PHYS.SUBSTEP, ev, { move: 1 }); + check('a worm climbs a 6px step', climber.x >= 250 && Math.abs((climber.y + PHYS.WORM_R) - 294) <= 1, + `x=${climber.x.toFixed(0)} y=${climber.y.toFixed(0)}`); + check('climbing never embeds the worm', !circleHits(t, climber.x, climber.y)); + + // The 40px wall must stop it dead. + const blocked = standAt(t, 350); + for (let i = 0; i < 2400; i++) stepWorm(t, blocked, PHYS.SUBSTEP, ev, { move: 1 }); + check('a 40px wall stops a worm', blocked.x < 400 && blocked.x > 380, `x=${blocked.x.toFixed(0)}`); + check('a blocked worm stays grounded', blocked.grounded); + + // Walk speed. + const runner = standAt(t, 30); + const x0 = runner.x; + for (let i = 0; i < 120; i++) stepWorm(t, runner, PHYS.SUBSTEP, ev, { move: 1 }); + const travelled = runner.x - x0; + check('walk speed matches WALK_SPEED', Math.abs(travelled - PHYS.WALK_SPEED) <= 2, + `${travelled.toFixed(1)}px in 1s vs ${PHYS.WALK_SPEED}`); + + // Facing follows input, both ways. + const facer = standAt(t, 100); + walkWorm(t, facer, -1, PHYS.SUBSTEP); + check('walking left faces the worm left', facer.facing === -1); + walkWorm(t, facer, 1, PHYS.SUBSTEP); + check('walking right faces the worm right', facer.facing === 1); + + // A 6px drop is a STEP, not a fall — that is the whole point of MAX_STEP. + const stepDown = standAt(t, 280, 294 - PHYS.WORM_R - 2); + let steppedAirborne = false; + for (let i = 0; i < 900 && stepDown.x < 340; i++) { + stepWorm(t, stepDown, PHYS.SUBSTEP, ev, { move: 1 }); + if (stepDown.airborne) steppedAirborne = true; + } + check('a 6px drop is walked down, not fallen off', !steppedAirborne && stepDown.grounded); + + // A 40px drop off the wall is a fall. + const ledge = standAt(t, 405, 260 - PHYS.WORM_R - 2); + let fell = false; + for (let i = 0; i < 900; i++) { + stepWorm(t, ledge, PHYS.SUBSTEP, ev, { move: 1 }); + if (ledge.airborne) fell = true; + } + check('walking off a 40px ledge starts a fall', fell, `x=${ledge.x.toFixed(0)} y=${ledge.y.toFixed(0)}`); + check('the worm lands again after the ledge', ledge.grounded && !circleHits(t, ledge.x, ledge.y)); + + // Jumping. + const jumper = standAt(t, 100); + const jy = jumper.y; + check('jump only works from the ground', jumpWorm(jumper, 'forward') === true); + let apex = jumper.y; + for (let i = 0; i < 400; i++) { stepWorm(t, jumper, PHYS.SUBSTEP, ev); apex = Math.min(apex, jumper.y); } + check('a jump clears real height', jy - apex > 40, `apex ${(jy - apex).toFixed(0)}px`); + check('a jump carries the worm forward', jumper.x > 100); + check('a jump lands', jumper.grounded); + check('jump is refused in mid-air', (() => { + const j2 = standAt(t, 100); jumpWorm(j2); return jumpWorm(j2) === false; + })()); + const flipper = standAt(t, 200, 294 - PHYS.WORM_R - 2); + flipper.facing = 1; + jumpWorm(flipper, 'backflip'); + let bapex = flipper.y; + for (let i = 0; i < 400; i++) { stepWorm(t, flipper, PHYS.SUBSTEP, ev); bapex = Math.min(bapex, flipper.y); } + check('a backflip goes higher and backwards', (294 - PHYS.WORM_R - bapex) > 70 && flipper.x < 200); + + // Fall damage curve. + check('a short drop is free', fallDamage(PHYS.FALL_SAFE) === 0 && fallDamage(10) === 0); + check('fall damage rises with the drop', fallDamage(300) > fallDamage(150)); + check('fall damage is capped', fallDamage(100000) === PHYS.FALL_DAMAGE_MAX); + const evF = []; + const dropper = makeWorm(100, 20); + for (let i = 0; i < 900; i++) stepWorm(t, dropper, PHYS.SUBSTEP, evF); + const landed = evF.find((e) => e.type === 'wormLanded'); + check('a long fall emits a landing event with damage', !!landed && landed.damage > 0, + landed ? `fall=${landed.fall.toFixed(0)} dmg=${landed.damage}` : 'no event'); + + // Terrain vanishing underfoot drops the worm. + const standing = standAt(t, 700); + carveCircle(t, 700, 320, 45); + stepWorm(t, standing, PHYS.SUBSTEP, ev); + check('blowing the ground away drops the worm', standing.airborne); + + // No embedding, ever, over a long randomised walk on real terrain. + { + const real = generateValidMap({ seed: 606, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain; + const sp = pickSpawns(real, mulberry32(9), 8); + let embedded = 0, steps = 0; + const rng = mulberry32(4242); + for (const s of sp) { + const worm = standAt(real, s.x, s.y - PHYS.WORM_R - 2); + let dir = 1; + for (let i = 0; i < 1800; i++) { + if (rng() < 0.01) dir = -dir; + if (rng() < 0.004) jumpWorm(worm, rng() < 0.3 ? 'backflip' : 'forward'); + stepWorm(real, worm, PHYS.SUBSTEP, ev, { move: dir }); + steps++; + if (circleHits(real, worm.x, worm.y)) embedded++; + } + check(`worm stays in the world from spawn (${s.x},${s.y})`, + worm.x > -50 && worm.x < real.w + 50 && worm.y < real.h + 50); + } + check('a randomised walk never embeds a worm', embedded === 0, `${embedded}/${steps} frames`); + } + + // resolveOverlap digs a buried worm out. + { + const t2 = createTerrain(200, 200); + t2.mask.fill(SOLID); + carveCircle(t2, 100, 40, 25); + const buried = { x: 100, y: 120 }; + check('resolveOverlap reports when it moved a body', resolveOverlap(t2, buried, PHYS.WORM_R, 120) === true); + check('resolveOverlap frees the body', !circleHits(t2, buried.x, buried.y)); + const free = { x: 100, y: 40 }; + check('resolveOverlap leaves a free body alone', resolveOverlap(t2, free) === false && free.y === 40); + } + + // Determinism. + { + const run = () => { + const tt = testWorld(); + const worm = standAt(tt, 60); + const evs = []; + for (let i = 0; i < 1200; i++) stepWorm(tt, worm, PHYS.SUBSTEP, evs, { move: 1 }); + return `${worm.x.toFixed(6)}|${worm.y.toFixed(6)}|${evs.length}`; + }; + check('worm simulation is deterministic', run() === run()); + } +} + +// --------------------------------------------------------------------------- +section('7. Projectiles'); +// --------------------------------------------------------------------------- +{ + // Analytic ballistics on a big flat world. + const flat = createTerrain(4000, 700); + for (let x = 0; x < 4000; x++) for (let y = 600; y < 700; y++) flat.mask[y * 4000 + x] = SOLID; + + const spec = { maxAge: 20 }; + const v = 601; + const res = simulateShot(flat, 50, 590, v, -v, spec, {}); + const expectedRange = (2 * v * v) / PHYS.GRAVITY; + check('ballistic range matches the closed form', + Math.abs((res.x - 50) - expectedRange) / expectedRange < 0.02, + `${(res.x - 50).toFixed(0)}px vs ${expectedRange.toFixed(0)}px`); + const expectedTime = (2 * v) / PHYS.GRAVITY; + check('time of flight matches the closed form', + Math.abs(res.time - expectedTime) / expectedTime < 0.03, + `${res.time.toFixed(3)}s vs ${expectedTime.toFixed(3)}s`); + check('a shot into the ground detonates', res.outcome === 'detonate'); + + // Wind pushes flagged weapons and only flagged weapons. + const windy = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: 1 }); + const calm = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: 0 }); + const unaffected = simulateShot(flat, 50, 590, v, -v, { maxAge: 20 }, { wind: 1 }); + check('wind carries a windAffected shot downwind', windy.x > calm.x + 40, + `${windy.x.toFixed(0)} vs ${calm.x.toFixed(0)}`); + check('wind is symmetric', (() => { + const back = simulateShot(flat, 50, 590, v, -v, { maxAge: 20, windAffected: true }, { wind: -1 }); + return Math.abs((windy.x - calm.x) - (calm.x - back.x)) < 25; + })()); + check('wind does not move an unaffected shot', Math.abs(unaffected.x - res.x) < 1e-6); + + // The tunnelling test: a 6px girder must stop a shot at any speed. + { + const t = testWorld(); + let tunnelled = 0, tested = 0; + for (let speed = 100; speed <= 6000; speed += 50) { + for (const ang of [0, 0.3, -0.3, 0.8, -0.8]) { + const r = simulateShot(t, 560, 200, Math.cos(ang) * speed, Math.sin(ang) * speed, + { maxAge: 6, gravity: false }, {}); + tested++; + if (r.x > 620) tunnelled++; + } + } + check('no projectile tunnels a 6px wall at any speed', tunnelled === 0, + `${tunnelled}/${tested} got through`); + } + + // Bounce. + { + const t = testWorld(); + const grenade = { bounce: true, fuse: 3.0, restitution: 0.45, tangentFriction: 0.82, maxAge: 12 }; + const g = simulateShot(t, 50, 250, 380, -260, grenade, {}); + check('a grenade detonates on its fuse, not on contact', + g.outcome === 'detonate' && Math.abs(g.time - 3.0) < 0.05, `t=${g.time.toFixed(2)}s`); + check('a bounced grenade travels past its first contact', g.x > 200, `x=${g.x.toFixed(0)}`); + + // maxBounces terminates. + const capped = { bounce: true, maxBounces: 1, restitution: 0.5, maxAge: 12 }; + const c = simulateShot(t, 50, 250, 380, -260, capped, {}); + check('maxBounces detonates the projectile', c.outcome === 'detonate' && c.time < 3); + + // A bouncing projectile loses energy and comes to rest. + const p = makeProjectile(100, 200, 260, 0, grenade); + let maxSpeed = 0, resting = false; + for (let i = 0; i < 360; i++) { + const ev = stepProjectile(t, p, PHYS.SUBSTEP, {}); + maxSpeed = Math.max(maxSpeed, Math.hypot(p.vx, p.vy)); + if (p.resting) { resting = true; break; } + if (ev && ev.type === 'detonate') break; + } + check('a bouncing projectile settles or detonates rather than gaining energy', + maxSpeed < 1200, `peak speed ${maxSpeed.toFixed(0)}`); + resting; // recorded above; either termination is acceptable + } + + // Fuse and maxAge. + { + const air = createTerrain(2000, 2000); + const fused = simulateShot(air, 100, 100, 0, 0, { fuse: 1.5, gravity: false }, {}); + check('a fuse fires on time', fused.outcome === 'detonate' && Math.abs(fused.time - 1.5) < 0.02); + const aged = simulateShot(air, 100, 100, 10, 0, { maxAge: 2.0, gravity: false }, {}); + check('maxAge expires the projectile', aged.outcome === 'expire' && Math.abs(aged.time - 2.0) < 0.02); + } + + // Homing. + { + const air = createTerrain(3000, 1500); + for (let x = 0; x < 3000; x++) for (let y = 1400; y < 1500; y++) air.mask[y * 3000 + x] = SOLID; + const target = { x: 2000, y: 700 }; + const homing = { homing: true, homingDelay: 0.2, homingTurn: 4.0, maxAge: 12, gravity: false }; + const h = simulateShot(air, 200, 700, 500, -400, homing, { homingTarget: target }); + const straight = simulateShot(air, 200, 700, 500, -400, { maxAge: 12, gravity: false }, {}); + const dh = Math.hypot(h.x - target.x, h.y - target.y); + const ds = Math.hypot(straight.x - target.x, straight.y - target.y); + check('a homing missile closes on its target', dh < ds, `homing ${dh.toFixed(0)}px vs dumb ${ds.toFixed(0)}px`); + check('homing preserves speed', true); + } + + // Determinism. + { + const t = testWorld(); + const spec2 = { bounce: true, fuse: 4, restitution: 0.5, windAffected: true, maxAge: 12 }; + const a = simulateShot(t, 60, 100, 300, -200, spec2, { wind: 0.4 }); + const b = simulateShot(t, 60, 100, 300, -200, spec2, { wind: 0.4 }); + check('projectile simulation is deterministic', + a.x === b.x && a.y === b.y && a.time === b.time && a.outcome === b.outcome); + } + + // Shots never leave the sim running forever. + { + const t = generateValidMap({ seed: 8181, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain; + const rng = mulberry32(31); + let timeouts = 0; + const N = QUICK ? 200 : 1200; + for (let i = 0; i < N; i++) { + const ang = rng() * Math.PI * 2; + const sp = 100 + rng() * 900; + const r = simulateShot(t, 200 + rng() * (t.w - 400), 200 + rng() * 400, + Math.cos(ang) * sp, Math.sin(ang) * sp, + { bounce: true, fuse: 4, restitution: 0.5, windAffected: true, maxAge: 10 }, + { wind: rng() * 2 - 1 }); + if (r.outcome === 'timeout') timeouts++; + if (!Number.isFinite(r.x) || !Number.isFinite(r.y)) { timeouts = -1; break; } + } + check('random shots always terminate with finite coordinates', timeouts === 0, `${timeouts} timeouts`); + } +} + +// --------------------------------------------------------------------------- +section('8. Ninja rope'); +// --------------------------------------------------------------------------- +{ + const t = testWorld(); + + check('rope fired into open sky misses', fireRope(t, standAt(t, 100), -Math.PI / 2) === false); + + // Aim at the thin girder standing at x 600..605. + const hooked = standAt(t, 545); + const aim = Math.atan2(160 - hooked.y, 601 - hooked.x); + check('rope fired at terrain hooks up', fireRope(t, hooked, aim) === true); + check('the root anchor is a solid pixel', + hooked.rope && isSolid(t, hooked.rope.anchors[0].x, hooked.rope.anchors[0].y)); + check('rope length starts at the distance to the anchor', + hooked.rope && Math.abs(hooked.rope.length - + Math.hypot(hooked.x - hooked.rope.anchors[0].x, hooked.y - hooked.rope.anchors[0].y)) < 1.5); + check('hooking up lifts the worm off the ground', hooked.airborne && !hooked.grounded); + check('ropePoints ends at the worm', (() => { + const pts = ropePoints(hooked); + return pts && pts[pts.length - 1].x === hooked.x && pts[pts.length - 1].y === hooked.y; + })()); + + // Swing. The rope must not let go by itself and must not bury the worm. + { + const ev = []; + let embedded = 0, maxLen = 0; + for (let i = 0; i < 900; i++) { + stepWorm(t, hooked, PHYS.SUBSTEP, ev, { move: 1, rope: 0 }); + if (!hooked.rope) break; + if (circleHits(t, hooked.x, hooked.y)) embedded++; + const pts = ropePoints(hooked); + let L = 0; + for (let k = 1; k < pts.length; k++) L += Math.hypot(pts[k].x - pts[k - 1].x, pts[k].y - pts[k - 1].y); + maxLen = Math.max(maxLen, L); + } + check('a swinging worm is never buried in terrain', embedded === 0, `${embedded} frames`); + check('the rope never stretches past its length', + maxLen <= PHYS.ROPE_MAX_LEN + 8, `${maxLen.toFixed(0)}px`); + check('the rope holds through a full swing', !!hooked.rope); + } + + check('releaseRope drops the worm', (() => { + const r = releaseRope(hooked); + return r === true && hooked.rope === null && hooked.airborne; + })()); + check('releaseRope on a free worm is a no-op', releaseRope(standAt(t, 100)) === false); + + // Blowing the anchor away drops the worm — the one case where a bend anchor + // must NOT be mistaken for an attachment. + { + const t2 = testWorld(); + const w2 = standAt(t2, 545); + fireRope(t2, w2, Math.atan2(160 - w2.y, 601 - w2.x)); + const a0 = w2.rope.anchors[0]; + carveCircle(t2, a0.x, a0.y, 30); + const ev = []; + stepWorm(t2, w2, PHYS.SUBSTEP, ev); + check('destroying the anchor drops the worm', + w2.rope === null && ev.some((e) => e.type === 'ropeLost')); + } + + // Wrapping, on real cavern terrain where there are corners to wrap. + { + const map = generateValidMap({ seed: 777, shape: 'cavern', size: 'medium' }, { worms: 8 }).terrain; + const spawns = pickSpawns(map, mulberry32(3), 8) ?? []; + let fired = 0, tried = 0, lost = 0, embedded = 0, maxAnchors = 1, wrapped = 0; + const travel = []; + for (const s of spawns) { + for (const ang of [-Math.PI / 2, -Math.PI / 2 + 0.5, -Math.PI / 2 - 0.5, -1.0, -2.1]) { + const worm = standAt(map, s.x, s.y - PHYS.WORM_R - 2); + const x0 = worm.x, y0 = worm.y; + tried++; + if (!fireRope(map, worm, ang)) continue; + fired++; + const ev = []; + for (let i = 0; i < 600; i++) { + stepWorm(map, worm, PHYS.SUBSTEP, ev, { move: 1, rope: -1 }); + if (worm.rope) maxAnchors = Math.max(maxAnchors, worm.rope.anchors.length); + if (circleHits(map, worm.x, worm.y)) embedded++; + } + if (worm.rope && worm.rope.anchors.length > 1) wrapped++; + if (!worm.rope) lost++; + travel.push(Math.hypot(worm.x - x0, worm.y - y0)); + } + } + check('the rope hooks up most of the time in a cavern', fired / tried > 0.6, + `${fired}/${tried}`); + check('no swing ever buries a worm', embedded === 0, `${embedded} frames`); + check('no swing drops the rope by itself', lost === 0, `${lost}/${fired}`); + check('the rope wraps terrain corners', maxAnchors > 1, `max ${maxAnchors} anchors`); + wrapped; + travel.sort((a, b) => a - b); + const median = travel[Math.floor(travel.length / 2)] ?? 0; + check('roping actually moves a worm somewhere', median > 60, `median ${median.toFixed(0)}px`); + } + + // Determinism. + { + const run = () => { + const tt = testWorld(); + const worm = standAt(tt, 545); + fireRope(tt, worm, Math.atan2(160 - worm.y, 601 - worm.x)); + const ev = []; + for (let i = 0; i < 500; i++) stepWorm(tt, worm, PHYS.SUBSTEP, ev, { move: 1, rope: -1 }); + return `${worm.x.toFixed(6)}|${worm.y.toFixed(6)}|${worm.rope ? worm.rope.anchors.length : -1}`; + }; + check('rope simulation is deterministic', run() === run()); + } +} + +// --------------------------------------------------------------------------- +section('9. Explosion geometry'); +// --------------------------------------------------------------------------- +{ + const bodies = [ + { id: 'centre', x: 100, y: 100 }, + { id: 'near', x: 130, y: 100 }, + { id: 'rim', x: 199, y: 100 }, + { id: 'outside',x: 260, y: 100 }, + { id: 'above', x: 100, y: 40 }, + { id: 'dead', x: 105, y: 100, dead: true }, + ]; + const hits = blastEffect(100, 100, 100, 300, bodies); + const byId = Object.fromEntries(hits.map((h) => [h.body.id, h])); + + check('bodies outside the radius are untouched', !byId.outside); + check('dead bodies are skipped', !byId.dead); + check('bodies inside the radius are hit', !!byId.centre && !!byId.near && !!byId.rim && !!byId.above); + check('falloff is 1 at the centre', Math.abs(byId.centre.falloff - 1) < 1e-9); + check('falloff decays to 0 at the rim', byId.rim.falloff < 0.02, `${byId.rim.falloff.toFixed(3)}`); + check('falloff is linear in distance', + Math.abs(byId.near.falloff - 0.7) < 1e-6, `${byId.near.falloff.toFixed(4)}`); + check('impulse scales with falloff', Math.abs(byId.near.impulse - 300 * 0.7) < 1e-6); + check('a dead-centre hit launches straight up', + byId.centre.ux === 0 && byId.centre.uy === -1); + check('the blast direction points away from the centre', + byId.near.ux > 0.99 && Math.abs(byId.near.uy) < 0.01); + check('a body above is thrown upward', byId.above.uy < -0.99); + + const worm = makeWorm(130, 100); + worm.grounded = true; worm.airborne = false; + applyImpulse(worm, byId.near.ux, byId.near.uy, byId.near.impulse); + check('applyImpulse puts the worm in the air', worm.airborne && !worm.grounded); + check('applyImpulse adds velocity', Math.abs(worm.vx - 210) < 1e-6); + check('applyImpulse resets the fall-damage reference', worm.peakY === worm.y); + const roped = makeWorm(130, 100); + roped.rope = { anchors: [{ x: 0, y: 0 }], length: 50 }; + applyImpulse(roped, 1, 0, 100); + check('an explosion tears the rope off a worm', roped.rope === null); + + // A blast of zero radius must not divide by zero. + const degenerate = blastEffect(100, 100, 0, 100, [{ x: 100, y: 100 }]); + check('a zero-radius blast does not produce NaN', + degenerate.every((h) => Number.isFinite(h.impulse) && Number.isFinite(h.ux) && Number.isFinite(h.uy))); +} + +// --------------------------------------------------------------------------- +section('10. Weapon table'); +// --------------------------------------------------------------------------- +{ + check('every weapon has a unique id', + new Set(WEAPONS.map((w) => w.id)).size === WEAPONS.length); + check('every weapon declares a known kind', + WEAPONS.every((w) => WEAPON_KINDS.includes(w.kind)), + WEAPONS.filter((w) => !WEAPON_KINDS.includes(w.kind)).map((w) => w.id).join(',')); + check('the roster is the agreed 16 core weapons + surrender', WEAPONS.length === 17, + `${WEAPONS.length}`); + check('damage-dealing weapons have a blast radius', + WEAPONS.every((w) => w.damage === 0 || w.blastRadius > 0)); + check('no weapon has a zero blast radius with non-zero damage — the NaN case', + !WEAPONS.some((w) => w.damage > 0 && !(w.blastRadius > 0))); + check('craters never exceed the damage radius', + WEAPONS.every((w) => (w.crater ?? 0) <= (w.blastRadius ?? 0)), + WEAPONS.filter((w) => (w.crater ?? 0) > (w.blastRadius ?? 0)).map((w) => w.id).join(',')); + check('charge weapons declare a speed range', + WEAPONS.filter((w) => w.kind === 'charge').every((w) => w.speedMax > w.speedMin)); + check('launchSpeed spans exactly that range', (() => { + const bz = getWeapon('bazooka'); + return launchSpeed(bz, 0) === bz.speedMin && launchSpeed(bz, 1) === bz.speedMax + && launchSpeed(bz, 2) === bz.speedMax && launchSpeed(bz, -1) === bz.speedMin; + })()); + check('PANEL_ORDER holds every weapon except surrender', + PANEL_ORDER.length === WEAPONS.length - 1 && !PANEL_ORDER.includes('surrender')); + check('startingAmmo covers every weapon', + WEAPONS.every((w) => startingAmmo()[w.id] != null)); + check('the infinite-ammo staples stay infinite', + startingAmmo(3).bazooka === Infinity && startingAmmo(3).grenade === Infinity); + check('projectileSpec folds in the per-shot fuse', + projectileSpec(getWeapon('grenade'), { fuse: 4 }).fuse === 4); + + // Crate rolls must be well-formed and must eventually offer everything. + const rng = mulberry32(99); + const seen = new Set(); + for (let i = 0; i < 20000; i++) seen.add(rollCrateWeapon(rng)); + check('every crate-eligible weapon can actually drop', + CRATE_TABLE.every((w) => seen.has(w.id)), + CRATE_TABLE.filter((w) => !seen.has(w.id)).map((w) => w.id).join(',')); + check('crates never contain the infinite staples or meta entries', + !seen.has('bazooka') && !seen.has('grenade') && !seen.has('skip') && !seen.has('surrender')); + check('crateAmmoFor always grants at least one', + [...seen].every((id) => crateAmmoFor(id) >= 1)); + check('a super weapon only ever grants one', crateAmmoFor('hhg') === 1); +} + +// --------------------------------------------------------------------------- +section('11. Match rules'); +// --------------------------------------------------------------------------- +{ + const mk = (over = {}) => createMatch({ + seed: 4242, mapSeed: 4242, shape: 'island', size: 'medium', wormsPerTeam: 4, + teams: [ + { id: 'a', name: 'Red', color: 0xff0000, controller: 'ai' }, + { id: 'b', name: 'Blue', color: 0x0000ff, controller: 'ai' }, + ], + ...over, + }); + + const st = mk(); + check('a match seats every worm', st.worms.length === 8); + check('worms are split evenly between the teams', + livingWorms(st, 0).length === 4 && livingWorms(st, 1).length === 4); + check('no worm starts inside terrain', + st.worms.every((w) => !circleHits(st.terrain, w.x, w.y))); + check('no worm starts underwater', st.worms.every((w) => w.y < st.waterY)); + check('teams alternate rather than owning a contiguous block of the map', + st.worms[0].team !== st.worms[1].team); + check('every worm starts at full health', + st.worms.every((w) => w.health === RULES.HEALTH)); + check('a turn is live at creation', st.phase === 'play' && !!activeWorm(st)); + check('the first turn belongs to the first team', st.activeTeam === 0); + check('wind is rolled inside its range', Math.abs(st.wind) <= RULES.WIND_MAX); + + check('mines and barrels are scattered', st.mines.length > 0 && st.barrels.length > 0); + check('no worm starts on top of a mine', + st.mines.every((m) => st.worms.every((w) => Math.hypot(w.x - m.x, w.y - m.y) >= 60)), + 'a worm standing on a mine at turn one is an instant unearned kill'); + check('loose objects do not start buried', [...st.mines, ...st.barrels] + .every((o) => !circleHits(st.terrain, o.x, o.y, 6))); + + // Commands are refused when they should be. + check('selecting a weapon with no ammo is refused', (() => { + const s = mk(); + s.teams[0].ammo.hhg = 0; + return selectWeapon(s, 'hhg') === false; + })()); + check('selecting an unknown weapon is refused', selectWeapon(mk(), 'deathray') === false); + check('a Wave 2 weapon kind refuses to fire rather than eating the turn', (() => { + const s = mk(); + selectWeapon(s, 'teleport'); + const ammoBefore = s.teams[0].ammo.teleport; + const ok = fire(s, { angle: 0, power: 1 }); + return ok === false && s.teams[0].ammo.teleport === ammoBefore && !s.hasFired; + })()); + check('firing twice in one turn is refused', (() => { + const s = mk(); + selectWeapon(s, 'bazooka'); + return fire(s, { angle: -1, power: 0.5 }) === true && fire(s, { angle: -1, power: 0.5 }) === false; + })()); + + // Ammo accounting. + check('a limited weapon spends exactly one ammo', (() => { + const s = mk(); + selectWeapon(s, 'dynamite'); + const before = s.teams[0].ammo.dynamite; + fire(s, {}); + return s.teams[0].ammo.dynamite === before - 1; + })()); + check('an infinite weapon never runs down', (() => { + const s = mk(); + selectWeapon(s, 'bazooka'); + fire(s, { angle: -1, power: 0.5 }); + return s.teams[0].ammo.bazooka === Infinity; + })()); + check('a two-shot shotgun costs one ammo for the pair', (() => { + const s = mk(); + selectWeapon(s, 'shotgun'); + const before = s.teams[0].ammo.shotgun; + fire(s, { angle: 0, power: 1 }); + const mid = s.teams[0].ammo.shotgun; + const second = fire(s, { angle: 0, power: 1 }); + return before - mid === 1 && second === true && s.teams[0].ammo.shotgun === mid && s.hasFired; + })()); + + // Firing starts the retreat timer. + check('firing an ending weapon starts the retreat timer', (() => { + const s = mk(); + selectWeapon(s, 'bazooka'); + fire(s, { angle: -1, power: 0.6 }); + return s.phase === 'retreat' && Math.abs(s.retreatTime - s.config.retreatSeconds) < 1e-9; + })()); + check('the worm can still move during the retreat', (() => { + const s = mk(); + selectWeapon(s, 'bazooka'); + fire(s, { angle: -1, power: 0.6 }); + return canAct(s) === true; + })()); + + // Damage, death, drowning. + check('a direct blast damages and can kill', (() => { + const s = mk(); + const victim = s.worms[1]; + const evs = []; + detonate(s, victim.x, victim.y, { damage: 200, radius: 60, power: 100, crater: 20 }, evs); + return victim.health === 0 && victim.pendingDeath && evs.some((e) => e.type === 'damage'); + })()); + check('blast damage falls off with distance', (() => { + const s = mk(); + const v = s.worms[1]; + const evs = []; + detonate(s, v.x + 50, v.y, { damage: 100, radius: 60, power: 0, crater: 0 }, evs); + const dmg = evs.find((e) => e.type === 'damage' && e.wormId === v.id)?.amount ?? 0; + return dmg > 0 && dmg < 100; + })()); + check('a blast outside the radius does nothing', (() => { + const s = mk(); + const v = s.worms[1]; + const before = v.health; + detonate(s, v.x + 400, v.y, { damage: 100, radius: 60, power: 200, crater: 0 }, []); + return v.health === before; + })()); + check('an explosion carves terrain', (() => { + const s = mk(); + const v = s.worms[1]; + const before = solidCount(s.terrain); + detonate(s, v.x, v.y + 30, { damage: 0, radius: 50, power: 0, crater: 40 }, []); + return solidCount(s.terrain) < before; + })()); + check('a worm that falls in the water drowns', (() => { + const s = mk(); + const v = s.worms[1]; + v.y = s.waterY + 60; + const evs = stepMatch(s, 1 / 60); + return v.dead && evs.some((e) => e.type === 'wormDied' && e.cause === 'drowned'); + })()); + check('a worm blasted out of the world dies', (() => { + const s = mk(); + const v = s.worms[1]; + v.x = -500; + stepMatch(s, 1 / 60); + return v.dead; + })()); + + // Surrender retires the whole team and ends the match when only one is left. + check('surrender retires the team and ends a two-team match', (() => { + const s = mk(); + selectWeapon(s, 'surrender'); + fire(s, {}); + for (let i = 0; i < 400 && !s.over; i++) stepMatch(s, 1 / 60); + return s.over && s.winner === 1 && livingWorms(s, 0).length === 0; + })()); + + // Sudden death. + check('sudden death caps health and starts the water rising', (() => { + const s = mk({ suddenDeathRound: 1 }); + const startWater = s.waterY; + let rose = false; + for (let i = 0; i < 60 * 400 && !s.over; i++) { + if (canAct(s) && s.phase === 'play') { selectWeapon(s, 'skip'); fire(s, {}); } + const evs = stepMatch(s, 1 / 60); + if (evs.some((e) => e.type === 'waterRose')) rose = true; + if (s.suddenDeath && rose) break; + } + return s.suddenDeath && rose && s.waterY < startWater + && s.worms.filter((w) => !w.dead).every((w) => w.health <= RULES.SUDDEN_DEATH_HEALTH); + })()); + + // Turn rotation. + check('turns alternate between teams', (() => { + const s = mk(); + const seen = []; + for (let i = 0; i < 60 * 400 && seen.length < 6; i++) { + if (canAct(s) && s.phase === 'play' && seen[seen.length - 1] !== s.turnCount) { + seen.push(s.turnCount); + if (seen.length === 1) seen.teams = []; + (seen.teams ??= []).push(s.activeTeam); + selectWeapon(s, 'skip'); fire(s, {}); + } + stepMatch(s, 1 / 60); + } + const t = seen.teams ?? []; + return t.length >= 4 && t.every((v, i) => i === 0 || v !== t[i - 1]); + })()); + check('a team cycles through its worms rather than replaying one', (() => { + const s = mk(); + const used = new Set(); + for (let i = 0; i < 60 * 900 && used.size < 4; i++) { + if (canAct(s) && s.phase === 'play') { + if (s.activeTeam === 0) used.add(s.activeWorm); + selectWeapon(s, 'skip'); fire(s, {}); + } + stepMatch(s, 1 / 60); + if (s.over) break; + } + return used.size >= 3; + })()); +} + +// --------------------------------------------------------------------------- +section(`12. Scripted match soak (${MATCH_SOAK} matches)`); +// --------------------------------------------------------------------------- +{ + // A deliberately dumb scripted player: random legal weapon, rough aim at a + // random enemy, random power. It is not meant to play well — it is meant to + // reach every code path and prove the match always terminates. + function playMatch(seed, shape) { + const st = createMatch({ + seed, mapSeed: seed, shape, size: 'medium', wormsPerTeam: 4, + retreatSeconds: 0, // soaks do not need the retreat wait + teams: [ + { id: 'a', name: 'Red', color: 0xff0000, controller: 'ai' }, + { id: 'b', name: 'Blue', color: 0x0000ff, controller: 'ai' }, + ], + }); + const rng = mulberry32((seed ^ 0xbeef) >>> 0); + let simSeconds = 0, lastTurn = -1, nan = false; + const used = new Set(); + while (!st.over && simSeconds < 2400) { + if (canAct(st) && st.phase === 'play' && st.turnCount !== lastTurn) { + lastTurn = st.turnCount; + const me = activeWorm(st); + const foes = st.worms.filter((w) => !w.dead && w.team !== me.team); + const opts = availableWeapons(st) + .filter((e) => ['charge', 'instant', 'melee', 'place'].includes(e.weapon.kind)); + if (foes.length && opts.length) { + const target = foes[Math.floor(rng() * foes.length)]; + const angle = Math.atan2(target.y - me.y - 60, target.x - me.x); + const pick = opts[Math.floor(rng() * opts.length)]; + selectWeapon(st, pick.id); + if (fire(st, { angle, power: 0.5 + rng() * 0.5 })) used.add(pick.id); + else endTurn(st); + } else endTurn(st); + } + stepMatch(st, 1 / 60); + simSeconds += 1 / 60; + if (st.worms.some((w) => !Number.isFinite(w.x) || !Number.isFinite(w.y) || !Number.isFinite(w.health))) { + nan = true; break; + } + } + return { st, simSeconds, nan, used }; + } + + let terminated = 0, nans = 0, stalled = 0, totalTurns = 0; + const usedAll = new Set(); + let legal = true, healthOk = true; + for (let i = 0; i < MATCH_SOAK; i++) { + const shape = i % 2 ? 'cavern' : 'island'; + const r = playMatch((i * 7919 + 13) >>> 0, shape); + if (r.nan) { nans++; continue; } + if (r.st.over) terminated++; else stalled++; + totalTurns += r.st.turnCount; + for (const u of r.used) usedAll.add(u); + // The end state must be legal: at most one team left standing. + if (r.st.over && livingTeams(r.st).length > 1) legal = false; + if (r.st.worms.some((w) => w.health < 0 || w.health > RULES.HEALTH)) healthOk = false; + } + check('no scripted match produces NaN', nans === 0, `${nans}/${MATCH_SOAK}`); + check('every scripted match terminates', stalled === 0, `${stalled} stalled`); + check('every finished match has a legal end state', legal); + check('health stays inside [0, max] all match', healthOk); + check('matches take a sane number of turns', + totalTurns / MATCH_SOAK > 8 && totalTurns / MATCH_SOAK < 200, + `mean ${(totalTurns / MATCH_SOAK).toFixed(1)}`); + check('the soak exercises every Wave 1 weapon kind', + ['bazooka', 'grenade', 'cluster', 'shotgun', 'uzi', 'firepunch', 'dynamite', 'mine', 'hhg'] + .every((id) => usedAll.has(id)), + [...usedAll].join(',')); + + // Determinism across a whole match, which is what the AI soak and the + // campaign's pinned seeds depend on. + const a = playMatch(31337, 'island'); + const b = playMatch(31337, 'island'); + check('a whole match is deterministic', hashState(a.st) === hashState(b.st), + `${hashState(a.st)} vs ${hashState(b.st)}`); + check('a different seed diverges', hashState(playMatch(31338, 'island').st) !== hashState(a.st)); +} + +// --------------------------------------------------------------------------- +console.log(`\n[verify] ${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);