SFML Typewriter Class

 

I'm sure you've seen a tutorial for creating the typewriter effect for your games. There is actually a good example of it working on the SFML forum. However, since I have been exploring c++ API design as of late, I will show you how to implement the class using a very useful c++ technique - Pointer to Implementation, or PIMPL, idiom. Here is a quick video of the finished product:

First off, a detailed description of the PIMPL idiom. The PIMPL idiom is often used in API design to make the interface clear and hide data that the consumer of the library does not need access to. Normally, this is acheived using "private" members and methods in your class. However, if someone is foolhardy enough, there are work arounds for accessing private members in header files. Some of those tricks take the following forms:


#define public private
#include "superCool.hpp"
#undef private

As you can see, the c++ text preprocessor can do some pretty insane things. I can't think of a use for "#undef" off the top of my head, but it is there for a reason I suppose. I'd rather have something be defined and wrong than "undefined" - we've all been warned about undefined behavior enough to know that it is not good! Fortunately, there is a way to combat this - PIMPL. Another word for what we are doing, probably better known in the C community than the C++ community, is creating an "opaque" pointer.

So, enough said about that. The other thing we are doing is creating a typewriter effect - I know, I've talked about the other thing enough, huh? Anyhow, the typewriter effect is quite a popular effect in games utilizing a lot of dialogue. You can find a console c++ example using std::this_thread::sleep_for to acheive the basic effect. However, I'm going to give you a more or less complete example conducive to use in a game. In other words, no sleeping threads, and it hooks in to data structures you may already be using to build your game. Here is the class definition (Typewriter.hpp):


#ifndef TYPEWRITER_HPP
#define TYPEWRITER_HPP

#include <SFML/Graphics/Drawable.hpp>
#include <SFML/Graphics/Transformable.hpp>

#include <iostream>
#include <string>
#include <memory>

namespace sf
{
    class Font;
    class RenderTarget;
    class RenderStates;
}

class Typewriter : public sf::Drawable, sf::Transformable
{
    public:
        explicit Typewriter(std::string s, sf::Font& f, sf::Uint32 charSize, float timeOffset);
        ~Typewriter();

        void write();

        void reset();

        void setString(std::string s);
        std::string const getString() const;

        void setPosition(sf::Vector2f position);

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

    private:
        class impl;
        std::unique_ptr<impl> m_impl;
};

#endif // TYPEWRITER_HPP

Pretty clean, huh? As you can see, a number of header files you would think would need to be included in the header are not there, as well as many data members that would be necessary to acheive the effect. The secret lies in the "impl" class, which is forward declared inside the Typewriter class. This is important - it must be a pointer to a private data member whose type is incomplete. Also, because we don't need to use a raw pointer, we use std::unique_ptr<impl>.

The Typewriter class inherits from sf::Drawable/sf::Transformable so that we can make the window.draw(Typewriter) call, instead of a slightly weird looking Typewriter.draw(window) call - not necessary but cleaner.

Another thing you will notice, is there are some forward declarations at the top under the "sf" namespace. This helps clean up as well, and hides some of the things that are used inside the class implentation (Typewriter.cpp) file. This is perfectly okay, and encouraged to an extent, as it defers the inclusion of those files until they are actually required by the compiler to deduce types.

So, basically, the typewriter does a few things - it takes a string as a message to type, uses a reset() method to reset the internal clock and reload the typewriter, and uses the write() method to perform all the updates behind the scenes. Of course, draw() was just an overload from sf::Drawable. As a consumer of this API, I think it looks pretty easy to use. Here is the class implementation (Typewriter.cpp):


#include <Typewriter.hpp>

#include <SFML/Graphics.hpp>
#include <SFML/System/Vector2.hpp>
#include <SFML/System/Clock.hpp>
#include <SFML/Audio.hpp>

class Typewriter::impl
{
public:

    explicit impl(std::string s, sf::Font& f, sf::Uint32 charSize, float timeOffset);
    ~impl();

public:

    std::string m_string;
    sf::Text m_text;
    float m_offset;
    sf::Vector2f m_position;
    std::size_t m_itr;
    sf::Clock m_timer;
    sf::SoundBuffer m_buffer;
    sf::Sound m_sound;
};

Typewriter::impl::impl(std::string s, sf::Font& f, sf::Uint32 charSize, float timeOffset)
    : m_text(s,f,charSize)
    , m_string(s)
    , m_offset(timeOffset)
    , m_itr(0)
{
    m_text.setColor(sf::Color(255,255,255,255));
    m_text.setOrigin(m_text.getGlobalBounds().width/2.f, 0.f);

    m_buffer.loadFromFile("resources/sounds/type.wav");
    m_sound.setBuffer(m_buffer);
    m_sound.setVolume(50.f);
}

Typewriter::impl::~impl()
{

}

Typewriter::Typewriter(std::string s, sf::Font& f, sf::Uint32 charSize, float timeOffset)
    : m_impl(new impl(s,f,charSize,timeOffset))
{

}

Typewriter::~Typewriter()
{

}

void Typewriter::setString(std::string s)
{
    m_impl->m_string = s;
};

std::string const Typewriter::getString() const
{
    return m_impl->m_string;
};

void Typewriter::setPosition(sf::Vector2f position)
{
    m_impl->m_text.setPosition(position);
};

void Typewriter::reset()
{
    m_impl->m_timer.restart();
    m_impl->m_itr = 0;
};

