Creating a City Building Game with SFML Part 9: A Complete City

Get started with this tutorial series here!

Before we can add a Gui to GameStateEditor, we'll need to create our final class; City. The City class will contain a Map, and will manage the actual gameplay. Yes, finally we'll have an actual playable game! This goes in city.hpp, as the header guard says.

#ifndef CITY_HPP
#define CITY_HPP

#include <vector>
#include <map>

#include "map.hpp"

class City
{
    private:

    float currentTime;
    float timePerDay;

    std::vector<int> shuffledTiles;

    /* Number of residents who are not in a residential zone. */
    double populationPool;

    /* Number of residents who are not currently employed but can work. */
    double employmentPool;

    /* Proportion of citizens who can work. */
    float propCanWork;

    /* Proportion of residents who die/give birth each day.
     * Estimate for death rate = 1 / (life expectancy * 360)
     * Current world values are 0.000055 and 0.000023, respectively */
    double birthRate;
    double deathRate;

    double distributePool(double& pool, Tile& tile, double rate);

    public:

    Map map;

    double population;
    double employable;

    double residentialTax;
    double commercialTax;
    double industrialTax;

    /* Running total of city earnings (from tax etc) this month. */
    double earnings;
    double funds;

    int day;

    City()
    {
        this->birthRate = 0.00055;
        this->deathRate = 0.00023;
        this->propCanWork = 0.50;
        this->populationPool = 0;
        this->population = populationPool;
        this->employmentPool = 0;
        this->employable = employmentPool;
        this->residentialTax = 0.05;
        this->commercialTax = 0.05;
        this->industrialTax = 0.05;
        this->earnings = 0;
        this->funds = 0;
        this->currentTime = 0.0;
        this->timePerDay = 1.0;
        this->day = 0;
    }

    City(std::string cityName, int tileSize, std::map<std::string, Tile>& tileAtlas) : City()
    {
        this->map.tileSize = tileSize;
        load(cityName, tileAtlas);
    }

    void load(std::string cityName, std::map<std::string, Tile>& tileAtlas);
    void save(std::string cityName);

    void update(float dt);
    void bulldoze(const Tile& tile);
    void shuffleTiles();
    void tileChanged();

    double getHomeless() { return this->populationPool; }
    double getUnemployed() { return this->employmentPool; }
};

#endif /* CITY_HPP */

Quite a big class, although it is mostly declarations. currentTime is the real world time (in seconds) since the day updated, and timePerDay is the amount of real world time each day should last. We've set this to 1.0 in the constructor to get a 1:1 correspondence of seconds to days. The game world will update at the end of each day, so the lower this value the faster the game will go. We then have shuffledTiles, which has an interesting use; if we were to update the tiles by iterating over them they would update from left to right and top to bottom on the map.

As you'll see when we program the update function this means that citizens will move into houses in the top left before they move into those in the bottom right. To fix this we use shuffledTiles, which is filled with array indices corresponding to Tiles in the map. The indices are stored in a random order and instead of iterating through the Tiles in map we iterate over shuffledTiles and use the indices to choose the Tiles in a "random" order. The order will be the same each day, but we'll have fixed the problem!

