[SOLUTION] Fix for slow game dialogues when the conversations are too big

Hey all

OK so if you have worked on the RPG Core Combat Series beyond what the courses teach and you tried to create quests with massive dialogues, say 20+ nodes per dialogue, you will have noticed that it gets really slow, like 5 second delay before the dialogue ever runs kind of slow

I was working a little on this recently and wanted to share my solution with you all, should anyone need it. I won’t run you down on what I did, though, because even I am still trying to understand what Sonnet (I used AI to help me with that) did to fix it, but I do know 2 major steps were involved in the process:

  1. Instead of getting each dialogue by name each time we enter a conversation with an NPC, I cached all dialogues in a static dictionary so that we can grab them later on when we enter that conversation again down the line, as follows (my full Dialogue.cs script attached below):
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;  // to be able to access the 'Undo' class (line 81)

namespace RPG.Dialogue {

    [CreateAssetMenu(fileName = "New Dialogue", menuName = "Dialogue", order = 0)]
    public class Dialogue : ScriptableObject, ISerializationCallbackReceiver    // ISerializationCallbackReceiver Interface avoids errors in Adding object to asset (line 90)
    {

        [SerializeField]
        List<DialogueNode> nodes = new List<DialogueNode>();

        [SerializeField]
        Vector2 newNodeOffset = new Vector2(250, 0);

        // key = string, value = DialogueNode (Dictionaries are used because of their ability to quickly find value of stuff, based on given keys)
        Dictionary<string, DialogueNode> nodeLookup = new Dictionary<string, DialogueNode>();

        private void Awake()
        {
            // Whilst OnValidate() is always called in the '#if UNITY_EDITOR' -> '#endif' range, it never
            // gets called outside of the Unity Editor. The result? Our 'NEXT' button for our game dialogues was never exported 
            // (because to the game, it's NEVER called).
            // To fix that, we call it in Awake(), as shown below (this calls ALL OnValidate() functions, under all scripts
            // with the same namespace as this one ('RPG.Dialogue' in this case)):
            OnValidate();
        }

        private void OnValidate()
        {
            nodeLookup.Clear();

            foreach (DialogueNode node in GetAllNodes())
            {
                nodeLookup[node.name] = node;
            }
        }

        public IEnumerable<DialogueNode> GetRootNodes()
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                bool isChild = false;

                foreach (DialogueNode lookupNode in GetAllNodes())
                {
                    if (lookupNode.GetChildren().Contains(node.name))
                    {
                        isChild = true;
                        break;
                    }
                }

