0
\$\begingroup\$

I'm trying to make a centralized event system for my unity game - a central hub where other parts of the game can register/broadcast their own types of events. This means I have a singleton GameEventSystem that is globally accessible.

This is an example structure:

1

I have tried to combine multiple sources, trying to figure out how to do this, and this is where I got.
I have a Dictionary where the key is Type(that supposed to be T : GameEvent), and the value is GameEventListener<GameEvent>, GameEventListener is supposed to be Action<T : GameEvent>.
What I'm trying to achieve is a singleton class that manages Dictionary<T : GameEvent, GameEventListener<T : GameEvent>>.

The implementation of RegisterListener:

Type eventType = typeof(T);

GameEventListener<GameEvent> listenerTemp;
if (EventListeners.TryGetValue(eventType, out listenerTemp))
{
    listenerTemp += (GameEventListener<GameEvent>)listener;
    EventListeners[eventType] = listenerTemp; // Copy the newly aggregated listener back into the dictionary.
}
else
{
    listenerTemp += (GameEventListener<GameEvent>)listener;
    EventListeners.Add(eventType, listenerTemp);
}

And this would be the UnregisterListener:

Type specificGameEventType = typeof(T);
if (!AnyListeners(specificGameEventType, this.EventListeners))
{
    // No one is listening, we are done.
    return;
}

EventListeners[specificGameEventType] -= (GameEventListener<GameEvent>)listener;

Passing an Action<UnitDiedEvent> to RegisterListener will lead to InvalidCastException: Specified cast is not valid., at the following line:

listenerTemp += (GameEventListener<GameEvent>)listener;

Why can't I cast a GameEventListener<UnitDiedEvent> class, to GameEventListener<GameEvent>?

Is there a better approach to achieve a centralized event system in unity?
From my research I can't just make GameEventSystem a static class and expect it to be accessed by the rest of the game objects/components. That makes global systems/managers like this one a little problematic to instantiate and access.

I think I'm not fully grasping events/delegates and generics as a whole to tackle this on my own.

\$\endgroup\$
3
  • \$\begingroup\$ What in your research says that you can't access a static class accessible from the rest of the game? Much of the Unity API is accessed in exactly this way. As for the invalid cast, this is because arguments to delegates are contravariant: you can invoke the delegate with a more derived argument, and that's still a valid value for the less derived type it expects. But if a delegate expects a Cat, you can't safely pass it an Animal — it's told you it expects only cats or their derived breeds. \$\endgroup\$ – DMGregory Apr 13 '20 at 23:47
  • \$\begingroup\$ All examples of singletons in unity were derived from MonoBehaviour, and a static property was assigned with "this". As far as I understand, if I want this class to interact with objects in the scene, I need to derive from MonoBehaviour. What is happening here is that my delegate expects GameEvent, and I'm passing an Action that expects UnitDiedEvent which is derived from GameEvent. Following the link and your explanation, that should work, as I'm passing and trying to cast a more derived class to a less derived class. \$\endgroup\$ – EcksDy Apr 14 '20 at 10:20
  • \$\begingroup\$ GameObject, Physics, Graphics, Mathf, Application, Screen, etc. are not derived from MoboBehaviour and can still be accessed from objects in the scene. You've read the covariance examples from the link, which show you can assign a value of a more derived type to a variable of a less derived type. Now read the contravariance examples where they show that delegate arguments follow the opposite rule: they assign an Action<Object> value (less derived argument) to an Action<String> variable (more derived argument), the opposite of what you've described in your comment. \$\endgroup\$ – DMGregory Apr 14 '20 at 12:02
4
\$\begingroup\$

This looks like a situation you can tackle with the curiously recurring template pattern.

First, we'll define a generic GameEvent, that could work on payloads of some unknown type, with static methods to register/unregister listeners who care about events of that type, and broadcast the event to all listeners:

public abstract class GameEvent<T>
{
    static event System.Action<T> onEvent;

    public static void RegisterListener(System.Action<T> listener) {
        onEvent += listener;
    }

    public static void UnregisterListener(System.Action<T> listener) {
        onEvent -= listener;
    }

