TileMap

SimVX provides grid-based 2D level design through three classes: TileSet (tile definitions), TileMapLayer (a single grid layer), and TileMap (a Node2D container with multiple layers).

TileData

Each tile in a TileSet is described by a TileData dataclass:

Field

Type

Description

texture_region

(x, y, w, h)

Rectangle in the texture atlas

collision_shapes

list

Polygon vertices for collision

navigation_polygon

list | None

Walkable polygon for pathfinding

custom_data

dict

Arbitrary per-tile metadata

animation_frames

list[int] | None

Tile IDs for animated tiles

animation_speed

float

Animation FPS (default 5.0)

terrain_type

str

Terrain label for auto-tiling

terrain_set

int

Which terrain set this tile belongs to

terrain_bits

int

Bitmask for auto-tile neighbor matching

TileSet

A TileSet holds tile definitions and terrain auto-tile rules:

from simvx.core import TileSet, TileData

ts = TileSet(tile_size=(16, 16))

# Add tiles manually
grass = ts.add_tile(TileData(texture_region=(0, 0, 16, 16), terrain_type="grass"))
water = ts.add_tile(TileData(texture_region=(16, 0, 16, 16), terrain_type="water"))

# Or auto-create from a grid atlas (256x256 image, 16x16 tiles)
tile_ids = ts.create_from_grid(atlas_width=256, atlas_height=256)

TileMapLayer

A TileMapLayer stores tiles in 32x32 chunks for efficient large-map support:

from simvx.core import TileMapLayer

layer = TileMapLayer("Ground")
layer.set_cell(5, 3, grass)       # Place tile at grid (5, 3)
tid = layer.get_cell(5, 3)        # Returns tile ID (-1 if empty)
layer.erase_cell(5, 3)            # Remove tile

cells = layer.get_used_cells()    # All non-empty (x, y) positions
rect = layer.get_used_rect()      # Bounding (x, y, w, h) of used cells

TileMap Node

TileMap is a Node2D that manages multiple layers and provides coordinate conversion:

from simvx.core import TileMap, TileSet, TileData

ts = TileSet(tile_size=(16, 16))
ground = ts.add_tile(TileData(texture_region=(0, 0, 16, 16)))
wall = ts.add_tile(TileData(texture_region=(16, 0, 16, 16)))

tilemap = TileMap(tile_set=ts, cell_size=(16, 16))

# Paint on layer 0 (created automatically)
tilemap.set_cell(0, 0, 0, ground)
tilemap.set_cell(0, 1, 0, wall)

# Add a second layer
overlay = tilemap.add_layer("Decoration")
tilemap.set_cell(overlay, 0, 0, ground)

# Coordinate conversion
grid_pos = tilemap.world_to_map((48.0, 32.0))   # -> (3, 2)
world_pos = tilemap.map_to_world((3, 2))          # -> (56.0, 40.0) center of cell

Terrain Auto-Tiling

Auto-tiling selects tile variants based on a 4-bit neighbor bitmask (up=1, right=2, down=4, left=8). Neighbors match when they share the same terrain_type.

ts.add_terrain_set(0)
ts.set_terrain_tile(0, 0b0000, lone_tile)   # No matching neighbors
ts.set_terrain_tile(0, 0b0101, vertical)    # Up + down
ts.set_terrain_tile(0, 0b1010, horizontal)  # Right + left
ts.set_terrain_tile(0, 0b1111, center)      # All four neighbors

# Apply auto-tiling at a cell
tilemap.auto_tile(layer=0, x=5, y=3, terrain_set=0)

Movement-Range Highlights

TileMap.highlight_cells(cells, colour) overlays a translucent fill on each named grid cell: the building block for tactics/strategy UIs that need to show movement range, attack range, targeting reticles, or area-of-effect previews. Multiple groups stack so distinct overlays (move + attack, ally + enemy) can render in one frame.

move_cells = [(cx, cy) for cx in range(2, 7) for cy in range(2, 7)]
attack_cells = [(8, 5), (5, 8)]

tilemap.highlight_cells(move_cells, colour=(0.3, 0.6, 1.0, 0.45))    # blue
tilemap.highlight_cells(attack_cells, colour=(1.0, 0.25, 0.25, 0.5)) # red

# Later, when the unit deselects:
tilemap.clear_highlights()

Notes:

  • The colour is a 4-tuple in 0.0-1.0 range. 3-tuples receive a default alpha of 0.45: enough to read tile detail underneath while staying clearly visible.

  • Each call adds a new group. Use tilemap.highlight_groups (a read-only defensive copy) to inspect the current overlays in tests.

  • Fills render via TileMap.draw(renderer) at tilemap.position + cell * cell_size: they follow whatever 2D camera transform the renderer is using.

Y-sort caveats

Y-sort (whether via YSortContainer or any per-child depth pass) applies to direct children only. Nesting a group of per-cell sprites under an intermediate Node2D like _floor or _units causes the group to sort as a single unit, not per descendant. This is fine for flat ground where everything in the group shares one logical depth, but wrong for isometric scenes or any layout with elevation.

tilemap = TileMap()

# WRONG for iso / elevation: the whole group sorts as one unit.
floor = Node2D(name="_floor")
tilemap.add_child(floor)
for x, y in cells:
    floor.add_child(Sprite2D(...))  # all sprites share floor's Y for sort

# RIGHT: direct children sort individually by their own Y.
for x, y in cells:
    tilemap.add_child(Sprite2D(position=Vec2(x*16, y*16)))

To catch the bug pattern early, TileMap.add_child emits a single WARNING the first time a nested-group child is added:

TileMap 'TileMap' received nested child '_floor' with 64 descendant(s).
Y-sort applies only to direct TileMap children: the nested group sorts
as a unit, not per descendant. Acceptable for flat ground; for isometric
/ elevated scenes attach sprites as direct children. See
docs/core/tilemap.md (Y-sort caveats). Set tilemap.warn_on_nested_ysort
= False to silence.

For flat-ground use cases where the warning is noise, disable it with tilemap.warn_on_nested_ysort = False before adding the group. Ports with mixed flat-and-elevated regions should attach the elevated sprites directly under the TileMap and reserve nested groups for purely decorative layers that don’t participate in Y-sort.

API Reference

See simvx.core.tilemap for the complete tilemap API.