diff --git a/.github/workflows/art-pipeline.yml b/.github/workflows/art-pipeline.yml index 39f3b2b..60a8b7d 100644 --- a/.github/workflows/art-pipeline.yml +++ b/.github/workflows/art-pipeline.yml @@ -22,4 +22,5 @@ jobs: - run: python -m pip install -r requirements-art.txt - run: python -m unittest discover -s scripts -p 'test_*.py' -v - run: python scripts/creek_layers.py + - run: python scripts/collision_check.py - run: python scripts/check_docs.py diff --git a/art/stages/rusty-creek/README.md b/art/stages/rusty-creek/README.md index 79d9d2f..8c624b1 100644 --- a/art/stages/rusty-creek/README.md +++ b/art/stages/rusty-creek/README.md @@ -34,6 +34,21 @@ The same place before the anomaly (clean campus architecture, nature layers iden [route.json](route.json) is the authored route: 47 nodes and 47 edges (walk, ladder, jump) covering the entry, the climbs, three dead ends with rewards, a reconnection, the boss clearing and the exit. The verifier checks that it is connected, that every dead end returns, that ladders are vertical and that jumps fit the envelope. The collision data that the game uses is drawn separately: see the colour code in [04](../../../docs/04-WORLD-AND-LEVELS.md). +## Collision + +Six files, one per chunk and state, in the [docs/13 schema](../../../docs/13-PRODUCTION-PIPELINE.md#contract-collisionjson). [chunks.json](chunks.json) maps the ids to the art: `rc-0-0` left, `rc-1-0` centre, `rc-2-0` right. + +- **Clean:** the bridge deck at y 132 across all three chunks, the water below as no-go, the player on the left. Jenny's ram is the `jenny-ram` trigger in the centre ([M4-S1](https://github.com/THD-AI/WinCS/issues/54)). +- **Anomaly:** the hero walks in front of the maze, so every girder deck is a one-way platform and the quays are floors. The first chunk has the chair on the entry deck, the tutorial rover on the low deck, and a pillar down to the water that needs a ladder up: the spur ladder onto the low girder or the tall ladder onto the high beam. The lookout tower holds a muffin and note 1. In the centre, a high route (crab, LeberkΓ€se cache) and a low route (two rovers under a drone) meet again at the coffee machine and Dexter. On the right, the top route and the low route both end at the boss gate before the open girder floor. Jenny and Dexter wait there, the overlook holds note 2, and the exit anomaly is behind the stone steps. + +| Chunk | Clean | Anomaly | +| --- | --- | --- | +| Left | ![](collision/rc-0-0.clean.png) | ![](collision/rc-0-0.anomaly.png) | +| Centre | ![](collision/rc-1-0.clean.png) | ![](collision/rc-1-0.anomaly.png) | +| Right | ![](collision/rc-2-0.clean.png) | ![](collision/rc-2-0.anomaly.png) | + +`python scripts/collision_check.py --overlay` checks them all and redraws these overlays. Colours are the [docs/04 legend](../../../docs/04-WORLD-AND-LEVELS.md#collision-and-interaction-annotation); the white box is the 28Γ—76 body. + ## Rebuild and verify ```bash diff --git a/art/stages/rusty-creek/chunks.json b/art/stages/rusty-creek/chunks.json new file mode 100644 index 0000000..1fe668c --- /dev/null +++ b/art/stages/rusty-creek/chunks.json @@ -0,0 +1,6 @@ +{ + "region": "rusty-creek", + "note": "Chunk ids are grid positions (column-row), left to right; art names the plate each state is drawn on.", + "chunks": {"rc-0-0": "left", "rc-1-0": "center", "rc-2-0": "right"}, + "art": {"clean": "images/before-{chunk}.png", "anomaly": "images/after-{chunk}.png"} +} diff --git a/art/stages/rusty-creek/collision/rc-0-0.anomaly.png b/art/stages/rusty-creek/collision/rc-0-0.anomaly.png new file mode 100644 index 0000000..3278d56 Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-0-0.anomaly.png differ diff --git a/art/stages/rusty-creek/collision/rc-0-0.clean.png b/art/stages/rusty-creek/collision/rc-0-0.clean.png new file mode 100644 index 0000000..9801aec Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-0-0.clean.png differ diff --git a/art/stages/rusty-creek/collision/rc-1-0.anomaly.png b/art/stages/rusty-creek/collision/rc-1-0.anomaly.png new file mode 100644 index 0000000..ec8d899 Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-1-0.anomaly.png differ diff --git a/art/stages/rusty-creek/collision/rc-1-0.clean.png b/art/stages/rusty-creek/collision/rc-1-0.clean.png new file mode 100644 index 0000000..403ba5a Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-1-0.clean.png differ diff --git a/art/stages/rusty-creek/collision/rc-2-0.anomaly.png b/art/stages/rusty-creek/collision/rc-2-0.anomaly.png new file mode 100644 index 0000000..738353e Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-2-0.anomaly.png differ diff --git a/art/stages/rusty-creek/collision/rc-2-0.clean.png b/art/stages/rusty-creek/collision/rc-2-0.clean.png new file mode 100644 index 0000000..5d58c51 Binary files /dev/null and b/art/stages/rusty-creek/collision/rc-2-0.clean.png differ diff --git a/art/stages/rusty-creek/rc-0-0.anomaly.collision.json b/art/stages/rusty-creek/rc-0-0.anomaly.collision.json new file mode 100644 index 0000000..a53d7ae --- /dev/null +++ b/art/stages/rusty-creek/rc-0-0.anomaly.collision.json @@ -0,0 +1,303 @@ +{ + "chunk": "rc-0-0", + "state": "anomaly", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 461 + ], + [ + 440, + 461 + ] + ], + [ + [ + 470, + 508 + ], + [ + 898, + 508 + ] + ], + [ + [ + 900, + 557 + ], + [ + 1098, + 557 + ] + ], + [ + [ + 1136, + 505 + ], + [ + 1378, + 505 + ] + ], + [ + [ + 1382, + 557 + ], + [ + 1488, + 557 + ] + ], + [ + [ + 1490, + 515 + ], + [ + 1720, + 515 + ], + [ + 1786, + 461 + ], + [ + 2160, + 461 + ] + ] + ], + "slide": [], + "oneWay": [ + [ + [ + 415, + 363 + ], + [ + 595, + 363 + ] + ], + [ + [ + 575, + 126 + ], + [ + 762, + 126 + ] + ], + [ + [ + 630, + 302 + ], + [ + 778, + 302 + ] + ], + [ + [ + 720, + 165 + ], + [ + 1345, + 165 + ] + ], + [ + [ + 780, + 387 + ], + [ + 1135, + 387 + ] + ], + [ + [ + 1015, + 275 + ], + [ + 1135, + 275 + ] + ], + [ + [ + 1300, + 83 + ], + [ + 1519, + 83 + ] + ], + [ + [ + 1300, + 368 + ], + [ + 1525, + 368 + ] + ], + [ + [ + 1525, + 189 + ], + [ + 1663, + 189 + ] + ], + [ + [ + 1665, + 299 + ], + [ + 1777, + 299 + ] + ] + ], + "climb": [ + { + "id": "ladder-entry", + "kind": "ladder", + "x": 468, + "y": 360, + "w": 28, + "h": 150 + }, + { + "id": "ladder-tall", + "kind": "ladder", + "x": 596, + "y": 120, + "w": 32, + "h": 390 + }, + { + "id": "ladder-spur", + "kind": "ladder", + "x": 895, + "y": 384, + "w": 28, + "h": 175 + }, + { + "id": "ladder-lookout", + "kind": "ladder", + "x": 1030, + "y": 272, + "w": 30, + "h": 118 + }, + { + "id": "ladder-return", + "kind": "ladder", + "x": 1336, + "y": 80, + "w": 34, + "h": 427 + }, + { + "id": "ladder-east", + "kind": "ladder", + "x": 1703, + "y": 296, + "w": 32, + "h": 221 + } + ], + "solid": [ + { + "x": 0, + "y": 0, + "w": 8, + "h": 720 + }, + { + "x": 1099, + "y": 390, + "w": 36, + "h": 167 + } + ], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [ + { + "id": "chair", + "kind": "pickup", + "item": "chair", + "x": 316, + "y": 425, + "w": 40, + "h": 36 + }, + { + "id": "muffin-1", + "kind": "pickup", + "item": "muffin", + "x": 1060, + "y": 247, + "w": 24, + "h": 28 + }, + { + "id": "note-1", + "kind": "pickup", + "item": "note-1", + "x": 1100, + "y": 251, + "w": 24, + "h": 24 + } + ], + "spawn": [ + { + "id": "player", + "kind": "player", + "x": 120, + "y": 461 + }, + { + "id": "rover-1", + "kind": "rover", + "x": 760, + "y": 508, + "patrol": [ + 640, + 880 + ] + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/rusty-creek/rc-0-0.clean.collision.json b/art/stages/rusty-creek/rc-0-0.clean.collision.json new file mode 100644 index 0000000..79382f7 --- /dev/null +++ b/art/stages/rusty-creek/rc-0-0.clean.collision.json @@ -0,0 +1,51 @@ +{ + "chunk": "rc-0-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 132 + ], + [ + 2160, + 132 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [], + "solid": [ + { + "x": 0, + "y": 0, + "w": 8, + "h": 720 + } + ], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [], + "spawn": [ + { + "id": "player", + "kind": "player", + "x": 160, + "y": 132 + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/rusty-creek/rc-1-0.anomaly.collision.json b/art/stages/rusty-creek/rc-1-0.anomaly.collision.json new file mode 100644 index 0000000..abe42cb --- /dev/null +++ b/art/stages/rusty-creek/rc-1-0.anomaly.collision.json @@ -0,0 +1,348 @@ +{ + "chunk": "rc-1-0", + "state": "anomaly", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 456 + ], + [ + 335, + 456 + ] + ], + [ + [ + 340, + 489 + ], + [ + 710, + 489 + ] + ], + [ + [ + 715, + 569 + ], + [ + 1030, + 569 + ] + ], + [ + [ + 1032, + 536 + ], + [ + 1240, + 536 + ] + ], + [ + [ + 1245, + 570 + ], + [ + 1550, + 570 + ] + ], + [ + [ + 1580, + 563 + ], + [ + 1700, + 563 + ], + [ + 1745, + 537 + ], + [ + 1840, + 537 + ], + [ + 1885, + 500 + ], + [ + 1915, + 500 + ] + ], + [ + [ + 1920, + 455 + ], + [ + 2160, + 455 + ] + ] + ], + "slide": [], + "oneWay": [ + [ + [ + 340, + 341 + ], + [ + 715, + 341 + ] + ], + [ + [ + 570, + 208 + ], + [ + 925, + 208 + ] + ], + [ + [ + 700, + 400 + ], + [ + 905, + 400 + ] + ], + [ + [ + 925, + 135 + ], + [ + 1345, + 135 + ] + ], + [ + [ + 925, + 343 + ], + [ + 1262, + 343 + ] + ], + [ + [ + 1115, + 241 + ], + [ + 1220, + 241 + ] + ], + [ + [ + 1210, + 400 + ], + [ + 1550, + 400 + ] + ], + [ + [ + 1340, + 65 + ], + [ + 1520, + 65 + ] + ], + [ + [ + 1340, + 273 + ], + [ + 1680, + 273 + ] + ], + [ + [ + 1460, + 135 + ], + [ + 1840, + 135 + ] + ], + [ + [ + 1551, + 460 + ], + [ + 1640, + 460 + ] + ], + [ + [ + 1715, + 343 + ], + [ + 1870, + 343 + ] + ] + ], + "climb": [ + { + "id": "ladder-west", + "kind": "ladder", + "x": 603, + "y": 200, + "w": 30, + "h": 292 + }, + { + "id": "ladder-loop", + "kind": "ladder", + "x": 963, + "y": 130, + "w": 30, + "h": 440 + }, + { + "id": "ladder-cache", + "kind": "ladder", + "x": 1140, + "y": 238, + "w": 26, + "h": 108 + }, + { + "id": "ladder-tower", + "kind": "ladder", + "x": 1397, + "y": 60, + "w": 26, + "h": 342 + }, + { + "id": "ladder-reconnect", + "kind": "ladder", + "x": 1735, + "y": 130, + "w": 28, + "h": 410 + }, + { + "id": "ladder-east", + "kind": "ladder", + "x": 1835, + "y": 130, + "w": 22, + "h": 215 + } + ], + "solid": [], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [ + { + "id": "coffee-1", + "kind": "coffee-machine", + "x": 1770, + "y": 457, + "w": 40, + "h": 80 + }, + { + "id": "leberkaese-1", + "kind": "pickup", + "item": "leberkaese", + "x": 1180, + "y": 213, + "w": 24, + "h": 28 + } + ], + "spawn": [ + { + "id": "rover-2", + "kind": "rover", + "x": 880, + "y": 569, + "patrol": [ + 740, + 1020 + ] + }, + { + "id": "rover-3", + "kind": "rover", + "x": 1400, + "y": 570, + "patrol": [ + 1260, + 1540 + ] + }, + { + "id": "drone-1", + "kind": "drone", + "x": 1150, + "y": 470, + "patrol": [ + 900, + 1500 + ] + }, + { + "id": "crab-1", + "kind": "crab", + "x": 1200, + "y": 135, + "patrol": [ + 1050, + 1330 + ] + }, + { + "id": "dexter-1", + "kind": "dexter", + "x": 1820, + "y": 537 + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/rusty-creek/rc-1-0.clean.collision.json b/art/stages/rusty-creek/rc-1-0.clean.collision.json new file mode 100644 index 0000000..2d0219f --- /dev/null +++ b/art/stages/rusty-creek/rc-1-0.clean.collision.json @@ -0,0 +1,47 @@ +{ + "chunk": "rc-1-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 132 + ], + [ + 2160, + 132 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [], + "solid": [], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [ + { + "id": "jenny-ram", + "kind": "jenny-ram", + "x": 1080, + "y": 40, + "w": 40, + "h": 92, + "note": "Jenny crosses the bridge and rams the hero; the region turns (M4-S1 #54)" + } + ], + "spawn": [], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/rusty-creek/rc-2-0.anomaly.collision.json b/art/stages/rusty-creek/rc-2-0.anomaly.collision.json new file mode 100644 index 0000000..4851fff --- /dev/null +++ b/art/stages/rusty-creek/rc-2-0.anomaly.collision.json @@ -0,0 +1,321 @@ +{ + "chunk": "rc-2-0", + "state": "anomaly", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 455 + ], + [ + 200, + 455 + ] + ], + [ + [ + 245, + 528 + ], + [ + 455, + 528 + ] + ], + [ + [ + 540, + 536 + ], + [ + 850, + 536 + ] + ], + [ + [ + 852, + 572 + ], + [ + 930, + 572 + ] + ], + [ + [ + 935, + 555 + ], + [ + 1088, + 555 + ] + ], + [ + [ + 1090, + 565 + ], + [ + 1690, + 565 + ] + ], + [ + [ + 1692, + 548 + ], + [ + 1760, + 548 + ], + [ + 1850, + 520 + ], + [ + 1900, + 500 + ], + [ + 2020, + 477 + ], + [ + 2160, + 477 + ] + ] + ], + "slide": [], + "oneWay": [ + [ + [ + 200, + 385 + ], + [ + 455, + 385 + ] + ], + [ + [ + 338, + 236 + ], + [ + 510, + 236 + ] + ], + [ + [ + 462, + 441 + ], + [ + 512, + 441 + ] + ], + [ + [ + 510, + 163 + ], + [ + 740, + 163 + ] + ], + [ + [ + 540, + 385 + ], + [ + 850, + 385 + ] + ], + [ + [ + 700, + 191 + ], + [ + 1060, + 191 + ] + ], + [ + [ + 763, + 298 + ], + [ + 844, + 298 + ] + ], + [ + [ + 1000, + 108 + ], + [ + 1181, + 108 + ] + ], + [ + [ + 1040, + 158 + ], + [ + 1717, + 158 + ] + ], + [ + [ + 1074, + 312 + ], + [ + 1169, + 312 + ] + ], + [ + [ + 1700, + 323 + ], + [ + 1893, + 323 + ] + ], + [ + [ + 1745, + 129 + ], + [ + 1852, + 129 + ] + ] + ], + "climb": [ + { + "id": "ladder-foot", + "kind": "ladder", + "x": 355, + "y": 233, + "w": 28, + "h": 300 + }, + { + "id": "ladder-upper", + "kind": "ladder", + "x": 560, + "y": 160, + "w": 26, + "h": 230 + }, + { + "id": "ladder-return", + "kind": "ladder", + "x": 1040, + "y": 105, + "w": 28, + "h": 455 + }, + { + "id": "ladder-overlook", + "kind": "ladder", + "x": 1700, + "y": 125, + "w": 26, + "h": 200 + }, + { + "id": "ladder-arena", + "kind": "ladder", + "x": 1830, + "y": 320, + "w": 28, + "h": 230 + } + ], + "solid": [ + { + "x": 2152, + "y": 0, + "w": 8, + "h": 720 + } + ], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [ + { + "id": "boss-gate", + "kind": "boss-gate", + "x": 1096, + "y": 465, + "w": 30, + "h": 100 + }, + { + "id": "note-2", + "kind": "pickup", + "item": "note-2", + "x": 1800, + "y": 105, + "w": 24, + "h": 24 + }, + { + "id": "exit-anomaly", + "kind": "chunk-exit", + "x": 2060, + "y": 397, + "w": 60, + "h": 80 + } + ], + "spawn": [ + { + "id": "jenny", + "kind": "jenny", + "x": 1440, + "y": 565 + }, + { + "id": "dexter-2", + "kind": "dexter", + "x": 1640, + "y": 565 + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/rusty-creek/rc-2-0.clean.collision.json b/art/stages/rusty-creek/rc-2-0.clean.collision.json new file mode 100644 index 0000000..45dbb17 --- /dev/null +++ b/art/stages/rusty-creek/rc-2-0.clean.collision.json @@ -0,0 +1,44 @@ +{ + "chunk": "rc-2-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 132 + ], + [ + 2160, + 132 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [], + "solid": [ + { + "x": 2152, + "y": 0, + "w": 8, + "h": 720 + } + ], + "noGo": [ + { + "x": 0, + "y": 600, + "w": 2160, + "h": 120 + } + ], + "hazard": [], + "interact": [], + "spawn": [], + "ledge": [], + "glideLift": [] +} diff --git a/docs/04-WORLD-AND-LEVELS.md b/docs/04-WORLD-AND-LEVELS.md index c2664d0..93d0a80 100644 --- a/docs/04-WORLD-AND-LEVELS.md +++ b/docs/04-WORLD-AND-LEVELS.md @@ -91,7 +91,7 @@ Every time a chunk's layers are made, it also gets an annotation drawn over the | 🀍 White | dot | Spawn: player, enemy, item, Dexter | | 🌬️ Pale blue | box | Glide lift (later): updraft that carries a gliding hero up | -Export: `art/stages//..collision.json` – polylines, points and boxes in stage pixels, one file per chunk and state. Lex draws them in **Tiled**, one object layer per row of the legend, on top of the composed chunk; `scripts/collision_from_tiled.py` converts the export. The game builds the physics from it. Pixel masks never stand in for collision. The exact schema is in [13 – Production pipeline](13-PRODUCTION-PIPELINE.md#contract-collisionjson). +Export: `art/stages//..collision.json` – polylines, points and boxes in stage pixels, one file per chunk and state. They are drawn in **Tiled**, one object layer per row of the legend, on top of the composed chunk, and `scripts/collision_from_tiled.py` converts the export; Rusty Creek's first set was written straight from the art. Chunk ids are grid positions (`rc-0-0`, `rc-1-0` …), mapped to their art in the region's `chunks.json`. `python scripts/collision_check.py --overlay` checks every file against the schema and the 28Γ—76 body (every spawn, pickup and trigger reachable, neighbouring chunks meeting at one ground height) and draws the review overlays into the region's `collision/` folder. The game builds the physics from it. Pixel masks never stand in for collision. The exact schema is in [13 – Production pipeline](13-PRODUCTION-PIPELINE.md#contract-collisionjson). ## Rusty Creek – the jam level diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md index 2254082..0603912 100644 --- a/docs/09-ART-PIPELINE.md +++ b/docs/09-ART-PIPELINE.md @@ -97,6 +97,7 @@ python scripts/make_boards.py crush [ids] [--config …] # rebuild co python scripts/make_boards.py pages [--config …] # Markdown page per image + index python scripts/export_atlas.py [--preview] # approved board -> Phaser atlas + anims python scripts/creek_layers.py [--build] # Rusty Creek layers: verify / rebuild +python scripts/collision_check.py [--overlay] # collision: schema, reach, overlays python scripts/stage_art.py prepare --config art/stages/rusty-creek/stage.json --out tmp/stage-art/next --band 512 python scripts/navigation.py && python scripts/check_docs.py ``` diff --git a/docs/13-PRODUCTION-PIPELINE.md b/docs/13-PRODUCTION-PIPELINE.md index e6001a1..302e151 100644 --- a/docs/13-PRODUCTION-PIPELINE.md +++ b/docs/13-PRODUCTION-PIPELINE.md @@ -179,7 +179,7 @@ Lane B codes against these from day one, with placeholders; lane A fills them wi | Atlas | `game/public/assets/sprites/.png` + `.json` | Phaser JSON hash; frame names `-` from `00`, one cell size per board; pivot at the feet (0.5, 1) | `export_atlas.py` (A) β†’ loader (B) | | Animations | `game/public/assets/sprites/.anims.json` | `{"walk": {"frames": 8, "fps": 12, "loop": true}, …}` – names and counts as in docs/07 | `export_atlas.py` (A) β†’ state machine (B) | | Stage manifest | `game/public/assets/stages//stage.json` | chunk ids with grid x/y, per chunk the layer files z0–z7 for each state, scroll factors | stage export (A) β†’ loader (B) | -| Collision | `art/stages//..collision.json` | above | Lex via converter β†’ loader (B) | +| Collision | `art/stages//..collision.json`, chunk ids `--` | above; checked by `collision_check.py` | A (Tiled + converter, or by hand) β†’ loader (B) | | Game events | `game/src/core/events.ts` | typed events: `enemy-defeated`, `player-hit`, `player-died`, `region-turned`, `boss-defeated`, `save` | combat and enemies (B) emit β†’ story and UI (A) listen | | Placeholder sizes | – | hero 28Γ—76 body in an 80 px frame; Dexter 20 px; rover β‰ˆ 25 px; crab β‰ˆ 40 px; drone β‰ˆ 45 px; Jenny β‰ˆ 200Γ—70 | docs/04 scale table | diff --git a/scripts/collision_check.py b/scripts/collision_check.py new file mode 100644 index 0000000..5b83f70 --- /dev/null +++ b/scripts/collision_check.py @@ -0,0 +1,284 @@ +"""Check stage collision files (docs/13 contract) and draw review overlays. + + art/stages//..collision.json + -> schema: fields, integer stage pixels, polylines left to right, boxes inside the chunk + -> reach: with the 28x76 body, every spawn, pickup and trigger can be reached from the + player spawn (or the chunk's left edge), and the chunk's right edge is reachable + -> joins: neighbouring chunks of one state meet at the same ground height + -> --overlay: /collision/..png, the docs/04 legend over the art at half + size, committed next to the data for review + +/chunks.json names the chunks left to right and the art each state is drawn on. + +Movement envelope (docs/02 + docs/04, conservative until the controller is tuned in M1): +a held jump rises 96 px, so a ledge counts as reachable up to 88 px higher and 140 px across. +Dropping is free: fall damage does not exist. Girder decks are one-way: the hero walks in front +of the maze, so headroom only matters under `solid` boxes. + +Usage: + python scripts/collision_check.py [art/stages/rusty-creek] [--overlay] +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STAGES = ROOT / "art" / "stages" +BODY = (28, 76) +RISE = 88 # highest ledge a held jump reaches, with margin (96 px in docs/04) +ACROSS = 140 # widest gap a running jump clears +SNAP = 6 # ends this close count as one continuous floor +LISTS = ["walk", "slide", "oneWay"] +BOXES = ["climb", "solid", "noGo", "hazard", "interact", "glideLift"] +POINTS = ["spawn", "ledge"] +KIND = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +# Legend colours from docs/04 (RGBA for the overlay). +COLOURS = {"walk": (40, 220, 60), "slide": (150, 90, 40), "oneWay": (255, 220, 0), "climb": (40, 110, 255), + "solid": (230, 30, 30), "noGo": (0, 0, 0), "hazard": (255, 140, 0), "interact": (170, 60, 220), + "spawn": (255, 255, 255), "ledge": (0, 230, 230), "glideLift": (170, 210, 255)} + + +def validate(data: dict, name: str) -> list[str]: + errors = [] + for key in ["chunk", "state", "size", *LISTS, *BOXES, *POINTS]: + if key not in data: + errors.append(f"{name}: missing field {key!r}") + if errors: + return errors + w, h = data["size"] + if not f"{data['chunk']}.{data['state']}" in name: + errors.append(f"{name}: chunk/state {data['chunk']}.{data['state']} do not match the file name") + + def inside(x, y, what): + if not (isinstance(x, int) and isinstance(y, int)): + errors.append(f"{name}: {what} has non-integer coordinates") + elif not (0 <= x <= w and 0 <= y <= h): + errors.append(f"{name}: {what} at ({x}, {y}) is outside the {w}x{h} chunk") + + for key in LISTS: + for i, line in enumerate(data[key]): + if len(line) < 2: + errors.append(f"{name}: {key}[{i}] needs two points") + for x, y in line: + inside(x, y, f"{key}[{i}]") + if any(b[0] <= a[0] for a, b in zip(line, line[1:])): + errors.append(f"{name}: {key}[{i}] must run left to right") + for a, b in zip(line, line[1:]): + steep = abs(b[1] - a[1]) > abs(b[0] - a[0]) * 0.8391 # tan 40Β° + if key == "walk" and steep: + errors.append(f"{name}: walk[{i}] has a slope over 40Β° at {a}->{b}; make it a slide") + for key in BOXES: + for i, box in enumerate(data[key]): + inside(box["x"], box["y"], f"{key}[{i}]") + inside(box["x"] + box["w"], box["y"] + box["h"], f"{key}[{i}]") + if key in ("climb", "interact") and not KIND.match(box.get("kind", "")): + errors.append(f"{name}: {key}[{i}] needs a lower-case kind") + if key == "interact" and not box.get("id"): + errors.append(f"{name}: interact[{i}] needs an id") + if key == "hazard" and not isinstance(box.get("damage"), int): + errors.append(f"{name}: hazard[{i}] needs an integer damage") + for key in POINTS: + for i, point in enumerate(data[key]): + inside(point["x"], point["y"], f"{key}[{i}]") + if key == "spawn" and not (KIND.match(point.get("kind", "")) and point.get("id")): + errors.append(f"{name}: spawn[{i}] needs a kind and an id") + ids = [o["id"] for k in ("climb", "interact", "spawn") for o in data[k] if o.get("id")] + for dup in {i for i in ids if ids.count(i) > 1}: + errors.append(f"{name}: id {dup!r} is used twice") + return errors + + +def y_at(line: list, x: float) -> float | None: + for (x0, y0), (x1, y1) in zip(line, line[1:]): + if x0 <= x <= x1: + return y0 + (y1 - y0) * (x - x0) / (x1 - x0) + return None + + +def surfaces(data: dict) -> list[dict]: + out = [] + for key in ("walk", "oneWay", "slide"): + for line in data[key]: + out.append({"key": key, "line": line, "x0": line[0][0], "x1": line[-1][0]}) + return out + + +def blocked(data: dict, x0: float, x1: float, feet: float) -> bool: + """Would the body walking along y=feet between x0 and x1 run into a solid box?""" + lo, hi = min(x0, x1), max(x0, x1) + return any(b["x"] < hi + BODY[0] / 2 and b["x"] + b["w"] > lo - BODY[0] / 2 + and b["y"] < feet - 2 and b["y"] + b["h"] > feet - BODY[1] for b in data["solid"]) + + +def links(data: dict, s: list[dict]) -> dict[int, set[int]]: + """Surface graph: walk on, step across, jump up, drop down, climb.""" + edges = {i: set() for i in range(len(s))} + + def link(a, b): + edges[a].add(b) + + for i, a in enumerate(s): + for j, b in enumerate(s): + if i == j: + continue + # Points of a and b to compare: ends, plus b's span over a (for drops and jumps through). + for ax, ay in (a["line"][0], a["line"][-1]): + outward = -1 if (ax, ay) == tuple(a["line"][0]) else 1 + for bx in (b["x0"], b["x1"], min(max(ax, b["x0"]), b["x1"])): + by = y_at(b["line"], bx) + dx = (bx - ax) * outward + if dx < -SNAP: + continue + wall = blocked(data, ax, bx, max(ay, by)) + if abs(dx) <= SNAP and abs(by - ay) <= SNAP and not wall: + link(i, j) # one continuous floor + elif by >= ay and dx <= ACROSS + (by - ay) / 2 and not blocked(data, ax, bx, ay): + link(i, j) # walk off the end: only walls taller than the start block + elif 0 < ay - by <= RISE and dx <= ACROSS and not wall: + link(i, j) # jump up from the end + # From anywhere on a: a one-way above is jumped through, a one-way below is dropped through. + lo, hi = max(a["x0"], b["x0"]), min(a["x1"], b["x1"]) + if lo < hi: + mid = (lo + hi) / 2 + ay, by = y_at(a["line"], mid), y_at(b["line"], mid) + if b["key"] == "oneWay" and 0 < ay - by <= RISE: + link(i, j) + if a["key"] == "oneWay" and by > ay: + link(i, j) + for box in data["climb"]: + touching = [i for i, a in enumerate(s) + if a["x0"] - BODY[0] / 2 <= box["x"] + box["w"] / 2 <= a["x1"] + BODY[0] / 2 + and (y := y_at(a["line"], min(max(box["x"] + box["w"] / 2, a["x0"]), a["x1"]))) is not None + and box["y"] - SNAP <= y <= box["y"] + box["h"] + SNAP] + for i in touching: + edges[i].update(t for t in touching if t != i) + return edges + + +def standing(s: list[dict], x: float, y: float, reach: float = 12) -> list[int]: + """Surfaces the body can stand on with its feet near (x, y).""" + out = [] + for i, a in enumerate(s): + if a["x0"] - BODY[0] / 2 <= x <= a["x1"] + BODY[0] / 2: + fy = y_at(a["line"], min(max(x, a["x0"]), a["x1"])) + if fy is not None and -reach <= fy - y <= reach: + out.append(i) + return out + + +def reach(data: dict, name: str) -> list[str]: + errors = [] + s = surfaces(data) + edges = links(data, s) + w = data["size"][0] + player = [p for p in data["spawn"] if p["kind"] == "player"] + start = standing(s, player[0]["x"], player[0]["y"]) if player else \ + [i for i, a in enumerate(s) if a["x0"] <= SNAP] + if not start: + return [f"{name}: no floor at the player spawn or the chunk's left edge"] + seen, todo = set(start), list(start) + while todo: + for j in edges[todo.pop()] - seen: + seen.add(j) + todo.append(j) + if not any(s[i]["x1"] >= w - SNAP for i in seen) and not any( + b["kind"] == "chunk-exit" for b in data["interact"]): + errors.append(f"{name}: the right edge is not reachable") + for p in data["spawn"]: + if p["kind"] in ("drone",): + continue # flies + if not set(standing(s, p["x"], p["y"], 40 if p["kind"] != "player" else 12)) & seen: + errors.append(f"{name}: spawn {p['id']} at ({p['x']}, {p['y']}) is not on a reachable floor") + for b in data["interact"]: + if not touches(s, seen, b): + errors.append(f"{name}: interact {b['id']} is not reachable by the body") + return errors + + +def touches(s: list[dict], seen: set[int], box: dict) -> bool: + """Can the body, standing on a reachable floor, overlap the box?""" + for i in seen: + a = s[i] + for x in range(max(a["x0"], box["x"] - BODY[0] // 2), min(a["x1"], box["x"] + box["w"] + BODY[0] // 2) + 1, 4): + y = y_at(a["line"], x) + if y is not None and y - BODY[1] < box["y"] + box["h"] and y > box["y"]: + return True + return False + + +def floor_at_edge(data: dict, right: bool) -> float | None: + x = data["size"][0] if right else 0 + ys = [y_at(a["line"], x) for a in surfaces(data) if a["key"] == "walk" and a["x0"] <= x <= a["x1"]] + ys = [y for y in ys if y is not None] + return max(ys) if ys else None + + +def overlay(data: dict, art: Path, out: Path) -> None: + from PIL import Image, ImageDraw + image = Image.open(art).convert("RGBA") + layer = Image.new("RGBA", image.size, (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + for key in ("noGo", "hazard", "climb", "solid", "interact", "glideLift"): + c = COLOURS[key] + for b in data[key]: + d.rectangle((b["x"], b["y"], b["x"] + b["w"] - 1, b["y"] + b["h"] - 1), fill=(*c, 70), outline=(*c, 255), width=3) + if b.get("id") or b.get("kind"): + d.text((b["x"] + 4, b["y"] + 4), b.get("id") or b["kind"], fill=(255, 255, 255, 255)) + for key in ("walk", "oneWay", "slide"): + for line in data[key]: + d.line([tuple(p) for p in line], fill=(*COLOURS[key], 255), width=5) + for p in data["spawn"]: + x, y = p["x"], p["y"] + if p["kind"] == "player": + d.rectangle((x - BODY[0] // 2, y - BODY[1], x + BODY[0] // 2, y), outline=(255, 255, 255, 255), width=2) + d.ellipse((x - 6, y - 6, x + 6, y + 6), fill=(255, 255, 255, 255), outline=(0, 0, 0, 255)) + d.text((x + 8, y - 16), p["id"], fill=(255, 255, 255, 255)) + out.parent.mkdir(parents=True, exist_ok=True) + done = Image.alpha_composite(image, layer).convert("RGB") + done.resize((done.width // 2, done.height // 2), Image.Resampling.LANCZOS).save(out, optimize=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("regions", nargs="*", type=Path, help="region folders (default: every stage)") + parser.add_argument("--overlay", action="store_true", help="draw /collision/..png") + args = parser.parse_args() + regions = args.regions or sorted(p for p in STAGES.iterdir() if p.is_dir()) + errors, count = [], 0 + for region in regions: + files = sorted(region.glob("*.collision.json")) + order = json.loads((region / "chunks.json").read_text(encoding="utf-8")) if (region / "chunks.json").exists() else None + loaded = {} + for path in files: + name = path.name + data = json.loads(path.read_text(encoding="utf-8")) + problems = validate(data, name) + if not problems: + problems = reach(data, name) + errors += problems + loaded[(data.get("chunk"), data.get("state"))] = data + count += 1 + if args.overlay and order and not problems: + art = region / order["art"][data["state"]].format(chunk=order["chunks"][data["chunk"]]) + overlay(data, art, region / "collision" / f"{data['chunk']}.{data['state']}.png") + if order: + for state in order["art"]: + chain = list(order["chunks"]) + for a, b in zip(chain, chain[1:]): + if (a, state) in loaded and (b, state) in loaded: + ya, yb = floor_at_edge(loaded[(a, state)], True), floor_at_edge(loaded[(b, state)], False) + if ya is None or yb is None or abs(ya - yb) > SNAP: + errors.append(f"{region.name}: {a} and {b} ({state}) meet at different ground heights {ya} / {yb}") + for e in errors: + print(e) + print(f"Checked {count} collision files: {len(errors)} problems.") + sys.exit(1 if errors else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_collision_check.py b/scripts/test_collision_check.py new file mode 100644 index 0000000..bea225c --- /dev/null +++ b/scripts/test_collision_check.py @@ -0,0 +1,85 @@ +"""Offline checks for collision_check.py: schema, reachability with the 28x76 body, committed stages.""" +import copy +from pathlib import Path +import subprocess +import sys +import unittest + +import collision_check as cc + +SCRIPT = Path(__file__).with_name('collision_check.py') + + +def chunk(**fields): + """A 600x400 chunk: one floor at y=300 from edge to edge, the player on it.""" + data = {'chunk': 'x-0-0', 'state': 'anomaly', 'size': [600, 400], 'walk': [[[0, 300], [600, 300]]], + 'slide': [], 'oneWay': [], 'climb': [], 'solid': [], 'noGo': [], 'hazard': [], 'interact': [], + 'spawn': [{'id': 'player', 'kind': 'player', 'x': 50, 'y': 300}], 'ledge': [], 'glideLift': []} + data.update(fields) + return data + + +NAME = 'x-0-0.anomaly.collision.json' + + +def problems(data): + return cc.validate(data, NAME) or cc.reach(data, NAME) + + +class CollisionCheckTests(unittest.TestCase): + def test_a_flat_floor_passes(self): + self.assertEqual(problems(chunk()), []) + + def test_schema_errors_are_named(self): + data = chunk(walk=[[[600, 300], [0, 300]]]) + self.assertIn(f'{NAME}: walk[0] must run left to right', problems(data)) + data = chunk(walk=[[[0, 300], [100, 150], [600, 150]]]) + self.assertTrue(any('slope over 40Β°' in p for p in problems(data))) + data = chunk(interact=[{'id': 'a', 'kind': 'Coffee Machine', 'x': 1, 'y': 1, 'w': 5, 'h': 5}]) + self.assertTrue(any('lower-case kind' in p for p in problems(data))) + data = chunk() + del data['ledge'] + self.assertEqual(problems(data), [f"{NAME}: missing field 'ledge'"]) + data = chunk(solid=[{'x': 590, 'y': 0, 'w': 20, 'h': 50}]) + self.assertTrue(any('outside the 600x400 chunk' in p for p in problems(data))) + + def test_a_deck_too_high_needs_a_ladder(self): + high = [[[200, 150], [400, 150]]] # 150 px above the floor: more than a held jump + pickup = {'id': 'muffin', 'kind': 'pickup', 'x': 290, 'y': 120, 'w': 20, 'h': 30} + data = chunk(oneWay=high, interact=[pickup]) + self.assertEqual(problems(data), [f'{NAME}: interact muffin is not reachable by the body']) + data['climb'] = [{'id': 'ladder', 'kind': 'ladder', 'x': 220, 'y': 145, 'w': 28, 'h': 160}] + self.assertEqual(problems(data), []) + + def test_a_low_deck_is_reached_by_jumping(self): + low = [[[200, 230], [400, 230]]] # 70 px: within a held jump + data = chunk(oneWay=low, spawn=chunk()['spawn'] + [{'id': 'crab', 'kind': 'crab', 'x': 300, 'y': 230}]) + self.assertEqual(problems(data), []) + + def test_a_wall_blocks_the_way_and_a_ladder_over_it_does_not_count_unless_it_lands(self): + wall = [{'x': 300, 'y': 100, 'w': 30, 'h': 200}] + data = chunk(walk=[[[0, 300], [300, 300]], [[330, 300], [600, 300]]], solid=wall) + self.assertEqual(problems(data), [f'{NAME}: the right edge is not reachable']) + # A deck over the wall, reached by a ladder, lets the body drop down on the far side. + data['oneWay'] = [[[200, 90], [360, 90]]] + data['climb'] = [{'id': 'ladder', 'kind': 'ladder', 'x': 220, 'y': 85, 'w': 28, 'h': 220}] + self.assertEqual(problems(data), []) + + def test_a_gap_wider_than_a_running_jump_is_not_crossed(self): + data = chunk(walk=[[[0, 300], [200, 300]], [[200 + cc.ACROSS + 30, 300], [600, 300]]]) + self.assertEqual(problems(data), [f'{NAME}: the right edge is not reachable']) + data['walk'][1][0][0] = 200 + cc.ACROSS - 10 + self.assertEqual(problems(data), []) + + def test_committed_stages_pass_and_neighbours_meet(self): + run = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_edge_heights(self): + left = chunk(walk=[[[0, 300], [600, 280]]]) + self.assertEqual(cc.floor_at_edge(left, True), 280) + self.assertEqual(cc.floor_at_edge(copy.deepcopy(left), False), 300) + + +if __name__ == '__main__': + unittest.main()