nodes/pause_menu.py

Part of Dungeon Explorer.

  1"""Pause overlay: centred panel, animated selector, floor/level/gold display."""
  2
  3import math
  4
  5from simvx.core import Control, Property
  6from simvx.core.input import MouseButton
  7from simvx.core.ui.enums import AnchorPreset
  8
  9from ._back_button import back_button_hit, check_menu_click, draw_back_button
 10
 11
 12class PauseMenu(Control):
 13    """Full-screen pause overlay with styled panel."""
 14
 15    visible = Property(
 16        False,
 17        coerce=bool,
 18        hint="Whether this node and its subtree are drawn",
 19        on_change="_on_visible_changed",
 20    )
 21
 22    # on_draw pulses the selected-row highlight off self._timer each frame ->
 23    # retained 2D must re-run it each frame.
 24    dynamic = True
 25
 26    OPTIONS = ["Resume", "Level Up", "Save Game", "Stats", "Achievements", "Settings", "Quit to Title"]
 27
 28    def __init__(self, on_action=None, **kwargs):
 29        super().__init__(name="PauseMenu", **kwargs)
 30        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 31
 32        self._selected = 0
 33        self._on_action = on_action
 34        self._timer = 0.0
 35        self._player = None
 36        self._dungeon_level = 1
 37
 38    def set_context(self, player=None, dungeon_level: int = 1) -> None:
 39        self._player = player
 40        self._dungeon_level = dungeon_level
 41
 42    def show(self):
 43        self._selected = 0
 44        self._timer = 0.0
 45        self.show_overlay("blocking", dismiss=True)
 46
 47    # -- Frame update --
 48
 49    def on_update(self, dt: float):
 50        self._timer += dt
 51
 52    # -- Input --
 53
 54    def _on_gui_input(self, event):
 55        if not self.visible:
 56            return
 57        if event.button == MouseButton.LEFT and event.pressed:
 58            self._handle_click(float(event.position[0]), float(event.position[1]), event)
 59            return
 60        if event.key and event.pressed:
 61            if event.key in ("w", "up"):
 62                self._selected = max(self._selected - 1, 0)
 63                event.handled = True
 64            elif event.key in ("s", "down"):
 65                self._selected = min(self._selected + 1, len(self.OPTIONS) - 1)
 66                event.handled = True
 67            elif event.key in ("space", "enter", "return", "e"):
 68                self._confirm()
 69                event.handled = True
 70            # Esc → router → cancel_requested → close_overlay (Resume).
 71
 72    def _handle_click(self, mx: float, my: float, event):
 73        if back_button_hit(mx, my):
 74            self._close_with_resume()
 75            event.handled = True
 76            return
 77        sw, sh = self._screen_size()
 78        panel_w, panel_h, row_h, _, _, info_h, _ = self._layout(sw, sh)
 79        px = (sw - panel_w) / 2
 80        py = (sh - panel_h) / 2
 81        oy = py + info_h
 82        idx = check_menu_click(mx, my, len(self.OPTIONS), px + 15, oy, panel_w - 30, row_h, row_h)
 83        if idx is not None:
 84            self._selected = idx
 85            self._confirm()
 86            event.handled = True
 87
 88    def _close_with_resume(self):
 89        # Same close path as the "Resume" option.
 90        self.cancel_requested.emit()
 91        self.close_overlay()
 92
 93    def _confirm(self):
 94        action = self.OPTIONS[self._selected].lower().replace(" ", "_")
 95        if action == "resume":
 96            self._close_with_resume()
 97            return
 98        # All non-resume actions: notify caller, close, let caller open follow-up popups.
 99        if self._on_action:
