D20 RPG – Entry Flow

We can create and display an Entry asset, but won’t be able to fully test our work without a a few more important steps.

Overview

The asset we are testing with right now was serialized into the scene, but that means it will always show only that Entry. We need to be able to load the Entry that the game data tells us is the correct Entry to show. Let’s implement the asset loading along with the rest of the “flow” so that selecting an Entry option can apply its changes and the game can make progress. At that point we would have enough functionality to complete a full text-only adventure. Not a bad start!

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.

This project will use Addressables, so there are a couple of setup steps we need to do. First, open the Package Manager (from the file menu, choose Window -> Package Manager). With the packages mode set to “Unity Registry” (1), search for “Addressables” by using the search bar in the upper right (2). Then click the “Install” button (3).

Addressables Package

In the Project pane, select Assets -> Sctipts -> Scripts.asmdef. Add two new Assembly Definition References: Unity.Addressables and Unity.ResourceManager. Click “Apply” at the bottom of the Inspector.

asmdef setup

Asset Manager

Create a new folder at Assets -> Scripts named AssetManager. Now make a script in the new folder of the same name. Add the following:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using Cysharp.Threading.Tasks;
using UnityEngine.ResourceManagement.AsyncOperations;

public interface IAssetManager<T> : IDependency<IAssetManager<T>>
{
    UniTask<T> InstantiateAsync(string key);
    UniTask<T> LoadAssetAsync(string key);
}

We are making a generic asset manager. You can create conforming classes that specify the generic type to be things like GameObject, Texture2D, etc. Whatever it is you want to load. The interface has two methods. InstantiateAsync creates an instance of an asset in the scene. LoadAssetAsync loads a reference to an asset in memory, but doesn’t create an instance. We will use this for things like our “Entry” assets, where we only need them for their data.

Copy the following:

public abstract class AssetManager<T> : MonoBehaviour, IAssetManager<T> where T : Object
{
    Dictionary<string, AsyncOperationHandle<T>> assetMap = new Dictionary<string, AsyncOperationHandle<T>>();
}

From the unity docs:

When you no longer need the resource provided by an AsyncOperationHandle return through the Addressables API, you should release through the Addressables.Release method.

It is this need of Memory Management which led to the creation of this class. As I load assets, I store the “handle” in a collection so that I can release them later.

Add the following:

public async UniTask<T> InstantiateAsync(string key)
{
    var asset = await LoadAssetAsync(key);
    return Instantiate(asset);
}

The InstantiateAsync method is implemented by first calling our own LoadAssetAsync method, and then simply calling Instantiate on the loaded asset. This is possible because we put a constraint on our generic class that the generic type will be a type of Object.

Add the following:

public async UniTask<T> LoadAssetAsync(string key)
{
    AsyncOperationHandle<T> handle;
    if (assetMap.ContainsKey(key))
    {
        handle = assetMap[key];
    }
    else
    {
        handle = Addressables.LoadAssetAsync<T>(key);
        assetMap[key] = handle;
    }

    if (!handle.IsDone)
        await handle;

    if (handle.Status == AsyncOperationStatus.Succeeded)
        return handle.Result;

    return null;
}

The LoadAssetAsync does a sort of lazy load. It first looks in the collection to see if it has already loaded (or is loading) an asset by the specified “key”. If so, it uses the “handle” for that asset. If the “key” is new, then it will obtain a new “handle” and store it in the collection.

After resolving the handle to use, the manager will simply “await” the handle which gives the asset time to load. Assuming the status shows that the asset was loaded successfully, then we return the “result” which is the asset itself.

Add the following:

private void OnEnable()
{
    IAssetManager<T>.Register(this);
}

private void OnDisable()
{
    IAssetManager<T>.Reset();
}

As we have done with other MonoBehaviour scripts that conform to an injectable interface, we are using OnEnable to register and OnDisable to clear our reference.

Add the following:

private void OnDestroy()
{
    foreach (var handle in assetMap.Values)
        Addressables.Release(handle);
}

We use the OnDestroy method to handle memory management for the addressable handles. We just loop over the collection’s values, and call Release on each one.

GameObject Asset Manager

Create a new C# script in the same folder named GameObjectAssetManager and add the following:

using UnityEngine;

public class GameObjectAssetManager : AssetManager<GameObject>
{

}

That’s the whole class. The parent class AssetManager already handles everything necessary, but in order to assign the script to an object in a scene, we needed a subclass to specify the generic type.

Explore Scene

Open the Explore scene. Remove the Demo script that we had used to test with in the previous lesson. Then add the GameObjectAssetManager to an object in the scene.

Entry Asset System

Create another script in the same folder named EntryAssetSystem and add the following:

using UnityEngine;
using Cysharp.Threading.Tasks;

public interface IEntryAssetSystem : IDependency<IEntryAssetSystem>
{
    UniTask<IEntry> Load();
    UniTask<IEntry> Load(string entryName);
}

