From c12371b654e37788d9104ca15060674cca88ebee Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 30 Jul 2026 19:07:16 -0600 Subject: [PATCH] =?UTF-8?q?feat(angrybirds):=20Waves=201-2=20=E2=80=94=20p?= =?UTF-8?q?layable=20core,=208=20birds,=20level=20bank,=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AngryBirdsLogic.js layers the game onto the Wave 0 solver: wood/stone/ice plus TNT with distinct density, friction and fragility; damage as a health model with crack stages rather than instant shattering; all 8 birds with one ability per shot; scoring, stars, win/lose, and a stepSim -> events[] contract the scene replays as FX. The load-bearing discovery is that damage must read approach velocity, not the solver's accumulated normal impulse. The accumulated value carries the static load of everything stacked above: measured 3.4e5 at the base of a 10-block stone tower, above the threshold that should shatter wood. Wiring damage to it makes tall towers quietly crush themselves at rest, and it reads as a level-design bug rather than a physics one. prestep now also publishes contact.impactImpulse — the momentum needed to stop the approach, zero at rest by construction. Resting fell to 2.4e4 against real hits of 1.9e5 to 2.7e6, and the whole material table was calibrated by measurement. AngryBirdsGame.js maps the 2400x1080 logical level through fixed sx()/sy() helpers at scale 0.78 rather than zooming the camera, which keeps HUD text in plain screen coordinates. Slingshot drag, ghost trails of previous shots, level select, star tracking and progress persistence are in. A follow-camera is deliberately deferred to Wave 5 — it is pure presentation and cannot be validated headlessly. Six starter levels ship as the Poached Eggs opening. All stand unaided, none damage themselves on load, and a greedy aim sweep clears every one. Registered as slug angrybirds, iconFrame 92, under Video Games. tools/verifyAngryBirds.js -> 114 checks green. Peggle (909) and Excitebike (620) still pass; Goo Tower's 2 failures are pre-existing and unrelated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M1wNRvLLtyfCDAET7oYouf --- assets/gamedata/angrybirds/level-001.json | 31 ++ assets/gamedata/angrybirds/level-002.json | 53 ++ assets/gamedata/angrybirds/level-003.json | 79 +++ assets/gamedata/angrybirds/level-004.json | 72 +++ assets/gamedata/angrybirds/level-005.json | 65 +++ assets/gamedata/angrybirds/level-006.json | 70 +++ assets/gamedata/angrybirds/levels.json | 50 ++ docs/angrybirds-build-plan.md | 58 ++- src/data/assetManifest.js | 5 + src/data/gamesRegistry.js | 1 + src/games/angrybirds/AngryBirdsGame.js | 599 ++++++++++++++++++++++ src/games/angrybirds/AngryBirdsLogic.js | 585 +++++++++++++++++++++ src/games/angrybirds/AngryBirdsPhysics.js | 17 +- src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 1 + tools/verifyAngryBirds.js | 411 +++++++++++++++ 17 files changed, 2078 insertions(+), 23 deletions(-) create mode 100644 assets/gamedata/angrybirds/level-001.json create mode 100644 assets/gamedata/angrybirds/level-002.json create mode 100644 assets/gamedata/angrybirds/level-003.json create mode 100644 assets/gamedata/angrybirds/level-004.json create mode 100644 assets/gamedata/angrybirds/level-005.json create mode 100644 assets/gamedata/angrybirds/level-006.json create mode 100644 assets/gamedata/angrybirds/levels.json create mode 100644 src/games/angrybirds/AngryBirdsGame.js create mode 100644 src/games/angrybirds/AngryBirdsLogic.js diff --git a/assets/gamedata/angrybirds/level-001.json b/assets/gamedata/angrybirds/level-001.json new file mode 100644 index 0000000..f7681bc --- /dev/null +++ b/assets/gamedata/angrybirds/level-001.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "level": 1, + "name": "Ground Level", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "red", + "red" + ], + "blocks": [], + "pigs": [ + { + "x": 1400, + "y": 878, + "r": 22 + } + ], + "stars": [ + 12000, + 20000, + 28000 + ] +} diff --git a/assets/gamedata/angrybirds/level-002.json b/assets/gamedata/angrybirds/level-002.json new file mode 100644 index 0000000..cca9d40 --- /dev/null +++ b/assets/gamedata/angrybirds/level-002.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "level": 2, + "name": "First House", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "red", + "red" + ], + "blocks": [ + { + "x": 1300, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1460, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1380, + "y": 778, + "w": 220, + "h": 24, + "material": "wood" + } + ], + "pigs": [ + { + "x": 1380, + "y": 878, + "r": 22 + } + ], + "stars": [ + 18000, + 27000, + 35000 + ] +} diff --git a/assets/gamedata/angrybirds/level-003.json b/assets/gamedata/angrybirds/level-003.json new file mode 100644 index 0000000..1b4735e --- /dev/null +++ b/assets/gamedata/angrybirds/level-003.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "level": 3, + "name": "Two Up", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "red", + "red" + ], + "blocks": [ + { + "x": 1280, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1460, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1370, + "y": 778, + "w": 240, + "h": 24, + "material": "wood" + }, + { + "x": 1310, + "y": 721, + "w": 24, + "h": 90, + "material": "wood" + }, + { + "x": 1430, + "y": 721, + "w": 24, + "h": 90, + "material": "wood" + }, + { + "x": 1370, + "y": 664, + "w": 180, + "h": 24, + "material": "wood" + } + ], + "pigs": [ + { + "x": 1370, + "y": 878, + "r": 22 + }, + { + "x": 1370, + "y": 744, + "r": 22 + } + ], + "stars": [ + 26000, + 38000, + 48000 + ] +} diff --git a/assets/gamedata/angrybirds/level-004.json b/assets/gamedata/angrybirds/level-004.json new file mode 100644 index 0000000..8cb59eb --- /dev/null +++ b/assets/gamedata/angrybirds/level-004.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "level": 4, + "name": "Thin Ice", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "red", + "red" + ], + "blocks": [ + { + "x": 1250, + "y": 840, + "w": 28, + "h": 120, + "material": "ice" + }, + { + "x": 1400, + "y": 840, + "w": 28, + "h": 120, + "material": "ice" + }, + { + "x": 1325, + "y": 768, + "w": 200, + "h": 24, + "material": "ice" + }, + { + "x": 1550, + "y": 840, + "w": 28, + "h": 120, + "material": "ice" + }, + { + "x": 1480, + "y": 768, + "w": 180, + "h": 24, + "material": "ice" + } + ], + "pigs": [ + { + "x": 1325, + "y": 878, + "r": 22 + }, + { + "x": 1480, + "y": 878, + "r": 22 + } + ], + "stars": [ + 30000, + 42000, + 54000 + ] +} diff --git a/assets/gamedata/angrybirds/level-005.json b/assets/gamedata/angrybirds/level-005.json new file mode 100644 index 0000000..afb09bb --- /dev/null +++ b/assets/gamedata/angrybirds/level-005.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "level": 5, + "name": "Hard Going", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "chuck", + "chuck" + ], + "blocks": [ + { + "x": 1300, + "y": 840, + "w": 34, + "h": 120, + "material": "stone" + }, + { + "x": 1480, + "y": 840, + "w": 34, + "h": 120, + "material": "stone" + }, + { + "x": 1390, + "y": 765, + "w": 240, + "h": 30, + "material": "stone" + }, + { + "x": 1390, + "y": 705, + "w": 24, + "h": 90, + "material": "wood" + } + ], + "pigs": [ + { + "x": 1390, + "y": 878, + "r": 22 + }, + { + "x": 1390, + "y": 730, + "r": 20 + } + ], + "stars": [ + 32000, + 45000, + 58000 + ] +} diff --git a/assets/gamedata/angrybirds/level-006.json b/assets/gamedata/angrybirds/level-006.json new file mode 100644 index 0000000..b9c136e --- /dev/null +++ b/assets/gamedata/angrybirds/level-006.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "level": 6, + "name": "Powder Keg", + "episode": 1, + "world": { + "w": 2400, + "h": 1080, + "groundY": 900, + "slingX": 300, + "slingY": 750 + }, + "birds": [ + "red", + "chuck", + "blue" + ], + "blocks": [ + { + "x": 1280, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1520, + "y": 845, + "w": 24, + "h": 110, + "material": "wood" + }, + { + "x": 1400, + "y": 775, + "w": 280, + "h": 30, + "material": "stone" + }, + { + "x": 1400, + "y": 860, + "w": 70, + "h": 80, + "material": "tnt" + } + ], + "pigs": [ + { + "x": 1300, + "y": 880, + "r": 20 + }, + { + "x": 1500, + "y": 880, + "r": 20 + }, + { + "x": 1400, + "y": 706, + "r": 22 + } + ], + "stars": [ + 40000, + 55000, + 70000 + ] +} diff --git a/assets/gamedata/angrybirds/levels.json b/assets/gamedata/angrybirds/levels.json new file mode 100644 index 0000000..e7c6b10 --- /dev/null +++ b/assets/gamedata/angrybirds/levels.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "episodes": [ + { + "id": 1, + "name": "Poached Eggs", + "blurb": "They took the eggs. Get them back.", + "from": 1, + "to": 6 + } + ], + "levels": [ + { + "level": 1, + "name": "Ground Level", + "episode": 1, + "file": "level-001.json" + }, + { + "level": 2, + "name": "First House", + "episode": 1, + "file": "level-002.json" + }, + { + "level": 3, + "name": "Two Up", + "episode": 1, + "file": "level-003.json" + }, + { + "level": 4, + "name": "Thin Ice", + "episode": 1, + "file": "level-004.json" + }, + { + "level": 5, + "name": "Hard Going", + "episode": 1, + "file": "level-005.json" + }, + { + "level": 6, + "name": "Powder Keg", + "episode": 1, + "file": "level-006.json" + } + ] +} diff --git a/docs/angrybirds-build-plan.md b/docs/angrybirds-build-plan.md index b011a80..417affc 100644 --- a/docs/angrybirds-build-plan.md +++ b/docs/angrybirds-build-plan.md @@ -4,9 +4,13 @@ Living plan doc. Survives context clears: **read this file first**, pick the nex ## Status -**Wave 0 complete 2026-07-30 — the gate is passed.** `node tools/verifyAngryBirds.js` → **51 checks green**. A 10-box tower settles in 0.68s, sleeps, drifts <1px sideways, and tilts 6.1e-3 rad (0.35°, invisible). A 15-box pyramid settles too. The solver runs a 6-second, 41-body shot in **254ms** headless. +**Waves 0 and 1 complete 2026-07-30. Playable end to end, never browser-tested.** `node tools/verifyAngryBirds.js` → **114 checks green**. -Next: Wave 1, the playable core. +The solver holds: a 10-box tower settles in 0.68s, sleeps, drifts <1px sideways and tilts 0.35°; a 15-box pyramid settles too; a 6-second 41-body shot runs in 254ms headless. The rules layer is in: 3 materials + TNT, health-model damage with crack stages, all 8 birds, scoring/stars/win/lose, and a 6-level starter bank that a greedy aim sweep clears. + +Registered and wired (slug `angrybirds`, iconFrame **92**) — it appears under **Video Games** and is reachable from the menu. + +Next: Wave 2 is largely done inside Wave 1 (all 8 birds and TNT landed early). The real remaining work is **Wave 3** (editor + generator + `AngryBirdsAuto`), **Wave 4** (grow 6 levels → 63), and **Wave 5** (artwork JSON, tutorial, icon art). ## Decisions taken up front @@ -24,16 +28,16 @@ Next: Wave 1, the playable core. | File | Role | State | |---|---|---| -| `src/games/angrybirds/AngryBirdsPhysics.js` | Rigid-body solver. Zero imports. | 🔨 Wave 0 | -| `src/games/angrybirds/AngryBirdsLogic.js` | Materials, damage, birds, scoring, `stepSim`. Zero imports. | ⬜ Wave 1 | -| `src/games/angrybirds/AngryBirdsGame.js` | Phaser scene, key `AngryBirdsGame`. | ⬜ Wave 1 | +| `src/games/angrybirds/AngryBirdsPhysics.js` | Rigid-body solver. Zero imports. | ✅ Wave 0 | +| `src/games/angrybirds/AngryBirdsLogic.js` | Materials, damage, birds, scoring, `stepSim`. Only imports Physics. | ✅ Wave 1 | +| `src/games/angrybirds/AngryBirdsGame.js` | Phaser scene, key `AngryBirdsGame`. | ✅ Wave 1 | | `src/games/angrybirds/AngryBirdsArt.js` | Procedural textures + artwork-JSON resolution. | ⬜ Wave 5 | | `src/games/angrybirds/AngryBirdsEditor.js` | Level editor, key `AngryBirdsEditor`. | ⬜ Wave 3 | | `src/games/angrybirds/AngryBirdsAuto.js` | Reference bot / winnability gate. In `src/` so the editor button and the verifier run the same gate. | ⬜ Wave 3 | | `src/games/angrybirds/tutorial.md` | Tutorial copy. | ⬜ Wave 5 | | `tools/genAngryBirds.js` | Level-bank generator; **measures** star thresholds. | ⬜ Wave 3 | -| `tools/verifyAngryBirds.js` | Node harness. | 🔨 Wave 0 | -| `assets/gamedata/angrybirds/` | `levels.json` manifest + 63 level files. | ⬜ Wave 4 | +| `tools/verifyAngryBirds.js` | Node harness. | ✅ 114 checks | +| `assets/gamedata/angrybirds/` | `levels.json` manifest + level files. | 🔨 6 of 63 | | `data/angrybirds-artwork.json` | Drop-in sprite map. | ⬜ Wave 5 | Wiring touchpoints: `src/data/gamesRegistry.js`, `src/main.js` (import + scene array, both scenes), `src/scenes/GameRoomScene.js:26` (`slugDispatch`), `src/data/assetManifest.js`, `src/scenes/PreloadScene.js` (manifest JSON + `?angrybirds-editor=1` entrance). @@ -74,30 +78,42 @@ Wiring touchpoints: `src/data/gamesRegistry.js`, `src/main.js` (import + scene a ### Baselines ``` -node tools/verifyAngryBirds.js -> 51 checks green +node tools/verifyAngryBirds.js -> 114 checks green node tools/verifyAngryBirds.js --seeds=40 -> slower monkey soak, still green ``` --- -## Wave 1 — playable core — ⬜ NEXT +## Wave 1 — playable core — ✅ DONE 2026-07-30 -- [ ] Materials: wood / stone / ice with distinct density, friction, restitution, hp, damage threshold -- [ ] Damage as a **health model** — impulse above threshold subtracts hp; 2/3 and 1/3 crossings emit crack stages -- [ ] Pigs take damage from debris, not just direct hits -- [ ] Slingshot: drag, clamp to max draw, release -- [ ] Win/lose evaluated only once the world is settled or `MAX_SHOT_TIME` expires -- [ ] `stepSim(state, dt) → events[]` contract -- [ ] Phaser scene with sim-space→screen-space mapping, camera pan/zoom -- [ ] Trajectory memory (dotted trail of previous shots) +- [x] Materials: wood / stone / ice with distinct density, friction, restitution, hp, damage threshold +- [x] Damage as a **health model** — impact above threshold subtracts hp; 2/3 and 1/3 crossings emit crack stages +- [x] Pigs take damage from debris, not just direct hits +- [x] Slingshot: drag, clamp to max draw, release +- [x] Win/lose evaluated only once the world is settled or `MAX_SHOT_TIME` expires +- [x] `stepSim(state, dt) → events[]` contract +- [x] Phaser scene with sim-space→screen-space mapping +- [x] Trajectory memory (dotted trail of previous shots) +- [x] Level select, progress persistence, win/lose panel +- [x] 6-level starter bank, all stable and sweep-clearable +- [ ] Camera pan/zoom — deliberately deferred, see finding 4 + +### Wave 1 findings + +1. **The damage signal must be approach velocity, not the solver's accumulated normal impulse — and this one is a trap.** The accumulated impulse includes the static load a contact carries, which grows with stack height. Measured: the base contact of a 10-block stone tower sits at **3.4e5**, which is *above* the threshold that should shatter wood (1.5e5) and level with stone's (3.2e5). Wiring damage to it makes tall towers quietly crush themselves while nothing is happening — and it looks like a level-design bug, not a physics one. `Physics.prestep` now also publishes `contact.impactImpulse` = the momentum needed to stop the approach, which is zero at rest by construction. Resting dropped to **2.4e4**, real hits run 1.9e5 (feeble) to 2.7e6 (full draw). `verifyAngryBirds.js` pins both ends. +2. **Calibrate thresholds by measuring, never by guessing.** The whole material table was set from a one-off script that fired birds at 400/800/1200/1600/2000 px/s and printed peak impact impulses. Re-run it after any change to density, gravity or `SUBSTEP_DT` — all three move the impulse scale. +3. **Levels must be authored already at rest.** `createState` drops blocks exactly where the file says; if the author's structure was mid-collapse when exported, the player sees a different level. The verifier asserts every level stands unaided for 4s with zero self-damage. Wave 3's editor gets a **Settle** button for this. +4. **No camera pan/zoom yet, on purpose.** The scene maps the 2400×1080 logical level to the canvas through fixed `sx()`/`sy()` helpers at scale 0.78 (the PeggleGame idiom) rather than zooming the camera, which keeps HUD text in plain screen coordinates. The whole level is visible at once. A follow-camera is the more faithful feel but is pure presentation and cannot be validated headlessly — it belongs in Wave 5 alongside a real browser pass. +5. **Bubbles has to be rebuilt, not resized.** The solver derives mass and inertia at creation and assumes they never change, so inflating means swapping in a new, larger, lower-density body and transferring velocity. Mutating `radius` in place would leave mass properties describing the old bird. +6. **Spent birds are removed when a shot resolves.** Left in, a dead bird props structures up forever and the next shot plays against a level the author never built. --- -## Wave 2 — the full roster, TNT, scoring — ⬜ +## Wave 2 — the full roster, TNT, scoring — ✅ DONE 2026-07-30 (landed inside Wave 1) -- [ ] 8 birds, abilities fire on tap mid-flight -- [ ] TNT blocks detonate when damaged -- [ ] Scoring: 5,000/pig + material destruction points + 10,000 per unused bird +- [x] 8 birds, abilities fire on tap mid-flight, one use per shot +- [x] TNT blocks detonate when damaged (removed before detonating, so they can't recurse into themselves) +- [x] Scoring: 5,000/pig + material destruction points + 10,000 per unused bird --- diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 40834c6..750919e 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -69,6 +69,11 @@ export const MANIFEST = { excitebike: [ (scene) => musicFrom(scene, 'nintendo-music'), ], + + // Angry Birds draws every block, pig and bird procedurally (see the MAT_COLORS + // and BIRD_COLORS tables in AngryBirdsGame.js), so there is nothing to fetch + // yet. Wave 5 adds data/angrybirds-artwork.json here as a drop-in sprite map. + angrybirds: [], forbiddenisland: [ // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded // frame 2i+1 (see IslandData.TILE_FRAME_ROW). diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 0904458..2da56f1 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -119,3 +119,4 @@ registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 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: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 }); +registerGame({ slug: 'angrybirds', name: 'Angry Birds', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 }); diff --git a/src/games/angrybirds/AngryBirdsGame.js b/src/games/angrybirds/AngryBirdsGame.js new file mode 100644 index 0000000..6930cbb --- /dev/null +++ b/src/games/angrybirds/AngryBirdsGame.js @@ -0,0 +1,599 @@ +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { Tooltip } from '../../ui/Tooltip.js'; +import { SFX, playSound } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + TUNING, MATERIALS, BIRDS, + createState, stepSim, launch, useAbility, clampDraw, slingAnchor, + currentBird, birdsRemaining, starsFor, +} from './AngryBirdsLogic.js'; + +// Logical level space is 2400x1080; the canvas is 1920x1080. Everything is +// drawn through sx()/sy() at a fixed scale rather than by zooming the camera, +// exactly as PeggleGame maps its 1200x900 board — that keeps HUD text in plain +// screen coordinates instead of fighting camera zoom. Sim space is never +// screen space. +const SCALE = 0.78; +const OFF_X = 24; +const OFF_Y = 158; + +const GAMEDATA = 'assets/gamedata/angrybirds'; +const SIM_STEP = 1 / 60; + +const MAT_COLORS = { + wood: { fill: 0xc8873c, line: 0x8b5a22 }, + stone: { fill: 0x9ba3a8, line: 0x656d72 }, + ice: { fill: 0x9bd8f0, line: 0x59a6cc }, + tnt: { fill: 0xd03a2a, line: 0x8c1d12 }, +}; + +const BIRD_COLORS = { + red: 0xd6332b, chuck: 0xf5c518, blue: 0x35a7dd, bomb: 0x3b3b42, + matilda: 0xf2f0e6, terence: 0x8f2b23, hal: 0x4aa64a, bubbles: 0xf07f22, +}; + +export default class AngryBirdsGame extends Phaser.Scene { + constructor() { super('AngryBirdsGame'); } + + init(data) { + this.gameDef = data?.game ?? { slug: 'angrybirds', name: 'Angry Birds' }; + this.testLevel = data?.testLevel ?? null; + this.returnToEditor = !!data?.returnToEditor; + this.state = null; + this.manifest = null; + this.levelCache = new Map(); + this.levelsCompleted = 0; + this.canPersist = true; + this.accum = 0; + } + + sx(x) { return x * SCALE + OFF_X; } + sy(y) { return y * SCALE + OFF_Y; } + wx(x) { return (x - OFF_X) / SCALE; } + wy(y) { return (y - OFF_Y) / SCALE; } + + async create() { + this.cameras.main.setBackgroundColor('#7ec8e8'); + + if (this.testLevel) { + this.startLevel(this.testLevel); + return; + } + + const cached = this.cache.json.get('angrybirds-levels'); + this.manifest = cached ?? await fetch(`${GAMEDATA}/levels.json`).then((r) => r.json()).catch(() => null); + + try { + const res = await api.get('/puzzles/angrybirds/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + // Offline or unavailable — play in-session without persisting. + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.showSelect(); + } + + // ── Level select ────────────────────────────────────────────────────────── + + clearScreen() { + if (this.tooltip) { this.tooltip.destroy?.(); this.tooltip = null; } + this.children.removeAll(); + this.input.removeAllListeners(); + } + + showSelect() { + this.clearScreen(); + this.state = null; + this.screen = 'select'; + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x7ec8e8); + this.drawHills(this.add.graphics()); + + this.add.text(GAME_WIDTH / 2, 70, 'ANGRY BIRDS', { + fontFamily: '"Righteous", sans-serif', fontSize: '58px', color: '#ffffff', + stroke: '#1d2a33', strokeThickness: 8, + }).setOrigin(0.5); + + if (!this.manifest) { + this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Could not load levels.', { + fontFamily: 'sans-serif', fontSize: '28px', color: '#ffffff', + }).setOrigin(0.5); + new Button(this, 120, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'), { width: 160 }); + return; + } + + this.tooltip = new Tooltip(this); + + for (const ep of this.manifest.episodes ?? []) { + this.add.text(GAME_WIDTH / 2, 150, ep.name, { + fontFamily: '"Righteous", sans-serif', fontSize: '34px', color: '#ffe9a8', + stroke: '#1d2a33', strokeThickness: 5, + }).setOrigin(0.5); + this.add.text(GAME_WIDTH / 2, 190, ep.blurb ?? '', { + fontFamily: 'sans-serif', fontSize: '20px', color: '#ffffff', + }).setOrigin(0.5); + } + + const levels = this.manifest.levels ?? []; + const COLS = 6; + const TW = 190; + const TH = 150; + const startX = GAME_WIDTH / 2 - ((Math.min(COLS, levels.length) - 1) * (TW + 20)) / 2; + levels.forEach((m, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = startX + col * (TW + 20); + const y = 320 + row * (TH + 24); + const unlocked = m.level <= this.levelsCompleted + 1; + this.makeLevelTile(x, y, m, unlocked, TW, TH); + }); + + new Button(this, 120, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'), { width: 160 }); + if (this.levelsCompleted > 0) { + new Button(this, GAME_WIDTH - 140, GAME_HEIGHT - 60, 'Reset', () => this.resetProgress(), + { width: 160, variant: 'ghost' }); + } + } + + makeLevelTile(x, y, m, unlocked, w, h) { + const best = this.bestFor(m.level); + const bg = this.add.rectangle(x, y, w, h, unlocked ? 0xffffff : 0x9aa6ad, unlocked ? 0.95 : 0.6) + .setStrokeStyle(4, unlocked ? 0x1d2a33 : 0x5c666d); + this.add.text(x, y - 40, `${m.level}`, { + fontFamily: '"Righteous", sans-serif', fontSize: '40px', + color: unlocked ? '#1d2a33' : '#e8eef1', + }).setOrigin(0.5); + this.add.text(x, y + 2, m.name, { + fontFamily: 'sans-serif', fontSize: '17px', + color: unlocked ? '#3a4a55' : '#e8eef1', + wordWrap: { width: w - 20 }, align: 'center', + }).setOrigin(0.5); + + // Stars earned so far. + for (let s = 0; s < 3; s += 1) { + this.add.text(x - 30 + s * 30, y + 46, '★', { + fontFamily: 'sans-serif', fontSize: '26px', + color: s < best.stars ? '#ffc93c' : (unlocked ? '#c9d3d9' : '#8b969c'), + }).setOrigin(0.5); + } + + if (!unlocked) { + this.add.text(x, y + 46, '🔒', { fontSize: '22px' }).setOrigin(0.5); + return; + } + + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => this.openLevel(m)); + this.tooltip?.attachTo(bg, () => ({ + title: `${m.level}. ${m.name}`, + lines: [ + best.score ? `Best: ${best.score.toLocaleString()}` : 'Not yet cleared', + best.stars ? `${best.stars} of 3 stars` : 'No stars yet', + ], + })); + } + + async openLevel(m) { + const def = await this.fetchLevel(m); + if (def) this.startLevel(def); + } + + async fetchLevel(m) { + if (this.levelCache.has(m.level)) return this.levelCache.get(m.level); + try { + const json = await fetch(`${GAMEDATA}/${m.file}`).then((r) => r.json()); + this.levelCache.set(m.level, json); + return json; + } catch (_) { return null; } + } + + // ── Progress ────────────────────────────────────────────────────────────── + + bestFor(level) { + try { + return { + score: Number(localStorage.getItem(`ab-best-${level}`) || 0), + stars: Number(localStorage.getItem(`ab-stars-${level}`) || 0), + }; + } catch (_) { return { score: 0, stars: 0 }; } + } + + recordBest(level, score, stars) { + try { + const prev = this.bestFor(level); + if (score > prev.score) localStorage.setItem(`ab-best-${level}`, String(score)); + if (stars > prev.stars) localStorage.setItem(`ab-stars-${level}`, String(stars)); + } catch (_) { /* storage full or blocked */ } + } + + async resetProgress() { + try { await api.post('/puzzles/angrybirds/reset', {}); } catch (_) { /* offline */ } + try { + for (const m of this.manifest?.levels ?? []) { + localStorage.removeItem(`ab-best-${m.level}`); + localStorage.removeItem(`ab-stars-${m.level}`); + } + } catch (_) { /* ignore */ } + this.levelsCompleted = 0; + this.showSelect(); + } + + // ── Play ────────────────────────────────────────────────────────────────── + + startLevel(def) { + this.clearScreen(); + this.screen = 'play'; + this.state = createState(def); + this.accum = 0; + this.dragging = false; + this.dragPt = null; + + this.bgGfx = this.add.graphics().setDepth(0); + this.worldGfx = this.add.graphics().setDepth(10); + this.aimGfx = this.add.graphics().setDepth(20); + this.drawBackground(); + + this.hud = this.add.container(0, 0).setDepth(40); + this.scoreText = this.add.text(GAME_WIDTH - 40, 32, '0', { + fontFamily: '"Righteous", sans-serif', fontSize: '40px', color: '#ffffff', + stroke: '#1d2a33', strokeThickness: 6, + }).setOrigin(1, 0); + this.levelText = this.add.text(GAME_WIDTH / 2, 32, `${def.level}. ${def.name}`, { + fontFamily: 'sans-serif', fontSize: '24px', color: '#ffffff', + stroke: '#1d2a33', strokeThickness: 4, + }).setOrigin(0.5, 0); + this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 34, '', { + fontFamily: 'sans-serif', fontSize: '20px', color: '#ffffff', + stroke: '#1d2a33', strokeThickness: 4, + }).setOrigin(0.5).setDepth(40); + this.hud.add([this.scoreText, this.levelText]); + + new Button(this, 110, 44, 'Back', () => this.exitLevel(), { width: 150, height: 46 }); + new Button(this, 280, 44, 'Retry', () => this.startLevel(this.state.level), { width: 150, height: 46, variant: 'ghost' }); + + this.input.on('pointerdown', (p) => this.onDown(p)); + this.input.on('pointermove', (p) => this.onMove(p)); + this.input.on('pointerup', () => this.onUp()); + } + + exitLevel() { + if (this.returnToEditor) this.scene.start('AngryBirdsEditor', { resume: true }); + else this.showSelect(); + } + + onDown(p) { + if (!this.state) return; + if (this.state.phase === 'flight') { useAbility(this.state); return; } + if (this.state.phase !== 'aim') return; + const a = slingAnchor(this.state); + const d = Math.hypot(this.wx(p.x) - a.x, this.wy(p.y) - a.y); + // Generous grab radius: the band, not the bird, is the target. + if (d > 260) return; + this.dragging = true; + this.dragPt = clampDraw(this.state, this.wx(p.x), this.wy(p.y)); + } + + onMove(p) { + if (!this.dragging || !this.state) return; + this.dragPt = clampDraw(this.state, this.wx(p.x), this.wy(p.y)); + } + + onUp() { + if (!this.dragging || !this.state) return; + this.dragging = false; + const pt = this.dragPt; + this.dragPt = null; + if (!pt) return; + if (launch(this.state, pt.x, pt.y)) playSound(this, SFX.EIGHTBIT_JUMP); + } + + update(time, delta) { + if (this.screen !== 'play' || !this.state) return; + this.accum = Math.min(this.accum + delta / 1000, 0.25); + while (this.accum >= SIM_STEP) { + this.accum -= SIM_STEP; + const events = stepSim(this.state, SIM_STEP); + if (events.length) this.processEvents(events); + } + this.render(); + } + + processEvents(events) { + for (const e of events) { + switch (e.t) { + case 'blockDestroyed': + playSound(this, SFX.EIGHTBIT_EXPLODE); + this.puff(e.x, e.y, MAT_COLORS[e.material]?.fill ?? 0xffffff); + break; + case 'pigKilled': + playSound(this, SFX.EIGHTBIT_EXPLODE_2); + this.puff(e.x, e.y, 0x86c232); + break; + case 'explosion': + playSound(this, SFX.SCIFI_EXPLODE); + this.blast(e.x, e.y, e.radius); + break; + case 'won': + playSound(this, SFX.VICTORY_SHORT); + this.onFinished(true, e.stars); + break; + case 'lost': + this.onFinished(false, 0); + break; + default: break; + } + } + } + + puff(x, y, color) { + const c = this.add.circle(this.sx(x), this.sy(y), 6, color, 0.9).setDepth(30); + this.tweens.add({ + targets: c, scale: 4, alpha: 0, duration: 340, ease: 'Cubic.Out', + onComplete: () => c.destroy(), + }); + } + + blast(x, y, radius) { + const c = this.add.circle(this.sx(x), this.sy(y), radius * SCALE * 0.3, 0xffd27f, 0.7).setDepth(30); + this.tweens.add({ + targets: c, scale: 3.4, alpha: 0, duration: 420, ease: 'Cubic.Out', + onComplete: () => c.destroy(), + }); + this.cameras.main.shake(220, 0.006); + } + + async onFinished(won, stars) { + const st = this.state; + const level = st.level.level; + if (won) this.recordBest(level, st.score, stars); + + // An editor test-play is not a real run: it must not advance saved + // progress or write a match record. + if (won && this.canPersist && !this.returnToEditor) { + try { + const res = await api.post('/puzzles/angrybirds/complete', { level }); + if (res?.levelsCompleted != null) { + this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); + } + } catch (_) { /* best effort */ } + api.post('/history/single-player', { + slug: 'angrybirds', + score: Math.min(st.score, 100000), + opponentScores: [], + result: 'win', + }).catch(() => { /* best effort */ }); + } + if (won && level > this.levelsCompleted) this.levelsCompleted = level; + + this.showResult(won, stars); + } + + showResult(won, stars) { + const panel = this.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2).setDepth(60); + panel.add(this.add.rectangle(0, 0, 620, 380, 0x11212b, 0.94).setStrokeStyle(5, 0xffc93c)); + panel.add(this.add.text(0, -128, won ? 'LEVEL CLEARED' : 'OUT OF BIRDS', { + fontFamily: '"Righteous", sans-serif', fontSize: '42px', + color: won ? '#ffc93c' : '#ff8a7a', + }).setOrigin(0.5)); + + if (won) { + for (let s = 0; s < 3; s += 1) { + panel.add(this.add.text(-90 + s * 90, -50, '★', { + fontFamily: 'sans-serif', fontSize: '64px', + color: s < stars ? '#ffc93c' : '#3c4b56', + }).setOrigin(0.5)); + } + } + panel.add(this.add.text(0, 30, `Score ${this.state.score.toLocaleString()}`, { + fontFamily: 'sans-serif', fontSize: '30px', color: '#ffffff', + }).setOrigin(0.5)); + + const nextEntry = (this.manifest?.levels ?? []).find((m) => m.level === this.state.level.level + 1); + const retry = new Button(this, -150, 120, 'Retry', () => { + panel.destroy(); + this.startLevel(this.state.level); + }, { width: 230, height: 56 }); + panel.add(retry); + + if (won && nextEntry) { + const next = new Button(this, 150, 120, 'Next', async () => { + panel.destroy(); + const def = await this.fetchLevel(nextEntry); + if (def) this.startLevel(def); else this.showSelect(); + }, { width: 230, height: 56 }); + panel.add(next); + } else { + const back = new Button(this, 150, 120, 'Levels', () => { panel.destroy(); this.showSelect(); }, + { width: 230, height: 56, variant: 'ghost' }); + panel.add(back); + } + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + drawHills(g) { + g.fillStyle(0x4f9d3a, 1); + g.fillEllipse(GAME_WIDTH * 0.2, GAME_HEIGHT + 60, 900, 340); + g.fillEllipse(GAME_WIDTH * 0.75, GAME_HEIGHT + 90, 1100, 420); + g.fillStyle(0x3f8c2e, 1); + g.fillRect(0, GAME_HEIGHT - 40, GAME_WIDTH, 40); + } + + drawBackground() { + const g = this.bgGfx; + g.clear(); + g.fillStyle(0x7ec8e8, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + // Distant hills behind the play line. + g.fillStyle(0x9fd6a0, 1); + g.fillEllipse(GAME_WIDTH * 0.25, this.sy(this.state.groundY) + 40, 1200, 420); + g.fillEllipse(GAME_WIDTH * 0.8, this.sy(this.state.groundY) + 60, 1500, 500); + // Ground. + const gy = this.sy(this.state.groundY); + g.fillStyle(0x6fbf46, 1); + g.fillRect(0, gy, GAME_WIDTH, GAME_HEIGHT - gy); + g.fillStyle(0x8b5a2b, 1); + g.fillRect(0, gy + 26, GAME_WIDTH, GAME_HEIGHT - gy - 26); + g.lineStyle(4, 0x4d8c2f, 1); + g.lineBetween(0, gy, GAME_WIDTH, gy); + } + + render() { + const st = this.state; + const g = this.worldGfx; + g.clear(); + + // Blocks. + for (const [id, rec] of st.blocks) { + const b = st.world.byId.get(id); + if (!b) continue; + const col = MAT_COLORS[rec.material] ?? MAT_COLORS.wood; + const pts = b.wverts.map(([x, y]) => new Phaser.Math.Vector2(this.sx(x), this.sy(y))); + g.fillStyle(col.fill, rec.material === 'ice' ? 0.72 : 1); + g.lineStyle(3, col.line, 1); + g.beginPath(); + g.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i].x, pts[i].y); + g.closePath(); + g.fillPath(); + g.strokePath(); + + // Damage reads as cracks across the face, darkening as hp falls. + const f = rec.hp / rec.maxHp; + if (f <= 2 / 3) { + const stage = f > 1 / 3 ? 1 : 2; + g.lineStyle(2, 0x2b1c10, 0.55); + const cx = this.sx(b.x); + const cy = this.sy(b.y); + const r = Math.min(rec.w, rec.h) * SCALE * 0.42; + for (let i = 0; i < stage * 2; i += 1) { + const a = (i / (stage * 2)) * Math.PI * 2 + b.angle; + g.lineBetween(cx, cy, cx + Math.cos(a) * r, cy + Math.sin(a) * r); + } + } + if (rec.material === 'tnt') { + g.fillStyle(0xffe9a8, 1); + g.fillRect(this.sx(b.x) - 14, this.sy(b.y) - 5, 28, 10); + } + } + + // Pigs. + for (const [id, rec] of st.pigs) { + const b = st.world.byId.get(id); + if (!b) continue; + const x = this.sx(b.x); + const y = this.sy(b.y); + const r = rec.r * SCALE; + const hurt = rec.hp / rec.maxHp; + g.fillStyle(hurt > 2 / 3 ? 0x86c232 : hurt > 1 / 3 ? 0x9ec24a : 0xb6b84a, 1); + g.fillCircle(x, y, r); + g.lineStyle(3, 0x4c7a1c, 1); + g.strokeCircle(x, y, r); + g.fillStyle(0xffffff, 1); + g.fillCircle(x - r * 0.3, y - r * 0.25, r * 0.22); + g.fillCircle(x + r * 0.3, y - r * 0.25, r * 0.22); + g.fillStyle(0x1d2a33, 1); + g.fillCircle(x - r * 0.26, y - r * 0.25, r * 0.1); + g.fillCircle(x + r * 0.34, y - r * 0.25, r * 0.1); + g.fillStyle(0x6ba428, 1); + g.fillCircle(x, y + r * 0.18, r * 0.32); + g.fillStyle(0x3f6b16, 1); + g.fillCircle(x - r * 0.12, y + r * 0.18, r * 0.08); + g.fillCircle(x + r * 0.12, y + r * 0.18, r * 0.08); + } + + // Slingshot. + const a = slingAnchor(st); + const ax = this.sx(a.x); + const ay = this.sy(a.y); + const groundY = this.sy(st.groundY); + g.lineStyle(14, 0x6b4423, 1); + g.lineBetween(ax, ay + 10, ax, groundY); + g.lineBetween(ax, ay + 12, ax - 18, ay - 14); + g.lineBetween(ax, ay + 12, ax + 18, ay - 14); + + // Birds in flight. + for (const id of st.activeBirds) { + const b = st.world.byId.get(id); + if (!b) continue; + this.drawBird(g, b, this.sx(b.x), this.sy(b.y), b.radius * SCALE); + } + + this.renderAim(); + this.scoreText?.setText(st.score.toLocaleString()); + this.updateHint(); + } + + drawBird(g, body, x, y, r) { + const def = BIRDS[body.userData?.bird] ?? BIRDS.red; + g.fillStyle(BIRD_COLORS[def.id] ?? 0xd6332b, 1); + g.fillCircle(x, y, r); + g.lineStyle(3, 0x6b1a15, 1); + g.strokeCircle(x, y, r); + g.fillStyle(0xffffff, 1); + g.fillCircle(x + r * 0.18, y - r * 0.28, r * 0.3); + g.fillStyle(0x1d2a33, 1); + g.fillCircle(x + r * 0.26, y - r * 0.28, r * 0.14); + g.fillStyle(0xf5a623, 1); + g.fillTriangle(x + r * 0.55, y, x + r * 1.15, y - r * 0.16, x + r * 0.55, y + r * 0.3); + } + + renderAim() { + const g = this.aimGfx; + g.clear(); + const st = this.state; + const a = slingAnchor(st); + + // Ghost trails of previous shots. Without this, aiming is guesswork + // instead of iteration — it is how the original teaches. + g.fillStyle(0xffffff, 0.4); + for (const trail of st.trails) { + for (let i = 0; i < trail.length; i += 6) { + g.fillCircle(this.sx(trail[i].x), this.sy(trail[i].y), 3); + } + } + + if (st.phase === 'aim') { + const def = currentBird(st); + const pt = this.dragPt; + const bx = pt ? this.sx(pt.x) : this.sx(a.x); + const by = pt ? this.sy(pt.y) : this.sy(a.y) - 8; + if (def) { + // Band, drawn behind the waiting bird. + g.lineStyle(8, 0x3a2416, 1); + g.lineBetween(this.sx(a.x) - 18, this.sy(a.y) - 14, bx, by); + g.lineBetween(this.sx(a.x) + 18, this.sy(a.y) - 14, bx, by); + this.drawBird(g, { userData: { bird: def.id } }, bx, by, def.r * SCALE); + } + // Queue of waiting birds beside the sling. + for (let i = st.birdIndex + 1; i < st.birdQueue.length; i += 1) { + const d = BIRDS[st.birdQueue[i]] ?? BIRDS.red; + const qx = this.sx(a.x) - 70 - (i - st.birdIndex - 1) * 46; + this.drawBird(g, { userData: { bird: d.id } }, qx, this.sy(st.groundY) - d.r * SCALE, d.r * SCALE * 0.85); + } + } + } + + updateHint() { + const st = this.state; + if (!this.hintText) return; + if (st.phase === 'aim') { + const def = currentBird(st); + const left = birdsRemaining(st); + this.hintText.setText(def + ? `${def.name} — ${def.blurb} (${left} left)` + : ''); + } else if (st.phase === 'flight' && !st.abilityUsed) { + const id = st.activeBirds[0]; + const body = st.world.byId.get(id); + const def = body ? BIRDS[body.userData?.bird] : null; + this.hintText.setText(def && def.ability !== 'none' ? 'Click to use ability' : ''); + } else { + this.hintText.setText(''); + } + } +} diff --git a/src/games/angrybirds/AngryBirdsLogic.js b/src/games/angrybirds/AngryBirdsLogic.js new file mode 100644 index 0000000..72aad37 --- /dev/null +++ b/src/games/angrybirds/AngryBirdsLogic.js @@ -0,0 +1,585 @@ +// Angry Birds — game rules over the rigid-body solver. Zero imports besides +// the physics module, no Phaser, no DOM, no timers. Shared by the client +// scene, the level editor, the offline generator and the verifier. +// +// ── WHAT MAKES IT READ AS ANGRY BIRDS ─────────────────────────────────────── +// Not the birds. The two things that actually matter are: +// +// * MATERIALS WITH DISTINCT DENSITY. Wood, stone and ice differ in mass, +// friction and fragility, so a structure has a load path and a support +// beam worth aiming at. Uniform blocks turn every level into "hit it +// harder". +// * DAMAGE AS A HEALTH MODEL, NOT INSTANT SHATTERING. A block accumulates +// damage from impacts above its threshold and cracks through visible +// stages before breaking. Pigs take damage the same way, which is why +// they die to DEBRIS LANDING ON THEM rather than only to direct hits — +// the collapse doing the killing is the whole feel of the game. Clones +// that destroy a block on any contact feel wrong immediately. +// +// ── UNITS ─────────────────────────────────────────────────────────────────── +// Pixels and seconds in the level's own logical space (level.world). The scene +// maps that to the 1920x1080 canvas with a camera; nothing in here knows about +// the screen. +// +// Impulse units: contact impulses are mass x velocity in the solver's units, +// where a 24x80 wood plank masses ~1050. A resting contact in a tall stack +// runs a few 1e4; a solid bird hit runs 1e6+. Damage thresholds live in that +// space — see calibrateDamage() in tools/verifyAngryBirds.js, which pins the +// separation between "resting" and "hit" so a tower can never crush itself. + +import { + PHYS, createWorld, addBox, addCircle, removeBody, + substep, isSettled, applyExplosion, cloneWorld, hashWorld, wakeBody, +} from './AngryBirdsPhysics.js'; + +export const TUNING = { + GROUND_Y: 900, + SLING_X: 260, + SLING_Y: 700, + SLING_HEIGHT: 150, // fork height above the ground contact + MAX_DRAW: 150, // px the band can be pulled back + SPEED_PER_DRAW: 13.5, // draw px -> launch px/s (150 * 13.5 = 2025) + MIN_DRAW: 12, // below this a release is treated as a cancel + MAX_SHOT_TIME: 12, // s before a shot is force-resolved + SETTLE_GRACE: 0.35, // s of stillness required after the world sleeps + DESPAWN_X: -400, // bodies past these bounds are removed + DESPAWN_X_MAX: 4200, + DESPAWN_Y: 1600, + DAMAGE_SCALE: 1 / 9000, // impulse above threshold -> hp + DEBRIS_MIN_IMPULSE: 60000, +}; + +// hp/threshold are in solver impulse units (see the header note). +export const MATERIALS = { + wood: { + id: 'wood', density: 0.55, friction: 0.55, restitution: 0.10, + hp: 42, threshold: 150000, points: 500, + }, + stone: { + id: 'stone', density: 1.35, friction: 0.62, restitution: 0.05, + hp: 105, threshold: 320000, points: 1000, + }, + ice: { + id: 'ice', density: 0.32, friction: 0.18, restitution: 0.14, + hp: 18, threshold: 90000, points: 300, + }, + // TNT is deliberately fragile: it is a trigger, not a wall. + tnt: { + id: 'tnt', density: 0.6, friction: 0.5, restitution: 0.08, + hp: 10, threshold: 70000, points: 800, + blast: { radius: 260, power: 1500, damage: 60 }, + }, +}; + +export const PIG = { + density: 0.5, friction: 0.5, restitution: 0.25, + hp: 26, threshold: 80000, points: 5000, +}; + +export const BIRDS = { + red: { + id: 'red', name: 'Red', r: 17, density: 2.2, restitution: 0.35, + ability: 'none', blurb: 'No trick. Just a bird.', + }, + chuck: { + id: 'chuck', name: 'Chuck', r: 15, density: 1.9, restitution: 0.3, + ability: 'dart', params: { mult: 2.15 }, + blurb: 'Tap to dart forward at speed. Best against wood.', + }, + blue: { + id: 'blue', name: 'Blue', r: 13, density: 1.5, restitution: 0.35, + ability: 'split', params: { count: 3, spreadDeg: 14 }, + blurb: 'Tap to split into three. Shreds ice.', + }, + bomb: { + id: 'bomb', name: 'Bomb', r: 21, density: 3.1, restitution: 0.2, + ability: 'blast', params: { radius: 240, power: 1400, damage: 45 }, + blurb: 'Tap to detonate. Levels stone.', + }, + matilda: { + id: 'matilda', name: 'Matilda', r: 19, density: 2.2, restitution: 0.25, + ability: 'egg', params: { radius: 170, power: 900, damage: 30, kick: 620 }, + blurb: 'Tap to drop an egg bomb and climb.', + }, + terence: { + id: 'terence', name: 'Terence', r: 27, density: 4.4, restitution: 0.15, + ability: 'none', blurb: 'Enormous. Goes through things.', + }, + hal: { + id: 'hal', name: 'Hal', r: 16, density: 2.0, restitution: 0.3, + ability: 'boomerang', params: { spin: 26 }, + blurb: 'Tap to boomerang back. Hits from behind.', + }, + bubbles: { + id: 'bubbles', name: 'Bubbles', r: 12, density: 1.6, restitution: 0.4, + ability: 'inflate', params: { scale: 3.0, push: 520 }, + blurb: 'Tap to inflate and shove everything apart.', + }, +}; + +export const SCORING = { + PIG: PIG.points, + BIRD_LEFT: 10000, +}; + +// ── Level construction ────────────────────────────────────────────────────── + +/** + * Build a live state from a level definition. + * + * Level schema: + * { version, level, name, episode, world: {w, h, groundY}, + * birds: ['red', 'chuck', ...], + * blocks: [{ x, y, w, h, angle?, material }], + * pigs: [{ x, y, r }], + * terrain?: [{ x, y, w, h, angle? }], // static scenery + * stars: [oneStar, twoStar, threeStar] } + */ +export function createState(level) { + const groundY = level.world?.groundY ?? TUNING.GROUND_Y; + const world = createWorld(); + const state = { + level, + world, + groundY, + blocks: new Map(), // bodyId -> { material, hp, maxHp, w, h } + pigs: new Map(), // bodyId -> { hp, maxHp, r } + birdQueue: (level.birds ?? ['red']).slice(), + birdIndex: 0, + activeBirds: [], // bodyIds currently in flight + abilityUsed: false, + phase: 'aim', // aim | flight | resolving | won | lost + score: 0, + shotTime: 0, + settleTimer: 0, + trails: [], // completed shot trails, for the aiming ghost + liveTrail: [], + events: [], + }; + + // Ground: wide enough that nothing rolls off the end of the world. + addBox(world, { + x: (level.world?.w ?? 1920) / 2, y: groundY + 400, w: 12000, h: 800, + isStatic: true, friction: 0.75, restitution: 0.05, + }); + + for (const t of level.terrain ?? []) { + addBox(world, { + x: t.x, y: t.y, w: t.w, h: t.h, angle: t.angle ?? 0, + isStatic: true, friction: t.friction ?? 0.7, restitution: 0.05, + }); + } + + for (const b of level.blocks ?? []) { + const mat = MATERIALS[b.material] ?? MATERIALS.wood; + const body = addBox(world, { + x: b.x, y: b.y, w: b.w, h: b.h, angle: b.angle ?? 0, + density: mat.density, friction: mat.friction, restitution: mat.restitution, + }); + body.userData = { kind: 'block', material: mat.id }; + state.blocks.set(body.id, { material: mat.id, hp: mat.hp, maxHp: mat.hp, w: b.w, h: b.h }); + } + + for (const p of level.pigs ?? []) { + const r = p.r ?? 22; + // Bigger pigs are tougher, so a boss pig reads as one at a glance. + const scale = r / 22; + const body = addCircle(world, { + x: p.x, y: p.y, r, + density: PIG.density, friction: PIG.friction, restitution: PIG.restitution, + }); + body.userData = { kind: 'pig' }; + const hp = PIG.hp * scale; + state.pigs.set(body.id, { hp, maxHp: hp, r }); + } + + return state; +} + +// ── Aiming and launching ──────────────────────────────────────────────────── + +export function slingAnchor(state) { + return { + x: state.level.world?.slingX ?? TUNING.SLING_X, + y: (state.level.world?.slingY ?? (state.groundY - TUNING.SLING_HEIGHT)), + }; +} + +/** Clamp a raw drag point to the band's reach. Returns the clamped point. */ +export function clampDraw(state, px, py) { + const a = slingAnchor(state); + let dx = px - a.x; + let dy = py - a.y; + const d = Math.hypot(dx, dy); + if (d > TUNING.MAX_DRAW) { + dx = (dx / d) * TUNING.MAX_DRAW; + dy = (dy / d) * TUNING.MAX_DRAW; + } + return { x: a.x + dx, y: a.y + dy, draw: Math.min(d, TUNING.MAX_DRAW) }; +} + +/** Launch velocity for a drag point — the bird flies OPPOSITE the pull. */ +export function drawToVelocity(state, px, py) { + const a = slingAnchor(state); + const c = clampDraw(state, px, py); + return { + vx: (a.x - c.x) * TUNING.SPEED_PER_DRAW, + vy: (a.y - c.y) * TUNING.SPEED_PER_DRAW, + draw: c.draw, + }; +} + +export function currentBird(state) { + if (state.birdIndex >= state.birdQueue.length) return null; + return BIRDS[state.birdQueue[state.birdIndex]] ?? BIRDS.red; +} + +export function birdsRemaining(state) { + return Math.max(0, state.birdQueue.length - state.birdIndex); +} + +function spawnBird(state, def, x, y, vx, vy) { + const body = addCircle(state.world, { + x, y, r: def.r, + density: def.density, friction: 0.4, restitution: def.restitution, + }); + body.userData = { kind: 'bird', bird: def.id }; + body.vx = vx; + body.vy = vy; + state.activeBirds.push(body.id); + return body; +} + +/** Fire the queued bird. Returns false if the draw was too short to count. */ +export function launch(state, px, py) { + if (state.phase !== 'aim') return false; + const def = currentBird(state); + if (!def) return false; + const { vx, vy, draw } = drawToVelocity(state, px, py); + if (draw < TUNING.MIN_DRAW) return false; + + const a = slingAnchor(state); + spawnBird(state, def, a.x, a.y, vx, vy); + state.birdIndex += 1; + state.abilityUsed = false; + state.phase = 'flight'; + state.shotTime = 0; + state.settleTimer = 0; + state.liveTrail = [{ x: a.x, y: a.y }]; + state.events.push({ t: 'birdLaunched', bird: def.id, vx, vy }); + return true; +} + +/** + * Fire the in-flight bird's ability. One per shot, as in the original — the + * limit is what makes timing the tap a decision rather than a reflex. + */ +export function useAbility(state) { + if (state.phase !== 'flight' || state.abilityUsed) return false; + const id = state.activeBirds[0]; + const body = state.world.byId.get(id); + if (!body) return false; + const def = BIRDS[body.userData?.bird] ?? BIRDS.red; + if (def.ability === 'none') return false; + + state.abilityUsed = true; + const p = def.params ?? {}; + + switch (def.ability) { + case 'dart': { + const sp = Math.hypot(body.vx, body.vy) || 1; + body.vx = (body.vx / sp) * sp * p.mult; + body.vy = (body.vy / sp) * sp * p.mult; + break; + } + case 'split': { + const ang = Math.atan2(body.vy, body.vx); + const sp = Math.hypot(body.vx, body.vy); + const spread = (p.spreadDeg * Math.PI) / 180; + for (let i = 1; i < p.count; i += 1) { + const off = (i % 2 === 1 ? 1 : -1) * spread * Math.ceil(i / 2); + spawnBird(state, def, body.x, body.y, + Math.cos(ang + off) * sp, Math.sin(ang + off) * sp); + } + break; + } + case 'blast': { + detonate(state, body.x, body.y, p.radius, p.power, p.damage); + killBody(state, body); + break; + } + case 'egg': { + detonate(state, body.x, body.y + def.r + 8, p.radius, p.power, p.damage); + body.vy = -p.kick; // Matilda climbs as the egg drops + break; + } + case 'boomerang': { + body.vx = -body.vx * 1.15; + body.omega = p.spin; + break; + } + case 'inflate': { + // Grow in place and shove neighbours apart. Rebuilding the body is + // simpler than mutating radius + mass in the solver, and keeps the + // invariant that a body's mass properties never change mid-life. + const grown = spawnBird(state, def, body.x, body.y, body.vx, body.vy); + grown.userData = { kind: 'bird', bird: def.id, inflated: true }; + state.activeBirds = state.activeBirds.filter((b) => b !== body.id); + removeBody(state.world, body); + const bigger = addCircle(state.world, { + x: grown.x, y: grown.y, r: def.r * p.scale, + density: def.density * 0.35, friction: 0.5, restitution: def.restitution, + }); + bigger.userData = { kind: 'bird', bird: def.id, inflated: true }; + bigger.vx = grown.vx; bigger.vy = grown.vy; + state.activeBirds = state.activeBirds.filter((b) => b !== grown.id); + removeBody(state.world, grown); + state.activeBirds.unshift(bigger.id); + applyExplosion(state.world, bigger.x, bigger.y, def.r * p.scale * 2.2, p.push); + break; + } + default: break; + } + + state.events.push({ t: 'abilityUsed', bird: def.id, x: body.x, y: body.y }); + return true; +} + +// ── Damage ────────────────────────────────────────────────────────────────── + +function crackStage(hp, maxHp) { + const f = hp / maxHp; + if (f > 2 / 3) return 0; + if (f > 1 / 3) return 1; + return 2; +} + +function killBody(state, body) { + state.activeBirds = state.activeBirds.filter((b) => b !== body.id); + state.blocks.delete(body.id); + state.pigs.delete(body.id); + removeBody(state.world, body); +} + +function detonate(state, x, y, radius, power, damage) { + state.events.push({ t: 'explosion', x, y, radius }); + const hit = applyExplosion(state.world, x, y, radius, power); + for (const { body, falloff } of hit) { + const dmg = damage * falloff; + if (state.blocks.has(body.id)) damageBlock(state, body, dmg); + else if (state.pigs.has(body.id)) damagePig(state, body, dmg); + } +} + +function damageBlock(state, body, amount) { + const rec = state.blocks.get(body.id); + if (!rec) return; + const before = crackStage(rec.hp, rec.maxHp); + rec.hp -= amount; + const after = crackStage(Math.max(rec.hp, 0), rec.maxHp); + if (rec.hp <= 0) { + const mat = MATERIALS[rec.material]; + state.score += mat.points; + state.events.push({ t: 'blockDestroyed', id: body.id, material: rec.material, x: body.x, y: body.y }); + // TNT takes the neighbourhood with it. Remove it BEFORE detonating so it + // cannot damage itself and recurse. + const blast = mat.blast; + killBody(state, body); + if (blast) detonate(state, body.x, body.y, blast.radius, blast.power, blast.damage); + return; + } + if (after !== before) { + state.events.push({ t: 'blockCracked', id: body.id, stage: after, x: body.x, y: body.y }); + } +} + +function damagePig(state, body, amount) { + const rec = state.pigs.get(body.id); + if (!rec) return; + const before = crackStage(rec.hp, rec.maxHp); + rec.hp -= amount; + if (rec.hp <= 0) { + state.score += SCORING.PIG; + state.events.push({ t: 'pigKilled', id: body.id, x: body.x, y: body.y }); + killBody(state, body); + return; + } + const after = crackStage(rec.hp, rec.maxHp); + if (after !== before) { + state.events.push({ t: 'pigHurt', id: body.id, stage: after, x: body.x, y: body.y }); + } +} + +// Convert this substep's contact impulses into damage. Reading the solver's +// per-contact peak (rather than a body's total) is what lets debris landing on +// a pig hurt it exactly as much as the geometry says it should. +function applyContactDamage(state) { + for (const c of state.world.contactList) { + // impactImpulse, never maxImpulse — see the note in Physics.prestep. The + // accumulated impulse carries the weight of everything stacked above, so + // using it would make tall towers damage themselves at rest. + const imp = c.impactImpulse; + if (imp <= 0) continue; + for (const body of [c.a, c.b]) { + const block = state.blocks.get(body.id); + if (block) { + const mat = MATERIALS[block.material]; + if (imp > mat.threshold) damageBlock(state, body, (imp - mat.threshold) * TUNING.DAMAGE_SCALE); + continue; + } + const pig = state.pigs.get(body.id); + if (pig && imp > PIG.threshold) { + damagePig(state, body, (imp - PIG.threshold) * TUNING.DAMAGE_SCALE); + } + } + } +} + +function despawnStrays(state) { + for (const b of [...state.world.bodies]) { + if (b.isStatic) continue; + if (b.x > TUNING.DESPAWN_X && b.x < TUNING.DESPAWN_X_MAX && b.y < TUNING.DESPAWN_Y) continue; + if (state.pigs.has(b.id)) { + // A pig that falls off the world still counts as dealt with. + state.score += SCORING.PIG; + state.events.push({ t: 'pigKilled', id: b.id, x: b.x, y: b.y, offscreen: true }); + } + killBody(state, b); + } +} + +// ── The frame ─────────────────────────────────────────────────────────────── + +/** + * Advance the sim. Returns an ordered event list the renderer replays as FX and + * sound — the same contract GooTower and Peggle use. + */ +export function stepSim(state, dt) { + state.events = []; + if (state.phase === 'won' || state.phase === 'lost') return state.events; + + const clamped = Math.min(dt, 0.05); + const steps = Math.max(1, Math.round(clamped / PHYS.SUBSTEP_DT)); + for (let i = 0; i < steps; i += 1) { + substep(state.world, PHYS.SUBSTEP_DT); + applyContactDamage(state); + } + despawnStrays(state); + + if (state.phase === 'flight' || state.phase === 'resolving') { + state.shotTime += clamped; + const lead = state.world.byId.get(state.activeBirds[0]); + if (lead) state.liveTrail.push({ x: lead.x, y: lead.y }); + + if (state.pigs.size === 0) { + finish(state, true); + return state.events; + } + + const still = isSettled(state.world); + state.settleTimer = still ? state.settleTimer + clamped : 0; + const resolved = state.settleTimer >= TUNING.SETTLE_GRACE + || state.shotTime >= TUNING.MAX_SHOT_TIME; + + if (resolved) { + if (state.liveTrail.length > 2) state.trails.push(state.liveTrail); + state.liveTrail = []; + // Spent birds are cleared so they can't prop a structure up forever. + for (const id of [...state.activeBirds]) { + const b = state.world.byId.get(id); + if (b) killBody(state, b); + } + state.activeBirds = []; + state.events.push({ t: 'shotEnded', score: state.score }); + + if (state.pigs.size === 0) finish(state, true); + else if (birdsRemaining(state) === 0) finish(state, false); + else state.phase = 'aim'; + } + } + + return state.events; +} + +function finish(state, won) { + if (won) { + const bonus = birdsRemaining(state) * SCORING.BIRD_LEFT; + state.score += bonus; + state.phase = 'won'; + state.events.push({ t: 'won', score: state.score, birdBonus: bonus, stars: starsFor(state) }); + } else { + state.phase = 'lost'; + state.events.push({ t: 'lost', score: state.score }); + } +} + +export function starsFor(state, score = state.score) { + const cuts = state.level.stars ?? []; + let stars = 0; + for (const c of cuts) if (score >= c) stars += 1; + return Math.min(3, Math.max(state.phase === 'won' ? 1 : 0, stars)); +} + +export const isWon = (state) => state.phase === 'won'; +export const isLost = (state) => state.phase === 'lost'; + +// ── Determinism helpers ───────────────────────────────────────────────────── + +export function cloneState(state) { + return { + ...state, + world: cloneWorld(state.world), + blocks: new Map([...state.blocks].map(([k, v]) => [k, { ...v }])), + pigs: new Map([...state.pigs].map(([k, v]) => [k, { ...v }])), + birdQueue: state.birdQueue.slice(), + activeBirds: state.activeBirds.slice(), + trails: state.trails.map((t) => t.slice()), + liveTrail: state.liveTrail.slice(), + events: [], + }; +} + +export function hashState(state) { + let h = hashWorld(state.world); + const mixInt = (v) => { h = (Math.imul(h ^ (v | 0), 0x01000193) >>> 0); }; + mixInt(state.score); + mixInt(state.birdIndex); + mixInt(state.pigs.size); + mixInt(state.blocks.size); + for (const [id, b] of [...state.blocks].sort((p, q) => p[0] - q[0])) { + mixInt(id); mixInt(Math.round(b.hp * 1000)); + } + for (const [id, p] of [...state.pigs].sort((a, b) => a[0] - b[0])) { + mixInt(id); mixInt(Math.round(p.hp * 1000)); + } + return h >>> 0; +} + +/** + * Dry-run a shot on a clone and report what it would achieve. This is what the + * winnability bot samples over and what the generator measures star thresholds + * from; it never touches the live state. + */ +export function simulateShot(state, px, py, opts = {}) { + const sim = cloneState(state); + if (!launch(sim, px, py)) return null; + const abilityAt = opts.abilityAt ?? -1; + let t = 0; + let guard = 0; + while (sim.phase === 'flight' && guard < 4000) { + if (abilityAt >= 0 && t >= abilityAt && !sim.abilityUsed) useAbility(sim); + stepSim(sim, 1 / 60); + t += 1 / 60; + guard += 1; + } + return { + score: sim.score - state.score, + pigsKilled: state.pigs.size - sim.pigs.size, + blocksDestroyed: state.blocks.size - sim.blocks.size, + won: sim.phase === 'won', + finalScore: sim.score, + state: sim, + }; +} + +export { PHYS, wakeBody }; diff --git a/src/games/angrybirds/AngryBirdsPhysics.js b/src/games/angrybirds/AngryBirdsPhysics.js index 0728f8f..79097cc 100644 --- a/src/games/angrybirds/AngryBirdsPhysics.js +++ b/src/games/angrybirds/AngryBirdsPhysics.js @@ -536,7 +536,8 @@ function updateContacts(world, pairs) { friction: Math.sqrt(a.friction * b.friction), restitution: Math.max(a.restitution, b.restitution), points, - maxImpulse: 0, // peak |pn| this substep — the damage signal the rules read + maxImpulse: 0, // peak accumulated |pn| — includes static load + impactImpulse: 0, // peak approach momentum — THIS is the damage signal }; world.contacts.set(key, contact); list.push(contact); @@ -564,6 +565,7 @@ function prestep(world, h) { const { nx, ny } = c; const tx = -ny; const ty = nx; + let peakImpact = 0; for (const p of c.points) { p.rax = p.x - a.x; p.ray = p.y - a.y; @@ -593,7 +595,20 @@ function prestep(world, h) { const rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega); const vn = rvx * nx + rvy * ny; p.restTarget = vn < -PHYS.REST_THRESHOLD ? -c.restitution * vn : 0; + + // IMPACT momentum: the impulse it would take to just stop the approach. + // + // This is the damage signal, and it must NOT be the solver's accumulated + // normal impulse. That value includes the static load a contact carries, + // which grows with stack height — measured 3.4e5 at the base of a + // 10-block stone tower, above the threshold that should shatter wood. A + // tower would quietly crush itself while nothing was happening. Approach + // velocity is zero at rest by definition, so this separates "being leaned + // on" from "being hit" for free. + const impact = vn < 0 ? p.massNormal * -vn : 0; + if (impact > peakImpact) peakImpact = impact; } + c.impactImpulse = peakImpact; } for (const b of world.bodies) { b.psx = 0; b.psy = 0; b.psw = 0; } } diff --git a/src/main.js b/src/main.js index 5e7c900..bfa0ba5 100644 --- a/src/main.js +++ b/src/main.js @@ -102,6 +102,7 @@ import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGa import BloxorzGame from './games/bloxorz/BloxorzGame.js'; import GooTowerGame from './games/gootower/GooTowerGame.js'; import GooTowerEditor from './games/gootower/GooTowerEditor.js'; +import AngryBirdsGame from './games/angrybirds/AngryBirdsGame.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; const config = { @@ -219,6 +220,7 @@ const config = { GooTowerGame, ExcitebikeGame, GooTowerEditor, + AngryBirdsGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8d664c6..dd24dc8 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', angrybirds: 'AngryBirdsGame' }; 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..ac9dc38 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -55,6 +55,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('bloxorz', 'data/bloxorz.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json'); + this.load.json('angrybirds-levels', 'assets/gamedata/angrybirds/levels.json'); this.load.json('shift-artwork', 'data/shift-artwork.json'); this.load.json('slots-artwork', 'data/slots-artwork.json'); this.load.json('blockfighter', 'data/blockfighter.json'); diff --git a/tools/verifyAngryBirds.js b/tools/verifyAngryBirds.js index a3ad24a..2dd8bcb 100644 --- a/tools/verifyAngryBirds.js +++ b/tools/verifyAngryBirds.js @@ -10,11 +10,20 @@ // Rendering, slingshot feel, camera and the editor's Blob export are // browser-only and must be smoke-tested manually. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + import { PHYS, createWorld, addBox, addPoly, addCircle, removeBody, step, substep, settle, isSettled, applyImpulse, applyExplosion, cloneWorld, hashWorld, contactImpulses, } from '../src/games/angrybirds/AngryBirdsPhysics.js'; +import { + TUNING, MATERIALS, PIG, BIRDS, SCORING, + createState, slingAnchor, clampDraw, drawToVelocity, currentBird, birdsRemaining, + launch, useAbility, stepSim, starsFor, cloneState, hashState, simulateShot, +} from '../src/games/angrybirds/AngryBirdsLogic.js'; let failures = 0; let passes = 0; @@ -465,6 +474,408 @@ section('10. Robustness'); `peak ${peak.toFixed(0)} vs resting ${resting.toFixed(0)}`); } +// ── 11. Materials and damage ──────────────────────────────────────────────── + +section('11. Materials and damage'); +{ + const order = ['ice', 'wood', 'stone']; + const dens = order.map((m) => MATERIALS[m].density); + const hps = order.map((m) => MATERIALS[m].hp); + const thr = order.map((m) => MATERIALS[m].threshold); + check('material densities are distinct and ordered ice b.hp); + for (let i = 0; i < 300; i += 1) stepSim(st, 1 / 60); + const hp1 = [...st.blocks.values()].map((b) => b.hp); + check('a tall stone tower does not damage itself at rest', + hp1.length === hp0.length && hp1.every((h, i) => h === hp0[i]), + `${hp0.length} blocks -> ${hp1.length}, min hp ${Math.min(...hp1).toFixed(1)}`); + + const restPeak = Math.max(0, ...st.world.contactList.map((c) => c.impactImpulse)); + check('settled resting impact impulse is below every threshold', + restPeak < MATERIALS.ice.threshold, + `${restPeak.toExponential(2)} vs ice ${MATERIALS.ice.threshold.toExponential(2)}`); +} +{ + // Damage must be graded: the same hit shatters ice, hurts wood, barely + // marks stone. This is what makes material choice matter to a level author. + const results = {}; + for (const material of ['ice', 'wood', 'stone']) { + const st = createState({ + world: { w: 1920, h: 1080, groundY: 900 }, + birds: ['red'], + blocks: [{ x: 1000, y: 850, w: 120, h: 100, material }], + pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3], + }); + const a = slingAnchor(st); + launch(st, a.x - 90, a.y); + const bird = st.world.byId.get(st.activeBirds[0]); + bird.x = 700; bird.y = 850; bird.vx = 900; bird.vy = 0; + for (let i = 0; i < 200 && st.phase === 'flight'; i += 1) stepSim(st, 1 / 60); + const rec = [...st.blocks.values()][0]; + results[material] = rec ? rec.hp / rec.maxHp : 0; + } + check('one medium hit destroys ice', results.ice === 0, `ice left ${results.ice}`); + check('the same hit leaves stone standing', results.stone > 0, `stone left ${results.stone.toFixed(2)}`); + check('stone survives better than wood', results.stone > results.wood, + `stone ${results.stone.toFixed(2)} vs wood ${results.wood.toFixed(2)}`); +} +{ + // Pigs must die to debris, not only to direct hits — the collapse doing the + // killing is the core feel of the game. + const st = createState({ + world: { w: 1920, h: 1080, groundY: 900 }, + birds: ['red'], + blocks: [{ x: 1200, y: 600, w: 200, h: 60, material: 'stone' }], + pigs: [{ x: 1200, y: 878, r: 22 }], + stars: [1, 2, 3], + }); + let killed = false; + for (let i = 0; i < 400; i += 1) { + for (const e of stepSim(st, 1 / 60)) if (e.t === 'pigKilled') killed = true; + if (killed) break; + } + check('a stone slab dropped on a pig kills it', killed); +} +{ + // Crack stages must fire in order as hp falls. + const st = createState({ + world: { w: 1920, h: 1080, groundY: 900 }, + birds: ['red', 'red', 'red'], + blocks: [{ x: 1000, y: 850, w: 140, h: 100, material: 'stone' }], + pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3], + }); + const stages = []; + for (let shot = 0; shot < 3; shot += 1) { + const a = slingAnchor(st); + if (!launch(st, a.x - 60, a.y)) break; + const bird = st.world.byId.get(st.activeBirds[0]); + bird.x = 780; bird.y = 850; bird.vx = 780; bird.vy = 0; + for (let i = 0; i < 300 && st.phase === 'flight'; i += 1) { + for (const e of stepSim(st, 1 / 60)) { + if (e.t === 'blockCracked') stages.push(e.stage); + if (e.t === 'blockDestroyed') stages.push(3); + } + } + } + check('crack stages fire in increasing order', + stages.length > 0 && stages.every((s, i) => i === 0 || s >= stages[i - 1]), stages.join(',')); +} + +// ── 12. Birds ─────────────────────────────────────────────────────────────── + +section('12. Birds'); +{ + check('all 8 birds are defined', Object.keys(BIRDS).length === 8, Object.keys(BIRDS).join(',')); + check('Terence is the heaviest', Object.values(BIRDS).every((b) => b.density <= BIRDS.terence.density)); + check('every ability is implemented', + Object.values(BIRDS).every((b) => ['none', 'dart', 'split', 'blast', 'egg', 'boomerang', 'inflate'].includes(b.ability))); +} +function flightState(birdId) { + const st = createState({ + world: { w: 1920, h: 1080, groundY: 900 }, + birds: [birdId], + blocks: [{ x: 1400, y: 850, w: 120, h: 100, material: 'wood' }], + pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3], + }); + const a = slingAnchor(st); + launch(st, a.x - 100, a.y - 40); + return st; +} +{ + const st = flightState('chuck'); + const b = st.world.byId.get(st.activeBirds[0]); + const before = Math.hypot(b.vx, b.vy); + useAbility(st); + const after = Math.hypot(b.vx, b.vy); + check('Chuck darts faster', after > before * 2, `${before.toFixed(0)} -> ${after.toFixed(0)}`); +} +{ + const st = flightState('blue'); + const before = st.activeBirds.length; + useAbility(st); + check('Blue splits into three', st.activeBirds.length === before + 2, `${before} -> ${st.activeBirds.length}`); +} +{ + const st = flightState('bomb'); + useAbility(st); + const evs = st.events.map((e) => e.t); + check('Bomb detonates', evs.includes('explosion'), evs.join(',')); + check('Bomb consumes itself', st.activeBirds.length === 0); +} +{ + const st = flightState('matilda'); + const b = st.world.byId.get(st.activeBirds[0]); + const vy0 = b.vy; + useAbility(st); + check('Matilda kicks upward', b.vy < vy0, `${vy0.toFixed(0)} -> ${b.vy.toFixed(0)}`); + check('Matilda drops an egg', st.events.some((e) => e.t === 'explosion')); +} +{ + const st = flightState('hal'); + const b = st.world.byId.get(st.activeBirds[0]); + const vx0 = b.vx; + useAbility(st); + check('Hal reverses direction', Math.sign(b.vx) === -Math.sign(vx0), `${vx0.toFixed(0)} -> ${b.vx.toFixed(0)}`); +} +{ + const st = flightState('bubbles'); + const r0 = st.world.byId.get(st.activeBirds[0]).radius; + useAbility(st); + const r1 = st.world.byId.get(st.activeBirds[0]).radius; + check('Bubbles inflates', r1 > r0 * 2, `${r0} -> ${r1}`); +} +{ + const st = flightState('red'); + check('Red has no ability to use', useAbility(st) === false); +} +{ + const st = flightState('chuck'); + check('ability fires once', useAbility(st) === true); + check('ability cannot fire twice in one shot', useAbility(st) === false); +} + +// ── 13. Rules and scoring ─────────────────────────────────────────────────── + +section('13. Rules and scoring'); +function winnableLevel(birds = ['red', 'red', 'red']) { + return { + world: { w: 1920, h: 1080, groundY: 900 }, + birds, + blocks: [{ x: 1000, y: 850, w: 40, h: 100, material: 'ice' }], + pigs: [{ x: 1060, y: 878, r: 22 }], + stars: [5000, 12000, 20000], + }; +} +{ + const st = createState(winnableLevel()); + check('starts in aim phase', st.phase === 'aim'); + check('birds remaining matches the queue', birdsRemaining(st) === 3); + + const a = slingAnchor(st); + check('a tap-sized draw does not launch', launch(st, a.x - 3, a.y) === false); + check('a full draw launches', launch(st, a.x - 150, a.y - 60) === true); + check('launching consumes a bird', birdsRemaining(st) === 2); + check('phase is flight after launch', st.phase === 'flight'); +} +{ + // Draw is clamped to MAX_DRAW in every direction. + const st = createState(winnableLevel()); + const a = slingAnchor(st); + const c = clampDraw(st, a.x - 9000, a.y - 9000); + check('draw clamps to MAX_DRAW', near(Math.hypot(c.x - a.x, c.y - a.y), TUNING.MAX_DRAW, 1e-6), + `${Math.hypot(c.x - a.x, c.y - a.y).toFixed(2)}`); + const v = drawToVelocity(st, a.x - 150, a.y); + check('bird flies opposite the pull', v.vx > 0, `vx=${v.vx.toFixed(0)}`); + check('launch speed matches SPEED_PER_DRAW', + near(Math.hypot(v.vx, v.vy), TUNING.MAX_DRAW * TUNING.SPEED_PER_DRAW, 1), + `${Math.hypot(v.vx, v.vy).toFixed(0)}`); +} +{ + // A shot that clears the only pig wins, and unused birds pay a bonus. + const st = createState(winnableLevel()); + const a = slingAnchor(st); + launch(st, a.x - 150, a.y - 30); + const bird = st.world.byId.get(st.activeBirds[0]); + bird.x = 900; bird.y = 860; bird.vx = 1500; bird.vy = 0; + let won = false; + let bonus = 0; + for (let i = 0; i < 900; i += 1) { + for (const e of stepSim(st, 1 / 60)) { + if (e.t === 'won') { won = true; bonus = e.birdBonus; } + } + if (won) break; + } + check('clearing every pig wins', won, `phase=${st.phase}`); + check('unused birds pay 10,000 each', bonus === 2 * SCORING.BIRD_LEFT, `bonus ${bonus}`); + check('score includes the pig', st.score >= SCORING.PIG + bonus, `score ${st.score}`); + check('stars awarded on a win', starsFor(st) >= 1, `stars ${starsFor(st)}`); +} +{ + // Running out of birds with a pig alive loses. + const st = createState({ + world: { w: 1920, h: 1080, groundY: 900 }, + birds: ['red'], + blocks: [], + pigs: [{ x: 1700, y: 878, r: 22 }], + stars: [5000, 12000, 20000], + }); + const a = slingAnchor(st); + launch(st, a.x - 20, a.y); // deliberately feeble + let lost = false; + for (let i = 0; i < 2000; i += 1) { + for (const e of stepSim(st, 1 / 60)) if (e.t === 'lost') lost = true; + if (lost || st.phase === 'won') break; + } + check('running out of birds loses', lost, `phase=${st.phase}`); + check('no stars on a loss', starsFor(st) === 0); +} +{ + const st = createState(winnableLevel()); + st.score = 13000; + st.phase = 'won'; + check('star cuts are thresholds', starsFor(st) === 2, `${starsFor(st)}`); + st.score = 99999; + check('stars cap at 3', starsFor(st) === 3); +} +{ + // Shot bookkeeping: the trail is retained for the next shot's aiming ghost. + const st = createState(winnableLevel()); + const a = slingAnchor(st); + launch(st, a.x - 150, a.y - 60); + for (let i = 0; i < 1200 && st.phase === 'flight'; i += 1) stepSim(st, 1 / 60); + check('a resolved shot records a trail', st.trails.length === 1, `${st.trails.length}`); + check('a resolved shot returns to aim', ['aim', 'won', 'lost'].includes(st.phase), st.phase); +} + +// ── 14. Rules-layer determinism ───────────────────────────────────────────── + +section('14. Rules determinism'); +{ + const mk = () => { + const st = createState(winnableLevel(['red', 'chuck', 'bomb'])); + const a = slingAnchor(st); + launch(st, a.x - 140, a.y - 50); + return st; + }; + const a1 = mk(); + const b1 = mk(); + for (let i = 0; i < 300; i += 1) { stepSim(a1, 1 / 60); stepSim(b1, 1 / 60); } + check('rules state replays bit-identically', hashState(a1) === hashState(b1), + `${hashState(a1)} vs ${hashState(b1)}`); +} +{ + const st = createState(winnableLevel(['red', 'red'])); + const before = hashState(st); + const a = slingAnchor(st); + const res = simulateShot(st, a.x - 150, a.y - 40); + check('simulateShot returns a result', !!res); + check('simulateShot does not touch the live state', hashState(st) === before, + `${hashState(st)} vs ${before}`); + check('simulateShot reports damage done', res.pigsKilled + res.blocksDestroyed >= 0); + + const res2 = simulateShot(st, a.x - 150, a.y - 40); + check('simulateShot is repeatable', res.score === res2.score && res.pigsKilled === res2.pigsKilled, + `${res.score} vs ${res2.score}`); +} +{ + // cloneState must deep-copy the damage bookkeeping too, not just the world. + const st = createState(winnableLevel()); + const copy = cloneState(st); + const firstBlock = [...copy.blocks.keys()][0]; + copy.blocks.get(firstBlock).hp = 1; + check('cloneState deep-copies block hp', + st.blocks.get(firstBlock).hp !== 1, `${st.blocks.get(firstBlock).hp}`); +} + +// ── 15. Level bank ────────────────────────────────────────────────────────── + +section('15. Level bank'); +{ + const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'gamedata', 'angrybirds'); + const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8')); + const entries = manifest.levels ?? []; + check('manifest lists levels', entries.length > 0, `${entries.length}`); + check('level numbers are contiguous from 1', + entries.every((m, i) => m.level === i + 1), entries.map((m) => m.level).join(',')); + + const defs = []; + let missing = 0; + for (const m of entries) { + try { defs.push(JSON.parse(readFileSync(join(DATA_DIR, m.file), 'utf8'))); } catch (_) { missing += 1; } + } + check('every manifest entry has a level file', missing === 0, `${missing} missing`); + check('level files agree with the manifest', + defs.every((d, i) => d.level === entries[i].level && d.name === entries[i].name)); + + // Every episode range must cover real levels. + for (const ep of manifest.episodes ?? []) { + const inRange = entries.filter((m) => m.level >= ep.from && m.level <= ep.to); + check(`episode "${ep.name}" covers its range`, inRange.length === ep.to - ep.from + 1, + `${inRange.length} of ${ep.to - ep.from + 1}`); + } + + // Data lint: materials, birds and star cuts must all be real. + let badMat = 0; let badBird = 0; let badStars = 0; let thin = 0; + for (const d of defs) { + for (const b of d.blocks ?? []) { + if (!MATERIALS[b.material]) badMat += 1; + if (Math.min(b.w, b.h) / 2 < PHYS.MIN_HALF_EXTENT) thin += 1; + } + for (const id of d.birds ?? []) if (!BIRDS[id]) badBird += 1; + const s = d.stars ?? []; + if (s.length !== 3 || s[0] >= s[1] || s[1] >= s[2]) badStars += 1; + } + check('every block uses a known material', badMat === 0, `${badMat} bad`); + check('every bird id is known', badBird === 0, `${badBird} bad`); + check('star cuts are three ascending values', badStars === 0, `${badStars} bad`); + check('no block is thinner than the anti-tunnel bound', thin === 0, + `${thin} blocks under ${PHYS.MIN_HALF_EXTENT * 2}px`); + + // Structural integrity: a level must stand on its own. If a structure + // collapses or damages itself on load, the author saw something different + // from what the player gets. + let unstable = []; + let selfDamaged = []; + for (const d of defs) { + const st = createState(d); + const hp0 = [...st.blocks.values()].map((b) => b.hp); + const nBlocks = st.blocks.size; + const nPigs = st.pigs.size; + for (let i = 0; i < 240; i += 1) stepSim(st, 1 / 60); + if (st.blocks.size !== nBlocks || st.pigs.size !== nPigs) unstable.push(d.level); + else if ([...st.blocks.values()].some((b, i) => b.hp < hp0[i])) selfDamaged.push(d.level); + } + check('every level stands up unaided', unstable.length === 0, `levels ${unstable.join(',')}`); + check('no level damages itself on load', selfDamaged.length === 0, `levels ${selfDamaged.join(',')}`); + + // Winnability: a coarse aim sweep must find a shot that clears every pig + // within the bird budget. One-directional gate — a sweep failure means + // "redesign or hand-verify", never "impossible". + const unwinnable = []; + for (const d of defs) { + let st = createState(d); + const a = slingAnchor(st); + let guard = 0; + while (st.phase === 'aim' && guard < 12) { + guard += 1; + let best = null; + for (let ang = -85; ang <= 15; ang += 5) { + for (const pw of [1, 0.85, 0.7, 0.55]) { + const r = (Math.PI * ang) / 180; + const res = simulateShot(st, a.x - Math.cos(r) * TUNING.MAX_DRAW * pw, + a.y - Math.sin(r) * TUNING.MAX_DRAW * pw); + if (!res) continue; + if (!best || res.score > best.score) best = res; + if (res.won) { best = res; break; } + } + if (best?.won) break; + } + if (!best) break; + st = best.state; + } + if (st.phase !== 'won') unwinnable.push(d.level); + } + check('a greedy aim sweep clears every level', unwinnable.length === 0, + `levels ${unwinnable.join(',')}`); +} + // ── Summary ───────────────────────────────────────────────────────────────── console.log(`\n${passes} passed, ${failures} failed`);