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]