Skip to content

Dialogue Graph From Scratch

CoffeeVampir3 edited this page Apr 11, 2021 · 9 revisions

This tutorial is going to show you how to create a basic dialogue graph with branching options. The goal here is to demonstrate how to use Graphify, the code will be as concise (read: terrible) as possible for the sake of illustraiting the graph API. You can find the files used for this example here: Dialouge Graph Example Here's what we're going to be working toward in this tutorial:

example

First steps

The first step you should take when creating a new graph is to define the Graph Blueprint:

[CreateAssetMenu]
public class DialogueBP : GraphBlueprint
{
}

Very simple. Not much to talk about is their? Next, we'll define a nice simple root node and ensure it's registered.

    [RegisterTo(typeof(DialogueBP))]
    public class DialogueRoot : RuntimeNode, IRootNode
    {
        [Out] 
        public ValuePort<Any> rootOut = new ValuePort<Any>();

        protected override RuntimeNode OnEvaluate(Context evContext)
        {
            return rootOut.FirstNode();
        }
    }

Let us tear this apart together. [RegisterTo] has instructed the graph to use this node type. Inheriting from RuntimeNode defines this as a graph node, and adding the RootNode interface tells the graph that this is... a root node. Cool. We've created an output port that can connect to any type, and we always return the first link's node whenever this gets evaluated. You could add more logic here if you wanted, but it's not something we need in our case.

Okay, lets create one more node;

//Note that we've added a path, without this the node will not be searchable!
[RegisterTo(typeof(DialogueBP), "Dialogue Node")]
public class DialogueNode : RuntimeNode
{
    [In(Capacity.Multi), SerializeField] 
    public ValuePort<string> stringIn = new ValuePort<string>();
    [Out(Capacity.Single), SerializeField] 
    public DynamicValuePort<string> stringOut = new DynamicValuePort<string>();
    [SerializeField]
    public string testDialogue = "";

    protected override RuntimeNode OnEvaluate(Context evContext)
    {
        return null
    }
}

dynamicExample

Here you can see we've created a node with a dynamic value output port, meaning we can have as many outputs as we want. This will be very handy. Additionally, we've defined some data we can use with testDialogue, the graph will automatically layout this node and we can use this as the graph's output dialogue!

Believe it or not, this simple skeleton is all we need on the graph side of things for now, we'll extend the features of DialogueNode to actually do something in a moment, but first we're going to construct a very quick and dirty dialogue system. If you're confused how to setup this system, you can take a look at the provided examples in the examples branch of this repo, Dialouge Graph Example

Let's create a class to define choices:

    public class Choicer : MonoBehaviour
    {
        public Button choiceButton;
        public Transform layoutParent;
        public CanvasGroup CanvasGroup;
        private readonly List<Button> choiceButtons = new List<Button>();

        public void ClearChoices()
        {
            for (int i = choiceButtons.Count-1; i >= 0; i--)
            {
                Destroy(choiceButtons[i].gameObject);
            }
            choiceButtons.Clear();
        }

        public void Show()
        {
            CanvasGroup.alpha = 1;
        }
        
        public void Hide()
        {
            CanvasGroup.alpha = 0;
        }
        
        public void CreateChoice(string choiceText, int choiceIndex, Action onChooose)
        {
            var item = Instantiate(choiceButton, layoutParent);
            var text = item.GetComponentInChildren<TMP_Text>();
            text.text = choiceText;
            choiceButtons.Add(item);
            item.name = choiceIndex.ToString();
            item.onClick.AddListener(new UnityAction(onChooose));
            item.gameObject.SetActive(true);
        }
    }

Here, we've defined a button prefab (in my case I was lazy and just used a disabled button in the hierarchy, as you'll see) which we'll use as a prototype to create our choice-buttons from. Additionally, we have a parent transform which is what object we're going to parent the buttons onto. There's also a canvas group, whatever you set as the transform parent should also be given a canvas group for this to work. Here's a screenshot of how I've setup my choicer:

choicer choicer hierarchy

Note that I've disabled the choice button, you can also just use a prefab like a sane person. It's quick and dirty, but hopefully the point is clear. We'll use this choicer in a moment, but we need one more system

    public class Dialogue : MonoBehaviour
    {
        [SerializeField] 
        private TMP_Text dialogueText;
        [SerializeField] 
        private Choicer dialogueChoicer;
        private static Dialogue instance = null;
        private int clickedIndex = -1;

        private void Awake()
        {
            //Real singletons need to keep track of multiple instances, but this is for example.
            instance = this;
        }

        public static bool AwaitingInput => instance.awaitingInput;

        public static void SendText(string text)
        {
            instance.dialogueText.text = text;
        }

        public static void SendChoice(string text, int index, Action onChosen)
        {
            instance.dialogueChoicer.CreateChoice(text, index, onChosen);
        }

        public static void ClearChoices()
        {
            instance.dialogueChoicer.Hide();
            instance.dialogueChoicer.ClearChoices();
        }

        public static void StartChoices()
        {
            instance.dialogueChoicer.Show();
        }

        public static void OnChoiceClicked()
        {
            ClearChoices();
            instance.clickedIndex = Convert.ToInt32(EventSystem.current.currentSelectedGameObject.name);
        }

        public static int ConsumeClickedIndex()
        {
            int val = instance.clickedIndex;
            instance.clickedIndex = -1;
            return val;
        }
    }

Now, setup the scene with the choicer all set up, and add this dialogue system game object to something and set it's components up. Shouldn't be much hastle. Okay, I'm not going into any detail with this the code because it's extremely simple and I trust you can figure out what it's doing. The important bit is how we're going to hook this into our dialogue node, we've going to focus only on the OnEvaluate function, because it's the only thing changing here:

    protected override RuntimeNode OnEvaluate(Context evContext)
    {
        if (!stringOut.IsLinked())
        {
            Dialogue.SendText(testDialogue);
            return this;
        }

        if (!stringOut.HasMultipleLinks())
        {
            Dialogue.SendText(testDialogue);
            return stringOut.FirstNode();
        }
        return this;
    }

Okay, we're simply sending the testDialogue so it outputs to our TMPro object right now. The first bit is checking if stringOut has any links, if it doesn't we send the text on this node and return itself. Why? Why not return null here? Simple, this way we can setup our graph so it reacts instantly to changes as we edit the graph in real time as the game is running. Yeah, it'll really be able to do that hold onto your pants kiddos.

Next we check if there's multiple links, if their are not we simply return the only existing link. This has a side effect that if you're editing the last node in real-time, it's going to hop over as soon as you connect any nodes to it. Keep that in mind, if it's not a disireable behaviour for you, you can of course change it to suite you.

Next we're going to add onto this idea:

        int clickedIndex = Dialogue.ConsumeClickedIndex();
        if(clickedIndex >= 0)
        {
            Debug.Log(clickedIndex);
            foreach (var link in stringOut.Links)
            {
                if (link.PortIndex == clickedIndex)
                {
                    return link.Node;
                }
            }

            return this;
        }

Here, we're going to check if the user clicked one of our buttons. If they did, Dialogue will consume the index and send it to our node. If we find a valid option, we return the first one we find. Otherwise, we'll return ourself and loop back to this option-select again. One more bit:

        Dialogue.ClearChoices();

        Dialogue.SendText(testDialogue);
        foreach (var link in stringOut.Links)
        {
            if (link.Node is DialogueNode dn)
            {
                Dialogue.SendChoice(dn.testDialogue, link.PortIndex, Dialogue.OnChoiceClicked);
            }
        }
        Dialogue.StartChoices();
        return this;

Here, we handle the final case where there's more than one connection that we're linked to. We get their dialogue data and construct a new choice, then simply show the choices and wait.

Here's the whole class we've constructed so far:

[RegisterTo(typeof(DialogueBP), "Dialogue Node")]
public class DialogueNode : RuntimeNode
{
    [In(Capacity.Multi), SerializeField] 
    public ValuePort<string> stringIn = new ValuePort<string>();
    [Out(Capacity.Single), SerializeField] 
    public DynamicValuePort<string> stringOut = new DynamicValuePort<string>();
    [SerializeField]
    public string testDialogue = "";

    protected override RuntimeNode OnEvaluate(Context evContext)
    {
        if (!stringOut.IsLinked())
        {
            Dialogue.SendText(testDialogue);
            return this;
        }

        if (!stringOut.HasMultipleLinks())
        {
            Dialogue.SendText(testDialogue);
            return stringOut.FirstNode();
        }

        int clickedIndex = Dialogue.ConsumeClickedIndex();
        if(clickedIndex >= 0)
        {
            Debug.Log(clickedIndex);
            foreach (var link in stringOut.Links)
            {
                if (link.PortIndex == clickedIndex)
                {
                    return link.Node;
                }
            }

            return this;
        }

        Dialogue.ClearChoices();
        Dialogue.SendText(testDialogue);
        foreach (var link in stringOut.Links)
        {
            if (link.Node is DialogueNode dn)
            {
                Dialogue.SendChoice(dn.testDialogue, link.PortIndex, Dialogue.OnChoiceClicked);
            }
        }
        Dialogue.StartChoices();
        return this;
    }

Finally, we need a way to test this graph, remember you can open the graph and see where in the graph your program is in real time by setting the GraphEvaluator's Should Link Editor to true in the inspector.

    public class GraphTester : MonoBehaviour
    {
        public GraphEvaluator executor;
        
        private void Start()
        {
            executor.Initialize();
        }

        private float lastTime = 0f;
        private void Update()
        {
            if (Dialogue.AwaitingInput)
                return;

            if ((Time.unscaledTime - lastTime < 0.50f)) return;
            executor.Step();
            lastTime = Time.unscaledTime;
        }
    }

Now just add this to the scene, setup it's parameters and play with the graph!

That's it, now you can create your dialogue graph and edit it in real time. Adding a few bells and whistles (Like waiting for the text to be fully displayed, awaiting user input before proceeding, writing the code well, etc) and you've got a completely working editable-in-real-time dialogue graph. Here's an in game screenshot of our beautiful system as it's being tracked in real time:

graph in action