5. Tilemaps and text

A sprite per object stops scaling somewhere around a tunnel wall or a line of text. A tilemap draws a whole grid from one record.

Tilemaps

def build(self):
    world = self.layer("world", projection=vs2.TUNNEL)
    self.ground = world.tilemap("trench.png", columns=16, rows=16,
                                view_width=256, view_height=160)

The tileset is an ordinary strip: one frame per distinct tile.

The trench.png strip: eight tiles named plate, seam, pipe, windows, vent, hazard, lights and conduit

A tileset: tile 0 is a plain hull plate, 3 is a row of lit windows, and so on. Most of the tiles are near-black; the colour is in a few lit structures.

Tile size comes from the image, so it can never disagree with it — tile_width and tile_height are read-only.

The grid is made of cells, one byte each, holding a tile index:

self.ground[col, row] = WINDOWS        # (column, row) order
tile = self.ground[col, row]
self.ground.fill(PLATE)                # every cell

Dark or black backgrounds work better on the real Ventilastation, so draw a wall in near-black tiles and keep the colour for a few lit structures, the way trench.png does. (Always check your colours and intensities on the real hardware; see Budgets and real hardware.)

vs2.EMPTY_TILE (255) leaves a cell blank and the renderer skips it. Freshly allocated grids are filled with it, so a new tilemap starts out dark. Here is a 16 by 16 map of the tiles above: a ring of pipe at the rim, a ring of glowing conduit further in, and some windows, vents and lights between them, with a ship on top. The map wraps all the way round the tunnel:

A dark tunnel wall of near-black plating with a ring of cyan conduit, orange vents, amber windows and a ring of pipe at the rim, and a ship at the bottom

A 16 by 16 tilemap on a TUNNEL layer. Tiles near the rim are drawn larger.

Scrolling

The view is a fixed window onto the grid. Scrolling moves the window, not the data, so it costs one write:

def update(self):
    pattern_height = 6 * self.ground.tile_height     # the wall repeats every 6 rows
    self.scroll = (self.scroll + 0.75) % pattern_height   # self.scroll starts at 0
    self.ground.view_y = self.scroll

If the picture repeats every 6 rows, the view can wrap back to the top after 6 rows and nobody can tell: the wall scrolls forever without a single cell being rewritten. A map that does not repeat has to rewrite a row of cells each time a whole tile has scrolled past instead, which is still much cheaper than rewriting the grid every tick.

Labels

A label is a tilemap you write strings into. Three lines of text as sprites cost 54 of your 100 slots; as labels they cost one tilemap record each, no matter how often the text changes.

def build(self):
    hud = self.layer("hud", projection=vs2.HUD)
    self.score  = hud.label("numerals.png", columns=5, x=246, y=1)   # bottom of the disc: upright
    self.status = hud.label("tinyfont.png", columns=21, rows=3, x=-42, y=0)
    self.title  = hud.label("steel8x8.png", columns=18, text="READY")

One-line labels get a text property; multi-line ones use write():

self.title.text = "GAME OVER"                # truncates and pads
self.status.write(0, 1, "J1:.... .... ..")   # (column, row, text)

You write ordinary left-to-right strings; the label handles the display’s direction for you. Which way up the text reads depends on where you put it, as the next section explains.

Scores without allocating

Formatting a number with "%05d" % value creates a new string every tick. set_number() writes the digits straight into the cells instead:

def update(self):
    self.score.set_number(self.points, width=5, pad="0")

Which way up? Flips

Glyphs are drawn with their tops pointing toward the centre of the disc. At the bottom of the disc the centre is above the text, so it reads upright. At the top the centre is below the text, so the same label is upside-down. On the sides it runs sideways.

Three copies of the score: upside-down at the top without flips, upright at the top with flips, and upright at the bottom without flips

The same label in three places. Only the top one without flips is upside-down.

To read upright at the top, flip the label both ways:

self.top = hud.label("numerals.png", columns=5, x=118, y=14,
                     flip_x=True, flip_y=True)

flip_x mirrors the label left to right and flip_y mirrors it top to bottom. Doing both turns it through 180 degrees, which is what you want at the top of the disc. One flip on its own just gives you mirrored text.

Warning

Upside-down text at the top of the disc is the most common newcomer mistake. Nothing is wrong with your string or your font. Add flip_x=True, flip_y=True to the label. The scoreboards in the games under games/ all do this. Sprites and tilemaps take the same two arguments.

A label with both flips, next to one with none, over a small map:

A score reading 07260 upright at the top and at the bottom of the disc, over a dark tunnel wall

Two labels: one at the top with flip_x and flip_y, one at the bottom with no flips.

Glyphs

A label shows a character by picking the glyph frame for it. By default that is CP437, where frame = ord(ch) — what steel8x8.png and the other full font strips use. A strip with only a few characters, like a row of digits, declares its own mapping in __images__.yaml, next to the strip:

- strip: numerals.png
  frames: 12
  glyphs: "0123456789 *"

Character n of the string is the glyph for frame n, so here frame 0 is 0, frame 9 is 9, frame 10 is a space and frame 11 is a *. The frames: entry is still needed; leave it out and the strip counts as one frame.

