How to Make a Space Invaders Clone with SFML - Part 3

In this part of the series, I'll show you how to create the main menu class for the game. First thing is first, we'll define the interface and data for the class (menu.hpp):


#ifndef MENU_HPP
#define MENU_HPP

#include <iostream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
#include <THOR/Shapes.hpp>
#include <starfield.hpp>
#include <hexgrid.hpp>
#include <opstruct.hpp>

namespace nasic
{
    class menu
    {
        public:
            menu();
            ~menu();
            void show(sf::RenderWindow& window);
            const sf::Uint32 menuState() const {return m_choice;};

            enum choice
            {
                s_uninitialized,
                s_undecided,
                s_play,
                s_info,
                s_options,
                s_quit
            };

        private:
            static choice m_choice;
            nasic::opstruct m_options;
            std::string m_filename;
            sf::Uint32 m_initialVol;
            sf::Uint32 m_initialEff;
            sf::Uint32 m_initialDif;
    };
}

#endif // MENU_HPP

The interface is quite similar to the intro object we created in Part 2 of the series. However, there are some new constructs here that we introduce for the purposes of loading some data and setting some defaults. The <opstruct.hpp> include is a class I created to load and save game option data for our project. See this short tutorial for a walkthrough of how it works and how to use it. Although it is quite heavy-weight for the simple task at hand, I felt compelled to introduce the boost::serialize library for a more robust set up for our game options. You WILL have to compile the boost library to use this functionality, but it is well worth the effort. Please take special notice that the this code will NOT run without compiling boost, simply including boost in the project "header-only" is not sufficient. I realize that most of boost can be utilized without compiling it, but this time it is a bit more painful. A little adversity never killed anyone - but there is plenty of documentation and forum activity out there to help you along. If all else fails, I may be able to help you out below via comment. Trudging ahead, here is the implementation (menu.cpp):

The bread and butter of this code is detecting and responding to events from our user. However, let's look at the constructor first. As previously mentioned, I have created an instance of to load the options for the menu. The first thing we do is call the opstruct::loadOptions() method which returns true if it loads, and false if it does not. In the event that it returns false, or the file does not exist, we write a message to the console and set our options to sensible defaults. Afterall, the lack of settings for music volume, sound effects volume, and difficulty should not be a reason to exit the game...unless you're that much of a stickler. If that's the case, then good for you! You could also do something more useful after assigning default values, like saving the data with opstruct::saveOptions(), which should save a nice copy with your defaults. I'll leave that to you to decide.


nasic::menu::menu()
{
    using nasic::menu;

    m_choice = choice::s_uninitialized;

    //sound and settings
    //read option settings from file
    m_filename =  std::string("data/options.xml");

    if(!m_options.loadOptions(m_options, m_filename.c_str()))
    {
        std::cerr<<"Could not load options data from options.xml"<<std::endl;
        m_initialVol = 3;
        m_initialEff = 5;
        m_initialDif = 1;
    }
    else
    {
        std::cout<<m_options.m_volume<<"\n"<<m_options.m_effects<<"\n"<<m_options.m_difficulty<<std::endl;

        m_initialVol = m_options.m_volume;
        m_initialEff = m_options.m_effects;
        m_initialDif = m_options.m_difficulty;
    }

    std::cout<<m_initialVol<<"\n"<<m_initialEff<<"\n"<<m_initialDif<<std::endl;
}

Next, there is a lot of set up for showing some eye candy, like the sf::Text for labels, an sf::Sprite for the title graphic, a starfield (courtesy of this tutorial), a hexgrid (courtesy of this tutorial), and some housekeeping variables to keep track of important events. In particular, notice these three variables:


...
//housekeeping variables for keeping track of the selection
std::size_t selection = 0;
std::size_t boundcount = 0;
std::size_t hovercount = 0;
...

