How to Create Simple Buttons for your SFML Game

 

Ah, yes, the button. The "Holy Grail" of GUI development. Without it, there is little you can accomplish. Whether it is a simple hyperlink-like clickable piece of text, or a full-on GUI-licious button you need something like this for your call-to-action. In this tutorial, I'll step you through the implementation of a simple reusable button component for your games. The code for this tutorial can be found on github. Please be advised that this project depends upon the Thor library for SFML. At the time this tutorial was written, the example was known to compile on Windows using some version of GCC. As time goes on and things get dusty, there may be some growing pains as APIs change and the C++ standards are revised.

First, we will create the class definition (button.hpp). It consists of a default constructor, an "I know what I'm doing here, so give me a real constructor" constructor, an enum for the styles of buttons we are supporting out of the box, an enum for the button's internal state, lots of housekeeping methods and variables for keeping track of states and returning the button's properties, and a partridge in a pear tree. Here is the class definition:


#ifndef BUTTON_HPP
#define BUTTON_HPP

#include <iostream>

#include <SFML/Graphics.hpp>
#include <THOR/Shapes.hpp>
#include <THOR/Graphics.hpp>

namespace gui
{
    namespace style
    {
        enum
        {
            none = 0,
            save = 1,
            cancel = 2,
            clean = 3,
        };
    };

    namespace state
    {
        enum
        {
            normal = 0,
            hovered = 1,
            clicked = 2
        };
    };

    class button : public sf::Drawable
    {
        public:
            button();
            button(std::string s, sf::Font& font, sf::Vector2f position, sf::Uint32 style);

            ~button();

            void setColorTextNormal(sf::Color text){m_textNormal = text;};
            void setColorTextHover(sf::Color text){m_textHover = text;};
            void setColorTextClicked(sf::Color text){m_textClicked = text;};
            void setColorNormal(sf::Color bgNormal){m_bgNormal = bgNormal;};
            void setColorHover(sf::Color bgHover){m_bgHover = bgHover;};
            void setColorClicked(sf::Color bgClicked){m_bgClicked = bgClicked;};
            void setBorderColor(sf::Color border){m_border = border;};
            void setBorderThickness(float t){m_borderThickness = t;};
            void setBorderRadius(float r){m_borderRadius = r;};
            void setPosition(sf::Vector2f position){m_position = position;};
            void setSize(unsigned int size);
            void setText(std::string s)
            {
                m_text.setString(s);
                m_shadow = m_text;
            };
            void setStyle(sf::Uint32 style);
            void setFont(sf::Font& font);

            sf::Vector2f getPosition(){return m_position;};
            sf::Vector2f getDimensions(){return sf::Vector2f(m_button.getGlobalBounds().width, m_button.getGlobalBounds().height);};
            sf::Uint32 getState(){return m_btnstate;};

            void update(sf::Event& e, sf::RenderWindow& window);

        private:

           virtual void draw(sf::RenderTarget& target,sf::RenderStates states) const;

        private:

            sf::Color m_bgNormal;
            sf::Color m_bgHover;
            sf::Color m_bgClicked;
            sf::Color m_textNormal;
            sf::Color m_textHover;
            sf::Color m_textClicked;
            sf::Color m_border;

            float m_borderThickness;
            float m_borderRadius;
            sf::Vector2f m_size;
            sf::Vector2f m_position;
            sf::Uint32 m_style;
            sf::Uint32 m_btnstate;

            sf::ConvexShape m_button;
            sf::Font m_font;
            unsigned int m_fontSize;
            sf::Text m_text;
            sf::Text m_shadow;
    };
};
#endif // BUTTON_HPP

There really isn't anything that exciting about any of this. We have many methods that are self-describing. Most of them set or get properties of either the sf::Text m_text or the sf::ConvexShape m_button. We side-step what is really going on under the covers, because whoever uses this class isn't likely to care - they just want it to work. So, we create methods to set button properties, but we hide things like event handling and instead simply give them public access to gui::Button::getState(), which returns an sf::Uint32 that maps to the state enum defined in the namespace gui. It is entirely up to the user of the button to use this value to their own advantage - or peril. But at least we clearly delineated what those values wil always be. They have the flexibility to implement reactionary content (such as the "pop-over" you frequently see on the interwebz that is used primarily to give you information about the object you clicked on or hovered over) or generate some action in response to a click. Trying to add such functionality to the button itself would most likely result in a class that is impossible to maintain or incredibly difficult to use. Finally, it is important to note, we inherited from sf::Drawable, and thus, need to override its sf::Drawable::draw(sf::RenderTarget& target, sf::RenderState states) const method. Easy as pie, but this is an important detail if you want people to easily understand your button implementation.

