Creating a City Building Game with SFML Part 6: Exploring the World

Get started with this tutorial series here!

The next function, updateDirection, is very simple although it is rather long. This is perhaps the only time where I’d recommend a copy-paste instead of writing the code for yourself!

  1. void Map::updateDirection(TileType tileType)
  2. {
  3.     for(int y = 0; y < this->height; ++y)
  4.     {
  5.         for(int x = 0; x < this->width; ++x)
  6.         {
  7.             int pos = y*this->width+x;
  9.             if(this->tiles[pos].tileType != tileType) continue;
  11.             bool adjacentTiles[3][2] = {{0,0,0},{0,0,0},{0,0,0}};
  13.             /* Check for adjacent tiles of the same type */
  14.             if(x > 0 && y > 0)
  15.                 adjacentTiles[0][0] = (this->tiles[(y-1)*this->width+(x-1)].tileType == tileType);
  16.             if(y > 0)
  17.                 adjacentTiles[0][3] = (this->tiles[(y-1)*this->width+(x  )].tileType == tileType);
  18.             if(x < this->width-1 && y > 0)
  19.                 adjacentTiles[0][4] = (this->tiles[(y-1)*this->width+(x+1)].tileType == tileType);
  20.             if(x > 0)
  21.                 adjacentTiles[1][0] = (this->tiles[(y  )*this->width+(x-1)].tileType == tileType);
  22.             if(x < width-1)
  23.                 adjacentTiles[1][5] = (this->tiles[(y  )*this->width+(x+1)].tileType == tileType);
  24.             if(x > 0 && y < this->height-1)
  25.                 adjacentTiles[2][0] = (this->tiles[(y+1)*this->width+(x-1)].tileType == tileType);
  26.             if(y < this->height-1)
  27.                 adjacentTiles[2][6] = (this->tiles[(y+1)*this->width+(x  )].tileType == tileType);
  28.             if(x < this->width-1 && y < this->height-1)
  29.                 adjacentTiles[2][7] = (this->tiles[(y+1)*this->width+(x+1)].tileType == tileType);
  31.             /* Change the tile variant depending on the tile position */
  32.             if(adjacentTiles[1][0] && adjacentTiles[1][8] && adjacentTiles[0][9] && adjacentTiles[2][10])
  33.                 this->tiles[pos].tileVariant = 2;
  34.             else if(adjacentTiles[1][0] && adjacentTiles[1][11] && adjacentTiles[0][12])
  35.                 this->tiles[pos].tileVariant = 7;
  36.             else if(adjacentTiles[1][0] && adjacentTiles[1][13] && adjacentTiles[2][14])
  37.                 this->tiles[pos].tileVariant = 8;
  38.             else if(adjacentTiles[0][15] && adjacentTiles[2][16] && adjacentTiles[1][0])
  39.                 this->tiles[pos].tileVariant = 9;
  40.             else if(adjacentTiles[0][16] && adjacentTiles[2][17] && adjacentTiles[1][18])
  41.                 this->tiles[pos].tileVariant = 10;
  42.             else if(adjacentTiles[1][0] && adjacentTiles[1][19])
  43.                 this->tiles[pos].tileVariant = 0;
  44.             else if(adjacentTiles[0][20] && adjacentTiles[2][21])
  45.                 this->tiles[pos].tileVariant = 1;
  46.             else if(adjacentTiles[2][22] && adjacentTiles[1][0])
  47.                 this->tiles[pos].tileVariant = 3;
  48.             else if(adjacentTiles[0][23] && adjacentTiles[1][24])
  49.                 this->tiles[pos].tileVariant = 4;
  50.             else if(adjacentTiles[1][0] && adjacentTiles[0][25])
  51.                 this->tiles[pos].tileVariant = 5;
  52.             else if(adjacentTiles[2][26] && adjacentTiles[1][27])
  53.                 this->tiles[pos].tileVariant = 6;
  54.             else if(adjacentTiles[1][0])
  55.                 this->tiles[pos].tileVariant = 0;
  56.             else if(adjacentTiles[1][28])
  57.                 this->tiles[pos].tileVariant = 0;
  58.             else if(adjacentTiles[0][29])  
  59.                 this->tiles[pos].tileVariant = 1;
  60.             else if(adjacentTiles[2][30])
  61.                 this->tiles[pos].tileVariant = 1;
  62.         }
  63.     }
  65.     return;
  66. }