There may well be other ways of doing this, but I've found it quite simple to just use an integer and a switch statement to handle user input for a screen selection. You don't have to use a std::size_t for this, you could use a plain old int or an unsigned int or an sf::Uint32 - whatever floats your boat. Just keep in mind, that if you use your integer to iterate or access elements in a container (such as std::vector or std::list), then std::size_t is the appropriate choice. One way to possibly shorten the code is to throw your labels into a std::vector and access them by index when the user makes a selection. I chose this method because it is clear and easy to understand for just about anyone. Depending on the number of menu selections you present to the user (which I'm sure UI research will tell you to keep that number to a minimum), you may want to switch to a container-based approach. So, that said, we want to set these values according to the user's input. For std::size_t selection, we are simply incrementing or decrementing its value when the user presses up or down on the keyboard. However, we are also listening for clicks and checking for collisions between the mouse and the boundaries (sf::Text::getGlobalBounds()) of our sf::Text menu labels. If the user is hovering over the "play" label, we set std::size_t selection to 0, if they are hovering over the "info" label we set it to 1, and finally if they are hovering over the "options" label we set it to 2. More important, however, are the std::size_t hovercount and std::size_t boundcount. We use these to counter two scenarios that would most certainly result in annoying and possibly alienating our users. In our example, and in most games, the menu items change color, sounds are played, and various other effects are used when users interact with the menu. If you fail to keep track of the hovercount, which simply increments each iteration of the game loop (in our case, 60 frames per second) the user's mouse has entered the menu label, then you will be calling upon whatever code you are using to respond to these events, you guessed it, 60 times per second!!! This is obviously unacceptable behavior. Simply keeping track of this, incrementing it when the user hovers the object, and resetting it to 0 when the user leaves the object will ensure that you have a means to mitigate this. You simply check the count, if it is equal to 1, we play the sound and immediately kill it to prevent any repeating of the sound. Additionally, and similar to the method we just used, we check the number of times our user has pressed up or down when they reach the boundaries of the menu. If they have reached either boundary, "play" or "options" in this case, we don't want to keep playing the sound if they keep pressing up. So if this variable, std::size_t boundcount, is greater than 0, we do not play the sound. Pretty simple stuff - but it's important to not annoy your gamers. To reiterate, this code can get unwieldy if you have to many more menu elements - whatever that point is for you I recommend taking another approach. But at least you have a solid grounding from which to build on.


void nasic::menu::show(sf::RenderWindow& window)
{
    using nasic::menu;

    /////////////////////////////////////////////////////
    //***************IMPORTANT NOTE*********************
    //we're getting the video mode and creating a scale
    //variable for the drawables using 800 as the base
    //800*600 will be the smallest screen supported
    //*****KEEPING IN MIND****
    //we're blowing up small images...
    //this is not ideal for non-retro-looking sprites
    /////////////////////////////////////////////////////
    sf::VideoMode mode = sf::VideoMode::getDesktopMode();
    float scale = mode.width/800.f;
    m_winsizeX = mode.width;
    m_winsizeY = mode.height;
    std::cout<<m_winsizeX<<"\n"<<m_winsizeY<<std::endl;
    m_scale = scale;

    window.setKeyRepeatEnabled(false);
    window.setFramerateLimit(60);//set the refresh limit to the current frame rate 60fps

    if(!m_choice == choice::s_uninitialized)
        return;

    m_choice = choice::s_undecided;

    //set up menu resources
    sf::Font standardfont;
    if(!standardfont.loadFromFile("fonts/contb.ttf"))
    {
        std::cerr<<"Could not load contb.ttf."<<std::endl;
    }

    sf::Texture bgtex;
    if(!bgtex.loadFromFile("img/title.png"))
    {
        std::cerr<<"Could not load title.png."<<std::endl;
    }

    sf::Sprite title;
    title.setTexture(bgtex);
    title.setOrigin(title.getGlobalBounds().width/2.f, title.getGlobalBounds().height/2.f);
    title.setScale(m_scale, m_scale);
    title.setPosition(m_winsizeX/2.f, m_winsizeY/6.f);
    title.setColor(sf::Color(255,255,255,255));
    std::cout<<title.getPosition().x<<"\n"<<title.getPosition().y<<std::endl;

    sf::Text subtitle("...not another Space Invaders clone!", standardfont, 24);
    subtitle.setOrigin(subtitle.getGlobalBounds().width/2.f, subtitle.getGlobalBounds().height/2.f);
    subtitle.setScale(m_scale, m_scale);
    subtitle.setPosition(title.getPosition().x, title.getPosition().y + (title.getGlobalBounds().height * 1.25f)/m_scale);
    subtitle.setColor(sf::Color(0,255,255,255));

    sf::Text play("Play", standardfont, 52);
    play.setOrigin(0.f, play.getGlobalBounds().height/2.f);
    play.setScale(m_scale, m_scale);
    play.setPosition(0.f, window.getSize().y/2.f);

    sf::Text info("Info", standardfont, 52);
    info.setOrigin(0.f, info.getGlobalBounds().height/2.f);
    info.setScale(m_scale, m_scale);
    info.setPosition(play.getPosition().x, play.getPosition().y + (play.getGlobalBounds().height * 2.0f));

    sf::Text options("Options", standardfont, 52);
    options.setOrigin(0.f, options.getGlobalBounds().height/2.f);
    options.setScale(m_scale, m_scale);
    options.setPosition(info.getPosition().x, info.getPosition().y + (info.getGlobalBounds().height * 2.0f));

    //set up hexgrid and starfield backgrounds
    nasic::hexgrid hex(window, nasic::hexgrid::hexStyle::cyan, scale);
    starfield stars(window, starfield::starStyle::starsAndPlanets, scale);

    sf::SoundBuffer menubuff;
    if(!menubuff.loadFromFile("sound/select.wav"))
    {
        std::cerr<<"Could not load select.wav."<<std::endl;
    }
    sf::Sound menusound;
    menusound.setBuffer(menubuff);
    menusound.setVolume(m_initialEff*5.f);

    sf::Music menumusic;
    if(!menumusic.openFromFile("sound/title.ogg"))
    {
        std::cout<<"Could not open stream for title.ogg"<<std::endl;
    }
    menumusic.setVolume(m_initialVol*5.f);
    menumusic.play();

    //housekeeping variables for keeping track of the selection
    std::size_t selection = 0;
    std::size_t boundcount = 0;
    std::size_t hovercount = 0;

    bool running = true;
    sf::Event e;

    //time stuff...
    sf::Clock tickClock;
    sf::Time timeSinceLastUpdate = sf::Time::Zero;
    sf::Time TimePerFrame = sf::seconds(1.f/60.f);
    sf::Time moveText = sf::Time::Zero;
    sf::Time colorChanger = sf::Time::Zero;

    while(running)
    {
        while(window.pollEvent(e))
        {
            if(e.type == sf::Event::Closed)
            {
                m_choice = choice::s_quit;
                return;
            }

            if(e.type == sf::Event::KeyPressed)
            {
                switch(e.key.code)
                {
                case sf::Keyboard::Escape:
                {
                    m_choice = choice::s_quit;
                    return;
                }
                break;

                case sf::Keyboard::Return:
                {
                    return;
                }
                break;

                case sf::Keyboard::Up:
                {
                    if(moveText.asSeconds() >= 1.f)
                        moveText = sf::Time::Zero;//reset the counter for text movement
                    if(selection <= 0)
                    {
                        boundcount++;
                        selection = 0;
                        std::cout<<"bound count : "<<boundcount<<std::endl;
                    }
                    else
                    {
                        selection--;
                        boundcount = 0;
                        std::cout<<"bound count : "<<boundcount<<std::endl;
                    }

                    if(boundcount == 0)
                        menusound.play();
                }
                break;

                case sf::Keyboard::Down:
                {
                    if(moveText.asSeconds() >= 1.f)
                        moveText = sf::Time::Zero;
                    if(selection >= 2)
                    {
                        boundcount++;
                        selection = 2;
                        std::cout<<"bound count : "<<boundcount<<std::endl;
                    }
                    else
                    {
                        boundcount = 0;
                        selection++;
                        std::cout<<"bound count : "<<boundcount<<std::endl;
                    }

                    if(boundcount == 0)
                        menusound.play();
                }
                break;

                default:
                    break;
                }
            }

            sf::Vector2i mousepos = sf::Mouse::getPosition(window);
            if(mousepos.x >= play.getPosition().x
                    && mousepos.y >= play.getPosition().y
                    && mousepos.x <= play.getPosition().x + play.getGlobalBounds().width
                    && mousepos.y <= play.getPosition().y + play.getGlobalBounds().height)
            {

                //set selection to play
                //and keep track of mouse over text
                selection = 0;
                hovercount++;

                //reset the text movement counter
                if(moveText.asSeconds() >= 1.f && hovercount == 1 && play.getPosition().x < 70.f)
                        moveText = sf::Time::Zero;

                //play sound according to mouse hover events
                if(hovercount == 1)//because there are multiple mouse events we just want the first one
                {
                    menusound.play();
                }
                else
                {
                    //kill it after the first play
                    //so it doesn't do that aweful repeating thing
                    if(!menusound.getStatus() == sf::Sound::Playing)
                        menusound.stop();
                }

                if(e.type == sf::Event::MouseButtonPressed)
                {
                    return;
                }

            }

            else if(mousepos.x >= info.getPosition().x
                    && mousepos.y >= info.getPosition().y
                    && mousepos.x <= info.getPosition().x + info.getGlobalBounds().width
                    && mousepos.y <= info.getPosition().y + info.getGlobalBounds().height)
            {

                //set selection to info
                //and keep track of mouse over text
                selection = 1;
                hovercount++;

                //reset the text movement counter
                if(moveText.asSeconds() >= 1.f && hovercount == 1 && info.getPosition().x < 70.f)
                        moveText = sf::Time::Zero;

                //play sound according to mouse hover events
                if(hovercount == 1)//because there are multiple mouse events we just want the first one
                {
                    menusound.play();
                }
                else
                {
                    //kill it after the first play
                    //so it doesn't do that aweful repeating thing
                    if(!menusound.getStatus() == sf::Sound::Playing)
                        menusound.stop();
                }

                if(e.type == sf::Event::MouseButtonPressed)
                {
                    return;
                }
            }

            else if(mousepos.x >= options.getPosition().x
                    && mousepos.y >= options.getPosition().y
                    && mousepos.x <= options.getPosition().x + options.getGlobalBounds().width
                    && mousepos.y <= options.getPosition().y + options.getGlobalBounds().height)
            {

                //set selection to options
                //and keep track of mouse over text
                selection = 2;
                hovercount++;

                //reset the text movement counter
                if(moveText.asSeconds() >= 1.f && hovercount == 1 && options.getPosition().x < 70.f)
                        moveText = sf::Time::Zero;

                //play sound according to mouse hover events
                if(hovercount == 1)//because there are multiple mouse events we just want the first one
                {
                    menusound.play();
                }
                else
                {
                    //kill it after the first play
                    //so it doesn't do that aweful repeating thing
                    if(!menusound.getStatus() == sf::Sound::Playing)
                        menusound.stop();
                }

                if(e.type == sf::Event::MouseButtonPressed)
                {
                    return;
                }
            }
            else
                hovercount = 0;
        }

        //handle game ticks and return a fixed dt
        //for updates
        timeSinceLastUpdate += tickClock.restart();
        while (timeSinceLastUpdate > TimePerFrame)
        {
            timeSinceLastUpdate -= TimePerFrame;
            stars.update(window,TimePerFrame);

            moveText += TimePerFrame;//for fixed movement of text below...
            colorChanger += TimePerFrame;//for color changing of title below...

            //visual indicators of selection made
            //as well as setting the choice enum accordingly
            //to trigger the proper state
            switch(selection)
            {
            case 0:
            {
                m_choice = choice::s_play;

                //set properties for the play option
                play.setColor(sf::Color(255,0,255,255));
                if(moveText.asSeconds() < 1.f && play.getPosition().x < 100.f && boundcount == 0)
                    play.move(10.f*interpolate::backEaseOut(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    play.setPosition(70.f, play.getPosition().y);//make sure it is sitting at the max when animation stops...

                //set properties for the info option
                info.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && info.getPosition().x > 10.f)
                    info.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    info.setPosition(10.f,info.getPosition().y);//make sure it is sitting at the min when animation ends...

                //set properties for the options option
                options.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && options.getPosition().x > 10.f)
                    options.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    options.setPosition(10.f, options.getPosition().y);//make sure it is sitting at the min when animation ends...
            }
            break;

            case 1:
            {
                m_choice = choice::s_info;
                play.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && play.getPosition().x > 10.f)
                    play.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    play.setPosition(10.f, play.getPosition().y);

                info.setColor(sf::Color(255,55,0,255));
                if(moveText.asSeconds() < 1.f && info.getPosition().x < 100.f)
                    info.move(10.f*interpolate::backEaseOut(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    info.setPosition(70.f, info.getPosition().y);

                options.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && options.getPosition().x > 10.f)
                    options.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    options.setPosition(10.f, options.getPosition().y);
            }
            break;

            case 2:
            {
                m_choice = choice::s_options;
                play.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && play.getPosition().x > 10.f)
                    play.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    play.setPosition(10.f, play.getPosition().y);

                info.setColor(sf::Color(255,255,255,255));
                if(moveText.asSeconds() < 1.f && info.getPosition().x > 10.f)
                    info.move(-10.f*interpolate::backEaseIn(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    info.setPosition(10.f,info.getPosition().y);

                options.setColor(sf::Color(0,255,0,255));
                if(moveText.asSeconds() < 1.f && options.getPosition().x < 100.f && boundcount == 0)
                    options.move(10.f*interpolate::backEaseOut(moveText.asSeconds(), 0.f, 1.f, 1.f), 0.f);
                else
                    options.setPosition(70.f, options.getPosition().y);
            }
            break;

            default:
                break;
            }

            if(colorChanger.asSeconds() < 3.f)//subtract result from 255 to fade from cyan(?) to green
                title.setColor(sf::Color(255, 255, 255-(int)interpolate::circularEaseIn(colorChanger.asSeconds(), 0.f, 255.f, 3.f), 255));
        }

        window.clear();

        window.draw(stars);
        window.draw(hex);
        window.draw(title);
        window.draw(subtitle);
        window.draw(play);
        window.draw(info);
        window.draw(options);
        window.display();
    }
    return;
}

//instantiate static members
nasic::menu::choice nasic::menu::m_choice = nasic::menu::choice::s_uninitialized;

On a side note - you might have noticed a call to an object interpolate, for which I have written another tutorial which has a showcase of all the interpolation methods. I used that here to achieve the text movement effect and the fade the title graphic from the cyan-like color to green. All of the functions are implemented as static in the class, so no need to instantiate an interpolate object. Check out the tutorial along with the video at the bottom of the page to get a visual.

Lastly, there is a public function in this object from which we are going to return a value to the game state machine (remember the first tutorial?). Make sure you add the object to the state machine nasic::Game::menu() method and handle the selections returned from nasic::menu::menuState(), which returns an sf::Uint32, that maps to our choice enum. Here is what it will look like:


...
void nasic::Game::menu()
{
    m_window.setTitle("Menu");
    std::cout<<"Menu State"<<std::endl;

    //create a menu object
    nasic::menu gameMenu;
    gameMenu.show(m_window);
    if(gameMenu.menuState() == nasic::menu::s_play)
        m_state = state::s_level;
    else if(gameMenu.menuState() == nasic::menu::s_options)
        m_state = state::s_options;
    else if(gameMenu.menuState() == nasic::menu::s_info)
        m_state = state::s_info;
    else if(gameMenu.menuState() == nasic::menu::s_quit)
        m_state = state::s_quit;
}
...

If this looks like too much for you to accomplish without seeing the rest, don't fret. I will release the full source complete with comments, resources, love, and a tray of your grandmother's sugar cookies after I complete all parts in this series. In the next part of the series, I'll show you how I implemented a sweet options screen complete with eye candy and GUI controls. Stay tuned.

Go to Part 4