Chapter     23

Useful Design Patterns for Game Development

Design patters are like blueprints for your code. They are systems you can use to complete tasks that are very similar in nature that arise while you are developing games. Just as STL data structures are reusable collections that can be used when needed to solve specific problems, design patterns can be utilized to solve logical problems in your code.

There are benefits to using design patterns in your game projects. First, they allow you to use a common language that many other developers will understand. This helps reduce the length of time it takes new programmers to get up to speed when helping on your projects because they might already be familiar with the concepts you have used when building your game’s infrastructure.

Design patterns can also be implemented using common code. This means that you can reuse this code for a given pattern. Code reuse reduces the number of lines of code in use in your game, which leads to a more stable and more easily maintainable code base, both of which mean you can write better games more quickly.

You’ve already seen at least one design patter used in this book already. Chapter 21 showed how you could use a template class to create a Singleton object. You can think of a Singleton as a reusable logical object that provides you with the ability to ensure that only a single instance of a given class is created and accessed throughout your game code. This chapter introduces you to three more patterns: the Factory, the Observer and the Visitor.

Using the Factory Pattern in Games

The factory pattern is a useful way to abstract out the creation of dynamic objects at runtime. A factory for our purposes is simply a function that takes a type of object as a parameter and returns a pointer to a new object instance. The returned object is created on the heap and therefore it is the caller’s responsibility to ensure that the object is deleted appropriately. Listing 23-1 shows a factory method that I have created to instantiate the different types of Option objects used in Text Adventure.

Listing 23-1. A Factory for Creating Option Instances

Option* CreateOption(PlayerOptions optionType)
{
        Option* pOption = nullptr;
 
        switch (optionType)
        {
        case PlayerOptions::GoNorth:
                pOption = new MoveOption(
                        Room::JoiningDirections::North,
                        PlayerOptions::GoNorth, "Go North");
                break;
        case PlayerOptions::GoEast:
                pOption = new MoveOption(
                        Room::JoiningDirections::East,
                        PlayerOptions::GoEast, "Go East");
                break;
        case PlayerOptions::GoSouth:
                pOption = new MoveOption(
                        Room::JoiningDirections::South,
                        PlayerOptions::GoSouth,
                        "Go South");
                break;
        case PlayerOptions::GoWest:
                pOption = new MoveOption(
                        Room::JoiningDirections::West,
                        PlayerOptions::GoWest,
                        "Go West");
                break;
        case PlayerOptions::OpenChest:
                pOption = new OpenChestOption("Open Chest");
                break;
        case PlayerOptions::AttackEnemy:
                pOption = new AttackEnemyOption();
                break;
        case PlayerOptions::Quit:
                pOption = new QuitOption("Quit");
                break;
        case PlayerOptions::None:
                break;
        default:
                break;
        }
 
        return pOption;
}

As you can see, the CreateOption factory function takes a PlayerOption enum as a parameter and then returns an appropriately constructed Option. This relies on polymorphism to return a base pointer for the object. The knock-on effect of this use of polymorphism is that any factory function can only create objects that derive from its return type. Many game engines manage this by having all creatable objects derive from a common base class. For our purposes, in the context of learning, it’s better to cover a couple of examples. Listing 23-2 shows a factory for the Enemy derived classes.

Listing 23-2. The Enemy Factory

Enemy* CreateEnemy(EnemyType enemyType)
{
        Enemy* pEnemy = nullptr;
        switch (enemyType)
        {
        case EnemyType::Dragon:
                pEnemy = new Enemy(EnemyType::Dragon);
                break;
        case EnemyType::Orc:
                pEnemy = new Enemy(EnemyType::Orc);
                break;
        default:
                assert(false); // Unknown enemy type
                break;
        }
        return pEnemy;
}

If you were to create new inherited classes for these enemy types at some point in the future, you would only be required to update the factory function to add these new classes to your game. This is a handy feature of using factory methods to take advantage of polymorphic base classes.

So far all of the Option and Enemy objects in Text Adventure have been member variables within the Game class. This doesn’t work too well with factory objects because the factory will create the objects on the heap, not using stack memory; therefore the Game class must be updated to store pointers to the Option and Enemy instances. You can see how this is done in Listing 23-3.

Listing 23-3. Updating Game to Store Pointers to Option and Enemy Instances

