../_images/cover.gif

Event Handling Pattern

The game design follows a simple observer pattern where event handlers respond when an event occurs. Unity Event Handling system has been based on the delegate type, which specifies a method signature and allows us to pass references to methods. The design pattern is shown in the system diagram below:

../_images/event_handling_system_diagram.png

System Diagram of Event Handling Design Pattern (ctrl + + to zoom in)

Event Manager

The centralised event manager script aims to manage connections between event listeners and event invokers. Therefore objects can interact without creating instances for them to know about each other. The core purpose of the event manager is to reduce the complexity of inflation as the program expands where more and more scripts need to know each other via instances. This idea can be shown in the plot below:

../_images/event_managing_complexity.png

Rather than defining each invoker and corresponding listener, an enum of event names has been declared in a separate file to extract all the events and actions of the same data type:

public enum EventName {
    HealthChangedEvent,
    SpeedUpActivatedEvent,
    GameOverEvent,
    TimerChangedEvent,
}

Then corresponding classes of events have been declared in separate files such as HealthChangedEvent:

public class HealthChangedEvent : UnityEvent<float> { }

Note

For the ease of implementation, I declare all the event as one float argument event.

Then, in the EventManager class, lists of invokers and listeners have been declared because we might have multiple invokers for a particular event:

private static readonly Dictionary<EventName, List<FloatEventInvoker>> Invokers =
    new Dictionary<EventName, List<FloatEventInvoker>>();

private static readonly Dictionary<EventName, List<UnityAction<float>>> Listeners =
    new Dictionary<EventName, List<UnityAction<float>>>();

Then we declare the Initalize() method to be called elsewhere when initalising the game session.

We create empty lists for all the dictionary entries, foreach goes through each of those four values in EventName enumeration. If the dictionary doesn’t have that name already, we create new lists for the invokers and listeners. If it already has the name, we clear the list because Initialize() method might be called multiple times as we play the game. We don’t want to try to add a new list if the dictionary already does contain a particular name, because it throws an exception when trying to add something with the same key as the dictionary already has.

public static void Initialize() {
    foreach (EventName name in Enum.GetValues(typeof(EventName))) {
        if (!Invokers.ContainsKey(name)) {
            Invokers.Add(name, new List<FloatEventInvoker>());
            Listeners.Add(name, new List<UnityAction<float>>());
        } else {
            Invokers[name].Clear();
            Listeners[name].Clear();
        }
    }
}

After that, we declare the float argument handlers to be called in listeners and invokers:

// Adds the given invoker for the given event name with float argument
public static void AddFloatArgInvoker(EventName eventName, FloatEventInvoker invoker) {
    // add listeners to new invoker and add new invoker to dictionary
    foreach (UnityAction<float> listener in Listeners[eventName]) {
        invoker.AddFloatArgListener(eventName, listener);
    }

    Invokers[eventName].Add(invoker);
}

// Adds the given listener for the given event name with float argument
public static void AddFloatArgListener(EventName eventName, UnityAction<float> listener) {
    // add a listener to all invokers and add new listener to dictionary
    foreach (FloatEventInvoker invoker in Invokers[eventName]) {
        invoker.AddFloatArgListener(eventName, listener);
    }

    Listeners[eventName].Add(listener);
}

Don’t forget to add removal functionality of the invoker when the invoker has been destroyed or no longer interacts with and scene objects to increase the code efficiency.

public static void RemoveFloatArgInvoker(EventName eventName, FloatEventInvoker invoker) {
    // remove invoker from dictionary
    Invokers[eventName].Remove(invoker);
}

Invokers

Instead of defining the invokers’ properties separately, we firstly define a parent class of invokers FloatEventInvoker. Dictionary once again has been utilised to enable us to invoke more than one event. We couldn’t just have a field for the UnityEvent<float>. We needed to have a dictionary fo UnityEvents so that classes can invoke multiple float events.The keys don’t have to be strings but any data type, in this case, keys are enumerations and values are float unity events.

public class FloatEventInvoker : MonoBehaviour {
    protected Dictionary<EventName, UnityEvent<float>> UnityEvents =
        new Dictionary<EventName, UnityEvent<float>>();

    ...
}

Then we define the function that adds the given listener for the given event name:

public void AddFloatArgListener(EventName eventName, UnityAction<float> listener) {
    // only add listeners for supported events, `ContainsKey` check for the key
    if (UnityEvents.ContainsKey(eventName)) {
        // get the invoker by putting the key in between square brackets
        UnityEvents[eventName].AddListener(listener);
    }
}

Note

