Creating a City Building Game with SFML Part 5: The Game World

3 Daniel Mansfield Aug 8, 2014

Get started with this tutorial series here!

With the Tile class now in place we can put them to use by combining them into a Map. The Map class will contain a 2D array of Tiles as well as a bunch of helper variables and functions for altering the array. This will be our largest class yet (definition-wise, there aren't too many declarations) as some of the functions are complicated. But anyway, let's examine map.hpp

#ifndef MAP_HPP
#define MAP_HPP

#include <SFML/Graphics.hpp>

#include <string>
#include <map>
#include <vector>

#include "tile.hpp"

class Map
{
    private:

    void depthfirstsearch(std::vector<TileType>& whitelist,
        sf::Vector2i pos, int label, int type);

    public:

    unsigned int width;
    unsigned int height;

    std::vector<Tile> tiles;

    /* Resource map */
    std::vector<int> resources;

    unsigned int tileSize;

    unsigned int numSelected;

    unsigned int numRegions[1];

    /* Load map from disk */
    void load(const std::string& filename, unsigned int width, unsigned int height,
        std::map<std::string, Tile>& tileAtlas);

    /* Save map to disk */
    void save(const std::string& filename);

    /* Draw the map */  
    void draw(sf::RenderWindow& window, float dt);

    /* Checks if one position in the map is connected to another by
     * only traversing tiles in the whitelist */
    void findConnectedRegions(std::vector<TileType> whitelist, int type);

    /* Update the direction of directional tiles so that they face the correct
     * way. Used to orient roads, pylons, rivers etc */
    void updateDirection(TileType tileType);

    /* Blank map constructor */
    Map()
    {
        this->tileSize = 8;
        this->width = 0;
        this->height = 0;
        this->numRegions[0] = 1;
    }
    /* Load map from file constructor */
    Map(const std::string& filename, unsigned int width, unsigned int height,
        std::map<std::string, Tile>& tileAtlas)
    {
        this->tileSize = 8;
        load(filename, width, height, tileAtlas);
    }
};

#endif /* MAP_HPP */

The width and height values are just the dimensions of the map, which we have to remember because we aren't using a 2D array to contain the tiles (I'm sorry, I lied); we're using a single std::vector and just pretending that it's two dimensional. This makes it much simpler (and more efficient) to iterate through when the position of the tile is not needed, and it isn't very complicated when the position is needed either.

The resources std::vector will be used later by the industrial zone tiles in order to produce their goods. Each tile will have a limited amount of resources that the zone can extract, which is what that array manages. Zones will also buy goods from other (smaller) zones that do have resources, and so industrial zones will not simply accelerate their production and then suddenly become useless. But that's all for another tutorial! Right now we have a few simple variables, and then finally some functions. We will use a custom binary format to load and save the maps, which we'll examine when we define the functions. Then there's the now familiar draw function, and some rather simple constructors.

There's three functions that we haven't discussed; findConnectingRegions, depthfirstsearch, and updateDirection. updateDirection is simple (if tedious to implement) and iterates over each Tile in the Map, changing their tileVariant so as to change their animation and orient (if they have a correctly created texture) them in the correct direction. The other two functions will be used to split the Map into different regions, labeling them according to what region they fall in. The whitelist argument is simply a std::vector containing all the different TileTypes that can make up the region. So if you wanted a "greenery" region you might pass TileType::GRASS and TileType::FOREST as arguments. type is the index of the regions array in the Tile class that the region information should be stored in. Currently we only have one possible region, but as mentioned above more will be added later.

Now let's begin creating map.cpp

#include <SFML/Graphics.hpp>
#include <string>
#include <map>
#include <vector>
#include <fstream>

#include "map.hpp"
#include "tile.hpp"

/* Load map from disk */
void Map::load(const std::string& filename, unsigned int width, unsigned int height,
    std::map<std::string, Tile>& tileAtlas)
{
    std::ifstream inputFile;
    inputFile.open(filename, std::ios::in | std::ios::binary);

    this->width = width;
    this->height = height;

    for(int pos = 0; pos < this->width * this->height; ++pos)
    {
        this->resources.push_back(255);

        TileType tileType;
        inputFile.read((char*)&tileType, sizeof(int));
        switch(tileType)
        {
            default:
            case TileType::VOID:
            case TileType::GRASS:
                this->tiles.push_back(tileAtlas.at("grass"));
                break;
            case TileType::FOREST:
                this->tiles.push_back(tileAtlas.at("forest"));
                break;
            case TileType::WATER:
                this->tiles.push_back(tileAtlas.at("water"));
                break;
            case TileType::RESIDENTIAL:
                this->tiles.push_back(tileAtlas.at("residential"));
                break;
            case TileType::COMMERCIAL:
                this->tiles.push_back(tileAtlas.at("commercial"));
                break;
            case TileType::INDUSTRIAL:
                this->tiles.push_back(tileAtlas.at("industrial"));
                break;
            case TileType::ROAD:
                this->tiles.push_back(tileAtlas.at("road"));
                break;
        }
        Tile& tile = this->tiles.back();
        inputFile.read((char*)&tile.tileVariant, sizeof(int));
        inputFile.read((char*)&tile.regions, sizeof(int)*1);
        inputFile.read((char*)&tile.population, sizeof(double));
        inputFile.read((char*)&tile.storedGoods, sizeof(float));
    }

    inputFile.close();

    return;
}

