Tilemap

GPU-rendered tilemap with multi-layer and player-follow camera.

▶ Run in browser

Tags: 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())