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/sprites/README.md b/art/sprites/README.md
index fd56ac4..c24dd99 100644
--- a/art/sprites/README.md
+++ b/art/sprites/README.md
@@ -12,4 +12,6 @@ Sprite reference boards: the hero sheet (approved) and the animation, enemy, ite
| [](enemy-drone.md)
**[Enemy · drone](enemy-drone.md)** | [](enemy-rover.md)
**[Enemy · rover](enemy-rover.md)** |
| [](enemy-crab.md)
**[Enemy · crab](enemy-crab.md)** | [](fx.md)
**[FX · black fluid](fx.md)** |
| [](boss-jenny.md)
**[Boss · Jenny, attacks](boss-jenny.md)** | [](boss-jenny-2.md)
**[Boss · Jenny, stunned and debris](boss-jenny-2.md)** |
-| [](dexter.md)
**[Dexter · moves](dexter.md)** | [](dexter-acts.md)
**[Dexter · gestures](dexter-acts.md)** |
+| [](pocket-computer.md)
**[Item · pocket computer](pocket-computer.md)** | [](mensa-food.md)
**[Mensa · food pickups](mensa-food.md)** |
+| [](coffee-machine.md)
**[Prop · coffee machine (save point)](coffee-machine.md)** | [](dexter.md)
**[Dexter · moves](dexter.md)** |
+| [](dexter-acts.md)
**[Dexter · gestures](dexter-acts.md)** | |
diff --git a/art/sprites/boards.json b/art/sprites/boards.json
index 3abf8f5..276f639 100644
--- a/art/sprites/boards.json
+++ b/art/sprites/boards.json
@@ -672,24 +672,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",
diff --git a/art/sprites/boss-jenny-2.md b/art/sprites/boss-jenny-2.md
index 09c00ed..6ea9b6d 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 / 21** · [Item · pocket computer →](pocket-computer.md)

@@ -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 / 21** · [Item · pocket computer →](pocket-computer.md)
diff --git a/art/sprites/boss-jenny.md b/art/sprites/boss-jenny.md
index 604755b..eb44be7 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 / 21** · [Boss · Jenny, stunned and debris →](boss-jenny-2.md)

