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))