C++ Game Engine Development – Part 13 – Tilemap

This is part of an ongoing series where we write a complete 2D game engine in C++ and SFML. A new tutorial is released every Monday. You can find the complete list of tutorials here and download the source code from the projects GitHub page.
In this tutorial, we’ll start work on the first level of our game by creating and importing a tile map.
Tilemap: A collection of individual images called ‘tiles’ that when constructed should form a cohesive whole so that it is hard to tell that they are made up of individual tiles.
In future, we will probably want to create our own tilemap editor so we can edit our own maps directly within our games engine but for now, we’ll be using a great bit of software called Tiled to create our maps. I will not go into detail about how Tiled works but there are many great tutorials a click away.
I’ve created a simple level for test purposes, which will not be used in the final game but will help us test the tilemap importer that we’ll be writing shortly. I used the tiles from here. If you would like to use these titles in your own game then you must support the developer and purchase them from that link.
Our test map in Tiled.
Our test map in Tiled.
You’ll find the test level (Test Map 1.tmx) in the resources folder for this tutorial. TMX is the file format used by Tiled and it is an acronym for ‘Tile Map XML’. This lets us know that the contents of the file are almost certainly XML and if you open the test map in your favourite text editor you’ll find that it is indeed XML. It contains data for each tilemap used and where to place the tiles within the map for each layer.
Test Map 1.tmx
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" tiledversion="1.1.5" orientation="orthogonal" renderorder="right-down" width="60" height="10" tilewidth="32" tileheight="32" infinite="0" nextobjectid="1">
 <tileset firstgid="1" name="Tiles" tilewidth="32" tileheight="32" tilecount="90" columns="10">
  <image source="Jungle Tiles.png" width="320" height="288"/>
 </tileset>
 <layer name="Platform" width="60" height="10">
  <data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
…
For a refresher on XML, you can go through the tutorial series here.
Now we know that we’ll be dealing with XML we need some way of importing and reading it. As XML parsing will not be a big part of our game (we’ll eventually be creating our own file system), I’ve chosen to use rapid XML library  to import our tile maps. This is a free and very fast library that can be used to parse XML files. Once you’ve downloaded the source files just copy the header files into your project and you’re good to go.
rapid XML imported into our project.
We’ll start by creating a structure that will hold all the common properties for a tile. These properties can be shared between many possible tiles which will help to minimise memory usage. This is an example of the flyweight pattern. A pattern that can prove very useful for games development, where performance can be important.
Create a class called Tile.
Tile.h
#ifndef Tile_h
#define Tile_h

#include "ResourceAllocator.hpp"

// Stores common tile data.
struct TileInfo
{
    TileInfo() : tileID(-1)
    {
    }
    
    TileInfo(int textureID, unsigned int tileID, sf::IntRect textureRect) 
		: textureID(textureID), tileID(tileID), textureRect(textureRect) { }
    
    int tileID;
    int textureID;
    sf::IntRect textureRect;
};

#endif /* Tile_h */
Our shared properties consist of:
  • textureID: the id of the texture that contains the tiles sprite. This will be returned by our texture allocator.
  • tileID: the id of the tile as defined by our Tiled application (more on this in a bit).
  • textureRect: the location of the tile in the sprite sheet.
We’ll also create a structure to hold individual tile data. Each tile will be represented as a pointer to its shared properties and an x and y position. The x and y represent the tiles position within the tile grid and do not equate to x and y positions onscreen. We’ll see how we can convert between the two shortly.
Tile.h
struct Tile
{
    std::shared_ptr<TileInfo> properties;
    int x;
    int y;
};
Now we need a class that can accept a location to a TMX file and return a collection of tile objects that we can add to our existing Object collection, we’ll call this class TileMapParser. Each tile will be represented as an Object.
TileMapParser.hpp
#ifndef TileMapParser_hpp
#define TileMapParser_hpp

#include <SFML/Graphics.hpp>
#include <unordered_map>
#include <sstream>

#include "rapidxml.hpp"
#include "rapidxml.hpp"
#include "rapidxml_utils.hpp"
#include "Tile.h"
#include "Utilities.h"
#include "Object.hpp"
#include "C_Sprite.hpp"

using namespace rapidxml;

