Creating a City Building Game with SFML Part 8: GUI System

4 Daniel Mansfield Aug 12, 2014

Get started with this tutorial series here!

We have but two more classes to make, and our game will be done! First up is the Gui class, which we'll use to draw (as you might expect) a graphical user interface. We could use a separate library dedicated to this task, but the task is simple enough (in this case) for us to roll our own. Actually, there are 4 classes, because gui.hpp contains 3; GuiStyle, GuiEntry, and Gui. A Gui is made up of a series of GuiEntrys which each display some text and are styled according to a GuiStyle.

#ifndef GUI_HPP
#define GUI_HPP

#include <vector>
#include <utility>
#include <string>

class GuiStyle
{
    public:

    sf::Color bodyCol;
    sf::Color bodyHighlightCol;
    sf::Color borderCol;
    sf::Color borderHighlightCol;
    sf::Color textCol;
    sf::Color textHighlightCol;
    sf::Font* font;

    float borderSize;

    GuiStyle(sf::Font* font, float borderSize,
        sf::Color bodyCol, sf::Color borderCol, sf::Color textCol,
        sf::Color bodyHighlightCol, sf::Color borderHighlightCol, sf::Color textHighlightCol)
    {
        this->bodyCol = bodyCol;
        this->borderCol = borderCol;
        this->textCol = textCol;
        this->bodyHighlightCol = bodyHighlightCol;
        this->borderHighlightCol = borderHighlightCol;
        this->textHighlightCol = textHighlightCol;
        this->font = font;
        this->borderSize = borderSize;
    }
    GuiStyle() { }
};

This class is just one big data structure with a constructor slapped on. The only new code would be sf::Font, which contains a pointer to the font the style we'll use. It's a pointer because sf::Fonts are large things, and so should only be created once, like sf::Textures. As a quick overview of the GuiStyle, bodyCol is the background color of the entry, borderCol is the color of the entry's outline, textCol is the color of the text written on the entry, and the highlight colors are used instead of the normal ones when the player hovers over the entry with their mouse (or selects it with the keyboard, etc.) Moving on to GuiEntry:

class GuiEntry
{
    public:

    /* Handles appearance of the entry */
    sf::RectangleShape shape;

    /* String returned when the entry is activated */
    std::string message;

    /* Text displayed on the entry */
    sf::Text text;

    GuiEntry(const std::string& message, sf::RectangleShape shape, sf::Text text)
    {
        this->message = message;
        this->shape = shape;
        this->text = text;
    }
    GuiEntry() { }
};

Also a data structure meets constructor combo, we have an sf::RectangleShape to store what the entry looks like. We aren't using sprites because everything is determined by the style, so instead we have a rectangle that can be displayed using the properties of a GuiStyle. sf::RectangleShapes can be drawn to the screen like a sprite however, as we will see. message is what the Gui will return when that particular entry is activated (i.e. clicked), and sf::Text is a string given the ability to be drawn to the screen using an sf::Font. Now let's examine the Gui class.

class Gui : public sf::Transformable, public sf::Drawable
{
    private:

    /* If true the menu entries will be horizontally, not vertically, adjacent */
    bool horizontal;

    GuiStyle style;

    sf::Vector2f dimensions;

    int padding;

    public:

    std::vector<GuiEntry> entries;

    bool visible;

    /* Constructor */
    Gui(sf::Vector2f dimensions, int padding, bool horizontal,
        GuiStyle& style, std::vector<std::pair<std::string, std::string>> entries)
    {
        visible = false;
        this->horizontal = horizontal;
        this->style = style;
        this->dimensions = dimensions;
        this->padding = padding;

        /* Construct the background shape */
        sf::RectangleShape shape;
        shape.setSize(dimensions);
        shape.setFillColor(style.bodyCol);
        shape.setOutlineThickness(-style.borderSize);
        shape.setOutlineColor(style.borderCol);

        /* Construct each gui entry */
        for(auto entry : entries)
        {
            /* Construct the text */
            sf::Text text;
            text.setString(entry.first);
            text.setFont(*style.font);
            text.setColor(style.textCol);
            text.setCharacterSize(dimensions.y-style.borderSize-padding);

            this->entries.push_back(GuiEntry(entry.second, shape, text));
        }
    }

    sf::Vector2f getSize();

    /* Return the entry that the mouse is hovering over. Returns
     * -1 if the mouse if outside of the Gui */
    int getEntry(const sf::Vector2f mousePos);

    /* Change the text of an entry. */
    void setEntryText(int entry, std::string text);

    /* Change the entry dimensions. */
    void setDimensions(sf::Vector2f dimensions);

    /* Draw the menu. */
    virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const;

    void show();

    void hide();

    /* Highlights an entry of the menu. */
    void highlight(const int entry);

