Get started with this tutorial series here!
Up until now we haven’t really discussed how the game will work, but it’s time to fix that! The city will be constructed from a 2D grid of tiles (drawn isometrically) that will evolve over time. The player will be able to (using their city’s funds) bulldoze areas and place new tiles, then watch as their population grows over time. We will only have a few tiles; grass, forest, water, roads, and three zones; residential, commercial, and industrial. We won’t go any further in this tutorial series, but it should be easy to add whatever kind of tile you like!
Residential zones will house the city’s population, commercial zones will sell goods and employ people, and industrial zones will supply those goods to the commercial zones, as well as employ people themselves. The goods will be transported via roads, and currently the other tiles will be purely aesthetic, other than imposing restrictions on what can be placed where. You won’t be able to place a commercial zone over a river, for example. Let’s start by examining the Tile
class (in tile.hpp
)
#ifndef TILE_HPP
#define TILE_HPP
#include <SFML/Graphics.hpp>
#include <vector>
#include "animation_handler.hpp"
enum class TileType { VOID, GRASS, FOREST, WATER, RESIDENTIAL, COMMERCIAL, INDUSTRIAL, ROAD };
std::string tileTypeToStr(TileType type);
class Tile
{
public:
AnimationHandler animHandler;
sf::Sprite sprite;
TileType tileType;
/* Tile variant, allowing for different looking versions of the
* same tile */
int tileVariant;
/* Region IDs of the tile, tiles in the same region are connected.
* First is for transport */
unsigned int regions[1];
/* Placement cost of the tile */
unsigned int cost;
/* Current residents / employees */
double population;
/* Maximum population per growth stage / tile variant */
unsigned int maxPopPerLevel;
/* Maximum number of building levels */
unsigned int maxLevels;
/* Production output per customer/worker per day, either monetary or goods */
float production;
/* Goods stored */
float storedGoods;
/* Constructor */
Tile() { }
Tile(const unsigned int tileSize, const unsigned int height, sf::Texture& texture,
const std::vector<Animation>& animations,
const TileType tileType, const unsigned int cost, const unsigned int maxPopPerLevel,
const unsigned int maxLevels)
{
this->tileType = tileType;
this->tileVariant = 0;
this->regions[0] = 0;
this->cost = cost;
this->population = 0;
this->maxPopPerLevel = maxPopPerLevel;
this->maxLevels = maxLevels;
this->production = 0;
this->storedGoods = 0;
this->sprite.setOrigin(sf::Vector2f(0.0f, tileSize*(height-1)));
this->sprite.setTexture(texture);
this->animHandler.frameSize = sf::IntRect(0, 0, tileSize*2, tileSize*height);
for(auto animation : animations)
{
this->animHandler.addAnim(animation);
}
this->animHandler.update(0.0f);
}
void draw(sf::RenderWindow& window, float dt);
void update();
/* Return a string containing the display cost of the tile */
std::string getCost()
{
return std::to_string(this->cost);
}
};
#endif /* TILE_HPP */
There’s quite a lot here but not much that is complicated! The first thing that you might not have seen before is the enum class
line. A c++11
feature, it’s a standard enum
but you access it like a static
class variable. So instead of just writing GRASS
you have to write TileType::GRASS
. This is makes it almost equivalent to including the enum
in its own namespace, but without the ability to add a using namespace
line.
We then declare the tileTypeToStr
function which converts the specified enum
entry to a string. Whilst it would be nicer to include both of these in some kind of class or namespace, I feel it makes the rest of the code too complicated and ugly to bother! Up until the constructor we have some standard variable declarations, whose comments describe. Two things should be mentioned: firstly, our tiles will use the tileVariant
variable in order to change appearance but provide the same function. For zones the population (employees) that they can support is proportional to their variant. So a tileVariant = 0
could support maxPopPerLevel
(say 50) and may look like some small shops, whereas a tileVariant = 4
could support 5*maxPopPerLevel
and may look like a large shopping center.
For other tiles the variant is purely cosmetic, and for roads will be used to store their orientation information so they point the right way. Secondly, why have we used an array for the regions
variable? That’s purely some cheeky foresight, in the future we’ll want to know not only which tiles are connected together by roads, but we may also want to know which tiles are connected electrically and which are in the same watered region.
The constructor itself is a little more complicated, taking quite a few arguments. tileSize
and height
are the half width of the tile’s sprite, measured in pixels, and the height of the tile, measured in tiles (or multiples of the half width). This will be either 1, for roads and small buildings, or 2 for larger buildings. We use this to set the origin of the sprite and to calcuate the correct frame size for the animation. Using the setOrigin
function we change what is regarded as (0,0)
on the sprite; here we set it to (0,tileSize*(height-1))
. The sprite is drawn starting from the origin position, so this ensures that the tiles will always be drawn in the correct place, regardless of their size.
After setting the frameSize
to the correct dimensions we pass each of the specified animations to the animation handler and then update it once in order to initialise everything correctly. Unlike with our GameState
class update
does not have a timestep parameter. This is because it should be called every time a new game day occurs, which will be the same for all tiles. It is therefore a waste to keep track of the time since each tile’s update. Also unlike our GameState
class, draw
takes an additional sf::RenderWindow
parameter. This is so the tiles do not have any knowledge of the Game
class, which they do not need.
Moving on to tile.cpp
,
#include <SFML/Graphics.hpp>
#include "animation_handler.hpp"
#include "tile.hpp"
void Tile::draw(sf::RenderWindow& window, float dt)
{
/* Change the sprite to reflect the tile variant */
this->animHandler.changeAnim(this->tileVariant);
/* Update the animation */
this->animHandler.update(dt);
/* Update the sprite */
this->sprite.setTextureRect(this->animHandler.bounds);
/* Draw the tile */
window.draw(this->sprite);
return;
}
void Tile::update()
{
/* If the population is at the maximum value for the tile,
* there is a small chance that the tile will increase its
* building stage */
if((this->tileType == TileType::RESIDENTIAL ||
this->tileType == TileType::COMMERCIAL ||
this->tileType == TileType::INDUSTRIAL) &&
this->population == this->maxPopPerLevel * (this->tileVariant+1) &&
this->tileVariant < this->maxLevels)
{
if(rand() % int(1e4) < 1e2 / (this->tileVariant+1)) ++this->tileVariant;
}
return;
}
std::string tileTypeToStr(TileType type)
{
switch(type)
{
default:
case TileType::VOID: return "Void";
case TileType::GRASS: return "Flatten";
case TileType::FOREST: return "Forest";
case TileType::WATER: return "Water";
case TileType::RESIDENTIAL: return "Residential Zone";
case TileType::COMMERCIAL: return "Commercial Zone";
case TileType::INDUSTRIAL: return "Industrial Zone";
}
}
Covering draw
first, we change the animation to whatever tileVariant
is. This is handy as it means that we can place all of the sprites for each tile in a single file, with the animation frames extending to the right and the tile variants extending downwards as separate animations. We then update the animation and use the created bounds
variable (the section of the texture that the frame is in) to tell the sprite which area to display. Finally we draw the tile to the screen. In the update function we check to see if we are dealing with a zone, and if we are we give the zone a chance to advance to the next tile variant if the population is outgrowing the current one.
What’s with the 1e4
and 1e2
though? As you probably know, rand
generates a number between 0 and RAND_MAX
, which on my system is equal to 2147483647 (but is guaranteed to be at least 32767). Ideally we’d use rand() < 0.1 / (this->tileVariant+1)
where rand
generates a number between 0 and 1, and so we to take rand() % 10000
to get a number from 0 to 10000 and then multiply the other side by 10000 to get an equivalent expression using the actual version of rand
. If we make the value we mod by larger than RAND_MAX
we run into problems, so 10000 is really the largest we can go safely. Essentially the chance is 10%
for tileVariant = 0
, 5%
for tileVariant = 1
, 3.33%
for tileVariant = 2
, and so on.
Lastly we have our non-member function that converts the TileType
to a string, using nothing but a simple switch
statement. We put default
first with no break
so that if an unlisted TileType
occurs (say we add a new one but forget to update the function) the function will just return "void"
. Since we’re using return
s we don’t need break
s in any of the other case
s either!
With the Tile
class done (for now), we can actually create the tiles we’ll be using. We’ll do this using what I like to call a “tile atlas,” which will be an std::map
from a string to a Tile
. Whenever we want a new tile, we copy an existing one from the tile atlas. Using an std::map
instead of an array just makes it easier for us; it’s much easier to remember that "forest"
is the forest tile than it is to remember that 4 is the index for a forest (fourest?). We’ll create this atlas, tileAtlas
, inside of the Game
class.
private:
void loadTextures();
void loadTiles();
public:
const static int tileSize = 8;
std::stack<GameState*> states;
sf::RenderWindow window;
TextureManager texmgr;
sf::Sprite background;
std::map<std::string, Tile> tileAtlas;
void pushState(GameState* state);
Don’t forget to include the necessary headers as well! (<map>
, <string>
, and "tile.hpp"
) Note the new tileSize
variable. This is equal to half width the width of each tile (in pixels), as we mentioned earlier. It’s the same for all tiles and remains constant, so we’ve made it const static
. We’ve also declared a loadTextures
function that will populate the atlas, and that we will now define in game.cpp
void Game::loadTiles()
{
Animation staticAnim(0, 0, 1.0f);
this->tileAtlas["grass"] =
Tile(this->tileSize, 1, texmgr.getRef("grass"),
{ staticAnim },
TileType::GRASS, 50, 0, 1);
tileAtlas["forest"] =
Tile(this->tileSize, 1, texmgr.getRef("forest"),
{ staticAnim },
TileType::FOREST, 100, 0, 1);
tileAtlas["water"] =
Tile(this->tileSize, 1, texmgr.getRef("water"),
{ Animation(0, 3, 0.5f),
Animation(0, 3, 0.5f),
Animation(0, 3, 0.5f) },
TileType::WATER, 0, 0, 1);
tileAtlas["residential"] =
Tile(this->tileSize, 2, texmgr.getRef("residential"),
{ staticAnim, staticAnim, staticAnim,
staticAnim, staticAnim, staticAnim },
TileType::RESIDENTIAL, 300, 50, 6);
tileAtlas["commercial"] =
Tile(this->tileSize, 2, texmgr.getRef("commercial"),
{ staticAnim, staticAnim, staticAnim, staticAnim},
TileType::COMMERCIAL, 300, 50, 4);
tileAtlas["industrial"] =
Tile(this->tileSize, 2, texmgr.getRef("industrial"),
{ staticAnim, staticAnim, staticAnim,
staticAnim },
TileType::INDUSTRIAL, 300, 50, 4);
tileAtlas["road"] =
Tile(this->tileSize, 1, texmgr.getRef("road"),
{ staticAnim, staticAnim, staticAnim,
staticAnim, staticAnim, staticAnim,
staticAnim, staticAnim, staticAnim,
staticAnim, staticAnim },
TileType::ROAD, 100, 0, 1);
return;
}
Include the "animation_handler.hpp"
header this time! First we’ve created a new Animation
in order to save ourselves some writing. It’s named staticAnim
as we will use it if the tile only has one frame of animation (regardless of how many different animations it has). Thus we give it a start and end frame of 0 and a duration of 1 second, although it doesn’t really matter what we set for that. Now we actually create the tiles and add them to the atlas. You should be able to understand most of it by looking at the Tile
constructor, but we’ll step through the first to save you checking back.
We first add an entry with the key "grass"
to the std::map
and assign it a new Tile
with a tileSize
equal to the const static
one inside the Game
class, a height
equal to 1, the texture named "grass"
, a single animation (which in this case is the static one), the tileType
TileType::GRASS
, a construction cost of 50, a population per tile variant of 0 and a maximum number of tile variants of 1. If you’ve never seen the { staticAnim, Animation(0, 3, 0.5f) }
code before, this is a new c++11
feature that gives us an easier way of passing std::vector
s (and others) to functions without creating them in advance. Personally it’s my favorite feature, as not only is it clear (you can initialize an array using that syntax already!) it’s a big time saver.
We’ll also need to load the textures that all these tiles will need, since as you can see we require them to create the tiles! Our updated loadTextures
function will look like
void Game::loadTextures()
{
texmgr.loadTexture("grass", "media/grass.png");
texmgr.loadTexture("forest", "media/forest.png");
texmgr.loadTexture("water", "media/water.png");
texmgr.loadTexture("residential", "media/residential.png");
texmgr.loadTexture("commercial", "media/commercial.png");
texmgr.loadTexture("industrial", "media/industrial.png");
texmgr.loadTexture("road", "media/road.png");
texmgr.loadTexture("background", "media/background.png");
}
Finally, make sure that you call the loadTiles
function inside of the constructor
Game::Game()
{
this->loadTextures();
this->loadTiles();
this->window.create(sf::VideoMode(800, 600), "City Builder");
this->window.setFramerateLimit(60);
this->background.setTexture(this->texmgr.getRef("background"));
}
That’s the end for the tiles at the moment, let’s move on to creating a game map!
Author: Daniel Mansfield