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]