class Game
        : public EventHandler
{
private:
        static const unsigned int m_numberOfRooms = 4;
        using Rooms = std::array<Room::Pointer, m_numberOfRooms>;
        Rooms m_rooms;
 
        Player m_player;
 
        Option::Pointer m_attackDragonOption;
        Option::Pointer m_attackOrcOption;
        Option::Pointer m_moveNorthOption;
        Option::Pointer m_moveEastOption;
        Option::Pointer m_moveSouthOption;
        Option::Pointer m_moveWestOption;
        Option::Pointer m_openSwordChest;
        Option::Pointer m_quitOption;
 
        Sword m_sword;
        Chest m_swordChest;
 
        using Enemies = std::vector<Enemy::Pointer>;
        Enemies m_enemies;
 
        bool m_playerQuit{ false };
 
        void InitializeRooms();
        void WelcomePlayer();
        void GivePlayerOptions() const;
        void GetPlayerInput(std::stringstream& playerInput) const;
        void EvaluateInput(std::stringstream& playerInput);
public:
        Game();
 
        void RunGame();
 
        virtual void HandleEvent(const Event* pEvent);
};

Game now references the Option and Enemy instances via a type alias that is defined in the respective Option and Enemy class definitions. These aliases are shown in Listing 23-4.

Listing 23-4. The Option::Pointer and Enemy::Pointer Type Aliases

class Option
{
public:
        using Pointer = std::shared_ptr<Option>;
 
protected:
        PlayerOptions m_chosenOption;
        std::string m_outputText;
 
public:
        Option(PlayerOptions chosenOption, const std::string& outputText)
                : m_chosenOption(chosenOption)
                , m_outputText(outputText)
        {
 
        }
 
        const std::string& GetOutputText() const
        {
                return m_outputText;
        }
 
        virtual void Evaluate(Player& player) = 0;
};
 
class Enemy
        : public Entity
{
public:
        using Pointer = std::shared_ptr<Enemy>;
private:
        EnemyType m_type;
        bool m_alive{ true };
 
public:
        Enemy(EnemyType type)
                : m_type{ type }
        {
 
        }
 
        EnemyType GetType() const
        {
                return m_type;
        }
 
        bool IsAlive() const
        {
                return m_alive;
        }
 
        void Kill()
        {
                m_alive = false;
        }
};

The Pointer aliases in both classes have been defined using the shared_ptr template. This means that once the instances have been created by the factories you will not need to worry about where the objects should be deleted. The shared_ptr will automatically delete the instance as soon as you no longer hold a shared_ptr reference.

Updating the Game class constructor is the next important change when using the two factory functions. This constructor is shown in Listing 23-5.

Listing 23-5. The Updated Game Constructor

Game::Game()
        : m_attackDragonOption{ CreateOption(PlayerOptions::AttackEnemy) }
        , m_attackOrcOption{ CreateOption(PlayerOptions::AttackEnemy) }
        , m_moveNorthOption{ CreateOption(PlayerOptions::GoNorth) }
        , m_moveEastOption{ CreateOption(PlayerOptions::GoEast) }
        , m_moveSouthOption{ CreateOption(PlayerOptions::GoSouth) }
        , m_moveWestOption{ CreateOption(PlayerOptions::GoWest) }
        , m_openSwordChest{ CreateOption(PlayerOptions::OpenChest) }
        , m_quitOption{ CreateOption(PlayerOptions::Quit) }
        , m_swordChest{ &m_sword }
{
        static_cast<OpenChestOption*>(m_openSwordChest.get())->SetChest(&m_swordChest);
 
        m_enemies.emplace_back(CreateEnemy(EnemyType::Dragon));
        static_cast<AttackEnemyOption*>(m_attackDragonOption.get())->SetEnemy(m_enemies[0]);
 
        m_enemies.emplace_back(CreateEnemy(EnemyType::Orc));
        static_cast<AttackEnemyOption*>(m_attackOrcOption.get())->SetEnemy(m_enemies[1]);
}

The constructor now calls the factory methods to create the proper instances needed to initialize the shared_ptr for each Option and Enemy. Each Option has its own pointer, but the Enemy instances are now placed into a vector using the emplace_back method. I’ve done this to show you how you can use the shared_ptr::get method along with static_cast to convert the polymorphic base class to the derived class needed to add the Enemy. The same type of cast is needed to add the address of m_swordChest to the m_openSwordChest option.

That’s all there is to creating basic factory functions in C++. These functions come into their own when writing level loading code. Your data can store the type of object you’d like to create at any given time and just pass it into a factory that knows how to instantiate the correct object. This reduces the amount of code in your loading logic, which can help reduce bugs! This is definitely a worthwhile goal.

