D20 RPG – Main Menu

Controlling a sequence of events over time can be tricky. This is particularly the case when there is a lot of state that can be changed from more than one path through your code. In this lesson we will see how task based asynchronous programming can approach this problem while creating a Main Menu scene.

Overview

Let’s suppose you have been assigned to create a Main Menu with the following requirements:

  1. The logo animates in from the side.
  2. After the logo slides in, the menu options fade in.
  3. User input can skip the entire entry transition.
  4. A user must only be able to select one menu option, and may not select it until after the transition has completed.
  5. The menu options and logo fade out after a menu selection.

There are many ways to approach this task. In previous projects I may have approached problems like this with a combination of: Coroutines, a Finite State Machine, and call-back methods. However, I have found that tasks elegantly solve the same problems as all the other patterns combined.

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.

UniTask

Unitask is a library that provides task based asynchronous programming (async and await), but which is also specially integrated into Unity. The UniTask task is allocation free, meaning it won’t need to be cleaned up by the garbage collector. There are also a variety of methods and extensions built to work with Unity features such as its player loop, events, scene manager, addressables, etc.

Follow along with their guide to install via the unity package manager.

This project uses something called Assembly Definitions so that I can do unit-testing. Because of this there are a few more setup steps required:

  1. In the project pane, navigate to Assets -> Scripts.
  2. Select the Scripts.asmdef asset.
  3. In the inspector, add a new “Assembly Definition Reference” which is “UniTask”.
  4. At the bottom of the inspector, click the Apply button.

Setup Assembly Definition Screen Grab

Lesson Materials

Download and unzip, then import this unity package into the project. It contains a modified copy of my tween animation library that I use on most of my projects. The most recent changes involve making it compatible with UniTask.

Import Package

Tip:

For anyone interested, the original posts where I created the animation tween library can be found here: Part 1 and Part 2. Keep in mind that the versions used in this project have changed.

UniTask also has support for DoTween in case you are already familiar with it, or prefer to work with it instead.

The unity package also includes the Main Menu scene, with already constructed UI and a few new scripts. There isn’t anything complex going on here, and I want to keep the focus on the programming side. If you open the Main Menu scene, you will find a simple Canvas setup, a programmer art game logo, and placeholder menu buttons that will eventually be used to start a new game or continue a saved game.

Main Menu Screen Grab

Main Menu Scene

If you haven’t already opened it, go ahead and open the MainMenu scene. Look in the Hierarchy pane and find and select the game object named “Demo”. Now if you look in the Inspector pane you can see that I have added a script named “MainMenu”. It has already been configured with references to various components within the hierarchy of that game object, as well as a few new data types. In the Assets -> Scripts -> UI folder you will find:

  • Anchor
  • Layout
  • MainMenu
  • Pivot

The MainMenu script is where we will add new code for this lesson, to implement the requirements listed above in the Overview section. The other scripts make it easier to programmatically handle the layout of Unity UI objects. For example, if you look at the RectTransform component of the Root Panel (one of the child objects within the Demo object hierarchy), then you will see something like this:

Rect Transform Screen Grab

There is a graphic in the upper left that represents the anchors for the RectTransform. The one in the image says middle center, where middle represents the vertical part of the layout and center represents the horizontal part of the layout. The Min and Max points represent that as having a value of 0.5 for both the X and Y values. You can click the image for another view of a variety of Anchor Presets:

Anchor Presets Screen Grab

You can click one of the options such as the “top left” icon in the upper left. If you do, then you will notice that the Min and Max points hold new values (0, 1). I have captured this combination of presets and min and max values in the script named Anchor where the Anchor is an enum with cases for each of the anchor presets. An extension on the enum allows you to easily get the corresponding min and max values of that preset. Then an extension on RectTransform allows you to configure an instance of that component by assigning a case of the Anchor enum.

