TileMap¶

GPU-batched tiles with a player-follow camera.

â–¶ Run in browser

Tags: 2d

Walk a character around a two-layer tile world with WASD or the arrow keys.

Demonstrates:

  • TileSet.from_atlas_array(): a procedurally generated atlas (grass, dirt, water, stone, flowers, trees, wall) used straight from memory, no file I/O

  • Two TileMap layers: background terrain plus foreground decorations, each submitted as a single instanced draw

  • TileMap.highlight_cells(): translucent cell overlays, the primitive strategy games use for movement and attack range

  • Camera2D following a Sprite2D: tiles and sprites share one world space and one canvas transform, so a single camera pans both

Run: uv run python examples/features/2d/tilemap.py

Controls: WASD / arrows - Move the character Escape - Quit

Source¶

  1#!/usr/bin/env python3
  2"""TileMap: GPU-batched tiles with a player-follow camera.
  3
  4# /// simvx
  5# web = { width = 1024, height = 768 }
  6# ///
  7
  8Walk a character around a two-layer tile world with WASD or the arrow keys.
  9
 10Demonstrates:
 11- TileSet.from_atlas_array(): a procedurally generated atlas (grass, dirt,
 12  water, stone, flowers, trees, wall) used straight from memory, no file I/O
 13- Two TileMap layers: background terrain plus foreground decorations, each
 14  submitted as a single instanced draw
 15- TileMap.highlight_cells(): translucent cell overlays, the primitive strategy
 16  games use for movement and attack range
 17- Camera2D following a Sprite2D: tiles and sprites share one world space and
 18  one canvas transform, so a single camera pans both
 19
 20Run: uv run python examples/features/2d/tilemap.py
 21
 22Controls:
 23    WASD / arrows - Move the character
 24    Escape        - Quit
 25"""
 26
 27import numpy as np
 28
 29from simvx.core import (
 30    AnchorPreset,
 31    Camera2D,
 32    CanvasLayer,
 33    Input,
 34    InputMap,
 35    Key,
 36    Label,
 37    Node2D,
 38    Property,
 39    Sprite2D,
 40    TileMap,
 41    TileSet,
 42    Vec2,
 43)
 44from simvx.graphics import App
 45
 46# -- Procedural tileset texture -----------------------------------------------
 47
 48TILE_PX = 16  # pixels per tile
 49ATLAS_COLS = 4
 50ATLAS_ROWS = 2
 51ATLAS_W = ATLAS_COLS * TILE_PX  # 64
 52ATLAS_H = ATLAS_ROWS * TILE_PX  # 32
 53
 54# Tile IDs (row-major from create_from_grid)
 55GRASS = 0
 56DIRT = 1
 57WATER = 2
 58STONE = 3
 59FLOWERS = 4
 60TREE_TOP = 5
 61WALL = 6
 62
 63
 64def _fill_tile(atlas: np.ndarray, col: int, row: int, colour: tuple[int, ...]):
 65    """Fill a tile region with a solid colour plus some noise for texture."""
 66    x0, y0 = col * TILE_PX, row * TILE_PX
 67    rng = np.random.RandomState(col * 7 + row * 13)
 68    for dy in range(TILE_PX):
 69        for dx in range(TILE_PX):
 70            noise = rng.randint(-15, 16)
 71            r = max(0, min(255, colour[0] + noise))
 72            g = max(0, min(255, colour[1] + noise))
 73            b = max(0, min(255, colour[2] + noise))
 74            a = colour[3] if len(colour) > 3 else 255
 75            atlas[y0 + dy, x0 + dx] = (r, g, b, a)
 76
 77
 78def _add_detail(atlas: np.ndarray, col: int, row: int, detail_colour: tuple[int, ...], count: int = 8):
 79    """Scatter random detail pixels on a tile."""
 80    x0, y0 = col * TILE_PX, row * TILE_PX
 81    rng = np.random.RandomState(col * 31 + row * 37)
 82    c = detail_colour if len(detail_colour) == 4 else (*detail_colour, 255)
 83    for _ in range(count):
 84        dx, dy = rng.randint(1, TILE_PX - 1), rng.randint(1, TILE_PX - 1)
 85        atlas[y0 + dy, x0 + dx] = c
 86
 87
 88def generate_tileset_atlas() -> np.ndarray:
 89    """Generate a simple procedural tileset atlas (RGBA uint8, shape HxWx4)."""
 90    atlas = np.zeros((ATLAS_H, ATLAS_W, 4), dtype=np.uint8)
 91
 92    # Row 0: terrain
 93    _fill_tile(atlas, 0, 0, (34, 139, 34))  # GRASS
 94    _add_detail(atlas, 0, 0, (50, 160, 50), 12)
 95    _fill_tile(atlas, 1, 0, (139, 90, 43))  # DIRT
 96    _add_detail(atlas, 1, 0, (120, 75, 35), 6)
 97    _fill_tile(atlas, 2, 0, (30, 100, 200))  # WATER
 98    _add_detail(atlas, 2, 0, (60, 130, 220), 10)
 99    _fill_tile(atlas, 3, 0, (128, 128, 128))  # STONE