Decoupling with the Observer Pattern

The observer pattern is very useful in decoupling your code. Coupled code is code that shares too much information about itself with other classes. This could be specific methods in its interface or variables that are exposed between classes. Coupling has a couple of major drawbacks. The first is that it increases the number of places where your code must be updated when making changes to exposed methods or functions and the second is that your code becomes much less reusable. Coupled code is less reusable because you have to take over any coupled and dependent classes when deciding to reuse just a single class.

Observers help with decoupling by providing an interface for classes to derive which provide event methods that will be called on objects when certain changes happen on another class. The Event system introduced earlier had an informal version of the observer pattern. The Event class maintained a list of listeners that had their HandleEvent method called whenever an event they were listening for was triggered. The observer pattern formalizes this concept into a Notifier template class and interfaces that can be used to create observer classes. Listing 23-6 shows the code for the Notifier class.

Listing 23-6. The Notifier Template Class

template <typename Observer>
class Notifier
{
private:
        using Observers = std::vector<Observer*>;
        Observers m_observers;
 
public:
        void AddObserver(Observer* observer);
        void RemoveObserver(Observer* observer);
 
        template <void (Observer::*Method)()>
        void Notify();
};

The Notifier class defines a vector of pointers to Observer objects. There are complementary methods to add and remove observers to the Notifier and finally a template method named Notify, which will be used to notify Observer objects of an event. Listing 23-7 shows the AddObserver and RemoveObserver method definitions.

Listing 23-7. The AddObserver and RemoveObserver method definitions

template <typename Observer>
void Notifier<Observer>::AddObserver(Observer* observer)
{
        assert(find(m_observers.begin(), m_observers.end(), observer) == m_observers.end());
        m_observers.emplace_back(observer);
}
 
template <typename Observer>
void Notifier<Observer>::RemoveObserver(Observer* observer)
{
        auto object = find(m_observers.begin(), m_observers.end(), observer);
        if (object != m_observers.end())
        {
                m_observers.erase(object);
        }
}

Adding an Observer is as simple as calling emplace_back on the m_observers vector. The assert is used to inform us if we are adding more than one copy of each Observer to the vector. The remove is achieved by using find to get an iterator to the object to be removed and calling erase if the iterator is valid.

The Notify method uses a C++ feature that you have not seen so far, method pointers. A method pointer allows us to pass the address of a method from a class definition that should be called on a specific object. Listing 23-8 contains the code for the Notify method.

Listing 23-8. The Notifier<Observer>::Notify Method

template <typename Observer>
template <void(Observer::*Method)()>
void Notifier<Observer>::Notify()
{
        for (auto& observer : m_observers)
        {
                 (observer->*Method)();
        }
}

The Notify template method specifies a method pointer parameter. The method pointer must have a void return type and take no arguments. The type of a method pointer takes the following format.

void (Class::*VariableName)()

Class here represents the name of the class the method belongs to and VariableName is the name we use to reference the method pointer in our code. You can see this in action in the Notify method when we call the method using the Method identifier. The object we are calling the method on here is an Observer* and the address of the method is dereferenced using the pointer operator.

Once our Notifier class is complete, we can use it to create Notifier objects. Listing 23-9 inherits a Notifier into the QuitOption class.

Listing 23-9. Updating QuitOption

class QuitOption
        : public Option
        , public Notifier<QuitObserver>
{
public:
        QuitOption(const std::string& outputText)
                : Option(PlayerOptions::Quit, outputText)
        {
 
        }
 
        virtual void Evaluate(Player& player);
};

QuitOption now inherits from the Notifier class, which is passed a new class as its template parameter. Listing 23-10 shows the QuitObserver class.

Listing 23-10. The QuitObserver Class

class QuitObserver
{
public:
        virtual void OnQuit() = 0;
};

QuitObserver is simply an interface that provides a method, OnQuit, to deriving classes. Listing 23-11 shows how you should update the QuitOption::Evaluate method to take advantage of the Notifier functionality.

Listing 23-11. Updating QuitOption::Notifier

void QuitOption::Evaluate(Player& player)
{
        Notify<&QuitObserver::OnQuit>();
}

Now you can see the very clean template method call. This simple call will call the OnQuit method on every object that has been added as an observer on the QuitOption. That’s our next step: The Game class is updated to inherit from QuitObserver in Listing 23-12.