This method has been called in EventManager class when we we declare the float argument handlers to be called in listeners and invokers.

For the children and grandchildren classes of invokers, we use Vehicle class as an example, register for HealthChangeEvent and GameOverEvent in the Start method:

protected override void Start() {
    ...

    UnityEvents.Add(EventName.HealthChangedEvent, new HealthChangedEvent());
    EventManager.AddFloatArgInvoker(EventName.HealthChangedEvent, this);

    UnityEvents.Add(EventName.GameOverEvent, new GameOverEvent());
    EventManager.AddFloatArgInvoker(EventName.GameOverEvent, this);
}

These events have been triggered when colliding with the player, each time colliding with the player, deduct one health point, and when the health point equals 0, trigger the game over event:

protected override void OnTriggerEnter2D(Collider2D coll) {
    if (coll.gameObject.CompareTag("Player")) {
        UnityEvents[EventName.HealthChangedEvent].Invoke(1.0f);

        // check for game over
        if (PlayerStatus.Health == 0) {
            UnityEvents[EventName.GameOverEvent].Invoke(0);
        }
    }

    base.OnTriggerEnter2D(coll);
}

Finally, don’t forget to unregister the invoker using the RemoveFloatArgInvoker static method we have talked above, since we don’t want the Vehicle script hanging around in that dictionary in the EventManager after the Vehicle game object itself was attached to gets destroyed.

protected override void OnDestroy() {
    EventManager.RemoveFloatArgInvoker(EventName.HealthChangedEvent, this);
    EventManager.RemoveFloatArgInvoker(EventName.GameOverEvent, this);
}

Listeners

In this game, there is only one current listener listening to all the events which are the PlayerStatus class. The listener is where we define the actual functionalities as an event handler, here we define the four event handling functions (the detailed functionality implementation will be discussed in separate sections):

// reduces health by the given damage
private void HandleHealthChangedEvent(float damage) {
    ...
}

// boost the player movement speed and turn invincible
private void HandleSpeedUpEffectEvent(float factor) {
    ...
}

// callback this function when buff timer finished
private void HandleBuffTimerFinishedEvent() {
    ...
}

// store the result and go to score page
private void HandleGameOverEvent(float unused) {
    ...
}

Then in the Start method, we register the event handling functions to the central event manager (the timer event handling follows a different pattern that would be described in below section):

void Start() {
    ...

    EventManager.AddFloatArgListener(EventName.HealthChangedEvent,    HandleHealthChangedEvent);
    EventManager.AddFloatArgListener(EventName.SpeedUpActivatedEvent, HandleSpeedUpEffectEvent);
    EventManager.AddFloatArgListener(EventName.GameOverEvent,         HandleGameOverEvent);
}

Timer Event Handling

The event handling pattern for the Customised Timer has been separated from the centralised event manager workflow. Logically the timer is a separate process, thus in a parallel system make it more modular and easier to debug. On the other hand, unlike the FloatEventInvoker where one or more float argument unity events could be triggered simultaneously, there should be only one kind of time pattern time starts > time changes > time flows > time finishes (as long as we are still in 3-dimensional world without applying Einstein’s relativity) thus no need for going through a central event manager as no various kinds of time events need to be flexibly manipulated. In this scenario, back to the plot in the previous event manager session above, going through the event manager is actually more complex than just using timer instances.

In this case, the CutomTimer acts as the invoker, we first declare the instance of events in the script without using dictionaries and enumerations:

private readonly TimerChangedEvent _timerChangedEvent = new TimerChangedEvent();
private readonly TimerFinishedEvent _timerFinishedEvent = new TimerFinishedEvent();

Then we define the function that adds the given listener for the given event name:

// Adds the given event handler as a listener
public void AddTimerChangedEventListener(UnityAction<float> handler) {
    _timerChangedEvent.AddListener(handler);
}

// Adds the given event handler as a listener
public void AddTimerFinishedEventListener(UnityAction handler) {
    _timerFinishedEvent.AddListener(handler);
}

In the listener which is also the PlayerStatus class, we first declare the timer instance and access to the invoker class by getting the CustomerTimer component from the game object, we declare the callback event handler in the bottom and add listener for no argument event in the Start method:

private CustomTimer _buffTimer;

...

void Start() {
    _buffTimer = gameObject.AddComponent<CustomTimer>();
    _buffTimer.Duration = ConfigUtils.BuffDuration;
    _buffTimer.AddTimerFinishedEventListener(HandleBuffTimerFinishedEvent);

    ...
}

...

// callback this function when buff timer finished
private void HandleBuffTimerFinishedEvent() {
    ...
}