Characters with no mapping, and spaces, are left blank. Other ways to set the mapping, and strips with a second colour, are in going further.

Note

Labels count against the tilemap budget, not the sprite budget — 16 tilemaps including labels. A score, a message line and a debug overlay are three before any tilemap for the world.

In the game

Give Trench Run a trench to fly down, and a score.

Choosing a look

A trench run is a good excuse for a dark game, so pick a setting where dark is natural. Trench Run takes place in a derelict space station: you fly down a maintenance trench between plates of dead, near-black hull, dodging the station’s enemies. The station has no power to spare, so the only colour is in the few things that are still lit:

  • cyan conduit running round the trench in a bright ring;

  • amber windows and orange vents, in warm contrast to the cold blue-grey plating;

  • red, green and blue status lights and yellow hazard stripes, as small accents;

  • a ring of steel pipe at the rim, a little lighter than the hull, so the edge of the wall is easy to find.

This is a setting rather than only a palette because it gives a reason for every tile you draw, and it answers the question “what goes here?” the same way each time: if nothing is lit, leave it dark. That is also what suits the real Ventilastation. The LEDs make their own light, so a black or near-black background costs nothing and makes the lit structures stand out, where a bright backdrop would wash them out and tire the eye. The plating in trench.png is only a few steps above black, and the structures on it are bright.

Note

The emulator shows you what the colours are, not how the LEDs will render them. The very dark tones in particular, such as the plating and its seams, may crush to black or look different on the disc. Treat them as a starting point and check them on the real hardware (see Budgets and real hardware), raising the darkest tones if the detail is lost.

trench.png is the tileset you saw above, and the wall repeats every six rows, which is what lets it scroll forever.

A second layer holds the score, so it is drawn over everything in the world. The wall goes into the world layer before the ship, so the ship is painted over it:

# The trench's tiles, in the order they appear in trench.png.
PLATE, SEAM, PIPE, WINDOWS, VENT, HAZARD, LIGHTS, CONDUIT = range(8)
TRENCH_COLUMNS = 16  # around the tunnel
TRENCH_ROWS = 16     # along it
PATTERN_ROWS = 6     # the wall repeats every 6 rows, so it can scroll forever


def trench_tile(col, band):
    """Which tile goes at ``col`` in row ``band`` of the repeating pattern."""
    if band == 0:
        return PIPE
    if band == 4:
        return CONDUIT
    if band == 2:
        if col % 4 == 1:
            return WINDOWS
        if col % 4 == 3:
            return VENT
    if band == 5:
        if col % 8 == 2:
            return LIGHTS
        if col % 8 == 6:
            return HAZARD
    return SEAM if col % 2 else PLATE


