TileMap¶
a two-layer tile world with a camera that follows the player.
▶ Run in browserTags: 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())