Unity3D - (Co)Routine stitching with ScriptableObjects and BeauRoutinePosted by dannymate on 2021-09-29
In this post I'm going to be detailing how and why I implemented a pluggable ScriptableObject action system with coroutines and the fixes for issues that arise using coroutines modularly and with scriptable objects.
When implementing a pluggable action system with ScriptableObjects (SOs from here) the typical implementation is something like this: A wrapper SO with an array of a base action class.
//Wrapper SO
ActionObject[] actions;
public void TriggerActions(GameObject caller)
{
foreach(ActionObject actionObject in actions)
actionObject.DoAction(caller);
}
//Base Action
public abstract void DoAction(GameObject caller);
What is essentially happening in the Wrapper above is equivalent to knocking over the starting domino in a chain where each starting domino is an action. This works well if changing variables, calling SFX or triggering events; things that work independently from eachother or in other words no matter what order you put the actions in you'll get the same result (with execptions of course). Good for fire and forget type stuff like an FPS or a Runner.
However, what if we wanted to wait for one of these actions to end before moving on to the next one, say we wanted to wait for an animation to play before showing dialogue. Well with the above solution what would happen is the animation would start playing and then the dialogue would show up the exact frame the animation starts. Not ideal. The solution to coordinating systems like this in Unity is coroutines.
Coroutines have a special power which is that it can halt its code execution. This allows you to 'WaitForSeconds' or yield its excution mid flow until the completion of another coroutine (existing or new). All you have to do is: yield return IEnumeratorMethod() or yield return coroutineVar. Now imagine our previous example, we could start a coroutine to play the animation and when that finishes we can then show our dialogue.
void Start()
{
StartCoroutine(DoAction());
}
IEnumerator Example()
{
// Waits for animation coroutine to finish
yield return PlayAnimation();
ShowDialogue();
}
Now currently our ShowDialogue isn't a coroutine but what if we had more actions that we wanted to execute only this time after the player finishes reading the shown dialogue? Well we can turn the ShowDialogue method into a coroutine, yield to it and in the Dialogue action we can tell it to wait until the dialogue has been dismissed. Now currently we're writing each action manually so lets adapt our Wrapper and Base Action code examples to show usage with coroutines:
//Wrapper SO
ActionObject[] actions;
// Replaced void with IEnumerator
public IEnumerator TriggerActions(GameObject caller)
{
foreach(ActionObject actionObject in actions)
yield return actionObject.DoAction(caller); // Yield to action
}
//Base Action
// Replaced void with IEnumerator
public abstract IEnumerator DoAction(GameObject caller);
All we have to do is replace 'void' with 'IEnumerator' and yield to each new action. Now we would create an action to play our animation and an action to show dialogue and drag them into our Wrapper's array. The order of actions in the array is very important. It's also worth noting that this solution can be used in conjunction with its void counterpart.
Now before I move on we've got a couple things to note. What if we DID want the dialogue to play at the same time as the animation? Well there's a couple solutions:
1. Wrap the Animation and Dialogue Actions under another Action. Then inside this parent Action call StartCoroutine(action.DoAction()) for each of action seperately while making sure to store them in a variable. We can then yield to each coroutine variable until done. We can also create a generic version of this that takes any number of actions and executes them in parallel and waits for all of them to finish before moving on.
2. Create a bool in the dialogue or animation action that skips them waiting allowing the Wrapper SO to continue onto the next action.
3. If you wanted to show an action at a set point in an animation you can add callbacks to the PlayAnimation method that Invokes the callback at a specified point triggering the dialogue. You can even make the callback a coroutine so the animation execution stops while the callback is executing.
4. All of the above solutions but together.
Now it's now worth mentioning that ScriptableObjects don't have the ability to call the 'StartCoroutine' method by themselves although they're capable of 'yield' if already executing as a coroutine. There are a few ways around that:
1. Having a MonoBehaviour running in scene that on Awake sets a static delegate variable. The downside being that the coroutine is tied to that one object:
public static class Toolbox
{
public delegate Coroutine StartCoroutineDelegate(IEnumerator routine);
// Set to StartCoroutine so it has the exact same syntax as the normal StartCoroutine method.
public static StartCoroutineDelegate StartCoroutine { get; set;}
}
void Awake()
{
Toolbox.StartCoroutine = this.StartCoroutine;
}
2. Using a library called BeauRoutine. It has the ability to start coroutines anywhere with 'Routine.Start()'. I'll come back to this library in a bit.
3. There's more sophisticated ways if you google "How to StartCoroutine in ScriptableObjects"
Another issue is if we don't need a coroutine to perform the action we're doing such as setting a boolean. For this, we can either just chuck it into a coroutine action and tack on a 'yield break;' at the end or we can do some kind of hybrid system of void and IEnumerator as mentioned before.
So to let everybody know where I was at with my game(which aren't neccessarily the correct decisions):
- Coroutine Wrapper and BaseAction
- All three solutions for running actions simultaneously.
- Initially had the static callback StartCoroutine before switching over to BeauRoutine.
- I wrap all my actions in an IEnumerator. Therefore, if I don't need the functionality of a coroutine I just tack on 'yield break;' at the end of 'DoAction'.
So now I can explain a bit about BeauRoutine and what caused me to switch to it from my StartCoroutine delegate. Replacing my StartCoroutine delegate with the BeauRoutine version was just a side affect of fixing a few bugs that start to pop up in regards to frame timings with modular coroutines.
Coroutines have one major issue. Due to the way Unity handles coroutines, almost everytime you yield it uses up a frame. This can cause havoc if you're indirectly controlling something. In my case I have a sprite that changes in response to certain booleans. The check occurs roughly once a frame and inside of a coroutine (seperate from the action system). So say we have a point-and-click game and you pick up an item, the item will disappear from screen a few frames before it shows in the inventory. There were quite a few little things like this. Now it's not really a big deal but it didn't sit right with me.
I remembered a few weeks prior I'd found a library called BeauRoutine which is all about coroutines. I browsed through the readme and it had everything I needed and more.
'Inline()' which removed the one frame 'yield' wastes. (Word of warning you can execute too much over single frame). This allowed me to turn what was a series of IEnumerator actions seperated by a single frame into effectively one stitched together IEnumerator method. This fixed a large chunk of the bugs I'd noticed. Those regained frames are important for the FEEL of your game. It may not matter here and there but over time you do feel the effects, a sort of near imperceptable lag.
However even with "Inline()" there were still small graphical glitches, in fact it'd reversed my issue with item pick up. The item would now show in the inventory a frame before it was removed from screen. My actions were executing faster than my graphics would update.
This is where BeauRoutine 'SetPriority(int)' comes in. It changes the order a coroutine is executed, the higher the priority the sooner its executed and the lower the later. For me I could make my Sprite Coroutine execute last which allowed for the effects of sequences to ripple to it in the same frame rather than the frame after.
Now along with all the solutions I've mentioned I have a fully coordinated modular action system where a sequence for all intents and purposes acts like one stitched together IEnumerator method. Even if you don't need to do what I've detailed here I really would recommend looking into BeauRoutines if you feel like Unity coroutines are a bit restrictive. If you've read this far thanks for reading.