Without further adieu, here is the complete implementation of our button class (button.cpp):



#include <button.hpp>

gui::button::button()
{

}

gui::button::button(std::string s, sf::Font& font, sf::Vector2f position, sf::Uint32 style)
{
    //set position
    m_position = position;

    //set initial state
    m_btnstate = gui::state::normal;

    //set button style
    m_style = style;

    switch(m_style)
    {
        case gui::style::none:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(255,255,255,100);
            m_bgHover = sf::Color(200,200,200,100);
            m_bgClicked = sf::Color(150,150,150);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        case gui::style::save:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(0,255,0,100);
            m_bgHover = sf::Color(0,200,0,100);
            m_bgClicked = sf::Color(0,150,0);
            m_border = sf::Color(0,0,0,100);
        }
        break;

        case gui::style::cancel:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(255,0,0,100);
            m_bgHover = sf::Color(200,0,0,100);
            m_bgClicked = sf::Color(150,0,0);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        case gui::style::clean:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(0,255,255,100);
            m_bgHover = sf::Color(0,200,200,100);
            m_bgClicked = sf::Color(0,150,150);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        default:
            break;
    }

    //set up text
    m_text.setString(s);
    m_text.setFont(font);
    m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
    m_text.setColor(m_textNormal);

    //set some defauts
    m_borderRadius = 5.f;
    m_borderThickness = 0.f;
    m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, m_text.getGlobalBounds().height * 1.5f);

    m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
    m_button.setOrigin(m_button.getGlobalBounds().width/2, m_button.getGlobalBounds().height/2);
    m_button.setPosition(m_position);

    sf::Vector2f textPosition = sf::Vector2f(m_button.getPosition().x, m_button.getPosition().y - m_button.getGlobalBounds().height/4);

    m_text.setPosition(textPosition);

    m_shadow.setFont(font);
    m_shadow = m_text;
    m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
    m_shadow.setPosition(m_text.getPosition().x + 3.f, m_text.getPosition().y + 3.f);
}

gui::button::~button()
{

}

void gui::button::setSize(unsigned int size)
{
    m_fontSize = size;
    m_text.setCharacterSize(m_fontSize);
    m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
    m_shadow.setCharacterSize(m_fontSize);
    m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
    m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, (m_text.getGlobalBounds().height + m_text.getGlobalBounds().height) * 1.5f);
    m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
}

void gui::button::setStyle(sf::Uint32 style)
{
    //set button style
    m_style = style;

    switch(m_style)
    {
        case gui::style::none:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(255,255,255,100);
            m_bgHover = sf::Color(200,200,200,100);
            m_bgClicked = sf::Color(150,150,150);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        case gui::style::save:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(0,255,0,100);
            m_bgHover = sf::Color(0,200,0,100);
            m_bgClicked = sf::Color(0,150,0);
            m_border = sf::Color(0,0,0,100);
        }
        break;

        case gui::style::cancel:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(255,0,0,100);
            m_bgHover = sf::Color(200,0,0,100);
            m_bgClicked = sf::Color(150,0,0);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        case gui::style::clean:
        {
            m_textNormal = sf::Color(255,255,255);
            m_textHover = sf::Color(255,255,255);
            m_textClicked = sf::Color(255,255,255);
            m_bgNormal = sf::Color(0,255,255,100);
            m_bgHover = sf::Color(0,200,200,100);
            m_bgClicked = sf::Color(0,150,150);
            m_border = sf::Color(255,255,255,100);
        }
        break;

        default:
            break;
    }
}

void gui::button::setFont(sf::Font& font)
{
    m_text.setFont(font);
    m_shadow.setFont(font);
}