def build(self):
    self.world = self.layer("world", projection=vs2.TUNNEL)
    self.hud = self.layer("hud", projection=vs2.HUD)

    # The trench wall: dark plating all the way round, with a few lit
    # structures. Its pattern repeats every PATTERN_ROWS rows.
    self.ground = self.world.tilemap(
        "trench.png", columns=TRENCH_COLUMNS, rows=TRENCH_ROWS,
        view_width=256, view_height=160)
    self.draw_trench()

    self.ship = self.world.sprite("ship.png", y=0)
    self.ship.x = -(self.ship.width // 2)
    # ... the pools, as before ...

    # Top of the disc, where x = 128, so the score is flipped to read upright.
    self.score_label = self.hud.label("numerals.png", columns=5, x=118, y=1,
                                      flip_x=True, flip_y=True)

    self.score = 0
    self.scroll = 0                     # how far the wall has scrolled
    self.ticks = 0                      # as in chapter 4
    self.show_score()

def draw_trench(self):
    for row in range(TRENCH_ROWS):
        for col in range(TRENCH_COLUMNS):
            self.ground[col, row] = trench_tile(col, row % PATTERN_ROWS)

def show_score(self):
    self.score_label.set_number(self.score, width=5, pad="0")

Why view_width=256, view_height=160? The view is the window onto the map, and it works in pixels. The map is 16 columns of 16-pixel tiles, so 256 pixels wide, which is exactly the 256 columns around the disc: the whole circle is visible. It is also 16 rows of 16 pixels, 256 pixels tall, but the view shows only 160 of them. The wall repeats every 6 rows, which is 96 pixels, so view_y only ever needs to run from 0 to 95 before it wraps. Then the window, at its lowest position, covers pixels 95 to 254, still inside the 256 pixels of the map; a view up to 161 pixels tall would fit, and 160 is the round number below that. If you use another map, work it out the same way: take the repeat length in pixels, and make sure the map is at least that much taller than the view.

Scroll the wall toward the ship in update(), right after the steering:

SPEED = 0.75         # depth units per tick: how fast the wall scrolls and enemies come

    # Scroll the wall toward the ship. After one whole pattern the picture
    # is the same again, so the view can wrap without rewriting any cells.
    pattern_height = PATTERN_ROWS * self.ground.tile_height
    self.scroll = (self.scroll + SPEED) % pattern_height
    self.ground.view_y = self.scroll

The wall moves at SPEED, the same number the enemies use, because the enemies are part of the trench: they come at you exactly as fast as the wall goes by. view_y takes whole pixels, so it ignores the fraction, while self.scroll keeps it, which is how the wall can scroll at three quarters of a pixel a tick.

Score points for every enemy that gets past the ship, in move_enemies():

POINTS = 10

def move_enemies(self):
    for enemy in self.enemies:
        # ... moving and animating, as before ...
        if enemy.y < -enemy.image.height:
            self.enemies.despawn(enemy)      # flew past the ship
            self.score += POINTS
            self.show_score()

numerals.png declares its own glyphs in __images__.yaml, as in the glyphs section above, so set_number() knows which frame is which digit. The score sits at the top of the disc, centred on x = 128 (the label is 20 columns wide, so x = 118), and the ship at the bottom. The score is flipped both ways, exactly as in the section above, so that it reads upright:

Trench Run: a dark trench wall with a cyan conduit ring, amber windows and orange vents, a ship at the bottom, enemies coming down the tunnel, and a score at the top

Trench Run so far.

The file so far

Everything from this chapter in one file, as it stands after chapter 5. If yours misbehaves, compare it with this one. It is docs/vs2/tutorial/steps/step5_tilemaps.py in the repository.

# Trench Run, chapter 5: a trench to fly down, and a score.
import vs2
from vs2.controls import *

LEVEL, TURN_LEFT, TURN_RIGHT = range(3)      # the frames of ship.png
SPEED = 0.75         # depth units per tick: how fast the wall scrolls and enemies come
ENEMY_START = 160    # depth at which enemies appear
POINTS = 10

# The trench's tiles, in the order they appear in trench.png.
PLATE, SEAM, PIPE, WINDOWS, VENT, HAZARD, LIGHTS, CONDUIT = range(8)
TRENCH_COLUMNS = 16  # around the tunnel
TRENCH_ROWS = 16     # along it
PATTERN_ROWS = 6     # the wall repeats every 6 rows, so it can scroll forever


def trench_tile(col, band):
    """Which tile goes at ``col`` in row ``band`` of the repeating pattern."""
    if band == 0:
        return PIPE
    if band == 4:
        return CONDUIT
    if band == 2:
        if col % 4 == 1:
            return WINDOWS
        if col % 4 == 3:
            return VENT
    if band == 5:
        if col % 8 == 2:
            return LIGHTS
        if col % 8 == 6:
            return HAZARD
    return SEAM if col % 2 else PLATE


class Game(vs2.Scene):
    def build(self):
        self.world = self.layer("world", projection=vs2.TUNNEL)
        self.hud = self.layer("hud", projection=vs2.HUD)

        # The trench wall: dark plating all the way round, with a few lit
        # structures. Its pattern repeats every PATTERN_ROWS rows.
        self.ground = self.world.tilemap(
            "trench.png", columns=TRENCH_COLUMNS, rows=TRENCH_ROWS,
            view_width=256, view_height=160)
        self.draw_trench()

        self.ship = self.world.sprite("ship.png", y=0)
        self.ship.x = -(self.ship.width // 2)     # centred on x = 0, the bottom of the disc
        self.enemies = self.world.sprite_pool("enemy.png", count=16)
        for i in range(5):
            self.enemies.spawn(x=i * 51, y=ENEMY_START)

        # Top of the disc, where x = 128. Text up there is upside-down unless it
        # is flipped both ways.
        self.score_label = self.hud.label("numerals.png", columns=5, x=118, y=1,
                                          flip_x=True, flip_y=True)

        self.score = 0
        self.scroll = 0                 # how far the wall has scrolled
        self.ticks = 0
        self.show_score()

    def draw_trench(self):
        for row in range(TRENCH_ROWS):
            for col in range(TRENCH_COLUMNS):
                self.ground[col, row] = trench_tile(col, row % PATTERN_ROWS)

    def show_score(self):
        self.score_label.set_number(self.score, width=5, pad="0")

    def update(self):
        self.ticks += 1

        # +1 for left, -1 for right, 0 for neither (or both held: they cancel).
        # At the bottom of the disc x counts up toward the left.
        steer = joy1.held(LEFT) - joy1.held(RIGHT)
        self.ship.x = (self.ship.x + steer) % vs2.display.width
        if steer > 0:
            self.ship.frame = TURN_LEFT
        elif steer < 0:
            self.ship.frame = TURN_RIGHT
        else:
            self.ship.frame = LEVEL

        # Scroll the wall toward the ship. After one whole pattern the picture
        # is the same again, so the view can wrap without rewriting any cells.
        pattern_height = PATTERN_ROWS * self.ground.tile_height
        self.scroll = (self.scroll + SPEED) % pattern_height
        self.ground.view_y = self.scroll

        self.move_enemies()

    def move_enemies(self):
        for enemy in self.enemies:
            enemy.y -= SPEED
            enemy.frame = (self.ticks // 6) % enemy.image.frames
            if enemy.y < -enemy.image.height:
                self.enemies.despawn(enemy)      # flew past the ship
                self.score += POINTS
                self.show_score()


def main():
    return Game()

Next: scenes, input and sound.