Welcome to the 4th tutorial in this series on creating a Tile Engine from scratch in C++. In the last tutorial we implemented a camera and a level class so we could display a lot of tiles on the screen at once. Currently our level is generated in the code, which is temporary and ugly. Also, we're loading each tile sprite from individual files, which isn't easy to maintain and edit.
So in this tutorial, we're going to load our levels from an editable file on the harddrive, and we're going to join our tiles together in what are called tilesets to be parsed and loaded as a whole.
File Format
In my experience, there have been two popular ways to store game related data on the hard drive. The first is to store everything in a binary format, by writing allocated structs straight to a file. The upside to this method is that it is fast (actually I can't think of a faster way to load info from a file into data structures in your program), and uses the least amount of space possible. However, the data written is mostly, or completely, unreadable by humans. It's in pure binary format, so trying to view the file in a text editor that tries to convert the written values into ASCII will result in complete garbage.
The other method is to create your own file structure, and manually read or write each required value. For instance, you might have map data stored as comma-delimited grid of numeric values each indicating a tile type, such as in this little example:
1,1,1,1,1,1,1,1,1,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,0,0,0,2,2,0,0,0,1 1,1,1,1,1,1,1,1,1,1
0 might be defined as a grass tile, 1 as a wall tile, 2 as a water tile, etc. Of course, if you want extra layers, you'd have to write out another grid for each layer, and if you wanted to store values for, say, walkability of the tile, you'd need another grid. Then there are other values needed such as the level name, dimensions, where any npcs are, etc. Then you need to write your own parser to read and write these files.
The upside to this method is that it is actually human readable. While there may be some uncertainty about what each number represents or the like, it's still readable with a text editor. That means that in the absence of an editor for these files, you could edit them manually with a text editor. The downside is that the parser for such a format would be much larger than the one for the binary format, and slower. The files are also going to be larger than the binary files.
However, I'd like to use another method for storing and reading these files. Rather than rely on our own convoluted parser, let's instead take advantage of a widely known and standardized file format; namely, XML. XML is well documented, is known by a wide range of web and software developers alike, and best of all is that there are libraries in many different programming languages designed specifically for parsing, reading, and writing XML files.
The upside to XML is its popularity and documentation. However we decide to implement our XML, it will be easily readable by anyone familiar with XML. The downside is that this method will easily take up much more harddrive space than the corresponding binary or new file format. Why use XML if it's going to take up so much space? Honestly, harddrive space and memory is so cheap nowadays that sacrificing file size for standardization and human readability is more than a fair tradeoff.
Alright so how are we going to read and write our XML files? Well, the library I'll be using for this project is RapidXML, which you can get here. It is a very small, very fast xml parsing library and it is very easy to use and to add to our project. Unlike SFML, which we needed to build and set up just right to get it to work, RapidXML comes as a few code files which we simply copy to our project's source directory, and include wherever we need to use it. You can't ask for a simpler way to do this!
Once you've downloaded RapidXML, go ahead and copy "rapidxml.hpp" to our source directory. This is the only file we need for this part of the project. Later on we'll want "rapidxml_print.hpp" so we can easily print our own XML directly to a filestream, which will prove very useful when we want to start saving game states and the like. But for now, we only need "rapidxml.hpp".
That's all there is to "installing" rapidxml for use with our project. Now it's time to decide how we're going to define our xml files.
Tileset File Format
Up till now, we've loaded our sprites manually as individual image files. This ends up becoming a management nightmare when you have hundreds of tiles (especially with any animation), so it's time to start combining our sprites into what are called tilesets. A tileset is simply a collection of sprites laid out in a grid pattern, where each grid cell contains a single sprite or animation frame.
Later on, when we start loading in character animations, objects, effects, and everything else, we'll be using grid cells that aren't necessarily square. However, for now our tiles will be square, making it easier to parse and load the tileset. Now that we know how we're storing our tiles, let's figure out how we're going to define them with xml.
I've found that the easiest way to determine how to define an xml format is to simply write out some xml. First of all, we don't need any xml headers since RapidXML doesn't do any DTD validation. Since we're defining a tileset, let's make that our first xml node:
<tileset> </tileset>
Great! Now what would be the next node we need. How about a node for each tileset image file we're loading from. We don't need to limit the user to one image per tileset. And, we can use an attribute to define the path of the image file being used. Here's my example xml:
<tileset> <imagefile path="tileset.png"> </imagefile> </tileset>
Now there's only one more piece to define, and that is a node for each of the tiles in our tileset. The reason I want to define our tiles in an xml file is so we can easily add in animation later on. You'll see what I mean in the next tutorial. Also, this way we can give each and every tile being used by the tile engine a separate ID, rather than having to worry about which image file or tileset the tile is coming from.
So we need a tile node, and that node needs all the attributes required to parse the tile. That means we need the x and y coordinates of the tile, which will be in tiles from the upper left corner of the screen, and an id give the tile in our engine. Just for kicks, and to get you excited for the next tutorial, I'm going to add in a frames attribute to determine how many frames are in the tile's animation. That means we'll need to extract more than one tile's worth of images for this tile. Alright, let's finish our tileset xml. Here's my example tileset:
<tileset> <imagefile path="tileset.png"> <tile x="0" y="0" frames="1" id="0" /> <tile x="1" y="0" frames="1" id="1" /> <tile x="2" y="0" frames="1" id="2" /> <tile x="3" y="0" frames="1" id="3" /> </imagefile> </tileset>
Alright, now let's write some code to parse this xml and use it to load a tileset into our engine. We'll start by adding a new method prototype in our ImageManager class called "LoadTileset". We also need a new std::map for linking the tile IDs to their respective index in the imageList. Here's the new ImageManager.h:
#ifndef _IMAGEMANAGER_H
#define _IMAGEMANAGER_H
#include <vector>
#include <map>
#include <string>
#include <SFML\Graphics.hpp>
class ImageManager
{
private:
std::vector<sf::Image> imageList;
std::map<int, int> imageIDs;
int tileSize;
public:
ImageManager();
~ImageManager();
void setTileSize(int tileSize) { this->tileSize = tileSize; }
void AddImage(sf::Image& image, int id);
sf::Image& GetImage(int id);
//Loads tileset from xml format
void LoadTileset(std::string filename);
};
#endif
Actually parsing the xml file using rapidxml is very easy. The only hiccup I've run into is loading the xml file into a format that rapidxml appreciates. Here is the code we'll use for doing just that, which will go into the method definition of LoadTileset in "ImageManager.cpp":
//Load the file
std::ifstream inFile(filename);
if(!inFile)
throw "Could not load tileset: " + filename;
//Dump contents of file into a string
std::string xmlContents;
//Blocked out of preference
{
std::string line;
while(std::getline(inFile, line))
xmlContents += line;
}
//Convert string to rapidxml readable char*
std::vector<char> xmlData = std::vector<char>(xmlContents.begin(), xmlContents.end());
xmlData.push_back('\0');
There we go, now the xml file has been loaded into xmlData. We can get the rapidxml compatible char* with &xmlData[0]. We can now create an xml_document and parse the xml file we've loaded. After that we can use the xml file to load and parse all of our tilesets, adding them to imageList. Here's the code we'll be using:
//Convert string to rapidxml readable char*
std::vector<char> xmlData = std::vector<char>(xmlContents.begin(), xmlContents.end());
xmlData.push_back('\0');
//Create a parsed document with &xmlData[0] which is the char*
xml_document<> doc;
doc.parse<parse_no_data_nodes>(&xmlData[0]);
//Get the root node
xml_node<>* root = doc.first_node();
//Some variables used in the following code
std::string imagePath;
sf::Image tileset;
//Go through each imagefile
xml_node<>* imagefile = root->first_node("imagefile");
while(imagefile)
{
//Get the image file we're parsing and load it
imagePath = imagefile->first_attribute("path")->value();
tileset.LoadFromFile(imagePath);
//Go through each tile
xml_node<>* tile = imagefile->first_node("tile");
while(tile)
{
//Get all the attributes
int x = atoi(tile->first_attribute("x")->value());
int y = atoi(tile->first_attribute("y")->value());
int frames = atoi(tile->first_attribute("frames")->value());
int id = atoi(tile->first_attribute("id")->value());
//Copy the right tile image from tileset
sf::Image tileImage;
tileImage.Create(tileSize, tileSize);
tileImage.Copy(tileset, 0, 0, sf::IntRect(x * tileSize, y * tileSize, frames * tileSize, tileSize), true);
//Add the image to our image list
AddImage(tileImage, id);
//Go to the next tile
tile = tile->next_sibling();
}
//Go to the next imagefile
imagefile = imagefile->next_sibling();
}
We parse the document and store the root node (<tileset>) in root. Then we load the first <imagefile> node into imagefile and, using a while loop, go through each imagefile in <tileset>. We use the path attribute of <imagepath> to load the tileset image into a new image called tileset.
Then, using the same method we used to loop through each imagefile, we loop through every tile in the <imagefile>. We store each of the four attributes we've defined in our xml into variables, using atoi to convert the string values to integers.
The next 3 lines are how we copy a piece of the tileset image into a new image using SFML. First we define a new Image, then use the Create method to get the image ready for the copy. Then we use the Copy method to copy a piece of the source image, which is our tileset, into our new image. The piece is defined with the IntRect, and is determined by the x, y, and frames values we've parsed from the xml file.
Once we have the new image, we add it to our imageList using AddImage, which we implemented last tutorial. Then we set tile to the next tile using tile->next_sibling();. After we've gone through all the tiles, we go to the next image file with imagefile->next_sibling();.
We need to modify AddImage slightly so that it adds the tile's id to imageIDs, and change GetImage to use imageIDs to return the right tile:
void ImageManager::AddImage(sf::Image& image, int id)
{
imageList.push_back(image);
//Map for pairing image ids and the image's index in imageList
imageIDs[id] = imageList.size() - 1;
}
sf::Image& ImageManager::GetImage(int id)
{
return imageList[imageIDs[id]];
}
Now we have a way to load a tileset into our engine with a tileset definition file written in xml! Piece of cake right? You can test this code out now by writing your own tileset xml file.
Level File Format
At the end of the last tutorial, we had set up a class for holding information about the level, mainly the 2D array of tiles that make up the map. Unfortunately, the only way we had of making a map was an ugly hard-coded map generator. Let's get rid of that, and instead use XML again to define levels.
Just like with our tileset xml definition, let's start by just writing some XML. We can skip the header once again, and start with just a level node:
<level width="20" height="20"> </level>
As I was writing the level node, I realized that we need to define the size of the level. Why not define it right here? So I've added a width and a height attribute. Now we need to think about what a level has. For instance, there are resources that we'll want to load for each level, such as any tilesets used by the level. How about a tileset node:
<level width="20" height="20"> <tileset path="tileset.xml"/> </level>
There we go, now our level has a tileset. At the moment we don't have any other resources to load, but we've allowed ourselves an easy way to define any resources we come up with later, such as scripts, npcs, etc. Now let's define a tile node, since our map is made up of a bunch of tiles. If we think about it, every tile on the map has an x and y coordinates as well as an id for which tile sprite to draw. In preparation for a feature we'll be adding in our next tutorial, I'm going to call that attribute "baseid". Don't worry, the reason for this will make sense after the next tutorial. Alright, let's define some tiles:
<level width="20" height="20"> <tileset path="tileset.xml" /> <tile x="0" y="0" baseid="0" walkable="true" /> <tile x="1" y="0" baseid="0" walkable="true" /> <tile x="2" y="0" baseid="1" walkable="false" /> <tile x="3" y="0" baseid="1" walkable="false" /> <tile x="4" y="0" baseid="0" walkable="true" /> </level>
I don't want to implement an entire map, since that would easily double the size of this tutorial. I have included an example map in the source code for this tutorial. I've also added a walkable attribute for a later tutorial. Since it doesn't matter if we actually parse an attribute or not, I figured it would be a good idea to add the walkable attribute now.
Alright, now that we have an idea how our level xml files are going to be laid out, let's write the code to load the data in to create a new level. You'll find that the code is actually very similar to the code for loading in a tileset. That's one of the nice effects of using the same file type for everything. Here's the code we'll use for our new Level::LoadLevel method:
void Level::LoadLevel(std::string filename, ImageManager& imageManager)
{
//Loads a level from xml file
//Load the file
std::ifstream inFile(filename);
if(!inFile)
throw "Could not load tileset: " + filename;
//Dump contents of file into a string
std::string xmlContents;
//Blocked out of preference
{
std::string line;
while(std::getline(inFile, line))
xmlContents += line;
}
//Convert string to rapidxml readable char*
std::vector<char> xmlData = std::vector<char>(xmlContents.begin(), xmlContents.end());
xmlData.push_back('\0');
//Create a parsed document with &xmlData[0] which is the char*
xml_document<> doc;
doc.parse<parse_no_data_nodes>(&xmlData[0]);
//Get the root node
xml_node<>* root = doc.first_node();
//Get level attributes
int width = atoi(root->first_attribute("width")->value());
int height = atoi(root->first_attribute("height")->value());
//Resize level
this->w = width;
this->h = height;
SetDimensions(width, height);
//Load each necessary tileset
xml_node<>* tileset = root->first_node("tileset");
while(tileset)
{
std::string path = tileset->first_attribute("path")->value();
//Load tileset
imageManager.LoadTileset(path);
//Go to next tileset
tileset = tileset->next_sibling("tileset");
}
//Go through each tile
xml_node<>* tile = root->first_node("tile");
while(tile)
{
//Get all the attributes
int x = atoi(tile->first_attribute("x")->value());
int y = atoi(tile->first_attribute("y")->value());
int baseid = atoi(tile->first_attribute("baseid")->value());
std::string walkString = tile->first_attribute("walkable")->value();
bool walkable = (walkString == "true")? true : false;
//Create the tile and add it to the level.
Tile* newTile = new Tile(imageManager.GetImage(baseid));
AddTile(x, y, newTile);
//Go to the next tile
tile = tile->next_sibling("tile");
}
}
This is very similar to our LoadTileset method. We load the xml file into a rapidxml compatible char*, and load the root <level> node into root. Before we begin running through all the resources and tiles, we grab the width and height and use them to set the map dimensions.
Then we loop through each required tileset in the same way we looped through imagefiles and tiles in the last part of this tutorial. After grabbing the path from the <tileset>, we use it to load the tileset with the supplied imageManager.
After we loop through the tilesets, we loop through each <tile> in the xml file. Just like loading tiles from the the tileset, we grab each of the attributes from each tile before creating a new Tile and adding it to the map with AddTile.
After editing Engine.cpp to use our new LoadTileset and LoadLevel methods, we're ready to run the code.
Conclusion
While there may not be any visible changes to our engine, implementing these two functions has greatly increased the versatility of our engine by allowing levels and tilesets to be modified without having to recompile the engine every time. In the end, this will greatly increase development speed and prevent a lot of design headaches. I hope you're not too disappointed by the lack of cool new visual additions, but don't worry because in the next tutorial we're going to start animating!
Also, unlike last tutorial, I actually have working source code for this tutorial, as well as an example tileset and level. So if you find yourself stuck or just want to be caught up for the next tutorial, go ahead and download it. See you soon!
Attached File(s)
-
TileEngineTutorialPart4Source.zip (33.5K)
Number of downloads: 1298




MultiQuote






|