Listing 23-12. The Game Class QuitObserver

class Game
        : public EventHandler
        , public QuitObserver
{
private:
        static const unsigned int m_numberOfRooms = 4;
        using Rooms = std::array<Room::Pointer, m_numberOfRooms>;
        Rooms m_rooms;
 
        Player m_player;
 
        Option::Pointer m_attackDragonOption;
        Option::Pointer m_attackOrcOption;
        Option::Pointer m_moveNorthOption;
        Option::Pointer m_moveEastOption;
        Option::Pointer m_moveSouthOption;
        Option::Pointer m_moveWestOption;
        Option::Pointer m_openSwordChest;
        Option::Pointer m_quitOption;
 
        Sword m_sword;
        Chest m_swordChest;
 
        using Enemies = std::vector<Enemy::Pointer>;
        Enemies m_enemies;
 
        bool m_playerQuit{ false };
 
        void InitializeRooms();
        void WelcomePlayer();
        void GivePlayerOptions() const;
        void GetPlayerInput(std::stringstream& playerInput) const;
        void EvaluateInput(std::stringstream& playerInput);
public:
        Game();
        ~Game();
 
        void RunGame();
 
        virtual void HandleEvent(const Event* pEvent);
 
        // From QuitObserver
        virtual void OnQuit();
};

The bolded lines show that the Game class inherits from QuitObserver, now has a destructor, and overloads the OnQuit method. Listing 23-13 shows how the constructor and destructor are responsible for adding and removing the class as a listener to QuitOption.

Listing 23-13. NThe Game Class Constructor and Destructor

Game::Game()
: m_attackDragonOption{ CreateOption(PlayerOptions::AttackEnemy) }
, m_attackOrcOption{ CreateOption(PlayerOptions::AttackEnemy) }
, m_moveNorthOption{ CreateOption(PlayerOptions::GoNorth) }
, m_moveEastOption{ CreateOption(PlayerOptions::GoEast) }
, m_moveSouthOption{ CreateOption(PlayerOptions::GoSouth) }
, m_moveWestOption{ CreateOption(PlayerOptions::GoWest) }
, m_openSwordChest{ CreateOption(PlayerOptions::OpenChest) }
, m_quitOption{ CreateOption(PlayerOptions::Quit) }
, m_swordChest{ &m_sword }
{
        static_cast<OpenChestOption*>(m_openSwordChest.get())->SetChest(&m_swordChest);
 
        m_enemies.emplace_back(CreateEnemy(EnemyType::Dragon));
        static_cast<AttackEnemyOption*>(m_attackDragonOption.get())->SetEnemy(m_enemies[0]);
 
        m_enemies.emplace_back(CreateEnemy(EnemyType::Orc));
        static_cast<AttackEnemyOption*>(m_attackOrcOption.get())->SetEnemy(m_enemies[1]);
 
        static_cast<QuitOption*>(m_quitOption.get())->AddObserver(this);
}
 
Game::~Game()
{
        static_cast<QuitOption*>(m_quitOption.get())->RemoveObserver(this);
}

The bolded lines again show the relevant updates to the code. The last update in Listing 23-14 implements the OnQuit method.

Listing 23-14. The Game::OnQuit Method

void Game::OnQuit()
{
        m_playerQuit = true;
}

This is all there is to implementing the observer pattern. This has achieved another decoupling between the QuitOption class and any other classes in the game that need to know about quit events. The observer class is especially useful when creating game framework code for systems such as online features. You can imagine a situation where you implement a class to download leaderboards from a web server. This class could be used in multiple game projects and each individual game could simply implement its own class to observe the downloader and act appropriately when the leaderboard data has been received.

Easily Adding New Functionality with the Visitor Pattern

One of the main goals of writing reusable game engine code is to try to avoid including game-specific functionality in your classes. This can be hard to achieve with a pure object-oriented approach, as the aim of encapsulation is to hide the data in your classes behind interfaces. This could mean that you are required to add methods to classes to work on data that are very specific to a certain class.

We can get around this problem by loosening our encapsulation on classes that must interact with game code, but we do so in a very structured manner. You can achieve this by using the visitor pattern. A visitor is an object that knows how to carry out a specific task on a type of object. These are incredibly useful when you need to carry out similar tasks on many objects that might inherit from the same base class but have different parameters or types. Listing 23-15 shows an interface class you can use to implement Visitor objects.

Listing 23-15. The Visitor Class

