Creating a City Building Game with SFML Part 10: Putting it All Together

Get started with this tutorial series here!

Now we’ll look at the (rather long) update function. We’ll split it into several parts.

  1. void City::update(float dt)
  2. {
  3.     double popTotal = 0;
  4.     double commercialRevenue = 0;
  5.     double industrialRevenue = 0;
  6.  
  7.     /* Update the game time */
  8.     this->currentTime += dt;
  9.     if(this->currentTime < this->timePerDay) return;
  10.     ++day;
  11.     this->currentTime = 0.0;
  12.     if(day % 30 == 0)
  13.     {
  14.         this->funds += this->earnings;
  15.         this->earnings = 0;
  16.     }
  17.     /* Run first pass of tile updates. Mostly handles pool distribution. */
  18.     for(int i = 0; i < this->map.tiles.size(); ++i)
  19.     {
  20.         Tile& tile = this->map.tiles[this->shuffledTiles[i]];
  21.  
  22.         if(tile.tileType == TileType::RESIDENTIAL)
  23.         {
  24.             /* Redistribute the pool and increase the population total by the tile's population */
  25.             this->distributePool(this->populationPool, tile, this->birthRate - this->deathRate);
  26.  
  27.             popTotal += tile.population;
  28.         }
  29.         else if(tile.tileType == TileType::COMMERCIAL)
  30.         {
  31.             /* Hire people. */
  32.             if(rand() % 100 < 15 * (1.0-this->commercialTax))
  33.                 this->distributePool(this->employmentPool, tile, 0.00);
  34.         }
  35.         else if(tile.tileType == TileType::INDUSTRIAL)
  36.         {
  37.             /* Extract resources from the ground. */
  38.             if(this->map.resources[i] > 0 && rand() % 100 < this->population)
  39.             {
  40.                 ++tile.production;
  41.                 --this->map.resources[i];
  42.             }
  43.             /* Hire people. */
  44.             if(rand() % 100 < 15 * (1.0-this->industrialTax))
  45.                 this->distributePool(this->employmentPool, tile, 0.0);
  46.         }
  47.  
  48.         tile.update();
  49.     }

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).

  1.     /* Run second pass. Mostly handles goods manufacture */
  2.     for(int i = 0; i < this->map.tiles.size(); ++i)
  3.     {
  4.         Tile& tile = this->map.tiles[this->shuffledTiles[i]];
  5.  
  6.         if(tile.tileType == TileType::INDUSTRIAL)
  7.         {
  8.             int receivedResources = 0;
  9.             /* Receive resources from smaller and connected zones */
  10.             for(auto& tile2 : this->map.tiles)
  11.             {
  12.                 if(tile2.regions[0] == tile.regions[0] && tile2.tileType == TileType::INDUSTRIAL)
  13.                 {
  14.                     if(tile2.production > 0)
  15.                     {
  16.                         ++receivedResources;
  17.                         --tile2.production;
  18.                     }
  19.                     if(receivedResources >= tile.tileVariant+1) break;
  20.                 }
  21.             }
  22.             /* Turn resources into goods */
  23.             tile.storedGoods += (receivedResources+tile.production)*(tile.tileVariant+1);
  24.         }
  25.     }

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 resources 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.

  1.     /* Run third pass. Mostly handles goods distribution. */
  2.     for(int i = 0; i < this->map.tiles.size(); ++i)
  3.     {
  4.         Tile& tile = this->map.tiles[this->shuffledTiles[i]];
  5.  
  6.         if(tile.tileType == TileType::COMMERCIAL)
  7.         {
  8.             int receivedGoods = 0;
  9.             double maxCustomers = 0.0;
  10.             for(auto& tile2 : this->map.tiles)
  11.             {
  12.                 if(tile2.regions[0] == tile.regions[0] &&
  13.                     tile2.tileType == TileType::INDUSTRIAL &&
  14.                     tile2.storedGoods > 0)
  15.                 {
  16.                     while(tile2.storedGoods > 0 && receivedGoods != tile.tileVariant+1)
  17.                     {
  18.                         --tile2.storedGoods;
  19.                         ++receivedGoods;
  20.                         industrialRevenue += 100 * (1.0-industrialTax);
  21.                     }
  22.                 }
  23.                 else if(tile2.regions[0] == tile.regions[0] &&
  24.                     tile2.tileType == TileType::RESIDENTIAL)
  25.                 {
  26.                     maxCustomers += tile2.population;
  27.                 }
  28.                 if(receivedGoods == tile.tileVariant+1) break;
  29.             }
  30.             /* Calculate the overall revenue for the tile. */
  31.             tile.production = (receivedGoods*100.0 + rand() % 20) * (1.0-this->commercialTax);
  32.  
  33.             double revenue = tile.production * maxCustomers * tile.population / 100.0;
  34.             commercialRevenue += revenue;
  35.         }
  36.     }

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.

  1.     /* Adjust population pool for births and deaths. */
  2.     this->populationPool = this->adjustPopulation(this->populationPool, this->birthRate - this->deathRate);
  3.     popTotal += this->populationPool;
  4.  
  5.     /* Adjust the employment pool for the changing population. */
  6.     float newWorkers = (popTotal - this->population) * this->propCanWork;
  7.     newWorkers *= newWorkers < 0 ? -1 : 1;
  8.     this->employmentPool += newWorkers;
  9.     this->employable += newWorkers;
  10.     if(this->employmentPool < 0) this->employmentPool = 0;
  11.     if(this->employable < 0) this->employable = 0;
  12.  
  13.     /* Update the city population. */
  14.     this->population = popTotal;
  15.  
  16.     /* Calculate city income from tax. */
  17.     this->earnings = (this->population - this->populationPool) * 15 * this->residentialTax;
  18.     this->earnings += commercialRevenue * this->commercialTax;
  19.     this->earnings += industrialRevenue * this->industrialTax;
  20.  
  21.     return;
  22. }

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 Citycity instead. So now instead of this->map.tileSize calls (for example) we will have this->city.map.tileSize calls. (Beware the mapPixelToCoordsfunction 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,

  1.     this->city = City("city", this->game->tileSize, this->game->tileAtlas);
  2.     this->city.shuffleTiles();

And we’ll also need to add the Gui system. This one will be considerably longer than in GameStateStart!

  1. /* Create gui elements. */
  2. this->guiSystem.emplace("rightClickMenu", Gui(sf::Vector2f(196, 16), 2, false, this->game->stylesheets.at("button"),
  3.     {
  4.         std::make_pair("Flatten $"          + this->game->tileAtlas["grass"].getCost(),         "grass"),
  5.         std::make_pair("Forest $"           + this->game->tileAtlas["forest"].getCost(),        "forest" ),
  6.         std::make_pair("Residential Zone $" + this->game->tileAtlas["residential"].getCost(),   "residential"),
  7.         std::make_pair("Commercial Zone $"  + this->game->tileAtlas["commercial"].getCost(),    "commercial"),
  8.         std::make_pair("Industrial Zone $"  + this->game->tileAtlas["industrial"].getCost(),    "industrial"),
  9.         std::make_pair("Road $"             + this->game->tileAtlas["road"].getCost(),          "road")
  10.     }));
  11.  
  12. this->guiSystem.emplace("selectionCostText", Gui(sf::Vector2f(196, 16), 0, false, this->game->stylesheets.at("text"),
  13.     { std::make_pair("", "") }));
  14.  
  15. this->guiSystem.emplace("infoBar", Gui(sf::Vector2f(this->game->window.getSize().x / 5 , 16), 2, true, this->game->stylesheets.at("button"),
  16.     {
  17.         std::make_pair("time",          "time"),
  18.         std::make_pair("funds",         "funds"),
  19.         std::make_pair("population",    "population"),
  20.         std::make_pair("employment",    "employment"),
  21.         std::make_pair("current tile",  "tile")
  22.     }));
  23. this->guiSystem.at("infoBar").setPosition(sf::Vector2f(0, this->game->window.getSize().y - 16));
  24. 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

  1. void GameStateEditor::update(const float dt)
  2. {
  3.     this->city.update(dt);
  4.  
  5.     /* Update the info bar at the bottom of the screen */
  6.     this->guiSystem.at("infoBar").setEntryText(0, "Day: " + std::to_string(this->city.day));
  7.     this->guiSystem.at("infoBar").setEntryText(1, "$" + std::to_string(long(this->city.funds)));
  8.     this->guiSystem.at("infoBar").setEntryText(2, std::to_string(long(this->city.population)) + " (" + std::to_string(long(this->city.getHomeless())) + ")");
  9.     this->guiSystem.at("infoBar").setEntryText(3, std::to_string(long(this->city.employable)) + " (" + std::to_string(long(this->city.getUnemployed())) + ")");
  10.     this->guiSystem.at("infoBar").setEntryText(4, tileTypeToStr(currentTile->tileType));
  11.  
  12.     /* Highlight entries of the right click context menu */
  13.     this->guiSystem.at("rightClickMenu").highlight(this->guiSystem.at("rightClickMenu").getEntry(this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->guiView)));
  14.  
  15.     return;
  16. }

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.

  1. void GameStateEditor::draw(const float dt)
  2. {
  3.     this->game->window.clear(sf::Color::Black);
  4.  
  5.     this->game->window.setView(this->guiView);
  6.     this->game->window.draw(this->game->background);
  7.  
  8.     this->game->window.setView(this->gameView);
  9.     this->city.map.draw(this->game->window, dt);
  10.  
  11.     this->game->window.setView(this->guiView);
  12.     for(auto gui : this->guiSystem) this->game->window.draw(gui.second);
  13.  
  14.     return;
  15. }

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.

  1. void GameStateEditor::handleInput()
  2. {
  3.     sf::Event event;
  4.  
  5.     sf::Vector2f guiPos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->guiView);
  6.     sf::Vector2f gamePos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->gameView);
  7.  
  8.     while(this->game->window.pollEvent(event))

