D20 RPG – Entry

It’s time to make a grand entry!

Overview

Our project has the ability to load the exploration scene, which already has a panel in place to present an “Entry”, but we haven’t actually defined what an “Entry” is or what it does. In this lesson we will fix that by creating some project assets which can be used to load that panel.

It is a simple matter to save a project asset. If you’ve ever made a Prefab then you’ve already done it. However, if “all” you need is data, then some would argue a ScriptableObject is a better choice. That may well be the case – as one example, all GameObjects will have an attached Transform, which would be unused and unrelated to the data you wanted saved.

Unity has made some nice improvements to the ScriptableObject workflow over the years, for example, you can create instances of any ScriptableObject that are marked with a simple CreateAssetMenu attribute:

using UnityEngine;

/* This is just an example, it is not necessary to add this to the project */

[CreateAssetMenu]
public class SampleData : ScriptableObject
{
    public string text;
}

In the example above, the SampleData is a ScriptableObject that has added the CreateAssetMenu attribute. This means you can use the file menu to create instances: Assets -> Create -> Sample Data.

Create Scriptable Object From Menu

Created instances can then be edited in the Inspector:

SampleData Inspector

I think this approach is great when your data needs are simple. However, when you start wanting something complex, such as object references, particularly if you want to program to an interface, then it gets to be trickier to assemble and serialize project assets with the ScriptableObject approach. What can be done will also often require a lot of custom Editor scripts. Especially if you want to see more than just the reference to another asset, but actually want to see its values at the same time.

A simpler approach for those kind of requirements is to just use a Prefab. It is trivial to create a GameObject and add any combination of Components to it that you wish, and even rely on things like child objects if necessary. All of the components can conform to an interface, and you can “Get” a component from the object even by its interface. Even better is that all of your data will be rendered in the inspector, all together, without needing to write any special Editor code.

Prefab workflows have also improved over time, and things like nested prefabs can be very useful – especially because you can make serialize changes to the nested prefab without changing the nested prefab itself. The flexibility in this system is really quite handy!

For the “Entry” assets we will be making, I do want to program to an interface. For example, the “Options” could have a variety of effects such as just navigating to another “Entry” page or starting an “Encounter”. Some of the effects will even be dynamic, such as going to a different “Entry” based on a skill check or depending on what is held in the player’s inventory. Each of these options will need to hold different kinds of data to work, and I want to be able to easily see and edit it all in one place. Therefore, I have decided to implement this prototype using the Prefab approach.

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.

Entry

Create a new C# script at Assets -> Scripts -> SoloAdventure -> Explore named Entry and add the following:

using UnityEngine;

public interface IEntry
{
    string Text { get; }
    IEntryOption[] Options { get; }
}

We start by specifying the interface, IEntry. Our asset needs to be able to provide the “Text” – which is the story to read, as well as have “Options” which will hold the data necessary to populate a button label, and apply some kind of logic if the button is clicked.

Add the following:

public class Entry : MonoBehaviour, IEntry
{
    public string Text { get { return text; } }
    [SerializeField] string text;

    public IEntryOption[] Options
    {
        get
        {
            return GetComponents<IEntryOption>();
        }
    }
}

This is the class that conforms to the interface. It has a serialized field to hold “text” – which we can set via the Inspector. Other scripts will only be able to see the “Text” via a public readonly property. The “Options” are also a read-only property and merely use a GetComponents on the same GameObject. This means we can attach any Component that conforms to IEntryOption and it will automatically be found and used.

Entry Option

We will have several types of options, so for organization sake, create a new folder at Assets -> Scripts -> SoloAdventure -> Explore named EntryOptions. Now create a new C# script in that folder named IEntryOption. Add the following:

public interface IEntryOption
{
    string Text { get; }
    void Select();
}

This simple interface defines all that we need to populate each button. The “Text” Property is for the label on the button itself, and the “Select” method is what happens when the button is clicked.

While we will eventually have multiple types of Entry Options, at the moment we only have enough functionality for a single type. We can make an Entry that changes the current Entry. So make a new C# script in the same folder named ExploreEntryOption and copy the following:

using UnityEngine;

public class ExploreEntryOption : MonoBehaviour, IEntryOption
{
    public string Text { get { return text; } }
    [SerializeField] string text;
    [SerializeField] string entryName;    

    public void Select()
    {
        IEntrySystem.Resolve().SetName(entryName);
    }
}

The interface requires a public readonly property for the button label, named “Text” which just returns the serialized field of the same name. The “Select” method uses our injected IEntrySystem to change the game’s Entry to the target “entryName” that is serialized along with the asset.

Entry Asset

We now have enough to create our first Entry asset. Create an Empty GameObject in the scene and name it “Entry_01”. Attach an “Entry” component and set the “Text” to something interesting. I used a silly opening paragraph with some lorem ipsum to help simulate a more realistic example and to be able to see the scrolling working.

