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/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/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/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/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()