100            self._on_action(action)
101        if action == "save_game":
102            return  # Stay open after save.
103        self.close_overlay()
104
105    # -- Layout --
106
107    def _screen_size(self) -> tuple[float, float]:
108        if self.tree is not None:
109            sw, sh = self.tree.screen_size
110            return float(sw), float(sh)
111        return 1280.0, 720.0
112
113    def _layout(self, sw: float, sh: float):
114        """Continuous layout: panel size derives from viewport with min/max clamps.
115
116        Returns (panel_w, panel_h, row_h, sel_h, text_scale, info_h, footer_h).
117        """
118        # Panel width: 70% of viewport, clamped 300..480, also bounded by
119        # available width minus a 20px gutter on each side.
120        max_w = max(260.0, sw - 40.0)
121        panel_w = min(max(sw * 0.7, 300.0), 480.0, max_w)
122
123        # Row height scales with available vertical space. Floor 40px, ceiling
124        # 64px. The clamp keeps rows readable on mobile and not absurdly tall
125        # on desktop.
126        row_h = min(max(sh * 0.07, 40.0), 64.0)
127        sel_h = row_h - 10.0
128        # Text scale follows row height; 1.0 at 40px row → ~1.6 at 64px row.
129        text_scale = min(max(row_h / 30.0, 1.2), 1.8)
130
131        info_h = 95.0  # title + info row band above the option list
132        footer_h = 35.0  # footer hint band
133        panel_h = info_h + len(self.OPTIONS) * row_h + footer_h
134
135        # If the panel would exceed viewport height, shrink rows uniformly.
136        max_panel_h = sh - 40.0
137        if panel_h > max_panel_h:
138            avail = max_panel_h - info_h - footer_h
139            row_h = max(28.0, avail / len(self.OPTIONS))
140            sel_h = row_h - 10.0
141            text_scale = min(max(row_h / 30.0, 1.0), 1.8)
142            panel_h = info_h + len(self.OPTIONS) * row_h + footer_h
143
144        return panel_w, panel_h, row_h, sel_h, text_scale, info_h, footer_h
145
146    # -- Rendering --
147
148    def on_draw(self, renderer):
149        if not self.visible:
150            return
151        sw, sh = self._screen_size()
152
153        renderer.draw_rect((0, 0), (sw, sh), colour=(0.0, 0.0, 0.0, 0.7), filled=True)
154
155        panel_w, panel_h, row_h, sel_h, text_scale, info_h, footer_h = self._layout(sw, sh)
156        px = (sw - panel_w) / 2
157        py = (sh - panel_h) / 2
158
159        renderer.draw_rect((px, py), (panel_w, panel_h), colour=(0.1, 0.1, 0.14, 0.95), filled=True)
160        renderer.draw_rect((px, py), (panel_w, 3), colour=(0.6, 0.5, 0.2, 0.8), filled=True)
161        renderer.draw_rect((px, py + panel_h - 3), (panel_w, 3), colour=(0.3, 0.3, 0.35, 0.5), filled=True)
162
163        # Title centred in the title band (top ~50px of info area).
164        renderer.draw_text(
165            "PAUSED",
166            rect=(px, py + 8, panel_w, 36),
167            scale=2.2,
168            alignment="center",
169            vertical_alignment="center",
170            colour=(1.0, 1.0, 1.0, 1.0),
171        )
172
173        info_y = py + 55
174        if self._player:
175            info_items = [
176                f"Lv {self._player.level}",
177                f"Floor {self._dungeon_level}",
178                f"Gold: {self._player.gold}",
179            ]
180            info_text = "  |  ".join(info_items)
181            renderer.draw_text(
182                info_text,
183                rect=(px + 20, info_y, panel_w - 40, 22),
184                scale=0.9,
185                alignment="left",
186                vertical_alignment="center",
187                fit_to_width=True,
188                min_scale=0.6,
189                colour=(0.7, 0.65, 0.4, 1.0),
190            )
191
192        renderer.draw_rect((px + 20, info_y + 22), (panel_w - 40, 1), colour=(0.3, 0.3, 0.35, 0.6), filled=True)
193
194        oy = py + info_h
195        for i, opt in enumerate(self.OPTIONS):
196            y = oy + i * row_h
197            if i == self._selected:
198                sel_pulse = 0.25 + 0.05 * math.sin(self._timer * 4.0)
199                renderer.draw_rect(
200                    (px + 15, y - 4),
201                    (panel_w - 30, sel_h),
202                    colour=(sel_pulse, sel_pulse, sel_pulse + 0.1, 0.8),
203                    filled=True,
204                )
205                colour = (1.0, 0.9, 0.3, 1.0)
206                prefix = "> "
207            else:
208                colour = (0.7, 0.7, 0.7, 1.0)
209                prefix = "  "
210            renderer.draw_text(
211                f"{prefix}{opt}",
212                rect=(px + 30, y, panel_w - 60, row_h),
213                scale=text_scale,
214                alignment="left",
215                vertical_alignment="center",
216                fit_to_width=True,
217                min_scale=0.8,
218                colour=colour,
219            )
220
221        renderer.draw_text(
222            "Arrows: Navigate   Space: Select   Esc: Resume",
223            rect=(px + 15, py + panel_h - footer_h + 5, panel_w - 30, footer_h - 10),
224            scale=0.8,
225            alignment="left",
226            vertical_alignment="center",
227            fit_to_width=True,
228            min_scale=0.55,
229            colour=(0.4, 0.4, 0.4, 1.0),
230        )
231        draw_back_button(renderer)