Tilemap

Play Demo

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 Code

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