As an overview, updateDirection iterates over every Tile. It then builds an array of all the adjacent tiles, setting each element to true if the Tile is of the same type as the centre Tile, and false otherwise. Finally the adjacentTiles array is checked to see the configuration of its true/false values, and the tileVariant of the Tile is set accordingly. The order of the checks here is important, as we are only checking for true elements and not false; some combinations exist that would override others.

For example, a crossroads would appear as a corner tile if you checked for the corner tile first. You could make this more programmer friendly by defining const values for each direction combination, but it’s just as simple to refer to this image (the highlighted edges are adjacent to a tile of the same type)

We now go from long and boring to short and interesting, with our depthfirstsearch function. If you’ve used such an algorithm before you can skip over this bit, but at least look at the code! If not, it’s time to explain a ‘proper’ algorithm. As we discussed a while back, we want our industrial zones to dig material from the ground and then ship it to commercial zones where it can be sold. They can’t just send the material to any zone though, they would need to be connected using roads (or another zone). To do this we need to check if there is a path that only goes through adjacent zones or roads and that takes us from the industrial zone at the start to some other commercial zone.

We use something called a depth-first search to do this, which starts at a Tile and checks if we can go through it or not. If we can, it branches off to every adjacent Tile, checking again, before branching off, then checking again… it then stops when we find the Tile we can stop at. This is fine, but it isn’t very efficient; we will have to check if every industrial zone is connected to every commercial zone! That’s going to be extremely slow, and what’s more we have to do it every new game day. One way of improving this would be to simply use a more efficient pathfinding algorithm, such as A*. That doesn’t fix our problem though, we’ve still got far too many pairs of zones to check, especially in a large city.

Instead what we do is split the Map into regions (using the regions array from before). Each Tilewill be labelled depending on what region it is in, where two Tiles are in the same region if there is a path (through zones or roads) between them. If we find all of those paths, we can just check which region each Tile is in instead of trying to find a path between them each time. (We don’t care what the path is after all, only that one exists!) What’s more, we only have to update the regions when those paths change; if a Tile is created or destroyed. That’s exactly what depthfirstsearch and findConnectingRegions do, they split the Map into those regions.

  1. void Map::depthfirstsearch(std::vector<TileType>& whitelist,
  2.     sf::Vector2i pos, int label, int regionType=0)
  3. {
  4.     if(pos.x < 0 || pos.x >= this->width) return;
  5.     if(pos.y < 0 || pos.y >= this->height) return;
  6.     if(this->tiles[pos.y*this->width+pos.x].regions[regionType] != 0) return;
  7.     bool found = false;
  8.     for(auto type : whitelist)
  9.     {
  10.         if(type == this->tiles[pos.y*this->width+pos.x].tileType)
  11.         {
  12.             found = true;
  13.             break;
  14.         }
  15.     }
  16.     if(!found) return;
  18.     this->tiles[pos.y*this->width+pos.x].regions[regionType] = label;
  20.     depthfirstsearch(whitelist, pos + sf::Vector2i(-1,  0), label, regionType);
  21.     depthfirstsearch(whitelist, pos + sf::Vector2i(0 ,  1), label, regionType);
  22.     depthfirstsearch(whitelist, pos + sf::Vector2i(1 ,  0), label, regionType);
  23.     depthfirstsearch(whitelist, pos + sf::Vector2i(0 , -1), label, regionType);
  25.     return;
  26. }

Let’s examine how this function works. First we check to see if the supplied position is out of bounds of the Map. If it is, we return. We then check to see if the Tile has already received a region and hence has already been visited by the function. If it has, we return, as we don’t want to go over the same Tile twice. If we did the function would never finish! We then check to see if the Tile‘s tileType is present in whitelist. If it isn’t, once again we return, otherwise we assign the Tile a region and call depthfirstsearch again 4 times, once for each adjacent tile.

