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 Tile
s in the map
. The indices are stored in a random order and instead of iterating through the Tile
s in map
we iterate over shuffledTiles
and use the indices to choose the Tile
s 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 Tile
s’ 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 Tile
s, 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 populationPoo
l 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.
Author: Daniel Mansfield