Dialogue System, Unity pausing if Dialogue node uses a Predicate

I have a 100% repeatable bug, where if you click on an NPC to start a Dialogue, Unity will hang, for 3-15 seconds, and then recover but only if a node in the Dialogue uses a predicate, and only the first time starting the dialogue. Suggesting to me that after what ever is causing the hang, after this it is loaded in memory?

I have made updates to the Predicate system following this post: Improving Conditions: A Property Editor

When I run the profiler, I can see there is a large spike during the hang

I’m not sure what else to try or where to look.

So that’s quite a lag spike, and quite unexpected during runtime (I actually expect a small bit of lag in the inspector for the PropertyDrawer because Resources.LoadAll isn’t as efficient if you have a large quantity of assets, even if most of them aren’t in a Resources folder). The PropertyDrawer shouldn’t be affecting anything at runtime, however, and the only changes in the implementations for runtime are using an enum instead of a string (which is actually significantly more efficient).

Looking at the profiler, it definitely looks like Quest and Inventory GetFromID methods are the culprit. What’s bugging me is it shouldn’t actually be reading that many files… it might scan the directories for Resources folders, but it actually read 94% of the files, which seems pretty crazy.

This is likely a change to the way Unity handles the filesystem in newer versions. Here’s a quick experiment… Build the game out with Build And Run. I’d be interested in seeing if this same issue occurs (because in the Player, Resources.LoadAll should only be looking in the composite Resources packed into the build file, and should be much more efficient).

Thank you for the reply Brian, I should have posted that yes this same pause happens in a build, and also when testing on another machine.

In my early post: Improving Conditions: A Property Editor - #92 by LuDiChRiS

I was getting a pause in Edit mode whenever I clicked on a Node that had a predicate, and I resolved that by switching out Resources.LoadAll for: AssetDatabase.FindAssets(“t:Quest”). This sovled it, but could this be related?

private void BuildQuestList()
    {
        if (quests != null) return;
        quests = AssetDatabase.FindAssets("t:Quest")
            .Select(guid => AssetDatabase.LoadAssetAtPath<Quest>(AssetDatabase.GUIDToAssetPath(guid)))
            .ToDictionary(quest => quest.name, quest => quest);
    }

    private void BuildInventoryItemsList()
    {
        if (items != null) return;
        items = AssetDatabase.FindAssets("t:InventoryItem")
            .Select(guid => AssetDatabase.LoadAssetAtPath<InventoryItem>(AssetDatabase.GUIDToAssetPath(guid)))
            .ToDictionary(item => item.GetItemID(), item => item);
    }

That’s from the PredicatePropertyDrawer.cs Full script here:


using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using RPG.Quests;
using System.Linq;
using GameDevTV.Inventories;
using System;
using RPG.Stats;

namespace RPG.Core.Editor
{
    [CustomPropertyDrawer(typeof(Condition.Predicate))]
    public class PredicatePropertyDrawer : PropertyDrawer
    {
        private Dictionary<string, Quest> quests;
        private Dictionary<string, InventoryItem> items;

        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            SerializedProperty predicate = property.FindPropertyRelative("predicate");
            SerializedProperty parameters = property.FindPropertyRelative("parameters");
            SerializedProperty negate = property.FindPropertyRelative("negate");

            float propHeight = EditorGUI.GetPropertyHeight(predicate);
            position.height = propHeight;
            EditorGUI.PropertyField(position, predicate);
            //EditorGUI.PropertyField(negate.rectValue, predicate);

            PredicateType selectedPredicate = (PredicateType)predicate.enumValueIndex;

            if (selectedPredicate == PredicateType.Select) return; //Stop drawing if there's no predicate
            while (parameters.arraySize < 2)
            {
                parameters.InsertArrayElementAtIndex(0);
            }

            SerializedProperty parameterZero = parameters.GetArrayElementAtIndex(0);
            SerializedProperty parameterOne = parameters.GetArrayElementAtIndex(1); //Edit, was accidentally 0 in first draft

            if (selectedPredicate == PredicateType.HasQuest || selectedPredicate == PredicateType.CompletedQuest || selectedPredicate == PredicateType.CompletedObjective)
            {
                position.y += propHeight;
                DrawQuest(position, parameterZero);
                if (selectedPredicate == PredicateType.CompletedObjective)
                {
                    position.y += propHeight;
                    DrawObjective(position, parameterOne, parameterZero);
                }
            }

            if (selectedPredicate == PredicateType.PlayerHasItem || selectedPredicate == PredicateType.PlayerHasItems || selectedPredicate == PredicateType.PlayerHasItemEquipped)
            {
                position.y += propHeight;
                DrawInventoryItemList(position, parameterZero, selectedPredicate == PredicateType.PlayerHasItems, selectedPredicate == PredicateType.PlayerHasItemEquipped);
                if (selectedPredicate == PredicateType.PlayerHasItems)
                {
                    position.y += propHeight;
                    DrawIntSlider(position, "Qty Needed", parameterOne, 1, 100);
                }
            }

            if (selectedPredicate == PredicateType.HasLevel)
            {
                position.y += propHeight;
                DrawIntSlider(position, "Level Required", parameterZero, 1, 100);
            }