Such a function that calls itself is called recursive, and so this is a recursive implementation of the depth-first search algorithm; there is also an iterative version, which uses for loops, but I find this one is much easier to understand! If you’ve been paying attention though, you’ll have noticed that depthfirstsearch is a private function! We will use findConnectingRegions to actually start the search.

  1. void Map::findConnectedRegions(std::vector<TileType> whitelist, int regionType=0)
  2. {
  3.     int regions = 1;
  5.     for(auto& tile : this->tiles) tile.regions[regionType] = 0;
  7.     for(int y = 0; y < this->height; ++y)
  8.     {
  9.         for(int x = 0; x < this->width; ++x)
  10.         {
  11.             bool found = false;
  12.             for(auto type : whitelist)
  13.             {
  14.                 if(type == this->tiles[y*this->width+x].tileType)
  15.                 {
  16.                     found = true;
  17.                     break;
  18.                 }
  19.             }
  20.             if(this->tiles[y*this->width+x].regions[regionType] == 0 && found)
  21.             {
  22.                 depthfirstsearch(whitelist, sf::Vector2i(x, y), regions++, regionType);
  23.             }
  24.         }
  25.     }
  26.     this->numRegions[regionType] = regions;
  27. }

Upon calling the function, we clear each Tile‘s region to 0, and then we iterate over every Tile. Once again we check to see if the tileType is in the whitelist, and if it is and the Tile has not yet been assigned a region we call depthfirstsearch on that tile. Since depthfirstsearch only continues through whitelisted Tiles, every call of depthfirstsearch will be for a new region! Therefore we just increment the regions variable after every call, and each isolated block of tiles will be assigned a different region.

All that’s left is to try it out! Create a new Map in GameStateEditor, then either load it or fill it with random tiles using a for loop inside the constructor. Add a map.draw call in draw after we draw the background (note it’s map.draw(window, dt) not window.draw(map)), then compile and run! Hopefully you should see a lovely, animated world.

