diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad6fb15..7de74b7 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 + F4 (dev) | Dexter at the coffee machine: bow, sweat, the pocket computer and a muffin, then he absorbs a pool; Tab opens the map after | | 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) | diff --git a/art/inventory.csv b/art/inventory.csv new file mode 100644 index 0000000..e9aab2e --- /dev/null +++ b/art/inventory.csv @@ -0,0 +1,41 @@ +id,kind,used by,game size px (measured),frames/anims,source refs,status,notes +hero-move,sprite,"hero (3), prologue (4)",76 body in 80 cell (docs/04),idle 6 · idle-walk 2 · walk 8,hero sheet + pose photos + style master,approved,game/public/assets/sprites/hero-move.* +hero-run,sprite,hero (3),76 (run scale 1.12 → ≈70 leaning),walk-run 2 · run-start 3 · run 8,hero sheet + pose photos,approved,stride pacing #164 +hero-idle,sprite,hero (3),76,look-around 4 · check-tablet 4 · check-wounds 4,hero sheet + pose photos,approved, +hero-air,sprite,hero (3),76 (ref jump-08),jump 9 · fall 3 · hard-landing 4 · ladder 4,hero sheet + pose photos,approved, +hero-hurt,sprite,"hero (3), crossing (4)",76,hit 8 · knockback 3 · interact 3 · pick-up 4 · look-at-device 3,hero sheet + pose photos,approved,the shove and the pull reuse hit + knockback +hero-death,sprite,hero (3),76,death 12,hero sheet,approved, +chair-side,sprite,hero (3) – Region 1,76,sweep-a 8 · sweep-b 8 · recoil 2 · chair-pickup 1,hero sheet + chair photos,approved,not used in the prologue +chair-overhead,sprite,hero (3) – Region 1,76,up 6 · slam 7 · pogo 4,hero sheet + chair photos,approved, +chair-block,sprite,hero (3) – Region 1,76,guard-up 4 · guard-hold 3 · guard-impact 4 · guard-break 4,hero sheet + chair photos,approved, +dexter,sprite,prologue (4),27 (191/547 of the hero in dexter-stages),idle 3 · hop 4 · walk 6 · point 3 · shove 4,dexter-stages crops + merch photos,approved,hop = hop off the bench +dexter-acts,sprite,prologue (4),27,beckon 3 · bow 1 · sweat 2 · proud 1 · creepy 1 · absorb 4,dexter-stages crops + merch photos,approved, +dexter-prologue,sprite,prologue (4),27,look-back 3 · wait 3 · sit 2,approved dexter sheet first + dexter-stages crops + merch,approved,"new 24 Oct: sit on the bench, look back, wait. Approved by Alexander 24 Oct" +fx-leaves,fx,atmosphere (2),8 (6–8 per leaf),birch 5 · maple 5 · oak 5,fx sheet (anchor) + hg-4-0 floating leaves + campus collage crops + style master,exported,new 24 Oct; awaiting review +fx-ground,fx,hero grounding (3),12 dust (others relative),dust 4 · grass 4 · leaf-kick 4 · splash 4,fx sheet (anchor) + hg-0-0 path + collage grass + hg-4-0 shore,exported,new 24 Oct; awaiting review +fx-water,fx,atmosphere (2),8 lily pad (ripple and sparkle relative),lily-bob 4 · ripple 4 · sparkle 4,fx sheet (anchor) + hg-4-0 / hg-0-0 pond crops,exported,new 24 Oct; awaiting review +fx,fx,enemies – Region 1; Dexter absorb,20,black-fluid 6,cast + style master,approved, +pocket-computer,sprite,prologue pickup? / HUD,10 closed,bob 4 · open 3,hud direction,approved, +mensa-food,prop,prologue secret (4.3: muffin behind the grass),13–18,muffin 1 · cake 1 · wafer 1 · leberkaese 1,coffee-machine sheet + food photos,approved,sparkle drawn in code +coffee-machine,prop,save point – Region 1,80,idle 3 · pour 4,coffee machine photos,approved, +enemy-rover,sprite,Region 1,25,idle 4 · drive 6 · charge 4 · wreck 5,photos + cast,approved, +enemy-crab,sprite,Region 1,46,idle 4 · walk 8 · stab 5 · collapse 5,photos + cast,approved, +enemy-drone,sprite,Region 1,47 span,hover 4 · dive 3 · crash 5 · screw 3,photos + cast,approved, +boss-jenny,sprite,Region 1 boss,≈270 long (134 board height),rev 4 · charge 4 · crash 4,boss-jenny direction crop,approved, +boss-jenny-2,sprite,Region 1 boss,159,stunned 3 · reboot 2 · debris-1..5 2 each,boss-jenny direction crop,approved, +ui atlas,ui,HUD + pocket computer,"cells 36, glitch 28, boss bar 560, frame ≈800",31 pieces (cells · cracks · orbs · boss bar · frame · CRT · map tiles · icons),hud direction crops,approved,game/public/assets/ui/ui.png + ui.json + ui-layout.json +menu-key,ui,loading + menu (1),1280×720,1 still,style master + hg-4-0 + hg-0-0 + hidden-garden direction,exported,game/public/assets/ui/menu-key.png; no text – the game draws DEGGSTER.EXE +loading motif,ui,loading (1),–,–,–,n/a,"not needed: the loading screen uses the pocket-computer UI atlas (boot/LoadingView.ts, #167)" +crash window THD.exe,ui,crossing (4),–,–,anomaly-language direction,approved,"drawn in code with real text: game/src/ui/crashWindow.ts (THD.exe crash, To be continued, 404)" +404 void / glitch FX,fx,crossing (4),screen,–,anomaly-language direction,approved,"drawn in code: game/src/fx/anomaly.ts (addImageCut, addPixelStretch, add404Void), used by Crossing 404 and Ending" +hidden-garden 00-sky,stage layer,renderer (2),2160×720 tiles ×2,scroll 0.1,hg chunks via depth_layers.py region,approved,"in review (docs/07); art/stages/hidden-garden/layers; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 01-far,stage layer,renderer (2),2160×720 tiles ×2,scroll 0.25,hg chunks,approved,"in review; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 02-mid,stage layer,renderer (2),6992 wide in 4 tiles,scroll 0.6,hg chunks,approved,"in review; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 03-rear,stage layer,renderer (2),2160×720 ×5 chunks,scroll 1.0,hg chunks,approved,"in review; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 04-water,stage layer,renderer (2) – reflection + ripples,2160×720 ×5,scroll 1.0,hg chunks,approved,"water band for atmosphere.json; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 05-play,stage layer,"renderer (2), hero (3)",2160×720 ×5,scroll 1.0,hg chunks,approved,"collision traced from the key's play band; approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden 06,stage layer,renderer (2),–,–,–,n/a,not needed: the foreground blur and occlusion use z 7 (stage/depth.ts) +hidden-garden 07-foreground,stage layer,renderer (2) – blur over hero,tiles ×5,scroll 1.2,hg chunks,approved,"approved by Alexander 24 Oct (""I am happy with garden"")" +hidden-garden bench,stage prop,prologue (4) – Dexter sits,painted in hg-0-0 05-play,–,hg-0-0,approved,part of the plate (seat ≈ x 700–880 in hg-0-0); Dexter's sit frames are placed on it +rusty-creek layers,stage layer,Region 1,3 chunks × 7 layers,clean + anomaly,rusty-creek sources,approved,art ✅; collision ⬜ (docs/07) +surfaces / atmosphere data,data,"(2), (3)",–,–,–,approved,"art/stages/hidden-garden/{atmosphere,surfaces}.json and rusty-creek/surfaces.json (#166, #168), exported with the stage" diff --git a/art/sprites/README.md b/art/sprites/README.md index fd56ac4..eb87be9 100644 --- a/art/sprites/README.md +++ b/art/sprites/README.md @@ -12,4 +12,8 @@ Sprite reference boards: the hero sheet (approved) and the animation, enemy, ite | [![Enemy · drone](images/enemy-drone-preview.png)](enemy-drone.md)
**[Enemy · drone](enemy-drone.md)** | [![Enemy · rover](images/enemy-rover-preview.png)](enemy-rover.md)
**[Enemy · rover](enemy-rover.md)** | | [![Enemy · crab](images/enemy-crab-preview.png)](enemy-crab.md)
**[Enemy · crab](enemy-crab.md)** | [![FX · black fluid](images/fx-preview.png)](fx.md)
**[FX · black fluid](fx.md)** | | [![Boss · Jenny, attacks](images/boss-jenny-preview.png)](boss-jenny.md)
**[Boss · Jenny, attacks](boss-jenny.md)** | [![Boss · Jenny, stunned and debris](images/boss-jenny-2-preview.png)](boss-jenny-2.md)
**[Boss · Jenny, stunned and debris](boss-jenny-2.md)** | -| [![Dexter · moves](images/dexter-preview.png)](dexter.md)
**[Dexter · moves](dexter.md)** | [![Dexter · gestures](images/dexter-acts-preview.png)](dexter-acts.md)
**[Dexter · gestures](dexter-acts.md)** | +| [![Item · pocket computer](images/pocket-computer-preview.png)](pocket-computer.md)
**[Item · pocket computer](pocket-computer.md)** | [![Mensa · food pickups](images/mensa-food-preview.png)](mensa-food.md)
**[Mensa · food pickups](mensa-food.md)** | +| [![Prop · coffee machine (save point)](images/coffee-machine-preview.png)](coffee-machine.md)
**[Prop · coffee machine (save point)](coffee-machine.md)** | [![Dexter · moves](images/dexter-preview.png)](dexter.md)
**[Dexter · moves](dexter.md)** | +| [![Dexter · gestures](images/dexter-acts-preview.png)](dexter-acts.md)
**[Dexter · gestures](dexter-acts.md)** | [![FX · falling leaves](images/fx-leaves-preview.png)](fx-leaves.md)
**[FX · falling leaves](fx-leaves.md)** | +| [![FX · footstep puffs](images/fx-ground-preview.png)](fx-ground.md)
**[FX · footstep puffs](fx-ground.md)** | [![FX · pond ripples and lily pad](images/fx-water-preview.png)](fx-water.md)
**[FX · pond ripples and lily pad](fx-water.md)** | +| [![Dexter · prologue additions](images/dexter-prologue-preview.png)](dexter-prologue.md)
**[Dexter · prologue additions](dexter-prologue.md)** | [![Menu · the Hidden Garden at first light](images/menu-key-preview.png)](menu-key.md)
**[Menu · the Hidden Garden at first light](menu-key.md)** | diff --git a/art/sprites/boards.json b/art/sprites/boards.json index 3abf8f5..8dd6819 100644 --- a/art/sprites/boards.json +++ b/art/sprites/boards.json @@ -46,7 +46,8 @@ [ { "anim": "walk", - "frames": 8 + "frames": 8, + "stride": 43 } ] ] @@ -122,7 +123,8 @@ { "anim": "run", "frames": 8, - "scale": 1.12 + "scale": 1.12, + "stride": 82 } ] ] @@ -672,24 +674,98 @@ "inputs": [ "ref2:20261023_125955.jpg", "ref2:20261023_130000.jpg", + "ref2:20261023_130003.jpg", "repo:art/direction/images/style-master-preview.png" ], - "prompt": "Key item: the aluminium experiment suitcase from the photos (silver case, black screen in the lid, circuit board, coloured buttons and plug sockets inside), shrunk into a hand-held 'pocket computer' the hero carries. Top row: the item as a pickup sprite - closed case glowing softly, 4-frame floating bob; then opened, screen lighting up, 3 frames. Bottom area: three mock in-game screens as the device would show them, drawn in the same pixel style with a green-on-black CRT look: (1) MAP screen - a rough grid map of connected campus rooms with a blinking player dot and unexplored rooms dark; (2) ARCHIVE screen - a list of collected notes as icons (no readable words); (3) MENU screen - icons for items, map, archive, settings. Screens use icons and blocks only, no readable words." + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Key item sprite sheet: the aluminium experiment suitcase from the photos, copied exactly - a silver hard case with rounded corners and a carry handle, a black screen in the lid, a white circuit tray inside with a green circuit board, rows of yellow and green buttons, plug sockets - shrunk into a hand-held 'pocket computer'. Side-on three-quarter view. 2 rows. Row 1, exactly 4 frames: the case CLOSED, lying flat, with a soft green glow at its seam, bobbing up and down a little (frames 1-4 of a gentle float). Row 2, exactly 3 frames: the case OPENING - the lid lifts halfway, the lid fully open with the dark screen, the screen lit up green with a simple block grid on it (no words). Every frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no scenery.", + "atlas": { + "height": 10, + "rows": [ + [ + { + "anim": "bob", + "frames": 4, + "float": true + } + ], + [ + { + "anim": "open", + "frames": 3, + "loop": false + } + ] + ] + }, + "review": "✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. Closed case 19 × 10 px (≈ 45 cm at the hero's 76 px = 183 cm): the photographed suitcase, shrunk to carry; open with the green screen 22 px tall. The map screens are the approved UI atlas (#127), so this board has no screen mock-ups." }, { "id": "mensa-food", "title": "Mensa · food pickups", "factor": 2, "inputs": [ + "repo:art/sprites/images/coffee-machine-preview.png", "ref2:20261023_125038.jpg", - "ref2:20261023_125042.jpg", "ref2:20261023_125049.jpg", "ref2:20261023_125100.jpg", "ref2:20261023_125259.jpg", - "ref2:20261023_125302.jpg", "repo:art/direction/images/style-master-preview.png" ], - "prompt": "Healing pickup sprites, copying each photographed item EXACTLY as it really looks, NOT an idealized product shot: the muffin squashed inside wrinkled, glare-streaked cling film with a creased sticker; the marbled chocolate-vanilla cake slice in crumpled cling film on a white paper; the chocolate-coated wafer in its yellow-red foil wrapper (no readable brand); the Leberkaese roll in a torn brown paper bag with the meat slice sticking out unevenly. Row 1: each item large, slightly lopsided, with the real imperfections and reflections. Row 2: each item as a small in-game pickup (about 16 pixels) with a 2-frame sparkle. Row 3: a 'being eaten' sequence for the Leberkaese roll - whole, one bite, two bites, crumbs - 4 frames." + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Image 1 is an approved sprite sheet from this game: draw this sheet in exactly that pixel-art look and at that sheet layout - small sprites in a row on magenta. Pickup sprite sheet of FOUR DIFFERENT cafeteria foods, one per FOOD photo (images 2 to 5), in that order - never repeat an item: (1) the chocolate MUFFIN from image 2, squashed in wrinkled, glare-streaked cling film; (2) the marbled chocolate-vanilla CAKE SLICE from image 3, a flat rectangular slab in crumpled cling film on a white paper; (3) the chocolate WAFER bar from image 4 in its flat yellow-red foil wrapper (no readable brand); (4) the LEBERKAESE ROLL from image 5, a round crusty kaiser roll with a thick pink meat slice sticking out, half in a torn brown paper bag. Copy each EXACTLY as it really looks, not an idealized product shot. All four at the SAME pixel scale, about the same size as each other, small pickups resting on the ground line. 1 row, exactly 4 frames: muffin, cake slice, wafer, Leberkaese roll (the game adds the pickup sparkle). The four foods must look clearly different from each other in shape and colour. Every frame stands on the SAME invisible ground line at the SAME scale. Frames are evenly spaced from left to right with wide clear magenta gaps between neighbours. Count the frames: the row has EXACTLY 4. No people, no scenery.", + "atlas": { + "height": 16, + "rows": [ + [ + { + "anim": "muffin", + "frames": 1 + }, + { + "anim": "cake", + "frames": 1 + }, + { + "anim": "wafer", + "frames": 1 + }, + { + "anim": "leberkaese", + "frames": 1 + } + ] + ] + }, + "review": "✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. Third draw: the first two copied only the muffin; anchoring on the approved coffee-machine sheet gave four distinct items. One frame each (the model ignored the sparkle frames; the game adds the sparkle). Muffin 16 × 14, cake 18 × 14, wafer 17 × 13, Leberkäse roll 18 × 18 px – around the documented 16 px." + }, + { + "id": "coffee-machine", + "title": "Prop · coffee machine (save point)", + "factor": 2, + "inputs": [ + "ref2:20261023_125138.jpg", + "ref2:20261023_125141.jpg", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Prop sprite sheet: the self-service coffee machine from the first two photos, copied exactly - black and brushed-steel body, three steel bean hoppers on top, a big glowing touchscreen with a grid of drink buttons, a bright green light strip down its left side, a steel drip tray and spout - standing on its own black base cabinet so the whole unit is a little TALLER than an adult (about 190 cm) and about half as wide as it is tall. Real wear: fingerprints, a coffee stain on the tray, a scuffed cabinet. Strict front-three-quarter side view, the same in every frame. 2 rows. Row 1, exactly 3 frames: IDLE, the screen glowing steadily, then the screen FLICKERING (a band of static across it), then the screen showing a still dark POND image for one frame (an anomaly hint). Row 2, exactly 4 frames: POUR, a white cafeteria mug under the spout fills with coffee in 4 steps, a little steam on the last. Every frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no scenery.", + "atlas": { + "height": 80, + "rows": [ + [ + { + "anim": "idle", + "frames": 3 + } + ], + [ + { + "anim": "pour", + "frames": 4, + "loop": false + } + ] + ] + }, + "review": "✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. 42 × 80 px in the game: fills the 40 × 80 coffee-1 box in Rusty Creek, a little taller than the 76 px hero on its base cabinet, as in the photos. Lineup in the stage: previews/coffee-visit-lineup.png." }, { "id": "mensa-drinks", @@ -818,6 +894,199 @@ }, "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": "fx-leaves", + "title": "FX · falling leaves", + "factor": 2, + "inputs": [ + "repo:art/sprites/images/fx-preview.png", + "repo:art/stages/hidden-garden/images/hg-4-0.png@520,470,900,230", + "repo:references/campus/images/hidden-gardens.png@435,700,630,240", + "repo:references/campus/images/hidden-gardens.png@20,1010,510,310", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet: single FALLING AUTUMN LEAVES that tumble through the Hidden Garden, copied from the leaves in images 2 to 4 (the floating maple leaves on the pond, the yellow leaves in the grass, the willow screen). Each leaf is drawn LARGE and simple because the game shows it only 8 pixels tall, in front of orange and dark autumn trees: a THICK very dark brown-black outline all the way round (as thick as the stem), a bright, saturated, LIGHT fill (a pale lemon birch, a vivid red-orange maple, a warm tan oak) with one light highlight side, no fine veins, a short thick stem. The silhouette must read against both bright orange foliage and dark shadows. 3 rows, each row is ONE leaf kind tumbling as it falls, seen from the side: the leaf turns over in the air - flat face, tilted, edge-on thin sliver, tilted the other way, the back face. Row 1, exactly 5 frames: a small oval BIRCH leaf, bright lemon yellow. Row 2, exactly 5 frames: a five-pointed MAPLE leaf, orange to red. Row 3, exactly 5 frames: a round-lobed OAK leaf, russet brown. All leaves the SAME size, centred on one line. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery.", + "atlas": { + "height": 8, + "rows": [ + [ + { + "anim": "birch", + "frames": 5, + "fps": 8, + "float": true + } + ], + [ + { + "anim": "maple", + "frames": 5, + "fps": 8, + "float": true + } + ], + [ + { + "anim": "oak", + "frames": 5, + "fps": 8, + "float": true + } + ] + ] + }, + "review": "🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Second draw: the first take's thin outlines and fine veins melted into the orange trees at 8 px (6 of 8 test frames LOW in scripts/readability.py); thick dark outlines and lighter fills brought it to 2 of 8. 7–10 px in the game." + }, + { + "id": "fx-ground", + "title": "FX · footstep puffs", + "factor": 2, + "inputs": [ + "repo:art/sprites/images/fx-preview.png", + "repo:art/stages/hidden-garden/images/hg-0-0.png@560,380,560,100", + "repo:references/campus/images/hidden-gardens.png@435,700,630,240", + "repo:art/stages/hidden-garden/images/hg-4-0.png@0,440,700,120", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet: small puffs kicked up by a footstep or a landing on the ground of the Hidden Garden (images 2 to 4: the stone-slab path, the grass with yellow leaves, the pond edge). Every effect sits ON the ground line and bursts up and outward from one point, seen from the side, low and wide (about twice as wide as tall), and fades out by breaking into specks. 4 rows. Row 1, exactly 4 frames: DUST, a soft warm grey-beige dust puff - small burst, round cloud, wider thinner cloud, a few last specks. Row 2, exactly 4 frames: GRASS, a few green and straw-yellow grass blades and seeds flicked up and falling back. Row 3, exactly 4 frames: LEAF KICK, three or four tiny yellow and orange leaves kicked up in an arc and settling. Row 4, exactly 4 frames: SPLASH, a small blue-white water splash with droplets, crown, droplets falling, a flat ring. All effects at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery.", + "atlas": { + "height": 12, + "rows": [ + [ + { + "anim": "dust", + "frames": 4, + "loop": false, + "even": true + } + ], + [ + { + "anim": "grass", + "frames": 4, + "loop": false, + "even": true + } + ], + [ + { + "anim": "leaf-kick", + "frames": 4, + "loop": false, + "even": true + } + ], + [ + { + "anim": "splash", + "frames": 4, + "loop": false, + "even": true + } + ] + ] + }, + "review": "🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Dust 21–30 × 5–12 px, grass and leaf kick ≈ 25 × 14, splash ≈ 25 × 16 – about one step wide next to the 76 px hero. Rows are `even` (they break into specks)." + }, + { + "id": "fx-water", + "title": "FX · pond ripples and lily pad", + "factor": 2, + "inputs": [ + "repo:art/sprites/images/fx-preview.png", + "repo:art/stages/hidden-garden/images/hg-4-0.png@280,560,1000,160", + "repo:art/stages/hidden-garden/images/hg-0-0.png@1000,480,1000,180", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet for the Hidden Garden pond (images 2 and 3: its lily pads, its blue water with warm orange reflections and white glints). 3 rows. Row 1, exactly 4 frames: LILY PAD BOB, one dark olive-green round lily pad with a notch, seen almost edge-on from the side like in image 2 (a flat ellipse about four times as wide as tall), bobbing on the water - level, tipped a little left, level and a little lower, tipped a little right. Row 2, exactly 4 frames: RIPPLE, a ring on the water seen from the side as a flat ellipse made of thin light-blue and white pixel lines, growing wider and fainter: small ring, wider ring, wide broken ring, a few faint dashes. Row 3, exactly 4 frames: SPARKLE, a sun glint on the water, a small white-gold four-point twinkle that grows, flares, shrinks and fades. Keep the lily pads and ripples the SAME width as each other. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery.", + "atlas": { + "height": 16, + "rows": [ + [ + { + "anim": "lily-bob", + "frames": 4, + "fps": 4 + } + ], + [ + { + "anim": "ripple", + "frames": 4, + "fps": 8, + "loop": false, + "even": true + } + ], + [ + { + "anim": "sparkle", + "frames": 4, + "fps": 10, + "loop": false, + "even": true, + "scale": 0.5 + } + ] + ], + "_measured": "Lily pads in hg-4-0 (x 670, y 660) measure about 68 x 14-17 px; height 16 on lily-bob-01 gives 58 x 16. The sparkle is halved (scale 0.5) to a 12 px glint." + }, + "review": "🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Lily pad 58 × 14–16 px, measured against the painted pads in hg-4-0 (≈ 68 × 14–17); ripple 37–71 px wide; sparkle halved to a 12 px glint. The plate's pads are browner than this olive pad – tint in code if it stands out." + }, + { + "id": "dexter-prologue", + "title": "Dexter · prologue additions", + "factor": 2, + "inputs": [ + "repo:art/sprites/images/dexter-preview.png", + "repo:art/direction/images/dexter-stages-preview.png@535,585,125,205", + "repo:art/direction/images/dexter-stages-preview.png@290,225,390,570", + "merch:Deggster-9487_1280x1280.jpg", + "repo:art/direction/images/style-master-preview.png" + ], + "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the feet), with NO text at all - no titles, no captions, no frame numbers, no labels under the figures: DEXTER, the university's black panther mascot as the small PLUSH toy from the first two images (an approved sprite sheet of him from this game, then his approved design) - copy that plush exactly: CHUNKY toy proportions, a BIG round head about 45 % of his whole height, wide rounded ears, big ice-blue eyes, an orange nose, small white fangs, long white whiskers, a short soft body in a light-grey THD crest T-shirt and light-grey shorts, short stubby black arms and legs, big black paw feet with white claw dots, a short curled tail. Black velvet fur with soft dark-grey highlights so the shapes read at small size. A cute, slightly too-alive plush that moves by itself - never slim, never a cat on two legs, never a human body. Strict side view, facing RIGHT, the head turned a little toward the viewer so both eyes show, the same size in every frame as on the approved sheet. He never talks: everything is body language. These are the poses his approved sheet (image 1) is missing for the first quest. 2 rows. Row 1, exactly 6 frames: LOOK BACK, 3 frames (standing, facing right, he turns his big head back over his shoulder toward the viewer and the left, then holds the look), then WAIT, 3 frames (standing, facing right, he taps one foot and his tail swishes, impatient but friendly). Row 2, exactly 2 frames: SIT, Dexter sitting on an invisible bench edge with his stubby legs dangling down in front, paws in his lap, facing right - frame 1 still, frame 2 his head tilted and the tail curled the other way. Draw the seat as nothing: he sits in the air at the height of a bench seat, his legs hanging. Every standing frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no bench, no scenery.", + "atlas": { + "height": 27, + "rows": [ + [ + { + "anim": "look-back", + "frames": 3, + "loop": false + }, + { + "anim": "wait", + "frames": 3, + "fps": 6 + } + ], + [ + { + "anim": "sit", + "frames": 2, + "fps": 2 + } + ] + ] + }, + "review": "✅ Approved 24 Oct by Alexander; exported to game/public/assets/sprites/ with export_atlas.py." + }, + { + "id": "menu-key", + "title": "Menu · the Hidden Garden at first light", + "factor": 2, + "keyed": false, + "size": "2560x1440", + "colours": 192, + "inputs": [ + "repo:art/direction/images/style-master-preview.png", + "repo:art/stages/hidden-garden/images/hg-4-0.png", + "repo:art/stages/hidden-garden/images/hg-0-0.png", + "repo:art/direction/images/hidden-garden-preview.png@0,0,1536,560" + ], + "prompt": "Key art for the game's title menu, a full 16:9 picture in exactly the hi-bit pixel-art look of the first image (the style master) - the magenta and sprite-sheet rules do NOT apply to this one image: it is a full scene. ABSOLUTELY NO TEXT, no logo, no title, no letters, no UI, no characters, no people, no animals. The scene: the Hidden Garden pond from images 2 and 3 at FIRST LIGHT, early morning - the low sun just rising behind the far conifer forest on the left, a pale gold-and-rose sky turning blue above, thin mist lying over the still pond. Strict side view like a game screen, everything parallel to the screen: the far hills and dark conifers, the band of autumn trees in yellow, orange and russet, the tall golden reed bed, the low stone wall and the small timber jetty at the water's edge, the empty park bench on the left. The pond fills the lower third and mirrors everything like a Kingdom Two Crowns reflection, with lily pads and a few floating maple leaves, and a dark blurred foreground of autumn leaves and grass along the bottom edge. Leave calm, simple sky in the upper middle (the game puts its title there). Cozy and beautiful, and only on a second look a little too still: in the reflection right below the jetty, one small patch reflects something that is not there - a faint rusty blue-green girder - very subtle.", + "review": "🟡 Drawn 24 Oct for the prologue menu (workstream 5); exported to game/public/assets/ui/menu-key.png with scripts/export_key_art.py, awaiting Alexander's review." + }, { "id": "intro-anomaly", "title": "Intro · the lake anomaly", diff --git a/art/sprites/boss-jenny-2.md b/art/sprites/boss-jenny-2.md index 09c00ed..8956891 100644 --- a/art/sprites/boss-jenny-2.md +++ b/art/sprites/boss-jenny-2.md @@ -1,7 +1,7 @@ # Boss · Jenny, stunned and debris -[← Boss · Jenny, attacks](boss-jenny.md) · [Sprite boards](README.md) · **16 / 18** · [Dexter · moves →](dexter.md) +[← Boss · Jenny, attacks](boss-jenny.md) · [Sprite boards](README.md) · **16 / 26** · [Item · pocket computer →](pocket-computer.md) ![Boss · Jenny, stunned and debris](images/boss-jenny-2-preview.png) @@ -29,5 +29,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/boss-jenny-2.png` · rebuild: `python scripts/make_boards.py draw boss-jenny-2 --redo --config art/sprites/boards.json` -[← Boss · Jenny, attacks](boss-jenny.md) · [Sprite boards](README.md) · **16 / 18** · [Dexter · moves →](dexter.md) +[← Boss · Jenny, attacks](boss-jenny.md) · [Sprite boards](README.md) · **16 / 26** · [Item · pocket computer →](pocket-computer.md) diff --git a/art/sprites/boss-jenny.md b/art/sprites/boss-jenny.md index 604755b..0bbfcf2 100644 --- a/art/sprites/boss-jenny.md +++ b/art/sprites/boss-jenny.md @@ -1,7 +1,7 @@ # Boss · Jenny, attacks -[← FX · black fluid](fx.md) · [Sprite boards](README.md) · **15 / 18** · [Boss · Jenny, stunned and debris →](boss-jenny-2.md) +[← FX · black fluid](fx.md) · [Sprite boards](README.md) · **15 / 26** · [Boss · Jenny, stunned and debris →](boss-jenny-2.md) ![Boss · Jenny, attacks](images/boss-jenny-preview.png) @@ -29,5 +29,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/boss-jenny.png` · rebuild: `python scripts/make_boards.py draw boss-jenny --redo --config art/sprites/boards.json` -[← FX · black fluid](fx.md) · [Sprite boards](README.md) · **15 / 18** · [Boss · Jenny, stunned and debris →](boss-jenny-2.md) +[← FX · black fluid](fx.md) · [Sprite boards](README.md) · **15 / 26** · [Boss · Jenny, stunned and debris →](boss-jenny-2.md) diff --git a/art/sprites/chair-block.md b/art/sprites/chair-block.md index 2771804..a713e70 100644 --- a/art/sprites/chair-block.md +++ b/art/sprites/chair-block.md @@ -1,7 +1,7 @@ # Chair · block -[← Chair · overhead slam](chair-overhead.md) · [Sprite boards](README.md) · **10 / 18** · [Enemy · drone →](enemy-drone.md) +[← Chair · overhead slam](chair-overhead.md) · [Sprite boards](README.md) · **10 / 26** · [Enemy · drone →](enemy-drone.md) ![Chair · block](images/chair-block-preview.png) @@ -30,5 +30,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/chair-block.png` · rebuild: `python scripts/make_boards.py draw chair-block --redo --config art/sprites/boards.json` -[← Chair · overhead slam](chair-overhead.md) · [Sprite boards](README.md) · **10 / 18** · [Enemy · drone →](enemy-drone.md) +[← Chair · overhead slam](chair-overhead.md) · [Sprite boards](README.md) · **10 / 26** · [Enemy · drone →](enemy-drone.md) diff --git a/art/sprites/chair-overhead.md b/art/sprites/chair-overhead.md index 1f780e2..80267e5 100644 --- a/art/sprites/chair-overhead.md +++ b/art/sprites/chair-overhead.md @@ -1,7 +1,7 @@ # Chair · overhead slam -[← Chair · side sweep](chair-side.md) · [Sprite boards](README.md) · **9 / 18** · [Chair · block →](chair-block.md) +[← Chair · side sweep](chair-side.md) · [Sprite boards](README.md) · **9 / 26** · [Chair · block →](chair-block.md) ![Chair · overhead slam](images/chair-overhead-preview.png) @@ -31,5 +31,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/chair-overhead.png` · rebuild: `python scripts/make_boards.py draw chair-overhead --redo --config art/sprites/boards.json` -[← Chair · side sweep](chair-side.md) · [Sprite boards](README.md) · **9 / 18** · [Chair · block →](chair-block.md) +[← Chair · side sweep](chair-side.md) · [Sprite boards](README.md) · **9 / 26** · [Chair · block →](chair-block.md) diff --git a/art/sprites/chair-side.md b/art/sprites/chair-side.md index 95968a5..7fdcfc1 100644 --- a/art/sprites/chair-side.md +++ b/art/sprites/chair-side.md @@ -1,7 +1,7 @@ # Chair · side sweep -[← The student · jump, fall, climb](hero-air.md) · [Sprite boards](README.md) · **8 / 18** · [Chair · overhead slam →](chair-overhead.md) +[← The student · jump, fall, climb](hero-air.md) · [Sprite boards](README.md) · **8 / 26** · [Chair · overhead slam →](chair-overhead.md) ![Chair · side sweep](images/chair-side-preview.png) @@ -30,5 +30,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/chair-side.png` · rebuild: `python scripts/make_boards.py draw chair-side --redo --config art/sprites/boards.json` -[← The student · jump, fall, climb](hero-air.md) · [Sprite boards](README.md) · **8 / 18** · [Chair · overhead slam →](chair-overhead.md) +[← The student · jump, fall, climb](hero-air.md) · [Sprite boards](README.md) · **8 / 26** · [Chair · overhead slam →](chair-overhead.md) diff --git a/art/sprites/coffee-machine.md b/art/sprites/coffee-machine.md new file mode 100644 index 0000000..1e80662 --- /dev/null +++ b/art/sprites/coffee-machine.md @@ -0,0 +1,31 @@ +# Prop · coffee machine (save point) + + +[← Mensa · food pickups](mensa-food.md) · [Sprite boards](README.md) · **19 / 26** · [Dexter · moves →](dexter.md) + + +![Prop · coffee machine (save point)](images/coffee-machine-preview.png) + +**Review:** ✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. 42 × 80 px in the game: fills the 40 × 80 coffee-1 box in Rusty Creek, a little taller than the 76 px hero on its base cabinet, as in the photos. Lineup in the stage: previews/coffee-visit-lineup.png. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: idle, pour. + +![idle](previews/coffee-machine--idle.gif) ![pour](previews/coffee-machine--pour.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Prop sprite sheet: the self-service coffee machine from the first two photos, copied exactly - black and brushed-steel body, three steel bean hoppers on top, a big glowing touchscreen with a grid of drink buttons, a bright green light strip down its left side, a steel drip tray and spout - standing on its own black base cabinet so the whole unit is a little TALLER than an adult (about 190 cm) and about half as wide as it is tall. Real wear: fingerprints, a coffee stain on the tray, a scuffed cabinet. Strict front-three-quarter side view, the same in every frame. 2 rows. Row 1, exactly 3 frames: IDLE, the screen glowing steadily, then the screen FLICKERING (a band of static across it), then the screen showing a still dark POND image for one frame (an anomaly hint). Row 2, exactly 4 frames: POUR, a white cafeteria mug under the spout fills with coffee in 4 steps, a little steam on the last. Every frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no scenery. + +## Inputs + +- `20261023_125138.jpg` (GameJam_References-2, outside Git) +- `20261023_125141.jpg` (GameJam_References-2, outside Git) +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/coffee-machine.png` · rebuild: `python scripts/make_boards.py draw coffee-machine --redo --config art/sprites/boards.json` + + +[← Mensa · food pickups](mensa-food.md) · [Sprite boards](README.md) · **19 / 26** · [Dexter · moves →](dexter.md) + diff --git a/art/sprites/dexter-acts.md b/art/sprites/dexter-acts.md index 44d078a..9de2e27 100644 --- a/art/sprites/dexter-acts.md +++ b/art/sprites/dexter-acts.md @@ -1,7 +1,7 @@ # Dexter · gestures -[← Dexter · moves](dexter.md) · [Sprite boards](README.md) · **18 / 18** · [Sprite boards →](README.md) +[← Dexter · moves](dexter.md) · [Sprite boards](README.md) · **21 / 26** · [FX · falling leaves →](fx-leaves.md) ![Dexter · gestures](images/dexter-acts-preview.png) @@ -30,5 +30,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/dexter-acts.png` · rebuild: `python scripts/make_boards.py draw dexter-acts --redo --config art/sprites/boards.json` -[← Dexter · moves](dexter.md) · [Sprite boards](README.md) · **18 / 18** · [Sprite boards →](README.md) +[← Dexter · moves](dexter.md) · [Sprite boards](README.md) · **21 / 26** · [FX · falling leaves →](fx-leaves.md) diff --git a/art/sprites/dexter-prologue.md b/art/sprites/dexter-prologue.md new file mode 100644 index 0000000..35d8f65 --- /dev/null +++ b/art/sprites/dexter-prologue.md @@ -0,0 +1,33 @@ +# Dexter · prologue additions + + +[← FX · pond ripples and lily pad](fx-water.md) · [Sprite boards](README.md) · **25 / 26** · [Menu · the Hidden Garden at first light →](menu-key.md) + + +![Dexter · prologue additions](images/dexter-prologue-preview.png) + +**Review:** ✅ Approved 24 Oct by Alexander; exported to game/public/assets/sprites/ with export_atlas.py. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: look-back, wait, sit. + +![look-back](previews/dexter-prologue--look-back.gif) ![wait](previews/dexter-prologue--wait.gif) ![sit](previews/dexter-prologue--sit.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the feet), with NO text at all - no titles, no captions, no frame numbers, no labels under the figures: DEXTER, the university's black panther mascot as the small PLUSH toy from the first two images (an approved sprite sheet of him from this game, then his approved design) - copy that plush exactly: CHUNKY toy proportions, a BIG round head about 45 % of his whole height, wide rounded ears, big ice-blue eyes, an orange nose, small white fangs, long white whiskers, a short soft body in a light-grey THD crest T-shirt and light-grey shorts, short stubby black arms and legs, big black paw feet with white claw dots, a short curled tail. Black velvet fur with soft dark-grey highlights so the shapes read at small size. A cute, slightly too-alive plush that moves by itself - never slim, never a cat on two legs, never a human body. Strict side view, facing RIGHT, the head turned a little toward the viewer so both eyes show, the same size in every frame as on the approved sheet. He never talks: everything is body language. These are the poses his approved sheet (image 1) is missing for the first quest. 2 rows. Row 1, exactly 6 frames: LOOK BACK, 3 frames (standing, facing right, he turns his big head back over his shoulder toward the viewer and the left, then holds the look), then WAIT, 3 frames (standing, facing right, he taps one foot and his tail swishes, impatient but friendly). Row 2, exactly 2 frames: SIT, Dexter sitting on an invisible bench edge with his stubby legs dangling down in front, paws in his lap, facing right - frame 1 still, frame 2 his head tilted and the tail curled the other way. Draw the seat as nothing: he sits in the air at the height of a bench seat, his legs hanging. Every standing frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no bench, no scenery. + +## Inputs + +- [dexter-preview.png](images/dexter-preview.png) +- [dexter-stages-preview.png](../direction/images/dexter-stages-preview.png), cropped to `535,585,125,205` +- [dexter-stages-preview.png](../direction/images/dexter-stages-preview.png), cropped to `290,225,390,570` +- `Deggster-9487_1280x1280.jpg` (GameJam_Merch, outside Git) +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/dexter-prologue.png` · rebuild: `python scripts/make_boards.py draw dexter-prologue --redo --config art/sprites/boards.json` + + +[← FX · pond ripples and lily pad](fx-water.md) · [Sprite boards](README.md) · **25 / 26** · [Menu · the Hidden Garden at first light →](menu-key.md) + diff --git a/art/sprites/dexter.md b/art/sprites/dexter.md index 05e88f2..3ff3a88 100644 --- a/art/sprites/dexter.md +++ b/art/sprites/dexter.md @@ -1,7 +1,7 @@ # Dexter · moves -[← Boss · Jenny, stunned and debris](boss-jenny-2.md) · [Sprite boards](README.md) · **17 / 18** · [Dexter · gestures →](dexter-acts.md) +[← Prop · coffee machine (save point)](coffee-machine.md) · [Sprite boards](README.md) · **20 / 26** · [Dexter · gestures →](dexter-acts.md) ![Dexter · moves](images/dexter-preview.png) @@ -30,5 +30,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/dexter.png` · rebuild: `python scripts/make_boards.py draw dexter --redo --config art/sprites/boards.json` -[← Boss · Jenny, stunned and debris](boss-jenny-2.md) · [Sprite boards](README.md) · **17 / 18** · [Dexter · gestures →](dexter-acts.md) +[← Prop · coffee machine (save point)](coffee-machine.md) · [Sprite boards](README.md) · **20 / 26** · [Dexter · gestures →](dexter-acts.md) diff --git a/art/sprites/enemy-crab.md b/art/sprites/enemy-crab.md index f587b7f..11cff7a 100644 --- a/art/sprites/enemy-crab.md +++ b/art/sprites/enemy-crab.md @@ -1,7 +1,7 @@ # Enemy · crab -[← Enemy · rover](enemy-rover.md) · [Sprite boards](README.md) · **13 / 18** · [FX · black fluid →](fx.md) +[← Enemy · rover](enemy-rover.md) · [Sprite boards](README.md) · **13 / 26** · [FX · black fluid →](fx.md) ![Enemy · crab](images/enemy-crab-preview.png) @@ -29,5 +29,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/enemy-crab.png` · rebuild: `python scripts/make_boards.py draw enemy-crab --redo --config art/sprites/boards.json` -[← Enemy · rover](enemy-rover.md) · [Sprite boards](README.md) · **13 / 18** · [FX · black fluid →](fx.md) +[← Enemy · rover](enemy-rover.md) · [Sprite boards](README.md) · **13 / 26** · [FX · black fluid →](fx.md) diff --git a/art/sprites/enemy-drone.md b/art/sprites/enemy-drone.md index 83c32ca..703d2c4 100644 --- a/art/sprites/enemy-drone.md +++ b/art/sprites/enemy-drone.md @@ -1,7 +1,7 @@ # Enemy · drone -[← Chair · block](chair-block.md) · [Sprite boards](README.md) · **11 / 18** · [Enemy · rover →](enemy-rover.md) +[← Chair · block](chair-block.md) · [Sprite boards](README.md) · **11 / 26** · [Enemy · rover →](enemy-rover.md) ![Enemy · drone](images/enemy-drone-preview.png) @@ -30,5 +30,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/enemy-drone.png` · rebuild: `python scripts/make_boards.py draw enemy-drone --redo --config art/sprites/boards.json` -[← Chair · block](chair-block.md) · [Sprite boards](README.md) · **11 / 18** · [Enemy · rover →](enemy-rover.md) +[← Chair · block](chair-block.md) · [Sprite boards](README.md) · **11 / 26** · [Enemy · rover →](enemy-rover.md) diff --git a/art/sprites/enemy-rover.md b/art/sprites/enemy-rover.md index a5aecfc..c5b0ace 100644 --- a/art/sprites/enemy-rover.md +++ b/art/sprites/enemy-rover.md @@ -1,7 +1,7 @@ # Enemy · rover -[← Enemy · drone](enemy-drone.md) · [Sprite boards](README.md) · **12 / 18** · [Enemy · crab →](enemy-crab.md) +[← Enemy · drone](enemy-drone.md) · [Sprite boards](README.md) · **12 / 26** · [Enemy · crab →](enemy-crab.md) ![Enemy · rover](images/enemy-rover-preview.png) @@ -29,5 +29,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/enemy-rover.png` · rebuild: `python scripts/make_boards.py draw enemy-rover --redo --config art/sprites/boards.json` -[← Enemy · drone](enemy-drone.md) · [Sprite boards](README.md) · **12 / 18** · [Enemy · crab →](enemy-crab.md) +[← Enemy · drone](enemy-drone.md) · [Sprite boards](README.md) · **12 / 26** · [Enemy · crab →](enemy-crab.md) diff --git a/art/sprites/fx-ground.md b/art/sprites/fx-ground.md new file mode 100644 index 0000000..d9818e1 --- /dev/null +++ b/art/sprites/fx-ground.md @@ -0,0 +1,33 @@ +# FX · footstep puffs + + +[← FX · falling leaves](fx-leaves.md) · [Sprite boards](README.md) · **23 / 26** · [FX · pond ripples and lily pad →](fx-water.md) + + +![FX · footstep puffs](images/fx-ground-preview.png) + +**Review:** 🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Dust 21–30 × 5–12 px, grass and leaf kick ≈ 25 × 14, splash ≈ 25 × 16 – about one step wide next to the 76 px hero. Rows are `even` (they break into specks). + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: dust, grass, leaf-kick, splash. + +![dust](previews/fx-ground--dust.gif) ![grass](previews/fx-ground--grass.gif) ![leaf-kick](previews/fx-ground--leaf-kick.gif) ![splash](previews/fx-ground--splash.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet: small puffs kicked up by a footstep or a landing on the ground of the Hidden Garden (images 2 to 4: the stone-slab path, the grass with yellow leaves, the pond edge). Every effect sits ON the ground line and bursts up and outward from one point, seen from the side, low and wide (about twice as wide as tall), and fades out by breaking into specks. 4 rows. Row 1, exactly 4 frames: DUST, a soft warm grey-beige dust puff - small burst, round cloud, wider thinner cloud, a few last specks. Row 2, exactly 4 frames: GRASS, a few green and straw-yellow grass blades and seeds flicked up and falling back. Row 3, exactly 4 frames: LEAF KICK, three or four tiny yellow and orange leaves kicked up in an arc and settling. Row 4, exactly 4 frames: SPLASH, a small blue-white water splash with droplets, crown, droplets falling, a flat ring. All effects at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery. + +## Inputs + +- [fx-preview.png](images/fx-preview.png) +- [hg-0-0.png](../stages/hidden-garden/images/hg-0-0.png), cropped to `560,380,560,100` +- [hidden-gardens.png](../../references/campus/images/hidden-gardens.png), cropped to `435,700,630,240` +- [hg-4-0.png](../stages/hidden-garden/images/hg-4-0.png), cropped to `0,440,700,120` +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/fx-ground.png` · rebuild: `python scripts/make_boards.py draw fx-ground --redo --config art/sprites/boards.json` + + +[← FX · falling leaves](fx-leaves.md) · [Sprite boards](README.md) · **23 / 26** · [FX · pond ripples and lily pad →](fx-water.md) + diff --git a/art/sprites/fx-leaves.md b/art/sprites/fx-leaves.md new file mode 100644 index 0000000..93b4271 --- /dev/null +++ b/art/sprites/fx-leaves.md @@ -0,0 +1,33 @@ +# FX · falling leaves + + +[← Dexter · gestures](dexter-acts.md) · [Sprite boards](README.md) · **22 / 26** · [FX · footstep puffs →](fx-ground.md) + + +![FX · falling leaves](images/fx-leaves-preview.png) + +**Review:** 🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Second draw: the first take's thin outlines and fine veins melted into the orange trees at 8 px (6 of 8 test frames LOW in scripts/readability.py); thick dark outlines and lighter fills brought it to 2 of 8. 7–10 px in the game. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: birch, maple, oak. + +![birch](previews/fx-leaves--birch.gif) ![maple](previews/fx-leaves--maple.gif) ![oak](previews/fx-leaves--oak.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet: single FALLING AUTUMN LEAVES that tumble through the Hidden Garden, copied from the leaves in images 2 to 4 (the floating maple leaves on the pond, the yellow leaves in the grass, the willow screen). Each leaf is drawn LARGE and simple because the game shows it only 8 pixels tall, in front of orange and dark autumn trees: a THICK very dark brown-black outline all the way round (as thick as the stem), a bright, saturated, LIGHT fill (a pale lemon birch, a vivid red-orange maple, a warm tan oak) with one light highlight side, no fine veins, a short thick stem. The silhouette must read against both bright orange foliage and dark shadows. 3 rows, each row is ONE leaf kind tumbling as it falls, seen from the side: the leaf turns over in the air - flat face, tilted, edge-on thin sliver, tilted the other way, the back face. Row 1, exactly 5 frames: a small oval BIRCH leaf, bright lemon yellow. Row 2, exactly 5 frames: a five-pointed MAPLE leaf, orange to red. Row 3, exactly 5 frames: a round-lobed OAK leaf, russet brown. All leaves the SAME size, centred on one line. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery. + +## Inputs + +- [fx-preview.png](images/fx-preview.png) +- [hg-4-0.png](../stages/hidden-garden/images/hg-4-0.png), cropped to `520,470,900,230` +- [hidden-gardens.png](../../references/campus/images/hidden-gardens.png), cropped to `435,700,630,240` +- [hidden-gardens.png](../../references/campus/images/hidden-gardens.png), cropped to `20,1010,510,310` +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/fx-leaves.png` · rebuild: `python scripts/make_boards.py draw fx-leaves --redo --config art/sprites/boards.json` + + +[← Dexter · gestures](dexter-acts.md) · [Sprite boards](README.md) · **22 / 26** · [FX · footstep puffs →](fx-ground.md) + diff --git a/art/sprites/fx-water.md b/art/sprites/fx-water.md new file mode 100644 index 0000000..9c2ba3f --- /dev/null +++ b/art/sprites/fx-water.md @@ -0,0 +1,32 @@ +# FX · pond ripples and lily pad + + +[← FX · footstep puffs](fx-ground.md) · [Sprite boards](README.md) · **24 / 26** · [Dexter · prologue additions →](dexter-prologue.md) + + +![FX · pond ripples and lily pad](images/fx-water-preview.png) + +**Review:** 🟡 Drawn 24 Oct for the prologue (workstream 5); exported for the slice, awaiting Alexander's review. Lily pad 58 × 14–16 px, measured against the painted pads in hg-4-0 (≈ 68 × 14–17); ripple 37–71 px wide; sparkle halved to a 12 px glint. The plate's pads are browner than this olive pad – tint in code if it stands out. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: lily-bob, ripple, sparkle. + +![lily-bob](previews/fx-water--lily-bob.gif) ![ripple](previews/fx-water--ripple.gif) ![sparkle](previews/fx-water--sparkle.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no arrows: Image 1 is an approved effect sprite sheet from this game: draw this sheet in exactly that pixel-art look and sheet layout - small sprites in rows on magenta. Effect sprite sheet for the Hidden Garden pond (images 2 and 3: its lily pads, its blue water with warm orange reflections and white glints). 3 rows. Row 1, exactly 4 frames: LILY PAD BOB, one dark olive-green round lily pad with a notch, seen almost edge-on from the side like in image 2 (a flat ellipse about four times as wide as tall), bobbing on the water - level, tipped a little left, level and a little lower, tipped a little right. Row 2, exactly 4 frames: RIPPLE, a ring on the water seen from the side as a flat ellipse made of thin light-blue and white pixel lines, growing wider and fainter: small ring, wider ring, wide broken ring, a few faint dashes. Row 3, exactly 4 frames: SPARKLE, a sun glint on the water, a small white-gold four-point twinkle that grows, flares, shrinks and fades. Keep the lily pads and ripples the SAME width as each other. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no hero, no scenery. + +## Inputs + +- [fx-preview.png](images/fx-preview.png) +- [hg-4-0.png](../stages/hidden-garden/images/hg-4-0.png), cropped to `280,560,1000,160` +- [hg-0-0.png](../stages/hidden-garden/images/hg-0-0.png), cropped to `1000,480,1000,180` +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/fx-water.png` · rebuild: `python scripts/make_boards.py draw fx-water --redo --config art/sprites/boards.json` + + +[← FX · footstep puffs](fx-ground.md) · [Sprite boards](README.md) · **24 / 26** · [Dexter · prologue additions →](dexter-prologue.md) + diff --git a/art/sprites/fx.md b/art/sprites/fx.md index df56b35..8da2b1d 100644 --- a/art/sprites/fx.md +++ b/art/sprites/fx.md @@ -1,7 +1,7 @@ # FX · black fluid -[← Enemy · crab](enemy-crab.md) · [Sprite boards](README.md) · **14 / 18** · [Boss · Jenny, attacks →](boss-jenny.md) +[← Enemy · crab](enemy-crab.md) · [Sprite boards](README.md) · **14 / 26** · [Boss · Jenny, attacks →](boss-jenny.md) ![FX · black fluid](images/fx-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/fx.png` · rebuild: `python scripts/make_boards.py draw fx --redo --config art/sprites/boards.json` -[← Enemy · crab](enemy-crab.md) · [Sprite boards](README.md) · **14 / 18** · [Boss · Jenny, attacks →](boss-jenny.md) +[← Enemy · crab](enemy-crab.md) · [Sprite boards](README.md) · **14 / 26** · [Boss · Jenny, attacks →](boss-jenny.md) diff --git a/art/sprites/hero-air.md b/art/sprites/hero-air.md index 41d0e2d..f7f8816 100644 --- a/art/sprites/hero-air.md +++ b/art/sprites/hero-air.md @@ -1,7 +1,7 @@ # The student · jump, fall, climb -[← The student · death](hero-death.md) · [Sprite boards](README.md) · **7 / 18** · [Chair · side sweep →](chair-side.md) +[← The student · death](hero-death.md) · [Sprite boards](README.md) · **7 / 26** · [Chair · side sweep →](chair-side.md) ![The student · jump, fall, climb](images/hero-air-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-air.png` · rebuild: `python scripts/make_boards.py draw hero-air --redo --config art/sprites/boards.json` -[← The student · death](hero-death.md) · [Sprite boards](README.md) · **7 / 18** · [Chair · side sweep →](chair-side.md) +[← The student · death](hero-death.md) · [Sprite boards](README.md) · **7 / 26** · [Chair · side sweep →](chair-side.md) diff --git a/art/sprites/hero-death.md b/art/sprites/hero-death.md index 875f1fd..20a01e4 100644 --- a/art/sprites/hero-death.md +++ b/art/sprites/hero-death.md @@ -1,7 +1,7 @@ # The student · death -[← The student · hurt and interact](hero-hurt.md) · [Sprite boards](README.md) · **6 / 18** · [The student · jump, fall, climb →](hero-air.md) +[← The student · hurt and interact](hero-hurt.md) · [Sprite boards](README.md) · **6 / 26** · [The student · jump, fall, climb →](hero-air.md) ![The student · death](images/hero-death-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-death.png` · rebuild: `python scripts/make_boards.py draw hero-death --redo --config art/sprites/boards.json` -[← The student · hurt and interact](hero-hurt.md) · [Sprite boards](README.md) · **6 / 18** · [The student · jump, fall, climb →](hero-air.md) +[← The student · hurt and interact](hero-hurt.md) · [Sprite boards](README.md) · **6 / 26** · [The student · jump, fall, climb →](hero-air.md) diff --git a/art/sprites/hero-hurt.md b/art/sprites/hero-hurt.md index faee872..e60ce99 100644 --- a/art/sprites/hero-hurt.md +++ b/art/sprites/hero-hurt.md @@ -1,7 +1,7 @@ # The student · hurt and interact -[← The student · run](hero-run.md) · [Sprite boards](README.md) · **5 / 18** · [The student · death →](hero-death.md) +[← The student · run](hero-run.md) · [Sprite boards](README.md) · **5 / 26** · [The student · death →](hero-death.md) ![The student · hurt and interact](images/hero-hurt-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-hurt.png` · rebuild: `python scripts/make_boards.py draw hero-hurt --redo --config art/sprites/boards.json` -[← The student · run](hero-run.md) · [Sprite boards](README.md) · **5 / 18** · [The student · death →](hero-death.md) +[← The student · run](hero-run.md) · [Sprite boards](README.md) · **5 / 26** · [The student · death →](hero-death.md) diff --git a/art/sprites/hero-idle.md b/art/sprites/hero-idle.md index 0b739b9..e110eb9 100644 --- a/art/sprites/hero-idle.md +++ b/art/sprites/hero-idle.md @@ -1,7 +1,7 @@ # The student · idle variants -[← The student · walk](hero-move.md) · [Sprite boards](README.md) · **3 / 18** · [The student · run →](hero-run.md) +[← The student · walk](hero-move.md) · [Sprite boards](README.md) · **3 / 26** · [The student · run →](hero-run.md) ![The student · idle variants](images/hero-idle-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-idle.png` · rebuild: `python scripts/make_boards.py draw hero-idle --redo --config art/sprites/boards.json` -[← The student · walk](hero-move.md) · [Sprite boards](README.md) · **3 / 18** · [The student · run →](hero-run.md) +[← The student · walk](hero-move.md) · [Sprite boards](README.md) · **3 / 26** · [The student · run →](hero-run.md) diff --git a/art/sprites/hero-move.md b/art/sprites/hero-move.md index 1ada96a..de21680 100644 --- a/art/sprites/hero-move.md +++ b/art/sprites/hero-move.md @@ -1,7 +1,7 @@ # The student · walk -[← The student · hero design](hero.md) · [Sprite boards](README.md) · **2 / 18** · [The student · idle variants →](hero-idle.md) +[← The student · hero design](hero.md) · [Sprite boards](README.md) · **2 / 26** · [The student · idle variants →](hero-idle.md) ![The student · walk](images/hero-move-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-move.png` · rebuild: `python scripts/make_boards.py draw hero-move --redo --config art/sprites/boards.json` -[← The student · hero design](hero.md) · [Sprite boards](README.md) · **2 / 18** · [The student · idle variants →](hero-idle.md) +[← The student · hero design](hero.md) · [Sprite boards](README.md) · **2 / 26** · [The student · idle variants →](hero-idle.md) diff --git a/art/sprites/hero-run.md b/art/sprites/hero-run.md index bb99bda..d626442 100644 --- a/art/sprites/hero-run.md +++ b/art/sprites/hero-run.md @@ -1,7 +1,7 @@ # The student · run -[← The student · idle variants](hero-idle.md) · [Sprite boards](README.md) · **4 / 18** · [The student · hurt and interact →](hero-hurt.md) +[← The student · idle variants](hero-idle.md) · [Sprite boards](README.md) · **4 / 26** · [The student · hurt and interact →](hero-hurt.md) ![The student · run](images/hero-run-preview.png) @@ -26,5 +26,5 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero-run.png` · rebuild: `python scripts/make_boards.py draw hero-run --redo --config art/sprites/boards.json` -[← The student · idle variants](hero-idle.md) · [Sprite boards](README.md) · **4 / 18** · [The student · hurt and interact →](hero-hurt.md) +[← The student · idle variants](hero-idle.md) · [Sprite boards](README.md) · **4 / 26** · [The student · hurt and interact →](hero-hurt.md) diff --git a/art/sprites/hero.md b/art/sprites/hero.md index 5a88448..1003f30 100644 --- a/art/sprites/hero.md +++ b/art/sprites/hero.md @@ -1,7 +1,7 @@ # The student · hero design -[← Sprite boards](README.md) · [Sprite boards](README.md) · **1 / 18** · [The student · walk →](hero-move.md) +[← Sprite boards](README.md) · [Sprite boards](README.md) · **1 / 26** · [The student · walk →](hero-move.md) ![The student · hero design](images/hero-preview.png) @@ -21,5 +21,5 @@ Keep the body, height (6 ft adult), proportions, pose, backpack, grey slim jeans Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/hero.png` · rebuild: `python scripts/make_boards.py draw hero --redo --config art/sprites/boards.json` -[← Sprite boards](README.md) · [Sprite boards](README.md) · **1 / 18** · [The student · walk →](hero-move.md) +[← Sprite boards](README.md) · [Sprite boards](README.md) · **1 / 26** · [The student · walk →](hero-move.md) diff --git a/art/sprites/images/coffee-machine-preview.png b/art/sprites/images/coffee-machine-preview.png new file mode 100644 index 0000000..f500164 Binary files /dev/null and b/art/sprites/images/coffee-machine-preview.png differ diff --git a/art/sprites/images/coffee-machine.png b/art/sprites/images/coffee-machine.png new file mode 100644 index 0000000..5d943bc Binary files /dev/null and b/art/sprites/images/coffee-machine.png differ diff --git a/art/sprites/images/dexter-prologue-preview.png b/art/sprites/images/dexter-prologue-preview.png new file mode 100644 index 0000000..d85d8d2 Binary files /dev/null and b/art/sprites/images/dexter-prologue-preview.png differ diff --git a/art/sprites/images/dexter-prologue.png b/art/sprites/images/dexter-prologue.png new file mode 100644 index 0000000..014f4d8 Binary files /dev/null and b/art/sprites/images/dexter-prologue.png differ diff --git a/art/sprites/images/fx-ground-preview.png b/art/sprites/images/fx-ground-preview.png new file mode 100644 index 0000000..652ca21 Binary files /dev/null and b/art/sprites/images/fx-ground-preview.png differ diff --git a/art/sprites/images/fx-ground.png b/art/sprites/images/fx-ground.png new file mode 100644 index 0000000..3855785 Binary files /dev/null and b/art/sprites/images/fx-ground.png differ diff --git a/art/sprites/images/fx-leaves-preview.png b/art/sprites/images/fx-leaves-preview.png new file mode 100644 index 0000000..f51be52 Binary files /dev/null and b/art/sprites/images/fx-leaves-preview.png differ diff --git a/art/sprites/images/fx-leaves.png b/art/sprites/images/fx-leaves.png new file mode 100644 index 0000000..933001a Binary files /dev/null and b/art/sprites/images/fx-leaves.png differ diff --git a/art/sprites/images/fx-water-preview.png b/art/sprites/images/fx-water-preview.png new file mode 100644 index 0000000..46f1be4 Binary files /dev/null and b/art/sprites/images/fx-water-preview.png differ diff --git a/art/sprites/images/fx-water.png b/art/sprites/images/fx-water.png new file mode 100644 index 0000000..54721f1 Binary files /dev/null and b/art/sprites/images/fx-water.png differ diff --git a/art/sprites/images/mensa-food-preview.png b/art/sprites/images/mensa-food-preview.png new file mode 100644 index 0000000..98c6550 Binary files /dev/null and b/art/sprites/images/mensa-food-preview.png differ diff --git a/art/sprites/images/mensa-food.png b/art/sprites/images/mensa-food.png new file mode 100644 index 0000000..abeacc6 Binary files /dev/null and b/art/sprites/images/mensa-food.png differ diff --git a/art/sprites/images/menu-key-preview.png b/art/sprites/images/menu-key-preview.png new file mode 100644 index 0000000..413807a Binary files /dev/null and b/art/sprites/images/menu-key-preview.png differ diff --git a/art/sprites/images/menu-key.png b/art/sprites/images/menu-key.png new file mode 100644 index 0000000..2d87fa1 Binary files /dev/null and b/art/sprites/images/menu-key.png differ diff --git a/art/sprites/images/pocket-computer-preview.png b/art/sprites/images/pocket-computer-preview.png new file mode 100644 index 0000000..d97ef5d Binary files /dev/null and b/art/sprites/images/pocket-computer-preview.png differ diff --git a/art/sprites/images/pocket-computer.png b/art/sprites/images/pocket-computer.png new file mode 100644 index 0000000..d4fb891 Binary files /dev/null and b/art/sprites/images/pocket-computer.png differ diff --git a/art/sprites/mensa-food.md b/art/sprites/mensa-food.md new file mode 100644 index 0000000..53ddc14 --- /dev/null +++ b/art/sprites/mensa-food.md @@ -0,0 +1,34 @@ +# Mensa · food pickups + + +[← Item · pocket computer](pocket-computer.md) · [Sprite boards](README.md) · **18 / 26** · [Prop · coffee machine (save point) →](coffee-machine.md) + + +![Mensa · food pickups](images/mensa-food-preview.png) + +**Review:** ✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. Third draw: the first two copied only the muffin; anchoring on the approved coffee-machine sheet gave four distinct items. One frame each (the model ignored the sparkle frames; the game adds the sparkle). Muffin 16 × 14, cake 18 × 14, wafer 17 × 13, Leberkäse roll 18 × 18 px – around the documented 16 px. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: muffin, cake, wafer, leberkaese. + +![muffin](previews/mensa-food--muffin.gif) ![cake](previews/mensa-food--cake.gif) ![wafer](previews/mensa-food--wafer.gif) ![leberkaese](previews/mensa-food--leberkaese.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Image 1 is an approved sprite sheet from this game: draw this sheet in exactly that pixel-art look and at that sheet layout - small sprites in a row on magenta. Pickup sprite sheet of FOUR DIFFERENT cafeteria foods, one per FOOD photo (images 2 to 5), in that order - never repeat an item: (1) the chocolate MUFFIN from image 2, squashed in wrinkled, glare-streaked cling film; (2) the marbled chocolate-vanilla CAKE SLICE from image 3, a flat rectangular slab in crumpled cling film on a white paper; (3) the chocolate WAFER bar from image 4 in its flat yellow-red foil wrapper (no readable brand); (4) the LEBERKAESE ROLL from image 5, a round crusty kaiser roll with a thick pink meat slice sticking out, half in a torn brown paper bag. Copy each EXACTLY as it really looks, not an idealized product shot. All four at the SAME pixel scale, about the same size as each other, small pickups resting on the ground line. 1 row, exactly 4 frames: muffin, cake slice, wafer, Leberkaese roll (the game adds the pickup sparkle). The four foods must look clearly different from each other in shape and colour. Every frame stands on the SAME invisible ground line at the SAME scale. Frames are evenly spaced from left to right with wide clear magenta gaps between neighbours. Count the frames: the row has EXACTLY 4. No people, no scenery. + +## Inputs + +- [coffee-machine-preview.png](images/coffee-machine-preview.png) +- `20261023_125038.jpg` (GameJam_References-2, outside Git) +- `20261023_125049.jpg` (GameJam_References-2, outside Git) +- `20261023_125100.jpg` (GameJam_References-2, outside Git) +- `20261023_125259.jpg` (GameJam_References-2, outside Git) +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/mensa-food.png` · rebuild: `python scripts/make_boards.py draw mensa-food --redo --config art/sprites/boards.json` + + +[← Item · pocket computer](pocket-computer.md) · [Sprite boards](README.md) · **18 / 26** · [Prop · coffee machine (save point) →](coffee-machine.md) + diff --git a/art/sprites/menu-key.md b/art/sprites/menu-key.md new file mode 100644 index 0000000..b8d1cb9 --- /dev/null +++ b/art/sprites/menu-key.md @@ -0,0 +1,26 @@ +# Menu · the Hidden Garden at first light + + +[← Dexter · prologue additions](dexter-prologue.md) · [Sprite boards](README.md) · **26 / 26** · [Sprite boards →](README.md) + + +![Menu · the Hidden Garden at first light](images/menu-key-preview.png) + +**Review:** 🟡 Drawn 24 Oct for the prologue menu (workstream 5); exported to game/public/assets/ui/menu-key.png with scripts/export_key_art.py, awaiting Alexander's review. + +## Brief + +Key art for the game's title menu, a full 16:9 picture in exactly the hi-bit pixel-art look of the first image (the style master) - the magenta and sprite-sheet rules do NOT apply to this one image: it is a full scene. ABSOLUTELY NO TEXT, no logo, no title, no letters, no UI, no characters, no people, no animals. The scene: the Hidden Garden pond from images 2 and 3 at FIRST LIGHT, early morning - the low sun just rising behind the far conifer forest on the left, a pale gold-and-rose sky turning blue above, thin mist lying over the still pond. Strict side view like a game screen, everything parallel to the screen: the far hills and dark conifers, the band of autumn trees in yellow, orange and russet, the tall golden reed bed, the low stone wall and the small timber jetty at the water's edge, the empty park bench on the left. The pond fills the lower third and mirrors everything like a Kingdom Two Crowns reflection, with lily pads and a few floating maple leaves, and a dark blurred foreground of autumn leaves and grass along the bottom edge. Leave calm, simple sky in the upper middle (the game puts its title there). Cozy and beautiful, and only on a second look a little too still: in the reflection right below the jetty, one small patch reflects something that is not there - a faint rusty blue-green girder - very subtle. + +## Inputs + +- [style-master-preview.png](../direction/images/style-master-preview.png) +- [hg-4-0.png](../stages/hidden-garden/images/hg-4-0.png) +- [hg-0-0.png](../stages/hidden-garden/images/hg-0-0.png) +- [hidden-garden-preview.png](../direction/images/hidden-garden-preview.png), cropped to `0,0,1536,560` + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/menu-key.png` · rebuild: `python scripts/make_boards.py draw menu-key --redo --config art/sprites/boards.json` + + +[← Dexter · prologue additions](dexter-prologue.md) · [Sprite boards](README.md) · **26 / 26** · [Sprite boards →](README.md) + diff --git a/art/sprites/pocket-computer.md b/art/sprites/pocket-computer.md new file mode 100644 index 0000000..e05d655 --- /dev/null +++ b/art/sprites/pocket-computer.md @@ -0,0 +1,32 @@ +# Item · pocket computer + + +[← Boss · Jenny, stunned and debris](boss-jenny-2.md) · [Sprite boards](README.md) · **17 / 26** · [Mensa · food pickups →](mensa-food.md) + + +![Item · pocket computer](images/pocket-computer-preview.png) + +**Review:** ✅ Approved 24 Oct by Alexander (#154); exported to game/public/assets/sprites/ with export_atlas.py. Closed case 19 × 10 px (≈ 45 cm at the hero's 76 px = 183 cm): the photographed suitcase, shrunk to carry; open with the green screen 22 px tall. The map screens are the approved UI atlas (#127), so this board has no screen mock-ups. + +## In motion + +At twice game size, 12 fps unless the atlas says otherwise: bob, open. + +![bob](previews/pocket-computer--bob.gif) ![open](previews/pocket-computer--open.gif) + +## Brief + +On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable brand names: Key item sprite sheet: the aluminium experiment suitcase from the photos, copied exactly - a silver hard case with rounded corners and a carry handle, a black screen in the lid, a white circuit tray inside with a green circuit board, rows of yellow and green buttons, plug sockets - shrunk into a hand-held 'pocket computer'. Side-on three-quarter view. 2 rows. Row 1, exactly 4 frames: the case CLOSED, lying flat, with a soft green glow at its seam, bobbing up and down a little (frames 1-4 of a gentle float). Row 2, exactly 3 frames: the case OPENING - the lid lifts halfway, the lid fully open with the dark screen, the screen lit up green with a simple block grid on it (no words). Every frame stands on the SAME invisible ground line at the SAME scale. Each row's frames are evenly spaced from left to right with wide clear magenta gaps between neighbours, and rows are separated by empty magenta bands. Draw distinct key poses, never blurred in-betweens. Count the frames: each row has EXACTLY the number stated. No people, no scenery. + +## Inputs + +- `20261023_125955.jpg` (GameJam_References-2, outside Git) +- `20261023_130000.jpg` (GameJam_References-2, outside Git) +- `20261023_130003.jpg` (GameJam_References-2, outside Git) +- [style-master-preview.png](../direction/images/style-master-preview.png) + +Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/pocket-computer.png` · rebuild: `python scripts/make_boards.py draw pocket-computer --redo --config art/sprites/boards.json` + + +[← Boss · Jenny, stunned and debris](boss-jenny-2.md) · [Sprite boards](README.md) · **17 / 26** · [Mensa · food pickups →](mensa-food.md) + diff --git a/art/sprites/previews/coffee-machine--idle.gif b/art/sprites/previews/coffee-machine--idle.gif new file mode 100644 index 0000000..db58fb0 Binary files /dev/null and b/art/sprites/previews/coffee-machine--idle.gif differ diff --git a/art/sprites/previews/coffee-machine--pour.gif b/art/sprites/previews/coffee-machine--pour.gif new file mode 100644 index 0000000..2d338cb Binary files /dev/null and b/art/sprites/previews/coffee-machine--pour.gif differ diff --git a/art/sprites/previews/coffee-visit-lineup.png b/art/sprites/previews/coffee-visit-lineup.png new file mode 100644 index 0000000..e66397f Binary files /dev/null and b/art/sprites/previews/coffee-visit-lineup.png differ diff --git a/art/sprites/previews/dexter-prologue--look-back.gif b/art/sprites/previews/dexter-prologue--look-back.gif new file mode 100644 index 0000000..679458d Binary files /dev/null and b/art/sprites/previews/dexter-prologue--look-back.gif differ diff --git a/art/sprites/previews/dexter-prologue--sit.gif b/art/sprites/previews/dexter-prologue--sit.gif new file mode 100644 index 0000000..85a5273 Binary files /dev/null and b/art/sprites/previews/dexter-prologue--sit.gif differ diff --git a/art/sprites/previews/dexter-prologue--wait.gif b/art/sprites/previews/dexter-prologue--wait.gif new file mode 100644 index 0000000..03980f9 Binary files /dev/null and b/art/sprites/previews/dexter-prologue--wait.gif differ diff --git a/art/sprites/previews/dexter-prologue-lineup.png b/art/sprites/previews/dexter-prologue-lineup.png new file mode 100644 index 0000000..85faeb1 Binary files /dev/null and b/art/sprites/previews/dexter-prologue-lineup.png differ diff --git a/art/sprites/previews/fx-ground--dust.gif b/art/sprites/previews/fx-ground--dust.gif new file mode 100644 index 0000000..68cbecf Binary files /dev/null and b/art/sprites/previews/fx-ground--dust.gif differ diff --git a/art/sprites/previews/fx-ground--grass.gif b/art/sprites/previews/fx-ground--grass.gif new file mode 100644 index 0000000..eb262bc Binary files /dev/null and b/art/sprites/previews/fx-ground--grass.gif differ diff --git a/art/sprites/previews/fx-ground--leaf-kick.gif b/art/sprites/previews/fx-ground--leaf-kick.gif new file mode 100644 index 0000000..fa97053 Binary files /dev/null and b/art/sprites/previews/fx-ground--leaf-kick.gif differ diff --git a/art/sprites/previews/fx-ground--splash.gif b/art/sprites/previews/fx-ground--splash.gif new file mode 100644 index 0000000..9d93e85 Binary files /dev/null and b/art/sprites/previews/fx-ground--splash.gif differ diff --git a/art/sprites/previews/fx-leaves--birch.gif b/art/sprites/previews/fx-leaves--birch.gif new file mode 100644 index 0000000..1043bdf Binary files /dev/null and b/art/sprites/previews/fx-leaves--birch.gif differ diff --git a/art/sprites/previews/fx-leaves--maple.gif b/art/sprites/previews/fx-leaves--maple.gif new file mode 100644 index 0000000..46153f0 Binary files /dev/null and b/art/sprites/previews/fx-leaves--maple.gif differ diff --git a/art/sprites/previews/fx-leaves--oak.gif b/art/sprites/previews/fx-leaves--oak.gif new file mode 100644 index 0000000..f3a2cb9 Binary files /dev/null and b/art/sprites/previews/fx-leaves--oak.gif differ diff --git a/art/sprites/previews/fx-water--lily-bob.gif b/art/sprites/previews/fx-water--lily-bob.gif new file mode 100644 index 0000000..c7b9c59 Binary files /dev/null and b/art/sprites/previews/fx-water--lily-bob.gif differ diff --git a/art/sprites/previews/fx-water--ripple.gif b/art/sprites/previews/fx-water--ripple.gif new file mode 100644 index 0000000..fa48bfa Binary files /dev/null and b/art/sprites/previews/fx-water--ripple.gif differ diff --git a/art/sprites/previews/fx-water--sparkle.gif b/art/sprites/previews/fx-water--sparkle.gif new file mode 100644 index 0000000..010ded3 Binary files /dev/null and b/art/sprites/previews/fx-water--sparkle.gif differ diff --git a/art/sprites/previews/mensa-food--cake.gif b/art/sprites/previews/mensa-food--cake.gif new file mode 100644 index 0000000..af74343 Binary files /dev/null and b/art/sprites/previews/mensa-food--cake.gif differ diff --git a/art/sprites/previews/mensa-food--leberkaese.gif b/art/sprites/previews/mensa-food--leberkaese.gif new file mode 100644 index 0000000..72410c4 Binary files /dev/null and b/art/sprites/previews/mensa-food--leberkaese.gif differ diff --git a/art/sprites/previews/mensa-food--muffin.gif b/art/sprites/previews/mensa-food--muffin.gif new file mode 100644 index 0000000..41b874c Binary files /dev/null and b/art/sprites/previews/mensa-food--muffin.gif differ diff --git a/art/sprites/previews/mensa-food--wafer.gif b/art/sprites/previews/mensa-food--wafer.gif new file mode 100644 index 0000000..eb485fc Binary files /dev/null and b/art/sprites/previews/mensa-food--wafer.gif differ diff --git a/art/sprites/previews/pocket-computer--bob.gif b/art/sprites/previews/pocket-computer--bob.gif new file mode 100644 index 0000000..d60ae1e Binary files /dev/null and b/art/sprites/previews/pocket-computer--bob.gif differ diff --git a/art/sprites/previews/pocket-computer--open.gif b/art/sprites/previews/pocket-computer--open.gif new file mode 100644 index 0000000..eb224c8 Binary files /dev/null and b/art/sprites/previews/pocket-computer--open.gif differ diff --git a/art/sprites/previews/prologue-readability.png b/art/sprites/previews/prologue-readability.png new file mode 100644 index 0000000..065568a Binary files /dev/null and b/art/sprites/previews/prologue-readability.png differ diff --git a/art/stages/hidden-garden/README.md b/art/stages/hidden-garden/README.md index 9993cb0..307a12c 100644 --- a/art/stages/hidden-garden/README.md +++ b/art/stages/hidden-garden/README.md @@ -2,9 +2,9 @@ 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** (#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/). +**Layers** (#142, docs/15 P2/P3): 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)). Back to front, like a hot dog: sky 0.1, far forest 0.25, mid trees 0.6 (region plates, painted whole under everything nearer and quilted), then at scroll 1 the rear (reeds, walls, the shed), the water, the trail, and the **near strip** – grass painted along each trail's walk line, z 6, drawn over the hero's feet – and last the blurred foreground leaves at 1.2. Every scroll-1 layer is filled behind the foreground and the strip ([fills/](fills/)), and the canopy pieces that hung from nothing on the grown chunks are painted out; the oak on the hill is a stump now. [docs/09](../../../docs/09-ART-PIPELINE.md#every-plate-complete--the-hot-dog-docs15-p2-p3) has the steps. **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`). +The whole walk: [prologue.png](images/prologue.png) · the parallax at five camera positions: [parallax.png](images/parallax.png) (`python scripts/compare.py parallax art/stages/hidden-garden --at 0 2000 4000 6000 8000`). | | | | --- | --- | diff --git a/art/stages/hidden-garden/atmosphere.json b/art/stages/hidden-garden/atmosphere.json new file mode 100644 index 0000000..1725c83 --- /dev/null +++ b/art/stages/hidden-garden/atmosphere.json @@ -0,0 +1,25 @@ +{ + "note": "Hidden Garden atmosphere (docs/09, Atmosphere): read by game/src/stage/render.ts. Sway in px at full weight; the weight grows from 0 at each column's anchor (its lowest opaque row, or its surface top for 'top') to 1 over 'reach' px. A layer without an entry takes 'defaults', its sway scaled by scroll squared.", + "wind": { "base": 0.45, "gust": 0.9, "gustSpeed": 240, "gustLength": 1400, "frequency": 0.55, "direction": -1 }, + "defaults": { "sway": 1.5, "reach": 260, "anchor": "base", "foliage": 0.3, "bob": 0, "blur": 0 }, + "layers": { + "00-sky": { "sway": 0 }, + "01-far": { "sway": 0.6, "reach": 220, "foliage": 0 }, + "02-mid": { "sway": 1, "reach": 260, "foliage": 0.25 }, + "03-rear": { "sway": 1.6, "reach": 300, "foliage": 0.3 }, + "04-water": { "ripple": 1.5, "shimmer": 0.5 }, + "05-play": { "sway": 1.5, "reach": 28, "anchor": "top", "foliage": 0.35 }, + "06-near": { "sway": 2.4, "reach": 36, "anchor": "base", "foliage": 0.6, "bob": 0, "blur": 0 }, + "07-foreground": { "sway": 3.5, "reach": 150, "foliage": 0.15, "bob": 1, "blur": 1.25 } + }, + "leaves": { + "palette": [], + "fall": "down", + "front": { "every": 420, "scale": [1, 2], "scroll": 1.15, "alpha": 1, "tint": "#ffffff" }, + "behind": { "every": 300, "scale": [1, 1.25], "scroll": 0.85, "alpha": 0.9, "tint": "#d8d0c8" } + }, + "water": { "layer": "04-water", "reflect": { "alpha": 0.5, "tint": "#7890b0" }, "pads": 3, "padTint": "#c8a070", "padScale": 1.15, "ripples": 900, "sparkles": 220, + "wrong": { "image": "wrong-reflection.png", "alpha": 0.75 } }, + "assets": ["wrong-reflection.png"], + "states": { "anomaly": { "leaves": { "fall": "up" } } } +} 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 a437daf..6ddfaa4 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 index c8b6c82..618eca4 100644 Binary files a/art/stages/hidden-garden/collision/hg-1-0.clean.png 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 index 8461308..4a2079b 100644 Binary files a/art/stages/hidden-garden/collision/hg-2-0.clean.png 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 index 28a21f1..8e936ae 100644 Binary files a/art/stages/hidden-garden/collision/hg-3-0.clean.png 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 index 9a0405e..504a200 100644 Binary files a/art/stages/hidden-garden/collision/hg-4-0.clean.png 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 6aaf1bb..f8d62f2 100644 --- a/art/stages/hidden-garden/export.json +++ b/art/stages/hidden-garden/export.json @@ -28,7 +28,8 @@ "z": 0, "scroll": 0.1, "region": true, - "rest": "tmp/boards/hidden-garden/filled/{name}.00-sky.filled.png" + "rest": "tmp/boards/hidden-garden/filled/{name}.00-sky.filled.png", + "blend": 480 }, "01-far": { "file": "layers/hidden-garden.01-far.{tile}.png", @@ -60,6 +61,11 @@ "z": 5, "scroll": 1.0 }, + "06-near": { + "file": "layers/{name}.06-near.png", + "z": 6, + "scroll": 1.0 + }, "07-foreground": { "file": "layers/hidden-garden.07-foreground.{tile}.png", "z": 7, @@ -76,6 +82,7 @@ "03-rear", "04-water", "05-play", + "06-near", "07-foreground" ] }, diff --git a/art/stages/hidden-garden/fills/hg-0-0.03-rear.png b/art/stages/hidden-garden/fills/hg-0-0.03-rear.png new file mode 100644 index 0000000..3762583 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-0-0.03-rear.png differ diff --git a/art/stages/hidden-garden/fills/hg-0-0.04-water.png b/art/stages/hidden-garden/fills/hg-0-0.04-water.png new file mode 100644 index 0000000..b7d55e3 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-0-0.04-water.png differ diff --git a/art/stages/hidden-garden/fills/hg-0-0.05-play.png b/art/stages/hidden-garden/fills/hg-0-0.05-play.png new file mode 100644 index 0000000..a69a51c Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-0-0.05-play.png differ diff --git a/art/stages/hidden-garden/fills/hg-1-0.03-rear.png b/art/stages/hidden-garden/fills/hg-1-0.03-rear.png new file mode 100644 index 0000000..e5ec2e0 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-1-0.03-rear.png differ diff --git a/art/stages/hidden-garden/fills/hg-1-0.04-water.png b/art/stages/hidden-garden/fills/hg-1-0.04-water.png new file mode 100644 index 0000000..9a430f4 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-1-0.04-water.png differ diff --git a/art/stages/hidden-garden/fills/hg-1-0.05-play.png b/art/stages/hidden-garden/fills/hg-1-0.05-play.png new file mode 100644 index 0000000..8821ae5 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-1-0.05-play.png differ diff --git a/art/stages/hidden-garden/fills/hg-2-0.03-rear.png b/art/stages/hidden-garden/fills/hg-2-0.03-rear.png new file mode 100644 index 0000000..1188d89 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-2-0.03-rear.png differ diff --git a/art/stages/hidden-garden/fills/hg-2-0.04-water.png b/art/stages/hidden-garden/fills/hg-2-0.04-water.png new file mode 100644 index 0000000..7d2b16d Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-2-0.04-water.png differ diff --git a/art/stages/hidden-garden/fills/hg-2-0.05-play.png b/art/stages/hidden-garden/fills/hg-2-0.05-play.png new file mode 100644 index 0000000..e94a6a1 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-2-0.05-play.png differ diff --git a/art/stages/hidden-garden/fills/hg-3-0.03-rear.png b/art/stages/hidden-garden/fills/hg-3-0.03-rear.png new file mode 100644 index 0000000..353e6b6 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-3-0.03-rear.png differ diff --git a/art/stages/hidden-garden/fills/hg-3-0.04-water.png b/art/stages/hidden-garden/fills/hg-3-0.04-water.png new file mode 100644 index 0000000..e42b1a0 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-3-0.04-water.png differ diff --git a/art/stages/hidden-garden/fills/hg-3-0.05-play.png b/art/stages/hidden-garden/fills/hg-3-0.05-play.png new file mode 100644 index 0000000..34328a5 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-3-0.05-play.png differ diff --git a/art/stages/hidden-garden/fills/hg-4-0.03-rear.png b/art/stages/hidden-garden/fills/hg-4-0.03-rear.png new file mode 100644 index 0000000..816e247 Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-4-0.03-rear.png differ diff --git a/art/stages/hidden-garden/fills/hg-4-0.04-water.png b/art/stages/hidden-garden/fills/hg-4-0.04-water.png new file mode 100644 index 0000000..9a2f39b Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-4-0.04-water.png differ diff --git a/art/stages/hidden-garden/fills/hg-4-0.05-play.png b/art/stages/hidden-garden/fills/hg-4-0.05-play.png new file mode 100644 index 0000000..19a168f Binary files /dev/null and b/art/stages/hidden-garden/fills/hg-4-0.05-play.png differ 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 022aba8..d2f3acf 100644 --- a/art/stages/hidden-garden/hg-0-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-0-0.clean.collision.json @@ -11,37 +11,65 @@ 0, 466 ], + [ + 130, + 461 + ], [ 240, - 452 + 454 + ], + [ + 330, + 462 ], [ 440, 470 ], [ - 652, + 1000, 470 ], [ - 700, + 1576, + 473 + ] + ], + [ + [ + 702, 431 ], [ - 960, + 958, 431 - ], + ] + ], + [ [ - 1008, - 470 + 1576, + 460 ], [ - 1550, - 474 + 1590, + 460 + ] + ], + [ + [ + 1590, + 446 ], [ - 1736, - 386 + 1880, + 446 + ] + ], + [ + [ + 1880, + 384 ], [ 2160, @@ -57,7 +85,17 @@ "x": 0, "y": 0, "w": 8, - "h": 720 + "h": 720, + "kind": "wall", + "note": "the garden's west wall" + }, + { + "x": 1630, + "y": 388, + "w": 206, + "h": 58, + "kind": "log", + "note": "the fallen birch: jump onto it or over it" } ], "noGo": [ @@ -65,7 +103,9 @@ "x": 0, "y": 482, "w": 2160, - "h": 238 + "h": 238, + "kind": "water", + "note": "the pond: never floor; falling in splashes and respawns at the bank" } ], "hazard": [], @@ -73,11 +113,12 @@ { "id": "dexter-waypoint-1", "kind": "dexter-waypoint", - "x": 760, + "x": 420, "y": 355, - "w": 80, + "w": 420, "h": 76, - "note": "the bench: walking up makes Dexter hop off and waddle ahead" + "note": "walking up the path towards the bench (x >= 420, ~9 m short of it) wakes Dexter: he looks round, bows, hops off and leads; the first quest starts", + "act": "wake" }, { "id": "dexter-waypoint-2", @@ -86,15 +127,17 @@ "y": 398, "w": 80, "h": 76, - "note": "at the fallen birch: Dexter hops onto it and looks back" + "note": "at the fallen birch: Dexter hops onto it and looks back", + "act": "wait" } ], "spawn": [ { "id": "player", "kind": "player", - "x": 120, - "y": 460 + "x": 205, + "y": 454, + "note": "the hero starts in the open gate (pillars x=95-310, gate leaves x=130-275), on the gravel path; Dexter is seen on the bench ahead (#160 P4)" }, { "id": "dexter-bench", @@ -105,5 +148,95 @@ } ], "ledge": [], - "glideLift": [] + "glideLift": [], + "terrain": [ + { + "x": [ + 1630, + 1836 + ], + "y": [ + 380, + 396 + ], + "kind": "log", + "material": "wood", + "note": "the fallen birch (solid)" + }, + { + "x": [ + 702, + 958 + ], + "y": [ + 420, + 440 + ], + "kind": "ledge", + "material": "gravel", + "note": "bench platform: the bank runs on in front of it" + }, + { + "x": [ + 0, + 130 + ], + "kind": "ground", + "material": "leaves" + }, + { + "x": [ + 130, + 285 + ], + "kind": "ground", + "material": "stone", + "note": "gate path pavers" + }, + { + "x": [ + 285, + 1008 + ], + "kind": "ground", + "material": "leaves", + "note": "the bank" + }, + { + "x": [ + 1008, + 1576 + ], + "kind": "ground", + "material": "grass", + "note": "the bank" + }, + { + "x": [ + 1576, + 1590 + ], + "kind": "step", + "material": "stone", + "note": "step up onto the slab" + }, + { + "x": [ + 1590, + 1880 + ], + "kind": "ground", + "material": "stone", + "note": "paved slab under the birch" + }, + { + "x": [ + 1880, + 2160 + ], + "kind": "wall", + "material": "stone", + "note": "low wall top: jump up from the slab or the log" + } + ] } diff --git a/art/stages/hidden-garden/hg-1-0.clean.collision.json b/art/stages/hidden-garden/hg-1-0.clean.collision.json index 0016d5f..56badb5 100644 --- a/art/stages/hidden-garden/hg-1-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-1-0.clean.collision.json @@ -11,111 +11,87 @@ 0, 384 ], + [ + 205, + 384 + ], + [ + 232, + 377 + ], [ 300, - 336 + 352 ], [ 407, - 312 + 327 ], [ 470, - 301 + 312 ] ], [ [ 640, - 268 + 278 ], [ 735, - 248 + 257 ], [ 829, - 233 + 241 ], [ 946, - 222 - ], - [ - 980, - 213 + 230 ], [ - 1040, - 203 + 1000, + 227 ], [ - 1059, - 218 + 1100, + 226 ], [ - 1075, - 219 - ], - [ - 1090, - 208 - ], - [ - 1115, - 219 - ], - [ - 1137, - 209 - ], - [ - 1194, - 223 - ], - [ - 1234, - 223 + 1200, + 229 ], [ 1255, - 231 + 236 ], [ 1327, - 247 + 252 ], [ 1362, - 251 + 257 ], [ 1481, - 277 + 284 ], [ 1701, - 339 + 345 ], [ 1807, - 364 + 369 ], [ 1918, - 379 + 383 ], [ 1942, - 385 - ], - [ - 2081, - 386 - ], - [ - 2104, - 389 + 387 ], [ 2160, @@ -138,15 +114,15 @@ [ [ 470, - 301 + 312 ], [ 556, - 286 + 296 ], [ 640, - 268 + 278 ] ] ], @@ -155,9 +131,9 @@ "id": "hollow-grass", "kind": "vines", "x": 472, - "y": 297, + "y": 306, "w": 24, - "h": 152, + "h": 143, "note": "the grass curtain: climb back out of the hollow" } ], @@ -165,9 +141,11 @@ "noGo": [ { "x": 0, - "y": 123, + "y": 482, "w": 2160, - "h": 597 + "h": 238, + "kind": "water", + "note": "the pond: never floor; falling in splashes and respawns at the bank" } ], "hazard": [], @@ -179,7 +157,8 @@ "y": 139, "w": 80, "h": 76, - "note": "the oak on the crest: Dexter waits and looks back" + "note": "the oak on the crest: Dexter waits and looks back", + "act": "wait" }, { "id": "hollow", @@ -200,5 +179,56 @@ } ], "ledge": [], - "glideLift": [] + "glideLift": [], + "terrain": [ + { + "x": [ + 480, + 628 + ], + "y": [ + 400, + 720 + ], + "kind": "ground", + "material": "earth", + "note": "the hollow under the path" + }, + { + "x": [ + 0, + 205 + ], + "kind": "wall", + "material": "stone", + "note": "wall top" + }, + { + "x": [ + 205, + 640 + ], + "kind": "ground", + "material": "gravel", + "note": "hill path over the hollow" + }, + { + "x": [ + 640, + 1942 + ], + "kind": "ground", + "material": "earth", + "note": "oak crest path" + }, + { + "x": [ + 1942, + 2160 + ], + "kind": "wall", + "material": "stone", + "note": "wall top" + } + ] } diff --git a/art/stages/hidden-garden/hg-2-0.clean.collision.json b/art/stages/hidden-garden/hg-2-0.clean.collision.json index b2a8f04..af61ed5 100644 --- a/art/stages/hidden-garden/hg-2-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-2-0.clean.collision.json @@ -12,65 +12,77 @@ 387 ], [ - 530, - 387 - ], + 552, + 388 + ] + ], + [ [ - 562, - 363 + 552, + 364 ], [ - 638, - 363 - ], + 655, + 364 + ] + ], + [ [ - 678, - 333 + 655, + 336 ], [ - 745, - 333 - ], + 757, + 336 + ] + ], + [ [ - 781, - 304 + 757, + 306 ], [ - 1440, - 304 - ], + 1452, + 306 + ] + ], + [ [ - 1476, - 333 + 1452, + 336 ], [ - 1545, - 333 - ], + 1553, + 336 + ] + ], + [ [ - 1581, - 363 + 1553, + 365 ], [ - 1640, - 363 - ], + 1647, + 365 + ] + ], + [ [ - 1672, - 387 + 1647, + 389 ], [ 2160, - 387 + 390 ] ], [ [ - 920, + 918, 146 ], [ - 1262, + 1268, 146 ] ] @@ -82,9 +94,9 @@ "id": "shed-ladder", "kind": "ladder", "x": 1240, - "y": 133, + "y": 140, "w": 47, - "h": 171 + "h": 166 } ], "solid": [], @@ -93,7 +105,9 @@ "x": 0, "y": 482, "w": 2160, - "h": 238 + "h": 238, + "kind": "water", + "note": "the pond: never floor; falling in splashes and respawns at the bank" } ], "hazard": [], @@ -105,7 +119,8 @@ "y": 228, "w": 80, "h": 76, - "note": "the top of the terrace: Dexter points up at the roof, then hops on" + "note": "the top of the terrace: Dexter points back up at the glint on the shed roof, then waits", + "act": "point" } ], "spawn": [ @@ -118,5 +133,65 @@ } ], "ledge": [], - "glideLift": [] + "glideLift": [], + "terrain": [ + { + "x": [ + 918, + 1268 + ], + "y": [ + 0, + 250 + ], + "kind": "plank", + "material": "wood", + "note": "shed roof" + }, + { + "x": [ + 0, + 552 + ], + "kind": "wall", + "material": "stone", + "note": "terrace foot" + }, + { + "x": [ + 552, + 757 + ], + "kind": "ledge", + "material": "stone", + "note": "terrace tiers: hop up, drop down" + }, + { + "x": [ + 757, + 1452 + ], + "kind": "ground", + "material": "stone", + "note": "terrace" + }, + { + "x": [ + 1452, + 1647 + ], + "kind": "ledge", + "material": "stone", + "note": "terrace tiers" + }, + { + "x": [ + 1647, + 2160 + ], + "kind": "wall", + "material": "stone", + "note": "terrace foot" + } + ] } diff --git a/art/stages/hidden-garden/hg-3-0.clean.collision.json b/art/stages/hidden-garden/hg-3-0.clean.collision.json index a33f154..207c2a2 100644 --- a/art/stages/hidden-garden/hg-3-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-3-0.clean.collision.json @@ -12,98 +12,68 @@ 390 ], [ - 294, - 390 + 838, + 391 ] ], [ [ - 362, - 390 - ], - [ - 577, - 393 - ], - [ - 627, - 390 + 838, + 437 ], [ - 832, - 389 + 866, + 437 ] ], [ [ - 911, - 462 - ], - [ - 922, + 902, 459 ], [ - 961, - 458 - ], - [ - 978, - 461 + 980, + 459 ] ], [ [ - 1039, - 468 - ], - [ - 1044, - 464 + 1040, + 462 ], [ - 1075, - 461 + 1077, + 462 ] ], [ [ - 1079, + 1077, 393 ], [ - 1224, - 392 - ], - [ - 1284, - 440 - ], - [ - 1332, - 437 - ], - [ - 1385, + 1253, 393 - ], + ] + ], + [ [ - 1818, - 391 + 1253, + 438 ], [ - 1989, - 392 + 1360, + 438 ] ], [ [ - 2027, - 392 + 1360, + 393 ], [ 2160, - 391 + 392 ] ] ], @@ -114,9 +84,11 @@ "noGo": [ { "x": 0, - "y": 482, + "y": 472, "w": 2160, - "h": 238 + "h": 248, + "kind": "water", + "note": "the pond: never floor; falling in splashes and respawns at the bank" } ], "hazard": [], @@ -128,10 +100,76 @@ "y": 320, "w": 80, "h": 76, - "note": "past the gap: Dexter waits on the far side, hopping on the spot" + "note": "past the gap: Dexter waits on the far side, hopping on the spot", + "act": "wait" } ], "spawn": [], "ledge": [], - "glideLift": [] + "glideLift": [], + "terrain": [ + { + "x": [ + 0, + 838 + ], + "kind": "wall", + "material": "stone", + "note": "wall top" + }, + { + "x": [ + 838, + 866 + ], + "kind": "ledge", + "material": "stone", + "note": "rubble at the broken wall end" + }, + { + "x": [ + 902, + 980 + ], + "kind": "ground", + "material": "stone", + "note": "stepping stone" + }, + { + "x": [ + 1040, + 1077 + ], + "kind": "ground", + "material": "stone", + "note": "stepping stone at the wall foot" + }, + { + "x": [ + 1077, + 1253 + ], + "kind": "wall", + "material": "stone", + "note": "wall top: a 69 px face, jump up from the stone" + }, + { + "x": [ + 1253, + 1360 + ], + "kind": "ledge", + "material": "stone", + "note": "the broken section: drop in, hop out" + }, + { + "x": [ + 1360, + 2160 + ], + "kind": "wall", + "material": "stone", + "note": "wall top" + } + ] } diff --git a/art/stages/hidden-garden/hg-4-0.clean.collision.json b/art/stages/hidden-garden/hg-4-0.clean.collision.json index 27383f8..9f96686 100644 --- a/art/stages/hidden-garden/hg-4-0.clean.collision.json +++ b/art/stages/hidden-garden/hg-4-0.clean.collision.json @@ -12,13 +12,13 @@ 392 ], [ - 160, + 177, 392 ] ], [ [ - 160, + 177, 441 ], [ @@ -35,7 +35,9 @@ "x": 1326, "y": 300, "w": 24, - "h": 141 + "h": 141, + "kind": "wall", + "note": "the pier's end post" } ], "noGo": [ @@ -43,7 +45,9 @@ "x": 0, "y": 478, "w": 2160, - "h": 242 + "h": 242, + "kind": "water", + "note": "the pond: never floor; falling in splashes and respawns at the bank" } ], "hazard": [], @@ -55,7 +59,7 @@ "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)" + "note": "the jetty edge: with Dexter at the jetty the finale runs - the wrong reflection (anomaly-reveal), the shove, the 404, the pull (story/Prologue.ts)" }, { "id": "to-rusty-creek", @@ -64,7 +68,7 @@ "y": 365, "w": 70, "h": 76, - "note": "the crossing: after the shove the hero lands in clean Rusty Creek (rc-0-0)" + "note": "the way out of the garden is the 404 pull into clean Rusty Creek (rc-0-0); story/Prologue.ts swaps the region" }, { "id": "dexter-waypoint-pond-1", @@ -73,7 +77,8 @@ "y": 365, "w": 80, "h": 76, - "note": "the old bench by the pond: Dexter waits here and looks back" + "note": "the old bench by the pond: Dexter waits here and looks back", + "act": "wait" }, { "id": "dexter-waypoint-pond-2", @@ -82,7 +87,8 @@ "y": 365, "w": 80, "h": 76, - "note": "mid-path: Dexter hops ahead and beckons" + "note": "mid-path: Dexter hops ahead and beckons", + "act": "wait" }, { "id": "dexter-waypoint-pond-3", @@ -91,10 +97,40 @@ "y": 365, "w": 80, "h": 76, - "note": "the jetty: Dexter stops and points at the water" + "note": "the jetty: Dexter stops and points at the water; the hero passes him to the edge", + "act": "pond" } ], "spawn": [], "ledge": [], - "glideLift": [] + "glideLift": [], + "terrain": [ + { + "x": [ + 0, + 177 + ], + "kind": "wall", + "material": "stone", + "note": "wall top: drop to the pond walk" + }, + { + "x": [ + 177, + 1020 + ], + "kind": "ground", + "material": "stone", + "note": "pond walk" + }, + { + "x": [ + 1020, + 1330 + ], + "kind": "plank", + "material": "wood", + "note": "pier" + } + ] } diff --git a/art/stages/hidden-garden/images/hg-0-0.png b/art/stages/hidden-garden/images/hg-0-0.png index fa68127..e522443 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.png b/art/stages/hidden-garden/images/hg-1-0.png index a42e5a6..a06f94f 100644 Binary files a/art/stages/hidden-garden/images/hg-1-0.png and b/art/stages/hidden-garden/images/hg-1-0.png differ diff --git a/art/stages/hidden-garden/images/hg-2-0.png b/art/stages/hidden-garden/images/hg-2-0.png index f70a260..cf89d5f 100644 Binary files a/art/stages/hidden-garden/images/hg-2-0.png and b/art/stages/hidden-garden/images/hg-2-0.png differ diff --git a/art/stages/hidden-garden/images/hg-3-0.png b/art/stages/hidden-garden/images/hg-3-0.png index cb798ec..56793fa 100644 Binary files a/art/stages/hidden-garden/images/hg-3-0.png and b/art/stages/hidden-garden/images/hg-3-0.png differ diff --git a/art/stages/hidden-garden/images/hg-4-0.png b/art/stages/hidden-garden/images/hg-4-0.png index 585a48c..294a919 100644 Binary files a/art/stages/hidden-garden/images/hg-4-0.png 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 index d8a7f57..f664189 100644 Binary files a/art/stages/hidden-garden/images/parallax.png and b/art/stages/hidden-garden/images/parallax.png differ diff --git a/art/stages/hidden-garden/images/surfaces-feet-preview.png b/art/stages/hidden-garden/images/surfaces-feet-preview.png new file mode 100644 index 0000000..22c09da Binary files /dev/null and b/art/stages/hidden-garden/images/surfaces-feet-preview.png differ diff --git a/art/stages/hidden-garden/layers.json b/art/stages/hidden-garden/layers.json index af48773..f863a31 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. 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.", + "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. 06-near is the grass strip painted in front of the trail (depth_layers.py near); fills/..png is what a scroll-1 layer shows behind the near strip and the foreground (depth_layers.py under; split_layers.py puts it under the nearer layers).", "chunks": { "hg-0-0": { "states": { @@ -27,6 +27,10 @@ "layer": "05-play", "alpha": "masks/hg-0-0.05-play.png" }, + { + "layer": "06-near", + "alpha": "masks/hg-0-0.06-near.png" + }, { "layer": "07-foreground", "alpha": "masks/hg-0-0.07-foreground.png" @@ -59,6 +63,10 @@ "layer": "05-play", "alpha": "masks/hg-1-0.05-play.png" }, + { + "layer": "06-near", + "alpha": "masks/hg-1-0.06-near.png" + }, { "layer": "07-foreground", "alpha": "masks/hg-1-0.07-foreground.png" @@ -91,6 +99,10 @@ "layer": "05-play", "alpha": "masks/hg-2-0.05-play.png" }, + { + "layer": "06-near", + "alpha": "masks/hg-2-0.06-near.png" + }, { "layer": "07-foreground", "alpha": "masks/hg-2-0.07-foreground.png" @@ -123,6 +135,10 @@ "layer": "05-play", "alpha": "masks/hg-3-0.05-play.png" }, + { + "layer": "06-near", + "alpha": "masks/hg-3-0.06-near.png" + }, { "layer": "07-foreground", "alpha": "masks/hg-3-0.07-foreground.png" @@ -174,6 +190,10 @@ 478 ] }, + { + "layer": "06-near", + "alpha": "masks/hg-4-0.06-near.png" + }, { "layer": "07-foreground", "colour": [ 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 index 20511dc..e3ea3d8 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.02-mid.png 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 5f37a0f..fbaf484 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 903b4c4..6da9728 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 0687f55..ee87fbc 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.06-near.png b/art/stages/hidden-garden/layers/hg-0-0.06-near.png new file mode 100644 index 0000000..4cfd45d Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.06-near.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 index 74ed610..9aec3a6 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.00-sky.png 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 index 58eb63b..370d1dd 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.01-far.png 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 index 1771634..737f3d4 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.02-mid.png 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 index b8c1b45..43d9fd2 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.03-rear.png 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 index 56a14ed..336b431 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.04-water.png 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 index 2be6019..8fbb651 100644 Binary files a/art/stages/hidden-garden/layers/hg-1-0.05-play.png 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.06-near.png b/art/stages/hidden-garden/layers/hg-1-0.06-near.png new file mode 100644 index 0000000..baec2be Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-1-0.06-near.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 index 70a0376..e47c5a5 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.00-sky.png 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 index bffc2d2..dd0458b 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.01-far.png 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 index 6fc29e5..1b0870c 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.02-mid.png 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 index 28168a5..5ae8c19 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.03-rear.png 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 index f3b8e87..ecfdbc8 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.04-water.png 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 index 6ae80f3..fdd2348 100644 Binary files a/art/stages/hidden-garden/layers/hg-2-0.05-play.png 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.06-near.png b/art/stages/hidden-garden/layers/hg-2-0.06-near.png new file mode 100644 index 0000000..a907192 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-2-0.06-near.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 index 8930896..5a5630d 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.00-sky.png 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 index cc277f4..b1df278 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.01-far.png 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 index 8a945dd..5993edf 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.02-mid.png 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 index aed5ade..83d99f2 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.03-rear.png 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 index 05cf1a7..cbeece9 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.04-water.png 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 index 69f4210..46b962d 100644 Binary files a/art/stages/hidden-garden/layers/hg-3-0.05-play.png 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.06-near.png b/art/stages/hidden-garden/layers/hg-3-0.06-near.png new file mode 100644 index 0000000..1586cb2 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-3-0.06-near.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 index e21f95b..196ef50 100644 Binary files a/art/stages/hidden-garden/layers/hg-4-0.03-rear.png 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 index d7b6095..eb5d239 100644 Binary files a/art/stages/hidden-garden/layers/hg-4-0.04-water.png 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 index 13bab75..73b4c69 100644 Binary files a/art/stages/hidden-garden/layers/hg-4-0.05-play.png 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.06-near.png b/art/stages/hidden-garden/layers/hg-4-0.06-near.png new file mode 100644 index 0000000..bf84abb Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-4-0.06-near.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 index 2a72df3..5424649 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.00-sky.0.png 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 index 4becb77..a72bf21 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.00-sky.1.png 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 index 165dc69..a7a0251 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.01-far.0.png 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 index 431d463..1fcca55 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.01-far.1.png 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 index eac322c..116984a 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.02-mid.0.png 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 index 1560dd3..720c73b 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.02-mid.1.png 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 index fb50c1f..c1c22c0 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.02-mid.2.png 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 index 8cbb998..e2a1cac 100644 Binary files a/art/stages/hidden-garden/layers/hidden-garden.02-mid.3.png and b/art/stages/hidden-garden/layers/hidden-garden.02-mid.3.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.06-near.png b/art/stages/hidden-garden/masks/hg-0-0.06-near.png new file mode 100644 index 0000000..ea03270 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.06-near.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 index e9279cb..3227d06 100644 Binary files a/art/stages/hidden-garden/masks/hg-1-0.00-sky.png 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 index f88eb7b..d5727fd 100644 Binary files a/art/stages/hidden-garden/masks/hg-1-0.01-far.png 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 index 6920a20..1287c18 100644 Binary files a/art/stages/hidden-garden/masks/hg-1-0.02-mid.png 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.06-near.png b/art/stages/hidden-garden/masks/hg-1-0.06-near.png new file mode 100644 index 0000000..39e707d Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-1-0.06-near.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 index a069303..07dd935 100644 Binary files a/art/stages/hidden-garden/masks/hg-2-0.00-sky.png 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 index 6596e69..e91f56d 100644 Binary files a/art/stages/hidden-garden/masks/hg-2-0.01-far.png 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 index 03313c0..7d8e6cf 100644 Binary files a/art/stages/hidden-garden/masks/hg-2-0.02-mid.png 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.06-near.png b/art/stages/hidden-garden/masks/hg-2-0.06-near.png new file mode 100644 index 0000000..4116c68 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-2-0.06-near.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 index 8e2f95a..304658e 100644 Binary files a/art/stages/hidden-garden/masks/hg-3-0.00-sky.png 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 index 1b875c2..4625c9d 100644 Binary files a/art/stages/hidden-garden/masks/hg-3-0.01-far.png 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 index c576166..66318fd 100644 Binary files a/art/stages/hidden-garden/masks/hg-3-0.02-mid.png 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.06-near.png b/art/stages/hidden-garden/masks/hg-3-0.06-near.png new file mode 100644 index 0000000..161fdcf Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-3-0.06-near.png differ diff --git a/art/stages/hidden-garden/masks/hg-4-0.06-near.png b/art/stages/hidden-garden/masks/hg-4-0.06-near.png new file mode 100644 index 0000000..518b243 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-4-0.06-near.png differ diff --git a/art/stages/hidden-garden/surfaces.json b/art/stages/hidden-garden/surfaces.json new file mode 100644 index 0000000..a602556 --- /dev/null +++ b/art/stages/hidden-garden/surfaces.json @@ -0,0 +1,56 @@ +{ + "region": "hidden-garden", + "note": "What the hero stands on. Ranges are chunk-local px (x from the left edge of the chunk, optional y band for stacked floors), read off layers/hg-*.05-play.png against the walk lines; the first match wins, else `default`. sink: px the feet sink in (visual only, physics unchanged); cover: lowest sprite rows hidden by the ground; blades: tufts drawn in front of the feet; puff: what a footfall or landing kicks up – `sprite` names an fx-ground clip, the procedural pixels in the garden palette are the fallback and the extra debris of a hard landing.", + "default": "grass", + "materials": { + "grass": {"sink": 3, "cover": 2, "blades": ["#c98a2a", "#e0a63c", "#8f5a1c"], "puff": {"sprite": "grass", "shape": "blade", "colors": ["#d79a34", "#f0bf55", "#9c6420"], "count": 5}}, + "leaves": {"sink": 3, "cover": 2, "blades": ["#d0702a", "#e89a3a", "#9a3f1c"], "puff": {"sprite": "leaf-kick", "shape": "leaf", "colors": ["#e07a2c", "#f2a640", "#b4471f"], "count": 5}}, + "earth": {"sink": 2, "cover": 1, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#b89a6e", "#d8c09a", "#8c7350"], "count": 5}}, + "gravel": {"sink": 2, "cover": 1, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#b8a58a", "#d9cbb0", "#8a7a66"], "count": 4}}, + "stone": {"sink": 1, "cover": 0, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#c9bfa8", "#e6dcc4", "#9b927f"], "count": 3}}, + "wood": {"sink": 1, "cover": 0, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#c8b08a", "#e9dbbf", "#9a8060"], "count": 3}}, + "water": {"sink": 0, "cover": 0, "puff": {"sprite": "splash", "shape": "dust", "colors": ["#9cc8f0", "#e8f4ff", "#4f86c6"], "count": 8}} + }, + "chunks": { + "hg-0-0": [ + {"x": [1630, 1836], "y": [380, 396], "material": "wood", "note": "log: the fallen birch (solid)"}, + {"x": [702, 958], "y": [420, 440], "material": "gravel", "note": "ledge: bench platform: the bank runs on in front of it"}, + {"x": [0, 130], "material": "leaves", "note": "ground"}, + {"x": [130, 285], "material": "stone", "note": "ground: gate path pavers"}, + {"x": [285, 1008], "material": "leaves", "note": "ground: the bank"}, + {"x": [1008, 1576], "material": "grass", "note": "ground: the bank"}, + {"x": [1576, 1590], "material": "stone", "note": "step: step up onto the slab"}, + {"x": [1590, 1880], "material": "stone", "note": "ground: paved slab under the birch"}, + {"x": [1880, 2160], "material": "stone", "note": "wall: low wall top: jump up from the slab or the log"} + ], + "hg-1-0": [ + {"x": [480, 628], "y": [400, 720], "material": "earth", "note": "ground: the hollow under the path"}, + {"x": [0, 205], "material": "stone", "note": "wall: wall top"}, + {"x": [205, 640], "material": "gravel", "note": "ground: hill path over the hollow"}, + {"x": [640, 1942], "material": "earth", "note": "ground: oak crest path"}, + {"x": [1942, 2160], "material": "stone", "note": "wall: wall top"} + ], + "hg-2-0": [ + {"x": [918, 1268], "y": [0, 250], "material": "wood", "note": "plank: shed roof"}, + {"x": [0, 552], "material": "stone", "note": "wall: terrace foot"}, + {"x": [552, 757], "material": "stone", "note": "ledge: terrace tiers: hop up, drop down"}, + {"x": [757, 1452], "material": "stone", "note": "ground: terrace"}, + {"x": [1452, 1647], "material": "stone", "note": "ledge: terrace tiers"}, + {"x": [1647, 2160], "material": "stone", "note": "wall: terrace foot"} + ], + "hg-3-0": [ + {"x": [0, 838], "material": "stone", "note": "wall: wall top"}, + {"x": [838, 866], "material": "stone", "note": "ledge: rubble at the broken wall end"}, + {"x": [910, 980], "material": "stone", "note": "ground: stepping stone"}, + {"x": [1040, 1077], "material": "stone", "note": "ground: stepping stone at the wall foot"}, + {"x": [1077, 1253], "material": "stone", "note": "wall: wall top: a 69 px face, jump up from the stone"}, + {"x": [1253, 1360], "material": "stone", "note": "ledge: the broken section: drop in, hop out"}, + {"x": [1360, 2160], "material": "stone", "note": "wall: wall top"} + ], + "hg-4-0": [ + {"x": [0, 177], "material": "stone", "note": "wall: wall top: drop to the pond walk"}, + {"x": [177, 1020], "material": "stone", "note": "ground: pond walk"}, + {"x": [1020, 1330], "material": "wood", "note": "plank: pier"} + ] + } +} diff --git a/art/stages/hidden-garden/wrong-reflection.png b/art/stages/hidden-garden/wrong-reflection.png new file mode 100644 index 0000000..939aa51 Binary files /dev/null and b/art/stages/hidden-garden/wrong-reflection.png differ diff --git a/art/stages/rusty-creek/rc-0-0.anomaly.collision.json b/art/stages/rusty-creek/rc-0-0.anomaly.collision.json index a53d7ae..b5bf59b 100644 --- a/art/stages/rusty-creek/rc-0-0.anomaly.collision.json +++ b/art/stages/rusty-creek/rc-0-0.anomaly.collision.json @@ -52,7 +52,7 @@ 557 ], [ - 1488, + 1490, 557 ] ], @@ -246,6 +246,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } @@ -296,6 +297,26 @@ 640, 880 ] + }, + { + "id": "rover-4", + "kind": "rover", + "x": 1250, + "y": 505, + "patrol": [ + 1150, + 1370 + ] + }, + { + "id": "rover-5", + "kind": "rover", + "x": 1950, + "y": 461, + "patrol": [ + 1820, + 2120 + ] } ], "ledge": [], diff --git a/art/stages/rusty-creek/rc-0-0.clean.collision.json b/art/stages/rusty-creek/rc-0-0.clean.collision.json index 79382f7..727f072 100644 --- a/art/stages/rusty-creek/rc-0-0.clean.collision.json +++ b/art/stages/rusty-creek/rc-0-0.clean.collision.json @@ -9,11 +9,11 @@ [ [ 0, - 132 + 566 ], [ 2160, - 132 + 566 ] ] ], @@ -32,6 +32,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } @@ -42,8 +43,9 @@ { "id": "player", "kind": "player", - "x": 160, - "y": 132 + "x": 600, + "y": 566, + "note": "The 404 lands the hero on the creek path under the rail bridge (P5)" } ], "ledge": [], diff --git a/art/stages/rusty-creek/rc-1-0.anomaly.collision.json b/art/stages/rusty-creek/rc-1-0.anomaly.collision.json index abe42cb..d7db381 100644 --- a/art/stages/rusty-creek/rc-1-0.anomaly.collision.json +++ b/art/stages/rusty-creek/rc-1-0.anomaly.collision.json @@ -271,6 +271,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } @@ -341,6 +342,16 @@ "kind": "dexter", "x": 1820, "y": 537 + }, + { + "id": "rover-6", + "kind": "rover", + "x": 520, + "y": 489, + "patrol": [ + 380, + 690 + ] } ], "ledge": [], diff --git a/art/stages/rusty-creek/rc-1-0.clean.collision.json b/art/stages/rusty-creek/rc-1-0.clean.collision.json index 2d0219f..c88b9f0 100644 --- a/art/stages/rusty-creek/rc-1-0.clean.collision.json +++ b/art/stages/rusty-creek/rc-1-0.clean.collision.json @@ -9,11 +9,11 @@ [ [ 0, - 132 + 566 ], [ 2160, - 132 + 566 ] ] ], @@ -25,6 +25,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } @@ -35,10 +36,10 @@ "id": "jenny-ram", "kind": "jenny-ram", "x": 1080, - "y": 40, + "y": 474, "w": 40, "h": 92, - "note": "Jenny crosses the bridge and rams the hero; the region turns (M4-S1 #54)" + "note": "Jenny comes down the creek path and rams the hero; the region turns (M4-S1 #54, P5)" } ], "spawn": [], diff --git a/art/stages/rusty-creek/rc-2-0.anomaly.collision.json b/art/stages/rusty-creek/rc-2-0.anomaly.collision.json index 4851fff..0577fb6 100644 --- a/art/stages/rusty-creek/rc-2-0.anomaly.collision.json +++ b/art/stages/rusty-creek/rc-2-0.anomaly.collision.json @@ -39,11 +39,11 @@ [ [ 852, - 572 + 571 ], [ - 930, - 572 + 935, + 571 ] ], [ @@ -270,6 +270,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } @@ -314,6 +315,16 @@ "kind": "dexter", "x": 1640, "y": 565 + }, + { + "id": "rover-7", + "kind": "rover", + "x": 700, + "y": 536, + "patrol": [ + 560, + 830 + ] } ], "ledge": [], diff --git a/art/stages/rusty-creek/rc-2-0.clean.collision.json b/art/stages/rusty-creek/rc-2-0.clean.collision.json index 45dbb17..32fe613 100644 --- a/art/stages/rusty-creek/rc-2-0.clean.collision.json +++ b/art/stages/rusty-creek/rc-2-0.clean.collision.json @@ -9,11 +9,11 @@ [ [ 0, - 132 + 566 ], [ 2160, - 132 + 566 ] ] ], @@ -32,6 +32,7 @@ { "x": 0, "y": 600, + "kind": "water", "w": 2160, "h": 120 } diff --git a/art/stages/rusty-creek/surfaces.json b/art/stages/rusty-creek/surfaces.json new file mode 100644 index 0000000..85d75b9 --- /dev/null +++ b/art/stages/rusty-creek/surfaces.json @@ -0,0 +1,14 @@ +{ + "region": "rusty-creek", + "note": "Same schema as hidden-garden/surfaces.json. The creek is girders and rails (steel) over concrete bank blocks near the water; read off images/after-*.png and before-*.png against the walk lines.", + "default": "steel", + "materials": { + "steel": {"sink": 1, "cover": 0, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#a86a3c", "#c98e5a", "#6f7d82"], "count": 3}}, + "stone": {"sink": 1, "cover": 0, "puff": {"sprite": "dust", "shape": "dust", "colors": ["#b9b3a3", "#d9d3c2", "#8a857a"], "count": 3}} + }, + "chunks": { + "rc-0-0": [{"x": [0, 2160], "y": [490, 720], "states": ["anomaly"], "material": "stone", "note": "bank blocks"}], + "rc-1-0": [{"x": [0, 2160], "y": [490, 720], "states": ["anomaly"], "material": "stone", "note": "bank blocks"}], + "rc-2-0": [{"x": [0, 2160], "y": [490, 720], "states": ["anomaly"], "material": "stone", "note": "bank blocks"}] + } +} diff --git a/docs/01-PROPOSAL.md b/docs/01-PROPOSAL.md index 8e0c431..33334b0 100644 --- a/docs/01-PROPOSAL.md +++ b/docs/01-PROPOSAL.md @@ -1,7 +1,7 @@ # 01 – Proposal -[← Design documents](README.md) · [Design docs](README.md) · **1 / 14** · [02 – Game design →](02-GAME-DESIGN.md) +[← Design documents](README.md) · [Design docs](README.md) · **1 / 15** · [02 – Game design →](02-GAME-DESIGN.md) ![Rusty Creek – our first stage, current art](../art/stages/rusty-creek/images/after-center.png) @@ -70,5 +70,5 @@ Everything else in this folder is the full game, kept so the jam slice is built | Alexander | Pipeline, art generation, build/deploy, integration | -[← Design documents](README.md) · [Design docs](README.md) · **1 / 14** · [02 – Game design →](02-GAME-DESIGN.md) +[← Design documents](README.md) · [Design docs](README.md) · **1 / 15** · [02 – Game design →](02-GAME-DESIGN.md) diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md index 77f97f2..d29531b 100644 --- a/docs/02-GAME-DESIGN.md +++ b/docs/02-GAME-DESIGN.md @@ -1,7 +1,7 @@ # 02 – Game design -[← 01 – Proposal](01-PROPOSAL.md) · [Design docs](README.md) · **2 / 14** · [03 – Story and cast →](03-STORY-AND-CAST.md) +[← 01 – Proposal](01-PROPOSAL.md) · [Design docs](README.md) · **2 / 15** · [03 – Story and cast →](03-STORY-AND-CAST.md) ![What playing looks like – HUD and the pocket computer's map](../art/direction/images/hud-preview.png) @@ -38,7 +38,7 @@ The weapon is switched in the pocket computer menu (or ←/→ while the menu is ## Movement -- **Walk / run:** one speed curve, about 3.5 m/s walking and 6 m/s running, with a short acceleration so the poncho has weight. +- **Walk / run:** one speed curve, **2.2 m/s walking and 4.6 m/s running** (95 and 198 px/s at 43 px/m), with a short acceleration so the poncho has weight. The speeds come from the art (#156): the approved walk cycle's stride is 43 px and the run's 82 px (`python scripts/compare.py stride hero-move:walk hero-run:run`), and both cycles are played by distance – one frame per stride ÷ frames of travel – so the planted foot never slides at any speed. The first numbers (3.5 / 6 m/s) were 2.3–2.5× faster than the feet could carry the body. - **Jump:** variable height, from about 1.2 m (tap) to 2.2 m (hold). Coyote time is 0.1 s and jump buffering 0.12 s. These two are the cheapest things that make a platformer feel good. - **Hills:** the ground is a smooth polyline. You walk up and down slopes up to 40°, like Planet of Lana and Ori. There are no staircase blocks. - **Slides:** slopes steeper than 40° are slides. You skid down them fast with the poncho flapping, and can jump off at any point. @@ -49,6 +49,30 @@ The weapon is switched in the pocket computer menu (or ←/→ while the menu is - **Four directions:** every region is explored left/right *and* up/down: a mountainside route, a lower branch, an underground pocket ([04](04-WORLD-AND-LEVELS.md)). - **No wall-jump, no dash in the jam slice.** +### Feel and grounding (prologue slice, workstream 3) + +The hero stands *in* the ground, not on it, and every pose comes from the drawn clips at their own timing. All of it is data: `art/stages//surfaces.json` (exported next to `stage.json`) and the atlases' `anims.json`. + +```mermaid +flowchart LR + SF["surfaces.json
chunk-local x / y ranges → material"] --> SM["SurfaceMap.at(x, y)"] + SM --> G["Grounding
sink + slope + landing settle,
eased, 0 in the air"] + G --> SP["sprite y + sink,
lowest `cover` rows cropped,
tufts in front"] + A["atlas pixels"] --> FF["footfalls
planted feet rise: walk 00/04, run 01/05"] + FF --> PF["fx-ground puff by surface
(dust / grass / leaf-kick)"] + L["landing / hard landing"] --> PF + AJ["anims.json fps"] --> T["clip timing, landing lengths,
hit / knockback / death"] +``` + +- **Sink by surface (visual only, physics unchanged).** Grass and leaves 3 px with the lowest 2 rows hidden and a few grass tufts drawn over the feet; earth and gravel 2 px; stone and wood 1 px; the creek's steel 1 px. The sink eases (no pop stepping from stone onto grass) and is 0 in the air. On a slope the sprite drops by the lower foot (8 px × slope, at most 4 px) instead of rotating the pixel art. A landing presses 1 px (hard: 2 px) further in and eases out over the landing clip – the drawn landing poses are the squash; nothing is scaled. +- **Footfalls** are measured from the atlas at load: a frame where the number of planted feet (opaque runs ≥ 6 px wide in the bottom 2 rows) rises. That is walk 00/04 and run 01/05 on the approved cycles. Each footfall kicks the surface's `fx-ground` clip behind the foot; a landing kicks one to each side, a hard landing wider plus debris pixels. +- **Jump and fall follow the arc, not the clock:** the jump row's crouch shows for one rendered frame on take-off (none out of a landing – the landing was the crouch), frames 1–4 map the rise (apex pose at vy 0) and 5–6 the descent; a fall longer than a jump's own drops into the fall loop; a walk-off starts on the descent. The landing is the jump row's own crouch and stand (07/08), the hard landing its clip with the impact held on twos; both lengths come from `anims.json`. +- **Steps are not falls (wave 2, P6):** a walk-off keeps its stride for 0.12 s of air (about 8 px), a step drop under 10 px lands without a pose, and a moving hero lands on the crouch only (a hard landing on its impact) and goes straight into the walk or run cycle it is in – never through the standing idle and a run-start. A released jump keeps its launch speed until it can still reach the 52 px minimum under 3× gravity, then brakes: a tap hop tops out in 0.18 s. A buffered press launches on the tick after touchdown. +- **Idle life:** the standing hero breathes – the chest rises 1 px for two idle loops in four, changing only where the idle clip loops – and the poncho's lower half takes 1 px downwind while a gust of the region's wind passes (whole pixels, three crops of the same frame; the legs never move). The variants (look-around, check-tablet; check-wounds at two hearts or fewer) start where the idle loops after 5–9 s standing, hold their key pose, and never repeat back to back. +- **Hurt and death** play the drawn `hit` clip (its first frame is the white flash), `knockback` for hits of 2+ hearts recovering through `hit` 03–07, and `death` – all at their fps. The invulnerability blink starts after the clip; walking away ends the recovery early. +- **Camera:** look-ahead 120 px at run speed, eased slowly enough that a turn never whips; a vertical dead zone (40 px up, 16 px down) so slopes and steps do not bob the view, and through a jump the framing holds unless the hero climbs 150 px above it or drops below it. The camera snaps to whole pixels as *hero position − rounded offset*, so at a stride-paced walk the hero stays on one screen pixel while the scenery scrolls. +- **Spawns and resets** stand on the walk line within 8 px, so an authored spawn a pixel inside the ground does not fall through. + ## Health, damage, death - **Health:** 5 battery cells (HUD top-left, Hollow Knight-sized). Enemy contact or a hit costs 1 cell; Jenny's charge costs 2. @@ -249,5 +273,5 @@ Concepts for every pair: [stage concepts](../art/locations/README.md). Gore, blood, an apocalypse, a minimap, crafting, an economy, procedural runs, a stamina bar, spells, **dialogue of any kind**. Nobody speaks and there are no thought bubbles (A14): Dexter acts – he points, bows, sweats, hands things over. The only text is UI: the crash window, the pocket computer, archive notes. -[← 01 – Proposal](01-PROPOSAL.md) · [Design docs](README.md) · **2 / 14** · [03 – Story and cast →](03-STORY-AND-CAST.md) +[← 01 – Proposal](01-PROPOSAL.md) · [Design docs](README.md) · **2 / 15** · [03 – Story and cast →](03-STORY-AND-CAST.md) diff --git a/docs/03-STORY-AND-CAST.md b/docs/03-STORY-AND-CAST.md index 3665cf7..e66a657 100644 --- a/docs/03-STORY-AND-CAST.md +++ b/docs/03-STORY-AND-CAST.md @@ -1,7 +1,7 @@ # 03 – Story and cast -[← 02 – Game design](02-GAME-DESIGN.md) · [Design docs](README.md) · **3 / 14** · [04 – World and levels →](04-WORLD-AND-LEVELS.md) +[← 02 – Game design](02-GAME-DESIGN.md) · [Design docs](README.md) · **3 / 15** · [04 – World and levels →](04-WORLD-AND-LEVELS.md) ![Dexter, from plush to final form](../art/direction/images/dexter-stages-preview.png) @@ -41,22 +41,44 @@ Dexter is THD's black panther mascot ("Deggster") as a **1.5 ft plush**: black v ## Prologue script (the jam's opening) -| Beat | What the player sees | What the player does | -| --- | --- | --- | -| 1 | Hidden Garden, late afternoon. Birdsong. A small black plush sits on a bench. | Walk right (movement tutorial) | -| 2 | The plush hops off and waddles away, looking back, like the White Rabbit. About 3 minutes of calm garden: hills, the deck, reeds, secrets behind grass. Lex's layout, toned down – less parkour, nothing fantastical yet | Follow; jump a log; climb a slope | -| 3 | Dexter stops at the pond and points at the ground. One patch of the reflection shows a rusty girder maze that isn't there | Walk up to the edge | -| 4 | Dexter shoves (or trips) you in → white flash, the normal damage recoil… No cutscene, no dialogue | – | -| 5 | …but instead of landing, the hero lifts and spins like a starfish; pixels tear | – | -| 6 | Freeze. An old Windows-style error window: **"THD.exe has stopped responding."** [OK] | Press Act to click OK | -| 7 | Black. Glitch lines. | – | -| 8 | The hero lands in clean Rusty Creek, by the metal bridge, and the damage animation finishes. Dexter is gone | Control returns | +About three minutes of play, without words or text: **cozy → something wrong → the shove → the 404 → the pull**. Every beat starts from the Hidden Garden's collision data (`art/stages/hidden-garden/*.collision.json`). `Flow` starts `story/Prologue.ts` whenever the stage has Dexter's bench. Dexter's lead is the pure model `prologueGuide.ts`, the pond is `prologueFinale.ts`, and the crash is `Crossing` in its `'404'` variant (`crossingSequence.ts` `TIMING_404`); all three are unit-tested. + +| Beat | Mood | What the player sees | Trigger (data) | Where in code | +| --- | --- | --- | --- | --- | +| 1 Arrival | cozy | Late afternoon: you start in the open garden gate, on the gravel path. Ahead, a small black plush sits on a bench, swinging its legs (`dexter-prologue` sit) | stage start, the `player` spawn in the gate (hg-0-0 x 205) and `dexter-bench` | `Prologue` seats him | +| 2 The first quest | cozy | Walk up the path and he looks round (`look-back`), bows (`dexter-acts` bow), hops off and beckons. That is the quest, and nobody explains it. Run straight at him and he skips the bow | `dexter-waypoint-1` (`act: wake`, from x 420, ~9 m short of the bench), the hero passes its left edge | `DexterGuide` sit → wake → hop-off → wait | +| 3 Follow Dexter | cozy | He **leads**: always ahead of you, at your own pace – about four plush-lengths in front, walking when you walk and scampering when you run, hopping ahead when you close in. You can never overtake him. He hops steps and gaps on the walk lines. Only when you lag (more than 5 m behind at a waypoint, or 8 m anywhere) does he stop, look back, beckon, and – left waiting – sweat | `dexter-waypoint-2…5`, `-pond-1…2` (`act: wait`) | `DexterGuide` lead / wait | +| 3a Secrets | cozy | A muffin shows itself in the hollow behind the grass when you come near (+1 health). At the top of the terraces Dexter points back up at a glint on the shed roof | `hollow` (`secret`) and its item spawn, `glint-roof`, `dexter-waypoint-4` (`act: point`) | `Prologue.placeSecrets` | +| 4 The jetty | something wrong | Dexter stops on the jetty and points at the water. You walk past him to the edge. Control is taken, the light drains a little, and the reflection shows the rusty girder maze that isn't there | `dexter-waypoint-pond-3` (`act: pond`), then the hero in `pond-edge` (`anomaly`) | `FinaleSequence` reveal; emits **`anomaly-reveal`** for the water renderer | +| 5 The shove | the turn | He steps up behind you and shoves (`dexter` shove). You are knocked out over the water, and for one frame he is a black silhouette (`creepy`) | the finale's timing (reveal 2.2 s, step 0.65 s, shove 0.48 s, fall 0.42 s) | `FinaleSequence` step / shove / fall | +| 6 The 404 | glitch | A white flash. The frame freezes and tears around you (image cuts, a stretched column, the 404 void in the water). Then the window: **THD.exe – "404: Hidden Garden not found."** [OK] | the end of the fall | `Crossing` `'404'`: hit → freeze → dialog | +| 7 The pull | dread | After OK, the torn garden spins and collapses into the void, streaks rush inwards and the hero is dragged in, shrinking. Then black | Act / Enter / Space / click | `Crossing` pull (1.7 s) → cut | +| 8 Landing | quiet | Clean Rusty Creek, by the metal bridge. Dexter is gone | the cut | `swapRegion(rusty-creek, clean)` | + +```mermaid +flowchart LR + A["1 arrival in the gate
Dexter sits on the bench"] -->|"hero passes wake box"| B["2 look round, bow,
hop off, beckon"] + B --> C["3 lead at the hero's pace
walk · scamper · hop ahead"] + C -->|"hero far behind"| W["wait: look back,
beckon, sweat"] -->|"hero within 6 plush"| C + C -->|"waypoint 4"| C2["point up at
the roof glint"] --> C + C -.->|"near the hollow"| M["muffin
(secret)"] + C -->|"pond-3"| D["4 jetty
Dexter points at the water"] + D -->|"hero in pond-edge"| R["reveal 2.2 s
anomaly-reveal → wrong reflection"] + R --> S["5 step + shove
knocked over the water,
one creepy frame"] + S --> T["6 404
frame tears, void opens,
THD.exe – 404: Hidden Garden not found."] + T -->|OK| P["7 pull
the frame collapses into the void"] + P --> L["8 clean Rusty Creek"] +``` + +Dexter is the 27 px plush (decision 41), drawn at depth 101 with a 1 px warm rim (`addRim`, `stage/atmosphere/pipeline.ts`) so the black plush reads against the hedges; a landing squashes him a little. + +**The lead rule** (#160 P4 – in the first build you could run past him, which was uncanny). Every distance is in plush-lengths (27 px) or metres (43 px), in `GUIDE` (`prologueGuide.ts`): he holds a **lead of 4 plush** (108 px) at the hero's own speed (walk 95, run 198 px/s, `player/movement.ts`); closer than that he speeds up, inside **3 plush** he hops ahead (one hop clip, at sprint speed), inside **2 plush** he scampers at his **sprint, 1.35 × the hero's run** – so a running hero can never close on him. Step and gap hops along the walk lines are never slower than the hero. When the hero stops he ambles on at 1 m/s until 8 m ahead, then waits; at a `wait` waypoint he pauses for a hero more than 5 m behind and not coming on. The pond is the one place he lets you pass: he stops on the jetty and you walk by him to the edge. The route is read from the walk lines at runtime, so re-authored terrain needs no change here. In dev builds, `game.scene.getScene('Flow').prologue.skipToPond()` puts Dexter on the jetty and the hero just before it. ## Region 1 story beats -1. You land in **clean** Rusty Creek: the real creek, rails and the metal bridge. Quiet, no enemies, nothing wrong yet. -2. On the bridge, **Jenny**, the driverless race car, comes out of nowhere and rams you. You fly back with the same cartoon knockback, and the anomaly activates: the region **turns** into the rust maze and you are back at its start ([02](02-GAME-DESIGN.md)). -3. The maze is the real tutorial: jumping, climbing, ladders. A plywood chair lies by the path – **pick it up**. First fight: one autonomous rover. +1. You land in **clean** Rusty Creek on the **creek path** – the cement walk along the water, under the rusted rail bridge. The bridge stays above you as the railway; you never walk on it. Quiet, no enemies, nothing wrong yet. +2. Further along the path, **Jenny**, the driverless race car, comes down it towards you. One event, under 3 s before the crash window: first only her **headlights and engine** from beyond the right edge (the tell), then she rolls into view revving and smoking, charges, and hits you – you are thrown the way she drives, in the hurt clip, while she skids on. The frame breaks into the **crash** crossing ("THD.exe has stopped responding", cracks – not the 404's tear), and you land at the start of the rust maze ([02](02-GAME-DESIGN.md)). Only Dexter pushes you from behind; Jenny only ever comes from the front. +3. The maze is the real tutorial: jumping, climbing, ladders. A plywood chair lies by the path with a **glint** over it – **pick it up**: the hero bends down for it (the four-frame `pick-up` clip), holds it up, and a wordless key/button glyph (J on a key cap, X on a pad button, a swing arc over both) floats over him until his first swing. Then the encounters: rovers on the decks you walk, a crab on the high route that raises its front legs before it stabs, and a drone over the low route that tilts and dives when you pass under it. 4. Midway, at the first coffee machine: **Dexter is already sitting there.** He bows and sweats – sorry – and hands you the **pocket computer** and a muffin. No words. 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. @@ -82,6 +104,26 @@ flowchart LR Lane B's `boss-defeated` starts it; in dev builds **Shift + F8** runs it anywhere. +### Dexter at the coffee machine ([#52](https://github.com/THD-AI/WinCS/issues/52)) + +`CoffeeVisit` (`game/src/story/`) draws beat 4 into the stage. When the stage starts it seats Dexter at every `coffee-machine` box with a `dexter` spawn within 160 px of its foot (Rusty Creek: `coffee-1` and `dexter-1` in rc-1-0), so he is there before you. Lane B's coffee save (`save` with the machine's id as `checkpoint`) starts the visit. Every `enemy-defeated` leaves an `fx` pool where the enemy fell; he takes them at your next save, so the beat works with and without fighting. + +```mermaid +flowchart LR + S["stage starts"] --> W["waiting
Dexter idle beside
the machine, 27 px"] + F["enemy-defeated
(position)"] -.-> P["pool
fx black-fluid
stays where it fell"] + W -->|"first save
at this machine"| B["bow 0.7 s"] --> SW["sweat 1 s"] + SW --> G["give 1.1 s
pocket computer + muffin
arc to the hero"] + G --> U["pocketMap.unlock()
Tab / Esc opens the map"] + G --> A{"pools
left?"} + W -->|"later saves"| A + P -.-> A + A -->|yes| AB["absorb 0.9 s
per pool, oldest first,
the pool thins and is gone"] --> A + A -->|no| I["idle"] +``` + +**Sizes, measured against the 76 px hero ([04 – Scale](04-WORLD-AND-LEVELS.md#scale--the-numbers-everything-follows)):** Dexter 27 px; the coffee machine 80 px tall on its base cabinet, a little taller than the hero, filling its 40 × 80 collision box; the pocket computer a 19 × 10 px case closed (≈ 45 cm, the photographed suitcase shrunk to carry); food pickups 13–18 px, around the 16 px icon size. He doesn't grow at a coffee machine: only bosses make him bigger (decision 41). In dev builds **Shift + F4** seats him in the view, leaves one pool and saves at his machine. + ## 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. @@ -98,5 +140,5 @@ Regions 2–5 each end with a boss that Dexter absorbs. Archive notes reveal tha **Secret replay bosses** (after one completion, a joke layer): professors and university figures as giant bosses in the Terraria Moon Lord / Wall of Flesh mould. They tie into the corrupted subjects (the AI / algorithms professor → the pathfinding rust maze). Private portraits stay out of the repository. -[← 02 – Game design](02-GAME-DESIGN.md) · [Design docs](README.md) · **3 / 14** · [04 – World and levels →](04-WORLD-AND-LEVELS.md) +[← 02 – Game design](02-GAME-DESIGN.md) · [Design docs](README.md) · **3 / 15** · [04 – World and levels →](04-WORLD-AND-LEVELS.md) diff --git a/docs/04-WORLD-AND-LEVELS.md b/docs/04-WORLD-AND-LEVELS.md index e637b86..8a81318 100644 --- a/docs/04-WORLD-AND-LEVELS.md +++ b/docs/04-WORLD-AND-LEVELS.md @@ -1,7 +1,7 @@ # 04 – World and levels -[← 03 – Story and cast](03-STORY-AND-CAST.md) · [Design docs](README.md) · **4 / 14** · [05 – Enemies and bosses →](05-ENEMIES-AND-BOSSES.md) +[← 03 – Story and cast](03-STORY-AND-CAST.md) · [Design docs](README.md) · **4 / 15** · [05 – Enemies and bosses →](05-ENEMIES-AND-BOSSES.md) ![Hidden Garden – where it starts](../art/direction/images/hidden-garden-preview.png) @@ -84,18 +84,45 @@ Every time a chunk's layers are made, it also gets an annotation drawn over the | Colour | Shape | Meaning | | --- | --- | --- | -| 🟩 Green | line | Walkable ground and gentle slopes (≤ 40°) | -| 🟫 Brown | line | Slide slope (> 40°): you skid down | +| 🟩 Green | line | Walkable ground and gentle slopes (≤ 40°); the hero never slides on it. Treads of planks and stairs are flat green lines, one per tread | +| 🟫 Brown | line | Slide slope: the only line you skid down | | 🟨 Yellow | line | One-way platform (decks, planks, tray stacks): stand on top, jump up through, ↓ + Jump to drop | | 🩵 Cyan | point | Ledge: a grabbable edge (reserved – the pull-up was cut for the jam) | | 🟦 Blue | box | Climb zone: ladder, vines, scaffold, stairs | -| 🟥 Red | box | Solid: wall or ceiling you can't enter | -| ⬛ Black | box | No-go / kill-free reset: out of bounds, deep water – respawn at last safe ground | +| 🟥 Red | box | Solid (`kind` wall / log / obstacle): stops the body, its top is floor – the fallen birch is one. In the overlays a short red stroke at a line end marks a **ledge face** | +| ⬛ Black | box | No-go / kill-free reset: out of bounds; `kind: "water"` (tinted blue in the overlays) is the pond – never floor, falling in splashes and respawns at the last safe ground | | 🟧 Orange | box | Hazard: current, sap, sparks – 1 cell of damage | | 🟪 Purple | box | Interactable or trigger: coffee machine, pickup, Dexter, sign, **the anomaly**, boss gate, chunk exit | | 🤍 White | dot | Spawn: player, enemy, item, Dexter | | 🌬️ Pale blue | box | Glide lift (later): updraft that carries a gliding hero up | +### Terrain model (P1) + +The trail is where you walk, and every stretch of it is typed. Physics reads only the geometry above; the `terrain` list in each collision file names what each stretch *is*, and the checker holds the geometry to it. + +```mermaid +flowchart LR + T["terrain range
{x, y?, kind, material}"] --> K{kind} + K -->|ground| G["walk line: follow it, any slope ≤ 40°"] + K -->|"step / stair / plank"| S["flat treads; a rise ≤ 16 px is walked up and down"] + K -->|"ledge / wall"| L["line end nothing passes under = a face:
16–100 px stops you, jump it (≤ 88)"] + K -->|"log / obstacle"| O["solid box: stops you, stand on its top"] + K -->|slide| SL["slide line: the only place you skid"] + W["noGo kind water"] --> R["never floor: splash, respawn at the bank"] + T --> M["material → surfaces.json
(sink, cover, blades, puff)"] +``` + +| Rule | Number | Where | +| --- | --- | --- | +| Step: walked up and down without a jump | rise ≤ 16 px | `STEP_UP`, `game/src/player/body.ts` | +| Ledge face: stops the body; higher ones are background you walk in front of | 16 < rise ≤ 100 px | `FACE_REACH` | +| An end with a floor ≤ 120 px under it is an overhang (the bench platform), not a face | 120 px | `FACE_DEPTH` | +| A face a lower floor stops at must be jumpable | ≤ 88 px | `collision_check.py` | +| Treads (`step`, `stair`, `plank`) are flat | slope ≤ 0.05 | `collision_check.py` | +| No floor over the pond's water pixels (04-water layer, not the play layer) | – | `collision_check.py` | + +`python scripts/collision_check.py --surfaces` rebuilds the region's `surfaces.json` ranges from the terrain (the materials stay hand-tuned there; a `water` material carries the splash). + Export: `art/stages//..collision.json` – polylines, points and boxes in stage pixels, one file per chunk and state. They are drawn in **Tiled**, one object layer per row of the legend, on top of the composed chunk, and `scripts/collision_from_tiled.py` converts the export; Rusty Creek's first set was written straight from the art. Chunk ids are grid positions (`rc-0-0`, `rc-1-0` …), mapped to their art in the region's `chunks.json`. `python scripts/collision_check.py --overlay` checks every file against the schema and the 28×76 body (every spawn, pickup and trigger reachable, neighbouring chunks meeting at one ground height) and draws the review overlays into the region's `collision/` folder. The game builds the physics from it. Pixel masks never stand in for collision. The exact schema is in [13 – Production pipeline](13-PRODUCTION-PIPELINE.md#contract-collisionjson). ## Rusty Creek – the jam level @@ -141,5 +168,5 @@ flowchart LR 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. -[← 03 – Story and cast](03-STORY-AND-CAST.md) · [Design docs](README.md) · **4 / 14** · [05 – Enemies and bosses →](05-ENEMIES-AND-BOSSES.md) +[← 03 – Story and cast](03-STORY-AND-CAST.md) · [Design docs](README.md) · **4 / 15** · [05 – Enemies and bosses →](05-ENEMIES-AND-BOSSES.md) diff --git a/docs/05-ENEMIES-AND-BOSSES.md b/docs/05-ENEMIES-AND-BOSSES.md index 3c3fb68..3cc05a3 100644 --- a/docs/05-ENEMIES-AND-BOSSES.md +++ b/docs/05-ENEMIES-AND-BOSSES.md @@ -1,7 +1,7 @@ # 05 – Enemies and bosses -[← 04 – World and levels](04-WORLD-AND-LEVELS.md) · [Design docs](README.md) · **5 / 14** · [06 – Visual direction →](06-VISUAL-DIRECTION.md) +[← 04 – World and levels](04-WORLD-AND-LEVELS.md) · [Design docs](README.md) · **5 / 15** · [06 – Visual direction →](06-VISUAL-DIRECTION.md) ![Boss – Jenny, the self-driving race car](../art/direction/images/boss-jenny-preview.png) @@ -15,6 +15,7 @@ Every enemy is a real **autonomous** machine from THD – things that already dr | **Rover** | THD's autonomous vehicle project (ultrasonic + lidar), yellow wheels | Knee-high (≈ 25 px) | 3 | Patrols a deck. Spots you with its lidar, then charges in a straight line | Lidar turns red, rears up for 0.5 s | Jump over the charge and hit it from behind; guard works | | **Crab** | Autonomous six-legged hexapod robot | Waist-high, wide (≈ 40 px) | 4 | Creeps toward you; rears up and stabs with its front legs; can walk under decks | Front legs raised for 0.6 s | Step back, then punish; up-attack when it's on a deck above | | **Drone** | Large autonomous quadcopter | 1 m span (≈ 45 px), flies at head height | 8 (chair) / 3 (tray) | Hovers out of reach and drops screws or sparking batteries; dives when you're under it | Red eye flashes and it tilts | **Avoid it** in Region 1 (hide under girders). Kill it later with the tray. Coming back for drones is intended backtracking | +| *In the game (P7):* | `enemies/rover.ts`, `crab.ts`, `drone.ts`, drawn by `rovers.ts` from the collision spawns | | | Rovers on the decks you walk (rc-0-0: rover-1, -4, -5; rc-1-0: rover-2, -3, -6; rc-2-0: rover-7); crab-1 on the high route, drone-1 over the low route | Crab and drone flash red and play their servo / hum during the tell | | | *Later:* Mutant plant | Toxic Garden | – | – | Stationary, spits sap | Mouth swells | Down-slam its head | All of them: contact damage 1 cell, white hit flash, 3–4 frame knockback, and they stop dead and leak black fluid when defeated. @@ -69,6 +70,8 @@ Lex's read (A14), which the fight follows: Jenny is a **shielded** enemy. Her no - **Charge** does 2 cells. **Parts** do 1. Guarding a charge breaks the guard. - The arena is the Rusty Creek boss clearing: one flat girder floor, two walls, the creek below. Falling in is a hazard (−1 cell). - **Death:** she stalls, smokes and leaks black fluid. Dexter appears and absorbs it. +- **In the game** (`enemies/jenny.ts`, `enemies/jennyBoss.ts`, #57): the arena comes from collision, not code: the `boss-gate` box's right edge is the left wall, which closes behind you once you're through, and the floor line under her `jenny` spawn ends at the right wall. Her hit box is the car body under the cockpit, 250 × 56 px inside the ~290 × 134 px sprite, and her charge speeds (540 / 600 / 660 px/s) are tuned so a standing full jump (96 px) lets her pass under in the arena (`jumpable()` is unit-tested). A revving Jenny also hurts on contact (1 cell). +- **What reads (P8, Alexander's playtest):** the **rev** shakes her, puffs exhaust from the tail and flashes her headlight – that is the tell. Her **charge** never passes through you without consequence: you are thrown up (520 px/s) and the way she drives, play the hurt clip, get the usual second of invulnerability, and she brakes to a third of her speed for 0.25 s with a hit-stop and a screen shake. Her **open back** glows orange at the tail – faint while she drives (hits from behind count), bright and pulsing while she is stunned, with the hazard light blinking over it. Every chair hit that lands flashes her white, shakes the screen a little and sparks; a hit on the **armoured nose** clanks, throws grey sparks and bounces you back. **Parts** bounce up to twice, then lie on the floor as hazards for 2.5 s and blink before they vanish. Clip lengths come from `boss-jenny*.anims.json`. Beating her emits `boss-defeated`, and the `Ending` scene takes over. Dying resets the fight. Dev: `?boss-test` spawns you at the gate. - What we took from Terraria's Wall of Flesh: one huge shape crossing the whole screen, very readable, and you know exactly what to do after seeing it once. ## Later bosses @@ -82,5 +85,5 @@ Lex's read (A14), which the fight follows: Jenny is a **shielded** enemy. Her no | Secret | **Professors** (replay) | Giant, Moon Lord-scale joke bosses after one completion | -[← 04 – World and levels](04-WORLD-AND-LEVELS.md) · [Design docs](README.md) · **5 / 14** · [06 – Visual direction →](06-VISUAL-DIRECTION.md) +[← 04 – World and levels](04-WORLD-AND-LEVELS.md) · [Design docs](README.md) · **5 / 15** · [06 – Visual direction →](06-VISUAL-DIRECTION.md) diff --git a/docs/06-VISUAL-DIRECTION.md b/docs/06-VISUAL-DIRECTION.md index 52004b4..00dad69 100644 --- a/docs/06-VISUAL-DIRECTION.md +++ b/docs/06-VISUAL-DIRECTION.md @@ -1,7 +1,7 @@ # 06 – Visual direction -[← 05 – Enemies and bosses](05-ENEMIES-AND-BOSSES.md) · [Design docs](README.md) · **6 / 14** · [07 – Asset manifest →](07-ASSET-MANIFEST.md) +[← 05 – Enemies and bosses](05-ENEMIES-AND-BOSSES.md) · [Design docs](README.md) · **6 / 15** · [07 – Asset manifest →](07-ASSET-MANIFEST.md) ![Style master – Rusty Creek](../art/direction/images/style-master-preview.png) @@ -69,5 +69,5 @@ Everything in the game must look like it belongs in the **style master** above. Hollow Knight: silhouettes, rim light, HUD restraint · Planet of Lana: scale, layers, soft hills (Lana is hand-painted, not pixel art – we take its framing and layering, not its rendering) · Dead Cells: Toxic Garden greenhouse, hit impact · 9 Years of Shadows: pixel richness · Kingdom Two Crowns: water reflections, goo · Children of Morta: concentrated colourful FX · Little Nightmares: the hooded silhouette · Terraria: boss size and readability · Monkey Island (classic): small funny details in scenes. -[← 05 – Enemies and bosses](05-ENEMIES-AND-BOSSES.md) · [Design docs](README.md) · **6 / 14** · [07 – Asset manifest →](07-ASSET-MANIFEST.md) +[← 05 – Enemies and bosses](05-ENEMIES-AND-BOSSES.md) · [Design docs](README.md) · **6 / 15** · [07 – Asset manifest →](07-ASSET-MANIFEST.md) diff --git a/docs/07-ASSET-MANIFEST.md b/docs/07-ASSET-MANIFEST.md index ce471ac..3534733 100644 --- a/docs/07-ASSET-MANIFEST.md +++ b/docs/07-ASSET-MANIFEST.md @@ -1,7 +1,7 @@ # 07 – Asset manifest -[← 06 – Visual direction](06-VISUAL-DIRECTION.md) · [Design docs](README.md) · **7 / 14** · [08 – Tech stack and where things live →](08-TECH-STACK.md) +[← 06 – Visual direction](06-VISUAL-DIRECTION.md) · [Design docs](README.md) · **7 / 15** · [08 – Tech stack and where things live →](08-TECH-STACK.md) Every asset the jam slice needs, with frame counts. **Status:** ✅ approved · 🟡 drawn, in review · ⬜ to do. "Board" is the sprite board in `art/sprites/boards.json`; game-ready atlases are exported from boards into `game/public/assets/`. @@ -43,9 +43,10 @@ Frame lists approved as they are (A14). | Asset | Board | Notes | Status | | --- | --- | --- | --- | -| Food pickups | `mensa-food` | muffin, cake, wafer, Leberkäse roll; 16 px icons + sparkle. Approved (A14) | ⬜ | -| Drinks + coffee machine (save point) | `mensa-drinks` | coffee machine idle/flicker/pour | ⬜ | -| Pocket computer | `pocket-computer` | pickup + 3 screen mockups (map, archive, menu) | ⬜ | +| Food pickups | `mensa-food` | muffin, cake, wafer, Leberkäse roll, 1 frame each, 13–18 px; the sparkle is drawn in code. Approved (A14) | ✅ ([#154](https://github.com/THD-AI/WinCS/pull/154)) | +| Coffee machine (save point) | `coffee-machine` | idle 3 (steady, flicker, pond on the screen) · pour 4; 80 px | ✅ ([#154](https://github.com/THD-AI/WinCS/pull/154)) | +| Drinks | `mensa-drinks` | iced coffee, strawberry milk, iced tea, mug; 16 px icons | ⬜ | +| Pocket computer | `pocket-computer` | bob 4 (closed, floating) · open 3; 10 px tall closed. The map screens are the UI atlas (#127) | ✅ ([#154](https://github.com/THD-AI/WinCS/pull/154)) | | Sign pole (post-jam weapon) | `wayfinder` | prop, pull-out 5, thrust 5 | ⬜ | | Campus robot (collectible companion, post-jam) | `companion-robot` | idle 4 · drive 6 · hop/scan/sleep | ⬜ | | HUD | – | 5 battery cells (full/cracked/empty, crack animation), glitch meter orbs, boss bar; pocket computer case, CRT screen and map tiles ([art/ui](../art/ui/README.md)). Approved (A14; sprites 24 Oct) | ✅ | @@ -68,5 +69,5 @@ Footsteps (girder, grass), chair whoosh + hit, rover motor, crab servos, drone h Every sound is **synthesized at runtime with Web Audio** – no audio files, so nothing to license and nothing that breaks decision 14 (no audio in Git). Recipes are plain data in `game/src/fx/sound/recipes.ts` (effects) and `loops.ts` (Hidden Garden, Rusty Creek, Jenny); the `Audio` scene plays them. Game code calls `sound.play('chair-whoosh')` / `sound.loop('boss-jenny')`; hits, defeats, the boss and region turns are wired to the game events, and the anomaly state plays the region loop slightly flat. M mutes. -[← 06 – Visual direction](06-VISUAL-DIRECTION.md) · [Design docs](README.md) · **7 / 14** · [08 – Tech stack and where things live →](08-TECH-STACK.md) +[← 06 – Visual direction](06-VISUAL-DIRECTION.md) · [Design docs](README.md) · **7 / 15** · [08 – Tech stack and where things live →](08-TECH-STACK.md) diff --git a/docs/08-TECH-STACK.md b/docs/08-TECH-STACK.md index 84d6646..0481ac6 100644 --- a/docs/08-TECH-STACK.md +++ b/docs/08-TECH-STACK.md @@ -1,7 +1,7 @@ # 08 – Tech stack and where things live -[← 07 – Asset manifest](07-ASSET-MANIFEST.md) · [Design docs](README.md) · **8 / 14** · [09 – Art pipeline →](09-ART-PIPELINE.md) +[← 07 – Asset manifest](07-ASSET-MANIFEST.md) · [Design docs](README.md) · **8 / 15** · [09 – Art pipeline →](09-ART-PIPELINE.md) ## The stack @@ -85,5 +85,5 @@ cd game && npm ci && npm run dev # predev exports the stages first ``` -[← 07 – Asset manifest](07-ASSET-MANIFEST.md) · [Design docs](README.md) · **8 / 14** · [09 – Art pipeline →](09-ART-PIPELINE.md) +[← 07 – Asset manifest](07-ASSET-MANIFEST.md) · [Design docs](README.md) · **8 / 15** · [09 – Art pipeline →](09-ART-PIPELINE.md) diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md index 47fe32e..7bffda7 100644 --- a/docs/09-ART-PIPELINE.md +++ b/docs/09-ART-PIPELINE.md @@ -1,7 +1,7 @@ # 09 – Art pipeline -[← 08 – Tech stack and where things live](08-TECH-STACK.md) · [Design docs](README.md) · **9 / 14** · [10 – Jam plan →](10-JAM-PLAN.md) +[← 08 – Tech stack and where things live](08-TECH-STACK.md) · [Design docs](README.md) · **9 / 15** · [10 – Jam plan →](10-JAM-PLAN.md) ![Rusty Creek – seven registered layers composed](../art/stages/rusty-creek/images/after-center.png) @@ -87,6 +87,24 @@ flowchart LR - **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. +### Every plate complete – the hot dog (docs/15 P2, P3) + +Masked and moved, a cut-out shows: holes where a nearer layer stood, a canopy that ends in a straight line, a mid plate that stops in steps. Alexander's playtest (24 Oct) asked for every layer **rendered complete**, stacked like a hot dog from the far background to the blurred leaves at the lens, with grass in front of the trail that covers the feet. The steps after `masks` and `split`: + +```mermaid +flowchart TB + C["clear: hanging canopy pieces
(rear touching the top, ending above 320 px)
painted out; sky / far / mid masks updated"] --> N["near: magenta band along the walk line
(collision walk, longest line per column)
model paints grass tufts, the key is dropped
→ masks/<chunk>.06-near.png, art updated"] + N --> U["under: rear, water, path painted behind
the foreground (model) and the near strip (old art)
column continuation picks the band
→ fills/<chunk>.<layer>.png"] + U --> P["complete: far and mid painted under EVERY
nearer pixel down to the bottom, skyline drawn
by the model, its sky dropped
→ tmp/…/filled/ (region sources)"] + P --> S["split_layers.py: a fill goes into its layer
only where a nearer layer owns the pixel
→ still exact at rest"] + S --> R["region: far / mid quilted on a min-error seam,
the sky cross-faded (blend 480 px)"] +``` + +- **Bands, back to front** (z · scroll): sky 0 · 0.1 → far forest 1 · 0.25 → mid trees 2 · 0.6 → rear (reeds, walls, sheds, big trees) 3 · 1 → water and its reflections 4 · 1 → the trail 5 · 1 → **near strip** 6 · 1 (over the actors, `NEAR_DEPTH` 120 in `depth.ts`) → foreground leaves 7 · 1.2 (blurred, `FOREGROUND_DEPTH` 200). The trail's geometry never moves, so its collision stays valid. +- **A fill never shows at rest.** It lives under nearer pixels only; `split_layers.py --check` and `export_stage.py --verify` still rebuild every chunk exactly. Fills are committed (`fills/`), their paintings stay in `tmp/boards//` (`--reuse` cuts again without the API). +- **The model leaves big holes white sometimes** (four of fifteen complete paintings): white counts as sky and is dropped, so check how deep the painting reaches before trusting it; a far plate only needs to reach below the mid skyline. +- **Magenta key for new plants.** Asked to paint grass on the art directly, the model repaints the whole band and nothing is separable; on flat magenta it keeps the key, and tinted edge pixels take the colour of the nearest clean plant pixel. + ## Prompting lessons (23 Oct, all runs) | What we tried | What happened | Rule now | @@ -245,6 +263,53 @@ flowchart LR **Masks in one line each:** the mask is a PNG the size of the first input; transparent = may change, opaque = keep. It always comes from a board's own raw (never hand-painted), and protected pixels are re-pasted after the call. +## Atmosphere – a stage that moves (atmosphere.json) + +The plates stay exactly as painted; `game/src/stage/render.ts` (`drawStage`) brings them to life from `art/stages//atmosphere.json`, which `export_stage.py` publishes next to `stage.json` (with every file in its `assets`). Nothing is placed by hand: each layer's anchors and waterline are read from its own alpha at load, the leaf colours from its own pixels. + +```mermaid +flowchart LR + A["atmosphere.json
wind · layers · leaves · water"] --> P["drawStage
z 0–5 under the actors, z 6–7 over them
(stage/depth.ts)"] + L["layer PNGs (alpha)"] --> X["analysis.ts
anchor per column · waterline
water points · autumn palette"] + X --> S["one WebGL pipeline
sway (top moves, base still, stone stays)
bob · DOF blur · ripple · shimmer · rim"] + P --> S + P --> F["leaves (fx-leaves) in front / behind
pads · rings · sparkles (fx-water)
hero reflection · anomaly-reveal"] +``` + +| Key | Meaning | +| --- | --- | +| `wind` | `base` steady sway share (0–1), `gust` extra lean (0–2), `gustSpeed` px/s, `gustLength` px between fronts, `frequency` Hz, `direction` −1 left / 1 right | +| `defaults` | a layer without its own entry: `sway` × scroll² (nearer moves more), `reach`, `anchor`, `foliage`; foreground (z ≥ 6) also gets `bob`, `blur` | +| `layers.` | `sway` px at full weight · `reach` px from the anchor to full weight · `anchor` `"base"` (lowest opaque row per column: trees, foreground plants) or `"top"` (surface top: grass on the path) · `foliage` saturation under which pixels never move (stone, wood) · `bob` px vertical · `blur` px depth of field · `ripple` px · `shimmer` 0–1 (water) | +| `leaves` | `palette` (`[]` = sampled from the art) · `fall` `"down"` / `"up"` · `front` / `behind`: `every` ms, `scale` [min, max], `scroll` parallax, `alpha`, `tint`; `null` turns one off. Uses the `fx-leaves` sheet, pixel leaves drawn in code without it | +| `water` | `layer` id (waterline from its alpha) · `reflect` {`alpha`, `tint`} for actors (`null`: none) · `pads` per 1000 px · `padTint` · `padScale` · `ripples` / `sparkles` mean ms (0: none) · `wrong` {`image`, `alpha`}: the picture `anomaly-reveal` flickers in the pond | +| `assets` | extra images in the region folder (published by the export, loaded with the stage) | +| `states.` | section-wise overrides for a state (the anomaly's leaves rise) | + +A bad field is reported in the console and replaced by its default; `{}` (no file) gives the defaults. The Hidden Garden's [atmosphere.json](../art/stages/hidden-garden/atmosphere.json); `wrong-reflection.png` there is a flipped, feathered crop of Rusty Creek's `after-center.png`. + +| | | +| --- | --- | +| ![Pond, layers ranked region-wide, ripples, pads, leaves](images/atmosphere/pond-after.png) | ![Terraces with the blurred foreground over the path](images/atmosphere/terraces-foreground.png) | +| ![The water ripples and shimmers (3×)](images/atmosphere/pond-ripple-zoom.png) | ![anomaly-reveal: the maze in the pond](images/atmosphere/anomaly-reveal.png) | + +## One asset system (assets.py, #158) + +`art/inventory.csv` is the plan: every asset the slice needs, with its measured size, animations, sources and status. `scripts/assets.py` checks it against the repo, so the sheet can't drift from the files: + +```mermaid +flowchart LR + I["art/inventory.csv
the plan"] --> D{"derive from files"} + B["boards.json
review ✅?"] --> D + G["art/sprites/images
drawn?"] --> D + E["game/public/assets
exported?"] --> D + D --> S["status
missing · planned · drawn ·
exported · approved · n/a
+ where the sheet disagrees"] + D --> C["check
exported clips vs the
inventory's frame counts"] + D --> N["next
what to do, with the command"] +``` + +`python scripts/assets.py status [--strict]` · `check ` · `next`. A new asset starts as a row in the sheet; `next` then names the step that moves it on (draw → export for review → Alexander's ✅). + ## Commands ```bash @@ -269,5 +334,5 @@ python scripts/navigation.py && python scripts/check_docs.py - ❌ photos, audio, raw API output and rejected takes (`tmp/`), contact sheets, HTML viewers -[← 08 – Tech stack and where things live](08-TECH-STACK.md) · [Design docs](README.md) · **9 / 14** · [10 – Jam plan →](10-JAM-PLAN.md) +[← 08 – Tech stack and where things live](08-TECH-STACK.md) · [Design docs](README.md) · **9 / 15** · [10 – Jam plan →](10-JAM-PLAN.md) diff --git a/docs/10-JAM-PLAN.md b/docs/10-JAM-PLAN.md index a311a9b..830ab38 100644 --- a/docs/10-JAM-PLAN.md +++ b/docs/10-JAM-PLAN.md @@ -1,7 +1,7 @@ # 10 – Jam plan -[← 09 – Art pipeline](09-ART-PIPELINE.md) · [Design docs](README.md) · **10 / 14** · [11 – Decisions →](11-DECISIONS.md) +[← 09 – Art pipeline](09-ART-PIPELINE.md) · [Design docs](README.md) · **10 / 15** · [11 – Decisions →](11-DECISIONS.md) **DEGDEV 3**, Deggendorf: Fri 23 Oct 14:00 → Sat 24 Oct 13:30. Jury voting starts at **11:30 Saturday**, and that is our hard stop. Event facts: [research/degdev-3.md](research/degdev-3.md). @@ -68,5 +68,5 @@ Every cell below is a set of issues on the board, assigned to its owner. Vatsal' AI-generated art and code policy (does it need declaring?) · exact submission format and deadline · is a hosted HTML5 link fine · does the jury need the source. -[← 09 – Art pipeline](09-ART-PIPELINE.md) · [Design docs](README.md) · **10 / 14** · [11 – Decisions →](11-DECISIONS.md) +[← 09 – Art pipeline](09-ART-PIPELINE.md) · [Design docs](README.md) · **10 / 15** · [11 – Decisions →](11-DECISIONS.md) diff --git a/docs/11-DECISIONS.md b/docs/11-DECISIONS.md index c2d2055..02d8c91 100644 --- a/docs/11-DECISIONS.md +++ b/docs/11-DECISIONS.md @@ -1,7 +1,7 @@ # 11 – Decisions -[← 10 – Jam plan](10-JAM-PLAN.md) · [Design docs](README.md) · **11 / 14** · [12 – Team workflow →](12-TEAM-WORKFLOW.md) +[← 10 – Jam plan](10-JAM-PLAN.md) · [Design docs](README.md) · **11 / 15** · [12 – Team workflow →](12-TEAM-WORKFLOW.md) One line per decision: what was decided, who decided it, and when. An **open** item needs the team. Older decision history (D001–D036) is in Git history; this log restarts from the consolidated design on 23 Oct. @@ -51,5 +51,5 @@ One line per decision: what was decided, who decided it, and when. An **open** i | 41 | Sizes and proportions follow the approved art in `art/`: where an approved direction image disagrees with a size computed from feet, it is measured there and the art wins (Dexter's plush 27 px, not 20) | ✅ | Alexander · 24 Oct | -[← 10 – Jam plan](10-JAM-PLAN.md) · [Design docs](README.md) · **11 / 14** · [12 – Team workflow →](12-TEAM-WORKFLOW.md) +[← 10 – Jam plan](10-JAM-PLAN.md) · [Design docs](README.md) · **11 / 15** · [12 – Team workflow →](12-TEAM-WORKFLOW.md) diff --git a/docs/12-TEAM-WORKFLOW.md b/docs/12-TEAM-WORKFLOW.md index 897e6f5..7d4b52d 100644 --- a/docs/12-TEAM-WORKFLOW.md +++ b/docs/12-TEAM-WORKFLOW.md @@ -1,7 +1,7 @@ # 12 – Team workflow -[← 11 – Decisions](11-DECISIONS.md) · [Design docs](README.md) · **12 / 14** · [13 – Production pipeline →](13-PRODUCTION-PIPELINE.md) +[← 11 – Decisions](11-DECISIONS.md) · [Design docs](README.md) · **12 / 15** · [13 – Production pipeline →](13-PRODUCTION-PIPELINE.md) How the three of us (and our AI agents) work in one repo through the night without stepping on each other. This was asked for in the 23 Oct meeting: "so that you understand what I'm doing and I understand what you are doing." The full rules are in [CONTRIBUTING.md](../CONTRIBUTING.md); this page is the jam-night version. @@ -61,5 +61,5 @@ When the last story of an epic closes, the epic gets `ready-for-acceptance`. Som Read [AGENTS.md](../AGENTS.md). In short: the docs in `docs/` are the design, `art/**/*.json` are the art specs, issues say what to build, and scripts are how outputs are made. Transcripts, chat logs and issue comments are evidence, not instructions. Agents work on the issue they were given, open draft PRs with `Closes #n`, and don't `/take`, merge, push to `main` or touch keys. -[← 11 – Decisions](11-DECISIONS.md) · [Design docs](README.md) · **12 / 14** · [13 – Production pipeline →](13-PRODUCTION-PIPELINE.md) +[← 11 – Decisions](11-DECISIONS.md) · [Design docs](README.md) · **12 / 15** · [13 – Production pipeline →](13-PRODUCTION-PIPELINE.md) diff --git a/docs/13-PRODUCTION-PIPELINE.md b/docs/13-PRODUCTION-PIPELINE.md index d684a8c..081dbbf 100644 --- a/docs/13-PRODUCTION-PIPELINE.md +++ b/docs/13-PRODUCTION-PIPELINE.md @@ -1,7 +1,7 @@ # 13 – Production pipeline -[← 12 – Team workflow](12-TEAM-WORKFLOW.md) · [Design docs](README.md) · **13 / 14** · [Meetings and voice notes →](meetings/README.md) +[← 12 – Team workflow](12-TEAM-WORKFLOW.md) · [Design docs](README.md) · **13 / 15** · [15 – The prologue slice (rebuild) →](15-PROLOGUE-SLICE.md) How every image, layer, sprite, animation, stage and collision box gets from a photo to the game, and how two AI agents build it in parallel without stepping on each other. The *what* is in [07 – Asset manifest](07-ASSET-MANIFEST.md) and the *how to prompt* in [09 – Art pipeline](09-ART-PIPELINE.md); this page is the order of work, the hand-over formats and the lanes. Every box in the diagrams names the issue that delivers it. @@ -90,10 +90,11 @@ Every sheet, in the order the game needs them: | `hero-hurt`, `hero-death` | hit 8 · knockback 3 · death 12 · interact 3 · pick up 4 · look at device 3 | [M2-S12 #83](https://github.com/THD-AI/WinCS/issues/83) | damage, the THD.exe crossing | | `chair-side`, `chair-overhead`, `chair-block` | sweep A 8 · sweep B 8 · recoil 2 · up 6 · slam 7 · pogo 4 · guard up 4 · hold 3 · impact 4 · break 4 | [M2-S10 #81](https://github.com/THD-AI/WinCS/issues/81) | combat | | `enemy-rover`, `enemy-crab`, `enemy-drone`, `fx` | rover 4/6/4/5 · crab 4/8/5/5 · drone 4/3/5/3 · black fluid 6 | [M2-S11 #82](https://github.com/THD-AI/WinCS/issues/82) | enemies | -| `mensa-food`, `mensa-drinks` | 16 px icons + sparkle; coffee machine idle/flicker/pour | [M2-S13 #84](https://github.com/THD-AI/WinCS/issues/84) | healing, save points | +| `mensa-food`, `mensa-drinks` | food 4 × 1 frame, 13–18 px (sparkle in code) · drink icons | [M2-S13 #84](https://github.com/THD-AI/WinCS/issues/84) | healing | +| `coffee-machine` | idle 3 · pour 4, 80 px | inside [M4-S4 #52](https://github.com/THD-AI/WinCS/issues/52) | save points | | `dexter`, `dexter-acts` | idle 3 · hop 4 · walk 6 · point 3 · shove 4 · beckon 3 · bow · sweat · proud · creepy shadow · absorb 4 | [M3-S5 #86](https://github.com/THD-AI/WinCS/issues/86) | prologue, Region 1 beats | | `boss-jenny`, `boss-jenny-2` | rev 4 · charge 4 · crash 4 · stunned 3 · reboot 2 · debris ×5 | [M4-S6 #87](https://github.com/THD-AI/WinCS/issues/87) | the boss | -| `pocket-computer` | pickup + 3 screens | inside [M4-S4 #52](https://github.com/THD-AI/WinCS/issues/52) | menu, map | +| `pocket-computer` | bob 4 · open 3 (screens: UI atlas) | inside [M4-S4 #52](https://github.com/THD-AI/WinCS/issues/52) | menu, map | Frames are drawn as **key poses**, never interpolated by the model; transitions are short bridge clips that start and end on poses shared with the loops ([09 – Smooth, not rubbery](09-ART-PIPELINE.md#smooth-not-rubbery)). @@ -140,7 +141,7 @@ Draw every object in **stage pixels** on top of the 2160×720 chunk, one Tiled o | `walk` | polyline | ground and gentle slopes ≤ 40°, girder tops you stand on | | `slide` | polyline | slopes > 40° you skid down | | `one-way` | polyline | decks, planks, scaffold boards: jump up through, ↓ + Jump drops | -| `climb` | box + `kind` | **ladders, vines, scaffold, stairs** – stairs are climb zones or a walk slope, never steps of boxes | +| `climb` | box + `kind` | **ladders, vines, scaffold** – stairs are flat walk treads (rises ≤ 16 px are stepped; docs/04 terrain model), never slopes and never steps of boxes | | `solid` | box | walls and ceilings | | `no-go` | box | out of bounds, deep water: reset without damage | | `hazard` | box + `damage` | current, sap, sparks | @@ -172,6 +173,8 @@ Draw every object in **stage pixels** on top of the 2160×720 chunk, one Tiled o Coordinates are integers in stage pixels, origin top left; polylines run left to right; `kind` values are lower-case with dashes. Adding a field is fine; renaming one needs both lanes to agree in an issue. +Optional `terrain` (docs/04 terrain model): `[{"x": [x0, x1], "y": [y0, y1]?, "kind": "ground|step|stair|plank|ledge|wall|log|obstacle|slide", "material": "", "note"?}]` – chunk-local ranges, first match wins, every walk/oneWay/slide segment covered. Solids take an optional `kind` (`wall`, `log`, `obstacle`), no-go boxes `kind: "water"`. A chunk with `terrain` was re-authored by hand: `depth_layers.py collision` keeps its floors. + ## 5 · The other hand-over contracts Lane B codes against these from day one, with placeholders; lane A fills them with real art. Neither lane changes a contract alone. @@ -253,5 +256,5 @@ Assignments live on the board ([Open work](https://github.com/orgs/THD-AI/projec | Lex | [M1-S7 #55](https://github.com/THD-AI/WinCS/issues/55) Rusty Creek collision in Tiled | M3-S2 Hidden Garden collision → M4-S3 encounters | -[← 12 – Team workflow](12-TEAM-WORKFLOW.md) · [Design docs](README.md) · **13 / 14** · [Meetings and voice notes →](meetings/README.md) +[← 12 – Team workflow](12-TEAM-WORKFLOW.md) · [Design docs](README.md) · **13 / 15** · [15 – The prologue slice (rebuild) →](15-PROLOGUE-SLICE.md) diff --git a/docs/15-PROLOGUE-SLICE.md b/docs/15-PROLOGUE-SLICE.md new file mode 100644 index 0000000..cf6f911 --- /dev/null +++ b/docs/15-PROLOGUE-SLICE.md @@ -0,0 +1,136 @@ +# 15 – The prologue slice (rebuild) + + +[← 13 – Production pipeline](13-PRODUCTION-PIPELINE.md) · [Design docs](README.md) · **14 / 15** · [Meetings and voice notes →](meetings/README.md) + + +Alexander, Sat 24 Oct ~04:30: the game must run, look and feel like `art/`, from the menu through the Hidden Garden prologue into the anomaly – as one system that works the same locally and in production, not a set of hardcoded scenes. This page is the plan and the contract every workstream builds against. Integration branch: **`slice/prologue`** (main + #154 + #164). Every workstream branches from it and opens a draft PR **into it**; Claude reviews, plays and merges there; `slice/prologue` reaches main on Alexander's word. Progress lives in [prologue-tracker.csv](prologue-tracker.csv). + +## What the player gets + +```mermaid +flowchart LR + L["loading
art-directed, progress,
never stalls"] --> M["menu
DEGGSTER.EXE over the garden,
Start / Credits"] + M --> G["Hidden Garden, clean
wind in grass and leaves,
falling leaves, pond reflections,
blurred foreground over the hero"] + G --> Q["first quest
Dexter sits on the bench;
walk up – he hops off and
waddles ahead, looking back"] + Q --> W["follow Dexter
waypoints 1–5: birch, oak crest,
terrace roof, gap;
secrets behind grass"] + W --> P["the pond
Dexter points at the water,
the reflection is wrong"] + P --> S["the shove
Dexter pushes you in"] + S --> E["404
the frame tears, a 404 void opens,
THD.exe – 404, you are pulled in"] + E --> A["the anomaly
land in Rusty Creek (clean)"] +``` + +## Quality bar (Alexander's list) + +| Area | What "done" means | +| --- | --- | +| Loading and menu | An art-directed loading screen with real progress; a menu in the game's style over the garden art; no stall, no race, the same on Vercel as on `npm run dev` | +| Layers | Every exported layer drawn in its order and scroll: sky, far, mid (region plates), rear, water, play, foreground | +| Foreground | Layers nearest the camera (07) are slightly blurred (depth of field) and **pass over the hero** | +| Wind | Grass, leaves and trees sway – the nearer, the more – with gusts; backgrounds drift; nothing is static | +| Leaves | Falling leaves in front of and behind the hero, in the garden's autumn palette | +| Water | The pond reflects the scene and the hero, with ripples and a shimmer; lily pads bob | +| Grounding | The hero stands *in* the ground, not on top of it: feet sink 2–4 px by surface (grass, mud, stone, wood), dust or leaves kicked up by steps and landings | +| Motion | Every animation smooth: stride-paced walk and run (#164), clean transitions, no pops, steady camera | +| Story | Wordless (A14): the first quest, Dexter, the pond, the shove, the 404, the pull into the anomaly | +| System | Data-driven: stage.json, collision.json, anims.json, a surfaces file and an atmosphere file per region – no region names or coordinates in code | + +## Workstreams + +| # | Workstream | Owns (only these files) | Delivers | +| --- | --- | --- | --- | +| 1 | **Boot, loading, menu** | `game/src/boot/` (new), `Boot` + `Preload` moved out of `game/src/core/scaffold.ts`, `game/src/ui/Title.ts`, `game/src/stage/assets.ts` | one manifest-driven loader (no scene loads on its own), loading screen, menu, fade into the garden | +| 2 | **Stage renderer and atmosphere** | `game/src/stage/render.ts`, `game/src/stage/atmosphere/` (new), `game/src/fx/atmosphere*.ts`, `art/stages//atmosphere.json` (new) | layer drawing, foreground blur + occlusion, wind sway, falling leaves, water reflection and ripples | +| 3 | **Hero feel and grounding** | `game/src/player/`, `game/src/stage/camera.ts`, `art/stages//surfaces.json` (new) | feet sink by surface, step / land FX, smooth transitions, camera | +| 4 | **Prologue story** | `game/src/story/` (new `Prologue*.ts`, `Flow.ts`, `Crossing.ts`), the `interact` and `spawn` entries of `art/stages/hidden-garden/*.collision.json`, docs/03 prologue | the first quest, Dexter's walk, the pond, the shove, the 404 and the pull | +| 5 | **Art** | `art/sprites/`, `art/ui/`, `scripts/make_boards.py` configs, `game/public/assets/sprites|ui/` exports | every missing sprite (falling leaves, ripples, dust, Dexter's missing poses, menu and loading art), readability fixes, the asset inventory | + +`game/src/core/scaffold.ts` (`Stage`) and `game/src/main.ts` are shared: change only the lines your workstream needs, keep them small, and say so in the PR. + +## Contracts between workstreams + +```mermaid +flowchart TD + subgraph data["per region (art/stages/hidden-garden)"] + SJ["stage.json
layers: id, z 0–7, scroll, file"] + CJ["*.collision.json
walk / interact / spawn"] + AJ["atmosphere.json (2)
wind per layer, leaves,
water band, blur"] + SF["surfaces.json (3)
x ranges → grass / mud / stone / wood"] + end + L1["(1) loader
loads everything once"] --> R2["(2) renderer + atmosphere"] + L1 --> H3["(3) hero"] + L1 --> P4["(4) prologue"] + SJ --> R2 + AJ --> R2 + SF --> H3 + CJ --> H3 + CJ --> P4 + H3 -->|"hero sprite depth = HERO_DEPTH"| R2 + P4 -->|"trigger-entered, swapRegion"| L1 +``` + +- **Depth bands** (Phaser depth, `game/src/stage/depth.ts`, owned by 2): layers z 0–5 below `HERO_DEPTH = 100`; actors 99–110 (props 99, hero 100, NPCs 101–105); z 6–7 foreground layers at 200+; screen FX and HUD above. In code: `PROP_DEPTH 99`, `HERO_DEPTH 100`, `NPC_DEPTH {101–105}`, `ACTOR_FX_DEPTH 110`, `LEAVES_BEHIND_DEPTH 90`, `LEAVES_FRONT_DEPTH 150`, `NEAR_DEPTH 120` (z 6: the grass strip in front of the trail, over the actors' feet), `FOREGROUND_DEPTH 200` (z 7), `SCREEN_FX_DEPTH 1000`. A layer's depth is its band plus its region-wide rank (`layerRanks`), so a chunk that lists fewer layers stacks like the others. +- **Stage handles** (2): `drawStage(scene, manifest, state, atmosphere?)` returns `{ images, layers, atmosphere, leaves, animated, waterlineAt(x), reflect(sprite), reveal(position, ms), destroy }`. `reflect` mirrors an actor in the water (Stage does it for the hero); `addRim(sprite, colour?, strength?)` (`stage/atmosphere/pipeline.ts`) gives any sprite a 1 px warm inner rim (Dexter on the dark hedge). On `anomaly-reveal` the water shows `atmosphere.water.wrong`; `stage.data.get('water-reveal')` is true when it will. Schema: [docs/09 – Atmosphere](09-ART-PIPELINE.md#atmosphere--a-stage-that-moves-atmospherejson). +- **Loader** (1): `Preload` loads every atlas in `BOARDS` plus the region's stage, collision, atmosphere and surfaces files, then starts `Stage` with `{ manifest, state, collisions, atmosphere, surfaces, spawn }`. Story and UI scenes never load assets themselves; they create animations lazily (`story/atlases.ts`). +- **Events** (`game/src/core/events.ts`): `trigger-entered`, `region-turned`, `save`, `enemy-defeated`, `boss-defeated`; the prologue adds `quest` events if it needs them – a new event goes in a small separate commit. +- **Region swap**: `swapRegion(scene, { region, state, spawn? })` (`stage/swap.ts`). +- **Dev handle**: `window.game` in dev builds for checks. + +## The loader contract (workstream 1) + +`game/src/boot/` holds the only two scenes that load files. **Boot** draws the pixel font in code and loads the UI atlas + `ui-layout.json`; **Preload** loads a region behind the loading screen and starts **Title** (first load) or **Stage** (a region swap). + +```mermaid +flowchart LR + B["Boot
pixel font, UI atlas"] --> P["Preload
{region?, state?, spawn?}"] + P --> W0["stage.json"] --> W1["wave 1 JSON (fetch)
atmosphere, surfaces, board JSON,
map stages"] --> W2["wave 2 (Phaser loader)
collision, plates, board sheets,
menu-key.png"] + W2 -->|"no errors"| D{"first load?"} + W2 -->|"required file failed"| E["Retry [R] / Continue [C]
production: 1 auto-retry,
then continues in 6 s"] --> D + D -->|yes| T["Title
key art ↔ live garden"] -->|Start: crossfade| S["Stage"] + D -->|"no (swapRegion)"| S +``` + +- **What loads:** every atlas in [`game/src/stage/boards.json`](../game/src/stage/boards.json) (`required` falls back to a placeholder, `optional` may be absent) – add a board with one line there; the region's `stage.json`, its collision files and plates; the optional `atmosphere.json` and `surfaces.json` from `game/public/assets/stages//` (absent → `{}`; `scripts/export_stage.py` must copy them from `art/stages//`); every region's `stage.json` for the map (`map-stage-`); `ui/menu-key.png` (optional). +- **What Stage receives** (`game/src/boot/contract.ts` `StageData`): `{ manifest, state, collisions, atmosphere, surfaces, spawn? }` – `atmosphere` and `surfaces` are plain objects, parsed by their owners (2, 3). +- **Caches:** atlases by board id (`dexter`, `fx-leaves`, …), clips in the JSON cache as `.anims`, plates as `:`, UI as `ui` / `ui-layout`. Scenes read these; they never call `this.load`. +- **Never hangs:** JSON requests time out after 20 s, a dev server's HTML fallback counts as missing, a wave with no byte moving for 25 s gives up, and every failure lands on screen. A region swap reloads only what the new region adds. +- **Dev checks:** `window.game.scene.getScene('Preload').tracker.summary()`; `?skip-menu` goes straight to the stage. + +| Loading | Menu (key art) | Menu (live garden) | +| --- | --- | --- | +| ![The loading screen mid-way](images/boot-loading.png) | ![The menu over the key art](images/boot-menu-key-art.png) | ![The menu over the live garden](images/boot-menu-live-garden.png) | + +## Rules for every workstream + +- Branch `feature/-` from `slice/prologue`; draft PR **into `slice/prologue`**, with a mermaid diagram, before/after evidence (screenshot, GIF or `compare.py`), and how to test. Never push to main or to another workstream's branch; never merge. +- Commit as `arudaev `, Conventional, no AI attribution. Before pushing: `npm --prefix game run typecheck`, `npx vitest run` (in `game/`), `npm --prefix game run build`; script tests `cd scripts && python -m pytest -q`; `python scripts/check_docs.py` (delete `game/public/assets/stages` first, then run `python scripts/export_stage.py` from `game/` again before playing). +- Art only through the pipeline: `make_boards.py` with `gpt-image-2.5-sunburst`, real references (photos in `~/Downloads/GameJam_*`, `repo:` crops of `art/direction`), magenta key, no text; `export_atlas.py`. The key is read from `.env` (copy the main checkout's `.env` into your worktree; never commit it, never put the key on a command line). +- Sizes from the art (decision 41), mechanics from the art (decision 43). Data in files, not constants in code. +- Windows: never `X.ts` next to `x.ts`; never a `node_modules` junction – run `npm ci` in your worktree. + +## Wave 2 – Alexander's playtest (Sat 24 Oct ~06:45) + +What the first build got wrong, in his words' order, and the workstream that owns each fix: + +| # | Found in play | Fix | Workstream | +| --- | --- | --- | --- | +| P1 | We walk on water, on planks and stairs as if they were hills (and slide), fall through where a log or a wall should stop us, "climb" a log instead of jumping it; the path's edges are guessed | A **terrain system**: every walkable surface authored from the art as a *trail band* with typed segments (ground, step, ledge, wall, plank, stair, log, water = never walkable); step-ups under ~16 px, ledges you must jump, walls that stop you; chunk ends that continue | 6 terrain | +| P2 | It feels like walking *on top of* the world; no grass at the feet | The **hot-dog layering**: far background → far greenery → near greenery → the trail (where you walk) → a grass/plants strip *in front of the trail* that covers the feet → water, rocks → blurred close leaves. The trail carries surface effects (slippery, particles per material) | 7 layers + 6 terrain – **layers done** (`feature/slice-layers`): bands sky · far · mid · rear · water · trail · near strip (z 6, `NEAR_DEPTH` 120) · foreground; trail geometry unchanged | +| P3 | The cut-outs show: leaves, water edges and trees line up wrongly as the layers move | Every layer **rendered complete**: the parts hidden behind nearer layers are painted in (gpt-image-2.5 inpainting per plate), edges cleaned, more bands where the depth needs them | 7 layers – **done** (`feature/slice-layers`): far and mid painted whole, water/path/rear filled behind the foreground and the strip, hanging canopies painted out, sky cross-faded ([docs/09](09-ART-PIPELINE.md#every-plate-complete--the-hot-dog-docs15-p2-p3)) | +| P4 | You outrun Dexter; you spawn left of the entrance while he waits at it | Dexter **leads at the hero's pace** – never overtaken, hops ahead when you close in; the hero starts at the entrance. **In review: [#171](https://github.com/THD-AI/WinCS/pull/171)** – 4-plush lead, hop ahead inside 3, sprint 1.35 × the run; waits only for a lagging hero; spawn in the gate (hg-0-0 x 205); wake from x 420 with a bow | 8 Dexter | +| P5 | After the 404 you land on the railway bridge; the cement road under it is gone; two pushes in a row (from behind, then Jenny from the front) confuse | Land on the **creek path**, the cement road under the rail bridge (the clean art has it); Jenny's ram reads as one clear event on that path; the region swap explains itself | 9 Region 1 – done in `feature/slice-region1` (landing on the path, one-event ram, crash crossing); the walk line on the path is provisional for 6 | +| P6 | The hero's movement is fidgety in Rusty Creek; jump-spam feels slow and buggy; idles are stiff | **Hero motion polish**: find the creek jitter (sink/slope/stride on steel), fix jump buffering under spam, more idle life (breathing, poncho sway) | 10 hero motion – draft #172: the jitter was the creek's deck seams (every 10–50 px step = fall, landing, run-start from a stand: 24 → 5 off-cycle frames per step); 1-frame crouch, tap hop apex 0.29 → 0.18 s; breath + gust sway. The step-*up* gaps (rc-0-0 x≈1488, rc-2-0 x≈5260) that reset the hero are left to 6 | +| P7 | The chair pops in, no idea how to attack; one rover and then nothing | A **pickup moment** (the hero picks it up, a wordless key/button glyph shows the attack); more encounters along the route | 9 Region 1 – done in `feature/slice-region1` (pick-up clip, glint, J/X glyph; 7 rovers, crab, drone) | +| P8 | Jenny drives through you – no knockback, nothing happens; parts just fall; unclear how to hurt her | **Jenny reads**: real knockback, a clear tell, a glowing open back while stunned, hit flashes and screen shake, parts that bounce and threaten | 9 Region 1 – done in `feature/slice-region1` (throw + brake, rev tell, open back, hit flash/shake/spark, clank, bouncing parts) | + +| # | Workstream | Owns | +| --- | --- | --- | +| 6 | Terrain and walkability | `game/src/player/body.ts`, `traversal.ts`, `game/src/stage/collision*.ts`, the walk/solid/oneWay/noGo geometry of `art/stages/*/*.collision.json` (not interact/spawn), `surfaces.json`, `scripts/collision_*.py` | +| 7 | Layers rendered complete | `art/stages/hidden-garden/` layers, masks, `export.json`, `layers.json`, `atmosphere.json`, `scripts/depth_layers.py`, `grow_stage.py` | +| 8 | Dexter and the prologue's staging | `game/src/story/Prologue*.ts`, `prologueGuide.ts`, `prologueFinale.ts`, hidden-garden `interact`/`spawn` entries | +| 9 | Region 1: landing, ram, chair, encounters, Jenny | `game/src/story/Flow.ts`, `game/src/enemies/`, `game/src/combat/chairPickup.ts`, rusty-creek `interact`/`spawn` entries, HUD glyphs | +| 10 | Hero motion | `game/src/player/animation.ts`, `grounding.ts`, `jump.ts`, `footfall.ts`, `hurt.ts`, hero idle art if needed | + + +[← 13 – Production pipeline](13-PRODUCTION-PIPELINE.md) · [Design docs](README.md) · **14 / 15** · [Meetings and voice notes →](meetings/README.md) + diff --git a/docs/README.md b/docs/README.md index 51dd782..58cd161 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,5 +17,6 @@ Read them in order; every page has Previous / Next at the top and bottom. The nu | 11 | [Decisions](11-DECISIONS.md) | What is decided, what is open | | 12 | [Team workflow](12-TEAM-WORKFLOW.md) | Setup, branches, PRs, rules for agents | | 13 | [Production pipeline](13-PRODUCTION-PIPELINE.md) | How every image, layer, sprite, stage and collision box is made; the hand-over formats; the two agent lanes | +| 15 | [The prologue slice (rebuild)](15-PROLOGUE-SLICE.md) | The menu-to-anomaly rebuild: what the player gets, the quality bar, five workstreams, their contracts; progress in [prologue-tracker.csv](prologue-tracker.csv) | **Evidence:** [meetings and transcripts](meetings/README.md) · **Research:** [event facts](research/degdev-3.md) · [what we love in each game](research/what-we-love.md) · [engine notes](research/engine-evidence.md) · [project board and automation](research/github-automation.md) diff --git a/docs/images/atmosphere/anomaly-reveal.png b/docs/images/atmosphere/anomaly-reveal.png new file mode 100644 index 0000000..2e23752 Binary files /dev/null and b/docs/images/atmosphere/anomaly-reveal.png differ diff --git a/docs/images/atmosphere/foreground-blur-zoom.png b/docs/images/atmosphere/foreground-blur-zoom.png new file mode 100644 index 0000000..898254e Binary files /dev/null and b/docs/images/atmosphere/foreground-blur-zoom.png differ diff --git a/docs/images/atmosphere/pond-after.png b/docs/images/atmosphere/pond-after.png new file mode 100644 index 0000000..02f21aa Binary files /dev/null and b/docs/images/atmosphere/pond-after.png differ diff --git a/docs/images/atmosphere/pond-before-ranks.png b/docs/images/atmosphere/pond-before-ranks.png new file mode 100644 index 0000000..c0e200a Binary files /dev/null and b/docs/images/atmosphere/pond-before-ranks.png differ diff --git a/docs/images/atmosphere/pond-ripple-zoom.png b/docs/images/atmosphere/pond-ripple-zoom.png new file mode 100644 index 0000000..440c2ea Binary files /dev/null and b/docs/images/atmosphere/pond-ripple-zoom.png differ diff --git a/docs/images/atmosphere/terraces-foreground.png b/docs/images/atmosphere/terraces-foreground.png new file mode 100644 index 0000000..ed33482 Binary files /dev/null and b/docs/images/atmosphere/terraces-foreground.png differ diff --git a/docs/images/boot-loading.png b/docs/images/boot-loading.png new file mode 100644 index 0000000..ab691e4 Binary files /dev/null and b/docs/images/boot-loading.png differ diff --git a/docs/images/boot-menu-key-art.png b/docs/images/boot-menu-key-art.png new file mode 100644 index 0000000..19a5797 Binary files /dev/null and b/docs/images/boot-menu-key-art.png differ diff --git a/docs/images/boot-menu-live-garden.png b/docs/images/boot-menu-live-garden.png new file mode 100644 index 0000000..977dd91 Binary files /dev/null and b/docs/images/boot-menu-live-garden.png differ diff --git a/docs/images/region1-chair-glint.png b/docs/images/region1-chair-glint.png new file mode 100644 index 0000000..4941947 Binary files /dev/null and b/docs/images/region1-chair-glint.png differ diff --git a/docs/images/region1-chair-glyph.png b/docs/images/region1-chair-glyph.png new file mode 100644 index 0000000..627c435 Binary files /dev/null and b/docs/images/region1-chair-glyph.png differ diff --git a/docs/images/region1-chair-pickup.png b/docs/images/region1-chair-pickup.png new file mode 100644 index 0000000..011ba10 Binary files /dev/null and b/docs/images/region1-chair-pickup.png differ diff --git a/docs/images/region1-jenny-chair-hit.png b/docs/images/region1-jenny-chair-hit.png new file mode 100644 index 0000000..18ac5ec Binary files /dev/null and b/docs/images/region1-jenny-chair-hit.png differ diff --git a/docs/images/region1-jenny-ram-knockback.png b/docs/images/region1-jenny-ram-knockback.png new file mode 100644 index 0000000..50fc91d Binary files /dev/null and b/docs/images/region1-jenny-ram-knockback.png differ diff --git a/docs/images/region1-jenny-rev-tell.png b/docs/images/region1-jenny-rev-tell.png new file mode 100644 index 0000000..e3d4260 Binary files /dev/null and b/docs/images/region1-jenny-rev-tell.png differ diff --git a/docs/images/region1-jenny-stunned-open-back.png b/docs/images/region1-jenny-stunned-open-back.png new file mode 100644 index 0000000..ab3c4f9 Binary files /dev/null and b/docs/images/region1-jenny-stunned-open-back.png differ diff --git a/docs/images/region1-landing.png b/docs/images/region1-landing.png new file mode 100644 index 0000000..8fb2fb5 Binary files /dev/null and b/docs/images/region1-landing.png differ diff --git a/docs/images/region1-ram-crash.png b/docs/images/region1-ram-crash.png new file mode 100644 index 0000000..282952d Binary files /dev/null and b/docs/images/region1-ram-crash.png differ diff --git a/docs/images/region1-ram-hit.png b/docs/images/region1-ram-hit.png new file mode 100644 index 0000000..a67dd6e Binary files /dev/null and b/docs/images/region1-ram-hit.png differ diff --git a/docs/images/region1-ram-tell.png b/docs/images/region1-ram-tell.png new file mode 100644 index 0000000..5e2a57e Binary files /dev/null and b/docs/images/region1-ram-tell.png differ diff --git a/docs/meetings/README.md b/docs/meetings/README.md index 4302d8f..e73933d 100644 --- a/docs/meetings/README.md +++ b/docs/meetings/README.md @@ -1,7 +1,7 @@ # Meetings and voice notes -[← 13 – Production pipeline](../13-PRODUCTION-PIPELINE.md) · [Design docs](../README.md) · **14 / 14** · [Design documents →](../README.md) +[← 15 – The prologue slice (rebuild)](../15-PROLOGUE-SLICE.md) · [Design docs](../README.md) · **15 / 15** · [Design documents →](../README.md) Fourteen recordings, 22–23 Oct 2026, transcribed with the OpenAI API. A11–A14 also have speaker labels (`gpt-4o-transcribe-diarize`). Audio stays in the team Drive; the transcripts are here so anyone (and any agent) can check where a design decision came from. The design itself lives in the numbered docs; transcripts are evidence, not instructions. @@ -38,5 +38,5 @@ The campus starts familiar and gets uncanny, with no gore and no apocalypse, bec [Transcript index](transcripts/README.md) · [Source hashes](transcripts/source-index.json) -[← 13 – Production pipeline](../13-PRODUCTION-PIPELINE.md) · [Design docs](../README.md) · **14 / 14** · [Design documents →](../README.md) +[← 15 – The prologue slice (rebuild)](../15-PROLOGUE-SLICE.md) · [Design docs](../README.md) · **15 / 15** · [Design documents →](../README.md) diff --git a/docs/prologue-tracker.csv b/docs/prologue-tracker.csv new file mode 100644 index 0000000..f68efae --- /dev/null +++ b/docs/prologue-tracker.csv @@ -0,0 +1,31 @@ +id,workstream,item,acceptance,status,pr +1.1,1 boot,Single loader,"Preload loads every atlas, stage, collision, atmosphere and surfaces once; no scene loads by itself; never stalls",done,#167 +1.2,1 boot,Loading screen,"Art-directed, real progress bar, error state with retry",done,#167 +1.3,1 boot,Menu,"DEGGSTER.EXE in the game's style over the garden art; Start / Credits; keyboard + pad",done,#167 +1.4,1 boot,Fade into the garden,"Menu → garden without a black flash; music/ambience start",done,#167 +2.1,2 atmosphere,All layers drawn,"sky, far, mid region plates, rear, water, play, foreground in order and scroll",review,#168 +2.2,2 atmosphere,Foreground blur + occlusion,"z 6–7 slightly blurred, drawn over the hero",review,#168 +2.3,2 atmosphere,Wind sway,"grass, leaves, trees sway by depth with gusts; data in atmosphere.json",review,#168 +2.4,2 atmosphere,Falling leaves,"front and back particles in the autumn palette",review,#168 +2.5,2 atmosphere,Water reflection,"pond reflects scene and hero, ripples, shimmer, lily pads bob",review,#168 +3.1,3 hero,Feet in the ground,"sink 2–4 px by surface from surfaces.json",review,#166 +3.2,3 hero,Step and land FX,"dust / leaves kicked up per surface",review,#166 +3.3,3 hero,Smooth transitions,"no pops between idle, walk, run, jump, land; stride pacing (#164)",review,#166 +3.4,3 hero,Camera,"steady follow, look-ahead, no jitter on slopes",review,#166 +4.1,4 prologue,First quest,"Dexter on the bench; walking up starts the quest; wordless",review,#169 +4.2,4 prologue,Dexter's walk,"waypoints 1–5 with walk/hop/point; waits and looks back",review,#169 +4.3,4 prologue,Secrets,"muffin behind the grass, the glint on the shed roof",review,#169 +4.4,4 prologue,The pond,"Dexter points, the reflection is wrong, the shove",review,#169 +4.5,4 prologue,404 and the pull,"frame tears, 404 void, THD.exe 404 window, pulled into the anomaly, land in clean Rusty Creek",review,#169 +5.1,5 art,Asset inventory,"art/inventory.csv: every prologue asset with size, frames, source, status",review,#165 +5.2,5 art,Falling leaf + ripple + dust sprites,"drawn from the garden refs, exported",review,#165 +5.3,5 art,Menu and loading art,"key art from the style master and the garden",review,#165 +5.4,5 art,Readability,"every prologue sprite readable at 1× on the garden background (#159)",review,#165 +P1,6 terrain,Walkable surfaces from the art,"no walking on water; planks and stairs as steps, not slopes; logs and walls stop you or must be jumped; no falling at chunk ends",review,#173 +P2,6+7,Feet in the trail,"a grass/plant strip in front of the trail covers the feet (7); surface effects per material – walk side: typed terrain drives surfaces.json, water splash (6, #173)",todo, +P3,7 layers,Layers rendered complete,"no visible cut-outs while layers scroll; occluded parts painted in; clean edges",todo, +P4,8 dexter,Dexter leads at your pace,"never overtaken; hero spawns at the entrance",todo, +P5,9 region 1,Land on the creek path,"after the 404 on the cement road under the rail bridge; one clear ram",todo, +P6,10 motion,Hero motion polish,"no jitter in Rusty Creek; jump spam crisp; more idle life",todo, +P7,9 region 1,Chair moment and encounters,"a pickup moment with a wordless attack glyph; more enemies on the route",todo, +P8,9 region 1,Jenny reads,"knockback, tell, open back, hit feedback, threatening parts",todo, diff --git a/game/public/assets/sprites/coffee-machine.anims.json b/game/public/assets/sprites/coffee-machine.anims.json new file mode 100644 index 0000000..2c114a0 --- /dev/null +++ b/game/public/assets/sprites/coffee-machine.anims.json @@ -0,0 +1,12 @@ +{ + "idle": { + "frames": 3, + "fps": 12, + "loop": true + }, + "pour": { + "frames": 4, + "fps": 12, + "loop": false + } +} diff --git a/game/public/assets/sprites/coffee-machine.json b/game/public/assets/sprites/coffee-machine.json new file mode 100644 index 0000000..0227758 --- /dev/null +++ b/game/public/assets/sprites/coffee-machine.json @@ -0,0 +1,182 @@ +{ + "frames": { + "idle-00": { + "frame": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "idle-01": { + "frame": { + "x": 46, + "y": 0, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "idle-02": { + "frame": { + "x": 92, + "y": 0, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "pour-00": { + "frame": { + "x": 0, + "y": 86, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "pour-01": { + "frame": { + "x": 46, + "y": 86, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "pour-02": { + "frame": { + "x": 92, + "y": 86, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "pour-03": { + "frame": { + "x": 138, + "y": 86, + "w": 44, + "h": 84 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 44, + "h": 84 + }, + "sourceSize": { + "w": 44, + "h": 84 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "coffee-machine.png", + "format": "RGBA8888", + "size": { + "w": 182, + "h": 170 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/coffee-machine.png b/game/public/assets/sprites/coffee-machine.png new file mode 100644 index 0000000..26a6b8b Binary files /dev/null and b/game/public/assets/sprites/coffee-machine.png differ diff --git a/game/public/assets/sprites/dexter-prologue.anims.json b/game/public/assets/sprites/dexter-prologue.anims.json new file mode 100644 index 0000000..b099fc1 --- /dev/null +++ b/game/public/assets/sprites/dexter-prologue.anims.json @@ -0,0 +1,17 @@ +{ + "look-back": { + "frames": 3, + "fps": 12, + "loop": false + }, + "wait": { + "frames": 3, + "fps": 6, + "loop": true + }, + "sit": { + "frames": 2, + "fps": 2, + "loop": true + } +} diff --git a/game/public/assets/sprites/dexter-prologue.json b/game/public/assets/sprites/dexter-prologue.json new file mode 100644 index 0000000..ab72041 --- /dev/null +++ b/game/public/assets/sprites/dexter-prologue.json @@ -0,0 +1,206 @@ +{ + "frames": { + "look-back-00": { + "frame": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "look-back-01": { + "frame": { + "x": 26, + "y": 0, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "look-back-02": { + "frame": { + "x": 52, + "y": 0, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "wait-00": { + "frame": { + "x": 0, + "y": 33, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "wait-01": { + "frame": { + "x": 26, + "y": 33, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "wait-02": { + "frame": { + "x": 52, + "y": 33, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sit-00": { + "frame": { + "x": 0, + "y": 66, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sit-01": { + "frame": { + "x": 26, + "y": 66, + "w": 24, + "h": 31 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 24, + "h": 31 + }, + "sourceSize": { + "w": 24, + "h": 31 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "dexter-prologue.png", + "format": "RGBA8888", + "size": { + "w": 76, + "h": 97 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/dexter-prologue.png b/game/public/assets/sprites/dexter-prologue.png new file mode 100644 index 0000000..5ff5e6c Binary files /dev/null and b/game/public/assets/sprites/dexter-prologue.png differ diff --git a/game/public/assets/sprites/fx-ground.anims.json b/game/public/assets/sprites/fx-ground.anims.json new file mode 100644 index 0000000..6401afe --- /dev/null +++ b/game/public/assets/sprites/fx-ground.anims.json @@ -0,0 +1,22 @@ +{ + "dust": { + "frames": 4, + "fps": 12, + "loop": false + }, + "grass": { + "frames": 4, + "fps": 12, + "loop": false + }, + "leaf-kick": { + "frames": 4, + "fps": 12, + "loop": false + }, + "splash": { + "frames": 4, + "fps": 12, + "loop": false + } +} diff --git a/game/public/assets/sprites/fx-ground.json b/game/public/assets/sprites/fx-ground.json new file mode 100644 index 0000000..51e68a2 --- /dev/null +++ b/game/public/assets/sprites/fx-ground.json @@ -0,0 +1,398 @@ +{ + "frames": { + "dust-00": { + "frame": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "dust-01": { + "frame": { + "x": 62, + "y": 0, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "dust-02": { + "frame": { + "x": 124, + "y": 0, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "dust-03": { + "frame": { + "x": 186, + "y": 0, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "grass-00": { + "frame": { + "x": 0, + "y": 24, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "grass-01": { + "frame": { + "x": 62, + "y": 24, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "grass-02": { + "frame": { + "x": 124, + "y": 24, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "grass-03": { + "frame": { + "x": 186, + "y": 24, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "leaf-kick-00": { + "frame": { + "x": 0, + "y": 48, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "leaf-kick-01": { + "frame": { + "x": 62, + "y": 48, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "leaf-kick-02": { + "frame": { + "x": 124, + "y": 48, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "leaf-kick-03": { + "frame": { + "x": 186, + "y": 48, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "splash-00": { + "frame": { + "x": 0, + "y": 72, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "splash-01": { + "frame": { + "x": 62, + "y": 72, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "splash-02": { + "frame": { + "x": 124, + "y": 72, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "splash-03": { + "frame": { + "x": 186, + "y": 72, + "w": 60, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 60, + "h": 22 + }, + "sourceSize": { + "w": 60, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "fx-ground.png", + "format": "RGBA8888", + "size": { + "w": 246, + "h": 94 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/fx-ground.png b/game/public/assets/sprites/fx-ground.png new file mode 100644 index 0000000..7da2611 Binary files /dev/null and b/game/public/assets/sprites/fx-ground.png differ diff --git a/game/public/assets/sprites/fx-leaves.anims.json b/game/public/assets/sprites/fx-leaves.anims.json new file mode 100644 index 0000000..115e37a --- /dev/null +++ b/game/public/assets/sprites/fx-leaves.anims.json @@ -0,0 +1,17 @@ +{ + "birch": { + "frames": 5, + "fps": 8, + "loop": true + }, + "maple": { + "frames": 5, + "fps": 8, + "loop": true + }, + "oak": { + "frames": 5, + "fps": 8, + "loop": true + } +} diff --git a/game/public/assets/sprites/fx-leaves.json b/game/public/assets/sprites/fx-leaves.json new file mode 100644 index 0000000..cf9e922 --- /dev/null +++ b/game/public/assets/sprites/fx-leaves.json @@ -0,0 +1,374 @@ +{ + "frames": { + "birch-00": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "birch-01": { + "frame": { + "x": 18, + "y": 0, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "birch-02": { + "frame": { + "x": 36, + "y": 0, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "birch-03": { + "frame": { + "x": 54, + "y": 0, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "birch-04": { + "frame": { + "x": 72, + "y": 0, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "maple-00": { + "frame": { + "x": 0, + "y": 16, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "maple-01": { + "frame": { + "x": 18, + "y": 16, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "maple-02": { + "frame": { + "x": 36, + "y": 16, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "maple-03": { + "frame": { + "x": 54, + "y": 16, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "maple-04": { + "frame": { + "x": 72, + "y": 16, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "oak-00": { + "frame": { + "x": 0, + "y": 32, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "oak-01": { + "frame": { + "x": 18, + "y": 32, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "oak-02": { + "frame": { + "x": 36, + "y": 32, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "oak-03": { + "frame": { + "x": 54, + "y": 32, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "oak-04": { + "frame": { + "x": 72, + "y": 32, + "w": 16, + "h": 14 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 16, + "h": 14 + }, + "sourceSize": { + "w": 16, + "h": 14 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "fx-leaves.png", + "format": "RGBA8888", + "size": { + "w": 88, + "h": 46 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/fx-leaves.png b/game/public/assets/sprites/fx-leaves.png new file mode 100644 index 0000000..f489898 Binary files /dev/null and b/game/public/assets/sprites/fx-leaves.png differ diff --git a/game/public/assets/sprites/fx-water.anims.json b/game/public/assets/sprites/fx-water.anims.json new file mode 100644 index 0000000..dadafaf --- /dev/null +++ b/game/public/assets/sprites/fx-water.anims.json @@ -0,0 +1,17 @@ +{ + "lily-bob": { + "frames": 4, + "fps": 4, + "loop": true + }, + "ripple": { + "frames": 4, + "fps": 8, + "loop": false + }, + "sparkle": { + "frames": 4, + "fps": 10, + "loop": false + } +} diff --git a/game/public/assets/sprites/fx-water.json b/game/public/assets/sprites/fx-water.json new file mode 100644 index 0000000..d55ec03 --- /dev/null +++ b/game/public/assets/sprites/fx-water.json @@ -0,0 +1,302 @@ +{ + "frames": { + "lily-bob-00": { + "frame": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "lily-bob-01": { + "frame": { + "x": 128, + "y": 0, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "lily-bob-02": { + "frame": { + "x": 256, + "y": 0, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "lily-bob-03": { + "frame": { + "x": 384, + "y": 0, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "ripple-00": { + "frame": { + "x": 0, + "y": 22, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "ripple-01": { + "frame": { + "x": 128, + "y": 22, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "ripple-02": { + "frame": { + "x": 256, + "y": 22, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "ripple-03": { + "frame": { + "x": 384, + "y": 22, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sparkle-00": { + "frame": { + "x": 0, + "y": 44, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sparkle-01": { + "frame": { + "x": 128, + "y": 44, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sparkle-02": { + "frame": { + "x": 256, + "y": 44, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "sparkle-03": { + "frame": { + "x": 384, + "y": 44, + "w": 126, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 126, + "h": 20 + }, + "sourceSize": { + "w": 126, + "h": 20 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "fx-water.png", + "format": "RGBA8888", + "size": { + "w": 510, + "h": 64 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/fx-water.png b/game/public/assets/sprites/fx-water.png new file mode 100644 index 0000000..4fb779e Binary files /dev/null and b/game/public/assets/sprites/fx-water.png differ diff --git a/game/public/assets/sprites/hero-move.anims.json b/game/public/assets/sprites/hero-move.anims.json index 6a22e8a..30cb336 100644 --- a/game/public/assets/sprites/hero-move.anims.json +++ b/game/public/assets/sprites/hero-move.anims.json @@ -12,6 +12,7 @@ "walk": { "frames": 8, "fps": 12, - "loop": true + "loop": true, + "stride": 43 } } diff --git a/game/public/assets/sprites/hero-run.anims.json b/game/public/assets/sprites/hero-run.anims.json index 26cd2ac..44b9f4b 100644 --- a/game/public/assets/sprites/hero-run.anims.json +++ b/game/public/assets/sprites/hero-run.anims.json @@ -12,6 +12,7 @@ "run": { "frames": 8, "fps": 12, - "loop": true + "loop": true, + "stride": 82 } } diff --git a/game/public/assets/sprites/mensa-food.anims.json b/game/public/assets/sprites/mensa-food.anims.json new file mode 100644 index 0000000..ef410c9 --- /dev/null +++ b/game/public/assets/sprites/mensa-food.anims.json @@ -0,0 +1,22 @@ +{ + "muffin": { + "frames": 1, + "fps": 12, + "loop": true + }, + "cake": { + "frames": 1, + "fps": 12, + "loop": true + }, + "wafer": { + "frames": 1, + "fps": 12, + "loop": true + }, + "leberkaese": { + "frames": 1, + "fps": 12, + "loop": true + } +} diff --git a/game/public/assets/sprites/mensa-food.json b/game/public/assets/sprites/mensa-food.json new file mode 100644 index 0000000..552fb8c --- /dev/null +++ b/game/public/assets/sprites/mensa-food.json @@ -0,0 +1,110 @@ +{ + "frames": { + "muffin-00": { + "frame": { + "x": 0, + "y": 0, + "w": 22, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 22, + "h": 22 + }, + "sourceSize": { + "w": 22, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "cake-00": { + "frame": { + "x": 0, + "y": 24, + "w": 22, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 22, + "h": 22 + }, + "sourceSize": { + "w": 22, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "wafer-00": { + "frame": { + "x": 0, + "y": 48, + "w": 22, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 22, + "h": 22 + }, + "sourceSize": { + "w": 22, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "leberkaese-00": { + "frame": { + "x": 0, + "y": 72, + "w": 22, + "h": 22 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 22, + "h": 22 + }, + "sourceSize": { + "w": 22, + "h": 22 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "mensa-food.png", + "format": "RGBA8888", + "size": { + "w": 22, + "h": 94 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/mensa-food.png b/game/public/assets/sprites/mensa-food.png new file mode 100644 index 0000000..77831cb Binary files /dev/null and b/game/public/assets/sprites/mensa-food.png differ diff --git a/game/public/assets/sprites/pocket-computer.anims.json b/game/public/assets/sprites/pocket-computer.anims.json new file mode 100644 index 0000000..2f732c6 --- /dev/null +++ b/game/public/assets/sprites/pocket-computer.anims.json @@ -0,0 +1,12 @@ +{ + "bob": { + "frames": 4, + "fps": 12, + "loop": true + }, + "open": { + "frames": 3, + "fps": 12, + "loop": false + } +} diff --git a/game/public/assets/sprites/pocket-computer.json b/game/public/assets/sprites/pocket-computer.json new file mode 100644 index 0000000..763cc05 --- /dev/null +++ b/game/public/assets/sprites/pocket-computer.json @@ -0,0 +1,182 @@ +{ + "frames": { + "bob-00": { + "frame": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "bob-01": { + "frame": { + "x": 32, + "y": 0, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "bob-02": { + "frame": { + "x": 64, + "y": 0, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "bob-03": { + "frame": { + "x": 96, + "y": 0, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "open-00": { + "frame": { + "x": 0, + "y": 29, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "open-01": { + "frame": { + "x": 32, + "y": 29, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + }, + "open-02": { + "frame": { + "x": 64, + "y": 29, + "w": 30, + "h": 27 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 30, + "h": 27 + }, + "sourceSize": { + "w": 30, + "h": 27 + }, + "pivot": { + "x": 0.5, + "y": 1 + } + } + }, + "meta": { + "app": "scripts/export_atlas.py", + "image": "pocket-computer.png", + "format": "RGBA8888", + "size": { + "w": 126, + "h": 56 + }, + "scale": "1" + } +} diff --git a/game/public/assets/sprites/pocket-computer.png b/game/public/assets/sprites/pocket-computer.png new file mode 100644 index 0000000..75b097c Binary files /dev/null and b/game/public/assets/sprites/pocket-computer.png differ diff --git a/game/public/assets/ui/menu-key.png b/game/public/assets/ui/menu-key.png new file mode 100644 index 0000000..dd0ee30 Binary files /dev/null and b/game/public/assets/ui/menu-key.png differ diff --git a/game/src/boot/Boot.ts b/game/src/boot/Boot.ts new file mode 100644 index 0000000..4833ad0 --- /dev/null +++ b/game/src/boot/Boot.ts @@ -0,0 +1,34 @@ +import Phaser from 'phaser'; +import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import type { PreloadData } from './contract'; +import { AssetLoader } from './loader'; +import { bootEntries, uiAtlasEntry } from './manifest'; +import { pixelText, registerPixelFont } from './pixelFont'; +import { LoadTracker } from './progress'; +import { PALETTE } from './LoadingView'; + +/** + * First scene: the pixel font (drawn in code) and the small UI atlas the loading screen is made of. Both are + * optional – without the atlas the loading screen draws itself in code – so Boot always moves on to Preload. + */ +export class Boot extends Phaser.Scene { + constructor() { super('Boot'); } + + async create(data: PreloadData = {}): Promise { + registerPixelFont(this); + this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, PALETTE.night).setOrigin(0); + pixelText(this, VIEW_WIDTH / 2, VIEW_HEIGHT / 2, 'C:\\> BOOT', 2, PALETTE.dim).setOrigin(0.5); + hideOverlays(this); + const loader = new AssetLoader(this, new LoadTracker()); + try { + await loader.run(bootEntries()); + await loader.run([uiAtlasEntry()]); + } catch (error) { console.error('[boot] UI atlas unavailable, drawing the loading screen in code', error); } + this.scene.start('Preload', data); + } +} + +/** Scenes that start active at boot (HUD) stay hidden until the stage runs. */ +export function hideOverlays(scene: Phaser.Scene): void { + if (scene.scene.get('HUD')) scene.scene.setVisible(false, 'HUD'); +} diff --git a/game/src/boot/LoadingView.ts b/game/src/boot/LoadingView.ts new file mode 100644 index 0000000..3eb9058 --- /dev/null +++ b/game/src/boot/LoadingView.ts @@ -0,0 +1,117 @@ +import Phaser from 'phaser'; +import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { UI_ATLAS_KEY, UI_LAYOUT_KEY } from './manifest'; +import { pixelSafe, pixelText } from './pixelFont'; +import { formatBytes, type Summary } from './progress'; + +/** The slice's palette (style master, HUD direction): night ink, cream, phosphor green, teal charge, rust. */ +export const PALETTE = { + night: 0x0c1218, ink: 0x101820, cream: 0xe8e4dc, dim: 0x7d8a90, teal: 0x46d1b4, + phosphor: 0x39ff6a, phosphorDim: 0x1c8a3a, screen: 0x041006, rust: 0xf2a24a, red: 0xe0503a, +} as const; + +const CELLS = 12; +const LOG_LINES = 11; +const FRAME = { w: 560, h: 365, screen: [45, 71, 456, 261] as [number, number, number, number] }; + +/** + * The loading screen: the pocket computer's rusted case (approved UI atlas) with a boot log on its CRT, a row of + * battery cells charging with real progress, and the window title that every player of the 90s knows – + * "(Not Responding)" – until the last file lands. Draws in code when the UI atlas itself is missing. + */ +export class LoadingView { + private log: { text: string; tint: number }[] = []; + private logText: Phaser.GameObjects.BitmapText[] = []; + private cells: Phaser.GameObjects.Image[] = []; + private bar?: Phaser.GameObjects.Graphics; + private stats: Phaser.GameObjects.BitmapText; + private status: Phaser.GameObjects.BitmapText; + private hint: Phaser.GameObjects.BitmapText; + private screen: { x: number; y: number; w: number; h: number }; + private cellsAt: { x: number; y: number; pitch: number }; + private done = false; + + constructor(private scene: Phaser.Scene, region: string) { + const add = scene.add; + add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, PALETTE.night).setOrigin(0); + // Faint scanlines over the whole view: the screen is the game. + const lines = add.graphics().setAlpha(0.18); + for (let y = 0; y < VIEW_HEIGHT; y += 4) lines.fillStyle(0x000000).fillRect(0, y, VIEW_WIDTH, 2); + + const art = scene.textures.exists(UI_ATLAS_KEY) && scene.textures.get(UI_ATLAS_KEY).has('pocket-frame'); + const layout = scene.cache.json.get(UI_LAYOUT_KEY) as { 'pocket-frame'?: { size: [number, number]; screen: [number, number, number, number] } } | undefined; + const frame = layout?.['pocket-frame'] ?? FRAME; + const [fw, fh] = 'size' in frame ? frame.size : [FRAME.w, FRAME.h]; + const fx = Math.round((VIEW_WIDTH - fw) / 2), fy = 150; + const [sx, sy, sw, sh] = frame.screen; + this.screen = { x: fx + sx, y: fy + sy, w: sw, h: sh }; + const glass = add.graphics(); + glass.fillStyle(PALETTE.screen).fillRect(this.screen.x, this.screen.y, sw, sh); + if (art) { + add.image(fx, fy, UI_ATLAS_KEY, 'pocket-frame').setOrigin(0); + } else { + glass.lineStyle(10, 0x6f767d).strokeRect(this.screen.x - 14, this.screen.y - 14, sw + 28, sh + 28); + } + const scan = add.graphics().setAlpha(0.35); + for (let y = this.screen.y; y < this.screen.y + sh; y += 3) scan.fillStyle(0x000000).fillRect(this.screen.x, y, sw, 1); + + // The window title above the case: the crash-OS joke is the game's identity. + pixelText(scene, VIEW_WIDTH / 2, 64, 'DEGGSTER.EXE', 5, PALETTE.cream).setOrigin(0.5); + this.status = pixelText(scene, VIEW_WIDTH / 2, 112, '(NOT RESPONDING)', 2, PALETTE.rust).setOrigin(0.5); + + for (let i = 0; i < LOG_LINES; i++) { + this.logText.push(pixelText(scene, this.screen.x + 14, this.screen.y + 14 + i * 20, '', 2, PALETTE.phosphor)); + } + this.cellsAt = { x: Math.round(VIEW_WIDTH / 2 - (CELLS * 40) / 2 + 3), y: fy + fh + 26, pitch: 40 }; + if (art && scene.textures.get(UI_ATLAS_KEY).has('cell-empty')) { + for (let i = 0; i < CELLS; i++) this.cells.push(add.image(this.cellsAt.x + i * this.cellsAt.pitch, this.cellsAt.y, UI_ATLAS_KEY, 'cell-empty').setOrigin(0)); + } else this.bar = add.graphics(); + this.stats = pixelText(scene, VIEW_WIDTH / 2, this.cellsAt.y + 70, '', 2, PALETTE.dim).setOrigin(0.5, 0); + this.hint = pixelText(scene, VIEW_WIDTH / 2, this.cellsAt.y + 96, '', 2, PALETTE.cream).setOrigin(0.5, 0); + this.line(`C:\\> DEGGSTER.EXE /REGION=${region}`, PALETTE.cream); + } + + /** Append a boot-log line (keeps the last LOG_LINES). */ + line(text: string, tint: number = PALETTE.phosphor): void { + const max = Math.floor((this.screen.w - 28) / 12); + const safe = pixelSafe(text); + this.log.push({ text: safe.length > max ? `${safe.slice(0, max - 3)}...` : safe, tint }); + this.log = this.log.slice(-LOG_LINES); + this.logText.forEach((t, i) => { const row = this.log[i]; t.setText(row?.text ?? '').setTint(row?.tint ?? PALETTE.phosphor); }); + } + + /** Redraw progress: cells charge with the fraction, the cell being filled flickers. */ + update(summary: Summary, time: number): void { + const filled = summary.fraction * CELLS; + const flicker = Math.floor(time / 120) % 2 === 0; + this.cells.forEach((cell, i) => cell.setFrame(i < Math.floor(filled) ? 'cell-full' + : i === Math.floor(filled) && !this.done && flicker ? 'crack-1' : 'cell-empty')); + if (this.bar) { + const w = CELLS * this.cellsAt.pitch - 6; + this.bar.clear().fillStyle(PALETTE.ink).fillRect(this.cellsAt.x, this.cellsAt.y, w, 40) + .fillStyle(PALETTE.teal).fillRect(this.cellsAt.x, this.cellsAt.y, Math.round(w * summary.fraction), 40); + } + const percent = Math.floor(summary.fraction * 100); + this.stats.setText(pixelSafe(`${summary.done}/${summary.files} FILES ${formatBytes(summary.bytes)} ${percent}%`)); + if (!this.done) this.status.setVisible(Math.floor(time / 700) % 2 === 0 || summary.errors.length > 0); + } + + /** The last file landed: the window responds again. */ + finish(): void { + this.done = true; + this.status.setText(pixelSafe('(RESPONDING)')).setTint(PALETTE.teal).setVisible(true); + this.line('DEGGSTER.EXE IS NOW RESPONDING.', PALETTE.cream); + this.hint.setText(''); + } + + /** Failed required files: list them and offer Retry / Continue. */ + showErrors(errors: { url: string; reason: string }[], countdown?: number): void { + this.status.setText(pixelSafe(`(NOT RESPONDING) - ${errors.length} FILE${errors.length === 1 ? '' : 'S'} FAILED`)).setTint(PALETTE.red).setVisible(true); + this.hint.setText(pixelSafe(`[R] RETRY [C] CONTINUE WITHOUT THEM${countdown !== undefined ? ` (${countdown})` : ''}`)); + } + + clearErrors(): void { + this.status.setText('(NOT RESPONDING)').setTint(PALETTE.rust); + this.hint.setText(''); + } +} diff --git a/game/src/boot/Preload.ts b/game/src/boot/Preload.ts new file mode 100644 index 0000000..2eee8cf --- /dev/null +++ b/game/src/boot/Preload.ts @@ -0,0 +1,118 @@ +import Phaser from 'phaser'; +import type { StageManifest } from '../stage/manifest'; +import { hideOverlays } from './Boot'; +import type { PreloadData, StageData } from './contract'; +import { AssetLoader } from './loader'; +import { LoadingView, PALETTE } from './LoadingView'; +import { registerPixelFont } from './pixelFont'; +import { LoadTracker } from './progress'; +import { logLine, resolveTarget } from './target'; + +/** Production continues past failed required files on its own after this many seconds (a retry first). */ +const AUTO_CONTINUE = 6; +const FADE = 260; + + +/** + * Loads everything a region needs through the one loader (boot/loader.ts), behind the loading screen, then + * starts the menu (first load) or the stage (a region swap). Takes `{ region, state, spawn }` from swapRegion; + * a swap reloads only what the new region adds. Failed required files show Retry / Continue; production + * retries once and then continues on its own with placeholders, so the game never waits forever. + */ +export class Preload extends Phaser.Scene { + tracker!: LoadTracker; + private loader!: AssetLoader; + private view!: LoadingView; + private target!: ReturnType & { spawn?: PreloadData['spawn'] }; + private manifest?: StageManifest; + private waiting?: { retry: () => void; proceed: () => void }; + private retries = 0; + private countdown?: Phaser.Time.TimerEvent; + + constructor() { super('Preload'); } + + create(data: PreloadData = {}): void { + registerPixelFont(this); + hideOverlays(this); + this.target = { ...resolveTarget(data, location.search), spawn: data.spawn }; + this.retries = 0; + this.waiting = undefined; + this.tracker = new LoadTracker(); + this.loader = new AssetLoader(this, this.tracker); + this.view = new LoadingView(this, this.target.region); + this.tracker.onChange = file => { + const line = logLine(file); + if (line) this.view.line(line, file.status === 'failed' ? (file.entry.optional ? PALETTE.phosphorDim : PALETTE.red) : PALETTE.phosphor); + }; + this.cameras.main.fadeIn(FADE, 12, 18, 24); + + const keys = this.input.keyboard; + keys?.on('keydown-R', () => this.waiting?.retry()); + keys?.on('keydown-ENTER', () => this.waiting?.retry()); + keys?.on('keydown-C', () => this.waiting?.proceed()); + keys?.on('keydown-ESC', () => this.waiting?.proceed()); + this.input.gamepad?.on('down', (_pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button) => { + if (button.index === 0) this.waiting?.retry(); + else if (button.index === 1) this.waiting?.proceed(); + }); + this.input.on('pointerdown', () => this.waiting?.retry()); + void this.attempt(); + } + + update(time: number): void { + this.view?.update(this.tracker.summary(), time); + } + + private async attempt(): Promise { + const { region, state, first } = this.target; + let crashed: Error | undefined; + try { this.manifest = await this.loader.region(region, state, first) ?? this.manifest; } + catch (error) { crashed = error as Error; console.error('[preload]', error); } + if (!this.scene.isActive()) return; // the scene was stopped meanwhile (a newer swap) + const data = this.manifest ? this.loader.stageData(this.manifest, state, this.target.spawn) : undefined; + const errors = this.tracker.summary().errors; + if (crashed && !errors.length) errors.push({ key: 'loader', url: 'loader', reason: crashed.message, optional: false }); + if (data && !errors.length) return this.finish(data); + for (const error of errors) console.error(`[preload] ${error.url}: ${error.reason}`); + // Production retries once quietly, then offers Retry / Continue and continues on its own. + if (!import.meta.env.DEV && this.retries === 0) { this.retries++; return this.attempt(); } + this.offer(errors); + } + + private offer(errors: { url: string; reason: string }[]): void { + let left = AUTO_CONTINUE; + const auto = !import.meta.env.DEV; + this.view.showErrors(errors, auto ? left : undefined); + const stop = () => { this.waiting = undefined; this.countdown?.remove(); this.countdown = undefined; }; + this.waiting = { + retry: () => { stop(); this.retries++; this.view.clearErrors(); this.view.line('RETRY...', PALETTE.cream); void this.attempt(); }, + proceed: () => { stop(); void this.proceed(); }, + }; + if (auto) this.countdown = this.time.addEvent({ delay: 1000, repeat: AUTO_CONTINUE - 1, callback: () => { + left--; + if (left > 0) this.view.showErrors(errors, left); + else this.waiting?.proceed(); + } }); + } + + /** Continue without the failed files: placeholders for boards and plates, a one-plate stage if needed. */ + private async proceed(): Promise { + const { region, state, spawn } = this.target; + this.view.line('CONTINUING WITHOUT THEM.', PALETTE.rust); + this.manifest = await this.loader.degrade(region, state, this.manifest); + if (this.scene.isActive()) this.finish(this.loader.stageData(this.manifest, state, spawn)); + } + + private finish(data: StageData): void { + this.view.finish(); + this.time.delayedCall(this.target.first ? 420 : 120, () => { + this.cameras.main.fadeOut(FADE, 12, 18, 24); + this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => { + const skipMenu = import.meta.env.DEV && new URLSearchParams(location.search).has('skip-menu'); + if (this.target.first && !skipMenu) return this.scene.start('Title', data); + this.scene.setVisible(true, 'HUD'); + this.scene.start('Stage', data); + }); + }); + } +} diff --git a/game/src/boot/contract.ts b/game/src/boot/contract.ts new file mode 100644 index 0000000..7ec8e1c --- /dev/null +++ b/game/src/boot/contract.ts @@ -0,0 +1,22 @@ +import type { Position } from '../core/events'; +import type { CollisionChunk } from '../stage/collision'; +import type { RegionState, StageManifest } from '../stage/manifest'; + +/** A region's optional JSON data (atmosphere.json, surfaces.json): `{}` when the file is absent. */ +export type RegionJson = Record; + +/** + * What Preload hands to `Stage` (and, on the first load, to `Title`). Workstreams 2 and 3 parse `atmosphere` + * and `surfaces` themselves; the loader only guarantees a plain object. + */ +export interface StageData { + manifest: StageManifest; + state: RegionState; + collisions: CollisionChunk[]; + atmosphere: RegionJson; + surfaces: RegionJson; + spawn?: Position; +} + +/** What Preload accepts: from `swapRegion` (stage/swap.ts) or empty on boot (the URL picks the region). */ +export interface PreloadData { region?: string; state?: RegionState; spawn?: Position } diff --git a/game/src/boot/loader.test.ts b/game/src/boot/loader.test.ts new file mode 100644 index 0000000..7093dfc --- /dev/null +++ b/game/src/boot/loader.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest'; +import type Phaser from 'phaser'; +import { BOARDS, OPTIONAL_BOARDS } from '../stage/assets'; +import { AssetLoader, fetchJson } from './loader'; +import { imageWave, jsonWave, MENU_KEY } from './manifest'; +import { LoadTracker } from './progress'; +import { logLine, resolveTarget } from './target'; + +const stage = (region = 'hidden-garden') => ({ region, chunkSize: [2160, 720], chunks: [0, 1].map(x => ({ + id: `c-${x}`, x, y: 0, states: { clean: { layers: [ + { id: 'sky', z: 0, scroll: 0.1, file: 'sky.png' }, { id: 'play', z: 5, scroll: 1, file: `${x}-play.png` }], + collision: `c-${x}.clean.collision.json` } } })) }); +const collision = (chunk: string) => ({ chunk, state: 'clean', size: [2160, 720], walk: [[[0, 500], [2160, 500]]], + slide: [], oneWay: [], climb: [], solid: [], noGo: [], hazard: [], interact: [], glideLift: [], + spawn: [{ id: 'player', kind: 'player', x: 120, y: 500 }], ledge: [] }); +const atlas = { frames: { 'idle-00': { frame: { x: 0, y: 0, w: 8, h: 8 } } } }; +const clips = { idle: { frames: 1, fps: 12, loop: true } }; + +/** A fake scene: JSON cache, texture set, and a Phaser-like loader that "loads" every queued file unless it is listed as broken. */ +function harness(opts: { missing?: string[]; brokenImages?: string[] } = {}) { + const textures = new Set(), json = new Map(); + const listeners = new Map void)[]>(); + let queue: { key: string }[] = []; + const emit = (event: string, ...args: unknown[]) => [...(listeners.get(event) ?? [])].forEach(fn => fn(...args)); + const load = { + image: vi.fn((key: string) => queue.push({ key })), + atlas: vi.fn((key: string) => queue.push({ key })), + on(event: string, fn: (...args: unknown[]) => void) { listeners.set(event, [...(listeners.get(event) ?? []), fn]); return load; }, + once(event: string, fn: (...args: unknown[]) => void) { return load.on(event, fn); }, + off(event: string, fn: (...args: unknown[]) => void) { listeners.set(event, (listeners.get(event) ?? []).filter(f => f !== fn)); return load; }, + reset: vi.fn(), + start() { + const files = queue; queue = []; + for (const file of files) { + if (opts.brokenImages?.includes(file.key)) { emit('loaderror', { key: file.key, xhrLoader: { status: 404 } }); continue; } + emit('fileprogress', { key: file.key, bytesLoaded: 1000, bytesTotal: 1000 }); + textures.add(file.key); emit('filecomplete', file.key); + } + emit('complete'); + }, + }; + const scene = { + load, textures: { exists: (k: string) => textures.has(k), createCanvas: vi.fn((k: string) => { textures.add(k); return { context: { fillRect() {}, set fillStyle(_: string) {} }, refresh() {} }; }) }, + cache: { json: { exists: (k: string) => json.has(k), add: (k: string, v: unknown) => json.set(k, v), get: (k: string) => json.get(k), remove: (k: string) => json.delete(k) } }, + }; + const request = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + const missing = opts.missing?.some(m => url.includes(m)); + const body = url.endsWith('stage.json') ? stage(url.split('/')[3]) + : url.endsWith('.collision.json') ? collision(url.split('/').pop()!.split('.')[0]) + : url.endsWith('.anims.json') ? clips : url.includes('/sprites/') || url.includes('/placeholder/') ? atlas : { wind: 1 }; + return { ok: !missing, status: missing ? 404 : 200, headers: new Headers({ 'content-type': 'application/json' }), + text: async () => JSON.stringify(body) } as unknown as Response; + }); + const tracker = new LoadTracker(); + return { scene: scene as unknown as Phaser.Scene, request: request as unknown as typeof fetch, tracker, textures, json, load, + loader: new AssetLoader(scene as unknown as Phaser.Scene, tracker, request as unknown as typeof fetch) }; +} + +describe('manifest', () => { + it('plans every board, the region data and the map stages in wave 1', () => { + const keys = jsonWave('hidden-garden', { boards: BOARDS, mapRegions: ['hidden-garden', 'rusty-creek'] }).map(e => e.key); + expect(keys.slice(0, 4)).toEqual(['stage:hidden-garden', 'atmosphere:hidden-garden', 'surfaces:hidden-garden', 'map-stage-rusty-creek']); + expect(keys).toContain('atlas:fx-leaves'); + expect(keys).toContain('hero-move.anims'); + }); + it('marks optional boards, region data and key art as optional', () => { + const wave = jsonWave('hidden-garden', { boards: OPTIONAL_BOARDS, mapRegions: [] }); + expect(wave.filter(e => e.optional).map(e => e.key)).toEqual(['atmosphere:hidden-garden', 'surfaces:hidden-garden', + ...OPTIONAL_BOARDS.flatMap(b => [`atlas:${b}`, `${b}.anims`])]); + }); + it('plans shared plates once and skips textures already loaded', () => { + const manifest = stage() as never; + const keys = imageWave(manifest, 'clean', { boards: ['hero-move'], has: k => k === 'hidden-garden:0-play.png', menuKey: true }).map(e => e.key); + expect(keys).toEqual(['collision:hidden-garden:c-0.clean.collision.json', 'collision:hidden-garden:c-1.clean.collision.json', + 'hidden-garden:sky.png', 'hidden-garden:1-play.png', 'hero-move', MENU_KEY]); + const extra = imageWave(manifest, 'clean', { boards: [], has: () => false, assets: ['wrong-reflection.png', '../x.png'] }); + expect(extra.filter(e => e.key === 'hidden-garden:wrong-reflection.png')).toEqual([ + { key: 'hidden-garden:wrong-reflection.png', url: '/assets/stages/hidden-garden/wrong-reflection.png', kind: 'image', optional: true }]); + expect(extra.some(e => e.url.includes('..'))).toBe(false); + }); +}); + +describe('fetchJson', () => { + it('treats a dev server HTML fallback as missing', async () => { + const request = vi.fn(async () => ({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/html' }), text: async () => '' })); + await expect(fetchJson(request as unknown as typeof fetch, '/x.json')).rejects.toThrow('HTTP 404'); + }); + it('times out instead of waiting forever', async () => { + const request = vi.fn((_: unknown, init?: RequestInit) => new Promise((_, reject) => + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))))); + await expect(fetchJson(request as unknown as typeof fetch, '/x.json', 10)).rejects.toThrow('timed out'); + }); +}); + +describe('asset loader', () => { + it('loads a region and hands Stage its data, with {} for absent optional files', async () => { + const h = harness({ missing: ['surfaces.json'] }); + const manifest = await h.loader.region('hidden-garden', 'clean', true); + expect(manifest?.region).toBe('hidden-garden'); + const data = h.loader.stageData(manifest!, 'clean'); + expect(data.collisions).toHaveLength(2); + expect(data.atmosphere).toEqual({ wind: 1 }); + expect(data.surfaces).toEqual({}); + const s = h.tracker.summary(); + expect(s.errors).toEqual([]); + expect(s.missing.map(f => f.key)).toContain('surfaces:hidden-garden'); + for (const board of BOARDS) expect(h.textures.has(board)).toBe(true); + expect(h.json.has('map-stage-hidden-garden') && h.json.has('map-stage-rusty-creek')).toBe(true); + expect(s.fraction).toBe(1); + }); + it('a region swap loads only what the new region adds', async () => { + const h = harness(); + await h.loader.region('hidden-garden', 'clean', true); + h.load.atlas.mockClear(); h.load.image.mockClear(); + await h.loader.region('rusty-creek', 'clean', false); + expect(h.load.atlas).not.toHaveBeenCalled(); + expect(h.load.image.mock.calls.map(c => c[0])).toEqual(['rusty-creek:sky.png', 'rusty-creek:0-play.png', 'rusty-creek:1-play.png']); + }); + it('reports a missing required file instead of throwing, and a retry fetches only that file', async () => { + const h = harness({ brokenImages: ['hero-run'] }); + await h.loader.region('hidden-garden', 'clean', false); + expect(h.tracker.summary().errors.map(e => e.key)).toEqual(['hero-run']); + const calls = (h.request as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + h.load.atlas.mockClear(); + await h.loader.region('hidden-garden', 'clean', false); + expect((h.request as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(calls); // JSON all cached + expect(h.load.atlas.mock.calls.map(c => c[0])).toEqual(['hero-run']); + }); + it('degrades to placeholders and a one-plate stage when continuing past failures', async () => { + const h = harness({ missing: ['stage.json'], brokenImages: ['hero-run'] }); + expect(await h.loader.region('hidden-garden', 'clean', false)).toBeUndefined(); + expect(h.tracker.summary().errors.map(e => e.key)).toContain('stage:hidden-garden'); + const stageData = await h.loader.degrade('hidden-garden', 'clean', undefined); + expect(stageData.chunks[0].id).toBe('fallback'); + expect(h.textures.has('hidden-garden:missing.png')).toBe(true); + expect(h.load.atlas).toHaveBeenCalledWith('hero-run', '/assets/placeholder/hero.png', atlas); + expect(h.loader.stageData(stageData, 'clean').collisions).toEqual([]); + }); + it('rejects an atlas whose clips name missing frames', async () => { + const h = harness(); + h.json.set('atlas:hero-move', { frames: {} }); h.json.set('hero-move.anims', clips); + await h.loader.region('hidden-garden', 'clean', false); + expect(h.tracker.summary().errors.map(e => e.key)).toEqual(expect.arrayContaining(['atlas:hero-move', 'hero-move'])); + }); +}); + +describe('preload', () => { + it('takes the region from swapRegion, then the URL, then the slice start', () => { + expect(resolveTarget({ region: 'rusty-creek', state: 'clean' }, '?region=x')).toEqual({ region: 'rusty-creek', state: 'clean', first: false }); + expect(resolveTarget({}, '?region=rusty-creek')).toEqual({ region: 'rusty-creek', state: 'anomaly', first: true }); + expect(resolveTarget({}, '')).toEqual({ region: 'hidden-garden', state: 'clean', first: true }); + }); + it('writes boot-log lines by file name', () => { + const entry = { key: 'a', url: '/assets/sprites/hero-move.png', kind: 'image' as const }; + expect(logLine({ entry, status: 'done', loaded: 1, total: 1 })).toBe('LOAD hero-move.png OK'); + expect(logLine({ entry, status: 'failed', loaded: 0, total: 0, reason: 'HTTP 404' })).toBe('FAIL hero-move.png (HTTP 404)'); + expect(logLine({ entry: { ...entry, optional: true }, status: 'failed', loaded: 0, total: 0 })).toBe('SKIP hero-move.png (NONE)'); + }); +}); diff --git a/game/src/boot/loader.ts b/game/src/boot/loader.ts new file mode 100644 index 0000000..3720c8d --- /dev/null +++ b/game/src/boot/loader.ts @@ -0,0 +1,239 @@ +import type Phaser from 'phaser'; +import { BOARDS, textureKey, validateAtlas, type AnimationTable } from '../stage/assets'; +import { parseCollision, placeCollision, type CollisionChunk } from '../stage/collision'; +import { assetFile, parseManifest, stageLayers, type RegionState, type StageManifest } from '../stage/manifest'; +import type { RegionJson, StageData } from './contract'; +import { + dataKey, imageWave, jsonWave, MAP_REGIONS, mapStageKey, missingBoards, stageKey, type Entry, +} from './manifest'; +import type { LoadTracker } from './progress'; + +/** A request that has not answered in this long fails (and can be retried); nothing waits forever. */ +export const REQUEST_TIMEOUT = 20_000; +/** No byte moved for this long while files are pending: the wave gives up on them. */ +export const STALL_TIMEOUT = 25_000; +const JSON_PARALLEL = 6; + +/** fetch + JSON with a timeout; a dev server's HTML fallback for a missing file counts as missing. */ +export async function fetchJson(request: typeof fetch, url: string, timeout = REQUEST_TIMEOUT): Promise<{ data: unknown; bytes: number }> { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), timeout); + try { + let response: Response; + try { response = await request(url, { signal: abort.signal }); } + catch (error) { throw new Error(abort.signal.aborted ? 'timed out' : `network error (${(error as Error).message})`); } + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + if ((response.headers?.get('content-type') ?? '').includes('text/html') || /^\s* + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** The scene surface the loader needs – a subset of Phaser.Scene, so tests can fake it. */ +export type LoaderScene = Pick; + +/** + * The one loader. JSON goes through fetch (parallel, timed out, HTML-fallback aware) into the JSON cache; + * images and atlases through the scene's Phaser loader. Every file reports to the tracker; nothing throws + * on a missing file – failures are collected for the loading screen to retry or degrade. + */ +export class AssetLoader { + constructor(private scene: LoaderScene, readonly tracker: LoadTracker, + private request: typeof fetch = (...args) => fetch(...args)) {} + + has = (key: string) => this.scene.textures.exists(key); + + async run(entries: Entry[]): Promise { + this.tracker.plan(entries); + const json = entries.filter(e => e.kind === 'json'); + const images = entries.filter(e => e.kind !== 'json'); + await this.runJson(json); + await this.runImages(images); + } + + private async runJson(entries: Entry[]): Promise { + const queue = entries.filter(entry => { + if (!this.scene.cache.json.exists(entry.key)) return true; + this.tracker.skip(entry.key); + return false; + }); + const worker = async () => { + for (let entry = queue.shift(); entry; entry = queue.shift()) { + this.tracker.start(entry.key); + try { + const { data, bytes } = await fetchJson(this.request, entry.url); + this.scene.cache.json.add(entry.key, data); + this.tracker.done(entry.key, bytes); + } catch (error) { this.tracker.fail(entry.key, (error as Error).message); } + } + }; + await Promise.all(Array.from({ length: JSON_PARALLEL }, worker)); + } + + private runImages(entries: Entry[]): Promise { + const load = this.scene.load; + const queued = entries.filter(entry => { + if (this.has(entry.key)) { this.tracker.skip(entry.key); return false; } + if (entry.kind === 'atlas') { + const data = entry.atlasJson && this.scene.cache.json.get(entry.atlasJson); + if (!data) { this.tracker.fail(entry.key, 'no frame data'); return false; } + load.atlas(entry.key, entry.url, data); + } else load.image(entry.key, entry.url); + return true; + }); + if (!queued.length) return Promise.resolve(); + const keys = new Set(queued.map(e => e.key)); + return new Promise(resolve => { + const progress = (file: Phaser.Loader.File) => { + if (!keys.has(file.key)) return; + if (this.tracker.status(file.key) === 'queued') this.tracker.start(file.key); + this.tracker.bytes(file.key, file.bytesLoaded, file.bytesTotal); + }; + const complete = (key: string) => { if (keys.has(key) && this.has(key)) this.tracker.done(key); }; + const error = (file: Phaser.Loader.File) => { + if (keys.has(file.key)) this.tracker.fail(file.key, file.xhrLoader?.status ? `HTTP ${file.xhrLoader.status}` : 'failed to load'); + }; + let watchdog: ReturnType | undefined; + const finish = () => { + clearInterval(watchdog); + load.off('fileprogress', progress).off('filecomplete', complete).off('loaderror', error).off('complete', finish); + for (const key of keys) { + const status = this.tracker.status(key); + if (this.has(key)) { if (status !== 'done') this.tracker.done(key); } + else if (status !== 'failed') this.tracker.fail(key, 'no response'); + } + resolve(); + }; + load.on('fileprogress', progress).on('filecomplete', complete).on('loaderror', error).once('complete', finish); + // A request the browser never answers must not hold the game: give up on the wave and offer a retry. + watchdog = setInterval(() => { if (this.tracker.stalled(STALL_TIMEOUT)) { load.reset(); finish(); } }, 1000); + load.start(); + }); + } + + /** + * Load a region: wave 1 (stage.json, atmosphere, surfaces, board JSON), validate, wave 2 (collision, plates, + * board sheets). `first` adds every region's stage.json for the map and the optional menu key art. + * Returns the parsed manifest, or undefined when stage.json itself failed. + */ + async region(region: string, state: RegionState, first: boolean): Promise { + if (!/^[a-z][a-z0-9-]*$/.test(region)) throw new Error(`Invalid region id ${region}`); + const boards = missingBoards(this.has); + const [stage, ...json] = jsonWave(region, { boards, mapRegions: first ? MAP_REGIONS : [] }); + // stage.json first (a few KB): it names the plates, so the whole plan – and an honest total – is known + // before the first big file starts. + this.tracker.plan([stage, ...json]); + await this.run([stage]); + const manifest = this.manifest(region, state); + // The region's JSON (atmosphere.json names extra images) lands before wave 2 is planned. + await this.run(json); + const listed = (this.regionJson(region, 'atmosphere') as { assets?: unknown }).assets; + const assets = Array.isArray(listed) ? listed.filter((file): file is string => typeof file === 'string') : []; + const images = manifest ? imageWave(manifest, state, { boards, has: this.has, menuKey: first, assets }) : []; + this.tracker.plan(images); + const invalid = new Set(boards.filter(board => !this.checkAtlas(board))); + for (const board of invalid) this.tracker.fail(board, 'no valid atlas JSON'); + await this.run(images.filter(entry => !invalid.has(entry.key))); + return manifest; + } + + /** Validate a board's frames against its clips; a bad pair is a failure (retry refetches it). */ + private checkAtlas(board: string): boolean { + const atlas = this.scene.cache.json.get(`atlas:${board}`), clips = this.scene.cache.json.get(`${board}.anims`); + if (!atlas || !clips) return false; + try { validateAtlas(board, atlas, clips); return true; } + catch (error) { + this.scene.cache.json.remove(`atlas:${board}`); + this.scene.cache.json.remove(`${board}.anims`); + this.tracker.fail(`atlas:${board}`, (error as Error).message); + return false; + } + } + + private manifest(region: string, state: RegionState): StageManifest | undefined { + const raw = this.scene.cache.json.get(stageKey(region)); + if (raw === undefined) return undefined; + try { + const manifest = parseManifest(raw); + if (manifest.region !== region) throw new Error(`region mismatch (${manifest.region})`); + stageLayers(manifest, state); + if (!this.scene.cache.json.exists(mapStageKey(region))) this.scene.cache.json.add(mapStageKey(region), raw); + return manifest; + } catch (error) { + this.scene.cache.json.remove(stageKey(region)); + this.tracker.fail(stageKey(region), (error as Error).message); + return undefined; + } + } + + /** Collision chunks from the cache; an invalid file is reported, never thrown. */ + collisions(manifest: StageManifest, state: RegionState): CollisionChunk[] { + return manifest.chunks.flatMap(chunk => { + const file = chunk.states[state]?.collision; + if (!assetFile(file)) return []; + const key = `collision:${manifest.region}:${file}`; + const raw = this.scene.cache.json.get(key); + if (raw === undefined) return []; + try { + return [placeCollision(parseCollision(raw, chunk.id, state), [chunk.x * manifest.chunkSize[0], chunk.y * manifest.chunkSize[1]])]; + } catch (error) { + this.scene.cache.json.remove(key); + this.tracker.fail(key, (error as Error).message); + return []; + } + }); + } + + /** An optional region file as a plain object, `{}` when absent or not an object. */ + regionJson(region: string, file: 'atmosphere' | 'surfaces'): RegionJson { + const raw = this.scene.cache.json.get(dataKey(region, file)); + return plainObject(raw) ? raw : {}; + } + + /** + * Degrade after failures the player chose to (or production did) continue past: bundled placeholder atlases + * for boards, a flat plate for stage images, a one-plate stage for a missing manifest. + */ + async degrade(region: string, state: RegionState, manifest: StageManifest | undefined): Promise { + const stage = manifest ?? fallbackManifest(region, state); + for (const board of BOARDS.filter(b => !this.has(b))) { + const fallback = board.startsWith('enemy-') ? board.slice(6) : board === 'fx' || board.startsWith('dexter') ? 'dexter' + : board.startsWith('boss-jenny') ? 'jenny' : 'hero'; + try { + const { data: atlas } = await fetchJson(this.request, `/assets/placeholder/${fallback}.json`); + const { data: clips } = await fetchJson(this.request, `/assets/placeholder/${fallback}.anims.json`); + this.scene.cache.json.remove(`atlas:${board}`); this.scene.cache.json.add(`atlas:${board}`, atlas); + this.scene.cache.json.remove(`${board}.anims`); this.scene.cache.json.add(`${board}.anims`, clips); + await this.runImages([{ key: board, url: `/assets/placeholder/${fallback}.png`, kind: 'atlas', atlasJson: `atlas:${board}` }]); + } catch (error) { console.error(`[loader] no placeholder for ${board}`, error); } + } + for (const layer of stageLayers(stage, state)) { + const key = textureKey(region, layer.file); + if (this.has(key)) continue; + const texture = this.scene.textures.createCanvas(key, ...stage.chunkSize); + if (texture) { texture.context.fillStyle = '#76516f'; texture.context.fillRect(0, 0, ...stage.chunkSize); texture.refresh(); } + } + return stage; + } + + /** Everything Stage needs for this region, from the caches. */ + stageData(manifest: StageManifest, state: RegionState, spawn?: StageData['spawn']): StageData { + return { + manifest, state, spawn, + collisions: manifest.chunks[0]?.id === 'fallback' ? [] : this.collisions(manifest, state), + atmosphere: this.regionJson(manifest.region, 'atmosphere'), + surfaces: this.regionJson(manifest.region, 'surfaces'), + }; + } + + clips(board: string): AnimationTable | undefined { return this.scene.cache.json.get(`${board}.anims`); } +} + +export function fallbackManifest(region: string, state: RegionState): StageManifest { + return { region, chunkSize: [2160, 720], chunks: [{ id: 'fallback', x: 0, y: 0, + states: { [state]: { layers: [{ id: 'missing-stage', z: 5, scroll: 1, file: 'missing.png' }], collision: null } } }] }; +} diff --git a/game/src/boot/manifest.ts b/game/src/boot/manifest.ts new file mode 100644 index 0000000..0f32fe2 --- /dev/null +++ b/game/src/boot/manifest.ts @@ -0,0 +1,85 @@ +import { BOARDS, OPTIONAL_BOARDS, stageBase, textureKey } from '../stage/assets'; +import { assetFile, stageLayers, type RegionState, type StageManifest } from '../stage/manifest'; +import { REGION_ORDER } from '../ui/mapModel'; + +/** + * The asset manifest: every file the game loads, in one place. Boot and Preload are the only scenes that load; + * story, UI and FX scenes read what is already in the texture and JSON caches. + * + * Files are planned in two waves – JSON first (the stage manifest names the plates and collision files), then + * images – and each entry says whether the game can run without it (`optional`: missing → `{}` or nothing). + */ +export type EntryKind = 'json' | 'image' | 'atlas'; +export interface Entry { + /** Unique id; for images and atlases also the Phaser texture key, for JSON the JSON-cache key. */ + key: string; + url: string; + kind: EntryKind; + optional?: boolean; + /** Atlas only: the URL of the frame JSON (already fetched in wave 1, cache key `atlas:`). */ + atlasJson?: string; +} + +/** The approved UI atlas and its layout (#127) – Boot loads them so the loading screen can use them. */ +export const UI_ATLAS_KEY = 'ui'; +export const UI_LAYOUT_KEY = 'ui-layout'; +/** Optional menu key art (workstream 5). Without it the menu shows the live garden only. */ +export const MENU_KEY = 'menu-key'; + +export const bootEntries = (): Entry[] => [ + { key: 'atlas:ui', url: '/assets/ui/ui.json', kind: 'json', optional: true }, + { key: UI_LAYOUT_KEY, url: '/assets/ui/ui-layout.json', kind: 'json', optional: true }, +]; +export const uiAtlasEntry = (): Entry => + ({ key: UI_ATLAS_KEY, url: '/assets/ui/ui.png', kind: 'atlas', optional: true, atlasJson: 'atlas:ui' }); + +/** Per-region data files beside stage.json; the two optional ones belong to workstreams 2 and 3. */ +export const REGION_DATA = { atmosphere: 'atmosphere.json', surfaces: 'surfaces.json' } as const; +export const stageKey = (region: string) => `stage:${region}`; +export const dataKey = (region: string, file: keyof typeof REGION_DATA) => `${file}:${region}`; +/** PocketComputer reads every region's stage.json from these cache keys (its own preload then has nothing to do). */ +export const mapStageKey = (region: string) => `map-stage-${region}`; + +/** Wave 1 for a region: its stage manifest, the optional data files, and (first load) every board's JSON. */ +export function jsonWave(region: string, opts: { boards: readonly string[]; mapRegions: readonly string[] }): Entry[] { + const base = stageBase(region); + return [ + { key: stageKey(region), url: `${base}stage.json`, kind: 'json' }, + { key: dataKey(region, 'atmosphere'), url: base + REGION_DATA.atmosphere, kind: 'json', optional: true }, + { key: dataKey(region, 'surfaces'), url: base + REGION_DATA.surfaces, kind: 'json', optional: true }, + ...opts.mapRegions.filter(r => r !== region).map(r => ({ key: mapStageKey(r), url: `${stageBase(r)}stage.json`, kind: 'json' as const, optional: true })), + ...opts.boards.flatMap(board => [ + { key: `atlas:${board}`, url: `/assets/sprites/${board}.json`, kind: 'json' as const, optional: isOptional(board) }, + { key: `${board}.anims`, url: `/assets/sprites/${board}.anims.json`, kind: 'json' as const, optional: isOptional(board) }, + ]), + ]; +} + +/** + * Wave 2 for a region: collision files, every stage plate not yet in the texture cache, the region's extra images + * (`atmosphere.json` `assets`, e.g. the pond's wrong reflection – optional), and the boards' sheets. + */ +export function imageWave(manifest: StageManifest, state: RegionState, opts: { + boards: readonly string[]; has: (key: string) => boolean; menuKey?: boolean; assets?: readonly string[]; +}): Entry[] { + const region = manifest.region, base = stageBase(region); + const collisions = manifest.chunks.flatMap(chunk => { + const file = chunk.states[state]?.collision; + return assetFile(file) ? [{ key: `collision:${region}:${file}`, url: base + file, kind: 'json' as const }] : []; + }); + const plates = [...new Set(stageLayers(manifest, state).map(layer => layer.file))] + .filter(file => !opts.has(textureKey(region, file))) + .map(file => ({ key: textureKey(region, file), url: base + file, kind: 'image' as const })); + const extras = [...new Set(opts.assets ?? [])].filter(file => assetFile(file) && !opts.has(textureKey(region, file))) + .map(file => ({ key: textureKey(region, file), url: base + file, kind: 'image' as const, optional: true })); + const boards = opts.boards.filter(board => !opts.has(board)) + .map(board => ({ key: board, url: `/assets/sprites/${board}.png`, kind: 'atlas' as const, atlasJson: `atlas:${board}`, optional: isOptional(board) })); + const menu = opts.menuKey && !opts.has(MENU_KEY) + ? [{ key: MENU_KEY, url: `/assets/ui/${MENU_KEY}.png`, kind: 'image' as const, optional: true }] : []; + return [...collisions, ...plates, ...extras, ...boards, ...menu]; +} + +/** The boards still missing from the texture cache (a region swap reloads none of them). */ +export const missingBoards = (has: (key: string) => boolean) => [...BOARDS, ...OPTIONAL_BOARDS].filter(board => !has(board)); +const isOptional = (board: string) => OPTIONAL_BOARDS.includes(board); +export const MAP_REGIONS: readonly string[] = REGION_ORDER; diff --git a/game/src/boot/pixelFont.ts b/game/src/boot/pixelFont.ts new file mode 100644 index 0000000..0a6598a --- /dev/null +++ b/game/src/boot/pixelFont.ts @@ -0,0 +1,74 @@ +import Phaser from 'phaser'; + +/** + * The game's own 5×7 pixel font, drawn in code at boot: no font file to fetch, and every glyph lands on whole + * pixels at any integer scale (`bitmapText(...).setScale(n)`). Upper case only; `pixelText` upper-cases for you. + */ +export const PIXEL_FONT = 'pixel'; +export const GLYPH = { w: 6, h: 8 } as const; + +const G: Record = { + A: '.###.|#...#|#...#|#####|#...#|#...#|#...#', B: '####.|#...#|#...#|####.|#...#|#...#|####.', + C: '.###.|#...#|#....|#....|#....|#...#|.###.', D: '####.|#...#|#...#|#...#|#...#|#...#|####.', + E: '#####|#....|#....|####.|#....|#....|#####', F: '#####|#....|#....|####.|#....|#....|#....', + G: '.###.|#...#|#....|#.###|#...#|#...#|.####', H: '#...#|#...#|#...#|#####|#...#|#...#|#...#', + I: '.###.|..#..|..#..|..#..|..#..|..#..|.###.', J: '..###|...#.|...#.|...#.|...#.|#..#.|.##..', + K: '#...#|#..#.|#.#..|##...|#.#..|#..#.|#...#', L: '#....|#....|#....|#....|#....|#....|#####', + M: '#...#|##.##|#.#.#|#.#.#|#...#|#...#|#...#', N: '#...#|#...#|##..#|#.#.#|#..##|#...#|#...#', + O: '.###.|#...#|#...#|#...#|#...#|#...#|.###.', P: '####.|#...#|#...#|####.|#....|#....|#....', + Q: '.###.|#...#|#...#|#...#|#.#.#|#..#.|.##.#', R: '####.|#...#|#...#|####.|#.#..|#..#.|#...#', + S: '.####|#....|#....|.###.|....#|....#|####.', T: '#####|..#..|..#..|..#..|..#..|..#..|..#..', + U: '#...#|#...#|#...#|#...#|#...#|#...#|.###.', V: '#...#|#...#|#...#|#...#|#...#|.#.#.|..#..', + W: '#...#|#...#|#...#|#.#.#|#.#.#|#.#.#|.#.#.', X: '#...#|#...#|.#.#.|..#..|.#.#.|#...#|#...#', + Y: '#...#|#...#|.#.#.|..#..|..#..|..#..|..#..', Z: '#####|....#|...#.|..#..|.#...|#....|#####', + 0: '.###.|#...#|#..##|#.#.#|##..#|#...#|.###.', 1: '..#..|.##..|..#..|..#..|..#..|..#..|.###.', + 2: '.###.|#...#|....#|...#.|..#..|.#...|#####', 3: '#####|...#.|..#..|...#.|....#|#...#|.###.', + 4: '...#.|..##.|.#.#.|#..#.|#####|...#.|...#.', 5: '#####|#....|####.|....#|....#|#...#|.###.', + 6: '..##.|.#...|#....|####.|#...#|#...#|.###.', 7: '#####|....#|...#.|..#..|.#...|.#...|.#...', + 8: '.###.|#...#|#...#|.###.|#...#|#...#|.###.', 9: '.###.|#...#|#...#|.####|....#|...#.|.##..', + ' ': '.....|.....|.....|.....|.....|.....|.....', '.': '.....|.....|.....|.....|.....|.....|..#..', + ',': '.....|.....|.....|.....|.....|..#..|.#...', ':': '.....|..#..|.....|.....|.....|..#..|.....', + '!': '..#..|..#..|..#..|..#..|..#..|.....|..#..', '?': '.###.|#...#|....#|...#.|..#..|.....|..#..', + '-': '.....|.....|.....|.###.|.....|.....|.....', _: '.....|.....|.....|.....|.....|.....|#####', + '/': '....#|....#|...#.|..#..|.#...|#....|#....', '\\': '#....|#....|.#...|..#..|...#.|....#|....#', '%': '##..#|##..#|...#.|..#..|.#...|#..##|#..##', + '(': '...#.|..#..|.#...|.#...|.#...|..#..|...#.', ')': '.#...|..#..|...#.|...#.|...#.|..#..|.#...', + '[': '.###.|.#...|.#...|.#...|.#...|.#...|.###.', ']': '.###.|...#.|...#.|...#.|...#.|...#.|.###.', + '<': '...#.|..#..|.#...|#....|.#...|..#..|...#.', '>': '.#...|..#..|...#.|....#|...#.|..#..|.#...', + "'": '..#..|..#..|.....|.....|.....|.....|.....', '"': '.#.#.|.#.#.|.....|.....|.....|.....|.....', + '+': '.....|..#..|..#..|#####|..#..|..#..|.....', '=': '.....|.....|#####|.....|#####|.....|.....', + '#': '.#.#.|.#.#.|#####|.#.#.|#####|.#.#.|.#.#.', '*': '.....|#.#.#|.###.|#####|.###.|#.#.#|.....', + '|': '..#..|..#..|..#..|..#..|..#..|..#..|..#..', '~': '#####|#####|#####|#####|#####|#####|#####', +}; +/** Every character the font draws; `~` is a solid block (the cursor). */ +export const PIXEL_CHARS = Object.keys(G).join(''); + +/** Map any text onto the font: upper case, en/em dashes to `-`, anything unknown to `?`. */ +export function pixelSafe(text: string): string { + return [...text.toUpperCase().replace(/[–—]/g, '-').replace(/…/g, '...')] + .map(ch => ch === '\n' || G[ch] ? ch : '?').join(''); +} + +/** Build the font texture and register it once per game (Boot calls this before anything draws text). */ +export function registerPixelFont(scene: Phaser.Scene): void { + if (scene.cache.bitmapFont.exists(PIXEL_FONT)) return; + const key = `${PIXEL_FONT}-glyphs`; + if (!scene.textures.exists(key)) { + const canvas = scene.textures.createCanvas(key, PIXEL_CHARS.length * GLYPH.w, GLYPH.h); + if (!canvas) return; + const ctx = canvas.context; + ctx.fillStyle = '#ffffff'; + [...PIXEL_CHARS].forEach((ch, i) => G[ch].split('|').forEach((row, y) => [...row].forEach((bit, x) => { + if (bit === '#') ctx.fillRect(i * GLYPH.w + x, y, 1, 1); + }))); + canvas.refresh(); + } + scene.cache.bitmapFont.add(PIXEL_FONT, Phaser.GameObjects.RetroFont.Parse(scene, { + image: key, width: GLYPH.w, height: GLYPH.h, chars: PIXEL_CHARS, charsPerRow: PIXEL_CHARS.length, + 'spacing.x': 0, 'spacing.y': 0, 'offset.x': 0, 'offset.y': 0, lineSpacing: 2, + })); +} + +/** Pixel text at an integer scale, tinted. `scale` 2 → 12 px per character. */ +export function pixelText(scene: Phaser.Scene, x: number, y: number, text: string, scale = 2, tint = 0xe8e4dc) { + return scene.add.bitmapText(x, y, PIXEL_FONT, pixelSafe(text)).setScale(Math.max(1, Math.round(scale))).setTint(tint); +} diff --git a/game/src/boot/progress.test.ts b/game/src/boot/progress.test.ts new file mode 100644 index 0000000..7784878 --- /dev/null +++ b/game/src/boot/progress.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { Entry } from './manifest'; +import { formatBytes, IMAGE_WEIGHT, LoadTracker } from './progress'; + +const json = (key: string, optional = false): Entry => ({ key, url: `/assets/${key}.json`, kind: 'json', optional }); +const image = (key: string): Entry => ({ key, url: `/assets/${key}.png`, kind: 'image' }); + +describe('load tracker', () => { + it('weights images over JSON and counts bytes of files in flight', () => { + const t = new LoadTracker(() => 0); + t.plan([json('a'), image('b')]); + t.done('a', 100); + expect(t.summary().fraction).toBeCloseTo(1 / (1 + IMAGE_WEIGHT)); + t.start('b'); t.bytes('b', 50, 100); + expect(t.summary().fraction).toBeCloseTo((1 + IMAGE_WEIGHT / 2) / (1 + IMAGE_WEIGHT)); + t.done('b'); + expect(t.summary()).toMatchObject({ files: 2, done: 2, bytes: 200, fraction: 1 }); + }); + it('never moves the bar backwards when more files are planned', () => { + const t = new LoadTracker(() => 0); + t.plan([json('stage')]); t.done('stage'); + expect(t.summary().fraction).toBe(1); + t.plan([image('plate')]); + expect(t.summary().fraction).toBe(1); + expect(t.summary().done).toBe(1); + }); + it('separates required errors from optional missing files', () => { + const t = new LoadTracker(() => 0); + t.plan([json('stage'), json('atmosphere', true)]); + t.fail('stage', 'HTTP 404'); t.fail('atmosphere', 'HTTP 404'); + const s = t.summary(); + expect(s.errors).toEqual([{ key: 'stage', url: '/assets/stage.json', reason: 'HTTP 404', optional: false }]); + expect(s.missing.map(f => f.key)).toEqual(['atmosphere']); + }); + it('a retry re-plans failed files but keeps finished ones', () => { + const t = new LoadTracker(() => 0); + t.plan([json('a'), json('b')]); + t.done('a'); t.fail('b', 'timed out'); + t.plan([json('a'), json('b')]); + expect(t.status('a')).toBe('done'); + expect(t.status('b')).toBe('queued'); + expect(t.summary().errors).toEqual([]); + }); + it('reports a stall only while files are pending', () => { + let now = 0; + const t = new LoadTracker(() => now); + t.plan([json('a')]); + now = 30_000; + expect(t.stalled(25_000)).toBe(true); + t.done('a'); + now = 90_000; + expect(t.stalled(25_000)).toBe(false); + }); + it('tells the boot log when a file changes status', () => { + const t = new LoadTracker(() => 0), seen: string[] = []; + t.onChange = f => seen.push(`${f.entry.key}:${f.status}`); + t.plan([json('a')]); t.start('a'); t.bytes('a', 1, 2); t.done('a'); + expect(seen).toEqual(['a:loading', 'a:done']); + }); + it('formats bytes', () => { + expect(formatBytes(512 * 1024)).toBe('512 KB'); + expect(formatBytes(25 * 1024 * 1024)).toBe('25.0 MB'); + }); +}); diff --git a/game/src/boot/progress.ts b/game/src/boot/progress.ts new file mode 100644 index 0000000..14a0b91 --- /dev/null +++ b/game/src/boot/progress.ts @@ -0,0 +1,102 @@ +import type { Entry } from './manifest'; + +/** An image or atlas sheet counts this many JSON files in the progress fraction. */ +export const IMAGE_WEIGHT = 8; +export type FileStatus = 'queued' | 'loading' | 'done' | 'failed' | 'skipped'; +export interface FileState { entry: Entry; status: FileStatus; loaded: number; total: number; reason?: string } +export interface Failure { key: string; url: string; reason: string; optional: boolean } +export interface Summary { + files: number; done: number; failed: number; bytes: number; + /** 0–1, by files with a byte-weighted share for files in flight; never goes backwards. */ + fraction: number; + /** Required files that failed: the screen offers Retry / Continue for these. */ + errors: Failure[]; + /** Optional files that were missing: the game runs without them. */ + missing: Failure[]; + /** The most recent file that changed, for the boot log. */ + last?: FileState; +} + +/** + * Pure bookkeeping for the loading screen: which files are planned, in flight, done or failed, how many bytes + * arrived, and when anything last moved (the stall watchdog). No Phaser, so it is unit-tested. + */ +export class LoadTracker { + private files = new Map(); + private shown = 0; + private last?: FileState; + lastActivity: number; + /** Called whenever a file changes status or receives bytes (the loading screen's boot log). */ + onChange?: (file: FileState) => void; + + constructor(private now: () => number = () => performance.now()) { this.lastActivity = now(); } + + plan(entries: Entry[]): void { + for (const entry of entries) { + const known = this.files.get(entry.key); + // A retry re-plans failed files; finished ones stay finished. + if (!known || known.status === 'failed') this.files.set(entry.key, { entry, status: 'queued', loaded: 0, total: 0 }); + } + this.touch(); + } + + start(key: string): void { this.set(key, { status: 'loading' }); } + bytes(key: string, loaded: number, total: number): void { this.set(key, { loaded, total: Math.max(total, loaded) }); } + done(key: string, bytes?: number): void { + const file = this.files.get(key); + const size = bytes ?? Math.max(file?.loaded ?? 0, file?.total ?? 0); + this.set(key, { status: 'done', loaded: size, total: size }); + } + fail(key: string, reason: string): void { this.set(key, { status: 'failed', reason }); } + /** Already cached (a region swap, a retry): counts as done without a request. */ + skip(key: string): void { this.set(key, { status: 'skipped' }); } + + status(key: string): FileStatus | undefined { return this.files.get(key)?.status; } + + /** True when nothing has moved for `ms` while files are still queued or in flight. */ + stalled(ms: number): boolean { + const pending = [...this.files.values()].some(f => f.status === 'queued' || f.status === 'loading'); + return pending && this.now() - this.lastActivity > ms; + } + + summary(): Summary { + const all = [...this.files.values()]; + const finished = (f: FileState) => f.status === 'done' || f.status === 'skipped' || f.status === 'failed'; + const doneCount = all.filter(finished).length; + // Images weigh more than JSON (they are most of the bytes); a file in flight counts by its bytes so far. + const weight = (f: FileState) => f.entry.kind === 'json' ? 1 : IMAGE_WEIGHT; + const total = all.reduce((sum, f) => sum + weight(f), 0); + const reached = all.reduce((sum, f) => sum + weight(f) * (finished(f) ? 1 + : f.status === 'loading' && f.total > 0 ? Math.min(1, f.loaded / f.total) : 0), 0); + const raw = total ? Math.min(1, reached / total) : 0; + this.shown = Math.max(this.shown, raw); + const failure = (f: FileState): Failure => ({ key: f.entry.key, url: f.entry.url, reason: f.reason ?? 'failed', optional: !!f.entry.optional }); + const failed = all.filter(f => f.status === 'failed'); + return { + files: all.length, done: doneCount, failed: failed.length, + bytes: all.reduce((sum, f) => sum + f.loaded, 0), + fraction: this.shown, + errors: failed.filter(f => !f.entry.optional).map(failure), + missing: failed.filter(f => f.entry.optional).map(failure), + last: this.last, + }; + } + + private set(key: string, patch: Partial): void { + const file = this.files.get(key); + if (!file) return; + const before = file.status; + Object.assign(file, patch); + this.last = file; + this.touch(); + if (file.status !== before) this.onChange?.(file); + } + + private touch(): void { this.lastActivity = this.now(); } +} + +/** "4.2 MB", "380 KB" – for the loading screen. */ +export function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${Math.round(bytes / 1024)} KB`; +} diff --git a/game/src/boot/target.ts b/game/src/boot/target.ts new file mode 100644 index 0000000..d39c96e --- /dev/null +++ b/game/src/boot/target.ts @@ -0,0 +1,22 @@ +import type { RegionState } from '../stage/manifest'; +import { START } from '../stage/swap'; +import type { PreloadData } from './contract'; +import type { FileState } from './progress'; + +/** Resolve the region to load: swapRegion's data first, then `?region=…&state=…`, then the slice start. */ +export function resolveTarget(data: PreloadData, search: string): { region: string; state: RegionState; first: boolean } { + const query = new URLSearchParams(search); + const first = !data.region; + 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'; + return { region, state, first }; +} + +/** The boot-log line for a file that finished or failed. */ +export function logLine(file: FileState): string | undefined { + const path = file.entry.url.split('/').pop() ?? file.entry.url; + if (file.status === 'done') return `LOAD ${path} OK`; + if (file.status === 'failed') return file.entry.optional ? `SKIP ${path} (NONE)` : `FAIL ${path} (${file.reason})`; + return undefined; +} diff --git a/game/src/combat/chair.ts b/game/src/combat/chair.ts index de4ba90..a6a67ad 100644 --- a/game/src/combat/chair.ts +++ b/game/src/combat/chair.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { carriesChair } from './chairPickup'; import type { Player } from '../player/player'; import { sound } from '../fx/sound/Audio'; import { HitStop, Sweeps, SWEEP_CONTACT, type Damageable, type Rect } from './sweeps'; @@ -28,8 +29,9 @@ export class ChairCombat { private label: Phaser.GameObjects.Text; private visible = false; enabled: boolean; - constructor(private scene: Phaser.Scene, private player: Player, region: string) { - this.enabled = region === 'rusty-creek'; + constructor(private scene: Phaser.Scene, private player: Player, _region: string) { + // Armed once the hero picked the chair up (chairPickup.ts), in every region after that. + this.enabled = carriesChair(scene); this.key = scene.input.keyboard!.addKey('J'); this.directions = scene.input.keyboard!.addKeys('W,S,UP,DOWN') as Record; this.previousY = player.body.y; diff --git a/game/src/combat/chairPickup.test.ts b/game/src/combat/chairPickup.test.ts new file mode 100644 index 0000000..d6ae8d1 --- /dev/null +++ b/game/src/combat/chairPickup.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +vi.mock('phaser', () => ({ default: {} })); +vi.mock('../ui/Hud', () => ({ drawControlGlyph: () => undefined })); +vi.mock('../fx/sound/Audio', () => ({ sound: { play: () => undefined } })); +import clips from '../../public/assets/sprites/hero-hurt.anims.json'; +import { Interaction } from '../player/interaction'; +import { PickupMoment, SHOW_SECONDS } from './chairPickup'; + +describe('the chair pick-up moment (P7)', () => { + const clip = clips['pick-up']; + const seconds = clip.frames / clip.fps; + + it('plays the four pick-up poses, not the drink clip', () => { + const action = new Interaction(); action.begin(clip, 'pick-up'); + const frames = [0, 1, 2, 3].map(() => action.step(1 / 12)?.frame); + expect(frames).toEqual(['pick-up-00', 'pick-up-01', 'pick-up-02', 'pick-up-03']); + expect(action.active).toBe(false); + }); + + it('picks, shows the chair, then hints until the first swing – and never before', () => { + const moment = new PickupMoment(seconds); + expect(moment.step(1, true)).toBe('waiting'); + moment.begin(); + expect(moment.step(seconds / 2, true)).toBe('picking'); // a swing during the clip does not skip the hint + expect(moment.step(seconds / 2, false)).toBe('showing'); + expect(moment.step(SHOW_SECONDS, false)).toBe('hinting'); + expect(moment.step(5, false)).toBe('hinting'); // the glyph waits as long as it takes + expect(moment.step(0.016, true)).toBe('done'); + }); +}); diff --git a/game/src/combat/chairPickup.ts b/game/src/combat/chairPickup.ts new file mode 100644 index 0000000..66515fb --- /dev/null +++ b/game/src/combat/chairPickup.ts @@ -0,0 +1,161 @@ +import Phaser from 'phaser'; +import { gameEvents } from '../core/events'; +import { sound } from '../fx/sound/Audio'; +import type { Player } from '../player/player'; +import type { Box, CollisionChunk } from '../stage/collision'; +import { drawControlGlyph } from '../ui/Hud'; +import type { ChairCombat } from './chair'; + +/** Game-wide flag (Phaser's registry outlives the stage): the hero carries the chair from here on. */ +export const CARRYING_CHAIR = 'carrying-chair'; +/** Game-wide flag: the hero has swung the chair once, so the control glyph never shows again. */ +export const SWUNG_CHAIR = 'swung-chair'; +/** Dev URLs that start mid-level and need the chair in hand. */ +const TESTS = ['combat-test', 'rover-test', 'coffee-test', 'boss-test']; +/** After the pick-up clip the hero holds the chair up in its ready pose (chair-side sweep-a-00) this long. */ +export const SHOW_SECONDS = 0.6; +/** The glyph floats this far above the hero's feet (the body is 76 px tall). */ +const GLYPH_LIFT = 100; +/** How far from the box the hero may still be when a jump into it lands (px). */ +const REACH = 24; +/** The carried chair on the hero's back: the centre of the drawn chair in its 86 x 81 frame, and where it hangs + * behind the hero's feet anchor (px), turned so the seat sits behind the backpack and the legs rise over the shoulder. */ +const BACK = { originX: 54 / 86, originY: 66 / 81, x: -10, y: -52, angle: -105, scale: 0.65 }; + +export function carriesChair(scene: Phaser.Scene): boolean { + if (import.meta.env.DEV && TESTS.some(test => new URLSearchParams(location.search).has(test))) return true; + return scene.registry.get(CARRYING_CHAIR) === true; +} + +/** The pick-up moment as pure state (P7): waiting → picking (the clip) → showing (chair held up) → hinting → done. */ +export type PickupPhase = 'waiting' | 'picking' | 'showing' | 'hinting' | 'done'; +export class PickupMoment { + phase: PickupPhase = 'waiting'; + elapsed = 0; + constructor(private clipSeconds: number) {} + /** The hero reached the chair and the clip started. */ + begin(): void { if (this.phase === 'waiting') { this.phase = 'picking'; this.elapsed = 0; } } + /** `swung`: the hero swung the chair this frame (any sweep or overhead). */ + step(seconds: number, swung: boolean): PickupPhase { + if (this.phase === 'waiting' || this.phase === 'done') return this.phase; + this.elapsed += seconds; + if (this.phase === 'picking' && this.elapsed >= this.clipSeconds) { this.phase = 'showing'; this.elapsed = 0; } + else if (this.phase === 'showing' && this.elapsed >= SHOW_SECONDS) { this.phase = 'hinting'; this.elapsed = 0; } + if (this.phase === 'hinting' && swung) this.phase = 'done'; + return this.phase; + } +} + +/** + * The plywood chair lying by the path (docs/03 beat 3, #156, P7): drawn from the approved `chair-pickup` frame at + * every `pickup` box whose item is `chair`, with a glint so you see it. Walking into it is a moment: the hero plays + * the `pick-up` clip, holds the chair up, and a wordless J / X glyph floats over him until the first swing. + */ +export class ChairPickup { + private sprites = new Map(); + private glints: Phaser.GameObjects.GameObject[] = []; + private moment?: PickupMoment; + private glyph?: Phaser.GameObjects.Container; + private pending?: Box; + /** The chair the hero carries between swings; the hero has no carrying frames, so it rides on his back. */ + private back?: Phaser.GameObjects.Image; + + constructor(private scene: Phaser.Scene, private player: Player, private chair: ChairCombat, chunks: CollisionChunk[]) { + const update = (_time: number, delta: number) => { this.update(delta); this.carry(); }; + scene.events.on('postupdate', update); + scene.events.once('shutdown', () => scene.events.off('postupdate', update)); + if (carriesChair(scene)) { + // Picked up earlier but never swung (a region swap in between): the hint is still owed. + if (scene.registry.get(CARRYING_CHAIR) === true && scene.registry.get(SWUNG_CHAIR) !== true) this.hint(); + return; + } + const boxes = chunks.flatMap(c => c.world.interact).filter(b => b.kind === 'pickup' && b.item === 'chair'); + for (const box of boxes) { + const x = box.x + box.w / 2, y = box.y + box.h; + this.sprites.set(box, scene.add.sprite(x, y, 'chair-side', 'chair-pickup-00').setOrigin(0.5, 1).setDepth(99)); + this.glints.push(...glint(scene, x, y - 40)); + } + const off = gameEvents.on('trigger-entered', ({ id }) => { + const box = [...this.sprites.keys()].find(b => b.id === id); + if (box) this.pending = box; + }); + scene.events.once('shutdown', off); + } + + private take(box: Box): void { + if (!this.player.beginInteraction('pick-up')) return; + this.pending = undefined; + const clip = this.scene.cache.json.get('hero-hurt.anims')?.['pick-up'] ?? { frames: 4, fps: 12 }; + this.moment = new PickupMoment(clip.frames / clip.fps); + this.moment.begin(); + const sprite = this.sprites.get(box); + // The chair rises into the hero's hands with the clip, then the hero holds it. + if (sprite) this.scene.tweens.add({ targets: sprite, x: this.player.body.x, y: sprite.y - 30, alpha: 0, + duration: (clip.frames / clip.fps) * 1000, ease: 'Quad.easeOut', onComplete: () => sprite.destroy() }); + this.sprites.clear(); + for (const item of this.glints) item.destroy(); + this.glints = []; + this.scene.registry.set(CARRYING_CHAIR, true); + sound.play('chair-hit'); + } + + /** Keeps the carried chair on the hero's back, behind him, hidden while a chair pose (swing, guard, slam) or the + * pick-up holds it in his hands. */ + private carry(): void { + if (!carriesChair(this.scene)) return; + const hero = this.player.sprite; + this.back ??= this.scene.add.image(hero.x, hero.y, 'chair-side', 'chair-pickup-00') + .setOrigin(BACK.originX, BACK.originY).setScale(BACK.scale); + const facing = this.player.facing < 0 ? -1 : 1; + const inHands = hero.texture.key.startsWith('chair-') || hero.texture.key === 'hero-hurt' && hero.frame.name.startsWith('pick-up') + || this.moment?.phase === 'picking' || this.moment?.phase === 'showing'; + this.back.setVisible(hero.visible && !inHands).setAlpha(hero.alpha).setDepth(hero.depth - 0.5) + .setPosition(Math.round(hero.x + BACK.x * facing), Math.round(hero.y + BACK.y)) + .setFlipX(facing < 0).setAngle(BACK.angle * facing); + } + + private hint(): void { + this.moment = new PickupMoment(0); + this.moment.phase = 'hinting'; + } + + private update(delta: number): void { + const body = this.player.body; + if (this.pending && body.grounded && Math.abs(body.x - (this.pending.x + this.pending.w / 2)) <= this.pending.w / 2 + REACH) { + this.take(this.pending); + } + const moment = this.moment; + if (!moment) return; + const swung = this.chair.sweeps.phase !== 'idle' || this.chair.overhead.phase !== 'idle'; + const phase = moment.step(Math.min(100, delta) / 1000, swung); + if (phase === 'showing') { + // Held up, ready: the first sweep pose. The chair arms when the show ends, so the J that dismisses the hint + // is the first real swing. + this.player.sprite.setTexture('chair-side', 'sweep-a-00').setFlipX(this.player.facing < 0); + } + if (phase === 'hinting' || phase === 'done') this.chair.enabled = true; + if (phase === 'hinting') { + this.glyph ??= drawControlGlyph(this.scene, body.x, body.y - GLYPH_LIFT).setDepth(10002); + const bob = Math.round(Math.sin(this.scene.time.now / 180) * 3); + this.glyph.setPosition(Math.round(body.x), Math.round(body.y - GLYPH_LIFT + bob)); + } + if (phase === 'done') { + this.scene.registry.set(SWUNG_CHAIR, true); + this.glyph?.destroy(); + this.glyph = undefined; + this.moment = undefined; + } + } +} + +/** A slow sparkle and a soft pulse over the chair, so it reads as something to take. */ +function glint(scene: Phaser.Scene, x: number, y: number): Phaser.GameObjects.GameObject[] { + const halo = scene.add.ellipse(x, y + 22, 70, 34, 0xfff4cb, 0.18).setDepth(98).setBlendMode(Phaser.BlendModes.ADD); + scene.tweens.add({ targets: halo, alpha: 0.05, scaleX: 1.15, scaleY: 1.15, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + const star = scene.add.graphics({ x, y }).setDepth(100).setBlendMode(Phaser.BlendModes.ADD); + star.fillStyle(0xffffff).fillRect(-1, -7, 2, 14).fillRect(-7, -1, 14, 2).fillStyle(0xfff4cb).fillRect(-2, -2, 4, 4); + star.setScale(0); + scene.tweens.add({ targets: star, scale: 1, angle: 90, duration: 260, yoyo: true, repeat: -1, repeatDelay: 1100, + onRepeat: () => star.setPosition(x + Phaser.Math.Between(-16, 16), y + Phaser.Math.Between(-6, 12)) }); + return [halo, star]; +} diff --git a/game/src/combat/coffee.test.ts b/game/src/combat/coffee.test.ts index 7c015b4..4bd0b5f 100644 --- a/game/src/combat/coffee.test.ts +++ b/game/src/combat/coffee.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CoffeeUse, coffeeAlpha } from './coffee'; +import { CoffeeUse, coffeeFrame } from './coffee'; import { Life } from './life'; import { parseCollision, placeCollision } from '../stage/collision'; import type { Body } from '../player/body'; @@ -10,10 +10,9 @@ const chunk = placeCollision(parseCollision(raw, 'rc-1-0', 'anomaly'), [2160, 0] const machines = chunk.world.interact; const body = (): Body => ({ x: 3950, y: 537, vx: 0, vy: 0, grounded: true }); describe('coffee save interaction', () => { - it('flickers for one 12 fps frame every two seconds while idle', () => { - expect(coffeeAlpha(0)).toBe(0.65); expect(coffeeAlpha(82)).toBe(0.65); - expect(coffeeAlpha(84)).toBe(1); expect(coffeeAlpha(1999)).toBe(1); - expect(coffeeAlpha(2000)).toBe(0.65); expect(coffeeAlpha(2084)).toBe(1); + it('glows steadily, flickers one frame every two seconds, and every fourth flicker shows the pond', () => { + expect(coffeeFrame(0, 12)).toBe(1); expect(coffeeFrame(84, 12)).toBe(0); expect(coffeeFrame(1999, 12)).toBe(0); + expect(coffeeFrame(2000, 12)).toBe(1); expect(coffeeFrame(4000, 12)).toBe(1); expect(coffeeFrame(6000, 12)).toBe(2); }); it('uses the authored 40×80 machine at the translated walk surface and the approved drink timing', () => { expect(machines.find(box => box.kind === 'coffee-machine')).toMatchObject({ id: 'coffee-1', x: 3930, y: 457, w: 40, h: 80 }); diff --git a/game/src/combat/coffee.ts b/game/src/combat/coffee.ts index 4ecc581..fe78796 100644 --- a/game/src/combat/coffee.ts +++ b/game/src/combat/coffee.ts @@ -3,8 +3,17 @@ import type { Box, CollisionChunk } from '../stage/collision'; import { overlaps, type Body } from '../player/body'; import type { Player } from '../player/player'; -// docs/02 placeholder timing: one authored 12 fps frame dimmed every two seconds. -export const coffeeAlpha = (milliseconds: number) => Math.floor(milliseconds / (1000 / 12)) % 24 === 0 ? 0.65 : 1; +/** + * The approved machine's idle clip (coffee-machine.anims.json: steady, flicker, the pond on the screen). It glows + * steadily; every 2 s the screen flickers for one frame, and every fourth flicker shows the pond instead – the + * Hidden Garden's anomaly leaking through (docs/03). `fps` comes from the clip. + */ +export function coffeeFrame(milliseconds: number, fps: number): number { + const frame = Math.floor(milliseconds / (1000 / fps)); + const period = 2 * fps; + if (frame % period !== 0) return 0; + return (frame / period) % 4 === 3 ? 2 : 1; +} /** Up is edge-triggered, including when it was already held on entering range. */ export class CoffeeUse { @@ -21,17 +30,21 @@ export class CoffeePoints { private keys; private actors: Phaser.GameObjects.Sprite[]; private machines: Box[]; + private fps: number; constructor(private scene: Phaser.Scene, private player: Player, chunks: CollisionChunk[]) { this.machines = chunks.flatMap(chunk => chunk.world.interact).filter(box => box.kind === 'coffee-machine'); this.keys = scene.input.keyboard!.addKeys('W,UP,J') as Record; + // The approved art is drawn at the box's measured size (42 × 80 for a 40 × 80 box), so it is never stretched. this.actors = this.machines.map(box => scene.add.sprite(box.x + box.w / 2, box.y + box.h, - 'coffee-placeholder', 'idle-00').setOrigin(0.5, 1).setDisplaySize(box.w, box.h).setDepth(99)); + 'coffee-machine', 'idle-00').setOrigin(0.5, 1).setDepth(99)); + this.fps = (scene.cache.json.get('coffee-machine.anims') as { idle?: { fps: number } } | undefined)?.idle?.fps ?? 12; } update() { const pad = this.scene.input.gamepad?.getAll().find(p => p.connected); const up = this.keys.W.isDown || this.keys.UP.isDown || !!pad?.up || (pad?.leftStick.y ?? 0) < -0.2; const machine = this.use.step(up, this.player.body, this.player.canAttack && !this.keys.J.isDown && !pad?.X, this.machines); if (machine && this.player.beginInteraction()) this.player.saveCheckpoint(machine.id!); - for (const actor of this.actors) actor.setAlpha(coffeeAlpha(this.scene.time.now)); + const frame = `idle-0${coffeeFrame(this.scene.time.now, this.fps)}`; + for (const actor of this.actors) actor.setFrame(frame); } } diff --git a/game/src/combat/overhead.test.ts b/game/src/combat/overhead.test.ts index 23802c2..522e1ed 100644 --- a/game/src/combat/overhead.test.ts +++ b/game/src/combat/overhead.test.ts @@ -48,7 +48,7 @@ describe('measured overhead chair attacks', () => { it('sweeps thin hazards without tunnelling and never bounces while rising or on wrecks', () => { const attack = new Overhead(), hazard = { x: 120, y: 500, w: 20, h: 2 }; attack.begin('slam', 1); attack.step(3 / 12); - expect(SLAM_SPEED).toBe(516); + expect(SLAM_SPEED).toBeCloseTo(2 * 4.6 * 43); // docs/02: twice run speed expect(attack.hazard({ x: 100, y: 440 }, 450, [hazard])).toBeUndefined(); expect(attack.hazard({ x: 100, y: 520 }, 450, [hazard])).toBe(500); expect(attack.hazard({ x: 100, y: 520 }, 450, [hazard])).toBeUndefined(); diff --git a/game/src/core/events.ts b/game/src/core/events.ts index 55eef5a..98228b5 100644 --- a/game/src/core/events.ts +++ b/game/src/core/events.ts @@ -11,6 +11,12 @@ export interface GameEvents { '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 }; + /** + * The prologue's pond shows what isn't there (docs/03, beat 'something wrong'): fired once when the hero + * reaches the jetty edge, before Dexter's shove. `position` is the water point under the edge (stage px); + * the water renderer reacts by showing the wrong reflection there for `duration` ms (then the frame tears). + */ + 'anomaly-reveal': { id: string; region: string; position: Position; duration: number }; } type Listener = (payload: T) => void; diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts index 48830c1..0a14e36 100644 --- a/game/src/core/scaffold.ts +++ b/game/src/core/scaffold.ts @@ -1,42 +1,18 @@ import Phaser from 'phaser'; -import { Assets, BOARDS } from '../stage/assets'; +import type { StageData } from '../boot/contract'; import { drawStage } from '../stage/render'; -import type { StageManifest, RegionState } from '../stage/manifest'; -import { loadCollisions, type CollisionChunk } from '../stage/collision'; import { collisionDebug } from '../stage/collision-debug'; import { Player } from '../player/player'; import { cameraFollow } from '../stage/camera'; import { ChairCombat } from '../combat/chair'; import { CoffeePoints } from '../combat/coffee'; +import { ChairPickup } from '../combat/chairPickup'; import { Rovers } from '../enemies/rovers'; +import { JennyBoss } from '../enemies/jennyBoss'; 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 { - constructor() { super('Boot'); } - create() { this.scene.start('Preload'); } -} - -export class Preload extends Phaser.Scene { - constructor() { super('Preload'); } - /** `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 = 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, spawn: data.spawn }); - } -} +// Boot and Preload live in game/src/boot/ (the one loader); Stage receives boot/contract.ts StageData. export class Stage extends Phaser.Scene { private player?: Player; private defeatedBosses = new Set(); @@ -45,26 +21,34 @@ export class Stage extends Phaser.Scene { private coffee?: CoffeePoints; private rovers?: Rovers; private triggers?: Triggers; + private jenny?: JennyBoss; get playerActor() { return this.player; } get chair() { return this.combat; } constructor() { super('Stage'); } - create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[]; spawn?: Position }) { - drawStage(this, data.manifest, data.state); + create(data: StageData) { + const stage = drawStage(this, data.manifest, data.state, data.atmosphere); collisionDebug(this, data.collisions); const machine = import.meta.env.DEV && new URLSearchParams(location.search).has('coffee-test') ? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'coffee-machine') : undefined; const rover = import.meta.env.DEV && new URLSearchParams(location.search).has('rover-test') ? data.collisions.flatMap(chunk => chunk.world.spawn).find(spawn => spawn.kind === 'rover') : undefined; + const gate = import.meta.env.DEV && new URLSearchParams(location.search).has('boss-test') + ? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'boss-gate') : 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 } : data.spawn); + : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } + : gate ? onFloor(data.collisions, gate.x - BODY_WIDTH * 2, gate.y + gate.h) : data.spawn); + this.player.ground(data.manifest, data.state, data.surfaces); + stage.reflect(this.player.sprite); 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); }; this.combat = new ChairCombat(this, this.player, data.manifest.region); this.coffee = new CoffeePoints(this, this.player, data.collisions); + new ChairPickup(this, this.player, this.combat, data.collisions); this.rovers = new Rovers(this, this.player, this.combat, data.collisions); + this.jenny = new JennyBoss(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); @@ -86,6 +70,7 @@ export class Stage extends Phaser.Scene { this.player?.update(active); this.combat?.update(active); this.rovers?.update(active); + this.jenny?.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); } @@ -94,3 +79,17 @@ export class Stage extends Phaser.Scene { export class UI extends Phaser.Scene { constructor() { super('UI'); } } + +/** A dev test spawn on the walk line under `x` nearest to `y` (the floor before a box can differ from its bottom). */ +function onFloor(chunks: StageData['collisions'], x: number, y: number) { + let best = y, gap = Infinity; + for (const line of chunks.flatMap(chunk => chunk.world.walk)) { + for (let i = 1; i < line.length; i++) { + const [a, b] = [line[i - 1], line[i]]; + if (x < a[0] || x > b[0]) continue; + const at = a[1] + (b[1] - a[1]) * (x - a[0]) / Math.max(1, b[0] - a[0]); + if (Math.abs(at - y) < gap) { gap = Math.abs(at - y); best = at; } + } + } + return { x, y: best }; +} diff --git a/game/src/enemies/crab.ts b/game/src/enemies/crab.ts new file mode 100644 index 0000000..252fe5d --- /dev/null +++ b/game/src/enemies/crab.ts @@ -0,0 +1,80 @@ +import { BODY_HEIGHT } from '../player/body'; +import { WALK_SPEED } from '../player/movement'; +import type { Position } from '../core/events'; +import type { Rect } from '../combat/sweeps'; + +/** + * The crab (docs/05, P7): a six-legged hexapod that creeps toward you, rears up with its front legs raised for + * 0.6 s (the tell) and stabs forward. Four chair hits. Modelled on rover.ts; frames from enemy-crab.anims.json + * (idle 4, walk 8, stab 5, collapse 5, all 12 fps). + */ +export const CRAB_WIDTH = 44, CRAB_HEIGHT = 36, CRAB_HEALTH = 4; +export const CRAB_TELL = 0.6, CRAB_SPEED = WALK_SPEED / 3, CRAB_SPOT = 110; +/** The stab reaches this far past its front (px) during the stab clip's middle frames. */ +export const STAB_REACH = 30; +const STAB_SECONDS = 5 / 12, COLLAPSE_SECONDS = 5 / 12, HIT_SECONDS = 3 / 12, RECOVER = 0.5; +export type CrabPhase = 'walk' | 'tell' | 'stab' | 'recover' | 'hit' | 'collapse' | 'dead'; + +export class Crab { + x: number; + readonly y: number; + health = CRAB_HEALTH; + phase: CrabPhase = 'walk'; + direction: -1 | 1 = -1; + elapsed = 0; + private hitDirection = 1; + constructor(readonly spawn: Position, readonly patrol: [number, number]) { this.x = spawn.x; this.y = spawn.y; } + private enter(phase: CrabPhase) { this.phase = phase; this.elapsed = 0; } + reset() { this.x = this.spawn.x; this.health = CRAB_HEALTH; this.direction = -1; this.enter('walk'); } + get bounds(): Rect { return { x: this.x - CRAB_WIDTH / 2, y: this.y - CRAB_HEIGHT, w: CRAB_WIDTH, h: CRAB_HEIGHT }; } + /** The stab's reach while its legs are out (frames 2–3 of 5), else undefined. */ + get attack(): Rect | undefined { + if (this.phase !== 'stab' || this.elapsed < 2 / 12 || this.elapsed >= 4 / 12) return undefined; + const front = this.x + this.direction * CRAB_WIDTH / 2; + return { x: this.direction > 0 ? front : front - STAB_REACH, y: this.y - CRAB_HEIGHT, w: STAB_REACH, h: CRAB_HEIGHT }; + } + hit(damage: number, direction: number) { + if (this.health <= 0 || this.phase === 'hit') return false; + this.health = Math.max(0, this.health - damage); this.hitDirection = direction; + this.enter(this.health ? 'hit' : 'collapse'); return true; + } + /** Returns true exactly when the collapse ends. */ + step(seconds: number, hero?: Position) { + let defeated = false, remaining = seconds; + while (remaining > 1e-9) { + const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt; + switch (this.phase) { + case 'walk': { + const near = hero && Math.abs(hero.y - this.y) <= BODY_HEIGHT / 2 && Math.abs(hero.x - this.x) <= CRAB_SPOT; + if (near) { this.direction = hero!.x >= this.x ? 1 : -1; this.enter('tell'); break; } + this.x += this.direction * CRAB_SPEED * dt; + if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) { + this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1; + } + break; + } + case 'tell': if (this.elapsed >= CRAB_TELL) this.enter('stab'); break; + case 'stab': if (this.elapsed >= STAB_SECONDS) this.enter('recover'); break; + case 'recover': if (this.elapsed >= RECOVER) this.enter('walk'); break; + case 'hit': + this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * 40 * dt)); + if (this.elapsed >= HIT_SECONDS) this.enter('recover'); + break; + case 'collapse': if (this.elapsed >= COLLAPSE_SECONDS) { this.enter('dead'); defeated = true; } break; + } + } + return defeated; + } + get pose() { + const n = Math.floor((this.elapsed + 1e-9) * 12); + const f = (clip: string, i: number) => `${clip}-${String(i).padStart(2, '0')}`; + switch (this.phase) { + case 'walk': return f('walk', n % 8); + case 'tell': return f('stab', Math.min(1, n)); // front legs raised and held + case 'stab': return f('stab', Math.min(4, 1 + n)); + case 'collapse': return f('collapse', Math.min(4, n)); + case 'dead': return f('collapse', 4); + default: return f('idle', n % 4); + } + } +} diff --git a/game/src/enemies/crabDrone.test.ts b/game/src/enemies/crabDrone.test.ts new file mode 100644 index 0000000..42259e7 --- /dev/null +++ b/game/src/enemies/crabDrone.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import crabClips from '../../public/assets/sprites/enemy-crab.anims.json'; +import droneClips from '../../public/assets/sprites/enemy-drone.anims.json'; +import { Crab, CRAB_HEALTH, CRAB_TELL } from './crab'; +import { Drone, DRONE_HEALTH } from './drone'; + +const frames = (clips: Record) => + new Set(Object.entries(clips).flatMap(([name, c]) => Array.from({ length: c.frames }, (_, i) => `${name}-${String(i).padStart(2, '0')}`))); + +describe('crab (P7)', () => { + it('raises its legs for the tell, then stabs forward with a reach, only from the approved frames', () => { + const crab = new Crab({ x: 1200, y: 135 }, [1050, 1330]); + const names = frames(crabClips); + const hero = { x: 1260, y: 135 }; + crab.step(1 / 60, hero); + expect(crab.phase).toBe('tell'); + expect(crab.direction).toBe(1); + expect(crab.attack).toBeUndefined(); + crab.step(CRAB_TELL, hero); + expect(crab.phase).toBe('stab'); + crab.step(2.5 / 12, hero); + expect(crab.attack!.x).toBeGreaterThanOrEqual(crab.bounds.x + crab.bounds.w); + for (let t = 0; t < 3; t += 1 / 30) { crab.step(1 / 30, hero); expect(names.has(crab.pose)).toBe(true); } + }); + + it('takes four hits and collapses once', () => { + const crab = new Crab({ x: 1200, y: 135 }, [1050, 1330]); + let defeated = 0; + for (let i = 0; i < CRAB_HEALTH; i++) { crab.hit(1, 1); for (let t = 0; t < 1; t += 1 / 60) defeated += crab.step(1 / 60) ? 1 : 0; } + expect([crab.phase, defeated]).toEqual(['dead', 1]); + }); +}); + +describe('drone (P7)', () => { + it('hovers out of reach, tilts when you pass under, dives, and climbs back', () => { + const drone = new Drone({ x: 1150, y: 470 }, [900, 1500]); + const names = frames(droneClips); + const hero = { x: 1150, y: 570 }; + drone.step(1 / 60, hero); + expect(drone.phase).toBe('tell'); + expect(drone.dangerous).toBe(false); + let lowest = drone.y, dangerous = false; + for (let t = 0; t < 3; t += 1 / 60) { + drone.step(1 / 60, { x: 0, y: 0 }); + lowest = Math.max(lowest, drone.y); dangerous ||= drone.dangerous; + expect(names.has(drone.pose)).toBe(true); + } + expect(dangerous).toBe(true); + expect(lowest).toBeGreaterThan(520); + expect([drone.phase, drone.y]).toEqual(['hover', 470]); + }); + + it('takes eight hits', () => { + const drone = new Drone({ x: 1150, y: 470 }, [900, 1500]); + for (let i = 0; i < DRONE_HEALTH; i++) { drone.hit(1, 1); for (let t = 0; t < 0.5; t += 1 / 60) drone.step(1 / 60); } + expect(drone.health).toBe(0); + }); +}); diff --git a/game/src/enemies/drone.ts b/game/src/enemies/drone.ts new file mode 100644 index 0000000..658990d --- /dev/null +++ b/game/src/enemies/drone.ts @@ -0,0 +1,73 @@ +import type { Position } from '../core/events'; +import type { Rect } from '../combat/sweeps'; + +/** + * The drone (docs/05, P7): a quadcopter hovering at head height. When you walk under it, it tilts with its eye + * flashing (the tell) and dives at you, then climbs back. Eight chair hits – in Region 1 you mostly avoid it. + * Frames from enemy-drone.anims.json (hover 4, dive 3, crash 5, 12 fps). + */ +export const DRONE_WIDTH = 45, DRONE_HEIGHT = 22, DRONE_HEALTH = 8; +export const DRONE_SPEED = 70, DRONE_TELL = 0.5, DIVE_SPEED = 380, CLIMB_SPEED = 160; +/** It dives when the hero is this close beneath it (px, horizontally). */ +export const DRONE_SPOT = 50; +const CRASH_SECONDS = 5 / 12, HIT_SECONDS = 3 / 12, DIVE_MAX = 0.6; +export type DronePhase = 'hover' | 'tell' | 'dive' | 'climb' | 'hit' | 'crash' | 'dead'; + +export class Drone { + x: number; + y: number; + health = DRONE_HEALTH; + phase: DronePhase = 'hover'; + direction: -1 | 1 = 1; + elapsed = 0; + private target = 0; + constructor(readonly spawn: Position, readonly patrol: [number, number]) { this.x = spawn.x; this.y = spawn.y; } + private enter(phase: DronePhase) { this.phase = phase; this.elapsed = 0; } + reset() { this.x = this.spawn.x; this.y = this.spawn.y; this.health = DRONE_HEALTH; this.enter('hover'); } + get bounds(): Rect { return { x: this.x - DRONE_WIDTH / 2, y: this.y - DRONE_HEIGHT, w: DRONE_WIDTH, h: DRONE_HEIGHT }; } + /** It only hurts on the way down. */ + get dangerous() { return this.phase === 'dive'; } + hit(damage: number, _direction: number) { + if (this.health <= 0 || this.phase === 'hit') return false; + this.health = Math.max(0, this.health - damage); + this.enter(this.health ? 'hit' : 'crash'); return true; + } + /** Returns true exactly when the crash ends. `hero` is the hero's feet. */ + step(seconds: number, hero?: Position) { + let defeated = false, remaining = seconds; + while (remaining > 1e-9) { + const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt; + switch (this.phase) { + case 'hover': + if (hero && hero.y > this.y && Math.abs(hero.x - this.x) <= DRONE_SPOT) { this.target = hero.y - 30; this.enter('tell'); break; } + this.x += this.direction * DRONE_SPEED * dt; + if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) { + this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1; + } + break; + case 'tell': if (this.elapsed >= DRONE_TELL) this.enter('dive'); break; + case 'dive': + this.y = Math.min(this.target, this.y + DIVE_SPEED * dt); + if (this.y >= this.target || this.elapsed >= DIVE_MAX) this.enter('climb'); + break; + case 'climb': case 'hit': + this.y = Math.max(this.spawn.y, this.y - CLIMB_SPEED * dt); + if (this.phase === 'hit' ? this.elapsed >= HIT_SECONDS : this.y <= this.spawn.y) this.enter(this.y <= this.spawn.y ? 'hover' : 'climb'); + break; + case 'crash': + this.y += 200 * dt; + if (this.elapsed >= CRASH_SECONDS) { this.enter('dead'); defeated = true; } + break; + } + } + return defeated; + } + get pose() { + const n = Math.floor((this.elapsed + 1e-9) * 12); + const f = (clip: string, i: number) => `${clip}-${String(i).padStart(2, '0')}`; + if (this.phase === 'tell' || this.phase === 'dive') return f('dive', Math.min(2, n)); + if (this.phase === 'crash') return f('crash', Math.min(4, n)); + if (this.phase === 'dead') return f('crash', 4); + return f('hover', n % 4); + } +} diff --git a/game/src/enemies/jenny.test.ts b/game/src/enemies/jenny.test.ts new file mode 100644 index 0000000..3dce16a --- /dev/null +++ b/game/src/enemies/jenny.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { arenaFrom, JENNY_BOX, JENNY_HEALTH, JennyFight, REV_SECONDS } from './jenny'; + +// boss-jenny*.anims.json as exported (approved 24 Oct). +const CLIPS = { + rev: { frames: 4, fps: 12, loop: true }, charge: { frames: 4, fps: 12, loop: false }, + crash: { frames: 4, fps: 12, loop: false }, stunned: { frames: 3, fps: 12, loop: true }, + reboot: { frames: 2, fps: 12, loop: false }, +}; +// Rusty Creek rc-2-0 anomaly in world px: gate at 4320 + 1096, floor y 565 from 5410 to 6010, Jenny at 5760. +const arena = arenaFrom({ x: 5416, w: 30 }, { x: 5760, y: 565 }, + [[[5410, 565], [6010, 565]], [[6012, 548], [6080, 548]]]); +const run = (fight: JennyFight, seconds: number) => { + const seen: string[] = []; + let beaten = false; + for (let t = 0; t < seconds; t += 1 / 60) { + beaten = fight.step(1 / 60) || beaten; + if (seen.at(-1) !== fight.phase) seen.push(fight.phase); + } + return { seen, beaten }; +}; + +describe('Jenny', () => { + it('reads her arena from the gate and the floor under her spawn', () => { + expect(arena).toEqual({ left: 5446, right: 6010, floor: 565 }); + }); + + it('waits behind the gate, then revs, charges, crashes, is stunned and reboots', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + expect(run(fight, 2).seen).toEqual(['waiting']); + fight.start(); + const { seen } = run(fight, REV_SECONDS + 5); + expect(seen.slice(0, 5)).toEqual(['rev', 'charge', 'crash', 'stunned', 'reboot']); + expect(fight.x).toBeGreaterThanOrEqual(arena.left + JENNY_BOX.w / 2); + }); + + it('crashes at the far wall and sheds three parts in phase 1', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'crash') fight.step(1 / 60); + expect(fight.x).toBe(arena.left + JENNY_BOX.w / 2); + expect(fight.parts).toHaveLength(3); + expect(fight.parts.every(p => p.vx > 0)).toBe(true); // thrown back out of the wall she hit + }); + + it('shrugs off hits on the armoured nose and takes them from behind or stunned', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + expect(fight.hit(1, 1)).toBe(false); // she faces left: a hit travelling right meets the nose + expect(fight.hit(1, -1)).toBe(true); // from behind + while (fight.phase !== 'stunned') fight.step(1 / 60); + expect(fight.hit(1, 1)).toBe(true); + expect(fight.health).toBe(JENNY_HEALTH - 2); + }); + + it('charges twice in phase 2 and stun is shorter in phase 3', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'stunned') fight.step(1 / 60); + for (let i = 0; i < 10; i++) fight.hit(1, 1); + expect(fight.stage).toBe(1); + while ((fight.phase as string) !== 'charge') fight.step(1 / 60); + const start = fight.facing; + while (fight.facing === start) fight.step(1 / 60); // turned at the wall instead of crashing + expect(fight.phase).toBe('charge'); + }); + + it('stalls at 0, fires once, and stays beaten through a reset', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + while (fight.phase !== 'stunned') fight.step(1 / 60); + for (let i = 0; i < JENNY_HEALTH; i++) fight.hit(1, 1); + expect(fight.phase).toBe('stall'); + expect(run(fight, 3)).toEqual({ seen: ['stall', 'gone'], beaten: true }); + fight.reset(); + expect(fight.phase).toBe('gone'); + }); + + it('resets to the start of the fight when the hero dies', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + run(fight, 2); + fight.reset(); + expect([fight.phase, fight.health, fight.x, fight.parts.length]).toEqual(['waiting', JENNY_HEALTH, 5760, 0]); + }); +}); diff --git a/game/src/enemies/jenny.ts b/game/src/enemies/jenny.ts new file mode 100644 index 0000000..21c7f76 --- /dev/null +++ b/game/src/enemies/jenny.ts @@ -0,0 +1,225 @@ +import type { AnimationTable } from '../stage/assets'; + +/** + * Jenny, the Region 1 boss (docs/05, #57) as pure state. She revs, charges across the arena nose first, crashes + * into the far wall, sheds parts and sits stunned; then she reboots, turns round and goes again. Her nose is + * armoured: chair hits land from behind, or anywhere while she is stunned. Three phases by health. Clip lengths + * come from her approved animations (boss-jenny*.anims.json), never from constants in code. + */ +export type JennyPhase = 'waiting' | 'rev' | 'charge' | 'crash' | 'stunned' | 'reboot' | 'stall' | 'gone'; + +export const JENNY_HEALTH = 30; +/** Health where phase 2 and phase 3 begin. */ +export const PHASE_AT = [20, 10] as const; +/** docs/05: charge 2 cells, parts 1; stunned 3 s (2 s in phase 3); 3 parts per crash (5 in phase 3). */ +export const CHARGE_DAMAGE = 2, PART_DAMAGE = 1; +const STUN = [3, 3, 2], PARTS = [3, 4, 5]; +/** + * Charge speed in px/s, per phase: a race car, faster than the hero's 258 px/s run. Tuned for the narrow arena + * (P8): a standing full jump keeps the hero's feet above her body for ~0.53 s, and she passes under in that time + * (jumpable()). + */ +export const SPEED = [540, 600, 660]; +/** Seconds of stall smoke before she is gone and `boss-defeated` fires. */ +export const STALL_SECONDS = 1.2; +/** Seconds of revving before a charge: the rev clip loops until then (docs/05: 1 s). The tell: she shakes and smokes. */ +export const REV_SECONDS = 1; +/** After her charge hits the hero she brakes this long, at this share of her speed, so the impact reads (P8). */ +export const BUMP_SECONDS = 0.25, BUMP_SPEED = 0.35; +/** A part bounces (keeping this share of its fall speed), then lies on the floor as a hazard this long, blinking at the end. */ +export const PART_BOUNCE = 0.45, PART_SECONDS = 2.5, PART_BLINK = 0.6; +/** + * Her hit box: the car body without the rear wing, inside the ~290 × 134 px sprite (measured on rev-00). It is + * the body under the cockpit, well below the hero's 96 px full jump, so you can jump her charge as docs/05 asks + * (P8); x is her centre, y the floor. + */ +export const JENNY_BOX = { w: 250, h: 56 }; +const GRAVITY = 1400; + +export interface Part { kind: string; x: number; y: number; vx: number; vy: number; landed: number; rolling: boolean; bounces: number } +export interface Arena { left: number; right: number; floor: number } + +export class JennyFight { + phase: JennyPhase = 'waiting'; + health = JENNY_HEALTH; + /** -1 nose left, 1 nose right. */ + facing: -1 | 1 = -1; + x: number; + elapsed = 0; + parts: Part[] = []; + /** Seconds of braking left after her charge hit the hero. */ + bump = 0; + /** Phase 2's first charge turns at the wall instead of crashing. */ + private turnsLeft = 0; + private seed = 1; + + constructor(readonly arena: Arena, readonly spawnX: number, private clips: AnimationTable) { + this.x = spawnX; + } + + /** 0, 1 or 2. */ + get stage(): number { return this.health > PHASE_AT[0] ? 0 : this.health > PHASE_AT[1] ? 1 : 2; } + get active(): boolean { return this.phase !== 'waiting' && this.phase !== 'gone'; } + get bounds() { return { x: this.x - JENNY_BOX.w / 2, y: this.arena.floor - JENNY_BOX.h, ...JENNY_BOX }; } + /** Where a hit does damage now: her open back (the tail), or the whole car while she is stunned or crashing. */ + get open(): 'back' | 'all' | 'none' { + if (!this.active || this.phase === 'stall') return 'none'; + return this.phase === 'stunned' || this.phase === 'crash' ? 'all' : 'back'; + } + /** The tail's x: the end away from her nose. */ + get tailX(): number { return this.x - this.facing * JENNY_BOX.w / 2; } + /** The rev tell's shake in px (0 outside the rev), growing as the charge comes. */ + get shake(): number { + if (this.phase !== 'rev') return 0; + return Math.round(Math.sin(this.elapsed * 70) * (1 + 2 * Math.min(1, this.elapsed / REV_SECONDS))); + } + /** Her charge hit the hero: she brakes for a moment, so the impact reads instead of driving through. */ + bumped(): void { if (this.phase === 'charge') this.bump = BUMP_SECONDS; } + + /** Seconds one pass of a clip takes, from its frames and fps. */ + clip(name: string): number { + const c = this.clips[name]; + return c ? c.frames / c.fps : 0.3; + } + + /** The current frame name, from the clip that plays in this phase. */ + get frame(): { atlas: 'boss-jenny' | 'boss-jenny-2'; name: string } { + const pick = (atlas: 'boss-jenny' | 'boss-jenny-2', clip: string, loop: boolean) => { + const c = this.clips[clip] ?? { frames: 1, fps: 12, loop }; + const n = Math.floor(this.elapsed * c.fps); + return { atlas, name: `${clip}-${String(loop ? n % c.frames : Math.min(c.frames - 1, n)).padStart(2, '0')}` }; + }; + switch (this.phase) { + case 'charge': return pick('boss-jenny', 'charge', true); + case 'crash': return pick('boss-jenny', 'crash', false); + case 'stunned': case 'stall': case 'gone': return pick('boss-jenny-2', 'stunned', true); + case 'reboot': return pick('boss-jenny-2', 'reboot', false); + default: return pick('boss-jenny', 'rev', true); + } + } + + start(): void { + if (this.phase === 'waiting') this.enter('rev'); + } + + /** Back to the start of the fight (the hero died). A beaten Jenny stays beaten. */ + reset(): void { + if (this.phase === 'stall' || this.phase === 'gone') return; + this.phase = 'waiting'; this.health = JENNY_HEALTH; this.facing = -1; this.x = this.spawnX; + this.elapsed = 0; this.parts = []; this.turnsLeft = 0; this.bump = 0; + } + + /** A chair hit travelling in `direction`. True if it did damage; false if the armoured nose took it. */ + hit(damage: number, direction: number): boolean { + if (!this.active || this.phase === 'stall' || this.health <= 0) return false; + const fromBehind = Math.sign(direction) === this.facing; + if (!fromBehind && this.phase !== 'stunned' && this.phase !== 'crash') return false; + this.health = Math.max(0, this.health - damage); + if (this.health === 0) { this.enter('stall'); this.parts = []; } + return true; + } + + /** Damage her body does to the hero on contact in this phase. */ + get contactDamage(): number { + return this.phase === 'charge' ? CHARGE_DAMAGE : this.phase === 'rev' ? 1 : 0; + } + + /** Advance; returns true exactly once, when the stall ends and she is beaten. */ + step(seconds: number): boolean { + let beaten = false; + let remaining = Math.min(seconds, 0.1); + while (remaining > 1e-9) { + const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt; + this.stepParts(dt); + switch (this.phase) { + case 'rev': if (this.elapsed >= REV_SECONDS) this.enter('charge'); break; + case 'charge': { + this.x += this.facing * SPEED[this.stage] * (this.bump > 0 ? BUMP_SPEED : 1) * dt; + this.bump = Math.max(0, this.bump - dt); + const wall = this.facing < 0 ? this.arena.left + JENNY_BOX.w / 2 : this.arena.right - JENNY_BOX.w / 2; + if ((this.x - wall) * this.facing >= 0) { + this.x = wall; + if (this.turnsLeft > 0) { this.turnsLeft--; this.facing = -this.facing as -1 | 1; this.elapsed = 0; } + else { this.enter('crash'); this.shed(); } + } + break; + } + case 'crash': if (this.elapsed >= this.clip('crash')) this.enter('stunned'); break; + case 'stunned': if (this.elapsed >= STUN[this.stage]) this.enter('reboot'); break; + case 'reboot': + if (this.elapsed >= this.clip('reboot')) { + this.facing = -this.facing as -1 | 1; + this.turnsLeft = this.stage === 1 ? 1 : 0; + this.enter('rev'); + } + break; + case 'stall': if (this.elapsed >= STALL_SECONDS) { this.enter('gone'); beaten = true; } break; + } + } + return beaten; + } + + private enter(phase: JennyPhase): void { this.phase = phase; this.elapsed = 0; } + + /** Parts fly back out of the crash in arcs: spring, bolt, wing shard … (debris-1…5); phase 2 adds a rolling wheel. */ + private shed(): void { + const count = PARTS[this.stage]; + for (let i = 0; i < count; i++) { + const kind = `debris-${(i % 5) + 1}`; + const spread = (i + 1) / count; + this.parts.push({ kind, x: this.x, y: this.arena.floor - JENNY_BOX.h * 0.6, + vx: -this.facing * (140 + 360 * spread + 40 * this.random()), vy: -(380 + 220 * this.random()), landed: 0, + rolling: this.stage === 1 && i === count - 1, bounces: 0 }); + } + } + + private stepParts(dt: number): void { + for (const p of this.parts) { + if (p.landed > 0 && !p.rolling) { p.landed += dt; continue; } + p.x += p.vx * dt; + if (p.landed === 0) { + p.vy += GRAVITY * dt; p.y += p.vy * dt; + if (p.y >= this.arena.floor) { + p.y = this.arena.floor; + // It bounces (twice at most), skidding slower each time, then lies there. + if (p.bounces < 2 && p.vy > 120) { p.bounces++; p.vy = -p.vy * PART_BOUNCE; p.vx *= 0.6; } + else { p.landed = dt; p.vx = p.rolling ? Math.sign(p.vx) * 300 : 0; } + } + } else p.landed += dt; + if (p.x < this.arena.left || p.x > this.arena.right) { + p.x = Math.max(this.arena.left, Math.min(this.arena.right, p.x)); + if (p.rolling) p.landed = Math.max(p.landed, 99); else p.vx = -p.vx * 0.5; + } + } + // A landed part lies there as a hazard, then it's gone; a rolling wheel leaves at the wall. + this.parts = this.parts.filter(p => p.landed < (p.rolling ? 99 : PART_SECONDS)); + } + + /** Deterministic jitter so tests and replays match. */ + private random(): number { + this.seed = (this.seed * 16807) % 2147483647; + return (this.seed - 1) / 2147483646; + } +} + +/** The arena from collision: the boss gate's right edge, the end of the floor under her spawn, its height. */ +export function arenaFrom(gate: { x: number; w: number }, spawn: { x: number; y: number }, walk: [number, number][][]): Arena { + const floor = walk.find(line => line.length >= 2 && line[0][0] <= spawn.x && line[line.length - 1][0] >= spawn.x + && line.some(p => Math.abs(p[1] - spawn.y) <= 4)); + const right = floor ? Math.max(...floor.filter(p => Math.abs(p[1] - spawn.y) <= 4).map(p => p[0])) : spawn.x + 300; + return { left: gate.x + gate.w, right, floor: spawn.y }; +} + +/** A landed part about to vanish blinks. */ +export function blinking(p: Part): boolean { return !p.rolling && p.landed > PART_SECONDS - PART_BLINK; } + +/** + * Whether a standing full jump clears her charge at `speed`: the time the hero's feet stay above her body must + * cover the time her body and his take to pass each other (the hero's jump: 485 px/s up, 1200 px/s² gravity). + */ +export function jumpable(speed: number, jumpSpeed = 485, gravity = 1200, heroWidth = 28): boolean { + const disc = jumpSpeed * jumpSpeed - 2 * gravity * JENNY_BOX.h; + if (disc <= 0) return false; + const above = (2 * Math.sqrt(disc)) / gravity; + return (JENNY_BOX.w + heroWidth) / speed <= above; +} diff --git a/game/src/enemies/jennyBoss.ts b/game/src/enemies/jennyBoss.ts new file mode 100644 index 0000000..680fb71 --- /dev/null +++ b/game/src/enemies/jennyBoss.ts @@ -0,0 +1,210 @@ +import Phaser from 'phaser'; +import { gameEvents } from '../core/events'; +import { sound } from '../fx/sound/Audio'; +import type { ChairCombat } from '../combat/chair'; +import { intersects } from '../combat/sweeps'; +import { BODY_HEIGHT, BODY_WIDTH } from '../player/body'; +import type { Player } from '../player/player'; +import type { AnimationTable } from '../stage/assets'; +import type { Box, CollisionChunk } from '../stage/collision'; +import { arenaFrom, blinking, JENNY_BOX, JENNY_HEALTH, JennyFight, PART_DAMAGE, type Part } from './jenny'; + +const ID = 'jenny'; +const NAME = 'Jenny'; +const DEPTH = 101; +/** Her charge throws the hero up and along her travel (px/s): he flies over the hood instead of her driving through. */ +export const THROW_UP = 520; +/** Camera shake per event (ms, intensity): her charge hitting the hero, a chair hit landing, the armoured nose. */ +const SHAKE = { ram: [220, 0.01], hit: [90, 0.004], clank: [60, 0.002] } as const; +type Hud = Phaser.Scene & { model?: { showBoss(name: string, max: number): void; setBossHealth(h: number): void; hideBoss(): void } }; + +/** + * Where her charge sends the hero (P8): the knock source sits behind him relative to her travel, so he is thrown + * the way she drives – never back through her – and up over the hood. Pure, so it is unit-tested. + */ +export function ramKnock(heroX: number, facing: -1 | 1): { sourceX: number; vy: number } { + return { sourceX: heroX - facing * BODY_WIDTH, vy: -THROW_UP }; +} + +/** + * Draws and wires Jenny's fight (#57, P8) into the stage. Everything comes from the region's collision: her `jenny` + * spawn, the `boss-gate` box that closes behind you, and the floor she drives on. Walking through the gate starts + * the fight and the boss bar; beating her emits `boss-defeated`, which the Ending scene turns into the end of the + * slice. Dying resets the fight. What reads: the rev shakes and smokes with the lights on (the tell); her charge + * throws you and she brakes; her open back glows, and while she is stunned a hazard light blinks over it; every + * chair hit flashes, shakes and sparks; the armoured nose clanks you back; shed parts bounce and lie as hazards. + */ +export class JennyBoss { + private fight?: JennyFight; + private sprite?: Phaser.GameObjects.Sprite; + private glow?: Phaser.GameObjects.Ellipse; + private hazard?: Phaser.GameObjects.Ellipse; + private lamp?: Phaser.GameObjects.Ellipse; + private parts = new Map(); + private hurt = new Set(); + private wall?: { box: Box; list: Box[] }; + private gate?: Box; + private flash = 0; + private puff = 0; + private cleanup: (() => void)[] = []; + + constructor(private scene: Phaser.Scene, private player: Player, private chair: ChairCombat, chunks: CollisionChunk[]) { + const spawn = chunks.flatMap(c => c.world.spawn).find(s => s.kind === 'jenny'); + const gateChunk = chunks.find(c => c.world.interact.some(b => b.kind === 'boss-gate')); + this.gate = gateChunk?.world.interact.find(b => b.kind === 'boss-gate'); + if (!spawn || !this.gate || !gateChunk || player.life.defeatedBosses.has(ID)) return; + const clips = { ...scene.cache.json.get('boss-jenny.anims'), ...scene.cache.json.get('boss-jenny-2.anims') } as AnimationTable; + const walk = chunks.flatMap(c => c.world.walk); + this.fight = new JennyFight(arenaFrom(this.gate, spawn, walk), spawn.x, clips); + this.wall = { box: { id: 'boss-wall', kind: 'boss-wall', x: this.gate.x, y: this.gate.y - 200, w: this.gate.w, h: this.gate.h + 200 }, + list: gateChunk.world.solid }; + // The open back's glow sits under the car; the hazard light and the headlight over it. + this.glow = scene.add.ellipse(spawn.x, spawn.y, 48, 40, 0xff9a3c, 0).setDepth(DEPTH - 1).setBlendMode(Phaser.BlendModes.ADD); + this.sprite = scene.add.sprite(spawn.x, spawn.y, 'boss-jenny', 'rev-00').setOrigin(0.5, 1).setDepth(DEPTH); + this.hazard = scene.add.ellipse(spawn.x, spawn.y, 14, 14, 0xffb020, 0).setDepth(DEPTH + 1).setBlendMode(Phaser.BlendModes.ADD); + this.lamp = scene.add.ellipse(spawn.x, spawn.y, 22, 12, 0xfff4cb, 0).setDepth(DEPTH + 1).setBlendMode(Phaser.BlendModes.ADD); + this.cleanup.push( + chair.register({ id: ID, active: () => !!this.fight && this.fight.active && this.fight.health > 0, + bounds: () => this.fight!.bounds, hit: hit => this.hit(hit.damage, hit.direction) }), + player.life.register(ID, () => this.reset(), true), + gameEvents.on('trigger-entered', ({ kind }) => { if (kind === 'boss-gate') this.start(); }), + ); + scene.events.once('shutdown', () => this.cleanup.forEach(off => off())); + } + + private get hud(): Hud | undefined { return this.scene.scene.get('HUD') as Hud | undefined; } + + private start(): void { + const fight = this.fight; + if (!fight || fight.phase !== 'waiting') return; + // Only once the hero is fully inside the arena, so the wall never lands on them. + if (this.player.body.x - BODY_WIDTH / 2 <= this.gate!.x + this.gate!.w) { + this.scene.time.delayedCall(250, () => this.start()); + return; + } + fight.start(); + if (!this.wall!.list.includes(this.wall!.box)) this.wall!.list.push(this.wall!.box); + this.hud?.model?.showBoss(NAME, JENNY_HEALTH); + } + + private hit(damage: number, direction: number): boolean { + const fight = this.fight!; + const landed = fight.hit(damage, direction); + const camera = this.scene.cameras.main; + if (landed) { + this.flash = 0.08; + this.hud?.model?.setBossHealth(fight.health); + camera.shake(SHAKE.hit[0], SHAKE.hit[1]); + this.sparks(fight.open === 'all' ? this.player.body.x + direction * 30 : fight.tailX, 0xffc35a, 10); + } else { + // The armoured nose: a dull clank, grey sparks, and the chair bounces the hero back. + sound.play('chair-hit'); + camera.shake(SHAKE.clank[0], SHAKE.clank[1]); + this.sparks(fight.x + fight.facing * JENNY_BOX.w / 2, 0xb8c2c8, 5); + this.player.pushGuard(-Math.sign(direction) || -1); + } + return landed; + } + + private sparks(x: number, colour: number, count: number): void { + const y = this.fight!.arena.floor - JENNY_BOX.h / 2; + for (let i = 0; i < count; i++) { + const pixel = this.scene.add.rectangle(x, y, 3, 3, colour).setDepth(DEPTH + 2); + const angle = (i / count) * Math.PI * 2; + this.scene.tweens.add({ targets: pixel, x: x + Math.cos(angle) * 26, y: y + Math.sin(angle) * 20 - 8, + alpha: 0, duration: 200, onComplete: () => pixel.destroy() }); + } + } + + private reset(): void { + this.fight?.reset(); + this.openWall(); + for (const s of this.parts.values()) s.destroy(); + this.parts.clear(); this.hurt.clear(); + if (this.fight?.phase === 'waiting') this.hud?.model?.hideBoss(); + } + + private openWall(): void { + if (!this.wall) return; + const i = this.wall.list.indexOf(this.wall.box); + if (i >= 0) this.wall.list.splice(i, 1); + } + + update(milliseconds: number): void { + const fight = this.fight, sprite = this.sprite; + if (!fight || !sprite) return; + if (fight.step(milliseconds / 1000)) { + this.openWall(); + gameEvents.emit('boss-defeated', { id: ID, position: { x: fight.x, y: fight.arena.floor } }); + } + const { atlas, name } = fight.frame; + if (this.scene.textures.get(atlas).has(name)) sprite.setTexture(atlas, name); + // The approved frames face left (nose left), so a right-facing Jenny is mirrored. The rev shakes her (the tell). + sprite.setPosition(Math.round(fight.x + fight.shake), fight.arena.floor + (fight.phase === 'rev' ? Math.abs(fight.shake) % 2 : 0)) + .setFlipX(fight.facing > 0) + .setVisible(fight.phase !== 'gone').setAlpha(fight.phase === 'stall' ? 0.6 + 0.4 * Math.random() : 1); + this.flash = Math.max(0, this.flash - milliseconds / 1000); + if (this.flash > 0) sprite.setTintFill(0xffffff); else sprite.clearTint(); + this.drawTells(fight, milliseconds); + this.drawParts(fight); + this.contact(fight); + } + + /** The rev's lights and exhaust; the open back's glow; the stunned hazard light. */ + private drawTells(fight: JennyFight, milliseconds: number): void { + const now = this.scene.time.now, floor = fight.arena.floor; + const pulse = 0.5 + 0.5 * Math.sin(now / 120); + const open = fight.open; + // The open back: faint while she drives (hit her from behind), bright and pulsing while she is stunned. + this.glow!.setPosition(fight.tailX + fight.facing * 22, floor - JENNY_BOX.h / 2) + .setFillStyle(0xff9a3c, open === 'all' ? 0.35 + 0.35 * pulse : open === 'back' ? 0.12 : 0).setScale(open === 'all' ? 1 + 0.2 * pulse : 1); + this.hazard!.setPosition(fight.tailX + fight.facing * 18, floor - JENNY_BOX.h - 10) + .setFillStyle(0xffb020, fight.phase === 'stunned' && Math.floor(now / 200) % 2 === 0 ? 0.95 : 0); + const revving = fight.phase === 'rev' || fight.phase === 'charge'; + this.lamp!.setPosition(fight.x + fight.facing * (JENNY_BOX.w / 2 - 6), floor - 22) + .setFillStyle(0xfff4cb, revving ? (fight.phase === 'rev' ? 0.4 + 0.5 * (Math.floor(now / 90) % 2) : 0.9) : 0); + if (fight.phase !== 'rev') return; + if ((this.puff -= milliseconds) > 0) return; + this.puff = 90; + if (Math.floor(fight.elapsed * 4) % 2 === 0) sound.play('jenny-engine'); + const puff = this.scene.add.circle(fight.tailX, floor - 18, 6, 0x9aa3a8, 0.7).setDepth(DEPTH - 1); + this.scene.tweens.add({ targets: puff, x: puff.x - fight.facing * (24 + Math.random() * 20), y: puff.y - 12 - Math.random() * 10, + scale: 2.4, alpha: 0, duration: 420, onComplete: () => puff.destroy() }); + } + + private drawParts(fight: JennyFight): void { + for (const [part, s] of this.parts) if (!fight.parts.includes(part)) { s.destroy(); this.parts.delete(part); this.hurt.delete(part); } + for (const part of fight.parts) { + let s = this.parts.get(part); + if (!s) { s = this.scene.add.sprite(part.x, part.y, 'boss-jenny-2', `${part.kind}-00`).setOrigin(0.5, 1).setDepth(DEPTH); this.parts.set(part, s); } + const frame = `${part.kind}-0${Math.floor(this.scene.time.now / 120) % 2}`; + if (this.scene.textures.get('boss-jenny-2').has(frame)) s.setFrame(frame); + // Debris art is drawn at the car's scale; a part is about a third of its board cell. It blinks before it goes. + s.setScale(0.35).setPosition(Math.round(part.x), Math.round(part.y)).setAngle(part.landed ? 0 : part.x * 2) + .setVisible(!blinking(part) || Math.floor(this.scene.time.now / 80) % 2 === 0); + } + } + + private contact(fight: JennyFight): void { + const body = this.player.body; + const hero = { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT }; + if (fight.contactDamage && intersects(fight.bounds, hero)) { + if (fight.phase === 'charge') { + // Her charge: thrown up and the way she drives; she brakes; the screen takes the hit. + const knock = ramKnock(body.x, fight.facing); + if (this.player.damage(fight.contactDamage, knock.sourceX)) { + body.vy = knock.vy; + fight.bumped(); + this.chair.pause.trigger(); + this.scene.cameras.main.shake(SHAKE.ram[0], SHAKE.ram[1]); + this.sparks(body.x, 0xfff4cb, 8); + } + } else this.player.damage(fight.contactDamage, fight.x); // revving: pushed off her + } + for (const part of fight.parts) { + if (this.hurt.has(part)) continue; + const box = { x: part.x - 12, y: part.y - 24, w: 24, h: 24 }; + if (intersects(box, hero) && this.player.damage(PART_DAMAGE, part.x)) this.hurt.add(part); + } + } +} diff --git a/game/src/enemies/jennyRam.test.ts b/game/src/enemies/jennyRam.test.ts new file mode 100644 index 0000000..77919ea --- /dev/null +++ b/game/src/enemies/jennyRam.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { JENNY_BOX } from './jenny'; +import { RAM, ramFrame, ramPlan, thrownAt } from './jennyRam'; + +describe("Jenny's ram on the creek path (P5)", () => { + const hero = { x: 3240, y: 566 }; + const viewRight = 3240 + 640; + const plan = ramPlan(hero.x, viewRight); + + it('tells first: lights from the right edge, then her rev, then the charge, all well under 6 s', () => { + expect(plan.revAt).toBeGreaterThan(0); + expect(plan.chargeAt).toBeGreaterThan(plan.revAt); + expect(plan.impactAt).toBeGreaterThan(plan.chargeAt); + expect(plan.crossAt).toBeLessThan(6); + const early = ramFrame(0.1, plan, hero, viewRight); + expect(early.phase).toBe('lights'); + expect(early.jennyX - JENNY_BOX.w / 2).toBeGreaterThan(viewRight); // not on screen yet + expect(ramFrame(plan.revAt * 0.99, plan, hero, viewRight).lights).toBeGreaterThan(early.lights); + }); + + it('is one hit: her nose meets the hero, and he is thrown the way she drives (left), ahead of her', () => { + expect(plan.impactX - JENNY_BOX.w / 2).toBeCloseTo(hero.x + 14); + const hit = ramFrame(plan.impactAt + 0.01, plan, hero, viewRight); + expect(hit.phase).toBe('thrown'); + expect(hit.hero).toBeDefined(); + const later = ramFrame(plan.impactAt + RAM.throw * 0.9, plan, hero, viewRight); + expect(later.hero!.x).toBeLessThan(hit.hero!.x); + expect(later.hero!.x).toBeLessThan(later.jennyX - JENNY_BOX.w / 2); + expect(ramFrame(plan.crossAt + 0.01, plan, hero, viewRight).phase).toBe('cross'); + }); + + it('throws in an arc that lands back on the path', () => { + expect(thrownAt(RAM.throw / 2, hero, -1).y).toBeLessThan(hero.y - RAM.throwHeight * 0.9); + expect(thrownAt(RAM.throw, hero, -1)).toMatchObject({ x: hero.x - RAM.throwDistance, y: hero.y }); + }); +}); diff --git a/game/src/enemies/jennyRam.ts b/game/src/enemies/jennyRam.ts new file mode 100644 index 0000000..f4a5fad --- /dev/null +++ b/game/src/enemies/jennyRam.ts @@ -0,0 +1,96 @@ +import { JENNY_BOX } from './jenny'; + +/** + * Jenny's ram on the clean creek path (docs/03 region 1, #54, P5) as one readable event, timed in seconds: + * her headlights and engine come first (the tell), she rolls into view revving, charges down the path, hits the + * hero and throws him the way she drives, and the frame breaks into the crash crossing. All before ~6 s. + */ +export const RAM = { + /** Lights and engine only: she is still beyond the right edge. */ + lights: 0.6, + /** She rolls into view and revs at the edge. */ + rev: 0.6, + /** Charge speed along the path (px/s). */ + speed: 900, + /** The throw before the frame breaks. */ + throw: 0.45, + /** How far the hit throws the hero along her direction of travel (px), and how high the arc rises. */ + throwDistance: 220, + throwHeight: 90, + /** Where she enters: this far beyond the view's right edge (px). */ + offscreen: 180, + /** Where she revs: her centre this far inside the right edge. */ + revInset: 60, +} as const; + +export interface RamPlan { + /** Her centre x at each step. */ + startX: number; revX: number; impactX: number; + /** Seconds from the trigger. */ + revAt: number; chargeAt: number; impactAt: number; crossAt: number; +} + +/** + * The ram for a hero standing at `heroX` with the view's right edge at `viewRight`: she drives right to left, so + * the impact is where her nose (the left end of her body box) meets the hero's leading edge. + */ +export function ramPlan(heroX: number, viewRight: number, heroHalfWidth = 14): RamPlan { + const startX = viewRight + RAM.offscreen; + const revX = Math.max(heroX + JENNY_BOX.w, viewRight - RAM.revInset); + const impactX = heroX + heroHalfWidth + JENNY_BOX.w / 2; + const revAt = RAM.lights; + const chargeAt = revAt + RAM.rev; + const impactAt = chargeAt + Math.max(0, revX - impactX) / RAM.speed; + return { startX, revX, impactX, revAt, chargeAt, impactAt, crossAt: impactAt + RAM.throw }; +} + +/** Everything the ram shows `t` seconds after the trigger: pure, so the choreography is tested and replayable. */ +export interface RamFrame { + phase: 'lights' | 'rev' | 'charge' | 'thrown' | 'cross'; + /** Her centre x and frame name (boss-jenny atlas, 12 fps clips). */ + jennyX: number; jennyFrame: string; + /** Headlight alpha 0…1 and the x of the lamps (the right edge before she is seen, her nose after). */ + lights: number; lightsX: number; + /** The hero thrown: undefined before the hit. */ + hero?: { x: number; y: number; angle: number; frame: string }; +} + +export function ramFrame(t: number, plan: RamPlan, hero: { x: number; y: number }, viewRight: number): RamFrame { + const clip = (name: string, since: number, loop = true) => { + const n = Math.floor(Math.max(0, t - since) * 12); + return `${name}-0${loop ? n % 4 : Math.min(3, n)}`; + }; + const nose = (x: number) => Math.min(viewRight - 8, x - JENNY_BOX.w / 2); + if (t < plan.revAt) { + return { phase: 'lights', jennyX: plan.startX, jennyFrame: clip('rev', 0), lights: (t / plan.revAt) ** 2, lightsX: viewRight - 8 }; + } + if (t < plan.chargeAt) { + const k = Math.min(1, (t - plan.revAt) / (RAM.rev * 0.6)); + const x = plan.startX + (plan.revX - plan.startX) * (1 - (1 - k) * (1 - k)); + return { phase: 'rev', jennyX: x, jennyFrame: clip('rev', plan.revAt), lights: 1, lightsX: nose(x) }; + } + if (t < plan.impactAt) { + const x = plan.revX - (t - plan.chargeAt) * RAM.speed; + return { phase: 'charge', jennyX: x, jennyFrame: clip('charge', plan.chargeAt), lights: 1, lightsX: nose(x) }; + } + // She skids on along the path, braking, while the hero flies ahead of her. + const s = Math.min(1, (t - plan.impactAt) / RAM.throw); + const x = plan.impactX - RAM.throwDistance * 0.6 * (1 - (1 - s) * (1 - s)); + const thrown = thrownAt(t - plan.impactAt, hero, -1); + return { + phase: t < plan.crossAt ? 'thrown' : 'cross', jennyX: x, jennyFrame: clip('crash', plan.impactAt, false), + lights: 1 - s * 0.6, lightsX: x - JENNY_BOX.w / 2, + hero: { ...thrown, frame: `hit-0${Math.min(7, Math.floor((t - plan.impactAt) * 12))}` }, + }; +} + +/** The hero's thrown position `t` seconds after the hit (t in 0…RAM.throw): along her travel, in an arc. */ +export function thrownAt(t: number, hero: { x: number; y: number }, direction: -1 | 1): { x: number; y: number; angle: number } { + const k = Math.min(1, Math.max(0, t / RAM.throw)); + const ease = 1 - (1 - k) * (1 - k); + return { + x: hero.x + direction * RAM.throwDistance * ease, + y: hero.y - RAM.throwHeight * 4 * k * (1 - k), + angle: direction * 25 * k, + }; +} diff --git a/game/src/enemies/jennyReads.test.ts b/game/src/enemies/jennyReads.test.ts new file mode 100644 index 0000000..645724a --- /dev/null +++ b/game/src/enemies/jennyReads.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +vi.mock('phaser', () => ({ default: {} })); +vi.mock('../fx/sound/Audio', () => ({ sound: { play: () => undefined } })); +import { Life } from '../combat/life'; +import { arenaFrom, blinking, BUMP_SECONDS, JENNY_BOX, JennyFight, jumpable, PART_SECONDS, SPEED } from './jenny'; +import { ramKnock, THROW_UP } from './jennyBoss'; + +const CLIPS = { + rev: { frames: 4, fps: 12, loop: true }, charge: { frames: 4, fps: 12, loop: false }, + crash: { frames: 4, fps: 12, loop: false }, stunned: { frames: 3, fps: 12, loop: true }, + reboot: { frames: 2, fps: 12, loop: false }, +}; +const arena = arenaFrom({ x: 5416, w: 30 }, { x: 5760, y: 565 }, [[[5410, 565], [6010, 565]]]); +const until = (fight: JennyFight, phase: string) => { for (let i = 0; i < 2000 && fight.phase !== phase; i++) fight.step(1 / 60); }; + +describe('Jenny reads (P8)', () => { + it('can be jumped at every phase speed in the narrow arena', () => { + for (const speed of SPEED) expect(jumpable(speed)).toBe(true); + expect(jumpable(300)).toBe(false); // too slow to pass under one jump: the reason she is fast + }); + + it('her charge throws the hero the way she drives and up, never back through her', () => { + for (const facing of [-1, 1] as const) { + const knock = ramKnock(5600, facing); + const life = new Life({ x: 5600, y: 565 }); + life.hit(2, 5600 >= knock.sourceX ? 1 : -1); // how Player.damage picks the knock direction + const step = life.tick(0.1); + expect(Math.sign(step.dx)).toBe(facing); + expect(knock.vy).toBe(-THROW_UP); + expect(life.invulnerable).toBeGreaterThan(0.5); // brief invulnerability after the throw + } + }); + + it('brakes when her charge hits, so the impact reads', () => { + const free = new JennyFight(arena, 5760, CLIPS), hit = new JennyFight(arena, 5760, CLIPS); + for (const f of [free, hit]) { f.start(); until(f, 'charge'); } + hit.bumped(); + free.step(BUMP_SECONDS); hit.step(BUMP_SECONDS); + expect(Math.abs(hit.x - 5760)).toBeLessThan(Math.abs(free.x - 5760) * 0.5); + }); + + it('tells before the charge: the rev shakes her, and only the rev', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + const shakes = new Set(); + for (let i = 0; i < 50; i++) { fight.step(1 / 60); shakes.add(fight.shake); } + expect(fight.phase).toBe('rev'); + expect([...shakes].some(s => s !== 0)).toBe(true); + until(fight, 'charge'); + expect(fight.shake).toBe(0); + }); + + it('opens her back: hits land at the tail while she drives, anywhere while stunned', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + expect(fight.open).toBe('back'); + expect(fight.tailX).toBe(5760 + JENNY_BOX.w / 2); // nose left, tail right + until(fight, 'stunned'); + expect(fight.open).toBe('all'); + }); + + it('sheds parts that bounce, then lie as hazards for a while and blink before they go', () => { + const fight = new JennyFight(arena, 5760, CLIPS); + fight.start(); + until(fight, 'crash'); + const part = fight.parts[0]; + let bounced = false; + for (let i = 0; i < 120 && part.landed === 0; i++) { fight.step(1 / 120); if (part.vy < 0 && part.bounces > 0) bounced = true; } + expect(bounced).toBe(true); + for (let t = 0; t < PART_SECONDS - 0.8; t += 1 / 60) fight.step(1 / 60); + expect(fight.parts).toContain(part); + expect(blinking(part)).toBe(false); + for (let t = 0; t < 0.5; t += 1 / 60) fight.step(1 / 60); + expect(blinking(part)).toBe(true); + for (let t = 0; t < 1; t += 1 / 60) fight.step(1 / 60); + expect(fight.parts).not.toContain(part); + }); +}); diff --git a/game/src/enemies/rover.test.ts b/game/src/enemies/rover.test.ts index 822b91d..38305bb 100644 --- a/game/src/enemies/rover.test.ts +++ b/game/src/enemies/rover.test.ts @@ -13,15 +13,15 @@ describe('autonomous rover', () => { it('patrols the real tutorial range at half walking speed without leaving its deck', () => { const rover = tutorial(); expect(rover.patrol).toEqual([640, 880]); expect(rover.bounds).toEqual({ x: 744.5, y: 483, w: 31, h: 25 }); - expect(PATROL_SPEED).toBe(75.25); rover.step(1); expect(rover.x).toBeCloseTo(835.25); + expect(PATROL_SPEED).toBeCloseTo(2.2 * 43 / 2); rover.step(1); expect(rover.x).toBeCloseTo(760 + PATROL_SPEED); for (let n = 0; n < 30; n++) { rover.step(0.1); expect(rover.x).toBeGreaterThanOrEqual(640); expect(rover.x).toBeLessThanOrEqual(880); } }); it('shows red lidar and rear-up for exactly half a second before a locked straight charge', () => { const rover = tutorial(); rover.step(1 / 120, { x: 840, y: 508 }); expect(rover.phase).toBe('tell'); expect(rover.pose).toBe('charge-00'); rover.step(0.25); expect(rover.pose).toBe('charge-01'); expect(rover.x).toBe(760); - rover.step(0.25); expect(rover.phase).toBe('charge'); expect(CHARGE_SPEED).toBe(258); - rover.step(0.1, { x: 700, y: 400 }); expect(rover.x).toBeCloseTo(785.8); expect(rover.direction).toBe(1); + rover.step(0.25); expect(rover.phase).toBe('charge'); expect(CHARGE_SPEED).toBeCloseTo(4.6 * 43); + rover.step(0.1, { x: 700, y: 400 }); expect(rover.x).toBeCloseTo(760 + CHARGE_SPEED * 0.1, 1); expect(rover.direction).toBe(1); rover.step(1); expect(rover.x).toBeLessThanOrEqual(880); }); it('spots only forward within the documented tuning band', () => { diff --git a/game/src/enemies/rover.ts b/game/src/enemies/rover.ts index 82ac055..c4f5e34 100644 --- a/game/src/enemies/rover.ts +++ b/game/src/enemies/rover.ts @@ -5,6 +5,8 @@ import type { Position } from '../core/events'; // docs/05: measured idle-00 alpha bounds and controller tuning. export const ROVER_WIDTH = 31, ROVER_HEIGHT = 25; export const TELL_SECONDS = 0.5, PATROL_SPEED = WALK_SPEED / 2, CHARGE_SPEED = RUN_SPEED; +/** How far ahead a rover's lidar spots the hero (px): its own range, no longer tied to the hero's walk speed. */ +export const SPOT_RANGE = 150; export type RoverPhase = 'patrol' | 'tell' | 'charge' | 'recover' | 'hit' | 'wreck' | 'dead'; export class Rover { x: number; @@ -37,7 +39,7 @@ export class Rover { const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt; switch (this.phase) { case 'patrol': - if (hero && (hero.x - this.x) * this.direction > 0 && Math.abs(hero.x - this.x) <= WALK_SPEED + if (hero && (hero.x - this.x) * this.direction > 0 && Math.abs(hero.x - this.x) <= SPOT_RANGE && Math.abs(hero.y - this.y) <= BODY_HEIGHT) { this.enter('tell'); break; } this.x += this.direction * PATROL_SPEED * dt; if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) { diff --git a/game/src/enemies/rovers.ts b/game/src/enemies/rovers.ts index 79a77db..ecd4429 100644 --- a/game/src/enemies/rovers.ts +++ b/game/src/enemies/rovers.ts @@ -1,51 +1,113 @@ import type Phaser from 'phaser'; import type { CollisionChunk } from '../stage/collision'; + +type Spawn = CollisionChunk['world']['spawn'][number]; import { BODY_HEIGHT, BODY_WIDTH } from '../player/body'; import type { Player } from '../player/player'; import type { ChairCombat } from '../combat/chair'; -import { intersects } from '../combat/sweeps'; +import { intersects, type Rect } from '../combat/sweeps'; import { gameEvents } from '../core/events'; +import { sound } from '../fx/sound/Audio'; import { Rover, ROVER_HEIGHT } from './rover'; +import { Crab } from './crab'; +import { Drone } from './drone'; + +/** What the stage needs from any small enemy model (rover, crab, drone). */ +interface Model { + x: number; y: number; health: number; phase: string; direction: -1 | 1; pose: string; + bounds: Rect; step(seconds: number, hero?: { x: number; y: number }): boolean; hit(damage: number, direction: number): boolean; + reset(): void; +} +interface Row { id: string; kind: 'rover' | 'crab' | 'drone'; model: Model; sprite: Phaser.GameObjects.Sprite; label: Phaser.GameObjects.Text; + /** Extra reach that hurts (the crab's stab), and whether the body hurts right now (the drone only on its dive). */ + attack?: () => Rect | undefined; harmful: () => boolean; onBlocked?: () => void } + +/** The atlas, the flip rule and the label height per kind. Rover and crab art face left; the drone faces right. */ +const KINDS = { + rover: { atlas: 'enemy-rover', flip: (d: number) => d > 0, max: 3, sound: 'rover-motor' }, + crab: { atlas: 'enemy-crab', flip: (d: number) => d > 0, max: 4, sound: 'crab-servo' }, + drone: { atlas: 'enemy-drone', flip: () => false, max: 8, sound: 'drone-hum' }, +} as const; +function patrolOf(spawn: Spawn): [number, number] { + const range = spawn.patrol; + if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) { + throw new Error(`Invalid patrol: ${spawn.id}`); + } + return range as [number, number]; +} + +/** + * The region's small enemies from collision spawns (#45, P7): rovers patrol the decks and charge, crabs creep and + * stab after raising their front legs, drones hover and dive when you pass under them. All take chair hits, + * flash white, and reset when the hero respawns. F2 shows their state. + */ export class Rovers { - private rows; + private rows: Row[]; private debug: Phaser.GameObjects.Graphics; private visible = false; constructor(private scene: Phaser.Scene, private player: Player, chair: ChairCombat, chunks: CollisionChunk[]) { const cleanup: (() => void)[] = []; this.debug = scene.add.graphics().setDepth(10003); - this.rows = chunks.flatMap(chunk => chunk.world.spawn).filter(spawn => spawn.kind === 'rover').map(spawn => { - const range = spawn.patrol; - if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) { - throw new Error(`Invalid rover patrol: ${spawn.id}`); + const spawns = chunks.flatMap(chunk => chunk.world.spawn); + const make = (spawn: Spawn): Row | undefined => { + const kind = spawn.kind as Row['kind']; + if (!(kind in KINDS) || (kind !== 'rover' && !scene.textures.exists(KINDS[kind].atlas))) return undefined; + const patrol = patrolOf(spawn); + const at = { x: spawn.x, y: spawn.y }; + let row: Omit; + if (kind === 'rover') { + const model = new Rover(at, patrol); + row = { model, harmful: () => true, onBlocked: () => model.deflect(model.x >= this.player.body.x ? 1 : -1) }; + } else if (kind === 'crab') { + const model = new Crab(at, patrol); + row = { model, harmful: () => model.phase !== 'collapse', attack: () => model.attack }; + } else { + const model = new Drone(at, patrol); + row = { model, harmful: () => model.dangerous }; } - const model = new Rover({ x: spawn.x, y: spawn.y }, range as [number, number]); - const sprite = scene.add.sprite(model.x, model.y, 'enemy-rover', 'idle-00').setOrigin(0.5, 1).setDepth(101); + const model = row.model; + const sprite = scene.add.sprite(model.x, model.y, KINDS[kind].atlas, model.pose).setOrigin(0.5, 1).setDepth(101); const label = scene.add.text(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2, '', { fontFamily: 'monospace', fontSize: '14px', backgroundColor: '#17242d' }).setDepth(102).setVisible(false); cleanup.push(chair.register({ id: spawn.id!, active: () => model.health > 0, bounds: () => model.bounds, hit: hit => model.hit(hit.damage, hit.direction) })); cleanup.push(player.life.register(spawn.id!, () => model.reset())); - return { id: spawn.id!, model, sprite, label }; - }); + return { ...row, id: spawn.id!, kind, sprite, label }; + }; + this.rows = spawns.map(make).filter((row): row is Row => !!row); const toggle = () => { this.visible = !this.visible; for (const row of this.rows) row.label.setVisible(this.visible); }; scene.input.keyboard?.on('keydown-F2', toggle); scene.events.once('shutdown', () => { scene.input.keyboard?.off('keydown-F2', toggle); cleanup.forEach(off => off()); }); } update(milliseconds: number) { this.debug.clear(); - for (const { id, model, sprite, label } of this.rows) { - const defeated = model.step(milliseconds / 1000, this.player.life.dead === 0 ? this.player.body : undefined); - if (defeated) gameEvents.emit('enemy-defeated', { id, kind: 'rover', position: { x: model.x, y: model.y } }); - sprite.setPosition(Math.round(model.x), model.y).setFrame(model.pose) - .setFlipX(model.direction > 0); // Approved charge has lidar left / trailing dust right. - if (model.phase === 'hit') sprite.setTintFill(0xffffff); else sprite.clearTint(); - const box = model.bounds, body = this.player.body; - if (model.health > 0 && intersects(box, { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT })) { - this.player.damage(1, model.x, () => model.deflect(model.x >= body.x ? 1 : -1)); + const body = this.player.body; + const hero = { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT }; + for (const row of this.rows) { + const { id, kind, model, sprite, label } = row; + const before = model.phase; + const defeated = model.step(milliseconds / 1000, this.player.life.dead === 0 ? body : undefined); + if (defeated) gameEvents.emit('enemy-defeated', { id, kind, position: { x: model.x, y: model.y } }); + // The tells get a sound: the crab's servos as its legs rise, the drone's hum as it tilts. + if (before !== model.phase && model.phase === 'tell' && kind !== 'rover') sound.play(KINDS[kind].sound); + if (this.scene.textures.get(KINDS[kind].atlas).has(model.pose)) sprite.setFrame(model.pose); + sprite.setPosition(Math.round(model.x), Math.round(model.y)).setFlipX(KINDS[kind].flip(model.direction)) + .setVisible(model.phase !== 'dead' || kind !== 'drone'); + if (model.phase === 'hit') sprite.setTintFill(0xffffff); + else if (model.phase === 'tell' && kind !== 'rover' && Math.floor(this.scene.time.now / 90) % 2 === 0) sprite.setTint(0xff8a7a); + else sprite.clearTint(); + if (model.health > 0 && row.harmful()) { + const reach = row.attack?.(); + if (intersects(model.bounds, hero) || (reach && intersects(reach, hero))) this.player.damage(1, model.x, row.onBlocked); + } + label.setPosition(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2).setText(`${id} ${model.health}/${KINDS[kind].max} ${model.phase}`); + if (this.visible && model.health > 0) { + const box = model.bounds; + this.debug.lineStyle(1, 0xff7835).strokeRect(box.x, box.y, box.w, box.h); + const reach = row.attack?.(); + if (reach) this.debug.lineStyle(1, 0xff3030).strokeRect(reach.x, reach.y, reach.w, reach.h); } - label.setPosition(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2).setText(`${id} ${model.health}/3 ${model.phase}`); - if (this.visible && model.health > 0) this.debug.lineStyle(1, 0xff7835).strokeRect(box.x, box.y, box.w, box.h); } } } diff --git a/game/src/main.ts b/game/src/main.ts index 6f1f54f..1b742f6 100644 --- a/game/src/main.ts +++ b/game/src/main.ts @@ -1,5 +1,7 @@ import Phaser from 'phaser'; -import { Boot, Preload, Stage, UI } from './core/scaffold'; +import { Boot } from './boot/Boot'; +import { Preload } from './boot/Preload'; +import { Stage, UI } from './core/scaffold'; import { displayScale, VIEW_HEIGHT, VIEW_WIDTH } from './core/viewport'; import { LANE_A_SCENES } from './scenes/registry'; import './style.css'; @@ -21,6 +23,8 @@ function resize() { game.scale.setZoom(displayScale(window.innerWidth, window.innerHeight)); } game.events.once(Phaser.Core.Events.READY, resize); +// Dev builds: the console can reach the game (`game.scene.getScene('Stage')`) for checks and debugging. +if (import.meta.env.DEV) (window as unknown as { game: Phaser.Game }).game = game; window.addEventListener('resize', resize); if (import.meta.hot) import.meta.hot.dispose(() => { window.removeEventListener('resize', resize); diff --git a/game/src/player/animation.test.ts b/game/src/player/animation.test.ts index c429cb0..cef89b5 100644 --- a/game/src/player/animation.test.ts +++ b/game/src/player/animation.test.ts @@ -8,9 +8,22 @@ const tick = (machine: LocomotionAnimation, pose: MotionPose, frames: number) => for (let i = 0; i < frames; i++) machine.update(pose, 1 / 12); }; describe('stepped locomotion animation', () => { + it('plays the walk and run cycles by distance, so the planted foot never slides', () => { + expect(clips.walk.stride).toBe(43); expect(clips.run.stride).toBe(82); + const machine = new LocomotionAnimation(clips); + tick(machine, 'walk', 4); expect(machine.clip).toBe('walk'); + const start = machine.frame; + // 43 px per 8-frame cycle: 5.375 px per frame. Travel 4 frames' worth at any speed and the cycle moves 4 frames. + for (const speed of [40, 94.6]) { + const from = machine.frame; + for (let t = 0; t < 4 * 5.375 / speed - 1e-6; t += 1 / 240) machine.update('walk', 1 / 240, speed); + expect((machine.frame - from + 8) % 8).toBe(4); + } + expect(machine.frame).toBe(start); + }); it('uses the actual movement animation tables', () => { expect(clips.walk.frames).toBe(8); expect(clips.run.frames).toBe(8); - expect(clips.land.start).toBe(8); + expect(clips.land.start).toBe(7); expect(clips.land.frames).toBe(2); // the jump row's own crouch and stand }); it('plays idle-to-walk and only changes a walk cycle at a contact pose', () => { const machine = new LocomotionAnimation(clips); @@ -29,13 +42,69 @@ describe('stepped locomotion animation', () => { expect(machine.update('jump', 1 / 12).frame).toBe('jump-01'); expect(machine.update('jump', 1 / 120).frame).toBe('jump-01'); expect(machine.update('fall', 0).frame).toBe('fall-00'); - expect(machine.update('land', 0)).toEqual({ board: 'hero-air', frame: 'jump-08' }); + expect(machine.update('land', 0)).toEqual({ board: 'hero-air', frame: 'jump-07' }); + expect(machine.update('land', 1 / 12)).toEqual({ board: 'hero-air', frame: 'jump-08' }); }); - it('starts a random idle variant after five seconds and interrupts it for movement', () => { - const machine = new LocomotionAnimation(clips, () => 0.5); + it('starts an idle variant where the idle loops, after 5–9 s, and interrupts it for movement', () => { + const machine = new LocomotionAnimation(clips, () => 0); tick(machine, 'idle', 59); expect(machine.clip).toBe('idle'); - tick(machine, 'idle', 2); expect(machine.clip).toBe('check-tablet'); + tick(machine, 'idle', 2); expect(machine.clip).toBe('look-around'); expect(machine.update('walk', 0).frame).toBe('idle-walk-00'); + const later = new LocomotionAnimation(clips, () => 0.5); // 5 + 0.5 × 4 = 7 s + tick(later, 'idle', 80); expect(later.clip).toBe('idle'); + tick(later, 'idle', 6); expect(later.clip).toBe('check-tablet'); + }); + it('holds each variant on its key pose, never repeats one, and checks the wounds when hurt', () => { + const machine = new LocomotionAnimation(clips, () => 0); + tick(machine, 'idle', 61); expect(machine.clip).toBe('look-around'); + const frames: string[] = []; + for (let i = 0; i < 48; i++) frames.push(machine.update('idle', 1 / 24).frame); + expect(frames.filter(f => f === 'look-around-02').length).toBeGreaterThanOrEqual(20); // 0.9 s held + expect(machine.clip).toBe('idle'); + tick(machine, 'idle', 61); expect(machine.clip).toBe('check-tablet'); // not look-around again + const hurt = new LocomotionAnimation(clips, () => 0); hurt.wounded = true; + tick(hurt, 'idle', 61); expect(hurt.clip).toBe('check-wounds'); + // check-wounds ends with the hand out: it plays back to its first frame before the idle resumes. + const out: string[] = []; + for (let i = 0; i < 60 && hurt.clip === 'check-wounds'; i++) out.push(hurt.update('idle', 1 / 24).frame); + expect(out.at(-2)).toBe('check-wounds-00'); expect(out.at(-1)).toBe('idle-00'); + }); + it('breathes: the chest is up for two idle loops and down for two, changing only at the loop', () => { + const machine = new LocomotionAnimation(clips, () => 0.99); + const breath: number[] = []; + for (let i = 0; i < 48; i++) { machine.update('idle', 1 / 12); breath.push(machine.breath); } + expect(breath.slice(0, 11).every(b => b === 0)).toBe(true); + expect(breath.slice(12, 23).every(b => b === 1)).toBe(true); + expect(breath.slice(24, 35).every(b => b === 0)).toBe(true); + breath.forEach((b, i) => { if (i && b !== breath[i - 1]) expect(machine.frame === 0 || i % 6 === 0).toBe(true); }); + machine.update('walk', 0); expect(machine.breath).toBe(0); + }); + it('keeps the stride through a step between deck plates instead of flashing the fall pose', () => { + const machine = new LocomotionAnimation(clips); + tick(machine, 'run', 8); expect(machine.clip).toBe('run'); + const air = { vy: 60, launch: 485 }; + for (let i = 0; i < 6; i++) expect(machine.update('fall', 1 / 60, 198, air).frame).toMatch(/^run-/); + machine.update('run', 1 / 60, 198); expect(machine.clip).toBe('run'); + }); + it('lands a moving hero straight into the cycle it is in, never through idle and run-start', () => { + const machine = new LocomotionAnimation(clips); + const air = (vy: number) => ({ vy, launch: 485 }); + machine.update('run', 0, 198); + for (let vy = -485; vy < 300; vy += 20) machine.update(vy < 0 ? 'jump' : 'fall', 1 / 60, 198, air(vy)); + expect(machine.update('land', 1 / 60, 198).frame).toBe('jump-07'); + const clipsSeen = new Set(); + for (let i = 0; i < 20; i++) { machine.update('run', 1 / 60, 198); clipsSeen.add(machine.clip); } + expect([...clipsSeen]).toEqual(['run']); + expect(machine.frame).toBeGreaterThan(0); // the cycle is advancing + }); + it('jumps out of a landing without a second crouch, and shows a fresh crouch for one rendered frame', () => { + const air = (vy: number) => ({ vy, launch: 485 }); + const machine = new LocomotionAnimation(clips); + expect(machine.update('jump', 1 / 60, 0, air(-485)).frame).toBe('jump-00'); + expect(machine.update('jump', 1 / 60, 0, air(-445)).frame).toBe('jump-01'); + for (let vy = -425; vy < 400; vy += 20) machine.update(vy < 0 ? 'jump' : 'fall', 1 / 60, 0, air(vy)); + machine.update('land', 1 / 60); + expect(machine.update('jump', 1 / 60, 0, air(-485)).frame).toBe('jump-01'); }); it('lets air and ladder poses interrupt a bridge immediately', () => { const machine = new LocomotionAnimation(clips); diff --git a/game/src/player/animation.ts b/game/src/player/animation.ts index b7941c7..b8eec86 100644 --- a/game/src/player/animation.ts +++ b/game/src/player/animation.ts @@ -4,6 +4,24 @@ export type Catalogue = Record; export type MotionPose = 'idle' | 'walk' | 'run' | 'jump' | 'fall' | 'ladder' | 'land' | 'hard-landing'; const VARIANTS = ['look-around', 'check-tablet', 'check-wounds']; const LOOPS = ['idle', 'walk', 'run', 'fall', 'ladder']; +/** Clips the feet are on the ground in: the cycles and the bridges between them. */ +const GROUND = ['idle', 'walk', 'run', 'idle-walk', 'walk-run', 'run-start']; +/** + * Seconds of air a walk-off keeps its stride for: the creek's deck plates and bank blocks meet with 2–5 px gaps + * and 10–50 px steps, and each one used to flash the fall pose, a landing crouch and a run-start from a stand + * (the Rusty Creek fidget). A drop shorter than this (about 8 px) is a step, not a fall. + */ +export const FALL_GRACE = 0.12; +/** Idle variants: the key pose each holds (frame, s), and whether it plays back out (its last frame is not a stand). */ +export const VARIANT_HOLD: Record = { + 'look-around': { frame: 2, hold: 0.9 }, + 'check-tablet': { frame: 2, hold: 1.4 }, + 'check-wounds': { frame: 3, hold: 0.7, back: true }, +}; +/** Seconds of standing before an idle variant: the first wait, plus a random extra up to the second. */ +export const VARIANT_DELAY = [5, 4]; +/** Idle loops (0.5 s each) per half breath: the chest rises for two loops and settles for two. */ +export const BREATH_LOOPS = 2; type Step = { clip: string; reverse?: boolean }; export function catalogue(tables: Record): Catalogue { @@ -11,12 +29,40 @@ export function catalogue(tables: Record): Catalogue { for (const [board, table] of Object.entries(tables)) for (const [name, clip] of Object.entries(table)) { clips[name] ??= { ...clip, board, name }; } + // The jump row ends in its own landing: a crouch, then the stand (jump-07/08), played at the row's fps. const jump = clips.jump; - clips.land = jump ? { ...jump, start: jump.frames - 1, frames: 1, fps: 6, loop: false } - : { ...clips.idle, frames: 1, fps: 6, loop: false }; + clips.land = jump ? { ...jump, start: Math.max(0, jump.frames - LAND_FRAMES), frames: Math.min(LAND_FRAMES, jump.frames), loop: false } + : { ...clips.idle, frames: 1, loop: false }; return clips; } +/** Frames at the end of the jump row that are its landing (crouch, stand). */ +export const LAND_FRAMES = 2; +/** Frames of the jump row: 0 anticipation, 1–4 rise to the apex, 5–6 descent, then the landing. */ +export const JUMP_APEX = 4; +/** A clip's played length in seconds at its own fps (anticipation holds included). */ +export function clipLength(clip: AnimationClip, name = '') { + let total = 0; + for (let n = 0; n < clip.frames; n++) total += holdFrames(name, n) / clip.fps; + return total; +} +/** Strong poses held on twos (docs/09 'Stepped timing'): the jump's crouch and the hard landing's impact. */ +function holdFrames(clip: string, frame: number) { + return (clip === 'jump' || clip === 'hard-landing') && frame === 0 ? 2 : 1; +} +/** + * The jump row's frame for a body in the air, by where it is on its arc rather than by time, so the apex pose is + * at the apex for every jump height and a walk-off ledge drops into the descent: rising maps vy −launch…0 onto + * frames 1…apex, falling maps 0…launch onto the descent frames; beyond a jump's own fall, null (the fall loop). + */ +export function arcFrame(vy: number, launch: number, frames: number) { + const descent = Math.max(JUMP_APEX + 1, frames - LAND_FRAMES - 1); + if (vy < 0) return 1 + Math.min(JUMP_APEX - 1, Math.floor((1 - Math.min(1, -vy / launch)) * JUMP_APEX)); + const t = vy / launch; + if (t >= 1) return null; + return Math.min(descent, JUMP_APEX + 1 + Math.floor(t * (descent - JUMP_APEX))); +} + /** Idle keeps planted feet. Moving rows use contact/down/passing/up twice: contacts 00/04. */ export class LocomotionAnimation { clip = 'idle'; @@ -26,7 +72,29 @@ export class LocomotionAnimation { private idleTime = 0; private finished = false; private queue: Step[] = []; - constructor(private clips: Catalogue, private random = Math.random) {} + private speed = 0; + private lastVy = 0; + private wasAirborne = false; + private airTime = 0; + private lastGround: MotionPose = 'idle'; + private idleLoops = 0; + private lastVariant = ''; + private variantDelay: number; + /** Set by the player at low health: the idle variant is then the check of the wounds. */ + wounded = false; + constructor(private clips: Catalogue, private random = Math.random) { this.variantDelay = this.nextDelay(); } + private nextDelay() { return VARIANT_DELAY[0] + this.random() * VARIANT_DELAY[1]; } + /** 1 while the chest is up: two idle loops in, two out, changing only where the idle clip loops (no pop mid-frame). */ + get breath() { return this.clip === 'idle' && Math.floor(this.idleLoops / BREATH_LOOPS) % 2 === 1 ? 1 : 0; } + /** Standing still: the idle loop or one of its variants (where the poncho may sway). */ + get standing() { return this.clip === 'idle' || VARIANTS.includes(this.clip); } + private pickVariant() { + const fresh = VARIANTS.filter(name => this.clips[name] && name !== this.lastVariant); + if (this.wounded && fresh.includes('check-wounds')) return 'check-wounds'; + const calm = fresh.filter(name => name !== 'check-wounds'); + const pool = calm.length ? calm : fresh; + return pool[Math.min(pool.length - 1, Math.floor(this.random() * pool.length))]; + } private metadata() { return this.clips[this.clip] ?? this.clips.idle; } private set(step: Step) { this.clip = step.clip; this.reverse = !!step.reverse; @@ -40,8 +108,16 @@ export class LocomotionAnimation { } if (this.queue.length) return; if (VARIANTS.includes(this.clip) && desired === 'idle' && !this.finished) return; - if (['land', 'hard-landing'].includes(this.clip) && !this.finished) return; - if (!['idle', 'walk', 'run'].includes(this.clip)) this.set({ clip: 'idle' }); + // A landing gives way to movement once its first pose has shown (a hard landing: its held impact); standing + // still, it plays out. + if (this.clip === 'land' && !this.finished && desired === 'idle') return; + if (this.clip === 'hard-landing' && !this.finished && (desired === 'idle' || this.frame < 1)) return; + // Out of the air, off a ladder or out of a landing: straight into the cycle the body is in, at its contact + // pose – never through the standing idle and a run-start while the body is already moving. + if (!['idle', 'walk', 'run'].includes(this.clip)) { + this.set({ clip: VARIANTS.includes(this.clip) || !this.clips[desired] ? 'idle' : desired }); + if (this.clip === desired) return; + } if (this.clip === desired) return; if (this.clip !== 'idle' && this.frame !== 0 && this.frame !== 4) return; const plans: Record = { @@ -55,30 +131,79 @@ export class LocomotionAnimation { const plan = plans[`${this.clip}:${desired}`]; if (plan) { this.queue = [...plan]; this.set(this.queue.shift()!); } } + /** Seconds this frame shows. A cycle with a measured stride advances by distance: one frame per stride/frames px. */ private duration() { - const hold = this.clip === 'jump' && this.frame === 0 || this.clip === 'hard-landing' && this.frame === 0; - return (hold ? 2 : 1) / Math.min(12, this.metadata().fps); + const clip = this.metadata(); + if (clip.stride && this.speed > 1) return clip.stride / clip.frames / this.speed; + const hold = VARIANT_HOLD[this.clip]; + if (hold && !this.reverse && this.frame === hold.frame) return hold.hold; + return holdFrames(this.clip, this.frame) / clip.fps; + } + /** Jump: hold the crouch on the ground frame, then follow the arc; fall: the descent, then the fall loop. */ + private arc(desired: MotionPose, dt: number, air: { vy: number; launch: number }) { + const relaunch = air.vy < 0 && this.lastVy >= 0; // a pogo bounce or a fresh jump from the fall loop + this.lastVy = air.vy; + if (this.clip !== 'jump' && this.clip !== 'fall' || this.clip === 'fall' && relaunch) { + // Only a jump from the ground shows the crouch; a walk-off or a bounce starts on the arc, and so does a jump + // out of a landing, whose own crouch was the anticipation. + const crouched = this.clip === 'land' || this.clip === 'hard-landing'; + this.queue = []; this.set({ clip: 'jump' }); + if (desired !== 'jump' || relaunch && this.wasAirborne || crouched) this.frame = 1; + } else if (relaunch) this.frame = 1; + this.wasAirborne = true; + if (this.clip === 'fall') return null; // the fall loop keeps looping until landing + // The crouch shows for one rendered frame: the body is already rising, and holding it for a 12 fps frame + // (5 rendered frames, ~25 px of rise) read as a jump that starts late – the jump-spam complaint. + const crouch = this.frame === 0 && this.elapsed === 0; + this.elapsed += Math.min(dt, 0.1); + if (crouch) return this.pose(); + const frame = arcFrame(air.vy, air.launch, this.metadata().frames); + if (frame === null) { this.set({ clip: 'fall' }); return null; } + // Never step backwards along the arc: a jump cut short goes from its own apex straight into the descent. + this.frame = Math.max(this.frame, frame); this.elapsed = 0; + return this.pose(); } private advance() { const next = this.frame + (this.reverse ? -1 : 1); if (next >= 0 && next < this.metadata().frames) { this.frame = next; return; } if (this.queue.length) this.set(this.queue.shift()!); - else if (LOOPS.includes(this.clip) && this.metadata().loop) this.frame = 0; - else this.finished = true; + else if (LOOPS.includes(this.clip) && this.metadata().loop) { this.frame = 0; if (this.clip === 'idle') this.idleLoops++; } + else if (VARIANT_HOLD[this.clip]?.back && !this.reverse && this.metadata().frames > 1) { + this.reverse = true; this.frame = this.metadata().frames - 2; // back out to the stand + } else this.finished = true; } - update(desired: MotionPose, dt: number) { + /** + * `speed`: the body's horizontal speed in px/s, which paces the strided cycles. `air`: the body's vertical + * speed and the jump's launch speed; with it, jump and fall follow the arc (see arcFrame). + */ + update(desired: MotionPose, dt: number, speed = 0, air?: { vy: number; launch: number }) { + this.speed = speed; + // A short walk-off (a step between deck plates) keeps the stride instead of flashing the fall pose. + this.airTime = desired === 'fall' ? this.airTime + Math.min(dt, 0.1) : 0; + if (desired === 'fall' && this.airTime <= FALL_GRACE && GROUND.includes(this.clip) && !this.wasAirborne + && (!air || air.vy >= 0)) desired = this.lastGround; + else if (desired === 'idle' || desired === 'walk' || desired === 'run') this.lastGround = desired; + if (air && (desired === 'jump' || desired === 'fall') && this.clips.jump) { + const pose = this.arc(desired, dt, air); + if (pose) return pose; + } else if (desired !== 'jump' && desired !== 'fall') { this.lastVy = 0; this.wasAirborne = false; } + if (desired !== 'idle') this.idleLoops = 0; this.idleTime = desired === 'idle' ? this.idleTime + dt : 0; this.request(desired); - if (this.clip === 'idle' && desired === 'idle' && this.idleTime >= 5) { - const available = VARIANTS.filter(name => this.clips[name]); - if (available.length) this.set({ clip: available[Math.min(available.length - 1, Math.floor(this.random() * available.length))] }); - this.idleTime = 0; + // A variant starts where the idle loops (idle-05 → variant-00), never the same one twice running. + if (this.clip === 'idle' && desired === 'idle' && this.idleTime >= this.variantDelay && this.frame === 0) { + const pick = this.pickVariant(); + if (pick) { this.set({ clip: pick }); this.lastVariant = pick; } + this.idleTime = 0; this.variantDelay = this.nextDelay(); } this.elapsed += Math.min(dt, 0.1); for (let n = 0; n < 12 && !this.finished && this.elapsed + 1e-9 >= this.duration(); n++) { const remainder = Math.max(0, this.elapsed - this.duration()); this.advance(); this.request(desired); this.elapsed = remainder; } + return this.pose(); + } + private pose() { const metadata = this.metadata(); return { board: metadata.board, frame: `${metadata.name}-${String((metadata.start ?? 0) + this.frame).padStart(2, '0')}` }; } diff --git a/game/src/player/body.ts b/game/src/player/body.ts index 3a30c7d..bdebe87 100644 --- a/game/src/player/body.ts +++ b/game/src/player/body.ts @@ -1,30 +1,107 @@ -import type { Box, Geometry } from '../stage/collision'; +import type { Box, Geometry, Point } from '../stage/collision'; import { GRAVITY } from './jump'; export const BODY_WIDTH = 28; export const BODY_HEIGHT = 76; +const HALF = BODY_WIDTH / 2; +/** A rise up to this many px is a step: the grounded body walks up (and down) it without a jump. */ +export const STEP_UP = 16; +/** + * A line end with nothing under it is a ledge face. It stops a body whose feet are more than STEP_UP and at + * most FACE_REACH below its top – higher ledges are background the hero walks in front of. + */ +export const FACE_REACH = 100; +/** A floor this far under a line's end means the end is an overhang the body passes under, not a face. */ +export const FACE_DEPTH = 120; +/** A falling body lands on a floor whose end is at most this far from its centre (half the 28 px body, less a margin). */ +export const FOOTHOLD = 8; export interface Body { x: number; y: number; vx: number; vy: number; grounded: boolean; slope?: number; sliding?: boolean } export function overlaps(body: Body, box: Box) { - return body.x + 14 > box.x && body.x - 14 < box.x + box.w + return body.x + HALF > box.x && body.x - HALF < box.x + box.w && body.y > box.y && body.y - BODY_HEIGHT < box.y + box.h; } -/** Feet origin and continuous polylines; one-way policy follows separately. */ +interface Floor { line: Point[]; slide: boolean } +/** Height of a polyline at x and the slope there, or null outside it. */ +export function heightAt(line: Point[], x: number): { y: number; slope: number } | null { + for (let i = 1; i < line.length; i++) { + const [a, b] = [line[i - 1], line[i]]; + if (x < a[0] || x > b[0]) continue; + const slope = (b[1] - a[1]) / (b[0] - a[0]); + return { y: a[1] + (x - a[0]) * slope, slope }; + } + return null; +} +function floors(geometry: Geometry[], ignoreOneWay: boolean): Floor[] { + return geometry.flatMap(world => [ + ...world.walk.map(line => ({ line, slide: false })), + ...world.slide.map(line => ({ line, slide: true })), + ...(ignoreOneWay ? [] : world.oneWay.map(line => ({ line, slide: false }))), + ]); +} + +/** + * The ledge faces of the terrain: the ends of walk and slide lines that nothing passes under. A tier of the + * terrace, the wall top after the stepping stones, a raised trail whose lower trail stops at its foot – each is a + * wall up to its top. A raised platform with the trail running on in front of it (the bench) is not. + */ +export type Face = { x: number; top: number; side: -1 | 1 }; +const cache = new WeakMap(); +export function faces(geometry: Geometry[]): Face[] { + if (!geometry.length) return []; + const hit = cache.get(geometry[0]); + if (hit && hit.worlds.length === geometry.length && hit.worlds.every((w, i) => w === geometry[i])) return hit.faces; + const out = findFaces(geometry); + cache.set(geometry[0], { worlds: [...geometry], faces: out }); + return out; +} +function findFaces(geometry: Geometry[]): Face[] { + const all = geometry.flatMap(world => [...world.walk, ...world.slide]); + const under = [...all, ...geometry.flatMap(world => world.oneWay)]; + const out: Face[] = []; + for (const line of all) for (const [end, side] of [[line[0], -1], [line[line.length - 1], 1]] as const) { + const inside = end[0] - side; + const passes = under.some(other => { + if (other === line) return false; + const h = heightAt(other, inside)?.y; + return h !== undefined && h > end[1] + STEP_UP && h <= end[1] + FACE_DEPTH; + }); + const continues = under.some(other => other !== line && Math.abs((heightAt(other, end[0] + side)?.y ?? -1e9) - end[1]) <= STEP_UP); + if (!passes && !continues) out.push({ x: end[0], top: end[1], side }); + } + return out; +} + +/** + * Feet origin. Floors are polylines; the body walks along them, steps up and down rises of STEP_UP or less, + * is stopped by solids and ledge faces, and slides only on authored `slide` lines. + */ export function stepBody(body: Body, geometry: Geometry[], dt: number, ignoreOneWay = false) { const oldX = body.x, oldY = body.y; const wasGrounded = body.grounded; - const oldSlope = body.slope ?? 0; if (body.sliding && wasGrounded && body.vy >= 0) { const slope = body.slope ?? 0; const acceleration = GRAVITY * slope / (1 + slope * slope); body.vx = Math.max(-420, Math.min(420, body.vx + acceleration * dt)); } body.x += body.vx * dt; - for (const world of geometry) for (const wall of world.solid) { + const solids = geometry.flatMap(world => world.solid); + for (const wall of solids) { if (oldY <= wall.y || oldY - BODY_HEIGHT >= wall.y + wall.h) continue; - if (body.vx > 0 && oldX + 14 <= wall.x && body.x + 14 > wall.x) { - body.x = wall.x - 14; body.vx = 0; - } else if (body.vx < 0 && oldX - 14 >= wall.x + wall.w && body.x - 14 < wall.x + wall.w) { - body.x = wall.x + wall.w + 14; body.vx = 0; + // A solid whose top is a step above grounded feet is walked onto, not into. + if (wasGrounded && body.vy >= 0 && oldY - wall.y <= STEP_UP) continue; + if (body.vx > 0 && oldX + HALF <= wall.x && body.x + HALF > wall.x) { + body.x = wall.x - HALF; body.vx = 0; + } else if (body.vx < 0 && oldX - HALF >= wall.x + wall.w && body.x - HALF < wall.x + wall.w) { + body.x = wall.x + wall.w + HALF; body.vx = 0; + } + } + for (const face of faces(geometry)) { + const rise = oldY - face.top; + if (rise <= (wasGrounded ? STEP_UP : 0.5) || rise > FACE_REACH) continue; + if (face.side < 0 && body.vx > 0 && oldX + HALF <= face.x && body.x + HALF > face.x) { + body.x = face.x - HALF; body.vx = 0; + } else if (face.side > 0 && body.vx < 0 && oldX - HALF >= face.x && body.x - HALF < face.x) { + body.x = face.x + HALF; body.vx = 0; } } body.vy = Math.min(900, body.vy + GRAVITY * dt); @@ -32,27 +109,40 @@ export function stepBody(body: Body, geometry: Geometry[], dt: number, ignoreOne body.grounded = false; body.sliding = false; body.slope = 0; - for (const world of geometry) { - for (const line of [...world.walk, ...world.slide, ...(ignoreOneWay ? [] : world.oneWay)]) for (let i = 1; i < line.length; i++) { - const [a, b] = [line[i - 1], line[i]]; - if (body.x < a[0] || body.x > b[0]) continue; - const slope = (b[1] - a[1]) / (b[0] - a[0]); - const surface = a[1] + (body.x - a[0]) * slope; - const crossedEnd = oldX < a[0] && body.x >= a[0] || oldX > b[0] && body.x <= b[0]; - // The authored chunk seams differ by up to 5px. Only bridge that small - // endpoint mismatch while already grounded, never snap a falling body up. - const follows = wasGrounded && Math.abs(surface - oldY) - <= Math.abs(body.x - oldX) * Math.max(Math.abs(slope), Math.abs(oldSlope)) + (crossedEnd ? 6 : 0.1); - if (body.vy >= 0 && (follows || oldY <= surface + 0.01 && nextY >= surface)) { - nextY = surface; body.vy = 0; body.grounded = true; body.slope = slope; - body.sliding = Math.abs(Math.atan(slope)) > 40 * Math.PI / 180 + 1e-9; - } + const surfaces: { y: number; slope: number; slide: boolean }[] = []; + const holds: typeof surfaces = []; + for (const floor of floors(geometry, ignoreOneWay)) { + const h = heightAt(floor.line, body.x); + if (h) surfaces.push({ ...h, slide: floor.slide }); + else { + // Feet overhang: with no floor under the centre, a floor end up to FOOTHOLD px away still holds (a stepping stone's edge). + const [first, last] = [floor.line[0], floor.line[floor.line.length - 1]]; + const end = body.x < first[0] && first[0] - body.x <= FOOTHOLD ? first : body.x > last[0] && body.x - last[0] <= FOOTHOLD ? last : null; + if (end) holds.push({ y: end[1], slope: 0, slide: floor.slide }); } - for (const wall of world.solid) { - if (body.x + 14 <= wall.x || body.x - 14 >= wall.x + wall.w) continue; - if (body.vy >= 0 && oldY <= wall.y && nextY >= wall.y) { - nextY = wall.y; body.vy = 0; body.grounded = true; body.slope = 0; body.sliding = false; - } else if (body.vy < 0 && oldY - BODY_HEIGHT >= wall.y + wall.h && nextY - BODY_HEIGHT < wall.y + wall.h) { + } + if (!surfaces.length) surfaces.push(...holds); + for (const wall of solids) { + if (body.x + HALF > wall.x && body.x - HALF < wall.x + wall.w) surfaces.push({ y: wall.y, slope: 0, slide: false }); + } + let contact: { y: number; slope: number; slide: boolean } | undefined; + if (body.vy >= 0) { + if (wasGrounded) { + // Stay on the ground: follow the floor, step down a step, or step up onto the highest floor a step above. + const reach = STEP_UP + Math.abs(body.x - oldX) * 1.2 + 0.1; + contact = surfaces.filter(s => Math.abs(s.y - oldY) <= reach) + .sort((a, b) => a.y - b.y)[0]; + } + // Falling: land on the first floor the feet cross from above. + contact ??= surfaces.filter(s => oldY <= s.y + 0.01 && nextY >= s.y).sort((a, b) => a.y - b.y)[0]; + } + if (contact) { + nextY = contact.y; body.vy = 0; body.grounded = true; body.slope = contact.slope; + body.sliding = contact.slide && Math.abs(contact.slope) > 0.05; + } else if (body.vy < 0) { + for (const wall of solids) { + if (body.x + HALF <= wall.x || body.x - HALF >= wall.x + wall.w) continue; + if (oldY - BODY_HEIGHT >= wall.y + wall.h && nextY - BODY_HEIGHT < wall.y + wall.h) { nextY = wall.y + wall.h + BODY_HEIGHT; body.vy = 0; } } diff --git a/game/src/player/dust.ts b/game/src/player/dust.ts new file mode 100644 index 0000000..f87f0f6 --- /dev/null +++ b/game/src/player/dust.ts @@ -0,0 +1,76 @@ +import type Phaser from 'phaser'; +import type { Puff } from './surfaces'; + +export type PuffKind = 'step' | 'land' | 'hard-land'; +export interface Particle { dx: number; dy: number; size: [number, number]; color: string; life: number; front: boolean; fall: number } + +/** How much a step, a landing and a hard landing kick up, as multiples of the surface's `count`. */ +const SCALE: Record = { + step: { count: 1, spread: 8, rise: 7 }, + land: { count: 2, spread: 16, rise: 9 }, + 'hard-land': { count: 3.5, spread: 26, rise: 14 }, +}; + +/** + * The particles of one puff, as offsets from the foot (pure, for tests). A step kicks backwards (against + * `direction`); a landing spreads to both sides. Blades and leaves are taller and drift down after the rise. + */ +export function puffParticles(puff: Puff, kind: PuffKind, direction: number, random = Math.random): Particle[] { + const scale = SCALE[kind]; + const count = Math.round(puff.count * scale.count); + return Array.from({ length: count }, (_, i) => { + const side = kind === 'step' ? -(Math.sign(direction) || 1) : i % 2 ? 1 : -1; + const dx = side * (2 + random() * scale.spread); + const dy = -(2 + random() * scale.rise); + const size: [number, number] = puff.shape === 'blade' ? [1, 3] : puff.shape === 'leaf' ? [2, 1] : [2, 2]; + return { dx, dy, size, color: puff.colors[i % puff.colors.length], life: 260 + random() * 220, + front: i % 2 === 0, fall: puff.shape === 'dust' ? 0 : 4 + random() * 4 }; + }); +} + +export const FX_BOARD = 'fx-ground'; + +/** + * Where the drawn puffs go (pure, for tests): a step kicks one puff back from the planted foot, mirrored against + * the walking direction; a landing kicks one to each side; a hard landing wider, with procedural debris on top. + */ +export function puffSprites(kind: PuffKind, direction: number) { + const back = -(Math.sign(direction) || 1); + if (kind === 'step') return [{ dx: back * 6, flip: back < 0 }]; + const spread = kind === 'hard-land' ? 14 : 8; + return [{ dx: -spread, flip: true }, { dx: spread, flip: false }]; +} + +/** Plays an fx-ground clip once at its anims.json fps; false when the atlas or clip is missing. */ +function playSprite(scene: Phaser.Scene, clip: string, x: number, y: number, flip: boolean, depth: number) { + const table = scene.cache.json.get(`${FX_BOARD}.anims`) as Record | undefined; + const meta = table?.[clip]; + if (!meta || !scene.textures.exists(FX_BOARD)) return false; + const key = `${FX_BOARD}:${clip}`; + if (!scene.anims.exists(key)) scene.anims.create({ key, frameRate: meta.fps, repeat: 0, + frames: scene.anims.generateFrameNames(FX_BOARD, { prefix: `${clip}-`, start: 0, end: meta.frames - 1, zeroPad: 2 }) }); + const sprite = scene.add.sprite(Math.round(x), Math.round(y), FX_BOARD, `${clip}-00`).setOrigin(0.5, 1) + .setFlipX(flip).setDepth(depth); + sprite.play(key); + sprite.once('animationcomplete', () => sprite.destroy()); + return true; +} + +/** The surface's drawn puff (fx-ground) when it exists, else procedural pixels in the surface palette. */ +export function spawnPuff(scene: Phaser.Scene, x: number, y: number, puff: Puff | undefined, kind: PuffKind, + direction: number, depth = 100) { + if (!puff) return; + let drawn = false; + if (puff.sprite) for (const s of puffSprites(kind, direction)) { + drawn = playSprite(scene, puff.sprite, x + s.dx, y, s.flip, depth + 1) || drawn; + } + if (drawn && kind !== 'hard-land') return; + for (const p of puffParticles(puff, kind, direction)) { + const pixel = scene.add.rectangle(Math.round(x), Math.round(y - 1), p.size[0], p.size[1], + Number.parseInt(p.color.slice(1), 16)).setOrigin(0.5, 1).setDepth(p.front ? depth + 1 : depth - 1); + scene.tweens.add({ targets: pixel, x: pixel.x + p.dx, duration: p.life, ease: 'Quad.Out' }); + scene.tweens.add({ targets: pixel, y: pixel.y + p.dy, duration: p.life * 0.45, ease: 'Quad.Out', + onComplete: () => scene.tweens.add({ targets: pixel, y: pixel.y + p.fall - p.dy * 0.4, alpha: 0, + duration: p.life * 0.55, ease: 'Quad.In', onComplete: () => pixel.destroy() }) }); + } +} diff --git a/game/src/player/feel.test.ts b/game/src/player/feel.test.ts new file mode 100644 index 0000000..4452333 --- /dev/null +++ b/game/src/player/feel.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { FALLBACK, parseSurfaces, SurfaceMap } from './surfaces'; +import { blades, coverRows, Grounding, sinkTarget } from './grounding'; +import { contactFrames, plantedFeet } from './footfall'; +import { puffParticles, puffSprites } from './dust'; +import { arcFrame, catalogue, clipLength, LocomotionAnimation } from './animation'; +import { HurtAnimation } from './hurt'; +import { JUMP_SPEED } from './jump'; +import { Life } from '../combat/life'; +import type { AnimationTable } from '../stage/assets'; + +const files = import.meta.glob('../../../art/stages/*/surfaces.json', { eager: true, import: 'default' }); +const art = (path: string) => files[`../../../art/stages/${path}`]; +const garden = parseSurfaces(art('hidden-garden/surfaces.json')); +const creek = parseSurfaces(art('rusty-creek/surfaces.json')); +const grid = (ids: string[]) => ({ chunkSize: [2160, 720] as [number, number], + chunks: ids.map((id, x) => ({ id, x, y: 0, states: {} })) }); +const tables = import.meta.glob('../../public/assets/sprites/hero*.anims.json', { eager: true, import: 'default' }); +const table = (board: string) => Object.entries(tables).find(([p]) => p.endsWith(`/${board}.anims.json`))![1]; +const clips = catalogue(Object.fromEntries(['hero-move', 'hero-run', 'hero-idle', 'hero-air'].map(b => [b, table(b)]))); + +describe('surfaces', () => { + const map = new SurfaceMap(garden, grid(['hg-0-0', 'hg-1-0', 'hg-2-0', 'hg-3-0', 'hg-4-0']), 'clean'); + it('looks up the authored material by world x, chunk-local ranges and y bands', () => { + expect(map.at(200, 460).name).toBe('stone'); // gate pavers + expect(map.at(1200, 470).name).toBe('grass'); + expect(map.at(1700, 388).name).toBe('wood'); // on the birch log (y band) + expect(map.at(1700, 446).name).toBe('stone'); // the slab beside it + expect(map.at(2160 + 550, 449).name).toBe('earth'); // under the bridge (y band) + expect(map.at(2160 + 550, 300).name).toBe('gravel'); // the path above it + expect(map.at(4320 + 1000, 146).name).toBe('wood'); // terrace roof + expect(map.at(4320 + 1000, 304).name).toBe('stone'); + expect(map.at(8640 + 1200, 441).name).toBe('wood'); // pier + }); + it('matches states, falls back to the default and without a file to the stone-like fallback', () => { + const rc = new SurfaceMap(creek, grid(['rc-0-0', 'rc-1-0', 'rc-2-0']), 'anomaly'); + expect(rc.at(600, 508).name).toBe('stone'); expect(rc.at(600, 461).name).toBe('steel'); + expect(new SurfaceMap(creek, grid(['rc-0-0']), 'clean').at(600, 508).name).toBe('steel'); + expect(new SurfaceMap(null, grid(['x']), 'clean').at(0, 0)).toBe(FALLBACK); + }); + it('rejects unknown materials and covers deeper than the sink', () => { + expect(() => parseSurfaces({ ...garden, chunks: { a: [{ x: [0, 1], material: 'lava' }] } })).toThrow(/lava/); + expect(() => parseSurfaces({ ...garden, materials: { ...garden.materials, grass: { sink: 1, cover: 3 } } })).toThrow(/cover/); + }); +}); + +describe('grounding', () => { + const grass = garden.materials.grass, stone = garden.materials.stone; + const input = { grounded: true, slope: 0, landing: 0, hard: false }; + it('sinks the feet by material (grass 3, stone 1), never in the air', () => { + expect(sinkTarget({ ...input, material: grass })).toBe(3); + expect(sinkTarget({ ...input, material: stone })).toBe(1); + expect(sinkTarget({ ...input, material: grass, grounded: false })).toBe(0); + }); + it('drops the lower foot onto a slope and settles a landing, hard deeper', () => { + expect(sinkTarget({ ...input, material: stone, slope: 0.25 })).toBe(3); + expect(sinkTarget({ ...input, material: stone, landing: 1 })).toBe(2); + expect(sinkTarget({ ...input, material: stone, landing: 1, hard: true })).toBe(3); + expect(sinkTarget({ ...input, material: stone, slope: 5 })).toBe(5); // capped at 4 px + }); + it('eases between surfaces instead of popping, and lifts out at once on take-off', () => { + const g = new Grounding(); + const offsets: number[] = []; + for (let i = 0; i < 4; i++) offsets.push(g.update({ ...input, material: stone }, 1 / 60).offset); + for (let i = 0; i < 30; i++) offsets.push(g.update({ ...input, material: grass }, 1 / 60).offset); + const steps = offsets.slice(1).map((o, i) => Math.abs(o - offsets[i])); + expect(Math.max(...steps)).toBeLessThanOrEqual(1); expect(offsets.at(-1)).toBe(3); + expect(g.update({ ...input, material: grass, grounded: false }, 1 / 60)).toEqual({ offset: 0, cover: 0 }); + }); + it('covers no more rows than the feet sank, and grass tufts stay put in the world', () => { + expect(coverRows({ ...input, material: grass }, 1)).toBe(1); + expect(coverRows({ ...input, material: grass }, 3)).toBe(2); + expect(coverRows({ ...input, material: stone }, 1)).toBe(0); + const a = blades(100, 12, 2, 3), b = blades(104, 12, 2, 3); + const at = (list: typeof a, x: number) => list.find(t => t.x === x); + for (let x = 92; x <= 112; x++) expect(at(a, x)).toEqual(at(b, x)); + expect(a.every(t => t.height >= 1 && t.height <= 4)).toBe(true); + }); +}); + +describe('footfalls', () => { + it('counts planted feet as opaque runs at least 6 px wide in the bottom rows', () => { + const row = (runs: [number, number][]) => Array.from({ length: 48 }, (_, x) => runs.some(([a, b]) => x >= a && x <= b) ? 255 : 0); + expect(plantedFeet([...row([[11, 19], [31, 37]]), ...row([])], 48)).toBe(2); + expect(plantedFeet(row([[13, 15]]), 48)).toBe(0); // a toe-off is not a step + }); + it('places footfalls where a foot lands: the measured walk 00/04 and run 01/05', () => { + expect(contactFrames([2, 1, 1, 1, 2, 2, 1, 1])).toEqual([0, 4]); // hero-move walk + expect(contactFrames([0, 1, 1, 0, 0, 1, 1, 0])).toEqual([1, 5]); // hero-run run + }); + it('kicks a step backwards and a landing to both sides, hard landings bigger', () => { + const puff = garden.materials.grass.puff!; + const step = puffParticles(puff, 'step', 1, () => 0.5); + expect(step).toHaveLength(5); expect(step.every(p => p.dx < 0 && p.dy < 0)).toBe(true); + const land = puffParticles(puff, 'land', 1, () => 0.5), hard = puffParticles(puff, 'hard-land', 1, () => 0.5); + expect(land.some(p => p.dx > 0) && land.some(p => p.dx < 0)).toBe(true); + expect(hard.length).toBeGreaterThan(land.length); + }); + it('places the drawn fx-ground puffs: one behind a step, mirrored pairs for landings', () => { + expect(puffSprites('step', 1)).toEqual([{ dx: -6, flip: true }]); + expect(puffSprites('step', -1)).toEqual([{ dx: 6, flip: false }]); + expect(puffSprites('hard-land', 1).map(s => s.dx)).toEqual([-14, 14]); + for (const m of Object.values(garden.materials)) expect(['dust', 'grass', 'leaf-kick', 'splash']).toContain(m.puff?.sprite); + }); +}); + +describe('clip timing from anims.json', () => { + it('derives landing lengths from the clips, holds included', () => { + expect(clipLength(clips.land, 'land')).toBeCloseTo(2 / clips.jump.fps); + expect(clipLength(clips['hard-landing'], 'hard-landing')).toBeCloseTo(5 / clips['hard-landing'].fps); + }); + it('follows the jump arc: rise frames 1–4 with the apex at vy 0, descent 5–6, then the fall loop', () => { + expect(arcFrame(-JUMP_SPEED, JUMP_SPEED, 9)).toBe(1); + expect(arcFrame(-1, JUMP_SPEED, 9)).toBe(4); + expect(arcFrame(10, JUMP_SPEED, 9)).toBe(5); + expect(arcFrame(JUMP_SPEED * 0.8, JUMP_SPEED, 9)).toBe(6); + expect(arcFrame(JUMP_SPEED * 1.2, JUMP_SPEED, 9)).toBeNull(); + }); + it('plays a jump crouch, the arc forwards only, and walks off a ledge straight into the descent', () => { + const m = new LocomotionAnimation(clips); + const air = (vy: number) => ({ vy, launch: JUMP_SPEED }); + expect(m.update('jump', 1 / 60, 0, air(-JUMP_SPEED)).frame).toBe('jump-00'); + const frames: number[] = []; + for (let vy = -JUMP_SPEED; vy < JUMP_SPEED * 1.1; vy += 10) frames.push(Number(m.update(vy < 0 ? 'jump' : 'fall', 1 / 60, 0, air(vy)).frame.slice(-2))); + expect(frames.every((f, i) => i === 0 || f >= frames[i - 1] || m.clip === 'fall')).toBe(true); + expect(m.clip).toBe('fall'); + const ledge = new LocomotionAnimation(clips); + ledge.update('walk', 1 / 60, 90); + // A step's worth of air (FALL_GRACE) keeps the stride; past it, the descent. + expect(ledge.update('fall', 1 / 60, 90, air(20)).frame).not.toMatch(/^jump/); + for (let i = 0; i < 7; i++) ledge.update('fall', 1 / 60, 90, air(20 + i * 20)); + expect(ledge.update('fall', 1 / 60, 90, air(180)).frame).toBe('jump-05'); + }); + it('plays the drawn hit clip at its fps instead of a tint, and the knockback for heavy hits', () => { + const hurt = new HurtAnimation(table('hero-hurt'), { death: { frames: 12, fps: 12, loop: false } }); + const life = new Life({ x: 0, y: 0 }); + life.hit(1); hurt.start(1); + expect(hurt.update(life, 0, false)?.frame).toBe('hit-00'); // the drawn flash + expect(hurt.update(life, 1 / 12, false)?.frame).toBe('hit-01'); + expect(hurt.alpha(life, 80)).toBe(1); // no blink while the clip plays + const heavy = new HurtAnimation(table('hero-hurt'), {}); + heavy.start(2); + expect(heavy.update(life, 0, false)?.frame).toBe('knockback-00'); + life.knock = 0; + expect(heavy.update(life, 0.3, false)?.frame).toBe('hit-03'); + expect(heavy.update(life, 0, true)).toBeNull(); // walking away ends the recovery + }); +}); diff --git a/game/src/player/footfall.ts b/game/src/player/footfall.ts new file mode 100644 index 0000000..79ed889 --- /dev/null +++ b/game/src/player/footfall.ts @@ -0,0 +1,70 @@ +import type Phaser from 'phaser'; +import type { Catalogue } from './animation'; + +/** A foot is a run of opaque pixels at least this wide in the frame's bottom rows (a 3 px toe-off is not a step). */ +export const FOOT_WIDTH = 6; +export const FOOT_BAND = 2; + +/** Planted feet in one frame: `alpha` is the frame's bottom `FOOT_BAND` rows, row-major, `width` px wide. */ +export function plantedFeet(alpha: ArrayLike, width: number, threshold = 128) { + const rows = Math.floor(alpha.length / width); + const solid: boolean[] = []; + for (let x = 0; x < width; x++) { + let any = false; + for (let y = 0; y < rows && !any; y++) any = alpha[y * width + x] > threshold; + solid.push(any); + } + const runs: [number, number][] = []; + for (let x = 0; x < width; x++) if (solid[x]) { + const last = runs[runs.length - 1]; + if (last && x - last[1] <= 2) last[1] = x; else runs.push([x, x]); + } + return runs.filter(([a, b]) => b - a + 1 >= FOOT_WIDTH).length; +} + +/** + * Footfall frames of a looping cycle: where the number of planted feet rises (a heel strike adds a foot to the + * ground). Measured on the approved hero: walk 00/04 (double support), run 01/05 (after the flight frames). + */ +export function contactFrames(feet: number[]) { + return feet.flatMap((count, i) => count > feet[(i - 1 + feet.length) % feet.length] ? [i] : []); +} + +/** The bottom rows' alpha of an atlas frame, read once from the texture source. */ +function bottomAlpha(scene: Phaser.Scene, board: string, name: string, canvas: HTMLCanvasElement) { + const frame = scene.textures.getFrame(board, name); + if (!frame) return null; + const image = frame.source.image as CanvasImageSource; + const { cutX, cutY, cutWidth, cutHeight } = frame; + canvas.width = cutWidth; canvas.height = FOOT_BAND; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) return null; + context.clearRect(0, 0, cutWidth, FOOT_BAND); + context.drawImage(image, cutX, cutY + cutHeight - FOOT_BAND, cutWidth, FOOT_BAND, 0, 0, cutWidth, FOOT_BAND); + const rgba = context.getImageData(0, 0, cutWidth, FOOT_BAND).data; + const alpha = new Uint8Array(cutWidth * FOOT_BAND); + for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3]; + return { alpha, width: cutWidth }; +} + +/** `clip → footfall frame indices` for every strided cycle, measured from the atlas pixels. */ +export function footfalls(scene: Phaser.Scene, clips: Catalogue): Record { + const table: Record = {}; + const canvas = document.createElement('canvas'); + for (const clip of Object.values(clips)) { + if (!clip.stride || !clip.loop) continue; + try { + const feet: number[] = []; + for (let n = 0; n < clip.frames; n++) { + const read = bottomAlpha(scene, clip.board, `${clip.name}-${String(n).padStart(2, '0')}`, canvas); + if (!read) throw new Error(`no frame ${clip.name}-${n}`); + feet.push(plantedFeet(read.alpha, read.width)); + } + table[clip.name] = contactFrames(feet); + } catch (error) { + console.warn('footfalls', clip.name, error); + table[clip.name] = [0, Math.floor(clip.frames / 2)]; // contact/down/passing/up twice + } + } + return table; +} diff --git a/game/src/player/grounding.ts b/game/src/player/grounding.ts new file mode 100644 index 0000000..e618c41 --- /dev/null +++ b/game/src/player/grounding.ts @@ -0,0 +1,61 @@ +import type { Material } from './surfaces'; + +/** Half the planted-feet span (walk double support, compare.py feet): on a slope the lower foot sits this far out. */ +export const FEET_HALF_SPAN = 8; +/** The extra settle of a landing: px pressed into the ground at touchdown, easing out over the landing clip. */ +export const LANDING_SETTLE = { soft: 1, hard: 2 }; +/** Sink eases in and out at this rate (1/s), so stepping from stone onto grass is a settle, not a pop. */ +export const SINK_RATE = 30; + +export interface GroundInput { + material: Material; grounded: boolean; slope: number; + /** 0–1: how much of the landing clip is left; `hard` picks the bigger settle. */ + landing: number; hard: boolean; +} + +/** + * Where the feet should be drawn below the physics contact (px, visual only): the surface's sink, plus the + * lower foot on a slope (pixel art is not rotated, so the sprite drops until neither foot floats), plus a + * landing settle in place of a squash – the drawn landing poses carry the squash, the ground takes the weight. + */ +export function sinkTarget(input: GroundInput) { + if (!input.grounded) return 0; + const slope = Math.min(4, Math.abs(input.slope) * FEET_HALF_SPAN); + const settle = (input.hard ? LANDING_SETTLE.hard : LANDING_SETTLE.soft) * Math.max(0, Math.min(1, input.landing)); + return input.material.sink + slope + settle; +} + +/** Rows of the sprite hidden by the ground: the surface's cover, never more than the feet sank. */ +export function coverRows(input: GroundInput, sink: number) { + return input.grounded ? Math.max(0, Math.min(Math.round(sink), input.material.cover ?? 0)) : 0; +} + +export class Grounding { + sink = 0; + /** Eases toward the target; leaving the ground lifts the feet out at once (a jump never drags its feet). */ + update(input: GroundInput, dt: number) { + const target = sinkTarget(input); + this.sink = !input.grounded ? 0 : this.sink + (target - this.sink) * (1 - Math.exp(-SINK_RATE * dt)); + if (Math.abs(this.sink - target) < 0.05) this.sink = target; + return { offset: Math.round(this.sink), cover: coverRows(input, this.sink) }; + } +} + +/** Stable pseudo-random 0–1 per integer (world pixel column), so grass in front of the feet stays put in the world. */ +export function hash(n: number) { + let h = (n | 0) * 374761393 ^ 0x5bd1e995; + h = Math.imul(h ^ h >>> 13, 1274126177); + return ((h ^ h >>> 16) >>> 0) / 4294967296; +} + +/** Tufts over the feet: world columns within `half` px of x, each with a height ≤ cover + 2 and a colour index. */ +export function blades(x: number, half: number, cover: number, colors: number) { + const out: { x: number; height: number; color: number }[] = []; + if (cover <= 0 || colors <= 0) return out; + for (let column = Math.floor(x - half); column <= Math.ceil(x + half); column++) { + const r = hash(column); + if (r < 0.45) continue; + out.push({ x: column, height: 1 + Math.floor(hash(column * 7 + 3) * (cover + 2)), color: Math.floor(r * 997) % colors }); + } + return out; +} diff --git a/game/src/player/hurt.ts b/game/src/player/hurt.ts new file mode 100644 index 0000000..ec673ff --- /dev/null +++ b/game/src/player/hurt.ts @@ -0,0 +1,49 @@ +import type { AnimationTable } from '../stage/assets'; +import { KNOCK_TIME, type Life } from '../combat/life'; + +export interface Pose { board: string; frame: string } +const name = (clip: string, n: number) => `${clip}-${String(n).padStart(2, '0')}`; +/** `hit-00` is the drawn white flash and `hit-03` the crouch the knockback ends in (hero-hurt board). */ +export const RECOVER_FROM = 3; + +/** + * The drawn hurt and death clips at their own fps (anims.json), replacing the generic white tint. + * A one-heart hit plays `hit` (flash, recoil, crouch, recover); a heavier hit throws the hero with `knockback` + * over the knock time and recovers through the rest of `hit`. Moving away cuts the recovery short; the + * invulnerability blink stays. + */ +export class HurtAnimation { + private elapsed = -1; + private heavy = false; + constructor(private hurt: AnimationTable, private death: AnimationTable) {} + start(damage: number) { this.elapsed = 0; this.heavy = damage >= 2 && !!this.hurt.knockback; } + cancel() { this.elapsed = -1; } + get active() { return this.elapsed >= 0; } + /** `moving`: the body has its own motion again (input after the knock), which ends the recovery. */ + update(life: Life, dt: number, moving: boolean): Pose | null { + if (life.dead > 0) { + const clip = this.death.death; + const frames = clip?.frames ?? 12, fps = clip?.fps ?? 12; + const time = 1 - life.dead; // `dead` counts down from 1 s + return { board: 'hero-death', frame: name('death', Math.min(frames - 1, Math.floor(time * fps))) }; + } + if (this.elapsed < 0) return null; + this.elapsed += dt; + if (life.knock === 0 && moving) { this.elapsed = -1; return null; } + const hit = this.hurt.hit, knock = this.hurt.knockback; + if (!hit) { this.elapsed = -1; return null; } + let t = this.elapsed; + if (this.heavy && knock) { + const length = Math.max(KNOCK_TIME, knock.frames / knock.fps); + if (t < length) return { board: 'hero-hurt', frame: name('knockback', Math.min(knock.frames - 1, Math.floor(t / length * knock.frames))) }; + t = t - length + RECOVER_FROM / hit.fps; + } + const frame = Math.floor(t * hit.fps); + if (frame >= hit.frames) { this.elapsed = -1; return null; } + return { board: 'hero-hurt', frame: name('hit', frame) }; + } + /** Blink while invulnerable, once the drawn clip has finished (the flash frame is in the clip). */ + alpha(life: Life, time: number) { + return life.dead === 0 && !this.active && life.invulnerable > 0 && Math.floor(time / 80) % 2 ? 0.35 : 1; + } +} diff --git a/game/src/player/idle-life.test.ts b/game/src/player/idle-life.test.ts new file mode 100644 index 0000000..599c36c --- /dev/null +++ b/game/src/player/idle-life.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { BREATH_ROW, HEM_ROW, PonchoSway, sliceRows, SWAY_HOLD, windPush } from './idle-life'; +import { DEFAULT_ATMOSPHERE } from '../stage/atmosphere/config'; + +const wind = DEFAULT_ATMOSPHERE.wind; + +describe('idle life', () => { + it('slices the frame into chest, poncho and legs, the chest one row taller on the in-breath, the cover off the legs', () => { + expect(sliceRows(0, 0)).toEqual({ chest: [0, BREATH_ROW], poncho: [BREATH_ROW, HEM_ROW - BREATH_ROW], legs: [HEM_ROW, 80 - HEM_ROW] }); + expect(sliceRows(1, 2).chest).toEqual([0, BREATH_ROW + 1]); // drawn 1 px up: the split row shows twice + expect(sliceRows(1, 2).legs).toEqual([HEM_ROW, 80 - HEM_ROW - 2]); + }); + it('sways the poncho 1 px downwind only while a gust passes, held, and never while moving', () => { + const sway = new PonchoSway(); + const offsets: number[] = []; + for (let t = 0; t < 30; t += 1 / 60) offsets.push(sway.update(wind, 900, t, 1 / 60, true)); + expect(new Set(offsets)).toEqual(new Set([0, wind.direction])); // only downwind, only 1 px + const changes = offsets.flatMap((o, i) => i && o !== offsets[i - 1] ? [i] : []); + expect(changes.length).toBeGreaterThan(1); // gusts come and go over 30 s + for (let i = 1; i < changes.length; i++) expect((changes[i] - changes[i - 1]) / 60).toBeGreaterThanOrEqual(SWAY_HOLD - 1e-6); + expect(sway.update(wind, 900, 31, 1 / 60, false)).toBe(0); + expect(sway.update(null, 900, 31, 1 / 60, true)).toBe(0); + }); + it('reads the same wind the foliage shows: the push follows the gust fronts downwind', () => { + const calm = { ...wind, gust: 0 }; + for (let t = 0; t < 10; t += 0.1) expect(Math.abs(windPush(calm, 500, t))).toBeLessThanOrEqual(wind.base + 1e-9); + let strongest = 0; + for (let t = 0; t < 10; t += 0.05) strongest = Math.min(strongest, windPush(wind, 500, t)); + expect(strongest).toBeLessThan(-0.6); // direction −1: gusts push left past the threshold + }); +}); diff --git a/game/src/player/idle-life.ts b/game/src/player/idle-life.ts new file mode 100644 index 0000000..e810730 --- /dev/null +++ b/game/src/player/idle-life.ts @@ -0,0 +1,76 @@ +import type Phaser from 'phaser'; +import type { Wind } from '../stage/atmosphere/config'; +import { gustAt } from '../stage/atmosphere/wind'; + +/** + * Idle life without new art (docs/09 'Smooth, not rubbery'): the standing hero is drawn as three crops of its own + * frame. Above BREATH_ROW the chest and hood rise 1 px on the in-breath (the row at the split is drawn twice, + * the classic pixel-art breath); BREATH_ROW–HEM_ROW, the poncho's lower half and fringe, takes 1 px downwind while + * a gust passes; the legs below never move. Whole pixels only: nothing is scaled, rotated or blended. + */ +export const BREATH_ROW = 30; +export const HEM_ROW = 50; +/** The idle frames are 80 px tall; any other frame (a chair pose) is drawn whole. */ +export const FRAME_HEIGHT = 80; +/** Wind push (the atmosphere's base sway + gust lean) at which the poncho takes its pixel, and below which it lets go. */ +export const SWAY_ON = 0.6; +export const SWAY_OFF = 0.35; +/** The poncho never changes its pixel more often than this (s): a gust is a lean, not a flicker. */ +export const SWAY_HOLD = 0.35; + +/** The push of the same wind the foliage shader and the leaves show, at world x and time t (s). */ +export function windPush(wind: Wind, x: number, t: number) { + return wind.base * Math.sin(Math.PI * 2 * wind.frequency * t + x * 0.004) + wind.gust * gustAt(wind, x, t) * wind.direction; +} + +/** −1, 0 or 1 px for the poncho, with hysteresis and a minimum hold; 0 whenever the hero is not standing. */ +export class PonchoSway { + offset = 0; + private held = SWAY_HOLD; + update(wind: Wind | null, x: number, t: number, dt: number, standing: boolean) { + this.held += dt; + if (!standing || !wind) { this.offset = 0; return 0; } + const push = windPush(wind, x, t); + const want = Math.abs(push) >= SWAY_ON ? Math.sign(push) : Math.abs(push) < SWAY_OFF ? 0 : this.offset; + if (want !== this.offset && this.held >= SWAY_HOLD) { this.offset = want; this.held = 0; } + return this.offset; + } +} + +/** Crop rows (top, height) of the three slices of a frame `height` px tall with `cover` rows hidden at the feet. */ +export function sliceRows(breath: number, cover: number, height = FRAME_HEIGHT) { + return { + chest: [0, BREATH_ROW + breath] as const, + poncho: [BREATH_ROW, HEM_ROW - BREATH_ROW] as const, + legs: [HEM_ROW, Math.max(0, height - HEM_ROW - cover)] as const, + }; +} + +/** The two upper slices over the player's sprite, which keeps the legs (and stays the sprite everyone else reads). */ +export class HeroSlices { + private chest: Phaser.GameObjects.Sprite; + private poncho: Phaser.GameObjects.Sprite; + constructor(scene: Phaser.Scene, private base: Phaser.GameObjects.Sprite) { + const make = () => scene.add.sprite(base.x, base.y, base.texture.key, base.frame.name).setOrigin(0.5, 1).setVisible(false); + this.chest = make(); this.poncho = make(); + } + destroy() { this.chest.destroy(); this.poncho.destroy(); } + /** `breath` 0/1 px up, `sway` −1/0/1 px in world x; both 0 (or a frame that is not a hero idle) draws the sprite whole. */ + apply(breath: number, sway: number, cover: number) { + const base = this.base, frame = base.frame; + const on = (breath !== 0 || sway !== 0) && base.visible && frame.height === FRAME_HEIGHT; + this.chest.setVisible(on); this.poncho.setVisible(on); + if (!on) { + if (cover > 0) base.setCrop(0, 0, frame.width, frame.height - cover); + else if (base.isCropped) base.setCrop(); + return; + } + const rows = sliceRows(breath, cover, frame.height); + for (const [slice, [top, height], dx, dy] of [[this.chest, rows.chest, 0, -breath], [this.poncho, rows.poncho, sway, 0]] as const) { + if (slice.texture !== base.texture || slice.frame.name !== frame.name) slice.setTexture(base.texture.key, frame.name); + slice.setPosition(base.x + dx, base.y + dy).setFlipX(base.flipX).setAlpha(base.alpha).setDepth(base.depth) + .setCrop(0, top, frame.width, height); + } + base.setCrop(0, rows.legs[0], frame.width, rows.legs[1]); + } +} diff --git a/game/src/player/interaction.ts b/game/src/player/interaction.ts index 1ee483f..46ac19f 100644 --- a/game/src/player/interaction.ts +++ b/game/src/player/interaction.ts @@ -4,8 +4,10 @@ import type { AnimationClip } from '../stage/assets'; export class Interaction { private elapsed = 0; private clip: AnimationClip | undefined; + private name = 'interact'; get active() { return this.clip !== undefined; } - begin(clip: AnimationClip) { this.clip = clip; this.elapsed = 0; } + /** `name` is the hero-hurt clip whose frames play (default `interact`; the chair uses `pick-up`). */ + begin(clip: AnimationClip, name = 'interact') { this.clip = clip; this.name = name; this.elapsed = 0; } cancel() { this.clip = undefined; } step(seconds: number) { if (!this.clip) return undefined; @@ -13,6 +15,6 @@ export class Interaction { const frame = Math.min(clip.frames - 1, Math.floor((this.elapsed + 1e-9) * clip.fps)); this.elapsed += seconds; if (this.elapsed + 1e-9 >= clip.frames / clip.fps) this.cancel(); - return { board: 'hero-hurt', frame: 'interact-' + String(frame).padStart(2, '0') }; + return { board: 'hero-hurt', frame: this.name + '-' + String(frame).padStart(2, '0') }; } } diff --git a/game/src/player/jump.test.ts b/game/src/player/jump.test.ts index 5abc28b..491b5ce 100644 --- a/game/src/player/jump.test.ts +++ b/game/src/player/jump.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { jump, jumpState, landing } from './jump'; +import { BUFFER, jump, jumpState, landing, LANDING_TIMES } from './jump'; import { stepBody, type Body } from './body'; import type { Geometry } from '../stage/collision'; const floor: Geometry = { walk: [[[0, 500], [1000, 500]]], slide: [], oneWay: [], solid: [], @@ -44,6 +44,56 @@ describe('controlled jump', () => { } expect(launches).toBe(1); }); + /** Simulates 120 Hz substeps with the key held on the given 120 Hz ticks; returns launch ticks and apex ticks. */ + const spam = (held: (tick: number) => boolean, ticks = 480, vx = 0) => { + const b = { ...body(), vx }, state = jumpState(b.y); + const launches: number[] = [], apexes: number[] = [], landings: number[] = [], presses: number[] = []; + for (let i = 0; i < ticks; i++) { + const before = b.vy; + if (held(i) && !(i > 0 && held(i - 1))) presses.push(i); + jump(b, state, held(i), dt); + if (b.vy === -485 && before !== -485) launches.push(i); + const grounded = b.grounded; stepBody(b, [floor], dt); landing(b, state, grounded, dt); + if (before < 0 && b.vy >= 0 && !b.grounded) apexes.push(i); + if (!grounded && b.grounded) landings.push(i); + } + return { launches, apexes, landings, presses, state }; + }; + /** Every press that is on the ground, or within the buffer before a touchdown, launches – the buffered ones on the next tick. */ + const noneSwallowed = ({ launches, landings, presses }: ReturnType) => { + let checked = 0; + for (const land of landings) { + const last = presses.filter(p => p <= land).at(-1); + if (last !== undefined && land - last < BUFFER * 120 - 1) { expect(launches).toContain(land + 1); checked++; } + } + return checked; + }; + it('jump spam: every tap on the ground or buffered before landing launches, at once', () => { + // Tap (2 frames at 60 Hz = 4 ticks) every 0.25 s: each tap either finds the ground or is buffered. + const run = spam(i => i % 30 < 4); + expect(run.launches.length).toBeGreaterThanOrEqual(8); + noneSwallowed(run); + }); + it('jump spam: a tap hop rises crisply to its 52 px and does not float', () => { + const { apexes, launches, state } = spam(i => i < 4, 120); + expect(launches).toEqual([0]); + expect(apexes[0] * dt).toBeLessThan(0.2); // was ~0.29 s with the clamped rise + expect(state.height).toBeGreaterThanOrEqual(50); expect(state.height).toBeLessThan(58); + }); + it('jump spam: mashing faster than the hop never swallows a press that comes within the buffer of touchdown', () => { + let checked = 0; + for (const period of [10, 14, 18, 22, 26, 34]) checked += noneSwallowed(spam(i => i % period < 2, 900)); + expect(checked).toBeGreaterThan(20); + }); + it('lands a moving hero on its crouch only, and a walk-off step without a landing', () => { + const run = { ...body(), vx: 198 }, state = jumpState(400); + state.active = true; landing(run, state, false, dt); + expect(state.landing).toBeCloseTo(LANDING_TIMES.soft / 2); + const step = { ...body(), vx: 95 }, walkOff = jumpState(494); + landing(step, walkOff, false, dt); expect(walkOff.landing).toBe(0); + const stand = body(), still = jumpState(450); landing(stand, still, false, dt); + expect(still.landing).toBe(LANDING_TIMES.soft); + }); it('selects the hard landing after a fall of at least two body heights', () => { const b = body(), state = jumpState(300); landing(b, state, false, dt); expect(state.hard).toBe(true); expect(state.landing).toBe(4 / 12); diff --git a/game/src/player/jump.ts b/game/src/player/jump.ts index 8ff2aa9..e62eb72 100644 --- a/game/src/player/jump.ts +++ b/game/src/player/jump.ts @@ -27,19 +27,34 @@ export function jump(body: Body, state: Jump, down: boolean, dt: number) { state.origin = body.y; state.peak = body.y; state.height = 0; state.landing = 0; } if (!down && state.active && !state.forced && body.vy < 0) { - // Early releases share a 52px minimum rise; later releases stop at their current height. + // A released jump keeps its launch speed until it can still reach the 52px minimum under the cut gravity, + // then brakes hard: a tap hop tops out in ~0.18 s instead of floating up for ~0.3 s at a clamped speed + // (the "slow jump when spamming"). A late release brakes from where it is. const remainingRise = Math.max(0, TAP_HEIGHT - (state.origin - body.y)); - body.vy = Math.max(body.vy, -Math.sqrt(2 * GRAVITY * remainingRise)); + if (body.vy * body.vy >= 2 * CUT_GRAVITY * remainingRise) body.vy = Math.min(0, body.vy + (CUT_GRAVITY - GRAVITY) * dt); } } +/** Gravity while a released jump is still rising (the body adds GRAVITY; the jump adds the rest). */ +export const CUT_GRAVITY = 3 * GRAVITY; +/** A walk-off drop shorter than this lands without a landing pose (a step between deck plates, not a fall). */ +export const STEP_DROP = 10; -export function landing(body: Body, state: Jump, wasGrounded: boolean, dt: number) { +/** How long each landing holds the pose, in s: the land and hard-landing clips' lengths (clipLength, anims.json). */ +export interface LandingTimes { soft: number; hard: number } +export const LANDING_TIMES: LandingTimes = { soft: 2 / 12, hard: 4 / 12 }; // fallback before the atlases load + +export function landing(body: Body, state: Jump, wasGrounded: boolean, dt: number, times: LandingTimes = LANDING_TIMES) { state.landing = Math.max(0, state.landing - dt); state.peak = Math.min(state.peak, body.y); if (state.active) state.height = Math.max(state.height, state.origin - body.y); if (!wasGrounded && body.grounded) { - state.hard = body.y - state.peak >= 152; // Two body heights; tune with the route playtest. - state.landing = state.hard ? 4 / 12 : 2 / 12; + const drop = body.y - state.peak; + state.hard = drop >= 152; // Two body heights; tune with the route playtest. + // Moving, a soft landing is its crouch only and a hard one its impact, then the stride takes over; + // a walk-off step is no landing at all. Standing, both play out. + const moving = Math.abs(body.vx) > 5; + state.landing = !state.active && drop < STEP_DROP ? 0 + : state.hard ? moving ? times.hard * 0.6 : times.hard : moving ? times.soft / 2 : times.soft; state.active = false; state.forced = false; state.peak = body.y; } } diff --git a/game/src/player/movement.ts b/game/src/player/movement.ts index 1796607..62dd156 100644 --- a/game/src/player/movement.ts +++ b/game/src/player/movement.ts @@ -1,5 +1,11 @@ -export const WALK_SPEED = 3.5 * 43; -export const RUN_SPEED = 6 * 43; +/** + * Speeds at 43 px/m (the 76 px hero is 1.83 m), from the approved cycles (#156): the walk's 43 px stride and the + * run's 82 px stride are played by distance, so at 2.2 m/s the walk cycles at ~18 fps and the run at ~19 fps, + * near the art's 12 fps key poses doubled, with the feet planted. The old 3.5 / 6 m/s were 2.3–2.5× faster than + * the feet could carry the body (the moonwalk). A running jump still clears ~157 px (collision_check: 140). + */ +export const WALK_SPEED = 2.2 * 43; +export const RUN_SPEED = 4.6 * 43; export const RUN_DELAY = 0.4; export interface Movement { vx: number; held: number; direction: number; facing: -1 | 1 } export const movement = (): Movement => ({ vx: 0, held: 0, direction: 0, facing: 1 }); diff --git a/game/src/player/player.ts b/game/src/player/player.ts index c7b1ce5..8af762b 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -1,17 +1,29 @@ import Phaser from 'phaser'; import type { CollisionChunk, Box } from '../stage/collision'; import { BODY_HEIGHT, BODY_WIDTH, overlaps, stepBody, type Body } from './body'; -import { horizontal, move, movement } from './movement'; -import { jump, jumpState, landing, pogo } from './jump'; -import { climb, dropThrough, traversal } from './traversal'; -import { catalogue, LocomotionAnimation, type MotionPose } from './animation'; +import { horizontal, move, movement, RUN_SPEED, WALK_SPEED } from './movement'; +import { jump, jumpState, JUMP_SPEED, landing, LANDING_TIMES, pogo, type LandingTimes } from './jump'; +import { climb, dropThrough, surfaceNear, traversal } from './traversal'; +import { catalogue, clipLength, LocomotionAnimation, type Catalogue, type MotionPose } from './animation'; import { gameEvents } from '../core/events'; import { Life } from '../combat/life'; -import { deathDust, lifePose, paintLife } from '../combat/life-visuals'; +import { deathDust } from '../combat/life-visuals'; +import type { StageManifest, RegionState } from '../stage/manifest'; +import { FALLBACK, loadSurfaces, parseSurfaces, SurfaceMap, type Puff, type Surface } from './surfaces'; +import { blades, Grounding } from './grounding'; +import { footfalls } from './footfall'; +import { spawnPuff, type PuffKind } from './dust'; +import { HurtAnimation } from './hurt'; import type { Position } from '../core/events'; import { Interaction } from './interaction'; +import { HeroSlices, PonchoSway } from './idle-life'; +import { parseAtmosphere, type Wind } from '../stage/atmosphere/config'; +import { dataKey } from '../boot/manifest'; import { GuardPush } from '../combat/guard'; +/** The splash of a region without a `water` material. */ +const SPLASH: Puff = { sprite: 'splash', shape: 'dust', colors: ['#9cc8f0', '#e8f4ff', '#4f86c6'], count: 8 }; + export class Player { readonly body: Body; readonly sprite: Phaser.GameObjects.Sprite; @@ -31,17 +43,46 @@ export class Player { private traversal = traversal(); private animation: LocomotionAnimation; private interaction = new Interaction(); + private clips: Catalogue; + private landingTimes: LandingTimes = LANDING_TIMES; + private surfaces: SurfaceMap | null = null; + private surface: Surface = FALLBACK; + private grounding = new Grounding(); + private footfalls: Record = {}; + private lastFrame = ''; + private landed: PuffKind | null = null; + private blades: Phaser.GameObjects.Graphics; + private hurt: HurtAnimation; + private groundOffset = { offset: 0, cover: 0 }; + private slices: HeroSlices; + private sway = new PonchoSway(); + private wind: Wind | null = null; + private idleLife = { breath: 0, sway: 0 }; get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0 && !this.interaction.active; } get facing() { return this.motion.facing; } constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[], private region = 'rusty-creek', bosses = new Set(), spawnOverride?: Position) { - this.spawn = spawnOverride ?? chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; + const spawn = spawnOverride ?? chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 }; + // An authored spawn a pixel or two off its walk line would start inside the ground and fall through it: + // stand it on the surface it was meant for. + this.spawn = { ...spawn, y: surfaceNear(chunks.map(c => c.world), spawn.x, spawn.y) }; this.body = { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true }; this.life = new Life(this.body, bosses); this.air = jumpState(this.body.y); - this.animation = new LocomotionAnimation(catalogue(Object.fromEntries( - ['hero-move', 'hero-run', 'hero-idle', 'hero-air'].map(board => [board, scene.cache.json.get(`${board}.anims`)])))); + this.clips = catalogue(Object.fromEntries( + ['hero-move', 'hero-run', 'hero-idle', 'hero-air'].map(board => [board, scene.cache.json.get(`${board}.anims`)]))); + this.animation = new LocomotionAnimation(this.clips); + if (this.clips.land && this.clips['hard-landing']) this.landingTimes = { + soft: clipLength(this.clips.land, 'land'), hard: clipLength(this.clips['hard-landing'], 'hard-landing') }; + this.footfalls = footfalls(scene, this.clips); + this.hurt = new HurtAnimation(scene.cache.json.get('hero-hurt.anims') ?? {}, scene.cache.json.get('hero-death.anims') ?? {}); this.sprite = scene.add.sprite(this.body.x, this.body.y, 'hero-move', 'idle-00').setOrigin(0.5, 1).setDepth(100); + this.slices = new HeroSlices(scene, this.sprite); + this.blades = scene.add.graphics().setDepth(101); + // After every scene update (the chair may swap the hero's frame): place the feet in the ground. + const settle = () => this.settle(); + scene.events.on('postupdate', settle); + scene.events.once('shutdown', () => scene.events.off('postupdate', settle)); this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,SPACE,K,J') as Record; this.debug = scene.add.graphics().setDepth(10001); this.label = scene.add.text(20, 135, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' }) @@ -69,6 +110,7 @@ export class Player { if (!this.life.hit(amount, this.body.x >= sourceX ? 1 : -1)) return false; this.guardPush.cancel(); this.interaction.cancel(); + this.hurt.start(amount); const position = { x: this.body.x, y: this.body.y }; gameEvents.emit('player-hit', { health: this.life.health, damage: amount, position }); if (this.life.dead > 0) { @@ -77,6 +119,20 @@ export class Player { } else this.body.vy = -90; return true; } + /** + * The region's ground materials (surfaces.json). `data` comes from Preload; when it is absent the file is + * fetched here, and a region without one keeps the fallback (stone-like, 1 px). + */ + ground(manifest: StageManifest, state: RegionState, data?: unknown) { + // The poncho sways with the region's own wind (atmosphere.json, the defaults without one). + this.wind = parseAtmosphere(this.scene.cache.json.get(dataKey(manifest.region, 'atmosphere')) ?? {}, state).atmosphere.wind; + const use =(parsed: ReturnType | null) => { this.surfaces = new SurfaceMap(parsed, manifest, state); }; + if (data && typeof data === 'object' && Object.keys(data).length) { + try { use(parseSurfaces(data)); return; } catch (error) { console.error(error); } + } + use(null); + void loadSurfaces(manifest.region).then(parsed => { if (parsed) use(parsed); }); + } heal(amount: number) { if (this.life.heal(amount)) this.onHealthChanged(this.life.health); } @@ -85,10 +141,11 @@ export class Player { this.onHealthChanged(this.life.health); gameEvents.emit('save', { checkpoint: id, region: this.region, position: { x: this.body.x, y: this.body.y } }); } - beginInteraction() { + beginInteraction(name = 'interact') { if (!this.canAttack || !this.body.grounded) return false; - const clip = this.scene.cache.json.get('hero-hurt.anims').interact; - this.interaction.begin(clip); + const clip = this.scene.cache.json.get('hero-hurt.anims')[name]; + if (!clip) return false; + this.interaction.begin(clip, name); this.motion = { ...movement(), facing: this.motion.facing }; this.body.vx = 0; this.body.vy = 0; return true; } @@ -99,6 +156,7 @@ export class Player { } private resetPosition(position: { x: number; y: number }) { this.guardPush.cancel(); + position = { ...position, y: surfaceNear(this.chunks.map(c => c.world), position.x, position.y) }; Object.assign(this.body, position, { vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 }); this.motion = movement(); this.air = jumpState(this.body.y); this.traversal = traversal(); } @@ -146,7 +204,8 @@ export class Player { jump(this.body, this.air, jumpDown, dt); } if (!climbing) stepBody(this.body, geometry, dt, this.traversal.drop > 0); - landing(this.body, this.air, wasGrounded, dt); + landing(this.body, this.air, wasGrounded, dt, this.landingTimes); + if (!wasGrounded && this.body.grounded && !climbing && !dropped) this.landed = this.air.hard ? 'hard-land' : 'land'; if (lifeStep.knockFinished || shove !== 0 && this.guardPush.remaining === 0) this.body.vx = 0; this.motion.vx = this.body.vx; remaining -= dt; @@ -155,21 +214,33 @@ export class Player { const hazards = geometry.flatMap(g => g.hazard); const bounced = this.onHazards(hazards); const hazard = hazards.find(box => overlaps(this.body, box)); + // Water is never floor: falling in splashes and puts the hero back on the bank he last stood on. + const water = geometry.flatMap(g => g.noGo).find(box => box.kind === 'water' && overlaps(this.body, box)); const noGo = this.body.y > 900 || geometry.some(g => g.noGo.some(box => overlaps(this.body, box))); if (hazard && !bounced) this.resetFromDanger(Number(hazard.damage) || 1); - else if (noGo) this.resetFromDanger(); + else if (noGo) { + if (water) spawnPuff(this.scene, this.body.x, water.y, this.surfaces?.named('water')?.material.puff ?? SPLASH, 'hard-land', 1); + this.resetFromDanger(); + } else if (this.body.grounded && this.life.knock === 0) this.life.rememberSafe(this.body); } const desired: MotionPose = this.traversal.ladder ? 'ladder' : this.air.landing > 0 ? this.air.hard ? 'hard-landing' : 'land' : this.body.sliding ? 'fall' : !this.body.grounded ? this.body.vy < 0 ? 'jump' : 'fall' - : Math.abs(this.body.vx) > 180 ? 'run' : Math.abs(this.body.vx) > 5 ? 'walk' : 'idle'; - const locomotion = this.animation.update(desired, desired === 'ladder' && vertical === 0 ? 0 : milliseconds / 1000); - const pose = lifePose(this.life) ?? interactionPose ?? locomotion; + : Math.abs(this.body.vx) > (WALK_SPEED + RUN_SPEED) / 2 ? 'run' : Math.abs(this.body.vx) > 5 ? 'walk' : 'idle'; + this.animation.wounded = this.life.health <= 2; + const locomotion = this.animation.update(desired, desired === 'ladder' && vertical === 0 ? 0 : milliseconds / 1000, + Math.abs(this.body.vx), { vy: this.body.vy, launch: JUMP_SPEED }); + const hurtPose = this.hurt.update(this.life, milliseconds / 1000, axis !== 0 || !this.body.grounded); + const pose = hurtPose ?? interactionPose ?? locomotion; + const standing = pose === locomotion && this.animation.standing && this.body.grounded && this.life.dead === 0; + this.idleLife = { breath: standing ? this.animation.breath : 0, + sway: this.sway.update(this.wind, this.body.x, this.scene.game.loop.time / 1000, milliseconds / 1000, standing) }; if (this.scene.textures.get(pose.board).has(pose.frame)) this.sprite.setTexture(pose.board, pose.frame); else this.sprite.setTexture('hero-move', 'idle-00'); - this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y)).setFlipX(this.motion.facing < 0); - paintLife(this.sprite, this.life, this.scene.time.now); + this.sprite.setFlipX(this.motion.facing < 0).setAlpha(this.hurt.alpha(this.life, this.scene.time.now)); + this.feel(pose, milliseconds / 1000); + this.settle(); if (axis) this.lastSpeed = Math.abs(this.body.vx); this.debug.clear(); if (this.showDebug) this.debug.lineStyle(2, 0xffffff).strokeRect(this.body.x - BODY_WIDTH / 2, @@ -180,6 +251,43 @@ export class Player { `held ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}`, `jump ${this.air.height.toFixed(1)}px | ${this.traversal.ladder ? 'climb' : this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).toFixed(1)}°`, `anim ${this.animation.clip} | ${pose.frame} | land ${this.air.hard ? 'hard' : 'normal'}`, + `ground ${this.surface.name} | sink ${this.groundOffset.offset}px | cover ${this.groundOffset.cover}`, ]); } + /** Surface under the feet, the sink it asks for, and the puffs of footfalls and landings. */ + private feel(pose: { board: string; frame: string }, dt: number) { + const grounded = this.body.grounded && this.traversal.ladder === null && this.life.dead === 0; + this.surface = this.surfaces?.at(this.body.x, this.body.y) ?? FALLBACK; + const hard = this.air.hard; + const left = this.air.landing / Math.max(1e-6, hard ? this.landingTimes.hard : this.landingTimes.soft); + this.groundOffset = this.grounding.update({ material: this.surface.material, grounded, + slope: this.body.slope ?? 0, landing: left, hard }, dt); + const puff = this.surface.material.puff; + if (this.landed) { + spawnPuff(this.scene, this.body.x, this.body.y, puff, this.landed, this.motion.facing); + this.landed = null; + } else if (grounded && pose.frame !== this.lastFrame) { + const clip = pose.frame.slice(0, -3), n = Number(pose.frame.slice(-2)); + // The planted foot is ahead of the hip at heel strike: the puff comes off that foot. + if (this.footfalls[clip]?.includes(n)) spawnPuff(this.scene, this.body.x + this.motion.facing * 4, this.body.y, + puff, 'step', this.body.vx || this.motion.facing); + } + this.lastFrame = pose.frame; + } + /** Runs after the player's and every later update: the feet sink, the ground covers their lowest rows, tufts stand in front. */ + private settle() { + const { offset, cover } = this.groundOffset; + this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y) + offset); + // Breath and poncho only on the hero's own standing frames (a chair pose swapped in later is drawn whole). + const own = this.sprite.texture.key === 'hero-move' || this.sprite.texture.key === 'hero-idle'; + this.slices.apply(own ? this.idleLife.breath : 0, own ? this.idleLife.sway : 0, cover); + this.blades.clear(); + const colors = this.surface.material.blades; + if (!colors?.length || cover === 0) return; + const ground = Math.round(this.body.y) + offset - cover; + for (const blade of blades(Math.round(this.body.x), 12, cover, colors.length)) { + this.blades.fillStyle(Number.parseInt(colors[blade.color].slice(1), 16), 1) + .fillRect(blade.x, ground + cover - blade.height, 1, blade.height); + } + } } diff --git a/game/src/player/surfaces.ts b/game/src/player/surfaces.ts new file mode 100644 index 0000000..abf9994 --- /dev/null +++ b/game/src/player/surfaces.ts @@ -0,0 +1,92 @@ +import type { StageManifest, RegionState } from '../stage/manifest'; +import { stageBase } from '../stage/assets'; + +/** What a footfall or landing kicks up: procedural pixels in the region's palette until sprites exist. */ +/** `sprite`: an fx-ground clip (dust, grass, leaf-kick, splash) played once at the foot; `shape`/`colors`/`count`: the procedural pixels. */ +export interface Puff { sprite?: string; shape: 'dust' | 'blade' | 'leaf'; colors: string[]; count: number } +/** `sink`: px the feet sink in (visual only). `cover`: lowest sprite rows the ground hides. `blades`: tuft colours drawn over the feet. */ +export interface Material { sink: number; cover?: number; blades?: string[]; puff?: Puff } +export interface SurfaceRange { x: [number, number]; y?: [number, number]; states?: string[]; material: string; note?: string } +export interface SurfaceData { + region: string; default: string; note?: string; + materials: Record; + chunks: Record; +} +export interface Surface { name: string; material: Material } + +/** Without a surfaces file the hero still stands slightly in the ground (stone-like). */ +export const FALLBACK: Surface = { name: 'ground', material: { sink: 1, cover: 0, + puff: { shape: 'dust', colors: ['#c9bfa8', '#e6dcc4', '#9b927f'], count: 3 } } }; +const SHAPES = ['dust', 'blade', 'leaf']; +const COLOR = /^#[0-9a-f]{6}$/i; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`Invalid surfaces: ${message}`); +} +const pair = (v: unknown): v is [number, number] => + Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n)) && v[0] < v[1]; + +export function parseSurfaces(value: unknown): SurfaceData { + assert(typeof value === 'object' && value !== null, 'object'); + const data = value as SurfaceData; + assert(typeof data.materials === 'object' && data.materials !== null, 'materials'); + for (const [name, m] of Object.entries(data.materials)) { + assert(typeof m.sink === 'number' && m.sink >= 0 && m.sink <= 8, `${name}.sink 0–8`); + assert(m.cover === undefined || typeof m.cover === 'number' && m.cover >= 0 && m.cover <= m.sink, `${name}.cover ≤ sink`); + assert(m.blades === undefined || Array.isArray(m.blades) && m.blades.every(c => COLOR.test(c)), `${name}.blades`); + if (m.puff) { + assert(SHAPES.includes(m.puff.shape), `${name}.puff.shape`); + assert(m.puff.sprite === undefined || /^[a-z][a-z-]*$/.test(m.puff.sprite), `${name}.puff.sprite`); + assert(Array.isArray(m.puff.colors) && m.puff.colors.length > 0 && m.puff.colors.every(c => COLOR.test(c)), `${name}.puff.colors`); + assert(Number.isInteger(m.puff.count) && m.puff.count >= 0, `${name}.puff.count`); + } + } + assert(data.materials[data.default], 'default must name a material'); + assert(typeof data.chunks === 'object' && data.chunks !== null, 'chunks'); + for (const [chunk, ranges] of Object.entries(data.chunks)) { + assert(Array.isArray(ranges), `${chunk} ranges`); + for (const r of ranges) { + assert(pair(r.x), `${chunk} x range`); + assert(r.y === undefined || pair(r.y), `${chunk} y range`); + assert(data.materials[r.material], `${chunk} unknown material ${r.material}`); + } + } + return data; +} + +/** World-space lookup: chunk by grid position, then the first authored range that holds the feet. */ +export class SurfaceMap { + private chunks: { x: number; y: number; ranges: SurfaceRange[] }[]; + constructor(private data: SurfaceData | null, manifest: Pick, private state: RegionState | string) { + const [w, h] = manifest.chunkSize; + this.chunks = manifest.chunks.map(c => ({ x: c.x * w, y: c.y * h, ranges: data?.chunks[c.id] ?? [] })); + this.size = [w, h]; + } + private size: [number, number]; + /** A material by name (the pond's `water` splash), or null when the region has none. */ + named(name: string): Surface | null { + const material = this.data?.materials[name]; + return material ? { name, material } : null; + } + at(x: number, y: number): Surface { + if (!this.data) return FALLBACK; + const chunk = this.chunks.find(c => x >= c.x && x < c.x + this.size[0] && y >= c.y && y < c.y + this.size[1]); + const lx = x - (chunk?.x ?? 0), ly = y - (chunk?.y ?? 0); + const range = chunk?.ranges.find(r => lx >= r.x[0] && lx < r.x[1] && (!r.y || ly >= r.y[0] && ly < r.y[1]) + && (!r.states || r.states.includes(this.state))); + const name = range?.material ?? this.data.default; + return { name, material: this.data.materials[name] }; + } +} + +/** Fallback loader for when the Preload scene did not pass `surfaces` (a region without the file yields null). */ +export async function loadSurfaces(region: string, request: typeof fetch = (...args) => fetch(...args)) { + try { + const response = await request(`${stageBase(region)}surfaces.json`); + if (!response.ok) return null; + return parseSurfaces(await response.json()); + } catch (error) { + console.warn(error); + return null; + } +} diff --git a/game/src/player/terrain.test.ts b/game/src/player/terrain.test.ts new file mode 100644 index 0000000..7a1052b --- /dev/null +++ b/game/src/player/terrain.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { faces, heightAt, overlaps, stepBody, STEP_UP, type Body } from './body'; +import { jump, jumpState } from './jump'; +import { WALK_SPEED, RUN_SPEED } from './movement'; +import { parseCollision, placeCollision, terrainAt, type Box, type CollisionData, type Geometry, type Point } from '../stage/collision'; + +const world = (walk: Point[][], extra: Partial = {}): Geometry => ({ walk, slide: [], oneWay: [], solid: [], + climb: [], noGo: [], hazard: [], interact: [], spawn: [], ledge: [], glideLift: [], ...extra }); +const body = (x: number, y: number, vx = WALK_SPEED): Body => ({ x, y, vx, vy: 0, grounded: true }); +const run = (b: Body, geometry: Geometry[], steps: number, vx = b.vx) => { + for (let i = 0; i < steps; i++) { b.vx = vx; stepBody(b, geometry, 1 / 120); } + return b; +}; + +describe('terrain: steps, ledges, walls, treads', () => { + it('walks up and down a rise of STEP_UP or less without leaving the ground', () => { + for (const rise of [4, 10, STEP_UP]) { + const g = [world([[[0, 300], [100, 300]], [[100, 300 - rise], [200, 300 - rise]], [[200, 300], [300, 300]]])]; + const b = body(50, 300); + for (let i = 0; i < 260; i++) { run(b, g, 1); expect(b.grounded).toBe(true); expect(b.sliding).toBe(false); } + expect(b.x).toBeGreaterThan(200); expect(b.y).toBe(300); + } + }); + it('stops at a ledge face taller than a step, and the jump gets on top of it', () => { + const g = [world([[[0, 300], [100, 300]], [[100, 260], [300, 260]]])]; + expect(faces(g)).toEqual(expect.arrayContaining([{ x: 100, top: 260, side: -1 }])); + const b = run(body(50, 300), g, 120); + expect(b.x).toBe(100 - 14); expect(b.y).toBe(300); expect(b.grounded).toBe(true); + const air = jumpState(b.y); + for (let i = 0; i < 120; i++) { jump(b, air, i < 40, 1 / 120); b.vx = WALK_SPEED; stepBody(b, g, 1 / 120); } + expect(b.x).toBeGreaterThan(100); expect(b.y).toBe(260); expect(b.grounded).toBe(true); + }); + it('walks on in front of a raised platform the trail runs under (the bench), and falls off a high ledge', () => { + const g = [world([[[0, 300], [400, 300]], [[100, 260], [200, 260]]])]; + expect(faces(g).some(f => f.top === 260)).toBe(false); + const b = run(body(50, 300), g, 400); + expect(b.x).toBeGreaterThan(250); expect(b.y).toBe(300); + const down = run(body(150, 260), g, 120); + expect(down.y).toBe(300); expect(down.grounded).toBe(true); + }); + it('climbs a stair of flat treads as steps: flat slope, never a slide, always on a tread', () => { + const treads: Point[][] = Array.from({ length: 6 }, (_, i) => [[i * 30, 300 - i * 12], [i * 30 + 30, 300 - i * 12]]); + const g = [world(treads)]; + const b = body(10, 300, RUN_SPEED); + for (let i = 0; i < 100 && b.x < 175; i++) { + run(b, g, 1); + expect(b.grounded).toBe(true); expect(b.slope).toBe(0); expect(b.sliding).toBe(false); + expect(treads.some(t => heightAt(t, b.x)?.y === b.y)).toBe(true); + } + expect(b.y).toBe(240); + }); + it('lands on a stepping stone whose edge is under the feet but not under the centre', () => { + const g = [world([[[100, 300], [160, 300]]])]; + const b: Body = { x: 94, y: 280, vx: 0, vy: 200, grounded: false }; + for (let i = 0; i < 30; i++) stepBody(b, g, 1 / 120); + expect(b.grounded).toBe(true); expect(b.y).toBe(300); + const off: Body = { x: 80, y: 280, vx: 0, vy: 200, grounded: false }; + for (let i = 0; i < 30; i++) stepBody(off, g, 1 / 120); + expect(off.grounded).toBe(false); + }); + it('slides only on authored slide lines', () => { + const steep: Point[] = [[0, 100], [200, 260]]; + const walking = run(body(100, 180, 0), [world([steep])], 30, 0); + expect(walking.sliding).toBe(false); expect(walking.x).toBe(100); + const sliding = body(100, 180, 0); + for (let i = 0; i < 30; i++) stepBody(sliding, [world([], { slide: [steep] })], 1 / 120); + expect(sliding.sliding).toBe(true); expect(sliding.x).toBeGreaterThan(100); + }); + it('stops at a wall and a log, steps onto a low solid, stands on top of the log after a jump', () => { + const log: Box = { x: 200, y: 250, w: 100, h: 50, kind: 'log' }; + const kerb: Box = { x: 100, y: 290, w: 40, h: 10, kind: 'obstacle' }; + const g = [world([[[0, 300], [500, 300]]], { solid: [kerb, log] })]; + const b = run(body(50, 300), g, 240); + expect(b.x).toBe(200 - 14); expect(b.y).toBe(300); + const kerbed = run(body(80, 300), g, 30); + expect(kerbed.y === 290 || kerbed.x > 140 + 14).toBe(true); + const air = jumpState(b.y); + for (let i = 0; i < 120; i++) { jump(b, air, i < 40, 1 / 120); b.vx = WALK_SPEED; stepBody(b, g, 1 / 120); } + expect(b.y).toBe(250); expect(b.x).toBeGreaterThan(200); + }); +}); + +const files = import.meta.glob('../../../art/stages/hidden-garden/*.collision.json', { eager: true, import: 'default' }); +const garden = Object.values(files).sort((a, b) => a.chunk.localeCompare(b.chunk)) + .map((data, i) => placeCollision(parseCollision(data, data.chunk, 'clean'), [i * 2160, 0])); +const geometry = garden.map(c => c.world); +const floorBelow = (x: number, y: number) => geometry.flatMap(g => [...g.walk, ...g.oneWay]) + .map(line => heightAt(line, x)?.y).filter((h): h is number => h !== undefined && h >= y - STEP_UP && h <= y + 130); + +describe('the Hidden Garden terrain', () => { + it('types every floor of every chunk', () => { + for (const { local } of garden) for (const line of [...local.walk, ...local.oneWay]) for (let i = 1; i < line.length; i++) { + const [a, b] = [line[i - 1], line[i]]; + expect(terrainAt(local, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2), `${local.chunk} ${a}->${b}`).toBeDefined(); + } + }); + it('meets at every chunk seam within a step, walking either way', () => { + for (let i = 1; i < garden.length; i++) { + const seam = i * 2160; + const left = Math.max(...geometry[i - 1].walk.map(l => heightAt(l, seam)?.y ?? -1)); + const right = Math.max(...geometry[i].walk.map(l => heightAt(l, seam)?.y ?? -1)); + expect(Math.abs(left - right)).toBeLessThanOrEqual(STEP_UP); + for (const vx of [WALK_SPEED, -RUN_SPEED]) { + const b = body(vx > 0 ? seam - 20 : seam + 20, vx > 0 ? left : right, vx); + run(b, geometry, 60); + expect(b.grounded).toBe(true); + } + } + }); + it('walks the whole garden gate to pier: steps, hops onto ledges and the birch, never in the pond', () => { + const spawn = garden[0].world.spawn.find(p => p.kind === 'player')!; + const b = body(spawn.x, heightAt(garden[0].world.walk[0], spawn.x)!.y); + const air = jumpState(b.y); + let hold = 0, jumps = 0, lastX = b.x; + const water = geometry.flatMap(g => g.noGo).filter(box => box.kind === 'water'); + for (let i = 0; i < 120 * 150 && b.x < 4 * 2160 + 1300; i++) { + // The player: walk right; jump when stopped by a face or at the brink of a gap. + if (b.grounded && hold === 0 && (Math.abs(b.x - lastX) < 0.01 && i > 0 || !floorBelow(b.x + 6, b.y).length)) { hold = 0.35; jumps++; } + lastX = b.x; + jump(b, air, hold > 0, 1 / 120); + hold = Math.max(0, hold - 1 / 120); + b.vx = WALK_SPEED; + stepBody(b, geometry, 1 / 120); + expect(water.some(box => overlaps(b, box)), `in the pond at x=${b.x.toFixed(0)}`).toBe(false); + expect(b.y).toBeLessThan(482); + } + expect(b.x).toBeGreaterThanOrEqual(4 * 2160 + 1300); + expect(jumps).toBeGreaterThanOrEqual(8); + }); + it('the birch is a log to jump, not a hill: the slab walker stops at it', () => { + const b = run(body(1600, 446), geometry, 120); + expect(b.x).toBe(1630 - 14); expect(b.y).toBe(446); + }); + it('the old 71 px step in hg-3-0 is a wall face you jump: the stone walker stops at it', () => { + const b = run(body(3 * 2160 + 1050, 462), geometry, 120); + expect(b.x).toBe(3 * 2160 + 1077 - 14); expect(b.y).toBe(462); expect(b.grounded).toBe(true); + }); +}); diff --git a/game/src/player/traversal.ts b/game/src/player/traversal.ts index 0ffde80..02c09ca 100644 --- a/game/src/player/traversal.ts +++ b/game/src/player/traversal.ts @@ -4,7 +4,8 @@ export const CLIMB_SPEED = 70; export interface Traversal { ladder: Box | null; drop: number; detach: number } export const traversal = (): Traversal => ({ ladder: null, drop: 0, detach: 0 }); -function surfaceNear(geometry: Geometry[], x: number, y: number) { +/** The walk or one-way surface within 8 px of (x, y), else y. */ +export function surfaceNear(geometry: Geometry[], x: number, y: number) { const candidates: number[] = []; for (const world of geometry) for (const line of [...world.walk, ...world.oneWay]) { for (let i = 1; i < line.length; i++) { diff --git a/game/src/scenes/registry.ts b/game/src/scenes/registry.ts index f625555..a68e36d 100644 --- a/game/src/scenes/registry.ts +++ b/game/src/scenes/registry.ts @@ -2,6 +2,7 @@ import type Phaser from 'phaser'; import { AnomalyTest } from '../fx/AnomalyTest'; import { AtmosphereTest } from '../fx/AtmosphereTest'; import { Audio } from '../fx/sound/Audio'; +import { CoffeeVisit } from '../story/CoffeeVisit'; import { Crossing } from '../story/Crossing'; import { Ending } from '../story/Ending'; import { Flow } from '../story/Flow'; @@ -14,7 +15,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, Flow, Hud, Crossing, PocketComputer, Ending, Title, + Audio, Flow, CoffeeVisit, 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 c99b56b..47acf14 100644 --- a/game/src/stage/assets.ts +++ b/game/src/stage/assets.ts @@ -1,13 +1,37 @@ 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', 'boss-jenny', 'boss-jenny-2'] as const; -export interface AnimationClip { frames: number; fps: number; loop: boolean } +import boards from './boards.json'; + +/** Every atlas the game needs – data in boards.json, one line per board. */ +export const BOARDS: readonly string[] = boards.required; +/** Atlases the game uses when they exist (not exported yet → simply absent, no placeholder, no error). */ +export const OPTIONAL_BOARDS: readonly string[] = boards.optional; +/** `stride`: a locomotion cycle's stride in game px (compare.py stride) – it is played by distance, not by time. */ +export interface AnimationClip { frames: number; fps: number; loop: boolean; stride?: number } export type AnimationTable = Record; export const stageBase = (region: string) => `/assets/stages/${region}/`; export const textureKey = (region: string, file: string) => `${region}:${file}`; +/** The exporter contract (scripts/export_atlas.py): every clip's frames `-NN` exist in the atlas. */ +export function validateAtlas(board: string, atlas: unknown, table: unknown): AnimationTable { + const frames = (atlas as { frames?: Record } | null)?.frames; + const clips = table as AnimationTable | null; + if (!frames || !clips || typeof clips !== 'object' || !Object.keys(clips).length) throw new Error(`Invalid atlas ${board}`); + for (const [name, clip] of Object.entries(clips)) { + if (!Number.isInteger(clip.frames) || clip.frames < 1 || !Number.isFinite(clip.fps) + || clip.fps <= 0 || typeof clip.loop !== 'boolean') throw new Error(`Invalid clip ${board}:${name}`); + for (let n = 0; n < clip.frames; n++) if (!frames[`${name}-${String(n).padStart(2, '0')}`]) { + throw new Error(`Missing frame ${board}:${name}-${n}`); + } + } + return clips; +} + +/** + * Ad-hoc loading for tools and tests. The game loads through `boot/loader.ts` (Boot and Preload only), which + * shares this file's BOARDS, keys and atlas validation. + */ export class Assets { constructor(private scene: Phaser.Scene, private strict = import.meta.env.DEV, private request: typeof fetch = (...args) => fetch(...args)) {} @@ -64,16 +88,7 @@ export class Assets { private async atlas(board: string, base: string): Promise { const atlas = await this.json(`${base}.json`); - const clips = await this.json(`${base}.anims.json`) as AnimationTable; - const frames = (atlas as { frames?: Record }).frames; - if (!frames || !clips || !Object.keys(clips).length) throw new Error(`Invalid atlas ${board}`); - for (const [name, clip] of Object.entries(clips)) { - if (!Number.isInteger(clip.frames) || clip.frames < 1 || !Number.isFinite(clip.fps) - || clip.fps <= 0 || typeof clip.loop !== 'boolean') throw new Error(`Invalid clip ${board}:${name}`); - for (let n = 0; n < clip.frames; n++) if (!frames[`${name}-${String(n).padStart(2, '0')}`]) { - throw new Error(`Missing frame ${board}:${name}-${n}`); - } - } + const clips = validateAtlas(board, atlas, await this.json(`${base}.anims.json`)); await this.queue(() => this.scene.load.atlas(board, `${base}.png`, atlas as object)); this.scene.cache.json.add(`${board}.anims`, clips); return clips; diff --git a/game/src/stage/atmosphere/analysis.ts b/game/src/stage/atmosphere/analysis.ts new file mode 100644 index 0000000..653a500 --- /dev/null +++ b/game/src/stage/atmosphere/analysis.ts @@ -0,0 +1,146 @@ +import type Phaser from 'phaser'; +/** + * What the atmosphere reads from the art itself, once per layer at load – pure, so it is tested without + * Phaser: where each column of a layer is anchored (its ground), the waterline, the autumn palette. + */ + +/** Alpha / colour access to a (possibly downscaled) copy of a layer: rgba bytes, row-major. */ +export interface Pixels { width: number; height: number; data: Uint8ClampedArray | Uint8Array; scale: number } + +/** Column buckets in a layer's anchor profile (the shader interpolates between them). */ +export const PROFILE_SIZE = 48; +const SOLID = 128; + +const alpha = (p: Pixels, x: number, y: number) => p.data[(y * p.width + x) * 4 + 3]; + +/** + * For each of PROFILE_SIZE column buckets, the row (in full-size layer px) a plant in it is rooted at. + * 'base': the lowest opaque row (trees and foreground plants grow up from it); + * 'top': the highest opaque row (grass on a walkable surface). Empty buckets copy their neighbours; + * the result is smoothed (1-2-1) so the sway field has no seams. + */ +export function anchorProfile(p: Pixels, anchor: 'base' | 'top', out: Float32Array = new Float32Array(PROFILE_SIZE)): Float32Array { + const raw = new Float32Array(PROFILE_SIZE).fill(NaN); + for (let b = 0; b < PROFILE_SIZE; b++) { + const x0 = Math.floor((b * p.width) / PROFILE_SIZE), x1 = Math.max(x0 + 1, Math.floor(((b + 1) * p.width) / PROFILE_SIZE)); + let best = NaN; + for (let x = x0; x < x1; x++) { + if (anchor === 'base') { + for (let y = p.height - 1; y >= 0; y--) if (alpha(p, x, y) >= SOLID) { if (!(best >= y)) best = y; break; } + } else { + for (let y = 0; y < p.height; y++) if (alpha(p, x, y) >= SOLID) { if (!(best <= y)) best = y; break; } + } + } + raw[b] = best; + } + // Fill empty buckets from the nearest filled one; a fully empty layer anchors at its bottom. + for (let b = 0; b < PROFILE_SIZE; b++) { + if (!Number.isNaN(raw[b])) continue; + let value = NaN; + for (let d = 1; d < PROFILE_SIZE && Number.isNaN(value); d++) { + if (b - d >= 0 && !Number.isNaN(raw[b - d])) value = raw[b - d]; + else if (b + d < PROFILE_SIZE && !Number.isNaN(raw[b + d])) value = raw[b + d]; + } + raw[b] = Number.isNaN(value) ? p.height - 1 : value; + } + for (let b = 0; b < PROFILE_SIZE; b++) { + const l = raw[Math.max(0, b - 1)], r = raw[Math.min(PROFILE_SIZE - 1, b + 1)]; + out[b] = ((l + 2 * raw[b] + r) / 4 + 0.5) * p.scale; + } + return out; +} + +/** + * The waterline of a water layer in full-size px: the first row that is mostly water, ignoring stray + * specks above it (a row counts when at least `cover` of it is opaque). -1 when the layer has no water. + */ +export function waterline(p: Pixels, cover = 0.5): number { + for (let y = 0; y < p.height; y++) { + let solid = 0; + for (let x = 0; x < p.width; x++) if (alpha(p, x, y) >= SOLID) solid++; + if (solid / p.width >= cover) return y * p.scale; + } + return -1; +} + +/** + * Autumn colours from the art: saturated orange, red and yellow pixels, bucketed and ranked by how + * often they occur; up to `count` distinct ones (at least `distance` apart in RGB), most common first. + */ +export function autumnPalette(p: Pixels, count = 6, distance = 48): number[] { + const histogram = new Map(); + for (let i = 0; i < p.data.length; i += 4) { + if (p.data[i + 3] < 250) continue; + const r = p.data[i], g = p.data[i + 1], b = p.data[i + 2]; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max < 90 || (max - min) / max < 0.5 || max !== r) continue; // dim, grey, green or blue + const hue = 60 * ((g - b) / (max - min)); + if (hue < 5 || hue > 55) continue; // red-orange to yellow + const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); + const bin = histogram.get(key) ?? { n: 0, r: 0, g: 0, b: 0 }; + bin.n++; bin.r += r; bin.g += g; bin.b += b; + histogram.set(key, bin); + } + const ranked = [...histogram.values()].sort((a, b) => b.n - a.n) + .map(bin => [Math.round(bin.r / bin.n), Math.round(bin.g / bin.n), Math.round(bin.b / bin.n)]); + const picked: number[][] = []; + for (const colour of ranked) { + if (picked.length >= count) break; + if (picked.every(c => Math.hypot(c[0] - colour[0], c[1] - colour[1], c[2] - colour[2]) >= distance)) picked.push(colour); + } + return picked.map(([r, g, b]) => (r << 16) | (g << 8) | b); +} + +/** Whether a layer image at world x (scroll factor `scroll`) touches the view at camera scroll `cameraX`. */ +export function onScreen(x: number, width: number, scroll: number, cameraX: number, viewWidth: number, margin = 16): boolean { + const left = x - cameraX * scroll; + return left < viewWidth + margin && left + width > -margin; +} + +/** + * Points on the water (full-size px, flat [x, y, x, y, …]) on a `step` grid below the waterline: + * every opaque one, and the bright ones (luma ≥ 0.7, where sky and sun are reflected) for sparkles. + */ +export function waterPoints(p: Pixels, line: number, step = 8): { all: number[]; bright: number[] } { + const all: number[] = [], bright: number[] = []; + if (line < 0) return { all, bright }; + const s = Math.max(1, Math.round(step / p.scale)); + for (let y = Math.ceil(line / p.scale); y < p.height; y += s) { + for (let x = 0; x < p.width; x += s) { + const i = (y * p.width + x) * 4; + if (p.data[i + 3] < 250) continue; + all.push(x * p.scale, y * p.scale); + if ((0.299 * p.data[i] + 0.587 * p.data[i + 1] + 0.114 * p.data[i + 2]) / 255 >= 0.7) bright.push(x * p.scale, y * p.scale); + } + } + return { all, bright }; +} + +/** A water surface on the stage: its world x range, the world row of its waterline and its image. */ +export interface WaterSpan { x0: number; x1: number; y: number; image: Phaser.GameObjects.Image } + +/** The world waterline under x, or null where there is no water. */ +export function waterlineAt(spans: readonly WaterSpan[], x: number): number | null { + for (const span of spans) if (x >= span.x0 && x < span.x1) return span.y; + return null; +} + +/** Where a sprite's mirror image goes: its feet at `feet` mirror to 2·waterline − feet. */ +export const mirrorY = (waterline: number, feet: number) => 2 * waterline - feet; + +/** After the reveal's `duration`, the wrong reflection fades out over this long (ms). */ +export const REVEAL_SETTLE = 700; + +/** + * Opacity (0–1) of the wrong reflection `elapsed` ms into a reveal of `duration` ms, or null when it is + * over. It flickers in 90 ms steps – on more often as the reveal builds – then settles out. + */ +export function revealAlpha(elapsed: number, duration: number): number | null { + if (elapsed < 0 || elapsed > duration + REVEAL_SETTLE) return null; + const build = Math.min(1, elapsed / Math.max(1, duration * 0.4)); + const settle = elapsed <= duration ? 1 : 1 - (elapsed - duration) / REVEAL_SETTLE; + const step = Math.floor(elapsed / 90); + const hash = Math.abs(Math.sin(step * 12.9898 + 78.233) * 43758.5453) % 1; + const on = elapsed > duration || hash < 0.3 + 0.45 * build; + return Math.max(0, (on ? 1 : 0.15) * build * settle); +} diff --git a/game/src/stage/atmosphere/atmosphere.test.ts b/game/src/stage/atmosphere/atmosphere.test.ts new file mode 100644 index 0000000..2eb2e95 --- /dev/null +++ b/game/src/stage/atmosphere/atmosphere.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import garden from '../../../../art/stages/hidden-garden/atmosphere.json'; +import { DEFAULT_ATMOSPHERE, layerAtmosphere, parseAtmosphere } from './config'; +import { gustAt, maxSway, swayAt, swayWeight } from './wind'; +import { anchorProfile, autumnPalette, mirrorY, revealAlpha, REVEAL_SETTLE, onScreen, PROFILE_SIZE, waterline, waterlineAt, waterPoints, type Pixels, type WaterSpan } from './analysis'; +import { seeded } from './sprites'; +import { ACTOR_FX_DEPTH, FOREGROUND_DEPTH, HERO_DEPTH, layerDepth, layerRanks, LEAVES_BEHIND_DEPTH, LEAVES_FRONT_DEPTH } from '../depth'; + +function pixels(width: number, height: number, paint: (x: number, y: number) => [number, number, number, number] | null, scale = 1): Pixels { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const c = paint(x, y); + if (c) data.set(c, (y * width + x) * 4); + } + return { width, height, data, scale }; +} + +describe('atmosphere.json', () => { + it('gives the defaults for {} or a missing file', () => { + expect(parseAtmosphere({})).toEqual({ atmosphere: DEFAULT_ATMOSPHERE, problems: [] }); + expect(parseAtmosphere(undefined).atmosphere).toEqual(DEFAULT_ATMOSPHERE); + }); + + it('reads the committed Hidden Garden file without a problem', () => { + const raw: unknown = garden; + const { atmosphere, problems } = parseAtmosphere(raw, 'clean'); + expect(problems).toEqual([]); + expect(atmosphere.layers['07-foreground'].blur).toBeGreaterThan(0); + expect(parseAtmosphere(raw, 'anomaly').atmosphere.leaves.fall).toBe('up'); + }); + + it('reports a bad field and keeps its default', () => { + const { atmosphere, problems } = parseAtmosphere({ wind: { base: 7, direction: 0 }, layers: { x: { sway: 'a', wobble: 1 } }, leaves: { front: { tint: 'red' } } }); + expect(atmosphere.wind.base).toBe(DEFAULT_ATMOSPHERE.wind.base); + expect(atmosphere.wind.direction).toBe(-1); + expect(atmosphere.leaves.front?.tint).toBe(0xffffff); + expect(problems.join('\n')).toMatch(/wind\.base/); + expect(problems.join('\n')).toMatch(/layers\.x\.wobble is unknown/); + expect(problems.join('\n')).toMatch(/leaves\.front\.tint/); + }); + + it('turns sections off with null', () => { + const { atmosphere } = parseAtmosphere({ leaves: { behind: null }, water: null }); + expect(atmosphere.leaves.behind).toBeNull(); + expect(atmosphere.leaves.front).not.toBeNull(); + expect(atmosphere.water).toBeNull(); + }); + + it('derives a layer without an entry from its nearness; an entry wins', () => { + const atmo = parseAtmosphere({ layers: { '03-rear': { sway: 9 } } }).atmosphere; + const far = layerAtmosphere(atmo, { id: '01-far', z: 1, scroll: 0.25 }); + const front = layerAtmosphere(atmo, { id: '07-foreground', z: 7, scroll: 1.2 }); + expect(far.sway).toBeLessThan(front.sway); + expect(far.blur).toBe(0); + expect(layerAtmosphere(atmo, { id: '03-rear', z: 3, scroll: 1 }).sway).toBe(9); + const water = layerAtmosphere(atmo, { id: '04-water', z: 4, scroll: 1 }); + expect(water.sway).toBe(0); + }); +}); + +describe('depth bands', () => { + it('keeps layers z 0–5 under the actors and z 6–7 over them', () => { + expect(layerDepth(5, 89)).toBeLessThan(HERO_DEPTH - 1); + expect(layerDepth(6, 0)).toBeGreaterThan(ACTOR_FX_DEPTH); + expect(layerDepth(6, 29)).toBeLessThan(LEAVES_FRONT_DEPTH); + expect(layerDepth(7, 0)).toBeGreaterThanOrEqual(FOREGROUND_DEPTH); + expect(LEAVES_BEHIND_DEPTH).toBeLessThan(HERO_DEPTH); + expect(LEAVES_FRONT_DEPTH).toBeGreaterThan(HERO_DEPTH); + expect(LEAVES_FRONT_DEPTH).toBeLessThan(FOREGROUND_DEPTH); + expect(() => layerDepth(3, 90)).toThrow(); + }); + + it('ranks a layer the same in every chunk, even where a chunk lists fewer', () => { + const full = [{ id: 'sky', z: 0 }, { id: 'far', z: 1 }, { id: 'rear', z: 3 }, { id: 'play', z: 5 }, { id: 'fg', z: 7 }]; + const short = [{ id: 'rear', z: 3 }, { id: 'play', z: 5 }, { id: 'fg', z: 7 }]; + const ranks = layerRanks([full, short]); + expect(ranks.get('rear')).toBeGreaterThan(ranks.get('far')!); + expect(layerDepth(3, ranks.get('rear')!)).toBeGreaterThan(layerDepth(1, ranks.get('far')!)); + }); + + it('keeps the authored list order inside a band over z (Rusty Creek water after the path)', () => { + const list = [{ id: 'bank', z: 2 }, { id: 'supports', z: 3 }, { id: 'path', z: 5 }, { id: 'rails', z: 6 }, { id: 'foliage', z: 7 }, { id: 'water', z: 4 }]; + const ranks = layerRanks([list]); + expect(ranks.get('water')).toBeGreaterThan(ranks.get('path')!); + expect(ranks.get('foliage')).toBeGreaterThan(ranks.get('rails')!); + expect(layerDepth(4, ranks.get('water')!)).toBeLessThan(HERO_DEPTH); + }); +}); + +describe('the wind', () => { + const wind = DEFAULT_ATMOSPHERE.wind; + const layer = { sway: 3, reach: 100 }; + + it('leaves the anchor still and moves the top most', () => { + expect(swayWeight(0, 100)).toBe(0); + expect(swayWeight(50, 100)).toBeCloseTo(0.5); + expect(swayWeight(400, 100)).toBe(1); + for (let t = 0; t < 20; t += 0.37) expect(Math.abs(swayAt(wind, layer, 500, 0, t))).toBe(0); + }); + + it('never moves a layer more than its amplitude allows', () => { + for (let t = 0; t < 30; t += 0.13) for (let x = 0; x < 3000; x += 211) { + expect(Math.abs(swayAt(wind, layer, x, 300, t))).toBeLessThanOrEqual(maxSway(wind, layer) + 1e-9); + } + }); + + it('sends gusts downwind', () => { + // Find a gust peak at t = 0 and check that it has moved left (direction -1) one second later. + let peak = 0; + for (let x = 0; x < wind.gustLength; x++) if (gustAt(wind, x, 0) > gustAt(wind, peak, 0)) peak = x; + let later = 0, best = -1; + for (let x = peak - wind.gustLength / 2; x < peak + wind.gustLength / 2; x++) { + const g = gustAt(wind, x, 1); + if (g > best) { best = g; later = x; } + } + expect(later - peak).toBeCloseTo(-wind.gustSpeed, -1); + }); +}); + +describe('reading the art', () => { + it('anchors a plant at its lowest pixel and grass at its surface', () => { + // A column of 'trunk' from row 2 to row 7 in a 10-row layer, across the whole width. + const p = pixels(96, 10, (_x, y) => (y >= 2 && y <= 7 ? [200, 100, 20, 255] : null)); + expect(anchorProfile(p, 'base')[10]).toBeCloseTo(7.5); + expect(anchorProfile(p, 'top')[10]).toBeCloseTo(2.5); + expect(anchorProfile(p, 'base').length).toBe(PROFILE_SIZE); + }); + + it('fills empty columns from their neighbours', () => { + const p = pixels(96, 10, x => (x < 10 ? [0, 0, 0, 255] : null)); + const profile = anchorProfile(p, 'base'); + expect(profile[PROFILE_SIZE - 1]).toBeCloseTo(9.5); + }); + + it('finds the waterline below stray specks, in full-size px', () => { + const p = pixels(40, 30, (x, y) => (y >= 20 || (y === 4 && x < 3) ? [40, 90, 200, 255] : null), 2); + expect(waterline(p)).toBe(40); + expect(waterline(pixels(4, 4, () => null))).toBe(-1); + }); + + it('collects water points and the bright ones for sparkles', () => { + const p = pixels(16, 16, (x, y) => (y >= 8 ? (x < 8 ? [250, 250, 250, 255] : [20, 40, 90, 255]) : null)); + const { all, bright } = waterPoints(p, 8, 4); + expect(all.length).toBe(2 * 2 * 4); + expect(bright.length).toBe(2 * 2 * 2); + expect(Math.min(...all.filter((_, i) => i % 2 === 1))).toBe(8); + }); + + it('samples autumn colours and skips greens, greys and blues', () => { + const p = pixels(10, 10, (x) => (x < 5 ? [220, 120, 30, 255] : x < 7 ? [40, 160, 40, 255] : x < 9 ? [128, 128, 128, 255] : [40, 80, 220, 255])); + const palette = autumnPalette(p); + expect(palette).toEqual([(220 << 16) | (120 << 8) | 30]); + }); + + it('culls a layer only when it is off screen at its own parallax', () => { + expect(onScreen(2160, 2160, 0.1, 9000, 1280)).toBe(true); // 2160 − 900 = 1260 < 1280 + expect(onScreen(4320, 2160, 1, 0, 1280)).toBe(false); + expect(onScreen(0, 2160, 1, 2200, 1280)).toBe(false); + }); +}); + +describe('reflections', () => { + const spans = [{ x0: 0, x1: 2160, y: 480 }, { x0: 2160, x1: 4320, y: 470 }] as unknown as WaterSpan[]; + it('mirrors an actor about the waterline under it', () => { + expect(waterlineAt(spans, 100)).toBe(480); + expect(waterlineAt(spans, 3000)).toBe(470); + expect(waterlineAt(spans, 5000)).toBeNull(); + expect(mirrorY(480, 470)).toBe(490); + }); + + it('places pads the same way every run', () => { + const a = seeded(404), b = seeded(404); + expect([a(), a(), a()]).toEqual([b(), b(), b()]); + }); +}); + +describe('anomaly-reveal', () => { + it('flickers in, holds, settles out, then is over', () => { + expect(revealAlpha(-1, 2000)).toBeNull(); + expect(revealAlpha(0, 2000)).toBe(0); + const during = Array.from({ length: 20 }, (_, i) => revealAlpha(1000 + i * 45, 2000)!); + expect(Math.max(...during)).toBe(1); + expect(Math.min(...during)).toBeLessThan(1); // it flickers + expect(revealAlpha(2000 + REVEAL_SETTLE / 2, 2000)).toBeCloseTo(0.5); + expect(revealAlpha(2000 + REVEAL_SETTLE + 1, 2000)).toBeNull(); + }); + + it('only takes a wrong image that is one of the region assets', () => { + const ok = parseAtmosphere({ assets: ['wrong.png'], water: { wrong: { image: 'wrong.png' } } }); + expect(ok.atmosphere.water?.wrong).toEqual({ image: 'wrong.png', alpha: 0.7 }); + const bad = parseAtmosphere({ assets: ['../x.png'], water: { wrong: { image: 'y.png' } } }); + expect(bad.atmosphere.water?.wrong).toBeNull(); + expect(bad.problems.length).toBe(2); + }); +}); diff --git a/game/src/stage/atmosphere/config.ts b/game/src/stage/atmosphere/config.ts new file mode 100644 index 0000000..d895580 --- /dev/null +++ b/game/src/stage/atmosphere/config.ts @@ -0,0 +1,290 @@ +/** + * atmosphere.json – the per-region description of everything that moves on a stage (docs/09, docs/15): + * wind, how each layer sways, depth of field, falling leaves and the water. Parsing is lenient: a bad + * field is reported and replaced by its default, so a typo never blanks the stage. + */ +import { isForeground } from '../depth'; +import { assetFile } from '../manifest'; + +export interface Wind { + /** Steady sway, as a share of a layer's amplitude (0–1). */ + base: number; + /** Extra lean while a gust passes (0–2). */ + gust: number; + /** Gust fronts travel this fast across the layer, px/s. */ + gustSpeed: number; + /** Distance between gust fronts, px. */ + gustLength: number; + /** Sway cycles per second. */ + frequency: number; + /** -1: the wind blows to the left, 1: to the right. */ + direction: 1 | -1; +} + +export interface LayerFx { + /** Horizontal sway at full weight, px. Far layers drift a pixel; the foreground moves a few. */ + sway: number; + /** Height over which the sway weight goes from 0 at the anchor to 1, px. */ + reach: number; + /** 'base': rooted at the lowest opaque row of each column (trees, foreground plants); 'top': the grass on a surface. */ + anchor: 'base' | 'top'; + /** Saturation below which a pixel never moves (stone, wood, walls); 0 lets everything move. */ + foliage: number; + /** Vertical bob at full weight, px (lily pads and flower heads in the foreground). */ + bob: number; + /** Depth-of-field blur radius, px (foreground layers). */ + blur: number; + /** Horizontal water ripple below the waterline, px. */ + ripple: number; + /** Highlights glinting on the water, 0–1. */ + shimmer: number; +} + +export interface LeafLayer { + /** ms between leaves. */ + every: number; + /** Pixel scale range. */ + scale: [number, number]; + /** Parallax of the leaves against the camera (foreground > 1, behind < 1). */ + scroll: number; + alpha: number; + /** Multiplied colour, e.g. '#c8c0b8' to sink the back leaves into the haze. */ + tint: number; +} + +export interface Leaves { + /** Leaf colours; empty: sampled from the stage art (autumn hues of the rear and foreground layers). */ + palette: number[]; + /** 'down': they fall; 'up': they rise (the anomaly). */ + fall: 'down' | 'up'; + front: LeafLayer | null; + behind: LeafLayer | null; +} + +export interface Water { + /** The layer id that holds the water; its waterline is found from its alpha. */ + layer: string; + /** The hero's mirror image in the water, or null for none. */ + reflect: { alpha: number; tint: number } | null; + /** Bobbing lily pads (fx-water lily-bob) per 1000 px of water, on the near half of the band. */ + pads: number; + /** Pad tint and scale, to match the pads painted in the art. */ + padTint: number; + padScale: number; + /** Mean ms between ripple rings / sparkles on screen (0: none). */ + ripples: number; + sparkles: number; + /** anomaly-reveal: the picture that flickers in the water (a file in `assets`) and its peak opacity. */ + wrong: { image: string; alpha: number } | null; +} + +export interface Atmosphere { + wind: Wind; + /** Applied to a layer without its own entry, scaled by nearness (sway × scroll²). */ + defaults: LayerFx; + layers: Record>; + leaves: Leaves; + water: Water | null; + /** Extra images in the region folder the atmosphere uses; exported and loaded with the stage. */ + assets: string[]; +} + +export const DEFAULT_ATMOSPHERE: Atmosphere = { + wind: { base: 0.45, gust: 0.9, gustSpeed: 240, gustLength: 1400, frequency: 0.55, direction: -1 }, + defaults: { sway: 1.5, reach: 260, anchor: 'base', foliage: 0.3, bob: 0, blur: 0, ripple: 0, shimmer: 0 }, + layers: {}, + leaves: { + palette: [], fall: 'down', + front: { every: 420, scale: [1, 2], scroll: 1.15, alpha: 1, tint: 0xffffff }, + behind: { every: 300, scale: [1, 1.25], scroll: 0.85, alpha: 0.9, tint: 0xd8d0c8 }, + }, + water: { layer: '04-water', reflect: { alpha: 0.5, tint: 0x7890b0 }, pads: 3, padTint: 0xc8a070, padScale: 1.15, ripples: 900, sparkles: 220, wrong: null }, + assets: [], +}; + +type Obj = Record; +const object = (value: unknown): value is Obj => typeof value === 'object' && value !== null && !Array.isArray(value); + +class Reader { + problems: string[] = []; + number(source: Obj, key: string, fallback: number, min: number, max: number, path: string): number { + const value = source[key]; + if (value === undefined) return fallback; + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) { + this.problems.push(`${path}.${key} must be a number in ${min}–${max}`); + return fallback; + } + return value; + } + colour(source: Obj, key: string, fallback: number, path: string): number { + const value = source[key]; + if (value === undefined) return fallback; + const parsed = parseColour(value); + if (parsed === null) { this.problems.push(`${path}.${key} must be a #rrggbb colour`); return fallback; } + return parsed; + } + section(source: Obj, key: string, path: string): Obj | null | undefined { + const value = source[key]; + if (value === undefined) return undefined; + if (value === null) return null; + if (!object(value)) { this.problems.push(`${path}.${key} must be an object`); return undefined; } + return value; + } +} + +export function parseColour(value: unknown): number | null { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? parseInt(value.slice(1), 16) : null; +} + +function layerFx(read: Reader, source: Obj, path: string, fallback?: LayerFx): Partial { + const out: Partial = {}; + const numbers: [keyof LayerFx, number, number][] = [ + ['sway', 0, 16], ['reach', 1, 2000], ['foliage', 0, 1], ['bob', 0, 8], ['blur', 0, 4], ['ripple', 0, 8], ['shimmer', 0, 1]]; + for (const [key, min, max] of numbers) { + if (source[key] !== undefined || fallback) { + (out as Record)[key] = read.number(source, key, fallback ? fallback[key] as number : 0, min, max, path); + } + } + if (source.anchor !== undefined) { + if (source.anchor === 'base' || source.anchor === 'top') out.anchor = source.anchor; + else read.problems.push(`${path}.anchor must be "base" or "top"`); + } + if (fallback && !out.anchor) out.anchor = fallback.anchor; + for (const key of Object.keys(source)) { + if (!['sway', 'reach', 'anchor', 'foliage', 'bob', 'blur', 'ripple', 'shimmer', 'note'].includes(key)) read.problems.push(`${path}.${key} is unknown`); + } + return out; +} + +function leafLayer(read: Reader, source: Obj | null | undefined, fallback: LeafLayer | null, path: string): LeafLayer | null { + if (source === null) return null; + if (source === undefined) return fallback; + fallback ??= DEFAULT_ATMOSPHERE.leaves.front!; + let scale = fallback.scale; + if (source.scale !== undefined) { + const s = source.scale; + if (Array.isArray(s) && s.length === 2 && s.every(v => typeof v === 'number' && v > 0 && v <= 8) && s[0] <= s[1]) scale = [s[0], s[1]]; + else read.problems.push(`${path}.scale must be [min, max] in 0–8`); + } + return { + every: read.number(source, 'every', fallback.every, 30, 10_000, path), + scale, + scroll: read.number(source, 'scroll', fallback.scroll, 0, 3, path), + alpha: read.number(source, 'alpha', fallback.alpha, 0, 1, path), + tint: read.colour(source, 'tint', fallback.tint, path), + }; +} + +/** + * Read an atmosphere.json value (anything – `{}` or a missing file give the defaults). + * `state` picks the optional `states.` overrides, merged section by section. + */ +export function parseAtmosphere(value: unknown, state?: string): { atmosphere: Atmosphere; problems: string[] } { + const read = new Reader(); + const d = DEFAULT_ATMOSPHERE; + let root: Obj = object(value) ? value : {}; + if (value !== undefined && value !== null && !object(value)) read.problems.push('atmosphere must be an object'); + const states = object(root.states) ? root.states : {}; + if (state && object(states[state])) { + const over = states[state] as Obj; + root = { ...root }; + for (const [key, section] of Object.entries(over)) { + root[key] = object(root[key]) && object(section) ? { ...(root[key] as Obj), ...section } : section; + } + } + + const w = read.section(root, 'wind', 'wind') ?? {}; + const direction = w.direction === undefined ? d.wind.direction : w.direction === 1 || w.direction === -1 ? w.direction : null; + if (direction === null) read.problems.push('wind.direction must be 1 or -1'); + const wind: Wind = { + base: read.number(w, 'base', d.wind.base, 0, 1, 'wind'), + gust: read.number(w, 'gust', d.wind.gust, 0, 2, 'wind'), + gustSpeed: read.number(w, 'gustSpeed', d.wind.gustSpeed, 0, 2000, 'wind'), + gustLength: read.number(w, 'gustLength', d.wind.gustLength, 50, 20_000, 'wind'), + frequency: read.number(w, 'frequency', d.wind.frequency, 0, 5, 'wind'), + direction: direction ?? d.wind.direction, + }; + + const defaults = layerFx(read, read.section(root, 'defaults', 'defaults') ?? {}, 'defaults', d.defaults) as LayerFx; + const layers: Record> = {}; + const layerSection = read.section(root, 'layers', 'layers') ?? {}; + for (const [id, entry] of Object.entries(layerSection)) { + if (!object(entry)) { read.problems.push(`layers.${id} must be an object`); continue; } + layers[id] = layerFx(read, entry, `layers.${id}`); + } + + const l = read.section(root, 'leaves', 'leaves'); + let leaves: Leaves; + if (l === null) leaves = { ...d.leaves, front: null, behind: null }; + else { + const source = l ?? {}; + const palette: number[] = []; + if (source.palette !== undefined) { + if (Array.isArray(source.palette) && source.palette.every(c => parseColour(c) !== null)) palette.push(...source.palette.map(c => parseColour(c) as number)); + else read.problems.push('leaves.palette must be a list of #rrggbb colours'); + } + const fall = source.fall === undefined ? d.leaves.fall : source.fall === 'down' || source.fall === 'up' ? source.fall : null; + if (fall === null) read.problems.push('leaves.fall must be "down" or "up"'); + leaves = { + palette, fall: fall ?? d.leaves.fall, + front: leafLayer(read, read.section(source, 'front', 'leaves'), d.leaves.front, 'leaves.front'), + behind: leafLayer(read, read.section(source, 'behind', 'leaves'), d.leaves.behind, 'leaves.behind'), + }; + } + + const wa = read.section(root, 'water', 'water'); + let water: Water | null = d.water; + if (wa === null) water = null; + else if (wa) { + const layer = wa.layer === undefined ? d.water!.layer : typeof wa.layer === 'string' && wa.layer ? wa.layer : null; + if (layer === null) read.problems.push('water.layer must be a layer id'); + const r = read.section(wa, 'reflect', 'water'); + water = { + layer: layer ?? d.water!.layer, + reflect: r === null ? null : { + alpha: read.number(r ?? {}, 'alpha', d.water!.reflect!.alpha, 0, 1, 'water.reflect'), + tint: read.colour(r ?? {}, 'tint', d.water!.reflect!.tint, 'water.reflect'), + }, + pads: read.number(wa, 'pads', d.water!.pads, 0, 40, 'water'), + padTint: read.colour(wa, 'padTint', d.water!.padTint, 'water'), + padScale: read.number(wa, 'padScale', d.water!.padScale, 0.25, 4, 'water'), + ripples: read.number(wa, 'ripples', d.water!.ripples, 0, 60_000, 'water'), + sparkles: read.number(wa, 'sparkles', d.water!.sparkles, 0, 60_000, 'water'), + wrong: null, + }; + } + const assets: string[] = []; + if (root.assets !== undefined) { + if (Array.isArray(root.assets) && root.assets.every(assetFile)) assets.push(...root.assets); + else read.problems.push('assets must be a list of file names in the region folder'); + } + if (water && wa) { + const wrong = read.section(wa, 'wrong', 'water'); + if (wrong) { + if (typeof wrong.image === 'string' && assets.includes(wrong.image)) { + water.wrong = { image: wrong.image, alpha: read.number(wrong, 'alpha', 0.7, 0, 1, 'water.wrong') }; + } else read.problems.push('water.wrong.image must be one of assets'); + } + } + return { atmosphere: { wind, defaults, layers, leaves, water, assets }, problems: read.problems }; +} + +/** + * What a layer does: its own entry over a default derived from its depth. Nearer layers (higher + * scroll) sway more (× scroll²); foreground layers get the default bob and blur; the water layer ripples. + */ +export function layerAtmosphere(atmosphere: Atmosphere, layer: { id: string; z: number; scroll: number }): LayerFx { + const d = atmosphere.defaults; + const water = atmosphere.water?.layer === layer.id; + const derived: LayerFx = { + sway: water ? 0 : d.sway * layer.scroll * layer.scroll, + reach: d.reach, + anchor: d.anchor, + foliage: d.foliage, + bob: isForeground(layer.z) ? d.bob : 0, + blur: isForeground(layer.z) ? d.blur : 0, + ripple: water ? d.ripple : 0, + shimmer: water ? d.shimmer : 0, + }; + return { ...derived, ...atmosphere.layers[layer.id] }; +} diff --git a/game/src/stage/atmosphere/leaves.ts b/game/src/stage/atmosphere/leaves.ts new file mode 100644 index 0000000..3b12290 --- /dev/null +++ b/game/src/stage/atmosphere/leaves.ts @@ -0,0 +1,114 @@ +import Phaser from 'phaser'; +import type { LeafLayer, Leaves, Wind } from './config'; +import { gustAt } from './wind'; +import { sheetAnimations } from './sprites'; + +/** + * Falling leaves in front of and behind the actors. They are screen-space emitters nudged against the + * camera at their own parallax, pushed by the same wind and gusts the foliage shows, and flutter + * (they flip and rock while they fall). Art: workstream 5's `fx-leaves` sheet when it is loaded, + * otherwise small pixel leaves drawn here in the stage's own autumn colours. + */ +export const LEAF_SHEET = 'fx-leaves'; +const PROCEDURAL = 'fx-leaves-procedural'; +/** Leaf silhouettes, 6×4 px: 1 = body, 2 = vein/stem (drawn darker). */ +const SHAPES = [ + ['..11..', '.1121.', '112211', '..2...'], + ['.11...', '11211.', '.11211', '....2.'], + ['..1...', '.121..', '11211.', '.2....'], +]; + +function proceduralSheet(scene: Phaser.Scene, palette: number[]): string[] { + const colours = palette.length ? palette : [0xd9a03a, 0xc9622b, 0xe8c454, 0x9a4a24]; + const key = `${PROCEDURAL}-${colours.join('-')}`; + const frames: string[] = []; + const width = 6, height = 4; + if (!scene.textures.exists(key)) { + const g = scene.make.graphics({}, false); + colours.forEach((colour, c) => SHAPES.forEach((shape, s) => { + const ox = (c * SHAPES.length + s) * (width + 1); + const dark = Phaser.Display.Color.IntegerToColor(colour).darken(25).color; + shape.forEach((row, y) => [...row].forEach((cell, x) => { + if (cell === '.') return; + g.fillStyle(cell === '2' ? dark : colour).fillRect(ox + x, y, 1, 1); + })); + })); + g.generateTexture(key, colours.length * SHAPES.length * (width + 1), height).destroy(); + const texture = scene.textures.get(key); + for (let i = 0; i < colours.length * SHAPES.length; i++) texture.add(`leaf-${i}`, 0, i * (width + 1), 0, width, height); + } + for (let i = 0; i < colours.length * SHAPES.length; i++) frames.push(`leaf-${i}`); + return [key, ...frames]; +} + +export interface LeafEmitters { + front: Phaser.GameObjects.Particles.ParticleEmitter | null; + behind: Phaser.GameObjects.Particles.ParticleEmitter | null; + /** Push with the wind and follow the camera; call once per frame. */ + update(t: number): void; + destroy(): void; +} + +export function addLeaves(scene: Phaser.Scene, leaves: Leaves, wind: Wind, palette: number[], depths: { front: number; behind: number }): LeafEmitters { + let texture: string, frames: string[]; + // The sheet's leaves tumble by their own frames; procedural ones flip by scaleX and are drawn smaller. + const animations = sheetAnimations(scene, LEAF_SHEET); + const anims = animations ? Object.values(animations) : null; + if (anims?.length) { + texture = LEAF_SHEET; + frames = scene.textures.get(LEAF_SHEET).getFrameNames(); + } else { + [texture, ...frames] = proceduralSheet(scene, leaves.palette.length ? leaves.palette : palette); + } + const size = anims?.length ? 1 : 1.5; + const { width, height } = scene.scale; + const rising = leaves.fall === 'up'; + const upwind = wind.direction < 0 ? width + 260 : -260; + + const make = (layer: LeafLayer | null, depth: number) => { + if (!layer) return null; + const emitter = scene.add.particles(0, 0, texture, { + ...(anims?.length ? { anim: anims } : { frame: frames }), + x: { min: Math.min(-40, upwind), max: Math.max(width + 40, upwind) }, + y: rising ? height + 12 : -12, + lifespan: { min: 9000, max: 16000 }, + speedX: { min: -12, max: 12 }, + speedY: rising ? { min: -42, max: -22 } : { min: 22, max: 46 }, + maxVelocityX: 150, + maxVelocityY: 70, + scaleY: { min: layer.scale[0] * size, max: layer.scale[1] * size }, + // Flutter: the leaf flips (scaleX through 0 – the sheet's frames already do) and rocks side to side. + scaleX: { onEmit: () => size * (layer.scale[0] + Math.random() * (layer.scale[1] - layer.scale[0])), + onUpdate: (p: Phaser.GameObjects.Particles.Particle) => anims?.length ? p.scaleY : p.scaleY * Math.cos(p.lifeCurrent * 0.004 + p.life) }, + accelerationX: { onEmit: () => 0, onUpdate: (p: Phaser.GameObjects.Particles.Particle) => 38 * Math.sin(p.lifeCurrent * 0.0025 + p.life) }, + rotate: { onEmit: () => Math.random() * 360, onUpdate: (p: Phaser.GameObjects.Particles.Particle, _k: string, _t: number, v: number) => v + 0.6 * Math.sin(p.lifeCurrent * 0.003 + p.life) }, + alpha: layer.alpha, + tint: layer.tint, + frequency: layer.every, + advance: 12_000, // the view starts full of leaves + }).setScrollFactor(0).setDepth(depth); + return { emitter, scroll: layer.scroll }; + }; + const front = make(leaves.front, depths.front); + const behind = make(leaves.behind, depths.behind); + const camera = scene.cameras.main; + let lastX = camera.scrollX, lastY = camera.scrollY, dx = 0, dy = 0; + const shift = (p: Phaser.GameObjects.Particles.Particle) => { p.x -= dx; p.y -= dy; }; + const step = (entry: typeof front, t: number) => { + if (!entry) return; + const { emitter, scroll } = entry; + // The gust passing the middle of the view pushes the leaves downwind. + emitter.gravityX = wind.direction * (18 + 120 * wind.gust * gustAt(wind, camera.scrollX * scroll + width / 2, t)); + dx = (camera.scrollX - lastX) * scroll; dy = (camera.scrollY - lastY) * scroll; + if (dx || dy) emitter.forEachAlive(shift, undefined); + }; + return { + front: front?.emitter ?? null, + behind: behind?.emitter ?? null, + update(t: number) { + step(front, t); step(behind, t); + lastX = camera.scrollX; lastY = camera.scrollY; + }, + destroy() { front?.emitter.destroy(); behind?.emitter.destroy(); }, + }; +} diff --git a/game/src/stage/atmosphere/pipeline.ts b/game/src/stage/atmosphere/pipeline.ts new file mode 100644 index 0000000..603f7e9 --- /dev/null +++ b/game/src/stage/atmosphere/pipeline.ts @@ -0,0 +1,199 @@ +import Phaser from 'phaser'; +import { PROFILE_SIZE } from './analysis'; +import { WIND_GLSL } from './wind'; +import type { Wind } from './config'; + +/** + * One WebGL pipeline for every moving stage layer: wind sway anchored to the art (the top of a plant + * moves, its base does not; stone and wood stay put), a vertical bob, a depth-of-field blur, and the + * water's ripple and shimmer below the waterline. Each object carries its own uniforms (LayerUniforms); + * the pipeline loads them in onBind, so the layers share one shader and no frame allocates. + */ +export const PIPELINE = 'WincsStageLayer'; + +export interface LayerUniforms { + /** amp px, reach px, foliage threshold, bob px */ + sway: Float32Array; + /** waterline px (-1: none), ripple px, shimmer 0–1, blur px */ + water: Float32Array; + /** world x of the layer's left edge (gust phase), anchor mode (0 base, 1 top) */ + place: Float32Array; + profile: Float32Array; + /** rim colour rgb 0–1, strength 0–1: a 1 px inner outline that lifts a sprite off a dark background */ + rim: Float32Array; +} + +export const newUniforms = (): LayerUniforms => ({ + sway: new Float32Array(4), water: new Float32Array([-1, 0, 0, 0]), place: new Float32Array(2), + profile: new Float32Array(PROFILE_SIZE), rim: new Float32Array(4), +}); + +const FRAG = /* glsl */ ` +#define SHADER_NAME WINCS_STAGE_LAYER_FS +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +#define PROFILE ${PROFILE_SIZE} + +uniform sampler2D uMainSampler; +uniform float uTime; +uniform vec4 uFrame; +uniform vec2 uSize; +uniform vec4 uSway; +uniform vec4 uWater; +uniform vec4 uWind; +uniform vec4 uWind2; +uniform float uProfile[PROFILE]; +uniform vec4 uRim; + +varying vec2 outTexCoord; +varying float outTintEffect; +varying vec4 outTint; +${WIND_GLSL} +float profileAt(float x) { + float f = clamp(x / uSize.x, 0.0, 1.0) * float(PROFILE - 1); + float i0 = floor(f); + float a = 0.0; + float b = 0.0; + for (int i = 0; i < PROFILE; i++) { + float fi = float(i); + if (fi == i0) a = uProfile[i]; + if (fi == i0 + 1.0) b = uProfile[i]; + } + if (i0 >= float(PROFILE - 1)) b = a; + return mix(a, b, f - i0); +} + +vec4 texel(vec2 px) { + px = clamp(px, vec2(0.5), uSize - 0.5); + return texture2D(uMainSampler, uFrame.xy + px / uSize * (uFrame.zw - uFrame.xy)); +} + +vec4 tap(vec2 px) { + if (uWater.w <= 0.0) return texel(px); + float r = uWater.w; + vec4 c = texel(px) * 0.25; + c += (texel(px + vec2(r, 0.0)) + texel(px - vec2(r, 0.0)) + texel(px + vec2(0.0, r)) + texel(px - vec2(0.0, r))) * 0.125; + c += (texel(px + vec2(r, r)) + texel(px - vec2(r, r)) + texel(px + vec2(r, -r)) + texel(px - vec2(r, -r))) * 0.0625; + return c; +} + +float movable(vec4 c) { + if (uSway.z <= 0.0 || c.a < 0.05) return 1.0; + vec3 rgb = c.rgb / c.a; + float hi = max(rgb.r, max(rgb.g, rgb.b)); + float lo = min(rgb.r, min(rgb.g, rgb.b)); + return smoothstep(uSway.z, uSway.z + 0.15, (hi - lo) / max(hi, 0.001)); +} + +void main () { + vec2 px = (outTexCoord - uFrame.xy) / (uFrame.zw - uFrame.xy) * uSize; + float t = uTime; + vec4 c = tap(px); + + if (uSway.x > 0.0) { + float anchor = profileAt(px.x); + float height = uWind2.w > 0.5 ? uSway.y - (px.y - anchor) : anchor - px.y; + float w = clamp(height / uSway.y, 0.0, 1.0); + w = w * w * (3.0 - 2.0 * w); + float wx = uWind2.z + px.x; + vec2 d = vec2(uSway.x * w * swayAt(wx, height, t), uSway.w * w * sin(uWind2.x * 1.7 * t + wx * 0.03)); + vec4 moved = tap(px - d); + c = mix(c, moved, min(movable(c), movable(moved))); + } + + if (uWater.x >= 0.0 && px.y >= uWater.x) { + float below = px.y - uWater.x; + if (uWater.y > 0.0) { + float depth = clamp(below / 120.0, 0.0, 1.0); + float r = uWater.y * (0.35 + 0.65 * depth) * sin(px.y * 0.45 - t * 2.4 + sin(px.x * 0.012 + t * 0.6) * 1.6); + vec4 rippled = tap(px - vec2(r, 0.0)); + if (rippled.a > 0.5) c = rippled; + } + if (uWater.z > 0.0 && c.a > 0.0) { + vec3 rgb = c.rgb / c.a; + float l = dot(rgb, vec3(0.299, 0.587, 0.114)); + float band = sin(px.y * 0.9 + t * 3.1 + sin(px.x * 0.02 - t) * 2.0); + vec2 cell = floor(px / 3.0); + float h = fract(sin(dot(cell, vec2(12.9898, 78.233)) + floor(t * 6.0) * 0.37) * 43758.5453); + float spark = step(0.985, h) * smoothstep(0.5, 0.8, l); + rgb += uWater.z * (0.06 * band * l + spark * 0.7); + c.rgb = clamp(rgb, 0.0, 1.0) * c.a; + } + } + + if (uRim.w > 0.0 && c.a > 0.5) { + float edge = step(texel(px + vec2(1.0, 0.0)).a, 0.5) + step(texel(px - vec2(1.0, 0.0)).a, 0.5) + + step(texel(px + vec2(0.0, 1.0)).a, 0.5) + step(texel(px - vec2(0.0, 1.0)).a, 0.5); + if (px.x < 1.0 || px.y < 1.0 || px.x > uSize.x - 1.0 || px.y > uSize.y - 1.0) edge += 1.0; + c.rgb = mix(c.rgb, uRim.rgb * c.a, uRim.w * min(edge, 1.0)); + } + + vec4 tint = vec4(outTint.bgr * outTint.a, outTint.a); + gl_FragColor = c * tint; +} +`; + +const UNIFORMS = new WeakMap(); + +export class LayerPipeline extends Phaser.Renderer.WebGL.Pipelines.SinglePipeline { + /** Shared by every layer; set by the stage's atmosphere (wind is per region). */ + wind = new Float32Array([0.45, 0.9, 240, 1400]); + wind2 = new Float32Array([0, -1]); + private none = newUniforms(); + + constructor(game: Phaser.Game) { + super({ game, fragShader: FRAG }); + } + + setWind(wind: Wind) { + this.wind[0] = wind.base; this.wind[1] = wind.gust; this.wind[2] = wind.gustSpeed; this.wind[3] = wind.gustLength; + this.wind2[0] = Math.PI * 2 * wind.frequency; this.wind2[1] = wind.direction; + } + + onBind(gameObject?: Phaser.GameObjects.GameObject) { + this.flush(); // the uniforms below belong to this object only + const u = (gameObject && UNIFORMS.get(gameObject)) || this.none; + const frame = (gameObject as Phaser.GameObjects.Image | undefined)?.frame; + this.set1f('uTime', this.game.loop.time / 1000); + if (frame) { + this.set4f('uFrame', frame.u0, frame.v0, frame.u1, frame.v1); + this.set2f('uSize', frame.cutWidth, frame.cutHeight); + } + this.set4f('uSway', u.sway[0], u.sway[1], u.sway[2], u.sway[3]); + this.set4f('uWater', u.water[0], u.water[1], u.water[2], u.water[3]); + this.set4f('uWind', this.wind[0], this.wind[1], this.wind[2], this.wind[3]); + this.set4f('uWind2', this.wind2[0], this.wind2[1], u.place[0], u.place[1]); + this.set1fv('uProfile', u.profile); + this.set4f('uRim', u.rim[0], u.rim[1], u.rim[2], u.rim[3]); + } +} + +/** The pipeline on this game, registered on first use; null on the Canvas renderer. */ +export function layerPipeline(game: Phaser.Game): LayerPipeline | null { + const renderer = game.renderer; + if (!(renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer)) return null; + if (!renderer.pipelines.has(PIPELINE)) renderer.pipelines.add(PIPELINE, new LayerPipeline(game)); + return renderer.pipelines.get(PIPELINE) as LayerPipeline; +} + +/** Draw `object` through the layer pipeline with these uniforms. */ +export function useLayerPipeline(object: Phaser.GameObjects.Image | Phaser.GameObjects.Sprite, uniforms: LayerUniforms) { + UNIFORMS.set(object, uniforms); + object.setPipeline(PIPELINE); +} + +/** + * A 1 px warm rim inside a sprite's silhouette, so a dark actor (Dexter's black plush) reads against a + * dark hedge. Works on any Image or Sprite, every frame of its atlas; a no-op on Canvas. Returns whether + * it applied. `strength` 0 removes it. + */ +export function addRim(sprite: Phaser.GameObjects.Image | Phaser.GameObjects.Sprite, colour = 0xffc878, strength = 0.65): boolean { + if (!layerPipeline(sprite.scene.game)) return false; + const uniforms = UNIFORMS.get(sprite) ?? newUniforms(); + uniforms.rim.set([((colour >> 16) & 255) / 255, ((colour >> 8) & 255) / 255, (colour & 255) / 255, strength]); + useLayerPipeline(sprite, uniforms); + return true; +} diff --git a/game/src/stage/atmosphere/reflection.ts b/game/src/stage/atmosphere/reflection.ts new file mode 100644 index 0000000..3e65ce3 --- /dev/null +++ b/game/src/stage/atmosphere/reflection.ts @@ -0,0 +1,80 @@ +import Phaser from 'phaser'; +import { newUniforms, useLayerPipeline } from './pipeline'; +import { mirrorY, revealAlpha, waterlineAt, type WaterSpan } from './analysis'; + +/** A mask that is the stage's water: copies of the water layers (not on the display list). */ +export function waterMask(scene: Phaser.Scene, spans: readonly WaterSpan[]) { + const source = scene.make.container({ x: 0, y: 0 }, false); + for (const span of spans) { + source.add(scene.make.image({ x: span.image.x, y: span.image.y, key: span.image.texture.key, add: false }).setOrigin(0)); + } + const mask = new Phaser.Display.Masks.BitmapMask(scene, source); + return { mask, destroy: () => { mask.destroy(); source.destroy(); } }; +} + +/** + * An actor's mirror image in the water: a flipped, tinted, rippled copy that follows the sprite's + * frame every frame, masked to the water so it never shows on the bank. + */ +export class Reflection { + readonly image: Phaser.GameObjects.Sprite; + + constructor(private source: Phaser.GameObjects.Sprite, private spans: readonly WaterSpan[], alpha: number, tint: number, + depth: number, mask: Phaser.Display.Masks.BitmapMask) { + const scene = source.scene; + this.image = scene.add.sprite(source.x, source.y, source.texture.key, source.frame.name) + .setOrigin(source.originX, source.originY).setFlipY(true).setAlpha(alpha).setTint(tint).setDepth(depth); + const uniforms = newUniforms(); + uniforms.water.set([0, 1.2, 0, 0]); // ripple the whole mirror image, no shimmer, no blur + useLayerPipeline(this.image, uniforms); + this.image.setMask(mask); + this.update(); + } + + update() { + const { source, image } = this; + const line = waterlineAt(this.spans, source.x); + image.setVisible(source.visible && source.active && line !== null); + if (line === null) return; + if (image.texture !== source.texture || image.frame.name !== source.frame.name) image.setTexture(source.texture.key, source.frame.name); + image.setFlipX(source.flipX); + image.setPosition(source.x, mirrorY(line, source.y)); + } + + destroy() { this.image.destroy(); } +} + +/** + * The wrong reflection (anomaly-reveal): what is not there – Rusty Creek's rusted maze – flickers in the + * water around a point for a while, strongest in the middle of it, then the pond settles. + */ +export class WrongReflection { + private image: Phaser.GameObjects.Image; + private start = 0; + private duration = 0; + + constructor(scene: Phaser.Scene, texture: string, depth: number, mask: Phaser.Display.Masks.BitmapMask, alpha: number) { + this.image = scene.add.image(0, 0, texture).setOrigin(0.5, 0).setDepth(depth).setVisible(false).setMask(mask); + this.peak = alpha; + const uniforms = newUniforms(); + uniforms.water.set([0, 2.5, 0.3, 0]); + useLayerPipeline(this.image, uniforms); + } + private peak: number; + + show(x: number, waterline: number, now: number, duration: number) { + this.start = now; this.duration = duration; + this.image.setPosition(x, waterline).setVisible(true).setAlpha(0); + } + + get active() { return this.image.visible; } + + update(now: number) { + if (!this.image.visible) return; + const alpha = revealAlpha(now - this.start, this.duration); + if (alpha === null) { this.image.setVisible(false); return; } + this.image.setAlpha(alpha * this.peak); + } + + destroy() { this.image.destroy(); } +} diff --git a/game/src/stage/atmosphere/sprites.ts b/game/src/stage/atmosphere/sprites.ts new file mode 100644 index 0000000..8f906fe --- /dev/null +++ b/game/src/stage/atmosphere/sprites.ts @@ -0,0 +1,35 @@ +import type Phaser from 'phaser'; +import type { AnimationTable } from '../assets'; + +/** + * Animations for the atmosphere's sprite sheets (fx-leaves, fx-water), made once from the sheet's + * anims.json (cached by the loader as `.anims`). Returns the animation keys per clip, or null + * when the sheet is not loaded. + */ +export function sheetAnimations(scene: Phaser.Scene, board: string): Record | null { + if (!scene.textures.exists(board)) return null; + const clips = scene.cache.json.get(`${board}.anims`) as AnimationTable | undefined; + if (!clips) return null; + const keys: Record = {}; + for (const [name, clip] of Object.entries(clips)) { + const key = `${board}:${name}`; + if (!scene.anims.exists(key)) { + scene.anims.create({ key, frameRate: clip.fps, repeat: clip.loop ? -1 : 0, + frames: scene.anims.generateFrameNames(board, { prefix: `${name}-`, start: 0, end: clip.frames - 1, zeroPad: 2 }) }); + } + keys[name] = key; + } + return keys; +} + +/** A small deterministic random (mulberry32), so placements are the same on every run. */ +export function seeded(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/game/src/stage/atmosphere/water.ts b/game/src/stage/atmosphere/water.ts new file mode 100644 index 0000000..654fc64 --- /dev/null +++ b/game/src/stage/atmosphere/water.ts @@ -0,0 +1,82 @@ +import type Phaser from 'phaser'; +import type { Water } from './config'; +import { seeded } from './sprites'; + +/** + * Life on the water from the fx-water sheet: lily pads bobbing on the near half of the pond, rings + * opening where something touched the surface, sparkles on the bright (sky-lit) water. Positions come + * from the water layer's own pixels (analysis.waterPoints); sprites are pooled, nothing allocates per frame. + */ +export class WaterLife { + private pads: Phaser.GameObjects.Sprite[] = []; + private ripples: Phaser.GameObjects.Sprite[] = []; + private sparkles: Phaser.GameObjects.Sprite[] = []; + private all: number[] = []; + private bright: number[] = []; + private random = seeded(404); + private nextRipple = 0; + private nextSparkle = 0; + + constructor(private scene: Phaser.Scene, private keys: Record, private config: Water, private depth: number) { + const pool = (key: string | undefined, count: number, into: Phaser.GameObjects.Sprite[]) => { + if (!key) return; + for (let i = 0; i < count; i++) { + const sprite = scene.add.sprite(0, 0, 'fx-water').setVisible(false).setDepth(depth + 0.02); + sprite.on('animationcomplete', () => sprite.setVisible(false)); + into.push(sprite); + } + }; + pool(keys.ripple, 6, this.ripples); + pool(keys.sparkle, 10, this.sparkles); + } + + /** A water surface: `points` and `bright` are flat [x, y, …] in its image's px; `line` its waterline row. */ + addSurface(x: number, y: number, width: number, line: number, points: number[], bright: number[]) { + for (let i = 0; i < points.length; i += 2) this.all.push(x + points[i], y + points[i + 1]); + for (let i = 0; i < bright.length; i += 2) this.bright.push(x + bright[i], y + bright[i + 1]); + if (!this.keys['lily-bob'] || !this.config.pads) return; + // Pads float on the near half of the band (below its middle). + const near: number[] = []; + let bottom = line; + for (let i = 1; i < points.length; i += 2) bottom = Math.max(bottom, points[i]); + const middle = (line + bottom) / 2; + for (let i = 0; i < points.length; i += 2) if (points[i + 1] >= middle && points[i + 1] <= bottom - 8) near.push(points[i], points[i + 1]); + const count = Math.round((width / 1000) * this.config.pads); + for (let n = 0; n < count && near.length; n++) { + const i = 2 * Math.floor(this.random() * (near.length / 2)); + const pad = this.scene.add.sprite(x + near[i], y + near[i + 1], 'fx-water').setDepth(this.depth + 0.01) + .setTint(this.config.padTint).setScale(this.config.padScale).setFlipX(this.random() < 0.5); + pad.play({ key: this.keys['lily-bob'], startFrame: Math.floor(this.random() * 4) }); + this.pads.push(pad); + } + } + + private spawn(pool: Phaser.GameObjects.Sprite[], key: string | undefined, points: number[], camera: Phaser.Cameras.Scene2D.Camera) { + if (!key || !points.length) return; + let sprite: Phaser.GameObjects.Sprite | undefined; + for (let i = 0; i < pool.length; i++) if (!pool[i].visible) { sprite = pool[i]; break; } + if (!sprite) return; + for (let tries = 0; tries < 6; tries++) { + const i = 2 * Math.floor(Math.random() * (points.length / 2)); + const px = points[i], py = points[i + 1]; + if (px < camera.scrollX || px > camera.scrollX + camera.width || py < camera.scrollY || py > camera.scrollY + camera.height) continue; + sprite.setPosition(px, py).setVisible(true).play(key); + return; + } + } + + update(time: number, camera: Phaser.Cameras.Scene2D.Camera) { + if (this.config.ripples > 0 && time >= this.nextRipple) { + this.spawn(this.ripples, this.keys.ripple, this.all, camera); + this.nextRipple = time + this.config.ripples * (0.5 + Math.random()); + } + if (this.config.sparkles > 0 && time >= this.nextSparkle) { + this.spawn(this.sparkles, this.keys.sparkle, this.bright, camera); + this.nextSparkle = time + this.config.sparkles * (0.5 + Math.random()); + } + } + + destroy() { + for (const sprite of [...this.pads, ...this.ripples, ...this.sparkles]) sprite.destroy(); + } +} diff --git a/game/src/stage/atmosphere/wind.ts b/game/src/stage/atmosphere/wind.ts new file mode 100644 index 0000000..7137155 --- /dev/null +++ b/game/src/stage/atmosphere/wind.ts @@ -0,0 +1,43 @@ +/** + * The wind, as pure numbers. The layer shader (pipeline.ts) evaluates the same formulas per pixel; + * the leaves read `gustAt` to lean with the same gusts the foliage shows. + */ +import type { LayerFx, Wind } from './config'; + +const TAU = Math.PI * 2; + +/** 0–1: how strongly a gust is passing at layer x (px) and time t (s). Gust fronts travel downwind. */ +export function gustAt(wind: Wind, x: number, t: number): number { + const phase = ((x - wind.direction * wind.gustSpeed * t) / wind.gustLength) * TAU; + const front = Math.max(0, Math.sin(phase)) ** 4; + return front * (0.55 + 0.45 * Math.sin(t * 0.21)); +} + +/** 0–1 sway weight of a pixel `height` px above its anchor (smoothstep over the layer's reach). */ +export function swayWeight(height: number, reach: number): number { + const w = Math.min(1, Math.max(0, height / reach)); + return w * w * (3 - 2 * w); +} + +/** Horizontal displacement in px of a layer pixel at x, `height` px above its anchor, at time t (s). */ +export function swayAt(wind: Wind, layer: Pick, x: number, height: number, t: number): number { + const steady = wind.base * Math.sin(TAU * wind.frequency * t + x * 0.004 + height * 0.01); + const lean = wind.gust * gustAt(wind, x, t) * wind.direction; + return layer.sway * swayWeight(height, layer.reach) * (steady + lean); +} + +/** The most a layer can move: never more than its amplitude × (base + gust). */ +export const maxSway = (wind: Wind, layer: Pick) => layer.sway * (wind.base + wind.gust); + +/** GLSL twin of gustAt / swayAt (uWind = base, gust, gustSpeed, gustLength; uWind2.xy = 2π·frequency, direction). */ +export const WIND_GLSL = /* glsl */ ` +float gustAt(float x, float t) { + float phase = (x - uWind2.y * uWind.z * t) / uWind.w * 6.2831853; + float s = max(0.0, sin(phase)); + return s * s * s * s * (0.55 + 0.45 * sin(t * 0.21)); +} +float swayAt(float x, float height, float t) { + float steady = uWind.x * sin(uWind2.x * t + x * 0.004 + height * 0.01); + return steady + uWind.y * gustAt(x, t) * uWind2.y; +} +`; diff --git a/game/src/stage/boards.json b/game/src/stage/boards.json new file mode 100644 index 0000000..b5c1a47 --- /dev/null +++ b/game/src/stage/boards.json @@ -0,0 +1,12 @@ +{ + "readme": "Every sprite atlas the game loads (game/public/assets/sprites/.png|json|anims.json), read by boot/manifest.ts. Add a board with one line here. 'required' boards fall back to a placeholder when missing; 'optional' boards are simply absent until they are exported.", + "required": [ + "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", "fx-leaves", "fx-ground", "fx-water", + "boss-jenny", "boss-jenny-2", "coffee-machine", + "dexter", "dexter-acts", "dexter-prologue", "pocket-computer", "mensa-food" + ], + "optional": [] +} diff --git a/game/src/stage/camera.test.ts b/game/src/stage/camera.test.ts index f38717a..53e0602 100644 --- a/game/src/stage/camera.test.ts +++ b/game/src/stage/camera.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { follow, gridBounds } from './camera'; +import { DEAD_ZONE, follow, gridBounds } from './camera'; import type { StageManifest } from './manifest'; const bounds = { x: 0, y: 0, width: 6480, height: 1440 }; describe('camera follow', () => { @@ -34,4 +34,24 @@ describe('camera follow', () => { }; expect(run(30).x).toBeCloseTo(run(120).x, 7); expect(run(30).y).toBeCloseTo(run(120).y, 7); }); + it('keeps the hero on one screen pixel at a stride-paced walk while the camera eases', () => { + const state = { x: 0, y: 0, lead: 0 }; + const screen: number[] = []; + for (let i = 0, x = 900; i < 600; i++, x += 94.6 / 60) { + const cam = follow(state, { x, y: 470, vx: 94.6, grounded: true }, bounds, 1 / 60); + if (i > 400) screen.push(Math.round(x) - cam.x); + } + expect(Math.max(...screen) - Math.min(...screen)).toBe(0); + }); + it('ignores slopes inside the dead zone and holds the framing through a jump', () => { + const state = { x: 0, y: 0, lead: 0 }; + for (let i = 0; i < 300; i++) follow(state, { x: 2000, y: 470, vx: 0, grounded: true }, bounds, 1 / 60); + const rest = state.y; + for (let i = 0; i < 300; i++) follow(state, { x: 2000, y: 470 - DEAD_ZONE.up + 1, vx: 0, grounded: true }, bounds, 1 / 60); + expect(state.y).toBeCloseTo(rest, 6); + for (let i = 0; i < 30; i++) follow(state, { x: 2000, y: 470 - 96 * Math.sin(i / 30 * Math.PI), vx: 0, grounded: false }, bounds, 1 / 60); + expect(state.y).toBeCloseTo(rest, 6); + for (let i = 0; i < 300; i++) follow(state, { x: 2000, y: 600, vx: 0, grounded: true }, bounds, 1 / 60); + expect(state.y).toBeGreaterThan(rest + 100); + }); }); diff --git a/game/src/stage/camera.ts b/game/src/stage/camera.ts index c25fa6c..7d85a3c 100644 --- a/game/src/stage/camera.ts +++ b/game/src/stage/camera.ts @@ -1,8 +1,9 @@ import type Phaser from 'phaser'; import type { StageManifest } from './manifest'; export interface Bounds { x: number; y: number; width: number; height: number } -export interface Follow { x: number; y: number; lead: number } -export interface Target { x: number; y: number; vx: number } +/** `focus`: the ground height the camera frames; it only moves when the hero leaves the vertical dead zone. */ +export interface Follow { x: number; y: number; lead: number; focus?: number } +export interface Target { x: number; y: number; vx: number; grounded?: boolean } export function gridBounds(manifest: StageManifest): Bounds { const xs = manifest.chunks.map(c => c.x * manifest.chunkSize[0]); const ys = manifest.chunks.map(c => c.y * manifest.chunkSize[1]); @@ -10,21 +11,43 @@ export function gridBounds(manifest: StageManifest): Bounds { width: Math.max(...xs) - Math.min(...xs) + manifest.chunkSize[0], height: Math.max(...ys) - Math.min(...ys) + manifest.chunkSize[1] }; } +/** Look-ahead in px at full run speed (258 px/s and above), and the speed below which there is none. */ +export const LEAD = 120; +export const LEAD_SPEED = 258; +/** Vertical dead zone around the framed ground: slopes and steps inside it never move the camera. */ +export const DEAD_ZONE = { up: 40, down: 16 }; +/** While airborne the focus holds, unless the hero rises this far above it (a climb) or drops below it. */ +export const AIR_ZONE = { up: 150, down: 0 }; +/** Where the framed ground sits on screen, from the top (0–1). */ +export const FRAME_Y = 0.64; const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); const ease = (from: number, to: number, rate: number, dt: number) => from + (to - from) * (1 - Math.exp(-rate * dt)); -/** Keep fractional smoothing state, but only expose integer camera coordinates. */ + +/** Moves `focus` just enough to keep `y` inside [focus − up, focus + down]. */ +export function deadZone(focus: number, y: number, zone: { up: number; down: number }) { + return clamp(focus, y - zone.down, y + zone.up); +} + +/** + * Keep fractional smoothing state, but expose whole pixels that keep the hero's own screen position steady: + * the camera is the hero's rounded position minus the rounded hero-to-camera offset, so at a steady + * (stride-paced) speed the hero does not shimmer by a pixel against the scenery while the camera eases. + */ export function follow(state: Follow, target: Target, bounds: Bounds, dt: number, width = 1280, height = 720) { - state.lead = ease(state.lead, clamp(target.vx / 258, -1, 1) * 120, 7, dt); + state.lead = ease(state.lead, clamp(target.vx / LEAD_SPEED, -1, 1) * LEAD, 3.5, dt); + state.focus = deadZone(state.focus ?? target.y, target.y, target.grounded === false ? AIR_ZONE : DEAD_ZONE); const maxX = bounds.x + Math.max(0, bounds.width - width); const maxY = bounds.y + Math.max(0, bounds.height - height); state.x = clamp(ease(state.x, clamp(target.x - width / 2 + state.lead, bounds.x, maxX), 8, dt), bounds.x, maxX); - state.y = clamp(ease(state.y, clamp(target.y - height * 0.64, bounds.y, maxY), 5, dt), bounds.y, maxY); - return { x: Math.round(state.x), y: Math.round(state.y) }; + state.y = clamp(ease(state.y, clamp(state.focus - height * FRAME_Y, bounds.y, maxY), 5, dt), bounds.y, maxY); + const snap = (hero: number, camera: number, min: number, max: number) => + clamp(Math.round(hero) - Math.round(hero - camera), Math.ceil(min), Math.floor(max)); + return { x: snap(target.x, state.x, bounds.x, maxX), y: snap(target.y, state.y, bounds.y, maxY) }; } export function cameraFollow(scene: Phaser.Scene, manifest: StageManifest) { const camera = scene.cameras.main, bounds = gridBounds(manifest); camera.roundPixels = true; - const state = { x: camera.scrollX, y: camera.scrollY, lead: 0 }; + const state: Follow = { x: camera.scrollX, y: camera.scrollY, lead: 0 }; return (target: Target, milliseconds: number) => { const position = follow(state, target, bounds, Math.min(milliseconds / 1000, 0.1), camera.width, camera.height); camera.setScroll(position.x, position.y); diff --git a/game/src/stage/collision.ts b/game/src/stage/collision.ts index b7b72a8..21d673b 100644 --- a/game/src/stage/collision.ts +++ b/game/src/stage/collision.ts @@ -9,7 +9,16 @@ export const BOXES = ['climb', 'solid', 'noGo', 'hazard', 'interact', 'glideLift export const POINTS = ['spawn', 'ledge'] as const; export type Geometry = Record & Record & Record; -export type CollisionData = Geometry & { chunk: string; state: RegionState; size: [number, number] }; +/** + * The terrain model (docs/04): what each stretch of floor is. Physics reads the geometry – walk lines step up + * rises up to STEP_UP, line ends nothing passes under are ledge faces, `solid` boxes (kind wall/log/obstacle) stop + * the body, only `slide` lines slide, `noGo` kind water is never floor – and the terrain names the kind and the + * material (surfaces.json is rebuilt from it by scripts/collision_check.py --surfaces). + */ +export const TERRAIN_KINDS = ['ground', 'step', 'stair', 'plank', 'ledge', 'wall', 'log', 'obstacle', 'slide'] as const; +export type TerrainKind = typeof TERRAIN_KINDS[number]; +export interface TerrainRange { x: [number, number]; y?: [number, number]; kind: TerrainKind; material: string; note?: string } +export type CollisionData = Geometry & { chunk: string; state: RegionState; size: [number, number]; terrain?: TerrainRange[] }; export interface CollisionChunk { local: CollisionData; origin: Point; world: Geometry } function assert(condition: unknown, message: string): asserts condition { @@ -53,9 +62,23 @@ export function parseCollision(value: unknown, chunk: string, state: RegionState if (key === 'hazard') assert(integer(item.damage) && item.damage > 0, 'hazard damage'); } } + if (value.terrain !== undefined) { + assert(Array.isArray(value.terrain), 'terrain must be an array'); + const range = (v: unknown) => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number') && v[0] < v[1]; + for (const t of value.terrain) { + assert(object(t) && range(t.x) && (t.y === undefined || range(t.y)), 'terrain range'); + assert((TERRAIN_KINDS as readonly unknown[]).includes(t.kind), `terrain kind ${String(t.kind)}`); + assert(kind(t.material), 'terrain material'); + } + } return value as unknown as CollisionData; } +/** The authored terrain at chunk-local feet (x, y): the first range that holds them. */ +export function terrainAt(data: Pick, x: number, y: number): TerrainRange | undefined { + return data.terrain?.find(t => x >= t.x[0] && x < t.x[1] && (!t.y || y >= t.y[0] && y < t.y[1])); +} + /** Grid offsets apply to geometry and local horizontal patrol bounds, not metadata. */ export function placeCollision(local: CollisionData, origin: Point): CollisionChunk { const [dx, dy] = origin; diff --git a/game/src/stage/depth.ts b/game/src/stage/depth.ts new file mode 100644 index 0000000..8db96b3 --- /dev/null +++ b/game/src/stage/depth.ts @@ -0,0 +1,73 @@ +/** + * Phaser depth bands (docs/15 contract). Every scene object that shares the stage picks its depth here, + * so the layers, the actors and the foreground always stack the same way. + * + * 0 – 99 stage layers z 0–5, in manifest list order (the list order is the alpha composition) + * 90 leaves falling behind the actors + * 99 – 110 actors: props 99, hero 100, NPCs 101–105, actor FX up to 110 + * 120 – 149 stage layers z 6 (near): the grass strip in front of the trail, over the actors' feet + * 150 leaves falling in front of the hero, behind the foreground plants + * 200 – 299 stage layers z 7 (foreground) – they pass over the hero + * 1000+ screen FX and HUD + */ +export const PROP_DEPTH = 99; +export const HERO_DEPTH = 100; +export const NPC_DEPTH = { min: 101, max: 105 } as const; +export const ACTOR_FX_DEPTH = 110; +export const LEAVES_BEHIND_DEPTH = 90; +export const LEAVES_FRONT_DEPTH = 150; +/** The near strip (z 6): grass and plants in front of the trail that cover the hero's and NPCs' feet. */ +export const NEAR_DEPTH = 120; +export const FOREGROUND_DEPTH = 200; +export const SCREEN_FX_DEPTH = 1000; +/** Layers with z at or above this are drawn over the actors (z 6 the near strip, z 7 the foreground). */ +export const FOREGROUND_Z = 6; +/** Layers with z at or above this are the out-of-focus foreground, over the falling leaves. */ +export const FAR_FOREGROUND_Z = 7; +/** Room for list positions inside a band. */ +const BAND = 90; + +export const isForeground = (z: number) => z >= FOREGROUND_Z; + +/** + * A layer's depth: z picks the band (behind or in front of the actors), `rank` orders it inside the band. + */ +export function layerDepth(z: number, rank: number): number { + if (!Number.isInteger(rank) || rank < 0 || rank >= BAND) throw new Error(`Layer rank ${rank} outside its depth band`); + return (z >= FAR_FOREGROUND_Z ? FOREGROUND_DEPTH : isForeground(z) ? NEAR_DEPTH : 0) + rank; +} + +/** + * One region-wide rank per layer id, so every chunk stacks the same way even when a chunk lists fewer + * layers (a chunk without sky starts its list at the rear – its list position alone would put the rear + * under the next chunk's far plate). The chunks' lists are merged: inside a band, an id listed before + * another in any chunk ranks below it (the authored alpha composition); ties go to lower z, then to the + * first appearance. Contradicting lists fall back to that tie order. + */ +export function layerRanks(lists: readonly (readonly { id: string; z: number }[])[]): Map { + const z = new Map(), seen: string[] = []; + const after = new Map>(); + for (const list of lists) { + for (const layer of list) if (!z.has(layer.id)) { z.set(layer.id, layer.z); seen.push(layer.id); after.set(layer.id, new Set()); } + for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) { + if (isForeground(list[i].z) === isForeground(list[j].z)) after.get(list[j].id)!.add(list[i].id); + } + } + const tie = (a: string, b: string) => (z.get(a)! - z.get(b)!) || (seen.indexOf(a) - seen.indexOf(b)); + const ranks = new Map(); + for (const front of [false, true]) { + const left = seen.filter(id => isForeground(z.get(id)!) === front); + let rank = 0; + while (left.length) { + left.sort(tie); + let next = left.findIndex(id => [...after.get(id)!].every(before => ranks.has(before) || !left.includes(before))); + if (next < 0) next = 0; // a cycle: the lists disagree, z decides + ranks.set(left[next], rank++); + left.splice(next, 1); + } + } + return ranks; +} + +/** Just above a layer and below the next one in the list – where a reflection on that layer goes. */ +export const above = (depth: number) => depth + 0.5; diff --git a/game/src/stage/render.ts b/game/src/stage/render.ts index 1e8375f..a36573e 100644 --- a/game/src/stage/render.ts +++ b/game/src/stage/render.ts @@ -1,13 +1,210 @@ -import type Phaser from 'phaser'; -import { stageLayers, type StageManifest, type RegionState } from './manifest'; +import Phaser from 'phaser'; +import { stageLayers, type StageManifest, type RegionState, type StageLayer } from './manifest'; import { textureKey } from './assets'; import { gridBounds } from './camera'; +import { above, isForeground, layerDepth, layerRanks, LEAVES_BEHIND_DEPTH, LEAVES_FRONT_DEPTH } from './depth'; +import { layerAtmosphere, parseAtmosphere, type Atmosphere, type LayerFx } from './atmosphere/config'; +import { anchorProfile, autumnPalette, onScreen, waterline, waterlineAt, waterPoints, type Pixels, type WaterSpan } from './atmosphere/analysis'; +import { WaterLife } from './atmosphere/water'; +import { sheetAnimations } from './atmosphere/sprites'; +import { layerPipeline, newUniforms, useLayerPipeline, type LayerUniforms } from './atmosphere/pipeline'; +import { addLeaves, type LeafEmitters } from './atmosphere/leaves'; +import { Reflection, WrongReflection, waterMask } from './atmosphere/reflection'; +import { gameEvents, type GameEvents } from '../core/events'; -/** z describes the art role; manifest list order describes alpha composition. */ -export function drawStage(scene: Phaser.Scene, manifest: StageManifest, state: RegionState) { - const images = stageLayers(manifest, state).map(layer => scene.add.image(layer.x, layer.y, - textureKey(manifest.region, layer.file)).setOrigin(0).setScrollFactor(layer.scroll).setDepth(layer.order)); +/** One drawn layer and what the atmosphere does with it. */ +export interface DrawnLayer { + image: Phaser.GameObjects.Image; + layer: StageLayer; + chunk: string; + fx: LayerFx; + uniforms: LayerUniforms; + /** The art has been read (anchor profile, waterline); done lazily, visible layers first. */ + analysed: boolean; +} + +export interface StageHandles { + /** Every layer image in draw order (as before: drawStage(...).images). */ + images: Phaser.GameObjects.Image[]; + layers: DrawnLayer[]; + atmosphere: Atmosphere; + leaves: LeafEmitters | null; + /** True when the layers sway, blur and ripple (WebGL); false on Canvas, where the stage is still. */ + animated: boolean; + /** World row of the waterline under world x, or null where there is no water. */ + waterlineAt(x: number): number | null; + /** Mirror an actor in the water (the hero, Dexter); returns null where the stage has no water reflection. */ + reflect(sprite: Phaser.GameObjects.Sprite): Reflection | null; + /** Show the wrong reflection around a water point for `duration` ms (what anomaly-reveal does); false if it can't. */ + reveal(position: { x: number; y: number }, duration: number): boolean; + destroy(): void; +} + +/** Half-size copies of layer art, read once – enough for anchors, waterlines and colours. */ +const SAMPLE_SCALE = 2; +let canvas: HTMLCanvasElement | undefined; +function readPixels(scene: Phaser.Scene, key: string): Pixels | null { + const source = scene.textures.get(key).getSourceImage() as HTMLImageElement | HTMLCanvasElement; + if (!source?.width) return null; + const width = Math.ceil(source.width / SAMPLE_SCALE), height = Math.ceil(source.height / SAMPLE_SCALE); + canvas ??= document.createElement('canvas'); + canvas.width = width; canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) return null; + context.imageSmoothingEnabled = false; + context.clearRect(0, 0, width, height); + context.drawImage(source, 0, 0, width, height); + return { width, height, data: context.getImageData(0, 0, width, height).data, scale: SAMPLE_SCALE }; +} + +/** Canvas renderer: no shader, so the foreground gets a pre-blurred copy (where the browser has ctx.filter). */ +function canvasBlur(scene: Phaser.Scene, image: Phaser.GameObjects.Image, radius: number) { + const key = `${image.texture.key}#blur${radius}`; + if (!scene.textures.exists(key)) { + const source = image.texture.getSourceImage() as HTMLImageElement; + const texture = scene.textures.createCanvas(key, source.width, source.height); + if (!texture) return; + texture.context.filter = `blur(${radius}px)`; + texture.context.drawImage(source, 0, 0); + texture.refresh(); + } + image.setTexture(key); +} + +/** + * Draw a region state: every layer in its order and scroll, z 0–5 behind the actors and z 6–7 over them + * (depth.ts), then bring it to life from `atmosphereData` (atmosphere.json, `{}` for defaults): wind sway, + * depth of field, water ripple and shimmer, falling leaves, reflections. Returns the handles. + */ +export function drawStage(scene: Phaser.Scene, manifest: StageManifest, state: RegionState, atmosphereData: unknown = {}): StageHandles { + const { atmosphere, problems } = parseAtmosphere(atmosphereData, state); + if (problems.length) console.warn(`atmosphere.json (${manifest.region}): ${problems.join('; ')}`); + const pipeline = layerPipeline(scene.game); + pipeline?.setWind(atmosphere.wind); + + const ranks = layerRanks(manifest.chunks.map(chunk => chunk.states[state]?.layers ?? [])); + const layers: DrawnLayer[] = stageLayers(manifest, state).map(layer => { + const image = scene.add.image(layer.x, layer.y, textureKey(manifest.region, layer.file)) + .setOrigin(0).setScrollFactor(layer.scroll).setDepth(layerDepth(layer.z, ranks.get(layer.id)!)); + const fx = layerAtmosphere(atmosphere, layer); + const uniforms = newUniforms(); + uniforms.sway.set([fx.sway, fx.reach, fx.foliage, fx.bob]); + uniforms.water.set([-1, fx.ripple, fx.shimmer, fx.blur]); + uniforms.place.set([layer.x, fx.anchor === 'top' ? 1 : 0]); + const chunk = manifest.chunks.find(c => c.x * manifest.chunkSize[0] === layer.x && c.y * manifest.chunkSize[1] === layer.y)?.id ?? ''; + return { image, layer, chunk, fx, uniforms, analysed: false }; + }); const bounds = gridBounds(manifest); scene.cameras.main.setBounds(bounds.x, bounds.y, bounds.width, bounds.height); - return images; + + const waterId = atmosphere.water?.layer; + const spans: WaterSpan[] = []; + const moving = (d: DrawnLayer) => d.fx.sway > 0 || d.fx.ripple > 0 || d.fx.shimmer > 0 || d.fx.blur > 0 || d.layer.id === waterId; + for (const d of layers) { + if (!moving(d)) { d.analysed = true; continue; } + if (pipeline) useLayerPipeline(d.image, d.uniforms); + else if (d.fx.blur > 0) canvasBlur(scene, d.image, d.fx.blur); + } + const palette: number[] = []; + const waterSheet = atmosphere.water && sheetAnimations(scene, 'fx-water'); + const waterDrawn = layers.find(d => d.layer.id === waterId); + const life = waterSheet && waterDrawn ? new WaterLife(scene, waterSheet, atmosphere.water!, waterDrawn.image.depth) : null; + const analyse = (d: DrawnLayer) => { + d.analysed = true; + const pixels = readPixels(scene, d.image.texture.key); + if (!pixels) return; + if (d.fx.sway > 0) anchorProfile(pixels, d.fx.anchor, d.uniforms.profile); + if (d.layer.id === waterId) { + const line = waterline(pixels); + d.uniforms.water[0] = line; + if (line >= 0) { + spans.push({ x0: d.image.x, x1: d.image.x + d.image.width, y: d.image.y + line, image: d.image }); + spans.sort((a, b) => a.x0 - b.x0); + if (life) { + const points = waterPoints(pixels, line); + life.addSurface(d.image.x, d.image.y, d.image.width, line, points.all, points.bright); + } + } + } + if (!palette.length && !isForeground(d.layer.z) && d.fx.sway > 0 && d.layer.scroll === 1) palette.push(...autumnPalette(pixels)); + }; + + const camera = scene.cameras.main; + const visible = (d: DrawnLayer) => onScreen(d.image.x, d.image.width, d.layer.scroll, camera.scrollX, camera.width) + && onScreen(d.image.y, d.image.height, d.layer.scroll, camera.scrollY, camera.height); + // The water everywhere (reflections need every span), then what is on screen now; the rest one per frame. + for (const d of layers) if (!d.analysed && (d.layer.id === waterId || visible(d))) analyse(d); + if (!palette.length) for (const d of layers) if (!isForeground(d.layer.z) && d.layer.scroll === 1 && d.layer.id !== waterId) { + const pixels = readPixels(scene, d.image.texture.key); + if (pixels) palette.push(...autumnPalette(pixels)); + break; + } + + const leaves = atmosphere.leaves.front || atmosphere.leaves.behind + ? addLeaves(scene, atmosphere.leaves, atmosphere.wind, palette, { front: LEAVES_FRONT_DEPTH, behind: LEAVES_BEHIND_DEPTH }) + : null; + const reflections: Reflection[] = []; + const water = waterDrawn; + let mask: ReturnType | undefined; + const sharedMask = () => (mask ??= waterMask(scene, spans)).mask; + + // anomaly-reveal (prologue): the wrong picture flickers in the water. Its image is one of the region's + // atmosphere `assets`, which the loader brings with the stage (scenes never load by themselves). + let wrong: WrongReflection | undefined; + const wrongConfig = atmosphere.water?.wrong; + const wrongKey = wrongConfig ? textureKey(manifest.region, wrongConfig.image) : ''; + const reveal = (position: { x: number; y: number }, duration: number) => { + const line = waterlineAt(spans, position.x); + if (!wrongConfig || !pipeline || !water || line === null || !scene.textures.exists(wrongKey)) return false; + wrong ??= new WrongReflection(scene, wrongKey, above(water.image.depth) - 0.1, sharedMask(), wrongConfig.alpha); + wrong.show(position.x, line, scene.time.now, duration); + return true; + }; + const onReveal = (event: GameEvents['anomaly-reveal']) => { if (event.region === manifest.region) reveal(event.position, event.duration); }; + const offReveal = gameEvents.on('anomaly-reveal', onReveal); + // Story code can tell the water reacts to anomaly-reveal (and drop its own fallback). + scene.data.set('water-reveal', Boolean(wrongConfig && pipeline && water && scene.textures.exists(wrongKey))); + + const frame = (time: number) => { + // Layers off screen are not drawn (each would still cost a draw call). + let pending = -1; + for (let i = 0; i < layers.length; i++) { + layers[i].image.visible = visible(layers[i]); + if (pending < 0 && !layers[i].analysed) pending = i; + } + if (pending >= 0) analyse(layers[pending]); + leaves?.update(time / 1000); + life?.update(time, camera); + for (let i = 0; i < reflections.length; i++) reflections[i].update(); + wrong?.update(scene.time.now); + }; + scene.events.on(Phaser.Scenes.Events.POST_UPDATE, frame); + const handles: StageHandles = { + images: layers.map(d => d.image), layers, atmosphere, leaves, animated: pipeline !== null, + waterlineAt: x => waterlineAt(spans, x), + reflect(sprite) { + const reflect = atmosphere.water?.reflect; + if (!reflect || !water || !pipeline) return null; + const r = new Reflection(sprite, spans, reflect.alpha, reflect.tint, above(water.image.depth), sharedMask()); + reflections.push(r); + return r; + }, + reveal, + destroy() { + scene.events.off(Phaser.Scenes.Events.POST_UPDATE, frame); + offReveal(); + wrong?.destroy(); + mask?.destroy(); + leaves?.destroy(); + life?.destroy(); + reflections.forEach(r => r.destroy()); + layers.forEach(d => d.image.destroy()); + }, + }; + scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + scene.events.off(Phaser.Scenes.Events.POST_UPDATE, frame); + offReveal(); + mask?.destroy(); + }); + return handles; } diff --git a/game/src/story/CoffeeVisit.ts b/game/src/story/CoffeeVisit.ts new file mode 100644 index 0000000..ccf3930 --- /dev/null +++ b/game/src/story/CoffeeVisit.ts @@ -0,0 +1,189 @@ +import Phaser from 'phaser'; +import { gameEvents, type Position } from '../core/events'; +import { VIEW_WIDTH } from '../core/viewport'; +import { sound } from '../fx/sound/Audio'; +import { pocketMap } from '../ui/mapModel'; +import { createAnims, type AtlasAnim } from './atlases'; +import { dexterFor, VisitSequence as Visit, type VisitPhase } from './coffeeSequence'; + +const ANIMS: AtlasAnim[] = [ + ['dexter', 'idle', 3, -1], ['dexter-acts', 'sweat', 2, -1], ['dexter-acts', 'absorb', 4, 0], + ['fx', 'black-fluid', 6, 0], ['pocket-computer', 'bob', 4, -1], +]; +const STAGE = 'Stage'; +/** Above the stage art and Codex's coffee machine actors (depth 99). */ +const DEPTH = 100; +/** Where the gifts land: in front of the hero's feet (stage px). */ +const GIFT_ARC = 48; +/** Without a stage (the dev key): a machine and Dexter in the middle of the view, on a ground line. */ +const FALLBACK = { machine: { id: 'dev-coffee', x: VIEW_WIDTH / 2 - 20, y: 520, w: 40, h: 80 }, dexter: { x: VIEW_WIDTH / 2 + 30, y: 600 } }; + +interface Box { id?: string; kind?: string; x: number; y: number; w: number; h: number } +interface Marker { id?: string; kind?: string; x: number; y: number } +/** The part of Codex's stage data (`Stage.create(data)`) this scene reads: world-space boxes and spawns. */ +interface StageData { collisions?: { world: { interact: Box[]; spawn: Marker[] } }[] } + +/** `shown` is the phase last drawn: a save starts the visit between frames, outside `update`. */ +interface Station { visit: Visit; dexter: Phaser.GameObjects.Sprite; at: Marker; shown: VisitPhase } + +/** + * Dexter at the coffee machine (#52, docs/03 "Region 1 story beats"). When the stage starts, Dexter already + * sits next to every coffee machine that has a Dexter spawn. The first save there: bow, sweat, the pocket + * computer and a muffin arc over to the hero, and the menu key unlocks. Every enemy you defeat leaves a pool of + * black fluid where it fell; at the next save Dexter absorbs each one while you watch. No words. + */ +export class CoffeeVisit extends Phaser.Scene { + private stations = new Map(); + private pools = new Map(); + private nextPool = 0; + private hero: Position = FALLBACK.dexter; + private host?: Phaser.Scene; + + constructor() { super({ key: 'CoffeeVisit', active: true }); } + + create(): void { + const stage = this.scene.get(STAGE); + stage?.events.on(Phaser.Scenes.Events.CREATE, () => this.seat(stage)); + if (stage && this.scene.isActive(STAGE)) this.seat(stage); + const stops = [ + gameEvents.on('save', ({ checkpoint, position }) => this.arrive(checkpoint, position)), + gameEvents.on('enemy-defeated', ({ position }) => this.leavePool(position)), + ]; + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => stops.forEach(stop => stop())); + // Dev builds: Shift + F4 puts Dexter and a pool in the view and saves at the machine (#52 verification; + // F4 alone hands over the pocket computer straight away). + if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F4', (event: KeyboardEvent) => { + if (!event.shiftKey) return; + if (!this.stations.size) this.seatAt(this, FALLBACK.machine, FALLBACK.dexter); + const [id, station] = [...this.stations][0]; + if (!this.pools.size) this.leavePool({ x: station.at.x + 90, y: station.at.y }); + this.arrive(id, { x: station.at.x - 70, y: station.at.y }); + }); + } + + update(_time: number, delta: number): void { + for (const [, station] of this.stations) { + const phase = station.visit.update(delta); + if (phase !== station.shown) { station.shown = phase; this.enter(station, phase); } + if (phase === 'bow') station.dexter.setAngle(Math.sin(station.visit.progress() * Math.PI) * 4); + } + } + + /** Dexter sits next to each machine with a Dexter spawn near it, before the hero gets there. */ + private seat(stage: Phaser.Scene): void { + const data = stage.sys.settings.data as StageData | undefined; + const worlds = (data?.collisions ?? []).map(chunk => chunk.world); + const machines = worlds.flatMap(w => w.interact).filter(box => box.kind === 'coffee-machine'); + const spawns = worlds.flatMap(w => w.spawn).filter(spawn => spawn.kind === 'dexter'); + for (const machine of machines) { + const at = dexterFor(machine, spawns); + if (at) this.seatAt(stage, machine, at); + } + } + + private seatAt(host: Phaser.Scene, machine: Box, at: Marker): void { + if (this.host !== host) this.clear(); + this.host = host; + // Another story scene may have loaded a shared atlas after this one started. + createAnims(this, ANIMS); + const dexter = this.sprite(host, at.x, at.y, 'dexter', 'idle-00', 0x101010, 10, 27); + if (this.anims.exists('dexter:idle')) dexter.play('dexter:idle'); + const id = machine.id ?? `coffee-${this.stations.size}`; + const station: Station = { at, dexter, visit: undefined!, shown: 'waiting' }; + station.visit = new Visit({ + onGift: () => this.give(station), + onAbsorb: pool => this.absorb(station, pool), + }); + for (const pool of this.pools.keys()) station.visit.addPool(pool); + this.stations.set(id, station); + } + + private arrive(checkpoint: string, position: Position): void { + this.hero = position; + this.stations.get(checkpoint)?.visit.arrive(); + } + + private enter(station: Station, phase: VisitPhase): void { + const d = station.dexter; + d.setAngle(0); + if (phase === 'bow') this.still(d, 'dexter-acts', 'bow-00'); + else if (phase === 'sweat') this.play(d, 'dexter-acts:sweat'); + else if (phase === 'give') { + this.still(d, 'dexter-acts', 'beckon-02'); + this.toss(station, 'pocket-computer', 'bob-00', 0, 0x9aa2a8); + this.toss(station, 'mensa-food', 'muffin-00', 160, 0x6b4a2b); + } else if (phase === 'absorb') { + this.play(d, 'dexter-acts:absorb'); + sound.play('fluid-absorb'); + } else if (phase === 'idle') this.play(d, 'dexter:idle'); + } + + /** The gifts leave his paws in an arc and land in front of the hero, then vanish into the inventory. */ + private toss(station: Station, atlas: string, frame: string, delay: number, color: number): void { + const host = this.host!; + const from = { x: station.at.x - 8, y: station.at.y - 14 }; + const to = { x: this.hero.x + Math.sign(station.at.x - this.hero.x) * 18, y: this.hero.y }; + const item = this.sprite(host, from.x, from.y, atlas, frame, color, 14, 10); + host.tweens.addCounter({ + from: 0, to: 1, delay, duration: 520, ease: 'Sine.easeIn', + onUpdate: tween => { + const t = tween.getValue() ?? 0; + item.setPosition(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t - Math.sin(t * Math.PI) * GIFT_ARC); + }, + onComplete: () => host.tweens.add({ targets: item, alpha: 0, y: item.y - 12, duration: 300, onComplete: () => item.destroy() }), + }); + } + + private give(_station: Station): void { + pocketMap.unlock(); + } + + /** A defeated enemy's pool stays where it fell until Dexter absorbs it. */ + private leavePool(position: Position): void { + const host = this.host ?? this.scene.get(STAGE); + if (!host) return; + const id = this.nextPool++; + const pool = this.sprite(host, position.x, position.y, 'fx', 'black-fluid-05', 0x050608, 22, 4).setDepth(DEPTH - 1); + if (this.anims.exists('fx:black-fluid')) pool.play('fx:black-fluid'); + this.pools.set(id, pool); + for (const [, station] of this.stations) station.visit.addPool(id); + } + + /** The pool thins into strands and is gone; every station forgets it. */ + private absorb(station: Station, id: number): void { + const pool = this.pools.get(id); + this.pools.delete(id); + for (const [, other] of this.stations) { + const at = other.visit.pools.indexOf(id); + if (other !== station && at >= 0) other.visit.pools.splice(at, 1); + } + if (pool) this.host?.tweens.add({ targets: pool, scaleX: 0, alpha: 0, duration: 600, onComplete: () => pool.destroy() }); + } + + private play(sprite: Phaser.GameObjects.Sprite, key: string): void { + if (this.anims.exists(key)) sprite.play(key); + } + + private still(sprite: Phaser.GameObjects.Sprite, atlas: string, frame: string): void { + if (this.textures.exists(atlas)) sprite.stop().setTexture(atlas, frame); + } + + /** A sprite from the atlas, or a placeholder rectangle of the measured size when it isn't exported yet. */ + private sprite(host: Phaser.Scene, x: number, y: number, atlas: string, frame: string, color: number, w: number, + h: number): Phaser.GameObjects.Sprite { + if (!this.textures.exists(atlas)) { + const key = `coffee-visit-${color.toString(16)}-${w}x${h}`; + if (!this.textures.exists(key)) { + const g = this.make.graphics({}, false).fillStyle(color).fillRect(0, 0, w, h); + g.generateTexture(key, w, h).destroy(); + } + return host.add.sprite(x, y, key).setOrigin(0.5, 1).setDepth(DEPTH); + } + return host.add.sprite(x, y, atlas, frame).setOrigin(0.5, 1).setDepth(DEPTH); + } + + private clear(): void { + for (const [, station] of this.stations) station.dexter.destroy(); + this.stations.clear(); + } +} diff --git a/game/src/story/Crossing.ts b/game/src/story/Crossing.ts index c22ec76..1bfa472 100644 --- a/game/src/story/Crossing.ts +++ b/game/src/story/Crossing.ts @@ -1,44 +1,84 @@ import Phaser from 'phaser'; import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; +import { add404Void, addImageCut, addPixelStretch } from '../fx/anomaly'; import { sound } from '../fx/sound/Audio'; import { crashWindow } from '../ui/crashWindow'; -import { CrossingSequence } from './crossingSequence'; +import { CrossingSequence, tearLayout, TIMING, TIMING_404, type CrossingPhase } from './crossingSequence'; +import { pullCurve } from './prologueFinale'; + +/** The prologue's window: the crash-OS voice, short (docs/03 "the 404"). */ +export const NOT_FOUND_TEXT = '404: Hidden Garden not found.'; + +/** The hero as the 404 pulls him: his view position and the frame he is frozen in. */ +export interface CrossingHero { x: number; y: number; texture: string; frame: string; flipX?: boolean; angle?: number } export interface CrossingData { /** The scene that freezes while the window is up (default: the stage). */ freeze?: string; /** Called on the cut: the story switches the stage here; the hero's damage animation finishes there. */ onLand?: () => void; + /** 'crash' (default): flash, cracks, "stopped responding". '404': the frame tears, the 404 window, the pull. */ + variant?: 'crash' | '404'; + /** 404: the hero, drawn over the torn frame and pulled into the void. */ + hero?: CrossingHero; } /** Act (J / X / Square), Enter or Space presses OK; so does a click. docs/02 controls. */ const OK_KEYS = ['J', 'ENTER', 'SPACE']; const OK_PADS = [0, 2]; +const FRAME_KEY = 'crossing-404-frame'; /** Runs the crossing over whatever scene is playing: flash, frozen cracked frame, the THD.exe window, cut. */ export class Crossing extends Phaser.Scene { private run!: CrossingSequence; + private variant: 'crash' | '404' = 'crash'; + private last: CrossingPhase = 'idle'; private flash!: Phaser.GameObjects.Rectangle; private dim!: Phaser.GameObjects.Rectangle; private cracks!: Phaser.GameObjects.Graphics; private dialog!: Phaser.GameObjects.Container; private black!: Phaser.GameObjects.Rectangle; + // 404 only. + private heroData?: CrossingHero; + private hero?: Phaser.GameObjects.Sprite; + private torn?: Phaser.GameObjects.Container; + private tears: Phaser.GameObjects.GameObject[] = []; + private frameImage?: Phaser.GameObjects.Image; + private backdrop?: Phaser.GameObjects.Rectangle; + private streaks?: Phaser.GameObjects.Graphics; + private voidCentre = { x: VIEW_WIDTH / 2, y: VIEW_HEIGHT / 2 }; constructor() { super('Crossing'); } create(data: CrossingData = {}): void { const frozen = data.freeze ?? 'Stage'; + this.variant = data.variant ?? 'crash'; + this.heroData = data.hero; + this.last = 'idle'; + this.tears = []; + this.torn = undefined; this.run = new CrossingSequence(() => { data.onLand?.(); if (this.scene.isPaused(frozen)) this.scene.resume(frozen); this.scene.stop(); - }); - this.flash = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0xffffff, 0.85).setOrigin(0); + }, this.variant === '404' ? TIMING_404 : TIMING); + const is404 = this.variant === '404'; + if (is404) { + // Oversized, so the camera shake during the pull never shows the stage behind it. + this.backdrop = this.add.rectangle(-40, -40, VIEW_WIDTH + 80, VIEW_HEIGHT + 80, 0x000000).setOrigin(0).setVisible(false); + this.streaks = this.add.graphics().setDepth(5); + } + this.flash = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0xffffff, 0.85).setOrigin(0).setDepth(8); this.dim = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0x000000, 0.35).setOrigin(0).setVisible(false); this.cracks = this.drawCracks().setVisible(false); - const { container, ok } = crashWindow(this, VIEW_WIDTH / 2, VIEW_HEIGHT / 2); - this.dialog = container.setVisible(false); - this.black = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0x000000).setOrigin(0).setVisible(false); + if (is404 && this.heroData) { + const h = this.heroData; + this.hero = this.add.sprite(h.x, h.y, h.texture, h.frame).setOrigin(0.5, 1).setFlipX(!!h.flipX) + .setAngle(h.angle ?? 0).setDepth(6); + } + const { container, ok } = crashWindow(this, VIEW_WIDTH / 2, VIEW_HEIGHT / 2 - 40, is404 ? NOT_FOUND_TEXT : undefined); + this.dialog = container.setVisible(false).setDepth(10); + this.black = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, 0x000000).setOrigin(0).setVisible(false).setDepth(20); ok.on('pointerdown', () => this.run.confirm()); 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) => { @@ -54,6 +94,7 @@ export class Crossing extends Phaser.Scene { const phase = this.run.update(delta); if (phase === 'freeze' || phase === 'dialog') this.events.emit('freeze'); if (phase === 'dialog') this.events.emit('dialog'); + if (this.variant === '404') return this.update404(phase); this.flash.setVisible(phase === 'hit'); this.dim.setVisible(phase === 'freeze' || phase === 'dialog'); this.cracks.setVisible(phase === 'freeze' || phase === 'dialog'); @@ -61,6 +102,91 @@ export class Crossing extends Phaser.Scene { this.black.setVisible(phase === 'cut'); } + /** The prologue's 404: the frame tears around the hero, the window, then the pull into the void. */ + private update404(phase: CrossingPhase): void { + const entered = phase !== this.last; + this.last = phase; + this.flash.setVisible(phase === 'hit').setAlpha(phase === 'hit' ? 0.9 * (1 - this.run.time / TIMING_404.hit) + 0.1 : 0); + if (entered && phase === 'hit') sound.play('player-hurt'); + if (entered && phase === 'freeze') this.tearFrame(); + if (phase === 'freeze') { + // The tears flicker in while the frame breaks; the hero jitters with them. + const settle = this.run.time / TIMING_404.freeze; + for (const item of this.tears) (item as Phaser.GameObjects.Image).setVisible(Math.random() < 0.35 + settle * 0.6); + this.hero?.setX((this.heroData?.x ?? 0) + (Math.random() < 0.3 ? Phaser.Math.Between(-3, 3) : 0)); + } + if (entered && phase === 'dialog') for (const item of this.tears) (item as Phaser.GameObjects.Image).setVisible(true); + this.dialog.setVisible(phase === 'dialog'); + if (entered && phase === 'pull') this.beginPull(); + if (phase === 'pull') this.pull(this.run.pullProgress()); + this.black.setVisible(phase === 'cut' || phase === 'land' || phase === 'done'); + } + + /** The anomaly language over a snapshot of the frozen frame: cuts, a stretched column and the 404 void. */ + private tearFrame(): void { + const hero = this.heroData ?? { x: VIEW_WIDTH / 2, y: VIEW_HEIGHT / 2 }; + const layout = tearLayout(hero, { w: VIEW_WIDTH, h: VIEW_HEIGHT }); + this.voidCentre = { x: layout.void.x + layout.void.w / 2, y: layout.void.y + layout.void.h / 2 }; + // The snapshot must not contain our own hero: he is drawn on top of the torn frame. + this.hero?.setVisible(false); + this.game.renderer.snapshot(shot => { + if (!(shot instanceof HTMLImageElement) || !this.scene.isActive()) return; + if (this.textures.exists(FRAME_KEY)) this.textures.remove(FRAME_KEY); + this.textures.addImage(FRAME_KEY, shot); + const frame = this.add.image(0, 0, FRAME_KEY).setOrigin(0).setVisible(false); + this.frameImage = frame; + this.tears = [ + ...layout.cuts.flatMap(c => addImageCut(frame, c.area, c.shift, c.copies)), + addPixelStretch(frame, layout.stretch, 6), + add404Void(frame, layout.void), + ]; + const { x, y } = this.voidCentre; + this.torn = this.add.container(x, y).setDepth(1); + for (const item of [frame, ...this.tears] as (Phaser.GameObjects.Image | Phaser.GameObjects.Graphics)[]) { + item.setPosition(item.x - x, item.y - y); + this.torn.add(item); + } + this.hero?.setVisible(true); + }); + } + + private beginPull(): void { + this.backdrop?.setVisible(true); + this.frameImage?.setVisible(true); + this.cameras.main.shake(TIMING_404.pull, 0.004); + sound.play('fluid-absorb'); + this.time.delayedCall(TIMING_404.pull * 0.55, () => sound.play('chair-whoosh')); + this.hero?.stop(); + } + + /** Everything spirals into the void: the torn frame collapses on it, the hero is dragged in and shrinks. */ + private pull(t: number): void { + const { scale, spin, reach } = pullCurve(t); + const { x: vx, y: vy } = this.voidCentre; + this.torn?.setScale(Math.max(0.001, 1 - reach * 0.97)).setRotation(spin * 0.12); + if (this.hero && this.heroData) { + const dx = (this.heroData.x - vx) * (1 - reach); + const dy = (this.heroData.y - 38 - vy) * (1 - reach); + const a = spin * 0.5; + this.hero.setOrigin(0.5, 0.5) + .setPosition(vx + dx * Math.cos(a) - dy * Math.sin(a), vy + dx * Math.sin(a) + dy * Math.cos(a)) + .setRotation(spin) + .setScale(Math.max(0.001, scale), Math.max(0.001, scale * (1 + reach * 0.8))); + } + // Streaks rushing into the void from the edges of the screen. + const g = this.streaks; + if (!g) return; + g.clear(); + const rng = new Phaser.Math.RandomDataGenerator([String(Math.floor(t * 30))]); + for (let i = 0; i < 26; i++) { + const angle = rng.realInRange(0, Math.PI * 2); + const far = rng.realInRange(380, 900) * (1 - reach * 0.6); + const near = far * rng.realInRange(0.25, 0.6); + g.lineStyle(rng.between(1, 3), i % 3 ? 0xe8e4dc : 0x9a9a9a, Math.min(1, reach * 1.6) * 0.8); + g.lineBetween(vx + Math.cos(angle) * far, vy + Math.sin(angle) * far, vx + Math.cos(angle) * near, vy + Math.sin(angle) * near); + } + } + /** A few pixel cracks radiating from the centre over the frozen picture. */ private drawCracks(): Phaser.GameObjects.Graphics { const g = this.add.graphics().lineStyle(2, 0xe8e4dc, 0.9); diff --git a/game/src/story/Ending.ts b/game/src/story/Ending.ts index cfe4994..e3b2676 100644 --- a/game/src/story/Ending.ts +++ b/game/src/story/Ending.ts @@ -5,13 +5,12 @@ import { addImageCut, addPixelStretch, add404Void } from '../fx/anomaly'; import { sound } from '../fx/sound/Audio'; import { crashWindow } from '../ui/crashWindow'; import { creditsText } from '../ui/creditsView'; +import { createAnims, type AtlasAnim } from './atlases'; 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][] = [ +const ANIMS: AtlasAnim[] = [ ['fx', 'black-fluid', 6, 0], ['dexter', 'hop', 4, -1], ['dexter-acts', 'absorb', 4, 0], ]; const STAGE = 'Stage'; @@ -38,19 +37,7 @@ export class Ending extends Phaser.Scene { 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). @@ -66,6 +53,7 @@ export class Ending extends Phaser.Scene { if (this.run) return; this.at = position ? this.toView(position) : FALLBACK; if (this.scene.isActive(STAGE)) this.scene.pause(STAGE); + createAnims(this, ANIMS); this.run = new EndingSequence(() => window.location.assign(window.location.pathname)); this.run.start(); this.scene.bringToTop(); diff --git a/game/src/story/Flow.ts b/game/src/story/Flow.ts index 7b5865c..dc8f72e 100644 --- a/game/src/story/Flow.ts +++ b/game/src/story/Flow.ts @@ -1,34 +1,74 @@ import Phaser from 'phaser'; import { gameEvents, type Position } from '../core/events'; -import { VIEW_WIDTH } from '../core/viewport'; +import { JENNY_BOX } from '../enemies/jenny'; +import { ramFrame, ramPlan, type RamFrame } from '../enemies/jennyRam'; import { sound } from '../fx/sound/Audio'; +import type { CollisionChunk } from '../stage/collision'; +import type { StageManifest } from '../stage/manifest'; import { swapRegion, type RegionTarget } from '../stage/swap'; -import type { CrossingData } from './Crossing'; +import type { CrossingData, CrossingHero } from './Crossing'; +import { Prologue } from './Prologue'; 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; +/** After the ram: the rust maze, back at its start (the anomaly's player spawn). */ +const TO_MAZE: RegionTarget = { region: 'rusty-creek', state: 'anomaly' }; +/** Where the prologue's 404 pull lands the hero (docs/03 region 1, beat 1). */ +const AFTER_PROLOGUE: RegionTarget = { region: 'rusty-creek', state: 'clean' }; + +/** The part of the stage's start data the story reads. */ +interface StageData { manifest?: StageManifest; collisions?: CollisionChunk[] } /** - * 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. + * The slice's route between regions (docs/03, #35 #50 #51 #54): in the Hidden Garden the prologue runs – + * Dexter leads the hero to the pond, shoves him in, and the 404 pulls him 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 the stage or a `trigger-entered` kind and swaps the region on the crossing's cut. */ export class Flow extends Phaser.Scene { private busy = false; + /** The running prologue, when the stage is a region with Dexter's bench (dev: `game.scene.getScene('Flow').prologue`). */ + prologue?: Prologue; constructor() { super({ key: 'Flow', active: true }); } create(): void { + const stage = this.scene.get(STAGE); + const begin = () => this.beginPrologue(stage); + stage?.events.on(Phaser.Scenes.Events.CREATE, begin); + if (stage && this.scene.isActive(STAGE)) begin(); 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' }); + // The pond is the prologue's finale; without a prologue (no bench in the data) it is the plain crossing. + if (kind === 'anomaly' && region === 'hidden-garden' && !this.prologue) this.cross(AFTER_PROLOGUE); 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(); }); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + stop(); + ready(); + stage?.events.off(Phaser.Scenes.Events.CREATE, begin); + }); + } + + /** Every time the stage starts: the prologue runs where the region's data seats Dexter on his bench. */ + private beginPrologue(stage: Phaser.Scene): void { + this.prologue = undefined; + const data = stage.sys.settings.data as StageData | undefined; + const chunks = data?.collisions ?? []; + if (!Prologue.fits(chunks)) return; + const prologue = new Prologue(stage, chunks, data?.manifest?.region ?? 'hidden-garden', (hero, onLand) => this.crash404(hero, onLand)); + this.prologue = prologue; + stage.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { if (this.prologue === prologue) this.prologue = undefined; }); + } + + /** The prologue's end: the 404 crossing pulls the hero into the void; on its cut, clean Rusty Creek. */ + private crash404(hero: CrossingHero, onLand: () => void): void { + this.busy = true; + const data: CrossingData = { + variant: '404', hero, + onLand: () => { onLand(); swapRegion(this, AFTER_PROLOGUE); }, + }; + this.scene.launch('Crossing', data); } /** The THD.exe crossing over the frozen stage; on the cut the region swaps. */ @@ -38,21 +78,79 @@ export class Flow extends Phaser.Scene { this.scene.launch('Crossing', data); } - /** Jenny comes out of nowhere along the bridge and hits the hero; the crash is the crossing. */ + /** + * Jenny's ram on the creek path (P5) as one event: headlights and engine from beyond the right edge (the tell), + * she rolls into view revving, charges, hits the hero and throws him the way she drives; the frame breaks into + * the crash crossing ("THD.exe has stopped responding" – not the 404's tear) and the hero lands at the maze start. + */ 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: () => { + const stage = this.scene.get(STAGE) as Phaser.Scene & { playerActor?: { sprite: Phaser.GameObjects.Sprite; body: { vx: number } } }; + if (!stage?.textures.exists('boss-jenny')) return this.cross(TO_MAZE); + // The hero freezes where the lights appear (a player holding right would otherwise outrun her); the swap gives + // the input back. + this.lockInput(stage, true); + if (stage.playerActor) stage.playerActor.body.vx = 0; + const camera = stage.cameras.main; + const viewRight = camera.scrollX + camera.width; + const plan = ramPlan(hero.x, viewRight); + const lights = headlights(stage); + const jenny = stage.add.sprite(plan.startX, hero.y, 'boss-jenny', 'rev-00').setOrigin(0.5, 1).setDepth(150); + let thrown: Phaser.GameObjects.Sprite | undefined; + let t = 0, last: RamFrame['phase'] | undefined, puff = 0; + sound.play('jenny-engine'); + camera.shake(plan.chargeAt * 1000, 0.0015); + // Driven by the stage clock (not tweens), so it pauses with the stage and steps in tests. + const step = (_time: number, delta: number) => { + t += delta / 1000; + const f = ramFrame(t, plan, hero, viewRight); + const entered = f.phase !== last; + last = f.phase; + jenny.setPosition(Math.round(f.jennyX), hero.y + (f.phase === 'rev' ? Math.floor(t * 24) % 2 : 0)).setFrame(f.jennyFrame); + lights.setPosition(Math.round(f.lightsX), hero.y).setAlpha(f.lights); + if (f.phase === 'rev' && (puff -= delta) <= 0) { puff = 80; exhaust(stage, jenny); } + if (entered && f.phase === 'rev') sound.play('jenny-engine'); + if (entered && f.phase === 'charge') sound.play('chair-whoosh'); + if (entered && f.phase === 'thrown') { sound.play('chair-hit'); - this.cross({ region: 'rusty-creek', state: 'anomaly' }); - }, - }); + sound.play('player-hurt'); + camera.shake(260, 0.012); + camera.flash(90, 255, 244, 203); + stage.playerActor?.sprite.setVisible(false); + thrown = stage.add.sprite(hero.x, hero.y, 'hero-hurt', 'hit-00').setOrigin(0.5, 1).setDepth(151); + } + if (f.hero) thrown?.setPosition(Math.round(f.hero.x), Math.round(f.hero.y)).setAngle(f.hero.angle).setFrame(f.hero.frame); + if (entered && f.phase === 'cross') { + stage.events.off('update', step); + this.lockInput(stage, false); + this.cross(TO_MAZE); + } + }; + stage.events.on('update', step); + stage.events.once(Phaser.Scenes.Events.SHUTDOWN, () => stage.events.off('update', step)); + } + + private lockInput(stage: Phaser.Scene, locked: boolean): void { + const keyboard = stage.input.keyboard; + if (locked) keyboard?.resetKeys(); + if (keyboard) keyboard.enabled = !locked; + if (stage.input.gamepad) stage.input.gamepad.enabled = !locked; } } + +/** Two headlights and their beam back along the path: the tell, growing out of the right edge before she is seen. */ +function headlights(stage: Phaser.Scene): Phaser.GameObjects.Container { + const beam = stage.add.graphics().setBlendMode(Phaser.BlendModes.ADD); + beam.fillStyle(0xfff4cb, 0.26).fillTriangle(0, -44, -520, -110, -520, 10); + beam.fillStyle(0xfff4cb, 0.2).fillTriangle(0, -44, -360, -80, -360, -10); + const lampA = stage.add.ellipse(0, -46, 26, 14, 0xffffff).setBlendMode(Phaser.BlendModes.ADD); + const lampB = stage.add.ellipse(14, -40, 18, 10, 0xfff4cb).setBlendMode(Phaser.BlendModes.ADD); + return stage.add.container(0, 0, [beam, lampA, lampB]).setDepth(152).setAlpha(0); +} + +/** A grey puff from her tail while she revs (her frames face left, so the tail is to the right). */ +function exhaust(stage: Phaser.Scene, jenny: Phaser.GameObjects.Sprite): void { + const puff = stage.add.circle(jenny.x + JENNY_BOX.w / 2 - 10, jenny.y - 24, 6, 0x9aa3a8, 0.7).setDepth(149); + stage.tweens.add({ targets: puff, x: puff.x + 30 + Math.random() * 20, y: puff.y - 14 - Math.random() * 10, + scale: 2.4, alpha: 0, duration: 420, onComplete: () => puff.destroy() }); +} diff --git a/game/src/story/Prologue.ts b/game/src/story/Prologue.ts new file mode 100644 index 0000000..964698f --- /dev/null +++ b/game/src/story/Prologue.ts @@ -0,0 +1,288 @@ +import Phaser from 'phaser'; +import { gameEvents, type Position } from '../core/events'; +import { sound } from '../fx/sound/Audio'; +import type { Player } from '../player/player'; +import { addRim } from '../stage/atmosphere/pipeline'; +import type { Box, CollisionChunk, Marker } from '../stage/collision'; +import { createAnims, type AtlasAnim } from './atlases'; +import type { CrossingHero } from './Crossing'; +import { FINALE, FinaleSequence, knockArc, type FinalePhase } from './prologueFinale'; +import { DexterGuide, stopsFrom, type GuideAnim, type GuideView, type Pt } from './prologueGuide'; + +const ANIMS: AtlasAnim[] = [ + ['dexter', 'idle', 3, -1], ['dexter', 'hop', 4, -1], ['dexter', 'walk', 6, -1], ['dexter', 'point', 3, 0], + ['dexter', 'shove', 4, 0], ['dexter-acts', 'beckon', 3, -1], ['hero-hurt', 'knockback', 3, 0], +]; +/** Each pose and its stand-in from the approved boards while `dexter-prologue` awaits review. */ +const ANIM_KEY: Record = { + sit: ['dexter-prologue:sit', 'dexter:idle'], look: ['dexter-prologue:look-back', 'dexter:idle'], + wait: ['dexter-prologue:wait', 'dexter:idle'], hop: ['dexter:hop', 'dexter:hop'], walk: ['dexter:walk', 'dexter:walk'], + point: ['dexter:point', 'dexter:point'], beckon: ['dexter-acts:beckon', 'dexter-acts:beckon'], + bow: ['dexter-acts:bow', 'dexter:idle'], sweat: ['dexter-acts:sweat', 'dexter-prologue:wait'], run: ['dexter:walk', 'dexter:walk'], +}; +/** A 1 px warm rim so the black plush reads against the dark hedge path (#159); the glow is the Canvas-free fallback. */ +const RIM = { color: 0xffc98a, strength: 0.7, outer: 1.4, distance: 2 }; +/** A landing squashes him this much at most (scale x up, y down) – a plush, not a rubber ball. */ +const SQUASH = { x: 0.14, y: 0.18 }; +/** NPCs draw at 101–105 (docs/15 depth bands); secrets sit with the props at 99. */ +const DEXTER_DEPTH = 101; +const PROP_DEPTH = 99; +/** The muffin shows itself when the hero is this close (px), and is taken at arm's length. */ +const REVEAL_NEAR = 200; +const TAKE_NEAR = 26; +/** A flickering girder patch for a pond without the painted wrong reflection; hg-4-0 has it in the art. */ +const FALLBACK_REFLECTION = false; + +type StageScene = Phaser.Scene & { playerActor?: Player }; + +/** Animations from an atlas's own table (frames, fps, loop), as exported by export_atlas.py. */ +function createTableAnims(scene: Phaser.Scene, atlas: string): void { + const table = scene.cache.json.get(`${atlas}.anims`) as Record | undefined; + if (!table || !scene.textures.exists(atlas)) return; + for (const [anim, clip] of Object.entries(table)) { + const key = `${atlas}:${anim}`; + if (scene.anims.exists(key)) continue; + const frames = Array.from({ length: clip.frames }, (_, i) => ({ key: atlas, frame: `${anim}-${String(i).padStart(2, '0')}` })); + scene.anims.create({ key, frames, frameRate: clip.fps, repeat: clip.loop ? -1 : 0 }); + } +} + +/** Flow's hand-off for the crash: the 404 crossing with the hero frozen in his knock pose. */ +export type Crash404 = (hero: CrossingHero, onLand: () => void) => void; + +/** + * The Hidden Garden prologue drawn into the stage (docs/03 "Prologue", #50 #51 #160): Dexter on the bench, his + * lead along the waypoints, the two secrets (the muffin in the hollow, the glint on the shed roof), and the + * pond finale – the wrong reflection, the shove, then the 404 crossing that pulls the hero into Rusty Creek. + * Everything comes from the region's collision data: `dexter-bench`, the `dexter-waypoint` boxes and their + * `act`, the `secret` box with its item, the roof glint, and the `anomaly` box at the jetty edge. + */ +export class Prologue { + readonly guide: DexterGuide; + readonly finale: FinaleSequence; + private dexter: Phaser.GameObjects.Sprite; + private anim = ''; + private pondEdge?: Box; + private proxy?: Phaser.GameObjects.Sprite; + private knockFrom: Position = { x: 0, y: 0 }; + private lastPhase: FinalePhase = 'idle'; + private reflection?: Phaser.GameObjects.Graphics; + private secrets: { sprite: Phaser.GameObjects.GameObject & { x: number; y: number }; at: Marker; shown: boolean; taken: boolean; kind: 'muffin' | 'glint' }[] = []; + private stops: (() => void)[] = []; + + constructor(private readonly stage: StageScene, chunks: CollisionChunk[], private readonly region: string, + private readonly crash: Crash404) { + const worlds = chunks.map(c => c.world); + const lines: Pt[][] = worlds.flatMap(w => [...w.walk, ...w.oneWay]); + const boxes = worlds.flatMap(w => w.interact); + const spawns = worlds.flatMap(w => w.spawn); + const bench = spawns.find(s => s.id === 'dexter-bench') ?? spawns.find(s => s.kind === 'dexter')!; + this.guide = new DexterGuide({ bench, lines, ...stopsFrom(boxes) }); + this.pondEdge = boxes.find(b => b.kind === 'anomaly'); + this.finale = new FinaleSequence(() => this.toCrash()); + createAnims(stage, ANIMS); + createTableAnims(stage, 'dexter-prologue'); + createTableAnims(stage, 'dexter-acts'); + this.dexter = stage.add.sprite(bench.x, bench.y, 'dexter', 'idle-00').setOrigin(0.5, 1).setDepth(DEXTER_DEPTH); + if (!addRim(this.dexter, RIM.color, RIM.strength)) { + this.dexter.preFX?.setPadding(RIM.distance + 1); + this.dexter.preFX?.addGlow(RIM.color, RIM.outer, 0, false, 0.1, RIM.distance); + } + this.placeSecrets(boxes, spawns); + const update = (_time: number, delta: number) => this.update(delta); + stage.events.on(Phaser.Scenes.Events.UPDATE, update); + this.stops.push(() => stage.events.off(Phaser.Scenes.Events.UPDATE, update)); + stage.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.destroy()); + } + + /** Prologue exists where the region has Dexter's bench. */ + static fits(chunks: CollisionChunk[]): boolean { + return chunks.some(c => c.world.spawn.some(s => s.id === 'dexter-bench')); + } + + get running(): boolean { return this.finale.phase !== 'idle'; } + + /** Dev: put the hero and Dexter at the jetty, ready for the finale. */ + skipToPond(): void { + const player = this.stage.playerActor; + this.guide.placeAt(Number.MAX_SAFE_INTEGER); + if (player && this.pondEdge) Object.assign(player.body, { x: this.pondEdge.x - 140, y: this.pondEdge.y + this.pondEdge.h, vx: 0, vy: 0 }); + } + + private update(delta: number): void { + const player = this.stage.playerActor; + if (!player) return; + const hero = player.body; + if (this.finale.phase === 'idle') { + this.draw(this.guide.update(delta, hero)); + this.updateSecrets(player); + const edge = this.pondEdge; + if (edge && this.guide.state === 'at-pond' && hero.x >= edge.x && hero.grounded && player.life.dead === 0) this.beginFinale(player); + return; + } + this.dexter.setScale(1); + this.dexter.anims.timeScale = 1; + const phase = this.finale.update(delta); + const t = this.finale.progress(); + const entered = phase !== this.lastPhase; + this.lastPhase = phase; + if (phase === 'reveal') this.flickerReflection(t); + if (phase === 'step') { + if (entered) { this.reflection?.setAlpha(0.55); this.play('dexter:walk'); } + const from = this.guide.x; + const to = hero.x - FINALE.behind; + this.dexter.setPosition(Math.round(from + (to - from) * t), Math.round(hero.y)).setFlipX(false); + } + if (phase === 'shove') { + if (entered) this.play('dexter:shove'); + if (!this.proxy && t * FINALE.shove >= (FINALE.contactFrame * 1000) / 12) this.knock(player); + } + if (phase === 'fall') { + if (entered) { + if (!this.proxy) this.knock(player); + // One frame of what he really is, while nobody is looking at him. + this.dexter.stop().setTexture('dexter-acts', 'creepy-00'); + } + const p = knockArc(this.knockFrom, t); + this.proxy?.setPosition(Math.round(p.x), Math.round(p.y)).setAngle(p.angle); + } + } + + private draw(view: GuideView): void { + this.dexter.setPosition(Math.round(view.x), Math.round(view.y)).setFlipX(view.facing < 0); + const [key, standIn] = ANIM_KEY[view.anim]; + this.play(this.stage.anims.exists(key) ? key : standIn); + // The walk plays faster as he speeds up (a scamper when the hero runs); a fresh landing squashes him. + this.dexter.anims.timeScale = view.anim === 'walk' || view.anim === 'run' ? view.rate : 1; + this.dexter.setScale(1 + SQUASH.x * view.squash, 1 - SQUASH.y * view.squash); + } + + private play(key: string): void { + if (key === this.anim || !this.stage.anims.exists(key)) return; + this.anim = key; + this.dexter.play(key); + } + + /** The hero reached the jetty edge with Dexter behind him: control is taken and the water goes wrong. */ + private beginFinale(player: Player): void { + if (!this.finale.start()) return; + this.lastPhase = 'reveal'; + const keyboard = this.stage.input.keyboard; + keyboard?.resetKeys(); + if (keyboard) keyboard.enabled = false; + if (this.stage.input.gamepad) this.stage.input.gamepad.enabled = false; + this.play('dexter:point'); + const edge = this.pondEdge!; + const water = { x: Math.round(player.body.x + 50), y: edge.y + edge.h + 18 }; + gameEvents.emit('anomaly-reveal', { id: edge.id ?? 'pond-edge', region: this.region, position: water, duration: FINALE.reveal }); + if (FALLBACK_REFLECTION) this.reflection = this.drawWrongReflection(water); + // The garden goes quiet: the light drains a little while you look at the water. + const { width, height } = this.stage.scale; + const dusk = this.stage.add.rectangle(0, 0, width, height, 0x0a0f1a, 0).setOrigin(0).setScrollFactor(0).setDepth(190); + this.stage.tweens.add({ targets: dusk, fillAlpha: 0.32, duration: FINALE.reveal + FINALE.step, ease: 'Sine.easeIn' }); + } + + /** The shove connects: the stage hero hides and a stand-in is knocked out over the water. */ + private knock(player: Player): void { + sound.play('player-hurt'); + this.knockFrom = { x: player.body.x, y: player.body.y }; + player.sprite.setVisible(false); + this.proxy = this.stage.add.sprite(player.body.x, player.body.y, 'hero-hurt', 'knockback-00').setOrigin(0.5, 1) + .setDepth(100).setFlipX(false); + if (this.stage.anims.exists('hero-hurt:knockback')) this.proxy.play('hero-hurt:knockback'); + } + + /** Hand over to the 404 crossing at the hero's view position; on its cut the region swaps. */ + private toCrash(): void { + const proxy = this.proxy; + const cam = this.stage.cameras.main; + const at = proxy ? { x: proxy.x, y: proxy.y } : this.knockFrom; + const hero: CrossingHero = { + x: (at.x - cam.worldView.x) * cam.zoom, y: (at.y - cam.worldView.y) * cam.zoom, + texture: proxy?.texture.key ?? 'hero-hurt', frame: String(proxy?.frame.name ?? 'knockback-02'), angle: proxy?.angle ?? 0, + }; + this.crash(hero, () => this.release()); + // The crossing draws the hero from here on. + this.proxy?.setVisible(false); + } + + /** Give the input back (the stage restarts with the new region, the plugin outlives it). */ + private release(): void { + const keyboard = this.stage.input.keyboard; + if (keyboard) keyboard.enabled = true; + if (this.stage.input.gamepad) this.stage.input.gamepad.enabled = true; + } + + /** + * Until the water renderer reacts to `anomaly-reveal`: a patch of rusty girders flickers into the water under + * the jetty, upside down like a reflection – the maze that isn't there (docs/03). + */ + private drawWrongReflection(at: Position): Phaser.GameObjects.Graphics { + const g = this.stage.add.graphics({ x: at.x, y: at.y }).setDepth(PROP_DEPTH + 0.5).setAlpha(0); + const w = 120; + const h = 46; + g.fillStyle(0x3a2014, 0.55).fillRect(-w / 2, 0, w, h); + g.lineStyle(4, 0x8a4a2a, 1).lineBetween(-w / 2, 6, w / 2, 6).lineBetween(-w / 2, h - 6, w / 2, h - 6); + g.lineStyle(3, 0x6e3a20, 1); + for (let x = -w / 2; x < w / 2; x += 30) g.lineBetween(x, 6, x + 30, h - 6).lineBetween(x + 30, 6, x, h - 6); + g.lineStyle(2, 0xb06a3a, 0.8); + for (let x = -w / 2; x <= w / 2; x += 30) g.lineBetween(x, 6, x, h - 6); + return g; + } + + private flickerReflection(t: number): void { + // Glimpses at first, then it stays: you can't unsee it. + const on = t > 0.6 || Math.random() < t * 0.6; + this.reflection?.setAlpha(on ? 0.35 + 0.3 * t : 0); + } + + /** The muffin in the hollow behind the grass, and the glint on the shed roof (docs/04, #50). */ + private placeSecrets(boxes: Box[], spawns: Marker[]): void { + const muffinAt = spawns.find(s => s.kind === 'item' && boxes.some(b => b.kind === 'secret' && s.x >= b.x && s.x <= b.x + b.w)); + if (muffinAt) { + const has = this.stage.textures.exists('mensa-food'); + const sprite = (has ? this.stage.add.sprite(muffinAt.x, muffinAt.y, 'mensa-food', 'muffin-00') + : this.stage.add.rectangle(muffinAt.x, muffinAt.y, 14, 10, 0x6b4a2b)).setOrigin(0.5, 1).setDepth(PROP_DEPTH).setAlpha(0); + this.secrets.push({ sprite, at: muffinAt, shown: false, taken: false, kind: 'muffin' }); + } + const glintAt = spawns.find(s => s.kind === 'item' && /glint/.test(s.id ?? '')); + if (glintAt) { + const g = this.stage.add.graphics({ x: glintAt.x, y: glintAt.y - 8 }).setDepth(PROP_DEPTH); + g.fillStyle(0xfff6d8, 1).fillTriangle(-2, 0, 2, 0, 0, -9).fillTriangle(-2, 0, 2, 0, 0, 9) + .fillTriangle(0, -2, 0, 2, -9, 0).fillTriangle(0, -2, 0, 2, 9, 0).fillRect(-2, -2, 4, 4); + this.stage.tweens.add({ targets: g, scale: { from: 0.4, to: 1.15 }, alpha: { from: 0.35, to: 1 }, duration: 520, + yoyo: true, repeat: -1, repeatDelay: 900, ease: 'Sine.easeInOut' }); + this.secrets.push({ sprite: g, at: glintAt, shown: true, taken: false, kind: 'glint' }); + } + } + + private updateSecrets(player: Player): void { + const hero = player.body; + for (const secret of this.secrets) { + if (secret.taken) continue; + const dx = Math.abs(hero.x - secret.at.x); + const dy = Math.abs(hero.y - secret.at.y); + if (!secret.shown && dx < REVEAL_NEAR && dy < 40) { + secret.shown = true; + this.stage.tweens.add({ targets: secret.sprite, alpha: 1, duration: 600 }); + this.stage.tweens.add({ targets: secret.sprite, y: secret.at.y - 4, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + if (secret.shown && dx < TAKE_NEAR && dy < 30) this.take(secret, player); + } + } + + private take(secret: Prologue['secrets'][number], player: Player): void { + secret.taken = true; + const sprite = secret.sprite; + this.stage.tweens.killTweensOf(sprite); + if (secret.kind === 'muffin') player.heal(1); + this.stage.tweens.add({ targets: sprite, y: secret.at.y - 46, alpha: 0, scale: 1.6, duration: 520, ease: 'Quad.easeOut', + onComplete: () => sprite.destroy() }); + } + + private destroy(): void { + this.release(); + for (const stop of this.stops) stop(); + this.stops = []; + } +} diff --git a/game/src/story/atlases.ts b/game/src/story/atlases.ts new file mode 100644 index 0000000..1b32814 --- /dev/null +++ b/game/src/story/atlases.ts @@ -0,0 +1,17 @@ +import type Phaser from 'phaser'; + +// Every atlas is loaded once, by Preload (stage/assets.ts BOARDS): story scenes start active at boot and only +// create their animations when they first need them, so two loaders never ask for the same texture key. + +/** An animation from an exported atlas (scripts/export_atlas.py names frames `-NN`). */ +export type AtlasAnim = [atlas: string, anim: string, frames: number, repeat: number]; + +/** Create the animations whose atlas has arrived (key `:`); call it again later for the rest. */ +export function createAnims(scene: Phaser.Scene, anims: readonly AtlasAnim[]): void { + for (const [atlas, anim, frames, repeat] of anims) { + const key = `${atlas}:${anim}`; + if (scene.anims.exists(key) || !scene.textures.exists(atlas)) continue; + const names = Array.from({ length: frames }, (_, i) => ({ key: atlas, frame: `${anim}-${String(i).padStart(2, '0')}` })); + scene.anims.create({ key, frames: names, frameRate: 12, repeat }); + } +} diff --git a/game/src/story/coffeeSequence.test.ts b/game/src/story/coffeeSequence.test.ts new file mode 100644 index 0000000..9c639bd --- /dev/null +++ b/game/src/story/coffeeSequence.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { dexterFor, VISIT_TIMING, VisitSequence } from './coffeeSequence'; + +const run = (visit: VisitSequence, ms: number) => { + const seen: string[] = []; + for (let t = 0; t < ms; t += 50) { + const phase = visit.update(50); + if (seen.at(-1) !== phase) seen.push(phase); + } + return seen; +}; +const FIRST = VISIT_TIMING.bow + VISIT_TIMING.sweat + VISIT_TIMING.give; + +describe('Dexter at the coffee machine', () => { + it('waits until the hero saves there', () => { + const visit = new VisitSequence(); + expect(run(visit, 3000)).toEqual(['waiting']); + }); + + it('bows, sweats and gives once, then idles without pools', () => { + let gifts = 0; + const visit = new VisitSequence({ onGift: () => gifts++ }); + visit.arrive(); + expect(run(visit, FIRST + 200)).toEqual(['bow', 'sweat', 'give', 'idle']); + visit.arrive(); + expect(run(visit, 3000)).toEqual(['idle']); + expect(gifts).toBe(1); + }); + + it('absorbs every pool after the gift, oldest first', () => { + const absorbed: number[] = []; + const visit = new VisitSequence({ onAbsorb: id => absorbed.push(id) }); + visit.addPool(4); + visit.addPool(7); + visit.arrive(); + expect(run(visit, FIRST + 2 * VISIT_TIMING.absorb + 200)).toEqual(['bow', 'sweat', 'give', 'absorb', 'idle']); + expect(absorbed).toEqual([4, 7]); + }); + + it('absorbs pools from later fights at the next save', () => { + const absorbed: number[] = []; + const visit = new VisitSequence({ onAbsorb: id => absorbed.push(id) }); + visit.arrive(); + run(visit, FIRST + 200); + visit.addPool(1); + visit.arrive(); + expect(run(visit, VISIT_TIMING.absorb + 200)).toEqual(['absorb', 'idle']); + expect(absorbed).toEqual([1]); + }); + + it('ignores a second save while he is busy', () => { + let gifts = 0; + const visit = new VisitSequence({ onGift: () => gifts++ }); + visit.arrive(); + visit.update(VISIT_TIMING.bow + 100); + visit.arrive(); + expect(visit.phase).toBe('sweat'); + run(visit, FIRST); + expect(gifts).toBe(1); + }); + + it('pairs a machine with the nearest Dexter spawn in reach', () => { + // Rusty Creek rc-1-0: coffee-1 at (1770, 457) 40×80, dexter-1 at (1820, 537). + const machine = { x: 1770, y: 457, w: 40, h: 80 }; + const near = { id: 'dexter-1', x: 1820, y: 537 }; + const far = { id: 'dexter-2', x: 1640 + 2160, y: 565 }; + expect(dexterFor(machine, [far, near])).toBe(near); + expect(dexterFor(machine, [far])).toBeUndefined(); + }); +}); diff --git a/game/src/story/coffeeSequence.ts b/game/src/story/coffeeSequence.ts new file mode 100644 index 0000000..8964e9f --- /dev/null +++ b/game/src/story/coffeeSequence.ts @@ -0,0 +1,80 @@ +/** + * Dexter at the coffee machine (docs/03 "Region 1 story beats", #52). He sits there before you arrive. The + * first save at his machine: he bows, sweats – sorry – and hands over the pocket computer and a muffin. Every + * save after that: he absorbs the black-fluid pools left by your fights, one after another, while you watch. + * No words. Pure state; the CoffeeVisit scene draws it. + */ +export type VisitPhase = 'waiting' | 'bow' | 'sweat' | 'give' | 'absorb' | 'idle'; + +/** Milliseconds per phase; `absorb` repeats once per pool. */ +export const VISIT_TIMING = { bow: 700, sweat: 1000, give: 1100, absorb: 900 } as const; +/** How far a Dexter spawn may be from a coffee machine to belong to it (stage px). */ +export const MACHINE_REACH = 160; + +type Timed = keyof typeof VISIT_TIMING; +const timed = (phase: VisitPhase): phase is Timed => phase in VISIT_TIMING; + +export class VisitSequence { + phase: VisitPhase = 'waiting'; + /** Pools waiting to be absorbed, oldest first. */ + readonly pools: number[] = []; + private gifted = false; + private elapsed = 0; + + /** `onGift` runs once, when the gifts leave his paws; `onAbsorb` once per pool, with its id. */ + constructor(private hooks: { onGift?: () => void; onAbsorb?: (pool: number) => void } = {}) {} + + addPool(id: number): void { this.pools.push(id); } + + /** The hero saved at this machine. Ignored while he is busy. */ + arrive(): void { + if (timed(this.phase)) return; + if (!this.gifted) this.enter('bow'); + else this.enter(this.pools.length ? 'absorb' : 'idle'); + } + + update(milliseconds: number): VisitPhase { + if (!timed(this.phase)) return this.phase; + this.elapsed += milliseconds; + while (timed(this.phase) && this.elapsed >= VISIT_TIMING[this.phase]) { + this.elapsed -= VISIT_TIMING[this.phase]; + this.leave(this.phase); + } + return this.phase; + } + + /** 0 → 1 through the current timed phase. */ + progress(): number { + return timed(this.phase) ? Math.min(1, this.elapsed / VISIT_TIMING[this.phase]) : 0; + } + + private leave(phase: Timed): void { + if (phase === 'bow') return this.enter('sweat'); + if (phase === 'sweat') return this.enter('give'); + if (phase === 'give') { + this.gifted = true; + this.hooks.onGift?.(); + } else { + const pool = this.pools.shift(); + if (pool !== undefined) this.hooks.onAbsorb?.(pool); + } + // Absorb repeats while pools are left; with none it is skipped. + this.enter(this.pools.length ? 'absorb' : 'idle'); + } + + private enter(phase: Exclude): void { + this.phase = phase; + if (!timed(phase)) this.elapsed = 0; + } +} + +/** The Dexter spawn that belongs to a machine: the nearest one within reach of its foot, or none. */ +export function dexterFor(machine: { x: number; y: number; w: number; h: number }, + spawns: T[]): T | undefined { + const cx = machine.x + machine.w / 2; + const foot = machine.y + machine.h; + return spawns + .map(s => ({ s, d: Math.hypot(s.x - cx, s.y - foot) })) + .filter(({ d }) => d <= MACHINE_REACH) + .sort((a, b) => a.d - b.d)[0]?.s; +} diff --git a/game/src/story/crossingSequence.test.ts b/game/src/story/crossingSequence.test.ts index e7fe9e4..f9a26f9 100644 --- a/game/src/story/crossingSequence.test.ts +++ b/game/src/story/crossingSequence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CrossingSequence, TIMING } from './crossingSequence'; +import { CrossingSequence, tearLayout, TIMING, TIMING_404 } from './crossingSequence'; describe('the crossing', () => { it('runs hit, freeze and the dialog, then waits for OK', () => { @@ -27,3 +27,49 @@ describe('the crossing', () => { expect(landed).toBe(1); }); }); + +describe('the 404 crossing', () => { + it('pulls the hero into the void after OK, then cuts and lands once', () => { + let landed = 0; + const run = new CrossingSequence(() => landed++, TIMING_404); + run.start(); + run.update(TIMING_404.hit); + expect(run.update(TIMING_404.freeze - 1)).toBe('freeze'); + expect(run.update(1)).toBe('dialog'); + run.update(TIMING_404.dialogGuard); + expect(run.confirm()).toBe(true); + expect(run.phase).toBe('pull'); + expect(run.confirm()).toBe(false); + run.update(TIMING_404.pull / 2); + expect(run.pullProgress()).toBeCloseTo(0.5); + expect(run.update(TIMING_404.pull / 2)).toBe('cut'); + expect(landed).toBe(0); + expect(run.update(TIMING_404.cut)).toBe('done'); + expect(landed).toBe(1); + }); + + it('tears the frame inside the view, the void just past the hero', () => { + const view = { w: 1280, h: 720 }; + for (const hero of [{ x: 640, y: 500 }, { x: 10, y: 700 }, { x: 1275, y: 20 }]) { + const tear = tearLayout(hero, view); + for (const a of [tear.void, tear.stretch, ...tear.cuts.map(c => c.area)]) { + expect(a.x).toBeGreaterThanOrEqual(0); + expect(a.y).toBeGreaterThanOrEqual(0); + expect(a.x + a.w).toBeLessThanOrEqual(view.w); + expect(a.y + a.h).toBeLessThanOrEqual(view.h); + } + } + const mid = tearLayout({ x: 640, y: 500 }, view).void; + expect(mid.x + mid.w / 2).toBeGreaterThan(640); + }); + + it('keeps the classic crossing without a pull', () => { + const run = new CrossingSequence(); + run.start(); + run.update(TIMING.hit); + run.update(TIMING.freeze); + run.update(TIMING.dialogGuard); + run.confirm(); + expect(run.phase).toBe('cut'); + }); +}); diff --git a/game/src/story/crossingSequence.ts b/game/src/story/crossingSequence.ts index 19a2a2b..f4af92e 100644 --- a/game/src/story/crossingSequence.ts +++ b/game/src/story/crossingSequence.ts @@ -3,24 +3,58 @@ * freezes and cracks, Windows says "THD.exe has stopped responding." – OK – cut – the hero lands somewhere * else and the damage animation finishes there. One sequence, reused by the pond (M3) and Jenny's ram (M4). */ -export type CrossingPhase = 'idle' | 'hit' | 'freeze' | 'dialog' | 'cut' | 'land' | 'done'; +export type CrossingPhase = 'idle' | 'hit' | 'freeze' | 'dialog' | 'pull' | 'cut' | 'land' | 'done'; -export const TIMING = { hit: 280, freeze: 220, dialogGuard: 300, cut: 200 } as const; +export interface CrossingTiming { hit: number; freeze: number; dialogGuard: number; pull: number; cut: number } + +/** The classic crossing: no pull, OK cuts straight to black. */ +export const TIMING: CrossingTiming = { hit: 280, freeze: 220, dialogGuard: 300, pull: 0, cut: 200 }; + +/** + * The prologue's 404 (docs/03 "the 404" → "the pull"): a short flash, the frame tears for longer, and after + * OK the hero is pulled into the 404 void before the cut. + */ +export const TIMING_404: CrossingTiming = { hit: 160, freeze: 1100, dialogGuard: 450, pull: 1700, cut: 450 }; + +export interface Area { x: number; y: number; w: number; h: number } + +/** Where the 404 tears the frame, around the hero's view position: the void opens in the water just past him. */ +export function tearLayout(hero: { x: number; y: number }, view: { w: number; h: number }): { + void: Area; cuts: { area: Area; shift: number; copies: number }[]; stretch: Area; +} { + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + const vw = 220; + const vh = 140; + const voidArea = { x: clamp(hero.x + 30 - vw / 2, 16, view.w - vw - 16), y: clamp(hero.y - 20, 16, view.h - vh - 16), w: vw, h: vh }; + const band = (y: number, h: number) => ({ x: 0, y: clamp(Math.round(y), 0, view.h - h), w: view.w, h }); + const sx = clamp(hero.x - 260, 0, view.w - 140); + return { + void: voidArea, + cuts: [ + { area: band(hero.y - 96, 46), shift: 56, copies: 2 }, + { area: band(hero.y + 150, 30), shift: -72, copies: 3 }, + { area: band(hero.y - 230, 22), shift: 34, copies: 2 }, + ], + stretch: { x: sx, y: clamp(hero.y - 300, 0, view.h - 60), w: 140, h: clamp(260, 60, view.h - clamp(hero.y - 300, 0, view.h - 60)) }, + }; +} export class CrossingSequence { phase: CrossingPhase = 'idle'; private elapsed = 0; - constructor(private readonly onLand: () => void = () => {}) {} + constructor(private readonly onLand: () => void = () => {}, readonly timing: CrossingTiming = TIMING) {} start(): void { this.enter('hit'); } /** Advance by dt milliseconds; returns the phase after the step. */ update(dt: number): CrossingPhase { this.elapsed += dt; - if (this.phase === 'hit' && this.elapsed >= TIMING.hit) this.enter('freeze'); - else if (this.phase === 'freeze' && this.elapsed >= TIMING.freeze) this.enter('dialog'); - else if (this.phase === 'cut' && this.elapsed >= TIMING.cut) { + const timing = this.timing; + if (this.phase === 'hit' && this.elapsed >= timing.hit) this.enter('freeze'); + else if (this.phase === 'freeze' && this.elapsed >= timing.freeze) this.enter('dialog'); + else if (this.phase === 'pull' && this.elapsed >= timing.pull) this.enter('cut'); + else if (this.phase === 'cut' && this.elapsed >= timing.cut) { this.enter('land'); this.onLand(); this.enter('done'); @@ -30,11 +64,19 @@ export class CrossingSequence { /** The player pressed OK. Ignored until the dialog has been up long enough to be read. */ confirm(): boolean { - if (this.phase !== 'dialog' || this.elapsed < TIMING.dialogGuard) return false; - this.enter('cut'); + if (this.phase !== 'dialog' || this.elapsed < this.timing.dialogGuard) return false; + this.enter(this.timing.pull > 0 ? 'pull' : 'cut'); return true; } + /** 0 → 1 through the pull (1 outside it). */ + pullProgress(): number { + return this.phase === 'pull' && this.timing.pull > 0 ? Math.min(1, this.elapsed / this.timing.pull) : 1; + } + + /** Milliseconds spent in the current phase. */ + get time(): number { return this.elapsed; } + private enter(phase: CrossingPhase): void { this.phase = phase; this.elapsed = 0; diff --git a/game/src/story/prologueFinale.test.ts b/game/src/story/prologueFinale.test.ts new file mode 100644 index 0000000..eb726fb --- /dev/null +++ b/game/src/story/prologueFinale.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { FINALE, FinaleSequence, knockArc, pullCurve } from './prologueFinale'; + +describe('the pond finale', () => { + it('runs reveal, step, shove and fall once, then hands over to the crash', () => { + let crashes = 0; + const run = new FinaleSequence(() => crashes++); + expect(run.phase).toBe('idle'); + expect(run.start()).toBe(true); + expect(run.start()).toBe(false); + expect(run.update(FINALE.reveal - 1)).toBe('reveal'); + expect(run.progress()).toBeGreaterThan(0.99); + expect(run.update(1)).toBe('step'); + expect(run.update(FINALE.step)).toBe('shove'); + expect(run.update(FINALE.shove)).toBe('fall'); + expect(crashes).toBe(0); + expect(run.update(FINALE.fall)).toBe('crash'); + run.update(10_000); + expect(crashes).toBe(1); + }); + + it('the shove contact lands inside the 4-frame clip', () => { + expect(FINALE.contactFrame).toBeLessThan(4); + expect((FINALE.contactFrame * 1000) / 12).toBeLessThan(FINALE.shove); + }); +}); + +describe('the knock and the pull', () => { + it('knocks the hero out over the water on an arc', () => { + const from = { x: 100, y: 441 }; + expect(knockArc(from, 0)).toEqual({ x: 100, y: 441, angle: 0 }); + expect(knockArc(from, 0.5).y).toBeLessThan(from.y); + expect(knockArc(from, 1).x).toBe(100 + FINALE.knockX); + }); + + it('starts slow and swallows the hero completely', () => { + expect(pullCurve(0)).toEqual({ scale: 1, spin: 0, reach: 0 }); + expect(pullCurve(0.5).reach).toBeLessThan(0.5); + expect(pullCurve(1).scale).toBe(0); + expect(pullCurve(2).reach).toBe(1); + }); +}); diff --git a/game/src/story/prologueFinale.ts b/game/src/story/prologueFinale.ts new file mode 100644 index 0000000..80ce781 --- /dev/null +++ b/game/src/story/prologueFinale.ts @@ -0,0 +1,75 @@ +/** + * The pond finale (docs/03 prologue, beats "something wrong" → "the shove", #50 #51): the hero reaches the + * jetty edge with Dexter at the jetty; control is taken, the reflection goes wrong while Dexter points at it, + * he steps up behind the hero and shoves – the hero is knocked out over the water, and the 404 crossing takes + * over (Crossing, variant '404'). Pure timing, tested without Phaser. + */ +export type FinalePhase = 'idle' | 'reveal' | 'step' | 'shove' | 'fall' | 'crash'; + +const TIMED = ['reveal', 'step', 'shove', 'fall'] as const; + +/** Milliseconds per phase; the shove clip is dexter `shove`, 4 frames at 12 fps, plus a held beat. */ +export const FINALE = { + reveal: 2200, + step: 650, + shove: 480, + fall: 420, + /** The shove clip frame on which his paws connect and the hero is knocked away. */ + contactFrame: 2, + /** How far the knock carries the hero out over the water (px) and how high it lifts him. */ + knockX: 64, + knockLift: 34, + /** Dexter stands this far behind the hero to shove (px). */ + behind: 20, +} as const; + +export class FinaleSequence { + phase: FinalePhase = 'idle'; + private elapsed = 0; + + constructor(private readonly onCrash: () => void = () => {}) {} + + start(): boolean { + if (this.phase !== 'idle') return false; + this.enter('reveal'); + return true; + } + + update(dt: number): FinalePhase { + this.elapsed += dt; + const i = TIMED.indexOf(this.phase as (typeof TIMED)[number]); + if (i >= 0 && this.elapsed >= FINALE[TIMED[i]]) { + const last = i === TIMED.length - 1; + this.enter(last ? 'crash' : TIMED[i + 1]); + if (last) this.onCrash(); + } + return this.phase; + } + + /** 0 → 1 through the current timed phase. */ + progress(): number { + const i = TIMED.indexOf(this.phase as (typeof TIMED)[number]); + return i >= 0 ? Math.min(1, this.elapsed / FINALE[TIMED[i]]) : 1; + } + + private enter(phase: FinalePhase): void { + this.phase = phase; + this.elapsed = 0; + } +} + +/** Where the knocked hero is `t` (0 → 1) through the fall: out over the water on an arc. */ +export function knockArc(from: { x: number; y: number }, t: number): { x: number; y: number; angle: number } { + const e = Math.min(1, Math.max(0, t)); + return { + x: from.x + FINALE.knockX * e, + y: from.y - FINALE.knockLift * 4 * e * (1 - e) + 10 * e * e, + angle: 40 * e, + }; +} + +/** The pull into the void after OK: eased in, so it starts slow and swallows fast. */ +export function pullCurve(t: number): { scale: number; spin: number; reach: number } { + const e = Math.min(1, Math.max(0, t)) ** 2.2; + return { scale: 1 - e, spin: e * Math.PI * 5, reach: e }; +} diff --git a/game/src/story/prologueGuide.test.ts b/game/src/story/prologueGuide.test.ts new file mode 100644 index 0000000..f40d20a --- /dev/null +++ b/game/src/story/prologueGuide.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { RUN_SPEED, WALK_SPEED } from '../player/movement'; +import { DexterGuide, GUIDE, PLUSH, Route, stopsFrom, type GuideView, type Pt } from './prologueGuide'; + +interface Chunk { chunk: string; walk: Pt[][]; oneWay: Pt[][]; interact: { id: string; kind: string; act?: string; x: number; y: number; w: number; h: number }[]; spawn: { id: string; kind: string; x: number; y: number }[] } +const files = import.meta.glob('../../../art/stages/hidden-garden/hg-*.clean.collision.json', { eager: true, import: 'default' }) as Record; +/** The five chunks in a row, 2160 px apart (docs/04), placed in stage px like placeCollision does. */ +const chunks = Object.values(files).sort((a, b) => a.chunk.localeCompare(b.chunk)); +const offset = (c: Chunk) => Number(c.chunk.split('-')[1]) * 2160; +const lines = chunks.flatMap(c => [...c.walk, ...c.oneWay].map(line => line.map(([x, y]): Pt => [x + offset(c), y]))); +const boxes = chunks.flatMap(c => c.interact.map(box => ({ ...box, x: box.x + offset(c) }))); +const spawns = chunks.flatMap(c => c.spawn.map(s => ({ ...s, x: s.x + offset(c) }))); +const bench = spawns.find(s => s.id === 'dexter-bench')!; +const start = spawns.find(s => s.kind === 'player')!; +const { wakeX, stops } = stopsFrom(boxes); +const pond = stops[stops.length - 1]; +const garden = () => new DexterGuide({ bench, lines, ...stopsFrom(boxes) }); +const DT = 1000 / 60; + +/** + * Plays the hero from the gate to the jetty with `speedAt(t)` px/s (accelerating like player/movement.ts) and + * returns the smallest lead Dexter kept while leading, and the log every `every` ms. + */ +function chase(speedAt: (t: number) => number, every = 1000) { + const dexter = garden(); + const route = new Route(lines); + const hero = { x: start.x, y: start.y }; + let vx = 0; + let minGap = Infinity; + let overtaken = 0; + const log: { t: number; hero: number; dexter: number; gap: number; state: string; anim: string }[] = []; + const anims = new Set(); + let view!: GuideView; + let t = 0; + for (; t < 300_000 && dexter.state !== 'at-pond'; t += DT) { + vx = Math.min(speedAt(t), vx + 1500 * DT / 1000); + if (speedAt(t) < vx) vx = Math.max(speedAt(t), vx - 2000 * DT / 1000); + hero.x = Math.min(hero.x + (vx * DT) / 1000, pond.x + 200); + hero.y = route.groundAt(hero.x, hero.y) ?? hero.y; + view = dexter.update(DT, hero); + anims.add(view.anim); + const state: string = view.state; + if (state !== 'sit' && state !== 'at-pond') minGap = Math.min(minGap, dexter.gap(hero)); + if (state !== 'at-pond' && dexter.gap(hero) <= 0) overtaken++; + if (Math.floor(t / every) !== Math.floor((t + DT) / every)) { + log.push({ t: Math.round(t / 100) / 10, hero: Math.round(hero.x), dexter: Math.round(dexter.x), gap: Math.round(dexter.gap(hero)), state: dexter.state, anim: view.anim }); + } + } + return { dexter, minGap, overtaken, log, anims, t }; +} + +describe('the route', () => { + const route = new Route([[[0, 100], [100, 100]], [[50, 200], [150, 200]]]); + it('keeps to the line nearest his height where lines overlap', () => { + expect(route.groundAt(75, 110)).toBe(100); + expect(route.groundAt(75, 190)).toBe(200); + expect(route.groundAt(200, 100)).toBeUndefined(); + }); + it('finds the next ground across a gap', () => { + const gap = new Route([[[0, 100], [100, 100]], [[180, 100], [300, 100]]]); + expect(gap.landing(100, 1)).toBe(180); + }); +}); + +describe('the garden staging', () => { + it('starts the hero in the open gate, on the path, with Dexter on the bench well ahead', () => { + // The gate in hg-0-0.png: stone pillars at x≈95–130 and 270–310, the open leaves between them. + expect(start.x).toBeGreaterThan(130); + expect(start.x).toBeLessThan(275); + expect(Math.abs(new Route(lines).groundAt(start.x, start.y)! - start.y)).toBeLessThan(3); + expect(start.x).toBeLessThan(wakeX); + expect(bench.x - start.x).toBeGreaterThan(GUIDE.pauseAt); + }); + + it('wakes at the bench, points at the shed roof, and ends at the jetty', () => { + expect(wakeX).toBeLessThan(bench.x - GUIDE.hopGap); + expect(stops.map(s => s.act)).toEqual(['wait', 'wait', 'point', 'wait', 'wait', 'wait', 'pond']); + expect(stops.every((s, i) => i === 0 || s.x > stops[i - 1].x)).toBe(true); + }); + + it('derives its distances from the 27 px plush and 43 px/m', () => { + expect(GUIDE.lead).toBe(4 * PLUSH); + expect(GUIDE.walk).toBe(WALK_SPEED); + expect(GUIDE.sprint).toBeGreaterThan(RUN_SPEED); + }); +}); + +describe('Dexter waking on the bench', () => { + it('sits facing the gate until the hero walks up, looks round, bows, hops off and beckons', () => { + const dexter = garden(); + let view = dexter.update(DT, start); + expect(view).toMatchObject({ state: 'sit', anim: 'sit', facing: -1, x: bench.x, y: bench.y }); + const hero = { x: wakeX + 1, y: 470 }; + view = dexter.update(DT, hero); + expect(view).toMatchObject({ state: 'wake', anim: 'look' }); + view = dexter.update(GUIDE.wakeLook, hero); + expect(view.anim).toBe('bow'); + view = dexter.update(GUIDE.bow, hero); + expect(view).toMatchObject({ state: 'hop-off', anim: 'hop' }); + for (let t = 0; t <= GUIDE.hopOff; t += DT) view = dexter.update(DT, hero); + view = dexter.update(DT, hero); + expect(view.state).toBe('wait'); + expect(view.y).toBeGreaterThan(bench.y); + expect(view.x).toBeGreaterThan(bench.x); + view = dexter.update(GUIDE.lookBack + GUIDE.minWait, hero); + expect(view.anim).toBe('beckon'); + expect(view.facing).toBe(-1); + // The hero comes closer: he turns and leads. + for (let t = 0; t < 4000 && dexter.state === 'wait'; t += DT) dexter.update(DT, { x: (hero.x += 1.6), y: 470 }); + expect(dexter.state).toBe('lead'); + expect(dexter.gap(hero)).toBeGreaterThan(GUIDE.hopGap - 5); + }); + + it('skips the bow and hops off at once when the hero is already at the bench', () => { + const dexter = garden(); + dexter.update(DT, { x: wakeX + 1, y: 470 }); + const view = dexter.update(DT, { x: bench.x - GUIDE.hopGap + 10, y: 470 }); + expect(view.anim).toBe('hop'); + }); +}); + +describe('Dexter leading', () => { + it('is never overtaken by a walking hero, and reaches the jetty first', () => { + const run = chase(() => WALK_SPEED); + expect(run.dexter.state).toBe('at-pond'); + expect(run.overtaken).toBe(0); + expect(run.minGap).toBeGreaterThan(PLUSH); + }); + + it('is never overtaken by a running hero: he hops ahead and scampers', () => { + const run = chase(() => RUN_SPEED); + expect(run.dexter.state).toBe('at-pond'); + expect(run.overtaken).toBe(0); + expect(run.minGap).toBeGreaterThan(PLUSH / 2); + expect(run.anims.has('hop')).toBe(true); + expect(run.anims.has('run')).toBe(true); + }); + + it('is never overtaken by a hero who sprints, stops and sprints again', () => { + const run = chase(t => (Math.floor(t / 2500) % 2 === 0 ? RUN_SPEED : t % 5000 < 3500 ? 0 : WALK_SPEED)); + expect(run.dexter.state).toBe('at-pond'); + expect(run.overtaken).toBe(0); + expect(run.minGap).toBeGreaterThan(PLUSH / 2); + }); + + it('holds about the lead distance at the hero\'s own pace', () => { + for (const speed of [WALK_SPEED, RUN_SPEED]) { + const run = chase(() => speed, 250); + const leading = run.log.filter(l => l.state === 'lead' && l.t > 10); + const mean = leading.reduce((s, l) => s + l.gap, 0) / leading.length; + expect(mean).toBeGreaterThan(GUIDE.hopGap); + expect(mean).toBeLessThan(GUIDE.pauseAt); + } + }); + + it('hops ahead when the hero closes in on him', () => { + const dexter = garden(); + dexter.placeAt(1); + const hero = { x: dexter.x - GUIDE.far + 10, y: 390 }; + let hopped = false; + for (let t = 0; t < 6000 && !hopped; t += DT) { + hero.x += (WALK_SPEED * DT) / 1000; + hopped = dexter.update(DT, hero).anim === 'hop'; + } + // He springs off ahead with a hop as soon as the hero is back within the lead band. + expect(hopped).toBe(true); + expect(dexter.gap(hero)).toBeLessThan(GUIDE.pauseResume + 2); + expect(dexter.gap(hero)).toBeGreaterThan(GUIDE.hopGap / 2); + }); + + it('waits and looks back only when the hero is far behind; beckons, then sweats', () => { + const dexter = garden(); + dexter.placeAt(1); + const hero = { x: dexter.x - GUIDE.lead, y: 390 }; + // The hero just stands: he leads on at an amble until the hero is far behind, then stops. + const views: GuideView[] = []; + for (let t = 0; t < 20_000 && (views.length < 60 || dexter.state !== 'wait'); t += DT) views.push(dexter.update(DT, hero)); + for (let t = 0; t < 400; t += DT) views.push(dexter.update(DT, hero)); + expect(dexter.state).toBe('wait'); + expect(dexter.gap(hero)).toBeGreaterThan(GUIDE.far); + expect(views.some(v => v.anim === 'walk')).toBe(true); + const anims = new Set(); + for (let t = 0; t < 12_000; t += DT) anims.add(dexter.update(DT, hero).anim); + expect(anims.has('beckon')).toBe(true); + expect(anims.has('sweat')).toBe(true); + expect(dexter.state).toBe('wait'); + // Very far: he sweats. + expect(dexter.update(DT, { x: dexter.x - GUIDE.veryFar - 10, y: 390 }).anim).toBe('sweat'); + }); + + it('passes a waypoint without stopping when the hero is on his heels', () => { + const run = chase(() => WALK_SPEED, 100); + const waits = run.log.filter(l => l.state === 'wait' && l.t > 12); + expect(waits.length).toBe(0); + }); + + it('points up at the shed roof when the hero is a little behind', () => { + const dexter = garden(); + const point = stops.findIndex(s => s.act === 'point'); + dexter.placeAt(point - 1); + const hero = { x: dexter.x - GUIDE.lead, y: 387 }; + let pointed = false; + for (let t = 0; t < 120_000 && !pointed; t += DT) { + if (dexter.gap(hero) > GUIDE.lead + 20) hero.x += (WALK_SPEED * DT) / 1000; + pointed = dexter.update(DT, hero).state === 'point'; + } + expect(pointed).toBe(true); + }); + + it('lands with a squash after a hop', () => { + const dexter = garden(); + dexter.placeAt(1); + const hero = { x: dexter.x - GUIDE.hopGap + 5, y: 390 }; + dexter.update(DT, hero); + let view = dexter.update(DT, hero); + while (dexter.airborne) view = dexter.update(DT, hero); + expect(view.squash).toBeGreaterThan(0.5); + }); + + it('can be put at the jetty for the finale', () => { + const dexter = garden(); + dexter.placeAt(Number.MAX_SAFE_INTEGER); + expect(dexter.state).toBe('at-pond'); + expect(dexter.x).toBe(pond.x); + }); +}); + diff --git a/game/src/story/prologueGuide.ts b/game/src/story/prologueGuide.ts new file mode 100644 index 0000000..648da0a --- /dev/null +++ b/game/src/story/prologueGuide.ts @@ -0,0 +1,381 @@ +import { RUN_SPEED, WALK_SPEED } from '../player/movement'; + +/** + * Dexter leading the first quest (docs/03 "Prologue", #50 #160): he sits on the bench, looks up and bows when + * the hero walks in through the gate, hops off, and then LEADS – he keeps a few plush-lengths ahead of the hero + * at the hero's own pace, hops ahead when the hero closes in and scampers when the hero runs at him, so he is + * never overtaken. Only when the hero falls far behind (or dawdles at a waypoint) does he stop, look back, + * beckon, and – left long enough – sweat. He points up at the shed roof on the way and ends at the jetty. + * Pure – positions in stage px, time in ms – so it is tested without Phaser. The route is read from the walk + * lines at runtime (they may be re-authored); every distance below is in plush-lengths or metres. + */ +export type Pt = [number, number]; +export interface Position { x: number; y: number } + +export type GuideState = 'sit' | 'wake' | 'hop-off' | 'lead' | 'wait' | 'point' | 'at-pond'; +/** `look` back on stopping, then `wait` with `beckon`/`sweat` fidgets; `run` is the walk clip played fast. */ +export type GuideAnim = 'sit' | 'look' | 'bow' | 'wait' | 'hop' | 'walk' | 'run' | 'point' | 'beckon' | 'sweat'; + +/** A stop along the route, from a `dexter-waypoint` interact box: x is the box centre. */ +export interface Stop { id: string; x: number; act: 'wait' | 'point' | 'pond' } + +export interface GuideSetup { + /** Where he sits (the `dexter-bench` spawn, his feet). */ + bench: Position; + /** The hero walking past this x wakes him (the left edge of the `wake` waypoint box). */ + wakeX: number; + stops: Stop[]; + /** walk + oneWay polylines in stage px. */ + lines: Pt[][]; +} + +/** The stage's scale (docs/09: the 76 px hero is 1.83 m) and Dexter's height in it (docs/03: a 27 px plush). */ +export const PX_PER_M = 43; +export const PLUSH = 27; +/** His hop clip: dexter `hop`, 4 frames at 12 fps. */ +const HOP_CLIP = (4 / 12) * 1000; + +export const GUIDE = { + /** He keeps this far ahead of the hero (px): four plush-lengths, ~2.5 m. */ + lead: 4 * PLUSH, + /** The hero closing within this (three plush-lengths) makes him hop ahead. */ + hopGap: 3 * PLUSH, + /** Inside two plush-lengths he drops everything and scampers at full speed. */ + scamper: 2 * PLUSH, + /** He stops to look back when the hero is this far behind (8 m)… */ + far: 8 * PX_PER_M, + /** …and leads on once the hero is back within 6 m. */ + resume: 6 * PX_PER_M, + /** At a `wait` waypoint he pauses only if the hero is more than 5 m behind… */ + pauseAt: 5 * PX_PER_M, + /** …and goes on when the hero is within six plush-lengths. */ + pauseResume: 6 * PLUSH, + /** Beyond 14 m he sweats. */ + veryFar: 14 * PX_PER_M, + /** The hero's speeds (player/movement.ts): he matches them. */ + walk: WALK_SPEED, + run: RUN_SPEED, + /** A slow amble (1 m/s) when the hero dawdles inside the lead distance band. */ + amble: 1 * PX_PER_M, + /** His plush sprint: a third faster than the hero's run, so a running hero can never close on him. */ + sprint: RUN_SPEED * 1.35, + /** Speed per px of gap error (1/s): pulling ahead when too close, easing off when too far. */ + gainIn: 4, + gainOut: 1, + /** Acceleration (px/s²) easing into a walk; when the hero is on his heels he takes off at once. */ + accel: 700, + burst: 3000, + /** A hop ahead: his hop clip's length, at sprint speed; then a short cooldown before the next. */ + hopTime: HOP_CLIP, + hopCooldown: 180, + hopLift: 0.6 * PLUSH, + /** Off the bench: two plush-lengths forward, a little slower than a hop. */ + hopOffDistance: 2 * PLUSH, + hopOff: HOP_CLIP * 1.4, + /** The wake beat: he looks round (look-back clip, 3 frames at 12 fps), then bows. */ + wakeLook: 250, + bow: 650, + /** The look-back on stopping (ms), and the shortest stop. */ + lookBack: 250, + minWait: 400, + /** While waiting he beckons for `beckonFor` ms every `beckonEvery` ms; after `sweatAfter` ms he sweats too. */ + beckonEvery: 2400, + beckonFor: 750, + sweatAfter: 4500, + pointFor: 1700, + /** Look-ahead for steps and gaps (px), and the rise or drop that makes him hop instead of walk. */ + lookAhead: 20, + stepRise: 12, + /** The squash after a landing (ms). */ + squash: 140, +} as const; + +/** Ground lookup over the walk lines; where lines overlap, the one nearest the current height wins. */ +export class Route { + constructor(private readonly lines: Pt[][]) {} + + groundAt(x: number, near: number): number | undefined { + let best: number | undefined; + for (const line of this.lines) { + for (let i = 1; i < line.length; i++) { + const [a, b] = [line[i - 1], line[i]]; + if (x < a[0] || x > b[0]) continue; + const y = a[1] + ((x - a[0]) / (b[0] - a[0])) * (b[1] - a[1]); + if (best === undefined || Math.abs(y - near) < Math.abs(best - near)) best = y; + break; + } + } + return best; + } + + /** First x after `from` (moving `dir`) that has ground, scanning up to `range` px. */ + landing(from: number, dir: number, range = 400): number | undefined { + for (let d = 4; d <= range; d += 4) if (this.groundAt(from + dir * d, 0) !== undefined) return from + dir * d; + return undefined; + } +} + +interface Hop { from: Position; to: Position; t: number; duration: number; height: number } + +export interface GuideView { + x: number; y: number; anim: GuideAnim; facing: 1 | -1; state: GuideState; + /** Animation speed (1 = the clip's own fps): the walk plays faster as he speeds up. */ + rate: number; + /** 0..1 squash of a fresh landing. */ + squash: number; +} + +export class DexterGuide { + state: GuideState = 'sit'; + x: number; + y: number; + facing: 1 | -1 = 1; + /** Index of the stop he is heading to or waiting at. */ + next = 0; + /** His ground speed (px/s) while leading. */ + v = 0; + /** The hero's smoothed forward speed (px/s). */ + heroV = 0; + private hop?: Hop; + private elapsed = 0; + private clock = 0; + private landedAt = -Infinity; + private hopReadyAt = 0; + private resumeAt: number = GUIDE.resume; + private lastHeroX?: number; + readonly route: Route; + + constructor(private readonly setup: GuideSetup) { + this.x = setup.bench.x; + this.y = setup.bench.y; + this.route = new Route(setup.lines); + } + + get stop(): Stop | undefined { return this.setup.stops[this.next]; } + get airborne(): boolean { return !!this.hop; } + /** How far ahead of the hero he is (px); negative would mean overtaken. */ + gap(hero: Position): number { return this.x - hero.x; } + + /** Advance by dt ms with the hero's feet at `hero`. */ + update(dt: number, hero: Position): GuideView { + this.elapsed += dt; + this.clock += dt; + this.trackHero(dt, hero); + if (this.hop && this.stepHop(dt)) return this.view(hero); + const gap = this.gap(hero); + switch (this.state) { + case 'sit': + this.facing = hero.x < this.x ? -1 : 1; + if (hero.x >= this.setup.wakeX) this.enter('wake'); + break; + case 'wake': + this.facing = hero.x < this.x ? -1 : 1; + // He looks round, bows – unless the hero is already at the bench, then he is off at once. + if (this.elapsed >= GUIDE.wakeLook + GUIDE.bow || gap < GUIDE.hopGap) this.hopOff(); + break; + case 'hop-off': + // Landed: wait for the hero, beckoning, then lead. + this.waitFor(GUIDE.pauseResume); + break; + case 'wait': + this.facing = hero.x < this.x ? -1 : 1; + // The hero comes on: he springs off ahead with a hop (at once if the hero is already on him). + if (gap < GUIDE.hopGap) this.leadOn(true); + else if (this.elapsed >= GUIDE.minWait && gap < this.resumeAt) this.leadOn(this.heroV > GUIDE.walk * 0.5); + break; + case 'point': + this.facing = 1; + if (gap < GUIDE.hopGap) this.leadOn(true); + else if (this.elapsed >= GUIDE.pointFor) this.waitFor(GUIDE.pauseResume); + break; + case 'lead': + this.lead(dt, hero); + break; + case 'at-pond': + this.facing = 1; + break; + } + return this.view(hero); + } + + /** Put him straight at a stop (dev shortcut, and the finale's start position). */ + placeAt(index: number): void { + this.next = Math.min(index, this.setup.stops.length - 1); + const stop = this.setup.stops[this.next]; + this.x = stop.x; + this.y = this.route.groundAt(stop.x, this.y) ?? this.y; + this.hop = undefined; + this.v = 0; + if (stop.act === 'pond') return this.enter('at-pond'); + this.next++; + this.waitFor(GUIDE.pauseResume); + } + + private trackHero(dt: number, hero: Position): void { + if (this.lastHeroX !== undefined && dt > 0) { + const v = Math.max(0, ((hero.x - this.lastHeroX) * 1000) / dt); + this.heroV += (Math.min(v, GUIDE.sprint) - this.heroV) * Math.min(1, dt / 60); + } + this.lastHeroX = hero.x; + } + + private hopOff(): void { + const toX = this.x + GUIDE.hopOffDistance; + const toY = this.route.groundAt(toX, this.y + PLUSH) ?? this.y; + this.facing = 1; + this.enter('hop-off'); + this.hop = { from: { x: this.x, y: this.y }, to: { x: toX, y: toY }, t: 0, duration: GUIDE.hopOff, height: GUIDE.hopLift + 6 }; + } + + private waitFor(resumeAt: number): void { + this.v = 0; + this.resumeAt = resumeAt; + this.enter('wait'); + } + + private leadOn(hopNow: boolean): void { + this.enter('lead'); + this.facing = 1; + if (hopNow) this.hopAhead(); + } + + private lead(dt: number, hero: Position): void { + const stop = this.stop; + if (!stop) return this.enter('at-pond'); + this.facing = 1; + const gap = this.gap(hero); + if (gap > GUIDE.far) return this.waitFor(GUIDE.resume); + if (gap < GUIDE.hopGap && this.clock >= this.hopReadyAt && this.heroV > GUIDE.walk * 0.5 && this.hopAhead()) return; + // Hold the lead distance at the hero's own pace; inside it, pull ahead (up to a sprint), beyond it, ease off. + const target = gap < GUIDE.lead + ? Math.min(GUIDE.sprint, Math.max(this.heroV, GUIDE.walk) + GUIDE.gainIn * (GUIDE.lead - gap)) + : Math.min(GUIDE.run, Math.max(GUIDE.amble, this.heroV - GUIDE.gainOut * (gap - GUIDE.lead))); + if (gap < GUIDE.scamper) this.v = GUIDE.sprint; + else { + const rate = target > this.v && gap < GUIDE.lead ? GUIDE.burst : GUIDE.accel; + this.v = approach(this.v, target, (rate * dt) / 1000); + } + const step = Math.min((this.v * dt) / 1000, this.end() - this.x); + if (this.terrainHop()) return; + this.x += step; + this.y = this.route.groundAt(this.x, this.y) ?? this.y; + this.passStops(hero); + } + + /** A gap or a step up or down just ahead: hop to the next ground, never slower than the hero. */ + private terrainHop(): boolean { + const aheadX = this.x + GUIDE.lookAhead; + const ahead = this.route.groundAt(aheadX, this.y); + if (ahead !== undefined && Math.abs(this.y - ahead) <= GUIDE.stepRise) return false; + const duration = GUIDE.hopTime * 0.75; + const reach = (this.hopSpeed() * duration) / 1000; + let toX = this.x + Math.max(GUIDE.lookAhead + 16, reach); + let toY = this.route.groundAt(toX, ahead ?? this.y); + if (toY === undefined) { + const land = this.route.landing(this.x + GUIDE.lookAhead, 1); + if (land === undefined) return false; + toX = Math.max(land + 6, toX); + toY = this.route.groundAt(toX, ahead ?? this.y) ?? this.route.groundAt(land + 6, this.y) ?? this.y; + } + toX = Math.min(toX, this.end()); + this.jump({ x: toX, y: toY }, Math.max(duration, ((toX - this.x) / this.hopSpeed()) * 1000), GUIDE.hopLift + Math.max(0, this.y - toY)); + return true; + } + + /** Hops cover ground at least at his sprint, and faster than the hero is going. */ + private hopSpeed(): number { return Math.max(GUIDE.sprint, this.heroV * 1.3, this.v); } + private end(): number { return this.setup.stops[this.setup.stops.length - 1].x; } + + /** The hero closed in: a hop ahead along the route at sprint speed (one hop clip long). */ + private hopAhead(): boolean { + const distance = Math.min(this.end() - this.x, (this.hopSpeed() * GUIDE.hopTime) / 1000); + if (distance < PLUSH) return false; + let toX = this.x + distance; + let toY = this.route.groundAt(toX, this.y); + if (toY === undefined) { + const land = this.route.landing(toX - 4, 1); + if (land === undefined) return false; + toX = land + 6; + toY = this.route.groundAt(toX, this.y) ?? this.y; + } + this.v = Math.max(this.v, this.heroV, GUIDE.walk); + this.jump({ x: toX, y: toY }, GUIDE.hopTime, GUIDE.hopLift + Math.max(0, this.y - toY)); + return true; + } + + private jump(to: Position, duration: number, height: number): void { + this.hop = { from: { x: this.x, y: this.y }, to, t: 0, duration, height }; + this.hopReadyAt = this.clock + duration + GUIDE.hopCooldown; + } + + /** Any stop he has now reached: a pause if the hero lags, the point at the shed roof, the pond at the end. */ + private passStops(hero: Position): void { + const gap = this.gap(hero); + while (this.stop && this.x >= this.stop.x - 0.5) { + const stop = this.stop; + if (stop.act === 'pond') { this.x = stop.x; this.y = this.route.groundAt(this.x, this.y) ?? this.y; this.v = 0; return this.enter('at-pond'); } + this.next++; + if (stop.act === 'point' && gap > GUIDE.hopGap) { this.v = 0; return this.enter('point'); } + // He pauses only for a hero lagging behind, not one already coming on. + if (stop.act === 'wait' && gap > GUIDE.pauseAt && this.heroV < GUIDE.walk * 0.5) return this.waitFor(GUIDE.pauseResume); + } + } + + /** Returns true while still in the air. */ + private stepHop(dt: number): boolean { + const hop = this.hop!; + hop.t = Math.min(1, hop.t + dt / hop.duration); + const t = hop.t; + this.x = hop.from.x + (hop.to.x - hop.from.x) * t; + this.y = hop.from.y + (hop.to.y - hop.from.y) * t - hop.height * 4 * t * (1 - t); + if (t < 1) return true; + this.hop = undefined; + this.y = hop.to.y; + this.landedAt = this.clock; + return false; + } + + private enter(state: GuideState): void { + this.state = state; + this.elapsed = 0; + } + + private view(hero: Position): GuideView { + const gap = this.gap(hero); + let anim: GuideAnim = 'wait'; + let rate = 1; + if (this.hop) anim = 'hop'; + else if (this.state === 'sit') anim = 'sit'; + else if (this.state === 'wake') anim = this.elapsed < GUIDE.wakeLook ? 'look' : 'bow'; + else if (this.state === 'lead') { + anim = this.v > GUIDE.walk * 1.15 ? 'run' : this.v > 4 ? 'walk' : 'wait'; + // The walk clip (6 frames at 12 fps) suits ~1.5 m/s; it speeds up with him, and bounds faster when running. + rate = Math.max(0.6, Math.min(2.4, this.v / GUIDE.walk)); + } else if (this.state === 'point' || this.state === 'at-pond') anim = 'point'; + else if (this.state === 'wait') { + const since = this.elapsed - GUIDE.lookBack; + const cycle = Math.floor(Math.max(0, since) / GUIDE.beckonEvery); + const beckoning = since >= 0 && since % GUIDE.beckonEvery < GUIDE.beckonFor; + if (since < 0) anim = 'look'; + else if (gap > GUIDE.veryFar) anim = 'sweat'; + else if (beckoning && gap > 2 * GUIDE.lead) anim = this.elapsed > GUIDE.sweatAfter && cycle % 2 === 1 ? 'sweat' : 'beckon'; + } + const squash = Math.max(0, 1 - (this.clock - this.landedAt) / GUIDE.squash); + return { x: this.x, y: this.y, anim, facing: this.facing, state: this.state, rate, squash }; + } +} + +const approach = (value: number, target: number, amount: number) => + value < target ? Math.min(target, value + amount) : Math.max(target, value - amount); + +/** The stops from the collision's `dexter-waypoint` boxes, in route order; `act` comes from the data. */ +export function stopsFrom(boxes: { id?: string; kind?: string; act?: unknown; x: number; w: number }[]): { wakeX: number; stops: Stop[] } { + const waypoints = boxes.filter(box => box.kind === 'dexter-waypoint').sort((a, b) => a.x - b.x); + const wake = waypoints.find(box => box.act === 'wake') ?? waypoints[0]; + const stops = waypoints.filter(box => box !== wake).map((box): Stop => ({ + id: box.id ?? 'waypoint', + x: box.x + box.w / 2, + act: box.act === 'point' || box.act === 'pond' ? box.act : 'wait', + })); + return { wakeX: wake ? wake.x : -Infinity, stops }; +} diff --git a/game/src/ui/Hud.ts b/game/src/ui/Hud.ts index 8da349e..624858b 100644 --- a/game/src/ui/Hud.ts +++ b/game/src/ui/Hud.ts @@ -29,10 +29,7 @@ export class Hud extends Phaser.Scene { constructor() { super({ key: 'HUD', active: true }); } - preload(): void { - this.load.atlas(UI_ATLAS, 'assets/ui/ui.png', 'assets/ui/ui.json'); - this.load.json(UI_LAYOUT, 'assets/ui/ui-layout.json'); - } + // The UI atlas is loaded by Boot (boot/manifest.ts), not here: two loaders asking for one texture key race. create(): void { this.graphics = this.add.graphics(); @@ -50,7 +47,9 @@ export class Hud extends Phaser.Scene { if (button.index === MENU_PAD) toggleMap(this.scene); }); // Dev builds: F4 hands over the pocket computer and marks the first rooms visited (#69 verification; F2 is the player's debug readout). - if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F4', () => { + // Shift + F4 is Dexter's coffee-machine visit (#52). + if (import.meta.env.DEV) this.input.keyboard?.on('keydown-F4', (event: KeyboardEvent) => { + if (event.shiftKey) return; pocketMap.unlock(); pocketMap.visit('hidden-garden', 'hg-0-0'); pocketMap.visit('rusty-creek', 'rc-0-0'); @@ -81,6 +80,7 @@ export class Hud extends Phaser.Scene { const now = this.time.now; const boss = this.model.boss; this.bossName.setText(boss ? boss.name.toUpperCase() : ''); + if (!this.art && this.textures.exists(UI_ATLAS)) this.art = this.buildArt(); // Boot's UI atlas arrives after HUD starts if (this.art) return this.updateArt(this.art, now); this.model.cells(now).forEach((state, i) => this.drawCell(g, CELL.x + i * (CELL.w + CELL.gap), CELL.y, state)); if (boss) this.drawBossBar(g, this.model.bossFraction()); @@ -140,3 +140,33 @@ export class Hud extends Phaser.Scene { g.fillStyle(COLOURS.cracked).fillRect(x, y, Math.round(BAR.w * fraction), BAR.h); } } + +/** + * A wordless control hint in the HUD's style (docs/06 restraint, P7): a key cap with J, a pad's X button and a + * swing arc over them, in the battery cells' teal and the cracked-cell amber on their dark edge. No words, so it + * reads in any language. Drawn into `scene` with (x, y) as its bottom centre, so the stage can float it over the + * hero's head. + */ +export function drawControlGlyph(scene: Phaser.Scene, x: number, y: number): Phaser.GameObjects.Container { + const g = scene.add.graphics(); + const label = (cx: number, cy: number, letter: string, color: string) => scene.add.text(cx, cy, letter, + { fontFamily: 'monospace', fontSize: '15px', color, fontStyle: 'bold' }).setOrigin(0.5); + // The key cap: J. + g.fillStyle(COLOURS.edge).fillRoundedRect(-45, -33, 26, 28, 4); + g.fillStyle(COLOURS.empty).fillRoundedRect(-43, -31, 22, 22, 3); + g.lineStyle(2, COLOURS.full).strokeRoundedRect(-43, -31, 22, 22, 3); + // The slash between keyboard and pad, as a pixel diagonal. + g.fillStyle(0xe8e4dc); + for (let i = 0; i < 5; i++) g.fillRect(-6 + i * 2, -12 - i * 4, 2, 4); + // The pad button: X. + g.fillStyle(COLOURS.edge).fillCircle(32, -19, 13); + g.fillStyle(0x2c4fa8).fillCircle(32, -20, 11); + g.lineStyle(2, 0x9ab8ff).strokeCircle(32, -20, 11); + // The swing: an arc with an arrow head over both, outlined so it reads over leaves and girders. + for (const [width, colour] of [[6, COLOURS.edge], [3, COLOURS.cracked]] as const) { + g.lineStyle(width, colour).beginPath().arc(0, -10, 54, Math.PI * 1.15, Math.PI * 1.85).strokePath(); + } + g.fillStyle(COLOURS.edge).fillTriangle(38, -56, 53, -44, 35, -37); + g.fillStyle(COLOURS.cracked).fillTriangle(40, -53, 50, -44, 37, -40); + return scene.add.container(x, y, [g, label(-32, -20, 'J', '#b8f5e6'), label(32, -20, 'X', COLOURS.text)]).setScale(1.5); +} diff --git a/game/src/ui/Title.ts b/game/src/ui/Title.ts index 4d1cb8d..b98bace 100644 --- a/game/src/ui/Title.ts +++ b/game/src/ui/Title.ts @@ -1,76 +1,170 @@ import Phaser from 'phaser'; +import type { StageData } from '../boot/contract'; +import { PALETTE } from '../boot/LoadingView'; +import { MENU_KEY } from '../boot/manifest'; +import { pixelText, registerPixelFont } from '../boot/pixelFont'; import { VIEW_HEIGHT, VIEW_WIDTH } from '../core/viewport'; import { sound } from '../fx/sound/Audio'; +import { gridBounds } from '../stage/camera'; +import { drawStage } from '../stage/render'; import { MENU, moveSelection, SUBTITLE, TITLE } from './credits'; import { creditsText } from './creditsView'; -const C = { bg: 0x101820, text: '#e8e4dc', dim: '#7d8a90', pick: '#46d1b4' }; -const FONT = 'monospace'; -/** Scenes the title holds back until Start: the stage freezes, the HUD hides. */ const STAGE = 'Stage'; const HUD = 'HUD'; +/** Over the UI, which sits over the stage art (depth bands in docs/15). */ +const UI_DEPTH = 10_000; +/** The idle pan across the garden, one way, in ms. */ +const PAN = 48_000; +const FADE_IN = 700; +/** Start: the menu dissolves while the stage starts underneath, then the title's garden dissolves into the stage's. */ +const HAND_OVER = 900; -/** Title and credits over the stage. Up/Down chooses; Act (J), Enter or Space confirms; Esc leaves the credits. */ +/** + * The menu (first load only; Preload starts it with the region's StageData). The game's name over a live view + * of the region's art – the title draws the stage itself and pans slowly across it, or shows the menu key art + * (`assets/ui/menu-key.png`) when workstream 5 has drawn it. Start launches the stage underneath and dissolves + * into it: the title's camera glides onto the stage camera while it fades, so the cut never goes black. + * Up/Down or W/S chooses; Enter, Space or J (pad A/X) confirms; Esc (pad B) leaves the credits. + */ export class Title extends Phaser.Scene { private choice = 0; private showingCredits = false; - private menu: Phaser.GameObjects.Text[] = []; + private leaving = false; + private stageData!: StageData; + private menu: Phaser.GameObjects.BitmapText[] = []; private titleLayer!: Phaser.GameObjects.Container; private creditsLayer!: Phaser.GameObjects.Container; + private keyArt?: Phaser.GameObjects.Image; + private pan?: Phaser.Tweens.Tween; - constructor() { super({ key: 'Title', active: true }); } + constructor() { super('Title'); } - create(): void { - this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, C.bg).setOrigin(0); - const title = this.add.text(VIEW_WIDTH / 2, 230, TITLE, { fontFamily: FONT, fontSize: '64px', color: C.text, fontStyle: 'bold' }).setOrigin(0.5); - const subtitle = this.add.text(VIEW_WIDTH / 2, 290, SUBTITLE, { fontFamily: FONT, fontSize: '20px', color: C.dim }).setOrigin(0.5); - this.menu = MENU.map((item, i) => this.add.text(VIEW_WIDTH / 2, 420 + i * 44, item, { fontFamily: FONT, fontSize: '28px', color: C.text }) - .setOrigin(0.5).setInteractive({ useHandCursor: true }) + create(data: StageData): void { + this.stageData = data; + this.choice = 0; + this.showingCredits = false; + this.leaving = false; + registerPixelFont(this); + if (this.scene.get(HUD)) this.scene.setVisible(false, HUD); + + this.drawGarden(); + this.drawOverlay(); + this.bindInput(); + this.select(0); + this.cameras.main.fadeIn(FADE_IN, 12, 18, 24); + } + + /** The live garden: the region's layers in this scene, with a slow pan; the key art over it when it exists. */ + private drawGarden(): void { + const camera = this.cameras.main; + try { + drawStage(this, this.stageData.manifest, this.stageData.state, this.stageData.atmosphere); + const bounds = gridBounds(this.stageData.manifest); + const reach = Math.min(Math.max(0, bounds.width - VIEW_WIDTH), 1800); + camera.setScroll(bounds.x, Math.max(bounds.y, bounds.y + bounds.height - VIEW_HEIGHT)); + camera.roundPixels = true; + if (reach > 0) this.pan = this.tweens.add({ targets: camera, scrollX: bounds.x + reach, duration: PAN, ease: 'Sine.easeInOut', yoyo: true, repeat: -1 }); + } catch (error) { console.error('[title] no garden to draw', error); } + if (this.textures.exists(MENU_KEY)) { + const source = this.textures.get(MENU_KEY).getSourceImage(); + const scale = Math.max(VIEW_WIDTH / source.width, VIEW_HEIGHT / source.height); + this.keyArt = this.add.image(VIEW_WIDTH / 2, VIEW_HEIGHT / 2, MENU_KEY).setScale(scale).setScrollFactor(0).setDepth(UI_DEPTH - 1); + // The key art and the live garden take turns: the painting dissolves into the panning stage and back. + this.tweens.add({ targets: this.keyArt, alpha: 0, delay: 9000, duration: 2600, hold: 16_000, yoyo: true, + repeat: -1, repeatDelay: 9000, ease: 'Sine.easeInOut' }); + } + } + + private drawOverlay(): void { + // Night bands behind the words, so they read over bright autumn leaves. + const shade = this.add.graphics(); + shade.fillGradientStyle(PALETTE.night, PALETTE.night, PALETTE.night, PALETTE.night, 0.82, 0.82, 0, 0).fillRect(0, 0, VIEW_WIDTH, 300); + shade.fillGradientStyle(PALETTE.night, PALETTE.night, PALETTE.night, PALETTE.night, 0, 0, 0.85, 0.85).fillRect(0, 380, VIEW_WIDTH, VIEW_HEIGHT - 380); + const cx = VIEW_WIDTH / 2; + const title = pixelText(this, cx, 120, TITLE, 10, PALETTE.cream).setOrigin(0.5).setDropShadow(1, 1, PALETTE.ink, 1); + const subtitle = pixelText(this, cx - 12, 206, SUBTITLE, 3, PALETTE.rust).setOrigin(0.5).setDropShadow(1, 1, PALETTE.ink, 1); + const cursor = pixelText(this, subtitle.x + subtitle.displayWidth / 2 + 6, 206, '~', 3, PALETTE.rust).setOrigin(0, 0.5).setDropShadow(1, 1, PALETTE.ink, 1); + this.time.addEvent({ delay: 530, loop: true, callback: () => cursor.setVisible(!cursor.visible) }); + this.menu = MENU.map((item, i) => pixelText(this, cx, 500 + i * 56, item, 4, PALETTE.cream).setOrigin(0.5).setDropShadow(1, 1, PALETTE.ink, 1) + .setInteractive({ useHandCursor: true }) .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, creditsText(this, 'Act / Esc – back')).setVisible(false); + const hint = pixelText(this, cx, VIEW_HEIGHT - 40, 'ARROWS / WASD CHOOSE ENTER / SPACE / J SELECT', 2, PALETTE.cream).setOrigin(0.5).setDropShadow(1, 1, PALETTE.ink, 1); + this.titleLayer = this.add.container(0, 0, [shade, title, subtitle, cursor, ...this.menu, hint]) + .setScrollFactor(0).setDepth(UI_DEPTH); + const veil = this.add.rectangle(0, 0, VIEW_WIDTH, VIEW_HEIGHT, PALETTE.night, 0.88).setOrigin(0); + this.creditsLayer = this.add.container(0, 0, [veil, ...creditsText(this, 'Act / Esc – back')]) + .setScrollFactor(0).setDepth(UI_DEPTH + 1).setVisible(false); + } + + private bindInput(): void { 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))); - keys?.on('keydown-W', () => this.select(moveSelection(this.choice, -1))); - keys?.on('keydown-DOWN', () => this.select(moveSelection(this.choice, 1))); - keys?.on('keydown-S', () => this.select(moveSelection(this.choice, 1))); + keys?.addCapture('UP,DOWN,SPACE'); // these would scroll the page under the canvas + for (const key of ['UP', 'W']) keys?.on(`keydown-${key}`, () => this.select(moveSelection(this.choice, -1))); + for (const key of ['DOWN', 'S']) keys?.on(`keydown-${key}`, () => this.select(moveSelection(this.choice, 1))); for (const key of ['J', 'ENTER', 'SPACE']) keys?.on(`keydown-${key}`, () => this.confirm()); keys?.on('keydown-ESC', () => this.showCredits(false)); this.input.gamepad?.on('down', (_pad: Phaser.Input.Gamepad.Gamepad, button: Phaser.Input.Gamepad.Button) => { if (button.index === 12) this.select(moveSelection(this.choice, -1)); else if (button.index === 13) this.select(moveSelection(this.choice, 1)); - else if (button.index === 0 || button.index === 2) this.confirm(); + else if (button.index === 0 || button.index === 2 || button.index === 9) this.confirm(); else if (button.index === 1) this.showCredits(false); }); - this.select(0); - } - - update(): void { - // Hold the game back while the title is up: the stage starts after preload, so pause it once it runs. - if (this.scene.isActive(STAGE)) this.scene.pause(STAGE); - if (this.scene.isVisible(HUD)) this.scene.setVisible(false, HUD); - this.scene.bringToTop(); + // Left stick: one step per push. + let held = 0; + this.events.on(Phaser.Scenes.Events.UPDATE, () => { + const y = this.input.gamepad?.pad1?.leftStick.y ?? 0; + const dir = y < -0.5 ? -1 : y > 0.5 ? 1 : 0; + if (dir && dir !== held && !this.showingCredits) this.select(moveSelection(this.choice, dir)); + held = dir; + }); } private select(index: number): void { + if (this.leaving) return; this.choice = index; - this.menu.forEach((text, i) => text.setColor(i === index ? C.pick : C.text).setText(i === index ? `> ${MENU[i]} <` : MENU[i])); + this.menu.forEach((text, i) => text.setTint(i === index ? PALETTE.teal : PALETTE.cream) + .setText(i === index ? `> ${MENU[i].toUpperCase()} <` : MENU[i].toUpperCase())); } private confirm(): void { + if (this.leaving) return; if (this.showingCredits) return this.showCredits(false); if (MENU[this.choice] === 'Credits') return this.showCredits(true); - if (this.scene.isPaused(STAGE)) this.scene.resume(STAGE); - this.scene.setVisible(true, HUD); - sound.loop('hidden-garden'); // the slice opens in the prologue garden; the first key press unlocked audio - this.scene.stop(); + this.start(); } private showCredits(on: boolean): void { + if (this.leaving) return; this.showingCredits = on; this.titleLayer.setVisible(!on); this.creditsLayer.setVisible(on); } + + /** Start: the stage begins underneath, this scene's garden glides onto its camera and dissolves away. */ + private start(): void { + this.leaving = true; + sound.loop('hidden-garden'); // the slice opens in the prologue garden; the key press unlocked audio + if (this.keyArt) this.tweens.killTweensOf(this.keyArt); + this.tweens.add({ targets: [this.titleLayer, this.keyArt].filter(Boolean), alpha: 0, duration: 450, ease: 'Quad.easeOut' }); + this.pan?.stop(); + this.scene.launch(STAGE, this.stageData); + this.scene.bringToTop(); + const stage = this.scene.get(STAGE); + const camera = this.cameras.main; + const glide = () => { + const target = stage.cameras?.main; + if (!target) return; + camera.setScroll(Phaser.Math.Linear(camera.scrollX, target.scrollX, 0.12), Phaser.Math.Linear(camera.scrollY, target.scrollY, 0.12)); + }; + this.events.on(Phaser.Scenes.Events.UPDATE, glide); + this.tweens.add({ + targets: camera, alpha: 0, delay: 250, duration: HAND_OVER, ease: 'Sine.easeInOut', + onComplete: () => { + if (this.scene.get(HUD)) this.scene.setVisible(true, HUD); + this.scene.stop(); + }, + }); + } } diff --git a/game/verification/prologue/01-bench.png b/game/verification/prologue/01-bench.png new file mode 100644 index 0000000..4d79979 Binary files /dev/null and b/game/verification/prologue/01-bench.png differ diff --git a/game/verification/prologue/03-look-back.png b/game/verification/prologue/03-look-back.png new file mode 100644 index 0000000..aa67772 Binary files /dev/null and b/game/verification/prologue/03-look-back.png differ diff --git a/game/verification/prologue/04-lead-hill.png b/game/verification/prologue/04-lead-hill.png new file mode 100644 index 0000000..50c17bd Binary files /dev/null and b/game/verification/prologue/04-lead-hill.png differ diff --git a/game/verification/prologue/05-point-roof.png b/game/verification/prologue/05-point-roof.png new file mode 100644 index 0000000..fb64ebd Binary files /dev/null and b/game/verification/prologue/05-point-roof.png differ diff --git a/game/verification/prologue/06-at-pond.png b/game/verification/prologue/06-at-pond.png new file mode 100644 index 0000000..18f5daf Binary files /dev/null and b/game/verification/prologue/06-at-pond.png differ diff --git a/game/verification/prologue/10-fall.png b/game/verification/prologue/10-fall.png new file mode 100644 index 0000000..a51b3ba Binary files /dev/null and b/game/verification/prologue/10-fall.png differ diff --git a/game/verification/prologue/12-404.png b/game/verification/prologue/12-404.png new file mode 100644 index 0000000..e4bf30f Binary files /dev/null and b/game/verification/prologue/12-404.png differ diff --git a/game/verification/prologue/13-pull.png b/game/verification/prologue/13-pull.png new file mode 100644 index 0000000..6768b9a Binary files /dev/null and b/game/verification/prologue/13-pull.png differ diff --git a/game/verification/prologue/14-pull-late.png b/game/verification/prologue/14-pull-late.png new file mode 100644 index 0000000..013febf Binary files /dev/null and b/game/verification/prologue/14-pull-late.png differ diff --git a/game/verification/prologue/15-rusty-creek.png b/game/verification/prologue/15-rusty-creek.png new file mode 100644 index 0000000..3b18128 Binary files /dev/null and b/game/verification/prologue/15-rusty-creek.png differ diff --git a/game/verification/prologue/16-hollow-muffin.png b/game/verification/prologue/16-hollow-muffin.png new file mode 100644 index 0000000..ee3e4c3 Binary files /dev/null and b/game/verification/prologue/16-hollow-muffin.png differ diff --git a/requirements-art.txt b/requirements-art.txt index d7f3d3e..4530f64 100644 --- a/requirements-art.txt +++ b/requirements-art.txt @@ -1,3 +1,4 @@ # Local deterministic art preparation, compositing and tests. API dependencies # belong to the installed imagegen CLI environment; no API key is needed here. Pillow>=11.0,<13 +numpy>=2.0,<3 diff --git a/scripts/assets.py b/scripts/assets.py new file mode 100644 index 0000000..8667e18 --- /dev/null +++ b/scripts/assets.py @@ -0,0 +1,163 @@ +"""The asset system (#158): the inventory checked against what the repo actually holds. + + art/inventory.csv every asset the slice needs: id, kind, size, frames/anims, sources, status + + art/sprites/boards.json the boards (prompt, inputs, atlas rows, review) + + art/sprites/images/ drawn boards (make_boards.py draw + crush) + + game/public/assets/ exported atlases, UI, stages (export_atlas.py, export_stage.py) + -> status: the real state of each asset, derived from the files, and where the sheet disagrees + -> check: an exported atlas against the frame counts the inventory documents + -> next: what is still missing or unreviewed, with the command that moves it on + +The sheet is the plan; the files are the truth. A row whose status no longer matches the files is reported, +so the inventory never quietly drifts (docs/15, docs/09 "One asset system"). + +Usage: + python scripts/assets.py status [--strict] # table; --strict exits 1 on any disagreement + python scripts/assets.py check # frame counts of one exported atlas vs the inventory + python scripts/assets.py next # missing / drawn / exported-unapproved, with the next command +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INVENTORY = ROOT / "art" / "inventory.csv" +BOARDS = ROOT / "art" / "sprites" / "boards.json" +IMAGES = ROOT / "art" / "sprites" / "images" +SPRITES = ROOT / "game" / "public" / "assets" / "sprites" +UI = ROOT / "game" / "public" / "assets" / "ui" +STAGES = ROOT / "art" / "stages" +# Derived states, from least to most done; "n/a" marks a row the slice turned out not to need. +ORDER = ["missing", "planned", "drawn", "exported", "approved", "n/a"] + + +def inventory(path: Path | None = None) -> list[dict[str, str]]: + with (path or INVENTORY).open(encoding="utf-8", newline="") as f: + return [row for row in csv.DictReader(f) if row.get("id")] + + +def boards(path: Path | None = None) -> dict[str, dict]: + return {b["id"]: b for b in json.loads((path or BOARDS).read_text(encoding="utf-8"))["boards"]} + + +def frame_spec(text: str) -> dict[str, int]: + """'idle 6 · idle-walk 2 · walk 8' -> {'idle': 6, 'idle-walk': 2, 'walk': 8}; anything else is ignored.""" + return {m.group(1): int(m.group(2)) for m in re.finditer(r"([a-z][a-z0-9-]*)\s+(\d+)\b", text or "")} + + +def derive(row: dict[str, str], board_map: dict[str, dict]) -> tuple[str, list[str]]: + """The asset's real state from the files, and the problems found on the way.""" + aid, kind, problems = row["id"], row.get("kind", ""), [] + if kind == "data": + found = sorted(p.relative_to(ROOT).as_posix() for p in STAGES.glob("*/*.json") + if p.name in ("atmosphere.json", "surfaces.json")) + return ("approved" if found else "missing"), problems + board = board_map.get(aid) + if board is None: + # Not a board (stage layers, code-drawn UI): the sheet's own status stands. + return row.get("status", "missing").strip() or "missing", problems + atlas = all((SPRITES / f"{aid}{ext}").exists() for ext in (".png", ".json", ".anims.json")) + # Key art (export_key_art.py) is a plain image in the UI folder, not an atlas. + exported = atlas or (UI / f"{aid}.png").exists() + drawn = (IMAGES / f"{aid}.png").exists() + approved = str(board.get("review", "")).startswith("✅") + if exported: + if atlas: + problems += check(aid, row) + state = "approved" if approved else "exported" + elif drawn: + state = "drawn" + else: + state = "planned" + return state, problems + + +def check(aid: str, row: dict[str, str]) -> list[str]: + """An exported atlas's clips against the frame counts in the inventory row (names it documents only).""" + anims_path = SPRITES / f"{aid}.anims.json" + if not anims_path.exists(): + return [f"{aid}: not exported"] + clips = json.loads(anims_path.read_text(encoding="utf-8")) + problems = [] + for name, frames in frame_spec(row.get("frames/anims", "")).items(): + clip = clips.get(name) + if clip is None: + problems.append(f"{aid}: inventory lists '{name}' but the atlas has no such clip") + elif clip.get("frames") != frames: + problems.append(f"{aid}:{name}: {clip.get('frames')} frames exported, inventory says {frames}") + return problems + + +def status(rows: list[dict[str, str]], board_map: dict[str, dict]) -> tuple[list[tuple[str, str, str, str]], list[str]]: + """(id, kind, sheet status, derived status) per row, and every disagreement or problem.""" + table, issues = [], [] + for row in rows: + derived, problems = derive(row, board_map) + sheet = row.get("status", "").strip() + table.append((row["id"], row.get("kind", ""), sheet, derived)) + issues += problems + if sheet and sheet != derived and not (sheet == "approved" and derived == "approved"): + issues.append(f"{row['id']}: the sheet says '{sheet}', the files say '{derived}'") + return table, issues + + +def upcoming(rows: list[dict[str, str]], board_map: dict[str, dict]) -> list[str]: + """What still needs work, least done first, each with the command that moves it on.""" + todo = [] + for row in rows: + state, _ = derive(row, board_map) + aid = row["id"] + if state in ("approved", "n/a"): + continue + if state in ("missing", "planned") and aid in board_map: + step = f"python scripts/make_boards.py draw {aid}" + elif state == "drawn" and aid in board_map: + step = f"python scripts/export_atlas.py {aid} --out tmp/atlas --preview # then review" + elif state == "exported" and aid in board_map: + step = "Alexander's review, then mark the board ✅ in art/sprites/boards.json" + else: + step = row.get("notes", "") or "add a board to art/sprites/boards.json" + todo.append((ORDER.index(state) if state in ORDER else 0, f"{aid} [{state}] -> {step}")) + return [line for _, line in sorted(todo, key=lambda t: t[0])] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="mode", required=True) + s = sub.add_parser("status", help="every asset's real state and where the sheet disagrees") + s.add_argument("--strict", action="store_true", help="exit 1 on any disagreement or failed check") + c = sub.add_parser("check", help="an exported atlas against the inventory's frame counts") + c.add_argument("id") + sub.add_parser("next", help="what is missing or unreviewed, with the next command") + args = parser.parse_args() + rows, board_map = inventory(), boards() + if args.mode == "status": + table, issues = status(rows, board_map) + width = max(len(r[0]) for r in table) + for aid, kind, sheet, derived in table: + mark = "" if sheet == derived else " <- sheet: " + (sheet or "-") + print(f"{aid:<{width}} {kind:<12} {derived}{mark}") + counts = {state: sum(1 for r in table if r[3] == state) for state in ORDER} + print("\n" + " · ".join(f"{state} {n}" for state, n in counts.items() if n)) + for issue in issues: + print("! " + issue) + sys.exit(1 if args.strict and issues else 0) + if args.mode == "check": + row = next((r for r in rows if r["id"] == args.id), None) + if row is None: + sys.exit(f"{args.id} is not in art/inventory.csv") + problems = check(args.id, row) + print("\n".join(problems) if problems else f"{args.id}: every documented clip matches") + sys.exit(1 if problems else 0) + for line in upcoming(rows, board_map): + print(line) + + +if __name__ == "__main__": + main() diff --git a/scripts/collision_check.py b/scripts/collision_check.py index 5b83f70..619974c 100644 --- a/scripts/collision_check.py +++ b/scripts/collision_check.py @@ -5,6 +5,10 @@ -> reach: with the 28x76 body, every spawn, pickup and trigger can be reached from the player spawn (or the chunk's left edge), and the chunk's right edge is reachable -> joins: neighbouring chunks of one state meet at the same ground height + -> terrain: typed floor ranges (docs/04 terrain model) - known kinds and materials, every floor + covered, plank/stair/step treads flat, no floor over the pond's water pixels, no wall face + taller than a jump where a lower floor stops at its foot + -> --surfaces: /surfaces.json chunk ranges rebuilt from the terrain (materials stay) -> --overlay: /collision/..png, the docs/04 legend over the art at half size, committed next to the data for review @@ -16,7 +20,7 @@ of the maze, so headroom only matters under `solid` boxes. Usage: - python scripts/collision_check.py [art/stages/rusty-creek] [--overlay] + python scripts/collision_check.py [art/stages/rusty-creek] [--overlay] [--surfaces] """ from __future__ import annotations @@ -33,6 +37,12 @@ RISE = 88 # highest ledge a held jump reaches, with margin (96 px in docs/04) ACROSS = 140 # widest gap a running jump clears SNAP = 6 # ends this close count as one continuous floor +STEP_UP = 16 # rises the grounded body walks up without a jump (game/src/player/body.ts) +FACE_DEPTH = 120 # a floor this far under a line end makes it an overhang, not a face (body.ts) +FACE_REACH = 100 # a face stops feet at most this far below its top; taller ones are background (body.ts) +TERRAIN = {"ground", "step", "stair", "plank", "ledge", "wall", "log", "obstacle", "slide"} +FLAT = {"step", "stair", "plank"} # treads: never a slope +FLAT_SLOPE = 0.05 LISTS = ["walk", "slide", "oneWay"] BOXES = ["climb", "solid", "noGo", "hazard", "interact", "glideLift"] POINTS = ["spawn", "ledge"] @@ -146,8 +156,8 @@ def link(a, b): if lo < hi: mid = (lo + hi) / 2 ay, by = y_at(a["line"], mid), y_at(b["line"], mid) - if b["key"] == "oneWay" and 0 < ay - by <= RISE: - link(i, j) + if 0 < ay - by <= RISE: + link(i, j) # floors are one-sided: jump up onto any line above if a["key"] == "oneWay" and by > ay: link(i, j) for box in data["climb"]: @@ -211,6 +221,106 @@ def touches(s: list[dict], seen: set[int], box: dict) -> bool: return False +def faces(data: dict) -> list[dict]: + """Line ends nothing passes under: the walls body.ts stops a body at (side -1 faces left).""" + every = [line for k in ("walk", "slide") for line in data[k]] + under = every + data["oneWay"] + out = [] + for line in every: + for (x, top), side in ((line[0], -1), (line[-1], 1)): + passes = any(o is not line and (h := y_at(o, x - side)) is not None and top + STEP_UP < h <= top + FACE_DEPTH + for o in under) + continues = any(o is not line and (h := y_at(o, x + side)) is not None and abs(h - top) <= STEP_UP + for o in under) + if not passes and not continues: + out.append({"x": x, "top": top, "side": side}) + return out + + +def terrain(data: dict, name: str, materials: set | None) -> list[str]: + """The typed terrain: kinds, materials, coverage of every floor, flat treads, wall heights.""" + errors = [] + ranges = data.get("terrain") + if ranges is None: + return errors + for i, t in enumerate(ranges): + x = t.get("x") + if not (isinstance(x, list) and len(x) == 2 and x[0] < x[1]): + errors.append(f"{name}: terrain[{i}] needs an x range") + return errors + if t.get("kind") not in TERRAIN: + errors.append(f"{name}: terrain[{i}] kind {t.get('kind')!r} is not one of {sorted(TERRAIN)}") + if materials is not None and t.get("material") not in materials: + errors.append(f"{name}: terrain[{i}] material {t.get('material')!r} is not in surfaces.json") + + def typed(x: float, y: float): + return next((t for t in ranges if t["x"][0] <= x < t["x"][1] + and (not t.get("y") or t["y"][0] <= y < t["y"][1])), None) + + for key in ("walk", "slide", "oneWay"): + for i, line in enumerate(data[key]): + for a, b in zip(line, line[1:]): + t = typed((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) + if t is None: + errors.append(f"{name}: {key}[{i}] at {a}->{b} has no terrain type") + elif t["kind"] in FLAT and abs(b[1] - a[1]) > abs(b[0] - a[0]) * FLAT_SLOPE: + errors.append(f"{name}: {key}[{i}] {t['kind']} at {a}->{b} is a slope; treads are flat") + for f in faces(data): + # The floor that stops at the face's foot, just outside it. + below = [h for line in data["walk"] + data["slide"] + data["oneWay"] + if (h := y_at(line, f["x"] + f["side"])) is not None and h > f["top"]] + if below and RISE < min(below) - f["top"] <= FACE_REACH: + errors.append(f"{name}: wall at x={f['x']} is {min(below) - f['top']:.0f} px tall, more than a jump ({RISE})") + return errors + + +def water(data: dict, name: str, region: Path) -> list[str]: + """No floor stands on the pond: the pixels under the feet are water and not play.""" + wet = region / "layers" / f"{data['chunk']}.04-water.png" + play = region / "layers" / f"{data['chunk']}.05-play.png" + if not wet.exists() or not play.exists(): + return [] + import numpy as np + from PIL import Image + w = np.array(Image.open(wet).convert("RGBA"))[:, :, 3] > 128 + p = np.array(Image.open(play).convert("RGBA"))[:, :, 3] > 128 + rows = np.where(w.mean(axis=1) > 0.5)[0] + if not len(rows): + return [] + surface = int(rows.min()) # the pond's waterline: the first row that is mostly water + errors = [] + for key in ("walk", "oneWay"): + for i, line in enumerate(data[key]): + bad = [] + for x in range(line[0][0], line[-1][0] + 1, 4): + yy = int(round(y_at(line, x))) + 4 + if surface - 2 <= yy < w.shape[0] and x < w.shape[1] and w[yy, x] and not p[yy, x]: + bad.append(x) + if bad: + errors.append(f"{name}: {key}[{i}] stands on water at x={bad[0]}..{bad[-1]}") + return errors + + +def write_surfaces(region: Path, loaded: dict) -> None: + """surfaces.json keeps its materials; each typed chunk's ranges come from its terrain.""" + path = region / "surfaces.json" + data = json.loads(path.read_text(encoding="utf-8")) + typed = {chunk: geo["terrain"] for (chunk, _state), geo in sorted(loaded.items()) if geo.get("terrain") is not None} + if not typed: + return + for chunk, ranges in typed.items(): + data["chunks"][chunk] = [{k: v for k, v in (("x", t["x"]), ("y", t.get("y")), ("material", t["material"]), + ("note", f"{t['kind']}: {t['note']}" if t.get("note") else t["kind"])) + if v is not None} for t in ranges] + # One material and one range per line, as the file is written by hand. + line = lambda v: json.dumps(v, ensure_ascii=False) + out = ["{"] + [f' {line(k)}: {line(v)},' for k, v in data.items() if k not in ("materials", "chunks")] + out += [' "materials": {', ",\n".join(f" {line(k)}: {line(v)}" for k, v in data["materials"].items()), " },"] + out += [' "chunks": {', ",\n".join(f" {line(c)}: [\n" + ",\n".join(f" {line(r)}" for r in rs) + "\n ]" + for c, rs in data["chunks"].items()), " }", "}"] + path.write_bytes(("\n".join(out) + "\n").encode("utf-8")) + + def floor_at_edge(data: dict, right: bool) -> float | None: x = data["size"][0] if right else 0 ys = [y_at(a["line"], x) for a in surfaces(data) if a["key"] == "walk" and a["x0"] <= x <= a["x1"]] @@ -229,9 +339,19 @@ def overlay(data: dict, art: Path, out: Path) -> None: d.rectangle((b["x"], b["y"], b["x"] + b["w"] - 1, b["y"] + b["h"] - 1), fill=(*c, 70), outline=(*c, 255), width=3) if b.get("id") or b.get("kind"): d.text((b["x"] + 4, b["y"] + 4), b.get("id") or b["kind"], fill=(255, 255, 255, 255)) + for b in data["noGo"]: + if b.get("kind") == "water": + d.rectangle((b["x"], b["y"], b["x"] + b["w"] - 1, b["y"] + b["h"] - 1), fill=(40, 120, 255, 70)) for key in ("walk", "oneWay", "slide"): for line in data[key]: d.line([tuple(p) for p in line], fill=(*COLOURS[key], 255), width=5) + for f in faces(data): + d.line([(f["x"], f["top"]), (f["x"], f["top"] + 50)], fill=(*COLOURS["solid"], 255), width=5) + for t in data.get("terrain", []): + ys = [y for k in ("walk", "oneWay", "slide") for line in data[k] + if (y := y_at(line, (t["x"][0] + t["x"][1]) / 2)) is not None and (not t.get("y") or t["y"][0] <= y < t["y"][1])] + if ys: + d.text(((t["x"][0] + t["x"][1]) / 2 - 24, min(ys) + 10), f"{t['kind']}/{t['material']}", fill=(255, 255, 255, 255)) for p in data["spawn"]: x, y = p["x"], p["y"] if p["kind"] == "player": @@ -247,6 +367,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("regions", nargs="*", type=Path, help="region folders (default: every stage)") parser.add_argument("--overlay", action="store_true", help="draw /collision/..png") + parser.add_argument("--surfaces", action="store_true", help="rebuild /surfaces.json ranges from the terrain") args = parser.parse_args() regions = args.regions or sorted(p for p in STAGES.iterdir() if p.is_dir()) errors, count = [], 0 @@ -254,18 +375,22 @@ def main() -> None: files = sorted(region.glob("*.collision.json")) order = json.loads((region / "chunks.json").read_text(encoding="utf-8")) if (region / "chunks.json").exists() else None loaded = {} + surfaces_file = region / "surfaces.json" + materials = set(json.loads(surfaces_file.read_text(encoding="utf-8"))["materials"]) if surfaces_file.exists() else None for path in files: name = path.name data = json.loads(path.read_text(encoding="utf-8")) problems = validate(data, name) if not problems: - problems = reach(data, name) + problems = reach(data, name) + terrain(data, name, materials) + water(data, name, region) errors += problems loaded[(data.get("chunk"), data.get("state"))] = data count += 1 if args.overlay and order and not problems: art = region / order["art"][data["state"]].format(chunk=order["chunks"][data["chunk"]]) overlay(data, art, region / "collision" / f"{data['chunk']}.{data['state']}.png") + if args.surfaces and surfaces_file.exists(): + write_surfaces(region, loaded) if order: for state in order["art"]: chain = list(order["chunks"]) diff --git a/scripts/compare.py b/scripts/compare.py index 1065346..58e24dc 100644 --- a/scripts/compare.py +++ b/scripts/compare.py @@ -27,7 +27,7 @@ SPRITES = ROOT / "game" / "public" / "assets" / "sprites" FOOT = 13 # px per foot at stage scale (docs/04) # Documented game heights in px (docs/04 scale table, docs/05): what each sprite should measure. -DOCUMENTED = {"hero": 76, "dexter": 27, "dexter-acts": 27, "enemy-rover": 25, "enemy-crab": 40, "enemy-drone": 45, +DOCUMENTED = {"hero": 76, "dexter": 27, "dexter-acts": 27, "dexter-prologue": 27, "enemy-rover": 25, "enemy-crab": 40, "enemy-drone": 45, "boss-jenny": 95, "boss-jenny-2": 95} GRID = (255, 0, 255) @@ -74,6 +74,40 @@ def frame(atlas: str, name: str) -> Image.Image: return piece.crop(piece.getchannel("A").getbbox()) +def feet(piece: Image.Image, band: int = 3) -> list[tuple[int, int]]: + """Runs of opaque pixels in the bottom `band` rows of a frame: the feet on the ground, left to right.""" + alpha = piece.getchannel("A") + w, h = piece.size + rows = [y for y in range(h) if any(alpha.getpixel((x, y)) > 128 for x in range(w))] + if not rows: + return [] + xs = sorted({x for y in range(max(rows) - band + 1, max(rows) + 1) for x in range(w) if alpha.getpixel((x, y)) > 128}) + runs: list[list[int]] = [] + for x in xs: + if runs and x - runs[-1][1] <= 2: + runs[-1][1] = x + else: + runs.append([x, x]) + return [(a, b) for a, b in runs] + + +def stride(atlas: str, anim: str) -> tuple[float, float, list[str]]: + """The step length of a locomotion cycle: the widest spread between two planted feet (double support), and + the stride – two steps – that the body must travel per cycle for the feet not to slide.""" + clips = json.loads((SPRITES / f"{atlas}.anims.json").read_text(encoding="utf-8")) + report, step = [], 0.0 + for i in range(clips[anim]["frames"]): + runs = feet(frame(atlas, f"{anim}-{i:02d}")) + spread = (runs[-1][0] + runs[-1][1]) / 2 - (runs[0][0] + runs[0][1]) / 2 if len(runs) >= 2 else 0.0 + step = max(step, spread) + report.append(f"{anim}-{i:02d}: feet {runs} spread {spread:.1f}") + fps = clips[anim]["fps"] + speed = 2 * step * fps / clips[anim]["frames"] + report.append(f"{atlas}:{anim}: step {step:.1f} px, stride {2 * step:.1f} px per {clips[anim]['frames']}-frame cycle" + f" -> {speed:.0f} px/s at {fps} fps without sliding") + return step, 2 * step, report + + def documented(atlas: str) -> int | None: if atlas.startswith("hero"): return DOCUMENTED["hero"] @@ -168,6 +202,8 @@ 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)") + four = sub.add_parser("stride", help="the stride of a walk or run cycle, measured from its planted feet") + four.add_argument("clips", nargs="+", help="atlas:anim, e.g. hero-move:walk") 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") @@ -177,6 +213,11 @@ def main() -> None: 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 == "stride": + for spec in args.clips: + atlas, anim = spec.split(":") + print("\n".join(stride(atlas, anim)[2])) + return if args.mode == "images": out = images(args.ref, args.ours, args.width, args.grid) elif args.mode == "parallax": diff --git a/scripts/depth_layers.py b/scripts/depth_layers.py index d3d1ca2..1b07aca 100644 --- a/scripts/depth_layers.py +++ b/scripts/depth_layers.py @@ -175,7 +175,9 @@ def raw_dir(region: Path) -> Path: return folder -def api(name: str, art: Image.Image, prompt: str, mask: Image.Image | None = None) -> Image.Image | None: +def api(name: str, art: Image.Image, prompt: str, mask: Image.Image | None = None, + refs: tuple[Path, ...] = ()) -> Image.Image | None: + """One gpt-image edit of `art` (the mask's white = paint here); `refs` go along as extra references.""" key = mb.api_key() with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "art.png" @@ -187,7 +189,7 @@ def api(name: str, art: Image.Image, prompt: str, mask: Image.Image | None = Non 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) + [path, *refs], key, mask=mask_path) return None if png is None else Image.open(io.BytesIO(png)).convert("RGB").resize(art.size, Image.Resampling.NEAREST) @@ -261,6 +263,390 @@ def fill(region: Path, chunk: str, art: Image.Image, only: tuple[str, ...] = PAR print(f"{chunk}: {name} filled -> {out.relative_to(ROOT)}", flush=True) +# --------------------------------------------------------------------------------------------------------- +# Complete plates (docs/15 P2/P3, the "hot dog"): every layer rendered whole behind what stands in front of it. +# --------------------------------------------------------------------------------------------------------- +STYLE = ROOT / "art" / "direction" / "images" / "style-master.png" +# split_layers.py layer id -> band name; 06-near is the grass strip in front of the trail (no key colour: +# the "near" step paints it onto the art, it is not found in it). +BAND_OF = {layer: name for name, (layer, _) in DEPTH.items()} | {"06-near": "near"} +LAYER_OF = {band: layer for layer, band in BAND_OF.items()} +STACK = ["sky", "far", "mid", "rear", "water", "play", "near", "foreground"] # split_layers.LAYERS order +LEVEL = ("rear", "water", "play") # the bands at scroll 1: what the foreground and the near strip stand in front of + +COMPLETE_PROMPT = ( + "This is one chunk of a side-scrolling stage. Paint ONLY the transparent area: {what}, continuing it " + "naturally from what is visible around the hole, the same light, palette, fine 2-pixel grain and scale. Where " + "the transparent area rises above that layer's own top edge, paint the plain late-afternoon sky there. Nothing " + "that stands in front of it - no {front}. Strict side view, no characters, no text. The second image is the " + "style master: match its pixel-art rendering.") +UNDER_PROMPT = ( + "This is one chunk of a side-scrolling stage. The transparent area is where dark out-of-focus leaves and grass " + "stood in front of the camera; they are removed. Paint ONLY the transparent area with what lies behind them, " + "continued from what is visible around the hole: the pond water with its mirrored reflections and lily pads, " + "the bank, the stone wall or the path's front face, the reeds - same light, palette, fine 2-pixel grain and " + "scale. No leaves, no grass or plants in front, no characters, no text. The second image is the style master.") +NEAR_PROMPT = ( + "This is one chunk of a side-scrolling stage. The flat magenta band runs along the front edge of the walking " + "path. Inside that band paint ONLY tufts of tall autumn grass, a few ferns, small plants and seed heads that " + "grow up from the band's lower edge in front of the path, their tips reaching to the top of the band - in " + "clumps with gaps between them, like the reeds and grass already in the picture, the same palette, light and " + "fine 2-pixel grain. On wooden planks, stone steps, a jetty, a roof or stepping stones paint nothing. Every " + "pixel of the band that is not a plant stays EXACTLY flat pure magenta #FF00FF. No characters, no text. The " + "second image is the style master.") +MAGENTA = (255, 0, 255) + + +def partition(region: Path, chunk: str, source: Image.Image | None = None): + """Each pixel's band, as split_layers.py assigns it from layers.json (H x W array of band names).""" + import numpy as np + import split_layers as sl + config = json.loads((region / "layers.json").read_text(encoding="utf-8"))["chunks"][chunk] + source = source or Image.open(region / config["states"]["clean"]).convert("RGBA") + owner = np.array(sl.assign(config, "clean", source, region)) + names = np.array([BAND_OF.get(layer, "rear") for layer in sl.LAYERS]) + return names[owner] + + +def components(mask, smallest: int): + """The mask without its specks: connected pieces under `smallest` px are dropped.""" + import numpy as np + from scipy import ndimage + labels, n = ndimage.label(mask) + if not n: + return mask + sizes = ndimage.sum(mask, labels, range(1, n + 1)) + keep = np.zeros(n + 1, bool) + keep[1:] = sizes >= smallest + return keep[labels] + + +def l_image(mask) -> Image.Image: + import numpy as np + return Image.fromarray(mask.astype(np.uint8) * 255) + + +def sky_array(rgb): + """skylike() for a whole H x W x 3 array.""" + r, g, b = (rgb[..., i].astype(int) for i in range(3)) + return ((b > 200) & (b > r + 60)) | ((r > 200) & (g > 200) & (b > 180)) | ((r > 220) & (g > 190) & (b > 120) & (b < r)) + + +def complete(region: Path, chunk: str, art: Image.Image, only: tuple[str, ...] = ("far", "mid"), + reuse: bool = False) -> None: + """Far and mid rendered whole: painted under EVERY nearer pixel, down to the bottom, the layer's skyline drawn + by the model (the sky it paints is dropped). The layer's own pixels stay the art's, so the chunk at rest is + unchanged; the plate (tmp/.../filled/, the region plate's source) has no hard cut and no step.""" + import numpy as np + from scipy import ndimage + bands = partition(region, chunk) + rgb = np.array(art.convert("RGB")) + for name in only: + own = bands == name + if not own.any(): + continue + front = np.isin(bands, STACK[STACK.index(name) + 1:]) + raw = raw_dir(region) / f"{chunk}--complete-{name}.png" + if reuse and raw.exists(): + painted = Image.open(raw).convert("RGB") + else: + what, nots = WHAT[name] + # A 2 px margin into the layer's own edge, so no fringe of the nearer layer survives in the painting. + hole = ndimage.binary_dilation(front, iterations=2) + painted = api(f"{chunk}--complete-{name}", art, COMPLETE_PROMPT.format(what=what, front=nots), + l_image(hole), (STYLE,)) + if painted is None: + return + painted.save(raw) + p = np.array(painted) + # The painting is kept on nearer pixels only (never over a farther layer), and only where it is not sky; + # a 3x3 majority smooths the sky cut, specks and pieces cut off from the layer go. + keep = front & ~sky_array(p) + keep = ndimage.median_filter(keep.astype(np.uint8), size=3).astype(bool) & front + keep &= components(keep | own, 600) + plate = np.zeros((*own.shape, 4), np.uint8) + plate[keep, :3], plate[keep, 3] = p[keep], 255 + plate[own, :3], plate[own, 3] = rgb[own], 255 # its own pixels stay exactly the art's + out = raw_dir(region) / "filled" / f"{chunk}.{DEPTH[name][0]}.filled.png" + out.parent.mkdir(exist_ok=True) + Image.fromarray(plate).save(out, optimize=True) + print(f"{chunk}: {name} complete -> {out.relative_to(ROOT)}", flush=True) + + +def before_near(region: Path, chunk: str) -> Path: + """The chunk's art before the near strip was painted on (kept in tmp/; the under step paints from it).""" + return raw_dir(region) / f"{chunk}--before-near.png" + + +def trail_top(bands, smooth: int = 9): + """Per column, the first row of the trail band (-1 where the column has none); a running median steadies it.""" + import numpy as np + from scipy import ndimage + play = components(bands == "play", 200) + has = play.any(axis=0) + top = np.where(has, play.argmax(axis=0), 10_000) + med = ndimage.median_filter(top, size=smooth, mode="nearest") + return np.where(has & (med < 10_000), med, -1) + + +def walk_top(region: Path, chunk: str, w: int, shortest: int = 200): + """Per column, the trail's walking line from the chunk's collision (-1 where none): where several lines cover + a column the longest wins (the trail, not a shed roof); lines under `shortest` px (a hollow's floor, + stepping stones, a ledge) grow no grass.""" + import numpy as np + path = region / f"{chunk}.clean.collision.json" + if not path.exists(): + return None + top, span = np.full(w, -1), np.zeros(w) + for line in json.loads(path.read_text(encoding="utf-8")).get("walk", []): + xs, ys = [p[0] for p in line], [p[1] for p in line] + length = xs[-1] - xs[0] + if length < shortest: + continue + cols = np.arange(max(0, xs[0]), min(w, xs[-1])) + better = span[cols] < length + top[cols[better]] = np.round(np.interp(cols[better], xs, ys)).astype(int) + span[cols[better]] = length + return top + + +def near_band(bands, above: int = 30, below: int = 14, top=None): + """Where the near strip may grow: from `below` px under the trail's top to `above` px over it.""" + import numpy as np + h, w = bands.shape + top = trail_top(bands) if top is None else top + rows = np.arange(h)[:, None] + return (top[None, :] >= 0) & (rows >= top[None, :] - above) & (rows < top[None, :] + below) + + +def match_hue(p, plant, reference, limit: float = 40.0): + """The `plant` pixels of `p` (H x W x 3, ints) with their hues rotated so their saturated mean hue meets that of + `reference` (N x 3: the chunk's own art under the strip, before it was painted), by at most `limit` degrees; + value and saturation stay. Pinks, which the garden doesn't have, are pulled toward red.""" + import colorsys + import numpy as np + + def mean_hue(pixels): + hsv = [colorsys.rgb_to_hsv(*(c / 255 for c in px)) for px in pixels[::5]] + angles = [h * 2 * np.pi for h, sat, _ in hsv if sat > 0.25] + if not angles: + return None + return np.degrees(np.arctan2(np.mean(np.sin(angles)), np.mean(np.cos(angles)))) + + target, current = mean_hue(reference), mean_hue(p[plant]) + if target is None or current is None: + return p[plant] + shift = ((target - current + 180) % 360 - 180) + shift = max(-limit, min(limit, shift)) / 360 + out = [] + for r, g, b in p[plant]: + h, sat, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) + h = (h + shift) % 1.0 + # The garden has no pink: a pink-magenta plant (300-360 degrees) is pulled 70 % of the way to red. + if h > 300 / 360: + h = (h + (1.0 - h) * 0.7) % 1.0 + out.append([round(c * 255) for c in colorsys.hsv_to_rgb(h, sat, v)]) + return np.array(out, dtype=p.dtype).reshape(-1, 3) + + +def near(region: Path, chunk: str, reuse: bool = False) -> Path | None: + """The grass strip in front of the trail (06-near, drawn over the hero's feet). The model paints plants into a + flat magenta band along the trail's front edge; what is not magenta and rises over the trail becomes the + strip. Writes its mask (masks/.06-near.png) and paints it onto the chunk's art; the art before it + stays in tmp/.""" + import numpy as np + from scipy import ndimage + config = json.loads((region / "layers.json").read_text(encoding="utf-8"))["chunks"][chunk] + art_path = region / config["states"]["clean"] + keep_art = before_near(region, chunk) + if not keep_art.exists(): + Image.open(art_path).convert("RGB").save(keep_art) + art = Image.open(keep_art).convert("RGB") + bands = partition(region, chunk, art.convert("RGBA")) + top = walk_top(region, chunk, art.width) + if top is None: + top = trail_top(bands) + band = near_band(bands, top=top) + rgb = np.array(art) + raw = raw_dir(region) / f"{chunk}--near.png" + if reuse and raw.exists(): + painted = Image.open(raw).convert("RGB") + else: + guide = rgb.copy() + guide[band] = MAGENTA + painted = api(f"{chunk}--near", Image.fromarray(guide), NEAR_PROMPT, l_image(band), (STYLE,)) + if painted is None: + return None + painted.save(raw) + p = np.array(painted).astype(int) + r, g, b = p[..., 0], p[..., 1], p[..., 2] + key = (r > 200) & (b > 200) & (g < 90) # the flat magenta + fringe = ((r + b) / 2 - g > 110) & (b > 110) # its blends at the plants' edges + plant = ndimage.binary_opening(band & ~key & ~fringe, iterations=1) + plant = components(plant, 24) + # Decontaminate: a plant pixel tinted by the magenta takes the colour of the nearest clean plant pixel. + tinted = plant & (b > g + 30) & (r > g + 30) + clean = plant & ~tinted + if tinted.any() and clean.any(): + _, (iy, ix) = ndimage.distance_transform_edt(~clean, return_indices=True) + p[tinted] = p[iy[tinted], ix[tinted]] + # The model paints the strip redder than the garden; turn its hues to the foliage around the trail. + p[plant] = match_hue(p, plant, rgb[band].astype(int)) + # Only plants that rise over the trail's top count; the rest would just repaint the path's front face. + rows = np.arange(plant.shape[0])[:, None] + labels, n = ndimage.label(plant) + rising = np.zeros(n + 1, bool) + if n: + rising[np.unique(labels[plant & (rows < top[None, :] - 2)])] = True + rising[0] = False + plant = rising[labels] + mask = Image.new("RGBA", art.size, (255, 255, 255, 0)) + mask.putalpha(l_image(plant)) + out = region / "masks" / f"{chunk}.06-near.png" + mask.save(out, optimize=True) + composed = rgb.copy() + composed[plant] = p[plant] + Image.fromarray(composed.astype(np.uint8)).save(art_path, optimize=True) + print(f"{chunk}: near strip ({int(plant.sum())} px) -> {out.relative_to(ROOT)}, painted onto " + f"{art_path.relative_to(ROOT)}", flush=True) + return out + + +def under(region: Path, chunk: str, reuse: bool = False) -> list[Path]: + """The scroll-1 layers (rear, water, play) rendered whole under the foreground and the near strip. + + Under the near strip the art from before it (tmp/) is exact; under the foreground the model repaints the + chunk without it. Each hole pixel goes to the band its column continues from above (water stays water, the + path's face stays path) and is written as that layer's fill, fills/..png, which split_layers.py + puts under the layer wherever a nearer layer owns the pixel - so the chunk rebuilds exactly at rest and + shows no hole when the foreground slides.""" + import numpy as np + from scipy import ndimage + config = json.loads((region / "layers.json").read_text(encoding="utf-8"))["chunks"][chunk] + base = before_near(region, chunk) + art = Image.open(base if base.exists() else region / config["states"]["clean"]).convert("RGB") + bands = partition(region, chunk) + fg, near_px = bands == "foreground", bands == "near" + fill = np.array(art) + if fg.any(): + raw = raw_dir(region) / f"{chunk}--under.png" + if reuse and raw.exists(): + painted = Image.open(raw).convert("RGB") + else: + painted = api(f"{chunk}--under", art, UNDER_PROMPT, l_image(ndimage.binary_dilation(fg, iterations=3)), + (STYLE,)) + if painted is None: + return [] + painted.save(raw) + fill[fg] = np.array(painted)[fg] + owner = continuation(bands, fg | near_px) + folder = region / "fills" + folder.mkdir(exist_ok=True) + written = [] + h, w = bands.shape + for name in LEVEL: + m = owner == name + path = folder / f"{chunk}.{LAYER_OF[name]}.png" + if not m.any(): + path.unlink(missing_ok=True) + continue + plate = np.zeros((h, w, 4), np.uint8) + plate[m, :3], plate[m, 3] = fill[m], 255 + Image.fromarray(plate).save(path, optimize=True) + written.append(path) + print(f"{chunk}: {name} under-fill ({int(m.sum())} px) -> {path.relative_to(ROOT)}", flush=True) + return written + + +def continuation(bands, hole): + """Per hole pixel, the scroll-1 band its column continues from: the nearest one above, else below, else rear.""" + import numpy as np + h, w = bands.shape + level = np.where(np.isin(bands, LEVEL), bands, "") + owner = np.full(bands.shape, "", dtype=" None: + """Paint the hanging canopy pieces out of the chunk's art (sky, far and mid continue there) and give their + pixels to sky, far or mid: sky where the painting is sky-coloured, else the nearer of the far and mid bands + around. Writes the art (the original stays in tmp/) and the sky/far/mid masks.""" + import numpy as np + from scipy import ndimage + config = json.loads((region / "layers.json").read_text(encoding="utf-8"))["chunks"][chunk] + art_path = region / config["states"]["clean"] + keep_art = raw_dir(region) / f"{chunk}--before-clear.png" + if not keep_art.exists(): + Image.open(art_path).convert("RGB").save(keep_art) + bands = partition(region, chunk, Image.open(keep_art).convert("RGBA")) + hang = hanging(bands) + if not hang.any(): + print(f"{chunk}: nothing hangs", flush=True) + return + hole = ndimage.binary_dilation(hang, iterations=3) & np.isin(bands, ("sky", "far", "mid", "rear")) + art = Image.open(art_path).convert("RGB") + raw = raw_dir(region) / f"{chunk}--clear.png" + if reuse and raw.exists(): + painted = Image.open(raw).convert("RGB") + else: + painted = api(f"{chunk}--clear", art, CLEAR_PROMPT, l_image(hole), (STYLE,)) + if painted is None: + return + painted.save(raw) + p, rgb = np.array(painted), np.array(art) + rgb[hole] = p[hole] + Image.fromarray(rgb).save(art_path, optimize=True) + sky = hole & sky_array(p) + d_far = ndimage.distance_transform_edt(~((bands == "far") & ~hole)) + d_mid = ndimage.distance_transform_edt(~((bands == "mid") & ~hole)) + new = bands.copy() + new[hole & ~sky] = np.where(d_far[hole & ~sky] < d_mid[hole & ~sky], "far", "mid") + new[sky] = "sky" + for name in ("sky", "far", "mid"): + path = region / "masks" / f"{chunk}.{DEPTH[name][0]}.png" + mask = Image.new("RGBA", art.size, (255, 255, 255, 0)) + mask.putalpha(l_image(new == name)) + mask.save(path, optimize=True) + print(f"{chunk}: cleared {int(hang.sum())} px of hanging canopy -> {art_path.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: @@ -344,9 +730,11 @@ def collision(region: Path, chunk: str, art: Image.Image) -> Path: "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", []) + if "terrain" not in data: # a typed chunk was re-authored by hand from the art: its floors stay + 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 [] + # The pond is never floor (docs/04 terrain): a water no-go, which splashes and respawns at the bank. + data["noGo"] = [{"x": 0, "y": min(water), "w": w, "h": h - min(water), "kind": "water"}] if water else [] path.write_text(json.dumps(data, separators=(", ", ": ")) + "\n", encoding="utf-8") return path @@ -368,6 +756,23 @@ def seam_path(a: Image.Image, b: Image.Image) -> list[int]: return path[::-1] +def blended_plate(plates: list[Image.Image], scroll: float, blend: int) -> Image.Image: + """Region plate for an opaque, soft layer (the sky): neighbouring plates cross-fade over `blend` px in the + middle of their overlap - a cut through clouds shows as a step, a fade does not.""" + import numpy as np + w, h = plates[0].size + step = round(w * scroll) + strip = np.zeros((h, step * (len(plates) - 1) + w, 4), float) + strip[:, :w] = np.asarray(plates[0], float) + for i, plate in enumerate(plates[1:], 1): + o, q = i * step, np.asarray(plate, float) + centre = (w - step) / 2 + t = np.clip((np.arange(w) - (centre - blend / 2)) / max(1, blend), 0, 1) if step < w else np.ones(w) + t = t[None, :, None] + strip[:, o:o + w] = strip[:, o:o + w] * (1 - t) + q * t + return Image.fromarray(np.round(strip).astype(np.uint8)) + + 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. @@ -417,7 +822,10 @@ def rest(layer: dict, chunk_id: str, chunk: dict) -> Path: 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)) + if layer.get("blend"): + plate = blended_plate(plates, layer["scroll"], layer["blend"]) + else: + 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") @@ -440,7 +848,7 @@ def rest(layer: dict, chunk_id: str, chunk: dict) -> Path: 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("step", choices=["key", "masks", "fill", "complete", "near", "under", "clear", "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") @@ -469,6 +877,14 @@ def main() -> None: 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)}") + elif args.step == "complete": + complete(region, args.chunk, art, tuple(n for n in args.only if n != "sky") or ("far", "mid"), args.reuse) + elif args.step == "near": + near(region, args.chunk, args.reuse) + elif args.step == "clear": + clear(region, args.chunk, args.reuse) + elif args.step == "under": + under(region, args.chunk, args.reuse) else: fill(region, args.chunk, art, tuple(args.only), args.reuse) diff --git a/scripts/export_atlas.py b/scripts/export_atlas.py index 47d81ba..1e0a25a 100644 --- a/scripts/export_atlas.py +++ b/scripts/export_atlas.py @@ -290,6 +290,9 @@ def export(board: dict, image: Image.Image) -> tuple[Image.Image, dict, dict]: "pivot": {"x": 0.5, "y": 1}, } table[anim["anim"]] = {"frames": len(frames), "fps": anim.get("fps", FPS), "loop": anim.get("loop", True)} + # A locomotion cycle's stride in game px (compare.py stride): the game plays it by distance, not by time. + if "stride" in anim: + table[anim["anim"]]["stride"] = anim["stride"] atlas = {"frames": hashed, "meta": { "app": "scripts/export_atlas.py", "image": f"{board['id']}.png", "format": "RGBA8888", "size": {"w": sheet.width, "h": sheet.height}, "scale": "1"}} diff --git a/scripts/export_key_art.py b/scripts/export_key_art.py new file mode 100644 index 0000000..6aea819 --- /dev/null +++ b/scripts/export_key_art.py @@ -0,0 +1,47 @@ +"""Export key art boards (full scenes, `"keyed": false`) as the game's menu and loading images. + + art/sprites/images/.png crushed 1x scene (make_boards.py crush) + -> game/public/assets/ui/.png opaque RGB, at least 1280x720 (the game's view, docs/04) + +The game copy is opaque (no alpha channel: a menu backdrop never shows through) and checked for size, +so a board drawn at the wrong ratio or too small stops here instead of stretching on screen. + +Usage: + python scripts/export_key_art.py menu-key [--out game/public/assets/ui] +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "art" / "sprites" / "images" +OUT = ROOT / "game" / "public" / "assets" / "ui" +VIEW = (1280, 720) + + +def export(board: str, source: Path = SOURCE, out: Path = OUT) -> Path: + image = Image.open(source / f"{board}.png").convert("RGB") + if image.width < VIEW[0] or image.height < VIEW[1]: + raise SystemExit(f"{board}: {image.width}x{image.height} is smaller than the {VIEW[0]}x{VIEW[1]} view") + if abs(image.width / image.height - VIEW[0] / VIEW[1]) > 0.02: + raise SystemExit(f"{board}: {image.width}x{image.height} is not 16:9") + out.mkdir(parents=True, exist_ok=True) + target = out / f"{board}.png" + image.save(target, optimize=True) + return target + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("boards", nargs="+") + parser.add_argument("--out", type=Path, default=OUT) + args = parser.parse_args() + for board in args.boards: + print(f"{board} -> {export(board, out=args.out)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/export_stage.py b/scripts/export_stage.py index 98c6891..63113cd 100644 --- a/scripts/export_stage.py +++ b/scripts/export_stage.py @@ -35,6 +35,8 @@ STAGES = ROOT / "art" / "stages" OUT = ROOT / "game" / "public" / "assets" / "stages" SIZE = [2160, 720] +# Region data the game loads next to stage.json when a region has it (docs/15 contracts), copied as is. +DATA_FILES = ("atmosphere.json", "surfaces.json") class ExportError(Exception): @@ -103,6 +105,20 @@ def export(region: Path, out: Path) -> dict: target.mkdir(parents=True) for name, path in files.items(): shutil.copyfile(path, target / name) + for name in DATA_FILES: # optional region data the game loads next to stage.json + if (region / name).exists(): + try: + data = json.loads((region / name).read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ExportError(f"{region.name}: {name} is not valid JSON ({error})") from error + shutil.copyfile(region / name, target / name) + # "assets": extra images in the region folder the file refers to (atmosphere.json) + for asset in data.get("assets", []) if isinstance(data, dict) else []: + path = region / asset + if Path(asset).is_absolute() or ".." in Path(asset).parts or not path.is_file(): + raise ExportError(f"{region.name}: {name} lists asset {asset!r}, which is not a file in the region") + (target / asset).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target / asset) (target / "stage.json").write_text(json.dumps(stage, indent=1) + "\n", encoding="utf-8", newline="\n") return stage diff --git a/scripts/readability.py b/scripts/readability.py new file mode 100644 index 0000000..08a6e7a --- /dev/null +++ b/scripts/readability.py @@ -0,0 +1,178 @@ +"""Readability contact sheet (#159): every prologue sprite at 1x on the Hidden Garden, as the game shows it. + + python scripts/readability.py --out art/sprites/previews/prologue-readability.png [--atlas-dir tmp/atlas] + python scripts/readability.py dexter:idle-00 fx-water:lily-bob-00@pond --out … + +Each spec is `atlas:frame`, optionally `@path` (default: stands on the stone path in front of the bench, +hg-0-0), `@pond` (floats on the pond surface, hg-4-0) or `@air` (in front of the trees, falling leaves). +The sheet shows the backgrounds at 1x (top) and the same at 3x (below), so a reviewer sees both what the +player sees and what the pixels are. For each sprite the report gives its size and its contrast: the +difference in mean luminance between its own pixels and the background right behind it, and its outline +contrast (the darkest sprite edge against the background). A sprite under 24 on both is flagged LOW – +it melts into the garden at game size and needs a darker outline or a lighter fill. After a gap, every LOW +sprite is drawn again with a 1 px warm rim light around it (`rim`, what the game can add in code with an +outline shader), so the reviewer can compare the fix side by side. Each strip is cut to the sprites' width. +Atlases are read from the game's folder first, then from --atlas-dir (a review export). +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +SPRITES = ROOT / "game" / "public" / "assets" / "sprites" +GARDEN = ROOT / "art" / "stages" / "hidden-garden" / "images" +# Backgrounds cut from the approved chunks, and the ground line inside each cut, in the plate's own pixels +# (stage px = game px): the path in front of Dexter's bench is at y ≈ 437 in hg-0-0, the pond surface in +# hg-4-0 at y ≈ 560 (measured on the 2160x720 chunks). +BANDS = { + "path": ("hg-0-0.png", (300, 300, 1380, 480), 137), + "air": ("hg-0-0.png", (1000, 160, 2080, 340), 120), + "pond": ("hg-4-0.png", (300, 500, 1380, 680), 70), +} +LOW = 24 +RIM = (255, 222, 160, 255) # the style master's warm rim light +PROLOGUE = [ + "hero-move:idle-00", "hero-move:walk-03", "hero-run:run-03", "hero-air:jump-04", "hero-hurt:knockback-01", + "dexter:idle-00", "dexter:hop-02", "dexter:walk-02", "dexter:point-02", "dexter:shove-02", + "dexter-acts:beckon-01", "dexter-acts:creepy-00", "dexter-prologue:sit-00", "dexter-prologue:look-back-02", + "dexter-prologue:wait-01", "mensa-food:muffin-00", "pocket-computer:bob-00", + "fx-ground:dust-01", "fx-ground:grass-01", "fx-ground:leaf-kick-01", + "fx-leaves:birch-00@air", "fx-leaves:maple-00@air", "fx-leaves:oak-00@air", "fx-leaves:maple-02@air", + "fx-water:lily-bob-00@pond", "fx-water:ripple-01@pond", "fx-water:sparkle-02@pond", "fx-ground:splash-01@pond", +] + + +def frame(atlas: str, name: str, dirs: list[Path]) -> Image.Image: + """One atlas frame trimmed to its pixels, from the first folder that has the atlas.""" + for folder in dirs: + if (folder / f"{atlas}.json").exists(): + data = json.loads((folder / f"{atlas}.json").read_text(encoding="utf-8")) + frames = data["frames"] if isinstance(data["frames"], dict) else {f["filename"]: f for f in data["frames"]} + if name not in frames: + raise KeyError(f"{atlas} has no frame {name}") + r = frames[name]["frame"] + piece = Image.open(folder / f"{atlas}.png").convert("RGBA").crop( + (r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])) + return piece.crop(piece.getchannel("A").getbbox()) + raise FileNotFoundError(f"no atlas {atlas} in " + ", ".join(str(d) for d in dirs)) + + +def luma(p: tuple) -> float: + return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2] + + +def contrast(sprite: Image.Image, behind: Image.Image) -> tuple[float, float]: + """(fill, edge): |mean luma of the sprite - mean luma behind it|, and the same for its outline pixels only.""" + alpha = sprite.getchannel("A") + s, b = sprite.convert("RGB"), behind.convert("RGB") + w, h = sprite.size + inside, edge, back = [], [], [] + for y in range(h): + for x in range(w): + if alpha.getpixel((x, y)) < 128: + back.append(luma(b.getpixel((x, y)))) + continue + v = luma(s.getpixel((x, y))) + inside.append(v) + if any(not (0 <= x + dx < w and 0 <= y + dy < h) or alpha.getpixel((x + dx, y + dy)) < 128 + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))): + edge.append(v) + if not back: # a sprite that fills its box: compare against the box's own background + back = [luma(p) for p in b.getdata()] + bg = sum(back) / len(back) + fill = abs(sum(inside) / len(inside) - bg) if inside else 0.0 + rim = abs(sum(edge) / len(edge) - bg) if edge else 0.0 + return fill, rim + + +def rim(piece: Image.Image, colour: tuple = RIM) -> Image.Image: + """The sprite padded by 1 px with a 1 px outline of `colour` around its opaque pixels.""" + alpha = piece.getchannel("A") + out = Image.new("RGBA", (piece.width + 2, piece.height + 2)) + for y in range(out.height): + for x in range(out.width): + inside = 0 < x <= piece.width and 0 < y <= piece.height and alpha.getpixel((x - 1, y - 1)) >= 128 + if not inside and any(0 < x + dx <= piece.width and 0 < y + dy <= piece.height + and alpha.getpixel((x + dx - 1, y + dy - 1)) >= 128 + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))): + out.putpixel((x, y), colour) + out.alpha_composite(piece, (1, 1)) + return out + + +def sheet(specs: list[str], dirs: list[Path], zoom: int = 3) -> tuple[Image.Image, list[str]]: + rows: dict[str, list[tuple[str, Image.Image]]] = {} + report = [] + for spec in specs: + name, _, band = spec.partition("@") + band = band or "path" + try: + piece = frame(*name.split(":", 1), dirs=dirs) + except (FileNotFoundError, KeyError) as error: + report.append(f"{spec}: MISSING ({error})") + continue + rows.setdefault(band, []).append((name, piece)) + strips = [] + for band, pieces in rows.items(): + file, box, ground = BANDS[band] + bg = Image.open(GARDEN / file).convert("RGBA").crop(box) + x, low = 12, [] + for name, piece in pieces: + if x + piece.width > bg.width: + break + y = ground - piece.height + behind = bg.crop((x, y, x + piece.width, y + piece.height)) + fill, edge = contrast(piece, behind) + flag = " LOW" if fill < LOW and edge < LOW else "" + report.append(f"{name} @{band}: {piece.width}x{piece.height} px, fill {fill:.0f}, outline {edge:.0f}{flag}") + if flag: + low.append(rim(piece)) + bg.alpha_composite(piece, (x, y)) + x += piece.width + 10 + x += 30 if low else 0 + for piece in low: + if x + piece.width > bg.width: + break + bg.alpha_composite(piece, (x, ground + 1 - piece.height)) + x += piece.width + 10 + bg = bg.crop((0, 0, min(bg.width, x + 2), bg.height)) + strips.append(bg) + if not strips: + raise SystemExit("nothing to draw") + width = max(s.width for s in strips) + one = sum(s.height for s in strips) + 4 * (len(strips) - 1) + out = Image.new("RGB", (width * zoom, one + 8 + one * zoom), (20, 20, 20)) + y = 0 + for s in strips: # 1x on top + out.paste(s.convert("RGB"), (0, y)) + y += s.height + 4 + y += 4 + for s in strips: # the same, 3x + big = s.resize((s.width * zoom, s.height * zoom), Image.Resampling.NEAREST).convert("RGB") + out.paste(big, (0, y)) + y += big.height + 4 * zoom + ImageDraw.Draw(out).line([(0, one + 3), (width - 1, one + 3)], fill=(255, 0, 255)) + return out, report + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("specs", nargs="*", help="atlas:frame[@path|air|pond]; default: the prologue set") + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--atlas-dir", type=Path, action="append", default=[], + help="extra atlas folders (review exports), searched after the game's") + args = parser.parse_args() + dirs = [SPRITES, *[(ROOT / d) if not d.is_absolute() else d for d in args.atlas_dir]] + out, report = sheet(args.specs or PROLOGUE, dirs) + args.out.parent.mkdir(parents=True, exist_ok=True) + out.save(args.out, optimize=True) + print("\n".join(report)) + print(f"-> {args.out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/split_layers.py b/scripts/split_layers.py index 20de8b5..d2f0924 100644 --- a/scripts/split_layers.py +++ b/scripts/split_layers.py @@ -17,6 +17,11 @@ {"layer": "06-near", "alpha": "images/06-near-rails-left.png"} # where that image is opaque A rule can name "states": ["anomaly"] to apply to one state only. +Fills (docs/15 P3, every layer rendered complete): art/stages//fills/..png, when it +exists, is what that layer shows behind the nearer layers (depth_layers.py under). It goes into the layer +only where a NEARER layer owns the pixel, so the stack still rebuilds the art exactly at rest, and a nearer +layer that moves at another scroll uncovers painted scenery instead of a hole. + Usage: python scripts/split_layers.py art/stages/hidden-garden # write the layer files python scripts/split_layers.py --check # CI: every region still rebuilds exactly @@ -87,19 +92,32 @@ def assign(chunk: dict, state: str, source: Image.Image, folder: Path) -> Image. return owner -def split(source: Image.Image, owner: Image.Image) -> dict[str, Image.Image]: - """Cut the source into one RGBA image per layer that owns any pixel.""" +def split(source: Image.Image, owner: Image.Image, fills: dict[str, Image.Image] | None = None) -> dict[str, Image.Image]: + """Cut the source into one RGBA image per layer that owns any pixel; a layer's fill goes under the nearer layers.""" out = {} for index, name in enumerate(LAYERS): keep = owner.point(lambda v, i=index: 255 if v == i else 0) if not keep.getbbox(): continue layer = Image.new("RGBA", source.size, (0, 0, 0, 0)) + fill = (fills or {}).get(name) + if fill is not None: + if fill.size != source.size: + raise SplitError(f"the {name} fill is {fill.size}, the chunk is {source.size}") + nearer = owner.point(lambda v, i=index: 255 if v > i else 0) + layer.paste(fill, mask=ImageChops.multiply(nearer, fill.getchannel("A"))) layer.paste(source, mask=ImageChops.multiply(keep, source.getchannel("A"))) out[name] = layer return out +def fills_for(region: Path, chunk_id: str) -> dict[str, Image.Image]: + """The chunk's committed fills, fills/..png (depth_layers.py under).""" + folder = region / "fills" + return {name: Image.open(folder / f"{chunk_id}.{name}.png").convert("RGBA") + for name in LAYERS if (folder / f"{chunk_id}.{name}.png").exists()} + + def stack(layers: dict[str, Image.Image], size: tuple[int, int]) -> Image.Image: canvas = Image.new("RGBA", size, (0, 0, 0, 0)) for name in LAYERS: @@ -157,7 +175,7 @@ def run(region: Path, check: bool) -> list[str]: continue states = {} for state, source in sources.items(): - layers = split(source, assign(chunk, state, source, region)) + layers = split(source, assign(chunk, state, source, region), fills_for(region, chunk_id)) if not same(stack(layers, size), source): # cannot happen unless split() is broken raise SplitError(f"{chunk_id}.{state}: recomposition differs from the source") states[state] = layers diff --git a/scripts/test_assets.py b/scripts/test_assets.py new file mode 100644 index 0000000..7a31781 --- /dev/null +++ b/scripts/test_assets.py @@ -0,0 +1,68 @@ +"""Offline checks for assets.py: frame specs, the state derived from files, sheet drift, and the next steps.""" +import json +from pathlib import Path +import tempfile +import unittest + +import assets as a + + +class AssetSystemTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.saved = (a.ROOT, a.IMAGES, a.SPRITES, a.UI, a.STAGES) + a.ROOT, a.IMAGES, a.SPRITES, a.UI, a.STAGES = base, base / "images", base / "sprites", base / "ui", base / "stages" + for d in (a.IMAGES, a.SPRITES, a.UI, a.STAGES / "hidden-garden"): + d.mkdir(parents=True) + self.addCleanup(lambda: (setattr(a, "ROOT", self.saved[0]), setattr(a, "IMAGES", self.saved[1]), + setattr(a, "SPRITES", self.saved[2]), setattr(a, "UI", self.saved[3]), + setattr(a, "STAGES", self.saved[4]))) + self.boards = {"walker": {"id": "walker", "review": "✅ Approved"}, "drafty": {"id": "drafty", "review": "🟡 Drawn"}, + "plan": {"id": "plan"}, "key": {"id": "key", "review": "🟡"}} + + def export(self, aid, clips): + for ext in (".png", ".json"): + (a.SPRITES / f"{aid}{ext}").write_text("x", encoding="utf-8") + (a.SPRITES / f"{aid}.anims.json").write_text(json.dumps(clips), encoding="utf-8") + + def test_frame_spec_reads_names_and_counts(self): + self.assertEqual(a.frame_spec("idle 6 · idle-walk 2 · walk 8"), {"idle": 6, "idle-walk": 2, "walk": 8}) + self.assertEqual(a.frame_spec("–"), {}) + + def test_states_come_from_the_files(self): + self.export("walker", {"walk": {"frames": 8}}) + (a.IMAGES / "drafty.png").write_text("x", encoding="utf-8") + (a.UI / "key.png").write_text("x", encoding="utf-8") + row = lambda aid, frames="": {"id": aid, "kind": "sprite", "frames/anims": frames, "status": ""} + self.assertEqual(a.derive(row("walker", "walk 8"), self.boards), ("approved", [])) + self.assertEqual(a.derive(row("drafty"), self.boards)[0], "drawn") + self.assertEqual(a.derive(row("plan"), self.boards)[0], "planned") + self.assertEqual(a.derive(row("key"), self.boards)[0], "exported") # key art: an image, not an atlas + self.assertEqual(a.derive({"id": "layer", "kind": "stage layer", "status": "approved"}, self.boards)[0], "approved") + + def test_check_reports_frame_count_drift(self): + self.export("walker", {"walk": {"frames": 6}}) + problems = a.check("walker", {"frames/anims": "walk 8 · run 8"}) + self.assertIn("walker:walk: 6 frames exported, inventory says 8", problems) + self.assertIn("walker: inventory lists 'run' but the atlas has no such clip", problems) + + def test_status_flags_a_sheet_that_drifted_and_next_skips_done_rows(self): + self.export("walker", {"walk": {"frames": 8}}) + rows = [{"id": "walker", "kind": "sprite", "frames/anims": "walk 8", "status": "exported"}, + {"id": "plan", "kind": "sprite", "status": "planned"}, + {"id": "gone", "kind": "ui", "status": "n/a"}] + _, issues = a.status(rows, self.boards) + self.assertEqual(issues, ["walker: the sheet says 'exported', the files say 'approved'"]) + self.assertEqual(a.upcoming(rows, self.boards), ["plan [planned] -> python scripts/make_boards.py draw plan"]) + + def test_data_rows_look_for_region_json(self): + row = {"id": "data", "kind": "data", "status": "missing"} + self.assertEqual(a.derive(row, self.boards)[0], "missing") + (a.STAGES / "hidden-garden" / "surfaces.json").write_text("{}", encoding="utf-8") + self.assertEqual(a.derive(row, self.boards)[0], "approved") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_collision_check.py b/scripts/test_collision_check.py index bea225c..e4e2815 100644 --- a/scripts/test_collision_check.py +++ b/scripts/test_collision_check.py @@ -75,6 +75,29 @@ def test_committed_stages_pass_and_neighbours_meet(self): run = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + def test_faces_are_line_ends_nothing_passes_under(self): + tiers = [[[0, 300], [200, 300]], [[200, 270], [400, 270]]] + self.assertIn({'x': 200, 'top': 270, 'side': -1}, cc.faces(chunk(walk=tiers))) + bench = [[[0, 300], [600, 300]], [[200, 260], [300, 260]]] # the trail runs on in front of it + self.assertFalse([f for f in cc.faces(chunk(walk=bench)) if f['top'] == 260]) + seam = [[[0, 300], [200, 300]], [[200, 290], [600, 290]]] # a step: no face + self.assertFalse([f for f in cc.faces(chunk(walk=seam)) if f['x'] == 200]) + + def test_terrain_kinds_materials_coverage_and_flat_treads(self): + typed = lambda *ranges: cc.terrain(chunk(walk=[[[0, 300], [300, 300]], [[300, 296], [600, 280]]], + terrain=list(ranges)), NAME, {'stone', 'wood'}) + ground = {'x': [0, 300], 'kind': 'ground', 'material': 'stone'} + self.assertEqual(typed(ground, {'x': [300, 600], 'kind': 'ground', 'material': 'stone'}), []) + self.assertTrue(any('is a slope; treads are flat' in p for p in typed(ground, {'x': [300, 600], 'kind': 'plank', 'material': 'wood'}))) + self.assertTrue(any('has no terrain type' in p for p in typed(ground))) + self.assertTrue(any("kind 'lava'" in p for p in typed(ground, {'x': [300, 600], 'kind': 'lava', 'material': 'stone'}))) + self.assertTrue(any("material 'ice'" in p for p in typed(ground, {'x': [300, 600], 'kind': 'ground', 'material': 'ice'}))) + + def test_a_wall_taller_than_a_jump_at_the_foot_of_a_floor_is_named(self): + walls = [[[0, 300], [200, 300]], [[200, 205], [600, 205]]] # 95 px: stops the body, no jump clears it + ranges = [{'x': [0, 600], 'kind': 'ground', 'material': 'stone'}] + self.assertTrue(any('more than a jump' in p for p in cc.terrain(chunk(walk=walls, terrain=ranges), NAME, None))) + def test_edge_heights(self): left = chunk(walk=[[[0, 300], [600, 280]]]) self.assertEqual(cc.floor_at_edge(left, True), 280) diff --git a/scripts/test_compare.py b/scripts/test_compare.py index b5e2c61..6428315 100644 --- a/scripts/test_compare.py +++ b/scripts/test_compare.py @@ -45,6 +45,22 @@ def test_lineup_scales_a_frame_and_its_documented_height(self): _, report = cp.lineup(['dexter:idle-00*1.6']) self.assertIn('dexter:idle-00*1.6: 16x43 px, documented 43 px (+0 %)', report) + def test_stride_is_twice_the_widest_spread_between_planted_feet(self): + # Two frames, 40x20 each: double support with feet 21 px apart, then one foot passing. + sheet = Image.new('RGBA', (80, 20)) + sheet.paste((40, 90, 160, 255), (2, 17, 10, 20)) # back foot, centre 5.5 + sheet.paste((40, 90, 160, 255), (23, 17, 31, 20)) # front foot, centre 26.5 + sheet.paste((40, 90, 160, 255), (52, 17, 60, 20)) # one foot + sheet.paste((40, 90, 160, 255), (50, 0, 62, 17)) # the body above it + sheet.save(self.base / 'hero-move.png') + frames = {'walk-00': {'frame': {'x': 0, 'y': 0, 'w': 40, 'h': 20}}, + 'walk-01': {'frame': {'x': 40, 'y': 0, 'w': 40, 'h': 20}}} + (self.base / 'hero-move.json').write_text(json.dumps({'frames': frames}), encoding='utf-8') + (self.base / 'hero-move.anims.json').write_text(json.dumps({'walk': {'frames': 2, 'fps': 12, 'loop': True}}), encoding='utf-8') + step, length, report = cp.stride('hero-move', 'walk') + self.assertEqual((step, length), (21.0, 42.0)) + self.assertIn('-> 252 px/s at 12 fps', report[-1]) # 42 px per 2-frame cycle at 12 fps + 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 index 16d57e4..52b3fdf 100644 --- a/scripts/test_depth_layers.py +++ b/scripts/test_depth_layers.py @@ -109,5 +109,51 @@ def test_masks_are_written_for_the_parallax_bands_only(self): self.assertEqual(alpha, [0, 255, 0, 0, 0, 0]) + def test_blended_plate_fades_between_neighbours(self): + # Two 100x2 opaque plates at scroll 0.5 overlap by 50 px; a 20 px fade sits in the overlap's middle. + a = Image.new("RGBA", (100, 2), (200, 0, 0, 255)) + b = Image.new("RGBA", (100, 2), (0, 0, 200, 255)) + strip = dl.blended_plate([a, b], 0.5, 20) + self.assertEqual(strip.size, (150, 2)) + self.assertEqual(strip.getpixel((60, 0)), (200, 0, 0, 255)) # before the fade + self.assertEqual(strip.getpixel((75, 0)), (100, 0, 100, 255)) # half way + self.assertEqual(strip.getpixel((90, 0)), (0, 0, 200, 255)) # after it + + def test_a_hole_continues_the_band_above_it(self): + import numpy as np + # One column: rear, water, then a foreground hole; the hole under the water is water. + bands = np.array([["mid"], ["rear"], ["water"], ["foreground"], ["foreground"]]) + hole = bands == "foreground" + self.assertEqual(list(dl.continuation(bands, hole)[:, 0]), ["", "", "", "water", "water"]) + # A hole with nothing above it at scroll 1 takes the band below. + bands = np.array([["sky"], ["near"], ["play"]]) + self.assertEqual(dl.continuation(bands, bands == "near")[1, 0], "play") + + def test_walk_top_prefers_the_longest_line(self): + import json as js + with tempfile.TemporaryDirectory() as tmp: + region = Path(tmp) + walk = [[[0, 50], [300, 50]], [[100, 10], [150, 10]], [[100, 20], [450, 20]]] + (region / "c.clean.collision.json").write_text(js.dumps({"walk": walk})) + top = dl.walk_top(region, "c", 500) + self.assertEqual(top[10], 50) + self.assertEqual(top[120], 20) # the 350 px line beats the roof and the shorter one + self.assertEqual(top[450], -1) + + def test_match_hue_turns_red_plants_toward_orange_foliage(self): + import colorsys + import numpy as np + p = np.zeros((2, 4, 3), int) + p[0] = [200, 40, 60] # pinkish red plants + p[1] = [210, 130, 40] # orange foliage beside them + plant = np.zeros((2, 4), bool); plant[0] = True + out = dl.match_hue(p, plant, p[~plant]) + hue = colorsys.rgb_to_hsv(*(c / 255 for c in out[0]))[0] * 360 + self.assertTrue(hue < 40 or hue > 350, hue) # toward the orange foliage, never left pink + pink = np.array([[[200, 60, 160]], [[200, 60, 160]]]); only = np.array([[True], [False]]) + out = dl.match_hue(pink, only, pink[~only]) + self.assertGreater(colorsys.rgb_to_hsv(*(c / 255 for c in out[0]))[0] * 360, 340) # pink pulled toward red + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_export_stage.py b/scripts/test_export_stage.py index 61dec66..33bcd65 100644 --- a/scripts/test_export_stage.py +++ b/scripts/test_export_stage.py @@ -61,6 +61,23 @@ def test_shared_plates_are_copied_once(self): self.assertEqual(names, ['front-left.png', 'front-right.png', 'sky-left.png', 'stage.json', 't-0-0.clean.collision.json']) + def test_region_data_files_are_published_and_checked(self): + (self.region / 'atmosphere.json').write_text('{"wind": {"base": 0.5}}', encoding='utf-8') + es.export(self.region, self.out) + published = json.loads((self.out / 'test-region' / 'atmosphere.json').read_text(encoding='utf-8')) + self.assertEqual(published, {'wind': {'base': 0.5}}) + self.assertFalse((self.out / 'test-region' / 'surfaces.json').exists()) + Image.new('RGBA', (4, 4)).save(self.region / 'wrong.png') + (self.region / 'atmosphere.json').write_text('{"assets": ["wrong.png"]}', encoding='utf-8') + es.export(self.region, self.out) + self.assertTrue((self.out / 'test-region' / 'wrong.png').exists()) + (self.region / 'atmosphere.json').write_text('{"assets": ["../escape.png"]}', encoding='utf-8') + with self.assertRaisesRegex(es.ExportError, 'not a file in the region'): + es.export(self.region, self.out) + (self.region / 'atmosphere.json').write_text('{"wind": ', encoding='utf-8') + with self.assertRaisesRegex(es.ExportError, 'atmosphere.json is not valid JSON'): + es.export(self.region, self.out) + def test_verify_rebuilds_and_catches_a_wrong_order(self): es.export(self.region, self.out) self.assertEqual(es.verify(self.region, self.out), ['t-0-0.clean', 't-1-0.clean']) diff --git a/scripts/test_readability.py b/scripts/test_readability.py new file mode 100644 index 0000000..025c3b0 --- /dev/null +++ b/scripts/test_readability.py @@ -0,0 +1,67 @@ +"""Offline checks for readability.py and export_key_art.py: contrast numbers, the sheet, key-art export.""" +import json +from pathlib import Path +import tempfile +import unittest + +from PIL import Image + +import export_key_art as ka +import readability as rd + + +class ReadabilityTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + + def atlas(self, name, colour): + sheet = Image.new('RGBA', (20, 20)) + sheet.paste(colour, (5, 2, 15, 20)) + sheet.save(self.base / f'{name}.png') + frames = {'idle-00': {'frame': {'x': 0, 'y': 0, 'w': 20, 'h': 20}}} + (self.base / f'{name}.json').write_text(json.dumps({'frames': frames}), encoding='utf-8') + + def test_contrast_dark_on_light_is_high_same_colour_is_low(self): + sprite = Image.new('RGBA', (4, 4), (0, 0, 0, 0)) + sprite.paste((20, 20, 20, 255), (1, 0, 3, 4)) + light = Image.new('RGB', (4, 4), (220, 220, 220)) + fill, rim = rd.contrast(sprite, light) + self.assertGreater(fill, 150) + self.assertGreater(rim, 150) + same = Image.new('RGB', (4, 4), (20, 20, 20)) + self.assertEqual(rd.contrast(sprite, same), (0.0, 0.0)) + + def test_rim_outlines_the_silhouette_only(self): + piece = Image.new('RGBA', (3, 3)) + piece.putpixel((1, 1), (0, 0, 0, 255)) + out = rd.rim(piece, (255, 255, 255, 255)) + self.assertEqual(out.size, (5, 5)) + self.assertEqual(out.getpixel((2, 2)), (0, 0, 0, 255)) + self.assertEqual(out.getpixel((2, 1)), (255, 255, 255, 255)) + self.assertEqual(out.getpixel((1, 1))[3], 0) # diagonal stays clear + + def test_sheet_reports_sizes_flags_low_and_missing(self): + self.atlas('dark', (10, 10, 10, 255)) + bg = Image.open(rd.GARDEN / 'hg-0-0.png').convert('RGB').crop(rd.BANDS['path'][1]) + ground = rd.BANDS['path'][2] + # A sprite painted in the background's own colour right where it will stand is unreadable. + self.atlas('melt', bg.getpixel((20, ground - 5)) + (255,)) + out, report = rd.sheet(['dark:idle-00', 'nothere:idle-00'], [self.base]) + self.assertTrue(any(r.startswith('dark:idle-00 @path: 10x18 px') for r in report)) + self.assertTrue(any(r.startswith('nothere:idle-00: MISSING') for r in report)) + self.assertLess(out.width, bg.width * 3) # cut to the sprites' width + + def test_key_art_exports_opaque_16_9_and_refuses_small(self): + Image.new('RGBA', (1280, 720), (30, 60, 90, 255)).save(self.base / 'menu.png') + target = ka.export('menu', source=self.base, out=self.base / 'ui') + self.assertEqual(Image.open(target).mode, 'RGB') + self.assertEqual(Image.open(target).size, (1280, 720)) + Image.new('RGBA', (640, 360)).save(self.base / 'tiny.png') + with self.assertRaises(SystemExit): + ka.export('tiny', source=self.base, out=self.base / 'ui') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_split_layers.py b/scripts/test_split_layers.py index f669564..600e4e6 100644 --- a/scripts/test_split_layers.py +++ b/scripts/test_split_layers.py @@ -103,6 +103,16 @@ def test_rusty_creek_matches_creek_layers(self): ours = ours.getchannel('A').point(lambda a: 255 if a else 0) if ours else Image.new('L', source.size, 0) self.assertIsNone(ImageChops.difference(ours, on_top).getbbox(), role) covered = ImageChops.lighter(covered, drawn) + def test_a_fill_goes_only_under_nearer_layers(self): + source = scene() + owner = sl.assign(self.config['chunks']['t-0-0'], 'clean', source, self.region) + fill = Image.new('RGBA', source.size, (1, 2, 3, 255)) + layers = sl.split(source, owner, {'01-far': fill}) + far = layers['01-far'] + self.assertEqual(far.getpixel((50, 36)), (1, 2, 3, 255)) # under the platform (nearer): filled + self.assertEqual(far.getpixel((5, 5))[3], 0) # over the sky (farther): never + self.assertTrue(sl.same(sl.stack(layers, source.size), source)) + if __name__ == '__main__': unittest.main()