Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/art-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
12 changes: 12 additions & 0 deletions art/stages/hidden-garden/export.json
Original file line number Diff line number Diff line change
@@ -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"}
}
24 changes: 24 additions & 0 deletions art/stages/rusty-creek/export.json
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions docs/09-ART-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<region> [--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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/13-PRODUCTION-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Lane B codes against these from day one, with placeholders; lane A fills them wi
| --- | --- | --- | --- |
| Atlas | `game/public/assets/sprites/<board>.png` + `.json` | Phaser JSON hash; frame names `<animation>-<nn>` 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/<board>.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/<region>/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/<region>/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/<region>/<chunk>.<state>.collision.json`, chunk ids `<prefix>-<column>-<row>` | 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 |
Expand Down
146 changes: 146 additions & 0 deletions scripts/export_stage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Export stage regions for the game: the docs/13 stage manifest, its layer plates and collision.

art/stages/<region>/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/<region>/stage.json the manifest the loader (#24) reads
-> game/public/assets/stages/<region>/<files> 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/<region> ...] [--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()
92 changes: 92 additions & 0 deletions scripts/test_export_stage.py
Original file line number Diff line number Diff line change
@@ -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()
Loading