The Pivot script is similar to the Anchor in that it is also an enum and has many of the same presets. It also has extensions to get the equivalent Vector2 representation of each case and to configure a RectTransform with one.

The anchor, pivot, and position are all three needed to fully layout a RectTransform. You can find this in the Layout script which is a struct made of of an Anchor, a Pivot and a Vector2 for the anchored position. This script also has an extension on RectTransform to be able to configure all of the above using the one struct.

The way I think of it, the Anchor is kind of like specifying an origin relative to the “parent” of the RectTransform. The Pivot is kind of like specifying the origin of the “panel” itself, and the “position” is the offset of the UI within its new coordinate space. Look at the Layout fields on the MainMenu:

  • Offscreen:
    • Anchor: Middle Right
    • Pivot: Middle Left
    • Position: (0, 0)
  • Onscreen:
    • Anchor: Middle Center
    • Pivot: Middle Center
    • Position: (0, 0)

I have represented an “Offscreen” layout for the logo’s panel because the “parent” origin point is on the right side of the screen while the panel’s own origin is on its left side. Given that there is no offset position, when we assign this layout, the logo will be off screen to the right.

Offscreen Layout

I have represented an “Onscreen” layout for the logo’s panel because the “parent” origin point is at the center AND the panel’s own origin is at the center. With no offset, when we assign this layout, the logo panel will appear at the center of the screen.

Onscreen Layout

Tip:

You can read more about Layout for Unity UI at their guide.

Main Menu Scene

Now that we have covered what has been provided, let’s begin to implement the assignment from the overview. Open the script: Assets -> Scripts -> UI -> MainMenu. Then add the following snippet to the class:

void Start()
{
    Enter();
}

void Enter()
{
    rootPanel.SetLayout(offscreen);
}

This shows an example of using the new extensions found in the Layout script. It will snap the logo to its offscreen position (no animation). If you press play, you should see that occur. This is synchronous programming, because when the method runs it will execute each statement, in order, without waiting.

Asynchronous Programming

Next let’s change the script to use asynchronous programming which will let the code run over time. Change the methods to look like this:

void Start()
{
    Enter().Forget();
}

async UniTask Enter()
{
    rootPanel.SetLayout(offscreen);
    await rootPanel.Layout(offscreen, onscreen, 5).Play();
    Debug.Log("Finished");
}

There are a few bits to point out. Here we are calling asynchronous code (the Enter method) from synchronous code (the Start method). I added the “Forget” call to the result of calling the Enter method to avoid a warning about needing to use the “await” keyword.

We modified the return value of the Enter method to return a UniTask. You can think of this as a “Job” that runs until it is finished. It is similar in some ways to a Coroutine. We also added the keyword “async” to the Enter method which lets us use “await” within the method. When executing the statements of an “async” method, it will still run them in order, but it will pause at any “await” statement until the associated statement has completed. In this case, we are awaiting the played animation of the logo coming in from its offscreen position to its onscreen position (this code is thanks to the animation tween library we included earlier). The ‘5’ is the number of seconds the animation should take. I set it to be somewhat slow so that it would be easier to tell later on when we were skipping it. I also added a Debug.Log statement to the end of the Enter method. If you play the scene, watch the output in the Console and you will see that the “Finished” log will not appear until after the animation completes.

By now you may have guessed how simple it will be to make the logo animate in, and then make the menu group fade in afterward. Modify the Enter method to look like the following:

async UniTask Enter()
{
    rootPanel.SetLayout(offscreen);
    menuGroup.alpha = 0;
    await rootPanel.Layout(offscreen, onscreen, 5).Play();
    await menuGroup.FadeIn(1).Play();
}

We initialize the CanvasGroup for the menu buttons so that it begins with its alpha at zero (meaning it is invisible), and then after the await for the animation of the logo, we use another await for fading in the menu. Two steps done!

When Any

