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)