TileMap

a two-layer tile world with a camera that follows the player.

▶ Run in browser

Tags: 2d

A TileMap draws a grid of tiles cut from one atlas image. Its tile_set says how the atlas is cut: TileSet.from_atlas_array(pixels, width, height, tile_size) takes an RGBA uint8 array of shape (height, width, 4) straight from memory and numbers the tiles row by row, so here ids 0-3 are the top row (grass, dirt, water, stone) and 4-6 the second (flowers, tree, wall). The atlas is generated with numpy, so the example loads no files.

A map has layers, drawn in order. Layer 0 exists already and add_layer() adds another; here layer 0 holds the terrain and layer 1 the flowers and trees on top of it. set_cell(layer, x, y, tile_id) writes one cell, and get_layer(i).set_cells(ids) writes a whole (rows, columns) numpy array at once, with -1 for an empty cell. Each layer is drawn as one instanced batch.

highlight_cells(cells, colour) lays a translucent fill over a list of (x, y) cells, the way a strategy game shows movement and attack range: the blue diamond and the red ring in the middle of the map are two such groups.

Tiles and sprites share one world space, so the player is an ordinary Sprite2D and a single Camera2D with the player as its target pans both. zoom = 2 draws each 16-pixel tile 32 pixels wide. The camera’s limit_* values clamp its centre, so the example insets them by half the visible area every frame to stop the view at the edge of the map, and centres the map when the window is larger than it.

Move with WASD, the arrow keys or a gamepad stick, or hold the mouse button (or a finger) down and the player heads for the pointer.

Source

  1# /// simvx
  2# web = { width = 1024, height = 768 }
  3# ///
  4"""TileMap: a two-layer tile world with a camera that follows the player.
  5
  6A `TileMap` draws a grid of tiles cut from one atlas image. Its `tile_set` says how the atlas is
  7cut: `TileSet.from_atlas_array(pixels, width, height, tile_size)` takes an RGBA `uint8` array of
  8shape `(height, width, 4)` straight from memory and numbers the tiles row by row, so here ids 0-3
  9are the top row (grass, dirt, water, stone) and 4-6 the second (flowers, tree, wall). The atlas is
 10generated with numpy, so the example loads no files.
 11
 12A map has layers, drawn in order. Layer 0 exists already and `add_layer()` adds another; here
 13layer 0 holds the terrain and layer 1 the flowers and trees on top of it. `set_cell(layer, x, y,
 14tile_id)` writes one cell, and `get_layer(i).set_cells(ids)` writes a whole `(rows, columns)`
 15numpy array at once, with -1 for an empty cell. Each layer is drawn as one instanced batch.
 16
 17`highlight_cells(cells, colour)` lays a translucent fill over a list of `(x, y)` cells, the way a
 18strategy game shows movement and attack range: the blue diamond and the red ring in the middle of
 19the map are two such groups.
 20
 21Tiles and sprites share one world space, so the player is an ordinary `Sprite2D` and a single
 22`Camera2D` with the player as its `target` pans both. `zoom = 2` draws each 16-pixel tile 32
 23pixels wide. The camera's `limit_*` values clamp its centre, so the example insets them by half
 24the visible area every frame to stop the view at the edge of the map, and centres the map when
 25the window is larger than it.
 26
 27Move with WASD, the arrow keys or a gamepad stick, or hold the mouse button (or a finger) down
 28and the player heads for the pointer.
 29"""
 30
 31import numpy as np
 32
 33from simvx.core import (
 34    Anchor,
 35    Axis2D,
 36    Camera2D,
 37    CanvasLayer,
 38    Controller,
 39    HAlign,
 40    Input,
 41    Label,
 42    Node2D,
 43    Pointer,
 44    Property,
 45    Sprite2D,
 46    TileMap,
 47    TileSet,
 48    Vec2,
 49)
 50from simvx.graphics import App
 51
 52TILE = 16  # atlas pixels per tile
 53MAP_W, MAP_H = 40, 30  # in cells
 54WORLD = Vec2(MAP_W, MAP_H) * TILE
 55PLAYER = 24  # sprite size in pixels
 56
 57# Tile ids, in atlas order.
 58GRASS, DIRT, WATER, STONE, FLOWERS, TREE, WALL = range(7)
 59
 60
 61def noisy_tile(rng, colour, specks=(), count=0):
 62    """A tile of `colour` with per-pixel noise, plus `count` pixels of each speck colour."""
 63    rgb = np.clip(np.array(colour) + rng.integers(-15, 16, (TILE, TILE, 1)), 0, 255)
 64    for speck in specks:
 65        ys, xs = rng.integers(1, TILE - 1, (2, count))
 66        rgb[ys, xs] = speck
 67    alpha = np.full((TILE, TILE, 1), 255)
 68    return np.concatenate([rgb, alpha], axis=2).astype(np.uint8)
 69
 70
 71def make_atlas(rng):
 72    wall = noisy_tile(rng, (90, 90, 90))
 73    wall[::8, :, :3] = wall[:, ::8, :3] = 60  # mortar lines
 74    tiles = [
 75        noisy_tile(rng, (34, 139, 34), [(50, 160, 50)], 12),  # GRASS
 76        noisy_tile(rng, (139, 90, 43), [(120, 75, 35)], 6),  # DIRT
 77        noisy_tile(rng, (30, 100, 200), [(60, 130, 220)], 10),  # WATER
 78        noisy_tile(rng, (128, 128, 128), [(100, 100, 100)], 8),  # STONE
 79        noisy_tile(rng, (34, 139, 34), [(255, 100, 100), (255, 255, 50)], 5),  # FLOWERS
 80        noisy_tile(rng, (20, 100, 20), [(30, 120, 30)], 15),  # TREE
 81        wall,  # WALL
 82        np.zeros((TILE, TILE, 4), np.uint8),  # unused slot, transparent
 83    ]
 84    return np.vstack([np.hstack(tiles[:4]), np.hstack(tiles[4:])])
 85
 86
 87def make_terrain(rng):
 88    ground = np.where(rng.random((MAP_H, MAP_W)) < 0.05, STONE, GRASS)
 89    ground[:, 19:22] = DIRT  # a path running north to south
 90    ground[13:16, 5:-5] = WATER  # a river across it
 91    ground[[0, -1], :] = ground[:, [0, -1]] = WALL
 92    return ground
 93
 94
 95def make_decorations(rng, ground):
 96    roll = rng.random(ground.shape)
 97    decorations = np.select([roll < 0.08, roll < 0.12], [FLOWERS, TREE], -1)
 98    decorations[ground != GRASS] = -1
 99    return decorations
