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