@@ -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 / 21** · [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..fbccd65 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 / 21** · [Enemy · drone →](enemy-drone.md)

@@ -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 / 21** · [Enemy · drone →](enemy-drone.md)
diff --git a/art/sprites/chair-overhead.md b/art/sprites/chair-overhead.md
index 1f780e2..d28950b 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 / 21** · [Chair · block →](chair-block.md)

@@ -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 / 21** · [Chair · block →](chair-block.md)
diff --git a/art/sprites/chair-side.md b/art/sprites/chair-side.md
index 95968a5..ee1d74a 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 / 21** · [Chair · overhead slam →](chair-overhead.md)

@@ -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 / 21** · [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..0f6827b
--- /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 / 21** · [Dexter · moves →](dexter.md)
+
+
+
+
+**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.
+
+ 
+
+## 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 / 21** · [Dexter · moves →](dexter.md)
+
diff --git a/art/sprites/dexter-acts.md b/art/sprites/dexter-acts.md
index 44d078a..3be92c1 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 / 21** · [Sprite boards →](README.md)

@@ -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 / 21** · [Sprite boards →](README.md)
diff --git a/art/sprites/dexter.md b/art/sprites/dexter.md
index 05e88f2..23d9a9b 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 / 21** · [Dexter · gestures →](dexter-acts.md)

@@ -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 / 21** · [Dexter · gestures →](dexter-acts.md)
diff --git a/art/sprites/enemy-crab.md b/art/sprites/enemy-crab.md
index f587b7f..aed4127 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 / 21** · [FX · black fluid →](fx.md)

@@ -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 / 21** · [FX · black fluid →](fx.md)
diff --git a/art/sprites/enemy-drone.md b/art/sprites/enemy-drone.md
index 83c32ca..ece7184 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 / 21** · [Enemy · rover →](enemy-rover.md)

@@ -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 / 21** · [Enemy · rover →](enemy-rover.md)
diff --git a/art/sprites/enemy-rover.md b/art/sprites/enemy-rover.md
index a5aecfc..30cecdb 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 / 21** · [Enemy · crab →](enemy-crab.md)

@@ -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 / 21** · [Enemy · crab →](enemy-crab.md)
diff --git a/art/sprites/fx.md b/art/sprites/fx.md
index df56b35..2b79cd9 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 / 21** · [Boss · Jenny, attacks →](boss-jenny.md)

@@ -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 / 21** · [Boss · Jenny, attacks →](boss-jenny.md)
diff --git a/art/sprites/hero-air.md b/art/sprites/hero-air.md
index 41d0e2d..7a235bd 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 / 21** · [Chair · side sweep →](chair-side.md)

@@ -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 / 21** · [Chair · side sweep →](chair-side.md)
diff --git a/art/sprites/hero-death.md b/art/sprites/hero-death.md
index 875f1fd..5108c82 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 / 21** · [The student · jump, fall, climb →](hero-air.md)

@@ -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 / 21** · [The student · jump, fall, climb →](hero-air.md)
diff --git a/art/sprites/hero-hurt.md b/art/sprites/hero-hurt.md
index faee872..5dc5396 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 / 21** · [The student · death →](hero-death.md)

@@ -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 / 21** · [The student · death →](hero-death.md)
diff --git a/art/sprites/hero-idle.md b/art/sprites/hero-idle.md
index 0b739b9..4c050f8 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 / 21** · [The student · run →](hero-run.md)

@@ -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 / 21** · [The student · run →](hero-run.md)
diff --git a/art/sprites/hero-move.md b/art/sprites/hero-move.md
index 1ada96a..d3c7e44 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 / 21** · [The student · idle variants →](hero-idle.md)

@@ -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 / 21** · [The student · idle variants →](hero-idle.md)
diff --git a/art/sprites/hero-run.md b/art/sprites/hero-run.md
index bb99bda..e39816a 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 / 21** · [The student · hurt and interact →](hero-hurt.md)

@@ -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 / 21** · [The student · hurt and interact →](hero-hurt.md)
diff --git a/art/sprites/hero.md b/art/sprites/hero.md
index 5a88448..c7bebc4 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 / 21** · [The student · walk →](hero-move.md)

@@ -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 / 21** · [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/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/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..59e3810
--- /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 / 21** · [Prop · coffee machine (save point) →](coffee-machine.md)
+
+
+
+
+**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.
+
+   
+
+## 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 / 21** · [Prop · coffee machine (save point) →](coffee-machine.md)
+
diff --git a/art/sprites/pocket-computer.md b/art/sprites/pocket-computer.md
new file mode 100644
index 0000000..c4b2263
--- /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 / 21** · [Mensa · food pickups →](mensa-food.md)
+
+
+
+
+**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.
+
+ 
+
+## 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 / 21** · [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/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/docs/03-STORY-AND-CAST.md b/docs/03-STORY-AND-CAST.md
index 3665cf7..922bf9d 100644
--- a/docs/03-STORY-AND-CAST.md
+++ b/docs/03-STORY-AND-CAST.md
@@ -82,6 +82,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.
diff --git a/docs/07-ASSET-MANIFEST.md b/docs/07-ASSET-MANIFEST.md
index ce471ac..9f83f5a 100644
--- a/docs/07-ASSET-MANIFEST.md
+++ b/docs/07-ASSET-MANIFEST.md
@@ -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) | ✅ |
diff --git a/docs/13-PRODUCTION-PIPELINE.md b/docs/13-PRODUCTION-PIPELINE.md
index d684a8c..b1fba6b 100644
--- a/docs/13-PRODUCTION-PIPELINE.md
+++ b/docs/13-PRODUCTION-PIPELINE.md
@@ -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)).
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/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/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/core/scaffold.ts b/game/src/core/scaffold.ts
index 48830c1..b81641c 100644
--- a/game/src/core/scaffold.ts
+++ b/game/src/core/scaffold.ts
@@ -32,7 +32,6 @@ export class Preload extends Phaser.Scene {
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 });
}
}
diff --git a/game/src/main.ts b/game/src/main.ts
index 6f1f54f..ba1ddff 100644
--- a/game/src/main.ts
+++ b/game/src/main.ts
@@ -21,6 +21,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/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..6d684eb 100644
--- a/game/src/stage/assets.ts
+++ b/game/src/stage/assets.ts
@@ -2,7 +2,8 @@ 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;
+ 'chair-side', 'chair-overhead', 'chair-block', 'enemy-rover', 'enemy-crab', 'enemy-drone', 'fx', 'boss-jenny', 'boss-jenny-2', 'coffee-machine',
+ 'dexter', 'dexter-acts', 'pocket-computer', 'mensa-food'] as const;
export interface AnimationClip { frames: number; fps: number; loop: boolean }
export type AnimationTable = Record;
export const stageBase = (region: string) => `/assets/stages/${region}/`;
diff --git a/game/src/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/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/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/ui/Hud.ts b/game/src/ui/Hud.ts
index 8da349e..bc5dcf1 100644
--- a/game/src/ui/Hud.ts
+++ b/game/src/ui/Hud.ts
@@ -50,7 +50,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');