Get started with this tutorial series here!
Now we’ll look at the (rather long) update
function. We’ll split it into several parts.
void City::update(float dt)
{
double popTotal = 0;
double commercialRevenue = 0;
double industrialRevenue = 0;
/* Update the game time */
this->currentTime += dt;
if(this->currentTime < this->timePerDay) return;
++day;
this->currentTime = 0.0;
if(day % 30 == 0)
{
this->funds += this->earnings;
this->earnings = 0;
}
/* Run first pass of tile updates. Mostly handles pool distribution. */
for(int i = 0; i < this->map.tiles.size(); ++i)
{
Tile& tile = this->map.tiles[this->shuffledTiles[i]];
if(tile.tileType == TileType::RESIDENTIAL)
{
/* Redistribute the pool and increase the population total by the tile's population */
this->distributePool(this->populationPool, tile, this->birthRate - this->deathRate);
popTotal += tile.population;
}
else if(tile.tileType == TileType::COMMERCIAL)
{
/* Hire people. */
if(rand() % 100 < 15 * (1.0-this->commercialTax))
this->distributePool(this->employmentPool, tile, 0.00);
}
else if(tile.tileType == TileType::INDUSTRIAL)
{
/* Extract resources from the ground. */
if(this->map.resources[i] > 0 && rand() % 100 < this->population)
{
++tile.production;
--this->map.resources[i];
}
/* Hire people. */
if(rand() % 100 < 15 * (1.0-this->industrialTax))
this->distributePool(this->employmentPool, tile, 0.0);
}
tile.update();
}
Initially we move to the next day if enough time has passed, much like how AnimationHandler::update
works. If a month has passed, then the earnings
are added to funds
and the earnings
reset to 0. The bulk of the update
function is a series of loops that iterate over every tile
in the map
(using shuffledTiles
of course). These loops must be separate as the order in which various things happen is important.
First, people attempt to move from the populationPool
into the residential zones, and the population of each zone is adjusted according to the net birth rate. The commercial zones then attempt to hire people, where they will hire more people the lower the commercialTax
is. Finally the industrial zones will attempt to hire people too, but will also extract resources from the ground if any are left. (Remember the resources
variable in Map
?) We then call update
on the tile
to change its tileVariant
if necessary (i.e. the population is full).
/* Run second pass. Mostly handles goods manufacture */
for(int i = 0; i < this->map.tiles.size(); ++i)
{
Tile& tile = this->map.tiles[this->shuffledTiles[i]];
if(tile.tileType == TileType::INDUSTRIAL)
{
int receivedResources = 0;
/* Receive resources from smaller and connected zones */
for(auto& tile2 : this->map.tiles)
{
if(tile2.regions[0] == tile.regions[0] && tile2.tileType == TileType::INDUSTRIAL)
{
if(tile2.production > 0)
{
++receivedResources;
--tile2.production;
}
if(receivedResources >= tile.tileVariant+1) break;
}
}
/* Turn resources into goods */
tile.storedGoods += (receivedResources+tile.production)*(tile.tileVariant+1);
}
}
In the second pass, the industrial zones attempt to take any resources from smaller zones that they are connected to via roads or zones (those in the same region) before turning those resource
s into storedGoods
. Each zone can only receive one resource from every other tile, and can only receive one more than its tileVariant
in total resources. The larger the tileVariant
(and so the larger and more advanced the industrial zone) the more goods it can produce per resource and the more resources it can receive.
/* Run third pass. Mostly handles goods distribution. */
for(int i = 0; i < this->map.tiles.size(); ++i)
{
Tile& tile = this->map.tiles[this->shuffledTiles[i]];
if(tile.tileType == TileType::COMMERCIAL)
{
int receivedGoods = 0;
double maxCustomers = 0.0;
for(auto& tile2 : this->map.tiles)
{
if(tile2.regions[0] == tile.regions[0] &&
tile2.tileType == TileType::INDUSTRIAL &&
tile2.storedGoods > 0)
{
while(tile2.storedGoods > 0 && receivedGoods != tile.tileVariant+1)
{
--tile2.storedGoods;
++receivedGoods;
industrialRevenue += 100 * (1.0-industrialTax);
}
}
else if(tile2.regions[0] == tile.regions[0] &&
tile2.tileType == TileType::RESIDENTIAL)
{
maxCustomers += tile2.population;
}
if(receivedGoods == tile.tileVariant+1) break;
}
/* Calculate the overall revenue for the tile. */
tile.production = (receivedGoods*100.0 + rand() % 20) * (1.0-this->commercialTax);
double revenue = tile.production * maxCustomers * tile.population / 100.0;
commercialRevenue += revenue;
}
}
In the third and final pass, the goods produced by the industrial zones are distributed amongst the commercial zones which then sell the goods. First, any goods in connected industrial zones are moved into the commercial zone producing taxable income for the industrial zones. Any connected residential regions increase the maximum number of customers the commercial zone can receive. We then calculate the revenue the commercial zones generate from selling the goods.
/* Adjust population pool for births and deaths. */
this->populationPool = this->adjustPopulation(this->populationPool, this->birthRate - this->deathRate);
popTotal += this->populationPool;
/* Adjust the employment pool for the changing population. */
float newWorkers = (popTotal - this->population) * this->propCanWork;
newWorkers *= newWorkers < 0 ? -1 : 1;
this->employmentPool += newWorkers;
this->employable += newWorkers;
if(this->employmentPool < 0) this->employmentPool = 0;
if(this->employable < 0) this->employable = 0;
/* Update the city population. */
this->population = popTotal;
/* Calculate city income from tax. */
this->earnings = (this->population - this->populationPool) * 15 * this->residentialTax;
this->earnings += commercialRevenue * this->commercialTax;
this->earnings += industrialRevenue * this->industrialTax;
return;
}
In the last part of update
we adjust the populationPool
based on the net birth rate, and we add new people to the employmentPool
if the total population is different from the population on the previous day. This simulates citizens entering the job market, although of course if the population decreases then the number of employable people will drop too. Finally, we tax all the income (as well as the residential zones) and increase earnings
by the total amount.
And with that the final class is completed! Now all that’s left is to incorporate this into GameStateEditor
and put a Gui
in that class too. Firstly, we’ll replace the map
variable in GameStateEditor
with a City
, city
instead. So now instead of this->map.tileSize
calls (for example) we will have this->city.map.tileSize
calls. (Beware the mapPixelToCoords
function if you find and replace map
with city.map
!) We’ll also need to add a guiSystem
variable like we did in GameStateStart
. Oh, and remember to include <map>
, <string
, and "gui.hpp"
!
Inside the constructor we should of course replace map.load
with the City
equivalent,
this->city = City("city", this->game->tileSize, this->game->tileAtlas);
this->city.shuffleTiles();
And we’ll also need to add the Gui
system. This one will be considerably longer than in GameStateStart
!
/* Create gui elements. */
this->guiSystem.emplace("rightClickMenu", Gui(sf::Vector2f(196, 16), 2, false, this->game->stylesheets.at("button"),
{
std::make_pair("Flatten $" + this->game->tileAtlas["grass"].getCost(), "grass"),
std::make_pair("Forest $" + this->game->tileAtlas["forest"].getCost(), "forest" ),
std::make_pair("Residential Zone $" + this->game->tileAtlas["residential"].getCost(), "residential"),
std::make_pair("Commercial Zone $" + this->game->tileAtlas["commercial"].getCost(), "commercial"),
std::make_pair("Industrial Zone $" + this->game->tileAtlas["industrial"].getCost(), "industrial"),
std::make_pair("Road $" + this->game->tileAtlas["road"].getCost(), "road")
}));
this->guiSystem.emplace("selectionCostText", Gui(sf::Vector2f(196, 16), 0, false, this->game->stylesheets.at("text"),
{ std::make_pair("", "") }));
this->guiSystem.emplace("infoBar", Gui(sf::Vector2f(this->game->window.getSize().x / 5 , 16), 2, true, this->game->stylesheets.at("button"),
{
std::make_pair("time", "time"),
std::make_pair("funds", "funds"),
std::make_pair("population", "population"),
std::make_pair("employment", "employment"),
std::make_pair("current tile", "tile")
}));
this->guiSystem.at("infoBar").setPosition(sf::Vector2f(0, this->game->window.getSize().y - 16));
this->guiSystem.at("infoBar").show();
The "rightClickMenu"
will (as its name implies) be shown when the player presses the right mouse button. It will list all of the possible tiles that they can place along with their prices, and when a tile is chosen currentTile
will be set to that tile. Any time the player selects tiles, from then on, will cause the selected tiles to be replaced with currentTile
. "selectionCostText"
will be displayed when the player is selecting tiles, and will tell them how much the tiles they are placing will cost. It will go red if the player does not have enough funds to place the tiles.
Lastly, "infoBar"
will sit and span the bottom of the screen, displaying the game day and other useful information for the player. If we want it to display information we’ll have to update that information all the time, so we can put that code inside of update
void GameStateEditor::update(const float dt)
{
this->city.update(dt);
/* Update the info bar at the bottom of the screen */
this->guiSystem.at("infoBar").setEntryText(0, "Day: " + std::to_string(this->city.day));
this->guiSystem.at("infoBar").setEntryText(1, "$" + std::to_string(long(this->city.funds)));
this->guiSystem.at("infoBar").setEntryText(2, std::to_string(long(this->city.population)) + " (" + std::to_string(long(this->city.getHomeless())) + ")");
this->guiSystem.at("infoBar").setEntryText(3, std::to_string(long(this->city.employable)) + " (" + std::to_string(long(this->city.getUnemployed())) + ")");
this->guiSystem.at("infoBar").setEntryText(4, tileTypeToStr(currentTile->tileType));
/* Highlight entries of the right click context menu */
this->guiSystem.at("rightClickMenu").highlight(this->guiSystem.at("rightClickMenu").getEntry(this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->guiView)));
return;
}
The first entry of the "infobar"
will be the game day, the second the city
‘s funds, the third the population (with the number of homeless in parentheses), the fourth the number of employable people (with the number left unemployed in parentheses), and the fifth the name of the currentTile
. We have to first typecast to a long
so that we don’t get floating point populations appearing (we used a double
for ease of calculation but we truncate it here to print the ‘real’ value). With update
done let’s make sure that the Gui
is actually drawn to the screen.
void GameStateEditor::draw(const float dt)
{
this->game->window.clear(sf::Color::Black);
this->game->window.setView(this->guiView);
this->game->window.draw(this->game->background);
this->game->window.setView(this->gameView);
this->city.map.draw(this->game->window, dt);
this->game->window.setView(this->guiView);
for(auto gui : this->guiSystem) this->game->window.draw(gui.second);
return;
}
First, we switch to the guiView
and then we draw each Gui
in turn like in GameStateStart
. The Gui
is of course drawn after the city
, otherwise the player wouldn’t be able to see it! Finally we can add the Gui
code to handleInput
. Before we get to the switch
statement it would be sensible to save ourselves some writing and create gamePos
and guiPos
variables that record the position of the mouse in gameView
and guiView
coordinates.
void GameStateEditor::handleInput()
{
sf::Event event;
sf::Vector2f guiPos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->guiView);
sf::Vector2f gamePos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->gameView);
while(this->game->window.pollEvent(event))
Inside of the MouseMoved
event we’ll add the code to display the "selectionCostText"
Gui
.
else
{
this->city.map.select(selectionStart, selectionEnd,
{
this->currentTile->tileType, TileType::FOREST,
TileType::WATER, TileType::ROAD,
TileType::RESIDENTIAL, TileType::COMMERCIAL,
TileType::INDUSTRIAL
});
}
this->guiSystem.at("selectionCostText").setEntryText(0, "$" + std::to_string(this->currentTile->cost * this->city.map.numSelected));
if(this->city.funds <= this->city.map.numSelected * this->currentTile->cost)
this->guiSystem.at("selectionCostText").highlight(0);
else
this->guiSystem.at("selectionCostText").highlight(-1);
this->guiSystem.at("selectionCostText").setPosition(guiPos + sf::Vector2f(16, -16));
this->guiSystem.at("selectionCostText").show();
}
/* Highlight entries of the right click context menu */
this->guiSystem.at("rightClickMenu").highlight(this->guiSystem.at("rightClickMenu").getEntry(guiPos));
break;
}
The total cost is of course the cost per tile multiplied by the number of selected tiles. If the city does not has enough funds
we highlight the text, making it red. We then position the text to the bottom right of the cursors and show
it. We also highlight
the entry of the "rightClickMenu"
that the player is hovering over. For the MouseButtonPressed
event, we hide the Gui
when the middle mouse button is pressed.
case sf::Event::MouseButtonPressed:
{
/* Start panning */
if(event.mouseButton.button == sf::Mouse::Middle)
{
this->guiSystem.at("rightClickMenu").hide();
this->guiSystem.at("selectionCostText").hide();
When the left mouse button is pressed we select a tile from the "rightClickMenu"
if it is visible, or we start selecting tiles if it isn’t.
else if(event.mouseButton.button == sf::Mouse::Left)
{
/* Select a context menu entry. */
if(this->guiSystem.at("rightClickMenu").visible == true)
{
std::string msg = this->guiSystem.at("rightClickMenu").activate(guiPos);
if(msg != "null") this->currentTile = &this->game->tileAtlas.at(msg);
this->guiSystem.at("rightClickMenu").hide();
}
/* Select map tile. */
else
{
/* Select map tile. */
if(this->actionState != ActionState::SELECTING)
{
this->actionState = ActionState::SELECTING;
selectionStart.x = gamePos.y / (this->city.map.tileSize) + gamePos.x / (2*this->city.map.tileSize) - this->city.map.width * 0.5 - 0.5;
selectionStart.y = gamePos.y / (this->city.map.tileSize) - gamePos.x / (2*this->city.map.tileSize) + this->city.map.width * 0.5 + 0.5;
}
}
}
When we created the "rightClickMenu"
we set the message
s to be equal to the name of the tile in the tileAtlas
, and so we can easily set currentTile
to the one clicked. When an entry is selected we hide
the menu. Finally, in the MouseButtonPressed
event we handle what happens when the right mouse button is pressed.
else if(event.mouseButton.button == sf::Mouse::Right)
{
/* Stop selecting. */
if(this->actionState == ActionState::SELECTING)
{
this->actionState = ActionState::NONE;
this->guiSystem.at("selectionCostText").hide();
this->city.map.clearSelected();
}
else
{
/* Open the tile select menu. */
sf::Vector2f pos = guiPos;
if(pos.x > this->game->window.getSize().x - this->guiSystem.at("rightClickMenu").getSize().x)
{
pos -= sf::Vector2f(this->guiSystem.at("rightClickMenu").getSize().x, 0);
}
if(pos.y > this->game->window.getSize().y - this->guiSystem.at("rightClickMenu").getSize().y)
{
pos -= sf::Vector2f(0, this->guiSystem.at("rightClickMenu").getSize().y);
}
this->guiSystem.at("rightClickMenu").setPosition(pos);
this->guiSystem.at("rightClickMenu").show();
}
}
break;
As before, we stop selecting if the right mouse button is pressed, but we also make sure that we hide the "selectionCostText"
. If the player isn’t selecting then we open the "rightClickMenu"
. The if
statements make sure that the Gui
is not opened in a position so as to go off the edge of the screen; it will always open with a corner on the mouse cursor, but which corner depends on where the mouse is when the button is pressed. We then set the position of the Gui
before show
ing it.
Next, we’ll add the code to change the selected tiles. When the left mouse button is released.
/* Stop selecting. */
else if(event.mouseButton.button == sf::Mouse::Left)
{
if(this->actionState == ActionState::SELECTING)
{
/* Replace tiles if enough funds and a tile is selected */
if(this->currentTile != nullptr)
{
unsigned int cost = this->currentTile->cost * this->city.map.numSelected;
if(this->city.funds >= cost)
{
this->city.bulldoze(*this->currentTile);
this->city.funds -= this->currentTile->cost * this->city.map.numSelected;
this->city.tileChanged();
}
}
this->guiSystem.at("selectionCostText").hide();
this->actionState = ActionState::NONE;
this->city.map.clearSelected();
}
}
Now, as well as clearing the selection, we hide the "selectionCostText"
and replace the selected tiles if the city
has enough funds
to do so. We also call tileChanged
to update the regions and roads.
Lastly, we need to readjust the dimensions and position of the "infobar"
when the screen is resized.
/* Resize the window. */
case sf::Event::Resized:
{
gameView.setSize(event.size.width, event.size.height);
gameView.zoom(zoomLevel);
guiView.setSize(event.size.width, event.size.height);
this->guiSystem.at("infoBar").setDimensions(sf::Vector2f(event.size.width / this->guiSystem.at("infoBar").entries.size(), 16));
this->guiSystem.at("infoBar").setPosition(this->game->window.mapPixelToCoords(sf::Vector2i(0, event.size.height - 16), this->guiView));
this->guiSystem.at("infoBar").show();
this->game->background.setPosition(this->game->window.mapPixelToCoords(sf::Vector2i(0, 0), this->guiView));
this->game->background.setScale(
float(event.size.width) / float(this->game->background.getTexture()->getSize().x),
float(event.size.height) / float(this->game->background.getTexture()->getSize().y));
break;
}
This is just the same code as we had in the constructor, but placed in the resize
function instead.
Phew, that was a lot of code! Try compiling and playing the game, hopefully it does everything it’s supposed to… I hope you enjoyed this adventure into SFML, there’s still a lot you can do with this game! Perhaps add some new power station, pylon tiles and create an electricity system (add another region to help distribute the electricity) or make the zones demand water. You could also add some nice background music (use the sf::Music
class) or add the option to create a new game instead of just continuing from an existing one. Above all, enjoy yourself whilst you do it, and happy programming!
Author: Daniel Mansfield