A Student's Take: Implementing Event Systems in Games and Using the Preprocessor

Event Systems are often the key for different systems to communicate with each other in games. It's a simple concept with many different ways to program it. There are different types of callback function registration, global Event sending, individual message senders/receivers, and the Lumberyard engine has its particularly advanced EBus system. However, for the scope of a student project, I tend to choose a rather simple and effective approach: the Observer pattern.

For video game programming, most of the principles of Object Oriented Programming (OOP) aren't used as often as they were in the past with the advent of Data Oriented, component based, and entity-component-system engine architectures. However the Observer pattern works marvels here. The Observer pattern (also known as the listener or subscriber pattern) makes use of multiple inheritance in C++.

The Observer pattern is simple:

  • Global singleton of the EventManager receives calls to Send out an Event of a certain type to all Observers (we'll call them EventListeners) who are Subscribed to that type.
  • Each EventListener will receive that event and process it however it wants to in its inherited method "OnEvent."
  • Events can be sent from anywhere that can instantiate the EventManager singleton.
  • EventListeners will only receive events that they are Subscribed to, and can Unsubscribe from Event types whenever they want.

The base EventListener class might look something like this:

template<typename T>
using UniqueContainer = std::unordered_set<T>;

class EventListener
{
public:
  virtual void OnEvent(Event *received) = 0;
  void Subscribe(EventType type);
  void Unsubscribe(EventType type);
  void UnsubscribeAll();

  virtual ~EventListener() { UnsubscribeAll(); }


private:
  UniqueContainer<EventType> m_subscribeList;
};

The global EventManager interface might look something like this:

// assume singleton<T> exists

template<typename T>
using UniqueContainer = std::unordered_set<T>;
class EventManager : public singleton<EventManager>
{
public:
  void Init();
  void Update();
  void Exit();

  void RemoveListener(EventListener *listener);
  void SendEvent(Event *sent);

  // future functionality:// void QueueEvent(Event *sent, float delay = 0.0f);  

private:
  friend class EventListener;
  void Subscribe(EventType type, EventListener *listener);
  void Unsubscribe(EventType type, EventListener *listener);

  std::vector<UniqueContainer<EventListener *> m_listeners;
};

This seems all pretty simple! Not many lines of code or that many methods to implement, but significant to the project. There's only one piece of the puzzle left: the Events themselves.

Since this is an Object Oriented pattern, you already know there will be lots of different classes. Here is where they come into play:

  • Events are defined by a base Event class.
  • The base Event class only contains an "EventType."
  • EventTypes are defined simply as an enum, enumerated starting from zero.
  • For each EventType there is a corresponding class inherited from the base Event class.
  • In classes derived from Event, information about the event can be parceled away in the derived class.

For example, at the start of a new game, we want to send out an Event saying that a new game has started, what time its started, and the number of players in the game. Code for that would look a bit like this:

enum EventType
{
  e_GameStartEvent = 0,

  e_EventTypeMax
};

class Event
{
public:
  explicit Event(EventType type) : m_type(type) {}
  EventType m_type;
};

class GameStartEvent : public Event
{
public:
  GameStartEvent() : Event(e_GameStartEvent) {}

  float m_startTime;
  size_t m_numPlayers;
};

Now that we have an actual EventType, we can add this to game logic! If we had some Logic component that existed somewhere in memory, we could add this code:

// in the .h file:
class LogicComponent 
  : public Component, 
    public EventListener
{
public:
  // ...

  void OnEvent(Event *received) override;

  // ...
private:
  /* component stuff here */
};

// in the .cpp file:
LogicComponent::LogicComponent()
{
  Subscribe(EventType::e_GameStartEvent);
}

void LogicComponent::OnEvent(Event *received)
{
  switch(received->m_type)
  {
  case e_GameStartEvent:
    GameStartEvent* gameStart = reinterpret_cast<GameStartEvent *>(received);
    ProcessGameStart(gameStart->m_startTime, gameStart->m_numPlayers);
    break;
  }
}

Since the EventListener knows exactly what types of events are being passed to it, because it literally Subscribes to only those EventTypes, EventListeners can switch the EventType and cast to exactly the right type of derived Event class (You can probably use any kind of cast here because you don't need to worry about type safety: you know exactly the type you're casting to because of the EventType)!

Now the only problem to worry about is upkeep: for every EventType, we have to make a new class, and vice versa. If you want to display this in an editor, you'll need to keep string versions of the EventTypes as well. Maintaining this is clearly not sustainable for any large-scale game. That's why it works well for student projects: they most likely won't be that expansive or have hundreds or thousands of EventTypes. However, even for student projects adding new EventTypes is still a pain. This brings us to Part 2:

USING THE PREPROCESSOR TO DO IT FOR YOU:

I really love writing macros in C++. They have a lot of power. And everything that they do happens before compile time. There are so many new C++ 11, 14, and 17 features that do amazing things at compile time, but with macros, everything is done before.

Like all computer scientists should, I thought "is there a way for the computer to do the work for me?"

One easy way could just be a pre-build script that parses through some registry file where all your Events are defined and creates a header file from that data. However, I did some digging into preprocessor tricks, and this is what I came up with:

  • When you #include a file, the preprocessor will literally copy and paste that file where the #include statement is.
  • You can #undef and then immediately #define the same macro to do different things.
  • One of the most useful sources for this was from Allan Deutsch's talk on the preprocessor in the DigiPen Game Engine Architecture Club, linked here.
  • Also it should be noted that I'm assuming that you (the reader) already know some common preprocessor tricks such as stringifying and token-pasting using # and ##.

So if we have some registry file where gameplay programmers can add new Events, then implementing an Event hierarchy in the preprocessor might look something like this:

///////////////////////
// in EventRegistry.h
EVENT(GameStart)
EVENT(GunShot)
EVENT(GameEnd)

/////////////////////////
// in EventInclude.h
#pragma once

// define the EventType enum:
#define EVENT(name) e_##name##Event ,

enum EventType
{
# include "EventRegistry.h"
  
  e_EventMax
};

#undef EVENT

// define a global list of strings to for all the Event names:
#define EVENT(name) #name ,
const char* g_EventNames[] = 
{
# include "EventRegistry.h"

  "EventMax"
};

#undef EVENT

// define the Event class hierarchy:
class Event
{
public:
  explicit Event(EventType type) : m_type(type) {}
  EventType m_type;
};

#define EVENT(name) \
class name##Event : public Event \
{ \
public: \
  name##Event() : Event(e_##name##Event) {} \
};

#include "EventRegistry.h"

#undef EVENT
#define EVENT

In the example above, it'll automatically create the Events for your IDE's intellisense before compile time. The EventRegistry file gets copy/pasted wherever it is #included. Each line with EVENT() on it expands to something different. However, we're missing the crucial part of Events that make them so powerful: the data inside them. I want to have a way to add as much data as I want into an Event, so I can just say:

///////////
// in the EventRegistry.h file:
EVENT(GameStart, float m_startTime, long m_numPlayers)

and have the GameStart Event have that data encapsulated inside it. After digging into Stack Overflow and the internet for answers, I found a good solution that I'll link here.

The implementation is dependent on the compiler that you're using, GNU tricks with the g++ or clang compiler won't work with the Microsoft compiler, but since I'm working in Visual Studio with the Microsoft compiler, I wrote the Microsoft version. Here is how I implemented the APPLY_ON_EACH macro that applies a macro on each variadic argument.

// In some Util.h file

// expand for the microsoft compiler is used everywhere
#define EXPAND(x) x

// finds out the number of variadic args to the macro
#define __NUM_VA_ARGS(_1,_2,_3,_4,_5,_6,_7,_8,_9, _10, _11, _12, _13, _14, _15, _16, _17, N, ...) N
#define NUM_VA_ARGS(...) EXPAND(__NUM_VA_ARGS(__VA_ARGS__, 17, 16, 15, 14, 13, 12, 11, 10, 9,8,7,6,5,4,3,2,1,0, -1))

// xpaste for extra expansion step
#define PASTE(a, b) a ## b
#define XPASTE(a, b) PASTE(a, b)

// here's the actual apply macro, its a lot:
#define APPLY_0(M)
#define APPLY_1(M, a) M(a)
#define APPLY_2(M, a, b) M(a) M(b)
#define APPLY_3(M, a, b, c) M(a) M(b) M(c)
#define APPLY_4(M, a, b, c, d) M(a) M(b) M(c) M(d)
#define APPLY_5(M, a, b, c, d, e) M(a) M(b) M(c) M(d) M(e)
#define APPLY_6(M, a, b, c, d, e, f) M(a) M(b) M(c) M(d) M(e) M(f)
#define APPLY_7(M, a, b, c, d, e, f, g) \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g)
#define APPLY_8(M, a, b, c, d, e, f, g, h)  \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h)
#define APPLY_9(M, a, b, c, d, e, f, g, h, i)   \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i)
#define APPLY_10(M, a, b, c, d, e, f, g, h, i, j)   \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j)
#define APPLY_11(M, a, b, c, d, e, f, g, h, i, j, k)    \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k)
#define APPLY_12(M, a, b, c, d, e, f, g, h, i, j, k, l) \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l)
#define APPLY_13(M, a, b, c, d, e, f, g, h, i, j, k, l, m)  \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l) M(m)
#define APPLY_14(M, a, b, c, d, e, f, g, h, i, j, k, l, m, n)   \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l) M(m) M(n)
#define APPLY_15(M, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o)    \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l) M(m) M(n) M(o)
#define APPLY_16(M, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p) \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l) M(m) M(n) M(o) M(p)
#define APPLY_17(M, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q)  \
    M(a) M(b) M(c) M(d) M(e) M(f) M(g) M(h) M(i) M(j) M(k) M(l) M(m) M(n) M(o) M(p) M(q)

#define APPLYX(APP, M, ...) EXPAND(APP(M, __VA_ARGS__))
#define APPLY_ON_EACH(M, ...) APPLYX(EXPAND(XPASTE(APPLY_, NUM_VA_ARGS(__VA_ARGS__))), M, __VA_ARGS__)

Now that we've defined these utilities, the task should be easy! Even though this only works for up to 17 arguments, I find it rare that anyone wants to parcel more than 4 pieces of information in an Event. The macros can also be expanded to have far more than 17 with minimal effort. Why did I pick 17 to be the max number of arguments? No reason at all. We can now define Events this way:

// in EventRegistry.h
EVENT(GameStart, float m_startTime, size_t m_numPlayers)
EVENT(GunShot, float m_shootTime, size_t m_playerID, size_t m_bulletID)
EVENT(LevelEnd)

// in EventInclude.h

//...

#include "Util.h"  // has APPLY_ON_EACH macro definition

class Event
{
public: 
  explicit Event(EventType type) : m_type(type) {}
  EventType m_type;
};

#define SEPARATE_ARGS(arg) arg ;

#define EVENT(name, ...) \
class name##Event : public Event \
{ \
public: \
  name##Event() : Event(e_##name##Event) {} \
  APPLY_ON_EACH(SEPARATE_ARGS, __VA_ARGS__) \
};

#include "EventRegistry.h"

#undef EVENT
#define EVENT

Now each Event can have data added to it in the preprocessor step.

Conclusions

For the scope of a student project, defining individual classes for each EventType is perfectly feasible and reasonable! With this method, it becomes even more so, with the possibility of dozens of EventTypes being registered before compile time. However, for any larger scope with larger amounts of polish and Event sending, this method can easily become unsustainable very quickly. For any student or small studio developing a custom game engine, designing your Event system using the Observer pattern is something I would highly recommend as it is both a simple concept, quick to implement, and has minimal and clean code.





要查看或添加评论,请登录

Matthew R.的更多文章

社区洞察

其他会员也浏览了