class Visitor
{
private:
        friend class Visitable;
        virtual void OnVisit(Visitable& visitable) = 0;
};

The Visitor class provides a pure virtual method OnVisit, which is passed an object that inherits from a class named Visitable. Listing 23-16 lists the Visitable class.

Listing 23-16. The Visitable Class

class Visitable
{
public:
        virtual ~Visitable() {}
 
        void Visit(Visitor& visitor)
        {
                visitor.OnVisit(*this);
        }
};

The Visitable class provides a Visit method that is passed the Visitor object. The Visit method calls the OnVisit method on the Visitor. This allows us to make the OnVisit method private, which ensures that only Visitable objects can be visited and that we are always passing a valid reference to the OnVisit method.

The visitor pattern is very simple to set up. You can see a concrete example of how to use the pattern in Listing 23-17, where the Option class from Text Adventure has been inherited from Visitable.

Listing 23-17. The Updated Option Class

class Option
        : public Visitable
{
public:
        using Pointer = std::shared_ptr<Option>;
 
protected:
        PlayerOptions m_chosenOption;
        std::string m_outputText;
 
public:
        Option(PlayerOptions chosenOption, const std::string& outputText)
                : m_chosenOption(chosenOption)
                , m_outputText(outputText)
        {
 
        }
 
        const std::string& GetOutputText() const
        {
                return m_outputText;
        }
 
        virtual void Evaluate(Player& player) = 0;
};

The only change required is to inherit the Option class from Visitable. To take advantage of this, a Visitor named EvaluateVisitor is created in Listing 23-18.

Listing 23-18. The EvaluateVisitor Class

class EvaluateVisitor
        : public Visitor
{
private:
        Player& m_player;
 
public:
        EvaluateVisitor(Player& player)
        : m_player{ player }
        {
 
        }
 
        virtual void OnVisit(Visitable& visitable)
        {
                Option* pOption = dynamic_cast<Option*>(&visitable);
                if (pOption != nullptr)
                {
                        pOption->Evaluate(m_player);
                }
        }
};

The EvaluateListener::OnVisit method uses a dynamic_cast to determine if the supplied visitable variable is an object derived from the Option class. If it is, the Option::Evaluate method is called. The only remaining update is to use the EvaluateVisitor class to interface with the chosen option in Game::EvaluateInput. This update is shown in Listing 23-19.

Listing 23-19. The Game::EvaluateInput Method

void Game::EvaluateInput(stringstream& playerInputStream)
{
        PlayerOptions chosenOption = PlayerOptions::None;
        unsigned int playerInputChoice{ 0 };
        playerInputStream >>playerInputChoice;
 
        try
        {
                Option::Pointer option =
                        m_player.GetCurrentRoom()->EvaluateInput(playerInputChoice);
                EvaluateVisitor evaluator{ m_player };
                option->Visit(evaluator);
        }
        catch (const std::out_of_range&)
        {
                cout << "I do not recognize that option, try again!" << endl << endl;
        }
}

As you can see, the code has been updated to call the Visit method on the Option rather than calling the Evaluate method directly. That’s all we needed to do to add the Visitor pattern to the Text Adventure game.

This example isn’t the best use of the Visitor pattern, as it is relatively simple. Visitors can come into their own in places such as a render queue in 3-D games. You can implement different types of rendering operations in Visitor objects and use that to determine how individual games render their 3-D objects. Once you get the hang of abstracting out logic in this way, you might find many places where being able to provide different implementations independently of the data is very useful.

Summary

This chapter has given you a brief introduction to the concept of design patterns. Design patterns are exceptionally useful as they provide a ready-made toolbox of techniques that can be used to solve many diverse problems. You’ve seen the Singleton, Factory, Observer, and Visitor patterns used in this book, but there are many, many more.

The de facto standard textbook on software engineering design patterns is Design Patterns: Elements of Reusable Object Oriented Software by Gamma, Helm, Johnson, and Vlissides (also known as the “Gang of Four”). If you find this concept interesting, you should read their book. It covers the examples shown here as well as other useful patterns. Bob Nystrom, a former software engineer at EA, has provided a free online collection of design patterns relevant to game development. You can find his web site here: http://gameprogrammingpatterns.com/.

You’ll find many patterns relevant and helpful when trying to solve game development problems. They also make your code easier to work with for other developers who are also versed in the common techniques that design patterns provide. Our next chapter is going to look at C++ IO streams and how we can use them to load and save game data.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.75.233