Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig d8296940e5 Suck into pipe effect for goos 2026-07-30 19:54:18 -06:00
Brian Fertig e1b1ab2bab feat(gootower): add background parallax, dynamic level bounds, ball eye animations
- Add background parallax layer that follows camera pan/zoom at a reduced
  rate for depth perception
- Replace fixed world-height Y clamping with dynamic level content bounds
  computed from terrain, pipe, and balls
- Add per-ball eye wander and desynchronized blink cycles for livelier
  ball rendering
- Reorder pickBallAt priority so direct clicks on detachable attached balls
  take precedence over loose balls, preventing accidental structure detachment
2026-07-30 19:38:37 -06:00
Brian Fertig c12371b654 feat(angrybirds): Waves 1-2 — playable core, 8 birds, level bank, wiring
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1wNRvLLtyfCDAET7oYouf
2026-07-30 19:07:16 -06:00
Brian Fertig 9ecbc9589f feat(angrybirds): Wave 0 — deterministic rigid-body solver
Angry Birds is fundamentally a rotating rigid-body stacking game, and
neither existing engine could be extended into it: PeggleLogic collides a
moving circle against static pegs with no rotation, and GooTowerLogic has
no angular state at all because its rigidity is emergent from
triangulation. Matter.js ships in the CDN Phaser build but is not
importable in bare Node and not reproducible, which would forfeit the
verifier, the generator's measured star thresholds, and the editor's
winnability gate.

AngryBirdsPhysics.js is a bespoke solver: convex polygons and circles,
uniform-grid broadphase, SAT with reference-face clipping to 2-point
manifolds, sequential impulses with warm starting keyed on stable feature
ids, Coulomb friction, split-impulse position correction, and island
sleeping.

Split impulse rather than plain Baumgarte is the load-bearing choice.
Biasing the real velocity makes penetration proportional to load, so the
bottom contact of a 10-box tower sank ~9x deeper than the top and the
stack sagged 12.85px. Correcting position through a discarded
pseudo-velocity removes a fixed fraction of the excess per substep
regardless of load; sag is now 0.46px per contact, exactly the slop band
we deliberately allow.

Gate: 10-box tower settles in 0.68s and sleeps with <1px drift and 0.35
degrees of tilt; friction holds on a 15 degree slope and slips on 40;
replay is bit-identical and frame-rate independent; 40 monkey seeds
produce no NaN, no overspeed and no sinking.

tools/verifyAngryBirds.js -> 51 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1wNRvLLtyfCDAET7oYouf
2026-07-30 18:47:18 -06:00
19 changed files with 3798 additions and 20 deletions

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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
]
}

View File

@ -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"
}
]
}

View File