void gui::button::update(sf::Event& e, sf::RenderWindow& window)
{
    //perform updates for settings from user
    switch(m_style)
    {
        case gui::style::none:
        {
            m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, m_text.getGlobalBounds().height * 1.75f);
            m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
            m_button.setOrigin(m_button.getGlobalBounds().width/2, m_button.getGlobalBounds().height/2);
            m_button.setPosition(m_position);
            m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
            sf::Vector2f textPosition = sf::Vector2f(m_button.getPosition().x, m_button.getPosition().y - m_button.getGlobalBounds().height/4);
            m_text.setPosition(textPosition);
            m_text.setColor(m_textNormal);
            m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
            m_shadow.setPosition(m_text.getPosition().x + 3.f, m_text.getPosition().y + 3.f);
            m_shadow.setColor(sf::Color(0,0,0));
        }
        break;

        case gui::style::save:
        {
            m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, m_text.getGlobalBounds().height * 1.75f);
            m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
            m_button.setOrigin(m_button.getGlobalBounds().width/2, m_button.getGlobalBounds().height/2);
            m_button.setPosition(m_position);
            m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
            sf::Vector2f textPosition = sf::Vector2f(m_button.getPosition().x, m_button.getPosition().y - m_button.getGlobalBounds().height/4);
            m_text.setPosition(textPosition);
            m_text.setColor(m_textNormal);
            m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
            m_shadow.setPosition(m_text.getPosition().x + 3.f, m_text.getPosition().y + 3.f);
            m_shadow.setColor(sf::Color(0,0,0));
        }
        break;

        case gui::style::cancel:
        {
           m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, m_text.getGlobalBounds().height * 1.75f);
            m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
            m_button.setOrigin(m_button.getGlobalBounds().width/2, m_button.getGlobalBounds().height/2);
            m_button.setPosition(m_position);
            m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
            sf::Vector2f textPosition = sf::Vector2f(m_button.getPosition().x, m_button.getPosition().y - m_button.getGlobalBounds().height/4);
            m_text.setPosition(textPosition);
            m_text.setColor(m_textNormal);
            m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
            m_shadow.setPosition(m_text.getPosition().x + 3.f, m_text.getPosition().y + 3.f);
            m_shadow.setColor(sf::Color(0,0,0));
        }
        break;

        case gui::style::clean:
        {
            m_size = sf::Vector2f(m_text.getGlobalBounds().width * 1.5f, m_text.getGlobalBounds().height * 1.75f);
            m_button = thor::Shapes::roundedRect(m_size, m_borderRadius, m_bgNormal, m_borderThickness, m_border);
            m_button.setOrigin(m_button.getGlobalBounds().width/2, m_button.getGlobalBounds().height/2);
            m_button.setPosition(m_position);
            m_text.setOrigin(m_text.getGlobalBounds().width/2, m_text.getGlobalBounds().height/2);
            sf::Vector2f textPosition = sf::Vector2f(m_button.getPosition().x, m_button.getPosition().y - m_button.getGlobalBounds().height/4);
            m_text.setPosition(textPosition);
            m_text.setColor(m_textNormal);
            m_shadow.setOrigin(m_shadow.getGlobalBounds().width/2, m_shadow.getGlobalBounds().height/2);
            m_shadow.setPosition(m_text.getPosition().x + 3.f, m_text.getPosition().y + 3.f);
            m_shadow.setColor(sf::Color(0,0,0));
        }
        break;

        default:
            break;
    }

    //perform updates for user mouse interactions
    sf::Vector2i m_mousePosition = sf::Mouse::getPosition(window);

    bool mouseInButton =    m_mousePosition.x >= m_button.getPosition().x - m_button.getGlobalBounds().width/2
                            && m_mousePosition.x <= m_button.getPosition().x + m_button.getGlobalBounds().width/2
                            && m_mousePosition.y >= m_button.getPosition().y - m_button.getGlobalBounds().height/2
                            && m_mousePosition.y <= m_button.getPosition().y + m_button.getGlobalBounds().height/2;

    if(e.type == sf::Event::MouseMoved)
    {
        if(mouseInButton)
        {
            m_btnstate = gui::state::hovered;
        }

        else
        {
            m_btnstate = gui::state::normal;
        }
    }

    if (e.type == sf::Event::MouseButtonPressed)
    {
        switch(e.mouseButton.button)
        {
        case sf::Mouse::Left:
        {
            if(mouseInButton)
            {
                m_btnstate = gui::state::clicked;
            }

            else
            {
                m_btnstate = gui::state::normal;
            }
        }
        break;
        }
    }

    if (e.type == sf::Event::MouseButtonReleased)
    {
        switch(e.mouseButton.button)
        {
        case sf::Mouse::Left:
        {
            if(mouseInButton)
            {
                m_btnstate = gui::state::hovered;
            }

            else
            {
                m_btnstate = gui::state::normal;
            }
        }
        }
    }

    switch(m_btnstate)
    {
    case gui::state::normal:
    {
        m_button.setFillColor(m_bgNormal);
        m_text.setColor(m_textNormal);
    }
    break;

    case gui::state::hovered:
    {
        m_button.setFillColor(m_bgHover);
        m_text.setColor(m_textHover);
    }
    break;

    case gui::state::clicked:
    {
        m_button.setFillColor(m_bgClicked);
        m_text.setColor(m_textClicked);
    }
    break;
    }
}