    /* Return the message bound to the entry. */
    std::string activate(const int entry);
    std::string activate(const sf::Vector2f mousePos);
};

#endif /* GUI_HPP */

Note that Gui inherits from two SFML classes, sf::Transformable and sf::Drawable. These allow us to move the Gui around (like we can a sprite) and also use the window.draw instead of draw(window) syntax. As arguments the constructors takes the dimensions of each GuiEntry, the padding (in pixels) that surrounds the text to stop it from overlapping the edges, whether the Gui should arrange the entries horizontally or vertically, which GuiStyle it should use, and an std::vector that contains a pair of std::strings, where the first is the text of the entry and the second is the message. The argument itself might look ugly but passing values to it isn't too bad.

We then set the variables accordingly and create the sf::RectangleShape that will be used on each GuiEntry. The functions describe themselves but one thing to note is the - in the setOutlineThickness function. This is so that the outline expands inwards toward the center of the shape instead of outwards, preserving the size of the shape and stopping the borders of adjacent elements from overlapping.

We then iterate over each entry pair and create the text using the arguments and the GuiStyle. Here setCharacterSize is the function of note; so that the text fits inside the entry correctly we subtract the borderSize and the padding from the height of the entry shape. There can still be overlap due to the descender on ys and gs for example, but this works well enough for us. Feel free to improve of course, I encourage it!

We will now look at each of the functions in turn and examine their definitions inside gui.cpp. First is getSize, which simply returns the total dimensions of the Gui (these should be placed in gui.cpp).

sf::Vector2f Gui::getSize()
{
    return sf::Vector2f(this->dimensions.x, this->dimensions.y * this->entries.size());
}

We then have getEntry, which takes the mouse position (in screen coordinates, or really the coordinates for the sf::View that the Gui is displayed on) as an argument and returns the index of the entry that mouse is hovering over. If the mouse is outside of the Gui then it returns -1.

int Gui::getEntry(const sf::Vector2f mousePos)
{
    /* If there are no entries then outside the menu. */
    if(entries.size() == 0) return -1;
    if(!this->visible) return -1;

    for(int i = 0; i < this->entries.size(); ++i)
    {
        /* Translate point to use the entry's local coordinates. */
        sf::Vector2f point = mousePos;
        point += this->entries[i].shape.getOrigin();
        point -= this->entries[i].shape.getPosition();

        if(point.x < 0 || point.x > this->entries[i].shape.getScale().x*this->dimensions.x) continue;
        if(point.y < 0 || point.y > this->entries[i].shape.getScale().y*this->dimensions.y) continue;
        return i;
    }

    return -1;
}

By adding the origin of the entry's shape to the mouse position and subtracting the position of the shape we convert point from view coordinates to 'local' coordinates, where (0,0) is the top left of the GuiEntry in question and the coordinates extend up to its dimensions. It's then very easy to check if the cursor lies within the current entry. Next we have setEntryText, which of course takes an std::string as an argument and sets the text of the specified entry accordingly.

void Gui::setEntryText(int entry, std::string text)
{
    if(entry >= entries.size() || entry < 0) return;

    entries[entry].text.setString(text);

    return;
}

setDimensions simply changes the size of all the entries

void Gui::setDimensions(sf::Vector2f dimensions)
{
    this->dimensions = dimensions;

    for(auto& entry : entries)
    {
        entry.shape.setSize(dimensions);
        entry.text.setCharacterSize(dimensions.y-style.borderSize-padding);
    }

    return;
}

Now draw is far more interesting. We are overriding the pure virtual function draw in the sf::Drawable class that Gui inherits from, and by doing this we can use the window.draw(gui) syntax as I said before. The function itself is very simple, and we don't have to worry about the sf::RenderTarget and sf::RenderStates classes; for our purposes sf::RenderTarget is just the window we are drawing to. If you haven't seen it before the const at the end denotes that the function does not change any member variables of the class it belongs to. This is necessary for us to override draw:

void Gui::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    if(!visible) return;

    /* Draw each entry of the menu. */
    for(auto entry : this->entries)
    {
        /* Draw the entry. */
        target.draw(entry.shape);
        target.draw(entry.text);
    }

    return;
}

The show and hide functions change the visibility of the Gui; if it isn't visible, it won't be drawn. show does more than that however, and is used to calculate the position that each GuiEntry should be in; we could put that in draw but we aren't allowed to because of the const!

void Gui::show()
{
    sf::Vector2f offset(0.0f, 0.0f);

    this->visible = true;

    /* Draw each entry of the menu. */
    for(auto& entry : this->entries)
    {
        /* Set the origin of the entry. */
        sf::Vector2f origin = this->getOrigin();
        origin -= offset;
        entry.shape.setOrigin(origin);
        entry.text.setOrigin(origin);

        /* Compute the position of the entry. */
        entry.shape.setPosition(this->getPosition());
        entry.text.setPosition(this->getPosition());

        if(this->horizontal) offset.x += this->dimensions.x;
        else offset.y += this->dimensions.y;
    }

    return;
}

