Pause Menu

a full-screen modal overlay with stacked buttons, toggled by a key.

▶ Run in browser

Tags: 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_RECT scrim Panel plus a CENTER-anchored panel, both top-level Controls using anchors and margins, never absolute position.

  • Toggling the whole overlay’s visible flag from a key press to show / hide.

  • A vertical VBoxContainer of Buttons wired with Button.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())