The animation is nice, but people are impatient these days. We need a way to skip the animation. If I were to just “say” what I want to happen, it would probably sound like: “When either the animation has finished, or the user has skipped, then … (next step of the flow)”. There is a similar syntax in “UniTask” that has a very similar feel called “WhenAny” which means that you can allow the async code to continue when “Any” of a collection of tasks completes.

Add the following methods:

async UniTask TransitionIn()
{
    await UniTask.WhenAny(
        Enter(),
        SkipEnter());
}

async UniTask SkipEnter()
{
    while (true)
    {
        await UniTask.NextFrame();
        if (Input.anyKey)
        {
            rootPanel.SetLayout(onscreen);
            menuGroup.alpha = 1;
            break;
        }
    }
}

I added the method TransitionIn to represent the two ways that the menu can appear. We can either let the animation run, or we can skip it, but at the end of the transition everything should be fully visible (logo in the correct location and buttons faded in).

I added the SkipEnter method to watch every frame for any user input, and when found, to forcibly set the logo and fading to their end state. It will break out of the loop, which completes the task, and will allow any code waiting on the TransitionIn to continue.

Modify the Start method to call TransitionIn instead of Enter:

void Start()
{
    TransitionIn().Forget();
}

Now save and press play and see what happens when you try to skip the animation.

Oh no, you may notice a brief flash of the menu being in the correct place, then going back to where it was and finishing the animation. The important bit to take from this is that WhenAny doesn’t CANCEL the other running tasks, it merely waits until one of them is done.

Cancelling Tasks

The fact that our tasks can keep running is currently a problem that needs to be fixed. Luckily, tasks have a convention where you can pass along something called a cancellation token. Many of them have built-in logic to watch for the token being cancelled and will stop on their own. Modify the exiting methods as follows:

async UniTask TransitionIn()
{
    var cts = new CancellationTokenSource();
    await UniTask.WhenAny(
        Enter(cts),
        SkipEnter(cts));
    cts.Dispose();
}

async UniTask Enter(CancellationTokenSource cts)
{
    rootPanel.SetLayout(offscreen);
    menuGroup.alpha = 0;
    rootGroup.alpha = 1;
    await rootPanel.Layout(offscreen, onscreen, 5).Play(cts.Token);
    await menuGroup.FadeIn(1).Play(cts.Token);
    cts.Cancel();
}

async UniTask SkipEnter(CancellationTokenSource cts)
{
    while (true)
    {
        await UniTask.NextFrame(cts.Token);
        if (Input.anyKey)
        {
            cts.Cancel();
            rootPanel.SetLayout(onscreen);
            menuGroup.alpha = 1;
            break;
        }
    }
}

In the TransitionIn method, we create a new instance of a CancellationTokenSource – which is a class that knows how to create the cancellation token, and can handle cancelling it. We will share the same source for both the Enter and Skip methods, and then once our WhenAny is complete, we make sure to clean up by calling Dispose.

In the Enter method, we pass the cancellation token to both of the awaited animation statements. This will cause the animation to end early if we request a “Cancel” from outside the task. In the event that both the logo tween and fade-in tween complete, we will call “Cancel” on the token source, so that our SkipEnter can know to stop looking for input.

In the SkipEnter method, we pass the cancellation token to the awaited NextFrame. Upon a request to cancel, this will cause execution of the method to end (including breaking out of the while loop). Inside the check for any input, we also call “Cancel” on the token source, so that the animation will know to stop.

Task Return Values

One of the coolest features of working with Tasks is that they can also return a value, and you can await that returned value. We will demonstrate this by awaiting a menu selection and then printing the value of the option that the user has selected. The menu selection will be represented as an enum. Add the following outside the MainMenu class:

public enum MainMenuOption
{
    NewGame,
    Continue
}

Next, replace all of the current MainMenu methods with the following:

void Start()
{
    DemoFlow().Forget();
}

