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