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