Now for a brief explanation on how population will work. The City has a populationPool, which stores the number of citizens who do not have a home. Each Tile has a population value (as we've seen) that stores the number of citizens living within. So to move people into houses we decrease populationPool and increase population. The total population of the City is calculated as the sum of all the Tiles' populations and the populationPool. The same applies for employable and employmentPool, but those are for commercial and industrial zones and not residential ones.

We then have propCanWork, which is the proportion of the population that can work and thus can be employed. Thus employable is approximately equal to propCanWork * population. Next we have birthRate and deathRate, which are set to be 100 times the real world value in order to speed up gameplay. Or you could just make the days run faster and keep them the same, of course.

We then have the three tax variables which store the proportion of income from each zone that is taxed by the City, and are all set to 5% in the constructor. The calculations using them are entirely unrealistic, but they work well for the game. (We'll see them in update.) Finally there's the City's funds which are used to build new Tiles, the earnings (due to tax) amassed since last month, and the number of days that have passed since the game was started. earnings is moved into funds after every 30 days.

As for the functions, load and save will load and save the City from files respectively (loading and saving the map too), update will move people around, calculate income, move goods around and so on, bulldoze will replace the selected (and valid) area of map with tile, shuffleTiles will generate the shuffledTiles std::vector, tileChanged will update the regions and directions of tiles and should of course be called whenever a Tile is changed, and finally distributePool will be used in update to move citizens around. Now let's create these function in city.cpp

#include <cmath>
#include <cstdlib>
#include <iostream>
#include <algorithm>
#include <vector>
#include <fstream>
#include <sstream>

#include "city.hpp"
#include "tile.hpp"

double City::distributePool(double& pool, Tile& tile, double rate = 0.0)
{
    const static int moveRate = 4;

    unsigned int maxPop = tile.maxPopPerLevel * (tile.tileVariant+1);

    /* If there is room in the zone, move up to 4 people from the
     * pool into the zone */
    if(pool > 0)
    {
        int moving = maxPop - tile.population;
        if(moving > moveRate) moving = moveRate;
        if(pool - moving < 0) moving = pool;
        pool -= moving;
        tile.population += moving;
    }

    /* Adjust the tile population for births and deaths */
    tile.population += tile.population * rate;

    /* Move population that cannot be sustained by the tile into
     * the pool */
    if(tile.population > maxPop)
    {
        pool += tile.population - maxPop;
        tile.population = maxPop;
    }

    return tile.population;
}

distributePool works by moving up to 4 people from the pool into the tile, and then adjusts the tile.population according to the rate passed as an argument. rate will be a birth rate if it's positive and a death rate if it's negative. Most of the code in this function is just to ensure that the right amount of people move and the overall population remains the same.

Next let's look at the bulldoze, shuffleTiles, and tileChanged functions.

void City::bulldoze(const Tile& tile)
{
    /* Replace the selected tiles on the map with the tile and
     * update populations etc accordingly */
    for(int pos = 0; pos < this->map.width * this->map.height; ++pos)
    {
        if(this->map.selected[pos] == 1)
        {
            if(this->map.tiles[pos].tileType == TileType::RESIDENTIAL)
            {
                this->populationPool += this->map.tiles[pos].population;
            }
            else if(this->map.tiles[pos].tileType == TileType::COMMERCIAL)
            {
                this->employmentPool += this->map.tiles[pos].population;
            }
            else if(this->map.tiles[pos].tileType == TileType::INDUSTRIAL)
            {
                this->employmentPool += this->map.tiles[pos].population;
            }
            this->map.tiles[pos] = tile;
        }
    }

    return;
}

void City::shuffleTiles()
{
    while(this->shuffledTiles.size() < this->map.tiles.size())
    {
        this->shuffledTiles.push_back(0);
    }
    std::iota(shuffledTiles.begin(), shuffledTiles.end(), 1);
    std::random_shuffle(shuffledTiles.begin(), shuffledTiles.end());

    return;
}

void City::tileChanged()
{
    this->map.updateDirection(TileType::ROAD);
    this->map.findConnectedRegions(
    {
        TileType::ROAD, TileType::RESIDENTIAL,
        TileType::COMMERCIAL, TileType::INDUSTRIAL
    }, 0);

    return;
}

In the bulldoze function we iterate over every tile in the map. If the tile is selected then we replace it with the given tile and adjust the populationPool if the tile that was destroyed had a population. shuffleTiles is simple but without the aid of std::iota and std::random_shuffle it would be more complicated; first shuffledTiles is created to have the same number of tiles as the map, then std::iota is used to fill shuffledTiles from start to finish with increasing values (starting at 0) before std::random_shuffle is used to randomly move the values about. Finally tileChanged first updates all of the roads to face the correct way, before creating regions where roads and zones are connected. Excellent!

There are but two functions left to examine (other than update), save and load. Unlike Map, which is stored as binary, the City will be saved as a text file with syntax like

void City::load(std::string cityName, std::map<std::string, Tile>& tileAtlas)
{
    int width = 0;
    int height = 0;

    std::ifstream inputFile(cityName + "_cfg.dat", std::ios::in);

    std::string line;

    while(std::getline(inputFile, line))
    {
        std::istringstream lineStream(line);
        std::string key;
        if(std::getline(lineStream, key, '='))
        {
            std::string value;
            if(std::getline(lineStream, value))
            {
                if(key == "width")                  width                   = std::stoi(value);
                else if(key == "height")            height                  = std::stoi(value);
                else if(key == "day")               this->day               = std::stoi(value);
                else if(key == "populationPool")    this->populationPool    = std::stod(value);
                else if(key == "employmentPool")    this->employmentPool    = std::stod(value);
                else if(key == "population")        this->population        = std::stod(value);
                else if(key == "employable")        this->employable        = std::stod(value);
                else if(key == "birthRate")         this->birthRate         = std::stod(value);
                else if(key == "deathRate")         this->deathRate         = std::stod(value);
                else if(key == "residentialTax")    this->residentialTax    = std::stod(value);
                else if(key == "commercialTax")     this->commercialTax     = std::stod(value);
                else if(key == "industrialTax")     this->industrialTax     = std::stod(value);
                else if(key == "funds")             this->funds             = std::stod(value);
                else if(key == "earnings")          this->earnings          = std::stod(value);
            }
            else
            {
                std::cerr << "Error, no value for key " << key << std::endl;
            }
        }
    }

    inputFile.close();

    this->map.load(cityName + "_map.dat", width, height, tileAtlas);
    tileChanged();

    return;
}

void City::save(std::string cityName)
{
    std::ofstream outputFile(cityName + "_cfg.dat", std::ios::out);

    outputFile << "width="              << this->map.width          << std::endl;
    outputFile << "height="             << this->map.height         << std::endl;
    outputFile << "day="                << this->day                << std::endl;
    outputFile << "populationPool="     << this->populationPool     << std::endl;
    outputFile << "employmentPool="     << this->employmentPool     << std::endl;
    outputFile << "population="         << this->population         << std::endl;
    outputFile << "employable="         << this->employable         << std::endl;
    outputFile << "birthRate="          << this->birthRate          << std::endl;
    outputFile << "deathRate="          << this->deathRate          << std::endl;
    outputFile << "residentialTax="     << this->residentialTax     << std::endl;
    outputFile << "commercialTax="      << this->commercialTax      << std::endl;
    outputFile << "industrialTax="      << this->industrialTax      << std::endl;
    outputFile << "funds="              << this->funds              << std::endl;
    outputFile << "earnings="           << this->earnings           << std::endl;

    outputFile.close();

    this->map.save(cityName + "_map.dat");

    return;
}

In load, we first open an input file stream like we did with Map::load, but this time we don't mark it as a binary file. We then iterate over every line in the file, and create an std::istringstream from the line. This allows us to easily extract data from it. (std::istringstream is like std::ifstream, but for strings and not files.) The file will look like:

width=64
height=64
population=101234

We need to split each line up into two parts; one before the '=', and one after. To do this we use the std::getline function again but we pass an extra argument called a delimiter. A delimiter is the character that marks the end of a line, and by default that is just the newline character '\n'. If we set it to '=' however then std::getline will put the first section into the key variable. By calling std::getline once more (with the default delimiter again) we store the second section in value.

All that's left is to check key against the possible values and convert the value (which is currently an std::string) into the correct type using std::stod (string to double) and std::stoi (string to int). Once every line has been read we close the file and then load the map. See the "+_map.dat" and "+_cfg.dat"? load should take the name of the City we want to load, say london, and will load the files london_map.dat and london_cfg.dat. save is far simpler and just outputs the correct key and value before saving the map.

In the next tutorial we'll examine the most complicated function in City, the update function.

Source code for this section

0 comments


Or enter your name and Email
No comments have been posted yet.