Extending Brackey’s Dialogue System à la Ace Attorney

To put a positive spin on it: it’s never been a better time to learn things. The wealth of knowledge freely available to us is remarkable. Need to know something? Google it! More of a visual learner? There’s probably a YouTube video on it somewhere. Hate the internet? (then why are you here) find a book at the library!

Whichever way you learn, there’s a rich expanse of media just waiting to be tapped into. So, when I needed to know how dialogue systems are implemented in video games, I checked for some explanations on YouTube. Lucky for me, there is plenty of tutorials for my editor of choice, Unity.

Following Brackey’s Dialogue System Tutorial

Brackey’s has a fine tutorial here that I followed. I really liked this tutorial as it’s clear, simple, and easy to follow. By the end of the video, I had myself a rudimentary dialogue system with the following:

  • A Dialogue class to hold a speaker’s name and dialogue,
  • A dialogue UI to display the speaker name and their dialogue on the screen
  • A DialogueManager class to update the UI with the current dialogue information
  • An animation to show/hide the dialogue box when necessary
  • A co-routine delay so the dialogue displays one letter at a time
  • A button to trigger all of the dialogue system’s functionality
Brackey’s Dialogue System final result

For a 16 minute video, that’s pretty solid! Now I understand how to implement a dialogue system in Unity. I can even use my existing Unity knowledge to extend this dialogue system into something more complex.

For some context, my need to understand dialogue systems comes from needing to implement one for a little game I’m working on. I need to be able to talk to people, pick from different talk points to start conversations, and show them items from my inventory. This dialogue system is a great foundation, and I can extend it to fit my requirements.

The end result I’m looking for here is something akin to the visual novel adventure games of the Ace Attorney series (shown below).

An example of the Conversation UI in Phoenix Wright: Ace Attorney (DS)

Extending the Dialogue System

Before we begin updating the dialogue system there are two small changes I want to make. Firstly, we hold the sentences of dialogue in an array. Let’s switch that to a list. This will make editing the length of the dialogue easier, as lists do not have fixed sizes like arrays.

Next, I want to add some more flexibility to what is displayed on the UI. Currently, only one person can be talking in one dialogue. What if we want to have a conversation between two people?

To fix this, I’ve created a new DialogueUI class, and that is going to be what is stored in our list in our Dialogue class. Now the speaker is stored with the sentence, and an image of the speaker is added too.

public class DialogueUI {
    public string name;
    public string sentence;
    public Sprite image;

    public DialogueUI(Actor actor, string sentence, Emotion 
    emotion)
    {
        name = actor.FirstName;
        this.sentence = sentence;
        image = EmotionHelper.GetSpriteOfEmotion(emotion, actor);
    }
}

Don’t mind the EmotionHelper class, that just gets the right image for the emotion of the current person speaking.

We’re also going to need to update the dialogue UI to handle the speaker’s image.

The updated Dialogue UI

Now the dialogue on the screen can switch between different characters, and even different emotions (for example, the talker going from angry to shocked)!

Creating a Conversation UI for Characters

Now to the extension! The first thing I want to implement in this dialogue system is a “conversation UI”. What I mean by this is the hub of sorts for when you interact with a character. From the conversation UI you will be able to trigger dialogue with characters from their talk points, and present them with items to react to.

Designing the Conversation UI

Since the conversation UI differs from the dialogue UI, we’re going to need to make that as well. Luckily, it’s very similar to the updated dialogue UI.

This time around, the character’s name is above the box, and the box itself it split. Talk points go on the left, and the presenting items option go on the right (don’t mind the image of Link from Hyrule Warriors, it’s a placeholder).

The Conversation UI

Scripting the Conversation UI

A lot of the scripting for the conversation UI is going to mirror the dialogue UI that Brackey has done. Having the existing tutorial is really beneficial here as they can be used as a reference.

To hold the conversation data, we’re going to create a Conversation class. This Conversation class is going to hold information on what dialogues you can trigger with a character, as well as whether or not you’ve triggered that dialogue before.

public class Conversation 
{
    public Actor actor;
    public TextBox[] textboxes = new TextBox[4];
    public Emotion emotion;

    public void SetAsVisited(string text)
    {
        foreach (TextBox textBox in textboxes)
        {
            if (textBox != null && textBox.text == text)
            {
                textBox.visited = true;
            }
        }
    }
}

public class TextBox
{
    public string text;
    public bool visited;

    public TextBox(string text)
    {
        this.text = text;
        visited = false;
    }
}

For now, I’ve artificially capped the maximum conversations you can trigger for a character at one time as 4. I don’t think I’ll need any more than that, but I can always change it if needed.

Next, much like the DialogueManager, we’re going to need a ConversationManager that handles showing/hiding the Conversation UI and populating it’s contents.

public class ConversationManager : Singleton<ConversationManager>
{
    public Animator animator;
    public bool canvasOpen;
    public Image speaker;
    public TextMeshProUGUI speakerName;
    public Button[] talkItems;

    private PlayerMovement playerMovement;
    private DialogueInitializer dialogueInitializer;
    private Conversation lastConversation;
    private Camera mainCamera;

    void Awake()
    {
        mainCamera = Camera.main;
        dialogueInitializer = DialogueInitializer.Instance;
        playerMovement = FindObjectOfType<PlayerMovement>();
        canvasOpen = false;
    }

    void Update()
    {
        if (canvasOpen && Input.GetKeyDown(KeyCode.Backspace)) {
            EndConversation();
        }
    }

    public void StartConversation(Conversation conversation)
    {
        mainCamera.GetComponent<CinemachineBrain>().enabled = false;
        playerMovement.DisablePlayerMovement();
        animator.SetBool("IsOpen", true);
        speaker.sprite = EmotionHelper.GetSpriteOfEmotion(conversation.emotion, conversation.actor);
        speakerName.text = conversation.actor.FirstName;
        canvasOpen = true;

        for (int i = 0; i < talkItems.Length; i++)
        {
            if (conversation.textboxes[i] != null)
            {
                talkItems[i].gameObject.SetActive(true);
                talkItems[i].GetComponentInChildren<TextMeshProUGUI>().text = conversation.textboxes[i].text;
                if (conversation.textboxes[i].visited)
                {
                    talkItems[i].GetComponent<Image>().color = Color.grey;
                }
            }
            else
            {
                talkItems[i].gameObject.SetActive(false);
            }
        }

        lastConversation = conversation;
        //Cursor.lockState = CursorLockMode.None;
    }

    public void EndConversation()
    {
        animator.SetBool("IsOpen", false);
        canvasOpen = false;
        playerMovement.EnablePlayerMovement();
        mainCamera.GetComponent<CinemachineBrain>().enabled = true;
        //Cursor.lockState = CursorLockMode.Locked;
    }

    public void TriggerDialogue(TextMeshProUGUI text)
    {
        EndConversation();
        lastConversation.SetAsVisited(text.text);
        dialogueInitializer.TriggerDialogue(text.text, true);
    }

    public void ReturnToConversation()
    {
        StartConversation(lastConversation);
    }
}

There’s quite a bit to unpack there, but let’s try to step through it:

Awake and Update methods

Awake deals with setting some private variables. I’ve defined the DialogueInitializer (responsible for showing dialogue) as a singleton, so that explains the .Instance.

The update method listens for when to close the UI.

StartConversation method

The StartConversation method begins by disabling the camera and player movement, and bringing the UI into the screen.

It then updates the speaker name and image (from the Conversation parameter that was passed into it), and sets the canvasOpen variable to true.

It then loops through the talk points defined in the Conversation parameter, and sets them on the UI. The last thing it does is set the lastConversation variable to the Conversation parameter. This is to enable to Conversation to be reopened once a dialogue is triggered and completed.

EndConversation method

The EndConversation method reenables the camera and player movement, and removes the UI from the screen. Not too much here.

TriggerDialogue and ReturnToConversation methods

The TriggerDialogue function is triggered when a talk point button is pressed. It will hide the conversation UI, set the talk point as visited, and then trigger the dialogue.

The ReturnToConversation method brings the conversation UI back into view once a dialogue triggered from it has ended.

Implementing the Conversation UI

Brackey’s tutorial triggered the dialogue via a button. That is not going to work for my implementation, as the Conversation UI needs to change depending on which character it is triggered for.

For that, we need to have some scripts on our characters! So, let’s go into our scene view and look and my wonderfully modelled NPC character:

The Actor Selector script on a NPC game object

As you can see, I’m quite the 3D modeller. On the left, I’ve attached an ActorSelector class to the NPC. This just lets me choose the character (actor) from an enum I’ve defined. This enum holds all the characters in the game.

Now, to my wonderfully modelled player character:

The ConversationTrigger script on a child of the Player game object

Okay, I lied, I got that model online. I’ve attached a ConversationTrigger class to the player, and this is what enables the conversation UI to appear when we are close enough to the character we want to talk to.

public class ConversationTrigger : Singleton<ConversationTrigger>
{
    private bool startConversation = false;
    private ActorSelector conversationWith;

    private ConversationInitializer conversationInitializer;

    void Awake()
    {
        conversationInitializer = ConversationInitializer.Instance;
        conversationWith = null;
    }

    void Update()
    {
        if (startConversation && Input.GetKeyDown(KeyCode.Space)) {
            conversationInitializer.TriggerConversation(conversationWith.actor);
        }
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.tag == "NPC")
        {
            startConversation = true;
            conversationWith = other.gameObject.GetComponent<ActorSelector>();
        }
    }

    void OnTriggerExit(Collider other)
    {
        if (other.tag == "NPC")
        {
            startConversation = false;
            conversationWith = null;
        }
    }
}

Complementing this script, the player has an trigger collider attached to a child object. This collider sticks out in front of them. This extra collision is what this script is checking for. It allows us to walk up to a character in the game and start a conversation, much how you would in real life (sans a picture of their face and name appearing in front of your eyes).

ConversationInitializer class

Okay, so now we have our conversation UI, a way to trigger it, and a way to control what it shows. So how does it know what to show? With our ConversationInitializer class, that’s how!

public class ConversationInitializer : Singleton<ConversationInitializer>
{
    private ConversationManager conversationManager;
    private DialogueInitializer dialogueInitializer;

    void Start()
    {
        conversationManager = ConversationManager.Instance;
        dialogueInitializer = DialogueInitializer.Instance;
        ConversationDatabase.InitializeConversations();
    }

    public void TriggerConversation(ActorList actor)
    {
        if (actor == ActorList.BLOCKING_GUARD)
        {
            conversationManager.StartConversation(ConversationDatabase.BLOCKING_GUARD);
        } else if (actor == ActorList.DETECTIVE)
        {
   dialogueInitializer.TriggerDialogue(DialogueKeys.DETECTIVE_CORONER_INTRO, false);
        }
        else if (actor == ActorList.CORONER)
        {
   conversationManager.StartConversation(ConversationDatabase.CORONER);
        }
    }
}

You would have seen the conversationInitializer.TriggerConversation(conversationWith.actor) line up above in the ConversationTrigger class and wonder where that goes to. The answer is here. This class is going to initialise all the possible conversations that can be triggered and show them based on the NPC the player is currently looking at. When it all boils down to it, it’s just a very long if else statement, but hey, they’re always hiding somewhere.

DialogueInitializer class

In the same vein, we have a DialogueInitializer class.

public class DialogueInitializer : Singleton<DialogueInitializer>
{
    private DialogueManager dialogueManager;
    public Dictionary<string, Dialogue[]> dialogues;
    
    void Start()
    {
        dialogueManager = DialogueManager.Instance;
        DialogueDatabase.InitializeDialogueDictionary();
    }

    public void TriggerDialogue(string key, bool returnToConversation)
    {
        Dialogue dialogue = DialogueDatabase.dialogues[key];

        dialogueManager.StartDialogue(dialogue, returnToConversation);
    }
}

This is going to initialise all the possible dialogues in the game into a dictionary. Then, when one needs to be displayed, the key is passed into the TriggerDialogue to find and display it.

There’s a neat trick here, wherein the text of the talk button on the conversation UI is the key for the dialogue within the dialogue dictionary. This prevents worry from wondering what to set as the talk button text.

Presenting Items to Characters

Now, how do we present an item? Luckily for us, it’s a similar notion to what we’ve done previously.

Updating the ConversationManager class

First things first, we’re going to need to add references to the UI components pertaining to presenting items.

public class ConversationManager : Singleton<ConversationManager>
{
...
    public Button[] talkItems;
    public Image inventoryItemImage;
    public TextMeshProUGUI inventoryItemName;
    public Button inventoryLeft;
    public Button inventoryRight;
...

    void Awake()
    {
        mainCamera = Camera.main;
        dialogueInitializer = DialogueInitializer.Instance;
        playerMovement = FindObjectOfType<PlayerMovement>();
        canvasOpen = false;

        inventoryIndex = 0;
        UpdateInventoryItem();
        UpdateInventoryButtons();
    }

...

    public void StartConversation(Conversation conversation)
    {
        mainCamera.GetComponent<CinemachineBrain>().enabled = false;
        playerMovement.DisablePlayerMovement();
        animator.SetBool("IsOpen", true);
        speaker.sprite = EmotionHelper.GetSpriteOfEmotion(conversation.emotion, conversation.actor);
        speakerName.text = conversation.actor.FirstName;
        UpdateInventoryButtons();
        canvasOpen = true;

        for (int i = 0; i < talkItems.Length; i++)
        {
            if (conversation.textboxes[i] != null)
            {
                talkItems[i].gameObject.SetActive(true);
                talkItems[i].GetComponentInChildren<TextMeshProUGUI>().text = conversation.textboxes[i].text;
                if (conversation.textboxes[i].visited)
                {
                    talkItems[i].GetComponent<Image>().color = Color.grey;
                }
            }
            else
            {
                talkItems[i].gameObject.SetActive(false);
            }
        }

        lastConversation = conversation;
        //Cursor.lockState = CursorLockMode.None;
    }

...

    public void MoveInventoryLeft()
    {
        if (inventoryIndex == 0)
        {
            inventoryIndex = InventoryManager.Instance.items.Count - 1;
        } else
        {
            inventoryIndex--;
        }

        UpdateInventoryItem();
    }

    public void MoveInventoryRight()
    {
        if (inventoryIndex == InventoryManager.Instance.items.Count - 1)
        {
            inventoryIndex = 0;
        }
        else
        {
            inventoryIndex++;
        }

        UpdateInventoryItem();
    }

    public void UpdateInventoryItem()
    {
        inventoryItemImage.sprite = InventoryManager.Instance.items[inventoryIndex].image;
        inventoryItemName.text = InventoryManager.Instance.items[inventoryIndex].name;
    }

    public void UpdateInventoryButtons()
    {
        if (InventoryManager.Instance.items.Count < 2)
        {
            inventoryLeft.interactable = false;
            inventoryRight.interactable = false;
        } else
        {
            inventoryLeft.interactable = true;
            inventoryRight.interactable = true;
        }
    }

    public void PresentItem()
    {
        Item itemToPresent = InventoryManager.Instance.items[inventoryIndex];

        EndConversation();
        dialogueInitializer.TriggerDialogue(lastConversation.actor, itemToPresent);
    }
}

Some small additions here and there (I wish WordPress would let me add formatting to the code snippets…).

We’ve added some variables to handle the item presenting side of things. Since this is presenting items, they are being accessed from our inventory. You’ll see some references to that in the script.

In the Awake() function, we’ve added some simple initialization. We set the index of our Inventory list and then populate the UI data with the item at that index. The code will disable item switching if only 1 item is in the inventory (there will never be less than 1 in my game).

The last new function, PresentItem(), takes the current item on the UI, and the current person we are having a conversation with, and sends it through to the DialogueInitializer class.

Updating the DialogueInitializer class

Now we’ve got a second way we want to trigger dialogue: by presenting an item. To account for this, I’ve overloaded the TriggerDialogue function in the DialogueInitializer class with a function that takes the item being presented and the person being presented with it.

    public void TriggerDialogue(Actor actor, Item item)
    {
        if (item == ItemDatabase.GetItem(ItemId.ID))
        {
            if (actor == ActorDatabase.BLOCKING_GUARD)
            {
                    TriggerDialogue(DialogueKeys.GUARD_SHOWING_ID, true);
            }
        } 
    }

In this function, it’s just a matter of matching the item and person to the dialogue needed. No matter how deep I try to bury my massive if-else blocks, they always come to light sooner or later.

My extension of the Brackey’s dialogue system

Retrospective

Using information available online to gain, and then extend upon knowledge is a great way to learn. Understanding how to search for and implement a solution to a problem is a fundamental learning method for a programmer. A simple online tutorial has now given me the wherewithal to implement dialogue systems of varying complexity in all of my projects.

However, as I type this up, I’ve noted a few things that can be worked upon to further improve/extend this dialogue system.

Implementing choices

At the moment, there isn’t the capability to control branching dialogue. For example, when the player needs to make a choice. Choices are commonplace in adventure games that have visual novel elements, so this is something that is going to need to be implemented eventually.

Confusing class names

The class names are somewhat confusing. Conversation and Dialogue as words have very similar meanings, but I couldn’t think of a more explanative naming convention (naming is slightly my failing). To combat this, I made a readme file for my GitHub repository, which explains these things in case I need a refresher. Not ideal, but still workable.

Changing presenting items display

At the moment the conversation UI displays a single item. It looks fine since the inventory is tiny, but if the inventory was expanded to include a larger list, it would be cumbersome to have to cycle through it every time you needed to find a specific item to present. In the future I’ll look at updating the presenting items UI.

Dictionary Key Constraint

Using the dialogue dictionary’s key for the talk button text means that I won’t be able to use the same talk button text for two different dialogues. That sucks if I want to have a generic “Need any help?” button for multiple characters, but I can get around this by adding identifiers to the keys and formatting them for the talk button text.


With that, I think I’ll switch some learning new things to tackling the mountain of games in my “to play” pile! It’s the perfect time, because I’m going back into my code and finding some pretty heinous mistakes. For a good portion of a week I forgot what constructors were and started using static methods for them instead. Boy is my face red. If anyone needs me, I’ll be finishing Act III of We Happy Few 😊 .

I hope you enjoyed this little write-up! If you have any feedback on how to improve this dialogue system, please let me know!

Feel free to comment below, or contact me on twitter.

Until next time,
Adrian

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s