D20 RPG – Action Menu

Ready, Action! No, I’m not making a movie, but we will use this lesson to add a menu where the player can choose from a list of actions for the hero to take during a combat encounter.

Overview

An encounter will alternate control between the hero and monster combatants. When it is the player’s turn, we should present an action menu so that they can pick from the list of actions the unit could perform. In this lesson, we will implement this menu.

The menu’s we have implemented so far would need to be interacted with either by touch (such as if we published the game to mobile) or by mouse click (publish web, Mac or PC). So as a quick bonus, I thought we could use this lesson to also show some ideas for a menu that is more compatible with a keyboard or joypad.

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.

Download and then import this new package. It contains a prefab of a super simple action menu prefab and script that we can use to rough in some functionality.

Encounter Scene

We need to do a little extra setup in our Encounter scene so go ahead and open it:

  1. Add an instance of the new Action Menu prefab to the scene as a child of the root Demo game object.
  2. From the menu, choose “GameObject -> UI -> EventSystem” to add an EventSystem to our scene.
  3. Save the scene.

Just three easy steps!

Encounter Actions

Let’s have a system that can provide the list of actions an Entity can perform during an encounter. This may be a subset of the actions that the Entity can actually perform, because some actions may only be allowed during a different phase of the game such as exploration.

For now, this system is largely placeholder. It will simply point to a table that maps from an Entity to a List of string, where each string is the name of an asset to represent the action that can be performed.

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

using System.Collections.Generic;

public interface IEncounterActionsSystem : IDependency<IEncounterActionsSystem>, IEntityTableSystem<List<string>>
{

}

public class EncounterActionsSystem : EntityTableSystem<List<string>>, IEncounterActionsSystem
{
    public override CoreDictionary<Entity, List<string>> Table => _table;
    CoreDictionary<Entity, List<string>> _table = new CoreDictionary<Entity, List<string>>();
}

public partial struct Entity
{
    public List<string> EncounterActions
    {
        get { return IEncounterActionsSystem.Resolve().Get(this); }
        set { IEncounterActionsSystem.Resolve().Set(this, value); }
    }
}

Open the SoloAdventureInjector and add the following to its Inject method:

IEncounterActionsSystem.Register(new EncounterActionsSystem());

Encounter Actions Provider

There could be any number of ways that our combatants acquire the list of actions which they can perform. Some could be provided by default, some may be added as the character levels, and others could even be added based on special events. For now, let’s provide a way to provide some actions by default, by creating another provider script.

Create a new C# script at Assets -> Scripts -> AttributeProvider named EncounterActionsProvider and add the following:

using System.Collections.Generic;
using UnityEngine;

public class EncounterActionsProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] List<string> value;

    public void Setup(Entity entity)
    {
        entity.EncounterActions = value;
    }
}

Hero Actions

Now let’s configure the asset that represents our Hero so that when it is created it will already have a couple of encounter actions available:

  1. Select the asset at Assets -> Objects -> EntityRecipe -> Hero for editing.
  2. Add a new EncounterActionsProvider script to the prefab.
  3. Add two new entries to the list: “Strike (Shortsword)” and “Stride”.

Input System

Let’s create our own system and interface to handle Input. As a reminder, this practice will help our code be testable – and if necessary, portable. You may not always want to keep the engine you are currently working in.

Create a new folder at Assets -> Scripts named Input. Then create a new C# script inside the folder named InputSystem and add the following:

using UnityEngine;

public enum InputAxis
{
    Vertical,
    Horizontal
}

public enum InputAction
{
    Confirm,
    Cancel
}

I started out by defining a couple of different enums. The InputAxis is an enum that defines something which has a positive and negative aspect, such as how a joystick can move side to side or up and down. The InputAction is an enum that defines something more binary, it is either on or off, like whether or not you have pressed a button.

Add the following:

public interface IInputSystem : IDependency<IInputSystem>
{
    int GetAxisUp(InputAxis axis);
    bool GetKeyUp(InputAction action);
}

For our interface, I have provided two methods. The first is called GetAxisUp because in this case what I really want is a way to treat an axis input as if it was a button press. In other words, I am looking for a 1 for positive input and a -1 for negative input. If there is no input, it would return 0.

The second method is GetKeyUp and simply returns true when the matching action button has been pressed.

Add the following:

public class InputSystem : IInputSystem
{
    public int GetAxisUp(InputAxis axis)
    {
        switch (axis)
        {
            case InputAxis.Vertical:
                if (Input.GetKeyUp(KeyCode.UpArrow))
                    return 1;
                if (Input.GetKeyUp(KeyCode.DownArrow))
                    return -1;
                break;
            case InputAxis.Horizontal:
                if (Input.GetKeyUp(KeyCode.RightArrow))
                    return 1;
                if (Input.GetKeyUp(KeyCode.LeftArrow))
                    return -1;
                break;
        }
        return 0;
    }

    public bool GetKeyUp(InputAction action)
    {
        switch (action)
        {
            case InputAction.Confirm:
                return Input.GetKeyUp(KeyCode.Return);
            case InputAction.Cancel:
                return Input.GetKeyUp(KeyCode.Escape);
        }
        return false;
    }
}

Here I have add a class which implements our new interface. It is implemented using a very simple version of Unity’s old Input class. Because this is merely a prototype, I have only handled keyboard based input, but a more complete system could handle multiple types of input, and could choose to make use of better unity tools:

  • Unity’s older Input Manager can give names to inputs that can cover a variety of device types all at the same time. For example “Fire1” could be triggered by the keyboard, mouse or joystick.

  • Unity also has a new Input System that you can install via their package manager. It has similar concepts of defining Input Actions which can be triggered by a variety of sources.

Open the Injector script and add the following to its Inject method:

IInputSystem.Register(new InputSystem());

Action Menu

Open the script named ActionMenu. Note that it has already defined the class as well as a few serialized fields which I left in place to help simplify the UI setup within the scene – it was saved as part of the imported prefab.

  • rootPanel: is the RectTransform representing the Panel that is the first child of the Canvas. It holds the rest of the menu elements (just buttons for now).
  • buttons: are the already instantiated menu buttons which can display action options.
  • onScreen: is a Layout configuration representing how to display the menu.
  • offScreen: is a Layout configuration representing how to hide the menu.

Now let’s implement some more of this menu. We will start by creating an injectable interface. We can discuss each method as we implement it:

public interface IActionMenu : IDependency<IActionMenu>
{
    UniTask Setup();
    UniTask TransitionIn();
    UniTask<string> SelectMenuItem();
    UniTask TransitionOut();
}

Make sure that the ActionMenu class conforms to the interface:

public class ActionMenu : MonoBehaviour, IActionMenu

Add a few more fields. One will hold a convenient reference to the Entity whose turn it is, another will hold the currently selected menu option, and finally we will add one to hold the number of menu items that the menu has.

Entity entity;
int selection;
int menuCount;

Add the Setup method:

public async UniTask Setup()
{
    selection = 0;
    buttons[0].Select();
    entity = ISoloHeroSystem.Resolve().Hero; // TODO: Get the "current" entity from a "turn" system
    var pairs = buttons.Zip(entity.EncounterActions, (Button button, string action) => (button, action));
    foreach (var pair in pairs)
    {
        var label = pair.button.GetComponentInChildren<TextMeshProUGUI>();
        label.text = pair.action;
    }
    menuCount = pairs.Count();
    await UniTask.CompletedTask;
}

This is responsible for configuring our menu so that it will display the available actions that the current player controlled entity can take on its turn. It returns a task just in case future iterations will want to do any loading of the actions themselves so that it can also determine which of the actions are “valid” – for example, we may want to gray out magic spells if the character can’t speak, or certain attacks if the character is grappled or stunned.

For the first pass on this menu, we aren’t doing any of that pre-loading or special handling. We haven’t even made the menu itself be able to load a dynamic number of rows. It simply loads two-encounter option’s names off the hero, and displays them in the two buttons in the menu.

Add the TransitionIn method:

public async UniTask TransitionIn()
{
    await rootPanel.Layout(offScreen, onScreen, 0.25f).Play();
}

This one is pretty straight-forward. It simply animates the layout of our UI from the offscreen to onscreen position.

Add the SelectMenuItem method:

public async UniTask<string> SelectMenuItem()
{
    var input = IInputSystem.Resolve();
    while (true)
    {
        await UniTask.NextFrame();
        if (input.GetKeyUp(InputAction.Confirm))
            break;

        var offset = -input.GetAxisUp(InputAxis.Vertical);
        if (offset == 0)
            continue;

        selection = (selection + offset + menuCount) % menuCount;
        buttons[selection].Select();
    }
    return entity.EncounterActions[selection];
}

