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. A minimap in the top-right
corner shows the whole world at once: landmarks and patrolling enemies as dots, the player as a
brighter marker, and the part of the world the camera is showing as an outlined rectangle.
The minimap is drawn, not rendered: a Control with its own on_draw redraws the same world data
at a smaller scale. A second camera onto the main world is not available, and a SubViewport
renders only its own subtree, so drawing the map from the data is the way to do it.
Placement. The map is a
Controlunder aCanvasLayer, so it is pinned to the screen and ignores the camera.anchor=Anchor.TOP_RIGHT, inset=12keeps it in the corner at any window size, and itson_drawworks in its own space:(0, 0)is the panel’s top-left corner.The world-to-map mapping (
to_map) shifts the world’s top-left corner to the origin and applies one uniform scale. The panel has the world’s aspect ratio, so one scale fits both axes.Clipping.
renderer.push_clip(...)/pop_clip()around the contents keep the camera rectangle from spilling past the panel’s border.The camera’s view comes from
camera.screen_to_world()on the two corners of the screen, which accounts for smoothing, the edge limits andzoom.dynamic = Trueon the map, because it reads moving nodes rather than its own Properties and must redraw every frame.
The camera follows with smoothing, and its limit_* edges are inset by half the visible area
every frame so the view stops at the edge of the world whatever the window size.
Move with WASD, the arrow keys or a stick, or hold the mouse button (or a finger) down and the player heads for the pointer. M or the Minimap button shows and hides the map.
Source¶
1# /// simvx
2# tags = ["ui", "minimap", "camera", "2d", "hud"]
3# web = { width = 960, height = 540, responsive = true }
4# ///
5"""Minimap: a corner overview of a scrolling 2D world.
6
7A player roams a world several screens wide while a `Camera2D` follows. A minimap in the top-right
8corner shows the whole world at once: landmarks and patrolling enemies as dots, the player as a
9brighter marker, and the part of the world the camera is showing as an outlined rectangle.
10
11The minimap is drawn, not rendered: a `Control` with its own `on_draw` redraws the same world data
12at a smaller scale. A second camera onto the main world is not available, and a `SubViewport`
13renders only its own subtree, so drawing the map from the data is the way to do it.
14
15- **Placement.** The map is a `Control` under a `CanvasLayer`, so it is pinned to the screen and
16 ignores the camera. `anchor=Anchor.TOP_RIGHT, inset=12` keeps it in the corner at any window
17 size, and its `on_draw` works in its own space: `(0, 0)` is the panel's top-left corner.
18- **The world-to-map mapping** (`to_map`) shifts the world's top-left corner to the origin and
19 applies one uniform scale. The panel has the world's aspect ratio, so one scale fits both axes.
20- **Clipping.** `renderer.push_clip(...)` / `pop_clip()` around the contents keep the camera
21 rectangle from spilling past the panel's border.
22- **The camera's view** comes from `camera.screen_to_world()` on the two corners of the screen,
23 which accounts for smoothing, the edge limits and `zoom`.
24- **`dynamic = True`** on the map, because it reads moving nodes rather than its own Properties
25 and must redraw every frame.
26
27The camera follows with `smoothing`, and its `limit_*` edges are inset by half the visible area
28every frame so the view stops at the edge of the world whatever the window size.
29
30Move with WASD, the arrow keys or a stick, or hold the mouse button (or a finger) down and the
31player heads for the pointer. M or the Minimap button shows and hides the map.
32"""
33
34from simvx.core import (
35 Anchor,
36 Axis2D,
37 Button,
38 Camera2D,
39 CanvasLayer,
40 Control,
41 Controller,
42 Input,
43 Key,
44 Label,
45 Node2D,
46 Pointer,
47 Vec2,
48 on_input,
49)
50from simvx.graphics import App
51
52HALF = Vec2(1500, 900) # the world spans -HALF .. HALF
53SPEED = 340.0
54ENEMY_COLOUR = (0.95, 0.3, 0.3, 1.0)
55PLAYER_COLOUR = (0.4, 0.85, 1.0, 1.0)
56
57# (x, y, colour): fixed geography the world and the map share.
58LANDMARKS = [
59 (-1250, -700, (0.9, 0.45, 0.4, 1.0)),
60 (-600, 300, (0.95, 0.8, 0.35, 1.0)),
61 (-150, -550, (0.5, 0.85, 0.5, 1.0)),
62 (450, 650, (0.7, 0.55, 0.95, 1.0)),
63 (900, -250, (0.4, 0.75, 0.95, 1.0)),
64 (1250, 500, (0.95, 0.6, 0.8, 1.0)),
65 (-950, 700, (0.6, 0.9, 0.85, 1.0)),
66 (150, 100, (0.85, 0.85, 0.85, 1.0)),
67]
68
69# Enemy patrols: (centre x, centre y, orbit radius, angular speed, starting angle).
70PATROLS = [
71 (-1000, -300, 140, 0.9, 0.0),
72 (-400, -650, 90, 1.4, 2.1),
73 (300, 400, 160, 0.7, 4.2),
74 (800, -600, 110, 1.1, 1.0),
75 (1200, 100, 130, 0.8, 3.3),
76 (-200, 750, 100, 1.3, 5.0),
77]
78
79
80class Player(Node2D):
81 def on_draw(self, renderer):
82 renderer.rect((-16, -16), (32, 32), fill=PLAYER_COLOUR)
83
84
85class Enemy(Node2D):
86 """Circles a fixed centre, so its dot visibly patrols on the map."""
87
88 def __init__(self, centre, radius: float, speed: float, angle: float, **kwargs):
89 super().__init__(**kwargs)
90 self.centre, self.radius, self.speed, self.angle = Vec2(centre), radius, speed, angle
91
92 def on_update(self, dt):
93 self.angle += self.speed * dt
94 self.position = self.centre + Vec2.from_angle(self.angle) * self.radius
95
96 def on_draw(self, renderer):
97 renderer.circle((0, 0), 14, fill=ENEMY_COLOUR)
98
99
100class Minimap(Control):
101 """The whole world at a glance, drawn in the panel's own space."""
102
103 dynamic = True # it reads moving nodes, so it redraws every frame
104
105 def __init__(self, world: MinimapDemo, **kwargs):
106 super().__init__(**kwargs)
107 self.world = world
108
109 def to_map(self, point) -> Vec2:
110 return (Vec2(point) + HALF) * (self.size.x / (2 * HALF.x))
111
112 def on_draw(self, renderer):
113 size = self.size
114 renderer.rect((0, 0), size, fill=(0.05, 0.06, 0.09, 0.85))
115 renderer.push_clip(0, 0, size.x, size.y)
116
117 for x, y, colour in LANDMARKS:
118 renderer.rect(self.to_map((x, y)) - 2, (4, 4), fill=colour)
119 for enemy in self.world.enemies:
120 renderer.circle(self.to_map(enemy.position), 2.5, fill=ENEMY_COLOUR)
121
122 camera = self.world.camera
123 top_left = self.to_map(camera.screen_to_world((0, 0)))
124 bottom_right = self.to_map(camera.screen_to_world(camera.viewport_size))
125 renderer.rect(top_left, bottom_right - top_left, stroke=(1.0, 1.0, 1.0, 0.9))
126
127 player = self.to_map(self.world.player.position)
128 renderer.circle(player, 4.0, fill=(0.05, 0.06, 0.09, 1.0))
129 renderer.circle(player, 2.8, fill=PLAYER_COLOUR)
130
131 renderer.pop_clip()
132 renderer.rect((0, 0), size, stroke=(0.75, 0.8, 0.95, 1.0))
133
134
135class MinimapDemo(Node2D):
136 input_context = {
137 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
138 "steer": [Pointer.PRIMARY],
139 "toggle_map": [Key.M],
140 }
141
142 def on_ready(self):
143 self.player = self.add_child(Player())
144 self.enemies = [self.add_child(Enemy((x, y), r, speed, angle)) for x, y, r, speed, angle in PATROLS]
145 self.camera = self.add_child(Camera2D(smoothing=6.0))
146 self.camera.target = self.player
147
148 hud = self.add_child(CanvasLayer())
149 map_size = (200, 200 * HALF.y / HALF.x) # the world's aspect ratio
150 self.minimap = hud.add_child(Minimap(self, anchor=Anchor.TOP_RIGHT, size=map_size, inset=12))
151 self.map_button = hud.add_child(
152 Button("Minimap", action="toggle_map", checked=True, anchor=Anchor.BOTTOM_RIGHT, inset=12)
153 )
154
155 # Example help overlay: not part of the technique shown.
156 help_text = "WASD / arrows / stick, or hold the pointer down to move M: toggle the map"
157 hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=14))
158
159 @on_input("toggle_map")
160 def toggle_map(self, _event):
161 self.minimap.visible = not self.minimap.visible
162 self.map_button.checked = self.minimap.visible
163
164 def on_update(self, dt):
165 step = SPEED * dt
166 if Input.pressed("steer"):
167 position = self.player.position.move_toward(self.camera.screen_to_world(Input.mouse_position), step)
168 else:
169 position = self.player.position + Input.value("move") * step
170 self.player.position = position.clamped(-HALF, HALF)
171
172 # The limits clamp the camera's centre, so inset them by half the visible area.
173 half = self.viewport_size / (2 * self.camera.zoom)
174 self.camera.limit_left, self.camera.limit_top = -HALF + half
175 self.camera.limit_right, self.camera.limit_bottom = HALF - half
176
177 def on_draw(self, renderer):
178 w, h = int(HALF.x), int(HALF.y)
179 for x in range(-w, w + 1, 200):
180 for y in range(-h, h + 1, 200):
181 renderer.circle((x, y), 3, fill=(0.28, 0.28, 0.34, 1.0))
182 renderer.rect(-HALF, 2 * HALF, stroke=(0.5, 0.5, 0.6, 1.0))
183 for x, y, colour in LANDMARKS:
184 renderer.rect((x - 28, y - 28), (56, 56), fill=colour)
185
186
187if __name__ == "__main__":
188 App(title="Minimap", width=960, height=540).run(MinimapDemo())