Creating a City Building Game with SFML Part 7: Tile Selection

2 Daniel Mansfield Aug 11, 2014

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 ints 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 chars instead of ints 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::Vector2is, 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.

A wide selection of tiles to choose from

Source code for this section

2 comments


Or enter your name and Email
  • AF Andy Felix 3 years ago
    Hello! Thanks for the guide, I'm pretty new to programming and up to this point haven't had too much trouble. My problem is that, while it complies, it fails to load the images. I'm not sure where to put the PNG files, or how to make them readable. Any help is appreciated!
    • CG Christian Gold 2 years ago
      Just leave them in the media folder in your project directory.