100    _add_detail(atlas, 3, 0, (100, 100, 100), 8)
101
102    # Row 1: decorations
103    _fill_tile(atlas, 0, 1, (34, 139, 34))  # FLOWERS (grass + dots)
104    _add_detail(atlas, 0, 1, (255, 100, 100), 6)
105    _add_detail(atlas, 0, 1, (255, 255, 50), 4)
106    _fill_tile(atlas, 1, 1, (20, 100, 20))  # TREE_TOP
107    _add_detail(atlas, 1, 1, (30, 120, 30), 15)
108    _fill_tile(atlas, 2, 1, (90, 90, 90))  # WALL (brick pattern)
109    for dy in [0, 8]:
110        for dx in range(TILE_PX):
111            atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
112    for dx in [0, 8]:
113        for dy in range(TILE_PX):
114            atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
115    # The last atlas slot is left as it was allocated: fully transparent.
116
117    return atlas
118
119
120# -- Map generation -----------------------------------------------------------
121
122MAP_W, MAP_H = 40, 30
123
124
125def build_tilemap() -> TileMap:
126    """Create a TileMap with two layers: terrain + decorations."""
127    ts = TileSet.from_atlas_array(
128        generate_tileset_atlas(),
129        width=ATLAS_W,
130        height=ATLAS_H,
131        tile_size=(TILE_PX, TILE_PX),
132    )
133
134    tilemap = TileMap(name="DemoTileMap")
135    tilemap.tile_set = ts
136    tilemap.cell_size = (TILE_PX, TILE_PX)
137    tilemap.add_layer("Decorations")
138
139    rng = np.random.RandomState(42)
140
141    # Layer 0: terrain
142    for y in range(MAP_H):
143        for x in range(MAP_W):
144            if x == 0 or x == MAP_W - 1 or y == 0 or y == MAP_H - 1:
145                tilemap.set_cell(0, x, y, WALL)
146            elif 13 <= y <= 15 and 5 <= x <= MAP_W - 6:
147                tilemap.set_cell(0, x, y, WATER)
148            elif 19 <= x <= 21:
149                tilemap.set_cell(0, x, y, DIRT)
150            elif rng.random() < 0.05:
151                tilemap.set_cell(0, x, y, STONE)
152            else:
153                tilemap.set_cell(0, x, y, GRASS)
154
155    # Layer 1: decorations on grass
156    for y in range(1, MAP_H - 1):
157        for x in range(1, MAP_W - 1):
158            if tilemap.get_cell(0, x, y) != GRASS:
159                continue
160            r = rng.random()
161            if r < 0.08:
162                tilemap.set_cell(1, x, y, FLOWERS)
163            elif r < 0.12:
164                tilemap.set_cell(1, x, y, TREE_TOP)
165
166    return tilemap
167
168
169# -- Player sprite ------------------------------------------------------------
170
171PLAYER_PX = 24  # sprite display size
172CAMERA_ZOOM = 2.0  # 16 px tiles are drawn 32 px wide
173
174
175def _make_player_sprite(size: int = PLAYER_PX) -> np.ndarray:
176    """Procedural player sprite: yellow disc with a dark outline."""
177    img = np.zeros((size, size, 4), dtype=np.uint8)
178    cx, cy = (size - 1) / 2.0, (size - 1) / 2.0
179    r_outer = size / 2.0 - 0.5
180    r_inner = r_outer - 2.0
181    for y in range(size):
182        for x in range(size):
183            d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
184            if d <= r_inner:
185                img[y, x] = (255, 215, 80, 255)  # warm yellow
186            elif d <= r_outer:
187                img[y, x] = (40, 28, 10, 255)  # dark outline
188    return img
189
190
191# -- Game scene ---------------------------------------------------------------
192
193
194class TileMapDemo(Node2D):
195    """Root node: tilemap + player sprite + a Camera2D that follows the player.
196
197    Tiles and sprites live in the same world space and project through the
198    same canvas transform, so one Camera2D pans the whole scene together.
199    """
200
201    player_speed = Property(180.0, range=(50, 500))
202
203    def on_ready(self):
204        InputMap.add_action("move_left", [Key.A, Key.LEFT])
205        InputMap.add_action("move_right", [Key.D, Key.RIGHT])
206        InputMap.add_action("move_up", [Key.W, Key.UP])
207        InputMap.add_action("move_down", [Key.S, Key.DOWN])
208        InputMap.add_action("quit", [Key.ESCAPE])
209
210        # Playable area = inside the wall border (one-tile-thick wall around
211        # the map). Half the sprite stays inside the wall so no pixel crosses
212        # the border.
213        half = PLAYER_PX / 2.0
214        self._bounds_min = Vec2(TILE_PX + half, TILE_PX + half)
215        self._bounds_max = Vec2(
216            (MAP_W - 1) * TILE_PX - half,
217            (MAP_H - 1) * TILE_PX - half,
218        )
219
220        # TileMap (atlas pixels are on the TileSet; scene adapter uploads lazily)
221        self._tilemap = self.add_child(build_tilemap())
222
223        # Demonstrate TileMap.highlight_cells: translucent overlay used by
224        # tactics/strategy ports to show movement and attack range. Two
225        # groups: a blue "move" diamond and a red "attack" ring around it.
226        move_cells = [
227            (cx, cy)
228            for cx in range(MAP_W // 2 - 4, MAP_W // 2 + 5)
229            for cy in range(MAP_H // 2 - 4, MAP_H // 2 + 5)
230            if abs(cx - MAP_W // 2) + abs(cy - MAP_H // 2) <= 4
231        ]
232        attack_cells = [
233            (cx, cy)
234            for cx in range(MAP_W // 2 - 6, MAP_W // 2 + 7)
235            for cy in range(MAP_H // 2 - 6, MAP_H // 2 + 7)
236            if 5 <= abs(cx - MAP_W // 2) + abs(cy - MAP_H // 2) <= 6
237        ]
238        self._tilemap.highlight_cells(move_cells, colour=(0.2, 0.55, 1.0, 0.4))
239        self._tilemap.highlight_cells(attack_cells, colour=(1.0, 0.25, 0.25, 0.45))
240
241        # Player sprite: an ordinary world-space Sprite2D standing on the tiles.
242        self._player = self.add_child(
243            Sprite2D(
244                texture=_make_player_sprite(),
245                width=PLAYER_PX,
246                height=PLAYER_PX,
247                position=Vec2(MAP_W * TILE_PX / 2, MAP_H * TILE_PX / 2),
248                name="Player",
249            )
250        )
251
252        # Camera2D follows the sprite; its limits stop the view at the map edges.
253        self._camera = self.add_child(Camera2D(name="Camera", zoom=CAMERA_ZOOM))
254        self._camera.target = self._player
255        self.tree.screen_resized.connect(self._fit_camera)
256        self._fit_camera()
257
258        # On-screen controls hint. A CanvasLayer keeps its subtree screen-pinned
259        # (the default follow_viewport=False), so the hint stays put as the
260        # camera pans; anchors keep it on the bottom edge at any window size.
261        hud = self.add_child(CanvasLayer(name="HUD", layer=CanvasLayer.Band.UI))
262        hint = hud.add_child(Label("WASD / arrows: move   Esc: quit", name="Hint"))
263        hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
264        hint.margin_left = 16
265        hint.margin_right = 16
266        hint.margin_top = -40
267        hint.margin_bottom = -12
268        hint.font_size = 18.0
269        hint.alignment = "center"
270
271    def _fit_camera(self, size: tuple[int, int] | None = None) -> None:
272        """Clamp the camera so the view never leaves the map. Re-run on resize."""
273        sw, sh = size if size is not None else self.tree.screen_size
274        world_w, world_h = MAP_W * TILE_PX, MAP_H * TILE_PX
275        # Camera2D clamps its centre, so inset the limits by half a viewport.
276        half_w, half_h = sw / (2.0 * CAMERA_ZOOM), sh / (2.0 * CAMERA_ZOOM)
277        if world_w > 2 * half_w:
278            self._camera.limit_left, self._camera.limit_right = half_w, world_w - half_w
279        else:
280            # The map is narrower than the view: nothing to pan to, so centre it.
281            self._camera.limit_left = self._camera.limit_right = world_w * 0.5
282        if world_h > 2 * half_h:
283            self._camera.limit_top, self._camera.limit_bottom = half_h, world_h - half_h
284        else:
285            self._camera.limit_top = self._camera.limit_bottom = world_h * 0.5
286
287    def on_update(self, dt: float):
288        if Input.is_action_just_pressed("quit"):
289            self.app.quit()
290            return
291        move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
292        if move.x != 0 or move.y != 0:
293            step = self.player_speed * dt
294            pos = self._player.position
295            self._player.position = Vec2(
296                min(max(pos.x + move.x * step, self._bounds_min.x), self._bounds_max.x),
297                min(max(pos.y + move.y * step, self._bounds_min.y), self._bounds_max.y),
298            )
299
300
301# -- Entry point --------------------------------------------------------------
302
303if __name__ == "__main__":
304    App(width=1024, height=768, title="TileMap Demo").run(TileMapDemo())