                if (!isChild) yield return node;
            }
        }

        // IEnumerables are an Interface (of type 'Dialogue Nodes' in this case), allowing objects to do 'for' loops over them:
        public IEnumerable<DialogueNode> GetAllNodes()
        {
            return nodes;
        }

        public DialogueNode GetRootNode()
        {
            return nodes[0];
        }

        public IEnumerable<DialogueNode> GetAllChildren(DialogueNode parentNode)
        {
            List<DialogueNode> result = new List<DialogueNode>();

            foreach (string childID in parentNode.GetChildren())
            {
                if (nodeLookup.ContainsKey(childID))
                {
                    yield return nodeLookup[childID];
                }
            }
        }

        // Cached dialogue lookup for performance
        private static Dictionary<string, Dialogue> dialogueCache = new Dictionary<string, Dialogue>();
        private static bool cacheInitialized = false;
        private static bool cacheInitializing = false;

        // TEST FUNCTION - 20/8/2024: OPTIMIZED VERSION WITH SAFE LOADING
        public static Dialogue GetByName(string dialogueName)
        {
            // If cache is ready, use it (instant lookup)
            if (cacheInitialized)
            {
                if (dialogueCache.ContainsKey(dialogueName))
                {
                    return dialogueCache[dialogueName];
                }
                else
                {
                    Debug.LogWarning($"Dialogue '{dialogueName}' not found in cache!");
                    return null;
                }
            }

            // If cache is currently initializing, force immediate completion
            // This is safe because it just finishes the loading process
            if (cacheInitializing)
            {
                Debug.Log($"Cache initializing, forcing immediate completion for: {dialogueName}");
                ForceInitializeCache(); // This will complete the cache immediately
                return GetByName(dialogueName); // Recursive call with cache now ready
            }

            // If cache not initialized, force initialize it immediately
            if (!cacheInitialized)
            {
                Debug.Log($"Cache not ready, force initializing for: {dialogueName}");
                ForceInitializeCache();
                return GetByName(dialogueName); // Recursive call with cache now ready
            }
            
            return null;
        }

        private static void InitializeDialogueCacheAsync()
        {
            if (cacheInitializing) return; // Already initializing
            
            cacheInitializing = true;
            
            // Use Unity's coroutine system for background loading
            if (Application.isPlaying)
            {
                // Find any MonoBehaviour to start coroutine
                var runner = UnityEngine.Object.FindObjectOfType<MonoBehaviour>();
                if (runner != null)
                {
                    runner.StartCoroutine(LoadDialoguesCacheCoroutine());
                }
                else
                {
                    // Fallback to immediate load if no MonoBehaviour found
                    InitializeDialogueCache();
                }
            }
            else
            {
                // In editor, load immediately
                InitializeDialogueCache();
            }
        }

        private static System.Collections.IEnumerator LoadDialoguesCacheCoroutine()
        {
            yield return null; // Wait one frame
            
            dialogueCache.Clear();
            Dialogue[] allDialogues = Resources.LoadAll<Dialogue>("");
            
            // Load in chunks to avoid frame drops
            int chunkSize = 5;
            for (int i = 0; i < allDialogues.Length; i += chunkSize)
            {
                for (int j = i; j < Mathf.Min(i + chunkSize, allDialogues.Length); j++)
                {
                    Dialogue dialogue = allDialogues[j];
                    if (dialogue != null && !string.IsNullOrEmpty(dialogue.name))
                    {
                        dialogueCache[dialogue.name] = dialogue;
                    }
                }
                yield return null; // Wait one frame between chunks
            }
            
            cacheInitialized = true;
            cacheInitializing = false;
            Debug.Log($"Dialogue cache loaded asynchronously with {dialogueCache.Count} dialogues");
        }

        private static void InitializeDialogueCache()
        {
            dialogueCache.Clear();
            Dialogue[] allDialogues = Resources.LoadAll<Dialogue>("");
            
            foreach (Dialogue dialogue in allDialogues)
            {
                if (dialogue != null && !string.IsNullOrEmpty(dialogue.name))
                {
                    dialogueCache[dialogue.name] = dialogue;
                }
            }
            
            cacheInitialized = true;
            cacheInitializing = false;
            Debug.Log($"Dialogue cache initialized with {dialogueCache.Count} dialogues");
        }

        // Call this if you add new dialogues at runtime
        public static void RefreshDialogueCache()
        {
            cacheInitialized = false;
            cacheInitializing = false;
        }

        // Call this early in your game (like in a loading screen) to preload dialogues
        public static void PreloadDialogueCache()
        {
            if (!cacheInitialized && !cacheInitializing)
            {
                InitializeDialogueCacheAsync();
            }
        }

        // Force immediate cache initialization (blocks until complete)
        public static void ForceInitializeCache()
        {
            if (!cacheInitialized)
            {
                Debug.Log("Dialogue: Force initializing cache...");
                // Stop any async initialization and do immediate load
                cacheInitializing = false;
                InitializeDialogueCache();
                Debug.Log("Dialogue: Force initialization complete!");
            }
        }

#if UNITY_EDITOR

        public void CreateNode(DialogueNode parent)
        {

            DialogueNode newNode = MakeNode(parent);

            Undo.RegisterCreatedObjectUndo(newNode, "Created Dialogue Node");
            Undo.RecordObject(this, "Added Dialogue Node");

            AddNode(newNode);

        }

        

        public void DeleteNode(DialogueNode nodeToDelete)
        {
            Undo.RecordObject(this, "Deleted Dialogue Node");
            nodes.Remove(nodeToDelete);
            OnValidate();
            CleanDanglingChildren(nodeToDelete);    // cleans up children (further nodes connected to node to delete down the line) of parent nodes that have been deleted
            Undo.DestroyObjectImmediate(nodeToDelete);  // destroys our node (but also saves a backup, just in case you need to Undo your changes)
        }

        private DialogueNode MakeNode(DialogueNode parent)
        {

            DialogueNode newNode = CreateInstance<DialogueNode>();
            newNode.name = Guid.NewGuid().ToString();

            if (parent != null)
            {

                parent.AddChild(newNode.name);
                newNode.SetPlayerSpeaking(!parent.IsPlayerSpeaking());
                newNode.SetPosition(parent.GetRect().position + newNodeOffset);

            }

            return newNode;

        }

        private void AddNode(DialogueNode newNode)
        {
            nodes.Add(newNode);
            OnValidate();
        }

        private void CleanDanglingChildren(DialogueNode nodeToDelete)
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                node.RemoveChild(nodeToDelete.name);
            }
        }
#endif

        public void OnBeforeSerialize()
        {

#if UNITY_EDITOR

            if (nodes.Count == 0)
            {
                DialogueNode newNode = MakeNode(null);
                AddNode(newNode);
            }
            
            // For saving files on the Hard Drive
            if (AssetDatabase.GetAssetPath(this) != "") {

                foreach(DialogueNode node in GetAllNodes()) {

                    if (AssetDatabase.GetAssetPath(node) == "") {

                        // For Loading a file off the Hard Drive/SSD
                        AssetDatabase.AddObjectToAsset(node, this); // adds the new nodes in the same dialogue under the original Dialogue Node

                    }

                }

            }
#endif

        }

        public void OnAfterDeserialize()
        {
            // Without this function, even if it's empty, the 'ISerializationCallbackReceiver' interface will stop the
            // game from running
        }

        public IEnumerable<DialogueNode> GetPlayerChildren(DialogueNode currentNode)
        {
            
            foreach (DialogueNode node in GetAllChildren(currentNode)) {

                if (node.IsPlayerSpeaking()) {

                    yield return node;

                }

            }

        }

        public IEnumerable<DialogueNode> GetAIChildren(DialogueNode currentNode)
        {

            foreach(DialogueNode node in GetAllChildren(currentNode)) {

                if (!node.IsPlayerSpeaking())
                {

                    yield return node;

                }

            }

        }
    
    }

}