            if (selectedPredicate == PredicateType.MinimumTrait)
            {
                position.y += propHeight;
                DrawTrait(position, parameterZero);
                position.y += propHeight;
                DrawIntSlider(position, "Minimum", parameterOne, 1, 100);
            }

            if (selectedPredicate == PredicateType.NPCHasItem || selectedPredicate == PredicateType.NPCHasItems || selectedPredicate == PredicateType.NPCHasItemEquipped)
            {
                position.y += propHeight;
                DrawInventoryItemList(position, parameterZero, selectedPredicate == PredicateType.NPCHasItems, selectedPredicate == PredicateType.NPCHasItemEquipped);
                if (selectedPredicate == PredicateType.NPCHasItems)
                {
                    position.y += propHeight;
                    DrawIntSlider(position, "Qty Needed", parameterOne, 1, 100);
                }
            }

            position.y += propHeight;

            EditorGUI.PropertyField(position, negate);
        }

        private void DrawQuest(Rect position, SerializedProperty element)
        {
            BuildQuestList();
            var names = quests.Keys.ToList();

            int index = names.IndexOf(element.stringValue);

            EditorGUI.BeginProperty(position, new GUIContent("Quest:"), element);
            int newIndex = EditorGUI.Popup(position, "Quest:", index, names.ToArray());
            if (newIndex != index)
            {
                element.stringValue = names[newIndex];
            }

            EditorGUI.EndProperty();
        }

        void DrawObjective(Rect position, SerializedProperty element, SerializedProperty selectedQuest)
        {
            string questName = selectedQuest.stringValue;
            if (!quests.ContainsKey(questName))
            {
                EditorGUI.HelpBox(position, "Please Select A Quest", MessageType.Error);
                return;
            }

            List<string> references = new List<string>();
            List<string> descriptions = new List<string>();
            foreach (Quest.Objective objective in quests[questName].GetObjectives())
            {
                references.Add(objective.reference);
                descriptions.Add(objective.description);
            }
            int index = references.IndexOf(element.stringValue);
            EditorGUI.BeginProperty(position, new GUIContent("objective"), element);
            int newIndex = EditorGUI.Popup(position, "Objective:", index, descriptions.ToArray());
            if (newIndex != index)
            {
                element.stringValue = references[newIndex];
            }
            EditorGUI.EndProperty();
        }

        void DrawInventoryItemList(Rect position, SerializedProperty element, bool stackable = false, bool equipment = false)
        {
            BuildInventoryItemsList();
            var filteredItems = items.Where(pair =>
                (!stackable || pair.Value.IsStackable()) &&
                (!equipment || pair.Value is EquipableItem))
                .ToList();

            var displayNames = filteredItems.Select(pair => pair.Value.GetDisplayName()).ToArray();
            var ids = filteredItems.Select(pair => pair.Key).ToArray();

            int index = Array.IndexOf(ids, element.stringValue);
            EditorGUI.BeginProperty(position, new GUIContent("Item"), element);
            int newIndex = EditorGUI.Popup(position, "Item", index, displayNames);
            if (newIndex != index) element.stringValue = ids[newIndex];
            EditorGUI.EndProperty();
        }


        void DrawTrait(Rect position, SerializedProperty element)
        {
            if (!Enum.TryParse(element.stringValue, out Trait trait))
            {
                trait = Trait.Strength;
            }
            EditorGUI.BeginProperty(position, new GUIContent("Trait"), element);
            Trait newTrait = (Trait)EditorGUI.EnumPopup(position, "Trait:", trait);
            if (newTrait != trait)
            {
                element.stringValue = $"{newTrait}";
            }
            EditorGUI.EndProperty();
        }

        private void BuildQuestList()
        {
            if (quests != null) return;
            quests = AssetDatabase.FindAssets("t:Quest")
                .Select(guid => AssetDatabase.LoadAssetAtPath<Quest>(AssetDatabase.GUIDToAssetPath(guid)))
                .ToDictionary(quest => quest.name, quest => quest);
        }

        private void BuildInventoryItemsList()
        {
            if (items != null) return;
            items = AssetDatabase.FindAssets("t:InventoryItem")
                .Select(guid => AssetDatabase.LoadAssetAtPath<InventoryItem>(AssetDatabase.GUIDToAssetPath(guid)))
                .ToDictionary(item => item.GetItemID(), item => item);
        }


        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            SerializedProperty predicate = property.FindPropertyRelative("predicate");
            float propHeight = EditorGUI.GetPropertyHeight(predicate);
            PredicateType selectedPredicate = (PredicateType)predicate.enumValueIndex;
            switch (selectedPredicate)
            {
                case PredicateType.Select: //No parameters, we only want the bare enum. 
                    return propHeight;
                case PredicateType.HasLevel:       //All of these take 1 parameter
                case PredicateType.CompletedQuest:
                case PredicateType.HasQuest:
                case PredicateType.PlayerHasItem:
                case PredicateType.PlayerHasItems:
                case PredicateType.PlayerHasItemEquipped:
                case PredicateType.NPCHasItem:
                case PredicateType.NPCHasItems:
                case PredicateType.NPCHasItemEquipped:
                    return propHeight * 3.0f; //Predicate + one parameter + negate
                case PredicateType.CompletedObjective: //All of these take 2 parameters
                case PredicateType.MinimumTrait:
                    return propHeight * 4.0f; //Predicate + 2 parameters + negate;
            }
            return propHeight * 2.0f;
        }

        private static void DrawIntSlider(Rect position, string caption, SerializedProperty intParameter, int minLevel = 1, int maxLevel = 100)
        {
            EditorGUI.BeginProperty(position, new GUIContent(caption), intParameter);
            if (!int.TryParse(intParameter.stringValue, out int value))
            {
                value = 1;
            }
            EditorGUI.BeginChangeCheck();
            int result = EditorGUI.IntSlider(position, caption, value, minLevel, maxLevel);
            if (EditorGUI.EndChangeCheck())
            {
                intParameter.stringValue = $"{result}";
            }
            EditorGUI.EndProperty();
        }
    }
}


