diff --git a/games/demos/dream_garden/README.md b/games/demos/dream_garden/README.md index f4bdb325..5b84ebf6 100644 --- a/games/demos/dream_garden/README.md +++ b/games/demos/dream_garden/README.md @@ -17,20 +17,29 @@ Windows uses `vs-emu.bat --game demos.dream_garden`. | Action | Controller | Player 1 keyboard | Player 2 keyboard | | --- | --- | --- | --- | -| Begin / join | A | Space | Z | -| Drift around the rim | Left / Right | Left / Right arrows | H / L | -| Slow everyone's garden | Hold A | Hold Space | Hold Z | -| Rest and keep the flowers on screen | B | O | X | -| Toggle the quiet lullaby and bloom sound | X | P | C | -| Return to the launcher | Y / Back | Y / Page Down | V / End | - -Catch a golden seed to plant a flower. Missing a seed has no penalty. A second +| Begin / join | A | Space | Numpad 0 | +| Drift around the rim | Left / Right | A / D | Left / Right arrows | +| Slow everyone's garden | Hold A | Hold Space | Hold Numpad 0 | +| Rest and keep the flowers on screen | B | B | Numpad 1 | +| Toggle the quiet lullaby and bloom sound | X | N | Numpad 2 | +| Return to the launcher | Y / Back / Home | M / 1 / Escape | Numpad 3 / + / Escape | + +The garden starts empty. Catch a golden seed to plant a flower at the catching +player’s depth, on the side they last moved toward, with a centre offset of +75% of the player’s width. Each player remembers their own direction while +stopped; opposite inputs cancel without changing it. Before the first movement, +planting defaults to the left. +Flowers drift inward at 8% of seed speed and disappear at the centre. +Smaller, subdued golden seeds occasionally pulse with a randomly triggered glow. +Missing a seed has no penalty. A second player joins by steering or pressing A, using an already reserved mint dreamer. Either player can slow seed movement, the drifting background and flower animation. New spawn intervals also lengthen, after the pending seed deadline. Flowers gradually replace the oldest ones when the garden fills up. -There is no death, score, countdown or difficulty ramp. Watching is welcome. Sound starts off. B stops the music and keeps the current flowers in a still garden. +There is no death, score, countdown or difficulty ramp. Watching is welcome. Music and bloom sounds start on. X toggles both, showing NOISY or QUIET +for two seconds before the text slides outward past the rim over 450 ms. +Another toggle resets the position and restarts the notice. B stops the music and keeps the current flowers in a still garden. A starts a new garden from the Rest scene. ## How the example works @@ -41,10 +50,13 @@ objects in `update()`. A collision grows a flower instead of ending a run. Pools cap seeds at 16 and flowers at 24, with explicit recycling for flowers. The second player costs one reserved sprite even before joining. -The Garden graph uses **42 sprites, 2 tilemaps, 2 layers and 5 image strips**. +The Garden graph uses **42 sprites, 3 tilemaps, 3 layers and 5 image strips**. The flower pool counts toward that cost even when its slots are hidden. The -ground uses sparse, mostly transparent 16-pixel tiles. The HUD says DRIFT or -SLOW near the top rim, with both flips so the letters read upright. +lower layer draws flowers first and players above them. The middle layer draws +smaller, darker flying accents in 16-pixel tiles, followed by 9-pixel seeds. +The top HUD shows RELAX while A is held, then slides it out on release, +plus temporary audio feedback. +Both flips keep the top text upright. The full CP437 font comes from the existing tutorial example. PNGs and YAML compile into a generated image ROM. MP3s stay on the emulator host or console @@ -62,4 +74,4 @@ mpy-cross games/demos/dream_garden/code/dream_garden.py -o /tmp/dream_garden.mpy ``` The tests exercise seam collisions, both controllers, shared slow time, safe -pool exhaustion, long play, opt-in audio, voluntary rest and the exit path. +pool exhaustion, long play, default-on audio and timed toggle feedback, voluntary rest and the exit path. diff --git a/games/demos/dream_garden/code/checkpoints/05-flowers.py b/games/demos/dream_garden/code/checkpoints/05-flowers.py index 50332bbb..5fc956ab 100644 --- a/games/demos/dream_garden/code/checkpoints/05-flowers.py +++ b/games/demos/dream_garden/code/checkpoints/05-flowers.py @@ -6,10 +6,12 @@ class Game(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.air = self.layer("air", projection=vs2.TUNNEL) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.flowers = self.world.sprite_pool("flowers.png", count=24, on_empty=vs2.RECYCLE) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) + self.last_direction = 1 self.speed = 0.45 self.spawn_ms = 1800 self.dream_time = 0 @@ -18,10 +20,16 @@ def build(self): def update(self): steer = joy1.held(LEFT) - joy1.held(RIGHT) self.dreamer.x = (self.dreamer.x + steer) % vs2.display.width + if steer: + self.last_direction = steer self.dreamer.frame = (3 if joy1.held(A) else 1 if steer > 0 else 2 if steer < 0 else 0) self.dream_time = (self.dream_time + self.speed) % 96 frame = int(self.dream_time // 3) % self.image("seed.png").frames + for flower in self.flowers: + flower.y += self.speed * 0.08 + if flower.y >= 255: + self.flowers.despawn(flower) for seed in self.seeds: seed.y -= self.speed seed.frame = frame @@ -35,9 +43,10 @@ def spawn_seed(self): self.call_later(self.spawn_ms, self.spawn_seed) def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.image("flowers.png").width // 2, - y=24, frame=randrange(4) * 2) + centre = self.dreamer.x + self.dreamer.width // 2 + centre += self.last_direction * self.dreamer.width * 0.75 + self.flowers.spawn(x=(centre - self.image("flowers.png").width // 2) % vs2.display.width, + y=self.dreamer.y, frame=randrange(4) * 2) self.seeds.despawn(seed) def main(): diff --git a/games/demos/dream_garden/code/checkpoints/06-tiles.py b/games/demos/dream_garden/code/checkpoints/06-tiles.py index 41a01a28..d1ecd74b 100644 --- a/games/demos/dream_garden/code/checkpoints/06-tiles.py +++ b/games/demos/dream_garden/code/checkpoints/06-tiles.py @@ -6,17 +6,19 @@ class Game(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) - self.ground = self.world.tilemap("garden.png", columns=16, rows=16, + self.air = self.layer("air", projection=vs2.TUNNEL) + self.ground = self.air.tilemap("garden.png", columns=16, rows=16, view_width=256, view_height=160) for row in range(16): for col in range(16): band = row % 6 self.ground[col, row] = ((col + band) % 8 if (col + band * 3) % 5 == 0 else vs2.EMPTY_TILE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.flowers = self.world.sprite_pool("flowers.png", count=24, on_empty=vs2.RECYCLE) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) + self.last_direction = 1 self.speed = 0.45 self.spawn_ms = 1800 self.dream_time = 0 @@ -25,10 +27,16 @@ def build(self): def update(self): steer = joy1.held(LEFT) - joy1.held(RIGHT) self.dreamer.x = (self.dreamer.x + steer) % vs2.display.width + if steer: + self.last_direction = steer self.dreamer.frame = (3 if joy1.held(A) else 1 if steer > 0 else 2 if steer < 0 else 0) self.dream_time = (self.dream_time + self.speed) % 96 frame = int(self.dream_time // 3) % self.image("seed.png").frames + for flower in self.flowers: + flower.y += self.speed * 0.08 + if flower.y >= 255: + self.flowers.despawn(flower) for seed in self.seeds: seed.y -= self.speed seed.frame = frame @@ -42,9 +50,10 @@ def spawn_seed(self): self.call_later(self.spawn_ms, self.spawn_seed) def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.image("flowers.png").width // 2, - y=24, frame=randrange(4) * 2) + centre = self.dreamer.x + self.dreamer.width // 2 + centre += self.last_direction * self.dreamer.width * 0.75 + self.flowers.spawn(x=(centre - self.image("flowers.png").width // 2) % vs2.display.width, + y=self.dreamer.y, frame=randrange(4) * 2) self.seeds.despawn(seed) def main(): diff --git a/games/demos/dream_garden/code/checkpoints/07-scroll.py b/games/demos/dream_garden/code/checkpoints/07-scroll.py index 5238e729..9646e1b5 100644 --- a/games/demos/dream_garden/code/checkpoints/07-scroll.py +++ b/games/demos/dream_garden/code/checkpoints/07-scroll.py @@ -6,17 +6,19 @@ class Game(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) - self.ground = self.world.tilemap("garden.png", columns=16, rows=16, + self.air = self.layer("air", projection=vs2.TUNNEL) + self.ground = self.air.tilemap("garden.png", columns=16, rows=16, view_width=256, view_height=160) for row in range(16): for col in range(16): band = row % 6 self.ground[col, row] = ((col + band) % 8 if (col + band * 3) % 5 == 0 else vs2.EMPTY_TILE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.flowers = self.world.sprite_pool("flowers.png", count=24, on_empty=vs2.RECYCLE) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) + self.last_direction = 1 self.speed = 0.45 self.spawn_ms = 1800 self.dream_time = 0 @@ -26,12 +28,18 @@ def build(self): def update(self): steer = joy1.held(LEFT) - joy1.held(RIGHT) self.dreamer.x = (self.dreamer.x + steer) % vs2.display.width + if steer: + self.last_direction = steer self.dreamer.frame = (3 if joy1.held(A) else 1 if steer > 0 else 2 if steer < 0 else 0) self.dream_time = (self.dream_time + self.speed) % 96 frame = int(self.dream_time // 3) % self.image("seed.png").frames self.scroll = (self.scroll + self.speed) % (6 * self.ground.tile_height) self.ground.view_y = self.scroll + for flower in self.flowers: + flower.y += self.speed * 0.08 + if flower.y >= 255: + self.flowers.despawn(flower) for seed in self.seeds: seed.y -= self.speed seed.frame = frame @@ -45,9 +53,10 @@ def spawn_seed(self): self.call_later(self.spawn_ms, self.spawn_seed) def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.image("flowers.png").width // 2, - y=24, frame=randrange(4) * 2) + centre = self.dreamer.x + self.dreamer.width // 2 + centre += self.last_direction * self.dreamer.width * 0.75 + self.flowers.spawn(x=(centre - self.image("flowers.png").width // 2) % vs2.display.width, + y=self.dreamer.y, frame=randrange(4) * 2) self.seeds.despawn(seed) def main(): diff --git a/games/demos/dream_garden/code/checkpoints/08-hud.py b/games/demos/dream_garden/code/checkpoints/08-hud.py index 83673bd2..b52cdbbd 100644 --- a/games/demos/dream_garden/code/checkpoints/08-hud.py +++ b/games/demos/dream_garden/code/checkpoints/08-hud.py @@ -2,21 +2,36 @@ import vs2 from vs2.controls import A, B, LEFT, RIGHT, joy1 from urandom import randrange +from utime import ticks_ms, ticks_diff + +HUD_EXIT_MS = 450 + + +def slide_text_out(label, start_y, started): + elapsed = ticks_diff(ticks_ms(), started) + if elapsed >= HUD_EXIT_MS: + label.hide() + return None + label.y = start_y - (start_y + label.tile_height) * elapsed / HUD_EXIT_MS + return started + class Game(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) - self.ground = self.world.tilemap("garden.png", columns=16, rows=16, + self.air = self.layer("air", projection=vs2.TUNNEL) + self.ground = self.air.tilemap("garden.png", columns=16, rows=16, view_width=256, view_height=160) for row in range(16): for col in range(16): band = row % 6 self.ground[col, row] = ((col + band) % 8 if (col + band * 3) % 5 == 0 else vs2.EMPTY_TILE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.flowers = self.world.sprite_pool("flowers.png", count=24, on_empty=vs2.RECYCLE) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) + self.last_direction = 1 self.speed = 0.45 self.spawn_ms = 1800 self.dream_time = 0 @@ -24,7 +39,8 @@ def build(self): self.scroll = 0 hud = self.layer("hud", projection=vs2.HUD) self.mood = hud.label("steel8x8.png", columns=5, x=108, y=1, - flip_x=True, flip_y=True, text="DRIFT") + flip_x=True, flip_y=True, text="RELAX", visible=False) + self.mood_exit = None self.slow = False self.slow_ready = not joy1.held(A) @@ -34,17 +50,30 @@ def update(self): slow = self.slow_ready and joy1.held(A) if slow != self.slow: self.slow = slow - self.mood.text = "SLOW" if slow else "DRIFT" + if slow: + self.mood.y = 1 + self.mood.show() + self.mood_exit = None + else: + self.mood_exit = ticks_ms() + if self.mood_exit is not None: + self.mood_exit = slide_text_out(self.mood, 1, self.mood_exit) self.speed = 0.12 if slow else 0.45 self.spawn_ms = 4000 if slow else 1800 steer = joy1.held(LEFT) - joy1.held(RIGHT) self.dreamer.x = (self.dreamer.x + steer) % vs2.display.width + if steer: + self.last_direction = steer self.dreamer.frame = (3 if slow else 1 if steer > 0 else 2 if steer < 0 else 0) self.dream_time = (self.dream_time + self.speed) % 96 frame = int(self.dream_time // 3) % self.image("seed.png").frames self.scroll = (self.scroll + self.speed) % (6 * self.ground.tile_height) self.ground.view_y = self.scroll + for flower in self.flowers: + flower.y += self.speed * 0.08 + if flower.y >= 255: + self.flowers.despawn(flower) for seed in self.seeds: seed.y -= self.speed seed.frame = frame @@ -58,9 +87,10 @@ def spawn_seed(self): self.call_later(self.spawn_ms, self.spawn_seed) def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.image("flowers.png").width // 2, - y=24, frame=randrange(4) * 2) + centre = self.dreamer.x + self.dreamer.width // 2 + centre += self.last_direction * self.dreamer.width * 0.75 + self.flowers.spawn(x=(centre - self.image("flowers.png").width // 2) % vs2.display.width, + y=self.dreamer.y, frame=randrange(4) * 2) self.seeds.despawn(seed) def main(): diff --git a/games/demos/dream_garden/code/checkpoints/09-scenes.py b/games/demos/dream_garden/code/checkpoints/09-scenes.py index 143f544a..099a898a 100644 --- a/games/demos/dream_garden/code/checkpoints/09-scenes.py +++ b/games/demos/dream_garden/code/checkpoints/09-scenes.py @@ -2,21 +2,36 @@ import vs2 from vs2.controls import A, B, LEFT, RIGHT, joy1 from urandom import randrange +from utime import ticks_ms, ticks_diff + +HUD_EXIT_MS = 450 + + +def slide_text_out(label, start_y, started): + elapsed = ticks_diff(ticks_ms(), started) + if elapsed >= HUD_EXIT_MS: + label.hide() + return None + label.y = start_y - (start_y + label.tile_height) * elapsed / HUD_EXIT_MS + return started + class Game(vs2.Scene): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) - self.ground = self.world.tilemap("garden.png", columns=16, rows=16, + self.air = self.layer("air", projection=vs2.TUNNEL) + self.ground = self.air.tilemap("garden.png", columns=16, rows=16, view_width=256, view_height=160) for row in range(16): for col in range(16): band = row % 6 self.ground[col, row] = ((col + band) % 8 if (col + band * 3) % 5 == 0 else vs2.EMPTY_TILE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.flowers = self.world.sprite_pool("flowers.png", count=24, on_empty=vs2.RECYCLE) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) + self.last_direction = 1 self.speed = 0.45 self.spawn_ms = 1800 self.dream_time = 0 @@ -24,29 +39,43 @@ def build(self): self.scroll = 0 hud = self.layer("hud", projection=vs2.HUD) self.mood = hud.label("steel8x8.png", columns=5, x=108, y=1, - flip_x=True, flip_y=True, text="DRIFT") + flip_x=True, flip_y=True, text="RELAX", visible=False) + self.mood_exit = None self.slow = False self.slow_ready = not joy1.held(A) def update(self): if joy1.just_pressed(B): - return self.switch(Rest([(flower.x, flower.frame) for flower in self.flowers])) + return self.switch(Rest([(flower.x, flower.y, flower.frame) for flower in self.flowers])) if not joy1.held(A): self.slow_ready = True slow = self.slow_ready and joy1.held(A) if slow != self.slow: self.slow = slow - self.mood.text = "SLOW" if slow else "DRIFT" + if slow: + self.mood.y = 1 + self.mood.show() + self.mood_exit = None + else: + self.mood_exit = ticks_ms() + if self.mood_exit is not None: + self.mood_exit = slide_text_out(self.mood, 1, self.mood_exit) self.speed = 0.12 if slow else 0.45 self.spawn_ms = 4000 if slow else 1800 steer = joy1.held(LEFT) - joy1.held(RIGHT) self.dreamer.x = (self.dreamer.x + steer) % vs2.display.width + if steer: + self.last_direction = steer self.dreamer.frame = (3 if slow else 1 if steer > 0 else 2 if steer < 0 else 0) self.dream_time = (self.dream_time + self.speed) % 96 frame = int(self.dream_time // 3) % self.image("seed.png").frames self.scroll = (self.scroll + self.speed) % (6 * self.ground.tile_height) self.ground.view_y = self.scroll + for flower in self.flowers: + flower.y += self.speed * 0.08 + if flower.y >= 255: + self.flowers.despawn(flower) for seed in self.seeds: seed.y -= self.speed seed.frame = frame @@ -60,9 +89,10 @@ def spawn_seed(self): self.call_later(self.spawn_ms, self.spawn_seed) def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.image("flowers.png").width // 2, - y=24, frame=randrange(4) * 2) + centre = self.dreamer.x + self.dreamer.width // 2 + centre += self.last_direction * self.dreamer.width * 0.75 + self.flowers.spawn(x=(centre - self.image("flowers.png").width // 2) % vs2.display.width, + y=self.dreamer.y, frame=randrange(4) * 2) self.seeds.despawn(seed) def label(hud, text, y): @@ -73,7 +103,7 @@ class Title(vs2.Scene): def build(self): hud = self.layer("hud", projection=vs2.HUD) label(hud, "DREAM GARDEN", 1) - label(hud, "A TO DRIFT", 13) + label(hud, "A TO BEGIN", 13) def update(self): if joy1.just_pressed(A): @@ -87,8 +117,8 @@ def __init__(self, flowers): def build(self): world = self.layer("world", projection=vs2.TUNNEL) flowers = world.sprite_pool("flowers.png", count=24) - for x, frame in self.saved_flowers: - flowers.spawn(x=x, y=48, frame=frame) + for x, y, frame in self.saved_flowers: + flowers.spawn(x=x, y=y, frame=frame) hud = self.layer("hud", projection=vs2.HUD) label(hud, "REST A WHILE", 1) label(hud, "A: NEW DREAM", 14) diff --git a/games/demos/dream_garden/code/checkpoints/10-shared.py b/games/demos/dream_garden/code/checkpoints/10-shared.py index a9e5ffff..5eb46326 100644 --- a/games/demos/dream_garden/code/checkpoints/10-shared.py +++ b/games/demos/dream_garden/code/checkpoints/10-shared.py @@ -1,13 +1,14 @@ """Dream Garden: a shared, unhurried circular garden for Overkill 2026. -LEFT/RIGHT drift, hold A to slow the whole garden, B rests, X toggles audio. +LEFT/RIGHT drift, hold A to relax the whole garden, B rests. A second controller can join at any time. Watching is also a way to play. """ from urandom import randrange +from utime import ticks_ms, ticks_diff import vs2 -from vs2.controls import A, B, X, LEFT, RIGHT, joy1, joy2 +from vs2.controls import A, B, LEFT, RIGHT, joy1, joy2 AWAKE, LEFT_POSE, RIGHT_POSE, ASLEEP = range(4) DRIFT_SPEED = 0.45 @@ -17,6 +18,21 @@ SEED_DEPTH = 160 FLOWER_COUNT = 24 PATTERN_ROWS = 6 +FLOWER_DRIFT_RATIO = 0.08 +FLOWER_DEPTH = 255 +GLOW_FRAMES = (1, 2, 3, 2, 1, 0) +GLOW_FRAME_TICKS = 6 +HUD_EXIT_MS = 450 + + +def slide_text_out(label, start_y, started): + elapsed = ticks_diff(ticks_ms(), started) + if elapsed >= HUD_EXIT_MS: + label.hide() + return None + label.y = start_y - (start_y + label.tile_height) * elapsed / HUD_EXIT_MS + return started + def garden_tiles(world): @@ -46,31 +62,40 @@ def __init__(self, joined=False): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) + self.air = self.layer("air", projection=vs2.TUNNEL) self.hud = self.layer("hud", projection=vs2.HUD) - self.ground = garden_tiles(self.world) + self.ground = garden_tiles(self.air) self.flowers = self.world.sprite_pool( "flowers.png", count=FLOWER_COUNT, on_empty=vs2.RECYCLE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) self.friend = self.world.sprite("dreamer.png", x=64 - self.dreamer.width // 2, y=0, frame=4, visible=self.joined) self.mood = self.hud.label("steel8x8.png", columns=5, x=108, y=1, - flip_x=True, flip_y=True, text="DRIFT") + flip_x=True, flip_y=True, text="RELAX", visible=False) + self.mood_exit = None self.flower_half = self.image("flowers.png").width // 2 self.flower_phase = 0 self.scroll = 0 self.dream_time = 0 self.slow = False + # Start toward LEFT; stopping keeps each player’s last steering direction. + self.last_direction1 = 1 + self.last_direction2 = 1 + self.seed_glows = {} self.speed = DRIFT_SPEED self.spawn_ms = DRIFT_SPAWN_MS - for index in range(4): - self.flowers.spawn(x=32 + index * 64 - self.flower_half, y=24, frame=index * 2) self.call_later(self.spawn_ms, self.spawn_seed) def steer(self, dreamer, controller, base_frame, sleeping): direction = controller.held(LEFT) - controller.held(RIGHT) dreamer.x = (dreamer.x + direction) % vs2.display.width + if direction: + if dreamer is self.friend: + self.last_direction2 = direction + else: + self.last_direction1 = direction pose = (ASLEEP if sleeping else LEFT_POSE if direction > 0 else RIGHT_POSE if direction < 0 else AWAKE) @@ -78,7 +103,7 @@ def steer(self, dreamer, controller, base_frame, sleeping): def update(self): if joy1.just_pressed(B) or joy2.just_pressed(B): - flowers = [(flower.x, flower.frame) for flower in self.flowers] + flowers = [(flower.x, flower.y, flower.frame) for flower in self.flowers] return self.switch(Rest(flowers)) if not self.joined and (joy2.held(LEFT) or joy2.held(RIGHT) @@ -98,7 +123,14 @@ def update(self): slow = slow1 or slow2 if slow != self.slow: self.slow = slow - self.mood.text = "SLOW" if slow else "DRIFT" + if slow: + self.mood.y = 1 + self.mood.show() + self.mood_exit = None + else: + self.mood_exit = ticks_ms() + if self.mood_exit is not None: + self.mood_exit = slide_text_out(self.mood, 1, self.mood_exit) self.speed = SLOW_SPEED if slow else DRIFT_SPEED self.spawn_ms = SLOW_SPAWN_MS if slow else DRIFT_SPAWN_MS self.dream_time = (self.dream_time + self.speed) % 96 @@ -110,25 +142,49 @@ def update(self): self.flower_phase = phase for flower in self.flowers: flower.frame = (flower.frame // 2) * 2 + phase + for flower in self.flowers: + flower.y += self.speed * FLOWER_DRIFT_RATIO + if flower.y >= FLOWER_DEPTH: + self.flowers.despawn(flower) self.move_seeds() - - def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.flower_half, y=24, + def bloom(self, seed, player): + side = self.last_direction2 if player is self.friend else self.last_direction1 + centre = player.x + player.width // 2 + self.flowers.spawn(x=(centre + side * player.width * 0.75 + - self.flower_half) % vs2.display.width, y=player.y, frame=randrange(4) * 2 + self.flower_phase) - self.seeds.despawn(seed) + self.retire_seed(seed) def move_seeds(self): - frame = int(self.dream_time // 3) % self.image("seed.png").frames for seed in self.seeds: seed.y -= self.speed - seed.frame = frame - if (seed.overlaps(self.dreamer) - or (self.joined and seed.overlaps(self.friend))): - self.bloom(seed) + self.glow_seed(seed) + if seed.overlaps(self.dreamer): + self.bloom(seed, self.dreamer) + elif self.joined and seed.overlaps(self.friend): + self.bloom(seed, self.friend) elif seed.y < -seed.image.height: - self.seeds.despawn(seed) # passing seeds cause no harm + self.retire_seed(seed) # passing seeds cause no harm + + def glow_seed(self, seed): + state = self.seed_glows.get(seed) + if state is None: + state = [randrange(90, 241), 0] + self.seed_glows[seed] = state + if state[0] > 0: + state[0] -= 1 + seed.frame = 0 + return + seed.frame = GLOW_FRAMES[state[1] // GLOW_FRAME_TICKS] + state[1] += 1 + if state[1] == len(GLOW_FRAMES) * GLOW_FRAME_TICKS: + state[0] = randrange(90, 241) + state[1] = 0 + + def retire_seed(self, seed): + self.seed_glows.pop(seed, None) + self.seeds.despawn(seed) def spawn_seed(self): self.seeds.spawn(x=randrange(vs2.display.width), y=SEED_DEPTH) @@ -140,8 +196,8 @@ class Title(vs2.Scene): def build(self): hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "DREAM GARDEN", y=1) - centred_label(hud, "A TO DRIFT", y=13) - centred_label(hud, "HOLD A: SLOW", y=25) + centred_label(hud, "A TO BEGIN", y=13) + centred_label(hud, "HOLD A: RELAX", y=25) def update(self): if joy1.just_pressed(A) or joy2.just_pressed(A): @@ -156,9 +212,8 @@ def __init__(self, flowers): def build(self): world = self.layer("world", projection=vs2.TUNNEL) flowers = world.sprite_pool("flowers.png", count=FLOWER_COUNT) - for x, frame in self.saved_flowers: - # Settle inward, leaving the rim clear for the invitation. - flowers.spawn(x=x, y=48, frame=frame) + for x, y, frame in self.saved_flowers: + flowers.spawn(x=x, y=y, frame=frame) hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "REST A WHILE", y=1) centred_label(hud, "A: NEW DREAM", y=14) diff --git a/games/demos/dream_garden/code/checkpoints/11-audio.py b/games/demos/dream_garden/code/checkpoints/11-audio.py index fb262510..94ce62cf 100644 --- a/games/demos/dream_garden/code/checkpoints/11-audio.py +++ b/games/demos/dream_garden/code/checkpoints/11-audio.py @@ -5,6 +5,7 @@ """ from urandom import randrange +from utime import ticks_ms, ticks_add, ticks_diff import vs2 from vs2.controls import A, B, X, LEFT, RIGHT, joy1, joy2 @@ -17,6 +18,20 @@ SEED_DEPTH = 160 FLOWER_COUNT = 24 PATTERN_ROWS = 6 +FLOWER_DRIFT_RATIO = 0.08 +FLOWER_DEPTH = 255 +GLOW_FRAMES = (1, 2, 3, 2, 1, 0) +GLOW_FRAME_TICKS = 6 +HUD_EXIT_MS = 450 + + +def slide_text_out(label, start_y, started): + elapsed = ticks_diff(ticks_ms(), started) + if elapsed >= HUD_EXIT_MS: + label.hide() + return None + label.y = start_y - (start_y + label.tile_height) * elapsed / HUD_EXIT_MS + return started def garden_tiles(world): @@ -46,32 +61,46 @@ def __init__(self, joined=False): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) + self.air = self.layer("air", projection=vs2.TUNNEL) self.hud = self.layer("hud", projection=vs2.HUD) - self.ground = garden_tiles(self.world) + self.ground = garden_tiles(self.air) self.flowers = self.world.sprite_pool( "flowers.png", count=FLOWER_COUNT, on_empty=vs2.RECYCLE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) self.friend = self.world.sprite("dreamer.png", x=64 - self.dreamer.width // 2, y=0, frame=4, visible=self.joined) self.mood = self.hud.label("steel8x8.png", columns=5, x=108, y=1, - flip_x=True, flip_y=True, text="DRIFT") + flip_x=True, flip_y=True, text="RELAX", visible=False) + self.mood_exit = None + self.sound_notice = self.hud.label("steel8x8.png", columns=5, x=108, y=11, + flip_x=True, flip_y=True, visible=False) + self.sound_notice_until = None + self.sound_notice_exit = None self.flower_half = self.image("flowers.png").width // 2 self.flower_phase = 0 self.scroll = 0 self.dream_time = 0 self.slow = False - self.sound_on = False + self.sound_on = True + vs2.audio.music("lullaby", loop=True) + # Start toward LEFT; stopping keeps each player’s last steering direction. + self.last_direction1 = 1 + self.last_direction2 = 1 + self.seed_glows = {} self.speed = DRIFT_SPEED self.spawn_ms = DRIFT_SPAWN_MS - for index in range(4): - self.flowers.spawn(x=32 + index * 64 - self.flower_half, y=24, frame=index * 2) self.call_later(self.spawn_ms, self.spawn_seed) def steer(self, dreamer, controller, base_frame, sleeping): direction = controller.held(LEFT) - controller.held(RIGHT) dreamer.x = (dreamer.x + direction) % vs2.display.width + if direction: + if dreamer is self.friend: + self.last_direction2 = direction + else: + self.last_direction1 = direction pose = (ASLEEP if sleeping else LEFT_POSE if direction > 0 else RIGHT_POSE if direction < 0 else AWAKE) @@ -79,7 +108,7 @@ def steer(self, dreamer, controller, base_frame, sleeping): def update(self): if joy1.just_pressed(B) or joy2.just_pressed(B): - flowers = [(flower.x, flower.frame) for flower in self.flowers] + flowers = [(flower.x, flower.y, flower.frame) for flower in self.flowers] vs2.audio.stop_music() return self.switch(Rest(flowers)) @@ -100,7 +129,14 @@ def update(self): slow = slow1 or slow2 if slow != self.slow: self.slow = slow - self.mood.text = "SLOW" if slow else "DRIFT" + if slow: + self.mood.y = 1 + self.mood.show() + self.mood_exit = None + else: + self.mood_exit = ticks_ms() + if self.mood_exit is not None: + self.mood_exit = slide_text_out(self.mood, 1, self.mood_exit) self.speed = SLOW_SPEED if slow else DRIFT_SPEED self.spawn_ms = SLOW_SPAWN_MS if slow else DRIFT_SPAWN_MS self.dream_time = (self.dream_time + self.speed) % 96 @@ -112,33 +148,70 @@ def update(self): self.flower_phase = phase for flower in self.flowers: flower.frame = (flower.frame // 2) * 2 + phase + for flower in self.flowers: + flower.y += self.speed * FLOWER_DRIFT_RATIO + if flower.y >= FLOWER_DEPTH: + self.flowers.despawn(flower) self.move_seeds() + if (self.sound_notice_until is not None + and ticks_diff(ticks_ms(), self.sound_notice_until) >= 0): + self.sound_notice_exit = self.sound_notice_until + self.sound_notice_until = None + if self.sound_notice_exit is not None: + self.sound_notice_exit = slide_text_out(self.sound_notice, 11, + self.sound_notice_exit) if joy1.just_pressed(X) or joy2.just_pressed(X): self.sound_on = not self.sound_on if self.sound_on: vs2.audio.music("lullaby", loop=True) else: vs2.audio.stop_music() - - def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.flower_half, y=24, + self.sound_notice.text = "NOISY" if self.sound_on else "QUIET" + self.sound_notice.y = 11 + self.sound_notice.show() + self.sound_notice_exit = None + self.sound_notice_until = ticks_add(ticks_ms(), 2000) + + def bloom(self, seed, player): + side = self.last_direction2 if player is self.friend else self.last_direction1 + centre = player.x + player.width // 2 + self.flowers.spawn(x=(centre + side * player.width * 0.75 + - self.flower_half) % vs2.display.width, y=player.y, frame=randrange(4) * 2 + self.flower_phase) - self.seeds.despawn(seed) + self.retire_seed(seed) if self.sound_on: vs2.audio.sound("bloom") def move_seeds(self): - frame = int(self.dream_time // 3) % self.image("seed.png").frames for seed in self.seeds: seed.y -= self.speed - seed.frame = frame - if (seed.overlaps(self.dreamer) - or (self.joined and seed.overlaps(self.friend))): - self.bloom(seed) + self.glow_seed(seed) + if seed.overlaps(self.dreamer): + self.bloom(seed, self.dreamer) + elif self.joined and seed.overlaps(self.friend): + self.bloom(seed, self.friend) elif seed.y < -seed.image.height: - self.seeds.despawn(seed) # passing seeds cause no harm + self.retire_seed(seed) # passing seeds cause no harm + + def glow_seed(self, seed): + state = self.seed_glows.get(seed) + if state is None: + state = [randrange(90, 241), 0] + self.seed_glows[seed] = state + if state[0] > 0: + state[0] -= 1 + seed.frame = 0 + return + seed.frame = GLOW_FRAMES[state[1] // GLOW_FRAME_TICKS] + state[1] += 1 + if state[1] == len(GLOW_FRAMES) * GLOW_FRAME_TICKS: + state[0] = randrange(90, 241) + state[1] = 0 + + def retire_seed(self, seed): + self.seed_glows.pop(seed, None) + self.seeds.despawn(seed) def spawn_seed(self): self.seeds.spawn(x=randrange(vs2.display.width), y=SEED_DEPTH) @@ -150,8 +223,8 @@ class Title(vs2.Scene): def build(self): hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "DREAM GARDEN", y=1) - centred_label(hud, "A TO DRIFT", y=13) - centred_label(hud, "HOLD A: SLOW", y=25) + centred_label(hud, "A TO BEGIN", y=13) + centred_label(hud, "HOLD A: RELAX", y=25) def update(self): if joy1.just_pressed(A) or joy2.just_pressed(A): @@ -166,9 +239,8 @@ def __init__(self, flowers): def build(self): world = self.layer("world", projection=vs2.TUNNEL) flowers = world.sprite_pool("flowers.png", count=FLOWER_COUNT) - for x, frame in self.saved_flowers: - # Settle inward, leaving the rim clear for the invitation. - flowers.spawn(x=x, y=48, frame=frame) + for x, y, frame in self.saved_flowers: + flowers.spawn(x=x, y=y, frame=frame) hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "REST A WHILE", y=1) centred_label(hud, "A: NEW DREAM", y=14) diff --git a/games/demos/dream_garden/code/dream_garden.py b/games/demos/dream_garden/code/dream_garden.py index fb262510..94ce62cf 100644 --- a/games/demos/dream_garden/code/dream_garden.py +++ b/games/demos/dream_garden/code/dream_garden.py @@ -5,6 +5,7 @@ """ from urandom import randrange +from utime import ticks_ms, ticks_add, ticks_diff import vs2 from vs2.controls import A, B, X, LEFT, RIGHT, joy1, joy2 @@ -17,6 +18,20 @@ SEED_DEPTH = 160 FLOWER_COUNT = 24 PATTERN_ROWS = 6 +FLOWER_DRIFT_RATIO = 0.08 +FLOWER_DEPTH = 255 +GLOW_FRAMES = (1, 2, 3, 2, 1, 0) +GLOW_FRAME_TICKS = 6 +HUD_EXIT_MS = 450 + + +def slide_text_out(label, start_y, started): + elapsed = ticks_diff(ticks_ms(), started) + if elapsed >= HUD_EXIT_MS: + label.hide() + return None + label.y = start_y - (start_y + label.tile_height) * elapsed / HUD_EXIT_MS + return started def garden_tiles(world): @@ -46,32 +61,46 @@ def __init__(self, joined=False): def build(self): self.world = self.layer("world", projection=vs2.TUNNEL) + self.air = self.layer("air", projection=vs2.TUNNEL) self.hud = self.layer("hud", projection=vs2.HUD) - self.ground = garden_tiles(self.world) + self.ground = garden_tiles(self.air) self.flowers = self.world.sprite_pool( "flowers.png", count=FLOWER_COUNT, on_empty=vs2.RECYCLE) - self.seeds = self.world.sprite_pool("seed.png", count=16) + self.seeds = self.air.sprite_pool("seed.png", count=16) self.dreamer = self.world.sprite("dreamer.png", y=0) self.dreamer.x = -(self.dreamer.width // 2) self.friend = self.world.sprite("dreamer.png", x=64 - self.dreamer.width // 2, y=0, frame=4, visible=self.joined) self.mood = self.hud.label("steel8x8.png", columns=5, x=108, y=1, - flip_x=True, flip_y=True, text="DRIFT") + flip_x=True, flip_y=True, text="RELAX", visible=False) + self.mood_exit = None + self.sound_notice = self.hud.label("steel8x8.png", columns=5, x=108, y=11, + flip_x=True, flip_y=True, visible=False) + self.sound_notice_until = None + self.sound_notice_exit = None self.flower_half = self.image("flowers.png").width // 2 self.flower_phase = 0 self.scroll = 0 self.dream_time = 0 self.slow = False - self.sound_on = False + self.sound_on = True + vs2.audio.music("lullaby", loop=True) + # Start toward LEFT; stopping keeps each player’s last steering direction. + self.last_direction1 = 1 + self.last_direction2 = 1 + self.seed_glows = {} self.speed = DRIFT_SPEED self.spawn_ms = DRIFT_SPAWN_MS - for index in range(4): - self.flowers.spawn(x=32 + index * 64 - self.flower_half, y=24, frame=index * 2) self.call_later(self.spawn_ms, self.spawn_seed) def steer(self, dreamer, controller, base_frame, sleeping): direction = controller.held(LEFT) - controller.held(RIGHT) dreamer.x = (dreamer.x + direction) % vs2.display.width + if direction: + if dreamer is self.friend: + self.last_direction2 = direction + else: + self.last_direction1 = direction pose = (ASLEEP if sleeping else LEFT_POSE if direction > 0 else RIGHT_POSE if direction < 0 else AWAKE) @@ -79,7 +108,7 @@ def steer(self, dreamer, controller, base_frame, sleeping): def update(self): if joy1.just_pressed(B) or joy2.just_pressed(B): - flowers = [(flower.x, flower.frame) for flower in self.flowers] + flowers = [(flower.x, flower.y, flower.frame) for flower in self.flowers] vs2.audio.stop_music() return self.switch(Rest(flowers)) @@ -100,7 +129,14 @@ def update(self): slow = slow1 or slow2 if slow != self.slow: self.slow = slow - self.mood.text = "SLOW" if slow else "DRIFT" + if slow: + self.mood.y = 1 + self.mood.show() + self.mood_exit = None + else: + self.mood_exit = ticks_ms() + if self.mood_exit is not None: + self.mood_exit = slide_text_out(self.mood, 1, self.mood_exit) self.speed = SLOW_SPEED if slow else DRIFT_SPEED self.spawn_ms = SLOW_SPAWN_MS if slow else DRIFT_SPAWN_MS self.dream_time = (self.dream_time + self.speed) % 96 @@ -112,33 +148,70 @@ def update(self): self.flower_phase = phase for flower in self.flowers: flower.frame = (flower.frame // 2) * 2 + phase + for flower in self.flowers: + flower.y += self.speed * FLOWER_DRIFT_RATIO + if flower.y >= FLOWER_DEPTH: + self.flowers.despawn(flower) self.move_seeds() + if (self.sound_notice_until is not None + and ticks_diff(ticks_ms(), self.sound_notice_until) >= 0): + self.sound_notice_exit = self.sound_notice_until + self.sound_notice_until = None + if self.sound_notice_exit is not None: + self.sound_notice_exit = slide_text_out(self.sound_notice, 11, + self.sound_notice_exit) if joy1.just_pressed(X) or joy2.just_pressed(X): self.sound_on = not self.sound_on if self.sound_on: vs2.audio.music("lullaby", loop=True) else: vs2.audio.stop_music() - - def bloom(self, seed): - centre = seed.x + seed.width // 2 - self.flowers.spawn(x=centre - self.flower_half, y=24, + self.sound_notice.text = "NOISY" if self.sound_on else "QUIET" + self.sound_notice.y = 11 + self.sound_notice.show() + self.sound_notice_exit = None + self.sound_notice_until = ticks_add(ticks_ms(), 2000) + + def bloom(self, seed, player): + side = self.last_direction2 if player is self.friend else self.last_direction1 + centre = player.x + player.width // 2 + self.flowers.spawn(x=(centre + side * player.width * 0.75 + - self.flower_half) % vs2.display.width, y=player.y, frame=randrange(4) * 2 + self.flower_phase) - self.seeds.despawn(seed) + self.retire_seed(seed) if self.sound_on: vs2.audio.sound("bloom") def move_seeds(self): - frame = int(self.dream_time // 3) % self.image("seed.png").frames for seed in self.seeds: seed.y -= self.speed - seed.frame = frame - if (seed.overlaps(self.dreamer) - or (self.joined and seed.overlaps(self.friend))): - self.bloom(seed) + self.glow_seed(seed) + if seed.overlaps(self.dreamer): + self.bloom(seed, self.dreamer) + elif self.joined and seed.overlaps(self.friend): + self.bloom(seed, self.friend) elif seed.y < -seed.image.height: - self.seeds.despawn(seed) # passing seeds cause no harm + self.retire_seed(seed) # passing seeds cause no harm + + def glow_seed(self, seed): + state = self.seed_glows.get(seed) + if state is None: + state = [randrange(90, 241), 0] + self.seed_glows[seed] = state + if state[0] > 0: + state[0] -= 1 + seed.frame = 0 + return + seed.frame = GLOW_FRAMES[state[1] // GLOW_FRAME_TICKS] + state[1] += 1 + if state[1] == len(GLOW_FRAMES) * GLOW_FRAME_TICKS: + state[0] = randrange(90, 241) + state[1] = 0 + + def retire_seed(self, seed): + self.seed_glows.pop(seed, None) + self.seeds.despawn(seed) def spawn_seed(self): self.seeds.spawn(x=randrange(vs2.display.width), y=SEED_DEPTH) @@ -150,8 +223,8 @@ class Title(vs2.Scene): def build(self): hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "DREAM GARDEN", y=1) - centred_label(hud, "A TO DRIFT", y=13) - centred_label(hud, "HOLD A: SLOW", y=25) + centred_label(hud, "A TO BEGIN", y=13) + centred_label(hud, "HOLD A: RELAX", y=25) def update(self): if joy1.just_pressed(A) or joy2.just_pressed(A): @@ -166,9 +239,8 @@ def __init__(self, flowers): def build(self): world = self.layer("world", projection=vs2.TUNNEL) flowers = world.sprite_pool("flowers.png", count=FLOWER_COUNT) - for x, frame in self.saved_flowers: - # Settle inward, leaving the rim clear for the invitation. - flowers.spawn(x=x, y=48, frame=frame) + for x, y, frame in self.saved_flowers: + flowers.spawn(x=x, y=y, frame=frame) hud = self.layer("hud", projection=vs2.HUD) centred_label(hud, "REST A WHILE", y=1) centred_label(hud, "A: NEW DREAM", y=14) diff --git a/games/demos/dream_garden/images/garden.png b/games/demos/dream_garden/images/garden.png index 8e8adc44..e7723de7 100644 Binary files a/games/demos/dream_garden/images/garden.png and b/games/demos/dream_garden/images/garden.png differ diff --git a/games/demos/dream_garden/images/seed.png b/games/demos/dream_garden/images/seed.png index befd7d89..e3e85091 100644 Binary files a/games/demos/dream_garden/images/seed.png and b/games/demos/dream_garden/images/seed.png differ diff --git a/games/demos/dream_garden/sources/README.md b/games/demos/dream_garden/sources/README.md index 78f0f58e..87355f7e 100644 --- a/games/demos/dream_garden/sources/README.md +++ b/games/demos/dream_garden/sources/README.md @@ -10,7 +10,9 @@ Neither Node nor sharp is a game runtime dependency. The generated sheet contains two dream creatures, golden seeds, four flower species and sparse garden accents. Dreamers have four poses each. Flowers have -two frames each. `images/steel8x8.png` is copied unchanged from the SDK's +two frames each. Seeds are packed at 9 × 9 pixels: a subdued idle frame and +three brighter glow frames. Flying accents are shrunk inside their 16-pixel +tiles and darkened so the planted flowers remain distinct. `images/steel8x8.png` is copied unchanged from the SDK's `games/demos/tutorial_game/images/steel8x8.png`. `make_audio.py` synthesizes the original bloom chime and a 32-second soft loop diff --git a/games/demos/dream_garden/sources/prepare-atlas.cjs b/games/demos/dream_garden/sources/prepare-atlas.cjs index ecd0a71b..49eda5db 100644 --- a/games/demos/dream_garden/sources/prepare-atlas.cjs +++ b/games/demos/dream_garden/sources/prepare-atlas.cjs @@ -7,7 +7,7 @@ const directory = path.resolve(__dirname, '..'); const source = path.join(__dirname, 'source-atlas.png'); let atlas; -async function frame(row, column, size, padding) { +async function frame(row, column, size, padding, brightness=1) { const metadata = atlas.info; const left = Math.round(column * metadata.width / 8); const top = Math.round(row * metadata.height / 4); @@ -28,12 +28,15 @@ async function frame(row, column, size, padding) { .resize(size-padding*2, size-padding*2, {fit:'contain', kernel:'nearest', background:'#00000000'}) .extend({top:padding,bottom:padding,left:padding,right:padding, - background:'#00000000'}).png().toBuffer(); + background:'#00000000'}).modulate({brightness}).png().toBuffer(); } -async function strip(name, row, columns, size, padding=1) { +async function strip(name, row, columns, size, padding=1, brightness=1) { const frames = []; - for (const column of columns) frames.push(await frame(row,column,size,padding)); + for (const [index, column] of columns.entries()) { + frames.push(await frame(row,column,size,padding, + Array.isArray(brightness) ? brightness[index] : brightness)); + } await sharp({create:{width:size*frames.length,height:size,channels:4, background:'#00000000'}}).composite(frames.map((input,index)=>({input, left:index*size,top:0}))).png().toFile(path.join(directory,'images',name)); @@ -43,9 +46,11 @@ async function strip(name, row, columns, size, padding=1) { atlas = await sharp(source).ensureAlpha().raw().toBuffer({resolveWithObject: true}); // Generated lean poses face right then left, so swap them in each set. await strip('dreamer.png',0,[0,2,1,3,4,6,5,7],19); - await strip('seed.png',1,[0,1,2,3],12); + // Subdued at rest; the game occasionally plays a brightening pulse. + await strip('seed.png',1,[0,1,2,3],9,1,[0.85,1,1.15,1.3]); await strip('flowers.png',2,[0,1,2,3,4,5,6,7],17); - await strip('garden.png',3,[0,1,2,3,4,5,6,7],16,2); + // Keep 16-pixel tile spacing while shrinking and dimming the flying accents. + await strip('garden.png',3,[0,1,2,3,4,5,6,7],16,4,0.85); const icon = await frame(0,0,29,1); await sharp({create:{width:64,height:30,channels:4,background:'#00000000'}}) .composite([{input:icon,left:18,top:0}]).png() diff --git a/tests/test_dream_garden.py b/tests/test_dream_garden.py index 01fd80f0..9a6d378c 100644 --- a/tests/test_dream_garden.py +++ b/tests/test_dream_garden.py @@ -27,7 +27,7 @@ class _Utime: STRIPS = { "dreamer.png": (19, 19, 8, None), - "seed.png": (12, 12, 4, None), + "seed.png": (9, 9, 4, None), "flowers.png": (17, 17, 8, None), "garden.png": (16, 16, 8, None), "steel8x8.png": (8, 8, 256, None), @@ -97,6 +97,163 @@ def test_steering_wraps_and_opposites_cancel(self): self.step(LEFT | RIGHT) self.assertEqual(garden.dreamer.x, 255) + def test_empty_garden_and_three_ordered_layers(self): + garden = self.start() + self.assertEqual(len(garden.flowers), 0) + self.assertEqual([layer.name for layer in garden.layers], ["world", "air", "hud"]) + self.assertIs(garden.dreamer.layer, garden.world) + self.assertIs(garden.friend.layer, garden.world) + self.assertTrue(all(sprite.layer is garden.world for sprite in garden.flowers._free)) + self.assertIs(garden.ground.layer, garden.air) + self.assertTrue(all(sprite.layer is garden.air for sprite in garden.seeds._free)) + # All reserved flowers precede both dreamers in the lower layer. + self.assertEqual(garden.world.sprites[-2:], [garden.dreamer, garden.friend]) + self.assertEqual(garden.hud.tilemaps, [garden.mood, garden.sound_notice]) + self.assertFalse(garden.mood.visible) + self.assertFalse(garden.sound_notice.visible) + + def test_planting_follows_last_motion_per_player_at_their_depth_and_wraps(self): + garden = self.start(player2=A) + garden.dreamer.y = 12 + garden.friend.y = 20 + for first, second, directions in ( + (0, 0, (1, 1)), + (LEFT, RIGHT, (1, -1)), + (0, 0, (1, -1)), + (LEFT | RIGHT, LEFT | RIGHT, (1, -1)), + (RIGHT | A, LEFT | A, (-1, 1)), + (0, 0, (-1, 1))): + self.step(first, second) + self.assertEqual((garden.last_direction1, garden.last_direction2), directions) + for player, side in zip((garden.dreamer, garden.friend), directions): + for _ in range(2): + seed = garden.seeds.spawn(x=100, y=100) + garden.bloom(seed, player) + flower = garden.flowers._newest + self.assertEqual(flower.y, player.y) + expected = (player.x + player.width // 2 + side * player.width * 0.75 + - garden.flower_half) % vs2.display.width + self.assertAlmostEqual(flower.x, expected) + self.assertEqual((garden.last_direction1, garden.last_direction2), directions) + + def test_catch_after_stopping_uses_heading_across_the_angular_seam(self): + garden = self.start() + self.assertEqual(garden.last_direction1, 1) + self.step(LEFT, count=10) + self.assertEqual(garden.dreamer.x, 1) + self.step() + garden.seeds.spawn(x=254, y=10) + self.step() + flower = garden.flowers._newest + expected = (garden.dreamer.x + garden.dreamer.width // 2 + + garden.dreamer.width * 0.75 - garden.flower_half) % 256 + self.assertAlmostEqual(flower.x, expected) + self.assertEqual(flower.y, garden.dreamer.y) + self.step(RIGHT, count=2) + self.assertEqual(garden.dreamer.x, 255) + self.step() + garden.seeds.spawn(x=254, y=10) + self.step() + flower = garden.flowers._newest + expected = (garden.dreamer.x + garden.dreamer.width // 2 + - garden.dreamer.width * 0.75 - garden.flower_half) % 256 + self.assertAlmostEqual(flower.x, expected) + + def test_flowers_drift_inward_much_slower_than_seeds_and_retire(self): + from games.demos.dream_garden.code.dream_garden import FLOWER_DRIFT_RATIO + garden = self.start() + flower = garden.flowers.spawn(x=100, y=20) + seed = garden.seeds.spawn(x=150, y=100) + for buttons in (0, A): + before_flower, before_seed = flower.y, seed.y + self.step(buttons) + self.assertAlmostEqual(flower.y - before_flower, garden.speed * FLOWER_DRIFT_RATIO) + self.assertAlmostEqual(before_seed - seed.y, garden.speed) + flower.y = 254.999 + self.step() + self.assertEqual(len(garden.flowers), 0) + + def test_seed_glow_is_random_independent_and_cleared_on_retirement(self): + from unittest.mock import patch + garden = self.start() + first = garden.seeds.spawn(x=100, y=100) + second = garden.seeds.spawn(x=150, y=100) + with patch.dict(garden.glow_seed.__globals__, {"randrange": lambda *args: 90}): + garden.glow_seed(first) + garden.glow_seed(second) + self.assertEqual(first.frame, 0) + garden.seed_glows[first][0] = 0 + frames = [] + for _ in range(36): + garden.glow_seed(first) + garden.glow_seed(second) + frames.append(first.frame) + self.assertEqual(second.frame, 0) + self.assertEqual(frames, [frame for frame in (1, 2, 3, 2, 1, 0) for _ in range(6)]) + self.assertEqual(garden.seed_glows[first], [90, 0]) + garden.retire_seed(first) + second.y = -10 + self.step() + self.assertEqual(garden.seed_glows, {}) + + def test_sound_toggle_notice_lasts_two_seconds_and_restarts_on_retoggling(self): + from unittest.mock import patch + garden = self.start() + now = [1000] + with patch.dict(garden.update.__globals__, {"ticks_ms": lambda: now[0]}): + self.step(X) + self.assertTrue(garden.sound_notice.visible) + self.assertEqual(garden.sound_notice_until, 3000) + now[0] = 2500 + self.step() + self.step(0, X) + self.assertEqual(garden.sound_notice.text, "NOISY") + self.assertEqual(garden.sound_notice_until, 4500) + now[0] = 4499 + self.step() + self.assertTrue(garden.sound_notice.visible) + now[0] = 4500 + self.step() + self.assertTrue(garden.sound_notice.visible) + self.assertIsNone(garden.sound_notice_until) + self.assertEqual(garden.sound_notice.y, 11) + now[0] = 4725 + self.step() + self.assertAlmostEqual(garden.sound_notice.y, 1.5) + now[0] = 4800 + self.step(X) + self.assertEqual(garden.sound_notice.text, "QUIET") + self.assertEqual(garden.sound_notice.y, 11) + self.assertIsNone(garden.sound_notice_exit) + now[0] = 7025 + self.step() + self.assertAlmostEqual(garden.sound_notice.y, 1.5) + now[0] = 7250 + self.step() + self.assertFalse(garden.sound_notice.visible) + self.assertIsNone(garden.sound_notice_exit) + + def test_relax_slides_out_and_a_new_request_cancels_the_exit(self): + from unittest.mock import patch + garden = self.start() + now = [1000] + with patch.dict(garden.update.__globals__, {"ticks_ms": lambda: now[0]}): + self.step(A) + self.step() + self.assertTrue(garden.mood.visible) + now[0] = 1225 + self.step() + self.assertAlmostEqual(garden.mood.y, -3.5) + self.assertTrue(garden.mood.visible) + self.step(0, A) + self.assertEqual(garden.mood.y, 1) + self.assertIsNone(garden.mood_exit) + self.step() + now[0] = 1675 + self.step() + self.assertFalse(garden.mood.visible) + self.assertIsNone(garden.mood_exit) + def test_second_player_joins_and_moves_independently(self): garden = self.start() self.assertFalse(garden.friend.visible) @@ -122,10 +279,12 @@ def test_either_player_slows_every_seed_and_the_background(self): self.assertAlmostEqual(seed.y, 100 - SLOW_SPEED, places=2) self.assertAlmostEqual(garden.scroll - previous_scroll, SLOW_SPEED, places=2) self.assertEqual(garden.spawn_ms, SLOW_SPAWN_MS) - self.assertEqual(garden.mood.text, "SLOW") + self.assertEqual(garden.mood.text, "RELAX") + self.assertTrue(garden.mood.visible) self.assertEqual(garden.friend.frame, 7) self.step() - self.assertEqual(garden.mood.text, "DRIFT") + self.assertTrue(garden.mood.visible) + self.assertIsNotNone(garden.mood_exit) self.step(A) self.assertTrue(garden.slow) self.assertEqual(garden.dreamer.frame, 3) @@ -149,7 +308,7 @@ def test_player_two_also_grows_flowers(self): def test_missing_a_seed_has_no_penalty(self): garden = self.start() before = len(list(garden.flowers)) - garden.seeds.spawn(x=100, y=-12) + garden.seeds.spawn(x=100, y=-9) self.step() self.assertEqual(len(list(garden.seeds)), 0) self.assertEqual(len(list(garden.flowers)), before) @@ -166,22 +325,26 @@ def test_full_pools_skip_seeds_and_recycle_flowers(self): garden.seeds.despawn_all() starting = [(flower.x, flower.frame) for flower in garden.flowers] for index in range(80): - garden.bloom(garden.seeds.spawn(x=130, y=10)) + garden.bloom(garden.seeds.spawn(x=130, y=10), garden.dreamer) self.assertTrue(all((flower.x, flower.frame) not in starting for flower in garden.flowers)) self.assertEqual(len(list(garden.flowers)), 24) self.assertEqual(garden._sprite_count, 42) - self.assertEqual(garden._tilemap_count, 2) + self.assertEqual(garden._tilemap_count, 3) vs2.export_scene_payload(garden) def test_rest_preserves_the_garden_and_a_begins_a_new_dream(self): from games.demos.dream_garden.code.dream_garden import Garden, Rest garden = self.start(keep_timer=True) self.assertEqual(len(garden.pending_calls), 1) - flowers = [(flower.x, flower.frame) for flower in garden.flowers] + garden.bloom(garden.seeds.spawn(x=130, y=10), garden.dreamer) + self.step(LEFT, count=10) + flowers = [(flower.x, flower.y, flower.frame) for flower in garden.flowers] self.step(B) rest = director.scene_stack[-1] self.assertIsInstance(rest, Rest) self.assertEqual(rest.saved_flowers, flowers) + self.assertEqual([(flower.x, flower.y, flower.frame) + for flower in rest.layers[0].sprites if flower.visible], flowers) self.assertEqual(garden.pending_calls, []) self.step() self.step(0, A) @@ -257,6 +420,11 @@ def test_every_presentation_checkpoint_runs_and_has_only_its_stage_features(self scene.seeds.spawn(x=scene.dreamer.x, y=10) self.step() self.assertGreater(len(scene.flowers), 0) + flower = scene.flowers._newest + expected = (scene.dreamer.x + scene.dreamer.width // 2 + + scene.dreamer.width * 0.75 + - scene.image("flowers.png").width // 2) % 256 + self.assertAlmostEqual(flower.x, expected) vs2.export_scene_payload(scene) self.step(extra=8) self.step() @@ -276,17 +444,20 @@ def test_packed_art_has_binary_alpha_and_host_tools_are_not_runtime_files(self): with Image.open(Path(ROOT) / "games/demos/dream_garden/images" / name) as image: self.assertLessEqual(set(image.convert("RGBA").getchannel("A").tobytes()), {0, 255}) - def test_audio_is_opt_in_and_stops_on_rest(self): + def test_audio_starts_on_toggles_and_stops_on_rest(self): garden = self.start() - self.assertFalse(garden.sound_on) - self.step(X) self.assertTrue(garden.sound_on) self.assertTrue(any(b"lullaby" in line for line, _ in director.platform.comms.sent)) - self.step() - self.step(X) + garden.bloom(garden.seeds.spawn(x=130, y=10), garden.dreamer) + self.assertTrue(any(b"bloom" in line for line, _ in director.platform.comms.sent)) + self.step(0, X) self.assertFalse(garden.sound_on) + self.assertEqual(garden.sound_notice.text, "QUIET") + self.assertEqual(director.platform.comms.sent[-1][0], b"music off") self.step() self.step(X) + self.assertTrue(garden.sound_on) + self.assertEqual(garden.sound_notice.text, "NOISY") self.step(B) self.assertEqual(director.platform.comms.sent[-1][0], b"music off") @@ -300,9 +471,11 @@ def test_long_drift_keeps_resources_and_time_bounded(self): RIGHT if tick % 50 < 25 else 0) self.assertIs(director.scene_stack[-1], garden) self.assertEqual(garden._sprite_count, 42) - self.assertEqual(garden._tilemap_count, 2) + self.assertEqual(garden._tilemap_count, 3) self.assertLessEqual(len(list(garden.seeds)), 16) self.assertLessEqual(len(list(garden.flowers)), 24) + self.assertLessEqual(len(garden.seed_glows), 16) + self.assertTrue(all(seed in list(garden.seeds) for seed in garden.seed_glows)) self.assertLess(garden.dream_time, 96) self.assertLess(garden.scroll, 96) vs2.export_scene_payload(garden)