struct TileSheetData
{
	// The texture id will be retrieved by using our texture allocator.
    int textureId; // The id of the tile sets texture. 
    sf::Vector2u imageSize; // The size of the texture.
    int columns; // How many columns in the tile sheet.
    int rows; // How many rows in the tile sheet.
    sf::Vector2u tileSize; // The size of an individual tile.
};

#endif /* TileMapParser_hpp */
We’ll include the rapid XML parser that we imported earlier and a few of our own classes. And we’ve also created a TileSheetData structure. This will store the data for a sprite sheet imported from Tiled. By using columns and rows we assume the tile sheet is arranged in a grid (as is most often the case) and by using a single tile size we assume that the tiles are of a uniform size. This is definitely something we would prefer, at least to start with, but we can look into ways of handling non-uniform tile sheets in a future tutorial.
While not necessary I have written a couple of type aliases to aid readability.
TileMapParser.hpp
using Layer = std::vector<std::shared_ptr<Tile>>;

// Stores layer names with layer.
using MapTiles = 
	std::map<std::string, std::shared_ptr<Layer>>;

// Stores the different tile types that can be used.
using TileSet =
	std::unordered_map<unsigned int, std::shared_ptr<TileInfo>>; 
The Layer variable represents the layers in Tiled. We currently only have the one layer ‘Platform’ but in future, we will definitely be adding more so will need a method of handling multiple layers.
You can have multiple layers in Tiled.
You can have multiple layers in Tiled.
MapTiles stores each layer along with the layer name and TileSet stores a tile id and its unique properties. We’ll use this to build a library of different tile properties that we can reference when creating new tiles. For example, the first time we create a tile with the id 1 we’ll store the textureID, textureRect, and tileID in this collection. As we build our grid of tiles, the next time we need to create a tile with the same id we can retrieve its properties from this collection rather than creating the data again.
Next we’ll write our TimeMapParser class.
TileMapParser.hpp
class TileMapParser
{
public:
    TileMapParser(ResourceAllocator<sf::Texture>& textureAllocator);
    
    std::vector<std::shared_ptr<Object>> 
		Parse(const std::string& file, sf::Vector2i offset);
    
private:
    std::shared_ptr<TileSheetData> BuildTileSheetData(xml_node<>* rootNode);
    std::shared_ptr<MapTiles> BuildMapTiles(xml_node<>* rootNode);
    std::pair<std::string, std::shared_ptr<Layer>> 
		BuildLayer(
			xml_node<>* layerNode, std::shared_ptr<TileSheetData> tileSheetData
		);
    
    ResourceAllocator<sf::Texture>& textureAllocator;
};
You’ll notice that there is one public method (excluding the constructor): Parse. This function returns a vector of Objects that represent our tiles and takes one parameter as input: the location of our TMX file.
To help accomplish this I’ve added a number of helper functions:
  • BuildTileSheetData: reads the XML file and loads in the tileset used. Currently, we only have support for one tileset but this will change shortly.
  • BuildMapTiles: this returns the data for each tile in each layer. We need this data to create our final Objects.
  • BuildLayer: this creates and returns the data for one layer within the time map.
Theres a lot going on but it will become clearer as we go through each function in detail. Lets start with the Parse method.
TileMapParser.cpp
#include "TileMapParser.hpp"

TileMapParser::TileMapParser(ResourceAllocator<sf::Texture>& textureAllocator) 
	: textureAllocator(textureAllocator){}

