Minimap¶
a corner overview of a scrolling 2D world
â–¶ Run in browserTags: 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=Trueon Draw2D calls bypasses the active Camera2D, so the panel stays pinned while the world scrolls.push_clip/pop_clipso map content never spills outside the panel.The camera’s viewport as a map rectangle, from
camera.current,zoom, and the live window size.Camera2Dfollow with smoothing andlimit_*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())