diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f9f499..ad6fb15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,6 +160,7 @@ npm --prefix game run dev # http://localhost:5173 – stages are genera | F4 then Tab (dev) | hand over the pocket computer, then open the map | | F6 / F7 (dev) | step through the sound loops / effects; M mutes | | F8 (dev) | the THD.exe crossing | +| Shift + F8 (dev) | the end of the slice: Dexter absorbs, the glitch, "To be continued.", the credits | | F9 / F10 (dev) | anomaly FX / atmosphere test chunks (V toggles, A switches the leaves, Esc closes) | Dev keys exist only in `npm run dev`, never in the preview or production build. Movement keys and the URL options arrive with lane B's stack (#111 → …). diff --git a/art/sprites/boards.json b/art/sprites/boards.json index 0cec33d..3abf8f5 100644 --- a/art/sprites/boards.json +++ b/art/sprites/boards.json @@ -767,7 +767,7 @@ ] ] }, - "review": "To review (24 Oct, #139): redrawn in the approved plush proportions; 27 px = 0.35 of the hero as in dexter-stages (compare.py)." + "review": "✅ Approved 24 Oct by Alexander (#141). 27 px = 0.35 of the hero as in the approved Dexter stages (docs/04, compare.py lineup); exported to game/public/assets/sprites/ with export_atlas.py. The looping previews below are at twice game size." }, { "id": "dexter-acts", @@ -816,7 +816,7 @@ ] ] }, - "review": "To review (24 Oct, #139): redrawn in the approved plush proportions; 27 px = 0.35 of the hero as in dexter-stages (compare.py)." + "review": "✅ Approved 24 Oct by Alexander (#141). 27 px = 0.35 of the hero as in the approved Dexter stages (docs/04, compare.py lineup); exported to game/public/assets/sprites/ with export_atlas.py. The looping previews below are at twice game size." }, { "id": "intro-anomaly", diff --git a/art/sprites/dexter-acts.md b/art/sprites/dexter-acts.md index 4bd27bf..44d078a 100644 --- a/art/sprites/dexter-acts.md +++ b/art/sprites/dexter-acts.md @@ -6,7 +6,7 @@ ![Dexter · gestures](images/dexter-acts-preview.png) -**Review:** To review (24 Oct, #139): redrawn in the approved plush proportions; 27 px = 0.35 of the hero as in dexter-stages (compare.py). +**Review:** ✅ Approved 24 Oct by Alexander (#141). 27 px = 0.35 of the hero as in the approved Dexter stages (docs/04, compare.py lineup); exported to game/public/assets/sprites/ with export_atlas.py. The looping previews below are at twice game size. ## In motion diff --git a/art/sprites/dexter.md b/art/sprites/dexter.md index 1d17a2b..05e88f2 100644 --- a/art/sprites/dexter.md +++ b/art/sprites/dexter.md @@ -6,7 +6,7 @@ ![Dexter · moves](images/dexter-preview.png) -**Review:** To review (24 Oct, #139): redrawn in the approved plush proportions; 27 px = 0.35 of the hero as in dexter-stages (compare.py). +**Review:** ✅ Approved 24 Oct by Alexander (#141). 27 px = 0.35 of the hero as in the approved Dexter stages (docs/04, compare.py lineup); exported to game/public/assets/sprites/ with export_atlas.py. The looping previews below are at twice game size. ## In motion diff --git a/art/sprites/previews/ending-lineup.png b/art/sprites/previews/ending-lineup.png new file mode 100644 index 0000000..67fe1cb Binary files /dev/null and b/art/sprites/previews/ending-lineup.png differ diff --git a/art/stages/hidden-garden/README.md b/art/stages/hidden-garden/README.md index 62ccd9e..9993cb0 100644 --- a/art/stages/hidden-garden/README.md +++ b/art/stages/hidden-garden/README.md @@ -1,9 +1,13 @@ # 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. +The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation: **five chunks, 10 800 px**, about 70 s of walking plus Dexter's waits and two detours ([docs/04 – Hidden Garden, the prologue level](../../../docs/04-WORLD-AND-LEVELS.md#hidden-garden--the-prologue-level), #50). `hg-4-0` is the approved pond chunk (the jetty where Dexter points, the wrong reflection). The four before it were grown leftwards from it with [grow_stage.py](../../../scripts/grow_stage.py) – scenery first, one masked repaint per route feature, the seams between new chunks repainted (docs/09): `hg-0-0` the gate (Dexter's bench, a birch log), `hg-1-0` the hill (an oak on the crest, a hollow behind the grass), `hg-2-0` the terraces (a shed with a ladder to its roof), `hg-3-0` the boardwalk (a gap with stepping stones). 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. +**Layers** (#142): each chunk's depth key comes from the model, its masks from the key ([depth_layers.py](../../../scripts/depth_layers.py), [layers.json](layers.json)). Sky, far and mid scroll at 0.1, 0.25 and 0.6 (docs/04) from plates filled where nearer layers hide them; the foreground at 1.2. The pond keeps its exact rules for path, water and foreground; the grown chunks take every band from their key. **Collision**: walk lines traced from each key's play band (`depth_layers.py collision`), spawns, Dexter's waypoints and triggers placed by hand; overlays in [collision/](collision/). + +The whole walk: [prologue.png](images/prologue.png) · the parallax at three camera positions: [parallax.png](images/parallax.png) (`python scripts/compare.py parallax art/stages/hidden-garden`). | | | | --- | --- | -| [![Hidden Garden · the bench and the pond](images/hg-0-0-preview.png)](hg-0-0.md)
**[Hidden Garden · the bench and the pond](hg-0-0.md)** | | +| [![Hidden Garden · the gate](images/hg-0-0-preview.png)](hg-0-0.md)
**[Hidden Garden · the gate](hg-0-0.md)** | [![Hidden Garden · the hill](images/hg-1-0-preview.png)](hg-1-0.md)
**[Hidden Garden · the hill](hg-1-0.md)** | +| [![Hidden Garden · the terraces](images/hg-2-0-preview.png)](hg-2-0.md)
**[Hidden Garden · the terraces](hg-2-0.md)** | [![Hidden Garden · the boardwalk](images/hg-3-0-preview.png)](hg-3-0.md)
**[Hidden Garden · the boardwalk](hg-3-0.md)** | +| [![Hidden Garden · the bench and the pond](images/hg-4-0-preview.png)](hg-4-0.md)
**[Hidden Garden · the bench and the pond](hg-4-0.md)** | | diff --git a/art/stages/hidden-garden/boards.json b/art/stages/hidden-garden/boards.json index 37d0d58..df45168 100644 --- a/art/stages/hidden-garden/boards.json +++ b/art/stages/hidden-garden/boards.json @@ -1,6 +1,6 @@ { "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.", + "readme": "The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation: **five chunks, 10 800 px**, about 70 s of walking plus Dexter's waits and two detours ([docs/04 – Hidden Garden, the prologue level](../../../docs/04-WORLD-AND-LEVELS.md#hidden-garden--the-prologue-level), #50). `hg-4-0` is the approved pond chunk (the jetty where Dexter points, the wrong reflection). The four before it were grown leftwards from it with [grow_stage.py](../../../scripts/grow_stage.py) – scenery first, one masked repaint per route feature, the seams between new chunks repainted (docs/09): `hg-0-0` the gate (Dexter's bench, a birch log), `hg-1-0` the hill (an oak on the crest, a hollow behind the grass), `hg-2-0` the terraces (a shed with a ladder to its roof), `hg-3-0` the boardwalk (a gap with stepping stones). Clean only.\n\n**Layers** (#142): each chunk's depth key comes from the model, its masks from the key ([depth_layers.py](../../../scripts/depth_layers.py), [layers.json](layers.json)). Sky, far and mid scroll at 0.1, 0.25 and 0.6 (docs/04) from plates filled where nearer layers hide them; the foreground at 1.2. The pond keeps its exact rules for path, water and foreground; the grown chunks take every band from their key. **Collision**: walk lines traced from each key's play band (`depth_layers.py collision`), spawns, Dexter's waypoints and triggers placed by hand; overlays in [collision/](collision/).\n\nThe whole walk: [prologue.png](images/prologue.png) · the parallax at three camera positions: [parallax.png](images/parallax.png) (`python scripts/compare.py parallax art/stages/hidden-garden`).", "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.", @@ -8,6 +8,290 @@ "boards": [ { "id": "hg-0-0", + "title": "Hidden Garden · the gate", + "inputs": [ + "repo:art/locations/images/hidden-garden-clean.png", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "The way into the garden: at the left edge an old wrought-iron garden gate stands open in a tall trimmed hedge (x=20-220, up to y=200). A gravel path at y=441 runs across the whole chunk. A wooden park bench with iron legs faces the viewer on the path at x=700-860 (seat at y=405, back up to y=370), under an old lantern post at x=900. Low flowering shrubs and the first reeds behind the path. Near the right edge a fallen birch log lies across the path at x=1700-1830, its top at y=411 (30 px high). Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below.", + "factor": 1, + "review": "To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined.", + "route": { + "walk": [ + [ + [ + 0, + 441 + ], + [ + 1700, + 441 + ], + [ + 1700, + 411 + ], + [ + 1830, + 411 + ], + [ + 1830, + 441 + ], + [ + 2160, + 441 + ] + ] + ], + "structure": [ + [ + 20, + 200, + 220, + 441 + ], + [ + 700, + 370, + 860, + 441 + ] + ] + }, + "keyed": false, + "colours": 256 + }, + { + "id": "hg-1-0", + "title": "Hidden Garden · the hill", + "inputs": [ + "repo:art/locations/images/hidden-garden-clean.png", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "A gravel path at y=441 at both edges rolls over a smooth grassy hill: it starts rising at x=320, reaches the round crest at x=1080, y=331, and comes back down to y=441 at x=1840 - gentle slopes, no steps. An old oak with a wide autumn crown stands on the crest (trunk at x=1040-1120). Under the hill's left flank, behind a curtain of tall golden grass, a small dark hollow opens below the path: its floor at y=521 from x=440 to x=700, its roof the hill itself; the grass curtain hangs from the path's edge. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below.", + "factor": 1, + "review": "To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined.", + "route": { + "walk": [ + [ + [ + 0, + 441 + ], + [ + 320, + 441 + ], + [ + 700, + 370 + ], + [ + 1080, + 331 + ], + [ + 1460, + 370 + ], + [ + 1840, + 441 + ], + [ + 2160, + 441 + ] + ], + [ + [ + 440, + 521 + ], + [ + 700, + 521 + ] + ] + ], + "hollow": [ + [ + 440, + 450, + 700, + 521 + ] + ] + }, + "keyed": false, + "colours": 256 + }, + { + "id": "hg-2-0", + "title": "Hidden Garden · the terraces", + "inputs": [ + "repo:art/locations/images/hidden-garden-clean.png", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "Old stone garden terraces. A gravel path at y=441 from x=0 to x=480. Three stone terrace steps rise to the right, each 40 px high: a step face at x=480 up to y=401, at x=620 up to y=361, at x=760 up to y=321. A flat stone terrace at y=321 runs from x=760 to x=1320, with a small weathered wooden garden shed standing on it from x=930 to x=1140 (walls up from y=321, its flat roof at y=170) and a wooden ladder leaning on the shed's right wall at x=1150-1180 from the terrace up to the roof. On the roof a small glint. Steps go down again on the right, 40 px each: at x=1320 to y=361, at x=1460 to y=401, at x=1600 to y=441, then the gravel path at y=441 to the right edge. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below.", + "factor": 1, + "review": "To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined.", + "route": { + "walk": [ + [ + [ + 0, + 441 + ], + [ + 480, + 441 + ], + [ + 480, + 401 + ], + [ + 620, + 401 + ], + [ + 620, + 361 + ], + [ + 760, + 361 + ], + [ + 760, + 321 + ], + [ + 1320, + 321 + ], + [ + 1320, + 361 + ], + [ + 1460, + 361 + ], + [ + 1460, + 401 + ], + [ + 1600, + 401 + ], + [ + 1600, + 441 + ], + [ + 2160, + 441 + ] + ] + ], + "structure": [ + [ + 930, + 170, + 1140, + 321 + ] + ], + "ladder": [ + [ + 1150, + 170, + 1180, + 321 + ] + ] + }, + "keyed": false, + "colours": 256 + }, + { + "id": "hg-3-0", + "title": "Hidden Garden · the boardwalk", + "inputs": [ + "repo:art/locations/images/hidden-garden-clean.png", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "A timber boardwalk on low wooden posts runs through tall golden reeds. Its walking surface is at y=441 from x=0 to x=880. At x=880-1000 the boardwalk is broken off: a 120 px gap over dark ditch water (the water surface at y=500). From x=1000 the boardwalk continues at y=441 to x=1500, then a low stone path at y=441 continues to the right edge, where it meets the stone path and the low wall of the existing strip. Three flat stepping stones stand in the ditch below the gap, tops at y=480, at x=900, x=940 and x=975, as a lower way across. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below.", + "factor": 1, + "review": "To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined.", + "route": { + "below": [ + 478, + "water" + ], + "walk": [ + [ + [ + 0, + 441 + ], + [ + 880, + 441 + ] + ], + [ + [ + 1000, + 441 + ], + [ + 2160, + 441 + ] + ], + [ + [ + 890, + 470 + ], + [ + 925, + 470 + ] + ], + [ + [ + 935, + 470 + ], + [ + 965, + 470 + ] + ], + [ + [ + 972, + 470 + ], + [ + 995, + 470 + ] + ] + ] + }, + "keyed": false, + "colours": 256 + }, + { + "id": "hg-4-0", "title": "Hidden Garden · the bench and the pond", "factor": 1, "keyed": false, @@ -24,4 +308,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/art/stages/hidden-garden/chunks.json b/art/stages/hidden-garden/chunks.json index 3439780..6d0d03a 100644 --- a/art/stages/hidden-garden/chunks.json +++ b/art/stages/hidden-garden/chunks.json @@ -1,6 +1,14 @@ { "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"} + "chunks": { + "hg-0-0": "hg-0-0", + "hg-1-0": "hg-1-0", + "hg-2-0": "hg-2-0", + "hg-3-0": "hg-3-0", + "hg-4-0": "hg-4-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 index ca72c44..a437daf 100644 Binary files a/art/stages/hidden-garden/collision/hg-0-0.clean.png and b/art/stages/hidden-garden/collision/hg-0-0.clean.png differ diff --git a/art/stages/hidden-garden/collision/hg-1-0.clean.png b/art/stages/hidden-garden/collision/hg-1-0.clean.png new file mode 100644 index 0000000..c8b6c82 Binary files /dev/null and b/art/stages/hidden-garden/collision/hg-1-0.clean.png differ diff --git a/art/stages/hidden-garden/collision/hg-2-0.clean.png b/art/stages/hidden-garden/collision/hg-2-0.clean.png new file mode 100644 index 0000000..8461308 Binary files /dev/null and b/art/stages/hidden-garden/collision/hg-2-0.clean.png differ diff --git a/art/stages/hidden-garden/collision/hg-3-0.clean.png b/art/stages/hidden-garden/collision/hg-3-0.clean.png new file mode 100644 index 0000000..28a21f1 Binary files /dev/null and b/art/stages/hidden-garden/collision/hg-3-0.clean.png differ diff --git a/art/stages/hidden-garden/collision/hg-4-0.clean.png b/art/stages/hidden-garden/collision/hg-4-0.clean.png new file mode 100644 index 0000000..9a0405e Binary files /dev/null and b/art/stages/hidden-garden/collision/hg-4-0.clean.png differ diff --git a/art/stages/hidden-garden/export.json b/art/stages/hidden-garden/export.json index 759589e..6aaf1bb 100644 --- a/art/stages/hidden-garden/export.json +++ b/art/stages/hidden-garden/export.json @@ -1,12 +1,85 @@ { - "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}}, + "note": "Layers from split_layers.py (layers.json) and depth_layers.py (#142). Sky, far and mid scroll slower than the path, so each is ONE region-wide plate quilted from the chunks' filled plates (depth_layers.py region): per chunk they would overlap by 2160 x (1 - scroll) and cut each other off. They come in chunk-wide tiles, tile j carried by chunk j (any loader that places a layer at its chunk's origin then shows the right part); --verify rebuilds every chunk at rest from its own plates ('rest', repo-relative; the filled ones are intermediates in tmp/). The mid layer carries the earlier chunk's trees into columns where the next has none ('carry'); the far line may end on open sky.", + "chunks": { + "hg-0-0": { + "x": 0, + "y": 0 + }, + "hg-1-0": { + "x": 1, + "y": 0 + }, + "hg-2-0": { + "x": 2, + "y": 0 + }, + "hg-3-0": { + "x": 3, + "y": 0 + }, + "hg-4-0": { + "x": 4, + "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} + "00-sky": { + "file": "layers/hidden-garden.00-sky.{tile}.png", + "z": 0, + "scroll": 0.1, + "region": true, + "rest": "tmp/boards/hidden-garden/filled/{name}.00-sky.filled.png" + }, + "01-far": { + "file": "layers/hidden-garden.01-far.{tile}.png", + "z": 1, + "scroll": 0.25, + "region": true, + "rest": "tmp/boards/hidden-garden/filled/{name}.01-far.filled.png" + }, + "02-mid": { + "file": "layers/hidden-garden.02-mid.{tile}.png", + "z": 2, + "scroll": 0.6, + "region": true, + "rest": "tmp/boards/hidden-garden/filled/{name}.02-mid.filled.png", + "carry": true + }, + "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/hidden-garden.07-foreground.{tile}.png", + "z": 7, + "scroll": 1.2, + "region": true, + "rest": "art/stages/hidden-garden/layers/{name}.07-foreground.png" + } + }, + "states": { + "clean": [ + "00-sky", + "01-far", + "02-mid", + "03-rear", + "04-water", + "05-play", + "07-foreground" + ] }, - "states": {"clean": ["03-rear", "04-water", "05-play", "07-foreground"]}, - "composed": {"clean": "images/{name}.png"} + "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 index 1d8c525..022aba8 100644 --- a/art/stages/hidden-garden/hg-0-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-0-0.clean.collision.json @@ -9,11 +9,43 @@ [ [ 0, - 441 + 466 ], [ - 1323, - 441 + 240, + 452 + ], + [ + 440, + 470 + ], + [ + 652, + 470 + ], + [ + 700, + 431 + ], + [ + 960, + 431 + ], + [ + 1008, + 470 + ], + [ + 1550, + 474 + ], + [ + 1736, + 386 + ], + [ + 2160, + 384 ] ] ], @@ -26,83 +58,50 @@ "y": 0, "w": 8, "h": 720 - }, - { - "x": 1326, - "y": 300, - "w": 24, - "h": 141 } ], "noGo": [ { "x": 0, - "y": 478, + "y": 482, "w": 2160, - "h": 242 + "h": 238 } ], "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, + "x": 760, + "y": 355, "w": 80, "h": 76, - "note": "the bench: Dexter waits here; walking up makes him hop off" + "note": "the bench: walking up makes Dexter hop off and waddle ahead" }, { "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, + "x": 1500, + "y": 398, "w": 80, "h": 76, - "note": "the jetty: Dexter stops and points at the water" + "note": "at the fallen birch: Dexter hops onto it and looks back" } ], "spawn": [ { "id": "player", "kind": "player", - "x": 90, - "y": 441 + "x": 120, + "y": 460 }, { "id": "dexter-bench", "kind": "dexter", - "x": 255, - "y": 421, - "note": "Dexter sits on the bench at the start" + "x": 800, + "y": 405, + "note": "Dexter sits on the bench at the start (#50)" } ], "ledge": [], diff --git a/art/stages/hidden-garden/hg-0-0.md b/art/stages/hidden-garden/hg-0-0.md index 3a27299..3825ee6 100644 --- a/art/stages/hidden-garden/hg-0-0.md +++ b/art/stages/hidden-garden/hg-0-0.md @@ -1,16 +1,16 @@ -# Hidden Garden · the bench and the pond +# Hidden Garden · the gate -![Hidden Garden · the bench and the pond](images/hg-0-0-preview.png) +![Hidden Garden · the gate](images/hg-0-0-preview.png) -**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. +**Review:** To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined. ## 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. +The way into the garden: at the left edge an old wrought-iron garden gate stands open in a tall trimmed hedge (x=20-220, up to y=200). A gravel path at y=441 runs across the whole chunk. A wooden park bench with iron legs faces the viewer on the path at x=700-860 (seat at y=405, back up to y=370), under an old lantern post at x=900. Low flowering shrubs and the first reeds behind the path. Near the right edge a fallen birch log lies across the path at x=1700-1830, its top at y=411 (30 px high). Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below. ## 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` +Model `gpt-image-2.5-sunburst` · pixel factor 1 · source image `images/hg-0-0.png` · rebuild: `python scripts/make_boards.py draw hg-0-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/hg-1-0.clean.collision.json b/art/stages/hidden-garden/hg-1-0.clean.collision.json new file mode 100644 index 0000000..0016d5f --- /dev/null +++ b/art/stages/hidden-garden/hg-1-0.clean.collision.json @@ -0,0 +1,204 @@ +{ + "chunk": "hg-1-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 384 + ], + [ + 300, + 336 + ], + [ + 407, + 312 + ], + [ + 470, + 301 + ] + ], + [ + [ + 640, + 268 + ], + [ + 735, + 248 + ], + [ + 829, + 233 + ], + [ + 946, + 222 + ], + [ + 980, + 213 + ], + [ + 1040, + 203 + ], + [ + 1059, + 218 + ], + [ + 1075, + 219 + ], + [ + 1090, + 208 + ], + [ + 1115, + 219 + ], + [ + 1137, + 209 + ], + [ + 1194, + 223 + ], + [ + 1234, + 223 + ], + [ + 1255, + 231 + ], + [ + 1327, + 247 + ], + [ + 1362, + 251 + ], + [ + 1481, + 277 + ], + [ + 1701, + 339 + ], + [ + 1807, + 364 + ], + [ + 1918, + 379 + ], + [ + 1942, + 385 + ], + [ + 2081, + 386 + ], + [ + 2104, + 389 + ], + [ + 2160, + 387 + ] + ], + [ + [ + 480, + 449 + ], + [ + 628, + 449 + ] + ] + ], + "slide": [], + "oneWay": [ + [ + [ + 470, + 301 + ], + [ + 556, + 286 + ], + [ + 640, + 268 + ] + ] + ], + "climb": [ + { + "id": "hollow-grass", + "kind": "vines", + "x": 472, + "y": 297, + "w": 24, + "h": 152, + "note": "the grass curtain: climb back out of the hollow" + } + ], + "solid": [], + "noGo": [ + { + "x": 0, + "y": 123, + "w": 2160, + "h": 597 + } + ], + "hazard": [], + "interact": [ + { + "id": "dexter-waypoint-3", + "kind": "dexter-waypoint", + "x": 1040, + "y": 139, + "w": 80, + "h": 76, + "note": "the oak on the crest: Dexter waits and looks back" + }, + { + "id": "hollow", + "kind": "secret", + "x": 480, + "y": 373, + "w": 148, + "h": 76, + "note": "behind the grass: a muffin (#50 'secrets behind grass')" + } + ], + "spawn": [ + { + "id": "muffin-hollow", + "kind": "item", + "x": 560, + "y": 449 + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/hidden-garden/hg-1-0.md b/art/stages/hidden-garden/hg-1-0.md new file mode 100644 index 0000000..ced9668 --- /dev/null +++ b/art/stages/hidden-garden/hg-1-0.md @@ -0,0 +1,16 @@ +# Hidden Garden · the hill + +![Hidden Garden · the hill](images/hg-1-0-preview.png) + +**Review:** To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined. + +## Brief + +A gravel path at y=441 at both edges rolls over a smooth grassy hill: it starts rising at x=320, reaches the round crest at x=1080, y=331, and comes back down to y=441 at x=1840 - gentle slopes, no steps. An old oak with a wide autumn crown stands on the crest (trunk at x=1040-1120). Under the hill's left flank, behind a curtain of tall golden grass, a small dark hollow opens below the path: its floor at y=521 from x=440 to x=700, its roof the hill itself; the grass curtain hangs from the path's edge. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below. + +## 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-1-0.png` · rebuild: `python scripts/make_boards.py draw hg-1-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/hg-2-0.clean.collision.json b/art/stages/hidden-garden/hg-2-0.clean.collision.json new file mode 100644 index 0000000..b2a8f04 --- /dev/null +++ b/art/stages/hidden-garden/hg-2-0.clean.collision.json @@ -0,0 +1,122 @@ +{ + "chunk": "hg-2-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 387 + ], + [ + 530, + 387 + ], + [ + 562, + 363 + ], + [ + 638, + 363 + ], + [ + 678, + 333 + ], + [ + 745, + 333 + ], + [ + 781, + 304 + ], + [ + 1440, + 304 + ], + [ + 1476, + 333 + ], + [ + 1545, + 333 + ], + [ + 1581, + 363 + ], + [ + 1640, + 363 + ], + [ + 1672, + 387 + ], + [ + 2160, + 387 + ] + ], + [ + [ + 920, + 146 + ], + [ + 1262, + 146 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [ + { + "id": "shed-ladder", + "kind": "ladder", + "x": 1240, + "y": 133, + "w": 47, + "h": 171 + } + ], + "solid": [], + "noGo": [ + { + "x": 0, + "y": 482, + "w": 2160, + "h": 238 + } + ], + "hazard": [], + "interact": [ + { + "id": "dexter-waypoint-4", + "kind": "dexter-waypoint", + "x": 1500, + "y": 228, + "w": 80, + "h": 76, + "note": "the top of the terrace: Dexter points up at the roof, then hops on" + } + ], + "spawn": [ + { + "id": "glint-roof", + "kind": "item", + "x": 1207, + "y": 146, + "note": "the glint on the shed roof" + } + ], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/hidden-garden/hg-2-0.md b/art/stages/hidden-garden/hg-2-0.md new file mode 100644 index 0000000..33086ea --- /dev/null +++ b/art/stages/hidden-garden/hg-2-0.md @@ -0,0 +1,16 @@ +# Hidden Garden · the terraces + +![Hidden Garden · the terraces](images/hg-2-0-preview.png) + +**Review:** To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined. + +## Brief + +Old stone garden terraces. A gravel path at y=441 from x=0 to x=480. Three stone terrace steps rise to the right, each 40 px high: a step face at x=480 up to y=401, at x=620 up to y=361, at x=760 up to y=321. A flat stone terrace at y=321 runs from x=760 to x=1320, with a small weathered wooden garden shed standing on it from x=930 to x=1140 (walls up from y=321, its flat roof at y=170) and a wooden ladder leaning on the shed's right wall at x=1150-1180 from the terrace up to the roof. On the roof a small glint. Steps go down again on the right, 40 px each: at x=1320 to y=361, at x=1460 to y=401, at x=1600 to y=441, then the gravel path at y=441 to the right edge. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below. + +## 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-2-0.png` · rebuild: `python scripts/make_boards.py draw hg-2-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/hg-3-0.clean.collision.json b/art/stages/hidden-garden/hg-3-0.clean.collision.json new file mode 100644 index 0000000..a33f154 --- /dev/null +++ b/art/stages/hidden-garden/hg-3-0.clean.collision.json @@ -0,0 +1,137 @@ +{ + "chunk": "hg-3-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 390 + ], + [ + 294, + 390 + ] + ], + [ + [ + 362, + 390 + ], + [ + 577, + 393 + ], + [ + 627, + 390 + ], + [ + 832, + 389 + ] + ], + [ + [ + 911, + 462 + ], + [ + 922, + 459 + ], + [ + 961, + 458 + ], + [ + 978, + 461 + ] + ], + [ + [ + 1039, + 468 + ], + [ + 1044, + 464 + ], + [ + 1075, + 461 + ] + ], + [ + [ + 1079, + 393 + ], + [ + 1224, + 392 + ], + [ + 1284, + 440 + ], + [ + 1332, + 437 + ], + [ + 1385, + 393 + ], + [ + 1818, + 391 + ], + [ + 1989, + 392 + ] + ], + [ + [ + 2027, + 392 + ], + [ + 2160, + 391 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [], + "solid": [], + "noGo": [ + { + "x": 0, + "y": 482, + "w": 2160, + "h": 238 + } + ], + "hazard": [], + "interact": [ + { + "id": "dexter-waypoint-5", + "kind": "dexter-waypoint", + "x": 1100, + "y": 320, + "w": 80, + "h": 76, + "note": "past the gap: Dexter waits on the far side, hopping on the spot" + } + ], + "spawn": [], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/hidden-garden/hg-3-0.md b/art/stages/hidden-garden/hg-3-0.md new file mode 100644 index 0000000..85cb5a4 --- /dev/null +++ b/art/stages/hidden-garden/hg-3-0.md @@ -0,0 +1,16 @@ +# Hidden Garden · the boardwalk + +![Hidden Garden · the boardwalk](images/hg-3-0-preview.png) + +**Review:** To review (24 Oct, #50): grown leftwards from the pond with grow_stage.py; route feature repainted; seams joined. + +## Brief + +A timber boardwalk on low wooden posts runs through tall golden reeds. Its walking surface is at y=441 from x=0 to x=880. At x=880-1000 the boardwalk is broken off: a 120 px gap over dark ditch water (the water surface at y=500). From x=1000 the boardwalk continues at y=441 to x=1500, then a low stone path at y=441 continues to the right edge, where it meets the stone path and the low wall of the existing strip. Three flat stepping stones stand in the ditch below the gap, tops at y=480, at x=900, x=940 and x=975, as a lower way across. Late-afternoon autumn light from the left, golden reeds, orange and yellow trees, a hazy blue conifer line far behind, warm stone and weathered timber, as in the pond chunk next to it. The walkable surface is clearly readable: a lit top edge, darker below. + +## 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-3-0.png` · rebuild: `python scripts/make_boards.py draw hg-3-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/hg-4-0.clean.collision.json b/art/stages/hidden-garden/hg-4-0.clean.collision.json new file mode 100644 index 0000000..27383f8 --- /dev/null +++ b/art/stages/hidden-garden/hg-4-0.clean.collision.json @@ -0,0 +1,100 @@ +{ + "chunk": "hg-4-0", + "state": "clean", + "size": [ + 2160, + 720 + ], + "walk": [ + [ + [ + 0, + 392 + ], + [ + 160, + 392 + ] + ], + [ + [ + 160, + 441 + ], + [ + 1323, + 441 + ] + ] + ], + "slide": [], + "oneWay": [], + "climb": [], + "solid": [ + { + "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-pond-1", + "kind": "dexter-waypoint", + "x": 215, + "y": 365, + "w": 80, + "h": 76, + "note": "the old bench by the pond: Dexter waits here and looks back" + }, + { + "id": "dexter-waypoint-pond-2", + "kind": "dexter-waypoint", + "x": 680, + "y": 365, + "w": 80, + "h": 76, + "note": "mid-path: Dexter hops ahead and beckons" + }, + { + "id": "dexter-waypoint-pond-3", + "kind": "dexter-waypoint", + "x": 1150, + "y": 365, + "w": 80, + "h": 76, + "note": "the jetty: Dexter stops and points at the water" + } + ], + "spawn": [], + "ledge": [], + "glideLift": [] +} diff --git a/art/stages/hidden-garden/hg-4-0.md b/art/stages/hidden-garden/hg-4-0.md new file mode 100644 index 0000000..17a97b6 --- /dev/null +++ b/art/stages/hidden-garden/hg-4-0.md @@ -0,0 +1,16 @@ +# Hidden Garden · the bench and the pond + +![Hidden Garden · the bench and the pond](images/hg-4-0-preview.png) + +**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-4-0.png` · rebuild: `python scripts/make_boards.py draw hg-4-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/images/hg-0-0-preview.png b/art/stages/hidden-garden/images/hg-0-0-preview.png index 3ba2c01..3753068 100644 Binary files a/art/stages/hidden-garden/images/hg-0-0-preview.png 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 index 585a48c..fa68127 100644 Binary files a/art/stages/hidden-garden/images/hg-0-0.png and b/art/stages/hidden-garden/images/hg-0-0.png differ diff --git a/art/stages/hidden-garden/images/hg-1-0-preview.png b/art/stages/hidden-garden/images/hg-1-0-preview.png new file mode 100644 index 0000000..d97dec8 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-1-0-preview.png differ diff --git a/art/stages/hidden-garden/images/hg-1-0.png b/art/stages/hidden-garden/images/hg-1-0.png new file mode 100644 index 0000000..a42e5a6 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-1-0.png differ diff --git a/art/stages/hidden-garden/images/hg-2-0-preview.png b/art/stages/hidden-garden/images/hg-2-0-preview.png new file mode 100644 index 0000000..45fcfd4 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-2-0-preview.png differ diff --git a/art/stages/hidden-garden/images/hg-2-0.png b/art/stages/hidden-garden/images/hg-2-0.png new file mode 100644 index 0000000..f70a260 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-2-0.png differ diff --git a/art/stages/hidden-garden/images/hg-3-0-preview.png b/art/stages/hidden-garden/images/hg-3-0-preview.png new file mode 100644 index 0000000..f95c75d Binary files /dev/null and b/art/stages/hidden-garden/images/hg-3-0-preview.png differ diff --git a/art/stages/hidden-garden/images/hg-3-0.png b/art/stages/hidden-garden/images/hg-3-0.png new file mode 100644 index 0000000..cb798ec Binary files /dev/null and b/art/stages/hidden-garden/images/hg-3-0.png differ diff --git a/art/stages/hidden-garden/images/hg-4-0-preview.png b/art/stages/hidden-garden/images/hg-4-0-preview.png new file mode 100644 index 0000000..3ba2c01 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-4-0-preview.png differ diff --git a/art/stages/hidden-garden/images/hg-4-0.png b/art/stages/hidden-garden/images/hg-4-0.png new file mode 100644 index 0000000..585a48c Binary files /dev/null and b/art/stages/hidden-garden/images/hg-4-0.png differ diff --git a/art/stages/hidden-garden/images/parallax.png b/art/stages/hidden-garden/images/parallax.png new file mode 100644 index 0000000..d8a7f57 Binary files /dev/null and b/art/stages/hidden-garden/images/parallax.png differ diff --git a/art/stages/hidden-garden/images/prologue.png b/art/stages/hidden-garden/images/prologue.png new file mode 100644 index 0000000..3b3c2e9 Binary files /dev/null and b/art/stages/hidden-garden/images/prologue.png differ diff --git a/art/stages/hidden-garden/layers.json b/art/stages/hidden-garden/layers.json index 72eec95..af48773 100644 --- a/art/stages/hidden-garden/layers.json +++ b/art/stages/hidden-garden/layers.json @@ -1,5 +1,5 @@ { - "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.", + "note": "Rules run in order; the last rule that claims a pixel wins. Sky, far and mid come from masks of the model's depth key (scripts/depth_layers.py, #142); the exact rectangles and colours for water, path and foreground follow and win. The grown chunks hg-0-0 to hg-3-0 take every band from their key's masks (masks --all); the pond keeps its exact rules for path, water and foreground.", "chunks": { "hg-0-0": { "states": { @@ -7,6 +7,146 @@ }, "default": "03-rear", "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-0-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-0-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-0-0.02-mid.png" + }, + { + "layer": "04-water", + "alpha": "masks/hg-0-0.04-water.png" + }, + { + "layer": "05-play", + "alpha": "masks/hg-0-0.05-play.png" + }, + { + "layer": "07-foreground", + "alpha": "masks/hg-0-0.07-foreground.png" + } + ] + }, + "hg-1-0": { + "states": { + "clean": "images/hg-1-0.png" + }, + "default": "03-rear", + "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-1-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-1-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-1-0.02-mid.png" + }, + { + "layer": "04-water", + "alpha": "masks/hg-1-0.04-water.png" + }, + { + "layer": "05-play", + "alpha": "masks/hg-1-0.05-play.png" + }, + { + "layer": "07-foreground", + "alpha": "masks/hg-1-0.07-foreground.png" + } + ] + }, + "hg-2-0": { + "states": { + "clean": "images/hg-2-0.png" + }, + "default": "03-rear", + "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-2-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-2-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-2-0.02-mid.png" + }, + { + "layer": "04-water", + "alpha": "masks/hg-2-0.04-water.png" + }, + { + "layer": "05-play", + "alpha": "masks/hg-2-0.05-play.png" + }, + { + "layer": "07-foreground", + "alpha": "masks/hg-2-0.07-foreground.png" + } + ] + }, + "hg-3-0": { + "states": { + "clean": "images/hg-3-0.png" + }, + "default": "03-rear", + "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-3-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-3-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-3-0.02-mid.png" + }, + { + "layer": "04-water", + "alpha": "masks/hg-3-0.04-water.png" + }, + { + "layer": "05-play", + "alpha": "masks/hg-3-0.05-play.png" + }, + { + "layer": "07-foreground", + "alpha": "masks/hg-3-0.07-foreground.png" + } + ] + }, + "hg-4-0": { + "states": { + "clean": "images/hg-4-0.png" + }, + "default": "03-rear", + "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-4-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-4-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-4-0.02-mid.png" + }, { "layer": "04-water", "rect": [ diff --git a/art/stages/hidden-garden/layers/hg-0-0.00-sky.png b/art/stages/hidden-garden/layers/hg-0-0.00-sky.png new file mode 100644 index 0000000..eecdce2 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.01-far.png b/art/stages/hidden-garden/layers/hg-0-0.01-far.png new file mode 100644 index 0000000..ca80fa2 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.02-mid.png b/art/stages/hidden-garden/layers/hg-0-0.02-mid.png new file mode 100644 index 0000000..20511dc Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.02-mid.png differ 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 index ec31612..5f37a0f 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.03-rear.png 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 index d7b6095..903b4c4 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.04-water.png 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 index 13bab75..0687f55 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.05-play.png 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 index a5efb90..9ba5baa 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.07-foreground.png and b/art/stages/hidden-garden/layers/hg-0-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.00-sky.png b/art/stages/hidden-garden/layers/hg-1-0.00-sky.png new file mode 100644 index 0000000..74ed610 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.01-far.png b/art/stages/hidden-garden/layers/hg-1-0.01-far.png new file mode 100644 index 0000000..58eb63b Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.02-mid.png b/art/stages/hidden-garden/layers/hg-1-0.02-mid.png new file mode 100644 index 0000000..1771634 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.02-mid.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.03-rear.png b/art/stages/hidden-garden/layers/hg-1-0.03-rear.png new file mode 100644 index 0000000..b8c1b45 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.03-rear.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.04-water.png b/art/stages/hidden-garden/layers/hg-1-0.04-water.png new file mode 100644 index 0000000..56a14ed Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.04-water.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.05-play.png b/art/stages/hidden-garden/layers/hg-1-0.05-play.png new file mode 100644 index 0000000..2be6019 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.05-play.png differ diff --git a/art/stages/hidden-garden/layers/hg-1-0.07-foreground.png b/art/stages/hidden-garden/layers/hg-1-0.07-foreground.png new file mode 100644 index 0000000..1ba9ba3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.00-sky.png b/art/stages/hidden-garden/layers/hg-2-0.00-sky.png new file mode 100644 index 0000000..70a0376 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.01-far.png b/art/stages/hidden-garden/layers/hg-2-0.01-far.png new file mode 100644 index 0000000..bffc2d2 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.02-mid.png b/art/stages/hidden-garden/layers/hg-2-0.02-mid.png new file mode 100644 index 0000000..6fc29e5 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.02-mid.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.03-rear.png b/art/stages/hidden-garden/layers/hg-2-0.03-rear.png new file mode 100644 index 0000000..28168a5 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.03-rear.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.04-water.png b/art/stages/hidden-garden/layers/hg-2-0.04-water.png new file mode 100644 index 0000000..f3b8e87 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.04-water.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.05-play.png b/art/stages/hidden-garden/layers/hg-2-0.05-play.png new file mode 100644 index 0000000..6ae80f3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.05-play.png differ diff --git a/art/stages/hidden-garden/layers/hg-2-0.07-foreground.png b/art/stages/hidden-garden/layers/hg-2-0.07-foreground.png new file mode 100644 index 0000000..c130f1b Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.00-sky.png b/art/stages/hidden-garden/layers/hg-3-0.00-sky.png new file mode 100644 index 0000000..8930896 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.01-far.png b/art/stages/hidden-garden/layers/hg-3-0.01-far.png new file mode 100644 index 0000000..cc277f4 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.02-mid.png b/art/stages/hidden-garden/layers/hg-3-0.02-mid.png new file mode 100644 index 0000000..8a945dd Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.02-mid.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.03-rear.png b/art/stages/hidden-garden/layers/hg-3-0.03-rear.png new file mode 100644 index 0000000..aed5ade Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.03-rear.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.04-water.png b/art/stages/hidden-garden/layers/hg-3-0.04-water.png new file mode 100644 index 0000000..05cf1a7 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.04-water.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.05-play.png b/art/stages/hidden-garden/layers/hg-3-0.05-play.png new file mode 100644 index 0000000..69f4210 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.05-play.png differ diff --git a/art/stages/hidden-garden/layers/hg-3-0.07-foreground.png b/art/stages/hidden-garden/layers/hg-3-0.07-foreground.png new file mode 100644 index 0000000..5b4d3fe Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.00-sky.png b/art/stages/hidden-garden/layers/hg-4-0.00-sky.png new file mode 100644 index 0000000..5e2b8e3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.01-far.png b/art/stages/hidden-garden/layers/hg-4-0.01-far.png new file mode 100644 index 0000000..36c4ff4 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.02-mid.png b/art/stages/hidden-garden/layers/hg-4-0.02-mid.png new file mode 100644 index 0000000..91aba6f Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.02-mid.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.03-rear.png b/art/stages/hidden-garden/layers/hg-4-0.03-rear.png new file mode 100644 index 0000000..e21f95b Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.03-rear.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.04-water.png b/art/stages/hidden-garden/layers/hg-4-0.04-water.png new file mode 100644 index 0000000..d7b6095 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.04-water.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.05-play.png b/art/stages/hidden-garden/layers/hg-4-0.05-play.png new file mode 100644 index 0000000..13bab75 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.05-play.png differ diff --git a/art/stages/hidden-garden/layers/hg-4-0.07-foreground.png b/art/stages/hidden-garden/layers/hg-4-0.07-foreground.png new file mode 100644 index 0000000..a5efb90 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.00-sky.0.png b/art/stages/hidden-garden/layers/hidden-garden.00-sky.0.png new file mode 100644 index 0000000..2a72df3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.00-sky.0.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.00-sky.1.png b/art/stages/hidden-garden/layers/hidden-garden.00-sky.1.png new file mode 100644 index 0000000..4becb77 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.00-sky.1.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.01-far.0.png b/art/stages/hidden-garden/layers/hidden-garden.01-far.0.png new file mode 100644 index 0000000..165dc69 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.01-far.0.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.01-far.1.png b/art/stages/hidden-garden/layers/hidden-garden.01-far.1.png new file mode 100644 index 0000000..431d463 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.01-far.1.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.02-mid.0.png b/art/stages/hidden-garden/layers/hidden-garden.02-mid.0.png new file mode 100644 index 0000000..eac322c Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.02-mid.0.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.02-mid.1.png b/art/stages/hidden-garden/layers/hidden-garden.02-mid.1.png new file mode 100644 index 0000000..1560dd3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.02-mid.1.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.02-mid.2.png b/art/stages/hidden-garden/layers/hidden-garden.02-mid.2.png new file mode 100644 index 0000000..fb50c1f Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.02-mid.2.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.02-mid.3.png b/art/stages/hidden-garden/layers/hidden-garden.02-mid.3.png new file mode 100644 index 0000000..8cbb998 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.02-mid.3.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.07-foreground.0.png b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.0.png new file mode 100644 index 0000000..31e6288 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.0.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.07-foreground.1.png b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.1.png new file mode 100644 index 0000000..63788db Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.1.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.07-foreground.2.png b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.2.png new file mode 100644 index 0000000..e414383 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.2.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.07-foreground.3.png b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.3.png new file mode 100644 index 0000000..d770390 Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.3.png differ diff --git a/art/stages/hidden-garden/layers/hidden-garden.07-foreground.4.png b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.4.png new file mode 100644 index 0000000..e71b9cc Binary files /dev/null and b/art/stages/hidden-garden/layers/hidden-garden.07-foreground.4.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.00-sky.png b/art/stages/hidden-garden/masks/hg-0-0.00-sky.png new file mode 100644 index 0000000..158a18e Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.01-far.png b/art/stages/hidden-garden/masks/hg-0-0.01-far.png new file mode 100644 index 0000000..9784831 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.02-mid.png b/art/stages/hidden-garden/masks/hg-0-0.02-mid.png new file mode 100644 index 0000000..e3ecbc7 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.02-mid.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.04-water.png b/art/stages/hidden-garden/masks/hg-0-0.04-water.png new file mode 100644 index 0000000..b174a59 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.04-water.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.05-play.png b/art/stages/hidden-garden/masks/hg-0-0.05-play.png new file mode 100644 index 0000000..bcb329a Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.05-play.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.07-foreground.png b/art/stages/hidden-garden/masks/hg-0-0.07-foreground.png new file mode 100644 index 0000000..714425c Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.00-sky.png b/art/stages/hidden-garden/masks/hg-1-0.00-sky.png new file mode 100644 index 0000000..e9279cb Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.01-far.png b/art/stages/hidden-garden/masks/hg-1-0.01-far.png new file mode 100644 index 0000000..f88eb7b Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.02-mid.png b/art/stages/hidden-garden/masks/hg-1-0.02-mid.png new file mode 100644 index 0000000..6920a20 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.02-mid.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.04-water.png b/art/stages/hidden-garden/masks/hg-1-0.04-water.png new file mode 100644 index 0000000..2e582d2 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.04-water.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.05-play.png b/art/stages/hidden-garden/masks/hg-1-0.05-play.png new file mode 100644 index 0000000..7121a2f Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.05-play.png differ diff --git a/art/stages/hidden-garden/masks/hg-1-0.07-foreground.png b/art/stages/hidden-garden/masks/hg-1-0.07-foreground.png new file mode 100644 index 0000000..0460734 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.00-sky.png b/art/stages/hidden-garden/masks/hg-2-0.00-sky.png new file mode 100644 index 0000000..a069303 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.01-far.png b/art/stages/hidden-garden/masks/hg-2-0.01-far.png new file mode 100644 index 0000000..6596e69 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.02-mid.png b/art/stages/hidden-garden/masks/hg-2-0.02-mid.png new file mode 100644 index 0000000..03313c0 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.02-mid.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.04-water.png b/art/stages/hidden-garden/masks/hg-2-0.04-water.png new file mode 100644 index 0000000..be8e23d Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.04-water.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.05-play.png b/art/stages/hidden-garden/masks/hg-2-0.05-play.png new file mode 100644 index 0000000..c3cdcf6 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.05-play.png differ diff --git a/art/stages/hidden-garden/masks/hg-2-0.07-foreground.png b/art/stages/hidden-garden/masks/hg-2-0.07-foreground.png new file mode 100644 index 0000000..a303d56 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.00-sky.png b/art/stages/hidden-garden/masks/hg-3-0.00-sky.png new file mode 100644 index 0000000..8e2f95a Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.01-far.png b/art/stages/hidden-garden/masks/hg-3-0.01-far.png new file mode 100644 index 0000000..1b875c2 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.02-mid.png b/art/stages/hidden-garden/masks/hg-3-0.02-mid.png new file mode 100644 index 0000000..c576166 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.02-mid.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.04-water.png b/art/stages/hidden-garden/masks/hg-3-0.04-water.png new file mode 100644 index 0000000..64f5370 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.04-water.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.05-play.png b/art/stages/hidden-garden/masks/hg-3-0.05-play.png new file mode 100644 index 0000000..9a0d71a Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.05-play.png differ diff --git a/art/stages/hidden-garden/masks/hg-3-0.07-foreground.png b/art/stages/hidden-garden/masks/hg-3-0.07-foreground.png new file mode 100644 index 0000000..8dc8689 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.07-foreground.png differ diff --git a/art/stages/hidden-garden/masks/hg-4-0.00-sky.png b/art/stages/hidden-garden/masks/hg-4-0.00-sky.png new file mode 100644 index 0000000..578adce Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-4-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-4-0.01-far.png b/art/stages/hidden-garden/masks/hg-4-0.01-far.png new file mode 100644 index 0000000..6675a17 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-4-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-4-0.02-mid.png b/art/stages/hidden-garden/masks/hg-4-0.02-mid.png new file mode 100644 index 0000000..7a0e234 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-4-0.02-mid.png differ diff --git a/art/stages/rusty-creek/README.md b/art/stages/rusty-creek/README.md index 8c624b1..792d569 100644 --- a/art/stages/rusty-creek/README.md +++ b/art/stages/rusty-creek/README.md @@ -28,7 +28,7 @@ The same place before the anomaly (clean campus architecture, nature layers iden | 06 | Near rails | [png](images/06-near-rails-left.png) | [png](images/06-near-rails-center.png) | = left | Foreground 1.1, never collides | | 07 | Near foliage | [png](images/07-foliage-left.png) | [png](images/07-foliage-center.png) | [png](images/07-foliage-right.png) | Foreground 1.15 | -"= left": shared scenery repeats on the right chunk and is stored once (alias in [media-sources.json](media-sources.json)). The clean architecture plates are [left](images/clean-architecture-left.png) and [centre](images/clean-architecture-center.png); right = left. +"= left": shared scenery repeats on the right chunk and is stored once (alias in [media-sources.json](media-sources.json)). In the game, layers 01, 06 and 07 come from one region-wide plate each ([layers/](layers/), `python scripts/depth_layers.py region art/stages/rusty-creek`, #150): per chunk they overlapped at 0.2 and left gaps at 1.1 and 1.15. The creek as the game draws it: [parallax.png](previews/parallax.png). The clean architecture plates are [left](images/clean-architecture-left.png) and [centre](images/clean-architecture-center.png); right = left. ## Route diff --git a/art/stages/rusty-creek/export.json b/art/stages/rusty-creek/export.json index 50f1313..4507970 100644 --- a/art/stages/rusty-creek/export.json +++ b/art/stages/rusty-creek/export.json @@ -1,24 +1,92 @@ { - "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.", + "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. 01-distance (0.2), 06-near-rails (1.1) and 07-foliage (1.15) are region-wide plates in chunk-wide tiles (depth_layers.py region, #150): per chunk they overlapped or left gaps.", "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"} + "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} + "01-distance": { + "file": "layers/rusty-creek.01-distance.{tile}.png", + "z": 1, + "scroll": 0.2, + "region": true, + "rest": "art/stages/rusty-creek/images/01-distance-{name}.png" + }, + "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": "layers/rusty-creek.06-near-rails.{tile}.png", + "z": 6, + "scroll": 1.1, + "region": true, + "rest": "art/stages/rusty-creek/images/06-near-rails-{name}.png" + }, + "07-foliage": { + "file": "layers/rusty-creek.07-foliage.{tile}.png", + "z": 7, + "scroll": 1.15, + "region": true, + "rest": "art/stages/rusty-creek/images/07-foliage-{name}.png" + }, + "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"] + "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" }, - "composed": {"anomaly": "images/after-{name}.png", "clean": "images/before-{name}.png"}, "aliases": "media-sources.json" } diff --git a/art/stages/rusty-creek/layers/rusty-creek.01-distance.0.png b/art/stages/rusty-creek/layers/rusty-creek.01-distance.0.png new file mode 100644 index 0000000..663a26b Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.01-distance.0.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.01-distance.1.png b/art/stages/rusty-creek/layers/rusty-creek.01-distance.1.png new file mode 100644 index 0000000..7871916 Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.01-distance.1.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.0.png b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.0.png new file mode 100644 index 0000000..702fea7 Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.0.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.1.png b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.1.png new file mode 100644 index 0000000..e268fff Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.1.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.2.png b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.2.png new file mode 100644 index 0000000..5cee465 Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.06-near-rails.2.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.07-foliage.0.png b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.0.png new file mode 100644 index 0000000..964b13f Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.0.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.07-foliage.1.png b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.1.png new file mode 100644 index 0000000..eff63b8 Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.1.png differ diff --git a/art/stages/rusty-creek/layers/rusty-creek.07-foliage.2.png b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.2.png new file mode 100644 index 0000000..28fd529 Binary files /dev/null and b/art/stages/rusty-creek/layers/rusty-creek.07-foliage.2.png differ diff --git a/art/stages/rusty-creek/previews/parallax.png b/art/stages/rusty-creek/previews/parallax.png new file mode 100644 index 0000000..abee86e Binary files /dev/null and b/art/stages/rusty-creek/previews/parallax.png differ diff --git a/docs/03-STORY-AND-CAST.md b/docs/03-STORY-AND-CAST.md index eecff51..3665cf7 100644 --- a/docs/03-STORY-AND-CAST.md +++ b/docs/03-STORY-AND-CAST.md @@ -61,6 +61,27 @@ Dexter is THD's black panther mascot ("Deggster") as a **1.5 ft plush**: black v 5. The boss clearing: **Jenny** again, now for real (see [enemies](05-ENEMIES-AND-BOSSES.md)). 6. Jenny stalls and leaks black fluid. Dexter appears and **absorbs it**, a little too eagerly. He is visibly taller, and he hops off ahead of you. The jam slice ends here: the screen glitches and a "to be continued" error dialog appears. +### The ending sequence ([#58](https://github.com/THD-AI/WinCS/issues/58)) + +One overlay scene, `Ending` (`game/src/story/`), runs it after lane B emits `boss-defeated` for Jenny. It reads Jenny's position from the event and draws over the frozen stage; nothing in it is a cinematic – Dexter's frames come from the approved `dexter` and `dexter-acts` atlases, the fluid from `fx`, the window from the THD.exe crossing. + +```mermaid +flowchart LR + E["boss-defeated
(Jenny, position)"] --> L["leak
fx black-fluid pool
under Jenny"] + L --> A["appear
Dexter hops in
beside the pool, 27 px"] + A --> B["absorb
dexter-acts absorb ×4,
pool shrinks, fluid-absorb sound"] + B --> G["grow
27 → 46 px,
one creepy frame"] + G --> H["leave
hops off ahead,
off screen"] + H --> X["glitch
the frame tears"] + X --> D["dialog
THD.exe – To be continued.
[OK]"] + D --> C["credits
(credits.ts)"] + C --> T["title"] +``` + +**Sizes, measured from [04 – Scale](04-WORLD-AND-LEVELS.md#scale--the-numbers-everything-follows):** Dexter is the 27 px plush when he hops in. He reaches 101 px (8.5 ft) as the final boss of region 5, after absorbing the bosses of regions 1–4, so each absorb adds a quarter of the 74 px: after Jenny he is **46 px (≈ 3.5 ft)**, about 0.6 of the hero's 76 px body – on the way to the approved 4 ft plush (56 px). The pool is drawn at the `fx` atlas size, centred under Jenny. + +Lane B's `boss-defeated` starts it; in dev builds **Shift + F8** runs it anywhere. + ## The rest of the arc (post-jam) Regions 2–5 each end with a boss that Dexter absorbs. Archive notes reveal that the experiment's logs mention a "mascot-shaped containment vessel". In the Playground region Dexter stops pretending. The final fight is Dexter at 8.5 ft. **Endings** (Duck Season style): defeat him (he collapses back into a torn plush), or refuse to feed him (skip the absorbs), which leads to a different, quieter ending. diff --git a/docs/04-WORLD-AND-LEVELS.md b/docs/04-WORLD-AND-LEVELS.md index d0cf367..e637b86 100644 --- a/docs/04-WORLD-AND-LEVELS.md +++ b/docs/04-WORLD-AND-LEVELS.md @@ -65,6 +65,7 @@ The anomaly state replaces only the layers that change (usually 3 and 5, sometim | Hero | 6 ft / 1.83 m | **80 px tall**, body box 28×76 | | 1 ft | 0.30 m | ≈ 13 px | | Dexter (plush) | 1.5 ft in the story; drawn ≈ 2 ft, head just above the hero's knee | **27 px** (0.35 of the hero) | +| Dexter (after Jenny, end of the slice) | ≈ 3.5 ft | 46 px (each region boss he absorbs adds a quarter of the way from 27 to 101 px) | | Dexter (bigger plush, mid-arc) | 4 ft | 56 px (0.73 of the hero) | | Dexter (final, the mascot) | 8.5 ft | 101 px (1.33 of the hero) | | Jenny (boss car) | ≈ 6.3 m drawn, as in the approved direction (Alexander, 24 Oct) | **≈ 270 px long**, body ≈ 95 px; rear wing and beacon above | @@ -111,6 +112,30 @@ The route already exists as a graph ([route.json](../art/stages/rusty-creek/rout Target play time for the slice: 8–12 minutes. +## Hidden Garden – the prologue level + +Docs/03 beat 2 asks for about three minutes of calm garden before the pond: hills, the deck, reeds, a secret behind grass, a log to jump, a slope to climb ([#50](https://github.com/THD-AI/WinCS/issues/50)). At 3.5 m/s (153 px/s) one 2160 px chunk is 14 s of walking, so the prologue is **five chunks** in a row: ≈ 70 s on foot, plus Dexter's waits and two detours. The pond chunk that exists becomes the last one, `hg-4-0`; the four before it grow leftwards from it, each from the edge of the one to its right, so the path meets at the same height (top y = 441). + +| Chunk | Beat | Route | Teaches | +| --- | --- | --- | --- | +| `hg-0-0` the gate | Hero enters at the garden gate; **Dexter sits on a bench**, hops off when you come close | flat path, a fallen log at the end (≈ 30 px, a tap jump of 52) | walk, the first jump | +| `hg-1-0` the hill | The path rolls over a grassy hill with an old oak on top | slopes ≤ 25°; below the hill a **hollow behind tall grass** with a muffin (lower branch, dead end) | slopes, secrets | +| `hg-2-0` the terraces | Old stone garden terraces and a wooden shed | steps ≤ 48 px; a **ladder up the shed** to its roof, a glint up there (higher branch, dead end) | ladders, looking up | +| `hg-3-0` the boardwalk | A timber boardwalk through the reeds | one gap (≈ 120 px, a running jump), stepping stones in a ditch | jumping gaps | +| `hg-4-0` the pond | The jetty, the wrong reflection, the shove (today's `hg-0-0`) | – | – | + +```mermaid +flowchart LR + P["hg-4-0 the pond
(approved chunk)"] -->|"grow left: its left 384 px
as context"| B["hg-3-0 boardwalk"] + B -->|grow left| T["hg-2-0 terraces"] + T -->|grow left| H["hg-1-0 hill"] + H -->|grow left| G["hg-0-0 gate"] + subgraph each["Each new chunk"] + direction TB + g1["grow_stage.py: canvas 2160 + 384,
the neighbour's edge band at the seam,
route brief with heights in px"] --> g2["review: seam, path height,
route readable"] --> g3["depth_layers.py key → masks → fill
(play band from the key too)"] --> g4["collision: walk lines traced
from the play band, then edited"] + end +``` + ## Lex's next maps For each next region: one hand-drawn route sketch on top of its collage. Mark the entry, branches, secrets, enemies and the boss room with arrows, using the colour legend above. Screenshots from reference games are welcome. These sketches are the input for generating that region's stage art. diff --git a/docs/07-ASSET-MANIFEST.md b/docs/07-ASSET-MANIFEST.md index bcfa102..ce471ac 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) | 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 | +| Hidden Garden (prologue) | 5 chunks `hg-0-0`…`hg-4-0` ([art/stages/hidden-garden](../art/stages/hidden-garden/README.md)): the gate and Dexter's bench, the hill and its hollow, the terraces and the shed, the boardwalk gap, the pond with the wrong reflection; layers 00–07 with parallax (#142, #50) | 🟡 in review · 🟡 collision traced, waypoints by hand | | Jenny's arena | right chunk of Rusty Creek (boss clearing) | ✅ art · ⬜ collision | ## Audio (synthesized in code) diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md index a5badbb..47fe32e 100644 --- a/docs/09-ART-PIPELINE.md +++ b/docs/09-ART-PIPELINE.md @@ -41,6 +41,52 @@ What Codex's runs proved on Rusty Creek, generalised: 6. **Transition chunks** join different spaces – surface to underground, outside to the Mensa inside. They are generated with *both* neighbours as context and blend the two layer stacks over their height. 7. **Clean and anomaly share everything they can.** The anomaly state replaces only the layers that change (on Rusty Creek, only the structure layers). Masters are rebuilt from the plates and verified pixel-exact (`creek_layers.py`). +## Growing a region chunk by chunk – grow, feature, join, trace + +`scripts/grow_stage.py` makes a region longer from an approved chunk, one 2160×720 chunk at a time, so a level is one continuous place instead of separate pictures ([#50](https://github.com/THD-AI/WinCS/issues/50), first used on the Hidden Garden prologue). + +```mermaid +flowchart TD + A["Approved chunk
(the pond, hg-4-0)"] --> G1["grow, piece 1
2160×720 canvas: 1440 px to paint
over the layout guide + 720 px of the neighbour"] + G1 --> G2["grow, piece 2
720 px more + 128 px over piece 1
→ min-error cut joins them"] + G2 --> M["match the seam's colours to the neighbour
(ramp over 160 px, shapes untouched)"] + M --> N{"next chunk?"} + N -->|yes| G1 + N -->|no| J["join: repaint 320 px across each
new–new seam, min-error cuts on both sides"] + J --> F["feature: one masked repaint per route feature
(the gap, the hill, the shed and ladder, the gate, the log)
+ 64 px margins, min-error cuts"] + F --> K["depth_layers.py key → masks → fill
(parallax, as below)"] + K --> C["depth_layers.py collision
walk lines traced from the key's play band
(RDP, split at steps > 8 px), water = no-go"] + C --> CC["collision_check.py: reach, joins, overlay
→ hand-placed spawns, Dexter waypoints, triggers"] +``` + +**What made it work** (24 Oct, the boardwalk chunk took five tries): + +| Tried | Happened | Rule now | +| --- | --- | --- | +| One canvas wider than 3:1 | The API refuses: at most 3:1 | Every call is 2160×720 | +| A wider canvas padded with sky to 3:1 | The model reframed it and moved the whole picture 60–90 px; the path came out 47 px too high | Never pad; grow in two pieces of 1440 + 720 px | +| The neighbour's band pasted back over a single painted canvas | A hard seam (colour step 45 against 8 inside the art) | Seam colours matched over a ramp; pieces overlap 128 px and join along a min-error cut | +| The route as numbers in the brief, or as a separate guide image | Ignored: a stone bridge instead of a gap | The guide is the image being edited, under the mask | +| The route's features in the same call as the scenery | Smoothed away: the path stays flat and continuous | Grow the scenery first, then one `feature` repaint per feature, with a short concrete brief | + +## Parallax layers from a depth key + +Painted chunks are one picture. To give them depth without hand masks or redrawing each layer (which drifts), the model repaints the finished chunk as a **depth key**: the same shapes, one flat colour per depth band. The masks come from that key, and a second pass paints what each slow layer has behind the faster ones ([#142](https://github.com/THD-AI/WinCS/issues/142), first used on the Hidden Garden). + +```mermaid +flowchart LR + A["Composed chunk
images/<chunk>.png"] --> K["depth_layers.py key
sunburst repaints it:
sky cyan · far blue · mid green
rear yellow · play red
water navy · foreground magenta"] + K --> M["depth_layers.py masks
snap to the 7 colours · vote specks away
sky edge decided by the real pixel
→ masks/<chunk>.00-sky / 01-far / 02-mid"] + M --> S["split_layers.py
masks first, then the exact rules
for path, water, foreground
every pixel in one layer"] + S --> F["depth_layers.py fill
mask = nearer layers below the skyline
the model paints only that layer
→ layers/<chunk>.0x.filled.png"] + F --> E["export.json · scroll 0.1 / 0.25 / 0.6 / 1.0 / 1.2"] + E --> V["export_stage.py --verify (exact at rest)
compare.py parallax (three camera positions)"] +``` + +- **The key only says which band, never which pixel.** It drifts a few pixels at fine edges, so near the sky the real pixel decides: open sky is ≈ (40–60, 120–140, 240) and hazy far treetops ≈ (80, 120, 180), so "sky" means blue above 200. Path, water and foreground keep their exact rectangles and colour rules, which run after the masks and win. +- **Fills never change the picture at rest.** A fill is kept only under the nearer layers, and the layer's own pixels are copied from the art, so `--verify` still rebuilds the chunk exactly. The camera only ever reveals painted scenery. +- **Several chunks: one plate per slow layer for the whole region.** Per chunk, plates at scroll *s* < 1 overlap by 2160 × (1 − *s*) and cut each other off with hard vertical edges; at *s* > 1 (the foreground) they spread apart and leave gaps. `depth_layers.py region` quilts each layer marked `"region": true` in `export.json` from the chunks' own plates (`"rest"`): chunk *i* at *i* × 2160 × *s*, each next plate taking over along a min-error path in a 192 px band and only where it has pixels; a foreground gap is filled with the earlier plate's edge mirrored. The first chunk carries the plate, so it lands at x = 0 under any placement rule, and `--verify` still rebuilds every chunk at rest from its own plates. The Hidden Garden's mid plate is 6992 px wide, in four 2160 px tiles. Rusty Creek's distance (0.2), rails (1.1) and foliage (1.15) are quilted the same way ([#150](https://github.com/THD-AI/WinCS/issues/150)); shared plates ("= left") resolve through its aliases. `python scripts/compare.py parallax art/stages/` shows any region as the game draws it. + ## Prompting lessons (23 Oct, all runs) | What we tried | What happened | Rule now | diff --git a/docs/13-PRODUCTION-PIPELINE.md b/docs/13-PRODUCTION-PIPELINE.md index cc1261d..d684a8c 100644 --- a/docs/13-PRODUCTION-PIPELINE.md +++ b/docs/13-PRODUCTION-PIPELINE.md @@ -182,7 +182,8 @@ Lane B codes against these from day one, with placeholders; lane A fills them wi | 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` (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 | +| Region swap | `game/src/stage/swap.ts` | `swapRegion(scene, { region, state, spawn? })` emits `region-turned` and reloads the stage; the slice starts at `START` (Hidden Garden, clean) | story (A) calls it on a crossing's cut | +| Game events | `game/src/core/events.ts` | typed events: `enemy-defeated`, `player-hit`, `player-died`, `region-turned`, `boss-defeated`, `save`, `trigger-entered` (the hero entered an `interact` box) | combat and enemies (B) emit → story and UI (A) listen | | Placeholder sizes | – | hero 28×76 body in an 80 px frame; Dexter 27 px; rover ≈ 25 px; crab ≈ 40 px; drone ≈ 45 px; Jenny ≈ 270×95 (body; wing and beacon above) | docs/04 scale table | ## 6 · Two agents, one repo diff --git a/game/public/assets/sprites/dexter-acts.json b/game/public/assets/sprites/dexter-acts.json index 6dd58fe..45d33df 100644 --- a/game/public/assets/sprites/dexter-acts.json +++ b/game/public/assets/sprites/dexter-acts.json @@ -4,20 +4,20 @@ "frame": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -26,22 +26,22 @@ }, "beckon-01": { "frame": { - "x": 22, + "x": 30, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -50,22 +50,22 @@ }, "beckon-02": { "frame": { - "x": 44, + "x": 60, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -75,21 +75,21 @@ "bow-00": { "frame": { "x": 0, - "y": 28, - "w": 20, - "h": 26 + "y": 37, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -99,21 +99,21 @@ "sweat-00": { "frame": { "x": 0, - "y": 56, - "w": 20, - "h": 26 + "y": 74, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -122,22 +122,22 @@ }, "sweat-01": { "frame": { - "x": 22, - "y": 56, - "w": 20, - "h": 26 + "x": 30, + "y": 74, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -147,21 +147,21 @@ "proud-00": { "frame": { "x": 0, - "y": 84, - "w": 20, - "h": 26 + "y": 111, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -171,21 +171,21 @@ "creepy-00": { "frame": { "x": 0, - "y": 112, - "w": 20, - "h": 26 + "y": 148, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -195,21 +195,21 @@ "absorb-00": { "frame": { "x": 0, - "y": 140, - "w": 20, - "h": 26 + "y": 185, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -218,22 +218,22 @@ }, "absorb-01": { "frame": { - "x": 22, - "y": 140, - "w": 20, - "h": 26 + "x": 30, + "y": 185, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -242,22 +242,22 @@ }, "absorb-02": { "frame": { - "x": 44, - "y": 140, - "w": 20, - "h": 26 + "x": 60, + "y": 185, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -266,22 +266,22 @@ }, "absorb-03": { "frame": { - "x": 66, - "y": 140, - "w": 20, - "h": 26 + "x": 90, + "y": 185, + "w": 28, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "sourceSize": { - "w": 20, - "h": 26 + "w": 28, + "h": 35 }, "pivot": { "x": 0.5, @@ -294,8 +294,8 @@ "image": "dexter-acts.png", "format": "RGBA8888", "size": { - "w": 86, - "h": 166 + "w": 118, + "h": 220 }, "scale": "1" } diff --git a/game/public/assets/sprites/dexter-acts.png b/game/public/assets/sprites/dexter-acts.png index 82c0006..7c350e5 100644 Binary files a/game/public/assets/sprites/dexter-acts.png and b/game/public/assets/sprites/dexter-acts.png differ diff --git a/game/public/assets/sprites/dexter.json b/game/public/assets/sprites/dexter.json index 13db038..0367257 100644 --- a/game/public/assets/sprites/dexter.json +++ b/game/public/assets/sprites/dexter.json @@ -4,20 +4,20 @@ "frame": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -26,22 +26,22 @@ }, "idle-01": { "frame": { - "x": 28, + "x": 34, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -50,22 +50,22 @@ }, "idle-02": { "frame": { - "x": 56, + "x": 68, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -75,21 +75,21 @@ "hop-00": { "frame": { "x": 0, - "y": 29, - "w": 26, - "h": 27 + "y": 37, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -98,22 +98,22 @@ }, "hop-01": { "frame": { - "x": 28, - "y": 29, - "w": 26, - "h": 27 + "x": 34, + "y": 37, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -122,22 +122,22 @@ }, "hop-02": { "frame": { - "x": 56, - "y": 29, - "w": 26, - "h": 27 + "x": 68, + "y": 37, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -146,22 +146,22 @@ }, "hop-03": { "frame": { - "x": 84, - "y": 29, - "w": 26, - "h": 27 + "x": 102, + "y": 37, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -171,21 +171,21 @@ "walk-00": { "frame": { "x": 0, - "y": 58, - "w": 26, - "h": 27 + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -194,22 +194,22 @@ }, "walk-01": { "frame": { - "x": 28, - "y": 58, - "w": 26, - "h": 27 + "x": 34, + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -218,22 +218,22 @@ }, "walk-02": { "frame": { - "x": 56, - "y": 58, - "w": 26, - "h": 27 + "x": 68, + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -242,22 +242,22 @@ }, "walk-03": { "frame": { - "x": 84, - "y": 58, - "w": 26, - "h": 27 + "x": 102, + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -266,22 +266,22 @@ }, "walk-04": { "frame": { - "x": 112, - "y": 58, - "w": 26, - "h": 27 + "x": 136, + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -290,22 +290,22 @@ }, "walk-05": { "frame": { - "x": 140, - "y": 58, - "w": 26, - "h": 27 + "x": 170, + "y": 74, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -315,21 +315,21 @@ "point-00": { "frame": { "x": 0, - "y": 87, - "w": 26, - "h": 27 + "y": 111, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -338,22 +338,22 @@ }, "point-01": { "frame": { - "x": 28, - "y": 87, - "w": 26, - "h": 27 + "x": 34, + "y": 111, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -362,22 +362,22 @@ }, "point-02": { "frame": { - "x": 56, - "y": 87, - "w": 26, - "h": 27 + "x": 68, + "y": 111, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -387,21 +387,21 @@ "shove-00": { "frame": { "x": 0, - "y": 116, - "w": 26, - "h": 27 + "y": 148, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -410,22 +410,22 @@ }, "shove-01": { "frame": { - "x": 28, - "y": 116, - "w": 26, - "h": 27 + "x": 34, + "y": 148, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -434,22 +434,22 @@ }, "shove-02": { "frame": { - "x": 56, - "y": 116, - "w": 26, - "h": 27 + "x": 68, + "y": 148, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -458,22 +458,22 @@ }, "shove-03": { "frame": { - "x": 84, - "y": 116, - "w": 26, - "h": 27 + "x": 102, + "y": 148, + "w": 32, + "h": 35 }, "rotated": false, "trimmed": false, "spriteSourceSize": { "x": 0, "y": 0, - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "sourceSize": { - "w": 26, - "h": 27 + "w": 32, + "h": 35 }, "pivot": { "x": 0.5, @@ -486,8 +486,8 @@ "image": "dexter.png", "format": "RGBA8888", "size": { - "w": 166, - "h": 143 + "w": 202, + "h": 183 }, "scale": "1" } diff --git a/game/public/assets/sprites/dexter.png b/game/public/assets/sprites/dexter.png index d4d01d8..9a32b64 100644 Binary files a/game/public/assets/sprites/dexter.png and b/game/public/assets/sprites/dexter.png differ diff --git a/game/src/core/events.ts b/game/src/core/events.ts index d5764b5..55eef5a 100644 --- a/game/src/core/events.ts +++ b/game/src/core/events.ts @@ -9,6 +9,8 @@ export interface GameEvents { 'region-turned': { region: string; state: RegionState }; 'boss-defeated': { id: string; position: Position }; 'save': { checkpoint: string; region: string; position: Position }; + /** The hero's body entered an interact box (collision `interact`); fires once per entry. */ + 'trigger-entered': { id: string; kind: string; region: string; position: Position }; } type Listener = (payload: T) => void; diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index eca38ef..48830c1 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -10,6 +10,9 @@ import { ChairCombat } from '../combat/chair'; import { CoffeePoints } from '../combat/coffee'; import { Rovers } from '../enemies/rovers'; import { BODY_WIDTH } from '../player/body'; +import { Triggers } from '../stage/triggers'; +import { START, type RegionTarget } from '../stage/swap'; +import type { Position } from './events'; // Bootstrap-only scenes live in lane B until lane A provides story/UI scenes. export class Boot extends Phaser.Scene { @@ -19,16 +22,18 @@ export class Boot extends Phaser.Scene { export class Preload extends Phaser.Scene { constructor() { super('Preload'); } - async create() { + /** `data` comes from swapRegion; the URL (`?region=…&state=…`) only picks the first region. */ + async create(data: Partial = {}) { const query = new URLSearchParams(location.search); - const region = query.get('region') ?? 'rusty-creek'; - const state: RegionState = query.get('state') === 'clean' ? 'clean' : 'anomaly'; + const region = data.region ?? query.get('region') ?? START.region; + const asked = data.state ?? query.get('state'); + const state: RegionState = asked === 'clean' || asked === 'anomaly' ? asked : region === START.region ? START.state : 'anomaly'; const assets = new Assets(this); const manifest = await assets.stage(region, state); const collisions = manifest.chunks[0].id === 'fallback' ? [] : await loadCollisions(manifest, state); for (const board of BOARDS) await assets.board(board); await assets.placeholder('coffee-placeholder'); - this.scene.start('Stage', { manifest, state, collisions }); + this.scene.start('Stage', { manifest, state, collisions, spawn: data.spawn }); } } @@ -39,10 +44,11 @@ export class Stage extends Phaser.Scene { private combat?: ChairCombat; private coffee?: CoffeePoints; private rovers?: Rovers; + private triggers?: Triggers; get playerActor() { return this.player; } get chair() { return this.combat; } constructor() { super('Stage'); } - create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) { + create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[]; spawn?: Position }) { drawStage(this, data.manifest, data.state); collisionDebug(this, data.collisions); const machine = import.meta.env.DEV && new URLSearchParams(location.search).has('coffee-test') @@ -51,7 +57,7 @@ export class Stage extends Phaser.Scene { ? data.collisions.flatMap(chunk => chunk.world.spawn).find(spawn => spawn.kind === 'rover') : undefined; this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses, machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h } - : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } : undefined); + : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } : data.spawn); this.player.onHealthChanged = health => { const hud = this.scene.get('HUD') as Phaser.Scene & { model?: { setHealth(health: number, at: number): void } }; hud.model?.setHealth(health, this.time.now); @@ -59,6 +65,7 @@ export class Stage extends Phaser.Scene { this.combat = new ChairCombat(this, this.player, data.manifest.region); this.coffee = new CoffeePoints(this, this.player, data.collisions); this.rovers = new Rovers(this, this.player, this.combat, data.collisions); + this.triggers = new Triggers(data.collisions, data.manifest.region); this.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect') ? undefined : cameraFollow(this, data.manifest); this.scene.launch('UI'); @@ -79,6 +86,7 @@ export class Stage extends Phaser.Scene { this.player?.update(active); this.combat?.update(active); this.rovers?.update(active); + if (this.player && this.player.life.dead === 0) this.triggers?.emit(this.player.body); if (this.player) this.follow?.(this.player.body, active); } } diff --git a/game/src/scenes/registry.ts b/game/src/scenes/registry.ts index 7e9166a..f625555 100644 --- a/game/src/scenes/registry.ts +++ b/game/src/scenes/registry.ts @@ -3,6 +3,8 @@ import { AnomalyTest } from '../fx/AnomalyTest'; import { AtmosphereTest } from '../fx/AtmosphereTest'; import { Audio } from '../fx/sound/Audio'; import { Crossing } from '../story/Crossing'; +import { Ending } from '../story/Ending'; +import { Flow } from '../story/Flow'; import { Hud } from '../ui/Hud'; import { PocketComputer } from '../ui/PocketComputer'; import { Title } from '../ui/Title'; @@ -12,7 +14,7 @@ import { Title } from '../ui/Title'; * so a new lane A scene is added here and the shared main.ts stays untouched. */ export const LANE_A_SCENES: Phaser.Types.Scenes.SceneType[] = [ - Audio, Hud, Crossing, PocketComputer, Title, + Audio, Flow, Hud, Crossing, PocketComputer, Ending, Title, // Dev-only test scenes stay out of production builds. ...(import.meta.env.DEV ? [AnomalyTest, AtmosphereTest] : []), ]; diff --git a/game/src/stage/assets.ts b/game/src/stage/assets.ts index c97470a..c99b56b 100644 --- a/game/src/stage/assets.ts +++ b/game/src/stage/assets.ts @@ -2,7 +2,7 @@ import type Phaser from 'phaser'; import { parseManifest, stageLayers, type StageManifest, type RegionState } from './manifest'; export const BOARDS = ['hero-move', 'hero-run', 'hero-idle', 'hero-air', 'hero-hurt', 'hero-death', - 'chair-side', 'chair-overhead', 'chair-block', 'enemy-rover', 'enemy-crab', 'enemy-drone', 'fx'] as const; + 'chair-side', 'chair-overhead', 'chair-block', 'enemy-rover', 'enemy-crab', 'enemy-drone', 'fx', 'boss-jenny', 'boss-jenny-2'] as const; export interface AnimationClip { frames: number; fps: number; loop: boolean } export type AnimationTable = Record; export const stageBase = (region: string) => `/assets/stages/${region}/`; diff --git a/game/src/stage/swap.ts b/game/src/stage/swap.ts new file mode 100644 index 0000000..926789d --- /dev/null +++ b/game/src/stage/swap.ts @@ -0,0 +1,19 @@ +import type Phaser from 'phaser'; +import { gameEvents, type Position } from '../core/events'; +import type { RegionState } from './manifest'; + +export interface RegionTarget { region: string; state: RegionState; spawn?: Position } + +/** Where the slice begins: the Hidden Garden prologue (docs/03). `?region=` in the URL starts elsewhere. */ +export const START: RegionTarget = { region: 'hidden-garden', state: 'clean' }; + +/** + * Swap the stage to another region or state (#35): announce it, then reload through Preload, which fetches the + * manifest and collision of the target. Without `spawn` the hero starts at the region's `player` spawn. + */ +export function swapRegion(scene: Phaser.Scene, target: RegionTarget): void { + gameEvents.emit('region-turned', { region: target.region, state: target.state }); + // Through the game's scene manager, so the caller (a story scene) keeps running. + scene.game.scene.stop('Stage'); + scene.game.scene.start('Preload', target); +} diff --git a/game/src/stage/triggers.test.ts b/game/src/stage/triggers.test.ts new file mode 100644 index 0000000..d3d6f4b --- /dev/null +++ b/game/src/stage/triggers.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import type { CollisionChunk } from './collision'; +import { Triggers } from './triggers'; + +const chunk = (interact: object[]) => ({ world: { interact } }) as unknown as CollisionChunk; +const body = (x: number, y: number) => ({ x, y, vx: 0, vy: 0, grounded: true }); + +describe('interact triggers', () => { + // Hidden Garden hg-4-0: the pond edge, 70 × 76 at (1250, 365) in chunk space. + const pond = { id: 'pond-edge', kind: 'anomaly', x: 1250, y: 365, w: 70, h: 76 }; + + it('fires once on entry and again only after leaving', () => { + const triggers = new Triggers([chunk([pond])], 'hidden-garden'); + expect(triggers.step(body(1200, 441))).toEqual([]); + expect(triggers.step(body(1260, 441))).toEqual([pond]); + expect(triggers.step(body(1280, 441))).toEqual([]); + expect(triggers.step(body(1400, 441))).toEqual([]); + expect(triggers.step(body(1300, 441))).toEqual([pond]); + }); + + it('ignores boxes without an id or kind', () => { + const triggers = new Triggers([chunk([{ x: 0, y: 0, w: 100, h: 100 }, pond])], 'hidden-garden'); + expect(triggers.step(body(50, 90))).toEqual([]); + }); +}); diff --git a/game/src/stage/triggers.ts b/game/src/stage/triggers.ts new file mode 100644 index 0000000..d2f0075 --- /dev/null +++ b/game/src/stage/triggers.ts @@ -0,0 +1,34 @@ +import { gameEvents } from '../core/events'; +import { overlaps, type Body } from '../player/body'; +import type { Box, CollisionChunk } from './collision'; + +/** + * Edge detection over the authored `interact` boxes (docs/13 contract): a box fires once when the hero's body + * enters it and again only after the body has left. Story and UI react by kind – `anomaly`, `jenny-ram`, + * `boss-gate`, `dexter-waypoint`, `chunk-exit` … – through `trigger-entered`. + */ +export class Triggers { + private readonly boxes: Box[]; + private inside = new Set(); + + constructor(chunks: CollisionChunk[], private region: string) { + this.boxes = chunks.flatMap(chunk => chunk.world.interact).filter(box => box.id && box.kind); + } + + /** The boxes the body entered since the last step. */ + step(body: Body): Box[] { + const entered: Box[] = []; + for (const box of this.boxes) { + const now = overlaps(body, box); + if (now && !this.inside.has(box)) entered.push(box); + if (now) this.inside.add(box); else this.inside.delete(box); + } + return entered; + } + + emit(body: Body): void { + for (const box of this.step(body)) { + gameEvents.emit('trigger-entered', { id: box.id!, kind: box.kind!, region: this.region, position: { x: body.x, y: body.y } }); + } + } +} diff --git a/game/src/story/Ending.ts b/game/src/story/Ending.ts new file mode 100644 index 0000000..cfe4994 --- /dev/null +++ b/game/src/story/Ending.ts @@ -0,0 +1,142 @@ +import Phaser from 'phaser'; +import { gameEvents, type Position } from '../core/events'; +import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { addImageCut, addPixelStretch, add404Void } from '../fx/anomaly'; +import { sound } from '../fx/sound/Audio'; +import { crashWindow } from '../ui/crashWindow'; +import { creditsText } from '../ui/creditsView'; +import { dexterScale, EndingSequence, type EndingPhase } from './endingSequence'; + +export const END_TEXT = 'To be continued.'; + +/** The approved atlases this scene draws (scripts/export_atlas.py); frame names are `-NN`. */ +const ATLASES = ['dexter', 'dexter-acts', 'fx'] as const; +const ANIMS: [atlas: string, anim: string, frames: number, repeat: number][] = [ + ['fx', 'black-fluid', 6, 0], ['dexter', 'hop', 4, -1], ['dexter-acts', 'absorb', 4, 0], +]; +const STAGE = 'Stage'; +const OK_KEYS = ['J', 'ENTER', 'SPACE']; +const OK_PADS = [0, 2]; +/** Where Dexter lands, left of the pool, and how far he hops in from (game px). */ +const DEXTER_OFFSET = -26; +const HOP_IN = 140; +/** Without a stage position (the dev key): the middle of the view, on a ground line. */ +const FALLBACK: Position = { x: VIEW_WIDTH / 2, y: 600 }; + +/** + * The end of the slice (docs/03 "The ending sequence", #58). Waits for lane B's `boss-defeated`, then + * draws over the frozen stage: the pool under Jenny, Dexter absorbing it and growing to 46 px, the + * glitch, the "to be continued" window, the credits, and back to the title. + */ +export class Ending extends Phaser.Scene { + private run?: EndingSequence; + private last: EndingPhase = 'idle'; + private at: Position = FALLBACK; + private pool?: Phaser.GameObjects.Sprite; + private dexter?: Phaser.GameObjects.Sprite; + private glitch: Phaser.GameObjects.GameObject[] = []; + + constructor() { super({ key: 'Ending', active: true }); } + + preload(): void { + for (const key of ATLASES) { + if (!this.textures.exists(key)) this.load.atlas(key, `assets/sprites/${key}.png`, `assets/sprites/${key}.json`); + } + } + + create(): void { + for (const [atlas, anim, frames, repeat] of ANIMS) { + const key = `${atlas}:${anim}`; + if (this.anims.exists(key) || !this.textures.exists(atlas)) continue; + const names = Array.from({ length: frames }, (_, i) => ({ key: atlas, frame: `${anim}-${String(i).padStart(2, '0')}` })); + this.anims.create({ key, frames: names, frameRate: 12, repeat }); + } + const stop = gameEvents.on('boss-defeated', ({ position }) => this.begin(position)); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, stop); + // Dev builds: Shift + F8 runs the ending anywhere (#58 verification; F8 alone is the crossing). + if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F8', (event: KeyboardEvent) => { if (event.shiftKey) this.begin(); }); + for (const key of OK_KEYS) this.input.keyboard?.on(`keydown-${key}`, () => this.run?.confirm()); + this.input.gamepad?.on('down', (_pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button) => { + if (OK_PADS.includes(button.index)) this.run?.confirm(); + }); + } + + /** Start once, at Jenny's stage position turned into view pixels. */ + begin(position?: Position): void { + if (this.run) return; + this.at = position ? this.toView(position) : FALLBACK; + if (this.scene.isActive(STAGE)) this.scene.pause(STAGE); + this.run = new EndingSequence(() => window.location.assign(window.location.pathname)); + this.run.start(); + this.scene.bringToTop(); + } + + update(_time: number, delta: number): void { + if (!this.run) return; + const phase = this.run.update(delta); + if (phase !== this.last) this.enter(phase); + this.last = phase; + const t = this.run.progress(); + const d = this.dexter; + if (phase === 'appear' && d) d.x = this.at.x + DEXTER_OFFSET - HOP_IN * (1 - t); + if (phase === 'absorb' && this.pool) this.pool.setScale(1 - t, 1 - t * 0.5).setAlpha(1 - t * 0.6); + if (d) d.setScale(dexterScale(phase, t)); + if (phase === 'leave' && d) d.x = this.at.x + DEXTER_OFFSET + (VIEW_WIDTH - this.at.x + 80) * t; + // The tears flicker while the frame breaks, then hold still under the window. + for (const item of this.glitch) (item as Phaser.GameObjects.Image).setVisible(phase !== 'glitch' || Math.random() < 0.7); + } + + private enter(phase: EndingPhase): void { + if (phase === 'leak') { + this.pool = this.add.sprite(this.at.x, this.at.y, 'fx').setOrigin(0.5, 1); + this.pool.play('fx:black-fluid'); + } else if (phase === 'appear') { + this.dexter = this.add.sprite(this.at.x + DEXTER_OFFSET - HOP_IN, this.at.y, 'dexter').setOrigin(0.5, 1); + this.dexter.play('dexter:hop'); + } else if (phase === 'absorb') { + this.dexter?.play('dexter-acts:absorb'); + sound.play('fluid-absorb'); + } else if (phase === 'grow') { + this.pool?.destroy(); + this.dexter?.stop().setTexture('dexter-acts', 'creepy-00'); + } else if (phase === 'leave') { + this.dexter?.play('dexter:hop'); + } else if (phase === 'glitch') { + this.dexter?.destroy(); + this.tearFrame(); + } else if (phase === 'dialog') { + const { container, ok } = crashWindow(this, VIEW_WIDTH / 2, VIEW_HEIGHT / 2, END_TEXT); + container.setDepth(10); + ok.on('pointerdown', () => this.run?.confirm()); + sound.play('crash-ding'); + } else if (phase === 'credits') { + this.children.removeAll(true); + this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0x101820).setOrigin(0); + creditsText(this, 'Act – back to the title'); + } + } + + /** The anomaly language over a snapshot of the frame: two cuts, a stretched row and a 404 void. */ + private tearFrame(): void { + this.game.renderer.snapshot((shot) => { + // The image arrives a frame or two later; under the window the torn frame stays. + if (!(shot instanceof HTMLImageElement) || (this.last !== 'glitch' && this.last !== 'dialog')) return; + if (this.textures.exists('ending-frame')) this.textures.remove('ending-frame'); + this.textures.addImage('ending-frame', shot); + const frame = this.add.image(0, 0, 'ending-frame').setOrigin(0).setVisible(false); + this.glitch = [ + ...addImageCut(frame, { x: 0, y: 180, w: VIEW_WIDTH, h: 60 }, 48), + ...addImageCut(frame, { x: 0, y: 470, w: VIEW_WIDTH, h: 36 }, -64, 3), + addPixelStretch(frame, { x: 760, y: 260, w: 220, h: 180 }, 4), + add404Void(frame, { x: 180, y: 300, w: 200, h: 140 }), + ]; + }); + } + + /** Stage pixels → view pixels through the stage's camera (no camera: unchanged). */ + private toView(p: Position): Position { + const cam = this.scene.get(STAGE)?.cameras?.main; + if (!cam) return p; + return { x: (p.x - cam.worldView.x) * cam.zoom, y: (p.y - cam.worldView.y) * cam.zoom }; + } +} diff --git a/game/src/story/Flow.ts b/game/src/story/Flow.ts new file mode 100644 index 0000000..7b5865c --- /dev/null +++ b/game/src/story/Flow.ts @@ -0,0 +1,58 @@ +import Phaser from 'phaser'; +import { gameEvents, type Position } from '../core/events'; +import { VIEW_WIDTH } from '../core/viewport'; +import { sound } from '../fx/sound/Audio'; +import { swapRegion, type RegionTarget } from '../stage/swap'; +import type { CrossingData } from './Crossing'; + +const STAGE = 'Stage'; +/** Jenny's ram on the clean bridge: she enters from beyond the right edge of the view at charge speed (px/s). */ +const RAM_SPEED = 900; +const RAM_LEAD = VIEW_WIDTH * 0.7; + +/** + * The slice's route between regions (docs/03, #35 #51 #54): the Hidden Garden's pond is the anomaly that + * crashes you into clean Rusty Creek; on the clean bridge Jenny rams you, and the crash turns the creek into + * the rust maze, back at its start. Each step reacts to a `trigger-entered` kind and swaps the region on the + * crossing's cut. + */ +export class Flow extends Phaser.Scene { + private busy = false; + + constructor() { super({ key: 'Flow', active: true }); } + + create(): void { + const stop = gameEvents.on('trigger-entered', ({ kind, region, position }) => { + if (this.busy) return; + if (kind === 'anomaly' && region === 'hidden-garden') this.cross({ region: 'rusty-creek', state: 'clean' }); + if (kind === 'jenny-ram' && region === 'rusty-creek') this.ram(position); + }); + const ready = gameEvents.on('region-turned', () => { this.time.delayedCall(500, () => { this.busy = false; }); }); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { stop(); ready(); }); + } + + /** The THD.exe crossing over the frozen stage; on the cut the region swaps. */ + private cross(target: RegionTarget): void { + this.busy = true; + const data: CrossingData = { onLand: () => swapRegion(this, target) }; + this.scene.launch('Crossing', data); + } + + /** Jenny comes out of nowhere along the bridge and hits the hero; the crash is the crossing. */ + private ram(hero: Position): void { + this.busy = true; + const stage = this.scene.get(STAGE); + if (!stage?.textures.exists('boss-jenny')) return this.cross({ region: 'rusty-creek', state: 'anomaly' }); + const jenny = stage.add.sprite(hero.x + RAM_LEAD, hero.y, 'boss-jenny', 'charge-00').setOrigin(0.5, 1).setDepth(150); + sound.play('chair-whoosh'); + stage.tweens.add({ + targets: jenny, x: hero.x + jenny.width * 0.3, duration: (RAM_LEAD / RAM_SPEED) * 1000, ease: 'Quad.easeIn', + // The approved charge clip, 4 frames at 12 fps (boss-jenny.anims.json). + onUpdate: tween => jenny.setFrame(`charge-0${Math.floor(tween.elapsed / (1000 / 12)) % 4}`), + onComplete: () => { + sound.play('chair-hit'); + this.cross({ region: 'rusty-creek', state: 'anomaly' }); + }, + }); + } +} diff --git a/game/src/story/endingSequence.test.ts b/game/src/story/endingSequence.test.ts new file mode 100644 index 0000000..cd3b0fb --- /dev/null +++ b/game/src/story/endingSequence.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { DEXTER_AFTER_JENNY_PX, dexterScale, EndingSequence, TIMING } from './endingSequence'; + +describe('the ending', () => { + it('runs leak, appear, absorb, grow, leave and glitch, then waits on the window', () => { + const run = new EndingSequence(); + run.start(); + const seen = [run.phase]; + for (const phase of ['leak', 'appear', 'absorb', 'grow', 'leave', 'glitch'] as const) seen.push(run.update(TIMING[phase])); + expect(seen).toEqual(['leak', 'appear', 'absorb', 'grow', 'leave', 'glitch', 'dialog']); + expect(run.update(60_000)).toBe('dialog'); + }); + + it('needs OK on the window, then a confirm after the credits, and finishes once', () => { + let done = 0; + const run = new EndingSequence(() => done++); + run.start(); + for (const phase of ['leak', 'appear', 'absorb', 'grow', 'leave', 'glitch'] as const) run.update(TIMING[phase]); + expect(run.confirm()).toBe(false); + run.update(TIMING.dialogGuard); + expect(run.confirm()).toBe(true); + expect(run.phase).toBe('credits'); + expect(run.confirm()).toBe(false); + run.update(TIMING.creditsGuard); + expect(run.confirm()).toBe(true); + expect(run.confirm()).toBe(false); + expect(done).toBe(1); + }); + + it('starts only once', () => { + const run = new EndingSequence(); + run.start(); + run.update(TIMING.leak); + run.start(); + expect(run.phase).toBe('appear'); + }); + + it('grows Dexter from the 27 px plush to 46 px, as docs/04 says', () => { + expect(DEXTER_AFTER_JENNY_PX).toBe(46); + expect(dexterScale('absorb', 1) * 27).toBe(27); + expect(dexterScale('grow', 0) * 27).toBe(27); + expect(dexterScale('grow', 1) * 27).toBeCloseTo(46); + expect(dexterScale('leave', 0) * 27).toBeCloseTo(46); + }); +}); diff --git a/game/src/story/endingSequence.ts b/game/src/story/endingSequence.ts new file mode 100644 index 0000000..f3dd3f3 --- /dev/null +++ b/game/src/story/endingSequence.ts @@ -0,0 +1,70 @@ +/** + * The end of the slice (docs/03 "The ending sequence", #58): Jenny leaks, Dexter hops in and absorbs the + * fluid, grows, hops off ahead; the frame glitches, the "to be continued" window waits for OK, the credits + * roll and the game returns to the title. Pure timing, so it is tested without Phaser. + */ +export type EndingPhase = 'idle' | 'leak' | 'appear' | 'absorb' | 'grow' | 'leave' | 'glitch' | 'dialog' | 'credits' | 'done'; + +const TIMED = ['leak', 'appear', 'absorb', 'grow', 'leave', 'glitch'] as const; + +/** Milliseconds per phase; the frame counts of the fx and dexter atlases at 12 fps set the first three. */ +export const TIMING = { + leak: 900, // fx black-fluid, 6 frames, then the pool sits + appear: 700, // dexter hop, 4 frames, plus the landing + absorb: 1200, // dexter-acts absorb, 4 frames, while the pool drains + grow: 900, + leave: 1100, + glitch: 900, + dialogGuard: 300, + creditsGuard: 1500, +} as const; + +/** docs/04 scale: the 27 px plush grows a quarter of the way to the 101 px mascot per region boss. */ +export const DEXTER_PX = { plush: 27, final: 101, regionBosses: 4 } as const; +export const DEXTER_AFTER_JENNY_PX = Math.round(DEXTER_PX.plush + (DEXTER_PX.final - DEXTER_PX.plush) / DEXTER_PX.regionBosses); + +export class EndingSequence { + phase: EndingPhase = 'idle'; + private elapsed = 0; + + constructor(private readonly onDone: () => void = () => {}) {} + + start(): void { if (this.phase === 'idle') this.enter('leak'); } + + /** Advance by dt milliseconds; returns the phase after the step. */ + update(dt: number): EndingPhase { + this.elapsed += dt; + const i = TIMED.indexOf(this.phase as (typeof TIMED)[number]); + if (i >= 0 && this.elapsed >= TIMING[TIMED[i]]) this.enter(TIMED[i + 1] ?? 'dialog'); + return this.phase; + } + + /** 0 → 1 through the current phase (1 once a timed phase is over or the phase is untimed). */ + progress(): number { + const timed = TIMED.includes(this.phase as (typeof TIMED)[number]); + return timed ? Math.min(1, this.elapsed / TIMING[this.phase as (typeof TIMED)[number]]) : 1; + } + + /** OK on the window, then any confirm after the credits have been up a moment. */ + confirm(): boolean { + if (this.phase === 'dialog' && this.elapsed >= TIMING.dialogGuard) return this.enter('credits'), true; + if (this.phase === 'credits' && this.elapsed >= TIMING.creditsGuard) { + this.enter('done'); + this.onDone(); + return true; + } + return false; + } + + private enter(phase: EndingPhase): void { + this.phase = phase; + this.elapsed = 0; + } +} + +/** Dexter's scale through the sequence: the plush until he absorbs, then eased up to the post-Jenny size. */ +export function dexterScale(phase: EndingPhase, progress: number): number { + const grown = DEXTER_AFTER_JENNY_PX / DEXTER_PX.plush; + if (phase === 'grow') return 1 + (grown - 1) * (1 - (1 - progress) ** 3); + return phase === 'leave' || phase === 'glitch' ? grown : 1; +} diff --git a/game/src/ui/Hud.ts b/game/src/ui/Hud.ts index b08fda4..8da349e 100644 --- a/game/src/ui/Hud.ts +++ b/game/src/ui/Hud.ts @@ -63,8 +63,8 @@ export class Hud extends Phaser.Scene { this.model.setBossHealth(7); }); // Dev builds: F8 runs the crossing anywhere (#51 verification). - if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F8', () => { - if (!this.scene.isActive('Crossing')) this.scene.launch('Crossing'); + if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F8', (event: KeyboardEvent) => { + if (!event.shiftKey && !this.scene.isActive('Crossing')) this.scene.launch('Crossing'); }); // Dev builds: F9 opens the anomaly FX test chunk (#37 verification). if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F9', () => { diff --git a/game/src/ui/Title.ts b/game/src/ui/Title.ts index 8c636ea..4d1cb8d 100644 --- a/game/src/ui/Title.ts +++ b/game/src/ui/Title.ts @@ -1,7 +1,8 @@ import Phaser from 'phaser'; import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; import { sound } from '../fx/sound/Audio'; -import { CREDITS, MENU, moveSelection, SUBTITLE, TITLE } from './credits'; +import { MENU, moveSelection, SUBTITLE, TITLE } from './credits'; +import { creditsText } from './creditsView'; const C = { bg: 0x101820, text: '#e8e4dc', dim: '#7d8a90', pick: '#46d1b4' }; const FONT = 'monospace'; @@ -28,7 +29,7 @@ export class Title extends Phaser.Scene { .on('pointerover', () => this.select(i)) .on('pointerdown', () => { this.select(i); this.confirm(); })); this.titleLayer = this.add.container(0, 0, [title, subtitle, ...this.menu]); - this.creditsLayer = this.add.container(0, 0, this.creditsText()).setVisible(false); + this.creditsLayer = this.add.container(0, 0, creditsText(this, 'Act / Esc – back')).setVisible(false); const keys = this.input.keyboard; keys?.addCapture('UP,DOWN,SPACE'); // these would scroll the page under the canvas keys?.on('keydown-UP', () => this.select(moveSelection(this.choice, -1))); @@ -72,20 +73,4 @@ export class Title extends Phaser.Scene { this.titleLayer.setVisible(!on); this.creditsLayer.setVisible(on); } - - private creditsText(): Phaser.GameObjects.Text[] { - const out: Phaser.GameObjects.Text[] = []; - let y = 110; - for (const section of CREDITS) { - out.push(this.add.text(VIEW_WIDTH / 2, y, section.heading, { fontFamily: FONT, fontSize: '24px', color: C.pick }).setOrigin(0.5)); - y += 36; - for (const line of section.lines) { - out.push(this.add.text(VIEW_WIDTH / 2, y, line, { fontFamily: FONT, fontSize: '18px', color: C.text }).setOrigin(0.5)); - y += 26; - } - y += 22; - } - out.push(this.add.text(VIEW_WIDTH / 2, VIEW_HEIGHT - 50, 'Act / Esc – back', { fontFamily: FONT, fontSize: '16px', color: C.dim }).setOrigin(0.5)); - return out; - } } diff --git a/game/src/ui/crashWindow.ts b/game/src/ui/crashWindow.ts index 193dcf8..9d974ad 100644 --- a/game/src/ui/crashWindow.ts +++ b/game/src/ui/crashWindow.ts @@ -13,7 +13,7 @@ export interface CrashWindow { } /** A bevelled grey window centred at (x, y), with the THD.exe title bar, the red X icon and one OK button. */ -export function crashWindow(scene: Phaser.Scene, x: number, y: number): CrashWindow { +export function crashWindow(scene: Phaser.Scene, x: number, y: number, text = CRASH_TEXT): CrashWindow { const g = scene.add.graphics(); bevel(g, -W / 2, -H / 2, W, H); g.fillStyle(C.title).fillRect(-W / 2 + 4, -H / 2 + 4, W - 8, 24); @@ -25,7 +25,7 @@ export function crashWindow(scene: Phaser.Scene, x: number, y: number): CrashWin g.lineStyle(1, C.dark).strokeRect(-37, H / 2 - 39, 74, 22); const font = { fontFamily: 'Tahoma, Verdana, sans-serif', fontSize: '15px' }; const title = scene.add.text(-W / 2 + 10, -H / 2 + 8, 'THD.exe', { ...font, color: '#ffffff', fontStyle: 'bold' }); - const message = scene.add.text(-W / 2 + 72, -16, CRASH_TEXT, { ...font, color: '#000000' }); + const message = scene.add.text(-W / 2 + 72, -16, text, { ...font, color: '#000000' }); const okLabel = scene.add.text(0, H / 2 - 28, 'OK', { ...font, color: '#000000' }).setOrigin(0.5); const ok = scene.add.zone(0, H / 2 - 28, 80, 28).setInteractive({ useHandCursor: true }); const container = scene.add.container(x, y, [g, title, message, okLabel, ok]); diff --git a/game/src/ui/creditsView.ts b/game/src/ui/creditsView.ts new file mode 100644 index 0000000..01bd09f --- /dev/null +++ b/game/src/ui/creditsView.ts @@ -0,0 +1,23 @@ +import Phaser from 'phaser'; +import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { CREDITS } from './credits'; + +const C = { text: '#e8e4dc', dim: '#7d8a90', pick: '#46d1b4' }; +const FONT = 'monospace'; + +/** The credits page, centred on the view, with one hint line at the bottom; shared by the title and the ending. */ +export function creditsText(scene: Phaser.Scene, hint: string): Phaser.GameObjects.Text[] { + const out: Phaser.GameObjects.Text[] = []; + let y = 110; + for (const section of CREDITS) { + out.push(scene.add.text(VIEW_WIDTH / 2, y, section.heading, { fontFamily: FONT, fontSize: '24px', color: C.pick }).setOrigin(0.5)); + y += 36; + for (const line of section.lines) { + out.push(scene.add.text(VIEW_WIDTH / 2, y, line, { fontFamily: FONT, fontSize: '18px', color: C.text }).setOrigin(0.5)); + y += 26; + } + y += 22; + } + out.push(scene.add.text(VIEW_WIDTH / 2, VIEW_HEIGHT - 50, hint, { fontFamily: FONT, fontSize: '16px', color: C.dim }).setOrigin(0.5)); + return out; +} diff --git a/scripts/compare.py b/scripts/compare.py index 48b9cb8..1065346 100644 --- a/scripts/compare.py +++ b/scripts/compare.py @@ -6,7 +6,13 @@ python scripts/compare.py lineup hero-move:walk-00 hero-run:run-03 enemy-drone:hover-00 --out … frames from the game's atlases (game/public/assets/sprites) on one baseline, at game scale ×3, with a 1 ft ruler (13 px, docs/04) and the documented height of each thing marked; - --atlas-dir tmp/atlas reads a review export instead of the game's atlases + --atlas-dir tmp/atlas reads a review export instead of the game's atlases; + atlas:frame*2.15 draws a frame the game scales in code (and scales its documented height too) + + python scripts/compare.py parallax art/stages/hidden-garden --state clean --at 0 440 880 --out … + the region's layers from export.json, drawn as the game draws them at each camera x (a layer sits + at its chunk's origin and moves s px per camera px; a region plate's tile j rides on chunk j), + one 1280x720 view per row Paths are relative to the repository. Images and crops are in their own pixels; `--width` sets the common width. """ @@ -74,11 +80,24 @@ def documented(atlas: str) -> int | None: return DOCUMENTED.get(atlas) +def wanted(spec: str) -> int | None: + """The documented height for a lineup spec, times its `*scale` if it has one.""" + name, _, scale = spec.partition("*") + want = documented(name.split(":")[0]) + return round(want * float(scale)) if want and scale else want + + def lineup(specs: list[str], zoom: int = 3) -> tuple[Image.Image, list[str]]: """Frames on one baseline at game scale ×zoom, a foot ruler on the left, documented heights as ticks.""" - pieces = [(spec, frame(*spec.split(":", 1))) for spec in specs] + pieces = [] + for spec in specs: + name, _, scale = spec.partition("*") + p = frame(*name.split(":", 1)) + if scale: + p = p.resize((round(p.width * float(scale)), round(p.height * float(scale))), Image.Resampling.NEAREST) + pieces.append((spec, p)) gap = 24 - tallest = max(max(p.height for _, p in pieces), *(documented(s.split(":")[0]) or 0 for s, _ in pieces)) + tallest = max(max(p.height for _, p in pieces), *(wanted(s) or 0 for s, _ in pieces)) width = 40 + sum(p.width + gap for _, p in pieces) out = Image.new("RGB", (width * zoom, (tallest + 20) * zoom), (232, 228, 220)) draw = ImageDraw.Draw(out) @@ -91,7 +110,7 @@ def lineup(specs: list[str], zoom: int = 3) -> tuple[Image.Image, list[str]]: for spec, p in pieces: out.paste(p.resize((p.width * zoom, p.height * zoom), Image.Resampling.NEAREST), (x * zoom, base - p.height * zoom), p.resize((p.width * zoom, p.height * zoom), Image.Resampling.NEAREST)) - want = documented(spec.split(":")[0]) + want = wanted(spec) if want: y = base - want * zoom draw.line([(x * zoom, y), ((x + p.width) * zoom, y)], fill=GRID, width=zoom) @@ -103,6 +122,39 @@ def lineup(specs: list[str], zoom: int = 3) -> tuple[Image.Image, list[str]]: return out, report +VIEW = (1280, 720) + + +def parallax(region: Path, state: str, cameras: list[int]) -> Image.Image: + """The stage at each camera x, every layer offset by its scroll factor; one view per row.""" + config = json.loads((region / "export.json").read_text(encoding="utf-8")) + w, h = 2160, 720 + rows = [] + for cam in cameras: + view = Image.new("RGBA", VIEW, (0, 0, 0, 255)) + for layer_id in config["states"][state]: + layer = config["layers"][layer_id] + for name, chunk in config["chunks"].items(): + path = region / (layer["file"].format(tile=chunk["x"]) if layer.get("region") + else layer["file"].format(name=chunk.get("name", name))) + if not path.exists(): + continue + s = layer["scroll"] + x = round(chunk["x"] * w - cam * s) # the game: a layer at its chunk's origin, moving at its scroll + if x <= -w or x >= VIEW[0]: # wholly off the view + continue + plate = Image.open(path).convert("RGBA") + if x < 0: # alpha_composite needs a non-negative destination: crop the plate instead + plate, x = plate.crop((-x, 0, plate.width, plate.height)), 0 + if x < VIEW[0] and plate.width: + view.alpha_composite(plate, (x, chunk["y"] * h)) + rows.append(view) + out = Image.new("RGB", (VIEW[0], (VIEW[1] + 8) * len(rows) - 8), GRID) + for i, view in enumerate(rows): + out.paste(view.convert("RGB"), (0, i * (VIEW[1] + 8))) + return out + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="mode", required=True) @@ -116,12 +168,20 @@ def main() -> None: two.add_argument("frames", nargs="+", help="atlas:frame, e.g. hero-move:walk-00") two.add_argument("--out", type=Path, required=True) two.add_argument("--atlas-dir", type=Path, help="read atlases from here (an export to tmp/ before approval)") + three = sub.add_parser("parallax", help="a stage region at several camera positions") + three.add_argument("region", type=Path) + three.add_argument("--state", default="clean") + three.add_argument("--at", type=int, nargs="+", default=[0, 440, 880], help="camera x positions") + three.add_argument("--out", type=Path, required=True) args = parser.parse_args() if getattr(args, "atlas_dir", None): global SPRITES SPRITES = args.atlas_dir if args.atlas_dir.is_absolute() else ROOT / args.atlas_dir if args.mode == "images": out = images(args.ref, args.ours, args.width, args.grid) + elif args.mode == "parallax": + out = parallax(args.region if args.region.is_absolute() else ROOT / args.region, args.state, args.at) + out = out.quantize(256, dither=Image.Dither.NONE) # a review image, not art: keep it small in Git else: out, report = lineup(args.frames) print("\n".join(report)) diff --git a/scripts/depth_layers.py b/scripts/depth_layers.py new file mode 100644 index 0000000..d3d1ca2 --- /dev/null +++ b/scripts/depth_layers.py @@ -0,0 +1,477 @@ +"""Parallax layers for a painted stage chunk: a depth key from the model, masks from the key, fills behind. + + python scripts/depth_layers.py key art/stages/ # API: repaint the chunk as flat depth colours + python scripts/depth_layers.py masks art/stages/ # key -> one mask per layer (committed) + [--all] # every band (grown chunks), not only parallax + python scripts/depth_layers.py collision art/stages/ # walk lines + water no-go from the key + python scripts/depth_layers.py region art/stages/ # quilt the chunks' slow layers into one plate each + python scripts/depth_layers.py fill art/stages/ # API: paint what nearer layers hide + [--only far mid] [--reuse] # --reuse: re-cut from tmp/ paintings + +1. key: the model repaints the composed chunk with one flat colour per depth band (DEPTH below), same + composition. Raw output stays in tmp/boards//. +2. masks: every pixel snaps to the nearest key colour, small specks are voted away, and the sky/far + edge is refined against the real pixels (the key drifts a few pixels at fine edges). Writes the + parallax bands' masks, art/stages//masks/..png; layers.json uses them as + "alpha" rules before its exact rules for rear, water, play and foreground, so + split_layers.py still sends every pixel to exactly one layer and the stack rebuilds the art exactly. +3. fill: a layer that scrolls slower than the ones in front of it shows what they hid. For each such + layer the model repaints the chunk with the nearer layers masked out, painting only that layer's + scenery behind them; the result is kept only under the nearer layers (and below the layer's own + skyline), so the composed picture at rest is unchanged. Writes tmp/boards//filled/ (intermediates: + the region plates below are quilted from them and committed). + +Needs OPENAI_API_KEY (environment or .env) for key and fill; the key goes in a header file (make_boards.edit). +""" + +from __future__ import annotations + +import argparse +import io +import json +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageChops, ImageFilter + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import make_boards as mb # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +# Depth band -> (docs/04 layer, key colour). Order = back to front. +DEPTH = { + "sky": ("00-sky", (0, 255, 255)), + "far": ("01-far", (0, 0, 255)), + "mid": ("02-mid", (0, 255, 0)), + "rear": ("03-rear", (255, 255, 0)), + "water": ("04-water", (0, 0, 128)), + "play": ("05-play", (255, 0, 0)), + "foreground": ("07-foreground", (255, 0, 255)), +} +BACK_TO_FRONT = list(DEPTH) +PARALLAX = ("sky", "far", "mid") # the bands that scroll slower than the play layer + +KEY_PROMPT = ( + "Repaint this exact image as a FLAT COLOUR DEPTH MAP for a 2D game's parallax layers. Keep every shape exactly " + "where it is, pixel-aligned with the original, same size, no new objects. Fill each region with ONE flat colour, " + "no shading, no outlines, no texture, no text:\n" + "- the open sky and clouds: pure cyan #00FFFF\n" + "- the hazy far tree line and distant forest on the horizon: pure blue #0000FF\n" + "- the mid-distance autumn trees behind the reeds: pure green #00FF00\n" + "- the reeds, bushes, stone walls, posts, benches, sheds, gates and the big trees close behind the path: " + "pure yellow #FFFF00\n" + "- ONLY what a person walks on - the path, a boardwalk or jetty deck, steps and terrace tops, the hill's " + "ground, stepping stones, a roof you can stand on - as a solid band from its walking surface down to the " + "water or the bottom: pure red #FF0000\n" + "- the pond water with its reflections: navy #000080\n" + "- the dark out-of-focus leaves and grass along the bottom edge, in front of everything: magenta #FF00FF\n" + "Every pixel is exactly one of these seven colours.") + +FILL_PROMPT = ( + "This is one chunk of a side-scrolling stage. Paint ONLY the transparent area, and paint there only {what}, " + "continuing it naturally from what is visible around the hole: the same light, palette, fine 2-pixel grain and " + "scale. Nothing that stands in front of it - no {front}. Strict side view, no characters, no text.") +WHAT = { + "sky": ("the open late-afternoon sky with its clouds and the sun's glow", "trees, reeds, ground or water"), + "far": ("the hazy far tree line and distant forest, down to the bottom of the transparent area", + "near trees, reeds, walls, path or water"), + "mid": ("the mid-distance autumn trees and their trunks, down to the bottom of the transparent area", + "reeds, bushes, walls, path, bench, jetty or water"), +} + + +def snap(key: Image.Image) -> list[str]: + """Each pixel of the key -> its depth band name (nearest key colour).""" + colours = [DEPTH[name][1] for name in BACK_TO_FRONT] + out = [] + for r, g, b in key.convert("RGB").getdata(): + best = min(range(len(colours)), key=lambda i: (r - colours[i][0]) ** 2 + (g - colours[i][1]) ** 2 + (b - colours[i][2]) ** 2) + out.append(BACK_TO_FRONT[best]) + return out + + +def vote(bands: list[str], size: tuple[int, int], radius: int = 2) -> list[str]: + """Remove specks: each band's mask is mode-filtered, the nearest band wins where several claim a pixel.""" + w, h = size + masks = {} + for name in BACK_TO_FRONT: + m = Image.new("L", size) + m.putdata([255 if b == name else 0 for b in bands]) + masks[name] = m.filter(ImageFilter.ModeFilter(2 * radius + 1)) + out = list(bands) + data = {name: masks[name].getdata() for name in BACK_TO_FRONT} + for i in range(w * h): + claim = [name for name in BACK_TO_FRONT if data[name][i]] + if claim: + out[i] = claim[-1] + return out + + +def skylike(px: tuple[int, int, int]) -> bool: + """Sky and cloud colours in the style master's late afternoon: blue, pale blue, white, the warm sun glow. + + Measured on hg-0-0: open sky ≈ (40–60, 120–140, 240); the hazy far treetops ≈ (80, 120, 180) and + (100, 120, 140) – blue, but never above 200 – so the blue test needs b > 200.""" + r, g, b = px + return (b > 200 and b > r + 60) or (r > 200 and g > 200 and b > 180) or (r > 220 and g > 190 and b > 120 and b < r) + + +def refine(bands: list[str], art: Image.Image, reach: int = 6) -> list[str]: + """The key drifts at fine edges: near the sky/far boundary, the real pixel's colour decides.""" + w, h = art.size + rgb = art.convert("RGB").load() + out = list(bands) + def grown(band: str): + m = Image.new("L", art.size) + m.putdata([255 if b == band else 0 for b in bands]) + return m.filter(ImageFilter.MaxFilter(2 * reach + 1)).getdata() + near_sky, near_tree = grown("sky"), {band: grown(band) for band in ("far", "mid", "rear")} + for y in range(h): + for x in range(w): + i = y * w + x + if not near_sky[i] or bands[i] not in ("sky", "far", "mid", "rear"): + continue + is_sky = skylike(rgb[x, y]) + if is_sky and bands[i] in ("far", "mid"): + out[i] = "sky" # a sky-coloured fringe on a tree top (the rear layer keeps its own: exact rules follow) + elif not is_sky and bands[i] == "sky": + # A treetop or leaf edge the key cut off – only right at trees, never a cloud rim or the sun's + # glow. The nearest band wins (a leaf fringe of the big near tree belongs to the rear layer). + tree = next((band for band in ("rear", "mid", "far") if near_tree[band][i]), None) + if tree: + out[i] = tree + return out + + +def write_masks(region: Path, chunk: str, bands: list[str], size: tuple[int, int], + which: tuple[str, ...] = PARALLAX) -> list[Path]: + """One mask per band. By default only the parallax bands: a hand-tuned chunk keeps exact rules for the rest; + a grown chunk takes every band from its key (--all).""" + folder = region / "masks" + folder.mkdir(exist_ok=True) + written = [] + for name in which: + m = Image.new("L", size) + m.putdata([255 if b == name else 0 for b in bands]) + if m.getbbox() is None: + continue + rgba = Image.new("RGBA", size, (255, 255, 255, 0)) + rgba.putalpha(m) + path = folder / f"{chunk}.{DEPTH[name][0]}.png" + rgba.save(path, optimize=True) + written.append(path) + return written + + +def art_for(region: Path, chunk: str) -> Image.Image: + layers = json.loads((region / "layers.json").read_text(encoding="utf-8")) + return Image.open(region / layers["chunks"][chunk]["states"]["clean"]).convert("RGB") + + +def raw_dir(region: Path) -> Path: + folder = ROOT / "tmp" / "boards" / region.name + folder.mkdir(parents=True, exist_ok=True) + return folder + + +def api(name: str, art: Image.Image, prompt: str, mask: Image.Image | None = None) -> Image.Image | None: + key = mb.api_key() + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "art.png" + art.save(path) + mask_path = None + if mask is not None: + rgba = art.convert("RGBA") + rgba.putalpha(mask.point(lambda v: 0 if v else 255)) # transparent = paint here + mask_path = Path(folder) / "mask.png" + rgba.save(mask_path) + png = mb.edit(name, {"model": "gpt-image-2.5-sunburst", "prompt": prompt, "size": f"{art.width}x{art.height}"}, + [path], key, mask=mask_path) + return None if png is None else Image.open(io.BytesIO(png)).convert("RGB").resize(art.size, Image.Resampling.NEAREST) + + +def bands_from_layers(region: Path, chunk: str, size: tuple[int, int]) -> list[str]: + """The split's own partition (split_layers.py wrote it from the masks and the exact rules).""" + out = ["rear"] * (size[0] * size[1]) + for name in BACK_TO_FRONT: + path = region / "layers" / f"{chunk}.{DEPTH[name][0]}.png" + if path.exists(): + for i, a in enumerate(Image.open(path).getchannel("A").getdata()): + if a: + out[i] = name + return out + + +def skyline(own: list[bool], w: int, h: int, smooth: int = 12) -> list[int]: + """Per column, the layer's top edge – defined everywhere, so its plate spans the whole chunk. + + Columns inside the layer's span without any of its pixels (hidden behind a nearer tree) take the + nearest defined column's top, so the layer continues behind what stands in front of it; a running + minimum over ±smooth px keeps single gaps from cutting a notch. Past the ends of the span there is + no plate (h): the model would invent scenery there – on hg-0-0 it painted sun glow with a flat top – + and a tree line that ends as the camera moves reads naturally.""" + raw = [min((y for y in range(h) if own[y * w + x]), default=None) for x in range(w)] + known = [x for x in range(w) if raw[x] is not None] + if not known: + return [h] * w + filled, j = [], 0 + for x in range(w): + while j + 1 < len(known) and abs(known[j + 1] - x) <= abs(known[j] - x): + j += 1 + filled.append(raw[x] if raw[x] is not None else raw[known[j]]) + tops = [min(filled[max(0, x - smooth):x + smooth + 1]) for x in range(w)] + return [tops[x] if known[0] <= x <= known[-1] else h for x in range(w)] + + +def fill(region: Path, chunk: str, art: Image.Image, only: tuple[str, ...] = PARALLAX, reuse: bool = False) -> None: + """For sky, far and mid: paint what the nearer layers hide, kept only there and below the layer's skyline.""" + w, h = art.size + bands = bands_from_layers(region, chunk, art.size) + for name in only: + own = [b == name for b in bands] + if not any(own): + continue + front = set(BACK_TO_FRONT[BACK_TO_FRONT.index(name) + 1:]) + top = [0] * w if name == "sky" else skyline(own, w, h) + hole = Image.new("L", art.size) + hole.putdata([255 if bands[y * w + x] in front and y >= top[x] else 0 for y in range(h) for x in range(w)]) + # The model gets a 2 px margin so no fringe of the nearer layer survives in its painting, but the + # plate keeps the painting only on pixels the nearer layers own: never over a farther layer. + prompt_hole = hole.filter(ImageFilter.MaxFilter(5)) + in_front = Image.new("L", art.size) + in_front.putdata([255 if b in front else 0 for b in bands]) + what, nots = WHAT[name] + raw = raw_dir(region) / f"{chunk}--fill-{name}.png" + if reuse and raw.exists(): # masks changed, the painting didn't: cut the plate again without the API + painted = Image.open(raw).convert("RGB") + else: + painted = api(f"{chunk}--fill-{name}", art, FILL_PROMPT.format(what=what, front=nots), prompt_hole) + if painted is None: + return + painted.save(raw) + plate = Image.new("RGBA", art.size) + keep = Image.new("L", art.size) + keep.putdata([255 if own[i] else 0 for i in range(w * h)]) + plate.paste(painted.convert("RGBA"), mask=ImageChops.multiply(prompt_hole, in_front)) + plate.paste(art.convert("RGBA"), mask=keep) # its own pixels stay exactly the art's + out = raw_dir(region) / "filled" / f"{chunk}.{DEPTH[name][0]}.filled.png" # an intermediate: not committed + out.parent.mkdir(exist_ok=True) + plate.save(out, optimize=True) + print(f"{chunk}: {name} filled -> {out.relative_to(ROOT)}", flush=True) + + +def rdp(points: list[tuple[int, int]], tolerance: float) -> list[tuple[int, int]]: + """Ramer–Douglas–Peucker: the fewest points that stay within `tolerance` px of the traced line.""" + if len(points) < 3: + return points + (x0, y0), (x1, y1) = points[0], points[-1] + length = max(1e-9, ((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5) + far, at = 0.0, 0 + for i, (x, y) in enumerate(points[1:-1], 1): + d = abs((y1 - y0) * x - (x1 - x0) * y + x1 * y0 - y1 * x0) / length + if d > far: + far, at = d, i + if far <= tolerance: + return [points[0], points[-1]] + return rdp(points[:at + 1], tolerance)[:-1] + rdp(points[at:], tolerance) + + +def surfaces(bands: list[str], w: int, h: int, x: int, every: bool = False) -> list[int]: + """Where the play band starts in column x: its top only, or (every) each start below something else. + + The top only by default: grass and bushes keyed in front of a hill punch holes into its play band, + and their lower edges are not floors. A real second floor (a hollow under the path) is measured by + hand and comes from the board's "walkExtra".""" + tops = [y for y in range(h) if bands[y * w + x] == "play" and (y == 0 or bands[(y - 1) * w + x] != "play")] + return tops if every else tops[:1] + + +def ramp(line: list[tuple[int, int]], rise: int, slope: float = 0.7) -> list[tuple[int, int]]: + """Stairs are walk slopes, never steps (docs/13): each jump of up to `rise` px becomes a ramp of about 35°.""" + pts = list(line) + for k in range(1, len(pts)): + dy = pts[k][1] - pts[k - 1][1] + if 1 < abs(dy) <= rise: + half = max(1, round(abs(dy) / slope / 2)) + a, b = max(0, k - half), min(len(pts) - 1, k + half - 1) + ya, yb = pts[a][1], pts[b][1] + for m in range(a, b + 1): + pts[m] = (pts[m][0], round(ya + (yb - ya) * (m - a) / max(1, b - a))) + return pts + + +def walk_lines(bands: list[str], w: int, h: int, step: int = 48, tolerance: float = 2.0, shortest: int = 24, + every: bool = False) -> list: + """The walkable surfaces of the play band, followed column by column. + + A surface continues into the next column at the nearest height within `step` px (a terrace step, + turned into a ramp); anything further starts a new line. Several surfaces may share a column (the + path over a hollow).""" + active: list[list[tuple[int, int]]] = [] + done: list[list[tuple[int, int]]] = [] + for x in range(w): + here = surfaces(bands, w, h, x, every) + nxt = [] + for line in active: + y0 = line[-1][1] + near = min(here, key=lambda y: abs(y - y0), default=None) + if near is not None and abs(near - y0) <= step: + line.append((x, near)) + here.remove(near) + nxt.append(line) + else: + done.append(line) + nxt += [[(x, y)] for y in here] + active = nxt + done += active + out = [] + for line in sorted((l for l in done if len(l) >= shortest), key=lambda l: (l[0][0], l[0][1])): + pts = rdp(ramp(line, step), tolerance) + pts[-1] = (pts[-1][0] + 1, pts[-1][1]) # the line covers its last column + out.append([[x, y] for x, y in pts]) + return out + + +def collision(region: Path, chunk: str, art: Image.Image) -> Path: + """Walk lines traced from the key's play band and the water as no-go; hand-placed fields are kept.""" + w, h = art.size + raw = raw_dir(region) / f"{chunk}--depth.png" + bands = vote(snap(Image.open(raw).resize(art.size, Image.Resampling.NEAREST)), art.size) + path = region / f"{chunk}.clean.collision.json" + data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else { + "chunk": chunk, "state": "clean", "size": [w, h], "walk": [], "slide": [], "oneWay": [], "climb": [], + "solid": [], "noGo": [], "hazard": [], "interact": [], "spawn": [], "ledge": [], "glideLift": []} + board = next((b for b in json.loads((region / "boards.json").read_text(encoding="utf-8"))["boards"] + if b["id"] == chunk), {}) + data["walk"] = walk_lines(bands, w, h) + board.get("walkExtra", []) + water = [y for y in range(h) for x in range(0, w, 8) if bands[y * w + x] == "water"] + data["noGo"] = [{"x": 0, "y": min(water), "w": w, "h": h - min(water)}] if water else [] + path.write_text(json.dumps(data, separators=(", ", ": ")) + "\n", encoding="utf-8") + return path + + +def seam_path(a: Image.Image, b: Image.Image) -> list[int]: + """Per row, the column of least difference between two RGBA strips; moves at most one column a row.""" + w, h = a.size + pa, pb = a.load(), b.load() + diff = [[sum(abs(pa[x, y][c] - pb[x, y][c]) for c in range(4)) for x in range(w)] for y in range(h)] + cost = [diff[0][:]] + for y in range(1, h): + prev = cost[-1] + cost.append([diff[y][x] + min(prev[max(0, x - 1):x + 2]) for x in range(w)]) + x = min(range(w), key=lambda i: cost[-1][i]) + path = [x] + for y in range(h - 2, -1, -1): + x = min(range(max(0, x - 1), min(w, x + 2)), key=lambda i: cost[y][i]) + path.append(x) + return path[::-1] + + +def region_plate(plates: list[Image.Image], scroll: float, window: int = 192, carry: bool = False) -> Image.Image: + """One plate for a row of chunks at scroll != 1: chunk i's plate sits at i * W * scroll. + + Slower than 1, neighbouring chunk plates overlap by W * (1 - scroll) and would cut each other off with + hard vertical edges; each next plate takes over along the least visible path through a `window` px band + just right of where it starts, and only where it has pixels (past its own tree line the earlier plate + shows on). Faster than 1 (a foreground), they spread apart by W * (scroll - 1): the gap is filled with + the earlier plate's last columns mirrored, which meets it without a seam, and the next plate takes over + from there the same way.""" + w, h = plates[0].size + step = round(w * scroll) + strip = Image.new("RGBA", (step * (len(plates) - 1) + w, h)) + strip.paste(plates[0], (0, 0)) + for i, plate in enumerate(plates[1:], 1): + o = i * step + prev_end = (i - 1) * step + w + if o > prev_end: # a gap: mirror the strip's last columns into it, plus a window to cut through + g = o - prev_end + window + mirrored = strip.crop((prev_end - g, 0, prev_end, h)).transpose(Image.Transpose.FLIP_LEFT_RIGHT) + strip.paste(mirrored, (prev_end, 0)) + k = min(window, max(1, w - step) if step < w else window) + cut = seam_path(strip.crop((o, 0, o + k, h)), plate.crop((0, 0, k, h))) + s, q = strip.load(), plate.load() + # With carry, a column where the plate has no pixels at all (past its own tree line) keeps the + # earlier plate (mid trees: no holes). Every other column is the plate's own, transparency included, + # so its chunk at rest looks exactly like its art. Without carry, open sky shows there instead. + empty = [carry and all(q[x, y][3] == 0 for y in range(h)) for x in range(w)] + for y in range(h): + for x in range(cut[y], w): + if not empty[x] or o > prev_end: + s[o + x, y] = q[x, y] + return strip + + +def region_layers(region: Path) -> list[Path]: + """Quilt every layer marked "region" in export.json from its chunks' "rest" plates into one plate each.""" + export = json.loads((region / "export.json").read_text(encoding="utf-8")) + chunks = sorted(export["chunks"].items(), key=lambda kv: kv[1]["x"]) + aliases = json.loads((region / export["aliases"]).read_text(encoding="utf-8")).get("aliases", {}) if export.get("aliases") else {} + + def rest(layer: dict, chunk_id: str, chunk: dict) -> Path: + """A chunk's own plate for a region layer; a shared plate (an alias, "= left") resolves to its file.""" + path = ROOT / layer["rest"].format(name=chunk.get("name", chunk_id)) + return path if path.exists() or path.name not in aliases else path.parent / aliases[path.name] + written = [] + for layer_id, layer in export["layers"].items(): + if not layer.get("region"): + continue + plates = [Image.open(rest(layer, c, chunk)).convert("RGBA") for c, chunk in chunks] + plate = region_plate(plates, layer["scroll"], carry=layer.get("carry", False)) + # A preview-free game plate: 256 colours keep it small; --verify checks the chunks' own plates ("rest"). + alpha = plate.getchannel("A") + plate = plate.convert("RGB").quantize(256, dither=Image.Dither.NONE).convert("RGBA") + plate.putalpha(alpha) + # Tile j (2160 px of the plate) is carried by chunk j: a layer at chunk.x * 2160 moving at its scroll + # factor then shows exactly the plate's column the camera is over, with any loader that places + # layers at their chunk's origin - and no file is wider than a chunk. + w, n = plates[0].width, len(plates) + (region / "layers").mkdir(exist_ok=True) + for old in (region / "layers").glob(f"{region.name}.{layer_id}.*.png"): + old.unlink() + for j in range(min(n, -(-plate.width // w))): + # The last chunk's tile takes the rest: a foreground faster than 1 runs past the last chunk. + right = plate.width if j == n - 1 else min(plate.width, (j + 1) * w) + out = region / "layers" / f"{region.name}.{layer_id}.{j}.png" + plate.crop((j * w, 0, right, plate.height)).save(out, optimize=True) + written.append(out) + return written + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("step", choices=["key", "masks", "fill", "collision", "region"]) + parser.add_argument("region", type=Path) + parser.add_argument("chunk", nargs="?", help="not for region") + parser.add_argument("--only", nargs="+", choices=PARALLAX, default=list(PARALLAX), help="fill: these layers") + parser.add_argument("--reuse", action="store_true", help="fill: cut the plates from the paintings in tmp/ again") + parser.add_argument("--all", action="store_true", help="masks: every band, not only the parallax ones") + args = parser.parse_args() + region = args.region if args.region.is_absolute() else ROOT / args.region + if args.step == "region": + for path in region_layers(region): + print(f"{region.name}: region plate -> {path.relative_to(ROOT)}") + return + art = art_for(region, args.chunk) + raw = raw_dir(region) / f"{args.chunk}--depth.png" + if args.step == "key": + key = api(f"{args.chunk}--depth", art, KEY_PROMPT) + if key is None: + sys.exit(1) + key.save(raw) + print(f"{args.chunk}: depth key -> {raw.relative_to(ROOT)}") + elif args.step == "masks": + if not raw.exists(): + sys.exit(f"{raw.relative_to(ROOT)} missing: run the key step first") + bands = refine(vote(snap(Image.open(raw)), art.size), art) + which = tuple(n for n in BACK_TO_FRONT if n != "rear") if args.all else PARALLAX # rear = the default rule + for path in write_masks(region, args.chunk, bands, art.size, which): + print(f"{args.chunk}: mask -> {path.relative_to(ROOT)}") + elif args.step == "collision": + print(f"{args.chunk}: walk lines and no-go -> {collision(region, args.chunk, art).relative_to(ROOT)}") + else: + fill(region, args.chunk, art, tuple(args.only), args.reuse) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_stage.py b/scripts/export_stage.py index 7856e2a..98c6891 100644 --- a/scripts/export_stage.py +++ b/scripts/export_stage.py @@ -14,6 +14,9 @@ "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 + # a layer with "region": true is one plate for the whole region (per chunk, plates at scroll != 1 + # overlap or leave gaps), cut into chunk-wide tiles: "file" takes {tile}, chunk j carries tile j; + # "rest": the chunk's own plate it was quilted from, which --verify rebuilds the chunk with "aliases": "media-sources.json"} # optional: shared plates Usage: @@ -75,6 +78,12 @@ def publish(path: Path) -> str: 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] + if layer.get("region"): # one plate for the region in chunk-wide tiles; chunk j carries tile j + tile = region / layer["file"].format(tile=chunk["x"]) + if chunk["y"] != 0 or not tile.exists(): + continue + layers.append({"id": layer_id, "z": layer["z"], "scroll": layer["scroll"], "file": publish(tile)}) + continue 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" @@ -99,11 +108,20 @@ def export(region: Path, out: Path) -> dict: def verify(region: Path, out: Path) -> list[str]: - """Rebuild every chunk state from the exported plates and compare with the region's art.""" + """Rebuild every chunk state from the exported plates and compare with the region's art. + + A region-wide parallax plate ("region": true) is not the chunk's own picture: for it, the chunk's + "rest" plate (its own filled layer, what the region plate was quilted from) stands in.""" 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")) + aliases = json.loads((region / config["aliases"]).read_text(encoding="utf-8")).get("aliases", {}) if config.get("aliases") else {} + + def rest(pattern: str, name: str) -> Path: + path = ROOT / pattern.format(name=name) + return path if path.exists() or path.name not in aliases else path.parent / aliases[path.name] + report = [] for chunk in stage["chunks"]: name = config["chunks"][chunk["id"]].get("name", chunk["id"]) @@ -111,9 +129,16 @@ def verify(region: Path, out: Path) -> list[str]: pattern = config.get("composed", {}).get(state) if not pattern: continue + exported = {layer["id"]: out / region.name / layer["file"] for layer in content["layers"]} + rests = {lid: rest(L["rest"], name) for lid, L in config["layers"].items() if L.get("region")} + if any(not path.exists() for path in rests.values()): + report.append(f"{chunk['id']}.{state} (not checked: its region plates' sources are not in tmp/)") + 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")) + for layer_id in config["states"][state]: + layer = config["layers"][layer_id] + path = rests[layer_id] if layer.get("region") else exported[layer_id] + canvas = Image.alpha_composite(canvas, Image.open(path).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 " diff --git a/scripts/grow_stage.py b/scripts/grow_stage.py new file mode 100644 index 0000000..8628620 --- /dev/null +++ b/scripts/grow_stage.py @@ -0,0 +1,338 @@ +"""Grow a stage region one chunk at a time, from the edge of the chunk next to it (docs/04, docs/09). + + python scripts/grow_stage.py art/stages/ + python scripts/grow_stage.py feature art/stages/ --box x0,y0,x1,y1 --brief "…" + python scripts/grow_stage.py join art/stages/ # both grown, both may change + +Every call to the model is one 2160x720 canvas (3:1, the API's limit): known art on one side, the area to +paint on the other. A chunk grows in two pieces – first 1440 px next to 720 px of its neighbour, then the +last 720 px next to that first piece – so the model always continues a wide strip of finished art and the +new chunk lies right next to its neighbour with nothing repeated. The second piece also paints 128 px over +the first; a minimum-error path through that overlap joins them (image quilting). The neighbour is never +repainted. (A wider canvas padded with sky to reach +3:1 failed: the model reframed it and moved the whole picture 60–90 px.) + +The image the model edits is the LAYOUT GUIDE: the board's "route" (walk lines, water, structures, ladders, +hollows, in chunk pixels) drawn flat where the new art goes, the known art beside it. The mask says where to +paint. Painting over the guide keeps the walkable surfaces where the route needs them. + +The model keeps a grown chunk's path continuous but tends to smooth route features away, so features are a +second step: `feature` repaints one window (plus 64 px margins joined back by min-error cuts) over the guide, +with a short concrete brief – a gap with stepping stones, a fallen log, a hill. + +After both pieces, the colours along the seam are matched to the neighbour (shapes untouched). Raw output +stays in tmp/boards//: .png, its guides and the model's pieces. After review, copy it to +the region's images/ and add it to chunks.json, layers.json and export.json. +Needs OPENAI_API_KEY (environment or .env); the key goes in a header file (make_boards.edit). +""" + +from __future__ import annotations + +import argparse +import io +import json +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import make_boards as mb # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +W, H = 2160, 720 +PIECES = (1440, 720) # painted per call; the rest of the canvas is known art +OVERLAP = 128 # a later piece also paints this far over the earlier one; a min-error cut joins them + +PROMPT = ( + "This image is part of a side-scrolling stage. The finished art along its {edge} side must stay exactly as it " + "is. Paint the rest - where a flat LAYOUT GUIDE is drawn - as the part of the stage {where} that art, one " + "continuous place: the ground line, the light, the palette, the fine 2-pixel grain and the scale continue " + "without a break, and the walkable path meets the finished art at exactly the same height. Follow the guide " + "exactly: red lines are the tops of the walkable surfaces - put the path, deck or step edge exactly there; " + "brown is solid ground under them; blue is water (by the pond it reaches up under the path); grey boxes are " + "built things (a gate, a bench, a shed); yellow is a ladder; purple is a hollow you can walk into; the pale " + "blue above is open for sky and trees. Never draw the guide's flat colours or lines themselves. Strict side " + "view, an architect's elevation: everything runs parallel to the screen, nothing recedes. The same depth " + "layers as the finished art: sky, a hazy far tree line, mid trees, the play layer, water if any, a dark " + "out-of-focus foreground along the bottom edge. No characters, no animals, no text.\n\n" + "WHAT IS HERE: {brief}") + +GUIDE = {"sky": (200, 220, 240), "ground": (120, 80, 50), "walk": (230, 30, 30), "water": (40, 90, 220), + "structure": (140, 140, 140), "ladder": (240, 220, 40), "hollow": (150, 60, 170)} + + +def guide_for(route: dict, known: Image.Image, ox: int) -> Image.Image: + """The canvas the model edits: the route drawn flat (chunk x + ox = canvas x), the known art pasted over it.""" + guide = Image.new("RGB", (W, H), GUIDE["sky"]) + draw = ImageDraw.Draw(guide) + for line in route.get("walk", []): # ground fills from each walk line down + pts = [(ox + x, y) for x, y in line] + draw.polygon(pts + [(pts[-1][0], H), (pts[0][0], H)], fill=GUIDE["ground"]) + if "below" in route: # what lies under the walkable layer from this height on (a pond: water) + y, kind = route["below"] + draw.rectangle((0, y, W, H), fill=GUIDE[kind]) + for kind in ("water", "structure", "hollow", "ladder"): + for x1, y1, x2, y2 in route.get(kind, []): + draw.rectangle((ox + x1, y1, ox + x2, y2), fill=GUIDE[kind]) + for line in route.get("walk", []): + draw.line([(ox + x, y) for x, y in line], fill=GUIDE["walk"], width=6) + guide.paste(known.convert("RGB"), mask=known.getchannel("A")) + return guide + + +def known_canvas(strip: Image.Image, left: bool, piece: int, overlap: int = 0) -> Image.Image: + """W x H: the nearest W - piece px of `strip` beside `piece` px to paint; transparent = paint. + + With an overlap the transparent area reaches that far over the strip too (the strip's pixels stay + underneath, so the model sees them, but it may repaint them).""" + canvas = Image.new("RGBA", (W, H)) + keep = W - piece + if left: # paint on the left, the strip's left end on the right + canvas.paste(strip.crop((0, 0, keep, H)), (piece, 0)) + if overlap: + alpha = canvas.getchannel("A") + alpha.paste(0, (piece, 0, piece + overlap, H)) + canvas.putalpha(alpha) + else: + canvas.paste(strip.crop((strip.width - keep, 0, strip.width, H)), (0, 0)) + if overlap: + alpha = canvas.getchannel("A") + alpha.paste(0, (keep - overlap, 0, keep, H)) + canvas.putalpha(alpha) + return canvas + + +def min_cut(a: Image.Image, b: Image.Image) -> list[int]: + """Per row, the column where the overlap switches from a to b: the vertical path of least colour difference. + + a and b are the same overlap strip painted twice; the path moves at most one column per row (image quilting).""" + w, h = a.size + diff = ImageChops.difference(a.convert("RGB"), b.convert("RGB")).convert("L").load() + cost = [[diff[x, 0] for x in range(w)]] + for y in range(1, h): + prev = cost[-1] + cost.append([diff[x, y] + min(prev[max(0, x - 1):x + 2]) for x in range(w)]) + x = min(range(w), key=lambda i: cost[-1][i]) + path = [x] + for y in range(h - 2, -1, -1): + x = min(range(max(0, x - 1), min(w, x + 2)), key=lambda i: cost[y][i]) + path.append(x) + return path[::-1] + + +def offset(left: bool, done: int, piece: int) -> int: + """Canvas x minus chunk x for this piece. Growing left, the new chunk's right part is painted first.""" + return -(W - done - piece) if left else W - piece - done + + +def match_seam(new: Image.Image, neighbour: Image.Image, left: bool, ramp: int = 160, blur: int = 9) -> Image.Image: + """Shift the new chunk's colours near the seam so its edge column meets the neighbour's, fading out over `ramp` px. + + Per row: the colour step across the seam, smoothed over ±blur rows; added in full at the seam and linearly + less further in. Shapes are untouched – only the light and tint the model got slightly wrong.""" + edge_x, other_x = (W - 1, 0) if left else (0, W - 1) + a, b = new.convert("RGB").load(), neighbour.convert("RGB").load() + step = [[b[other_x, y][c] - a[edge_x, y][c] for c in range(3)] for y in range(H)] + smooth = [[sum(step[j][c] for j in range(max(0, y - blur), min(H, y + blur + 1))) / + (min(H, y + blur + 1) - max(0, y - blur)) for c in range(3)] for y in range(H)] + out = new.convert("RGB").copy() + px = out.load() + for i in range(ramp): + x = W - 1 - i if left else i + k = 1 - i / ramp + for y in range(H): + r, g, bl = px[x, y] + dr, dg, db = smooth[y] + px[x, y] = (max(0, min(255, round(r + dr * k))), max(0, min(255, round(g + dg * k))), + max(0, min(255, round(bl + db * k)))) + return out + + +def column_step(a: Image.Image, ax: int, b: Image.Image, bx: int) -> float: + """Mean colour jump between column ax of a and column bx of b.""" + diff = ImageChops.difference(a.crop((ax, 0, ax + 1, H)).convert("RGB"), b.crop((bx, 0, bx + 1, H)).convert("RGB")) + return sum(sum(px) for px in diff.getdata()) / (3 * H) + + +def seam_step(new: Image.Image, neighbour: Image.Image, left: bool) -> float: + """The jump across the seam between the new chunk and its neighbour.""" + return column_step(new, W - 1, neighbour, 0) if left else column_step(neighbour, W - 1, new, 0) + + +def own_step(neighbour: Image.Image, left: bool) -> float: + """The jump between the neighbour's own two columns at the seam: what a seamless join looks like.""" + return column_step(neighbour, 0, neighbour, 1) if left else column_step(neighbour, W - 2, neighbour, W - 1) + + +FEATURE = ( + "This is one chunk of a side-scrolling stage. Change ONLY the transparent window: {brief} Everything outside " + "the window stays exactly as it is, and the window's edges join it seamlessly - the same light, palette, fine " + "2-pixel grain and scale. Under the window lies a flat LAYOUT GUIDE: red lines are the tops of walkable " + "surfaces, blue is water, brown is solid ground, grey is a built thing, yellow a ladder, purple a hollow. " + "Follow it exactly, but never draw its flat colours or lines. Strict side view, no characters, no text.") +MARGIN = 64 # the feature window also repaints this much on each side; min-error cuts join it back + + +def repaint(art: Image.Image, box: tuple[int, int, int, int], route: dict, prompt: str, refs_in: list[str], + model: str, name: str, guide_out: Path) -> Image.Image: + """Repaint one window of a W x H image over the layout guide, joined back on both sides by min-error cuts. + + The window is the box plus MARGIN px each side; inside the box the painting wins, in the margins the + least visible path decides where the painting ends and the old art begins.""" + x0, y0, x1, y1 = box + wx0, wx1 = max(0, x0 - MARGIN), min(W, x1 + MARGIN) + known = art.copy() + alpha = known.getchannel("A") + alpha.paste(0, (wx0, y0, wx1, y1)) + known.putalpha(alpha) + guide = guide_for(route, known, 0) + guide.save(guide_out) + with tempfile.TemporaryDirectory() as folder: + base, mask = Path(folder) / "guide.png", Path(folder) / "mask.png" + guide.save(base) + known.save(mask) + refs = [base, *(mb.load_ref(ref, Path(folder)) for ref in refs_in)] + png = mb.edit(name, {"model": model, "prompt": prompt, "size": f"{W}x{H}"}, refs, mb.api_key(), mask=mask) + if png is None: + sys.exit(1) + painted = Image.open(io.BytesIO(png)).convert("RGBA").resize((W, H)) + out = art.copy() + o, s = out.load(), painted.load() + left_cut = min_cut(painted.crop((wx0, y0, x0, y1)), art.crop((wx0, y0, x0, y1))) if x0 > wx0 else [] + right_cut = min_cut(art.crop((x1, y0, wx1, y1)), painted.crop((x1, y0, wx1, y1))) if wx1 > x1 else [] + for y in range(y0, y1): + start = wx0 + (left_cut[y - y0] if left_cut else 0) + end = x1 + (right_cut[y - y0] if right_cut else 0) + for x in range(start, end): + o[x, y] = s[x, y] + return out + + +def feature(region: Path, chunk: str, box: tuple[int, int, int, int], brief: str) -> None: + """Repaint one window of a grown chunk to put a route feature there (a gap, a log, a hill), following the route.""" + config = json.loads((region / "boards.json").read_text(encoding="utf-8")) + board = next(b for b in config["boards"] if b["id"] == chunk) + raw = ROOT / "tmp" / "boards" / region.name + art = Image.open(raw / f"{chunk}.png").convert("RGBA") + prompt = "\n\n".join([FEATURE.format(brief=brief), config["style"], config["people"]]) + out = repaint(art, box, board.get("route", {}), prompt, board["inputs"], config["model"], f"{chunk} feature", + raw / f"{chunk}--feature-guide.png") + out.convert("RGB").save(raw / f"{chunk}.png") + print(f"{chunk}: feature painted in {box} -> {(raw / (chunk + '.png')).relative_to(ROOT)}") + + +JOIN = ("This image shows two neighbouring chunks of a side-scrolling stage meeting in the middle. Repaint ONLY the " + "transparent window across the join so the two sides become one continuous place: the sky, the trees, the " + "path, the water and the foreground run through without a break, a cut or a dark column, in the same light, " + "palette, fine 2-pixel grain and scale. Keep the walkable path at the height the guide's red line shows. " + "Strict side view, no characters, no text.") + + +def join(region: Path, left_chunk: str, right_chunk: str, half: int = 160) -> None: + """Repaint a window across the seam of two grown chunks (both raw, both may change) and split them again.""" + config = json.loads((region / "boards.json").read_text(encoding="utf-8")) + board = next(b for b in config["boards"] if b["id"] == left_chunk) + raw = ROOT / "tmp" / "boards" / region.name + a, b = (Image.open(raw / f"{c}.png").convert("RGBA") for c in (left_chunk, right_chunk)) + strip = Image.new("RGBA", (W, H)) + strip.paste(a.crop((W // 2, 0, W, H)), (0, 0)) + strip.paste(b.crop((0, 0, W // 2, H)), (W // 2, 0)) + route = {"walk": [[[0, 441], [W, 441]]]} if "walk" in board.get("route", {}) else {} + prompt = "\n\n".join([JOIN, config["style"], config["people"]]) + out = repaint(strip, (W // 2 - half, 0, W // 2 + half, H), route, prompt, board["inputs"], config["model"], + f"{left_chunk}|{right_chunk} join", raw / f"{left_chunk}--join-guide.png") + a.paste(out.crop((0, 0, W // 2, H)), (W // 2, 0)) + b.paste(out.crop((W // 2, 0, W, H)), (0, 0)) + a.convert("RGB").save(raw / f"{left_chunk}.png") + b.convert("RGB").save(raw / f"{right_chunk}.png") + print(f"{left_chunk}|{right_chunk}: joined (seam step now {column_step(a, W - 1, b, 0):.1f})") + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == "join": + parser = argparse.ArgumentParser(description="Repaint the seam between two grown chunks.") + parser.add_argument("step") + parser.add_argument("region", type=Path) + parser.add_argument("left") + parser.add_argument("right") + args = parser.parse_args() + region = args.region if args.region.is_absolute() else ROOT / args.region + return join(region, args.left, args.right) + if len(sys.argv) > 1 and sys.argv[1] == "feature": + parser = argparse.ArgumentParser(description="Repaint one window of a grown chunk with a route feature.") + parser.add_argument("step") + parser.add_argument("region", type=Path) + parser.add_argument("chunk") + parser.add_argument("--box", required=True, help="x0,y0,x1,y1 in chunk pixels") + parser.add_argument("--brief", required=True) + args = parser.parse_args() + region = args.region if args.region.is_absolute() else ROOT / args.region + return feature(region, args.chunk, tuple(int(v) for v in args.box.split(",")), args.brief) + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("region", type=Path) + parser.add_argument("source") + parser.add_argument("direction", choices=["left", "right"]) + parser.add_argument("new") + args = parser.parse_args() + region = args.region if args.region.is_absolute() else ROOT / args.region + config = json.loads((region / "boards.json").read_text(encoding="utf-8")) + board = next((b for b in config["boards"] if b["id"] == args.new), None) + if board is None: + sys.exit(f"{args.new}: add its board (id, brief, route, inputs) to {region.name}/boards.json first") + raw = ROOT / "tmp" / "boards" / region.name + raw.mkdir(parents=True, exist_ok=True) + source = raw / f"{args.source}.png" + if not source.exists(): + source = region / "images" / f"{args.source}.png" + neighbour = Image.open(source).convert("RGBA") + left = args.direction == "left" + prompt = "\n\n".join([PROMPT.format(edge="right" if left else "left", + where="to the left of" if left else "to the right of", brief=board["prompt"]), + config["style"], config["people"]]) + strip, done = neighbour, 0 # the known art, growing piece by piece; px of the new chunk painted so far + for n, piece in enumerate(PIECES, 1): + overlap = OVERLAP if n > 1 else 0 # the first piece meets the neighbour, which stays exact + known = known_canvas(strip, left, piece, overlap) # the mask: the overlap is open for painting + guide = guide_for(board.get("route", {}), known_canvas(strip, left, piece), offset(left, done, piece)) + guide.save(raw / f"{args.new}--guide-{n}.png") + with tempfile.TemporaryDirectory() as folder: + base, mask = Path(folder) / "guide.png", Path(folder) / "mask.png" + guide.save(base) + known.save(mask) # transparent = paint here + refs = [base, *(mb.load_ref(ref, Path(folder)) for ref in board["inputs"])] + png = mb.edit(f"{args.new} piece {n}", {"model": config["model"], "prompt": prompt, "size": f"{W}x{H}"}, + refs, mb.api_key(), mask=mask) + if png is None: + sys.exit(1) + painted = Image.open(io.BytesIO(png)).convert("RGBA").resize((W, H)) + painted.save(raw / f"{args.new}--piece-{n}.png") + part = painted.crop((0, 0, piece, H)) if left else painted.crop((W - piece, 0, W, H)) + grown = Image.new("RGBA", (strip.width + piece, H)) + grown.paste(part, (0, 0) if left else (strip.width, 0)) + grown.paste(strip, (piece, 0) if left else (0, 0)) + if overlap: # join the new piece to the earlier one along the least visible path through the overlap + ox = piece if left else strip.width - overlap # the overlap in `grown` + px0 = piece if left else W - piece - overlap # the same columns in `painted` + new_side = painted.crop((px0, 0, px0 + overlap, H)) + old_side = grown.crop((ox, 0, ox + overlap, H)) + cut = min_cut(new_side, old_side) + g, s = grown.load(), new_side.load() + for y, c in enumerate(cut): + for x in (range(c) if left else range(c, overlap)): + g[ox + x, y] = s[x, y] + strip, done = grown, done + piece + new = (strip.crop((0, 0, W, H)) if left else strip.crop((strip.width - W, 0, strip.width, H))).convert("RGB") + before = seam_step(new, neighbour, left) + new = match_seam(new, neighbour, left) + new.save(raw / f"{args.new}.png") + strip.paste(new, (0, 0) if left else (strip.width - W, 0)) + strip.convert("RGB").save(raw / f"{args.new}--with-neighbour.png") + print(f"{args.new}: grown {args.direction} of {args.source} -> {(raw / (args.new + '.png')).relative_to(ROOT)} " + f"(seam step {before:.1f} -> {seam_step(new, neighbour, left):.1f}; " + f"the neighbour's own column step is {own_step(neighbour, left):.1f})") + + +if __name__ == "__main__": + main() diff --git a/scripts/split_layers.py b/scripts/split_layers.py index b3f6c6f..20de8b5 100644 --- a/scripts/split_layers.py +++ b/scripts/split_layers.py @@ -35,6 +35,11 @@ LAYERS = ("00-sky", "01-far", "02-mid", "03-rear", "04-water", "05-play", "06-near", "07-foreground") +def filled(path: Path) -> bool: + """depth_layers.py's parallax plates (..filled.png) live next to the split; never ours to remove.""" + return path.name.endswith(".filled.png") + + class SplitError(Exception): """The config or the files on disk do not add up; the message says where.""" @@ -143,7 +148,7 @@ def run(region: Path, check: bool) -> list[str]: raise SplitError(f"{chunk_id}: its states have different sizes {sizes}") size = sizes.pop() if check: - on_disk = sorted(p.name for p in (region / "layers").glob(f"{chunk_id}.*.png")) + on_disk = sorted(p.name for p in (region / "layers").glob(f"{chunk_id}.*.png") if not filled(p)) for state, source in sources.items(): if not same(stack(load(region, chunk_id, state, on_disk), size), source): raise SplitError(f"{chunk_id}.{state}: the committed layers no longer rebuild " @@ -160,7 +165,7 @@ def run(region: Path, check: bool) -> list[str]: out = region / "layers" out.mkdir(exist_ok=True) for old in out.glob(f"{chunk_id}.*.png"): - if old.name not in files: + if old.name not in files and not filled(old): old.unlink() for name, image in files.items(): image.save(out / name, optimize=True) diff --git a/scripts/test_compare.py b/scripts/test_compare.py index c9bf5c8..b5e2c61 100644 --- a/scripts/test_compare.py +++ b/scripts/test_compare.py @@ -37,6 +37,14 @@ def test_lineup_reports_against_documented_heights(self): self.assertIn('hero-move:idle-00: 20x76 px, documented 76 px (+0 %)', report) self.assertIn('enemy-drone:hover-00: 20x18 px, documented 45 px (-56 %)', report) + def test_lineup_scales_a_frame_and_its_documented_height(self): + sheet = Image.new('RGBA', (20, 31)) + sheet.paste((40, 90, 160, 255), (2, 4, 12, 31)) # dexter: 10x27, the documented plush + sheet.save(self.base / 'dexter.png') + (self.base / 'dexter.json').write_text(json.dumps({'frames': {'idle-00': {'frame': {'x': 0, 'y': 0, 'w': 20, 'h': 31}}}}), encoding='utf-8') + _, report = cp.lineup(['dexter:idle-00*1.6']) + self.assertIn('dexter:idle-00*1.6: 16x43 px, documented 43 px (+0 %)', report) + def test_unknown_frame_stops(self): Image.new('RGBA', (10, 10)).save(self.base / 'fx.png') (self.base / 'fx.json').write_text(json.dumps({'frames': {}}), encoding='utf-8') diff --git a/scripts/test_depth_layers.py b/scripts/test_depth_layers.py new file mode 100644 index 0000000..16d57e4 --- /dev/null +++ b/scripts/test_depth_layers.py @@ -0,0 +1,113 @@ +"""Offline checks for depth_layers.py: snapping the key, voting specks away, the sky edge, the masks it writes.""" +from pathlib import Path +import tempfile +import unittest + +from PIL import Image + +import depth_layers as dl + +SKY, FAR, REAR = dl.DEPTH["sky"][1], dl.DEPTH["far"][1], dl.DEPTH["rear"][1] + + +class DepthLayerTests(unittest.TestCase): + def test_snap_takes_the_nearest_key_colour(self): + key = Image.new("RGB", (3, 1)) + key.putdata([(10, 240, 250), (20, 10, 230), (250, 250, 30)]) # off-key cyan, blue, yellow + self.assertEqual(dl.snap(key), ["sky", "far", "rear"]) + + def test_vote_removes_a_speck(self): + bands = ["sky"] * 81 + bands[40] = "far" # one stray pixel in the middle of a 9x9 sky + self.assertEqual(dl.vote(bands, (9, 9))[40], "sky") + + def test_refine_moves_a_sky_fringe_and_a_cut_treetop(self): + # 6 wide, 4 tall: sky on top, far trees below. The key put a tree's top pixel in the sky (x=2, y=1) + # and a blue sky pixel into the trees (x=4, y=2). + art = Image.new("RGB", (6, 4), (70, 140, 230)) + for x in range(6): + for y in (2, 3): + art.putpixel((x, y), (90, 100, 80)) + art.putpixel((2, 1), (90, 100, 80)) + art.putpixel((4, 2), (70, 140, 230)) + bands = ["sky"] * 12 + ["far"] * 12 + out = dl.refine(bands, art, reach=2) + self.assertEqual(out[1 * 6 + 2], "far") + self.assertEqual(out[2 * 6 + 4], "sky") + self.assertEqual(out[0], "sky") + + def test_hazy_treetops_are_not_sky(self): + self.assertTrue(dl.skylike((50, 130, 240))) + self.assertTrue(dl.skylike((240, 240, 235))) + self.assertFalse(dl.skylike((80, 120, 180))) + self.assertFalse(dl.skylike((100, 120, 140))) + + def test_refine_leaves_cloud_rims_in_the_sky(self): + # A grey cloud rim far from any tree line stays sky. + art = Image.new("RGB", (20, 20), (70, 140, 230)) + art.putpixel((10, 3), (150, 150, 150)) + bands = ["sky"] * 400 + self.assertEqual(dl.refine(bands, art)[3 * 20 + 10], "sky") + + def test_skyline_spans_every_column(self): + # 8 wide, 5 tall: the layer shows at x=2 (top 1) and x=5 (top 3); everything else is hidden. + own = [False] * 40 + own[1 * 8 + 2] = own[3 * 8 + 5] = True + self.assertEqual(dl.skyline(own, 8, 5, smooth=0), [5, 5, 1, 1, 3, 3, 5, 5]) # no plate past the ends + self.assertEqual(dl.skyline(own, 8, 5, smooth=1)[4], 1) + + def test_walk_lines_turn_a_small_step_into_a_ramp_and_split_at_a_cliff(self): + # 90 wide, 80 tall: floor y=60 for x<30, a 10 px step up to y=50 until x=60, a 30 px cliff up to y=20. + w, h = 90, 80 + bands = ["sky"] * (w * h) + for x in range(w): + top = 60 if x < 30 else 50 if x < 60 else 20 + for y in range(top, h): + bands[y * w + x] = "play" + lines = dl.walk_lines(bands, w, h, step=12, shortest=10) + self.assertEqual(len(lines), 2) + first = lines[0] + self.assertEqual((first[0], first[-1]), ([0, 60], [60, 50])) + self.assertEqual(first, [[0, 60], [23, 60], [36, 50], [60, 50]]) # the step became a ramp, ~38° + self.assertEqual(lines[1], [[60, 20], [90, 20]]) + + def test_walk_lines_find_a_hollow_under_the_path(self): + # The path at y=10 across, and a hollow floor at y=30 under x=20..40 (sky between). + w, h = 60, 40 + bands = ["play"] * (w * h) + for x in range(w): + for y in range(10): + bands[y * w + x] = "sky" + for x in range(20, 40): + for y in range(20, 30): + bands[y * w + x] = "rear" + self.assertEqual(dl.walk_lines(bands, w, h, step=5, shortest=10), [[[0, 10], [60, 10]]]) # top only + lines = dl.walk_lines(bands, w, h, step=5, shortest=10, every=True) + self.assertIn([[0, 10], [60, 10]], lines) + self.assertIn([[20, 30], [40, 30]], lines) + + def test_rdp_keeps_corners_only(self): + pts = [(x, 10) for x in range(10)] + [(10 + x, 10 - x) for x in range(10)] + self.assertEqual(dl.rdp(pts, 1.0), [(0, 10), (10, 10), (19, 1)]) + + def test_region_plate_places_each_chunk_at_its_scroll_offset(self): + # Two 100x4 plates at scroll 0.5: the strip is 150 wide, the second plate starts at 50. + a = Image.new("RGBA", (100, 4), (255, 0, 0, 255)) + b = Image.new("RGBA", (100, 4), (0, 0, 255, 255)) + strip = dl.region_plate([a, b], 0.5, window=20) + self.assertEqual(strip.size, (150, 4)) + self.assertEqual(strip.getpixel((10, 1)), (255, 0, 0, 255)) # chunk 0 at rest + self.assertEqual(strip.getpixel((140, 1)), (0, 0, 255, 255)) # chunk 1 past the window + + def test_masks_are_written_for_the_parallax_bands_only(self): + with tempfile.TemporaryDirectory() as tmp: + region = Path(tmp) + bands = ["sky", "far", "mid", "rear", "play", "water"] + written = dl.write_masks(region, "c-0-0", bands, (6, 1)) + self.assertEqual([p.name for p in written], ["c-0-0.00-sky.png", "c-0-0.01-far.png", "c-0-0.02-mid.png"]) + alpha = list(Image.open(written[1]).getchannel("A").getdata()) + self.assertEqual(alpha, [0, 255, 0, 0, 0, 0]) + + +if __name__ == "__main__": + unittest.main()