public class EntryAssetSystem : IEntryAssetSystem
{
    public async UniTask<IEntry> Load()
    {
        var entryName = IEntrySystem.Resolve().GetName();
        return await Load(entryName);
    }

    public async UniTask<IEntry> Load(string entryName)
    {
        var assetManager = IAssetManager<GameObject>.Resolve();
        var key = string.Format("Assets/Objects/Entries/{0}.prefab", entryName);
        var asset = await assetManager.LoadAssetAsync(key);
        return asset.GetComponent<IEntry>();
    }
}

This script wraps our asset manager and handles additional loading details such as knowing the “path” to an Entry. This allows our other code to only need to worry about the name of the asset. The class also understands that we loaded a GameObject and can use GetComponent to obtain the Entry script itself. The end result is that any consumer of this class can work with just the IEntry interface, and not need to understand how the asset was created.

I provided two different “Load” methods. If you don’t specify the name of the asset to load, it will assume you wanted to load the “current” Entry – the one that is obtained via the EntrySystem. The second variation allows you to specify the asset name, in case you don’t want the default. This could be used for testing etc.

Injection

Create another script in the same folder named AssetManagerInjector and add the following:

public static class AssetManagerInjector
{
    public static void Inject()
    {
        IEntryAssetSystem.Register(new EntryAssetSystem());
    }
}

Then open the main Injector script and add the following:

AssetManagerInjector.Inject();

Entry Flow

Open the EntryFlow script and complete it. Each snippet below shows a “TODO” comment and the code to replace it with:

// TODO: Load an Entry asset by name
var entry = await IEntryAssetSystem.Resolve().Load();

// TODO: Resolve the Entry Panel
var panel = IEntryPanel.Resolve();

// TODO: Configure the panel with the asset
panel.Setup(entry);

// TODO: Enter transition for the panel
await panel.TransitionIn();

That finishes the “Enter” portion of the EntryFlow.

// TODO: Interact with the panel
// Either select a menu option or interact with a link in the text
var cts = new CancellationTokenSource();
var (win, menu, link) = await UniTask.WhenAny(
        panel.SelectMenuOption(cts.Token),
        panel.SelectLink(cts.Token)
    );
cts.Cancel();
cts.Dispose();

if (win == 0)
{
    // Selected a menu option
    entry.Options[menu].Select();
    break;
}
else
{
    // TODO: Selected a link in the text
}

That finishes the “Loop” portion of the EntryFlow. Note that we are only “handling” one thing at a time, either one of the entry options (for navigation) or a link in the entry text. You may interact with the same link more than once (say to pickup and then drop an item) without actually leaving the “Entry”. Since we are in a loop, after interacting with a link, it will continue running through the code until we select one of the navigation options.

// TODO: Exit transition for the panel
IDataSystem.Resolve().Save();
await panel.TransitionOut();

In addition to handling the exit transition, we also added a line to save our progress. When you play test this project later, make sure to test that out. Advance a page, then stop the game, restart the game and check for the “Continue” button on the Main Menu. That finishes the “Exit” portion of the EntryFlow.

Addressable Assets

The last step for this lesson is to make our assets addressable. In the Project pane, select the Assets -> Objects -> Entries folder. Now select all of the assets in the folder. In the inspector, click the checkbox named “Addressable”.

Addressable Asset

Try it out

Play the project from the LoadingScreen. At the first Entry you should now be able to click the only option, “Next”, and see that the game will navigate to the next page.

Of course we have only implemented two “Entry” assets so if you click the “Run Away” option at this point, it will cause an error:

UnityEngine.AddressableAssets.InvalidKeyException: Exception of type ‘UnityEngine.AddressableAssets.InvalidKeyException’ was thrown. No Location found for Key=Assets/Objects/Entries/Entry_03.prefab

We may as well finish this branch of the adventure. Create an “Entry_03” asset. For the Entry text I used:

The cowards path eh? Well at least we can test ending the game play loop.

For the ExploreEntryOption I used the text “Game Over” and left the “Entry Name” as an empty string. When the game loop detects that there is no longer a “current” entry, then it will treat it as a game over scenario and exit the play loop.

Don’t forget to mark the new asset as Addressable, then play the game again. After choosing to run away from battle, and clicking “Game Over” you will return to the main menu and can try again to your hearts content.

Note* I saw the issue again where the main entry text would lose the padding on the left side. I still maintain that it is probably a Unity bug, but I made another change that seems to help. I selected the ScrollRect component and set the “Vertical Scrollbar Visibility” to “Auto Hide”. My guess is that that setting would help trigger an extra layout pass or at least mark things to delay layout in some way. If anyone knows for sure something I should be doing differently with my UI setup, feel free to share in the comments below.

Summary

In this lesson we created a generic asset management system so that we could load assets via Addressables. We used this to load our Entry prefabs dynamically based on the game data. Then we hooked up the Entry flow so that we can actually make progress through the game by selecting Entry options. Progress is also saved now and a saved game can be continued!

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 *