In the previous lesson we started implementing the Main Menu. In order to see what you were working on I provided a temporary “DemoFlow” method that connected together the various abilities of the menu into a sequence of tasks. In this lesson we will elaborate on the idea of a “Flow” as a reusable pattern – one which is an alternative to the StateMachine.
Overview
State Machines have been one of my favorite patterns to control the flow of my game overall, but rarely will you ever think a pattern is perfect. Some of the things I didn’t like included:
- The way that individual states were necessarily coupled together (at a minimum, they were responsible for knowing what state or states that they could transition to)
- Nested state machines require extra polling or call backs (for example, how does the parent state know when the child state machine is complete?)
- Transient data can’t be easily shared between states (if a state provides data, it may need to be saved to a more global location for use by other states)
These are all issues that I feel are solved by using async and await. Consider the following snippet and consider how it would look using StateMachine in comparison:
// In the old pattern, this "parent" flow could have been a nested state machine. Compare how simple and readable it is now. async UniTask SomeParentFlow() { while (true) { // In the old pattern, a state representing "doSomeStuff" would need to be coupled with the next state representing "someChildFlow", and would tell the state machine to transition to it. In the flow example, it is completely independent and only performs its own job, not caring what comes next. await doSomeStuff(); // In the old pattern, the need to pass transient data from one state to another may have resulted in more global state, or forced coupling that wasn't strictly necessary var result = await someChildFlow(); // In the old pattern, the use of polymorphism on the state as an object, would prevent you from passing custom information to the entrance of a new state. In addition, the need to exit from the end of a nested state machine back to its parent state machine would require extra code. In this example, it simply returns control to the parent loop without even needing to know it is the end of the nested flow. await someOtherChildFlow(result); } }
Of course, there are aspects of the StateMachine Pattern that I do like. I like the simple interface of a state: it does something when it enters, it often has an update loop, and it does something when it exits. Of course you aren’t required to implement these methods, but as a general idea of what to expect, I feel like it helps keep my code intuitive and my thoughts organized.
Getting Started
Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.
Main Menu
A frequent “challenge” when architecting a game is how do you get references to what you need where you need it. This is one of the thoughts that inspired me to come up with the interface injection pattern. One thing that may not have been obvious is that you can even use it for things like our scene’s MainMenu. We would start by making an interface. Open the MainMenu script and add the following:
public interface IMainMenu : IDependency<IMainMenu> { void Setup(bool hasSavedGame); UniTask TransitionIn(); UniTask<MainMenuOption> SelectMenuOption(); UniTask TransitionOut(); }
I will eventually want to manage the “flow” of the main menu via another script, but the things the menu can do need to be exposed. That includes being able to do the menu Setup – where the menu needs to know whether or not to show the continue button, the transitions in and out, and obtaining the selected menu option. We already had the Setup, TransitionIn, SelectMenuOption and TransitionOut methods, but they all need to be made public. Add the public keyword to the beginning of each of these method definitions in the MainMenu class.
Next we need to make the MainMenu class conform to the interface. Update the class definition:
public class MainMenu : MonoBehaviour, IMainMenu
We can use the MonoBehaviour methods OnEnable and OnDisable to handle registering and resetting registration of our interface. Add the following to the class:
void OnEnable() { IMainMenu.Register(this); } void OnDisable() { IMainMenu.Reset(); }
Now for some cleanup. We no longer need the Start or DemoFlow methods, so go ahead and delete both.
Main Menu Flow
Create a new folder: Assets -> Scripts -> Flow. Then create a new script inside, named MainMenuFlow and add the following:
using Cysharp.Threading.Tasks; using UnityEngine.SceneManagement; public interface IMainMenuFlow : IDependency<IMainMenuFlow> { UniTask<MainMenuOption> Play(); } public class MainMenuFlow : IMainMenuFlow { public async UniTask<MainMenuOption> Play() { // MARK: - Enter await SceneManager.LoadSceneAsync("MainMenu"); var canContinue = IDataSystem.Resolve().HasFile(); var menu = IMainMenu.Resolve(); menu.Setup(canContinue); await menu.TransitionIn(); // MARK: - Main Loop var result = await menu.SelectMenuOption(); // MARK: - Exit await menu.TransitionOut(); return result; } }
I created an interface for the flow of the main menu. We know that we can “Play” this as a task that will return a selected menu option when it has completed. The class that conforms to the interface handles all the details. It knows how to load the “MainMenu” scene, and can then find the MainMenu script instance because it will have registered itself to the IMainMenu interface.
The flow will handle much of what the demo flow did in the last lesson, except that it is doing Setup using “real” integration – it actually queries whether or not our data system has a saved data file.
Loading Scene
Although our project is now more “finished” than it was in the previous lesson, it may feel a bit less exciting because pressing play in the “MainMenu” scene no longer does anything. It worked before because the “Start” method was triggering a flow, and the flow that we have now isn’t triggered by anything. Let’s go ahead and fix that by creating the “LoadingScene” which will actually be the first scene our app loads.
Create a new Scene using the Basic template, and save it to Assets -> Scenes named LoadingScreen. Feel free to get fancy by adding some sort of label, spinner, or progress bar if you want. All I have done is to set the camera’s background color to black and its clear flags to solid color.
Add an empty game object named App Flow to the scene. Create a new C# script at Assets -> Scripts -> Flow named AppFlow. Attach the script to the game object in the scene.
Open the AppFlow script and add the following:
using UnityEngine; using Cysharp.Threading.Tasks; public class AppFlow : MonoBehaviour { async UniTaskVoid Start() { DontDestroyOnLoad(gameObject); IMainMenuFlow.Register(new MainMenuFlow()); IDataSerializer.Register(new DataSerializer()); IDataStore.Register(new DataStore("GameData")); IDataSystem.Register(new DataSystem()); while (true) { await IMainMenuFlow.Resolve().Play(); await UniTask.NextFrame(this.GetCancellationTokenOnDestroy()); } } }
The code here is just placeholder. It will get the MainMenu scene working again. Of note, there is a new version of the Start method. It is similar to the normal MonoBehaviour version as far as when and how it will be triggered, but the method is an async method and it returns UniTaskVoid. That particular task is a lightweight version of UniTask.
Inside the Start method, I mark the script’s GameObject such that it won’t be destroyed when changing scenes. This is important because I am using a cancellation token that triggers when the object gets destroyed. Now, it can survive the scene change and the game loop can continue running.
Next I handle the interface injection for the main menu flow, and data related systems that it relies on. Finally, I can do a “game loop” that for now consists only of our main menu.
Build Settings
Before we can dynamically change scenes, we need to add our scenes to the Build Settings. From the File Menu, choose “File -> Build Settings…”. At the top of the window you will see “Scenes In Build”. Drag and drop the “LoadingScreen” and then “MainMenu” scene from the Assets pane. If you did it correctly, the “LoadingScreen” will be at the top showing an index of 0, and “MainMenu” will have an index of 1.
Play Test
Make sure the “LoadingScreen” is the current scene, then press Play. You should see the “MainMenu” appear, complete with transitions and the ability to select a menu option etc. However, if you stop the test during an animation you may notice an error printed to the console: MissingReferenceException: The object of type ‘RectTransform’ has been destroyed but you are still trying to access it.
Let’s pretend that I added this bug on purpose – as a teaching opportunity π
Bug Hunt
Looking through the stack trace I was able to determine that the missing reference exception was thrown inside of the LayoutTween’s OnUpdate – we use that inside the MainMenu script, so let’s open that script and take a look. In the Enter method, I animate the Layout of the rootPanel. I even pass along a CancellationToken! So what could the issue be? Feel free to try and figure it out, but if you give up, then keep reading.
The problem is that we only call Cancel on the Token’s Source if the animation actually completes, or is skipped. When we simply quit the app, neither one of those scenarios can occur. Therefore the task continues to play, but Unity will still destroy the scene and objects, thus causing a null reference in my animation code.
One solution to this problem is that I need to also call Cancel on the Token’s Source in the event that the object is destroyed. If we promote the source from a local variable in the “TransitionIn” method, to a field in the class, then we can more easily grab a reference later, such as in the OnDisable method.
Add the following field to the class:
CancellationTokenSource cts = new CancellationTokenSource();
Then modify TransitionIn since we don’t need a local source:
public async UniTask TransitionIn() { await UniTask.WhenAny( Enter(cts), SkipEnter(cts)); }
Add a new method that can Cancel, Dispose and null out the field:
void CancelToken() { if (cts != null) { cts.Cancel(); cts.Dispose(); cts = null; } }
Where we had called cts.Cancel()
in the Enter and SkipEnter methods, now just call the new CancelToken method. Finally, also call CancelToken from OnDisable.
Another bug could trigger from the FadeOut animation in the TransitionOut method. This one is simpler to fix – just pass along the on destroy token:
await rootGroup.FadeOut().Play(this.GetCancellationTokenOnDestroy());
Another play test should (hopefully) show that no further bugs remain.
Summary
In this lesson, we made our main menu conform to an injectable interface, then used an external “flow” to manage displaying and interacting with it. We discussed how these “flows” can be a new type of pattern that we will use to replace state machines.
If you got stuck along the way, feel free to download the finished project for this lesson here.
If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!