Now, this fixes the problem if its your 2nd time running that dialogue onwards, but not your first time. To cache it the first time and avoid any sort of latency there as well, I shifted the dialogue startup to when the game scene starts, in a special script that I created called ‘DialogueManager.cs’:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace RPG.Dialogue {

    // This manager automatically preloads all dialogues at game start
    public class DialogueManager : MonoBehaviour
    {
        [Header("Dialogue Preloading")]
        [SerializeField] private bool preloadOnStart = true;
        [SerializeField] private bool showDebugLogs = true;

        private static DialogueManager instance;
        
        private void Awake()
        {
            // Singleton pattern to ensure only one manager exists
            if (instance == null)
            {
                instance = this;
                DontDestroyOnLoad(gameObject);
                
                if (preloadOnStart)
                {
                    StartCoroutine(PreloadDialoguesOnStart());
                }
            }
            else
            {
                Destroy(gameObject);
            }
        }

        private IEnumerator PreloadDialoguesOnStart()
        {
            if (showDebugLogs)
                Debug.Log("DialogueManager: Starting dialogue preload...");

            // Wait a frame to let other systems initialize
            yield return null;

            // Force immediate cache initialization
            Dialogue.ForceInitializeCache();

            if (showDebugLogs)
                Debug.Log("DialogueManager: Dialogue preload complete!");
        }

        // Call this manually if you want to preload at a specific time
        public static void PreloadDialogues()
        {
            if (instance != null)
            {
                instance.StartCoroutine(instance.PreloadDialoguesOnStart());
            }
            else
            {
                // If no manager exists, force immediate load
                Dialogue.ForceInitializeCache();
            }
        }
    }
}

In your ‘Core’ component, or wherever you store cached stuff and what not, create an empty gameObject, call it DialogueManager, throw that 2nd script on it and enjoy your fast performance dialogue system :slight_smile:

Optional: If your Dialogue Editor gets slow as well, you may want to replace your DialogueEditor.cs script with this script:

using System.Collections;
using System.Collections.Generic;
using UnityEditor;  // for Showing the Window Editor of our Quest Dialogues
using UnityEngine;
using UnityEditor.Callbacks; // for [OnOpenAssetAttribute()]
using System;

namespace RPG.Dialogue.Editor {

    // Creating the Dialogue Editor (under 'Window' in Unity)

    public class DialogueEditor : EditorWindow
    {
     
      Dialogue selectedDialogue = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      GUIStyle nodeStyle;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      GUIStyle playerNodeStyle;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode draggingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      Vector2 draggingOffset;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode creatingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode deletingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode linkingParentNode = null;
      Vector2 scrollPosition;   // gives us a scrolling view for our Dialogue Editor
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      bool draggingCanvas = false;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      Vector2 draggingCanvasOffset;
      [NonSerialized]   // Cache for performance
      Texture2D backgroundTex;
      [NonSerialized]   // Cache node lists to avoid repeated calls
      List<DialogueNode> cachedNodes;
      [NonSerialized]   // Track if we need to refresh cache
      bool needsNodeRefresh = true;

      const float canvasSize = 4000;
      const float backgroundSize = 50;

        // Callback by Annotation [MenuItem()]
        [MenuItem("Window/Dialogue Editor")]
     public static void ShowEditorWindow() {

        GetWindow(typeof(DialogueEditor), false, "Dialogue Editor");

     }

     
     // Callback by Annotation [OnOpenAsset(1)]
     [OnOpenAsset(1)]
     public static bool OnOpenAsset(int instanceID, int line) {

        // 1. Get InstanceID
        // 2. Convert to Object
        // 3. Cast to 'Dialogue' Scriptable Object (if that fails, return null)

        Dialogue dialogue = EditorUtility.InstanceIDToObject(instanceID) as Dialogue;   // opens the window as a dialogue
        
        // 4. If the Dialogue does exist, open it, otherwise return false (i.e: Operation failed)
        if (dialogue != null) {
        
        ShowEditorWindow();
        return true;
        }

        return false;

     }