Inside of the MouseMoved event we’ll add the code to display the "selectionCostText" Gui.

  1.     else
  2.     {
  3.         this->city.map.select(selectionStart, selectionEnd,
  4.             {
  5.                 this->currentTile->tileType,    TileType::FOREST,
  6.                 TileType::WATER,                TileType::ROAD,
  7.                 TileType::RESIDENTIAL,          TileType::COMMERCIAL,
  8.                 TileType::INDUSTRIAL
  9.             });
  10.     }
  11.  
  12.     this->guiSystem.at("selectionCostText").setEntryText(0, "$" + std::to_string(this->currentTile->cost * this->city.map.numSelected));
  13.     if(this->city.funds <= this->city.map.numSelected * this->currentTile->cost)
  14.         this->guiSystem.at("selectionCostText").highlight(0);
  15.     else
  16.         this->guiSystem.at("selectionCostText").highlight(-1);
  17.     this->guiSystem.at("selectionCostText").setPosition(guiPos + sf::Vector2f(16, -16));
  18.     this->guiSystem.at("selectionCostText").show();
  19. }
  20. /* Highlight entries of the right click context menu */
  21. this->guiSystem.at("rightClickMenu").highlight(this->guiSystem.at("rightClickMenu").getEntry(guiPos));
  22. 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.

  1. case sf::Event::MouseButtonPressed:
  2. {
  3.     /* Start panning */
  4.     if(event.mouseButton.button == sf::Mouse::Middle)
  5.     {
  6.         this->guiSystem.at("rightClickMenu").hide();
  7.         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.

  1. else if(event.mouseButton.button == sf::Mouse::Left)
  2. {
  3.     /* Select a context menu entry. */
  4.     if(this->guiSystem.at("rightClickMenu").visible == true)
  5.     {
  6.         std::string msg = this->guiSystem.at("rightClickMenu").activate(guiPos);
  7.         if(msg != "null") this->currentTile = &this->game->tileAtlas.at(msg);
  8.  
  9.         this->guiSystem.at("rightClickMenu").hide();
  10.     }
  11.     /* Select map tile. */
  12.     else
  13.     {
  14.         /* Select map tile. */
  15.         if(this->actionState != ActionState::SELECTING)
  16.         {
  17.             this->actionState = ActionState::SELECTING;
  18.             selectionStart.x = gamePos.y / (this->city.map.tileSize) + gamePos.x / (2*this->city.map.tileSize) - this->city.map.width * 0.5 - 0.5;
  19.             selectionStart.y = gamePos.y / (this->city.map.tileSize) - gamePos.x / (2*this->city.map.tileSize) + this->city.map.width * 0.5 + 0.5;
  20.         }
  21.     }
  22. }

When we created the "rightClickMenu" we set the messages 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.

  1. else if(event.mouseButton.button == sf::Mouse::Right)
  2. {
  3.     /* Stop selecting. */
  4.     if(this->actionState == ActionState::SELECTING)
  5.     {
  6.         this->actionState = ActionState::NONE;
  7.         this->guiSystem.at("selectionCostText").hide();
  8.         this->city.map.clearSelected();
  9.     }
  10.     else
  11.     {
  12.         /* Open the tile select menu. */
  13.         sf::Vector2f pos = guiPos;
  14.  
  15.         if(pos.x > this->game->window.getSize().x - this->guiSystem.at("rightClickMenu").getSize().x)
  16.         {
  17.             pos -= sf::Vector2f(this->guiSystem.at("rightClickMenu").getSize().x, 0);
  18.         }
  19.         if(pos.y > this->game->window.getSize().y - this->guiSystem.at("rightClickMenu").getSize().y)
  20.         {
  21.             pos -= sf::Vector2f(0, this->guiSystem.at("rightClickMenu").getSize().y);
  22.         }
  23.         this->guiSystem.at("rightClickMenu").setPosition(pos);
  24.         this->guiSystem.at("rightClickMenu").show();
  25.     }
  26. }
  27. 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 showing it.

Next, we’ll add the code to change the selected tiles. When the left mouse button is released.

  1. /* Stop selecting. */
  2. else if(event.mouseButton.button == sf::Mouse::Left)
  3. {
  4.     if(this->actionState == ActionState::SELECTING)
  5.     {
  6.         /* Replace tiles if enough funds and a tile is selected */
  7.         if(this->currentTile != nullptr)
  8.         {
  9.             unsigned int cost = this->currentTile->cost * this->city.map.numSelected;
  10.             if(this->city.funds >= cost)
  11.             {
  12.                 this->city.bulldoze(*this->currentTile);
  13.                 this->city.funds -= this->currentTile->cost * this->city.map.numSelected;
  14.                 this->city.tileChanged();
  15.             }
  16.         }
  17.         this->guiSystem.at("selectionCostText").hide();
  18.         this->actionState = ActionState::NONE;
  19.         this->city.map.clearSelected();
  20.     }
  21. }

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.

  1. /* Resize the window. */
  2. case sf::Event::Resized:
  3. {
  4.     gameView.setSize(event.size.width, event.size.height);
  5.     gameView.zoom(zoomLevel);
  6.     guiView.setSize(event.size.width, event.size.height);
  7.     this->guiSystem.at("infoBar").setDimensions(sf::Vector2f(event.size.width / this->guiSystem.at("infoBar").entries.size(), 16));
  8.     this->guiSystem.at("infoBar").setPosition(this->game->window.mapPixelToCoords(sf::Vector2i(0, event.size.height - 16), this->guiView));
  9.     this->guiSystem.at("infoBar").show();
  10.     this->game->background.setPosition(this->game->window.mapPixelToCoords(sf::Vector2i(0, 0), this->guiView));
  11.     this->game->background.setScale(
  12.         float(event.size.width) / float(this->game->background.getTexture()->getSize().x),
  13.         float(event.size.height) / float(this->game->background.getTexture()->getSize().y));
  14.     break;
  15. }

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!

Source code for this section

Author: Daniel Mansfield