Minimap¶

a corner overview of a scrolling 2D world

â–¶ Run in browser

Tags: ui minimap camera 2d hud

A player roams a world several screens wide while a Camera2D follows, and a minimap panel in the top-right corner shows the whole world at once: a world-to-panel affine maps landmarks and patrolling enemies to dots, the player to a brighter marker, and the camera’s current viewport to an outlined rectangle. Drawing the map with screen-space Draw2D from the same world data is the standard approach in 2D: a render-to-texture second view of the main world is not available, and a SubViewport renders only its own subtree.

What it demonstrates¶

  • A world -> minimap affine (world_to_map): one uniform scale plus the panel’s top-left offset maps any world position onto the panel.

  • Screen-space overlay drawing: screen_space=True on Draw2D calls bypasses the active Camera2D, so the panel stays pinned while the world scrolls.

  • push_clip / pop_clip so map content never spills outside the panel.

  • The camera’s viewport as a map rectangle, from camera.current, zoom, and the live window size.

  • Camera2D follow with smoothing and limit_* clamped to the world edges.

Controls: WASD / arrows - Move the player (camera follows) M - Toggle the minimap ESC - Quit

Run: uv run python examples/features/ui/minimap.py Headless self-check: uv run python examples/features/ui/minimap.py –test

Source¶

  1"""Minimap: a corner overview of a scrolling 2D world
  2
  3A player roams a world several screens wide while a Camera2D follows, and a
  4minimap panel in the top-right corner shows the whole world at once: a
  5world-to-panel affine maps landmarks and patrolling enemies to dots, the
  6player to a brighter marker, and the camera's current viewport to an outlined
  7rectangle. Drawing the map with screen-space Draw2D from the same world data
  8is the standard approach in 2D: a render-to-texture second view of the main
  9world is not available, and a SubViewport renders only its own subtree.
 10
 11# /// simvx
 12# tags = ["ui", "minimap", "camera", "2d", "hud"]
 13# web = { root = "MinimapDemo", width = 960, height = 540, responsive = true }
 14# ///
 15
 16## What it demonstrates
 17
 18- A world -> minimap affine (`world_to_map`): one uniform scale plus the
 19  panel's top-left offset maps any world position onto the panel.
 20- Screen-space overlay drawing: `screen_space=True` on Draw2D calls bypasses
 21  the active Camera2D, so the panel stays pinned while the world scrolls.
 22- `push_clip` / `pop_clip` so map content never spills outside the panel.
 23- The camera's viewport as a map rectangle, from `camera.current`, `zoom`,
 24  and the live window size.
 25- `Camera2D` follow with smoothing and `limit_*` clamped to the world edges.
 26
 27Controls:
 28  WASD / arrows - Move the player (camera follows)
 29  M             - Toggle the minimap
 30  ESC           - Quit
 31
 32Run: uv run python examples/features/ui/minimap.py
 33Headless self-check: uv run python examples/features/ui/minimap.py --test
 34"""
 35
 36import math
 37
 38from simvx.core import Camera2D, Input, Key, Node2D, Vec2
 39from simvx.graphics import App
 40
 41WIDTH, HEIGHT = 960, 540
 42WORLD_W, WORLD_H = 3000.0, 1800.0  # world spans [-W/2, W/2] x [-H/2, H/2]
 43
 44# Minimap panel: same aspect ratio as the world, so one uniform scale fits both axes.
 45MAP_W = 200.0
 46MAP_SCALE = MAP_W / WORLD_W
 47MAP_H = WORLD_H * MAP_SCALE
 48MAP_MARGIN = 12.0
 49
 50# World-space landmarks (x, y, colour): fixed geography the map and world share.
 51LANDMARKS = [
 52    (-1250, -700, (0.9, 0.45, 0.4, 1.0)),
 53    (-600, 300, (0.95, 0.8, 0.35, 1.0)),
 54    (-150, -550, (0.5, 0.85, 0.5, 1.0)),
 55    (450, 650, (0.7, 0.55, 0.95, 1.0)),
 56    (900, -250, (0.4, 0.75, 0.95, 1.0)),
 57    (1250, 500, (0.95, 0.6, 0.8, 1.0)),
 58    (-950, 700, (0.6, 0.9, 0.85, 1.0)),
 59    (150, 100, (0.85, 0.85, 0.85, 1.0)),
 60]
 61
 62# Enemy patrols (centre x, centre y, orbit radius, angular speed, phase).
 63PATROLS = [
 64    (-1000, -300, 140, 0.9, 0.0),
 65    (-400, -650, 90, 1.4, 2.1),
 66    (300, 400, 160, 0.7, 4.2),
 67    (800, -600, 110, 1.1, 1.0),
 68    (1200, 100, 130, 0.8, 3.3),
 69    (-200, 750, 100, 1.3, 5.0),
 70]
 71
 72
 73def world_to_map(wx: float, wy: float, panel_x: float, panel_y: float) -> tuple[float, float]:
 74    """The world -> minimap affine: uniform scale, then the panel's top-left offset."""
 75    return (
 76        panel_x + (wx + WORLD_W / 2) * MAP_SCALE,
 77        panel_y + (wy + WORLD_H / 2) * MAP_SCALE,
 78    )
 79
 80
 81class Player(Node2D):
 82    SPEED = 340.0
 83
 84    def on_update(self, dt: float):
 85        v = Input.get_vector("move_left", "move_right", "move_up", "move_down")
 86        self.position += v * self.SPEED * dt
 87        self.position.x = max(-WORLD_W / 2, min(WORLD_W / 2, self.position.x))
 88        self.position.y = max(-WORLD_H / 2, min(WORLD_H / 2, self.position.y))
 89
 90
 91class Enemy(Node2D):
 92    """Orbits a fixed centre so its map dot visibly patrols."""
 93
 94    def __init__(self, centre: Vec2, radius: float, speed: float, phase: float, **kwargs):
 95        super().__init__(**kwargs)
 96        self._centre = centre
 97        self._radius = radius
 98        self._speed = speed
 99        self._angle = phase