Imagine you are reading really great introductory material that establishes characters, environments, and goals etc. To make it feel even more impressive, I’ve added a couple of paragraphs of placeholder text. All you need to do is click “Next” below to test that we can go from one entry to another.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque eu ornare nulla. Nam vulputate mauris quis massa vestibulum, a aliquet augue mattis. Morbi egestas augue sapien, malesuada bibendum ligula mollis dignissim. In vestibulum neque tempus augue tempus, non pretium leo dapibus. Integer id diam lorem. Suspendisse velit neque, porta a convallis pretium, vulputate sit amet felis. Cras non rutrum erat. Aliquam vehicula erat mollis urna ullamcorper, ac viverra justo accumsan. Nunc at neque vel libero lacinia faucibus. Maecenas vel pulvinar sem, sed malesuada sapien. Nullam consectetur pulvinar orci, nec dapibus tortor ullamcorper sit amet. Duis non iaculis arcu. Quisque volutpat nunc nec tellus facilisis feugiat. Nullam vitae tempus erat. Vivamus malesuada nisl sed pulvinar euismod.

Praesent tempus, nisl at lacinia tincidunt, diam nisi tincidunt massa, quis fringilla lorem massa ut mi. Phasellus mattis quis nulla ac auctor. Ut malesuada, diam in porttitor cursus, libero lacus rutrum lacus, eu pretium orci urna nec lectus. Donec tempor quam mi, tempus porttitor est sodales vel. Nulla vel congue sapien, quis sollicitudin mi. Proin tincidunt elementum dolor, eget venenatis massa pharetra non. Vivamus cursus vel orci id hendrerit. Integer at velit mollis, feugiat ante non, dignissim arcu. Maecenas condimentum eget tortor eget congue. Aenean quis nunc nec lectus consectetur gravida vitae sit amet nulla. Vestibulum et purus ultrices, gravida turpis in, imperdiet elit. Mauris nisl neque, facilisis at arcu ut, sodales posuere mi. Duis magna lacus, ornare eu lorem pharetra, gravida tincidunt lacus. Donec eget sapien sapien.

Next, attach an ExploreEntryOption to the GameObject. For the “Text”, I added “Next” and for the “Entry Name” I added “Entry_02”.

Entry_01 Inspector

Add a subfolder to Assets -> Objects named Entries. Then drag the “Entry_01” GameObject from the Hierarchy pane to the new folder in the Project pane. This will create a new Prefab asset. You can delete the instance in the scene.

Create a second empty GameObject named “Entry_02”. Add an “Entry” component to it as well, and for the text, I used:

It looks like there is a monster up ahead. Kill or be killed. Or I supposed you could run away…

* Click “Fight” to start a combat encounter.

* Click “Run Away” to skip the fight and demo branching options.

We won’t add the “Fight” option for this Entry yet, because it will require some functionality around starting an Encounter. We can however implement the “Run Away” option – go ahead and add an ExploreEntryOption with “Run Away” as the “Text”, and “Entry_03” as the “Entry Name”. Create a prefab from this entry like you did for the first entry, then delete the instance from the scene.

Link Opener

Open the LinkOpener script and add the following:

using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

[RequireComponent(typeof(TMP_Text))]
public class LinkOpener : MonoBehaviour, IPointerClickHandler
{
    public UnityEvent<string> onClick;

    public void OnPointerClick(PointerEventData eventData)
    {
        var text = GetComponent<TMP_Text>();
        int linkIndex = TMP_TextUtilities.FindIntersectingLink(text, eventData.position, null);
        if (linkIndex != -1)
        {
            var linkInfo = text.textInfo.linkInfo[linkIndex];
            onClick.Invoke(linkInfo.GetLinkID());
        }
    }
}

The link opener is a script that was attached to the same object as the main entry’s text component. It implements the IPointerClickHandler interface so that it can observe user interactions like mouse clicks (also would work with touch input on mobile). It uses a utility to check if the location of the user interaction overlaps with the location of an html link in the text. If there is an intersection, then it will invoke the “onClick” event and pass along the ID of the link.

Entry Panel

Open the EntryPanel script and add the following to the IEntryPanel interface:

void Setup(IEntry entry);
UniTask TransitionIn();
UniTask<int> SelectMenuOption(CancellationToken token);
UniTask<string> SelectLink(CancellationToken token);
UniTask TransitionOut();

Just like when we setup our Main Menu, we need the ability to do “Setup”, “TransitionIn” and “TransitionOut”. There is a bit of a difference in that the Main Menu needed only a single interaction – select one menu button and you were done. In this case, the user may interact with links in the text or select an entry option. The entry option would exit the current entry’s flow, but the links do not. We need to handle both.

Add one more using statement, using System.Linq;, then add the following to the EntryPanel class:

public void Setup(IEntry entry)
{
    // Setup the main entry text
    entryText.text = entry.Text;

    // Setup buttons for entry options
    var pairs = entryOptions.Zip(entry.Options, (GameObject view, IEntryOption data) => (view, data));
    foreach (var pair in pairs)
    {
        pair.view.SetActive(true);
        var label = pair.view.GetComponentInChildren<TextMeshProUGUI>();
        label.text = pair.data.Text;
    }

    // Hide any unused buttons
    for (int i = pairs.Count(); i < entryOptions.Count; ++i)
        entryOptions[i].SetActive(false);
}