100
101
102def make_player():
103    """A yellow disc with a dark outline."""
104    ys, xs = np.mgrid[:PLAYER, :PLAYER]
105    radius = PLAYER / 2 - 0.5
106    distance = np.hypot(xs - radius, ys - radius)
107    image = np.zeros((PLAYER, PLAYER, 4), np.uint8)
108    image[distance <= radius] = (40, 28, 10, 255)
109    image[distance <= radius - 2] = (255, 215, 80, 255)
110    return image
111
112
113def diamond(inner, outer):
114    """The cells whose grid distance from the map centre is between `inner` and `outer`."""
115    cx, cy = MAP_W // 2, MAP_H // 2
116    return [(x, y) for x in range(MAP_W) for y in range(MAP_H) if inner <= abs(x - cx) + abs(y - cy) <= outer]
117
118
119class TileMapDemo(Node2D):
120    input_context = {
121        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
122        "steer": [Pointer.PRIMARY],
123    }
124    player_speed = Property(180.0, range=(50, 500))
125
126    def on_ready(self):
127        rng = np.random.default_rng(42)
128        atlas = make_atlas(rng)
129        tile_set = TileSet.from_atlas_array(atlas, width=atlas.shape[1], height=atlas.shape[0], tile_size=(TILE, TILE))
130        self.tilemap = self.add_child(TileMap(tile_set=tile_set, cell_size=(TILE, TILE)))
131        self.tilemap.add_layer("Decorations")
132        ground = make_terrain(rng)
133        self.tilemap.get_layer(0).set_cells(ground)
134        self.tilemap.get_layer(1).set_cells(make_decorations(rng, ground))
135
136        self.tilemap.highlight_cells(diamond(0, 4), colour=(0.2, 0.55, 1.0, 0.4))  # movement range
137        self.tilemap.highlight_cells(diamond(5, 6), colour=(1.0, 0.25, 0.25, 0.45))  # attack range
138
139        self.player = self.add_child(Sprite2D(texture=make_player(), position=WORLD / 2, name="Player"))
140        self.camera = self.add_child(Camera2D(zoom=2.0))
141        self.camera.target = self.player
142
143        # Example help overlay: not part of the technique shown.
144        self.add_child(CanvasLayer()).add_child(
145            Label(
146                "WASD / arrows / stick, or hold the pointer down to move",
147                anchor=Anchor.BOTTOM_WIDE,
148                inset=(16, 12),
149                size=(0, 28),
150                font_size=18,
151                alignment=HAlign.CENTRE,
152            )
153        )
154
155    def on_update(self, dt):
156        step = self.player_speed * dt
157        if Input.pressed("steer"):
158            goal = self.camera.screen_to_world(Input.mouse_position)
159            position = self.player.position.move_toward(goal, step)
160        else:
161            position = self.player.position + Input.value("move") * step
162        # Keep the whole sprite inside the one-tile wall around the map.
163        margin = TILE + PLAYER / 2
164        self.player.position = position.clamped((margin, margin), WORLD - margin)
165
166        # The limits clamp the camera's centre, so inset them by half the visible area; a map
167        # smaller than the view gets both limits at its centre.
168        inset = np.minimum(self.viewport_size / (2 * self.camera.zoom), WORLD / 2)
169        self.camera.limit_left, self.camera.limit_top = map(float, inset)
170        self.camera.limit_right, self.camera.limit_bottom = map(float, WORLD - inset)
171
172
173if __name__ == "__main__":
174    App(width=1024, height=768, title="TileMap").run(TileMapDemo())