void Gui::hide()
{
    this->visible = false;

    return;
}

As we iterate over the entries we maintain an offset variable that is used to modify the origin of each entry in order to place it in the correct place. At the end of each iteration we modify the offset depending on whether the Gui is displayed horizontally or vertically.

Entries are displayed relative to each other by changing their origins

Lastly, we have the highlight and activate functions. highlight simply changes the colors of each entry to use the highlighted or normal versions from the GuiStyle depending on whether they are marked as highlighted or not, and activate returns the message associated with the entry. The second activate combines the first activate and getEntry together into a more handy function.

/* Highlights an entry of the menu. */
void Gui::highlight(const int entry)
{
    for(int i = 0; i < entries.size(); ++i)
    {
        if(i == entry) 
        {
            entries[i].shape.setFillColor(style.bodyHighlightCol);
            entries[i].shape.setOutlineColor(style.borderHighlightCol);
            entries[i].text.setColor(style.textHighlightCol);
        }
        else
        {
            entries[i].shape.setFillColor(style.bodyCol);
            entries[i].shape.setOutlineColor(style.borderCol);
            entries[i].text.setColor(style.textCol);
        }
    }

    return;
}

/* Return the message bound to the entry. */
std::string Gui::activate(const int entry)
{
    if(entry == -1) return "null";
    return entries[entry].message;
}

std::string Gui::activate(sf::Vector2f mousePos)
{
    int entry = this->getEntry(mousePos);
    return this->activate(entry);
}

Now we have a completed Gui class! Before we can add any Gui systems though it would make sense to create some GuiStyles. It will look better if we have a consistent theme across the entire game, and so we should add the GuiStyles to the Game class to keep them in an easy to access place. Inside of game.hpp we'll create an std::map of std::strings to GuiStyles called stylesheets to store the styles and then we'll also add a loadStylesheets function to fill that map, like we did with loadTiles. We will also requrie a loadFonts function and an std::map to go with it. Don't forget to include gui.hpp!

private:

void loadTextures();
void loadTiles();
void loadStylesheets();
void loadFonts();

public:

const static int tileSize = 8;

std::stack<GameState*> states;

sf::RenderWindow window;
TextureManager texmgr;
sf::Sprite background;

std::map<std::string, Tile> tileAtlas;
std::map<std::string, GuiStyle> stylesheets;
std::map<std::string, sf::Font> fonts;

In game.cpp we'll define the two new functions.

void Game::loadFonts()
{
    sf::Font font;
    font.loadFromFile("media/font.ttf");
    this->fonts["main_font"] = font;

    return;
}

void Game::loadStylesheets()
{
    this->stylesheets["button"] = GuiStyle(&this->fonts.at("main_font"), 1,
        sf::Color(0xc6,0xc6,0xc6), sf::Color(0x94,0x94,0x94), sf::Color(0x00,0x00,0x00),
        sf::Color(0x61,0x61,0x61), sf::Color(0x94,0x94,0x94), sf::Color(0x00,0x00,0x00));
    this->stylesheets["text"] = GuiStyle(&this->fonts.at("main_font"), 0,
        sf::Color(0x00,0x00,0x00,0x00), sf::Color(0x00,0x00,0x00), sf::Color(0xff,0xff,0xff),
        sf::Color(0x00,0x00,0x00,0x00), sf::Color(0x00,0x00,0x00), sf::Color(0xff,0x00,0x00));

    return;
}

Sadly SFML does not allow us to create a new sf::Font directly from a file so we have to go through a variable instead, but both functions are quite self-explanatory. Just note that the backgroundCol and backgroundHighlightCol for the text stylesheet have 4 arguments and not 3; the last is an optional alpha value, so by setting it to 0 we remove the background and are left with just some text. Very handy! All that's left now is to call both functions in the Game constructor. Make sure you call loadFonts before loadStylesheets!

Now we can finally add a Gui to GameStateEditor and GameStateStart. First then let's go into game_state_start.hpp

#include <SFML/Graphics.hpp>
#include <map>
#include <string>

#include "game_state.hpp"
#include "gui.hpp"

class GameStateStart : public GameState
{
    private:

    sf::View view;

    std::map<std::string, Gui> guiSystem;