async UniTask DemoFlow()
{
    while (true)
    {
        Setup(Random.value > 0.5f);
        await TransitionIn();
        var option = await SelectMenuOption();
        Debug.Log("Selected: " + option.ToString());
        await TransitionOut();
    }
}

void Setup(bool hasSavedGame)
{
    continueButton.gameObject.SetActive(hasSavedGame);
}

async UniTask TransitionIn()
{
    var cts = new CancellationTokenSource();
    await UniTask.WhenAny(
        Enter(cts),
        SkipEnter(cts));
    cts.Dispose();
}

async UniTask Enter(CancellationTokenSource cts)
{
    rootPanel.SetLayout(offscreen);
    menuGroup.alpha = 0;
    rootGroup.alpha = 1;
    await rootPanel.Layout(offscreen, onscreen, 5).Play(cts.Token);
    await menuGroup.FadeIn(1).Play(cts.Token);
    cts.Cancel();
}

async UniTask SkipEnter(CancellationTokenSource cts)
{
    while (true)
    {
        await UniTask.NextFrame(cts.Token);
        if (Input.anyKey)
        {
            cts.Cancel();
            rootPanel.SetLayout(onscreen);
            menuGroup.alpha = 1;
            break;
        }
    }
}

public async UniTask<MainMenuOption> SelectMenuOption()
{
    var result = await UniTask.WhenAny(
        Press(newGameButton),
        Press(continueButton)
        );
    return (MainMenuOption)result;
}

async UniTask Press(Button button)
{
    using (var handler = button.GetAsyncClickEventHandler(this.GetCancellationTokenOnDestroy()))
    {
        await handler.OnClickAsync();
    }
}

async UniTask TransitionOut()
{
    await rootGroup.FadeOut().Play();
}

This time we had Start call a new method called DemoFlow. DemoFlow handles the entire flow of our scene. It first calls another new method named Setup which accepts a bool representing whether or not there is a saved game. For now, we are just “cheating” by passing true or false at random. After Setup, the flow waits for the screen to transition in, then awaits a selected menu option, prints the option to the console window, and finally transitions out. The whole method appears inside of a while loop so that you can test out the flow repeatedly without having to constantly stop and re-run the game.

Take a look at the SelectMenuOption. The return type on the method is a generic version of UniTask. The generic type is the expected return type that the awaited task will provide. In this case, it will return an enum called MainMenuOption that represents the possible options from this menu. In the DemoFlow method, we were able to “await” the assignment of the selected menu option, and then use it on the next line to print it to the console! You will also see the same pattern inside SelectMenuOption with the use of WhenAny which will return the index of the first task that completes. We then cast the index to the enum and return it.

The Press method observes the OnClick UI event from a passed UI Button. Note that I wrap the waiting of a click in a handler that observes a special type of cancellation token – GetCancellationTokenOnDestroy. When the script is destroyed, the token will be marked as cancelled and any still running task will be cancelled along with it.

Note that in this implementation, we never actually disable interaction on any of the menu buttons – either before or after interacting with them (although if you wanted to it would be easy to do). It would be more accurate to say that while a user can still click either button, that there is only a brief period of time where the app “responds” to a button click, and even then it is only to the first option that is clicked.

Finally, we also added the TransitionOut method, though there isn’t much new here. It simply awaits the FadeOut animation of another CanvasGroup – This time at the root level so that both the logo and menu buttons fade out together.

If you haven’t already tried it, go ahead and play the scene and give the menu a try. Experiment with letting the entry transition play to completion vs skipping it, make sure that you only see the results of clicking a Manu option when you expect to, and also note that sometimes the Continue button will not be present.

Summary

In this lesson we learned about task based asynchronous programming while implementing a Main Menu scene with a variety of requirements. We learned how to do a sequence of animations, how to cancel tasks to skip animations, how to look for the first of a group of tasks to complete, and we also saw how to return values from a task.

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!

Leave a Reply

Your email address will not be published. Required fields are marked *