Creating a Dialogue System with branch memory

As I’ve been finishing up the technical debt of my rpg, one of the things that jumped out at me was how there is no memory of conversation threads, so we couldn’t have a thread with NPC A and when we come back, the options and dialogue are different or having the conversation with NPC A allowing us to open up new dialogue with NPC B(without making it part of a quest objective). I put something together that is minimally invasive to the greater dialogue system by utilizing a DialogueStore, IItemStore interface and the HasInventoryItems Predicate, but definitely there may be a better way that doesn’t tie so closely to the Inventory system that I’d be very open to. Any advice would be super helpful.

With that said, here it goes

Setting up the groundwork, each NPC that we want to save dialogue branches will have a DialogueItem created. The npc string is their SaveableEntity ID that without prefabbing each NPC means it needs to be copy and pasted onto the item. I’m not making a Bethesda scale game, so I can live with that. At runtime the item is set to Stackable, so we can use the number being passed in the HasInventoryItems predicate that we’ll see later.

using UnityEngine;
using GameDevTV.Inventories;

namespace RPG.Inventories.Items
{
    [CreateAssetMenu(fileName = "NPC Dialogue Item", menuName = ("RPG/Inventory/Create New NPC Dialogue"))]
    public class NPCDialogueItem : InventoryItem
    {
        [SerializeField] string npc;

        public string NPC { get { return npc; } }
    }
}

Adding a bunch of DialogueBranchFinished triggers to our TriggerType enum

namespace RPG.Dialogue
{
    public enum TriggerType
    {
        Select, // Default State that returns immediately from TriggerAction
        Attack, // References AggroGroup component
        GiveItem, // References ItemGiver Component (NPC item->Player)
        GiveQuest, // References QuestGiver Component
        CompleteObjective,  // References QuestCompletion Component on NPC or Child Object
        CompleteObjective1, // References QuestCompletion Component on Child Object
        CompleteObjective2, // References QuestCompletion Component on Child Object
        CompleteObjective3, // References QuestCompletion Component on Child Object
        CompleteObjective4,  // References QuestCompletion Component on Child Object
        ReceiveItem, // References ItemGiver Component (Player->NPC)
        TriggerPortal, // References SetPortalState on Portal Object
        TriggerShop, // References SetActiveShop on Player Object
        DialogBranchFinished1, // References DialogueItemGiver component 
        DialogBranchFinished2, // References DialogueItemGiver component 
        DialogBranchFinished3, // References DialogueItemGiver component 
        DialogBranchFinished4, // References DialogueItemGiver component 
        DialogBranchFinished5, // References DialogueItemGiver component 
        DialogBranchFinished6, // References DialogueItemGiver component 
        DialogBranchFinished7, // References DialogueItemGiver component 
        DialogBranchFinished8, // References DialogueItemGiver component 
        DialogBranchFinished9, // References DialogueItemGiver component 
        DialogBranchFinished10, // References DialogueItemGiver component 
    }   // Use an OnDie call to CompleteObjective to complete kill quests
}

A DialogueItemGiver component that will be triggered and contain the NPC we are talking to and the branch number we want to remember. This is where it can get a little bit messy in implementation as each branch will have to have the number manually assigned.

using GameDevTV.Inventories;
using RPG.Inventories.Items;
using UnityEngine;

namespace RPG.Inventories
{
    public class DialogueItemGiver : MonoBehaviour
    {
        // CONFIG DATA
        [SerializeField] NPCDialogueItem item;
        [SerializeField] int dialogBranchNumber;

        // PUBLIC
        public void GiveDIalogueItem()
        {
            Inventory playerInventory = GameObject.FindWithTag("Player").GetComponent<Inventory>();

            playerInventory.AddToFirstEmptySlot(item, dialogBranchNumber);
        }
    }
}

On to the meat of the Store. The nice thing about the store is we never have to take information out of it or do anything with the InventoryItem itself, so all we need to store is the npc ID and an integer list of branches that have been visited. This also allows us to implement probably the easiest Capture/Restore in the entire RPG. I added GetItemCount(InventoryItem item) to IItemStore for use by the Predicate Evaluator for some other Stores I created, which isn’t used in this class, but am using the one that takes the number.

using System;
using System.Collections;
using System.Collections.Generic;
using GameDevTV.Inventories;
using GameDevTV.Saving;
using RPG.Inventories.Items;
using UnityEngine;

namespace RPG.Inventories
{
    public class DialogueStore : MonoBehaviour, IItemStore, ISaveable
    {
        Dictionary<string, List<int>> slotDictionary = new Dictionary<string, List<int>>();

        public int AddItems(InventoryItem item, int number)
        {
            NPCDialogueItem dialogueItem = item as NPCDialogueItem;

            if (slotDictionary.ContainsKey(dialogueItem.NPC))
            {
                foreach (int branchNumber in slotDictionary[dialogueItem.NPC])
                {
                    if (branchNumber == number)
                    {
                        //Debug.Log("Branch Exists");
                        return number; // Branch already exists in the dictionary for the object
                    }
                }

                List<int> branchNumbers = new List<int>();
                branchNumbers = slotDictionary[dialogueItem.NPC];
                branchNumbers.Add(number);
                //Debug.Log("Added Branch to existing NPC");
                return number; // NPC slot exists and new Branch is added
            }

            List<int> newBranchNumber = new List<int>();
            newBranchNumber.Add(number);
            slotDictionary[dialogueItem.NPC] = newBranchNumber;
            //Debug.Log("Added New Branch to New NPC");
            return number;
        }

        public int GetItemCount(InventoryItem item)
        {
            return 0;
        }

        public bool GetItemCount(InventoryItem item, int number)
        {
            NPCDialogueItem dialogueItem = item as NPCDialogueItem;

            if (slotDictionary.ContainsKey(dialogueItem.NPC))
            {
                foreach (var branchNumber in slotDictionary[dialogueItem.NPC])
                {
                    if (branchNumber == number)
                        return true;
                }
            }

            return false;
        }

        object ISaveable.CaptureState()
        {
            return slotDictionary;
        }

        void ISaveable.RestoreState(object state)
        {
            slotDictionary = (Dictionary<string, List<int>>)state;
        }
    }
}

I made a small change to Inventory.Evaluate for this (and larger changes for other stores). This is the only place of integration outside of the dialogue system.

                case EPredicate.HasInventoryItems: //Only works for stackable items.
                    // This will work for Gold and Experience
                    int otherStoreCount = CheckForConditionsInStores(InventoryItem.GetFromID(parameters[0]));
                    if (otherStoreCount > 0 && (int.TryParse(parameters[1], out int storeResult)))
                        return otherStoreCount > storeResult;

                    // This will work for Dialogue Branches
                    if (InventoryItem.GetFromID(parameters[0]) is NPCDialogueItem)
                    {
                        int dialogueBranch;
                        int.TryParse(parameters[1], out dialogueBranch);
                        return GetComponent<DialogueStore>().GetItemCount(InventoryItem.GetFromID(parameters[0]), dialogueBranch);
                    }

                    // This will work for aggregating stackable items in ActionStore and Inventory
                    int actionStoreCount = 0;
                    if (InventoryItem.GetFromID(parameters[0]) is ActionItem)
                        actionStoreCount = CheckForConditionsInActionStore(InventoryItem.GetFromID(parameters[0]) as ActionItem);

                    InventoryItem item = InventoryItem.GetFromID(parameters[0]);
                    int stack = FindStack(item) + actionStoreCount;
                    if (stack == -1) return false;
                    if (int.TryParse(parameters[1], out int result))
                    {
                        return slots[stack].number + actionStoreCount >= result;
                    }

                    return false;

Not directly related, but I added the Predicate Evaluator check into Dialogue.GetRootNode which allows you to put conditions on the root node, so if you have visited certain branches (or other conditions), the entry point may change.

        public DialogueNode GetRootNode()
        {
            foreach(DialogueNode node in nodes)
            {
                if (!HasParent(node))
                {
                    if (node.CheckCondition(GameObject.FindGameObjectWithTag("Player").GetComponents<IPredicateEvaluator>()))
                        return node;
                }
            }

            return nodes[0];

        }

From a configuration perspective, the only things that are important on the NPCDialogueItem are the Display Name, Stackable set to true, and NPC GUID.

On the NPC, the DialogueTrigger “should” match the Dialogue Branch Number.

And then the Node condition just uses the standard Has Inventory Items with the NPC and branch number selected.

Wow, this is a creative way to take advantage of the IItemStore interface to create a DialogueMemory system. Well done.

This could be done without the InventoryItem/IItemStore tie in by manipulating the DialogueMemory directly in a subclass of DialogueTrigger.

I would make my DialogueMemory an IPredicateEvaluator in it’s own right, with a new Predicate HasMemory <string, int>

In DialogueStore.AddItems, change from an InventoryItem to a string

public int AddItems(string token, int number)
{
     if(slotDictionary.ContainsKey(token))
     {
          if(!slotDictionary[token].IndexOf(number)==-1)
          {
                slotDictionary[token].Add(number);
          }
          return number;
      }
      slotDictionary[token] = new List<int>();
      slotDictionary[token].Add(number);
      return number;
}
//Consider renaming this to HasMemory
public bool GetItemCount(string token, int number)
{
    if(slotDictionary.ContainsKey(token)) return slotDictionary[token].IndexOf(number)>=0;
    return false;
}

public bool? IPredicateEvaluator(EPredicate predicate, string[] parameters)
{
    switch(predicate)
    case EPredicate.HasMemory:
        if(int.TryParse(string[1], out int number)
        {
             return GetItemCount(parameters[0], number);
        }
        return false;
    default: return null;
}
1 Like

Wow. Thanks so much Brian. It hadn’t even occurred to me to use a subclass of DialogueTrigger. This should simplify things immensely and remove the brittleness of needing to manually enter the SaveEntity ID as well as forcing this through the inventory system.

If I re-indexed the TriggerType enum (eg, DialogueMemory1 = 1) and cached the SaveableEntity id, then there would be no need for any configuration on storing the memory.

The Predicate Drawer may be the trickiest part, but FindObjectsOfType(typeOf(AIConversant)) should get me where I want to go.

Edit: Also, didn’t realize you could manipulate Lists directly in a Dictionary. I think I’ve been working with structs too much the last few weeks and got used to having to pull the object out in order to manipulate it.

Yeah, my shortcuts there would never have worked with a struct.

You can simplify the code even further if instead of a

Dictionary<string, List<int>>

You used a

Dictionary<string, Dictionary<int, bool>>

Then

public int AddItems(string token, int number)
{
     if(slotDictionary.ContainsKey(token))
     {
          slotDictionary[token][number] = true;
          return number;
      }
      slotDictionary[token] = Dictionary<int, bool>();
      slotDictionary[token][number] = true;
      return number;
}

Thanks so much Brian. I re-implemented it according to your thoughts and it’s so much simpler and easier to setup.

I added the following to TriggerType

        TriggerMemory1 = 101, // References DialogueMemoryTrigger component 
        TriggerMemory2 = 102, // References DialogueMemoryTrigger component 
        TriggerMemory3 = 103, // References DialogueMemoryTrigger component 
        TriggerMemory4 = 104, // References DialogueMemoryTrigger component 
        TriggerMemory5 = 105, // References DialogueMemoryTrigger component 
        TriggerMemory6 = 106, // References DialogueMemoryTrigger component 
        TriggerMemory7 = 107, // References DialogueMemoryTrigger component 
        TriggerMemory8 = 108, // References DialogueMemoryTrigger component 
        TriggerMemory9 = 109, // References DialogueMemoryTrigger component 
        TriggerMemory10 = 110, // References DialogueMemoryTrigger component 

and the following to IPredicateEvaluator. I had to update the PredicateDrawer to use intValue instead of enumValueIndex, but it doesn’t look like that change broke anything. I really wanted to make this as painless as possible to setup and remove any mistyping of a number. A little duplication of code will go a long way towards making setup easier.

        HasMemory1 = 101, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory2 = 102, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory3 = 103, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory4 = 104, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory5 = 105, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory6 = 106, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory7 = 107, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory8 = 108, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory9 = 109, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore
        HasMemory10 = 110, //1, DialogueMemoryStore needs to know the character token.  Evaluated in DialogueMemoryStore

The DialogueTrigger subclass makes this super easy to add a memory and doesn’t require much configuration.

using GameDevTV.Saving;
using UnityEngine;

namespace RPG.Dialogue
{
    public class DialogueMemoryTrigger : DialogueTrigger
    {
        string characterToken;
        int memoryNumber;

        GameObject player;

        private void Awake()
        {
            characterToken = GetComponent<SaveableEntity>().GetUniqueIdentifier();
            memoryNumber = (int)action;
            player = GameObject.FindGameObjectWithTag("Player");
        }

        public void AddMemoryToStore()
        {
            player.GetComponent<DialogueMemoryStore>().AddMemory(characterToken, memoryNumber);
        }
    }
}

The MemoryStore is much more streamlined as well.

using System.Collections.Generic;
using GameDevTV.Saving;
using GameDevTV.Utils;
using UnityEngine;

namespace RPG.Dialogue
{
    public class DialogueMemoryStore : MonoBehaviour, ISaveable, IPredicateEvaluator
    {
        // CACHE
        Dictionary<string, List<int>> memoryDictionary = new Dictionary<string, List<int>>();

        //PUBLIC
        public void AddMemory(string token, int memoryNumber)
        {
            if (memoryDictionary.ContainsKey(token))
            {
                if (memoryDictionary[token].IndexOf(memoryNumber) < 0)
                {
                    memoryDictionary[token].Add(memoryNumber);
                    return;
                }
                return;
            }

            memoryDictionary[token] = new List<int>();
            memoryDictionary[token].Add(memoryNumber);
        }

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            switch (predicate)
            {
                case EPredicate.HasMemory1:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory1);
                case EPredicate.HasMemory2:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory2);
                case EPredicate.HasMemory3:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory3);
                case EPredicate.HasMemory4:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory4);
                case EPredicate.HasMemory5:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory5);
                case EPredicate.HasMemory6:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory6);
                case EPredicate.HasMemory7:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory7);
                case EPredicate.HasMemory8:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory8);
                case EPredicate.HasMemory9:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory9);
                case EPredicate.HasMemory10:
                    return HasMemory(parameters[0], (int)EPredicate.HasMemory10);
                default: return null;
            }

        }

        public bool HasMemory(string token, int number)
        {
            if (memoryDictionary.ContainsKey(token))
                return memoryDictionary[token].IndexOf(number) >= 0;

            return false;
        }

        // PRIVATE

        object ISaveable.CaptureState()
        {
            return memoryDictionary;
        }

        void ISaveable.RestoreState(object state)
        {
            memoryDictionary = (Dictionary<string, List<int>>)state;
        }
    }
}

Saving the memory is basically just a callback now

And the predicate check is just selecting who the memory applies. What’s nice is I can use this very easily for any sort of trigger event

Privacy & Terms