TileMap¶
GPU-batched tiles with a player-follow camera.
â–¶ Run in browserTags: 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())