    void loadgame();

By using an std::map instead of an std::vector we can refer to the Guis by name instead of by an index, which makes things much simpler for us! Inside of GameStateStart's constructor we will create the Gui

this->guiSystem.emplace("menu", Gui(sf::Vector2f(192, 32), 4, false, game->stylesheets.at("button"),
    { std::make_pair("Load Game", "load_game") }));

this->guiSystem.at("menu").setPosition(pos);
this->guiSystem.at("menu").setOrigin(96, 32*1/2);
this->guiSystem.at("menu").show();

This Gui will act as a main menu for the game. To add new elements to every othe std::map we've used the [] syntax, but we can't do that here and must use emplace instead. This is because [] works by first creating an empty object of the type you are trying to add (thereby calling an empty constructor), then copying the object to the right of the = into std::map. Gui doesn't have an empty constructor so we use emplace, which works differently.

Remember to include the <utility> header so that we can use std::make_pair. We then place the Gui in the center of the screen before setting the Gui's origin to its center. setOrigin uses local coordinates ((0,0) is the top left of the Gui) unlike setPosition that uses world coordinates. Finally we show the Gui to make it visible and place all the entries in the correct location.

If we were to run the game now nothing new will appear, even though we've shown the Gui! We need to call its draw function first, which we will do in GameStateStart's draw function.

this->game->window.draw(this->game->background);

for(auto gui : this->guiSystem) this->game->window.draw(gui.second);

return;

Simple as that! Unlike in our previous for each loops, we are iterating over an std::map which has both a key and a value. We want to access the value and so we pass gui.second to the draw function instead of gui. Try running the program, and hopefully there will be a big "Load Game" button in the middle of the screen! To add some interactivity to the button we will have to go to the handleInput function

void GameStateStart::handleInput()
{
    sf::Event event;

    sf::Vector2f mousePos = this->game->window.mapPixelToCoords(sf::Mouse::getPosition(this->game->window), this->view);

    while(this->game->window.pollEvent(event))
    {
        switch(event.type)
        {
            /* Close the window. */
            case sf::Event::Closed:
            {
                game->window.close();
                break;
            }
            /* Resize the window. */
            case sf::Event::Resized:
            {
                this->view.setSize(event.size.width, event.size.height);
                this->game->background.setPosition(this->game->window.mapPixelToCoords(sf::Vector2i(0, 0), this->view));
                sf::Vector2f pos = sf::Vector2f(event.size.width, event.size.height);
                pos *= 0.5f;
                pos = this->game->window.mapPixelToCoords(sf::Vector2i(pos), this->view);
                this->guiSystem.at("menu").setPosition(pos);
                this->game->background.setScale(
                    float(event.size.width) / float(this->game->background.getTexture()->getSize().x),
                    float(event.size.height) / float(this->game->background.getTexture()->getSize().y));
                break;
            }
            /* Highlight menu items. */
            case sf::Event::MouseMoved:
            {
                this->guiSystem.at("menu").highlight(this->guiSystem.at("menu").getEntry(mousePos));
                break;
            }
            /* Click on menu items. */
            case sf::Event::MouseButtonPressed:
            {
                if(event.mouseButton.button == sf::Mouse::Left)
                {
                    std::string msg = this->guiSystem.at("menu").activate(mousePos);

                    if(msg == "load_game")
                    {
                        this->loadgame();
                    }
                }
                break;
            }
            case sf::Event::KeyPressed:
            {
                if(event.key.code == sf::Keyboard::Escape) this->game->window.close();
                break;
            }
            default: break;
        }
    }

    return;
}

Note the new mousePos variable that saves us a lot of typing and some really long lines. We've also made sure to re-center the Gui when the window changes size, and have removed the ability to press the space bar to move to the next state. Instead the player hovers over the Gui (highlighting it) and then left clicks. If the load_game message is received (i.e. the player has pressed the "Load Game" entry) then the game will move to the next state. Try it and see!

Cute as a button!

Source code for this section

4 comments


Or enter your name and Email
  • C ChineseShoPpinG 5 months ago
    very helpful ! Thank you so much!
  • K Keivn 1 year ago
    Hey so if anyone else was stuck like me where there was an issue with the emplace in the GameStateStart constructor, all I had to do to solve the issue and everything work the way it should is call the loadFonts and StyleSheets methods inside of the Game constructor of Game.cpp this->loadTextures(); this->loadFonts(); this->loadStyleSheets(); I hope this helps!
  • JC Joshua Cossey 1 year ago
    Hey i was wondering if the code was getting an out_of_range error for anyone else when the program gets to the line: this->guiSystem.emplace("menu", Gui(sf::Vector2f(192, 32), 4, false, game->stylesheets.at("button"), { std::make_pair("Load Game", "load_game") }));
  • JA Jake Adams 3 years ago
    Hi I'm using SFML 2.3 (latest as of this writing) and getting an error at the following line: this->guiSystem.emplace("menu", Gui(sf::Vector2f(192, 32), 4, false, game->stylesheets.at("button"), { std::make_pair("Load Game", "load_game") })); Error: error C2259: 'Gui' : cannot instantiate abstract class 1> due to following members: 1> 'void sf::Drawable::draw(sf::RenderTarget &,sf::RenderStates) const' : is abstract