void gui::button::draw(sf::RenderTarget& target,sf::RenderStates states) const
{
    switch(m_style)
    {
        case gui::style::none:
        {
            target.draw(m_button, states);
            target.draw(m_shadow, states);
            target.draw(m_text, states);
        }
        break;

        case gui::style::save:
        {
            target.draw(m_button, states);
            target.draw(m_shadow, states);
            target.draw(m_text, states);
        }
        break;

        case gui::style::cancel:
        {
            target.draw(m_button, states);
            target.draw(m_shadow, states);
            target.draw(m_text, states);
        }
        break;

        case gui::style::clean:
        {
            target.draw(m_button, states);
            target.draw(m_shadow, states);
            target.draw(m_text, states);
        }
        break;

        default:
            break;
    }
}

Although the user of the button class need only pass in a reference to an sf::Event and an sf::RenderWindow, there is still quite a bit going on in the gui::button::update() method. Every property that is set by the user is updated in the switch(m_style) statement. There may be a way to avoid this or implement it in a cleaner way, but I have not noticed an impact performance-wise, so I have no reason to change it. Perhaps setting some flags that indicate that a setting was changed would cut down on the number of function calls, but implementing this type of functionality seemed overly complicated for the purposes it is serving.

On the upside, the local variable bool mouseInButton cuts down on a lot of boilerplate for detecting mouseovers for the button, and makes the rest of the related code much easier to follow. Now we could read it out loud and not sound too deranged. It simply reads, if the user moves the mouse and it is inside the button, the state is hovering - else it is normal. Then we check for clicks the in the same manner - if the user clicks and mouseInButton is true we set the state to clicked - else it is normal. We also check for mouse button release and use similar logic there. The rest is cake. We simply set up a switch statement for m_btnstate, which was set according to mouse events, and set the properties of the m_text and m_button elements according to the settings set by the user or the canned style we provided via the m_style member variable.

Finally, we override the pure virtual function sf::Drawable::draw so that we can draw our wonderful button with a simple call to window.draw(button) - or whatever the button happens to be instantiated as. Nifty, and pretty clean on the part of the user. Let's see a simple, minimal, and complete example of using our new button class:



#include <SFML/Graphics.hpp>
#include <button.hpp>

int main()
{
    sf::RenderWindow window(sf::VideoMode(800,600,32), "Starfield Example", sf::Style::Default);

    sf::Font myfont;
    if(!myfont.loadFromFile("fonts/contb.ttf"))
    {
        std::cerr<<"Could not find contb.ttf font."<<std::endl;
    }

    gui::button yeah("Yeah!", myfont, sf::Vector2f(100.f,100.f), gui::style::save);
    gui::button nope("Nope", myfont, sf::Vector2f(100.f, 200.f), gui::style::cancel);
    gui::button nice("Nice...", myfont, sf::Vector2f(300.f, 100.f), gui::style::clean);
    gui::button custom("Sweet", myfont, sf::Vector2f(300.f, 200.f), gui::style::none);
    custom.setBorderThickness(2.f);
    custom.setBorderRadius(20.f);
    custom.setBorderColor(sf::Color(255,255,255,255));
    custom.setColorNormal(sf::Color(200,0,200,255));
    custom.setColorHover(sf::Color(255,0,255,100));
    custom.setColorClicked(sf::Color(150,0,150,255));
    custom.setColorTextNormal(sf::Color(255,255,255,255));
    custom.setColorTextHover(sf::Color(255,255,0,255));
    custom.setColorTextClicked(sf::Color(255,0,0,255));

    sf::Event e;
    bool running = true;
    while(running)
    {
        while(window.pollEvent(e))
        {
            if(e.type == sf::Event::Closed)
            {
                window.close();
                return 0;
            }
        }
            yeah.update(e,window);
            nope.update(e,window);
            nice.update(e,window);
            custom.update(e,window);

            window.clear();
            window.draw(yeah);
            window.draw(nope);
            window.draw(nice);
            window.draw(custom);
            window.display();
    }
    return 0;
}

Enjoy button-making bliss - hack as you see fit. There are some obvious improvements to be made - perhaps an sf::Sprite used as a button icon - or you could even use an icon set from some popular web frameworks, many of them offer their resources with a pretty promiscuous license. In many cases they have already done the research to determine which icons drive user behavior effectively for a particular call-to-action.