Compare commits
4 Commits
abb55d6d7b
...
d8296940e5
| Author | SHA1 | Date |
|---|---|---|
|
|
d8296940e5 | |
|
|
e1b1ab2bab | |
|
|
c12371b654 | |
|
|
9ecbc9589f |
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue