Dev Diaries - Building a JRPG - Part 11

Before we attempt to create a menu which looks like the Final Fantasy VII Party Menu, we need to implement MenuContentOption in order to support Menus with options which can be navigated with a cursor, and selected.

The goal today is to create a simple dialog serving as a prompt, which can trigger some sort of action on depending on which option is chosen. It will be a simple question with either a Yes, or No response as shown below:

Example Prompt

First, the Iterator!

I mentioned in the last post that it would be better if the MenuStack class had basic iterator methods to access the Menu items found within it. I believe such methods like these improve the quality of life.

The methods which will be made available are:

  • HasNext - If there are more elements to traverse in the Valuescollection in the Menus dictionary.
  • Next - Gets the current element pointed by the current tracked Index.
  • ResetIterator - Sets the tracked index of the current element in the Valuescollection.

Yeah, it isn't a true iterator, but it is simple, and gives us what we want. 🤔

public class MenuStack 
{
    private int ValuesIndex;
    
    ...
    
    public bool HasNext()
    {
        if (Menus.Values.Count == 0)
            return false;
        if (ValuesIndex == Menus.Values.Count)
            return false;

        return true;
    }

    public Menu Next()
    {
        return Menus.Values.ToList()[ValuesIndex++];
    }

    public void ResetIterator()
    {
        ValuesIndex = 0;
    }
}

Assuming that ResetIterator is called, we can simply traverse the MenuStack like this:

while(Menus.HasNext())
{
    var currentMenu = Menus.Next();
    
    // Do something with the currentMenu here.
}

The Definition File

Again, like all other menus, we should be able to easily construct this dialog through a JSON definition. Here is the definition I have written to render the above dialog prompt:

{
    "Key": "menu-prompt-example",
    "Location": {
        "X": 15,
        "Y": 11
    },
    "Size": {
        "Width": 32,
        "Height": 7
    },
    "Contents": [
        {
            "Key": "prompt-question",
            "Type": "TEXT",
            "Size": {
                "Width": 29,
                "Height": 1
            },
            "Location": {
                "X": 1,
                "Y": 1
            },
            "Content": "Would you like to be healed?"
        },
        {
            "Key": "prompt-choice-yes",
            "Type": "OPTION",
            "Size": {
                "Width": 3,
                "Height": 1
            },
            "Location": {
                "X": 4,
                "Y": 3
            },
            "Content": "Yes",
            "Index": 0,
            "Handler": "MenuContentOptionYesHandler, Assembly-CSharp"
        },
        {
            "Key": "prompt-choice-no",
            "Type": "OPTION",
            "Size": {
                "Width": 2,
                "Height": 1
            },
            "Location": {
                "X": 4,
                "Y": 4
            },
            "Content": "No",
            "Index": 1,
            "Handler": "MenuContentOptionNoHandler, Assembly-CSharp"
        }
    ]
}

The dialog is composed of:

  • A single MenuContentText which asks the question "Would you like to be healed?"
  • 2 MenuContentOption elements which serve as the Yes, and No prompts respectively.

Rendering the question itself already happens automatically by the code we have written so far with MenuContentText. We then need to be able to render out the options. Notice that there are 2 distinct properties here which a MenuContentOption has that makes it different from the other content types:

  • Index
  • Handler

The cursor operates within the context of the current dialog, which is the top-most Menu found in they menu stack. The Index property of the definition indicates the ordinal value which the current option falls within the rest of the other options. This helps the cursor navigate forwards, and backwards around the current dialog.

The Handler property indicates the .NET class which should execute upon choosing the option. Handlers implement MenuContentOptionHandler which expect the global GameStore as a parameter in its Handle method. Refer to MenuContentOption::Handle in how these are instantiated, and invoked.

From the above definition, this means that Yes is the first option in which the cursor will focus on, by default (index: 0). If an action to take the cursor to the next element is invoked, then the cursor will place itself on whatever option has an index of 1 (in this case, No). Of course, the previous action does something similar, but in the reverse direction.

Rendering Menu Options

We will just need to modify MenuBuilder::BuildFromDefinition, and MenuController::RenderMenus to support rendering the MenuContentOption type.

Let's first start with BuildFromDefinition within MenuBuilder. We'll just need to add an additional else-if statment to check for the content type to be OPTION within the JSON definition, and instantiate the MenuContent type to the appropriate type of MenuContentType.

else if (menuContentType.Equals("OPTION"))
{
    var menuContentContent = contentToken.Value<string>("Content");

    MenuContentOption mcOption = new MenuContentOption(GameStore.GetInstance());
    mcOption.Key = menuContentKey;
    mcOption.Content = menuContentContent;
    mcOption.Size = new MenuSize(menuContentSizeWidth, menuContentSizeHeight);
    mcOption.Location = new TilePoint(menuContentLocationX, menuContentLocationY);
    mcOption.Index = contentToken["Index"].Value<int>();
    mcOption.Handler = contentToken["Handler"].Value<string>(); ;

    m.AddContent(mcOption);
}

Building these options is the same as the rest of the content types. We just need to make sure we assign the Index, and Handler properties here. 😊

Once the Menus contain a Menu dialog with the MenuContentOption objects built, we want to be able to render it! We'll add the necessary code in MenuController::RenderMenus.

else if(mc.Type == MenuContentType.Option)
{
    MenuContentOption mcOption = (MenuContentOption)mc;

    var sprites = ToFontSprites(mcOption.Content);
    for(var k = 0; k < sprites.Count; k++)
    {
        Sprite letterAsset = sprites[k];

        if (letterAsset.name.StartsWith("menu_tile"))
            continue;

        GameObject lg = new GameObject();
        lg.transform.parent = dialog.transform;
        lg.name = $"menu-content-option-{k}";

        SpriteRenderer ls = lg.AddComponent<SpriteRenderer>();
        ls.sprite = letterAsset;
        ls.sortingOrder = 3;
        ls.sortingLayerName = SORTING_LAYER;

        lg.transform.position = TileToWorldPoint(
            peeked.Location.X + mcOption.Location.X + k,
            peeked.Location.Y + (peeked.Size.Height - mcOption.Location.Y - 1));
    }
}

The code looks super familiar here since it is similar to the rest of the code that render out other MenuContent types.

Okay, at this point, the game should be able to render out the Menu with the options -- they just won't be interactive.

Rendered Options

The Cursor

Next, let's add some code to render a basic cursor. We'll first need a graphic. I pulled this one off the internet.

FF Cursor

We'll down-sample this image from 100x100 to 4 sizes which our game will support: 48x48, 32x32, 24x24, and 16x16. It will be imported as a Unity asset, and will be a rendered to the game as a sprite using a SpriteRenderer belonging to a GameObject within the current Menu dialog.

Rendering the cursor should be in the same place in code where all dialogs are rendered. After rendering the dialogs in RenderMenus, we can render out the cursor before returning from the method:

// Render the Cursor
if (GameObject.Find("cursor") != null)
    Destroy(GameObject.Find("cursor"));

if (Menus.Pointer.Visible)
{
    var topMostDialog = Menus.Peek();

    GameObject cursorGo = new GameObject();
    cursorGo.transform.parent = GameObject.Find(DialogCacheKey(topMostDialog.Key)).transform;
    cursorGo.name = $"cursor";

    SpriteRenderer cursorRenderer = cursorGo.AddComponent<SpriteRenderer>();
    cursorRenderer.sprite = game.LoadSpriteFromResource(GetCursorTileAsset());
    cursorRenderer.sortingOrder = 3;
    cursorRenderer.sortingLayerName = SORTING_LAYER;

    var cursorLocation = Menus.Pointer.CurrentLocation();
    cursorGo.transform.position = TileToWorldPoint(
        topMostDialog.Location.X + cursorLocation.X - 1,
        topMostDialog.Location.Y + (topMostDialog.Size.Height - cursorLocation.Y - 1));
}

We make a few assumptions here:

  1. We will always use the top-most dialog in the MenuStack as the parent GameObject for which the cursor GameObject belongs to.
  2. We will always destroy the existing GameObject rendering the cursor, and recreate it as needed, if and only if the cursor is currently visible.
  3. The visibility of the cursor is determined by the the top-most menu dialog -- whether or not there is a MenuContent item which is of MenuContentType.Option type.

Notice that we have used a couple helper methods which we assume to be provided by our Cursor class. Let's make changes in Jrpg.MenuSystem to accommodate that.

First we'll need to add some additional properties to MenuContentMemory. These will make sense in by the end of this section:

namespace Jrpg.MenuSystem
{
    public class MenuContentMemory
    {
        public string MenuKey { get; set; }
        public int Index { get; set; }
        public bool Visible { get; set; }
    }
}

Basically, MenuContentMemory represents the current context of where, and what the cursor is focused on.

The MenuStack instance should contain a single Cursor instance. We'll call this Pointer, because I suck at naming things. 😆

public class MenuStack
{
    ...
    public Cursor Pointer { get; private set; }
    
    public MenuStack()
    {
        ...
        Pointer = new Cursor(this);
    }
    ...
}

Cursor needs to be modified to receive the MenuStack instance. We will also want to add the additional features:

  • MovePrevious - The cursor focuses at the previous option.
  • MoveNext - The cursor focuses on the next option
  • CurrentLocation - Retrieves the current TilePoint location of the MenuContentOption at the current index.
  • CurrentIndex - The current index within the context of the Menu containing the options where the Cursor is focused at.
  • GetOptionCount - Gets the number of MenuContentOption elements found in the current Menu.

Moving the cursor around the dialog is as simple as keeping track of the index within top-most MenuContentMemory entry. We increment, and decrement the index within this state object respective to the directional operation.

The cursor should also loop through options. This means that if the player has the cursor at the first option and chooses to go to the previous option, the cursor should then focus on the last option, and vice versa.

public void MovePrevious()
{
    MenuContentMemory mcm = Peek();

    var optionCount = GetOptionCount();

    if (mcm.Index <= 0)
        mcm.Index = optionCount - 1;
    else
        mcm.Index--;
}

public void MoveNext()
{
    MenuContentMemory mcm = Peek();

    var optionCount = GetOptionCount();

    if (mcm.Index >= optionCount - 1)
        mcm.Index = 0;
    else
        mcm.Index++;
}

CurrentIndex returns the index of the top-most MenuContentMemory entry in the Cursor stack.

public int CurrentIndex()
{
    return Peek().Index;
}

GetOptionCount is a helper method to get the number of options pointed by the Menu found in the current top-most MenuContentMemory entry found in the Cursor stack.

private int GetOptionCount()
{
    MenuContentMemory mcm = Peek();
    Menu m = Menus.Get(mcm.MenuKey);

    List<string> mKeys = m.Keys();

    var optionCount = mKeys
        .Select(k => m.GetContent(k).Type == MenuContentType.Option)
        .Where(b => b == true)
        .Count();

    return optionCount;
}

Cursor should also indicate to the caller if it is currently supposed to be rendered, or not. This is where the Visible property is handy. The Visible property is set to the Visible value found in the top-most entry of the cursor's MenuContentMemory stack.

public bool Visible
{
    get
    {
        return Peek().Visible;
    }
    set
    {
        Peek().Visible = value;
    }
}

Finally, if the cursor knows which index of the listMenuContentOption to focus on, it will also need to know the location to where to place itself at. CurrentLocation will return us the TilePoint on where the current focused MenuContentOption is at.

public TilePoint CurrentLocation()
{
    MenuContentMemory mcm = Peek();

    Menu m = Menus.Get(mcm.MenuKey);

    int index = mcm.Index;

    foreach (var key in m.Keys())
    {
        MenuContent mc = m.GetContent(key);

        if (mc.Type != MenuContentType.Option)
            continue;

        MenuContentOption mcOption = (MenuContentOption)mc;

        if (mcOption.Index == index)
        {
            return mcOption.Location;
        }
    }

    return m.Location;
}

Input Handling‌