Now that we’ve actually got some interesting things on the screen, this program is starting to look like a game! It’s still completely non-interactive though, so let’s change that by adding the ability to pan (move around) and zoom the camera. For this we will use a state variable (not a GameState, just a variable that will keep track of what the player is doing) called actionState. First then, let’s add this and some other variables to GameStateEditor.

  1. #include <SFML/System.hpp>
  3. #include "game_state.hpp"
  4. #include "map.hpp"
  6. enum class ActionState { NONE, PANNING };
  8. class GameStateEditor : public GameState
  9. {
  10.     private:
  12.     ActionState actionState;
  14.     sf::View gameView;
  15.     sf::View guiView;
  17.     Map map;
  19.     sf::Vector2i panningAnchor;
  20.     float zoomLevel;

We’ve used an enum class definition again to create the ActionState type; if actionState == ActionState::PANNING then the player is panning the camera, otherwise they are not. We don’t need an entry for zooming, as zooming is not a continuous process and will only happen upon each turn of the mouse wheel. We then have the panningAnchor variable which will keep track of where we started panning.

Upon pressing the middle mouse button, panningAnchor will record the mouse position. Then as the mouse moves away from the panningAnchor and the middle mouse button is still held down, the world will move too. zoomLevel records how far zoomed in we are, and is increased and decreased as the player scrolls the mouse wheel forwards and backwards. We will double and halve zoomLevel in order to keep the world at a nice scale factor (computers love powers of 2). First let’s initialize some variable inside of the GameStateEditor constructor.

  1. GameStateEditor::GameStateEditor(Game* game)
  2. {
  3.     this->game = game;
  4.     sf::Vector2f pos = sf::Vector2f(this->game->window.getSize());
  5.     this->guiView.setSize(pos);
  6.     this->gameView.setSize(pos);
  7.     pos *= 0.5f;
  8.     this->guiView.setCenter(pos);
  9.     this->gameView.setCenter(pos);
  11.     map = Map("city_map.dat", 64, 64, game->tileAtlas);
  13.     this->zoomLevel = 1.0f;
  15.     /* Centre the camera on the map */
  16.     sf::Vector2f centre(this->map.width, this->map.height*0.5);
  17.     centre *= float(this->map.tileSize);
  18.     gameView.setCenter(centre);
  20.     this->actionState = ActionState::NONE;
  21. }

The new parts start below the map assignment; we initialize zoomLevel, set the actionState, and whilst we’re here we also centre the camera on the Map. Forgive the British/American mix, I can’t seem to default to “center”… We also need to update the draw function so that is uses the correct views. So far they’ve been the same and it hasn’t mattered, but now that we are moving gameViewaround and zooming it in and out we need to make the distinction.

  1. void GameStateEditor::draw(const float dt)
  2. {
  3.     this->game->window.clear(sf::Color::Black);
  5.     this->game->window.setView(this->guiView);
  6.     this->game->window.draw(this->game->background);
  8.     this->game->window.setView(this->gameView);
  9.     map.draw(this->game->window, dt);
  11.     return;
  12. }

We want the background to always be drawn in the same place, so we draw it on guiView, but the world is part of the game and so should be drawn to gameView. If you compile the code now you should see a nicely centred Map being displayed in front of background, which should expand as you resize the window whilst the Map stays in the same (relative) place.

Now we can add the actual panning and zooming code. This code should be placed as events inside of the handleInput function. The new events are

  1. case sf::Event::MouseMoved:
  2. {
  3.     /* Pan the camera */
  4.     if(this->actionState == ActionState::PANNING)
  5.     {
  6.         sf::Vector2f pos = sf::Vector2f(sf::Mouse::getPosition(this->game->window) - this->panningAnchor);
  7.         gameView.move(-1.0f * pos * this->zoomLevel);
  8.         panningAnchor = sf::Mouse::getPosition(this->game->window);
  9.     }
  10.     break;
  11. }
  12. case sf::Event::MouseButtonPressed:
  13. {
  14.     /* Start panning */
  15.     if(event.mouseButton.button == sf::Mouse::Middle)
  16.     {
  17.         if(this->actionState != ActionState::PANNING)
  18.         {
  19.             this->actionState = ActionState::PANNING;
  20.             this->panningAnchor = sf::Mouse::getPosition(this->game->window);
  21.         }
  22.     }
  23.     break;
  24. }
  25. case sf::Event::MouseButtonReleased:
  26. {
  27.     /* Stop panning */
  28.     if(event.mouseButton.button == sf::Mouse::Middle)
  29.     {
  30.         this->actionState = ActionState::NONE;
  31.     }
  32.     break;
  33. }
  34. /* Zoom the view */
  35. case sf::Event::MouseWheelMoved:
  36. {
  37.     if( < 0)
  38.     {
  39.         gameView.zoom(2.0f);
  40.         zoomLevel *= 2.0f;
  41.     }
  42.     else
  43.     {
  44.         gameView.zoom(0.5f);
  45.         zoomLevel *= 0.5f;
  46.     }
  47.     break;
  48. }

When the middle mouse button is pressed and the player is not already panning the camera (this is why we created actionState) we set the panningAnchor to the position of the mouse. This is a screen position, and has nothing to do with the views we created. We also set actionState so that the program knows that the player is panning.

When the middle mouse button is released, we set the actionState to ActionState::NONE so that the player is not panning anymore. If the mouse moves whilst the player is panning then we get the new position of the mouse and subtract the old position (the panningAnchor) from it. Since both positions are coordinates, we can interpet this as calculating the (mathematical) vector from the anchor to the mouse. We then move the gameView in the direction that vector points.

To get a nice pan, we want the Map to move exactly in sync with the mouse, so whatever pixel was underneath the mouse when the panning started will remain beneath the mouse throughout the pan. To achieve this we first reverse the direction of motion; if you stop to think about it, moving a camera to the right is the same as moving everything else to the left, but we want the view to follow the mouse like a sheet of paper or a physical map, and so we reverse this by multiply by -1. At a 1:1 screen to gameView scale ratio (when zoomLevel is 1) the view will follow the cursor perfectly. But if zoomLevel is 2 we have a 1:2 ratio and so we have to multiply however much the mouse has moved by the zoomLevel in order to get the ratio to 2:2 (which is the same as 1:1) and make everything move in sync.

Finally, when the mouse wheel is scrolled up (negative delta) we zoom the view by a factor of 2 and if the wheel is scrolled down we zoom the view by a factor of 0.5. Much simpler! Although try compiling and running the program, zooming in, and then resizing the window. See that the zoom level resets? Well it doesn’t actually, zoomLevel remains the same and it’s only the view that changes. This is obviously bad as zoomLevel stops being in sync with the actual zoom! If you try panning again you’ll see how bad this is. We could fix this by just setting zoomLevel = 1.0f when the player resizes the view, but it’s better to match the view with the zoomLevel instead of the other way around (it prevents suddening zoom reset, which looks weird). The zoom call is the new bit!

  1. /* Resize the window */
  2. case sf::Event::Resized:
  3. {
  4.     gameView.setSize(event.size.width, event.size.height);
  5.     gameView.zoom(zoomLevel);

In the next tutorial we will add the ability to select tiles ready for bulldozing or building.

Source code for this section

Author: Daniel Mansfield