Tilemap¶
GPU-rendered tilemap with multi-layer and player-follow camera.
▶ Run in browserTags: 2d
Procedural tileset, multi-layer composition, and arrow-key panning.
Demonstrates the GPU-batched tilemap renderer with:
Procedurally generated tileset texture (grass, dirt, water, stone, flowers)
Two layers: background terrain + foreground decorations
Arrow key / WASD camera panning
Single draw call per layer via SSBO instancing
Source¶
1#!/usr/bin/env python3
2"""Tilemap: GPU-rendered tilemap with multi-layer and player-follow camera.
3
4# /// simvx
5# web = { width = 1024, height = 768, reason = "TileMap rendering is desktop-only." }
6# ///
7
8Procedural tileset, multi-layer composition, and arrow-key panning.
9
10Demonstrates the GPU-batched tilemap renderer with:
11- Procedurally generated tileset texture (grass, dirt, water, stone, flowers)
12- Two layers: background terrain + foreground decorations
13- Arrow key / WASD camera panning
14- Single draw call per layer via SSBO instancing
15"""
16
17
18import numpy as np
19
20from simvx.core import AnchorPreset, Camera3D, Input, InputMap, Key, Label, Node, Property, Sprite2D, Vec2
21from simvx.core.tilemap import TileMap, TileSet
22from simvx.graphics import App
23
24# -- Procedural tileset texture -----------------------------------------------
25
26TILE_PX = 16 # pixels per tile
27ATLAS_COLS = 4
28ATLAS_ROWS = 2
29ATLAS_W = ATLAS_COLS * TILE_PX # 64
30ATLAS_H = ATLAS_ROWS * TILE_PX # 32
31
32# Tile IDs (row-major from create_from_grid)
33GRASS = 0
34DIRT = 1
35WATER = 2
36STONE = 3
37FLOWERS = 4
38TREE_TOP = 5
39WALL = 6
40EMPTY = 7
41
42
43def _fill_tile(atlas: np.ndarray, col: int, row: int, colour: tuple[int, ...]):
44 """Fill a tile region with a solid colour plus some noise for texture."""
45 x0, y0 = col * TILE_PX, row * TILE_PX
46 rng = np.random.RandomState(col * 7 + row * 13)
47 for dy in range(TILE_PX):
48 for dx in range(TILE_PX):
49 noise = rng.randint(-15, 16)
50 r = max(0, min(255, colour[0] + noise))
51 g = max(0, min(255, colour[1] + noise))
52 b = max(0, min(255, colour[2] + noise))
53 a = colour[3] if len(colour) > 3 else 255
54 atlas[y0 + dy, x0 + dx] = (r, g, b, a)
55
56
57def _add_detail(atlas: np.ndarray, col: int, row: int, detail_colour: tuple[int, ...], count: int = 8):
58 """Scatter random detail pixels on a tile."""
59 x0, y0 = col * TILE_PX, row * TILE_PX
60 rng = np.random.RandomState(col * 31 + row * 37)
61 c = detail_colour if len(detail_colour) == 4 else (*detail_colour, 255)
62 for _ in range(count):
63 dx, dy = rng.randint(1, TILE_PX - 1), rng.randint(1, TILE_PX - 1)
64 atlas[y0 + dy, x0 + dx] = c
65
66
67def generate_tileset_atlas() -> np.ndarray:
68 """Generate a simple procedural tileset atlas (RGBA uint8, shape HxWx4)."""
69 atlas = np.zeros((ATLAS_H, ATLAS_W, 4), dtype=np.uint8)
70
71 # Row 0: terrain
72 _fill_tile(atlas, 0, 0, (34, 139, 34)) # GRASS
73 _add_detail(atlas, 0, 0, (50, 160, 50), 12)
74 _fill_tile(atlas, 1, 0, (139, 90, 43)) # DIRT
75 _add_detail(atlas, 1, 0, (120, 75, 35), 6)
76 _fill_tile(atlas, 2, 0, (30, 100, 200)) # WATER
77 _add_detail(atlas, 2, 0, (60, 130, 220), 10)
78 _fill_tile(atlas, 3, 0, (128, 128, 128)) # STONE
79 _add_detail(atlas, 3, 0, (100, 100, 100), 8)
80
81 # Row 1: decorations
82 _fill_tile(atlas, 0, 1, (34, 139, 34)) # FLOWERS (grass + dots)
83 _add_detail(atlas, 0, 1, (255, 100, 100), 6)
84 _add_detail(atlas, 0, 1, (255, 255, 50), 4)
85 _fill_tile(atlas, 1, 1, (20, 100, 20)) # TREE_TOP
86 _add_detail(atlas, 1, 1, (30, 120, 30), 15)
87 _fill_tile(atlas, 2, 1, (90, 90, 90)) # WALL (brick pattern)
88 for dy in [0, 8]:
89 for dx in range(TILE_PX):
90 atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
91 for dx in [0, 8]:
92 for dy in range(TILE_PX):
93 atlas[TILE_PX + dy, 2 * TILE_PX + dx] = (60, 60, 60, 255)
94 _fill_tile(atlas, 3, 1, (0, 0, 0, 0)) # EMPTY (transparent)
95
96 return atlas
97
98
99# -- Map generation -----------------------------------------------------------
100
101MAP_W, MAP_H = 40, 30
102
103
104def build_tilemap() -> TileMap:
105 """Create a TileMap with two layers: terrain + decorations."""
106 ts = TileSet.from_atlas_array(
107 generate_tileset_atlas(),
108 width=ATLAS_W,
109 height=ATLAS_H,
110 tile_size=(TILE_PX, TILE_PX),
111 )
112
113 tilemap = TileMap(name="DemoTileMap")
114 tilemap.tile_set = ts
115 tilemap.cell_size = (TILE_PX, TILE_PX)
116 tilemap.add_layer("Decorations")
117
118 rng = np.random.RandomState(42)
119
120 # Layer 0: terrain
121 for y in range(MAP_H):
122 for x in range(MAP_W):
123 if x == 0 or x == MAP_W - 1 or y == 0 or y == MAP_H - 1:
124 tilemap.set_cell(0, x, y, WALL)
125 elif 13 <= y <= 15 and 5 <= x <= MAP_W - 6:
126 tilemap.set_cell(0, x, y, WATER)
127 elif 19 <= x <= 21:
128 tilemap.set_cell(0, x, y, DIRT)
129 elif rng.random() < 0.05:
130 tilemap.set_cell(0, x, y, STONE)
131 else:
132 tilemap.set_cell(0, x, y, GRASS)
133
134 # Layer 1: decorations on grass
135 for y in range(1, MAP_H - 1):
136 for x in range(1, MAP_W - 1):
137 if tilemap.get_cell(0, x, y) != GRASS:
138 continue
139 r = rng.random()
140 if r < 0.08:
141 tilemap.set_cell(1, x, y, FLOWERS)
142 elif r < 0.12:
143 tilemap.set_cell(1, x, y, TREE_TOP)
144
145 return tilemap
146
147
148# -- Player sprite ------------------------------------------------------------
149
150PLAYER_PX = 24 # sprite display size
151CAMERA_Z = 500.0
152
153
154def _make_player_sprite(size: int = PLAYER_PX) -> np.ndarray:
155 """Procedural player sprite: yellow disc with a dark outline."""
156 img = np.zeros((size, size, 4), dtype=np.uint8)
157 cx, cy = (size - 1) / 2.0, (size - 1) / 2.0
158 r_outer = size / 2.0 - 0.5
159 r_inner = r_outer - 2.0
160 for y in range(size):
161 for x in range(size):
162 d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
163 if d <= r_inner:
164 img[y, x] = (255, 215, 80, 255) # warm yellow
165 elif d <= r_outer:
166 img[y, x] = (40, 28, 10, 255) # dark outline
167 return img
168
169
170# -- Game scene ---------------------------------------------------------------
171
172
173class TileMapDemo(Node):
174 """Root node: sets up tilemap + camera + player, handles player input.
175
176 The tilemap renders in world space via the 3D camera, while the player
177 sprite is drawn through Draw2D in screen space. To unify the two, we
178 keep the sprite pinned at screen centre and move a separate world-space
179 cursor that both the camera and movement bounds use.
180 """
181
182 player_speed = Property(180.0, range=(50, 500))
183
184 def on_ready(self):
185 InputMap.add_action("move_left", [Key.A, Key.LEFT])
186 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
187 InputMap.add_action("move_up", [Key.W, Key.UP])
188 InputMap.add_action("move_down", [Key.S, Key.DOWN])
189 InputMap.add_action("quit", [Key.ESCAPE])
190
191 # Playable area = inside the wall border (one-tile-thick wall around
192 # the map). Half the sprite stays inside the wall so no pixel crosses
193 # the border. Bounds are in tilemap-world coordinates.
194 half = PLAYER_PX / 2.0
195 self._bounds_min = Vec2(TILE_PX + half, TILE_PX + half)
196 self._bounds_max = Vec2(
197 (MAP_W - 1) * TILE_PX - half,
198 (MAP_H - 1) * TILE_PX - half,
199 )
200
201 # Player's world-space position (not the sprite's screen position).
202 self._player_world = Vec2(MAP_W * TILE_PX / 2, MAP_H * TILE_PX / 2)
203
204 # TileMap (atlas pixels are on the TileSet; scene adapter uploads lazily)
205 self._tilemap = self.add_child(build_tilemap())
206
207 # Demonstrate TileMap.highlight_cells: translucent overlay used by
208 # tactics/strategy ports to show movement and attack range. Two
209 # groups: a blue "move" diamond and a red "attack" ring around it.
210 move_cells = [
211 (cx, cy)
212 for cx in range(MAP_W // 2 - 4, MAP_W // 2 + 5)
213 for cy in range(MAP_H // 2 - 4, MAP_H // 2 + 5)
214 if abs(cx - MAP_W // 2) + abs(cy - MAP_H // 2) <= 4
215 ]
216 attack_cells = [
217 (cx, cy)
218 for cx in range(MAP_W // 2 - 6, MAP_W // 2 + 7)
219 for cy in range(MAP_H // 2 - 6, MAP_H // 2 + 7)
220 if 5 <= abs(cx - MAP_W // 2) + abs(cy - MAP_H // 2) <= 6
221 ]
222 self._tilemap.highlight_cells(move_cells, colour=(0.2, 0.55, 1.0, 0.4))
223 self._tilemap.highlight_cells(attack_cells, colour=(1.0, 0.25, 0.25, 0.45))
224
225 # Player sprite: centred on screen. Its position is updated every
226 # frame to ``tree.screen_size / 2`` so it stays put even on resize.
227 self._player = self.add_child(Sprite2D(
228 texture=_make_player_sprite(),
229 width=PLAYER_PX, height=PLAYER_PX,
230 name="Player",
231 ))
232
233 # On-screen controls hint, anchored to the bottom edge so it stays
234 # put on any window size.
235 hint = self.add_child(Label("WASD / arrows: move Esc: quit", name="Hint"))
236 hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
237 hint.margin_left = 16
238 hint.margin_right = 16
239 hint.margin_top = -40
240 hint.margin_bottom = -12
241 hint.font_size = 18.0
242 hint.alignment = "center"
243
244 # Camera looks straight down at the tile plane and follows the
245 # player's world position.
246 self._cam = self.add_child(Camera3D(name="Camera"))
247 self._cam.fov = 60
248 self._cam.near = 0.1
249 self._cam.far = 2000.0
250 self._sync_view()
251
252 def _sync_view(self) -> None:
253 """Point the camera at the player's world position; re-centre the sprite."""
254 self._cam.position = np.array(
255 [self._player_world.x, self._player_world.y, CAMERA_Z],
256 dtype=np.float32,
257 )
258 sw, sh = self.tree.screen_size
259 self._player.position = Vec2(sw / 2.0, sh / 2.0)
260
261 def on_update(self, dt: float):
262 if Input.is_action_just_pressed("quit"):
263 self.app.quit()
264 return
265 move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
266 if move.x != 0 or move.y != 0:
267 step = self.player_speed * dt
268 # Input.get_vector treats "down" as +Y, but the tilemap renders
269 # with world +Y pointing up-screen via the Camera3D projection,
270 # so negate to map "up key → visually up".
271 nx = min(max(self._player_world.x + move.x * step, self._bounds_min.x), self._bounds_max.x)
272 ny = min(max(self._player_world.y - move.y * step, self._bounds_min.y), self._bounds_max.y)
273 self._player_world = Vec2(nx, ny)
274 # Keep camera + sprite in sync every frame (also re-centres on resize).
275 self._sync_view()
276
277
278# -- Entry point --------------------------------------------------------------
279
280if __name__ == "__main__":
281 App(width=1024, height=768, title="TileMap Demo").run(TileMapDemo())