From 94e2152918ad0790db4395ea43493e7d417cecc0 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 31 Jul 2026 16:29:49 -0600 Subject: [PATCH] remove Angry Birds game; enhance Goo Tower visuals Remove the entire Angry Birds implementation including physics solver, game logic, Phaser scene, level data, verification tool, and registry wiring. Also delete the build-plan documentation. Meanwhile, upgrade Goo Tower rendering: spikes now have faceted steel teeth with dynamic lighting, rust speckling, and twinkling glints; lava features layered heat gradients, animated flow streaks, and rising bubbles. Update gootower levels 6-7 and zuma level 6. --- assets/gamedata/angrybirds/level-001.json | 31 - assets/gamedata/angrybirds/level-002.json | 53 -- assets/gamedata/angrybirds/level-003.json | 79 -- assets/gamedata/angrybirds/level-004.json | 72 -- assets/gamedata/angrybirds/level-005.json | 65 -- assets/gamedata/angrybirds/level-006.json | 70 -- assets/gamedata/angrybirds/levels.json | 50 -- assets/gamedata/gootower/level-006.json | 21 + assets/gamedata/gootower/level-007.json | 412 +++++++--- assets/gamedata/zuma/level-006.json | 465 +++++------ docs/angrybirds-build-plan.md | 159 ---- src/data/assetManifest.js | 5 - src/data/gamesRegistry.js | 1 - src/games/angrybirds/AngryBirdsGame.js | 599 -------------- src/games/angrybirds/AngryBirdsLogic.js | 585 -------------- src/games/angrybirds/AngryBirdsPhysics.js | 943 ---------------------- src/games/gootower/GooTowerGame.js | 313 ++++++- src/main.js | 2 - src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 1 - tools/verifyAngryBirds.js | 882 -------------------- 21 files changed, 847 insertions(+), 3963 deletions(-) delete mode 100644 assets/gamedata/angrybirds/level-001.json delete mode 100644 assets/gamedata/angrybirds/level-002.json delete mode 100644 assets/gamedata/angrybirds/level-003.json delete mode 100644 assets/gamedata/angrybirds/level-004.json delete mode 100644 assets/gamedata/angrybirds/level-005.json delete mode 100644 assets/gamedata/angrybirds/level-006.json delete mode 100644 assets/gamedata/angrybirds/levels.json delete mode 100644 docs/angrybirds-build-plan.md delete mode 100644 src/games/angrybirds/AngryBirdsGame.js delete mode 100644 src/games/angrybirds/AngryBirdsLogic.js delete mode 100644 src/games/angrybirds/AngryBirdsPhysics.js delete mode 100644 tools/verifyAngryBirds.js diff --git a/assets/gamedata/angrybirds/level-001.json b/assets/gamedata/angrybirds/level-001.json deleted file mode 100644 index f7681bc..0000000 --- a/assets/gamedata/angrybirds/level-001.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": 1, - "level": 1, - "name": "Ground Level", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "red", - "red" - ], - "blocks": [], - "pigs": [ - { - "x": 1400, - "y": 878, - "r": 22 - } - ], - "stars": [ - 12000, - 20000, - 28000 - ] -} diff --git a/assets/gamedata/angrybirds/level-002.json b/assets/gamedata/angrybirds/level-002.json deleted file mode 100644 index cca9d40..0000000 --- a/assets/gamedata/angrybirds/level-002.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "version": 1, - "level": 2, - "name": "First House", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "red", - "red" - ], - "blocks": [ - { - "x": 1300, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1460, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1380, - "y": 778, - "w": 220, - "h": 24, - "material": "wood" - } - ], - "pigs": [ - { - "x": 1380, - "y": 878, - "r": 22 - } - ], - "stars": [ - 18000, - 27000, - 35000 - ] -} diff --git a/assets/gamedata/angrybirds/level-003.json b/assets/gamedata/angrybirds/level-003.json deleted file mode 100644 index 1b4735e..0000000 --- a/assets/gamedata/angrybirds/level-003.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "version": 1, - "level": 3, - "name": "Two Up", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "red", - "red" - ], - "blocks": [ - { - "x": 1280, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1460, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1370, - "y": 778, - "w": 240, - "h": 24, - "material": "wood" - }, - { - "x": 1310, - "y": 721, - "w": 24, - "h": 90, - "material": "wood" - }, - { - "x": 1430, - "y": 721, - "w": 24, - "h": 90, - "material": "wood" - }, - { - "x": 1370, - "y": 664, - "w": 180, - "h": 24, - "material": "wood" - } - ], - "pigs": [ - { - "x": 1370, - "y": 878, - "r": 22 - }, - { - "x": 1370, - "y": 744, - "r": 22 - } - ], - "stars": [ - 26000, - 38000, - 48000 - ] -} diff --git a/assets/gamedata/angrybirds/level-004.json b/assets/gamedata/angrybirds/level-004.json deleted file mode 100644 index 8cb59eb..0000000 --- a/assets/gamedata/angrybirds/level-004.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "version": 1, - "level": 4, - "name": "Thin Ice", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "red", - "red" - ], - "blocks": [ - { - "x": 1250, - "y": 840, - "w": 28, - "h": 120, - "material": "ice" - }, - { - "x": 1400, - "y": 840, - "w": 28, - "h": 120, - "material": "ice" - }, - { - "x": 1325, - "y": 768, - "w": 200, - "h": 24, - "material": "ice" - }, - { - "x": 1550, - "y": 840, - "w": 28, - "h": 120, - "material": "ice" - }, - { - "x": 1480, - "y": 768, - "w": 180, - "h": 24, - "material": "ice" - } - ], - "pigs": [ - { - "x": 1325, - "y": 878, - "r": 22 - }, - { - "x": 1480, - "y": 878, - "r": 22 - } - ], - "stars": [ - 30000, - 42000, - 54000 - ] -} diff --git a/assets/gamedata/angrybirds/level-005.json b/assets/gamedata/angrybirds/level-005.json deleted file mode 100644 index afb09bb..0000000 --- a/assets/gamedata/angrybirds/level-005.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "version": 1, - "level": 5, - "name": "Hard Going", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "chuck", - "chuck" - ], - "blocks": [ - { - "x": 1300, - "y": 840, - "w": 34, - "h": 120, - "material": "stone" - }, - { - "x": 1480, - "y": 840, - "w": 34, - "h": 120, - "material": "stone" - }, - { - "x": 1390, - "y": 765, - "w": 240, - "h": 30, - "material": "stone" - }, - { - "x": 1390, - "y": 705, - "w": 24, - "h": 90, - "material": "wood" - } - ], - "pigs": [ - { - "x": 1390, - "y": 878, - "r": 22 - }, - { - "x": 1390, - "y": 730, - "r": 20 - } - ], - "stars": [ - 32000, - 45000, - 58000 - ] -} diff --git a/assets/gamedata/angrybirds/level-006.json b/assets/gamedata/angrybirds/level-006.json deleted file mode 100644 index b9c136e..0000000 --- a/assets/gamedata/angrybirds/level-006.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "version": 1, - "level": 6, - "name": "Powder Keg", - "episode": 1, - "world": { - "w": 2400, - "h": 1080, - "groundY": 900, - "slingX": 300, - "slingY": 750 - }, - "birds": [ - "red", - "chuck", - "blue" - ], - "blocks": [ - { - "x": 1280, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1520, - "y": 845, - "w": 24, - "h": 110, - "material": "wood" - }, - { - "x": 1400, - "y": 775, - "w": 280, - "h": 30, - "material": "stone" - }, - { - "x": 1400, - "y": 860, - "w": 70, - "h": 80, - "material": "tnt" - } - ], - "pigs": [ - { - "x": 1300, - "y": 880, - "r": 20 - }, - { - "x": 1500, - "y": 880, - "r": 20 - }, - { - "x": 1400, - "y": 706, - "r": 22 - } - ], - "stars": [ - 40000, - 55000, - 70000 - ] -} diff --git a/assets/gamedata/angrybirds/levels.json b/assets/gamedata/angrybirds/levels.json deleted file mode 100644 index e7c6b10..0000000 --- a/assets/gamedata/angrybirds/levels.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 1, - "episodes": [ - { - "id": 1, - "name": "Poached Eggs", - "blurb": "They took the eggs. Get them back.", - "from": 1, - "to": 6 - } - ], - "levels": [ - { - "level": 1, - "name": "Ground Level", - "episode": 1, - "file": "level-001.json" - }, - { - "level": 2, - "name": "First House", - "episode": 1, - "file": "level-002.json" - }, - { - "level": 3, - "name": "Two Up", - "episode": 1, - "file": "level-003.json" - }, - { - "level": 4, - "name": "Thin Ice", - "episode": 1, - "file": "level-004.json" - }, - { - "level": 5, - "name": "Hard Going", - "episode": 1, - "file": "level-005.json" - }, - { - "level": 6, - "name": "Powder Keg", - "episode": 1, - "file": "level-006.json" - } - ] -} diff --git a/assets/gamedata/gootower/level-006.json b/assets/gamedata/gootower/level-006.json index 66d14d6..bb652ba 100644 --- a/assets/gamedata/gootower/level-006.json +++ b/assets/gamedata/gootower/level-006.json @@ -205,6 +205,27 @@ 997 ] ] + }, + { + "kind": "solid", + "poly": [ + [ + 754, + 652 + ], + [ + 754, + 682 + ], + [ + 875, + 682 + ], + [ + 873, + 647 + ] + ] } ], "gears": [ diff --git a/assets/gamedata/gootower/level-007.json b/assets/gamedata/gootower/level-007.json index 2e04209..437d9e4 100644 --- a/assets/gamedata/gootower/level-007.json +++ b/assets/gamedata/gootower/level-007.json @@ -70,30 +70,275 @@ 1000 ] ] + }, + { + "kind": "solid", + "poly": [ + [ + 322, + 356 + ], + [ + 719, + 356 + ], + [ + 740, + 364 + ], + [ + 754, + 376 + ], + [ + 763, + 395 + ], + [ + 762, + 412 + ], + [ + 393, + 417 + ], + [ + 376, + 425 + ], + [ + 354, + 452 + ], + [ + 350, + 483 + ], + [ + 341, + 819 + ], + [ + 366, + 854 + ], + [ + 243, + 855 + ], + [ + 264, + 386 + ], + [ + 273, + 365 + ], + [ + 292, + 356 + ] + ] + }, + { + "kind": "spike", + "poly": [ + [ + 632, + 9 + ], + [ + 656, + 60 + ], + [ + 691, + 120 + ], + [ + 725, + 173 + ], + [ + 768, + 211 + ], + [ + 818, + 257 + ], + [ + 872, + 277 + ], + [ + 916, + 296 + ], + [ + 982, + 301 + ], + [ + 1062, + 298 + ], + [ + 1111, + 280 + ], + [ + 1155, + 227 + ], + [ + 1217, + 157 + ], + [ + 1256, + 97 + ], + [ + 1266, + 60 + ], + [ + 1272, + 17 + ], + [ + 1270, + 5 + ] + ] + }, + { + "kind": "solid", + "poly": [ + [ + 900, + 428 + ], + [ + 891, + 441 + ], + [ + 886, + 461 + ], + [ + 900, + 474 + ], + [ + 1362, + 479 + ], + [ + 1387, + 494 + ], + [ + 1396, + 516 + ], + [ + 1395, + 816 + ], + [ + 1368, + 849 + ], + [ + 1466, + 849 + ], + [ + 1527, + 268 + ], + [ + 1546, + 257 + ], + [ + 1568, + 250 + ], + [ + 1593, + 249 + ], + [ + 1592, + 194 + ], + [ + 1363, + 197 + ], + [ + 1344, + 216 + ], + [ + 1337, + 255 + ], + [ + 1395, + 272 + ], + [ + 1414, + 313 + ], + [ + 1404, + 392 + ], + [ + 1376, + 431 + ] + ] + } + ], + "gears": [ + { + "x": 689, + "y": 460, + "r": 100, + "teeth": 9, + "omega": 1.4 } ], "balls": [ { - "x": 566, - "y": 836, - "type": "common", - "pinned": true + "x": 1376, + "y": 175, + "type": "ivy", + "pinned": false }, { - "x": 628, - "y": 836, - "type": "common", - "pinned": true + "x": 1442, + "y": 173, + "type": "ivy", + "pinned": false }, { - "x": 597, - "y": 782, - "type": "common" + "x": 1409, + "y": 108, + "type": "ivy", + "pinned": false } ], "strands": [ [ - 2, + 1, 0 ], [ @@ -101,135 +346,90 @@ 1 ], [ - 0, - 1 + 2, + 0 ] ], "pile": [ { - "x": 857, - "y": 834, - "type": "common" + "x": 1492, + "y": 173, + "type": "ivy" }, { - "x": 887, - "y": 834, - "type": "common" + "x": 1527, + "y": 171, + "type": "ivy" }, { - "x": 917, - "y": 834, - "type": "common" + "x": 1559, + "y": 170, + "type": "ivy" }, { - "x": 947, - "y": 834, - "type": "common" + "x": 1563, + "y": 135, + "type": "ivy" }, { - "x": 977, - "y": 834, - "type": "common" + "x": 1527, + "y": 138, + "type": "ivy" }, { - "x": 1007, - "y": 834, - "type": "common" + "x": 1488, + "y": 137, + "type": "ivy" }, { - "x": 1037, - "y": 834, - "type": "common" + "x": 1486, + "y": 96, + "type": "ivy" }, { - "x": 1067, - "y": 834, - "type": "common" + "x": 1527, + "y": 97, + "type": "ivy" }, { - "x": 857, - "y": 804, - "type": "common" + "x": 1567, + "y": 97, + "type": "ivy" }, { - "x": 887, - "y": 804, - "type": "common" + "x": 1563, + "y": 63, + "type": "ivy" }, { - "x": 917, - "y": 804, - "type": "common" + "x": 1521, + "y": 60, + "type": "ivy" }, { - "x": 947, - "y": 804, - "type": "common" + "x": 1486, + "y": 64, + "type": "ivy" }, { - "x": 977, - "y": 804, - "type": "common" + "x": 1480, + "y": 23, + "type": "ivy" }, { - "x": 1007, - "y": 804, - "type": "common" + "x": 1521, + "y": 28, + "type": "ivy" }, { - "x": 1037, - "y": 804, - "type": "common" - }, - { - "x": 1067, - "y": 804, - "type": "common" - }, - { - "x": 857, - "y": 774, - "type": "common" - }, - { - "x": 887, - "y": 774, - "type": "common" - }, - { - "x": 917, - "y": 774, - "type": "common" - }, - { - "x": 947, - "y": 774, - "type": "common" - }, - { - "x": 977, - "y": 774, - "type": "common" - }, - { - "x": 1007, - "y": 774, - "type": "common" - }, - { - "x": 1037, - "y": 774, - "type": "common" - }, - { - "x": 1067, - "y": 774, - "type": "common" + "x": 1562, + "y": 22, + "type": "ivy" } ], "pipe": { - "x": 597, - "y": 553, + "x": 495, + "y": 75, "r": 46 }, "required": 4, diff --git a/assets/gamedata/zuma/level-006.json b/assets/gamedata/zuma/level-006.json index 20ea518..3158452 100644 --- a/assets/gamedata/zuma/level-006.json +++ b/assets/gamedata/zuma/level-006.json @@ -1,232 +1,237 @@ { - "level": 6, - "name": "Crossroads", - "frog": [ - 960, - 990 - ], - "colors": 4, - "background": "zuma-background-1", - "seed": 48514, - "starScores": [ - 1120, - 1670, - 2230 - ], - "paths": [ - { - "shape": "figureEight", - "points": [ - [ - -80, - 830 - ], - [ - 60, - 690 - ], - [ - 130, - 505 - ], - [ - 136, - 416 - ], - [ - 154, - 333 - ], - [ - 183, - 259 - ], - [ - 223, - 199 - ], - [ - 274, - 156 - ], - [ - 334, - 134 - ], - [ - 404, - 132 - ], - [ - 481, - 152 - ], - [ - 565, - 191 - ], - [ - 655, - 249 - ], - [ - 749, - 321 - ], - [ - 846, - 403 - ], - [ - 945, - 491 - ], - [ - 1044, - 581 - ], - [ - 1142, - 665 - ], - [ - 1237, - 741 - ], - [ - 1328, - 803 - ], - [ - 1414, - 849 - ], - [ - 1494, - 874 - ], - [ - 1566, - 879 - ], - [ - 1629, - 863 - ], - [ - 1683, - 826 - ], - [ - 1726, - 771 - ], - [ - 1759, - 701 - ], - [ - 1780, - 620 - ], - [ - 1789, - 532 - ], - [ - 1787, - 443 - ], - [ - 1773, - 357 - ], - [ - 1747, - 280 - ], - [ - 1710, - 215 - ], - [ - 1663, - 167 - ], - [ - 1605, - 138 - ], - [ - 1538, - 130 - ], - [ - 1463, - 143 - ], - [ - 1381, - 177 - ], - [ - 1293, - 230 - ], - [ - 1200, - 298 - ], - [ - 1103, - 377 - ], - [ - 1005, - 464 - ], - [ - 906, - 554 - ], - [ - 808, - 640 - ], - [ - 711, - 719 - ], - [ - 619, - 786 - ], - [ - 531, - 837 - ], - [ - 449, - 869 - ], - [ - 375, - 880 - ], - [ - 309, - 870 - ], - [ - 252, - 839 - ] - ], - "tunnels": [], - "quota": 34, - "introBalls": 10, - "pushSpeed": 88, - "powerUpRate": 0.06 - } - ] + "level": 6, + "name": "Crossroads", + "frog": [ + 960, + 990 + ], + "colors": 4, + "seed": 48514, + "starScores": [ + 1120, + 1670, + 2230 + ], + "paths": [ + { + "shape": "figureEight", + "points": [ + [ + -80, + 830 + ], + [ + 60, + 690 + ], + [ + 130, + 505 + ], + [ + 136, + 416 + ], + [ + 154, + 333 + ], + [ + 183, + 259 + ], + [ + 223, + 199 + ], + [ + 274, + 156 + ], + [ + 334, + 134 + ], + [ + 404, + 132 + ], + [ + 481, + 152 + ], + [ + 565, + 191 + ], + [ + 655, + 249 + ], + [ + 749, + 321 + ], + [ + 846, + 403 + ], + [ + 945, + 491 + ], + [ + 1044, + 581 + ], + [ + 1142, + 665 + ], + [ + 1237, + 741 + ], + [ + 1328, + 803 + ], + [ + 1414, + 849 + ], + [ + 1494, + 874 + ], + [ + 1566, + 879 + ], + [ + 1629, + 863 + ], + [ + 1683, + 826 + ], + [ + 1726, + 771 + ], + [ + 1759, + 701 + ], + [ + 1780, + 620 + ], + [ + 1789, + 532 + ], + [ + 1787, + 443 + ], + [ + 1773, + 357 + ], + [ + 1747, + 280 + ], + [ + 1710, + 215 + ], + [ + 1663, + 167 + ], + [ + 1605, + 138 + ], + [ + 1538, + 130 + ], + [ + 1463, + 143 + ], + [ + 1381, + 177 + ], + [ + 1293, + 230 + ], + [ + 1200, + 298 + ], + [ + 1103, + 377 + ], + [ + 1005, + 464 + ], + [ + 906, + 554 + ], + [ + 808, + 640 + ], + [ + 711, + 719 + ], + [ + 619, + 786 + ], + [ + 531, + 837 + ], + [ + 449, + 869 + ], + [ + 375, + 880 + ], + [ + 309, + 870 + ], + [ + 252, + 839 + ] + ], + "tunnels": [ + [ + 1320, + 1846 + ] + ], + "quota": 34, + "introBalls": 10, + "pushSpeed": 60, + "powerUpRate": 0.06 + } + ], + "background": "zuma-background-1" } diff --git a/docs/angrybirds-build-plan.md b/docs/angrybirds-build-plan.md deleted file mode 100644 index 417affc..0000000 --- a/docs/angrybirds-build-plan.md +++ /dev/null @@ -1,159 +0,0 @@ -# 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. diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 04ce569..22c3080 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -71,11 +71,6 @@ export const MANIFEST = { excitebike: [ (scene) => musicFrom(scene, 'nintendo-music'), ], - - // Angry Birds draws every block, pig and bird procedurally (see the MAT_COLORS - // and BIRD_COLORS tables in AngryBirdsGame.js), so there is nothing to fetch - // yet. Wave 5 adds data/angrybirds-artwork.json here as a drop-in sprite map. - angrybirds: [], forbiddenisland: [ // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded // frame 2i+1 (see IslandData.TILE_FRAME_ROW). diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 2da56f1..0904458 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -119,4 +119,3 @@ registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 }); registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 }); registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 }); -registerGame({ slug: 'angrybirds', name: 'Angry Birds', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 }); diff --git a/src/games/angrybirds/AngryBirdsGame.js b/src/games/angrybirds/AngryBirdsGame.js deleted file mode 100644 index 6930cbb..0000000 --- a/src/games/angrybirds/AngryBirdsGame.js +++ /dev/null @@ -1,599 +0,0 @@ -import * as Phaser from 'phaser'; -import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; -import { Button } from '../../ui/Button.js'; -import { Tooltip } from '../../ui/Tooltip.js'; -import { SFX, playSound } from '../../ui/Sounds.js'; -import { api } from '../../services/api.js'; -import { - TUNING, MATERIALS, BIRDS, - createState, stepSim, launch, useAbility, clampDraw, slingAnchor, - currentBird, birdsRemaining, starsFor, -} from './AngryBirdsLogic.js'; - -// Logical level space is 2400x1080; the canvas is 1920x1080. Everything is -// drawn through sx()/sy() at a fixed scale rather than by zooming the camera, -// exactly as PeggleGame maps its 1200x900 board — that keeps HUD text in plain -// screen coordinates instead of fighting camera zoom. Sim space is never -// screen space. -const SCALE = 0.78; -const OFF_X = 24; -const OFF_Y = 158; - -const GAMEDATA = 'assets/gamedata/angrybirds'; -const SIM_STEP = 1 / 60; - -const MAT_COLORS = { - wood: { fill: 0xc8873c, line: 0x8b5a22 }, - stone: { fill: 0x9ba3a8, line: 0x656d72 }, - ice: { fill: 0x9bd8f0, line: 0x59a6cc }, - tnt: { fill: 0xd03a2a, line: 0x8c1d12 }, -}; - -const BIRD_COLORS = { - red: 0xd6332b, chuck: 0xf5c518, blue: 0x35a7dd, bomb: 0x3b3b42, - matilda: 0xf2f0e6, terence: 0x8f2b23, hal: 0x4aa64a, bubbles: 0xf07f22, -}; - -export default class AngryBirdsGame extends Phaser.Scene { - constructor() { super('AngryBirdsGame'); } - - init(data) { - this.gameDef = data?.game ?? { slug: 'angrybirds', name: 'Angry Birds' }; - this.testLevel = data?.testLevel ?? null; - this.returnToEditor = !!data?.returnToEditor; - this.state = null; - this.manifest = null; - this.levelCache = new Map(); - this.levelsCompleted = 0; - this.canPersist = true; - this.accum = 0; - } - - sx(x) { return x * SCALE + OFF_X; } - sy(y) { return y * SCALE + OFF_Y; } - wx(x) { return (x - OFF_X) / SCALE; } - wy(y) { return (y - OFF_Y) / SCALE; } - - async create() { - this.cameras.main.setBackgroundColor('#7ec8e8'); - - if (this.testLevel) { - this.startLevel(this.testLevel); - return; - } - - const cached = this.cache.json.get('angrybirds-levels'); - this.manifest = cached ?? await fetch(`${GAMEDATA}/levels.json`).then((r) => r.json()).catch(() => null); - - try { - const res = await api.get('/puzzles/angrybirds/progress'); - this.levelsCompleted = res?.levelsCompleted ?? 0; - } catch (_) { - // Offline or unavailable — play in-session without persisting. - this.canPersist = false; - this.levelsCompleted = 0; - } - - this.showSelect(); - } - - // ── Level select ────────────────────────────────────────────────────────── - - clearScreen() { - if (this.tooltip) { this.tooltip.destroy?.(); this.tooltip = null; } - this.children.removeAll(); - this.input.removeAllListeners(); - } - - showSelect() { - this.clearScreen(); - this.state = null; - this.screen = 'select'; - - this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x7ec8e8); - this.drawHills(this.add.graphics()); - - this.add.text(GAME_WIDTH / 2, 70, 'ANGRY BIRDS', { - fontFamily: '"Righteous", sans-serif', fontSize: '58px', color: '#ffffff', - stroke: '#1d2a33', strokeThickness: 8, - }).setOrigin(0.5); - - if (!this.manifest) { - this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Could not load levels.', { - fontFamily: 'sans-serif', fontSize: '28px', color: '#ffffff', - }).setOrigin(0.5); - new Button(this, 120, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'), { width: 160 }); - return; - } - - this.tooltip = new Tooltip(this); - - for (const ep of this.manifest.episodes ?? []) { - this.add.text(GAME_WIDTH / 2, 150, ep.name, { - fontFamily: '"Righteous", sans-serif', fontSize: '34px', color: '#ffe9a8', - stroke: '#1d2a33', strokeThickness: 5, - }).setOrigin(0.5); - this.add.text(GAME_WIDTH / 2, 190, ep.blurb ?? '', { - fontFamily: 'sans-serif', fontSize: '20px', color: '#ffffff', - }).setOrigin(0.5); - } - - const levels = this.manifest.levels ?? []; - const COLS = 6; - const TW = 190; - const TH = 150; - const startX = GAME_WIDTH / 2 - ((Math.min(COLS, levels.length) - 1) * (TW + 20)) / 2; - levels.forEach((m, i) => { - const col = i % COLS; - const row = Math.floor(i / COLS); - const x = startX + col * (TW + 20); - const y = 320 + row * (TH + 24); - const unlocked = m.level <= this.levelsCompleted + 1; - this.makeLevelTile(x, y, m, unlocked, TW, TH); - }); - - new Button(this, 120, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'), { width: 160 }); - if (this.levelsCompleted > 0) { - new Button(this, GAME_WIDTH - 140, GAME_HEIGHT - 60, 'Reset', () => this.resetProgress(), - { width: 160, variant: 'ghost' }); - } - } - - makeLevelTile(x, y, m, unlocked, w, h) { - const best = this.bestFor(m.level); - const bg = this.add.rectangle(x, y, w, h, unlocked ? 0xffffff : 0x9aa6ad, unlocked ? 0.95 : 0.6) - .setStrokeStyle(4, unlocked ? 0x1d2a33 : 0x5c666d); - this.add.text(x, y - 40, `${m.level}`, { - fontFamily: '"Righteous", sans-serif', fontSize: '40px', - color: unlocked ? '#1d2a33' : '#e8eef1', - }).setOrigin(0.5); - this.add.text(x, y + 2, m.name, { - fontFamily: 'sans-serif', fontSize: '17px', - color: unlocked ? '#3a4a55' : '#e8eef1', - wordWrap: { width: w - 20 }, align: 'center', - }).setOrigin(0.5); - - // Stars earned so far. - for (let s = 0; s < 3; s += 1) { - this.add.text(x - 30 + s * 30, y + 46, '★', { - fontFamily: 'sans-serif', fontSize: '26px', - color: s < best.stars ? '#ffc93c' : (unlocked ? '#c9d3d9' : '#8b969c'), - }).setOrigin(0.5); - } - - if (!unlocked) { - this.add.text(x, y + 46, '🔒', { fontSize: '22px' }).setOrigin(0.5); - return; - } - - bg.setInteractive({ useHandCursor: true }); - bg.on('pointerdown', () => this.openLevel(m)); - this.tooltip?.attachTo(bg, () => ({ - title: `${m.level}. ${m.name}`, - lines: [ - best.score ? `Best: ${best.score.toLocaleString()}` : 'Not yet cleared', - best.stars ? `${best.stars} of 3 stars` : 'No stars yet', - ], - })); - } - - async openLevel(m) { - const def = await this.fetchLevel(m); - if (def) this.startLevel(def); - } - - async fetchLevel(m) { - if (this.levelCache.has(m.level)) return this.levelCache.get(m.level); - try { - const json = await fetch(`${GAMEDATA}/${m.file}`).then((r) => r.json()); - this.levelCache.set(m.level, json); - return json; - } catch (_) { return null; } - } - - // ── Progress ────────────────────────────────────────────────────────────── - - bestFor(level) { - try { - return { - score: Number(localStorage.getItem(`ab-best-${level}`) || 0), - stars: Number(localStorage.getItem(`ab-stars-${level}`) || 0), - }; - } catch (_) { return { score: 0, stars: 0 }; } - } - - recordBest(level, score, stars) { - try { - const prev = this.bestFor(level); - if (score > prev.score) localStorage.setItem(`ab-best-${level}`, String(score)); - if (stars > prev.stars) localStorage.setItem(`ab-stars-${level}`, String(stars)); - } catch (_) { /* storage full or blocked */ } - } - - async resetProgress() { - try { await api.post('/puzzles/angrybirds/reset', {}); } catch (_) { /* offline */ } - try { - for (const m of this.manifest?.levels ?? []) { - localStorage.removeItem(`ab-best-${m.level}`); - localStorage.removeItem(`ab-stars-${m.level}`); - } - } catch (_) { /* ignore */ } - this.levelsCompleted = 0; - this.showSelect(); - } - - // ── Play ────────────────────────────────────────────────────────────────── - - startLevel(def) { - this.clearScreen(); - this.screen = 'play'; - this.state = createState(def); - this.accum = 0; - this.dragging = false; - this.dragPt = null; - - this.bgGfx = this.add.graphics().setDepth(0); - this.worldGfx = this.add.graphics().setDepth(10); - this.aimGfx = this.add.graphics().setDepth(20); - this.drawBackground(); - - this.hud = this.add.container(0, 0).setDepth(40); - this.scoreText = this.add.text(GAME_WIDTH - 40, 32, '0', { - fontFamily: '"Righteous", sans-serif', fontSize: '40px', color: '#ffffff', - stroke: '#1d2a33', strokeThickness: 6, - }).setOrigin(1, 0); - this.levelText = this.add.text(GAME_WIDTH / 2, 32, `${def.level}. ${def.name}`, { - fontFamily: 'sans-serif', fontSize: '24px', color: '#ffffff', - stroke: '#1d2a33', strokeThickness: 4, - }).setOrigin(0.5, 0); - this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 34, '', { - fontFamily: 'sans-serif', fontSize: '20px', color: '#ffffff', - stroke: '#1d2a33', strokeThickness: 4, - }).setOrigin(0.5).setDepth(40); - this.hud.add([this.scoreText, this.levelText]); - - new Button(this, 110, 44, 'Back', () => this.exitLevel(), { width: 150, height: 46 }); - new Button(this, 280, 44, 'Retry', () => this.startLevel(this.state.level), { width: 150, height: 46, variant: 'ghost' }); - - this.input.on('pointerdown', (p) => this.onDown(p)); - this.input.on('pointermove', (p) => this.onMove(p)); - this.input.on('pointerup', () => this.onUp()); - } - - exitLevel() { - if (this.returnToEditor) this.scene.start('AngryBirdsEditor', { resume: true }); - else this.showSelect(); - } - - onDown(p) { - if (!this.state) return; - if (this.state.phase === 'flight') { useAbility(this.state); return; } - if (this.state.phase !== 'aim') return; - const a = slingAnchor(this.state); - const d = Math.hypot(this.wx(p.x) - a.x, this.wy(p.y) - a.y); - // Generous grab radius: the band, not the bird, is the target. - if (d > 260) return; - this.dragging = true; - this.dragPt = clampDraw(this.state, this.wx(p.x), this.wy(p.y)); - } - - onMove(p) { - if (!this.dragging || !this.state) return; - this.dragPt = clampDraw(this.state, this.wx(p.x), this.wy(p.y)); - } - - onUp() { - if (!this.dragging || !this.state) return; - this.dragging = false; - const pt = this.dragPt; - this.dragPt = null; - if (!pt) return; - if (launch(this.state, pt.x, pt.y)) playSound(this, SFX.EIGHTBIT_JUMP); - } - - update(time, delta) { - if (this.screen !== 'play' || !this.state) return; - this.accum = Math.min(this.accum + delta / 1000, 0.25); - while (this.accum >= SIM_STEP) { - this.accum -= SIM_STEP; - const events = stepSim(this.state, SIM_STEP); - if (events.length) this.processEvents(events); - } - this.render(); - } - - processEvents(events) { - for (const e of events) { - switch (e.t) { - case 'blockDestroyed': - playSound(this, SFX.EIGHTBIT_EXPLODE); - this.puff(e.x, e.y, MAT_COLORS[e.material]?.fill ?? 0xffffff); - break; - case 'pigKilled': - playSound(this, SFX.EIGHTBIT_EXPLODE_2); - this.puff(e.x, e.y, 0x86c232); - break; - case 'explosion': - playSound(this, SFX.SCIFI_EXPLODE); - this.blast(e.x, e.y, e.radius); - break; - case 'won': - playSound(this, SFX.VICTORY_SHORT); - this.onFinished(true, e.stars); - break; - case 'lost': - this.onFinished(false, 0); - break; - default: break; - } - } - } - - puff(x, y, color) { - const c = this.add.circle(this.sx(x), this.sy(y), 6, color, 0.9).setDepth(30); - this.tweens.add({ - targets: c, scale: 4, alpha: 0, duration: 340, ease: 'Cubic.Out', - onComplete: () => c.destroy(), - }); - } - - blast(x, y, radius) { - const c = this.add.circle(this.sx(x), this.sy(y), radius * SCALE * 0.3, 0xffd27f, 0.7).setDepth(30); - this.tweens.add({ - targets: c, scale: 3.4, alpha: 0, duration: 420, ease: 'Cubic.Out', - onComplete: () => c.destroy(), - }); - this.cameras.main.shake(220, 0.006); - } - - async onFinished(won, stars) { - const st = this.state; - const level = st.level.level; - if (won) this.recordBest(level, st.score, stars); - - // An editor test-play is not a real run: it must not advance saved - // progress or write a match record. - if (won && this.canPersist && !this.returnToEditor) { - try { - const res = await api.post('/puzzles/angrybirds/complete', { level }); - if (res?.levelsCompleted != null) { - this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); - } - } catch (_) { /* best effort */ } - api.post('/history/single-player', { - slug: 'angrybirds', - score: Math.min(st.score, 100000), - opponentScores: [], - result: 'win', - }).catch(() => { /* best effort */ }); - } - if (won && level > this.levelsCompleted) this.levelsCompleted = level; - - this.showResult(won, stars); - } - - showResult(won, stars) { - const panel = this.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2).setDepth(60); - panel.add(this.add.rectangle(0, 0, 620, 380, 0x11212b, 0.94).setStrokeStyle(5, 0xffc93c)); - panel.add(this.add.text(0, -128, won ? 'LEVEL CLEARED' : 'OUT OF BIRDS', { - fontFamily: '"Righteous", sans-serif', fontSize: '42px', - color: won ? '#ffc93c' : '#ff8a7a', - }).setOrigin(0.5)); - - if (won) { - for (let s = 0; s < 3; s += 1) { - panel.add(this.add.text(-90 + s * 90, -50, '★', { - fontFamily: 'sans-serif', fontSize: '64px', - color: s < stars ? '#ffc93c' : '#3c4b56', - }).setOrigin(0.5)); - } - } - panel.add(this.add.text(0, 30, `Score ${this.state.score.toLocaleString()}`, { - fontFamily: 'sans-serif', fontSize: '30px', color: '#ffffff', - }).setOrigin(0.5)); - - const nextEntry = (this.manifest?.levels ?? []).find((m) => m.level === this.state.level.level + 1); - const retry = new Button(this, -150, 120, 'Retry', () => { - panel.destroy(); - this.startLevel(this.state.level); - }, { width: 230, height: 56 }); - panel.add(retry); - - if (won && nextEntry) { - const next = new Button(this, 150, 120, 'Next', async () => { - panel.destroy(); - const def = await this.fetchLevel(nextEntry); - if (def) this.startLevel(def); else this.showSelect(); - }, { width: 230, height: 56 }); - panel.add(next); - } else { - const back = new Button(this, 150, 120, 'Levels', () => { panel.destroy(); this.showSelect(); }, - { width: 230, height: 56, variant: 'ghost' }); - panel.add(back); - } - } - - // ── Rendering ───────────────────────────────────────────────────────────── - - drawHills(g) { - g.fillStyle(0x4f9d3a, 1); - g.fillEllipse(GAME_WIDTH * 0.2, GAME_HEIGHT + 60, 900, 340); - g.fillEllipse(GAME_WIDTH * 0.75, GAME_HEIGHT + 90, 1100, 420); - g.fillStyle(0x3f8c2e, 1); - g.fillRect(0, GAME_HEIGHT - 40, GAME_WIDTH, 40); - } - - drawBackground() { - const g = this.bgGfx; - g.clear(); - g.fillStyle(0x7ec8e8, 1); - g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); - // Distant hills behind the play line. - g.fillStyle(0x9fd6a0, 1); - g.fillEllipse(GAME_WIDTH * 0.25, this.sy(this.state.groundY) + 40, 1200, 420); - g.fillEllipse(GAME_WIDTH * 0.8, this.sy(this.state.groundY) + 60, 1500, 500); - // Ground. - const gy = this.sy(this.state.groundY); - g.fillStyle(0x6fbf46, 1); - g.fillRect(0, gy, GAME_WIDTH, GAME_HEIGHT - gy); - g.fillStyle(0x8b5a2b, 1); - g.fillRect(0, gy + 26, GAME_WIDTH, GAME_HEIGHT - gy - 26); - g.lineStyle(4, 0x4d8c2f, 1); - g.lineBetween(0, gy, GAME_WIDTH, gy); - } - - render() { - const st = this.state; - const g = this.worldGfx; - g.clear(); - - // Blocks. - for (const [id, rec] of st.blocks) { - const b = st.world.byId.get(id); - if (!b) continue; - const col = MAT_COLORS[rec.material] ?? MAT_COLORS.wood; - const pts = b.wverts.map(([x, y]) => new Phaser.Math.Vector2(this.sx(x), this.sy(y))); - g.fillStyle(col.fill, rec.material === 'ice' ? 0.72 : 1); - g.lineStyle(3, col.line, 1); - g.beginPath(); - g.moveTo(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i].x, pts[i].y); - g.closePath(); - g.fillPath(); - g.strokePath(); - - // Damage reads as cracks across the face, darkening as hp falls. - const f = rec.hp / rec.maxHp; - if (f <= 2 / 3) { - const stage = f > 1 / 3 ? 1 : 2; - g.lineStyle(2, 0x2b1c10, 0.55); - const cx = this.sx(b.x); - const cy = this.sy(b.y); - const r = Math.min(rec.w, rec.h) * SCALE * 0.42; - for (let i = 0; i < stage * 2; i += 1) { - const a = (i / (stage * 2)) * Math.PI * 2 + b.angle; - g.lineBetween(cx, cy, cx + Math.cos(a) * r, cy + Math.sin(a) * r); - } - } - if (rec.material === 'tnt') { - g.fillStyle(0xffe9a8, 1); - g.fillRect(this.sx(b.x) - 14, this.sy(b.y) - 5, 28, 10); - } - } - - // Pigs. - for (const [id, rec] of st.pigs) { - const b = st.world.byId.get(id); - if (!b) continue; - const x = this.sx(b.x); - const y = this.sy(b.y); - const r = rec.r * SCALE; - const hurt = rec.hp / rec.maxHp; - g.fillStyle(hurt > 2 / 3 ? 0x86c232 : hurt > 1 / 3 ? 0x9ec24a : 0xb6b84a, 1); - g.fillCircle(x, y, r); - g.lineStyle(3, 0x4c7a1c, 1); - g.strokeCircle(x, y, r); - g.fillStyle(0xffffff, 1); - g.fillCircle(x - r * 0.3, y - r * 0.25, r * 0.22); - g.fillCircle(x + r * 0.3, y - r * 0.25, r * 0.22); - g.fillStyle(0x1d2a33, 1); - g.fillCircle(x - r * 0.26, y - r * 0.25, r * 0.1); - g.fillCircle(x + r * 0.34, y - r * 0.25, r * 0.1); - g.fillStyle(0x6ba428, 1); - g.fillCircle(x, y + r * 0.18, r * 0.32); - g.fillStyle(0x3f6b16, 1); - g.fillCircle(x - r * 0.12, y + r * 0.18, r * 0.08); - g.fillCircle(x + r * 0.12, y + r * 0.18, r * 0.08); - } - - // Slingshot. - const a = slingAnchor(st); - const ax = this.sx(a.x); - const ay = this.sy(a.y); - const groundY = this.sy(st.groundY); - g.lineStyle(14, 0x6b4423, 1); - g.lineBetween(ax, ay + 10, ax, groundY); - g.lineBetween(ax, ay + 12, ax - 18, ay - 14); - g.lineBetween(ax, ay + 12, ax + 18, ay - 14); - - // Birds in flight. - for (const id of st.activeBirds) { - const b = st.world.byId.get(id); - if (!b) continue; - this.drawBird(g, b, this.sx(b.x), this.sy(b.y), b.radius * SCALE); - } - - this.renderAim(); - this.scoreText?.setText(st.score.toLocaleString()); - this.updateHint(); - } - - drawBird(g, body, x, y, r) { - const def = BIRDS[body.userData?.bird] ?? BIRDS.red; - g.fillStyle(BIRD_COLORS[def.id] ?? 0xd6332b, 1); - g.fillCircle(x, y, r); - g.lineStyle(3, 0x6b1a15, 1); - g.strokeCircle(x, y, r); - g.fillStyle(0xffffff, 1); - g.fillCircle(x + r * 0.18, y - r * 0.28, r * 0.3); - g.fillStyle(0x1d2a33, 1); - g.fillCircle(x + r * 0.26, y - r * 0.28, r * 0.14); - g.fillStyle(0xf5a623, 1); - g.fillTriangle(x + r * 0.55, y, x + r * 1.15, y - r * 0.16, x + r * 0.55, y + r * 0.3); - } - - renderAim() { - const g = this.aimGfx; - g.clear(); - const st = this.state; - const a = slingAnchor(st); - - // Ghost trails of previous shots. Without this, aiming is guesswork - // instead of iteration — it is how the original teaches. - g.fillStyle(0xffffff, 0.4); - for (const trail of st.trails) { - for (let i = 0; i < trail.length; i += 6) { - g.fillCircle(this.sx(trail[i].x), this.sy(trail[i].y), 3); - } - } - - if (st.phase === 'aim') { - const def = currentBird(st); - const pt = this.dragPt; - const bx = pt ? this.sx(pt.x) : this.sx(a.x); - const by = pt ? this.sy(pt.y) : this.sy(a.y) - 8; - if (def) { - // Band, drawn behind the waiting bird. - g.lineStyle(8, 0x3a2416, 1); - g.lineBetween(this.sx(a.x) - 18, this.sy(a.y) - 14, bx, by); - g.lineBetween(this.sx(a.x) + 18, this.sy(a.y) - 14, bx, by); - this.drawBird(g, { userData: { bird: def.id } }, bx, by, def.r * SCALE); - } - // Queue of waiting birds beside the sling. - for (let i = st.birdIndex + 1; i < st.birdQueue.length; i += 1) { - const d = BIRDS[st.birdQueue[i]] ?? BIRDS.red; - const qx = this.sx(a.x) - 70 - (i - st.birdIndex - 1) * 46; - this.drawBird(g, { userData: { bird: d.id } }, qx, this.sy(st.groundY) - d.r * SCALE, d.r * SCALE * 0.85); - } - } - } - - updateHint() { - const st = this.state; - if (!this.hintText) return; - if (st.phase === 'aim') { - const def = currentBird(st); - const left = birdsRemaining(st); - this.hintText.setText(def - ? `${def.name} — ${def.blurb} (${left} left)` - : ''); - } else if (st.phase === 'flight' && !st.abilityUsed) { - const id = st.activeBirds[0]; - const body = st.world.byId.get(id); - const def = body ? BIRDS[body.userData?.bird] : null; - this.hintText.setText(def && def.ability !== 'none' ? 'Click to use ability' : ''); - } else { - this.hintText.setText(''); - } - } -} diff --git a/src/games/angrybirds/AngryBirdsLogic.js b/src/games/angrybirds/AngryBirdsLogic.js deleted file mode 100644 index 72aad37..0000000 --- a/src/games/angrybirds/AngryBirdsLogic.js +++ /dev/null @@ -1,585 +0,0 @@ -// Angry Birds — game rules over the rigid-body solver. Zero imports besides -// the physics module, no Phaser, no DOM, no timers. Shared by the client -// scene, the level editor, the offline generator and the verifier. -// -// ── WHAT MAKES IT READ AS ANGRY BIRDS ─────────────────────────────────────── -// Not the birds. The two things that actually matter are: -// -// * MATERIALS WITH DISTINCT DENSITY. Wood, stone and ice differ in mass, -// friction and fragility, so a structure has a load path and a support -// beam worth aiming at. Uniform blocks turn every level into "hit it -// harder". -// * DAMAGE AS A HEALTH MODEL, NOT INSTANT SHATTERING. A block accumulates -// damage from impacts above its threshold and cracks through visible -// stages before breaking. Pigs take damage the same way, which is why -// they die to DEBRIS LANDING ON THEM rather than only to direct hits — -// the collapse doing the killing is the whole feel of the game. Clones -// that destroy a block on any contact feel wrong immediately. -// -// ── UNITS ─────────────────────────────────────────────────────────────────── -// Pixels and seconds in the level's own logical space (level.world). The scene -// maps that to the 1920x1080 canvas with a camera; nothing in here knows about -// the screen. -// -// Impulse units: contact impulses are mass x velocity in the solver's units, -// where a 24x80 wood plank masses ~1050. A resting contact in a tall stack -// runs a few 1e4; a solid bird hit runs 1e6+. Damage thresholds live in that -// space — see calibrateDamage() in tools/verifyAngryBirds.js, which pins the -// separation between "resting" and "hit" so a tower can never crush itself. - -import { - PHYS, createWorld, addBox, addCircle, removeBody, - substep, isSettled, applyExplosion, cloneWorld, hashWorld, wakeBody, -} from './AngryBirdsPhysics.js'; - -export const TUNING = { - GROUND_Y: 900, - SLING_X: 260, - SLING_Y: 700, - SLING_HEIGHT: 150, // fork height above the ground contact - MAX_DRAW: 150, // px the band can be pulled back - SPEED_PER_DRAW: 13.5, // draw px -> launch px/s (150 * 13.5 = 2025) - MIN_DRAW: 12, // below this a release is treated as a cancel - MAX_SHOT_TIME: 12, // s before a shot is force-resolved - SETTLE_GRACE: 0.35, // s of stillness required after the world sleeps - DESPAWN_X: -400, // bodies past these bounds are removed - DESPAWN_X_MAX: 4200, - DESPAWN_Y: 1600, - DAMAGE_SCALE: 1 / 9000, // impulse above threshold -> hp - DEBRIS_MIN_IMPULSE: 60000, -}; - -// hp/threshold are in solver impulse units (see the header note). -export const MATERIALS = { - wood: { - id: 'wood', density: 0.55, friction: 0.55, restitution: 0.10, - hp: 42, threshold: 150000, points: 500, - }, - stone: { - id: 'stone', density: 1.35, friction: 0.62, restitution: 0.05, - hp: 105, threshold: 320000, points: 1000, - }, - ice: { - id: 'ice', density: 0.32, friction: 0.18, restitution: 0.14, - hp: 18, threshold: 90000, points: 300, - }, - // TNT is deliberately fragile: it is a trigger, not a wall. - tnt: { - id: 'tnt', density: 0.6, friction: 0.5, restitution: 0.08, - hp: 10, threshold: 70000, points: 800, - blast: { radius: 260, power: 1500, damage: 60 }, - }, -}; - -export const PIG = { - density: 0.5, friction: 0.5, restitution: 0.25, - hp: 26, threshold: 80000, points: 5000, -}; - -export const BIRDS = { - red: { - id: 'red', name: 'Red', r: 17, density: 2.2, restitution: 0.35, - ability: 'none', blurb: 'No trick. Just a bird.', - }, - chuck: { - id: 'chuck', name: 'Chuck', r: 15, density: 1.9, restitution: 0.3, - ability: 'dart', params: { mult: 2.15 }, - blurb: 'Tap to dart forward at speed. Best against wood.', - }, - blue: { - id: 'blue', name: 'Blue', r: 13, density: 1.5, restitution: 0.35, - ability: 'split', params: { count: 3, spreadDeg: 14 }, - blurb: 'Tap to split into three. Shreds ice.', - }, - bomb: { - id: 'bomb', name: 'Bomb', r: 21, density: 3.1, restitution: 0.2, - ability: 'blast', params: { radius: 240, power: 1400, damage: 45 }, - blurb: 'Tap to detonate. Levels stone.', - }, - matilda: { - id: 'matilda', name: 'Matilda', r: 19, density: 2.2, restitution: 0.25, - ability: 'egg', params: { radius: 170, power: 900, damage: 30, kick: 620 }, - blurb: 'Tap to drop an egg bomb and climb.', - }, - terence: { - id: 'terence', name: 'Terence', r: 27, density: 4.4, restitution: 0.15, - ability: 'none', blurb: 'Enormous. Goes through things.', - }, - hal: { - id: 'hal', name: 'Hal', r: 16, density: 2.0, restitution: 0.3, - ability: 'boomerang', params: { spin: 26 }, - blurb: 'Tap to boomerang back. Hits from behind.', - }, - bubbles: { - id: 'bubbles', name: 'Bubbles', r: 12, density: 1.6, restitution: 0.4, - ability: 'inflate', params: { scale: 3.0, push: 520 }, - blurb: 'Tap to inflate and shove everything apart.', - }, -}; - -export const SCORING = { - PIG: PIG.points, - BIRD_LEFT: 10000, -}; - -// ── Level construction ────────────────────────────────────────────────────── - -/** - * Build a live state from a level definition. - * - * Level schema: - * { version, level, name, episode, world: {w, h, groundY}, - * birds: ['red', 'chuck', ...], - * blocks: [{ x, y, w, h, angle?, material }], - * pigs: [{ x, y, r }], - * terrain?: [{ x, y, w, h, angle? }], // static scenery - * stars: [oneStar, twoStar, threeStar] } - */ -export function createState(level) { - const groundY = level.world?.groundY ?? TUNING.GROUND_Y; - const world = createWorld(); - const state = { - level, - world, - groundY, - blocks: new Map(), // bodyId -> { material, hp, maxHp, w, h } - pigs: new Map(), // bodyId -> { hp, maxHp, r } - birdQueue: (level.birds ?? ['red']).slice(), - birdIndex: 0, - activeBirds: [], // bodyIds currently in flight - abilityUsed: false, - phase: 'aim', // aim | flight | resolving | won | lost - score: 0, - shotTime: 0, - settleTimer: 0, - trails: [], // completed shot trails, for the aiming ghost - liveTrail: [], - events: [], - }; - - // Ground: wide enough that nothing rolls off the end of the world. - addBox(world, { - x: (level.world?.w ?? 1920) / 2, y: groundY + 400, w: 12000, h: 800, - isStatic: true, friction: 0.75, restitution: 0.05, - }); - - for (const t of level.terrain ?? []) { - addBox(world, { - x: t.x, y: t.y, w: t.w, h: t.h, angle: t.angle ?? 0, - isStatic: true, friction: t.friction ?? 0.7, restitution: 0.05, - }); - } - - for (const b of level.blocks ?? []) { - const mat = MATERIALS[b.material] ?? MATERIALS.wood; - const body = addBox(world, { - x: b.x, y: b.y, w: b.w, h: b.h, angle: b.angle ?? 0, - density: mat.density, friction: mat.friction, restitution: mat.restitution, - }); - body.userData = { kind: 'block', material: mat.id }; - state.blocks.set(body.id, { material: mat.id, hp: mat.hp, maxHp: mat.hp, w: b.w, h: b.h }); - } - - for (const p of level.pigs ?? []) { - const r = p.r ?? 22; - // Bigger pigs are tougher, so a boss pig reads as one at a glance. - const scale = r / 22; - const body = addCircle(world, { - x: p.x, y: p.y, r, - density: PIG.density, friction: PIG.friction, restitution: PIG.restitution, - }); - body.userData = { kind: 'pig' }; - const hp = PIG.hp * scale; - state.pigs.set(body.id, { hp, maxHp: hp, r }); - } - - return state; -} - -// ── Aiming and launching ──────────────────────────────────────────────────── - -export function slingAnchor(state) { - return { - x: state.level.world?.slingX ?? TUNING.SLING_X, - y: (state.level.world?.slingY ?? (state.groundY - TUNING.SLING_HEIGHT)), - }; -} - -/** Clamp a raw drag point to the band's reach. Returns the clamped point. */ -export function clampDraw(state, px, py) { - const a = slingAnchor(state); - let dx = px - a.x; - let dy = py - a.y; - const d = Math.hypot(dx, dy); - if (d > TUNING.MAX_DRAW) { - dx = (dx / d) * TUNING.MAX_DRAW; - dy = (dy / d) * TUNING.MAX_DRAW; - } - return { x: a.x + dx, y: a.y + dy, draw: Math.min(d, TUNING.MAX_DRAW) }; -} - -/** Launch velocity for a drag point — the bird flies OPPOSITE the pull. */ -export function drawToVelocity(state, px, py) { - const a = slingAnchor(state); - const c = clampDraw(state, px, py); - return { - vx: (a.x - c.x) * TUNING.SPEED_PER_DRAW, - vy: (a.y - c.y) * TUNING.SPEED_PER_DRAW, - draw: c.draw, - }; -} - -export function currentBird(state) { - if (state.birdIndex >= state.birdQueue.length) return null; - return BIRDS[state.birdQueue[state.birdIndex]] ?? BIRDS.red; -} - -export function birdsRemaining(state) { - return Math.max(0, state.birdQueue.length - state.birdIndex); -} - -function spawnBird(state, def, x, y, vx, vy) { - const body = addCircle(state.world, { - x, y, r: def.r, - density: def.density, friction: 0.4, restitution: def.restitution, - }); - body.userData = { kind: 'bird', bird: def.id }; - body.vx = vx; - body.vy = vy; - state.activeBirds.push(body.id); - return body; -} - -/** Fire the queued bird. Returns false if the draw was too short to count. */ -export function launch(state, px, py) { - if (state.phase !== 'aim') return false; - const def = currentBird(state); - if (!def) return false; - const { vx, vy, draw } = drawToVelocity(state, px, py); - if (draw < TUNING.MIN_DRAW) return false; - - const a = slingAnchor(state); - spawnBird(state, def, a.x, a.y, vx, vy); - state.birdIndex += 1; - state.abilityUsed = false; - state.phase = 'flight'; - state.shotTime = 0; - state.settleTimer = 0; - state.liveTrail = [{ x: a.x, y: a.y }]; - state.events.push({ t: 'birdLaunched', bird: def.id, vx, vy }); - return true; -} - -/** - * Fire the in-flight bird's ability. One per shot, as in the original — the - * limit is what makes timing the tap a decision rather than a reflex. - */ -export function useAbility(state) { - if (state.phase !== 'flight' || state.abilityUsed) return false; - const id = state.activeBirds[0]; - const body = state.world.byId.get(id); - if (!body) return false; - const def = BIRDS[body.userData?.bird] ?? BIRDS.red; - if (def.ability === 'none') return false; - - state.abilityUsed = true; - const p = def.params ?? {}; - - switch (def.ability) { - case 'dart': { - const sp = Math.hypot(body.vx, body.vy) || 1; - body.vx = (body.vx / sp) * sp * p.mult; - body.vy = (body.vy / sp) * sp * p.mult; - break; - } - case 'split': { - const ang = Math.atan2(body.vy, body.vx); - const sp = Math.hypot(body.vx, body.vy); - const spread = (p.spreadDeg * Math.PI) / 180; - for (let i = 1; i < p.count; i += 1) { - const off = (i % 2 === 1 ? 1 : -1) * spread * Math.ceil(i / 2); - spawnBird(state, def, body.x, body.y, - Math.cos(ang + off) * sp, Math.sin(ang + off) * sp); - } - break; - } - case 'blast': { - detonate(state, body.x, body.y, p.radius, p.power, p.damage); - killBody(state, body); - break; - } - case 'egg': { - detonate(state, body.x, body.y + def.r + 8, p.radius, p.power, p.damage); - body.vy = -p.kick; // Matilda climbs as the egg drops - break; - } - case 'boomerang': { - body.vx = -body.vx * 1.15; - body.omega = p.spin; - break; - } - case 'inflate': { - // Grow in place and shove neighbours apart. Rebuilding the body is - // simpler than mutating radius + mass in the solver, and keeps the - // invariant that a body's mass properties never change mid-life. - const grown = spawnBird(state, def, body.x, body.y, body.vx, body.vy); - grown.userData = { kind: 'bird', bird: def.id, inflated: true }; - state.activeBirds = state.activeBirds.filter((b) => b !== body.id); - removeBody(state.world, body); - const bigger = addCircle(state.world, { - x: grown.x, y: grown.y, r: def.r * p.scale, - density: def.density * 0.35, friction: 0.5, restitution: def.restitution, - }); - bigger.userData = { kind: 'bird', bird: def.id, inflated: true }; - bigger.vx = grown.vx; bigger.vy = grown.vy; - state.activeBirds = state.activeBirds.filter((b) => b !== grown.id); - removeBody(state.world, grown); - state.activeBirds.unshift(bigger.id); - applyExplosion(state.world, bigger.x, bigger.y, def.r * p.scale * 2.2, p.push); - break; - } - default: break; - } - - state.events.push({ t: 'abilityUsed', bird: def.id, x: body.x, y: body.y }); - return true; -} - -// ── Damage ────────────────────────────────────────────────────────────────── - -function crackStage(hp, maxHp) { - const f = hp / maxHp; - if (f > 2 / 3) return 0; - if (f > 1 / 3) return 1; - return 2; -} - -function killBody(state, body) { - state.activeBirds = state.activeBirds.filter((b) => b !== body.id); - state.blocks.delete(body.id); - state.pigs.delete(body.id); - removeBody(state.world, body); -} - -function detonate(state, x, y, radius, power, damage) { - state.events.push({ t: 'explosion', x, y, radius }); - const hit = applyExplosion(state.world, x, y, radius, power); - for (const { body, falloff } of hit) { - const dmg = damage * falloff; - if (state.blocks.has(body.id)) damageBlock(state, body, dmg); - else if (state.pigs.has(body.id)) damagePig(state, body, dmg); - } -} - -function damageBlock(state, body, amount) { - const rec = state.blocks.get(body.id); - if (!rec) return; - const before = crackStage(rec.hp, rec.maxHp); - rec.hp -= amount; - const after = crackStage(Math.max(rec.hp, 0), rec.maxHp); - if (rec.hp <= 0) { - const mat = MATERIALS[rec.material]; - state.score += mat.points; - state.events.push({ t: 'blockDestroyed', id: body.id, material: rec.material, x: body.x, y: body.y }); - // TNT takes the neighbourhood with it. Remove it BEFORE detonating so it - // cannot damage itself and recurse. - const blast = mat.blast; - killBody(state, body); - if (blast) detonate(state, body.x, body.y, blast.radius, blast.power, blast.damage); - return; - } - if (after !== before) { - state.events.push({ t: 'blockCracked', id: body.id, stage: after, x: body.x, y: body.y }); - } -} - -function damagePig(state, body, amount) { - const rec = state.pigs.get(body.id); - if (!rec) return; - const before = crackStage(rec.hp, rec.maxHp); - rec.hp -= amount; - if (rec.hp <= 0) { - state.score += SCORING.PIG; - state.events.push({ t: 'pigKilled', id: body.id, x: body.x, y: body.y }); - killBody(state, body); - return; - } - const after = crackStage(rec.hp, rec.maxHp); - if (after !== before) { - state.events.push({ t: 'pigHurt', id: body.id, stage: after, x: body.x, y: body.y }); - } -} - -// Convert this substep's contact impulses into damage. Reading the solver's -// per-contact peak (rather than a body's total) is what lets debris landing on -// a pig hurt it exactly as much as the geometry says it should. -function applyContactDamage(state) { - for (const c of state.world.contactList) { - // impactImpulse, never maxImpulse — see the note in Physics.prestep. The - // accumulated impulse carries the weight of everything stacked above, so - // using it would make tall towers damage themselves at rest. - const imp = c.impactImpulse; - if (imp <= 0) continue; - for (const body of [c.a, c.b]) { - const block = state.blocks.get(body.id); - if (block) { - const mat = MATERIALS[block.material]; - if (imp > mat.threshold) damageBlock(state, body, (imp - mat.threshold) * TUNING.DAMAGE_SCALE); - continue; - } - const pig = state.pigs.get(body.id); - if (pig && imp > PIG.threshold) { - damagePig(state, body, (imp - PIG.threshold) * TUNING.DAMAGE_SCALE); - } - } - } -} - -function despawnStrays(state) { - for (const b of [...state.world.bodies]) { - if (b.isStatic) continue; - if (b.x > TUNING.DESPAWN_X && b.x < TUNING.DESPAWN_X_MAX && b.y < TUNING.DESPAWN_Y) continue; - if (state.pigs.has(b.id)) { - // A pig that falls off the world still counts as dealt with. - state.score += SCORING.PIG; - state.events.push({ t: 'pigKilled', id: b.id, x: b.x, y: b.y, offscreen: true }); - } - killBody(state, b); - } -} - -// ── The frame ─────────────────────────────────────────────────────────────── - -/** - * Advance the sim. Returns an ordered event list the renderer replays as FX and - * sound — the same contract GooTower and Peggle use. - */ -export function stepSim(state, dt) { - state.events = []; - if (state.phase === 'won' || state.phase === 'lost') return state.events; - - const clamped = Math.min(dt, 0.05); - const steps = Math.max(1, Math.round(clamped / PHYS.SUBSTEP_DT)); - for (let i = 0; i < steps; i += 1) { - substep(state.world, PHYS.SUBSTEP_DT); - applyContactDamage(state); - } - despawnStrays(state); - - if (state.phase === 'flight' || state.phase === 'resolving') { - state.shotTime += clamped; - const lead = state.world.byId.get(state.activeBirds[0]); - if (lead) state.liveTrail.push({ x: lead.x, y: lead.y }); - - if (state.pigs.size === 0) { - finish(state, true); - return state.events; - } - - const still = isSettled(state.world); - state.settleTimer = still ? state.settleTimer + clamped : 0; - const resolved = state.settleTimer >= TUNING.SETTLE_GRACE - || state.shotTime >= TUNING.MAX_SHOT_TIME; - - if (resolved) { - if (state.liveTrail.length > 2) state.trails.push(state.liveTrail); - state.liveTrail = []; - // Spent birds are cleared so they can't prop a structure up forever. - for (const id of [...state.activeBirds]) { - const b = state.world.byId.get(id); - if (b) killBody(state, b); - } - state.activeBirds = []; - state.events.push({ t: 'shotEnded', score: state.score }); - - if (state.pigs.size === 0) finish(state, true); - else if (birdsRemaining(state) === 0) finish(state, false); - else state.phase = 'aim'; - } - } - - return state.events; -} - -function finish(state, won) { - if (won) { - const bonus = birdsRemaining(state) * SCORING.BIRD_LEFT; - state.score += bonus; - state.phase = 'won'; - state.events.push({ t: 'won', score: state.score, birdBonus: bonus, stars: starsFor(state) }); - } else { - state.phase = 'lost'; - state.events.push({ t: 'lost', score: state.score }); - } -} - -export function starsFor(state, score = state.score) { - const cuts = state.level.stars ?? []; - let stars = 0; - for (const c of cuts) if (score >= c) stars += 1; - return Math.min(3, Math.max(state.phase === 'won' ? 1 : 0, stars)); -} - -export const isWon = (state) => state.phase === 'won'; -export const isLost = (state) => state.phase === 'lost'; - -// ── Determinism helpers ───────────────────────────────────────────────────── - -export function cloneState(state) { - return { - ...state, - world: cloneWorld(state.world), - blocks: new Map([...state.blocks].map(([k, v]) => [k, { ...v }])), - pigs: new Map([...state.pigs].map(([k, v]) => [k, { ...v }])), - birdQueue: state.birdQueue.slice(), - activeBirds: state.activeBirds.slice(), - trails: state.trails.map((t) => t.slice()), - liveTrail: state.liveTrail.slice(), - events: [], - }; -} - -export function hashState(state) { - let h = hashWorld(state.world); - const mixInt = (v) => { h = (Math.imul(h ^ (v | 0), 0x01000193) >>> 0); }; - mixInt(state.score); - mixInt(state.birdIndex); - mixInt(state.pigs.size); - mixInt(state.blocks.size); - for (const [id, b] of [...state.blocks].sort((p, q) => p[0] - q[0])) { - mixInt(id); mixInt(Math.round(b.hp * 1000)); - } - for (const [id, p] of [...state.pigs].sort((a, b) => a[0] - b[0])) { - mixInt(id); mixInt(Math.round(p.hp * 1000)); - } - return h >>> 0; -} - -/** - * Dry-run a shot on a clone and report what it would achieve. This is what the - * winnability bot samples over and what the generator measures star thresholds - * from; it never touches the live state. - */ -export function simulateShot(state, px, py, opts = {}) { - const sim = cloneState(state); - if (!launch(sim, px, py)) return null; - const abilityAt = opts.abilityAt ?? -1; - let t = 0; - let guard = 0; - while (sim.phase === 'flight' && guard < 4000) { - if (abilityAt >= 0 && t >= abilityAt && !sim.abilityUsed) useAbility(sim); - stepSim(sim, 1 / 60); - t += 1 / 60; - guard += 1; - } - return { - score: sim.score - state.score, - pigsKilled: state.pigs.size - sim.pigs.size, - blocksDestroyed: state.blocks.size - sim.blocks.size, - won: sim.phase === 'won', - finalScore: sim.score, - state: sim, - }; -} - -export { PHYS, wakeBody }; diff --git a/src/games/angrybirds/AngryBirdsPhysics.js b/src/games/angrybirds/AngryBirdsPhysics.js deleted file mode 100644 index 79097cc..0000000 --- a/src/games/angrybirds/AngryBirdsPhysics.js +++ /dev/null @@ -1,943 +0,0 @@ -// 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); -} diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js index 1bfe239..7d829f8 100644 --- a/src/games/gootower/GooTowerGame.js +++ b/src/games/gootower/GooTowerGame.js @@ -25,9 +25,33 @@ const GRASS_DARK = 0x3f7d28; const GRASS_INSET = DIRT_STROKE_W; // keeps the whole accent stroke visible; grass starts past it const GRASS_DEPTH = 15; // how far the patchy band reaches down into the dirt, beyond the inset const GRASS_SEG = 20; // approx width of one grass patch, in px -const SPIKE = 0x7d3550; -const SPIKE_RIM = 0xd46a8c; -const FIRE = 0xd4642a; +// Spikes -- gunmetal plate with faceted steel teeth stamped on every outward +// edge. SPIKE_LIGHT_DIR is a fixed "sun" used to pick which facet of each +// tooth reads as lit vs shadowed, regardless of which way the edge faces. +const SPIKE_BASE = 0x3a4250; +const SPIKE_BASE_DARK = 0x1f232b; +const SPIKE_BASE_EDGE_HI = 0x788597; +const SPIKE_BASE_EDGE_LO = 0x13151a; +const SPIKE_TOOTH_LIGHT = 0xcdd9e6; +const SPIKE_TOOTH_DARK = 0x4a5262; +const SPIKE_RIVET = 0x14161b; +const SPIKE_RIVET_HI = 0x9aa5b4; +const SPIKE_RUST = 0x8a5a3a; +const SPIKE_GLINT = 0xf3fbff; +const SPIKE_LIGHT_DIR = { x: -0.55, y: -0.83 }; +const SPIKE_TOOTH_LEN = 20; +const SPIKE_TOOTH_STEP = 24; + +// Fire/lava -- layered heat gradient (crust rim -> white-hot core) with an +// animated surface: drifting flow streaks and rising/popping bubbles. +const LAVA_CRUST = 0x2a1410; +const LAVA_CRUST_EDGE = 0x140a08; +const LAVA_OUTER = 0x8a2410; +const LAVA_MID = 0xd4642a; +const LAVA_HOT = 0xf59a3c; +const LAVA_CORE = 0xffe9a8; +const LAVA_FLOW_LINE = 0xffb459; +const LAVA_BUBBLE = 0xffd98a; const STRAND = 0x1c1626; const STRAND_HOT = 0xff5a4a; const PIPE_BODY = 0x3d5a6c; @@ -103,7 +127,10 @@ const hash01 = (n) => { // Splits a terrain polygon into edges tagged with their true outward normal // (found via the centroid, so it works regardless of winding order) and // whether that edge faces up -- i.e. is exposed ground a grass patch belongs -// on, as opposed to a side or the underside. +// on, as opposed to a side or the underside. Despite the historical name, +// every edge is returned (not just top-facing ones): callers that only want +// the top filter on `.top` themselves; spike teeth and lava crust want all of +// them. function topFacingEdges(poly) { const n = poly.length; let cx = 0, cy = 0; @@ -123,6 +150,32 @@ function topFacingEdges(poly) { return edges; } +function polyCentroid(poly) { + let cx = 0, cy = 0; + for (const [x, y] of poly) { cx += x; cy += y; } + return { x: cx / poly.length, y: cy / poly.length }; +} + +// Shrinks a polygon toward a center point by factor f (1 = unchanged, 0 = +// collapsed to the point). Used to fake a soft radial "hot core" gradient on +// lava by layering a few of these, each hotter and more shrunk than the last, +// instead of one flat fill. +function scalePoly(poly, cx, cy, f) { + return poly.map(([x, y]) => [cx + (x - cx) * f, cy + (y - cy) * f]); +} + +// The outward-facing unit normal of edge p0->p1, chosen to point away from +// `insidePt` -- used to shade a tooth's two facets against a fixed light +// direction regardless of which way the tooth itself is pointing. +function edgeNormalAway(p0, p1, insidePt) { + const ex = p1.x - p0.x, ey = p1.y - p0.y; + const len = Math.hypot(ex, ey) || 1; + let nx = -ey / len, ny = ex / len; + const mx = (p0.x + p1.x) / 2, my = (p0.y + p1.y) / 2; + if ((insidePt.x - mx) * nx + (insidePt.y - my) * ny > 0) { nx = -nx; ny = -ny; } + return { x: nx, y: ny }; +} + // Standard ray-cast point-in-polygon test, used to clip the dirt-fleck // texture to a terrain piece's actual shape rather than its bounding box -- // most pieces are simple rectangles/trapezoids, but this keeps flecks from @@ -530,12 +583,11 @@ export default class GooTowerGame extends Phaser.Scene { if (e.top) this.drawGrassEdge(g, e); } } else if (t.kind === 'spike') { - g.fillStyle(SPIKE, 1); - g.fillPoints(pts, true); + this.drawSpikeBase(g, t); this.drawTeeth(g, t); + this.drawMetalGrit(g, t); } else if (t.kind === 'fire') { - g.fillStyle(FIRE, 0.75); - g.fillPoints(pts, true); + this.drawLavaBase(g, t); } if (t.destructible) { // Cross-hatch marks rock a blast can open. @@ -646,29 +698,225 @@ export default class GooTowerGame extends Phaser.Scene { } } - // Spikes read as spikes because of the teeth, not the fill colour. Teeth are - // stamped along whichever edge faces away from the polygon's interior. + // Gunmetal plate under the teeth: a dark base, a soft highlight blob + // pulled toward the fixed light direction so it doesn't read as flat, and + // a bright/dark rim stroke on whichever edges face toward/away from that + // light -- the same trick drawTeeth uses per-tooth, applied to the plate. + drawSpikeBase(g, t) { + const pts = t.poly.map(([x, y]) => ({ x, y })); + const c = polyCentroid(t.poly); + g.fillStyle(SPIKE_BASE_DARK, 1); + g.fillPoints(pts, true); + const hiPts = scalePoly( + t.poly, c.x + SPIKE_LIGHT_DIR.x * 14, c.y + SPIKE_LIGHT_DIR.y * 14, 0.62, + ).map(([x, y]) => ({ x, y })); + g.fillStyle(SPIKE_BASE, 0.55); + g.fillPoints(hiPts, true); + for (const e of topFacingEdges(t.poly)) { + const lit = e.nx * SPIKE_LIGHT_DIR.x + e.ny * SPIKE_LIGHT_DIR.y; + if (lit > 0.3) { g.lineStyle(2, SPIKE_BASE_EDGE_HI, 0.8); g.lineBetween(e.ax, e.ay, e.bx, e.by); } + else if (lit < -0.3) { g.lineStyle(2, SPIKE_BASE_EDGE_LO, 0.8); g.lineBetween(e.ax, e.ay, e.bx, e.by); } + } + } + + // Spikes read as spikes because of the teeth, not the fill colour. Unlike + // the old version, teeth are stamped along EVERY outward edge (via + // topFacingEdges' true per-edge normal), not just horizontal ones, so a + // block bristles on every side that faces open air. Each tooth is split + // into two facets -- (p0, mid, tip) and (mid, p1, tip) -- shaded against a + // fixed light direction via edgeNormalAway, so it reads as a faceted steel + // point instead of a flat silhouette regardless of which way it points. + // Tip world positions are cached on the piece for drawSpikeTwinkle. drawTeeth(g, t) { - const poly = t.poly; - const cy = poly.reduce((s, p) => s + p[1], 0) / poly.length; - g.fillStyle(SPIKE_RIM, 1); - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const [ax, ay] = poly[j]; - const [bx, by] = poly[i]; - const mid = (ay + by) / 2; - if (Math.abs(ay - by) > 4) continue; // only horizontal edges - const up = mid < cy ? -1 : 1; // point away from the middle - const len = Math.abs(bx - ax); - const n = Math.max(2, Math.round(len / 26)); - const step = (bx - ax) / n; + const tips = []; + for (const e of topFacingEdges(t.poly)) { + const dx = e.bx - e.ax, dy = e.by - e.ay; + const len = Math.hypot(dx, dy); + if (len < 1) continue; + const n = Math.max(1, Math.round(len / SPIKE_TOOTH_STEP)); for (let k = 0; k < n; k += 1) { - const x0 = ax + step * k; - const x1 = x0 + step; - g.fillTriangle( - x0, mid, - x1, mid, - (x0 + x1) / 2, mid + up * 20, - ); + const p0 = { x: e.ax + dx * (k / n), y: e.ay + dy * (k / n) }; + const p1 = { x: e.ax + dx * ((k + 1) / n), y: e.ay + dy * ((k + 1) / n) }; + const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }; + const tip = { x: mid.x + e.nx * SPIKE_TOOTH_LEN, y: mid.y + e.ny * SPIKE_TOOTH_LEN }; + tips.push({ x: tip.x, y: tip.y, seed: hash01(tip.x * 0.13 + tip.y * 0.29) }); + + const n1 = edgeNormalAway(p0, tip, mid); + const n2 = edgeNormalAway(tip, p1, mid); + const lit1 = n1.x * SPIKE_LIGHT_DIR.x + n1.y * SPIKE_LIGHT_DIR.y; + const lit2 = n2.x * SPIKE_LIGHT_DIR.x + n2.y * SPIKE_LIGHT_DIR.y; + g.fillStyle(lit1 >= lit2 ? SPIKE_TOOTH_LIGHT : SPIKE_TOOTH_DARK, 1); + g.fillTriangle(p0.x, p0.y, mid.x, mid.y, tip.x, tip.y); + g.fillStyle(lit1 >= lit2 ? SPIKE_TOOTH_DARK : SPIKE_TOOTH_LIGHT, 1); + g.fillTriangle(mid.x, mid.y, p1.x, p1.y, tip.x, tip.y); + g.lineStyle(1, SPIKE_BASE_EDGE_LO, 0.5); + g.lineBetween(p0.x, p0.y, p1.x, p1.y); + + // A rivet stamped into the plate at most tooth bases, inset along + // the inward normal so it sits on the metal, not floating past it. + if (hash01(mid.x * 0.07 + mid.y * 0.11) > 0.4) { + const rx = mid.x - e.nx * 7, ry = mid.y - e.ny * 7; + g.fillStyle(SPIKE_RIVET, 1); + g.fillCircle(rx, ry, 2.4); + g.fillStyle(SPIKE_RIVET_HI, 0.8); + g.fillCircle(rx - 0.6, ry - 0.6, 0.9); + } + } + } + t._teethTips = tips; + } + + // Rust speckling + fine scratches, reusing the dirt-fleck clipping trick + // (drawDirtTexture) so grit stays inside the plate's actual silhouette. + drawMetalGrit(g, t) { + const bb = t.bounds; + const STEP = 26; + for (let gx = bb.minX; gx < bb.maxX; gx += STEP) { + for (let gy = bb.minY; gy < bb.maxY; gy += STEP) { + const seed = gx * 0.053 + gy * 0.091; + if (hash01(seed) < 0.72) continue; + const jx = gx + (hash01(seed + 1.3) - 0.5) * STEP * 0.8; + const jy = gy + (hash01(seed + 2.7) - 0.5) * STEP * 0.8; + if (!pointInPoly(jx, jy, t.poly)) continue; + if (hash01(seed + 3.1) < 0.5) { + g.fillStyle(SPIKE_RUST, 0.3); + g.fillCircle(jx, jy, 1.5 + hash01(seed + 4.2) * 1.8); + } else { + const ang = hash01(seed + 5.5) * Math.PI; + const len = 4 + hash01(seed + 6.6) * 5; + g.lineStyle(1, SPIKE_TOOTH_LIGHT, 0.12); + g.lineBetween( + jx - Math.cos(ang) * len / 2, jy - Math.sin(ang) * len / 2, + jx + Math.cos(ang) * len / 2, jy + Math.sin(ang) * len / 2, + ); + } + } + } + } + + // Molten pool: a layered heat gradient (crust rim -> white-hot core) faked + // with a few polygons shrunk toward the centroid via scalePoly instead of a + // real radial gradient, topped with jagged "broken crust" chunks along + // every edge with glowing seams between them. Also seeds the flow-lane and + // bubble-slot data drawLavaFlow animates every frame. + drawLavaBase(g, t) { + const pts = t.poly.map(([x, y]) => ({ x, y })); + const c = polyCentroid(t.poly); + g.fillStyle(LAVA_CRUST, 1); + g.fillPoints(pts, true); + const layers = [ + { f: 0.86, color: LAVA_OUTER }, + { f: 0.62, color: LAVA_MID }, + { f: 0.36, color: LAVA_HOT }, + { f: 0.16, color: LAVA_CORE }, + ]; + for (const layer of layers) { + const lp = scalePoly(t.poly, c.x, c.y, layer.f).map(([x, y]) => ({ x, y })); + g.fillStyle(layer.color, 1); + g.fillPoints(lp, true); + } + + for (const e of topFacingEdges(t.poly)) { + const dx = e.bx - e.ax, dy = e.by - e.ay; + const len = Math.hypot(dx, dy); + if (len < 1) continue; + const seed = e.ax * 0.11 + e.ay * 0.19 + e.bx * 0.07 + e.by * 0.23; + const n = Math.max(1, Math.round(len / 30)); + for (let k = 0; k < n; k += 1) { + const s = seed + k * 5.31; + if (hash01(s + 2.2) < 0.2) continue; // occasional gap: a wider crack of bare lava + const depth = 8 + hash01(s) * 10; + const p0 = { x: e.ax + dx * (k / n), y: e.ay + dy * (k / n) }; + const p1 = { x: e.ax + dx * ((k + 1) / n), y: e.ay + dy * ((k + 1) / n) }; + const q0 = { x: p0.x - e.nx * depth, y: p0.y - e.ny * depth }; + const q1 = { x: p1.x - e.nx * depth, y: p1.y - e.ny * depth }; + g.fillStyle(LAVA_CRUST, 0.9); + g.fillPoints([p0, p1, q1, q0], true); + g.lineStyle(1.5, LAVA_FLOW_LINE, 0.55); + g.lineBetween(p0.x, p0.y, q0.x, q0.y); + } + } + + const bb = t.bounds; + const laneCount = Math.max(2, Math.round((bb.maxX - bb.minX) / 40)); + const lanes = []; + for (let i = 0; i < laneCount; i += 1) { + const s = i * 3.7 + bb.minX * 0.01; + lanes.push({ + y: bb.minY + (bb.maxY - bb.minY) * (0.25 + 0.5 * hash01(s)), + speed: 14 + hash01(s + 1) * 10, + amp: 3 + hash01(s + 2) * 4, + freq: 0.02 + hash01(s + 3) * 0.02, + phase: hash01(s + 4) * Math.PI * 2, + dir: hash01(s + 5) < 0.5 ? 1 : -1, + }); + } + t._lavaLanes = lanes; + + const area = (bb.maxX - bb.minX) * (bb.maxY - bb.minY); + const bubbleCount = Math.max(2, Math.round(area / 4500)); + const bubbles = []; + for (let i = 0; i < bubbleCount; i += 1) { + const s = i * 5.3 + bb.minY * 0.017; + bubbles.push({ + x: bb.minX + (bb.maxX - bb.minX) * hash01(s), + y: bb.minY + (bb.maxY - bb.minY) * (0.3 + 0.5 * hash01(s + 1)), + period: 1.4 + hash01(s + 2) * 1.8, + offset: hash01(s + 3) * 10, + maxR: 3 + hash01(s + 4) * 4, + }); + } + t._lavaBubbles = bubbles; + } + + // A handful of tooth tips catch a bright glint at a time, cycling on + // desynced per-tip timers via `time` (the sim clock) -- reads as light + // twinkling off steel points rather than a static texture. + drawSpikeTwinkle(g, t, time) { + const tips = t._teethTips; + if (!tips) return; + for (const tip of tips) { + const phase = (time * 0.35 + tip.seed * 6.28) % (Math.PI * 2); + const bright = Math.pow(Math.max(0, Math.sin(phase)), 10); // sharp, brief peak + if (bright < 0.05) continue; + g.fillStyle(SPIKE_GLINT, bright * 0.9); + g.fillCircle(tip.x, tip.y, 2 + bright * 1.5); + } + } + + // Drifting wavy flow-streaks (per-lane sine offset scrolling over time) and + // rising/popping bubbles, animated each frame from the slots drawLavaBase + // seeded once per piece. + drawLavaFlow(g, t, time) { + const lanes = t._lavaLanes; + const bb = t.bounds; + if (lanes) { + for (const lane of lanes) { + const linePts = []; + const steps = 10; + for (let i = 0; i <= steps; i += 1) { + const x = bb.minX + (bb.maxX - bb.minX) * (i / steps); + const y = lane.y + Math.sin(x * lane.freq + time * lane.speed * lane.dir + lane.phase) * lane.amp; + if (pointInPoly(x, y, t.poly)) linePts.push({ x, y }); + } + if (linePts.length > 1) { + g.lineStyle(2, LAVA_FLOW_LINE, 0.4); + g.strokePoints(linePts, false); + } + } + } + const bubbles = t._lavaBubbles; + if (bubbles) { + for (const b of bubbles) { + const phase = ((time + b.offset) % b.period) / b.period; + let r, a; + if (phase < 0.7) { r = b.maxR * (phase / 0.7); a = 0.8; } + else { r = b.maxR * (1 - (phase - 0.7) / 0.3); a = 0.8 * (1 - (phase - 0.7) / 0.3); } + if (r <= 0.3) continue; + g.fillStyle(LAVA_BUBBLE, a); + g.fillCircle(b.x, b.y - r * 0.3, r); + g.lineStyle(1, LAVA_CORE, a * 0.7); + g.strokeCircle(b.x, b.y - r * 0.3, r); } } } @@ -921,6 +1169,13 @@ export default class GooTowerGame extends Phaser.Scene { this.drawGears(g); } + // Spike glints and lava flow/bubbles animate every frame off the sim + // clock, layered on top of the static plate/pool fill drawn in terrainG. + for (const tr of st.terrain) { + if (tr.kind === 'spike') this.drawSpikeTwinkle(g, tr, st.clock); + else if (tr.kind === 'fire') this.drawLavaFlow(g, tr, st.clock); + } + // Strands first, under the balls — drawn as pinched goo shapes (thick // at the ends, thin in the middle with curved edges). for (const s of st.strands) { diff --git a/src/main.js b/src/main.js index bfa0ba5..5e7c900 100644 --- a/src/main.js +++ b/src/main.js @@ -102,7 +102,6 @@ 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 = { @@ -220,7 +219,6 @@ const config = { GooTowerGame, ExcitebikeGame, GooTowerEditor, - AngryBirdsGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index dd24dc8..8d664c6 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', angrybirds: 'AngryBirdsGame' }; + 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' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index ac9dc38..05e77d7 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -55,7 +55,6 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('bloxorz', 'data/bloxorz.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json'); - this.load.json('angrybirds-levels', 'assets/gamedata/angrybirds/levels.json'); this.load.json('shift-artwork', 'data/shift-artwork.json'); this.load.json('slots-artwork', 'data/slots-artwork.json'); this.load.json('blockfighter', 'data/blockfighter.json'); diff --git a/tools/verifyAngryBirds.js b/tools/verifyAngryBirds.js deleted file mode 100644 index 2dd8bcb..0000000 --- a/tools/verifyAngryBirds.js +++ /dev/null @@ -1,882 +0,0 @@ -// 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 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);