afterglow/game.py¶

Part of Afterglow.

  1"""Afterglow game root: the full state machine that hosts the flagship demo.
  2
  3``GameRoot`` is the App root node and the single long-lived host for everything:
  4the 3D diorama scene, the post-processing environment, the lighting rig, the
  5game-feel effects layer, per-world music, the HUD, and the menu screens. It runs
  6a small explicit state machine and swaps the active UI screen by add/remove child
  7(never a whole-App scene change), so the 3D scene and audio survive transitions:
  8
  9    TITLE  ->  LEVEL_SELECT  ->  PLAY(room)  ->  RESULTS
 10                                   ^  |
 11                          PAUSE / OPTIONS overlays
 12
 13* TITLE shows a slowly idle-rotating 3D diorama behind the menu for flair.
 14* LEVEL_SELECT lists worlds/rooms from ``Progress`` (best times, shards, locks).
 15* PLAY builds the per-room 3D view, reads input via ``controls.read_player_input``,
 16  steps the sim, forwards events to ``Effects``, and syncs view + camera + HUD.
 17  Death respawns instantly; a win records progress and advances to the next room
 18  (or to RESULTS at the end of a world, unlocking the next).
 19* PAUSE / OPTIONS overlay PLAY without tearing down the room. Options apply live
 20  (volumes, screenshake, photosensitive-safe, assist toggles) and persist.
 21
 22Progress loads on boot and saves on every meaningful change and on exit.
 23"""
 24
 25from __future__ import annotations
 26
 27import logging
 28import math
 29import os
 30import sys
 31
 32from afterglow.assets import audio
 33from afterglow.progress import Progress
 34from afterglow.sim.entities import GLOW_FROM_ORB
 35from afterglow.sim.room import Room
 36from afterglow.sim.rooms_data import WORLDS
 37from afterglow.ui import menus
 38from afterglow.ui.controls import ControlHints, RoomIntro, read_player_input, should_show_touch
 39from afterglow.ui.hud import HUD
 40from afterglow.ui.menus import LevelSelect, OptionsMenu, PauseMenu, ResultsScreen, TitleScreen
 41from afterglow.view.camera import WORLD_SCALE, GameCamera
 42from afterglow.view.diorama import Diorama
 43from afterglow.view.effects import Effects
 44from afterglow.view.environment import per_world_environment
 45from afterglow.view.lighting import setup_world_lights
 46from afterglow.view.sprites import EntityView
 47from afterglow.view.title_backdrop import TitleBackdrop
 48from simvx.core import (
 49    Anchor,
 50    AudioBusLayout,
 51    AudioPlayer,
 52    Controller,
 53    Input,
 54    Key,
 55    Label,
 56    Node,
 57    Node3D,
 58    Quat,
 59    Vec3,
 60)
 61from simvx.core.input.router import InputRouter
 62from simvx.core.ui import TouchController, TouchControllerLayout
 63
 64log = logging.getLogger(__name__)
 65
 66# Game states.
 67TITLE = "title"
 68LEVEL_SELECT = "level_select"
 69PLAY = "play"
 70RESULTS = "results"
 71
 72#: Real-time delay (seconds) between death and instant respawn.
 73RESPAWN_DELAY = 0.4
 74
 75#: Real-time pause (seconds) after reaching the exit before advancing / showing
 76#: results, so the next room / menu does not appear under still-held keys.
 77WIN_DELAY = 0.9
 78
 79#: World ids in play order (mirrors WORLDS / progress.WORLD_ORDER).
 80_WORLD_ORDER = [w["id"] for w in WORLDS]
 81_WORLD_BY_ID = {w["id"]: w for w in WORLDS}
 82
 83#: Screenshot/gallery affordance ONLY: when ``AFTERGLOW_COVER=glade:3`` (worldid:roomidx)
 84#: is set, the game boots straight into that play room instead of the title screen
 85#: and runs a tiny scripted "walk right" so the no-input gallery thumbnail captures a
 86#: Wisp-in-room moment rather than the menu. Unset in every normal run; never affects
 87#: gameplay, input, or progress. The value defaults to a hand-picked glade room.
 88_COVER_ENV = "AFTERGLOW_COVER"
 89
 90#: Self-diagnosing console trace. When ``AFTERGLOW_DEBUG`` is set in the
 91#: environment the game prints a ``[afterglow]`` line on every room entry, death,
 92#: reset/respawn, win/advance and ``_enter_play`` call, plus a once-per-second
 93#: fps/dt heartbeat while playing. Captured once at import so the per-frame path
 94#: is a single bool test (zero work when unset).
 95_DEBUG = bool(os.environ.get("AFTERGLOW_DEBUG"))
 96
 97
 98def _dbg(msg: str) -> None:
 99    """Print one ``[afterglow]`` trace line (no-op unless ``AFTERGLOW_DEBUG`` set)."""