    public static void BroadcastEvent(T eventData) {
        onEvent?.Invoke(eventData);
    }
}

Then we can specialize it by defining derived types of GameEvent<T> where T is their own type:

public class UnitDiedEvent : GameEvent<UnitDiedEvent>
{
    public enum CauseOfDeath {
        Unknown,
        NaturalCauses,
        Murder
    }

    public GameObject deadUnit;
    public CauseOfDeath cause;
    public GameObject killer;
}

Now we can register/unregister a listener by calling a static method on the derived event type we care about:

UnitDiedEvent.RegisterListener(OnDeath);

And we can fire the event to all listeners similarly:

var death = new UnitDiedEvent();
death.deadUnit = gameObject;
death.cause = UnitDiedEvent.CauseOfDeath.NaturalCauses;

UnitDiedEvent.BroadcastEvent(death);

And we've done it all without any casting, hashing, or dictionary lookups along the way. :D


As to why your original attempt didn't work, it's because arguments to delegates are contravariant.

We generally find covariance most familiar:

Animal someAnimalVariable = new Animal();
Cat someCatVariable = new Cat();

// This is valid, because all cats are animals, so we preserve the meaning of
// "someAnimalVariable contains an animal"
someAnimalVariable = someCatVariable;

// This is not valid, because not all animals are cats.
someCatVariable = someAnimalVariable;
// If someAnimalVariable contained a Turtle, our code would become very
// confused when it tries to call someCatVariable.Meow() to make a turtle meow!

But delegate arguments are contravariant:

System.Action<Animal> someAnimalAction;
System.Action<Cat> someCatAction;

// This is valid. (Delegate with a less derived argument 
// assigned to a variable with a more derived argument)
someCatAction = someAnimalAction;

// This is not. (Delegate with a more derived argument
// assigned to a variable with a less derived argument)
someAnimalAction = someCatAction;

Why this difference? Think about what happens when code tries to call someCatAction:

Cat myFavouriteKitty = GetFavouriteCat();
someCatAction(myFavouriteKitty);

As long as someCatAction accepts an animal, this call is still valid, since all cats are animals. So any System.Action<Animal> is valid as a value for a delegate we want to pass cats to, like someCatAction.

Now think about what happens when code tries to call someAnimalAction:

Turtle myFavouriteTurtle = GetFavouriteTurtle();
someAnimalAction(myFavouriteTurtle);

This call looks valid, since we're calling a delegate that says it expects an animal, and passing it a turtle, which is an animal.

But if somewhere else in your code you were allowed to say this:

someAnimalAction = someCatAction;

...then now the code above is trying to invoke a delegate that expects a cat, but passing it a turtle! So if the variable is of type Action<Animal>, then only delegates that accept all animals (or possible more types) are valid values, because they're the only ones that could handle any invocation that tries to send any old animal down the pipe.

\$\endgroup\$
3
  • \$\begingroup\$ This answer is beyond comprehensive! I was thinking to have a one class to accept different variations of GameEvent and un/registering or broadcasting from that one class. Your first solution goes the other way if I understand it correctly. It gives each variation of GameEvent the ability to un/register/broadcast itself by calling the static methods derived from the base GameEvent, so I can just call the specific GameEvent I want and it will handle the process, without duplicating the un/register/broadcast implementation. Very interesting and clean, thank you! \$\endgroup\$ – EcksDy Apr 15 '20 at 9:06
  • \$\begingroup\$ As to your explanation why my attempt didn't work, I've got to a point when I understand it on an intuitive level why it doesn't work. It's because a delegate of a derived class will necessarily be able to work with it's parent class, it derives from it, but delegate of the parent class can't accept the variations of its children since it's not aware of what's implemented in them, hence delegates are contravariant. I need to solidify my understanding of contravariance and delegates in general. \$\endgroup\$ – EcksDy Apr 15 '20 at 9:17
  • \$\begingroup\$ One possible improvement to make here: you can separate the type for data being passed to the event from the type that handles the registration/broadcast. This would let you make the data type a struct, so you don't incur the same allocation/GC overhead when firing a lot of short-lived event payloads. \$\endgroup\$ – DMGregory Apr 15 '20 at 12:05

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.