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