afterglow/ui/menus.py¶
Part of Afterglow.
1"""Afterglow menu screens.
2
3Standalone, responsive ``Control`` screens built on the ``simvx.core.ui``
4toolkit. Each screen is self-contained: it owns its layout, draws its own dark
5on-theme panel + glowing accents, and exposes plain-callable hooks the game sets
6(``screen.on_play = handler``). No screen reaches into game state directly; the
7game wires the hooks and feeds in a ``Progress`` for state-driven screens (level
8select, options, results).
9
10All screens are anchored (``set_anchor_preset``) so they scale to any aspect
11ratio, keyboard / gamepad navigable (buttons take focus and are linked in tab
12order), and touch-tappable (buttons respond to ``MouseButton.LEFT``, which the
13web runtime surfaces touch through).
14
15Public screens:
16 * :class:`TitleScreen` -- wordmark + Play / Levels / Options / Quit.
17 * :class:`LevelSelect` -- the three worlds, rooms with tick / time / shard.
18 * :class:`PauseMenu` -- Resume / Restart / Options / Quit to map.
19 * :class:`OptionsMenu` -- volume sliders + toggles + assist group.
20 * :class:`ResultsScreen` -- per-world / final completion summary.
21"""
22
23from __future__ import annotations
24
25from collections.abc import Callable, Sequence
26
27from simvx.core import (
28 AnchorPreset,
29 Button,
30 CheckBox,
31 Control,
32 FocusMode,
33 HBoxContainer,
34 Label,
35 Panel,
36 Slider,
37 VBoxContainer,
38)
39from simvx.core.ui.navigation import UI_ACCEPT, ui_action_matches
40
41from .hud import format_time
42
43#: Optional sound sink. The game assigns a callable taking an SFX name
44#: (``"menu_move"`` when focus walks, ``"menu_confirm"`` on an activation) so the
45#: menus are audible; left unset they are silent, which keeps this module free of
46#: any audio dependency for tests and reuse.
47play_sound: Callable[[str], None] | None = None
48
49
50def _sound(name: str) -> None:
51 if play_sound is not None:
52 play_sound(name)
53
54
55# Shared palette: dark panels, glowing accent. Kept here (not a Theme) so this
56# module stays a single self-contained file.
57ACCENT = (0.55, 1.0, 0.78, 1.0)
58ACCENT_DIM = (0.40, 0.72, 0.58, 1.0)
59GOLD = (1.0, 0.78, 0.30, 1.0)
60TEXT = (0.93, 0.97, 0.95, 1.0)
61TEXT_DIM = (0.62, 0.68, 0.70, 1.0)
62LOCKED = (0.45, 0.47, 0.52, 1.0)
63PANEL_BG = (0.05, 0.06, 0.09, 0.92)
64ROW_BG = (0.09, 0.11, 0.15, 0.9)
65BACKDROP = (0.02, 0.03, 0.05, 0.78)
66
67
68#: Key names (engine lower-case convention) that move focus down / up the chain
69#: or back out of the screen. Tab and Shift+Tab pair up with down and up, matching
70#: the engine's own tab order. Activation is NOT listed here: it is the engine's
71#: ``ui_accept`` action, asked for per key (see :func:`_is_activate`).
72_NEXT_KEYS = {"down", "arrow_down", "right", "tab"}
73_PREV_KEYS = {"up", "arrow_up", "left", "shift+tab"}
74#: Split directions for 2D (grid) navigation. A button with ``focus_left`` /
75#: ``focus_right`` set uses them for left/right; otherwise left/right fall back to
76#: the vertical chain (so plain vertical menus are unaffected).
77_DOWN_KEYS = {"down", "arrow_down", "tab"}
78_UP_KEYS = {"up", "arrow_up", "shift+tab"}
79_RIGHT_KEYS = {"right", "arrow_right"}
80_LEFT_KEYS = {"left", "arrow_left"}
81_BACK_KEYS = {"escape"}
82#: Horizontal keys a focused slider consumes to nudge its value (instead of
83#: walking the focus chain). Buttons / checkboxes keep using them to move focus.
84_ADJUST_KEYS = {"left", "arrow_left", "right", "arrow_right"}
85#: Fixed keys these controls act on. Claimed on the press as well as the release,
86#: because the engine's own Tab traversal runs on the press: without the claim it
87#: would move focus out from under the release we are waiting for, and the move
88#: would happen silently. Escape is in the set for the same reason -- these screens
89#: are plain scenes with their own back handler, and the press has to survive to
90#: the release that acts on it.
91_FIXED_OWNED_KEYS = (
92 _NEXT_KEYS | _PREV_KEYS | _DOWN_KEYS | _UP_KEYS | _RIGHT_KEYS | _LEFT_KEYS | _BACK_KEYS
93) | _ADJUST_KEYS
94
95
96def _is_activate(control: Control, key: str) -> bool:
97 """True when ``key`` is bound to the engine's ``ui_accept`` action.
98
99 Asked of the engine rather than compared against a literal, so a game that
100 rebinds ``ui_accept`` moves menu confirmation with it, exactly as it moves a
101 plain ``Button``'s. Space is therefore only ever a confirm key if the project
102 binds it: the default is Enter, and Space stays the gameplay JUMP key, which
103 is what stopped a screen opening under a held jump from confirming itself.
104 """
105 tree = control.tree
106 return ui_action_matches(UI_ACCEPT, key, tree.input_map if tree is not None else None)
107
108
109def _owns_key(control: Control, key: str) -> bool:
110 """True when this control acts on ``key`` and so must claim it on the press."""
111 return key in _FIXED_OWNED_KEYS or _is_activate(control, key)
112
113
114class _MenuButton(Button):
115 """A menu button that is keyboard- and gamepad-navigable when focused.
116
117 The engine's base ``Button`` only reacts to ``MouseButton.LEFT``, so the
118 focused menu button drives its own directional navigation: up/down (and
119 left/right/Tab/Shift+Tab) walk the ``focus_next``/``focus_previous`` chain
120 ``_link_focus`` wired, and Escape/gamepad-B invokes the screen's back handler
121 if one is set. Mouse / touch keep working through the base handler.
122
123 Confirmation is the ENGINE's: the key is whatever ``ui_accept`` is bound to,
124 and the activation itself is ``Button.activate()``. These screens still claim
125 it on the press and act on the release, because that release-side bookkeeping
126 is what makes the menu-opening key's orphan release a no-op (see below); the
127 router's own press-side activation stands down for a claimed key, so the two
128 never both fire.
129 """
130
131 on_back: Callable[[], None] | None = None
132
133 def __init__(self, *args, **kwargs):
134 super().__init__(*args, **kwargs)
135 # Keys whose PRESS this button observed while focused + in-tree. We only
136 # act on a release if we saw its matching press: otherwise the very key
137 # that OPENED this menu (an Escape press routed to the game, which spawns
138 # the menu, then its release lands on the freshly-focused button) would
139 # immediately fire "back" and the menu would flicker open->shut. Tracking
140 # press ownership makes that orphan release a no-op.
141 self._key_armed: set[str] = set()
142 # One confirm chime per activation, however it was triggered (mouse, touch,
143 # Enter or gamepad-A all end up emitting ``pressed``).
144 self.pressed.connect(self._play_confirm)
145
146 @staticmethod
147 def _play_confirm() -> None:
148 _sound("menu_confirm")
149
150 def _on_gui_input(self, event):
151 # Mouse / touch: defer to the base button behaviour.
152 if event.button is not None or not event.key:
153 super()._on_gui_input(event)
154 return
155 # Only ever act while still in the live tree: a button whose screen has
156 # been torn down (the title's Play button once PLAY begins) must not fire
157 # its activation on a stray key and restart the game.
158 if self.tree is None:
159 return
160 if not self.focused:
161 return
162 key = event.key
163 if event.pressed:
164 # Arm: remember we saw this key's press while focused, so its later
165 # release is ours to act on (and not the menu-opening key's release).
166 self._key_armed.add(key)
167 if _owns_key(self, key):
168 event.accept()
169 return # act on key release, matching the engine's widget convention
170 if key not in self._key_armed:
171 return # release with no matching press we owned: ignore (orphan)
172 self._key_armed.discard(key)
173 if key in _DOWN_KEYS:
174 self.focus_next_control()
175 _sound("menu_move")
176 event.accept()
177 elif key in _UP_KEYS:
178 self.focus_previous_control()
179 _sound("menu_move")
180 event.accept()
181 elif key in _RIGHT_KEYS:
182 target = getattr(self, "focus_right", None)
183 target.grab_focus() if target is not None else self.focus_next_control()
184 _sound("menu_move")
185 event.accept()
186 elif key in _LEFT_KEYS:
187 target = getattr(self, "focus_left", None)
188 target.grab_focus() if target is not None else self.focus_previous_control()
189 _sound("menu_move")
190 event.accept()
191 elif _is_activate(self, key):
192 self.activate()
193 event.accept()
194 elif key in _BACK_KEYS and self.on_back is not None:
195 self.on_back()
196 event.accept()
197
198
199class _KeyNav:
200 """Mixin: arm-on-press / act-on-release keyboard navigation for a focused
201 control whose base widget handles only the mouse (``Slider``, ``CheckBox``).
202
203 The engine's non-modal key path delivers events solely to the focused
204 control, and the base ``Slider`` / ``CheckBox`` ignore keys entirely, so a
205 keyboard-only player who lands on one is stranded: arrows can't move focus and
206 Escape can't back out (the Options menu trap). This mixin gives any such
207 control the same up/down/Tab/Shift+Tab focus walk, Escape-to-back, and (for a slider)
208 left/right adjust that :class:`_MenuButton` provides, using the identical
209 arm-on-press bookkeeping so the menu-opening key's orphan release is ignored.
210
211 Subclasses set ``on_back`` (Escape handler) and override ``_activate`` /
212 ``_adjust`` for the ``ui_accept`` key and the horizontal (left/right) keys.
213 """
214
215 on_back: Callable[[], None] | None = None
216 #: When True, left/right adjust the value instead of walking the focus chain
217 #: (sliders); when False, left/right move focus like a button (checkboxes).
218 _consumes_horizontal: bool = False
219
220 def _init_keynav(self) -> None:
221 self._key_armed: set[str] = set()
222
223 def _keynav_handled(self, event) -> bool:
224 """Handle a key event for a focused, in-tree control. Returns True if the
225 event was consumed (caller should not fall through to the base widget)."""
226 if event.button is not None or not event.key:
227 return False
228 if self.tree is None or not self.focused:
229 return True # drop keys aimed at an orphaned / unfocused control
230 key = event.key
231 if event.pressed:
232 self._key_armed.add(key)
233 if _owns_key(self, key):
234 event.accept()
235 return True
236 if key not in self._key_armed:
237 return True # orphan release (e.g. the key that opened this menu)
238 self._key_armed.discard(key)
239 adjust = self._consumes_horizontal and key in _ADJUST_KEYS
240 if adjust:
241 self._adjust(+1 if key in ("right", "arrow_right") else -1)
242 elif key in _NEXT_KEYS:
243 self.focus_next_control()
244 _sound("menu_move")
245 elif key in _PREV_KEYS:
246 self.focus_previous_control()
247 _sound("menu_move")
248 elif _is_activate(self, key):
249 self._activate()
250 elif key in _BACK_KEYS and self.on_back is not None:
251 self.on_back()
252 else:
253 return True
254 event.accept()
255 return True
256
257 # Subclass hooks (no-ops by default).
258 def _activate(self) -> None: ...
259
260 def _adjust(self, direction: int) -> None: ...
261
262
263class _MenuSlider(_KeyNav, Slider):
264 """A :class:`Slider` that is keyboard-navigable when focused: up/down/Tab/Shift+Tab
265 walk the focus chain, left/right nudge the value by ``step``, Escape backs out."""
266
267 _consumes_horizontal = True
268
269 def __init__(self, *args, **kwargs):
270 super().__init__(*args, **kwargs)
271 self._init_keynav()
272
273 def _on_gui_input(self, event):
274 if self._keynav_handled(event):
275 return
276 super()._on_gui_input(event)
277
278 def _adjust(self, direction: int) -> None:
279 step = self.step if self.step > 0 else 0.05
280 new_value = max(self.min_value, min(self.max_value, self.value + direction * step))
281 if new_value != self.value:
282 self.value = new_value
283 self.value_changed.emit(self.value)
284
285
286class _MenuCheckBox(_KeyNav, CheckBox):
287 """A :class:`CheckBox` that is keyboard-navigable when focused: up/down/Tab/Shift+Tab
288 walk the focus chain, ``ui_accept`` toggles, Escape backs out."""
289
290 def __init__(self, *args, **kwargs):
291 super().__init__(*args, **kwargs)
292 self._init_keynav()
293
294 def _on_gui_input(self, event):
295 if self._keynav_handled(event):
296 return
297 super()._on_gui_input(event)
298
299 def _activate(self) -> None:
300 self.activate() # the engine's own toggle, exactly what a click does
301 _sound("menu_confirm")
302
303
304def _accent_button(text: str, on_press: Callable[[], None] | None = None, *, width: float = 280.0) -> _MenuButton:
305 """A focusable, keyboard-navigable, on-theme menu button sized to a width."""
306 btn = _MenuButton(text, on_press=on_press)
307 btn.font_size = 20.0
308 btn.size = (width, 46.0)
309 btn.size_flags_horizontal = 0 # SHRINK_BEGIN: keep our width in a VBox
310 btn.focus_mode = FocusMode.ALL
311 btn.text_colour = TEXT
312 btn.bg_colour = (0.10, 0.13, 0.18, 0.95)
313 btn.hover_colour = (0.16, 0.30, 0.26, 1.0)
314 btn.pressed_colour = (0.20, 0.40, 0.34, 1.0)
315 btn.border_colour = ACCENT_DIM
316 return btn
317
318
319def _link_focus(buttons: Sequence[Control], *, on_back: Callable[[], None] | None = None) -> None:
320 """Wire a vertical focus chain so up/down + tab cycle the buttons.
321
322 ``on_back`` (when given) is attached to every :class:`_MenuButton` so Escape /
323 gamepad-B backs out of the screen from any focused button.
324 """
325 n = len(buttons)
326 for i, b in enumerate(buttons):
327 b.focus_next = buttons[(i + 1) % n]
328 b.focus_previous = buttons[(i - 1) % n]
329 if on_back is not None and isinstance(b, _MenuButton | _KeyNav):
330 b.on_back = on_back
331
332
333def _link_grid_focus(
334 columns: Sequence[Sequence[Control]], back: Control, *, on_back: Callable[[], None] | None = None
335) -> None:
336 """Wire 2D focus for a column grid: up/down within a column, left/right across.
337
338 ``columns`` is one list of buttons per column. ``back`` sits below the grid:
339 Down from a column's last button (or any vertical wrap) reaches it; left/right
340 jump to the same-row button in the adjacent column (clamped to its length).
341 """
342 cols = [list(c) for c in columns if c]
343 for col in cols:
344 n = len(col)
345 for i, b in enumerate(col):
346 b.focus_next = col[i + 1] if i + 1 < n else back
347 b.focus_previous = col[i - 1] if i > 0 else back
348 if cols:
349 back.focus_next = cols[0][0]
350 back.focus_previous = cols[0][-1]
351 for ci, col in enumerate(cols):
352 left_col = cols[ci - 1] if ci > 0 else None
353 right_col = cols[ci + 1] if ci + 1 < len(cols) else None
354 for r, b in enumerate(col):
355 b.focus_left = left_col[min(r, len(left_col) - 1)] if left_col else None
356 b.focus_right = right_col[min(r, len(right_col) - 1)] if right_col else None
357 back.focus_left = None
358 back.focus_right = None
359 if on_back is not None:
360 for b in [*[x for c in cols for x in c], back]:
361 if isinstance(b, _MenuButton | _KeyNav):
362 b.on_back = on_back
363
364
365def _centred_panel(width: float, height: float, *, name: str = "Panel") -> Panel:
366 """A centred dark panel anchored to the screen centre with a fixed size."""
367 panel = Panel(name=name)
368 panel.set_anchor_preset(AnchorPreset.CENTER)
369 panel.size = (width, height)
370 panel.margin_left = -width / 2
371 panel.margin_top = -height / 2
372 panel.margin_right = -width / 2
373 panel.margin_bottom = -height / 2
374 panel.bg_colour = PANEL_BG
375 panel.border_colour = ACCENT_DIM
376 return panel
377
378
379class _Screen(Control):
380 """Base for full-screen menus: a dimmed backdrop filling the viewport."""
381
382 def __init__(self, **kwargs):
383 super().__init__(**kwargs)
384 self.set_anchor_preset(AnchorPreset.FULL_RECT)
385 self._backdrop = self.add_child(Panel(name="Backdrop"))
386 self._backdrop.set_anchor_preset(AnchorPreset.FULL_RECT)
387 self._backdrop.bg_colour = BACKDROP
388 self._backdrop.border_colour = (0, 0, 0, 0)
389
390 def focus_first(self) -> None:
391 """Grab focus on the first focusable control (call after entering tree)."""
392 first = self._first_focusable_descendant()
393 if first is not None:
394 first.grab_focus()
395
396
397# ==========================================================================
398# TitleScreen
399# ==========================================================================
400
401
402class TitleScreen(_Screen):
403 """Splash / main menu: wordmark, tagline, and the primary actions.
404
405 ``Play`` always begins at the first room; ``Continue`` (shown only when
406 ``can_continue`` is set, i.e. there is saved progress) resumes at the first
407 unfinished room.
408
409 Hooks: ``on_play``, ``on_continue``, ``on_levels``, ``on_options``, ``on_quit``.
410 """
411
412 def __init__(self, can_continue: bool = False, **kwargs):
413 super().__init__(**kwargs)
414 self.on_play: Callable[[], None] | None = None
415 self.on_continue: Callable[[], None] | None = None
416 self.on_levels: Callable[[], None] | None = None
417 self.on_options: Callable[[], None] | None = None
418 self.on_quit: Callable[[], None] | None = None
419
420 column = self.add_child(VBoxContainer(name="Column"))
421 column.set_anchor_preset(AnchorPreset.CENTER)
422 column.size = (320, 420)
423 column.margin_left = -160
424 column.margin_top = -210
425 column.margin_right = -160
426 column.margin_bottom = -210
427 column.separation = 14.0
428 column.alignment = "center"
429
430 wordmark = column.add_child(Label("AFTERGLOW", name="Wordmark"))
431 wordmark.font_size = 56.0
432 wordmark.alignment = "center"
433 wordmark.text_colour = ACCENT
434 wordmark.size = (320, 70)
435
436 tagline = column.add_child(Label("chase the light", name="Tagline"))
437 tagline.font_size = 18.0
438 tagline.alignment = "center"
439 tagline.text_colour = TEXT_DIM
440 tagline.size = (320, 28)
441 column.add_child(_spacer(18))
442
443 # Returning players get a primary "Continue" (resume at the first unfinished
444 # room); "Play" always starts a fresh run from the first room.
445 buttons = []
446 if can_continue:
447 self.continue_button = _accent_button("Continue", self._fire("on_continue"))
448 buttons.append(self.continue_button)
449 self.play_button = _accent_button("Play", self._fire("on_play"))
450 self.levels_button = _accent_button("Levels", self._fire("on_levels"))
451 self.options_button = _accent_button("Options", self._fire("on_options"))
452 self.quit_button = _accent_button("Quit", self._fire("on_quit"))
453 buttons += [self.play_button, self.levels_button, self.options_button, self.quit_button]
454 for b in buttons:
455 column.add_child(b)
456 _link_focus(buttons)
457
458 def _fire(self, name: str) -> Callable[[], None]:
459 return lambda: _call(getattr(self, name, None))
460
461
462# ==========================================================================
463# LevelSelect
464# ==========================================================================
465
466
467class LevelSelect(_Screen):
468 """World / room picker rendered from a ``Progress`` and a ``worlds`` list.
469
470 ``worlds`` is the ``rooms_data.WORLDS`` structure (a list of dicts with
471 ``id``, ``name``, ``rooms``). Locked worlds render dimmed and non-clickable.
472
473 Hooks: ``on_select(world_id, room_index)``, ``on_back``.
474 """
475
476 def __init__(self, worlds: Sequence[dict], progress=None, screen_size=None, **kwargs):
477 super().__init__(**kwargs)
478 self.on_select: Callable[[str, int], None] | None = None
479 self.on_back: Callable[[], None] | None = None
480 self._worlds = list(worlds)
481 self._progress = progress
482 self._screen_size = screen_size or (960.0, 600.0)
483 self._room_buttons: list[Button] = []
484 self.build()
485
486 def set_progress(self, progress) -> None:
487 """Swap the progress source and rebuild the list (call before showing)."""
488 self._progress = progress
489 self.build()
490
491 def build(self) -> None:
492 """Rebuild the picker as a per-world column layout sized to the viewport.
493
494 The worlds sit side by side (a compact "world map"), so all rooms fit on
495 screen without the picker running off the bottom. The panel is sized to the
496 viewport, with a heading on top and a Back button below the columns.
497 """
498 for child in list(self.children):
499 if child is not self._backdrop:
500 self.remove_child(child)
501 self._room_buttons = []
502
503 sw, sh = float(self._screen_size[0]), float(self._screen_size[1])
504 n_worlds = max(1, len(self._worlds))
505 col_sep, inset, heading_h, back_h, sep = 16.0, 24.0, 34.0, 44.0, 12.0
506 panel_w = min(360.0 + 260.0 * n_worlds, max(360.0, sw - 40.0))
507 panel_h = min(600.0, max(300.0, sh - 40.0))
508 inner_w = panel_w - 2 * inset
509 col_w = (inner_w - col_sep * (n_worlds - 1)) / n_worlds
510 cols_h = panel_h - 2 * inset - heading_h - back_h - 2 * sep
511
512 panel = self.add_child(_centred_panel(panel_w, panel_h, name="LevelPanel"))
513 column = panel.add_child(VBoxContainer(name="Column"))
514 column.set_anchor_preset(AnchorPreset.FULL_RECT)
515 column.margin_left = inset
516 column.margin_top = inset
517 column.margin_right = inset
518 column.margin_bottom = inset
519 column.separation = sep
520
521 heading = column.add_child(Label("SELECT A ROOM", name="Heading"))
522 heading.font_size = 26.0
523 heading.text_colour = ACCENT
524 heading.size = (inner_w, heading_h)
525
526 worlds_row = column.add_child(HBoxContainer(name="Worlds"))
527 worlds_row.size = (inner_w, cols_h)
528 worlds_row.separation = col_sep
529
530 prog = self._progress
531 grid: list[list[Control]] = []
532 for world in self._worlds:
533 wid = world["id"]
534 unlocked = prog.is_world_unlocked(wid) if prog is not None else (wid == self._worlds[0]["id"])
535 col = worlds_row.add_child(VBoxContainer(name=f"Col_{wid}"))
536 col.size = (col_w, cols_h)
537 col.separation = 6.0
538
539 title = col.add_child(Label(world["name"], name=f"World_{wid}"))
540 title.font_size = 18.0
541 title.text_colour = TEXT if unlocked else LOCKED
542 title.size = (col_w, 26)
543
544 rooms = world.get("rooms", [])
545 if not rooms:
546 empty = col.add_child(Label("(coming soon)", name=f"Empty_{wid}"))
547 empty.font_size = 13.0
548 empty.text_colour = TEXT_DIM
549 empty.size = (col_w, 22)
550 continue
551 if not unlocked:
552 locked = col.add_child(Label("locked", name=f"Locked_{wid}"))
553 locked.font_size = 13.0
554 locked.text_colour = LOCKED
555 locked.size = (col_w, 22)
556 continue
557
558 col_buttons: list[Control] = []
559 for idx, room in enumerate(rooms):
560 btn = self._room_row(wid, idx, room, prog, col_w)
561 col.add_child(btn)
562 col_buttons.append(btn)
563 grid.append(col_buttons)
564
565 self.back_button = _accent_button("Back", self._fire_back, width=200.0)
566 self.back_button.size = (200.0, back_h)
567 column.add_child(self.back_button)
568 self._room_buttons = [*[b for c in grid for b in c], self.back_button]
569 # Up/down walks a world column; left/right hops between worlds.
570 _link_grid_focus(grid, self.back_button, on_back=self._fire_back)
571
572 def _room_row(self, world_id: str, room_index: int, room, prog, width: float = 584.0) -> Control:
573 """One room button labelled with its tick, best time, and shard dot."""
574 done = prog.is_completed(world_id, room_index) if prog is not None else False
575 shard = prog.has_shard(world_id, room_index) if prog is not None else False
576
577 tick = "x" if done else " "
578 dot = " *" if shard else ""
579 name = getattr(room, "name", f"Room {room_index + 1}")
580 label = f"[{tick}] {name}{dot}"
581
582 btn = _accent_button(label, None, width=width)
583 btn.font_size = 14.0
584 btn.size = (width, 32.0)
585 btn.alignment = "left"
586 btn.text_colour = TEXT if done else TEXT_DIM
587 btn.pressed.connect(lambda w=world_id, i=room_index: self._select(w, i))
588 return btn
589
590 def _select(self, world_id: str, room_index: int) -> None:
591 if self.on_select is not None:
592 self.on_select(world_id, room_index)
593
594 def _fire_back(self) -> None:
595 _call(self.on_back)
596
597
598# ==========================================================================
599# PauseMenu
600# ==========================================================================
601
602
603class PauseMenu(_Screen):
604 """In-run pause overlay.
605
606 Hooks: ``on_resume``, ``on_restart``, ``on_options``, ``on_quit``.
607 """
608
609 def __init__(self, **kwargs):
610 super().__init__(**kwargs)
611 self.on_resume: Callable[[], None] | None = None
612 self.on_restart: Callable[[], None] | None = None
613 self.on_options: Callable[[], None] | None = None
614 self.on_quit: Callable[[], None] | None = None
615
616 panel = self.add_child(_centred_panel(340, 360, name="PausePanel"))
617 column = panel.add_child(VBoxContainer(name="Column"))
618 column.set_anchor_preset(AnchorPreset.FULL_RECT)
619 column.margin_left = 30
620 column.margin_top = 28
621 column.margin_right = 30
622 column.margin_bottom = 28
623 column.separation = 14.0
624 column.alignment = "center"
625
626 heading = column.add_child(Label("PAUSED", name="Heading"))
627 heading.font_size = 30.0
628 heading.alignment = "center"
629 heading.text_colour = ACCENT
630 heading.size = (280, 40)
631 column.add_child(_spacer(8))
632
633 self.resume_button = _accent_button("Resume", lambda: _call(self.on_resume))
634 self.restart_button = _accent_button("Restart Room", lambda: _call(self.on_restart))
635 self.options_button = _accent_button("Options", lambda: _call(self.on_options))
636 self.quit_button = _accent_button("Quit to Map", lambda: _call(self.on_quit))
637 buttons = [self.resume_button, self.restart_button, self.options_button, self.quit_button]
638 for b in buttons:
639 column.add_child(b)
640 # Escape / gamepad-B resumes (closes the pause overlay) from any button.
641 _link_focus(buttons, on_back=lambda: _call(self.on_resume))
642
643
644# ==========================================================================
645# OptionsMenu
646# ==========================================================================
647
648
649class OptionsMenu(_Screen):
650 """Volume / accessibility / assist options, read & written via ``Progress``.
651
652 All widgets read their initial value from ``progress.get_option`` and write
653 back through ``progress.set_option`` on change, then fire ``on_change(key,
654 value)`` so the game can apply the setting live (re-bus a volume, toggle
655 fullscreen, enable an assist). Toggles whose option key is absent from the
656 progress schema are skipped, so the screen degrades gracefully.
657
658 Hooks: ``on_change(key, value)``, ``on_back``.
659 """
660
661 #: (option_key, label) for the simple boolean toggles.
662 TOGGLES = (
663 ("screenshake", "Screen shake"),
664 ("photosensitive_safe", "Photosensitive-safe"),
665 )
666 #: (option_key, label) for the assist-mode group.
667 ASSIST = (
668 ("assist_mode", "Assist mode"),
669 ("assist_slow_time", "Slow time"),
670 ("assist_invincible", "Invincible"),
671 ("assist_infinite_dash", "Infinite dash"),
672 ("assist_extended_glow", "Extended glow"),
673 )
674
675 def __init__(self, progress=None, *, allow_fullscreen: bool = True, **kwargs):
676 super().__init__(**kwargs)
677 self.on_change: Callable[[str, object], None] | None = None
678 self.on_back: Callable[[], None] | None = None
679 self._progress = progress
680 self._allow_fullscreen = allow_fullscreen
681 self._focusables: list[Control] = []
682 self.build()
683
684 def set_progress(self, progress) -> None:
685 """Swap the progress source and rebuild from its current option values."""
686 self._progress = progress
687 self.build()
688
689 def build(self) -> None:
690 for child in list(self.children):
691 if child is not self._backdrop:
692 self.remove_child(child)
693 self._focusables = []
694
695 panel = self.add_child(_centred_panel(520, 600, name="OptionsPanel"))
696 column = panel.add_child(VBoxContainer(name="Column"))
697 column.set_anchor_preset(AnchorPreset.FULL_RECT)
698 column.margin_left = 30
699 column.margin_top = 24
700 column.margin_right = 30
701 column.margin_bottom = 24
702 column.separation = 9.0
703
704 heading = column.add_child(Label("OPTIONS", name="Heading"))
705 heading.font_size = 28.0
706 heading.text_colour = ACCENT
707 heading.size = (460, 38)
708
709 self._add_slider(column, "master_volume", "Master")
710 self._add_slider(column, "sfx_volume", "SFX")
711 self._add_slider(column, "music_volume", "Music")
712 column.add_child(_spacer(6))
713
714 if self._allow_fullscreen:
715 self._add_toggle(column, "fullscreen", "Fullscreen", schema_optional=True)
716 for key, label in self.TOGGLES:
717 self._add_toggle(column, key, label)
718
719 column.add_child(_spacer(6))
720 assist_head = column.add_child(Label("Assist", name="AssistHead"))
721 assist_head.font_size = 16.0
722 assist_head.text_colour = GOLD
723 assist_head.size = (460, 24)
724 for key, label in self.ASSIST:
725 self._add_toggle(column, key, label, schema_optional=True)
726
727 column.add_child(_spacer(8))
728 back = _accent_button("Back", lambda: _call(self.on_back), width=240.0)
729 column.add_child(back)
730 self._focusables.append(back)
731 _link_focus(self._focusables, on_back=lambda: _call(self.on_back))
732
733 def _opt(self, key: str, default):
734 """Read an option, tolerating keys absent from the progress schema."""
735 if self._progress is None:
736 return default
737 try:
738 return self._progress.get_option(key)
739 except KeyError:
740 return default
741
742 def _write(self, key: str, value) -> None:
743 if self._progress is not None:
744 try:
745 self._progress.set_option(key, value)
746 except KeyError:
747 pass # not in schema: still fire on_change so the game can apply
748 if self.on_change is not None:
749 self.on_change(key, value)
750
751 def _add_slider(self, column, key: str, label: str) -> None:
752 row = column.add_child(HBoxContainer(name=f"Row_{key}"))
753 row.size = (460, 30)
754 row.separation = 12.0
755 cap = row.add_child(Label(label, name=f"Cap_{key}"))
756 cap.font_size = 16.0
757 cap.text_colour = TEXT
758 cap.size = (130, 28)
759 slider = row.add_child(_MenuSlider(0.0, 1.0, value=float(self._opt(key, 1.0))))
760 slider.step = 0.05
761 slider.size = (300, 22)
762 slider.focus_mode = FocusMode.ALL
763 slider.value_changed.connect(lambda v, k=key: self._write(k, float(v)))
764 self._focusables.append(slider)
765
766 def _add_toggle(self, column, key: str, label: str, *, schema_optional: bool = False) -> None:
767 # Skip toggles whose key is not in the schema unless explicitly optional
768 # (optional ones still fire on_change so the game may apply them live).
769 if not schema_optional and self._progress is not None:
770 try:
771 self._progress.get_option(key)
772 except KeyError:
773 return
774 checked = bool(self._opt(key, False))
775 cb = column.add_child(_MenuCheckBox(label, checked=checked, name=f"Toggle_{key}"))
776 cb.font_size = 16.0
777 cb.text_colour = TEXT
778 cb.size = (460, 26)
779 cb.focus_mode = FocusMode.ALL
780 cb.toggled.connect(lambda on, k=key: self._write(k, bool(on)))
781 self._focusables.append(cb)
782
783
784# ==========================================================================
785# ResultsScreen
786# ==========================================================================
787
788
789class ResultsScreen(_Screen):
790 """End-of-world / end-of-game completion summary.
791
792 Call :meth:`set_summary` with the totals to render. Hooks: ``on_continue``,
793 ``on_replay``, ``on_map``.
794 """
795
796 def __init__(self, **kwargs):
797 super().__init__(**kwargs)
798 self.on_continue: Callable[[], None] | None = None
799 self.on_replay: Callable[[], None] | None = None
800 self.on_map: Callable[[], None] | None = None
801 self._title = "WORLD COMPLETE"
802 self._total_time = 0.0
803 self._deaths = 0
804 self._shards = 0
805 self._shards_total = 0
806 self._final = False
807 self.build()
808
809 def set_summary(
810 self,
811 *,
812 title: str,
813 total_time: float,
814 deaths: int,
815 shards: int,
816 shards_total: int,
817 final: bool = False,
818 ) -> None:
819 """Set the summary numbers and rebuild. ``final`` switches the wording."""
820 self._title = title
821 self._total_time = float(total_time)
822 self._deaths = int(deaths)
823 self._shards = int(shards)
824 self._shards_total = int(shards_total)
825 self._final = bool(final)
826 self.build()
827
828 def build(self) -> None:
829 for child in list(self.children):
830 if child is not self._backdrop:
831 self.remove_child(child)
832
833 panel = self.add_child(_centred_panel(460, 440, name="ResultsPanel"))
834 column = panel.add_child(VBoxContainer(name="Column"))
835 column.set_anchor_preset(AnchorPreset.FULL_RECT)
836 column.margin_left = 32
837 column.margin_top = 28
838 column.margin_right = 32
839 column.margin_bottom = 28
840 column.separation = 12.0
841 column.alignment = "center"
842
843 heading = column.add_child(Label(self._title, name="Heading"))
844 heading.font_size = 30.0
845 heading.alignment = "center"
846 heading.text_colour = GOLD if self._final else ACCENT
847 heading.size = (396, 40)
848 column.add_child(_spacer(6))
849
850 self._stat(column, "Total time", format_time(self._total_time), ACCENT)
851 self._stat(column, "Deaths", str(self._deaths), TEXT)
852 self._stat(column, "Shards", f"{self._shards} / {self._shards_total}", GOLD)
853 column.add_child(_spacer(12))
854
855 self.continue_button = _accent_button(
856 "Next World" if not self._final else "Finish", lambda: _call(self.on_continue)
857 )
858 self.replay_button = _accent_button("Replay", lambda: _call(self.on_replay))
859 self.map_button = _accent_button("Map", lambda: _call(self.on_map))
860 buttons = [self.continue_button, self.replay_button, self.map_button]
861 for b in buttons:
862 column.add_child(b)
863 _link_focus(buttons, on_back=lambda: _call(self.on_map))
864
865 def _stat(self, column, label: str, value: str, value_colour) -> None:
866 row = column.add_child(HBoxContainer(name=f"Stat_{label}"))
867 row.size = (396, 28)
868 cap = row.add_child(Label(label, name="Cap"))
869 cap.font_size = 18.0
870 cap.text_colour = TEXT_DIM
871 cap.size = (220, 26)
872 val = row.add_child(Label(value, name="Val"))
873 val.font_size = 18.0
874 val.alignment = "right"
875 val.text_colour = value_colour
876 val.size = (176, 26)
877
878
879# -- shared helpers --------------------------------------------------------
880
881
882def _spacer(height: float) -> Control:
883 """An empty, non-interactive control used as vertical padding in a VBox."""
884 s = Control(name="Spacer")
885 s.size = (1.0, float(height))
886 s.mouse_filter = False
887 return s
888
889
890def _call(fn: Callable[[], None] | None) -> None:
891 """Invoke a hook if one is set (no-op when the game left it unwired)."""
892 if fn is not None:
893 fn()
894
895
896__all__ = [
897 "TitleScreen",
898 "LevelSelect",
899 "PauseMenu",
900 "OptionsMenu",
901 "ResultsScreen",
902 "ACCENT",
903 "GOLD",
904]