void Typewriter::write()
{
    //std::cout<<"Writing...."<<std::endl;
    if(m_impl->m_timer.getElapsedTime().asSeconds() > m_impl->m_offset && m_impl->m_itr < m_impl->m_string.size())
    {
        m_impl->m_timer.restart();
        m_impl->m_itr++;

        if(m_impl->m_sound.getStatus() == sf::Sound::Stopped)
            m_impl->m_sound.play();

        m_impl->m_text.setString(sf::String(m_impl->m_string.substr(0, m_impl->m_itr)));

        m_impl->m_text.setOrigin(m_impl->m_text.getGlobalBounds().width/2.f, 0.f);

        //std::cout<<m_impl->m_string.substr(0, m_impl->m_itr)<<std::endl;
    }
}

void Typewriter::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    states.transform *= getTransform();
    target.draw(m_impl->m_text, states);
}

Above, you will see that the class implementation for "impl" is taken on first. In order to use "impl", the class must be fully defined before you access its data members. Basically, it has no functions associated with it, only data members. Some key points to consider before implementing your own classes utilizing the PIMPL idiom, make sure you define the class using the scope it was declared in (in this case, Typewriter::impl). The compiler will complain if you don't, and it can be easy to get hung up on these subtleties.

Other things you may have noticed, there are values hard-coded in the constructor of "impl". This is because of the limited scope of use for this particular example. However, you may want to make a fancier Typewriter. Depending on how you would like to use this class, it may be beneficial to inherit from sf::Text rather than use sf::Text as a component of the Typewriter class. However, if your Typewriter class includes other graphical components, you may like this design decision. Anyhow, you did not come here to learn how to use SFML text, sounds, buffers, and clocks. You came here for the typewriter!

The main event, so to speak, is in the Typewriter::write() and Typewriter::reset() methods. The write() method takes no parameters, nice for your users, so all the hardwork is in the constructor. The write() method does a few things, it checks to see if the time elapsed has exceeded the offset (in this case 0.05 seconds) and that the iterator has stayed in the bounds of the string supplied by the user. If all these conditions are met, the internal clock is reset, the iterator bumped forward, a sound is played, and the string is set and centered. The reset() method is more "how you use it" than "how it works", I'll explain more below.

Now, a somewhat minimal example:


#include <Typewriter.hpp>
#include <DataTables.hpp>

#include <SFML/Graphics/RenderWindow.hpp>
#include <SFML/Graphics/Font.hpp>
#include <SFML/Window/Event.hpp>

#include <sstream>
#include <iostream>
#include <vector>
#include <map>
#include <string>

int main()
{
    sf::RenderWindow window(sf::VideoMode(800,600,32), "Typewriter", sf::Style::Default);
    sf::Event e;

    std::vector<EnemyProfileData> myData = initializeEnemyProfileData();

    std::map<sf::Uint32, std::string> myInfo;
    myInfo[0] = myData[0].name + "\n\n" + myData[0].description;

    myInfo[1] = myData[1].name + "\n\n" + myData[1].description;
    myInfo[2] = myData[2].name + "\n\n" + myData[2].description;
    myInfo[3] = myData[3].name + "\n\n" + myData[3].description;

    std::size_t currItem = 0;
    std::size_t maxIter = myInfo.size()-1;
    std::cout<<maxIter<<std::endl;

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

    Typewriter myTypewriter("empty", myfont, 32, .05f);
    myTypewriter.setPosition(sf::Vector2f(400.f, 300.f));

    while(window.isOpen())
    {
        while(window.pollEvent(e))
        {
            if(e.type == sf::Event::Closed)
            {
                window.close();
                return 0;
            }

            if(e.type == sf::Event::KeyPressed)
            {
                switch(e.key.code)
                {
                    case sf::Keyboard::Escape:
                    {
                        window.close();
                        return 0;
                    }
                    break;

                    case sf::Keyboard::Left:
                    {
                        if(currItem <= 0)
                            currItem = 0;
                        else
                            currItem--;

                        myTypewriter.reset();
                    }
                    break;

                    case sf::Keyboard::Right:
                    {
                        if(currItem >= maxIter)
                            currItem = maxIter;
                        else
                            currItem++;

                        myTypewriter.reset();
                    }
                    break;

                    default:
                        break;
                }
            }
        }

        myTypewriter.setString(myInfo[currItem]);
        myTypewriter.write();

        window.setTitle("Enemies: " + std::to_string(myInfo.size()) + " -------- Current Enemy: " + myData[currItem].name);

        window.clear();

        window.draw(myTypewriter);

        window.display();
    }
    return 0;
}

The first thing you'll notice at the top, is there is a "DataTables.hpp" include at the top. All you need to know is that the data in these tables is held in a vector, each element is a struct, and each struct contains a name and a description (both std::string types). This is in the spirit of separating your game components from the data they consume - making your data centrally located and easily/globally accessible. You can find a nearly identical data table example in the SFML book.

In the main() function, there are mostly boilerplate SFML objects and setup, as well as some housekeeping variables for iterating the list of "enemy profiles". The std::map is used to map resource keys (in this case an unsigned integer) to their corresponding resource (in this case a string). The string is obtained by creating a copy of the data table (a std::vector of structs remember) and joining the "name" attribute with a couple carriage returns ("\n") and the description.

After that, the standard game loop and event polling. We make sure to iterate the std::map using "std::size_t currItem" and set the Typewriter text accordingly using myTypewriter.setString(myInfo[currItem]). At the same time, make sure to call reset() on the Typewriter so that when you flip through the "pages" of data the typewriter character iterator and clocks are reset and the effect plays again.

After that, it's simply a matter of making the Typewriter "write" and then drawing the text. Hopefully this has been a good explanation of both the PIMPL idiom and the typewriter effect. All the code in its entirety is available on GitHub.

Thanks, and enjoy!