The Setup method is what will configure the UI based on a given "entry". The first line is pretty intuitive - we set the main area's text component to hold the contents of the entry text.

The Zip method is what required the additional Linq statement. Zip is a handy method that will create a new collection from two other collections. The new collection is made up of pairs (one from each of the zipped collections). The total length of the new collection is the shortest length of the two collections. In this example, I had a List of four UI buttons that was zipped with a dynamic number of entry options. So let's say there were two entry options, then the zipped result would hold two pairs because two is the size of the shortest collection. The first pair would have the first button and first option, and the second pair would hold the second button and second option.

Iterating over the collection of zipped pairs, we can do our setup. Any button that can be paired with an entry option will be set to active, and have its label's text display the option's text. Following the button setup, we do a separate loop that handles hiding any buttons that weren't needed.

Add the following:

const float transitionTime = 0.25f;

public async UniTask TransitionIn()
{
    await canvasGroup
        .FadeIn(transitionTime, EasingEquations.EaseInOutQuad)
        .Play(this.GetCancellationTokenOnDestroy());
}

public async UniTask TransitionOut()
{
    await canvasGroup
        .FadeOut(transitionTime, EasingEquations.EaseInOutQuad)
        .Play(this.GetCancellationTokenOnDestroy());
}

These look pretty similar to the transition methods used by the main menu. We are simply using the tween animation library to FadeIn or FadeOut CanvasGroup.

Add the following:

public async UniTask<int> SelectMenuOption(CancellationToken token)
{
    List<UniTask> tasks = new List<UniTask>(entryOptions.Count);
    for (int i = 0; i < entryOptions.Count; ++i)
    {
        if (!entryOptions[i].activeSelf)
            break;
        var button = entryOptions[i].GetComponent<Button>();
        var task = Press(button, token);
        tasks.Add(task);
    }
    var result = await UniTask.WhenAny(tasks);
    return result;
}

async UniTask Press(Button button, CancellationToken token)
{
    using (var handler = button.GetAsyncClickEventHandler(token))
    {
        await handler.OnClickAsync();
    }
}

This is also similar to the way we handled selecting a main menu option. The primary difference is that the WhenAny is passed a dynamic list of "Press" button tasks. We loop over our buttons, and for any active button, append a "Press" task for the button to that List.

Another slight difference is that we return an int instead of an enum. The int that we are returning represents the index of the option that the player chose. Providing the index makes more sense in this case, because at any given entry, the options can represent a different choice.

Add the following:

public async UniTask<string> SelectLink(CancellationToken token)
{
    var linkOpener = entryText.GetComponent<LinkOpener>();
    string result = "";
    using (var handler = linkOpener.onClick.GetAsyncEventHandler(token))
    {
        result = await handler.OnInvokeAsync();
    }
    return result;
}

This code looks similar to the way we handle a button press via a task. In both cases we are doing an async wait for the "onClick" event to fire. In this case, the "onClick" passes along a string parameter representing the link that was clicked. We will return that result once it is provided.

Add the following:

private void Awake()
{
    canvasGroup.alpha = 0;
}

After loading the scene, we will also need to load the asset before we can configure the panel with the asset. This means that the scene may have a few frames where it could appear like you see it now. To prevent that from happening, we added the "Awake" method to make the panel hidden.

Add the following:

private void OnEnable()
{
    IEntryPanel.Register(this);
}

private void OnDisable()
{
    IEntryPanel.Reset();
}

Once again, we allow a MonoBehaviour to handle its own injection via the OnEnable and OnDisable methods.

Demo

Let's provide a quick demo to verify that our EntryPanel can correctly display an Entry. Rather than handling all of the code necessary for the whole Entry Flow, including how to load an Entry asset, we will just assign a reference to an Entry and call Setup from a simple script.

Add (or replace if you still have it) the following Demo script:

using UnityEngine;
using Cysharp.Threading.Tasks;

public class Demo : MonoBehaviour
{
    [SerializeField] Entry entry;

    async UniTaskVoid Start()
    {
        var panel = IEntryPanel.Resolve();
        panel.Setup(entry);
        await panel.TransitionIn();
    }
}

Attach the Demo script to something in the "Explore" scene. Drag the "Entry_01" prefab reference to the script's "entry" field. Press Play and observe that the panel should fade in, and should display the story and option text from the provided asset. Clicking buttons won't do anything until we hook up the flow.

Tip - sometimes I noticed that the text would lose its leading padding when the panel was setup. I am guessing this is a Unity bug. If you see the same thing, try this: add a Layout Element component to the main text's object (the same object that has the Link Opener component. I set the "Min Height" of the Layout Element to 100. It seems to have helped for me.

Layout Element

Summary

In this lesson we discussed approaches for making ScriptableObject assets as well as Prefab assets, and why we chose Prefab assets for our Entry. We created scripts for the Entry and EntryOptions and then created a couple of Prefabs based on those scripts. Finally, we hooked up the EntryPanel so that it could display one of our Entry assets.

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 *