100    if _DEBUG:
101        print(f"[afterglow] {msg}", flush=True)
102
103
104#: Assist tuning.
105_ASSIST_SLOW_SCALE = 0.6  # time scale when "slow time" assist is on
106_EXTENDED_GLOW = 8.0  # glow_timer floor topped up each frame under extended-glow
107
108
109def _world_room_count(world_id: str) -> int:
110    world = _WORLD_BY_ID.get(world_id)
111    return len(world["rooms"]) if world else 0
112
113
114class GameRoot(Node):
115    """Full Afterglow game: state machine hosting the 3D scene + menus + audio."""
116
117    # Declared at class scope, so they are in force for the whole tree while
118    # this root is mounted, on the desktop and the web alike. Keyboard + controller.
119    input_context = {
120        "move_left": [Key.LEFT, Key.A, Controller.DPAD_LEFT],
121        "move_right": [Key.RIGHT, Key.D, Controller.DPAD_RIGHT],
122        "move_up": [Key.UP, Key.W, Controller.DPAD_UP],
123        "move_down": [Key.DOWN, Key.S, Controller.DPAD_DOWN],
124        "jump": [Key.SPACE, Controller.A],
125        # B as well as X: the on-screen pad draws the two face buttons a thumb
126        # can reach, and dash is the second of them.
127        "dash": [Key.LEFT_SHIFT, Key.J, Controller.X, Controller.B],
128        "pause": [Key.ESCAPE, Controller.START],
129        # NB: confirm is Enter / controller-A only, NOT Space. Space is the jump key;
130        # binding it to confirm too let a still-focused menu button re-fire on every
131        # in-game jump and restart the room.
132        "confirm": [Key.ENTER, Controller.A],
133        "back": [Key.ESCAPE, Controller.B],
134        "restart": [Key.R],
135    }
136
137    def __init__(self, **kwargs):
138        super().__init__(**kwargs)
139        self.progress = Progress.load()
140        self.state = TITLE
141
142        # 3D scene host (everything 3D lives under this one node).
143        self._scene: Node3D | None = None
144        self._env = None
145        self._diorama: Diorama | None = None
146        self._entities: EntityView | None = None
147        self._camera: GameCamera | None = None
148        self._title_backdrop: TitleBackdrop | None = None
149        self._lights = None
150        self._effects: Effects | None = None
151        self._music: AudioPlayer | None = None
152        self._music_world: str | None = None
153
154        # Active room + location.
155        self.room: Room | None = None
156        self._world_id = _WORLD_ORDER[0]
157        self._room_index = 0
158        self._respawn_timer = 0.0
159        self._win_timer = 0.0
160        self._title_spin = 0.0
161        self._cover_walk: float | None = None  # screenshot-only scripted walk timer
162        self._cover_window: InputRouter | None = None  # the keyboard the cover walk presses
163        self._cover_held: set[Key] = set()
164        self._dbg_was_dead = False  # AFTERGLOW_DEBUG: death-edge detection
165        self._dbg_hb = 0.0  # AFTERGLOW_DEBUG: fps heartbeat accumulator
166
167        # Per-world / run tallies for the results screen.
168        self._world_time = 0.0
169        self._world_deaths = 0
170        self._world_shards = 0
171
172        # UI: persistent HUD + on-screen pad + the one active menu screen.
173        self._hud: HUD | None = None
174        self._touch: TouchController | None = None
175        self._intro: RoomIntro | None = None  # fading room-name card on entry
176        self._hints: ControlHints | None = None  # first-room control cheat-sheet
177        self._loading: Label | None = None  # "Loading" indicator during scene build
178        self._build_pending = False  # defer the heavy scene build one frame
179        self._screen = None  # current menu overlay (Control) or None during PLAY
180
181        # Assist toggles the OptionsMenu offers but the Progress schema does not
182        # persist (infinite dash, extended glow): tracked locally, in-memory only.
183        self._assist_extra: dict[str, bool] = {
184            "assist_infinite_dash": False,
185            "assist_extended_glow": False,
186        }
187
188    # -- lifecycle ---------------------------------------------------------
189
190    def on_ready(self) -> None:
191        self._scene = self.add_child(Node3D(name="Scene3D"))
192        self._effects = Effects(
193            reduced_motion=bool(self.progress.get_option("photosensitive_safe")),
194            screenshake_enabled=bool(self.progress.get_option("screenshake")),
195        )
196        self._apply_audio_volumes()
197        # Menus are audio-free on their own; hand them a sink so navigating and
198        # confirming are audible (and follow the SFX volume slider).
199        menus.play_sound = self._play_ui_sfx
200        # Re-evaluate the touch overlay on every resize / orientation change so a
201        # window shrunk small (or a device rotated to portrait) surfaces or hides
202        # the virtual pad mid-room, not just at the next room load.
203        self.tree.screen_resized.connect(self._refresh_touch)
204
205        # Gallery/screenshot affordance (see ``_COVER_ENV``): boot straight into a
206        # representative play room so the no-input thumbnail shows the game, not a
207        # menu. No effect in normal runs (env var unset).
208        cover = os.environ.get(_COVER_ENV)
209        if cover:
210            wid, _, idx = cover.partition(":")
211            self._cover_walk = 0.0
212            self._enter_play(wid or _WORLD_ORDER[0], int(idx or 0))
213            return
214
215        self._cover_walk = None
216        self._enter_title()
217
218    def on_update(self, dt: float) -> None:
219        if self.state == TITLE:
220            self._update_title(dt)
221        elif self.state == PLAY:
222            self._update_play(dt)
223        # LEVEL_SELECT / RESULTS are pure UI: nothing to tick here.
224
225    # ======================================================================
226    # 3D scene (built once per room; reused across rooms of the same shape)
227    # ======================================================================
228
229    def _build_scene(self, world_id: str, room: Room) -> None:
230        """(Re)build the whole 3D view + lighting + effects for ``room``.
231
232        Music is deliberately NOT touched here: the looping per-world bed is owned
233        by ``_play_music`` / ``_stop_music`` and must survive a scene rebuild. The
234        caller (``_enter_play``) calls ``_play_music(world_id)`` after this, which
235        is a no-op when the world is unchanged, so advancing to the next room of
236        the SAME world keeps one continuous music player instead of tearing it
237        down and spawning a fresh one (the audible per-room music restart).
238        """
239        scene = self._scene
240        scene.clear_children()
241        self._title_backdrop = None  # destroyed by clear_children above
242
243        self._env = scene.add_child(per_world_environment(world_id))
244        self._diorama = scene.add_child(Diorama())
245        self._diorama.build(room)
246        self._entities = scene.add_child(EntityView())
247        self._entities.build(room)
248        self._camera = scene.add_child(GameCamera())
249        self._camera.frame_room(room)
250        self._lights = setup_world_lights(world_id, room.w, room.h).add_to(scene)
251
252        # clear_children() above destroyed the previous room's pooled emitters and
253        # ambient motes; drop the stale references so attach rebuilds them here.
254        self._effects.reset()
255        self._effects.attach(scene, self._camera, self.app)
256        self._effects.set_ambient_centre(Vec3(room.w * 0.5, -room.h * 0.5, 0.0))
257        self._sync_lights()
258
259    def _sync_lights(self) -> None:
260        if self._lights is None or self.room is None:
261            return
262        p = self.room.player
263        pos = Vec3(p.cx * WORLD_SCALE, -p.cy * WORLD_SCALE, 0.0)
264        self._lights.update(pos, glowing=p.glowing)
265
266    def _teardown_scene(self) -> None:
267        """Drop the 3D view (used when returning to the title's idle diorama)."""
268        if self._scene is not None:
269            self._scene.clear_children()
270        self._env = self._diorama = self._entities = self._camera = None
271        self._title_backdrop = None
272        self._lights = None
273        self._stop_music()
274
275    # ======================================================================
276    # Music
277    # ======================================================================
278
279    def _play_music(self, world_id: str) -> None:
280        if self._music_world == world_id:
281            return
282        self._stop_music()
283        self._music = AudioPlayer(stream=audio.build_music(world_id), bus=audio.MUSIC_BUS)
284        self._music.loop = True
285        self._music.autoplay = True
286        self.add_child(self._music)
287        self._music_world = world_id
288
289    def _stop_music(self) -> None:
290        if self._music is not None:
291            # Stop the channel NOW: destroy() only removes the node (and fires the
292            # AudioPlayer's stop-on-exit) at end of frame, so without this the old
293            # bed keeps playing over the newly started one for the rest of the
294            # frame, layering two tracks on every world change.
295            self._music.stop()
296            self._music.destroy()
297            self._music = None
298        self._music_world = None
299
300    # ======================================================================
301    # TITLE
302    # ======================================================================
303
304    def _enter_title(self) -> None:
305        self.state = TITLE
306        self.room = None
307        self._hide_play_overlays()
308        # A CLEAN, intentional abstract backdrop (NOT a gameplay room): a dark
309        # field of soft drifting motes over the environment's gradient sky, so
310        # the menu never reads as a confusing blurred level behind it.
311        scene = self._scene
312        scene.clear_children()
313        self._diorama = self._entities = None
314        env = scene.add_child(per_world_environment(_WORLD_ORDER[0]))
315        env.bloom_intensity = 1.2  # let the motes glow softly against the dark
316        env.vignette_intensity = 0.42  # heavier vignette frames it as menu art
317        self._title_backdrop = scene.add_child(TitleBackdrop(name="TitleBackdrop"))
318        self._title_backdrop.build()
319        self._camera = scene.add_child(GameCamera())
320        self._frame_title_camera()
321        setup_world_lights(_WORLD_ORDER[0]).add_to(scene)
322        self._title_spin = 0.0
323        self._play_music(_WORLD_ORDER[0])
324
325        # Offer "Continue" only when there is progress to resume (the first
326        # unfinished room is past the very first room).
327        can_continue = self._first_unfinished() != (_WORLD_ORDER[0], 0)
328        screen = TitleScreen(can_continue=can_continue, name="TitleScreen")
329        screen.on_play = self._title_play
330        screen.on_continue = self._title_continue
331        screen.on_levels = self._enter_level_select
332        screen.on_options = lambda: self._open_options(return_to=self._enter_title)
333        screen.on_quit = self._quit
334        self._set_screen(screen)
335
336    def _frame_title_camera(self) -> None:
337        """Point the camera at the abstract backdrop (centred on the origin)."""
338        cam = self._camera
339        if cam is None:
340            return
341        cam.position = Vec3(0.0, 0.0, 9.0)
342        cam.look_at((0.0, 0.0, 0.0))
343
344    def _update_title(self, dt: float) -> None:
345        # Drift the decorative motes + a barely-there scene sway for life.
346        if self._scene is None:
347            return
348        if self._title_backdrop is not None:
349            self._title_backdrop.update(dt)
350        self._title_spin += dt * 0.18
351        self._scene.rotation = Quat.from_euler(0.0, math.sin(self._title_spin) * 0.06, 0.0)
352
353    def _title_play(self) -> None:
354        # Play always starts a fresh run at the very first room (completion data is
355        # kept, so Level Select stars and Continue still work).
356        self._enter_play(_WORLD_ORDER[0], 0)
357
358    def _title_continue(self) -> None:
359        # Continue resumes at the first not-yet-completed room of the first
360        # unlocked world, else the first room.
361        wid, idx = self._first_unfinished()
362        self._enter_play(wid, idx)
363
364    def _first_unfinished(self) -> tuple[str, int]:
365        for wid in _WORLD_ORDER:
366            if not self.progress.is_world_unlocked(wid):
367                continue
368            for i in range(_world_room_count(wid)):
369                if not self.progress.is_completed(wid, i):
370                    return wid, i
371        return _WORLD_ORDER[0], 0
372
373    # ======================================================================
374    # LEVEL_SELECT
375    # ======================================================================
376
377    def _enter_level_select(self) -> None:
378        self.state = LEVEL_SELECT
379        self._hide_play_overlays()
380        screen = LevelSelect(WORLDS, self.progress, screen_size=self.tree.screen_size, name="LevelSelect")
381        screen.on_select = self._enter_play
382        screen.on_back = self._enter_title
383        self._set_screen(screen)
384
385    # ======================================================================
386    # PLAY
387    # ======================================================================
388
389    def _enter_play(self, world_id: str, room_index: int) -> None:
390        if _DEBUG:
391            caller = sys._getframe(1).f_code.co_name
392            _dbg(f"_enter_play(world={world_id!r}, room={room_index}) called by {caller}()")
393        if world_id not in _WORLD_BY_ID or not (0 <= room_index < _world_room_count(world_id)):
394            world_id, room_index = _WORLD_ORDER[0], 0
395        new_world = world_id != self._world_id or self.state != PLAY
396        # Reset per-world tallies when starting a fresh world from the menu.
397        if new_world:
398            self._world_time = 0.0
399            self._world_deaths = 0
400            self._world_shards = 0
401
402        self.state = PLAY
403        self._world_id = world_id
404        self._room_index = room_index
405        self._scene.rotation = Quat()
406        self.room = Room(_WORLD_BY_ID[world_id]["rooms"][room_index])
407        self._respawn_timer = 0.0
408        self._win_timer = 0.0
409
410        # Cover/screenshot mode builds synchronously (no menus, deterministic frame).
411        if self._cover_walk is not None:
412            self._finish_enter_play()
413            return
414        # Normal play: defer the heavy diorama build one frame so the "Loading"
415        # indicator paints first. The displayed frame stays "Loading" until the
416        # build (which blocks the next frame) completes, so there is clear feedback
417        # instead of an apparent hang/crash.
418        self._set_screen(None)
419        self._show_loading(True)
420        self._build_pending = True
421
422    def _finish_enter_play(self) -> None:
423        """Build the deferred room scene + UI (runs the frame after _enter_play)."""
424        self._build_scene(self._world_id, self.room)
425        self._play_music(self._world_id)
426        self._ensure_hud()
427        self._refresh_touch()
428        self._show_room_intro(self._world_id, self._room_index)
429        self._show_loading(False)
430        self._set_screen(None)
431
432        if _DEBUG:
433            p = self.room.player
434            _dbg(
435                f"ENTER ROOM world={self._world_id!r} index={self._room_index} "
436                f"name={self.room.name!r} spawn=({p.x:.1f},{p.y:.1f})"
437            )
438        # Per-frame death/respawn edge detection + fps heartbeat (debug-only).
439        self._dbg_was_dead = False
440        self._dbg_hb = 0.0
441
442    def _show_loading(self, on: bool) -> None:
443        """Show / hide a centred 'Loading' indicator while a room scene builds."""
444        if on:
445            if self._loading is None:
446                lbl = Label(text="Loading…", name="Loading", anchor=Anchor.CENTER)
447                lbl.alignment = "centre"
448                self._loading = self.add_child(lbl)
449            self._loading.visible = True
450        elif self._loading is not None:
451            self._loading.visible = False
452
453    def _show_room_intro(self, world_id: str, room_index: int) -> None:
454        """Flash the room's name on entry (so resuming Play is never a surprise),
455        and on the very first room of a fresh run show the control cheat-sheet.
456
457        Both overlays are persistent children built once and reused; ``cover``
458        screenshot mode keeps a clean menu-free look so it skips them.
459        """
460        if self._cover_walk is not None:
461            return
462        if self._intro is None:
463            self._intro = self.add_child(RoomIntro(name="RoomIntro"))
464        world = _WORLD_BY_ID.get(world_id, {})
465        subtitle = f"{world.get('name', '')}  ·  Room {room_index + 1}"
466        self._intro.show(self.room.name if self.room else "", subtitle.strip(" ·"))
467
468        # Control hints: only the tutorial (glade:0) of a brand-new save, where
469        # nothing has been completed yet, so a returning player is never nagged.
470        first_room = (world_id, room_index) == (_WORLD_ORDER[0], 0)
471        fresh = not self.progress.is_completed(_WORLD_ORDER[0], 0)
472        if first_room and fresh:
473            if self._hints is None:
474                self._hints = self.add_child(ControlHints(name="ControlHints"))
475            self._hints.show()
476        elif self._hints is not None:
477            self._hints.visible = False
478
479    def _cover_press(self, key: Key, down: bool) -> None:
480        """Hold or let go of *key* for the cover shot, through a window of its own like a player's."""
481        if self._cover_window is None:
482            self._cover_window = InputRouter(self.tree)
483        held = self._cover_held
484        if down != (key in held):
485            self._cover_window.key(key, down)
486            (held.add if down else held.discard)(key)
487
488    def _update_play(self, dt: float) -> None:
489        room = self.room
490        if room is None:
491            return
492
493        # Deferred room build: the previous frame painted the "Loading" indicator;
494        # do the heavy diorama build now (it blocks this frame, but Loading stays
495        # on screen until it finishes), then resume normal play next frame.
496        if self._build_pending:
497            self._build_pending = False
498            self._finish_enter_play()
499            return
500
501        # Cover-shot affordance: drive the Wisp right (then a brief dash) for a
502        # short window so the gallery thumbnail catches it mid-room with a trail.
503        if self._cover_walk is not None:
504            self._cover_walk += dt
505            self._cover_press(Key.RIGHT, self._cover_walk < 0.95)
506            self._cover_press(Key.LEFT_SHIFT, 0.45 <= self._cover_walk < 0.6)
507
508        # A menu overlay (pause / options) is open over PLAY: freeze the sim and
509        # swallow gameplay input so nothing moves, dies, wins, or restarts behind
510        # the menu. Resume/restart happen via the menu buttons (which clear the
511        # screen); the pause action only re-opens an already-open menu, so gate it.
512        if self._screen is not None:
513            return
514
515        if Input.just_pressed("pause"):
516            self._open_pause()
517            return
518        if Input.just_pressed("restart"):
519            self._restart_room()
520            return
521
522        assist = bool(self.progress.get_option("assist_mode"))
523        invincible = assist and self._assist("assist_invincible")
524        sim_dt = dt
525        if assist and self._assist("assist_slow_time"):
526            sim_dt = dt * _ASSIST_SLOW_SCALE
527
528        if _DEBUG:
529            self._dbg_trace_play(room, dt)
530
531        if room.dead and not invincible:
532            self._respawn_timer += dt
533            if self._respawn_timer >= RESPAWN_DELAY:
534                if _DEBUG:
535                    _dbg(f"RESPAWN room={room.name!r} (deaths so far={room.deaths})")
536                room.reset()
537                self._respawn_timer = 0.0
538        elif room.won:
539            # Brief beat after reaching the exit before advancing / showing the
540            # results menu, so it does not pop up under the player's still-held
541            # keys the instant they touch the exit.
542            self._win_timer += dt
543            if self._win_timer >= WIN_DELAY:
544                self._win_timer = 0.0
545                self._on_room_won()
546            return
547        else:
548            if room.dead and invincible:
549                room.reset()  # assist: shrug off the death immediately
550            inp = read_player_input(self.tree)
551            room.step(sim_dt, inp)
552            self._apply_assist_post_step(assist)
553
554        # Drive the view + feel from the post-step state.
555        self._effects.play_events(room.drain_events(), WORLD_SCALE)
556        self._entities.sync(room, dt)
557        self._camera.update(dt, room)
558        self._sync_lights()
559        if self._hud is not None:
560            self._hud.update(room, self.progress, world_id=self._world_id, room_index=self._room_index, dt=dt)
561        if self._intro is not None:
562            self._intro.update(dt)
563        if self._hints is not None:
564            self._hints.update(dt)
565
566    def _dbg_trace_play(self, room: Room, dt: float) -> None:
567        """Debug-only: log the first frame ``room.dead`` flips True (with WHY) and
568        a once-per-second fps/dt heartbeat. Cheap; only called when _DEBUG is set."""
569        if room.dead and not self._dbg_was_dead:
570            p = room.player
571            below = p.y > room.h * room.tile_size + room.tile_size
572            hazard = room._hazard_overlap(p)
573            why = "hazard tile overlap" if hazard else ("fell below room / out of bounds" if below else "unknown")
574            _dbg(f"DEATH room={room.name!r} player=({p.x:.1f},{p.y:.1f}) why={why}")
575        self._dbg_was_dead = room.dead
576
577        self._dbg_hb += dt
578        if self._dbg_hb >= 1.0:
579            fps = 1.0 / dt if dt > 0 else 0.0
580            _dbg(f"PLAY room={room.name!r} fps={fps:.1f} dt={dt * 1000:.1f}ms time={room.time:.1f}s")
581            self._dbg_hb = 0.0
582
583    def _assist(self, key: str) -> bool:
584        """Read an assist toggle, falling back to the schema-absent local dict."""
585        try:
586            return bool(self.progress.get_option(key))
587        except KeyError:
588            return self._assist_extra.get(key, False)
589
590    def _apply_assist_post_step(self, assist: bool) -> None:
591        if not assist or self.room is None:
592            return
593        p = self.room.player
594        if self._assist("assist_infinite_dash"):
595            p.refill_dash()
596        if self._assist("assist_extended_glow") and p.glow_timer > 0.0:
597            p.glow_timer = max(p.glow_timer, _EXTENDED_GLOW)
598
599    def _restart_room(self) -> None:
600        if self.room is not None:
601            if _DEBUG:
602                _dbg(f"RESTART room={self.room.name!r} (manual restart)")
603            self.room.reset()
604            self._respawn_timer = 0.0
605            self._dbg_was_dead = False
606
607    def _on_room_won(self) -> None:
608        room = self.room
609        # Cover/screenshot mode is a throwaway scripted run: never record results
610        # or save, so it can't pollute the player's real progress (and thus skip
611        # them past the tutorial room). Just hold the won room for the thumbnail.
612        if self._cover_walk is not None:
613            return
614        self.progress.record_room_result(
615            self._world_id,
616            self._room_index,
617            room.time,
618            room.shard_collected,
619            died_count=room.deaths,
620            world_room_count=_world_room_count(self._world_id),
621        )
622        self.progress.save()
623
624        self._world_time += room.time
625        self._world_deaths += room.deaths
626        if room.shard_collected:
627            self._world_shards += 1
628
629        last = self._room_index >= _world_room_count(self._world_id) - 1
630        if _DEBUG:
631            _dbg(
632                f"WIN room={room.name!r} time={room.time:.2f}s shard={room.shard_collected} "
633                f"deaths={room.deaths} -> {'RESULTS (world complete)' if last else 'advance to next room'}"
634            )
635        if last:
636            self._enter_results()
637        else:
638            self._enter_play(self._world_id, self._room_index + 1)
639
640    # ======================================================================
641    # RESULTS
642    # ======================================================================
643
644    def _enter_results(self) -> None:
645        self.state = RESULTS
646        self._hide_play_overlays()
647        self._teardown_scene_to_idle()
648
649        wid = self._world_id
650        widx = _WORLD_ORDER.index(wid) if wid in _WORLD_ORDER else 0
651        final = widx >= len(_WORLD_ORDER) - 1
652
653        screen = ResultsScreen(name="ResultsScreen")
654        screen.set_summary(
655            title="GAME COMPLETE" if final else f"{_WORLD_BY_ID[wid]['name'].upper()} COMPLETE",
656            total_time=self._world_time,
657            deaths=self._world_deaths,
658            shards=self._world_shards,
659            shards_total=_world_room_count(wid),
660            final=final,
661        )
662        next_world = None if final else _WORLD_ORDER[widx + 1]
663        screen.on_continue = self._enter_title if final else (lambda w=next_world: self._enter_play(w, 0))
664        screen.on_replay = lambda w=wid: self._enter_play(w, 0)
665        screen.on_map = self._enter_level_select
666        self._set_screen(screen)
667
668    def _teardown_scene_to_idle(self) -> None:
669        """Clear the gameplay diorama for the results screen.
670
671        The bright, bloomed level geometry otherwise renders behind the summary
672        panel and bleeds through its translucent backdrop, so the "<world>
673        COMPLETE" popup reads as if it is buried under a coloured map overlay.
674        Dropping the diorama (and its environment/camera) leaves the panel on a
675        clean dim backdrop. Music is deliberately left playing for continuity
676        (this is not ``_teardown_scene``, which also stops the bed).
677        """
678        self.room = None
679        if self._scene is not None:
680            self._scene.clear_children()
681        self._env = self._diorama = self._entities = self._camera = None
682        self._title_backdrop = None
683        self._lights = None
684
685    # ======================================================================
686    # PAUSE overlay
687    # ======================================================================
688
689    def _open_pause(self) -> None:
690        screen = PauseMenu(name="PauseMenu")
691        screen.on_resume = self._resume_from_pause
692        screen.on_restart = self._resume_then_restart
693        screen.on_options = lambda: self._open_options(return_to=self._open_pause)
694        screen.on_quit = self._enter_level_select
695        self._set_screen(screen)
696
697    def _resume_from_pause(self) -> None:
698        self._set_screen(None)
699
700    def _resume_then_restart(self) -> None:
701        self._restart_room()
702        self._set_screen(None)
703
704    # ======================================================================
705    # OPTIONS overlay
706    # ======================================================================
707
708    def _open_options(self, *, return_to) -> None:
709        screen = OptionsMenu(self.progress, allow_fullscreen=False, name="OptionsMenu")
710        screen.on_change = self._apply_option
711        screen.on_back = lambda: (self.progress.save(), return_to())
712        self._set_screen(screen)
713
714    def _apply_option(self, key: str, value) -> None:
715        """Apply a single option live and persist the whole document."""
716        try:
717            self.progress.set_option(key, value)
718        except KeyError:
719            # Assist-only keys the schema does not persist: keep them in-memory.
720            if key in self._assist_extra:
721                self._assist_extra[key] = bool(value)
722        if key in ("master_volume", "sfx_volume", "music_volume"):
723            self._apply_audio_volumes()
724        elif key == "screenshake" and self._effects is not None:
725            self._effects.screenshake_enabled = bool(value) and not self._effects.reduced_motion
726        elif key == "photosensitive_safe" and self._effects is not None:
727            self._effects.reduced_motion = bool(value)
728            self._effects.screenshake_enabled = bool(self.progress.get_option("screenshake")) and not bool(value)
729        self.progress.save()
730
731    def _play_ui_sfx(self, name: str) -> None:
732        """Play one menu sound and free the player when it ends.
733
734        Parented to the game root rather than the menu screen, so a sound started
735        by the button that closes a screen still finishes.
736        """
737        if self.tree is None:
738            return
739        player = AudioPlayer(stream=audio.get_sfx(name), bus=audio.SFX_BUS)
740        player.autoplay = True
741        player.queue_free_on_end = True
742        self.add_child(player)
743
744    def _apply_audio_volumes(self) -> None:
745        layout = AudioBusLayout.get_default()
746        for bus_name, opt in (
747            (audio.MASTER_BUS, "master_volume"),
748            (audio.SFX_BUS, "sfx_volume"),
749            (audio.MUSIC_BUS, "music_volume"),
750        ):
751            if layout.has_bus(bus_name):
752                layout.get_bus(bus_name).volume_db = _linear_to_db(float(self.progress.get_option(opt)))
753
754    # ======================================================================
755    # UI plumbing
756    # ======================================================================
757
758    def _set_screen(self, screen) -> None:
759        """Swap the active menu overlay; ``None`` clears it (PLAY).
760
761        The old screen is detached before it is freed. ``destroy()`` alone is a
762        deferred delete, so the torn-down screen would stay in the tree (and its
763        button stay focused) for the rest of the frame: a stray Enter could then
764        re-fire the title's Play button and skip into a fresh room. Detaching
765        runs ``Control._exit_tree`` immediately, which is where the engine hands
766        focus back, so the dead screen is unreachable at once. The new screen
767        re-establishes focus via its own ``focus_first()`` below.
768        """
769        if self._screen is not None:
770            self.remove_child(self._screen)
771            self._screen.destroy()
772            self._screen = None
773        if screen is not None:
774            self.add_child(screen)
775            self._screen = screen
776            focus = getattr(screen, "focus_first", None)
777            if focus is not None:
778                focus()
779
780    def _ensure_hud(self) -> None:
781        if self._hud is None:
782            self._hud = self.add_child(HUD(glow_max=GLOW_FROM_ORB, name="HUD"))
783        self._hud.visible = True
784
785    def _hide_play_overlays(self) -> None:
786        """Hide the HUD + touch controls when leaving PLAY for a menu screen.
787
788        Both are persistent children (built once, reused across rooms); hiding
789        keeps the last room's readout and the virtual pad off the menus instead
790        of drawing stale data / live inputs over the title, level-select, and
791        results screens.
792        """
793        if self._hud is not None:
794            self._hud.visible = False
795        if self._touch is not None:
796            self._touch.visible = False
797        if self._intro is not None:
798            self._intro.visible = False
799        if self._hints is not None:
800            self._hints.visible = False
801
802    def _refresh_touch(self, *_args) -> None:
803        # Only surface the on-screen pad during PLAY; on the menus the HUD/pad
804        # are hidden (see _hide_play_overlays). Accepts *args so it can also serve
805        # as the screen_resized handler (which passes the new size).
806        # Cover mode is a clean desktop-look thumbnail: keep the pad off it.
807        #
808        # The pad publishes as a real controller, so the D-pad and buttons drive
809        # the controller bindings `input_context` already declares and nothing
810        # else in the game knows a thumb was involved.
811        want = self.state == PLAY and self._cover_walk is None and should_show_touch(self.tree)
812        if want and self._touch is None:
813            self._touch = self.add_child(
814                TouchController(layout=TouchControllerLayout.DPAD_AND_BUTTONS, name="TouchController")
815            )
816        elif want and self._touch is not None:
817            self._touch.visible = True
818        elif not want and self._touch is not None:
819            self._touch.destroy()
820            self._touch = None
821
822    # ======================================================================
823    # Quit
824    # ======================================================================
825
826    def _quit(self) -> None:
827        try:
828            self.progress.save()
829        except OSError:
830            log.exception("Afterglow: failed to save progress on quit")
831        self.app.quit()
832
833    def on_exit_tree(self) -> None:
834        if menus.play_sound == self._play_ui_sfx:
835            menus.play_sound = None  # never leave the menus holding a dead root
836        try:
837            self.tree.screen_resized.disconnect(self._refresh_touch)
838        except ValueError, KeyError:
839            pass
840        try:
841            self.progress.save()
842        except OSError:
843            log.exception("Afterglow: failed to save progress on exit")
844
845
846def _linear_to_db(linear: float) -> float:
847    """Convert a 0..1 slider value to a bus dB (0 -> silence floor, 1 -> 0 dB)."""
848    linear = max(0.0, min(1.0, linear))
849    if linear <= 0.0:
850        return -80.0
851    return max(-80.0, 20.0 * math.log10(linear))