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:
- 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 ![]()
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]