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)