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. 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 Control under a CanvasLayer, so it is pinned to the screen and ignores the camera. anchor=Anchor.TOP_RIGHT, inset=12 keeps it in the corner at any window size, and its on_draw works 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 and zoom.

  • dynamic = True on 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())