std::vector<std::shared_ptr<Object>> 
TileMapParser::Parse(const std::string& file, sf::Vector2i offset)
{
    char* fileLoc = new char[file.size() + 1]; // 1
    
	//TODO: make multi format version of string copy
#ifdef MACOS
    strlcpy(fileLoc, file.c_str(), file.size() + 1); 
#else
    strcpy_s(fileLoc, file.size() + 1, file.c_str());
#endif 
   
      //TODO: error checking - check file exists before attempting open.
    rapidxml::file<> xmlFile(fileLoc);
    rapidxml::xml_document<> doc;
    doc.parse<0>(xmlFile.data());
    xml_node<>* rootNode = doc.first_node(“map");

	// Loads tile layers from XML.
    std::shared_ptr<MapTiles> tiles = BuildMapTiles(rootNode); 
    
	// We need these to calculate the tiles position in world space
    int tileSizeX = std::atoi(rootNode->first_attribute("tilewidth")->value());
    int tileSizeY = std::atoi(rootNode->first_attribute("tileheight")->value());
    int mapsizeX = std::atoi(rootNode->first_attribute("width")->value());
    int mapsizeY = std::atoi(rootNode->first_attribute("height")->value());
    
	// This will contain all of our tiles as objects.
    std::vector<std::shared_ptr<Object>> tileObjects;
    	
	// 2
	// We iterate through each layer in the tile map
    for (const auto& layer : *tiles)
    {
		// And each tile in the layer
        for (const auto& tile : *layer.second)
        {
            std::shared_ptr<TileInfo> tileInfo = tile->properties;
            
            std::shared_ptr<Object> tileObject = std::make_shared<Object>();
           
			//TODO: tile scale should be set at the data level.
            const unsigned int tileScale = 3;
            
			// Allocate sprite.
                      auto sprite = tileObject->AddComponent<C_Sprite>();
            sprite->SetTextureAllocator(&textureAllocator);
            sprite->Load(tileInfo->textureID);
            sprite->SetTextureRect(tileInfo->textureRect);
            sprite->SetScale(tileScale, tileScale);            
			// Calculate world position.
		  float x = tile->x * tileSizeX * tileScale + offset.x;
            float y = tile->y * tileSizeY * tileScale + offset.y;
            tileObject->transform->SetPosition(x, y);

            // Add new tile Object to the collection.
            tileObjects.emplace_back(tileObject);
        }
    }
    
    return tileObjects;
}
  1. rapid XML parser requires the file location to be in the c string format (char*) rather than the C++ string format. There are pros and cons to each but for now, all we need to be concerned with is that before we can load the XML file we need to convert between the two. Mac and windows handle this in slightly different ways unfortunately so, for now, we’ll add some branching logic for the compiler. We’ll change this in future when we write a multi-format string copy.
  2. Once we’ve created the tilemap we can iterate over each layer, and each tile in the layer and create an Object to represent the tile. We use its shared properties for information on the tiles sprite and we use the tilemap data (tile size and scale) and the tiles position in the grid to work out where to place the tile in the world space. When we create a tile Object we are only concerned about what it looks like (sprite) and where it is in the world (transform position) for the moment.
While there is a lot going on, each step is relatively straightforward. Our parse method starts by creating a rapid XML file. I won’t go into detail about how to use rapid XML but you can find the guide for rapid XML here. LINK TO RAPIDXML Once the XML document has been loaded from disk we call the ‘BuildMapTiles’ function and pass it the root node. BuildMapTiles retrieves data for each tile in each layer from the XML document.
TileMapParser.cpp
std::shared_ptr<MapTiles> TileMapParser::BuildMapTiles(xml_node<>* rootNode)
{
    std::shared_ptr<TileSheetData> tileSheetData = BuildTileSheetData(rootNode);
    
    std::shared_ptr<MapTiles> map = std::make_shared<MapTiles>();
    
	// We loop through each layer in the XML document.
    for (xml_node<> * node = rootNode->first_node("layer"); 
		 node; node = node->next_sibling())
    {
        std::pair<std::string, std::shared_ptr<Layer>> mapLayer = 
			BuildLayer(node, tileSheetData);
		
        map->emplace(mapLayer);
    }
    
    return map;
}
We loop through each layer and pass the parent node for that layer along with the tile sheet data to our BuildLayer function. This returns parsed data for the layer. We’ll discuss the BuildLayer function soon but before we can call this function we need to create the tile sheet data by calling BuildTileSheetData.
TileMapParser.cpp
std::shared_ptr<TileSheetData> 
TileMapParser::BuildTileSheetData(xml_node<> *rootNode)
{
    TileSheetData tileSheetData;
    
	// Traverse to the tile set node.
    xml_node<>* tilesheetNode = rootNode->first_node(“tileset"); 
    
    //TODO: add error checking to ensure these values actually exist.
    //TODO: add support for multiple tile sets.
	//TODO: implement this.
    int firstid = std::atoi(tilesheetNode->first_attribute("firstgid")->value()); 
    
	// Build the tile set data.
	tileSheetData.tileSize.x = 
		std::atoi(tilesheetNode->first_attribute("tilewidth")->value());
    tileSheetData.tileSize.y = 
		std::atoi(tilesheetNode->first_attribute("tileheight")->value());
    int tileCount = 
		std::atoi(tilesheetNode->first_attribute("tilecount")->value());
    tileSheetData.columns = 
		std::atoi(tilesheetNode->first_attribute("columns")->value());
    tileSheetData.rows = tileCount / tileSheetData.columns;
    
    xml_node<>* imageNode = tilesheetNode->first_node("image");
    tileSheetData.textureId = 
		textureAllocator.Add(
			std::string(imageNode->first_attribute("source")->value())
	);
													 
    //TODO: add error checking - we want to output a 
	//message if the texture is not found.
    
    tileSheetData.imageSize.x = 
		std::atoi(imageNode->first_attribute("width")->value());
    tileSheetData.imageSize.y = 
		std::atoi(imageNode->first_attribute("height")->value());
    
    return std::make_shared<TileSheetData>(tileSheetData);
}

For the tileset, we retrieve specific data from the XML document. We store the:

  • Width of tile sprite
  • Height of tile sprite
  • Number of tiles in the tileset
  • Number of columns in the tileset
  • Number of rows in tileset (calculated by dividing the number of tiles by columns)
  • The texture id for the tileset (retrieved using our texture allocator)
  • Width of the tileset
  • Height of the tileset
We then return a pointer to our TileSheetData. Currently, we only have support for the one tile sheet but that will change soon.
BuildLayer loops through the layer node in our XML document. As you can see below the layer node consists of a list of integers. Each number represents a different tile sprite in the tileset and by using the position in the list we can define where to place the tile.
Layer Node
<layer name="Platform" width="60" height="10">
  <data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,72,73,22,22,22,22,22,22,22,22,22,23,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,65,82,83,32,32,32,32,32,32,32,32,32,33,0,0,0,0,0,0,0,0,0,0,0,0,0,
21,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,71,72,72,72,72,72,72,72,72,72,72,72,72,72,75,82,83,32,32,32,32,32,32,32,32,32,33,0,0,0,0,0,0,0,0,0,0,0,0,0,
31,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,81,82,82,82,82,82,82,82,82,82,82,82,82,82,82,82,83,32,32,32,32,32,32,32,32,32,33,0,0,0,0,0,0,0,0,0,0,0,0,0,
41,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,81,82,82,82,82,82,82,82,82,82,82,82,82,82,82,82,83,42,42,42,42,42,42,42,42,42,43,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
 </layer>
TileMapParser.cpp
std::pair<std::string, std::shared_ptr<Layer>> 
TileMapParser::BuildLayer(xml_node<>* layerNode, 
						  std::shared_ptr<TileSheetData> tileSheetData)
{
    
    TileSet tileSet;
    std::shared_ptr<Layer> layer = std::make_shared<Layer>();
    
    int width = std::atoi(layerNode->first_attribute("width")->value());
    int height = std::atoi(layerNode->first_attribute("height")->value());
    
    xml_node<>* dataNode = layerNode->first_node("data");
    char* mapIndices = dataNode->value();
    
    std::stringstream fileStream(mapIndices);
    
    int count = 0;
    
    std::string line;
    while (fileStream.good())
    {
        std::string substr;
        std::getline(fileStream, substr, ‘,'); // 1
        
        if (!Utilities::IsInteger(substr)) // 2
        {
			// We remove special characters from the int before parsing
            substr.erase(
				std::remove(substr.begin(), substr.end(), '\r'), substr.end()); 
            substr.erase(
				std::remove(substr.begin(), substr.end(), '\n'), substr.end());
            
            //TODO: add additional check to 
			//confirm that the character removals have worked:
        }
        
        int tileId = std::stoi(substr); // 3
        
        if (tileId != 0) // 4
        {
            auto itr = tileSet.find(tileId); // 5
            if (itr == tileSet.end()) // 6
            {
                int textureX = tileId % tileSheetData->columns - 1;
                int textureY = tileId / tileSheetData->columns;
                
                std::shared_ptr<TileInfo> tileInfo = 
					std::make_shared<TileInfo>(
						tileSheetData->textureId, tileId, 
						sf::IntRect(
							textureX * tileSheetData->tileSize.x, 
							textureY * tileSheetData->tileSize.y, 
							tileSheetData->tileSize.x, 
							tileSheetData->tileSize.y)
					);
                
                itr = tileSet.insert(std::make_pair(tileId, tileInfo)).first;
            }
            
            std::shared_ptr<Tile> tile = std::make_shared<Tile>();
            
            // Bind properties of a tile from a set.
            tile->properties = itr->second; // 7
            tile->x = count % width - 1;
            tile->y = count / width;
            

            layer->emplace_back(tile); // 8
        }
        
        count++;
    }
    
    const std::string layerName = layerNode->first_attribute("name")->value();
    return std::make_pair(layerName, layer);
}

The steps to read a tile are:

  1. Get the next integer. We split at each comma.
  2. Check if the value can be parsed as an integer using a function we will write shortly. If it cannot be parsed, we remove special characters that are used for carriage returns and new lines.
  3. Parse the value to an int.
  4. Check if the int is not 0, which is represents a space with no tile.
  5. Try to find the tiles id in our tileset.
  6. Create new tile data if the tile was not found in the tileset.
  7. Create a new tile and set its shared properties using the value from the tileset.
  8. Add the new tile to our collection.

This is repeated for each value in the dataset.

To check if an int can be parsed, we’ll create a new class called Utilities. We’ll place functions that we want to share between classes here. This may not prove to be the best way to do things and is probably something we’ll change in the future.
Utilities.hpp
#ifndef Utilities_h
#define Utilities_h

class Utilities
{
public:
// Used answers from: 
// https://stackoverflow.com/questions/4654636/how-to-determine-if-a-string-is-a-number-with-c
    //TODO: not robust. Only correctly handles whole positive numbers.
    static inline bool IsInteger(const std::string & s)
    {
        if (s.empty() 
			|| ((!isdigit(s[0])) && (s[0] != '-') && (s[0] != '+'))) 
		{
			return false;
		}
        
        char * p;
        strtol(s.c_str(), &p, 10);
        return (*p == 0);
    }
    
};

#endif /* Utilities_h */
With our tilemap parser complete, its time to test it in our game scene. First, we’ll create a new parser.
SceneGame.hpp
…
#include "TileMapParser.hpp"

class SceneGame : public Scene
{
private:
…
    TileMapParser mapParser;
};

#endif /* SceneGame_hpp */
We’ll pass in the path to our test map in our OnCreate function. As you know this returns a vector of Objects so we’ll need to add the objects to our Object collection.
SceneGame.cpp
SceneGame::SceneGame(WorkingDirectory& workingDir, 
					 ResourceAllocator<sf::Texture>& textureAllocator) 
	: workingDir(workingDir), textureAllocator(textureAllocator), 
		mapParser(textureAllocator) 

void SceneGame::OnCreate()
{
…

    // You will need to play around with this offset until it fits 
	// the level in at your chosen resolution. This worls for 1920 * 1080.
    // In future we will remove this hardcoded offset when we 
	// look at allowing the player to change resolutions.
    sf::Vector2i mapOffset(-100, 128);
    std::vector<std::shared_ptr<Object>> levelTiles 
		= mapParser.Parse(workingDir.Get() + "Test Map 1.tmx", mapOffset);
    
    objects.Add(levelTiles);
    
}
Now when you run the game it should (fingers crossed) load in our new tile map.
Tilemap imported into our game
Our first tilemap.
You’ll also notice that we can pass through the tile map as we have not set up any collisions. That is something we will be fixing very shortly.
One last thing before I wrap up this weeks tutorial. When writing an article on AI, I discovered the first bug (of many I’m sure) in our games code. In our Object class in the ProcessNewObjects to copy the objects from the newObjects collection to the object’s vector, we call ‘assign’. This, as many of you probably know, overwrites what is already in the original vector, which of course we do not want. Luckily it’s an easy fix, just change:
ObjectCollection.cpp
 objects.assign(newObjects.begin(), newObjects.end());
to:
ObjectCollection.cpp
 objects.insert(objects.end(), newObjects.begin(), newObjects.end());
And the crises has been averted!
You may have also noticed a few ‘TODOs’ scattered throughout the code today. As we progress and the TODOs build up, I’ll have a number of tutorials where we go back and complete them. I add them for a number of reasons, this week I knew that this tutorial would be long enough already without writing a multi-format string copy. But don’t worry we will come back to them.
As always, if you have any suggestions for what you would like covered or are having any trouble implementing a feature, then let me know in the comments and I’ll get back to you as soon as I can. Thank you for reading 🙂