@ -0,0 +1,159 @@
# Angry Birds — build plan
Living plan doc. Survives context clears: **read this file first**, pick the next unchecked item, update the checkboxes and the Status line as work lands.
## Status
**Waves 0 and 1 complete 2026-07-30. Playable end to end, never browser-tested.** `node tools/verifyAngryBirds.js` → **114 checks green**.
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
| Question | Answer |
|---|---|
| Slug / category / iconFrame | `angrybirds` / `arcade-console-pc` ("Video Games") / **92** (next free after Excitebike's 91) |
| Physics | **Bespoke deterministic rigid-body solver.** Matter.js ships inside the CDN Phaser build and would work in-browser, but it is not importable in bare Node and not reproducible — that forfeits the verifier, the generator's measured star thresholds, and the editor's winnability gate. |
| Visual treatment | **No CRT overlay, no scanlines, no pixel font.** It's a 2009 touch game, not an arcade cabinet. Clean cartoon look, app's normal fonts. |
| Campaign scale | **63 levels, 3 episodes of 21** — Poached Eggs / Mighty Hoax / Danger Above. |
| Art | Procedural, with a `data/angrybirds-artwork.json` drop-in hook so real sprites can replace the fallbacks later with no code change. Procedural fallbacks must always work standalone. |
| Bird roster | All 8 — Red, Chuck, Blue, Bomb, Matilda, Terence, Hal, Bubbles. |
| Modes | Single-player campaign only. `maxOpponents: 0`, so it skips `OpponentSelect` entirely. |
## Files
| 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`. 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. | ✅ 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).
---
## Wave 0 — the rigid-body solver — ✅ DONE 2026-07-30
- [x] Convex polygon + circle bodies, mass/inertia derived from geometry
- [x] Uniform-grid AABB broadphase
- [x] SAT narrowphase with reference-face clipping → 2-point manifolds
- [x] Sequential-impulse solver with warm starting
- [x] Coulomb friction clamped against the accumulated normal impulse
- [x] **Split-impulse** position correction with penetration slop (not plain Baumgarte — see finding 1)
- [x] Island-based sleeping
- [x] `cloneWorld` / `hashWorld` for determinism tests
- [x] Verifier green — all four gate conditions pass
### The gate — all four passed
1. ✅ 10-box tower settles in 0.68s, every body asleep, drift 0.00px, tilt 6.1e-3 rad. A 15-box pyramid also settles.
2. ✅ Box on a 15° slope with µ=0.9 does not creep; a µ=0.2 box on 40° does slide.
3. ✅ Bit-identical replay; 1/60 frame == 4 substeps exactly; ragged frame pacing matches even pacing.
4. ✅ 12 monkey seeds × 900 substeps: no NaN, no overspeed, nothing sinks.
### Wave 0 findings
1. **Plain Baumgarte is not good enough for a 10-high stack, and the failure looks like a bug when it isn't.** Feeding position error back as bias velocity makes a contact behave like a stiff spring, so penetration is proportional to load — the bottom contact of a 10-box tower carries 9 boxes and sinks ~9× deeper than the top one. Measured sag was 12.85px. Switching to **split impulse** (a separate pseudo-velocity accumulator, added into the position integration then discarded) removes a fixed fraction of the excess per substep *regardless of load*, and never leaks the correction back as bounce energy. Sag dropped to 0.46px per contact, which is exactly `SLOP` — i.e. the remaining sag is the slop band we deliberately allow, not solver error. **Do not "simplify" this back to a single bias term.**
2. **Total stack sag is bounded by `SLOP × contactCount`, by design.** The verifier asserts against that budget rather than a flat pixel value. Shrinking `SLOP` tightens stacks but reintroduces resting-contact jitter — 0.5px on a 40px block is the sweet spot.
3. **Warm starting must key on a stable feature id, not array position.** Contact points are matched across substeps by `(refFace << 8) | incidentVert`, with a flip bit. Matching by index scrambles impulses the moment the clip order changes and the stack sags visibly.
4. **The reference-face choice needs hysteresis.** Preferring A unless B is deeper by a relative margin (`0.1 × |sepA| + 0.01`) keeps the choice stable frame to frame, which is what keeps feature ids — and therefore warm starting — stable for a resting contact.
5. **Sequential impulses are Gauss-Seidel, so solve order genuinely changes the answer.** Bit-identical results across different body *creation* orders are not achievable and were never the goal; the verifier asserts the dependence stays sub-pixel (measured 0.4px). What must be exact — and is — is that a *given* scene replays identically.
6. **Island sleeping, not per-body sleeping.** Union-find over contacts; an island sleeps only when its slowest member has been slow for `SLEEP_TIME`. Per-body sleeping freezes half a tower while the rest still moves. Sleeping is also how the rules layer knows a shot is over, so it is not just an optimization.
7. **Anti-tunnelling is a design constraint, not a runtime check.** `MAX_SPEED × SUBSTEP_DT < MIN_HALF_EXTENT` (10px < 12px) is asserted by the verifier, which is what forced `SUBSTEP_DT` to 1/240 and `MAX_SPEED` to 2400. No block may be thinner than 24px.
8. **String keys dominated the profile.** Numeric composite keys for the grid and contact maps, plus building the contact list in already-sorted pair order instead of re-sorting it four times per substep, took a 6-second shot from 368ms to 254ms with bit-identical output.
9. **Contacts cache direct body references, so `cloneWorld` must rewire them.** A shallow spread leaves the clone's contacts pointing at the original's bodies — the clone then drives the world it was supposed to leave alone. The winnability bot and shot preview both depend on this; the verifier now asserts it explicitly.
### Baselines
```
node tools/verifyAngryBirds.js -> 114 checks green
node tools/verifyAngryBirds.js --seeds=40 -> slower monkey soak, still green
```
---
## Wave 1 — playable core — ✅ DONE 2026-07-30
- [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 — ✅ DONE 2026-07-30 (landed inside Wave 1)
- [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
---
## Wave 3 — editor and generator — ⬜
- [ ] `?angrybirds-editor=1` entrance, authoring in play coordinates
- [ ] **Settle** button so levels export already at rest
- [ ] **Test Winnable** button running the same gate as the verifier
- [ ] `AngryBirdsAuto` beam search over (angle, power, ability timing)
- [ ] Blob export of `level-NNN.json` + regenerated `levels.json`
Gate is **one-directional**: if the bot wins, a human can. A bot failure is *not* proof a level is impossible.
---
## Wave 4 — the 63-level campaign — ⬜
- [ ] Poached Eggs (1-21) — wood then stone; Red, Chuck, Blue
- [ ] Mighty Hoax (22-42) — ice, TNT, taller structures; adds Bomb, Matilda
- [ ] Danger Above (43-63) — mixed materials, elevated structures; adds Terence, Hal, Bubbles
Every level needs a **load-bearing** support whose removal collapses the structure. That, not bird variety, is what makes a shot feel clever.
---
## Wave 5 — art, audio, progress, polish — ⬜
- [ ] Procedural art + artwork-JSON drop-in
- [ ] Progress via `/puzzles/angrybirds/{progress,complete}`, per-level best/stars in `ab-best-N` / `ab-stars-N`
- [ ] Match history — use `{ slug, score, opponentScores, result }`. ⚠️ Goo Tower gets this **wrong** (`GooTowerGame.js:879-881`); copy `RushHourGame.js:513-516` instead.
- [ ] `tutorial.md` + `hasTutorial: true`
- [ ] Paint `game-icons.png` frame 92 (row 6, col 2 → 44×44 at x=88, y=264)
---
## How difficulty is decided
`TUNING` in `AngryBirdsLogic.js` owns damage thresholds and material hp — tune there, not in level files. Star thresholds are **measured** by `tools/genAngryBirds.js` from the bot's achieved score, never hand-authored (same discipline as Excitebike's `qualifyMs`).
## Sources for original-game behaviour
- Rovio *Angry Birds* (2009) — material behaviour, bird abilities, 5,000/pig and 10,000/unused-bird scoring.
- Episode structure: Poached Eggs, Mighty Hoax, Danger Above — 21 levels each.

View File

@ -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).

View File

@ -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 });

View File

@ -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('');
}
}
}

View File

@ -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 };

View File

@ -0,0 +1,943 @@
// Angry Birds — rigid-body physics. Zero imports, deterministic, Node-testable.
//
// Generic 2D rigid-body solver: it knows about convex polygons and circles and
// nothing at all about birds, pigs or scoring. AngryBirdsLogic.js layers the
// game on top.
//
// ── WHY A BESPOKE SOLVER ────────────────────────────────────────────────────
// Matter.js ships inside the CDN Phaser build and would have worked in the
// browser, but it cannot be imported in bare Node and is not reproducible.
// This repo's whole quality apparatus — tools/verifyAngryBirds.js, the level
// generator's MEASURED star thresholds, and the editor's winnability gate —
// depends on replaying a shot headlessly and getting the same answer twice.
//
// Neither existing engine could be extended into this. PeggleLogic collides a
// moving circle against STATIC pegs with no rotation and no persistent
// contacts; GooTowerLogic has no angular state anywhere, because its rigidity
// is emergent from triangulation. A toppling tower of boxes needs both
// rotation and lasting contacts, so this is new code.
//
// ── THE THREE THINGS THAT MAKE STACKS STAND UP ──────────────────────────────
// 1. TWO-POINT MANIFOLDS. A single contact point cannot resist rotation, so a
// box resting on a box rocks forever. Reference-face clipping yields up to
// 2 points per pair, which is what makes a face-to-face rest contact rigid.
// 2. WARM STARTING. Each contact point remembers last substep's accumulated
// impulse — keyed by a FEATURE ID that survives across frames, not by array
// position — and reapplies it before iterating. Without it a 6-box tower
// visibly sags every frame because the solver spends all its iterations
// rediscovering gravity from zero.
// 3. ISLAND SLEEPING. Bodies are grouped by contact into islands; an island
// sleeps only when EVERY member has been slow for SLEEP_TIME. Per-body
// sleeping would let half a tower freeze while the other half moves.
// Sleeping is load-bearing three times over: stack stability, perf, and the
// "shot is finished" test the rules layer waits on.
//
// ── UNITS AND CONVENTIONS ───────────────────────────────────────────────────
// Pixels, seconds, radians. Y IS DOWN (screen convention). Polygon winding is
// normalized on insert so the shoelace area is positive; the outward normal of
// edge v[i]->v[i+1] is then (e.y, -e.x). Contact normals point from A to B.
//
// Determinism rules, all load-bearing:
// * bodies and contacts are always iterated in ascending id order;
// * no Math.random() anywhere — callers pass a seeded rng if they need one;
// * no Date/performance reads;
// * nothing that affects the result may depend on Map/Set iteration order —
// broadphase pairs are sorted by (aId, bId) and the solver walks that list.
export const PHYS = {
SUBSTEP_DT: 1 / 240,
VEL_ITERS: 8, // sequential-impulse passes per substep
GRAVITY: 1400, // px/s²
BAUMGARTE: 0.25, // fraction of excess penetration removed per substep
SLOP: 0.5, // allowed penetration, px — stops resting contacts buzzing
REST_THRESHOLD: 120, // approach speed below which restitution is ignored
MAX_SPEED: 2400, // px/s
MAX_OMEGA: 40, // rad/s
LINEAR_DAMPING: 0.002,
ANGULAR_DAMPING: 0.004,
SLEEP_LIN: 14, // px/s
SLEEP_ANG: 0.14, // rad/s
SLEEP_TIME: 0.5, // s below both thresholds before an island sleeps
GRID_CELL: 96, // broadphase cell size, px
MIN_HALF_EXTENT: 12, // smallest half-extent any body may have (anti-tunnel)
};
// ── Small math helpers ──────────────────────────────────────────────────────
const cross = (ax, ay, bx, by) => ax * by - ay * bx;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// Numeric composite keys. Body ids are dense and small, so this is exact well
// past any level we'd author, and it keeps Map lookups off the string path.
const PAIR_STRIDE = 1 << 20;
const pairKey = (aId, bId) => aId * PAIR_STRIDE + bId;
// Grid cells can be negative, so bias into non-negative space before packing.
const GRID_BIAS = 1 << 14;
const cellKey = (gx, gy) => (gx + GRID_BIAS) * (1 << 15) + (gy + GRID_BIAS);
// ── Mass properties ─────────────────────────────────────────────────────────
// Signed shoelace area. Positive means the winding matches our normal formula.
function signedArea(verts) {
let a = 0;
for (let i = 0; i < verts.length; i += 1) {
const [x0, y0] = verts[i];
const [x1, y1] = verts[(i + 1) % verts.length];
a += x0 * y1 - x1 * y0;
}
return a * 0.5;
}
// Area, centroid and second moment of a convex polygon, integrated over the
// triangle fan from the origin. Inertia comes out about the ORIGIN, so it is
// shifted to the centroid with the parallel-axis theorem before returning.
function polyMassData(verts, density) {
let area = 0;
let cx = 0;
let cy = 0;
let inertia = 0;
for (let i = 0; i < verts.length; i += 1) {
const [x0, y0] = verts[i];
const [x1, y1] = verts[(i + 1) % verts.length];
const c = x0 * y1 - x1 * y0;
area += c;
cx += (x0 + x1) * c;
cy += (y0 + y1) * c;
inertia += c * (x0 * x0 + x0 * x1 + x1 * x1 + y0 * y0 + y0 * y1 + y1 * y1);
}
area *= 0.5;
const inv6A = 1 / (6 * area);
cx *= inv6A;
cy *= inv6A;
const mass = density * Math.abs(area);
let I = density * Math.abs(inertia) / 12;
I -= mass * (cx * cx + cy * cy); // parallel axis: origin -> centroid
return { mass, cx, cy, I: Math.max(I, 1e-6) };
}
// ── World ───────────────────────────────────────────────────────────────────
export function createWorld(opts = {}) {
return {
bodies: [],
byId: new Map(),
// Keyed by pairKey(a,b) — a NUMBER, not a string. Contact lookup runs a few
// thousand times a substep and string keys dominated the profile.
contacts: new Map(),
// Rebuilt in pair order each substep; the solver iterates this, never the
// Map, so nothing depends on Map insertion history.
contactList: [],
nextId: 1,
gravity: opts.gravity ?? PHYS.GRAVITY,
accum: 0,
time: 0,
};
}
function addBody(world, body) {
body.id = world.nextId;
world.nextId += 1;
world.bodies.push(body);
world.byId.set(body.id, body);
return body;
}
function baseBody(o) {
return {
id: 0,
x: o.x, y: o.y, angle: o.angle ?? 0,
vx: 0, vy: 0, omega: 0,
// Pseudo-velocity for the split-impulse position solve. Accumulated during
// the bias pass, added into the position integration, then zeroed. It never
// survives a substep, so it can't inject energy into the real velocity.
psx: 0, psy: 0, psw: 0,
friction: o.friction ?? 0.5,
restitution: o.restitution ?? 0.1,
isStatic: !!o.isStatic,
sleeping: false,
sleepTimer: 0,
// Free-form pointer the rules layer hangs blocks/pigs/birds off. The solver
// never reads it, which is what keeps this file game-agnostic.
userData: o.userData ?? null,
aabb: { minx: 0, miny: 0, maxx: 0, maxy: 0 },
};
}
/** Convex polygon from local-space verts (any winding, recentred on centroid). */
export function addPoly(world, o) {
let verts = o.verts.map(([x, y]) => [x, y]);
if (signedArea(verts) < 0) verts.reverse();
const md = polyMassData(verts, o.density ?? 1);
// Recentre so the body origin IS the centre of mass; the solver assumes it.
verts = verts.map(([x, y]) => [x - md.cx, y - md.cy]);
const b = baseBody(o);
b.kind = 'poly';
b.verts = verts;
b.normals = verts.map(([x0, y0], i) => {
const [x1, y1] = verts[(i + 1) % verts.length];
const ex = x1 - x0;
const ey = y1 - y0;
const len = Math.hypot(ex, ey) || 1;
return [ey / len, -ex / len];
});
b.wverts = verts.map(() => [0, 0]);
b.wnormals = verts.map(() => [0, 0]);
b.radius = Math.max(...verts.map(([x, y]) => Math.hypot(x, y)));
b.invMass = o.isStatic ? 0 : 1 / md.mass;
b.invI = o.isStatic ? 0 : 1 / md.I;
b.mass = o.isStatic ? Infinity : md.mass;
addBody(world, b);
syncTransform(b);
return b;
}
/** Axis-aligned-at-rest box helper; `angle` still rotates it. */
export function addBox(world, o) {
const hw = o.w / 2;
const hh = o.h / 2;
return addPoly(world, {
...o,
verts: [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]],
});
}
export function addCircle(world, o) {
const b = baseBody(o);
b.kind = 'circle';
b.radius = o.r;
const mass = (o.density ?? 1) * Math.PI * o.r * o.r;
b.invMass = o.isStatic ? 0 : 1 / mass;
b.invI = o.isStatic ? 0 : 1 / (0.5 * mass * o.r * o.r);
b.mass = o.isStatic ? Infinity : mass;
addBody(world, b);
syncTransform(b);
return b;
}
export function removeBody(world, body) {
const i = world.bodies.indexOf(body);
if (i >= 0) world.bodies.splice(i, 1);
world.byId.delete(body.id);
for (const [key, c] of [...world.contacts]) {
if (c.aId === body.id || c.bId === body.id) world.contacts.delete(key);
}
world.contactList = world.contactList.filter((c) => c.aId !== body.id && c.bId !== body.id);
}
// Recompute world-space verts/normals and the AABB after a transform change.
function syncTransform(b) {
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
if (b.kind === 'poly') {
let minx = Infinity; let miny = Infinity; let maxx = -Infinity; let maxy = -Infinity;
for (let i = 0; i < b.verts.length; i += 1) {
const [lx, ly] = b.verts[i];
const wx = b.x + lx * c - ly * s;
const wy = b.y + lx * s + ly * c;
b.wverts[i][0] = wx;
b.wverts[i][1] = wy;
const [nx, ny] = b.normals[i];
b.wnormals[i][0] = nx * c - ny * s;
b.wnormals[i][1] = nx * s + ny * c;
if (wx < minx) minx = wx;
if (wy < miny) miny = wy;
if (wx > maxx) maxx = wx;
if (wy > maxy) maxy = wy;
}
b.aabb.minx = minx; b.aabb.miny = miny; b.aabb.maxx = maxx; b.aabb.maxy = maxy;
} else {
b.aabb.minx = b.x - b.radius; b.aabb.miny = b.y - b.radius;
b.aabb.maxx = b.x + b.radius; b.aabb.maxy = b.y + b.radius;
}
}
export function wakeBody(b) {
if (b.isStatic) return;
b.sleeping = false;
b.sleepTimer = 0;
}
export function applyImpulse(b, ix, iy, px, py) {
if (b.isStatic) return;
wakeBody(b);
b.vx += ix * b.invMass;
b.vy += iy * b.invMass;
if (px !== undefined) b.omega += cross(px - b.x, py - b.y, ix, iy) * b.invI;
}
// ── Broadphase ──────────────────────────────────────────────────────────────
// Uniform grid over AABBs, rebuilt each substep. Bodies here are large relative
// to their per-substep travel, so a rebuild is cheaper than incremental upkeep.
function broadphase(world) {
const cell = PHYS.GRID_CELL;
const grid = new Map();
const bodies = world.bodies;
for (let i = 0; i < bodies.length; i += 1) {
const b = bodies[i];
const x0 = Math.floor(b.aabb.minx / cell);
const x1 = Math.floor(b.aabb.maxx / cell);
const y0 = Math.floor(b.aabb.miny / cell);
const y1 = Math.floor(b.aabb.maxy / cell);
for (let gx = x0; gx <= x1; gx += 1) {
for (let gy = y0; gy <= y1; gy += 1) {
const key = cellKey(gx, gy);
let bucket = grid.get(key);
if (!bucket) { bucket = []; grid.set(key, bucket); }
bucket.push(b);
}
}
}
const pairs = [];
const seen = new Set();
for (const bucket of grid.values()) {
for (let i = 0; i < bucket.length; i += 1) {
for (let j = i + 1; j < bucket.length; j += 1) {
let a = bucket[i];
let b = bucket[j];
if (a.id > b.id) { const t = a; a = b; b = t; }
// A pair where neither side can move produces no useful contact. A
// sleeping body against a static one is already at rest by definition.
if (a.isStatic && b.isStatic) continue;
if (a.sleeping && b.sleeping) continue;
if (a.sleeping && b.isStatic) continue;
if (b.sleeping && a.isStatic) continue;
const key = pairKey(a.id, b.id);
if (seen.has(key)) continue;
seen.add(key);
if (a.aabb.maxx < b.aabb.minx || b.aabb.maxx < a.aabb.minx) continue;
if (a.aabb.maxy < b.aabb.miny || b.aabb.maxy < a.aabb.miny) continue;
pairs.push(a, b); // flat, to avoid a per-pair array allocation
}
}
}
// Sorted so the solve order never depends on Map/Set iteration order.
const order = [];
for (let i = 0; i < pairs.length; i += 2) order.push(i);
order.sort((p, q) => (pairs[p].id - pairs[q].id) || (pairs[p + 1].id - pairs[q + 1].id));
const sorted = [];
for (const i of order) sorted.push(pairs[i], pairs[i + 1]);
return sorted;
}
// ── Narrowphase ─────────────────────────────────────────────────────────────
// Largest separation of B's verts from any of A's faces. Negative => overlap.
function maxSeparation(a, b) {
let best = -Infinity;
let bestFace = 0;
for (let i = 0; i < a.wverts.length; i += 1) {
const [nx, ny] = a.wnormals[i];
const [vx, vy] = a.wverts[i];
// Support point of B in direction -n: the deepest vert against this face.
let lowest = Infinity;
for (let j = 0; j < b.wverts.length; j += 1) {
const d = (b.wverts[j][0] - vx) * nx + (b.wverts[j][1] - vy) * ny;
if (d < lowest) lowest = d;
}
if (lowest > best) { best = lowest; bestFace = i; }
}
return { sep: best, face: bestFace };
}
// The face of `inc` most anti-parallel to the reference normal.
function incidentFace(inc, refNx, refNy) {
let best = Infinity;
let bestFace = 0;
for (let i = 0; i < inc.wnormals.length; i += 1) {
const d = inc.wnormals[i][0] * refNx + inc.wnormals[i][1] * refNy;
if (d < best) { best = d; bestFace = i; }
}
return bestFace;
}
// Clip a segment against a half-plane, keeping the portion where
// dot(n, p) - offset <= 0. Feature ids ride along so warm starting can match
// points across frames even when clipping order changes.
function clipSegment(pts, nx, ny, offset) {
const out = [];
const d0 = pts[0].x * nx + pts[0].y * ny - offset;
const d1 = pts[1].x * nx + pts[1].y * ny - offset;
if (d0 <= 0) out.push(pts[0]);
if (d1 <= 0) out.push(pts[1]);
if (d0 * d1 < 0) {
const t = d0 / (d0 - d1);
out.push({
x: pts[0].x + t * (pts[1].x - pts[0].x),
y: pts[0].y + t * (pts[1].y - pts[0].y),
fid: d0 > 0 ? pts[1].fid : pts[0].fid,
});
}
return out;
}
function collidePolyPoly(a, b) {
const sa = maxSeparation(a, b);
if (sa.sep > 0) return null;
const sb = maxSeparation(b, a);
if (sb.sep > 0) return null;
// Prefer A as reference unless B is clearly deeper — the small bias keeps the
// choice stable frame to frame, which keeps feature ids (and warm starting)
// stable for a resting contact.
let ref = a; let inc = b; let refFace = sa.face; let flip = false;
if (sb.sep > sa.sep + 0.1 * Math.abs(sa.sep) + 0.01) {
ref = b; inc = a; refFace = sb.face; flip = true;
}
const [rnx, rny] = ref.wnormals[refFace];
const rv0 = ref.wverts[refFace];
const rv1 = ref.wverts[(refFace + 1) % ref.wverts.length];
const incFace = incidentFace(inc, rnx, rny);
const iv0 = inc.wverts[incFace];
const iv1 = inc.wverts[(incFace + 1) % inc.wverts.length];
// Tangent of the reference face; clip the incident edge to its side planes.
const tx = rv1[0] - rv0[0];
const ty = rv1[1] - rv0[1];
const tlen = Math.hypot(tx, ty) || 1;
const utx = tx / tlen;
const uty = ty / tlen;
let pts = [
{ x: iv0[0], y: iv0[1], fid: (refFace << 8) | incFace },
{ x: iv1[0], y: iv1[1], fid: (refFace << 8) | ((incFace + 1) % inc.wverts.length) },
];
pts = clipSegment(pts, -utx, -uty, -(rv0[0] * utx + rv0[1] * uty));
if (pts.length < 2) return null;
pts = clipSegment(pts, utx, uty, rv1[0] * utx + rv1[1] * uty);
if (pts.length < 2) return null;
const offset = rv0[0] * rnx + rv0[1] * rny;
const points = [];
for (const p of pts) {
const sep = p.x * rnx + p.y * rny - offset;
if (sep <= 0) points.push({ x: p.x, y: p.y, sep, fid: p.fid | (flip ? 0x10000 : 0) });
}
if (!points.length) return null;
// Normal must always point A -> B.
return { nx: flip ? -rnx : rnx, ny: flip ? -rny : rny, points };
}
function collideCirclePoly(c, p, circleIsA) {
// Work in the polygon's local frame so the face tests are cheap.
const cs = Math.cos(-p.angle);
const sn = Math.sin(-p.angle);
const dx = c.x - p.x;
const dy = c.y - p.y;
const lx = dx * cs - dy * sn;
const ly = dx * sn + dy * cs;
let best = -Infinity;
let bestFace = 0;
for (let i = 0; i < p.verts.length; i += 1) {
const [nx, ny] = p.normals[i];
const [vx, vy] = p.verts[i];
const d = (lx - vx) * nx + (ly - vy) * ny;
if (d > c.radius) return null;
if (d > best) { best = d; bestFace = i; }
}
const [v0x, v0y] = p.verts[bestFace];
const [v1x, v1y] = p.verts[(bestFace + 1) % p.verts.length];
let nlx; let nly; let sep;
if (best < 1e-6) {
// Centre is inside the polygon — push straight out along the closest face.
[nlx, nly] = p.normals[bestFace];
sep = best - c.radius;
} else {
// Voronoi regions: nearest to v0, to v1, or to the face interior.
const ex = v1x - v0x;
const ey = v1y - v0y;
const t = clamp(((lx - v0x) * ex + (ly - v0y) * ey) / (ex * ex + ey * ey), 0, 1);
const px = v0x + ex * t;
const py = v0y + ey * t;
const ddx = lx - px;
const ddy = ly - py;
const dist = Math.hypot(ddx, ddy);
if (dist > c.radius) return null;
if (dist < 1e-9) { [nlx, nly] = p.normals[bestFace]; } else { nlx = ddx / dist; nly = ddy / dist; }
sep = dist - c.radius;
}
// Back to world space. Local normal points polygon -> circle.
const wc = Math.cos(p.angle);
const ws = Math.sin(p.angle);
const nwx = nlx * wc - nly * ws;
const nwy = nlx * ws + nly * wc;
const contactX = c.x - nwx * (c.radius + sep * 0.5);
const contactY = c.y - nwy * (c.radius + sep * 0.5);
// Caller's A is `circleIsA ? c : p`; normal must run A -> B.
const sign = circleIsA ? -1 : 1;
return {
nx: nwx * sign, ny: nwy * sign,
points: [{ x: contactX, y: contactY, sep, fid: 1 }],
};
}
function collideCircleCircle(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const r = a.radius + b.radius;
const d2 = dx * dx + dy * dy;
if (d2 >= r * r) return null;
const d = Math.sqrt(d2);
let nx; let ny;
if (d < 1e-9) { nx = 0; ny = -1; } else { nx = dx / d; ny = dy / d; }
return {
nx, ny,
points: [{ x: a.x + nx * a.radius, y: a.y + ny * a.radius, sep: d - r, fid: 1 }],
};
}
function collide(a, b) {
if (a.kind === 'circle' && b.kind === 'circle') return collideCircleCircle(a, b);
if (a.kind === 'circle') return collideCirclePoly(a, b, true);
if (b.kind === 'circle') return collideCirclePoly(b, a, false);
return collidePolyPoly(a, b);
}
// ── Contact bookkeeping ─────────────────────────────────────────────────────
// Rebuild manifolds, carrying accumulated impulses across from last substep by
// feature id. This is the warm-starting half of stack stability; matching by
// array index instead would scramble impulses the moment a clip order flips.
function updateContacts(world, pairs) {
const live = new Set();
const list = [];
for (let pi = 0; pi < pairs.length; pi += 2) {
const a = pairs[pi];
const b = pairs[pi + 1];
const m = collide(a, b);
const key = pairKey(a.id, b.id);
if (!m) { world.contacts.delete(key); continue; }
live.add(key);
const prev = world.contacts.get(key);
const points = m.points.map((p) => {
const old = prev?.points.find((q) => q.fid === p.fid);
return {
x: p.x, y: p.y, sep: p.sep, fid: p.fid,
pn: old?.pn ?? 0, pt: old?.pt ?? 0,
pnBias: 0, // never warm-started: position error is transient
massNormal: 0, massTangent: 0, bias: 0, restTarget: 0,
rax: 0, ray: 0, rbx: 0, rby: 0,
};
});
const contact = {
aId: a.id, bId: b.id, a, b,
nx: m.nx, ny: m.ny,
friction: Math.sqrt(a.friction * b.friction),
restitution: Math.max(a.restitution, b.restitution),
points,
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);
}
for (const [key] of [...world.contacts]) {
if (!live.has(key)) world.contacts.delete(key);
}
// Pairs arrive pre-sorted by (aId, bId), so this list is already in the
// canonical solve order and needs no further sorting.
world.contactList = list;
}
// The solver's contact iteration order. Never iterate world.contacts directly:
// Map order depends on insertion history, which would make the sim depend on
// the order bodies happened to be created in.
function orderedContacts(world) {
return world.contactList;
}
function prestep(world, h) {
const invH = 1 / h;
for (const c of orderedContacts(world)) {
const a = c.a;
const b = c.b;
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;
p.rbx = p.x - b.x; p.rby = p.y - b.y;
const rnA = cross(p.rax, p.ray, nx, ny);
const rnB = cross(p.rbx, p.rby, nx, ny);
p.massNormal = 1 / (a.invMass + b.invMass + a.invI * rnA * rnA + b.invI * rnB * rnB);
const rtA = cross(p.rax, p.ray, tx, ty);
const rtB = cross(p.rbx, p.rby, tx, ty);
p.massTangent = 1 / (a.invMass + b.invMass + a.invI * rtA * rtA + b.invI * rtB * rtB);
// Position error is corrected by a SEPARATE pseudo-velocity pass (split
// impulse), not by biasing the real velocity. With plain Baumgarte the
// steady-state penetration is proportional to the load, so the bottom
// contact of a 10-box tower sinks ~10x deeper than the top one and the
// stack visibly sags. Driving position through pseudo-velocities removes
// a fixed fraction of the excess per substep regardless of load, and the
// correction never leaks into the real velocity as bounce energy.
p.bias = -PHYS.BAUMGARTE * invH * Math.min(0, p.sep + PHYS.SLOP);
p.pnBias = 0;
// Restitution, sampled once from the APPROACH velocity. Sampling it every
// iteration would let a resting box slowly bounce itself apart.
const rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega);
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; }
}
function warmStart(world) {
for (const c of orderedContacts(world)) {
const a = c.a;
const b = c.b;
const { nx, ny } = c;
const tx = -ny;
const ty = nx;
for (const p of c.points) {
const ix = p.pn * nx + p.pt * tx;
const iy = p.pn * ny + p.pt * ty;
if (!a.isStatic) {
a.vx -= ix * a.invMass; a.vy -= iy * a.invMass;
a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI;
}
if (!b.isStatic) {
b.vx += ix * b.invMass; b.vy += iy * b.invMass;
b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI;
}
}
}
}
function solveVelocities(world) {
const contacts = orderedContacts(world);
for (let iter = 0; iter < PHYS.VEL_ITERS; iter += 1) {
for (const c of contacts) {
const a = c.a;
const b = c.b;
const { nx, ny } = c;
const tx = -ny;
const ty = nx;
for (const p of c.points) {
// Normal impulse. Accumulate then clamp to >= 0, applying only the
// delta — clamping the per-iteration impulse instead would let a
// contact pull bodies together.
let rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega);
let rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega);
const vn = rvx * nx + rvy * ny;
let dPn = p.massNormal * (-vn + p.restTarget);
const pn0 = p.pn;
p.pn = Math.max(pn0 + dPn, 0);
dPn = p.pn - pn0;
let ix = dPn * nx;
let iy = dPn * ny;
if (!a.isStatic) {
a.vx -= ix * a.invMass; a.vy -= iy * a.invMass;
a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI;
}
if (!b.isStatic) {
b.vx += ix * b.invMass; b.vy += iy * b.invMass;
b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI;
}
// Split-impulse position pass, run on pseudo-velocities only.
const pvx = (b.psx - p.rby * b.psw) - (a.psx - p.ray * a.psw);
const pvy = (b.psy + p.rbx * b.psw) - (a.psy + p.rax * a.psw);
const pvn = pvx * nx + pvy * ny;
let dPb = p.massNormal * (-pvn + p.bias);
const pb0 = p.pnBias;
p.pnBias = Math.max(pb0 + dPb, 0);
dPb = p.pnBias - pb0;
const bx = dPb * nx;
const by = dPb * ny;
if (!a.isStatic) {
a.psx -= bx * a.invMass; a.psy -= by * a.invMass;
a.psw -= cross(p.rax, p.ray, bx, by) * a.invI;
}
if (!b.isStatic) {
b.psx += bx * b.invMass; b.psy += by * b.invMass;
b.psw += cross(p.rbx, p.rby, bx, by) * b.invI;
}
// Coulomb friction, clamped against the ACCUMULATED normal impulse.
rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega);
rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega);
const vt = rvx * tx + rvy * ty;
let dPt = p.massTangent * -vt;
const maxPt = c.friction * p.pn;
const pt0 = p.pt;
p.pt = clamp(pt0 + dPt, -maxPt, maxPt);
dPt = p.pt - pt0;
ix = dPt * tx;
iy = dPt * ty;
if (!a.isStatic) {
a.vx -= ix * a.invMass; a.vy -= iy * a.invMass;
a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI;
}
if (!b.isStatic) {
b.vx += ix * b.invMass; b.vy += iy * b.invMass;
b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI;
}
}
}
}
for (const c of contacts) {
let peak = 0;
for (const p of c.points) if (p.pn > peak) peak = p.pn;
c.maxImpulse = peak;
}
}
// ── Islands and sleeping ────────────────────────────────────────────────────
// Union-find over contacts between dynamic bodies. An island sleeps only when
// every member has been slow for SLEEP_TIME — per-body sleeping would freeze
// half a tower while the rest still moves.
function updateSleeping(world, h) {
const parent = new Map();
const find = (x) => {
let r = x;
while (parent.get(r) !== r) r = parent.get(r);
while (parent.get(x) !== r) { const nx = parent.get(x); parent.set(x, r); x = nx; }
return r;
};
const union = (x, y) => {
const rx = find(x);
const ry = find(y);
if (rx !== ry) parent.set(rx > ry ? rx : ry, rx > ry ? ry : rx);
};
for (const b of world.bodies) if (!b.isStatic) parent.set(b.id, b.id);
for (const c of orderedContacts(world)) {
const a = c.a;
const b = c.b;
if (!a.isStatic && !b.isStatic) union(a.id, b.id);
}
// Per-body slow timer.
for (const b of world.bodies) {
if (b.isStatic || b.sleeping) continue;
const slow = Math.hypot(b.vx, b.vy) < PHYS.SLEEP_LIN && Math.abs(b.omega) < PHYS.SLEEP_ANG;
b.sleepTimer = slow ? b.sleepTimer + h : 0;
}
// An island's timer is its slowest member's.
const islandTimer = new Map();
for (const b of world.bodies) {
if (b.isStatic) continue;
const root = find(b.id);
const t = b.sleeping ? Infinity : b.sleepTimer;
const cur = islandTimer.get(root);
if (cur === undefined || t < cur) islandTimer.set(root, t);
}
for (const b of world.bodies) {
if (b.isStatic) continue;
const t = islandTimer.get(find(b.id)) ?? 0;
if (t >= PHYS.SLEEP_TIME) {
b.sleeping = true;
b.vx = 0; b.vy = 0; b.omega = 0;
} else if (t < PHYS.SLEEP_TIME && b.sleeping) {
// A neighbour woke up; the whole island comes with it.
b.sleeping = false;
b.sleepTimer = 0;
}
}
}
// ── Integration ─────────────────────────────────────────────────────────────
function integrateVelocities(world, h) {
for (const b of world.bodies) {
if (b.isStatic || b.sleeping) continue;
b.vy += world.gravity * h;
b.vx *= 1 - PHYS.LINEAR_DAMPING;
b.vy *= 1 - PHYS.LINEAR_DAMPING;
b.omega *= 1 - PHYS.ANGULAR_DAMPING;
}
}
function integratePositions(world, h) {
for (const b of world.bodies) {
if (b.isStatic || b.sleeping) continue;
const sp = Math.hypot(b.vx, b.vy);
if (sp > PHYS.MAX_SPEED) { const k = PHYS.MAX_SPEED / sp; b.vx *= k; b.vy *= k; }
b.omega = clamp(b.omega, -PHYS.MAX_OMEGA, PHYS.MAX_OMEGA);
// Pseudo-velocity moves the body but is then discarded, so position error
// is repaired without the correction showing up as momentum next substep.
b.x += (b.vx + b.psx) * h;
b.y += (b.vy + b.psy) * h;
b.angle += (b.omega + b.psw) * h;
b.psx = 0; b.psy = 0; b.psw = 0;
syncTransform(b);
}
}
// ── The frame ───────────────────────────────────────────────────────────────
export function substep(world, h) {
integrateVelocities(world, h);
const pairs = broadphase(world);
updateContacts(world, pairs);
prestep(world, h);
warmStart(world);
solveVelocities(world);
integratePositions(world, h);
updateSleeping(world, h);
world.time += h;
}
/**
* Advance by `dt` seconds in whole fixed substeps. Any leftover is carried in
* world.accum, which is what makes 1/60 and 1/120 frames produce identical
* trajectories — the property tools/verifyAngryBirds.js asserts.
*/
export function step(world, dt) {
world.accum += Math.min(dt, 0.05); // a backgrounded tab must not teleport
let steps = 0;
while (world.accum >= PHYS.SUBSTEP_DT && steps < 16) {
substep(world, PHYS.SUBSTEP_DT);
world.accum -= PHYS.SUBSTEP_DT;
steps += 1;
}
return steps;
}
/** True when nothing is moving — the rules layer's "shot is over" test. */
export function isSettled(world) {
for (const b of world.bodies) {
if (b.isStatic) continue;
if (!b.sleeping) return false;
}
return true;
}
/** Peak normal impulse seen on each body this substep, keyed by body id. */
export function contactImpulses(world) {
const out = new Map();
for (const c of world.contacts.values()) {
if (c.maxImpulse <= 0) continue;
for (const id of [c.aId, c.bId]) {
const cur = out.get(id) ?? 0;
if (c.maxImpulse > cur) out.set(id, c.maxImpulse);
}
}
return out;
}
/** Radial impulse + falloff, used by Bomb and TNT. Returns bodies affected. */
export function applyExplosion(world, x, y, radius, power) {
const hit = [];
for (const b of world.bodies) {
if (b.isStatic) continue;
const dx = b.x - x;
const dy = b.y - y;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const falloff = 1 - d / radius;
const nx = d < 1e-6 ? 0 : dx / d;
const ny = d < 1e-6 ? -1 : dy / d;
const j = power * falloff * b.mass;
applyImpulse(b, nx * j, ny * j, b.x, b.y);
hit.push({ body: b, falloff, dist: d });
}
return hit;
}
// ── Determinism helpers ─────────────────────────────────────────────────────
export function cloneWorld(world) {
const copy = createWorld({ gravity: world.gravity });
copy.nextId = world.nextId;
copy.accum = world.accum;
copy.time = world.time;
for (const b of world.bodies) {
const nb = { ...b, aabb: { ...b.aabb } };
if (b.kind === 'poly') {
nb.verts = b.verts.map((v) => [v[0], v[1]]);
nb.normals = b.normals.map((v) => [v[0], v[1]]);
nb.wverts = b.wverts.map((v) => [v[0], v[1]]);
nb.wnormals = b.wnormals.map((v) => [v[0], v[1]]);
}
copy.bodies.push(nb);
copy.byId.set(nb.id, nb);
}
// Contacts cache direct body references for speed, so a shallow spread would
// leave the clone's contacts pointing at the ORIGINAL world's bodies — the
// clone would silently drive the thing it was supposed to leave untouched.
// Rewire every reference through the copy's id map.
for (const [key, c] of world.contacts) {
copy.contacts.set(key, {
...c,
a: copy.byId.get(c.aId),
b: copy.byId.get(c.bId),
points: c.points.map((p) => ({ ...p })),
});
}
copy.contactList = world.contactList.map((c) => copy.contacts.get(pairKey(c.aId, c.bId))).filter(Boolean);
return copy;
}
// FNV-1a over the Float64 bit patterns of every body's transform and velocity.
// Hashing the bits rather than rounded values means a 1-ULP divergence still
// shows up, which is the point.
export function hashWorld(world) {
const buf = new ArrayBuffer(8);
const f64 = new Float64Array(buf);
const u32 = new Uint32Array(buf);
let h = 0x811c9dc5;
const mix = (v) => {
f64[0] = v;
for (let i = 0; i < 2; i += 1) {
h ^= u32[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
};
const sorted = [...world.bodies].sort((a, b) => a.id - b.id);
for (const b of sorted) {
mix(b.x); mix(b.y); mix(b.angle);
mix(b.vx); mix(b.vy); mix(b.omega);
mix(b.sleeping ? 1 : 0);
}
return h >>> 0;
}
/** Run until everything sleeps (or `maxSeconds` elapses). Used by the editor. */
export function settle(world, maxSeconds = 10) {
const limit = Math.ceil(maxSeconds / PHYS.SUBSTEP_DT);
for (let i = 0; i < limit; i += 1) {
substep(world, PHYS.SUBSTEP_DT);
if (isSettled(world)) return true;
}
return isSettled(world);
}

View File

@ -68,6 +68,7 @@ const ZOOM_MAX = 9;
const ZOOM_STEP = 1.15;
const VIEW_KEEP = 0.35; // fraction of the viewport that must stay over the board
const EDGE_PAN_THRESHOLD = 50; // px from screen edge to trigger panning
const BG_PARALLAX = 0.15; // fraction of the camera's pan/zoom the background follows, so it reads as farther away
const shade = (color, f) => {
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
@ -82,6 +83,31 @@ const lerpColor = (a, b, t) => {
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
};
// Vertical extent of a level's actual content (terrain, pipe, balls) — used to
// stop the camera ever showing empty space below the floor or above the
// topmost element, rather than the old fixed-world-height slack. Computed
// once at level start: balls drift during simulation, and a bound that
// chased live ball positions would jitter the clamp as the tower settles.
function levelVerticalBounds(state) {
let top = Infinity;
let bottom = -Infinity;
for (const t of state.terrain) {
if (t.bounds.minY < top) top = t.bounds.minY;
if (t.bounds.maxY > bottom) bottom = t.bounds.maxY;
}
if (state.pipe) {
top = Math.min(top, state.pipe.y - state.pipe.r);
bottom = Math.max(bottom, state.pipe.y + state.pipe.r);
}
for (const b of state.balls) {
if (!b) continue;
top = Math.min(top, b.y - TUNING.BALL_R);
bottom = Math.max(bottom, b.y + TUNING.BALL_R);
}
if (top === Infinity) { top = 0; bottom = WORLD_H; }
return { top, bottom };
}
export default class GooTowerGame extends Phaser.Scene {
constructor() { super('GooTowerGame'); }
@ -110,6 +136,9 @@ export default class GooTowerGame extends Phaser.Scene {
this.panFrom = null;
this.introAnim = null; // intro animation state
this.debugZoomText = null;
this.levelBounds = null; // { top, bottom }, set per level in startLevel()
this.bgImage = null;
this.suckFX = []; // short-lived cosmetic tails for balls the pipe just collected
}
async create() {
@ -145,12 +174,33 @@ export default class GooTowerGame extends Phaser.Scene {
}
drawBackground() {
this.bgImage = null;
if (this.textures.exists('gootower-bg')) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'gootower-bg')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sky);
const img = this.track(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'gootower-bg')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sky));
// Base scale at rest (view = VIEW_HOME); parallax below multiplies on top
// of this rather than the raw texture scale, so it stays correct
// whatever size the source image is.
this.bgBaseScaleX = img.scaleX;
this.bgBaseScaleY = img.scaleY;
this.bgImage = img;
}
}
// Moves/zooms the background by a fraction of the camera's own pan/zoom, so
// it reads as a distant layer instead of being glued to the foreground.
// Driven from applyView() so it stays in sync with drag-pan, wheel-zoom,
// edge-pan and the level-intro cinematic alike.
updateBackgroundParallax() {
const img = this.bgImage;
if (!img) return;
const zoomFactor = 1 + (this.view.scale - VIEW_HOME.scale) * BG_PARALLAX;
const panDX = (this.view.ox - VIEW_HOME.ox) * BG_PARALLAX;
const panDY = (this.view.oy - VIEW_HOME.oy) * BG_PARALLAX;
img.setScale(this.bgBaseScaleX * zoomFactor, this.bgBaseScaleY * zoomFactor);
img.setPosition(GAME_WIDTH / 2 + panDX, GAME_HEIGHT / 2 + panDY);
}
clearBoard() {
for (const o of this.boardObjs) o.destroy();
this.boardObjs = [];
@ -163,6 +213,7 @@ export default class GooTowerGame extends Phaser.Scene {
this.terrainG = null;
this.board = null;
this.panFrom = null;
this.suckFX = [];
this.needText = null;
this.hudText = null;
if (this.introAnim) { this.tweens.killTweensOf(this.introAnim); this.introAnim = null; }
@ -328,6 +379,7 @@ export default class GooTowerGame extends Phaser.Scene {
this.level = level;
this.levelDef = def;
this.state = createState(def, 0x60071e + level);
this.levelBounds = levelVerticalBounds(this.state);
this.accum = 0;
this.held = null;
this.wasAttached = false;
@ -486,6 +538,7 @@ export default class GooTowerGame extends Phaser.Scene {
applyView() {
this.clampView();
this.updateBackgroundParallax();
if (!this.board) return;
this.board.setScale(this.view.scale);
this.board.setPosition(this.view.ox, this.view.oy);
@ -502,7 +555,21 @@ export default class GooTowerGame extends Phaser.Scene {
Math.min(viewport * (1 - VIEW_KEEP), v),
);
this.view.ox = clampAxis(this.view.ox, WORLD_W * this.view.scale, GAME_WIDTH);
this.view.oy = clampAxis(this.view.oy, WORLD_H * this.view.scale, GAME_HEIGHT);
// Y axis is a hard stop at the level's own content, not fixed world slack:
// the floor must never scroll off the bottom of the screen, and nothing
// above the topmost element should ever come into view.
const { top, bottom } = this.levelBounds || { top: 0, bottom: WORLD_H };
const scale = this.view.scale;
if ((bottom - top) * scale <= GAME_HEIGHT) {
// Content is shorter than the viewport at this zoom -- centre it instead
// of leaving it free to slide between two clamps that would overlap.
this.view.oy = GAME_HEIGHT / 2 - ((top + bottom) / 2) * scale;
} else {
const oyMax = -top * scale;
const oyMin = GAME_HEIGHT - bottom * scale;
this.view.oy = Math.max(oyMin, Math.min(oyMax, this.view.oy));
}
}
// Zoom about a screen point: the world position under the cursor must stay
@ -607,6 +674,11 @@ export default class GooTowerGame extends Phaser.Scene {
const events = stepSim(this.state, dt);
this.handleEvents(events);
if (this.suckFX.length) {
for (const fx of this.suckFX) fx.t += dt;
this.suckFX = this.suckFX.filter((fx) => fx.t < fx.dur);
}
this.drawDynamic();
this.refreshHud();
@ -638,7 +710,24 @@ export default class GooTowerGame extends Phaser.Scene {
handleEvents(events) {
for (const e of events) {
if (e.type === 'strandBreak') playSound(this, SFX.SQUASH);
else if (e.type === 'collect') playSound(this, SFX.UI_CHIME);
else if (e.type === 'collect') {
playSound(this, SFX.UI_CHIME);
// The sim deletes the ball this same tick; spawn a short cosmetic
// tail continuing the pre-collect pull tendril so it reads as being
// swallowed rather than popping out of existence.
const b = this.state.balls[e.ball];
const pipe = this.state.pipe;
if (b && pipe) {
const ddx = pipe.x - b.x;
const ddy = pipe.y - b.y;
const dlen = Math.hypot(ddx, ddy) || 1;
this.suckFX.push({
x: b.x, y: b.y, dirX: ddx / dlen, dirY: ddy / dlen,
r: b.r, color: GOO_COLOR[b.type] ?? GOO_COLOR.common,
t: 0, dur: 0.18,
});
}
}
else if (e.type === 'ballDie') playSound(this, SFX.SQUISH);
else if (e.type === 'pipeOpen') playSound(this, SFX.GEM_DROP);
else if (e.type === 'stick') playSound(this, SFX.PIECE_CLICK);
@ -731,6 +820,7 @@ export default class GooTowerGame extends Phaser.Scene {
this.drawBall(g, b);
}
this.drawSuckFX(g);
this.drawPipe(g);
this.drawGhost();
}
@ -745,6 +835,25 @@ export default class GooTowerGame extends Phaser.Scene {
const vy = b.y - b.py;
const sp = Math.min(1, Math.hypot(vx, vy) / 6);
// A walking ball nearing an open pipe grows a tapering tendril reaching
// toward the mouth, so the last stretch of its walk visibly pulls it in
// rather than the sim's instant collect reading as a random disappearance.
// Drawn UNDER the body circles below so it merges seamlessly into the ball.
const pipe = this.state.pipe;
if (b.walking && pipe && pipe.open) {
const pdx = pipe.x - x;
const pdy = pipe.y - y;
const pdist = Math.hypot(pdx, pdy);
const PULL_RANGE = b.r * 7;
if (pdist < PULL_RANGE) {
const pullAmt = Math.pow(1 - pdist / PULL_RANGE, 1.6);
const inv = pdist > 0.001 ? 1 / pdist : 0;
const pullX = pdx * inv;
const pullY = pdy * inv;
this.drawPullTendril(g, x, y, pullX, pullY, b.r, pullAmt, base);
}
}
g.fillStyle(shade(base, 0.55), 1);
g.fillCircle(x, y + 2, b.r);
g.fillStyle(base, 1);
@ -774,21 +883,89 @@ export default class GooTowerGame extends Phaser.Scene {
g.strokeCircle(x, y, b.r + 3);
}
// Eyes. Pupils lean into the direction of motion; loose goo has two eyes,
// structural goo one, which is a cheap way to read the pile at a glance.
// Eyes. Pupils lean into the direction of motion and, at rest, drift in a
// slow per-ball wander so the pupils read as looking around inside a
// round head instead of sitting dead-center -- the cheapest way to sell a
// flat circle as a 3D ball. Loose goo has two eyes, structural goo one,
// which is also a cheap way to read the pile at a glance. Every ball
// blinks on its own desynced cycle (seeded off its id) rather than a
// shared timer, so a pile of goo never blinks in unison.
const eyes = b.attached ? 1 : 2;
const er = Math.max(2.6, b.r * 0.30);
const pr = er * 0.52;
const dx = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
const dy = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
const seed = b.id * 2.399963; // irrational spacing keeps per-ball cycles desynced
const wt = this.state.clock * 0.6 + seed;
const wanderX = Math.sin(wt) * er * 0.3;
const wanderY = Math.sin(wt * 0.63 + 1.7) * er * 0.2;
const leanX = sp > 0.02 ? (vx / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
const leanY = sp > 0.02 ? (vy / (Math.hypot(vx, vy) || 1)) * er * 0.35 : 0;
let dx = leanX + wanderX;
let dy = leanY + wanderY;
const maxOff = er - pr * 0.9;
const offLen = Math.hypot(dx, dy);
if (offLen > maxOff) { dx = (dx / offLen) * maxOff; dy = (dy / offLen) * maxOff; }
const blinkPeriod = 2.6 + (b.id % 7) * 0.45;
const blinkDur = 0.14;
const bt = (this.state.clock + seed * 3.1) % blinkPeriod;
const closeAmt = bt < blinkDur ? Math.sin((bt / blinkDur) * Math.PI) : 0;
const eyeScaleY = Math.max(0.12, 1 - closeAmt);
const spread = eyes === 2 ? b.r * 0.38 : 0;
for (let i = 0; i < eyes; i += 1) {
const ex = x + (eyes === 2 ? (i === 0 ? -spread : spread) : 0);
const ey = y - b.r * 0.12;
g.fillStyle(0xffffff, 1);
g.fillCircle(ex, ey, er);
g.fillEllipse(ex, ey, er * 2, er * 2 * eyeScaleY);
g.fillStyle(0x101018, 1);
g.fillCircle(ex + dx, ey + dy, pr);
g.fillEllipse(ex + dx, ey + dy * eyeScaleY, pr * 2, pr * 2 * eyeScaleY);
}
}
// A tapering blob stretching from (x, y) toward unit direction (dirX, dirY),
// built the same way strands are (a perpendicular-offset polygon) rather
// than a canvas rotate/scale, so it works for any pull direction without
// having to un-rotate the body's fixed-light-source highlight. `amt` in
// [0,1] drives both how far it reaches and how much it thins toward the
// tip; `alpha` lets the post-collect fade dim it out as it finishes.
drawPullTendril(g, x, y, dirX, dirY, r, amt, color, alpha = 1) {
const len = r * (0.5 + 3.2 * amt);
const nx = -dirY;
const ny = dirX;
const baseW = r * 2 * (1 - 0.2 * amt);
const tipW = r * 2 * Math.max(0.05, 0.5 - 0.45 * amt);
const steps = 6;
const verts = [];
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const w = baseW + (tipW - baseW) * t;
const cx = x + dirX * len * t;
const cy = y + dirY * len * t;
verts.push({ x: cx + nx * (w / 2), y: cy + ny * (w / 2) });
}
for (let i = steps; i >= 0; i -= 1) {
const t = i / steps;
const w = baseW + (tipW - baseW) * t;
const cx = x + dirX * len * t;
const cy = y + dirY * len * t;
verts.push({ x: cx - nx * (w / 2), y: cy - ny * (w / 2) });
}
g.fillStyle(color, alpha);
g.fillPoints(verts, true);
}
// Short-lived cosmetic tail spawned when a ball is instantly collected by
// the pipe (see handleEvents 'collect'). The sim has already deleted the
// ball by the time this plays, so it stands in for a few frames to turn
// that hard cut into a continuation of the pre-collect pull tendril above,
// shrinking and reaching further in as it fades.
drawSuckFX(g) {
if (!this.suckFX.length) return;
for (const fx of this.suckFX) {
const t = Math.min(1, fx.t / fx.dur);
const fade = 1 - t;
if (fade <= 0.02) continue;
this.drawPullTendril(g, fx.x, fx.y, fx.dirX, fx.dirY, fx.r * fade, 0.6 + 0.4 * t, fx.color, fade);
}
}

View File

@ -983,11 +983,14 @@ export function placeBall(state, ball, x, y, events = null) {
// The nearest ball the player is allowed to pick up:
//
// 1. Loose (unattached) balls within reach — preferred so you can grab crawlers
// without accidentally detaching structural balls.
// 2. Attached detachable balls ONLY when clicked directly on them (center-to-
// center distance ≤ ball radius). A glancing pick near the edge does NOT
// detach them — this prevents the "oops I ripped off my ivy" problem.
// 1. Attached detachable balls, but ONLY when clicked directly on them
// (center-to-center distance ≤ ball radius) — a direct click reads as
// "detach this one", even if a loose ball happens to be sitting right
// next to it (e.g. a crawler that wandered up against the structure).
// 2. Otherwise, the nearest loose (unattached) ball within reach — this is
// what lets you grab a crawler by clicking near it without detaching
// structure, since a glancing click near the edge of an attached ball
// does NOT count as "direct" and falls through to here instead.
//
// Non-detachable attached balls are never returned.
export function pickBallAt(state, x, y, reach = 42) {
@ -1008,10 +1011,11 @@ export function pickBallAt(state, x, y, reach = 42) {
if (d <= reach + b.r && d < bestLooseD) { bestLooseD = d; bestLoose = b; }
}
}
// Prefer a loose ball; only return an attached one if clicked directly on it
// and there's no loose ball closer.
if (bestLoose) return bestLoose;
return bestDirect;
// A direct hit on a detachable structural ball wins even over a nearby
// loose ball; only fall back to the loose ball when the click wasn't
// squarely on the attached one.
if (bestDirect) return bestDirect;
return bestLoose;
}
export function beginDrag(state, ball, events = null) {

View File

@ -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,
],
};

View File

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

View File

@ -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');

882
tools/verifyAngryBirds.js Normal file
View File

@ -0,0 +1,882 @@
// Headless verification for Angry Birds.
// node tools/verifyAngryBirds.js
// Exits non-zero on any failure.
//
// 1. Physics invariants — mass properties, resting contacts, stack stability,
// friction, restitution, anti-tunnel bound, explosion falloff.
// 2. Determinism — seeded replay, frame-rate independence, clone independence.
// 3. Robustness — random-impulse monkey test for NaN / overspeed / sinking.
//
// 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;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
}
const near = (a, b, tol) => Math.abs(a - b) <= tol;
function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); }
// Deterministic RNG for the monkey test (never Math.random — see the header
// contract in AngryBirdsPhysics.js).
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Ground plane spanning the test area. */
function withGround(world, y = 800) {
addBox(world, { x: 600, y: y + 50, w: 4000, h: 100, isStatic: true, friction: 0.7 });
return world;
}
// ── 1. Mass properties ──────────────────────────────────────────────────────
section('1. Mass properties');
{
const w = createWorld();
const b = addBox(w, { x: 0, y: 0, w: 40, h: 20, density: 2 });
const expectMass = 2 * 40 * 20;
check('box mass = density x w x h', near(b.mass, expectMass, 1e-6), `${b.mass} vs ${expectMass}`);
const expectI = (expectMass * (40 * 40 + 20 * 20)) / 12;
check('box inertia = m(w^2+h^2)/12', near(1 / b.invI, expectI, expectI * 1e-6),
`${1 / b.invI} vs ${expectI}`);
const c = addCircle(w, { x: 0, y: 0, r: 10, density: 3 });
const cMass = 3 * Math.PI * 100;
check('circle mass = density x pi r^2', near(c.mass, cMass, 1e-6), `${c.mass} vs ${cMass}`);
const s = addBox(w, { x: 0, y: 0, w: 10, h: 10, isStatic: true });
check('static body has zero inverse mass', s.invMass === 0 && s.invI === 0);
}
{
// Winding must be normalized: a CW polygon must produce the same body as CCW.
const w = createWorld();
const ccw = addPoly(w, { x: 0, y: 0, verts: [[-10, -10], [10, -10], [10, 10], [-10, 10]], density: 1 });
const cw = addPoly(w, { x: 0, y: 0, verts: [[-10, 10], [10, 10], [10, -10], [-10, -10]], density: 1 });
check('polygon winding normalized', near(ccw.mass, cw.mass, 1e-9) && near(ccw.invI, cw.invI, 1e-12),
`${ccw.mass}/${cw.mass}`);
// Outward normals must point away from the centroid.
const outward = ccw.normals.every(([nx, ny], i) => {
const [vx, vy] = ccw.verts[i];
return vx * nx + vy * ny > 0;
});
check('polygon normals point outward', outward);
}
{
// Verts are recentred on the centroid, so an off-centre polygon still
// rotates about its true centre of mass.
const w = createWorld();
const b = addPoly(w, { x: 0, y: 0, verts: [[0, 0], [40, 0], [40, 20], [0, 20]], density: 1 });
const cx = b.verts.reduce((s, v) => s + v[0], 0) / b.verts.length;
const cy = b.verts.reduce((s, v) => s + v[1], 0) / b.verts.length;
check('polygon recentred on centroid', near(cx, 0, 1e-9) && near(cy, 0, 1e-9), `${cx},${cy}`);
}
// ── 2. Anti-tunnelling bound ────────────────────────────────────────────────
section('2. Anti-tunnelling');
{
const travel = PHYS.MAX_SPEED * PHYS.SUBSTEP_DT;
check('MAX_SPEED x SUBSTEP_DT < MIN_HALF_EXTENT',
travel < PHYS.MIN_HALF_EXTENT, `${travel.toFixed(2)} !< ${PHYS.MIN_HALF_EXTENT}`);
}
{
// A fast circle fired at a thin static wall must not pass through it.
const w = createWorld();
addBox(w, { x: 400, y: 300, w: 20, h: 400, isStatic: true });
const ball = addCircle(w, { x: 100, y: 300, r: 12, density: 1 });
ball.vx = PHYS.MAX_SPEED;
w.gravity = 0;
for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('fast body does not tunnel through a wall', ball.x < 400, `x=${ball.x.toFixed(1)}`);
}
// ── 3. Resting contacts and stack stability (THE WAVE 0 GATE) ───────────────
section('3. Resting contacts and stacks');
{
const w = withGround(createWorld());
const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 });
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
// Ground top is y=800; a 60-tall box rests with its centre at 770.
check('single box rests on ground', near(b.y, 770, PHYS.SLOP + 0.5), `y=${b.y.toFixed(3)}`);
check('resting box does not sink', b.y < 772, `y=${b.y.toFixed(3)}`);
check('resting box falls asleep', b.sleeping, `timer=${b.sleepTimer.toFixed(2)}`);
}
{
// THE GATE: a 10-box tower must settle, sleep, and not drift.
const w = withGround(createWorld());
const boxes = [];
// Ground top is y=800, boxes are 40 tall, so box i rests centred at 780-40i.
// Spawning them exactly at rest isolates solver sag from free-fall settling.
for (let i = 0; i < 10; i += 1) {
boxes.push(addBox(w, { x: 600, y: 780 - i * 40, w: 60, h: 40, density: 1, friction: 0.6 }));
}
const startX = boxes.map((b) => b.x);
let sleptAt = -1;
for (let i = 0; i < 720; i += 1) { // 3 simulated seconds at 1/240
substep(w, PHYS.SUBSTEP_DT);
if (sleptAt < 0 && isSettled(w)) sleptAt = i;
}
check('10-box tower settles within 3s', sleptAt >= 0, `never settled`);
check('10-box tower is fully asleep', boxes.every((b) => b.sleeping),
`${boxes.filter((b) => !b.sleeping).length} awake`);
const drift = Math.max(...boxes.map((b, i) => Math.abs(b.x - startX[i])));
check('tower horizontal drift < 1px', drift < 1, `max drift ${drift.toFixed(3)}px`);
// Total sag is bounded by SLOP per contact — the solver deliberately stops
// correcting once penetration is inside the slop band, so 10 stacked
// contacts can each give up to SLOP. Anything beyond that is real sag.
const sag = boxes[9].y - (780 - 9 * 40);
const sagBudget = PHYS.SLOP * 10;
check('tower sag within the slop budget', Math.abs(sag) < sagBudget,
`sag ${sag.toFixed(3)}px vs budget ${sagBudget}px`);
const tilt = Math.max(...boxes.map((b) => Math.abs(b.angle)));
check('tower stays upright', tilt < 0.02, `max |angle| ${tilt.toFixed(4)} rad`);
}
{
// A pyramid is the harder stacking case: contacts are shared sideways.
const w = withGround(createWorld());
const bodies = [];
for (let row = 0; row < 5; row += 1) {
const n = 5 - row;
for (let i = 0; i < n; i += 1) {
bodies.push(addBox(w, {
x: 600 - (n - 1) * 35 + i * 70,
y: 770 - row * 40,
w: 64, h: 40, density: 1, friction: 0.6,
}));
}
}
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('pyramid settles and sleeps', bodies.every((b) => b.sleeping),
`${bodies.filter((b) => !b.sleeping).length} awake`);
const maxTilt = Math.max(...bodies.map((b) => Math.abs(b.angle)));
check('pyramid stays upright', maxTilt < 0.05, `max |angle| ${maxTilt.toFixed(4)}`);
}
// ── 4. Friction ─────────────────────────────────────────────────────────────
section('4. Friction');
{
// 15 degrees, mu = 0.8 -> tan(15) = 0.27 < 0.8, so the box must not creep.
const w = createWorld();
const slope = 15 * Math.PI / 180;
addBox(w, { x: 600, y: 800, w: 2000, h: 60, angle: slope, isStatic: true, friction: 0.9 });
const b = addBox(w, { x: 600, y: 745, w: 60, h: 40, angle: slope, density: 1, friction: 0.9 });
const x0 = b.x;
for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('box on 15deg slope does not creep', Math.abs(b.x - x0) < 2,
`moved ${(b.x - x0).toFixed(2)}px`);
check('box on slope sleeps', b.sleeping);
}
{
// 40 degrees, mu = 0.2 -> tan(40) = 0.84 > 0.2, so it must slide.
const w = createWorld();
const slope = 40 * Math.PI / 180;
addBox(w, { x: 600, y: 800, w: 3000, h: 60, angle: slope, isStatic: true, friction: 0.2 });
const b = addBox(w, { x: 400, y: 800 - 200 * Math.tan(slope) - 52, w: 60, h: 40, angle: slope, density: 1, friction: 0.2 });
const x0 = b.x;
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('low-friction box slides down a steep slope', b.x - x0 > 20,
`moved ${(b.x - x0).toFixed(2)}px`);
}
// ── 5. Restitution ──────────────────────────────────────────────────────────
section('5. Restitution');
{
const w = withGround(createWorld());
const ball = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0.8 });
let peakUp = 0;
for (let i = 0; i < 400; i += 1) {
substep(w, PHYS.SUBSTEP_DT);
if (ball.vy < peakUp) peakUp = ball.vy;
}
check('bouncy ball rebounds upward', peakUp < -100, `peak vy ${peakUp.toFixed(1)}`);
}
{
const w = withGround(createWorld());
const dead = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0 });
for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('zero-restitution ball comes to rest', dead.sleeping && near(dead.y, 780, 1.5),
`y=${dead.y.toFixed(2)} sleeping=${dead.sleeping}`);
}
// ── 6. Shape-pair coverage ──────────────────────────────────────────────────
section('6. Shape pairs');
{
const w = createWorld();
w.gravity = 0;
const a = addCircle(w, { x: 100, y: 300, r: 20, density: 1 });
const b = addCircle(w, { x: 200, y: 300, r: 20, density: 1 });
a.vx = 200;
for (let i = 0; i < 200; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('circle-circle transfers momentum', b.vx > 50 && a.vx < 200, `a=${a.vx.toFixed(1)} b=${b.vx.toFixed(1)}`);
}
{
const w = createWorld();
w.gravity = 0;
const c = addCircle(w, { x: 100, y: 300, r: 20, density: 1 });
const p = addBox(w, { x: 300, y: 300, w: 60, h: 60, density: 1 });
c.vx = 300;
for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('circle-poly transfers momentum', p.vx > 20, `box vx=${p.vx.toFixed(1)}`);
check('circle-poly does not overlap after impact',
Math.hypot(c.x - p.x, c.y - p.y) > 40, `dist ${Math.hypot(c.x - p.x, c.y - p.y).toFixed(1)}`);
}
{
// A circle dropped into a closed V must wedge, not squeeze through the seam.
// Two slabs tilted toward each other, overlapping at the bottom so there is
// no gap for the ball to slip through.
const w = createWorld();
addBox(w, { x: 480, y: 780, w: 400, h: 40, angle: -0.6, isStatic: true, friction: 0.6 });
addBox(w, { x: 720, y: 780, w: 400, h: 40, angle: 0.6, isStatic: true, friction: 0.6 });
const ball = addCircle(w, { x: 600, y: 300, r: 25, density: 1 });
for (let i = 0; i < 1800; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('circle wedges in a V without escaping', ball.y < 820 && ball.sleeping,
`y=${ball.y.toFixed(1)} sleeping=${ball.sleeping}`);
}
// ── 7. Sleeping and waking ──────────────────────────────────────────────────
section('7. Sleeping');
{
const w = withGround(createWorld());
const stack = [];
for (let i = 0; i < 4; i += 1) stack.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 }));
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('stack asleep before impact', stack.every((b) => b.sleeping));
// A projectile must wake the whole island, not just the box it touches.
const shot = addCircle(w, { x: 200, y: 700, r: 16, density: 4 });
shot.vx = 1200;
let allAwake = false;
for (let i = 0; i < 240; i += 1) {
substep(w, PHYS.SUBSTEP_DT);
if (stack.every((b) => !b.sleeping)) { allAwake = true; break; }
}
check('impact wakes the whole island', allAwake,
`${stack.filter((b) => b.sleeping).length} still asleep`);
}
{
const w = withGround(createWorld());
const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 });
settle(w, 10);
check('settle() reaches rest', isSettled(w) && b.sleeping);
const yRest = b.y;
for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT);
check('sleeping body does not drift', near(b.y, yRest, 1e-9), `${b.y} vs ${yRest}`);
}
// ── 8. Explosions ───────────────────────────────────────────────────────────
section('8. Explosions');
{
const w = withGround(createWorld());
const near1 = addBox(w, { x: 620, y: 700, w: 40, h: 40, density: 1 });
const far1 = addBox(w, { x: 900, y: 700, w: 40, h: 40, density: 1 });
const outside = addBox(w, { x: 1400, y: 700, w: 40, h: 40, density: 1 });
const hit = applyExplosion(w, 600, 700, 400, 600);
check('explosion hits bodies inside the radius', hit.length === 2, `hit ${hit.length}`);
check('explosion falls off with distance',
Math.hypot(near1.vx, near1.vy) > Math.hypot(far1.vx, far1.vy),
`${Math.hypot(near1.vx, near1.vy).toFixed(1)} vs ${Math.hypot(far1.vx, far1.vy).toFixed(1)}`);
check('explosion spares bodies outside the radius',
outside.vx === 0 && outside.vy === 0);
check('explosion pushes away from the centre', near1.vx > 0 && far1.vx > 0);
check('explosion wakes sleeping bodies', !near1.sleeping);
}
// ── 9. Determinism ──────────────────────────────────────────────────────────
section('9. Determinism');
function scene() {
const w = withGround(createWorld());
for (let i = 0; i < 6; i += 1) addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 });
addBox(w, { x: 660, y: 730, w: 30, h: 120, density: 0.8 });
const ball = addCircle(w, { x: 200, y: 600, r: 16, density: 4 });
ball.vx = 900; ball.vy = -120;
return w;
}
{
const a = scene();
const b = scene();
for (let i = 0; i < 300; i += 1) { substep(a, PHYS.SUBSTEP_DT); substep(b, PHYS.SUBSTEP_DT); }
check('identical scenes replay bit-identically', hashWorld(a) === hashWorld(b),
`${hashWorld(a)} vs ${hashWorld(b)}`);
}
{
// Frame-rate independence: one 1/60 frame must equal exactly N substeps.
const perFrame = Math.round((1 / 60) / PHYS.SUBSTEP_DT);
const c = scene();
const d = scene();
for (let i = 0; i < 300; i += 1) {
step(c, 1 / 60);
for (let k = 0; k < perFrame; k += 1) substep(d, PHYS.SUBSTEP_DT);
}
check(`1/60 step == ${perFrame}x substep`, hashWorld(c) === hashWorld(d),
`${hashWorld(c)} vs ${hashWorld(d)}`);
}
{
// Ragged frame pacing: the accumulator only ever runs WHOLE substeps, so N
// substeps reached through jittery frame times must be bit-identical to N
// substeps reached evenly. (Total elapsed time can differ by up to one
// substep's worth of carry — that leftover is the accumulator's whole job —
// so the comparison is on substeps run, not on wall time.)
const e = scene();
const f = scene();
const rng = mulberry32(99);
let ran = 0;
for (let i = 0; i < 400; i += 1) ran += step(e, 0.004 + rng() * 0.02);
for (let i = 0; i < ran; i += 1) substep(f, PHYS.SUBSTEP_DT);
check('ragged frame pacing matches even pacing', hashWorld(e) === hashWorld(f),
`${ran} substeps: ${hashWorld(e)} vs ${hashWorld(f)}`);
}
{
const src = scene();
for (let i = 0; i < 60; i += 1) substep(src, PHYS.SUBSTEP_DT);
const before = hashWorld(src);
const copy = cloneWorld(src);
check('clone starts hash-identical', before === hashWorld(copy));
// Stepping the clone must not disturb the original by any route — including
// through the body references cached on contacts. simulatePreview and the
// winnability bot both depend on this being airtight.
for (let i = 0; i < 120; i += 1) substep(copy, PHYS.SUBSTEP_DT);
check('stepping a clone leaves the original untouched', hashWorld(src) === before,
`${hashWorld(src)} vs ${before}`);
check('clone diverges from a stationary original', hashWorld(copy) !== before);
for (let i = 0; i < 120; i += 1) substep(src, PHYS.SUBSTEP_DT);
check('clone and original converge when stepped equally', hashWorld(src) === hashWorld(copy),
`${hashWorld(src)} vs ${hashWorld(copy)}`);
}
{
// Sequential impulses are Gauss-Seidel, so solve order genuinely affects the
// answer — bit-identical results across creation orders are NOT achievable
// and not required. What matters is that the dependence stays sub-pixel, so
// an author reordering blocks in the editor can't change whether a level
// works. Exact reproducibility of a GIVEN scene is covered above.
const build = (reverse) => {
const w = withGround(createWorld());
const spec = [];
for (let i = 0; i < 5; i += 1) spec.push({ x: 600, y: 770 - i * 40 });
const order = reverse ? [...spec].reverse() : spec;
for (const s of order) addBox(w, { ...s, w: 60, h: 40, density: 1 });
return w;
};
const fwd = build(false);
const rev = build(true);
for (let i = 0; i < 480; i += 1) { substep(fwd, PHYS.SUBSTEP_DT); substep(rev, PHYS.SUBSTEP_DT); }
const fy = [...fwd.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b);
const ry = [...rev.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b);
const worst = Math.max(...fy.map((v, i) => Math.abs(v - ry[i])));
check('creation order shifts results by under 1px', worst < 1, `worst ${worst.toFixed(3)}px`);
}
// ── 10. Robustness monkey test ──────────────────────────────────────────────
section('10. Robustness');
{
const seeds = Number((process.argv.find((a) => a.startsWith('--seeds=')) ?? '--seeds=12').split('=')[1]);
let bad = 0;
let sank = 0;
let overspeed = 0;
for (let s = 0; s < seeds; s += 1) {
const rng = mulberry32(1000 + s);
const w = withGround(createWorld());
const bodies = [];
for (let i = 0; i < 14; i += 1) {
const b = rng() < 0.3
? addCircle(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, r: 10 + rng() * 18, density: 0.5 + rng() })
: addBox(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, w: 24 + rng() * 60, h: 24 + rng() * 60, angle: rng() * Math.PI, density: 0.5 + rng() });
bodies.push(b);
}
for (let i = 0; i < 900; i += 1) {
if (i % 60 === 0) {
const b = bodies[Math.floor(rng() * bodies.length)];
applyImpulse(b, (rng() - 0.5) * 4e5, (rng() - 0.5) * 4e5, b.x, b.y);
}
substep(w, PHYS.SUBSTEP_DT);
for (const b of bodies) {
if (!Number.isFinite(b.x) || !Number.isFinite(b.y) || !Number.isFinite(b.angle)
|| !Number.isFinite(b.vx) || !Number.isFinite(b.vy) || !Number.isFinite(b.omega)) bad += 1;
if (Math.hypot(b.vx, b.vy) > PHYS.MAX_SPEED + 1e-6) overspeed += 1;
if (b.y > 1000) sank += 1;
}
}
}
check(`no NaN across ${seeds} monkey seeds`, bad === 0, `${bad} non-finite samples`);
check('nothing exceeds MAX_SPEED', overspeed === 0, `${overspeed} samples`);
check('nothing sinks through the ground', sank === 0, `${sank} samples`);
}
{
// Bodies must be removable mid-sim without leaving dangling contacts.
const w = withGround(createWorld());
const boxes = [];
for (let i = 0; i < 5; i += 1) boxes.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 }));
for (let i = 0; i < 120; i += 1) substep(w, PHYS.SUBSTEP_DT);
removeBody(w, boxes[2]);
let threw = false;
try { for (let i = 0; i < 480; i += 1) substep(w, PHYS.SUBSTEP_DT); } catch (e) { threw = true; }
check('removing a mid-stack body is safe', !threw);
check('stack recovers after a removal', boxes.filter((b, i) => i !== 2).every((b) => b.sleeping),
`${boxes.filter((b, i) => i !== 2 && !b.sleeping).length} awake`);
}
{
// contactImpulses is the damage signal the rules layer reads — a hard hit
// must report a much larger impulse than a resting contact.
const w = withGround(createWorld());
const target = addBox(w, { x: 600, y: 770, w: 60, h: 60, density: 1 });
settle(w, 5);
substep(w, PHYS.SUBSTEP_DT);
const resting = contactImpulses(w).get(target.id) ?? 0;
const shot = addCircle(w, { x: 200, y: 740, r: 16, density: 6 });
shot.vx = 1800;
let peak = 0;
for (let i = 0; i < 240; i += 1) {
substep(w, PHYS.SUBSTEP_DT);
peak = Math.max(peak, contactImpulses(w).get(target.id) ?? 0);
}
check('impact impulse exceeds resting impulse', peak > resting * 3,
`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<wood<stone',
dens[0] < dens[1] && dens[1] < dens[2], dens.join(' < '));
check('material hp ordered ice<wood<stone', hps[0] < hps[1] && hps[1] < hps[2], hps.join(' < '));
check('damage thresholds ordered ice<wood<stone', thr[0] < thr[1] && thr[1] < thr[2], thr.join(' < '));
check('ice is the most slippery', MATERIALS.ice.friction < MATERIALS.wood.friction);
check('TNT is fragile and carries a blast',
MATERIALS.tnt.hp < MATERIALS.wood.hp && !!MATERIALS.tnt.blast);
}
{
// THE self-crush guard. A tall stone tower left alone must take no damage —
// the reason damage reads impactImpulse and not the accumulated normal
// impulse. Regressing this makes towers quietly disintegrate at rest.
const blocks = [];
for (let i = 0; i < 10; i += 1) blocks.push({ x: 1200, y: 880 - i * 40, w: 120, h: 40, material: 'stone' });
const st = createState({
world: { w: 1920, h: 1080, groundY: 900 },
birds: ['red'], blocks, pigs: [{ x: 1700, y: 878, r: 22 }], stars: [1, 2, 3],
});
const hp0 = [...st.blocks.values()].map((b) => 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`);
process.exit(failures ? 1 : 0);