Also I was curious how you could tell from the Profiler image that it is the Quest and Inventory GetFromID methods that are the cause. Thanks for your help

Experience, and your description of the issue.

Now looking at your revised code, the AssetDatabase.FindAssets(“t:InventoryItem”) is searching through your entire Asset Database to build the InventoryItemsList. In all likelyhood however (at least through the course design and the way I tend to set my own projects up), your InventoryItems and Quests are all in subfolders of Game.
You can restrict the search to specific folders/subfolders with a 2nd parameter to AssetDatabase.FindAssets()

items = AssetDatabase.FindAssets("t: InventoryItem", new[] {"Assets/Game"}).
Select(guid => AssetDatabase.LaodAssetAtPath<InventoryItem>(AssetDatabase.GUIDToAssetPath(guid))).
ToDictionary(item=>item.GetItemID(), item => item);

Remember that for each call in a Linq expression, a new copy of the data structure is created and added to the Garbage Collection.

Now… all of that being said… this code only runs during Edit time, and doesn’t even exist within a built executable game. (All Editor code is stripped away). At runtime, InventoryItem and Quest’s GetFromID are completely dependent on the Resources.LoadAll() method. Once these are loaded, they never need to be loaded again while the game is running (as the resulting dictionaries are static).

One possible alternative to the Resources trick which would actually be more performative (but break one of Sam’s rules about Singletons) would be to create QuestWrapper and InventoryWrapper classes that would go in the PersistentObjectsPrefab and behave as a Singleton.
These wrapper classes would get a reference to every single InventoryItem and Quest in the game.

For example:

public class QuestWrapper : MonoBehaviour
{
    public static QuestWrapper Instance;
    
    [SerializeField] Quest[] quests;

    Dictionary<string, Quest> lookup;

    void Awake()
    {
        if(Instance!=null) 
        {
             Destroy(this);
             return;
        }
        Instance=this;
        lookup = new();
        foreach(Quest quest in quests)
        {
             lookup[quest.GetID()] = quest;
        }
    }

   public Quest GetByID(string id)
   {
        return quests.TryGetComponent(id, out Quest quest)? quest : null;
   }
        
}

Note that this is a quick hackup, so I apologize for any syntax or spelling errors, it was not run through a code editor.

The biggest issue with this is that you’ll need to ensure that any new Quest is added to the prefab that this QuestWrapper is on. It increases the likelyhood that a quest will be forgotten, although there are editor tricks you can pull to ensure that every Quest is caught up in the list.

The upside is that while the constructing of the Dictionary will take some time, this is on the loading end of the game and won’t be noticeable like it appears to be when the Dialogue is first run.

SOLVED!

Thank you so much, this had me blocked for ages! I added the path parameter to AssetDatabase.FindAssets, this made no difference, as you expected.

Next I added static Dictionary to Quest.cs and made a new QuestPreloader.cs that I placed on a gameObject in the PersistentGameObjects prefab.

using UnityEngine;
using RPG.Quests;

public class QuestPreloader : MonoBehaviour
{
    private void Awake()
    {
        // Force the quest cache to be initialized at game start
        Quest.GetByName(""); // This will load all quests into memory
    }
}

On my Quest.cs I changed:

private static Dictionary<string, Quest> questLookup;

/// <summary>
/// Preloads all quests into a dictionary for fast lookup.
/// </summary>
private static void LoadQuestsIfNeeded()
{
    if (questLookup == null)
    {
        questLookup = new Dictionary<string, Quest>();
        Quest[] allQuests = UnityEngine.Resources.LoadAll<Quest>(""); // Load once at startup
        foreach (Quest quest in allQuests)
        {
            if (!questLookup.ContainsKey(quest.name))
            {
                questLookup.Add(quest.name, quest);
            }
        }
    }
}

/// <summary>
/// Gets a quest by name without reloading all assets every time.
/// </summary>
public static Quest GetByName(string questName)
{
    LoadQuestsIfNeeded();
    return questLookup.TryGetValue(questName, out Quest quest) ? quest : null;
}

This seems to have resoved the issue with the game pausing and I can continue to make and test conversations!

Thanks again!

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms