diff --git a/.github/workflows/art-pipeline.yml b/.github/workflows/art-pipeline.yml
index 14db333..661bd3f 100644
--- a/.github/workflows/art-pipeline.yml
+++ b/.github/workflows/art-pipeline.yml
@@ -24,4 +24,5 @@ jobs:
- run: python scripts/creek_layers.py
- run: python scripts/split_layers.py --check
- run: python scripts/collision_check.py
+ - run: python scripts/export_stage.py --verify --out tmp/stage-export
- run: python scripts/check_docs.py
diff --git a/.gitignore b/.gitignore
index 40e4a60..a0fef8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,6 @@ tmp/
*.log
.DS_Store
Thumbs.db
+
+# Generated by scripts/export_stage.py from art/stages/ (never committed)
+game/public/assets/stages/
diff --git a/art/stages/hidden-garden/README.md b/art/stages/hidden-garden/README.md
new file mode 100644
index 0000000..62ccd9e
--- /dev/null
+++ b/art/stages/hidden-garden/README.md
@@ -0,0 +1,9 @@
+# Hidden Garden – stage chunks
+
+The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation with its figures removed. One chunk, `hg-0-0` (2160×720): the bench where Dexter sits at the start (x ≈ 190–320), the stone path and the timber jetty (path top y = 441), the end of the jetty where Dexter points (x ≈ 1290–1323), and one wrong patch in the pond's reflection below it – a rusty girder with a ladder that isn't there. Clean only.
+
+**Layers** ([layers.json](layers.json), `python scripts/split_layers.py art/stages/hidden-garden`): [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). Everything above the path is one rear layer at scroll 1.0, and the foreground also scrolls at 1.0: rectangles and colours would tear the big tree or the reeds at another speed, so sky/far/mid parallax waits for hand-drawn masks.
+
+| | |
+| --- | --- |
+| [](hg-0-0.md)
**[Hidden Garden · the bench and the pond](hg-0-0.md)** | |
diff --git a/art/stages/hidden-garden/boards.json b/art/stages/hidden-garden/boards.json
new file mode 100644
index 0000000..37d0d58
--- /dev/null
+++ b/art/stages/hidden-garden/boards.json
@@ -0,0 +1,27 @@
+{
+ "name": "Hidden Garden – stage chunks",
+ "readme": "The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation with its figures removed. One chunk, `hg-0-0` (2160×720): the bench where Dexter sits at the start (x ≈ 190–320), the stone path and the timber jetty (path top y = 441), the end of the jetty where Dexter points (x ≈ 1290–1323), and one wrong patch in the pond's reflection below it – a rusty girder with a ladder that isn't there. Clean only.\n\n**Layers** ([layers.json](layers.json), `python scripts/split_layers.py art/stages/hidden-garden`): [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). Everything above the path is one rear layer at scroll 1.0, and the foreground also scrolls at 1.0: rectangles and colours would tear the big tree or the reeds at another speed, so sky/far/mid parallax waits for hand-drawn masks.",
+ "model": "gpt-image-2.5-sunburst",
+ "size": "2160x720",
+ "style": "FORMAT: a STAGE CONCEPT for a 2D side-scroller explored in all four directions - one wide 2:1 image in strict side view, drawn like an architect's ELEVATION: every path, wall, bed and building runs PARALLEL to the screen from left to right; depth comes only from layers stacked behind each other, never from things receding toward a vanishing point; no path, corridor or bed leads into the distance. The hero is small, about one ninth of the image height. Build real level structure into the scenery: a main route plus at least one higher branch and one lower branch or underground pocket, smooth hills and slopes (some steep enough to slide down), ledges with grabbable edges, something to climb (ladder, vines, scaffold, stairs), a dead end with a small reward glint. DEPTH in clear layers: hazy far background, mid-ground, the play layer, and a dark, out-of-focus foreground of leaves and grass partly covering the bottom edge and corners as if the camera sits behind foliage; reflections where there is water; a few falling leaves. LOOK: detailed hi-bit pixel art, fine 2-pixel grain, the warm palette and light of the style master. TONE: secret horror - calm and pretty first. No text, no logos, no UI.",
+ "people": "No people, no animals, no plush toys and no characters of any kind: this is an empty stage the game puts its characters into.",
+ "boards": [
+ {
+ "id": "hg-0-0",
+ "title": "Hidden Garden · the bench and the pond",
+ "factor": 1,
+ "keyed": false,
+ "colours": 256,
+ "inputs": [
+ "repo:art/locations/images/hidden-garden-clean.png",
+ "repo:art/direction/images/style-master-preview.png"
+ ],
+ "prompt": "Wide side-scrolling stage chunk at 3:1, drawn as an architect's elevation with everything parallel to the screen (no vanishing point, no receding paths): the Hidden Garden pond from the first image, redrawn as a flat side view. Keep its look exactly - late-afternoon autumn light, golden reeds, orange and yellow trees, the misty pine forest behind, a blue sky with small clouds, the big tree in the upper right. Ground: from the left edge a stone-slab path at ground level, its top at about two thirds of the height, runs right onto a timber jetty over the pond; the jetty ends a little right of the middle with two posts - the spot where someone would stand and look down into the water. The calm pond fills the bottom across the whole width and reflects the reeds and trees. ONE patch of the reflection just below the end of the jetty is WRONG: it shows a rusty blue-green riveted girder structure that does not exist above the water. On the right, reeds and a low mossy stone wall close the scene. Along the very bottom edge, a few dark out-of-focus reed silhouettes in the foreground. Remove the two figures of the first image: NO people, NO animals, NO plush toy, nothing standing on the jetty.",
+ "review": "🟡 Drawn 24 Oct, waiting for Alexander's Design review. The figures of the concept are removed; the bench and the wrong reflection were painted in afterwards with masked edits that change only their own rectangles.",
+ "edits": [
+ "bench: masked edit of (190, 392)–(320, 446) – an empty wooden park bench on the path",
+ "wrong reflection: masked edit of (1080, 505)–(1330, 610) – the water mirrors a rusty girder with a ladder that isn't there"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/art/stages/hidden-garden/chunks.json b/art/stages/hidden-garden/chunks.json
new file mode 100644
index 0000000..3439780
--- /dev/null
+++ b/art/stages/hidden-garden/chunks.json
@@ -0,0 +1,6 @@
+{
+ "region": "hidden-garden",
+ "note": "Chunk ids are grid positions (column-row); art names the plate each state is drawn on.",
+ "chunks": {"hg-0-0": "hg-0-0"},
+ "art": {"clean": "images/{chunk}.png"}
+}
diff --git a/art/stages/hidden-garden/collision/hg-0-0.clean.png b/art/stages/hidden-garden/collision/hg-0-0.clean.png
new file mode 100644
index 0000000..ca72c44
Binary files /dev/null and b/art/stages/hidden-garden/collision/hg-0-0.clean.png differ
diff --git a/art/stages/hidden-garden/export.json b/art/stages/hidden-garden/export.json
new file mode 100644
index 0000000..759589e
--- /dev/null
+++ b/art/stages/hidden-garden/export.json
@@ -0,0 +1,12 @@
+{
+ "note": "Layers from split_layers.py (layers.json). Everything scrolls at 1.0 until sky/far/mid get hand-drawn masks.",
+ "chunks": {"hg-0-0": {"x": 0, "y": 0}},
+ "layers": {
+ "03-rear": {"file": "layers/{name}.03-rear.png", "z": 3, "scroll": 1.0},
+ "04-water": {"file": "layers/{name}.04-water.png", "z": 4, "scroll": 1.0},
+ "05-play": {"file": "layers/{name}.05-play.png", "z": 5, "scroll": 1.0},
+ "07-foreground": {"file": "layers/{name}.07-foreground.png", "z": 7, "scroll": 1.0}
+ },
+ "states": {"clean": ["03-rear", "04-water", "05-play", "07-foreground"]},
+ "composed": {"clean": "images/{name}.png"}
+}
diff --git a/art/stages/hidden-garden/hg-0-0.clean.collision.json b/art/stages/hidden-garden/hg-0-0.clean.collision.json
new file mode 100644
index 0000000..1d8c525
--- /dev/null
+++ b/art/stages/hidden-garden/hg-0-0.clean.collision.json
@@ -0,0 +1,110 @@
+{
+ "chunk": "hg-0-0",
+ "state": "clean",
+ "size": [
+ 2160,
+ 720
+ ],
+ "walk": [
+ [
+ [
+ 0,
+ 441
+ ],
+ [
+ 1323,
+ 441
+ ]
+ ]
+ ],
+ "slide": [],
+ "oneWay": [],
+ "climb": [],
+ "solid": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 8,
+ "h": 720
+ },
+ {
+ "x": 1326,
+ "y": 300,
+ "w": 24,
+ "h": 141
+ }
+ ],
+ "noGo": [
+ {
+ "x": 0,
+ "y": 478,
+ "w": 2160,
+ "h": 242
+ }
+ ],
+ "hazard": [],
+ "interact": [
+ {
+ "id": "pond-edge",
+ "kind": "anomaly",
+ "x": 1250,
+ "y": 365,
+ "w": 70,
+ "h": 76,
+ "note": "Dexter points at the ground here, then shoves the hero into the pond (M3-S3 #50, M3-S4 #51)"
+ },
+ {
+ "id": "to-rusty-creek",
+ "kind": "chunk-exit",
+ "x": 1250,
+ "y": 365,
+ "w": 70,
+ "h": 76,
+ "note": "the crossing: after the shove the hero lands in clean Rusty Creek (rc-0-0)"
+ },
+ {
+ "id": "dexter-waypoint-1",
+ "kind": "dexter-waypoint",
+ "x": 215,
+ "y": 365,
+ "w": 80,
+ "h": 76,
+ "note": "the bench: Dexter waits here; walking up makes him hop off"
+ },
+ {
+ "id": "dexter-waypoint-2",
+ "kind": "dexter-waypoint",
+ "x": 680,
+ "y": 365,
+ "w": 80,
+ "h": 76,
+ "note": "mid-path: Dexter hops ahead and beckons"
+ },
+ {
+ "id": "dexter-waypoint-3",
+ "kind": "dexter-waypoint",
+ "x": 1150,
+ "y": 365,
+ "w": 80,
+ "h": 76,
+ "note": "the jetty: Dexter stops and points at the water"
+ }
+ ],
+ "spawn": [
+ {
+ "id": "player",
+ "kind": "player",
+ "x": 90,
+ "y": 441
+ },
+ {
+ "id": "dexter-bench",
+ "kind": "dexter",
+ "x": 255,
+ "y": 421,
+ "note": "Dexter sits on the bench at the start"
+ }
+ ],
+ "ledge": [],
+ "glideLift": []
+}
diff --git a/art/stages/hidden-garden/hg-0-0.md b/art/stages/hidden-garden/hg-0-0.md
new file mode 100644
index 0000000..3a27299
--- /dev/null
+++ b/art/stages/hidden-garden/hg-0-0.md
@@ -0,0 +1,16 @@
+# Hidden Garden · the bench and the pond
+
+
+
+**Review:** 🟡 Drawn 24 Oct, waiting for Alexander's Design review. The figures of the concept are removed; the bench and the wrong reflection were painted in afterwards with masked edits that change only their own rectangles.
+
+## Brief
+
+Wide side-scrolling stage chunk at 3:1, drawn as an architect's elevation with everything parallel to the screen (no vanishing point, no receding paths): the Hidden Garden pond from the first image, redrawn as a flat side view. Keep its look exactly - late-afternoon autumn light, golden reeds, orange and yellow trees, the misty pine forest behind, a blue sky with small clouds, the big tree in the upper right. Ground: from the left edge a stone-slab path at ground level, its top at about two thirds of the height, runs right onto a timber jetty over the pond; the jetty ends a little right of the middle with two posts - the spot where someone would stand and look down into the water. The calm pond fills the bottom across the whole width and reflects the reeds and trees. ONE patch of the reflection just below the end of the jetty is WRONG: it shows a rusty blue-green riveted girder structure that does not exist above the water. On the right, reeds and a low mossy stone wall close the scene. Along the very bottom edge, a few dark out-of-focus reed silhouettes in the foreground. Remove the two figures of the first image: NO people, NO animals, NO plush toy, nothing standing on the jetty.
+
+## Inputs
+
+- [hidden-garden-clean.png](../../locations/images/hidden-garden-clean.png)
+- [style-master-preview.png](../../direction/images/style-master-preview.png)
+
+Model `gpt-image-2.5-sunburst` · pixel factor 1 · source image `images/hg-0-0.png` · rebuild: `python scripts/make_boards.py draw --config art/stages/hidden-garden/boards.json --redo hg-0-0`
diff --git a/art/stages/hidden-garden/images/hg-0-0-preview.png b/art/stages/hidden-garden/images/hg-0-0-preview.png
new file mode 100644
index 0000000..3ba2c01
Binary files /dev/null and b/art/stages/hidden-garden/images/hg-0-0-preview.png differ
diff --git a/art/stages/hidden-garden/images/hg-0-0.png b/art/stages/hidden-garden/images/hg-0-0.png
new file mode 100644
index 0000000..585a48c
Binary files /dev/null and b/art/stages/hidden-garden/images/hg-0-0.png differ
diff --git a/art/stages/hidden-garden/layers.json b/art/stages/hidden-garden/layers.json
new file mode 100644
index 0000000..72eec95
--- /dev/null
+++ b/art/stages/hidden-garden/layers.json
@@ -0,0 +1,130 @@
+{
+ "note": "Rules run in order; the last rule that claims a pixel wins. Everything above the path stays one rear layer (scroll 1.0): rectangles would tear the big tree from its trunk at another scroll speed, so sky/far/mid parallax waits for hand-drawn masks.",
+ "chunks": {
+ "hg-0-0": {
+ "states": {
+ "clean": "images/hg-0-0.png"
+ },
+ "default": "03-rear",
+ "rules": [
+ {
+ "layer": "04-water",
+ "rect": [
+ 0,
+ 478,
+ 2160,
+ 720
+ ]
+ },
+ {
+ "layer": "05-play",
+ "rect": [
+ 140,
+ 441,
+ 1790,
+ 478
+ ]
+ },
+ {
+ "layer": "05-play",
+ "rect": [
+ 1026,
+ 392,
+ 1323,
+ 478
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 40,
+ 14,
+ 5
+ ],
+ "tolerance": 32,
+ "rect": [
+ 0,
+ 560,
+ 330,
+ 720
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 80,
+ 38,
+ 10
+ ],
+ "tolerance": 28,
+ "rect": [
+ 0,
+ 560,
+ 330,
+ 720
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 40,
+ 14,
+ 5
+ ],
+ "tolerance": 32,
+ "rect": [
+ 1380,
+ 560,
+ 2160,
+ 720
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 80,
+ 38,
+ 10
+ ],
+ "tolerance": 28,
+ "rect": [
+ 1380,
+ 560,
+ 2160,
+ 720
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 40,
+ 14,
+ 5
+ ],
+ "tolerance": 32,
+ "rect": [
+ 330,
+ 690,
+ 1380,
+ 720
+ ]
+ },
+ {
+ "layer": "07-foreground",
+ "colour": [
+ 80,
+ 38,
+ 10
+ ],
+ "tolerance": 28,
+ "rect": [
+ 330,
+ 690,
+ 1380,
+ 720
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/art/stages/hidden-garden/layers/hg-0-0.03-rear.png b/art/stages/hidden-garden/layers/hg-0-0.03-rear.png
new file mode 100644
index 0000000..ec31612
Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.03-rear.png differ
diff --git a/art/stages/hidden-garden/layers/hg-0-0.04-water.png b/art/stages/hidden-garden/layers/hg-0-0.04-water.png
new file mode 100644
index 0000000..d7b6095
Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.04-water.png differ
diff --git a/art/stages/hidden-garden/layers/hg-0-0.05-play.png b/art/stages/hidden-garden/layers/hg-0-0.05-play.png
new file mode 100644
index 0000000..13bab75
Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.05-play.png differ
diff --git a/art/stages/hidden-garden/layers/hg-0-0.07-foreground.png b/art/stages/hidden-garden/layers/hg-0-0.07-foreground.png
new file mode 100644
index 0000000..a5efb90
Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.07-foreground.png differ
diff --git a/art/stages/rusty-creek/export.json b/art/stages/rusty-creek/export.json
new file mode 100644
index 0000000..50f1313
--- /dev/null
+++ b/art/stages/rusty-creek/export.json
@@ -0,0 +1,24 @@
+{
+ "note": "Draw order follows creek_layers.py compose(): the plates are soft and blend, so the water is drawn last. z names the docs/04 role; loaders draw in list order.",
+ "chunks": {
+ "rc-0-0": {"x": 0, "y": 0, "name": "left"},
+ "rc-1-0": {"x": 1, "y": 0, "name": "center"},
+ "rc-2-0": {"x": 2, "y": 0, "name": "right"}
+ },
+ "layers": {
+ "01-distance": {"file": "images/01-distance-{name}.png", "z": 1, "scroll": 0.2},
+ "03-bank": {"file": "images/03-bank-{name}.png", "z": 2, "scroll": 1.0},
+ "02-supports": {"file": "images/02-supports-{name}.png", "z": 3, "scroll": 1.0},
+ "clean-architecture": {"file": "images/clean-architecture-{name}.png", "z": 3, "scroll": 1.0},
+ "05-traversal": {"file": "images/05-traversal-{name}.png", "z": 5, "scroll": 1.0},
+ "06-near-rails": {"file": "images/06-near-rails-{name}.png", "z": 6, "scroll": 1.1},
+ "07-foliage": {"file": "images/07-foliage-{name}.png", "z": 7, "scroll": 1.15},
+ "04-water": {"file": "images/04-water-{name}.png", "z": 4, "scroll": 1.0}
+ },
+ "states": {
+ "anomaly": ["01-distance", "03-bank", "02-supports", "05-traversal", "06-near-rails", "07-foliage", "04-water"],
+ "clean": ["01-distance", "03-bank", "clean-architecture", "06-near-rails", "07-foliage", "04-water"]
+ },
+ "composed": {"anomaly": "images/after-{name}.png", "clean": "images/before-{name}.png"},
+ "aliases": "media-sources.json"
+}
diff --git a/docs/07-ASSET-MANIFEST.md b/docs/07-ASSET-MANIFEST.md
index 9b78ac0..cbeaa34 100644
--- a/docs/07-ASSET-MANIFEST.md
+++ b/docs/07-ASSET-MANIFEST.md
@@ -58,7 +58,7 @@ Frame lists approved as they are (A14).
| --- | --- | --- |
| Rusty Creek | 3 chunks × 7 layers, clean + anomaly states, route graph | ✅ art · ⬜ collision · ⬜ foreground-blur + particle layers |
| Stage concepts (6 regions × clean/anomaly) | 2176×1088 concepts with up/down routes ([art/locations](../art/locations/README.md)) | 🟡 in review |
-| Hidden Garden (prologue) | 2–3 chunks (about 3 minutes of play), layer stack + collision, from its stage concept and Lex's layout | ⬜ |
+| Hidden Garden (prologue) | 1 chunk `hg-0-0` ([art/stages/hidden-garden](../art/stages/hidden-garden/README.md)): bench, path, jetty, the wrong reflection; layers 03/04/05/07. A second chunk waits for seam repair | 🟡 in review · ⬜ collision |
| Jenny's arena | right chunk of Rusty Creek (boss clearing) | ✅ art · ⬜ collision |
## Audio (to source during the jam)
diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md
index fb4dc3d..9efd1b4 100644
--- a/docs/09-ART-PIPELINE.md
+++ b/docs/09-ART-PIPELINE.md
@@ -100,6 +100,7 @@ python scripts/creek_layers.py [--build] # Rusty Creek
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/split_layers.py art/stages/ [--check] # composed chunk -> z0–z7 layers, exact
+python scripts/export_stage.py [--verify] # art/stages -> game stage.json + plates (generated)
python scripts/navigation.py && python scripts/check_docs.py
```
diff --git a/docs/13-PRODUCTION-PIPELINE.md b/docs/13-PRODUCTION-PIPELINE.md
index 302e151..cfcd49f 100644
--- a/docs/13-PRODUCTION-PIPELINE.md
+++ b/docs/13-PRODUCTION-PIPELINE.md
@@ -178,7 +178,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) |
+| Stage manifest | `game/public/assets/stages//stage.json` (generated by `scripts/export_stage.py`, not committed) | `{"region", "chunkSize": [2160, 720], "chunks": [{"id": "rc-0-0", "x": 0, "y": 0, "states": {"anomaly": {"layers": [{"id", "z", "scroll", "file"}], "collision": "rc-0-0.anomaly.collision.json"}}}]}`. Draw the layers **in list order** (Rusty Creek's soft water goes last); `z` names the docs/04 role. Files sit next to the manifest | stage export (A) → 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/export_stage.py b/scripts/export_stage.py
new file mode 100644
index 0000000..7856e2a
--- /dev/null
+++ b/scripts/export_stage.py
@@ -0,0 +1,146 @@
+"""Export stage regions for the game: the docs/13 stage manifest, its layer plates and collision.
+
+ art/stages//export.json chunks (grid x/y, art name), layers (file pattern, z, scroll),
+ the layers each state draws, in draw order
+ -> game/public/assets/stages//stage.json the manifest the loader (#24) reads
+ -> game/public/assets/stages// plates and collision, each copied once
+
+The output is generated, never committed (the plates already live in art/stages/). Only the
+standard library is needed, so the game's dev and build scripts can run it; --verify needs Pillow
+and rebuilds every state from the exported files, in manifest order, against the region's art.
+
+export.json:
+ {"chunks": {"rc-0-0": {"x": 0, "y": 0, "name": "left"}},
+ "layers": {"01-distance": {"file": "images/01-distance-{name}.png", "z": 1, "scroll": 0.2}},
+ "states": {"anomaly": ["01-distance", ...], "clean": [...]},
+ "composed": {"anomaly": "images/after-{name}.png"}, # what --verify compares with
+ "aliases": "media-sources.json"} # optional: shared plates
+
+Usage:
+ python scripts/export_stage.py [art/stages/ ...] [--out game/public/assets/stages] [--verify]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+STAGES = ROOT / "art" / "stages"
+OUT = ROOT / "game" / "public" / "assets" / "stages"
+SIZE = [2160, 720]
+
+
+class ExportError(Exception):
+ """The export config or the art doesn't add up; the message says where."""
+
+
+def source(region: Path, pattern: str, name: str, aliases: dict) -> Path:
+ """The real file behind a layer pattern, following a shared-plate alias."""
+ path = Path(pattern.format(name=name))
+ alias = aliases.get(path.name)
+ real = region / (path.parent / alias if alias else path)
+ if not real.exists():
+ raise ExportError(f"{region.name}: {real.relative_to(region)} does not exist")
+ return real
+
+
+def manifest(region: Path) -> tuple[dict, dict[str, Path]]:
+ """The stage.json content and the files to copy (published name -> source)."""
+ config = json.loads((region / "export.json").read_text(encoding="utf-8"))
+ aliases = {}
+ if config.get("aliases"):
+ aliases = json.loads((region / config["aliases"]).read_text(encoding="utf-8")).get("aliases", {})
+ files: dict[str, Path] = {}
+ published: dict[Path, str] = {}
+
+ def publish(path: Path) -> str:
+ if path not in published:
+ name = path.name
+ if name in files: # same file name from another folder: keep both
+ name = f"{path.parent.name}-{name}"
+ published[path] = name
+ files[name] = path
+ return published[path]
+
+ chunks = []
+ for chunk_id, chunk in config["chunks"].items():
+ states = {}
+ for state, layer_ids in config["states"].items():
+ layers = []
+ for layer_id in layer_ids:
+ if layer_id not in config["layers"]:
+ raise ExportError(f"{region.name}: state {state} draws unknown layer {layer_id!r}")
+ layer = config["layers"][layer_id]
+ plate = source(region, layer["file"], chunk.get("name", chunk_id), aliases)
+ layers.append({"id": layer_id, "z": layer["z"], "scroll": layer["scroll"], "file": publish(plate)})
+ collision = region / f"{chunk_id}.{state}.collision.json"
+ states[state] = {"layers": layers, "collision": publish(collision) if collision.exists() else None}
+ chunks.append({"id": chunk_id, "x": chunk["x"], "y": chunk["y"], "states": states})
+ stage = {"region": region.name, "chunkSize": config.get("chunkSize", SIZE),
+ "note": "Generated by scripts/export_stage.py from art/stages/; layers are listed in draw order.",
+ "chunks": chunks}
+ return stage, files
+
+
+def export(region: Path, out: Path) -> dict:
+ stage, files = manifest(region)
+ target = out / region.name
+ if target.exists():
+ shutil.rmtree(target)
+ target.mkdir(parents=True)
+ for name, path in files.items():
+ shutil.copyfile(path, target / name)
+ (target / "stage.json").write_text(json.dumps(stage, indent=1) + "\n", encoding="utf-8", newline="\n")
+ return stage
+
+
+def verify(region: Path, out: Path) -> list[str]:
+ """Rebuild every chunk state from the exported plates and compare with the region's art."""
+ from PIL import Image, ImageChops # only --verify needs Pillow
+
+ config = json.loads((region / "export.json").read_text(encoding="utf-8"))
+ stage = json.loads((out / region.name / "stage.json").read_text(encoding="utf-8"))
+ report = []
+ for chunk in stage["chunks"]:
+ name = config["chunks"][chunk["id"]].get("name", chunk["id"])
+ for state, content in chunk["states"].items():
+ pattern = config.get("composed", {}).get(state)
+ if not pattern:
+ continue
+ canvas = Image.new("RGBA", tuple(stage["chunkSize"]), (0, 0, 0, 0))
+ for layer in content["layers"]:
+ canvas = Image.alpha_composite(canvas, Image.open(out / region.name / layer["file"]).convert("RGBA"))
+ art = Image.open(region / pattern.format(name=name)).convert("RGB")
+ if ImageChops.difference(canvas.convert("RGB"), art).getbbox():
+ raise ExportError(f"{region.name}/{chunk['id']}.{state}: the exported layers don't rebuild "
+ f"{pattern.format(name=name)}")
+ report.append(f"{chunk['id']}.{state}")
+ return report
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("regions", nargs="*", type=Path, help="region folders (default: every one with an export.json)")
+ parser.add_argument("--out", type=Path, default=OUT, help="output folder (default: game/public/assets/stages)")
+ parser.add_argument("--verify", action="store_true", help="rebuild each state from the export and compare (Pillow)")
+ args = parser.parse_args()
+ regions = args.regions or sorted(p.parent for p in STAGES.glob("*/export.json"))
+ try:
+ for region in regions:
+ stage = export(region, args.out)
+ states = sum(len(c["states"]) for c in stage["chunks"])
+ files = len(list((args.out / region.name).iterdir())) - 1
+ line = f"{region.name}: {len(stage['chunks'])} chunks, {states} states, {files} files"
+ if args.verify:
+ line += f"; rebuilt exactly: {', '.join(verify(region, args.out))}"
+ print(line)
+ except ExportError as error:
+ sys.exit(f"export_stage: {error}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/make_boards.py b/scripts/make_boards.py
index 4aed37d..139b69f 100644
--- a/scripts/make_boards.py
+++ b/scripts/make_boards.py
@@ -187,7 +187,8 @@ def crush(board: dict) -> None:
else:
small = image.resize(size, Image.Resampling.BOX).convert("RGBA")
alpha = small.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
- colours = small.convert("RGB").quantize(COLOURS, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
+ colours = small.convert("RGB").quantize(board.get("colours", COLOURS), method=Image.Quantize.MEDIANCUT,
+ dither=Image.Dither.NONE)
sprite = colours.convert("RGBA")
sprite.putalpha(alpha)
small = sprite
diff --git a/scripts/test_export_stage.py b/scripts/test_export_stage.py
new file mode 100644
index 0000000..61dec66
--- /dev/null
+++ b/scripts/test_export_stage.py
@@ -0,0 +1,92 @@
+"""Offline checks for export_stage.py: manifest shape, shared plates, collision, exact rebuild."""
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from PIL import Image
+
+import export_stage as es
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class ExportStageTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ base = Path(self.tmp.name)
+ self.region = base / 'test-region'
+ self.out = base / 'out'
+ (self.region / 'images').mkdir(parents=True)
+ for name, colour in (('left', (200, 60, 40, 255)), ('right', (40, 60, 200, 255))):
+ Image.new('RGBA', (40, 20), (90, 140, 200, 255)).save(self.region / 'images' / f'sky-{name}.png')
+ front = Image.new('RGBA', (40, 20), (0, 0, 0, 0))
+ front.paste(colour, (10, 10, 30, 20))
+ front.save(self.region / 'images' / f'front-{name}.png')
+ composed = Image.new('RGBA', (40, 20), (90, 140, 200, 255))
+ composed.alpha_composite(front)
+ composed.convert('RGB').save(self.region / 'images' / f'all-{name}.png')
+ (self.region / 'images' / 'sky-right.png').unlink() # the right chunk reuses the left sky
+ (self.region / 'media-sources.json').write_text(json.dumps({'aliases': {'sky-right.png': 'sky-left.png'}}))
+ (self.region / 't-0-0.clean.collision.json').write_text('{"chunk": "t-0-0"}')
+ self.config = {
+ 'chunkSize': [40, 20],
+ 'chunks': {'t-0-0': {'x': 0, 'y': 0, 'name': 'left'}, 't-1-0': {'x': 1, 'y': 0, 'name': 'right'}},
+ 'layers': {'sky': {'file': 'images/sky-{name}.png', 'z': 0, 'scroll': 0.1},
+ 'front': {'file': 'images/front-{name}.png', 'z': 5, 'scroll': 1.0}},
+ 'states': {'clean': ['sky', 'front']},
+ 'composed': {'clean': 'images/all-{name}.png'},
+ 'aliases': 'media-sources.json'}
+ self.save()
+
+ def save(self):
+ (self.region / 'export.json').write_text(json.dumps(self.config), encoding='utf-8')
+
+ def test_manifest_lists_chunks_layers_and_collision(self):
+ stage = es.export(self.region, self.out)
+ self.assertEqual([c['id'] for c in stage['chunks']], ['t-0-0', 't-1-0'])
+ left = stage['chunks'][0]['states']['clean']
+ self.assertEqual([(l['id'], l['z'], l['scroll']) for l in left['layers']], [('sky', 0, 0.1), ('front', 5, 1.0)])
+ self.assertEqual(left['collision'], 't-0-0.clean.collision.json')
+ self.assertIsNone(stage['chunks'][1]['states']['clean']['collision'])
+ written = json.loads((self.out / 'test-region' / 'stage.json').read_text())
+ self.assertEqual(written['chunks'], stage['chunks'])
+
+ def test_shared_plates_are_copied_once(self):
+ stage = es.export(self.region, self.out)
+ skies = {c['states']['clean']['layers'][0]['file'] for c in stage['chunks']}
+ self.assertEqual(skies, {'sky-left.png'})
+ names = sorted(p.name for p in (self.out / 'test-region').iterdir())
+ self.assertEqual(names, ['front-left.png', 'front-right.png', 'sky-left.png', 'stage.json',
+ 't-0-0.clean.collision.json'])
+
+ def test_verify_rebuilds_and_catches_a_wrong_order(self):
+ es.export(self.region, self.out)
+ self.assertEqual(es.verify(self.region, self.out), ['t-0-0.clean', 't-1-0.clean'])
+ self.config['states']['clean'] = ['front', 'sky'] # the opaque sky now covers the front
+ self.save()
+ es.export(self.region, self.out)
+ with self.assertRaisesRegex(es.ExportError, "don't rebuild images/all-left.png"):
+ es.verify(self.region, self.out)
+
+ def test_bad_config_is_named(self):
+ self.config['states']['clean'].append('fog')
+ self.save()
+ with self.assertRaisesRegex(es.ExportError, "unknown layer 'fog'"):
+ es.export(self.region, self.out)
+ self.config['states']['clean'].pop()
+ self.config['layers']['front']['file'] = 'images/missing-{name}.png'
+ self.save()
+ with self.assertRaisesRegex(es.ExportError, 'missing-left.png does not exist'):
+ es.export(self.region, self.out)
+
+ def test_committed_regions_rebuild_exactly(self):
+ for config in sorted((ROOT / 'art' / 'stages').glob('*/export.json')):
+ with self.subTest(region=config.parent.name):
+ es.export(config.parent, self.out)
+ self.assertTrue(es.verify(config.parent, self.out))
+
+
+if __name__ == '__main__':
+ unittest.main()