Okay, now it's time to hook up some code to listen to keyboard events to move the cursor around! We'll specify the S, W, and Space keyboard buttons to navigate around this specific prompt.

The Space button will invoke the Handle method of the option currently focused by the cursor. How it works:

// Update is called once per frame
void Update()
{
    var showMenu = GameStore.GetInstance().Get<bool>("ShowMenu");

    if (showMenu)
    {
        DisplayMenu();
        
        // Navigate through prompt list
        if (Input.GetKeyDown(KeyCode.S) && HasPrompt())
        {
            GameObject.Find("Game").GetComponent<Game>().PlaySound("cursor", 50f);
            Menus.Pointer.MoveNext();
        }
        if (Input.GetKeyDown(KeyCode.W) && HasPrompt())
        {
            GameObject.Find("Game").GetComponent<Game>().PlaySound("cursor", 50f);
            Menus.Pointer.MovePrevious();
        }
        if (Input.GetKeyDown(KeyCode.Space) && HasPrompt())
        {
            Menu menu = Menus.Peek();
            var index = Menus.Pointer.CurrentIndex();
            var mcKeys = menu.Keys();
            foreach(var key in mcKeys)
            {
                MenuContent mc = menu.GetContent(key);

                if (mc.Type == MenuContentType.Option)
                {
                    MenuContentOption mcOption = (MenuContentOption)mc;

                    if (mcOption.Index == index)
                    {
                        GameObject.Find("Game").GetComponent<Game>().PlaySound("confirm", 75f);
                        mcOption.Handle();
                        Menus.Pop();
                        Destroy(GameObject.Find(DialogCacheKey("menu-prompt-example")));
                    }
                }
            }

            BustCachedMenu("menu-prompt-example");
            BustCachedMenu("menu-character-stats");
            BustCachedMenu("menu-debug");
        }
    }
}

One case we need to consider is that our character will still be walking when the prompt is displayed. That is because the WalkController is still listening on input. We can fix this by accessing an instance of MenuController within the GameStore, and check to see if the prompt is being displayed. If so, do not handle any movement input!

public bool HasPrompt()
{
    var hasPrompt = Menus
        .Peek()
        .Keys()
        .Select(k => Menus.Peek().GetContent(k))
        .Where(c => c.Type == MenuContentType.Option)
        .Count() > 0;

    return hasPrompt;
}

And, in WalkController::FixedUpdate:

if (GameStore.GetInstance().Get<MenuController>("MenuController").HasPrompt())
{
    return;
}

Handling Options

When a user has selected an option, we will want to be able to handle the event with some logic. The handlers that had been defined in the definition file were MenuContentOptionYesHandler, and MenuContentOptionNoHandler which both implement the MenuContentOptionHandler interface.

Here are the implementations. In short, if the player selects Yes, then their HP, and MP will be healed to the maximum values. Choosing No will not do anything, and dismiss the menu.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Jrpg.MenuSystem;
using Jrpg.System;
using Jrpg.CharacterSystem;

public class MenuContentOptionYesHandler : MenuContentOptionHandler
{
    public void Handle(GameStore gs)
    {
        var active = gs.MainParty.GetActiveCharacter();

        active.Statistics[StatisticType.HpCurrent].CurrentValue = active.Statistics[StatisticType.HpMax].CurrentValue;
        active.Statistics[StatisticType.MpCurrent].CurrentValue = active.Statistics[StatisticType.MpMax].CurrentValue;

        DebugHelper.Log("You have been healed!");
    }
}

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Jrpg.MenuSystem;
using Jrpg.System;

public class MenuContentOptionNoHandler : MenuContentOptionHandler
{
    public void Handle(GameStore gs)
    {
        DebugHelper.Log("Okay, I won't heal you.");
    }
}

Demo!

 

Conclusion

We'll make one more attempt to create a menu that will leverage everything we done so far for the menu system. This menu will attempt to emulate the Party menu found in games like the 16-bit Final Fantasy games.

This will be a great way to see if we can apply our design for a third time, and validate that it is an easy to use system.