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 not yet supported on web." }
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 Camera3D, Input, InputMap, Key, 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 # Camera looks straight down at the tile plane and follows the
234 # player's world position.
235 self._cam = self.add_child(Camera3D(name="Camera"))
236 self._cam.fov = 60
237 self._cam.near = 0.1
238 self._cam.far = 2000.0
239 self._sync_view()
240
241 def _sync_view(self) -> None:
242 """Point the camera at the player's world position; re-centre the sprite."""
243 self._cam.position = np.array(
244 [self._player_world.x, self._player_world.y, CAMERA_Z],
245 dtype=np.float32,
246 )
247 sw, sh = self.tree.screen_size
248 self._player.position = Vec2(sw / 2.0, sh / 2.0)
249
250 def on_process(self, dt: float):
251 if Input.is_action_just_pressed("quit"):
252 self.app.quit()
253 return
254 move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
255 if move.x != 0 or move.y != 0:
256 step = self.player_speed * dt
257 # Input.get_vector treats "down" as +Y, but the tilemap renders
258 # with world +Y pointing up-screen via the Camera3D projection,
259 # so negate to map "up key → visually up".
260 nx = min(max(self._player_world.x + move.x * step, self._bounds_min.x), self._bounds_max.x)
261 ny = min(max(self._player_world.y - move.y * step, self._bounds_min.y), self._bounds_max.y)
262 self._player_world = Vec2(nx, ny)
263 # Keep camera + sprite in sync every frame (also re-centres on resize).
264 self._sync_view()
265
266
267# -- Entry point --------------------------------------------------------------
268
269if __name__ == "__main__":
270 App(width=1024, height=768, title="TileMap Demo").run(TileMapDemo())