100
101    def on_update(self, dt: float):
102        self._angle += self._speed * dt
103        self.position = self._centre + Vec2(
104            math.cos(self._angle) * self._radius,
105            math.sin(self._angle) * self._radius,
106        )
107
108
109class MinimapDemo(Node2D):
110    dynamic = True  # the world scrolls and the map dots patrol every frame
111
112    input_actions = {
113        "move_left": [Key.A, Key.LEFT],
114        "move_right": [Key.D, Key.RIGHT],
115        "move_up": [Key.W, Key.UP],
116        "move_down": [Key.S, Key.DOWN],
117        "toggle_map": [Key.M],
118        "quit": [Key.ESCAPE],
119    }
120
121    def on_ready(self):
122        self._map_visible = True
123
124        self.player = self.add_child(Player(position=Vec2(0, 0)))
125        self._enemies = [
126            self.add_child(Enemy(Vec2(cx, cy), r, speed, phase))
127            for cx, cy, r, speed, phase in PATROLS
128        ]
129
130        self.camera = self.add_child(Camera2D())
131        self.camera.target = self.player
132        self.camera.smoothing = 6.0
133        # Clamp the camera centre so the view never shows past the world edges
134        # (computed from the launch size, as in features/2d/camera.py).
135        self.camera.limit_left = -WORLD_W / 2 + WIDTH / 2
136        self.camera.limit_right = WORLD_W / 2 - WIDTH / 2
137        self.camera.limit_top = -WORLD_H / 2 + HEIGHT / 2
138        self.camera.limit_bottom = WORLD_H / 2 - HEIGHT / 2
139
140    def on_update(self, dt: float):
141        if Input.is_action_just_pressed("toggle_map"):
142            self._map_visible = not self._map_visible
143        if Input.is_action_just_pressed("quit"):
144            self.app.quit()
145
146    def on_draw(self, renderer):
147        self._draw_world(renderer)
148        if self._map_visible:
149            self._draw_minimap(renderer)
150        renderer.draw_text(
151            "Minimap   WASD/arrows: move   M: toggle map   ESC: quit",
152            (12, HEIGHT - 26),
153            colour=(0.65, 0.65, 0.7, 1.0),
154            screen_space=True,
155        )
156
157    def _draw_world(self, renderer):
158        # World-space scenery: a dot grid to scroll against, the world border,
159        # the landmarks, the patrols, and the player. All camera-transformed.
160        for gx in range(int(-WORLD_W / 2), int(WORLD_W / 2) + 1, 200):
161            for gy in range(int(-WORLD_H / 2), int(WORLD_H / 2) + 1, 200):
162                renderer.draw_circle((gx, gy), 3, colour=(0.28, 0.28, 0.34, 1.0), filled=True)
163        renderer.draw_rect(
164            (-WORLD_W / 2, -WORLD_H / 2), (WORLD_W, WORLD_H), colour=(0.5, 0.5, 0.6, 1.0), filled=False
165        )
166        for lx, ly, col in LANDMARKS:
167            renderer.draw_rect((lx - 28, ly - 28), (56, 56), colour=col, filled=True)
168        for e in self._enemies:
169            p = e.position
170            renderer.draw_circle((p.x, p.y), 14, colour=(0.95, 0.3, 0.3, 1.0), filled=True)
171        pp = self.player.position
172        renderer.draw_rect((pp.x - 16, pp.y - 16), (32, 32), colour=(0.4, 0.85, 1.0, 1.0), filled=True)
173
174    def _draw_minimap(self, renderer):
175        # Panel origin from the live window width, so the map hugs the corner
176        # after a resize.
177        px = self.app.width - MAP_W - MAP_MARGIN
178        py = MAP_MARGIN
179
180        renderer.draw_rect((px, py), (MAP_W, MAP_H), colour=(0.05, 0.06, 0.09, 0.85), filled=True, screen_space=True)
181
182        # Everything inside the panel is clipped to it, so the viewport
183        # rectangle cannot spill past the border however the camera sits.
184        renderer.push_clip(int(px), int(py), int(MAP_W), int(MAP_H))
185
186        for lx, ly, col in LANDMARKS:
187            mx, my = world_to_map(lx, ly, px, py)
188            renderer.draw_rect((mx - 2, my - 2), (4, 4), colour=col, filled=True, screen_space=True)
189        for e in self._enemies:
190            mx, my = world_to_map(e.position.x, e.position.y, px, py)
191            renderer.draw_circle((mx, my), 2.5, colour=(0.95, 0.3, 0.3, 1.0), filled=True, screen_space=True)
192
193        # The camera's viewport: centre is the smoothed, limit-clamped camera
194        # position; extent is the window size divided by zoom.
195        zoom = float(self.camera.zoom) if self.camera.zoom > 0 else 1.0
196        half_w = self.app.width / (2 * zoom)
197        half_h = self.app.height / (2 * zoom)
198        cx, cy = self.camera.current.x, self.camera.current.y
199        tlx, tly = world_to_map(cx - half_w, cy - half_h, px, py)
200        brx, bry = world_to_map(cx + half_w, cy + half_h, px, py)
201        renderer.draw_rect(
202            (tlx, tly), (brx - tlx, bry - tly), colour=(1.0, 1.0, 1.0, 0.9), filled=False, screen_space=True
203        )
204
205        # Player last, on top of everything: a distinct ringed marker.
206        mx, my = world_to_map(self.player.position.x, self.player.position.y, px, py)
207        renderer.draw_circle((mx, my), 4.0, colour=(0.05, 0.06, 0.09, 1.0), filled=True, screen_space=True)
208        renderer.draw_circle((mx, my), 2.8, colour=(0.4, 0.9, 1.0, 1.0), filled=True, screen_space=True)
209
210        renderer.pop_clip()
211        renderer.draw_rect((px, py), (MAP_W, MAP_H), colour=(0.75, 0.8, 0.95, 1.0), filled=False, screen_space=True)
212
213
214def _selftest() -> bool:
215    """Headless: check the affine, the camera follow, and the M toggle."""
216    from simvx.core.testing import InputSimulator
217    from simvx.graphics.testing import assert_not_blank, save_png
218
219    ok = True
220
221    def check(label: str, passed: bool, detail: str) -> None:
222        nonlocal ok
223        ok = ok and passed
224        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
225
226    # The affine alone: world corners land on panel corners, the centre on the
227    # panel centre, and the scale is uniform in x and y.
228    px, py = 100.0, 20.0
229    tl = world_to_map(-WORLD_W / 2, -WORLD_H / 2, px, py)
230    br = world_to_map(WORLD_W / 2, WORLD_H / 2, px, py)
231    mid = world_to_map(0, 0, px, py)
232    check("world corners map to panel corners", tl == (px, py) and br == (px + MAP_W, py + MAP_H), f"{tl} .. {br}")
233    check(
234        "world centre maps to panel centre",
235        abs(mid[0] - (px + MAP_W / 2)) < 1e-9 and abs(mid[1] - (py + MAP_H / 2)) < 1e-9,
236        f"{mid}",
237    )
238    sx = (br[0] - tl[0]) / WORLD_W
239    sy = (br[1] - tl[1]) / WORLD_H
240    check("scale is uniform", abs(sx - sy) < 1e-12 and abs(sx - MAP_SCALE) < 1e-12, f"sx={sx} sy={sy}")
241
242    # Headless run: hold D to walk right, then tap M twice; the demo's own
243    # handlers own the camera and the toggle flag, so this watches the real
244    # input wiring rather than a shortcut the test added.
245    FRAMES = 240
246    sim = InputSimulator()
247    app = App(title="Minimap", width=WIDTH, height=HEIGHT, visible=False)
248    scene = MinimapDemo(name="MinimapDemo")
249    samples: dict[int, bool] = {}
250    start_x = [0.0]
251
252    def on_frame(idx: int, _t: float) -> bool:
253        if idx == 10:
254            start_x[0] = float(scene.player.position.x)
255        if idx == 20:
256            sim.press_key(Key.D)
257        elif idx == 120:
258            sim.release_key(Key.D)
259        elif idx == 140:
260            sim.press_key(Key.M)
261        elif idx == 141:
262            sim.release_key(Key.M)
263        elif idx == 170:
264            sim.press_key(Key.M)
265        elif idx == 171:
266            sim.release_key(Key.M)
267        if idx in (130, 160, 200):
268            samples[idx] = scene._map_visible
269        return True
270
271    frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
272    assert_not_blank(frames[0])
273    save_png(frames[0], "/tmp/minimap_test.png")
274
275    end_x = float(scene.player.position.x)
276    check("holding D moved the player right", end_x > start_x[0] + 200, f"x {start_x[0]:.0f} -> {end_x:.0f}")
277    check(
278        "the camera converged on the player",
279        abs(float(scene.camera.current.x) - end_x) < 30,
280        f"camera x {float(scene.camera.current.x):.0f} vs player x {end_x:.0f}",
281    )
282    check(
283        "M toggles the minimap off and back on",
284        samples.get(130) is True and samples.get(160) is False and samples.get(200) is True,
285        f"visible at 130/160/200: {samples.get(130)}/{samples.get(160)}/{samples.get(200)}",
286    )
287
288    # The viewport rectangle the map would draw sits inside the panel, because
289    # the camera limits keep the view within the world the panel spans.
290    panel_x, panel_y = WIDTH - MAP_W - MAP_MARGIN, MAP_MARGIN
291    cx, cy = float(scene.camera.current.x), float(scene.camera.current.y)
292    tlx, tly = world_to_map(cx - WIDTH / 2, cy - HEIGHT / 2, panel_x, panel_y)
293    brx, bry = world_to_map(cx + WIDTH / 2, cy + HEIGHT / 2, panel_x, panel_y)
294    inside = (
295        panel_x - 1e-6 <= tlx
296        and panel_y - 1e-6 <= tly
297        and brx <= panel_x + MAP_W + 1e-6
298        and bry <= panel_y + MAP_H + 1e-6
299    )
300    check("the viewport rectangle stays inside the panel", inside, f"({tlx:.1f},{tly:.1f})..({brx:.1f},{bry:.1f})")
301
302    print("screenshot: /tmp/minimap_test.png")
303    print("SELFTEST:", "PASS" if ok else "FAIL")
304    return ok
305
306
307if __name__ == "__main__":
308    import sys
309
310    if "--test" in sys.argv:
311        sys.exit(0 if _selftest() else 1)
312    App(title="Minimap", width=WIDTH, height=HEIGHT).run(MinimapDemo())