     private void OnEnable() {

      Selection.selectionChanged += OnSelectionChanged;  // Adding OnSelectionChanged() function to a list of functions Unity Calls, whenever a selection changes
      
      nodeStyle = new GUIStyle();
      nodeStyle.normal.background = EditorGUIUtility.Load("node0") as Texture2D;
      nodeStyle.normal.textColor = Color.white;
      nodeStyle.padding = new RectOffset(20, 20, 20, 20);
      nodeStyle.border = new RectOffset(12, 12, 12, 12);

      playerNodeStyle = new GUIStyle();
      playerNodeStyle.normal.background = EditorGUIUtility.Load("node1") as Texture2D;
      playerNodeStyle.normal.textColor = Color.white;
      playerNodeStyle.padding = new RectOffset(20, 20, 20, 20);
      playerNodeStyle.border = new RectOffset(12, 12, 12, 12);

      // Cache background texture to avoid loading every frame
      if (backgroundTex == null)
      {
          backgroundTex = Resources.Load("background") as Texture2D;
      }


        }

     private void OnSelectionChanged() {

      Dialogue newDialogue = Selection.activeObject as Dialogue;

      if (newDialogue != null) {

         selectedDialogue = newDialogue;
         needsNodeRefresh = true;  // Mark that we need to refresh node cache
         Repaint();

      }

     }

     private void OnGUI() {

        if (selectedDialogue == null) {

        EditorGUILayout.LabelField("No Dialogue Selected.");

        }

         else {

            // Cache nodes list to avoid repeated GetAllNodes() calls
            if (needsNodeRefresh || cachedNodes == null)
            {
                RefreshNodeCache();
            }

            ProcessEvents();

            scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
            
            Rect canvas = GUILayoutUtility.GetRect(canvasSize, canvasSize);

            // Use cached background texture
            if (backgroundTex != null)
            {
                Rect texCoords = new Rect(0, 0, canvasSize/backgroundSize, canvasSize/backgroundSize);
                GUI.DrawTextureWithTexCoords(canvas, backgroundTex, texCoords);
            }

            // Use cached nodes instead of calling GetAllNodes() multiple times
            foreach (DialogueNode node in cachedNodes)
            {
                DrawConnections(node);
            }

            foreach (DialogueNode node in cachedNodes) {

                DrawNode(node);

            }

                EditorGUILayout.EndScrollView();

                if (creatingNode != null) {

                  
                  selectedDialogue.CreateNode(creatingNode);
                  creatingNode = null;      // stops creating more than one node
                  needsNodeRefresh = true;  // Refresh cache when nodes change

                }

                if (deletingNode != null) {

                  selectedDialogue.DeleteNode(deletingNode);
                  deletingNode = null;
                  needsNodeRefresh = true;  // Refresh cache when nodes change

                }

            }

        }

        private void RefreshNodeCache()
        {
            if (selectedDialogue != null)
            {
                cachedNodes = new List<DialogueNode>(selectedDialogue.GetAllNodes());
                needsNodeRefresh = false;
            }
        }

        private void ProcessEvents() {

         if (Event.current.type == EventType.MouseDown && draggingNode == null) {

            draggingNode = GetNodeAtPoint(Event.current.mousePosition + scrollPosition);  // accumulates the position of the Node on the Dialogue editor, then adds the Mouse Scroll Position to it
            if (draggingNode != null) {

               draggingOffset = draggingNode.GetRect().position - Event.current.mousePosition;
               Selection.activeObject = draggingNode;

            }

            else {

            // Record dragOffset and dragging
               draggingCanvas = true;
               draggingCanvasOffset = Event.current.mousePosition + scrollPosition;
               Selection.activeObject = selectedDialogue;

            }



         }

         else if (Event.current.type == EventType.MouseDrag && draggingNode != null) {

            draggingNode.SetPosition(Event.current.mousePosition + draggingOffset);
            
            GUI.changed = true;

         }

         else if (Event.current.type == EventType.MouseDrag && draggingCanvas) {

                // Update scrollPosition
               scrollPosition = draggingCanvasOffset - Event.current.mousePosition;
               GUI.changed = true;

            }

         else if (Event.current.type == EventType.MouseUp && draggingNode != null) {

            draggingNode = null;

         }

         else if (Event.current.type == EventType.MouseUp && draggingCanvas) {

            draggingCanvas = false;

         }

        }

        private void DrawNode(DialogueNode node)
        {
            GUIStyle style = nodeStyle;

            if (node.IsPlayerSpeaking())
            {
               style = playerNodeStyle;
            }

            GUILayout.BeginArea(node.GetRect(), style);
            // EditorGUI.BeginChangeCheck();

            node.SetText(EditorGUILayout.TextField(node.GetText()));

            GUILayout.BeginHorizontal();

            if (GUILayout.Button("x"))
            {
                deletingNode = node;
            }

            DrawLinkButtons(node);

            if (GUILayout.Button("+"))
            {
                creatingNode = node;
            }

            GUILayout.EndHorizontal();
            GUILayout.EndArea();
        }

        private void DrawLinkButtons(DialogueNode node)
        {
            if (linkingParentNode == null)
            {
                if (GUILayout.Button("link"))
                {
                    linkingParentNode = node;
                }
            }

            else if (linkingParentNode == node)
            {
               if (GUILayout.Button("cancel")) 
               {

                  linkingParentNode = null;
               }
            }

            else if (linkingParentNode.GetChildren().Contains(node.name))
            {
               if (GUILayout.Button("unlink"))
                {
                    linkingParentNode.RemoveChild(node.name);
                    linkingParentNode = null;
                }
            }
            else
            {
                if (GUILayout.Button("child"))
                {
                    Undo.RecordObject(selectedDialogue, "Add Dialogue Link");
                    linkingParentNode.AddChild(node.name);
                    linkingParentNode = null;
                }
            }
        }

        private void DrawConnections(DialogueNode node)
        {
            Vector3 startPosition = new Vector2(node.GetRect().xMax, node.GetRect().center.y);

            foreach (DialogueNode childNode in selectedDialogue.GetAllChildren(node))
            {
               Vector3 endPosition = new Vector2(childNode.GetRect().xMin, childNode.GetRect().center.y);
               Vector3 controlPointOffset = endPosition - startPosition;

               controlPointOffset.y = 0;
               controlPointOffset.x *= 0.8f;

               Handles.DrawBezier(startPosition, endPosition, 
               startPosition + controlPointOffset, endPosition - controlPointOffset, 
               Color.white, null, 4f);
            }
        }

        private DialogueNode GetNodeAtPoint(Vector2 point)
        {
            // Use cached nodes instead of calling GetAllNodes()
            foreach (DialogueNode node in cachedNodes) {

               if (node.GetRect().Contains(point)) {

                  return node;

               }

            }

            return null;

        }
    }

}

[NOTE: I haven’t fully reviewed this code yet, I just know it worked out for me. If you got any suggestions or improvements or run into any issues, please let me know]

Nope, that was a horrible solution. It just made everything worse in the build version of the game, so yeah reversed it :confused:

@Brian_Trotter any chance we can find a solution for this bug? What I am basically trying to do is to speed up the dialogue loading time of dialogues that have way too many dialogue nodes in them, for chats that have way too many options

I’d appreciate any help. If it helps, here is my current DialogueEditor.cs script:

using System.Collections;
using System.Collections.Generic;
using UnityEditor;  // for Showing the Window Editor of our Quest Dialogues
using UnityEngine;
using UnityEditor.Callbacks; // for [OnOpenAssetAttribute()]
using System;

namespace RPG.Dialogue.Editor {

    // Creating the Dialogue Editor (under 'Window' in Unity)

    public class DialogueEditor : EditorWindow
    {
     
      Dialogue selectedDialogue = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      GUIStyle nodeStyle;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      GUIStyle playerNodeStyle;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode draggingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      Vector2 draggingOffset;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode creatingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode deletingNode = null;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      DialogueNode linkingParentNode = null;
      Vector2 scrollPosition;   // gives us a scrolling view for our Dialogue Editor
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      bool draggingCanvas = false;
      [NonSerialized]   // avoids us creating an extra unwanted node (by default) when creating Dialogue Nodes
      Vector2 draggingCanvasOffset;

      const float canvasSize = 4000;
      const float backgroundSize = 50;

     // Callback by Annotation [MenuItem()]
     [MenuItem("Window/Dialogue Editor")]
     public static void ShowEditorWindow()
     {
        GetWindow(typeof(DialogueEditor), false, "Dialogue Editor");
     }

     // Callback by Annotation [OnOpenAsset(1)]
     [OnOpenAsset(1)]
     public static bool OnOpenAsset(int instanceID, int line) {

        // 1. Get InstanceID
        // 2. Convert to Object
        // 3. Cast to 'Dialogue' Scriptable Object (if that fails, return null)

        Dialogue dialogue = EditorUtility.InstanceIDToObject(instanceID) as Dialogue;   // opens the window as a dialogue

        // 4. If the Dialogue does exist, open it, otherwise return false (i.e: Operation failed)
        if (dialogue != null) {

        ShowEditorWindow();
        return true;
        }

        return false;

     }

     private void OnEnable()
     {

      Selection.selectionChanged += OnSelectionChanged;  // Adding OnSelectionChanged() function to a list of functions Unity Calls, whenever a selection changes

      nodeStyle = new GUIStyle();
      nodeStyle.normal.background = EditorGUIUtility.Load("node0") as Texture2D;
      nodeStyle.normal.textColor = Color.white;
      nodeStyle.padding = new RectOffset(20, 20, 20, 20);
      nodeStyle.border = new RectOffset(12, 12, 12, 12);

      playerNodeStyle = new GUIStyle();
      playerNodeStyle.normal.background = EditorGUIUtility.Load("node1") as Texture2D;
      playerNodeStyle.normal.textColor = Color.white;
      playerNodeStyle.padding = new RectOffset(20, 20, 20, 20);
      playerNodeStyle.border = new RectOffset(12, 12, 12, 12);


        }

     private void OnSelectionChanged() {

      Dialogue newDialogue = Selection.activeObject as Dialogue;

      if (newDialogue != null) {

         selectedDialogue = newDialogue;
         Repaint();

      }

     }

     private void OnGUI() {

         if (selectedDialogue == null) {
        
        EditorGUILayout.LabelField("No Dialogue Selected.");
        
         }

         else {

            ProcessEvents();

            scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); // ends with 'EditorGUILayout.EndScrollView()' below
            
            Rect canvas = GUILayoutUtility.GetRect(canvasSize, canvasSize);  // creates virtual space so we can horizontally scroll our Dialogue Editor (to make it bigger, increment this number)

            Texture2D backgroundTex = Resources.Load("background") as Texture2D;
            Rect texCoords = new Rect(0, 0, canvasSize/backgroundSize, canvasSize/backgroundSize);

            GUI.DrawTextureWithTexCoords(canvas, backgroundTex, texCoords); // line 120

                foreach (DialogueNode node in selectedDialogue.GetAllNodes())
                {
                    DrawConnections(node);
                }

                foreach (DialogueNode node in selectedDialogue.GetAllNodes()) {

                    DrawNode(node);

                }

                EditorGUILayout.EndScrollView();

                if (creatingNode != null) {

                  
                  selectedDialogue.CreateNode(creatingNode);
                  creatingNode = null;      // stops creating more than one node

                }

                if (deletingNode != null) {

                  selectedDialogue.DeleteNode(deletingNode);
                  deletingNode = null;

                }

            }

        }

        private void ProcessEvents() {

         if (Event.current.type == EventType.MouseDown && draggingNode == null) {

            draggingNode = GetNodeAtPoint(Event.current.mousePosition + scrollPosition);  // accumulates the position of the Node on the Dialogue editor, then adds the Mouse Scroll Position to it
            if (draggingNode != null) {

               draggingOffset = draggingNode.GetRect().position - Event.current.mousePosition;
               Selection.activeObject = draggingNode;

            }

            else {

            // Record dragOffset and dragging
               draggingCanvas = true;
               draggingCanvasOffset = Event.current.mousePosition + scrollPosition;
               Selection.activeObject = selectedDialogue;

            }



         }

         else if (Event.current.type == EventType.MouseDrag && draggingNode != null) {

            draggingNode.SetPosition(Event.current.mousePosition + draggingOffset);
            
            GUI.changed = true;

         }

         else if (Event.current.type == EventType.MouseDrag && draggingCanvas) {

                // Update scrollPosition
               scrollPosition = draggingCanvasOffset - Event.current.mousePosition;
               GUI.changed = true;

            }

         else if (Event.current.type == EventType.MouseUp && draggingNode != null) {

            draggingNode = null;

         }

         else if (Event.current.type == EventType.MouseUp && draggingCanvas) {

            draggingCanvas = false;

         }

        }

        private void DrawNode(DialogueNode node)
        {
            GUIStyle style = nodeStyle;

            if (node.IsPlayerSpeaking())
            {
               style = playerNodeStyle;
            }

            GUILayout.BeginArea(node.GetRect(), style);
            // EditorGUI.BeginChangeCheck();

            node.SetText(EditorGUILayout.TextField(node.GetText()));

            GUILayout.BeginHorizontal();

            if (GUILayout.Button("x"))
            {
                deletingNode = node;
            }

            DrawLinkButtons(node);

            if (GUILayout.Button("+"))
            {
                creatingNode = node;
            }

            GUILayout.EndHorizontal();
            GUILayout.EndArea();
        }

        private void DrawLinkButtons(DialogueNode node)
        {
            if (linkingParentNode == null)
            {
                if (GUILayout.Button("link"))
                {
                    linkingParentNode = node;
                }
            }

            else if (linkingParentNode == node)
            {
               if (GUILayout.Button("cancel")) 
               {
                  linkingParentNode = null;
               }
            }

            else if (linkingParentNode.GetChildren().Contains(node.name))
            {
               if (GUILayout.Button("unlink"))
                {
                    linkingParentNode.RemoveChild(node.name);
                    linkingParentNode = null;
                }
            }
            else
            {
                if (GUILayout.Button("child"))
                {
                    Undo.RecordObject(selectedDialogue, "Add Dialogue Link");
                    linkingParentNode.AddChild(node.name);
                    linkingParentNode = null;
                }
            }
        }

        private void DrawConnections(DialogueNode node)
        {
            Vector3 startPosition = new Vector2(node.GetRect().xMax, node.GetRect().center.y);

            foreach (DialogueNode childNode in selectedDialogue.GetAllChildren(node))
            {
               Vector3 endPosition = new Vector2(childNode.GetRect().xMin, childNode.GetRect().center.y);
               Vector3 controlPointOffset = endPosition - startPosition;

               controlPointOffset.y = 0;
               controlPointOffset.x *= 0.8f;

               Handles.DrawBezier(startPosition, endPosition, 
               startPosition + controlPointOffset, endPosition - controlPointOffset, 
               Color.white, null, 4f);
            }
        }

        private DialogueNode GetNodeAtPoint(Vector2 point)
        {

            DialogueNode foundNode = null;

            foreach (DialogueNode nodes in selectedDialogue.GetAllNodes()) {

               if (nodes.GetRect().Contains(point)) {

                  foundNode = nodes;

               }

            }

            return foundNode;

        }
    }

}

My DialogueUI.cs:

using UnityEngine;
using UnityEngine.UI;
using RPG.Dialogue;
using TMPro;
using GameDevTV.UI; // so we can use 'ShowHideUI.cs' to deactivate the Dialogue when the game is paused

namespace RPG.UI 
{
    public class DialogueUI : WindowController
    {
        PlayerConversant playerConversant;
        [SerializeField] TextMeshProUGUI AIText;
        [SerializeField] Button nextButton;
        [SerializeField] GameObject AIResponse;
        [SerializeField] Transform choiceRoot;
        [SerializeField] GameObject choicePrefab;
        [SerializeField] Button quitButton; // quit button, to quit the dialogue midway through
        [SerializeField] TextMeshProUGUI conversantName;    // name of our conversant in a dialogue conversation
        
        // Start is called before the first frame update
        void Start()
        {
            nextButton.onClick.AddListener(() => playerConversant.Next());   // calls the 'Next' function (yes, the function not a variable) Subscriber (to the onClick event) when our player hits 'Next' in the dialogue, using the Lambda "() => {}" function
            quitButton.onClick.AddListener(() => playerConversant.Quit());  // Lambda Function, "() => {}", is used here to add a listener for our quit button. When clicked the script is closed
            // ShowHideUI.OnModalActive += playerConversant.Quit;  // Turn off the Dialogue UI when the game is paused
            UpdateUI();
        }

        // ShowHideUI.OnModalActive is a static event, hence this function is mandatory:
        void OnDestroy()
        {
            // ShowHideUI.OnModalActive -= playerConversant.Quit;
        }

        // Update is called once per frame
        void UpdateUI()
        {
            /* Debug.Log($"UpdateUI playerConversant.IsActive() == {playerConversant.IsActive()}");
            gameObject.SetActive(playerConversant.IsActive());
            if (!playerConversant.IsActive())
            {
                Debug.Log("Exiting DialogueUI as playerConversant is not active");
                return;
            }
            Debug.Log("Continuing with DialogueUI as playeConversant is active");
            //rest of method */

            // The following line ensures the dialogue is invisible until the return time 
            // of the IEnumerator in 'playerConversant.cs' time counter is over
            gameObject.SetActive(playerConversant.IsActive());

            if (!playerConversant.IsActive()) 
            {
                return;
            }

            conversantName.text = playerConversant.GetCurrentConversantName();
            AIResponse.SetActive(!playerConversant.IsChoosing());
            choiceRoot.gameObject.SetActive(playerConversant.IsChoosing());
            
            if (playerConversant.IsChoosing())
            {
                BuildChoiceList();
            }

            else 
            {
                AIText.text = playerConversant.GetText();
                nextButton.gameObject.SetActive(playerConversant.HasNext());
            }
        }

        private void BuildChoiceList()
        {
            foreach (Transform item in choiceRoot)
            {
                // avoids Dangling Nodes in the Hierarchy, which will eventually slow our entire game down
                Destroy(item.gameObject);
            }

            foreach (DialogueNode choice in playerConversant.GetChoices())
            {
                GameObject choiceInstance = Instantiate(choicePrefab, choiceRoot);
                var textComp = choiceInstance.GetComponentInChildren<TextMeshProUGUI>();
                textComp.text = choice.GetText();
                Button button = choiceInstance.GetComponentInChildren<Button>();
                button.onClick.AddListener(() => 
                {          // "() => {}" is a 'lambda' function ("()" is the argument, "{}" is the internals of the function), which only works when a button is clicked (do some research about this)
                    playerConversant.SelectChoice(choice);
                });
            }
        }

        protected override void Subscribe()
        {
            playerConversant = GameObject.FindGameObjectWithTag("Player").GetComponent<PlayerConversant>();
            playerConversant.onConversationUpdated += UpdateUI;
        }

        protected override void Unsubscribe()
        {
            playerConversant.onConversationUpdated -= UpdateUI;
        }

        protected override void OnDisable()
        {
            base.OnDisable();
            playerConversant.Quit();
        }
    }
}

and my Dialogue.cs script:

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;  // to be able to access the 'Undo' class (line 81)

namespace RPG.Dialogue {

    [CreateAssetMenu(fileName = "New Dialogue", menuName = "Dialogue", order = 0)]
    public class Dialogue : ScriptableObject, ISerializationCallbackReceiver    // ISerializationCallbackReceiver Interface avoids errors in Adding object to asset (line 90)
    {

        [SerializeField]
        List<DialogueNode> nodes = new List<DialogueNode>();

        [SerializeField]
        Vector2 newNodeOffset = new Vector2(250, 0);

        // key = string, value = DialogueNode (Dictionaries are used because of their ability to quickly find value of stuff, based on given keys)
        Dictionary<string, DialogueNode> nodeLookup = new Dictionary<string, DialogueNode>();

        private void Awake()
        {
            // Whilst OnValidate() is always called in the '#if UNITY_EDITOR' -> '#endif' range, it never
            // gets called outside of the Unity Editor. The result? Our 'NEXT' button for our game dialogues was never exported 
            // (because to the game, it's NEVER called).
            // To fix that, we call it in Awake(), as shown below (this calls ALL OnValidate() functions, under all scripts
            // with the same namespace as this one ('RPG.Dialogue' in this case)):
            OnValidate();
        }

        private void OnValidate()
        {
            nodeLookup.Clear();

            foreach (DialogueNode node in GetAllNodes())
            {
                nodeLookup[node.name] = node;
            }
        }

        public IEnumerable<DialogueNode> GetRootNodes()
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                bool isChild = false;

                foreach (DialogueNode lookupNode in GetAllNodes())
                {
                    if (lookupNode.GetChildren().Contains(node.name))
                    {
                        isChild = true;
                        break;
                    }
                }

                if (!isChild) yield return node;
            }
        }

        // IEnumerables are an Interface (of type 'Dialogue Nodes' in this case), allowing objects to do 'for' loops over them:
        public IEnumerable<DialogueNode> GetAllNodes()
        {
            return nodes;
        }

        public DialogueNode GetRootNode()
        {
            return nodes[0];
        }

        public IEnumerable<DialogueNode> GetAllChildren(DialogueNode parentNode)
        {
            List<DialogueNode> result = new List<DialogueNode>();

            foreach (string childID in parentNode.GetChildren())
            {
                if (nodeLookup.ContainsKey(childID))
                {
                    yield return nodeLookup[childID];
                }
            }
        }

        // TEST FUNCTION - 19/5/2024:
        public static Dialogue GetByName(string dialogueName) 
        {
            foreach (Dialogue dialogue in Resources.LoadAll<Dialogue>("")) 
            {
                if (dialogue.name == dialogueName) 
                {
                    return dialogue;
                }
            }
            return null;
        }

#if UNITY_EDITOR

        public void CreateNode(DialogueNode parent)
        {

            DialogueNode newNode = MakeNode(parent);

            Undo.RegisterCreatedObjectUndo(newNode, "Created Dialogue Node");
            Undo.RecordObject(this, "Added Dialogue Node");

            AddNode(newNode);

        }

        

        public void DeleteNode(DialogueNode nodeToDelete)
        {
            Undo.RecordObject(this, "Deleted Dialogue Node");
            nodes.Remove(nodeToDelete);
            OnValidate();
            CleanDanglingChildren(nodeToDelete);    // cleans up children (further nodes connected to node to delete down the line) of parent nodes that have been deleted
            Undo.DestroyObjectImmediate(nodeToDelete);  // destroys our node (but also saves a backup, just in case you need to Undo your changes)
        }

        private DialogueNode MakeNode(DialogueNode parent)
        {

            DialogueNode newNode = CreateInstance<DialogueNode>();
            newNode.name = Guid.NewGuid().ToString();

            if (parent != null)
            {

                parent.AddChild(newNode.name);
                newNode.SetPlayerSpeaking(!parent.IsPlayerSpeaking());
                newNode.SetPosition(parent.GetRect().position + newNodeOffset);

            }

            return newNode;

        }

        private void AddNode(DialogueNode newNode)
        {
            nodes.Add(newNode);
            OnValidate();
        }

        private void CleanDanglingChildren(DialogueNode nodeToDelete)
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                node.RemoveChild(nodeToDelete.name);
            }
        }
#endif

        public void OnBeforeSerialize()
        {

#if UNITY_EDITOR

            if (nodes.Count == 0)
            {
                DialogueNode newNode = MakeNode(null);
                AddNode(newNode);
            }
            
            // For saving files on the Hard Drive
            if (AssetDatabase.GetAssetPath(this) != "") {

                foreach(DialogueNode node in GetAllNodes()) {

                    if (AssetDatabase.GetAssetPath(node) == "") {

                        // For Loading a file off the Hard Drive/SSD
                        AssetDatabase.AddObjectToAsset(node, this); // adds the new nodes in the same dialogue under the original Dialogue Node

                    }

                }

            }
#endif

        }

        public void OnAfterDeserialize()
        {
            // Without this function, even if it's empty, the 'ISerializationCallbackReceiver' interface will stop the
            // game from running
        }

        public IEnumerable<DialogueNode> GetPlayerChildren(DialogueNode currentNode)
        {
            
            foreach (DialogueNode node in GetAllChildren(currentNode)) {

                if (node.IsPlayerSpeaking()) {

                    yield return node;

                }

            }

        }

        public IEnumerable<DialogueNode> GetAIChildren(DialogueNode currentNode)
        {

            foreach(DialogueNode node in GetAllChildren(currentNode)) {

                if (!node.IsPlayerSpeaking())
                {

                    yield return node;

                }

            }

        }
    
    }

}

I was thinking of something in the lines of ‘only process what this next node is about to show, nothing else’, that way the conversation isn’t slow because its firing up every single node in the conversation simultaneously