Pause Menu¶
a full-screen modal overlay with stacked buttons, toggled by a key.
▶ Run in browserTags: ui menu pause modal anchors
A classic pause screen layered over the running game: pressing P (or ESC), or
clicking the on-screen Pause button in the top-right corner, drops a dimmed
full-rect scrim across the whole viewport and pops a centred panel with
Resume / Restart / Quit buttons stacked vertically. The scrim fills the screen
via AnchorPreset.FULL_RECT, the panel sits dead-centre via AnchorPreset.CENTER
with symmetric margins, and both stay correctly placed at any window size. The
background keeps a few orbiting markers over a dark fill so it is obvious the
overlay sits on top of live content.
What it demonstrates¶
A modal overlay built from a
FULL_RECTscrim Panel plus aCENTER-anchored panel, both top-level Controls using anchors and margins, never absolute position.Toggling the whole overlay’s
visibleflag from a key press to show / hide.A vertical
VBoxContainerofButtons wired withButton.pressed.connect()(Resume hides the menu, Restart resets state, Quit closes the app).The centred panel staying centred and the scrim staying full-screen on resize.
Controls: P / ESC - Toggle the pause overlay Pause button - Toggle the overlay by mouse / touch (top-right) Mouse - Click Resume / Restart / Quit
Run: uv run python examples/features/ui/pause_menu.py Headless self-check: uv run python examples/features/ui/pause_menu.py –test
Source¶
1"""Pause Menu: a full-screen modal overlay with stacked buttons, toggled by a key.
2
3A classic pause screen layered over the running game: pressing P (or ESC), or
4clicking the on-screen Pause button in the top-right corner, drops a dimmed
5full-rect scrim across the whole viewport and pops a centred panel with
6Resume / Restart / Quit buttons stacked vertically. The scrim fills the screen
7via `AnchorPreset.FULL_RECT`, the panel sits dead-centre via `AnchorPreset.CENTER`
8with symmetric margins, and both stay correctly placed at any window size. The
9background keeps a few orbiting markers over a dark fill so it is obvious the
10overlay sits on top of live content.
11
12# /// simvx
13# tags = ["ui", "menu", "pause", "modal", "anchors"]
14# web = { root = "PauseMenuDemo", width = 800, height = 600, responsive = true }
15# ///
16
17## What it demonstrates
18
19- A modal overlay built from a `FULL_RECT` scrim Panel plus a `CENTER`-anchored
20 panel, both top-level Controls using anchors and margins, never absolute
21 position.
22- Toggling the whole overlay's `visible` flag from a key press to show / hide.
23- A vertical `VBoxContainer` of `Button`s wired with `Button.pressed.connect()`
24 (Resume hides the menu, Restart resets state, Quit closes the app).
25- The centred panel staying centred and the scrim staying full-screen on resize.
26
27Controls:
28 P / ESC - Toggle the pause overlay
29 Pause button - Toggle the overlay by mouse / touch (top-right)
30 Mouse - Click Resume / Restart / Quit
31
32Run: uv run python examples/features/ui/pause_menu.py
33Headless self-check: uv run python examples/features/ui/pause_menu.py --test
34"""
35
36import math
37
38from simvx.core import (
39 AnchorPreset,
40 Button,
41 Colour,
42 Control,
43 Input,
44 InputMap,
45 Key,
46 Label,
47 Node2D,
48 Panel,
49 VBoxContainer,
50 Vec2,
51)
52from simvx.graphics import App
53
54WIDTH, HEIGHT = 800, 600
55
56
57class PauseOverlay(Control):
58 """Full-screen modal: dimmed scrim + centred button panel.
59
60 Itself a FULL_RECT top-level Control so it covers the viewport; toggling
61 `self.visible` shows or hides the whole overlay at once.
62 """
63
64 def __init__(self, on_resume, on_restart, on_quit, **kwargs):
65 super().__init__(**kwargs)
66 self.set_anchor_preset(AnchorPreset.FULL_RECT)
67
68 # Dimmed scrim across the entire viewport (semi-transparent black).
69 scrim = Panel(name="Scrim")
70 scrim.set_anchor_preset(AnchorPreset.FULL_RECT)
71 scrim.bg_colour = Colour.rgba(0.0, 0.0, 0.0, 0.6)
72 self.add_child(scrim)
73
74 # Centred panel: CENTER anchor + symmetric margins make a fixed-size
75 # box that stays centred at any window size.
76 panel = Panel(name="MenuPanel")
77 panel.set_anchor_preset(AnchorPreset.CENTER)
78 panel.margin_left = -160
79 panel.margin_right = 160
80 panel.margin_top = -150
81 panel.margin_bottom = 150
82 panel.bg_colour = Colour.hex("#1A1A2E")
83 self.add_child(panel)
84
85 # TOP_WIDE collapses the vertical axis: the top/bottom margin pair sets
86 # the title band's height (16..52), not size_y.
87 title = Label("Paused")
88 title.set_anchor_preset(AnchorPreset.TOP_WIDE)
89 title.margin_top = 16
90 title.margin_bottom = 52
91 title.font_size = 24.0
92 title.text_colour = Colour.WHITE
93 title.alignment = "center"
94 panel.add_child(title)
95
96 # Stacked buttons in a vertical container.
97 buttons = VBoxContainer(name="Buttons")
98 buttons.set_anchor_preset(AnchorPreset.CENTER)
99 buttons.margin_left = -110
100 buttons.margin_right = 110
101 buttons.margin_top = -50
102 buttons.margin_bottom = 90
103 buttons.separation = 12.0
104 panel.add_child(buttons)
105
106 for label, handler in (("Resume", on_resume), ("Restart", on_restart), ("Quit", on_quit)):
107 btn = Button(label, name=f"{label}Button")
108 btn.size = Vec2(220, 36)
109 btn.pressed.connect(handler)
110 buttons.add_child(btn)
111
112
113class PauseMenuDemo(Node2D):
114 """Root: animated background content with a toggleable pause overlay on top."""
115
116 # on_draw orbits a marker from the per-frame _t timer (no Property), so it
117 # must re-run every frame under retained 2D.
118 dynamic = True
119
120 def on_ready(self):
121 InputMap.add_action("pause", [Key.P, Key.ESCAPE])
122
123 self._t = 0.0
124
125 hint = Label("Press P or ESC to pause")
126 hint.set_anchor_preset(AnchorPreset.CENTER_TOP)
127 hint.margin_left = -160
128 hint.margin_right = 160
129 hint.margin_top = 20
130 hint.font_size = 16.0
131 hint.text_colour = Colour.LIGHT_GRAY
132 hint.alignment = "center"
133 self.add_child(hint)
134
135 # Always-visible Pause button so mouse / touch users can open the menu
136 # without a keyboard. TOP_RIGHT anchor + margins keep it in the corner
137 # at any window size (margin_right - margin_left encodes the width).
138 pause_btn = Button("Pause", name="PauseButton")
139 pause_btn.set_anchor_preset(AnchorPreset.TOP_RIGHT)
140 pause_btn.margin_left = -112
141 pause_btn.margin_right = -16
142 pause_btn.margin_top = 16
143 pause_btn.margin_bottom = 48
144 pause_btn.pressed.connect(self._toggle_pause)
145 self.add_child(pause_btn)
146
147 self._overlay = PauseOverlay(
148 self._resume,
149 self._restart,
150 self._quit,
151 name="PauseOverlay",
152 )
153 self._overlay.visible = False # hidden until paused
154 self.add_child(self._overlay)
155
156 @property
157 def paused(self) -> bool:
158 return self._overlay.visible
159
160 def _toggle_pause(self):
161 self._overlay.visible = not self._overlay.visible
162
163 def _resume(self):
164 self._overlay.visible = False
165
166 def _restart(self):
167 self._t = 0.0
168 self._overlay.visible = False
169
170 def _quit(self):
171 self.app.quit()
172
173 def on_update(self, dt: float):
174 if Input.is_action_just_pressed("pause"):
175 self._toggle_pause()
176
177 # Background only advances while not paused, proving the overlay gates it.
178 if not self.paused:
179 self._t += dt
180
181 def on_draw(self, renderer):
182 # Live background: a dark fill plus markers orbiting the centre at
183 # different radii and speeds, so the dimmed overlay is visibly layered
184 # over moving content (sized from the live viewport each frame).
185 w, h = self.tree.screen_size
186 renderer.draw_rect((0, 0), (w, h), colour=Colour.hex("#10131C"), filled=True)
187
188 cx, cy = w / 2, h / 2
189 for radius, speed, size, hexcode in (
190 (160, 1.5, 36, "#4FC3F7"),
191 (110, -0.9, 26, "#F4A261"),
192 (215, 0.6, 18, "#9B5DE5"),
193 ):
194 x = cx + math.cos(self._t * speed) * radius
195 y = cy + math.sin(self._t * speed) * radius * 0.75
196 renderer.draw_rect((x - size / 2, y - size / 2), (size, size), colour=Colour.hex(hexcode), filled=True)
197
198
199def _selftest() -> bool:
200 """Headless: open and close the overlay by every route the demo offers.
201
202 Pausing is only ever triggered the way a player triggers it -- a key through
203 the action map, or a click on the Pause button's rectangle -- and what is
204 checked afterwards is that the background clock really stopped, not just that
205 a flag flipped. Quit is left alone: it closes the app.
206 """
207 from simvx.core.testing import InputSimulator
208 from simvx.core.ui.testing import UITestHarness
209
210 harness = UITestHarness(PauseMenuDemo(name="PauseMenuDemo"), screen_size=(WIDTH, HEIGHT))
211 scene = harness.tree.root
212 sim = InputSimulator(tree=harness.tree)
213 ok = True
214
215 def check(label: str, passed: bool, detail: str) -> None:
216 nonlocal ok
217 ok = ok and passed
218 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
219
220 def tap(key: Key) -> None:
221 sim.press_key(key)
222 harness.tick()
223 sim.release_key(key)
224
225 harness.tick(count=10)
226 check(
227 "it starts unpaused, with the background running",
228 not scene.paused and scene._t > 0.0,
229 f"overlay hidden, clock at {scene._t:.2f}s",
230 )
231
232 tap(Key.P)
233 paused_at = scene._t
234 harness.tick(count=20)
235 check(
236 "P pauses, and the background clock stops with it",
237 scene.paused and scene._t == paused_at,
238 f"clock held at {scene._t:.2f}s across 20 more frames",
239 )
240
241 tap(Key.ESCAPE)
242 harness.tick(count=10)
243 check(
244 "ESC is bound to the same toggle, and the clock runs again",
245 not scene.paused and scene._t > paused_at,
246 f"clock resumed to {scene._t:.2f}s",
247 )
248
249 harness.click(harness.find_by_name("PauseButton"))
250 harness.tick()
251 by_button = scene.paused
252 harness.click(harness.find_by_name("ResumeButton"))
253 harness.tick()
254 check(
255 "the Pause button opens the overlay and Resume closes it",
256 by_button and not scene.paused,
257 f"paused = {by_button} after the button, {scene.paused} after Resume",
258 )
259
260 tap(Key.P)
261 harness.click(harness.find_by_name("RestartButton"))
262 restarted, closed = scene._t, not scene.paused
263 harness.tick(count=10)
264 check(
265 "Restart puts the background back to the beginning and closes the menu",
266 restarted == 0.0 and closed and scene._t > 0.0,
267 f"clock reset to {restarted:.2f}s, then running again at {scene._t:.2f}s",
268 )
269
270 # The scrim has to cover the viewport and the panel stay centred, whatever
271 # size the window is: that is the anchoring claim, so it is measured twice.
272 tap(Key.P)
273 geometry = {}
274 for size in ((WIDTH, HEIGHT), (1024, 768)):
275 harness.tree.screen_size = size
276 harness.tick()
277 scrim = harness.find_by_name("Scrim").get_global_rect()
278 panel = harness.find_by_name("MenuPanel").get_global_rect()
279 geometry[size] = (
280 tuple(round(v) for v in scrim),
281 (round(panel[0] + panel[2] / 2), round(panel[1] + panel[3] / 2), round(panel[2]), round(panel[3])),
282 )
283 check(
284 "the scrim covers the whole viewport at any window size",
285 all(scrim == (0, 0, *size) for size, (scrim, _) in geometry.items()),
286 " | ".join(f"{size[0]}x{size[1]} -> {scrim}" for size, (scrim, _) in geometry.items()),
287 )
288 check(
289 "and the menu panel keeps its size, centred, at any window size",
290 all(centre == (size[0] // 2, size[1] // 2, 320, 300) for size, (_, centre) in geometry.items()),
291 " | ".join(f"{size[0]}x{size[1]} -> centre {c[:2]} size {c[2:]}" for size, (_, c) in geometry.items()),
292 )
293
294 harness.teardown()
295 print("SELFTEST:", "PASS" if ok else "FAIL")
296 return ok
297
298
299if __name__ == "__main__":
300 import sys
301
302 if "--test" in sys.argv:
303 sys.exit(0 if _selftest() else 1)
304 app = App(title="SimVX Pause Menu", width=WIDTH, height=HEIGHT)
305 app.run(PauseMenuDemo())