First we open a binary std::ifstream for the file specified. If you are not familiar with std::ifstream, it is the C++ way of reading from files. It inherits from the same class std::cin does (both are input streams) and so has a very similar interface, although we won't be using that here as we are dealing with binary files, not text files. Instead we will use the read member function. Once we've opened the stream we set width and height and then we loop enough times to read every Tile from the stream, reading the data upon each loop. read requires a char* as the first argument, which can be interpreted as a pointer to the start of an array of individual bytes (a char is the size of a byte).

The second argument is the number of bytes to read. If we pass a pointer to the variable we want to read and pretend it's a char* pointer by using a cast, read will fill the variable with the data we want. We repeat this for all the variables we require. Note that in our first read call we are also pretending that tileType is of type int and not an enum; this is valid, as enums are (kind of) the same as ints, just referred to differently.

void Map::save(const std::string& filename)
{
    std::ofstream outputFile;
    outputFile.open(filename, std::ios::out | std::ios::binary);

    for(auto tile : this->tiles)
    {
        outputFile.write((char*)&tile.tileType, sizeof(int));
        outputFile.write((char*)&tile.tileVariant, sizeof(int));
        outputFile.write((char*)&tile.regions, sizeof(int)*1);
        outputFile.write((char*)&tile.population, sizeof(double));
        outputFile.write((char*)&tile.storedGoods, sizeof(float));
    }

    outputFile.close();

    return;
}

As you might expect, save is load in reverse. Well almost, we're still processing the file in the same direction, so mostly we just replace the reads with writes! And we replace the std::ifstream with an std::ofstream of course. We then have a for loop, although in a form you won't be familiar with unless you've seen some c++11; it says "for each tile in this->tiles, do the following" The auto keyword is another c++11 feature, and can be used instead of the variable's type if the type is obvious. 'auto' is far easier to write than std::vector<Tile>::iterator, and without c++11 the loop would look far worse.

void Map::draw(sf::RenderWindow& window, float dt)
{
    for(int y = 0; y < this->height; ++y)
    {
        for(int x = 0; x < this->width; ++x)
        {
            /* Set the position of the tile in the 2d world */
            sf::Vector2f pos;
            pos.x = (x - y) * this->tileSize + this->width * this->tileSize;
            pos.y = (x + y) * this->tileSize * 0.5;
            this->tiles[y*this->width+x].sprite.setPosition(pos);

            /* Draw the tile */
            this->tiles[y*this->width+x].draw(window, dt);
        }
    }
    return;
}

With the saving and loading functions done, we can write the code to draw the Map. For this we need to know the coordinates of the Tile, and so we use some normal nested for loops (y before x so we iterate horizontally first, and then vertically). We then have some formulae, seemingly conjured out of thin air, that convert the Map coordinates (x,y) to world coordinates that we can use to draw each Tile to the screen. They aren't conjured from thin air however, and are created with just a small amount of reasoning!

An isometric grid, for your convenience

Consider an isometric grid, where the very top point has coordinates (0, 0), the x axis extends along the right edge and the y axis extends along the left edge. If we increase x by 1, then pos.x will increase by tileSize (remember tileSize is half the width, or the height, of each sprite) and pos.y will increase by tileSize * 0.5.

pos.x = x * this->tileSize;
pos.y = x * this->tileSize * 0.5;

If we increase y by 1, then pos.x will decrease by tileSize (follow along the grid lines) and pos.y will increase by tileSize * 0.5.

pos.x = x * this->tileSize - y * this->tileSize;
pos.y = x * this->tileSize * 0.5 + y * this->tileSize * 0.5;

With a tiny bit of algebra we get

pos.x = (x - y) * this->tileSize;
pos.y = (x + y) * this->tileSize * 0.5;

The way we've set up our coordinates means that all sprites to the left will have negative world coordinates. We don't want that, so we shift the world coordinates to the right by half the width of the Map (in pixels). Once we've converted to the world coordinates, we set the position of the Tile's sprite and then draw it. Because we're using a 1D std::vector instead of a 2D array, we have to convert our (x,y) coordinates to a single value by using index = y * this->width + x.

That's all for now, in the next tutorial we will complete the definitions of the Map member functions!

Source code for this section

3 comments


Or enter your name and Email
  • K Kenneth 3 years ago
    Why is the multiple at end different? inputFile.read((char*)&tile.regions, sizeof(int) * 1); outputFile.write((char*)&tile.regions, sizeof(int) * 3);
    • DM Daniel Mansfield 3 years ago
      We're only dealing with one region at this point, not 3, so that should be a 1 instead, thanks for pointing it out!
  • RN Reinout Nonhebel 3 years ago
    Loving it so far, but "The way we've set up our coordinates means that all sprites to the left will have negative world coordinates. We don't want that" Why don't we want that? It seems easy enough to just move the view to the left? (Must admit I haven't tried it yet)