This method returns a task which will ultimately return the name of the action that a user has selected from the menu. To determine the selection, we poll each frame for user input using our new input system. In the event that the Confirm action has been selected, we can break out of the polling loop and return the action at our selection.

If the user has not confirmed, then we check to see if the user has provided an input that would adjust the menu’s selection such as by pressing up or down on the keyboard. In the context of the input system, I felt like it was most intuitive for up to represent positive and down to represent negative. In the context of this menu though, I feel like the opposite is more desirable, because the buttons are arrayed from top to bottom. Therefore I multiplied the returned value by -1.

Anytime that the input system detects an axis event (something other than zero), then we will adjust the selection accordingly. The math that is there may be a bit scary, but basically it means that the selected index will be whatever it was, plus whatever the offset is. The addition of `menuCount` along with using a modulus operation on the sum of it all is a simple way to handle “wrapping” the values. So for example if you were at index 0, and had an offset of -1, then we need to end up at the last index. Or if we were at the last index and added 1, we would need to end up at 0 again.

Add the TransitionOut method:

public async UniTask TransitionOut()
{
    await rootPanel.Layout(onScreen, offScreen, 0.25f).Play();
}

You can probably guess this one: it simply animates the layout of our UI from the onscreen to offscreen position.

Add the following methods:

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

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

Here I have used OnEnable and OnDisable to handle the registering and clearing of the interface injection.

Hero Action Flow

We will use a flow to control making our new menu appear, obtain a selection, dismiss the menu and finally perform the selected menu option. Create a new C# script at Assets -> Scripts -> Flow named HeroActionFlow and add the following:

using Cysharp.Threading.Tasks;

public interface IHeroActionFlow : IDependency<IHeroActionFlow>
{
    UniTask<CombatResult?> Play();
}

public class HeroActionFlow : IHeroActionFlow
{
    public async UniTask<CombatResult?> Play()
    {
        var menu = IActionMenu.Resolve();
        await menu.Setup();
        await menu.TransitionIn();
        var actionName = await menu.SelectMenuItem();
        UnityEngine.Debug.Log("Selected: " + actionName);
        await menu.TransitionOut();

        return ICombatResultSystem.Resolve().CheckResult();
    }
}

Playing this flow returns a generic task with a type of optional combat result. The idea is that after having performed an action, the outcome of the battle may have been decided. If your action is to attack, and the final monster is knocked out, then that would indeed be the case.

To play this flow, we first obtain a reference to the menu, await the transition for it to enter, await the user making a selection, and await the menu’s exit transition. We would also await performing the selected action, but we haven’t defined what that actually is yet. So for now all we do is print the name to the debug console.

Open the FlowInjector script and add the following to its Inject method:

IHeroActionFlow.Register(new HeroActionFlow());

Encounter Flow

Now we need to connect our new flow to the current flow of the Game. This won’t be its final resting place, but it is good enough to help us make progress. Open the EncounterFlow script. Inside the Loop method, make the following change:

// Change line 31 from this:
combatResult = ICombatResultSystem.Resolve().CheckResult();

// To this:
combatResult = await IHeroActionFlow.Resolve().Play();

Demo

Play the game from the LoadingScreen. When you reach the Encounter, you will see the new menu panel appear in the lower right corner. You can use arrow keys to change the selected action, and then use the Enter key to confirm the selection. The menu will disappear, only to reappear once again. You should see that we at least know what the user selected, because the chosen action will be printed to the console. Not a bad start.

As a quick note, you won’t be able to move beyond the encounter scene due to the changes made in this lesson. The placeholder code that we had put in the CombatResultSystem won’t be very helpful, because now you would only have a single frame between each presentation of the action menu with which to attempt to pick the outcome.

Hopefully that isn’t a concern, as we will eventually finish implementing the battle anyway.

Summary

In this lesson we added an action menu to allow the player to determine what action the hero will take on the battlefield during its turn. We made a new flow to handle transitions and awaiting the actual menu selection, and then connected the new flow to the overall game’s flow. As a bonus, we implemented this new menu using a different style of input, one which would be compatible with keyboard or joypad rather than mouse or touch.

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!

2 thoughts on “D20 RPG – Action Menu

Leave a Reply

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