Get started with this tutorial series here!
With that all done we can start to add the ability to change the Map
by bulldozing tiles and placing new ones. For this to work we’d need a way for the player to select the tiles to be changed. For this we will use an std::vector
like tiles
, but it will store int
s instead. The player will left click and drag to select the tiles, and when they release the left mouse button the selection will be replaced with the new tiles. First then, we’ll need to create this std::vector
. Whilst it is only relevant to the GameStateEditor
class, we will place it in Map
instead, as it’s far easier to manage there.
/* 0 = Deselected, 1 = Selected, 2 = Invalid */
std::vector<char> selected;
unsigned int numSelected;
/* Select the tiles within the bounds */
void select(sf::Vector2i start, sf::Vector2i end, std::vector<TileType> blacklist);
/* Deselect all tiles */
void clearSelected();
We’ve used char
s instead of int
s to be more efficient but we’ll still interpret them as numbers. numSelected
is, unsurprisingly, the number of tiles that are currently selected (and are not invalid). We have the extra invalid option (so sadly we can’t use an std::vector<bool>
) for tiles that are within the selection area but cannot be replaced by the tile we are planning on adding (no zones over rivers, for example). We then have the select
function that selects all the tiles within the bounding rectangle of start
and end
, and sets all the tiles within that rectangle that are in the blacklist
to invalid. Finally, we have clearSelected
to just deselect every tile.
Now we have to update the constructors
/* Blank map constructor */
Map()
{
this->numSelected = 0;
this->tileSize = 8;
this->width = 0;
this->height = 0;
this->numRegions[0] = 1;
}
/* Load map from file constructor */
Map(const std::string& filename, unsigned int width, unsigned int height,
std::map<std::string, Tile>& tileAtlas)
{
this->numSelected = 0;
this->tileSize = 8;
load(filename, width, height, tileAtlas);
}
With the data structures and declarations set up, let’s go to map.cpp
to write the function definitions for select
and clearSelected
.
void Map::clearSelected()
{
for(auto& tile : this->selected) tile = 0;
this->numSelected = 0;
return;
}
void Map::select(sf::Vector2i start, sf::Vector2i end, std::vector<TileType> blacklist)
{
/* Swap coordinates if necessary */
if(end.y < start.y) std::swap(start.y, end.y);
if(end.x < start.x) std::swap(start.x, end.x);
/* Clamp in range */
if(end.x >= this->width) end.x = this->width - 1;
else if(end.x < 0) end.x = 0;
if(end.y >= this->height) end.y = this->height - 1;
else if(end.y < 0) end.y = 0;
if(start.x >= this->width) start.x = this->width - 1;
else if(start.x < 0) start.x = 0;
if (start.y >= this->height) start.y = this->height - 1;
else if(start.y < 0) start.y = 0;
for(int y = start.y; y <= end.y; ++y)
{
for(int x = start.x; x <= end.x; ++x)
{
/* Check if the tile type is in the blacklist. If it is, mark it as
* invalid, otherwise select it */
this->selected[y*this->width+x] = 1;
++this->numSelected;
for(auto type : blacklist)
{
if(this->tiles[y*this->width+x].tileType == type)
{
this->selected[y*this->width+x] = 2;
--this->numSelected;
break;
}
}
}
}
return;
}
I don’t think clearSelected
requires explanation anymore, but select
deserves some. We first make sure that the bounding rectangle is extending down and to the right (increasing in both axes) by using the std::swap
function (found in the <algorithm>
header) to make sure that the start
coordinates are smaller than the end
ones. We then ensure that the bounding rectangle does not extend off the edges of the map (this would cause a buffer overflow, which we certainly do not want!), before starting at the top left of the rectangle and iterating over every tile within it. To save us an if
we just default to selecting the tile, and then mark it as invalid if the tile’s tileType
is in the blacklist
. We have one more thing to in map.cpp
; go to the load
function and construct selected
(the last line is the new one)
/* Load map from disk */
void Map::load(const std::string& filename, unsigned int width, unsigned int height,
std::map<std::string, Tile>& tileAtlas)
{
std::ifstream inputFile;
inputFile.open(filename, std::ios::in | std::ios::binary);
this->width = width;
this->height = height;
for(int pos = 0; pos < this->width * this->height; ++pos)
{
this->resources.push_back(255);
this->selected.push_back(0);
With the selection functions in place the player needs a way to use them! As discussed before the left mouse button will control all of the selecting. Obviously this will be done in the GameStateEditor
class, so let’s go to game_state_editor.hpp
and add a few necessary variables.
enum class ActionState { NONE, PANNING, SELECTING };
class GameStateEditor : public GameState
{
private:
ActionState actionState;
sf::View gameView;
sf::View guiView;
Map map;
sf::Vector2i panningAnchor;
float zoomLevel;
sf::Vector2i selectionStart;
sf::Vector2i selectionEnd;
Tile* currentTile;
As you can see we’ve added another entry to the ActionState
enum, ActionState::SELECTING
, and we’ve added two new sf::Vector2i
s, selectionStart
and selectionEnd
. These will be the start
and end
arguments that we pass to select
. We’ve also added a pointer to a Tile
. currentTile
will point to whatever tile the player wants to replace the selection with. Not forgetting to initialize them in the constructor,
this->selectionStart = sf::Vector2i(0, 0);
this->selectionEnd = sf::Vector2i(0, 0);
this->currentTile = &this->game->tileAtlas.at("grass");
this->actionState = ActionState::NONE;
}
We’ll let the player choose currentTile
later, for now we’ll just set it to the TileType::GRASS
tile. The selection code itself will be in the handleInput
function like before, which now looks like:
case sf::Event::MouseMoved:
{
/* Pan the camera */
if(this->actionState == ActionState::PANNING)
{
sf::Vector2f pos = sf::Vector2f(sf::Mouse::getPosition(this->game->window) - this->panningAnchor);
gameView.move(-1.0f * pos * this->zoomLevel);
panningAnchor = sf::Mouse::getPosition(this->game->window);
}
/* Select tiles */
else if(actionState == ActionState::SELECTING)
{
sf::Vector2f pos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->gameView);
selectionEnd.x = pos.y / (this->map.tileSize) + pos.x / (2*this->map.tileSize) - this->map.width * 0.5 - 0.5;
selectionEnd.y = pos.y / (this->map.tileSize) - pos.x / (2*this->map.tileSize) + this->map.width * 0.5 + 0.5;
this->map.clearSelected();
if(this->currentTile->tileType == TileType::GRASS)
{
this->map.select(selectionStart, selectionEnd, {this->currentTile->tileType, TileType::WATER});
}
else
{
this->map.select(selectionStart, selectionEnd,
{
this->currentTile->tileType, TileType::FOREST,
TileType::WATER, TileType::ROAD,
TileType::RESIDENTIAL, TileType::COMMERCIAL,
TileType::INDUSTRIAL
});
}
}
break;
}
case sf::Event::MouseButtonPressed:
{
/* Start panning */
if(event.mouseButton.button == sf::Mouse::Middle)
{
if(this->actionState != ActionState::PANNING)
{
this->actionState = ActionState::PANNING;
this->panningAnchor = sf::Mouse::getPosition(this->game->window);
}
}
else if(event.mouseButton.button == sf::Mouse::Left)
{
/* Select map tile */
if(this->actionState != ActionState::SELECTING)
{
this->actionState = ActionState::SELECTING;
sf::Vector2f pos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->gameView);
selectionStart.x = pos.y / (this->map.tileSize) + pos.x / (2*this->map.tileSize) - this->map.width * 0.5 - 0.5;
selectionStart.y = pos.y / (this->map.tileSize) - pos.x / (2*this->map.tileSize) + this->map.width * 0.5 + 0.5;
}
}
else if(event.mouseButton.button == sf::Mouse::Right)
{
/* Stop selecting */
if(this->actionState == ActionState::SELECTING)
{
this->actionState = ActionState::NONE;
this->map.clearSelected();
}
}
break;
}
case sf::Event::MouseButtonReleased:
{
/* Stop panning */
if(event.mouseButton.button == sf::Mouse::Middle)
{
this->actionState = ActionState::NONE;
}
/* Stop selecting */
else if(event.mouseButton.button == sf::Mouse::Left)
{
if(this->actionState == ActionState::SELECTING)
{
this->actionState = ActionState::NONE;
this->map.clearSelected();
}
}
break;
}
Examining what happens when the left mouse button is pressed, we see that if the player is not already selecting tiles then actionState
is set accordingly and the position of the mouse in game world coordinates is recorded. We then use a far fancier looking formula than before to convert from the world coordinates to the Map
coordinates. This is the reverse of what we did previously (tile coordinates to screen coordinates) and so a little bit of algebra can be used to rearrange the old equation into these new ones. I encourage you to try it out for yourself as sadly the derivation is too cumbersome to write in this format! I’m not just being lazy, I promise…
When the mouse is moved and the player is selecting tiles we repeat the same calculation on the new mouse position to compute the end point of the rectangle. We then use the select
function to select the tiles. The if
statement is there because the grass tile acts like a bulldozer, replacing anything that isn’t water with grass, but when placing any other tile you are building and not demolishing, and so the land must be free of other buildings first.
Hopefully this code should compile fine, and under the hood it should work although the selection box will not be visible. To fix this we need to go back into map.cpp
(sorry, I was wrong when I said that was all!) and alter the draw
function. A simple and effective way of marking the selection area is to simply darken all the tiles. We can do this using sf::Sprite
‘s setColor
function, which changes the overall color of the sprite using a color multiply. The sf::Color
constructor takes rgb
values from 0-255, or 0x0-0xff in hexadecimal
/* Change the color if the tile is selected */
if(this->selected[y*this->width+x])
this->tiles[y*this->width+x].sprite.setColor(sf::Color(0x7d, 0x7d, 0x7d));
else
this->tiles[y*this->width+x].sprite.setColor(sf::Color(0xff, 0xff, 0xff));
/* Draw the tile */
this->tiles[y*this->width+x].draw(window, dt);
If the Tile
isn’t selected, we set it’s color to white (so that it is unchanged) and if it is we halve its brightness. Now running the code should allow you to draw lovely selection boxes! They might not do anything, but at it’s a step in the right direction.
Author: Daniel Mansfield