I'm a bit new here still so bear with me, but I wanted to talk out this event system I made a while back.
And by made I mean translated from Game Coding Complete 4th Ed's C++ version to C#. Also, there are a few bits that I know for a fact I did weird and I'm not sure if that's because of Unity/C# or my idiocy. Here we go.
The whole thing goes through three overarching steps:
- Spawn event
- Process event
- Alert involved parties
GCC advises going for a class based approach because, as you will quickly learn if you ever do this, the number of events you have gets ridiculous and can become highly specialized to the specific event. It's a lot easier to set it up as classes. So, we start out with having a class of BaseEventData. BED is never called directly by anything, it's just a parent class that we're using to generalize our system. It looks something like this.
public class BaseEventData
{
private readonly static System.Guid evType = System.Guid.NewGuid();
public static System.Guid EvType
{
get
{
return evType;
}
}
public virtual System.Guid GetEvType() { return evType; }
private readonly float timeStamp;
public BaseEventData(float time = 0.0f) {timeStamp = time;}
public float GetTimeStamp() { return timeStamp; }
}
Note: one of the things I think is weird/poorly done is how the GUID needs
so many different ways to access it.
BED, as stated, holds only what every single event has, which, in this case, is just a GUID and timestamp. The GUID is used to distinguish events quickly, a kind of
who am I? typechecking that comes in handy. The timestamp may seem like a strange thing to add, and in a lot of cases it isn't going to be used, but it can be used if you have a ton of these events firing all the time. We'll get back to this in a bit, but suffice to say that the timestamp can help you out if things get slow.
With the BED set up, we can start working on the second and third step: processing events and sending them out. We won't do the first step until the end because we need to know what to call first.
The processing is handled in an EventManager, which for the sake of code simplicity/stupidity prevention I will make a singleton. Some people don't like singletons while others think they are super powerful. It really doesn't matter how you do it so long as you are sure you have everything talking to just
one EventManager, be it a singleton or whatever.
The EM does a lot of stuff: it keeps track of which classes listen to which events, takes in events, and calls the specific classes for each event. The first step is to set up that relationship between classes and events, which since we're in C#, also helps with calling those classes when that event fires. How does it do this? The fancy pants things called delegates!
Delegates are sweet, in case you don't know. They are, in a really simplified statement, function pointers. However, delegates
also have simple arithmetic applied to them. That is, you can add and subtract functions from it. So, a user can have one delegate call several functions, which can be built up one at a time and altered at runtime. Pretty freakin sweet!
We're going to use delegates to define which classes are listening to which events. This is nicer than having the EM carry a reference to the whole class for each event the class is listening to, because we could have a class listen to several events, and having the EM carry all of that extra data is just a mess. Not to mention that in order to use that option we would have to define an overarching
EventListener class that all classes inherit from, and that's just disgusting. So! Let's use a dictionary, because those allow easy lookup on a value, in this case we're going to use those GUIDs from the BED as the keys and a special delegate
EventHandler as the value. EventHandler is a void function which takes in a BED, just to keep things simple.
This guy is going to look something like this:
public delegate void EventHandler(BaseEventData data);
private Dictionary<System.Guid, EventHandler> EventListenerDict;
Adding and removing listeners from this is pretty easy.
public bool AddListener (System.Guid type, EventHandler helperDelegate)
{
if(!EventListenerDict.ContainsKey(type))
{
EventHandler temp = null;
EventListenerDict.Add(type, temp);
}
EventListenerDict [type] += helperDelegate;
return true;
}
public bool RemoveListener(System.Guid type, EventHandler helperDelegate)
{
if(EventListenerDict.ContainsKey(type))
{
EventListenerDict[type] -= helperDelegate;
return true;
}
return false;
}
The boolean return isn't really necessary, but can be helpful if your class has ambiguity in keeping track of whether or not it is in the system or not. These two functions are taking advantage of what I was talking about earlier, where we can use arithmetic operations or delegates to compile a list of all the functions we want to call.
Now we can move on to the part we've all been waiting for... adding events! First, we need to set up how exactly we're storing these events. Naturally, you'd want to use a Queue for these guys. First event fired should be the first event to be heard, right? It's a good idea to do this not just from a fairness point, but sometimes you have events that fire in a sequence, so the queue can make these operate correctly and as expected. The simple Queue however, does not have all we need. Imagine that we have a system where there are literally thousands of events fired each second. Okay maybe that's a little unrealistic, but imagine we have a system where a thousand events can fire occasionally all at once, while usually the events never go above 50 per frame. Normally, the game runs fine, but when those thousand events hit, the game stumbles, because our EM has to handle all of these events all at the same time, in one frame. That's no good!
So, we need to somehow limit how many events can be processed each frame. The way our EM is going to do it is through a timer: it can only run for x milliseconds before it just stops and waits for the next opportunity. Okay, so what does this have to do with the Queue itself? We just keep going afterwards, right? Events can very frequently cause new events to happen. If you have a simple queue like we do, those events will just go to the back of the queue, and you may never actually finish the queue up. That's a big problem! So, we're going to do a "double buffering" for events, have an active queue (events being currently handled) and an upcoming queue. In our version of the EM, we're going to abstract it to however many upcomings you want, but really two should be fine. If any of you have a good reason for more, tell me! I'm curious.
private const int NUM_QUEUES = 2;
private Queue<BaseEventData>[] eventQueues = new Queue<BaseEventData>[NUM_QUEUES];
private int activeQueue = 0;
Our adding events is pretty easy using this
public void EnqueueEvent(BaseEventData evt)
{
eventQueues[(activeQueue+1)%NUM_QUEUES].Enqueue (evt);
}
Basically this code is putting the event passed in into the next queue in the list. If we're at the final spot in the queue list, we wrap around to the first element.
Alright, so that's cool. Now all we have to do is handle the processing of the event.
public void ProcessEvents(float milliseconds)
{
float endTime = Time.time + milliseconds / 1000;
while(eventQueues[activeQueue].Count > 0 && Time.time <= endTime)
{
BaseEventData process = eventQueues[activeQueue].Dequeue();
if(EventListenerDict.ContainsKey(process.GetEvType()))
{
EventListenerDict[process.GetEvType()](process);
}
}
if(eventQueues[activeQueue].Count <= 0)
{
activeQueue = (activeQueue+1)%NUM_QUEUES;
}
}
So the first thing we do is figure out when we need to end. This is all done in milliseconds, so we need to convert those milliseconds to seconds. The while loop keeps going until either we're out of events or we've run out of time, whichever comes first. Then we check to see if we're done with the current events, and, if we are, move on to the next queue of events, again wrapping if we reach the end.
When I'm doing this in Unity, this function is called in FixedFrame. The float is really a number you have to toy with to get right. I went with 10, because I don't fire many events.
Alright! We have steps 2 and 3 down. Now onto step 1, spawning events.
The first thing we have to do is define events that make sense. For this example I'm going to use an example from my own space fighter game's EventData_SpaceshipDeath, which is fired whenever any spaceship blows up.
In GCC they recommend using a bitstream to store extra data, but for the most part this is overkill on a singleplayer, low impact game. They mostly say to use bitstreams for when you are on a network, which makes a lot of sense. However, since this isn't a network, we're just going to have our classes be defined with extra data members. Our EventData_SpaceshipDeath has the ID of the ship, the tag that is used for that ship, and the name of the pilot.
public class EventData_SpaceshipDeath : BaseEventData {
private readonly static System.Guid evType = System.Guid.NewGuid();
new public static System.Guid EvType
{
get
{
return evType;
}
}
public override System.Guid GetEvType() { return evType; }
private int shipID;
public int ID
{
get
{
return shipID;
}
}
private string tag;
public string Tag
{
get
{
return tag;
}
}
private string name;
public string Name
{
get
{
return name;
}
}
public EventData_SpaceshipDeath(Spaceship s) : base()
{
shipID = s.gameObject.GetInstanceID ();
tag = s.tag;
name = s.name;
}
}
Note: for some reason the GUID stuff is needed. I don't know why, and I feel like it is a problem with the BED's GUID setup. If someone knows how to fix it, that'd be awesome!
After that we have to define in our classes a way to spawn events, as well as listen for events. The system for listening goes something like this:
if(EventManager.Instance != null)
{
EventManager.EventHandler hlp = OnShipDeath;
EventManager.Instance.AddListener (EventData_SpaceshipDeath.EvType, hlp);
}
First we check to see if an EventManager exists (for the singleton implementation) and then we define an EventHandler for the function we want called when a spaceship dies. Then we pass the corresponding GUID and EventHandler to the AddListener function. Easy peasy!
I won't include the code, but the remove process is the exact same, just replace AddListener with RemoveListener. I usually call this in the OnDisable function in Unity, but really it depends on the system. You need to make sure you call RemoveListener though, but you'll usually get some errors in your EventManager class if you forget to RemoveListener.
Also note that you have to do the above for
each event you want to listen to. One possible addition to this system could be allowing one call to add multiple.
To create an event is even simpler:
EventData_SpaceshipDeath evt = new EventData_SpaceshipDeath (this);
EventManager.Instance.EnqueueEvent (evt);
We just create an EventData_SpaceshipDeath object with our ship as the parameter (or whatever ship you want), and then call EnqueueEvent. It should be noted that you do still need to check that EventManager.Instance is not null.
That's it! The biggest downside of this system is that these last few steps tend to get littered in weird places in your program. For me most of my coding errors come from forgetting to check if EventManager.Instance exists before doing things. If you have any questions, feel free to ask! Also, get Game Coding Complete 4th Ed! It's pretty sweet.