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`);