Improving Conditions: A Property Editor

Probably one of the most difficult sections of the RPG series code to debug is the Conditions we use to guide our dialogues and later in the course to allow things like requiring a certain trait or level before equipping an item. Much of this is because the entire condition system becomes a bit obscure to the debugging process once it starts evaluating predicates. There are many things that can go wrong, much of this because of a reliance on magic strings.

The goal of this post is to make some structural changes to the way that the Conditions work, and create a PropertyDrawer to draw those predicates in a way that is easier to view and will select the possible parameters for us.

This one’s not for the faint of heart, we’re going to be creating two property drawers to create our one property drawer which will evaluate conditions…

First things first, we’re going to make a fundamental change to the way we handle predicates. Note that this is going to break all of your existing conditions you have in any dialogues or equipment requirements. This is because we’re going to be changing the predicate itself from an enum to a string. You might take the time to write down all of your conditions, or even consider forking your project at this point into a new project so you can have them side by side if you have a lot of them. (For continuity’s sake, make a copy of your project in a new project folder and add the new project to your Unity Hub. You can then open this project when it’s time to redo all of your conditions when we’re done. Put that copy aside, and work on your original project.)
Either way, BACK UP YOUR PROJECT AND COMMIT THE CURRENT STATE TO SOURCE CONTROL. I only make this reminder once for each major change I do to the code, so make sure you do it.

Predicates Should Be Enums!!!
So one of the biggest problems with the condition system starts in the predicate itself. We are using a string instead of an enum. This means that while you may have written a beautiful IPredicateEvaluator for HasQuest, if you put Hasquest in your predicate field, nobody will evaluate it. In the scenarios in which students have said “this isn’t being evaluated”, this accounts for about 1/2 of the issues. Let’s fix it so that we are using an Enum instead of a string. Even if you don’t take the deep dive to the propertydrawer, you can make this simple fix, and you’ll just have to select the predicates in your existing conditions and you’re on your way, knowing you’ll never misspell a predicate again…

Let’s start in our IPredicateEvaluator.cs script. I decided that this is the best place to add the Enum.

namespace GameDevTV.Utils
{
    public enum EPredicate
    {
        Select,
        HasQuest,
        CompletedObjective,
        CompletedQuest,
        HasLevel,
        MinimumTrait,
        HasItem,
        HasItems,
        HasItemEquipped
    }
    
    public interface IPredicateEvaluator
    {
        bool? Evaluate(EPredicate predicate, string[] parameters);
        
    }
}

If it happens that you’re still in Dialogues and Quests, in the next course, Shops and Abilities, we moved the Conditions code into the GameDevTV.Utils namespace. This allows us to include the conditions in the InventorySystem without relying on your personal code (keeping the inventory system portable and easy to move into other games).

So we’ve created a new Enum, EPredicate. Since we already have a Predicate class in our conditions, I went with the convention of prefixing an enum with an E, which is something I personally do in all of my projects. The EPredicate has all of the possible conditions in your game. If you want to add a condition type, add it here and then make sure you have a component capable of interpreting it.

Next, we change the IPredicateEvaluator interface itself. In this case, we’re changing the predicate from a string to an EPredicate.

At this point, your code editor should be flagging some warnings. Here’s my errors window from JetBrains Rider:


Most of these deal with the methods that implement IPredicateEvaluator. One deals with Condition trying to pass a string instead of an EPredicate. Let’s tackle those one by one. If there’s a component in my list you haven’t implemented yet, don’t worry about it, you can do it when you get to it. If you have a condition not listed here, you should have a good understanding of what needs to be fixed.

Let’s start with the error in Condition:

Argument type 'string' is not assignable to parameter type 'GameDevTV.Utils.EPredicate'

This is a pretty simple fix, just change the declaration in class Predicate:

        class Predicate
        {
            [SerializeField]
            EPredicate predicate; //This was string predicate;

Note that at this point, all of your conditions are broken, and you’ll need to reassign the predicates. Trust me, it’ll be worth it for yourself or anybody you allow to wear the designer hat. You’ve misspelled your last predicate!

We still have a lot more errors, though. We’ll start with QuestList since this is the one that everybody has certainly implemented. The compiler’s complaint is:

Interface member 'bool? GameDevTV.Utils.IpredicateEvaluator.Evaluate(EPredicate, string[])' is not implemented

There are two solutions to this: One is to create a new method to cover the interface. Personally, I prefer just editing the method we already have. Here’s the current QuestList.Evaluate() method:

        public bool? Evaluate(string predicate, string[] parameters)
        {
            switch (predicate)
            {
                case "HasQuest": 
                return HasQuest(Quest.GetByName(parameters[0]));
                case "CompletedQuest":
                return GetQuestStatus(Quest.GetByName(parameters[0])).IsComplete();
                case "CompletedObjective": //This may not be implemented in your version, consider it a freebie
                    QuestStatus status = GetQuestStatus(Quest.GetByName(parameters[0]));
                    if (status==null) return false;
                    return status.IsObjectiveComplete(parameters[1]);
            }
            return null;
        }

The header doesn’t match, as it’s expecting a string. Here’s my revised script with the EPredicate. I’ve also taken the liberty of doing a little error checking in CompletedQuest.

 public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            switch (predicate)
            {
                case EPredicate.HasQuest: 
                return HasQuest(Quest.GetByName(parameters[0]));
                case EPredicate.CompletedQuest:
                    QuestStatus status = GetQuestStatus(Quest.GetByName(parameters[0]));
                    if (status == null) return false;
                    return status.IsComplete();
                return GetQuestStatus(Quest.GetByName(parameters[0])).IsComplete();
                case EPredicate.CompletedObjective:
                    QuestStatus teststatus = GetQuestStatus(Quest.GetByName(parameters[0]));
                    if (teststatus==null) return false;
                    return teststatus.IsObjectiveComplete(parameters[1]);
            }
            return null;
        }

Simple enough, we’ve replaced the strings with enums here. We’ll do the same thing in the remaining components. (Note: If you don’t have these components/IPredicateEvaluators implemented yet, don’t worry about them, just make the adjustments when you do).

Inventory.cs

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            switch (predicate)
            {
                case EPredicate.HasItem:
                return HasItem(InventoryItem.GetFromID(parameters[0]));
                case EPredicate.HasItems: //Only works for stackable items.
                    InventoryItem item = InventoryItem.GetFromID(parameters[0]);
                    int stack = FindStack(item);
                    if (stack == -1) return false;
                    if (int.TryParse(parameters[1], out int result))
                    {
                        return slots[stack].number >= result;
                    }
                    return false;
            }
            return null;
        }

Equipment.cs

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            if (predicate == EPredicate.HasItemEquipped)
            {
                foreach (var item in equippedItems.Values)
                {
                    if (item.GetItemID() == parameters[0])
                    {
                        return true;
                    }
                }
                return false;
            }
            return null;
        }

TraitStore.cs

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            if (predicate == EPredicate.MinimumTrait)
            {
                if (Enum.TryParse<Trait>(parameters[0], out Trait trait))
                {
                    return GetPoints(trait) >= Int32.Parse(parameters[1]);
                } 
            }
            return null;
        }

BaseStats.cs

        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            if (predicate == EPredicate.HasLevel)
            {
                if (int.TryParse(parameters[0], out int testLevel))
                {
                    return currentLevel.value >= testLevel;
                } 
            }
            return null;
        }

Now is a good time to go through all of your dialogues, and anything else that uses Conditions and change the Predicate from “Select” to the correct predicate. If you didn’t make notes or save a copy of your project to open and refer to, you can usually guess by the context…

So we’ve made the conversion of the Predicate from string to enum, but we still have the issue of the string references (and in the case of InventoryItems, obscure ones) plaguing us.

That’s going to take a bit more work, and we’ll tackle that in the next post.

8 Likes

Keep up the incredible work Brian!

This is awesome Brian. Can’t wait for part 2!

Part 2: The Predicate Property Drawer
Note: This section deals with editor code. Editor code is notoriously hard to debug, and can cause unpredictable results. Back up your project or commit your progress before going into this section.

So we’ve solved the problem of the predicate being a string value, but we still have to deal with the parameters. The easiest way to accomplish this task is to take over the editor’s drawing of the Predicate class in the inspector. Since Conditions are not components attached to a GameObject, or ScriptableObjects, we need to use what is called a PropertyDrawer to override the default behavior.

The first thing we need to do is create a script for the PropertyDrawer. We’re going to start with the Predicate class, as this is actually the class doing the most work in our conditions. It’s where we select the EPredicate, and specify the parameters that are passed to an IPredicateEvaluator.

Create a folder under the folder your Condition.cs called Editor. Like the DialogueEditor, we’re putting this in an Editor folder so that the compiler knows to strip this code automagically when the game is built. If you put this script in a regular folder, your project will not build.

Within the folder, create a new script and call it PredicatePropertyDrawer.cs.

We’re going to make some immediate changes to the script. We’ll start by adding

using UnityEditor;

We’re also going to want to surround the class declaration in a namespace. If you’ve only done the Dialogues and Quests course, then your Condition is likely in the RPG.Core namespace. If you’ve completed Shops and Abilities, you’ll likely have moved the Condition and IPredicateEvaluator scripts to the GameDevTV.Utils namespace. We’re going to put this script in the same namespace, but add .Editor to the end of it. Everything in this namespace will have access to the original namespace, but the further qualifier of .Editor will mean that nothing in the higher order namespace will automatically have access to the code in the .Editor namespace.

Now, above the class declaration, add the line

[CustomPropertyDrawer(typeof(Condition.Predicate))]

Note that you might have to go into Condition.cs and change the scope of the inner Predicate class to Public or the compiler will throw an error.
Finally, change the MonoBehavior to PropertyDrawer and delete the boilerplate Start and Update methods. PropertyDrawers use neither of these methods. At this point, your class should look like this:

using UnityEditor;
using UnityEngine;

namespace GameDevTV.Utils.Editor
{
    [CustomPropertyDrawer(typeof(Condition.Predicate))]
    public class PredicatePropertyDrawer : PropertyDrawer
    {
    }
}

At this point, if you go back into Unity, your Disjunction arrays will complain that No GUI Implemented because… well… We haven’t implemented one.

Let’s change that…
Add the following method to your class.

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
    base.OnGUI(position, property, label);
}

Now when you go back into your inspector, things will be back the way they were before we started writing the inspector. It does this because we’re calling base.OnGUI() to let it draw. That’s not really what we want either, though, so go ahead and delete that base.OnGUI() line.

So we have three fields in the Predicate that need to be drawn. The predicate, the array of parameters and the boolean negate field.

We’re going to start by setting these properties up in the OnGUI method:

SerializedProperty predicate = property.FindPropertyRelative("predicate");
SerializedProperty parameters = property.FindPropertyRelative("parameters");
SerializedProperty negate = property.FindPropertyRelative("negate");

These lines let us grab each field in the current Predicate and store them as SerializedProperties. The FindPropertyRelative() method uses the string to find the property.
Now we need to draw the properties. To do that, we need to know a few things about the size of the properties. This is where things get a little tricky.
We’ll start by just drawing the Predicate, using the whole rec provided by the OnGUI call:

EditorGUI.PropertyField(position, predicate);

We don’t need any special handling of the predicate, because we’re only doing the PropertyDrawer to handle the parameters. predicate and negate can be handled by the PropertyField method. The PropertyField method determines what the property is and uses the default PropertyDrawer for that property to draw it. For the predicate, that means that it will automatically draw an EnumPopup and manage any changes and write them back to the property if they are changed.

Now our elements have Predicates, but nothing else.

For the first property, predicate, I simply used the existing rect to draw the PropertyField. The trouble is that each PropertyField (really, all the fields in our PropertyDrawer) need their own rects. We need to tell the Editor where we want the property to be drawn. For this, we need to know the height of the property. We can get this from another EditorGUI method, GetPropertyHeight().

float propHeight = EditorGUI.GetPropertyHeight(predicate);

This method will get us the exact height needed to properly draw the predicate property.
Now we can adjust the position rect to only include the predicate’s space

position.height = propHeight;

Let’s draw the negate field next. We know that the negate field should be the same height as the predicate field, as it’s not variable in height. To do this, we’ll just add the propHeight to the position’s y component

position.y+=propHeight;
EditorGUI.PropertyField(position, negate);

Now if we go into the Editor, we get…
image

That certainly doesn’t look right, does it? The reason for this is because the Propertydrawer assumed the property needed one line. The PropertyDrawer doesn’t automatically know how large the property is supposed to be. For that, we’ll need to override another method.

        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            SerializedProperty predicate = property.FindPropertyRelative("predicate");
            float propHeight = EditorGUI.GetPropertyHeight(predicate);
            return propHeight*2;
        }

This method is automatically called by the editor to determine the correct size of the property. For now, let’s return the height of two rows and check the result:


Now we can see that the box containing the Predicate is the correct size.
Of course, we still haven’t actually drawn the parameters.

We’re going to make some assumptions about the parameters… Let’s take a look at the EPredicates we have to choose from. After each EPredicate, I’m going to make a note of how many parameters a method handling that predicate would be expecting.

    public enum EPredicate
    {
        Select,   //0 - no handlers will be handling Select, it's a placeholder to remind us to choose a predicate
        HasQuest, //1, QuestList needs the name of the quest
        CompletedObjective, //2, QuestList needs the name of the quest and the identifier of the objective
        CompletedQuest, //1, QuestList needs the name of the quest
        HasLevel, //1, BaseStats needs to know the level we're shooting for
        MinimumTrait, //2, TraitStore needs the trait and the minimum level of the trait
        HasItem, //1, Inventory needs the ID of the item
        HasItems, //2, Inventory needs the ID of the item and the required quantity
        HasItemEquipped //1, Equipment needs the ID of the item.
    }

We’re going to use this information to revise the GetPropertyHeight method.

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

What we’re doing here is returning one line for each needed parameter. It’s going to look a bit strange at this point, since we’re not drawing the parameters yet:


It’s time to get started with some actual parameters. We’ll start by drawing the HasQuest parameters…

Before we draw the Quests, we need a way to get the actual list of quests contained within the assets.

We’re going to start by building a list of Quests. We don’t want to build this list every frame, so we’re going to only build the list when a Quest is needed, and only once.

Dictionary<string, Quest> quests;

        void BuildQuestList()
        {
            if (quests != null) return;
            quests = new Dictionary<string, Quest>();
            foreach (Quest quest in Resources.LoadAll<Quest>(""))
            {
                quests[quest.name] = quest;
            }
        }

This part should look familiar, it’s similar to a lazy construction we do in the Progression, only building the Dictionary when we need it.
Why a Dictionary instead of a string? Simple, for a CompletedObjective list, it will make getting the Quest easier to retrieve the objectves.

Next, we need a method to draw the first parameter when it is a Quest related item. By making this a method, we can re-use the method instead of repeating ourselves.

        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();
        }

This method will take a little explanation…
Our inputs are the rect to draw the first parameter, then an element which represents the first element in the parameters array.
First, we call BuildQuestList and make sure that we have our quests catalogued.
Next, we get just the names from the list.
We find the current value of the element within the list (index). SerializedProperties are special generic wrappers. We can get their values by using special accessors that manage the conversion. stringValue is just what it says, we want to work with the property as if it were a string. Note that if we try to use some other type, unexpected results will happen, up to and including corrupting the YAML file defining the property.
Once we have the index, we use a special routine designed to wrap around custom code to create a custom property. BeginProperty wants the overall position of the property and the property itself.
Then we use a Popup method to give a list of quests to choose from. There are only quest names in the list. It’s impossible to put a quest name that doesn’t exist within the editor. If the contents of parameter zero are not the name of a quest, it will be blank until you select a quest.
Note that if you delete the quest or change it’s name, these changes will NOT cascade to the Condition, you’ll have to go back in and fix it.

Ok, we’ve got our method, it’s time to put it to work.
After the first PropertyField in OnGUI, add this code:

            EPredicate selectedPredicate = (EPredicate)predicate.enumValueIndex;
            
            if (selectedPredicate == EPredicate.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 == EPredicate.HasQuest || selectedPredicate == EPredicate.CompletedQuest || selectedPredicate == EPredicate.CompletedObjective)
            {
                position.y += propHeight;
                DrawQuest(position, parameterZero);
            }

First, we find out what the selected EPredicate is. This helps us decide which fields to draw.
If the predicate is Select, then we just leave the method. We don’t even draw the negate field. This helps prompt the user (you) to select a predicate!
Next we take care to ensure that there are two parameters. None of our predicates will ever need more than two parameters (you can adjust this if you create a predicate that does). If the arraysize is too small, we just insert them. In this case, InsertArrayElementAtIndex(0) will just insert an empty string.
We then get our parameters from the array (that we just cleverly ensured was two). We’re going to be using parameterZero and ParameterOne as the properties for the remainder of the lesson.
Next we check to see if the predicate is one that requires a Quest parameter. These are the HasQuest, CompletedQuest and CompletedObjective. (CompletedObjective will also need to draw in an objective, but it needs a Quest first, and that should always be parameter 0)
If it is one of those predicates, then it adjusts the position box and calls DrawQuest(), the method we defined earlier.

Now when you go back into the Editor, you should be able to select your quest from a list of Quests when your predicate is HasQuest, CompletedQuest or CompletedObjective.
image

So we still have to handle the remaining cases, something we will tackle in the next post (I gotta sleep!)

I promise we’re close to the end of it.

PredicatePropertyDrawer.cs as of this post
using System.Collections.Generic;
using System.Linq;
using GameDevTV.Inventories;
using RPG.Quests;
using UnityEditor;
using UnityEngine;

namespace GameDevTV.Utils.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);

            EPredicate selectedPredicate = (EPredicate)predicate.enumValueIndex;
            
            if (selectedPredicate == EPredicate.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 == EPredicate.HasQuest || selectedPredicate == EPredicate.CompletedQuest || selectedPredicate == EPredicate.CompletedObjective)
            {
                position.y += propHeight;
                DrawQuest(position, parameterZero);
            }
            position.y += propHeight;
            EditorGUI.PropertyField(position, negate);
        }

        private void DrawQuest(Rect position, SerializedProperty element)
        {
            BuildQuestList();
            var names = quests.Keys.ToList();
            Debug.Log(names.Count());
            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 BuildQuestList()
        {
            Debug.Log("BuildQuests()");
            if (quests != null) return;
            quests = new Dictionary<string, Quest>();
            foreach (Quest quest in Resources.LoadAll<Quest>(""))
            {
                Debug.Log($"Adding Quest {quest.name}");
                quests[quest.name] = quest;
            }
        }

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


    }
}
4 Likes

Once again amazing! Learned tons about property drawers.

Thank you Brian!

Only correction i needed to make in my version(may be because i am using lists rather then arrays in condition.cs?)
I needed to define the path to predicate. Condition.Disjunction.Predicate

In case anyone else runs into that issue.

[CustomPropertyDrawer(typeof(**Condition.Disjunction.Predicate**))]
public class PredicatePropertyDrawer : PropertyDrawer

I’m guessing your Predicate is an inner class to Disjunction (which is an inner class to Condition).
Does your class structure look like this:

public class Condition
{
    //stuff
    public class Disjunction
    {
         //stuff
         public class Predicate
         {
          }
     }
}

or like this?

public class Condition
{
     //stuff
     public class Disjunction
     {
          ///stuff
     }
     public class Predicate
     {
          //stuff
     }
} 

That’s it, I just reviewed the code from the github.

I had thought it was supposed to be a subclass of disjunction, but apparently not!

My nemesis, the misplaced curly brace.

Evil curly braces, they’re not just for 20’s movie villians.

2 Likes

Part 3: Continuing the Predicate Property Drawer
At this point, you’ve read the warnings

Ok, we’ve actually finished with the predicates HasQuest and CompletedQuest, and we’re half way through the bonus predicate CompletedObjective (this wasn’t in the core course, but I added it when helping a student who needed to know if an Objective was complete for a dialogue).

Let’s finish that now. To keep the clutter in OnGUI to a minimum, we’re going to add a new method to handle drawing an objective.

        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();
        }

For this method, we’re passing in the position to draw the objective, a reference to the SerializedProperty representing parameter[1], and also a reference to parameters[0]. We need this so that we have a hook into what the currently selected quest is.
So the first thing we have to do is determine if a valid quest is selected in the first place. If not, the rest of this code will cause an exception, and when it comes to Editor code, unhandled exceptions can cause real issues in the editor.
As this will only be called when the predicate is EPredicate.CompletedObjective, and we know that we’ve already drawn the Quest when CompletedObjective is drawn, we know that quests is valid and fully populated. This means we can check the quests dictionary to see if the string in parameters[0] refers to a valid quest. If it doesn’t, we inform the user that we need a valid quest and gracefully exit. The HelpBox gives us a little extra emphasis so the designer can see that something isn’t properly filled out.
image

Now, like in the DrawQuest, we need a list to work with… this time of the objectives within the game. For this, we get all of the objectives in the selected quest and build a list.
Like the DrawQuest, we find the index of the current parameters[1] in the list of references. If it doesn’t exist, then index will be -1, and the blank drop down will appear.
We use the same BeginProperty/EndProperty trick to let us use a Popup to select the correct reference.
In this case, however, we’re using an alternate list from the references to display in the popup. We’re using the descriptions like they would appear in the Quest UI instead of the reference. The magic of this is that while we’re displaying the description, behind the scenes, we’re still storing the ID. This is a powerful feature of a custom editor/propertydrawer.
image

Now, after the first set of if statements in OnGUI, we’re going to add another if statement to check for CompletedObjective

            if (selectedPredicate == EPredicate.CompletedObjective)
            {
                position.y += propHeight;
                DrawObjective(position, parameterOne, parameterZero);
            }

You should see a pattern developing… we advance the position’s y element and draw the objective… th
Now, when you select CompletedObjective, you should get a list of objectives:

Next, we’ll take on the InventoryItem… We have three predicates to deal with here, HasItem, HasItems (only works on stacked items), and HasItemEquipped. All three need to select from a list of items.

We’re going to do the same thing we did with the Quest… we’re going to get a Dictionary of InventoryItems. This will allow us to build a list and have access to the items to get DisplayNames and the IsStackable properties. If you didn’t already know, I’m a huge fan of Dictionaries.

        Dictionary<string, InventoryItem> items;
        void BuildInventoryItemsList()
        {
            if (items != null) return;
            items = new Dictionary<string, InventoryItem>();
            foreach (InventoryItem item in Resources.LoadAll<InventoryItem>(""))
            {
                items[item.GetItemID()] = item;
            }
        }

This is fairly straightfoward, like Quests, we’re just snagging the list from the Resources.
Next, we need a method to draw a list of InventoryItems for our propertydrawer:

        void DrawInventoryItemList(Rect position, SerializedProperty element, bool stackable = false, bool equipment = false)
        {
            BuildInventoryItemsList();
            List<string> ids = items.Keys.ToList();
            if (stackable) ids = ids.Where(s => items[s].IsStackable()).ToList();
            if (equipment) ids = ids.Where(s => items[s] is EquipableItem e).ToList();
            List<string> displayNames = new List<string>();
            foreach (string id in ids)
            {
                displayNames.Add(items[id].GetDisplayName());
            }
            int index = ids.IndexOf(element.stringValue);
            EditorGUI.BeginProperty(position, new GUIContent("items"), element);
            int newIndex = EditorGUI.Popup(position, "Item", index, displayNames.ToArray());
            if (newIndex != index)
            {
                element.stringValue = ids[newIndex];
            }
        }

You’ll note this time, I’ve included a bool for stackable. I want this so that if our objective is HasItems (which stands for “Has this many items of this type”), we only draw items that are by nature stackable.
First, we call our method to lazily build the items list.
Next, we get the list of inventory items by polling the keys. Each item in the list will be an item ID.
If the stackable is true, then we filter the list with a Linq expression… Let’s break this down…

ids = ids.Where( //SQL like method to used to filter.
               s=> //This tells the compiler that for the rest of the Where, s is an individual element of type string
               items[s].IsStackable()) //iterates over each element in ids testing this expression
               .ToList(); //converts the result to a list (default is IEnumerable<string>)

We use a similar construction to test if the item is an EquipableItem.
To put that in plain english: The Where method is a filter, which takes each item in the collection (in this case ids, a List and tests the condition…
This is functionally equivalent to:

List<string> result=new List<string>();
foreach(string s in ids)
{
     if(items[s].IsStackable()
     {
           result.Add(s);
     }
}
ids=result;

Linq expressions like these can be used to do amazing filtering jobs, far beyond what I’ve done in this query.
Back to the topic at hand… once the ids list is assembled (filtered or not), we need a list of displaynames that corresponds to the list of ids. I waited until after the ids were filtered, so our lists are in sync… filtered or not, each element of ids and displaynames should line up. For this, we iterate over the list and get the displaynames one by one.
The rest of the code should look very familiar, as we’ve done this twice before, getting the index into the list of ids, drawing a Popup field and assigning the item. We’re showing DisplayNames, but we’re storing item ids.

Now to our OnGui method:

            if (selectedPredicate == EPredicate.HasItem || selectedPredicate== EPredicate.HasItems || selectedPredicate == EPredicate.HasItemEquipped)
            {
                position.y += propHeight;
                DrawInventoryItemList(position, parameterZero, selectedPredicate==EPredicate.HasItems, selectedPredicate == EPredicate.HasItemEquipped);
            }

For the DrawInventoryitemsList, I set the filter bools with simple tests…

Here’s our popup list in action:
image
Now filtered for stackable items (HasItems)
image
And filtered for EquipableItems (HasItemEquipped)
image
In ever case, the value displayed is the Item’s GetDisplayName, but the item stored is the itemID

Now for our HasItems, we actually need a number field so we know how many items we’re expected to have. For this, we need another helper method, this one to draw an IntSlider. The nice part is we’ll be able to re-use this particular method several more times throughout the remainder of this PropertyDrawer.

        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();
        }

This method will create an IntSlider, taking in the position, a caption for the slider, the reference to the property, and a minimum and maximum level for the slider.
First it tries to determine the value already in the parameter. Since the parameter is a string, we need to use TryParse to try to get the value. If the Tryparse fails, then we’ll set the value to 1.
Then we use an IntSlider to draw the parameter. You could use an IntField if you wished, but what I love about IntSliders is that they can impose Min/Max limits on the field quite easily, and they’re great to work with. We’ll be using this same method for HasLevel, and MinimumTrait as well.

Now let’s put it into action, within the if(inventoryrelatedpredicates) block in OnGUI

                if (selectedPredicate == EPredicate.HasItems)
                {
                    position.y += propHeight;
                    DrawIntSlider(position, "Qty Needed", parameterOne, 1,100);
                }

Now, when HasItems is the Predicate, in addition to a properly filtered list of stackable items, you’ll have a slider to set the desired quantity.
image

Ok, it’s time for a challenge:
We’ve finished drawing the parameters for several predicates, HasQuest, CompletedQuest, CompletedObjective, HasItem, HasItems, and HasItemEquipped…
Let’s see if you can figure out how to do the HasLevel. As you might guess, the parameter will represent an integer value. I’ll obscure the solution below. Have a go!

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

How did you do?

Ok, one more item to draw, the MinimumTrait.
You might think, since Trait is an Enum, that it would be as simple as drawing the Predicate with an EnumPopup, but we have the complication that the enum is actually being stored as a string, not an enum value. We’ll need to convert the string to a Trait first:

        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();
        }

Now all we have to do is wire this up to the OnGUI method:

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

image

This concludes the Predicate PropertyDrawer.
You could stop here and have all of the full functionality of Condition system, with a virtual guarantee of no more magic strings causing errors.
In the next post, we’ll take a look at the Disjunction, and find a way to draw them in a bit friendlier way than Unity handles lists.

Complete PredicatePropertyDrawer.cs:

Complete PredicatePropertyDrawer.cs final
using System;
using System.Collections.Generic;
using System.Linq;
using GameDevTV.Inventories;
using RPG.Quests;
using RPG.Stats;
using UnityEditor;
using UnityEngine;

namespace GameDevTV.Utils.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);

            EPredicate selectedPredicate = (EPredicate)predicate.enumValueIndex;
            
            if (selectedPredicate == EPredicate.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);
            if (selectedPredicate == EPredicate.HasQuest || selectedPredicate == EPredicate.CompletedQuest || selectedPredicate == EPredicate.CompletedObjective)
            {
                position.y += propHeight;
                DrawQuest(position, parameterZero);
                if (selectedPredicate == EPredicate.CompletedObjective)
                {
                    position.y += propHeight;
                    DrawObjective(position, parameterOne, parameterZero);
                }
            }
            
            if (selectedPredicate == EPredicate.HasItem !! selectedPredicate==EPredicate.HasItems || selectedPredicate==EPredicate.HasItemEquippted)
            {
                position.y += propHeight;
                DrawInventoryItemList(position, parameterZero, selectedPredicate==EPredicate.HasItems, selectedPredicate == EPredicate.HasItemEquipped);
                if (selectedPredicate == EPredicate.HasItems)
                {
                    position.y += propHeight;
                    DrawIntSlider(position, "Qty Needed", parameterOne, 1,100);
                }
            }

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

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

        private void DrawQuest(Rect position, SerializedProperty element)
        {
            BuildQuestList();
            var names = quests.Keys.ToList();
            Debug.Log(names.Count());
            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();
            List<string> ids = items.Keys.ToList();
            if (stackable) ids = ids.Where(s => items[s].IsStackable()).ToList();
            if (equipment) ids = ids.Where(s => items[s] is EquipableItem e).ToList();
            List<string> displayNames = new List<string>();
            foreach (string id in ids)
            {
                displayNames.Add(items[id].GetDisplayName());
            }
            int index = ids.IndexOf(element.stringValue);
            EditorGUI.BeginProperty(position, new GUIContent("items"), element);
            int newIndex = EditorGUI.Popup(position, "Item", index, displayNames.ToArray());
            if (newIndex != index)
            {
                element.stringValue = ids[newIndex];
            }
        }

        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();
        }
        
        void BuildQuestList()
        {
            Debug.Log("BuildQuests()");
            if (quests != null) return;
            quests = new Dictionary<string, Quest>();
            foreach (Quest quest in Resources.LoadAll<Quest>(""))
            {
                Debug.Log($"Adding Quest {quest.name}");
                quests[quest.name] = quest;
            }
        }
        
        
        void BuildInventoryItemsList()
        {
            if (items != null) return;
            items = new Dictionary<string, InventoryItem>();
            foreach (InventoryItem item in Resources.LoadAll<InventoryItem>(""))
            {
                items[item.GetItemID()] = item;
            }
        }
        
        private static void DrawIntSlider(Rect rect, string caption, SerializedProperty intParameter, int minLevel=1,
                                         int maxLevel=100)
        {
            EditorGUI.BeginProperty(rect, new GUIContent(caption), intParameter);
            if (!int.TryParse(intParameter.stringValue, out int value))
            {
                value = 1;
            }
            EditorGUI.BeginChangeCheck();
            int result = EditorGUI.IntSlider(rect, caption, value, minLevel, maxLevel);
            if (EditorGUI.EndChangeCheck())
            {
                intParameter.stringValue = $"{result}";
            }
            EditorGUI.EndProperty();
        }
        
        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            SerializedProperty predicate = property.FindPropertyRelative("predicate");
            float propHeight = EditorGUI.GetPropertyHeight(predicate);
            EPredicate selectedPredicate = (EPredicate)predicate.enumValueIndex;
            switch (selectedPredicate)
            {
                case EPredicate.Select: //No parameters, we only want the bare enum. 
                    return propHeight; 
                case EPredicate.HasLevel:       //All of these take 1 parameter
                case EPredicate.CompletedQuest:
                case EPredicate.HasQuest:
                case EPredicate.HasItem:
                case EPredicate.HasItemEquipped:
                    return propHeight * 3.0f; //Predicate + one parameter + negate
                case EPredicate.CompletedObjective: //All of these take 2 parameters
                case EPredicate.HasItems:
                case EPredicate.MinimumTrait:
                    return propHeight * 4.0f; //Predicate + 2 parameters + negate;
            }
            return propHeight * 2.0f;
        }
    }
}
5 Likes

Thanks again.

I got some errors from the cleaned up “selectedPredicate” if statement.

If i use " (selectedPredicate is EPredicate.HasItem or EPredicate.HasItems or EPredicate.HasItemEquipped) " i get the errors.

if i switch to " (selectedPredicate == EPredicate.HasQuest || selectedPredicate == EPredicate.CompletedQuest || selectedPredicate == EPredicate.CompletedObjective)" errors go away

Also switching the item “(selectedPredicate is EPredicate.HasItem or EPredicate.HasItems or EPredicate.HasItemEquipped)” as well.

I am in unity 2020.3.10f1

Ah, I’ll go back in and edit that tonight… I see what happened
I’m using Unity 2021.2 which uses C#9. Unity 2020 and previous uses C#7.3…
Hmm… does the line in DrawInventoryItemList() with is compile?

if(equipment) ids = ids.Where(s=> items[s] is EquipableItem e).ToList();

(I believe it does, I recall using this construction back when the my version of the codebase was in Unity 2020)

Yes, everything else compiled and is working beautifully.

1 Like

Brian, you rock!! Many thanks!!! <3

Hmm… my predicates are having their negate drawn ontop them… looks like this

image

even if i use the original get property height without the changes and strip everything back to just have the negate and the predicate , i still get that view of it.?? i.e. even with the vanilla getpropertyheight value it throws that view. Here is the code without any fancy changes to get property height.

Blockquote 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”);

        EditorGUI.PropertyField(position, predicate);

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

Why does your code use position.x+= prop height? I switched it to .y and it works now but now i can’t selected the negate box, it keeps targetting the enum predicate field…

My bad on the .x, it was on the code I was walking through step by step (typed by hand into the editor), but it was always supposed to be .y, which it was in my final code. Sorry about that.

Make sure that you are changing the height of position before you draw the predicate:

float propHeight = EditorGUI.GetPropertyHeight(predicate);
position.height = propHeight;
EditorGUI.PropertyField(position, predicate);
position.y+=propHeight;
EditorGUI.PropertyField(negate, predicate);
1 Like

Thank you brian. That last part was not particularly clear. Appreciate it. This fixed my issue! :slight_smile: <3

Part 4: Disjunction Junction, What’s your Function
We’ve got our Predicate property drawer in place, now it’s time to work with the Disjunction property drawer. At this point, you could actually skip the rest of this tutorial and have a fully operational inspector, relying on Unity’s built in array/list handling methods to manage the rest of the Condition. This is fine, but I’m sure some of you have noticed that sometimes Unity’s List and Array handling can be a bit buggy. This next lesson will give us a chance to implement a custom inspector for handling our predicates, and learn the best practices for crawling through a list or array of unknown size in a PropertyDrawer…

We’re going to start by creating, in the same folder as the PredicatePropertyDrawer, a new cs file, DisjuncitonPropertyDrawer.cs. Do the same sort of setup you did with the PredicatePropertyDrawer

using UnityEngine;
using UnityEditor;

namespace GameDevTV.Utils.Editor;
{
     [CustomPropertyDrawer(typeof(Condition.Disjunction))]
     public class DisjunctionPropertyDrawer : PropertyDrawer
     {
           public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
           {
                 
           }
     }
}

We’re going to start out by getting the size of our GetPropertyHeight. We’ll need this sooner than later, since predicates take several lines each to draw. For this, we’ll override GetPropertyHeight()

public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
      float result=0;
      float propHeight = EditorGUIUtility.singleLineHeight;

      return result+propHeight;
}

Before I go much further, in the previous section, PropertyDrawer, I used EditorGUI.GetPropertyHeight() on the Epredicate property to get our property height. I confess to forgetting that this number can more easily be obtained with EditorGUIUtility.singleLineHeight. This will give you the standard size of one line. I had to recall this property for the disjuction editor because our editor only contains one property, an array of Predicates, and this value can be variable, depending on the predicate.
I’ve set the method up with my sandwitch of initializing a result and returning that result. The rest of what we’ll be doing is between the statements.

IEnumerator enumerator = property.FindPropertyRelative("or").GetEnumerator();

Since or is a Predicate[], we’re going to need to iterate over the array. One might be tempted to use a for loop, but I ran into difficulties when the array had zero elements. This actually affords us an opportunity to iterate over the array in another way. It happens that you can get an enumerator from a serialized property and iterate over the values. Bear in mind that the feature set of this enumerator is a bit restricted compared to an IEnumerable, array or a list.

while(enumerator.MoveNext())
{
    SerializedProperty p = enumerator.Current as SerializedProperty;
    result+=EditorGUI.GetPropertyHeight(p) +propHeight;
}

MoveNext() looks in the container to see if there is another item. If there is, it sets the Current pointer to that item. We take that enumerator.Current (which is an object) and convert it to a SerializedProperty.
Next, we add the GetPropertyHeight() of the property and add it to result, + one line for the buttons that will allow us to move the element up/down the array or remove it altogether.
You might be wondering, at this point, where does EditorGUI.GetPropertyHeight() get the height for the property? Quite simple, since the property is a Predicate, it asks the PredicatePropertyDrawer.GetPropertyHeight(). Since we’re passing in the specific property, the propertydrawer is able to calculate the correct height. Once there are no more elements in the array, MoveNext() will return false and the while loop will exit

So now GetPropertyHeight looks like this:

        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            float result = 0;
            float propHeight = EditorGUIUtility.singleLineHeight;
            IEnumerator enumerator = property.FindPropertyRelative("or").GetEnumerator();
            while (enumerator.MoveNext())
            {
                SerializedProperty p = enumerator.Current as SerializedProperty;
                result += EditorGUI.GetPropertyHeight(p)+propHeight;
            }
            return result+propHeight;
        }

That last propHeight will be for our Add Predicate button.

With our GetPropertyHeight set up, it’s time to start drawing the property:

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            SerializedProperty or = property.FindPropertyRelative("or");
            float propHeight = EditorGUIUtility.singleLineHeight;
           
            var enumerator = or.GetEnumerator();
            while (enumerator.MoveNext())
            {
                SerializedProperty p = enumerator.Current as SerializedProperty;
                position.height = EditorGUI.GetPropertyHeight(p);
                EditorGUI.PropertyField(position, p);
                position.y += position.height;
                position.height = propHeight;
            }
        }

At this point, we’re getting the standard line size, and we’re getting the enumerator we used in the GetPropertyHeight method. This time, we’re going to set the position’s height to the size of the property and draw each element using the EditorGUI.PropertyField() method. In this case, PropertyField will find the property drawer we wrote for the Predicate and draw it, just like as if it was a built in component. By now you should be starting to see just how powerful property drawers can be.
Here’s our new inspector:


Of course, we have issues… there’s a lot of extra space, since we added that space in when we did the calculations for GetPropertyHeight, and there is no way to add or remove elements. Plus, if you look at the last element, you’ll see it’s two Predicates smashed together. That’s not terribly readable.

Let’s start by adding functionality to add a record
at the end of the OnGUI method, add this:

            if (GUI.Button(position, "Add Predicate"))
            {
                or.InsertArrayElementAtIndex(idx);
            }

This draws a button at the current position, and if the button is pressed, then it inserts an array element. Of course, we haven’t defined idx. Before the while loop, add this line:

     int idx=0;

and at the end of the while loop add this line:

    int idx++;

Now you should have an Add Predicate button after each set of or
image
But we still want to be able to reorder and remove the items…
We’re going to need some extra rects and integer variables to make this happen…
Let’s start with the rects… all of this should come before var enumerator = or.GetEnumerator()

            Rect upPosition = position;
            upPosition.width -= EditorGUIUtility.labelWidth;
            upPosition.x = position.xMax - upPosition.width;
            upPosition.width /= 3.0f;
            upPosition.height = propHeight;
            Rect downPosition = upPosition;
            downPosition.x += upPosition.width;
            Rect deletePosition = upPosition;
            deletePosition.x = position.xMax - deletePosition.width;
            int itemToRemove = -1;
            int itemToMoveUp = -1;
            int itemToMoveDown = -1;

There’s a lot going on here. In short, we’re creating three rects, one for the up arrow, one for the down arrow, and one for the delete button. We start by copying the position rect. We deduct the label spacing from the new rect, and divide the width by 1/3rd. We then set the height to be a single line height. Then we clone this rect and move it over next to the first rect, and clone it again moving it to the far right edge of the property. Finally, we create three ints as placeholders for if any of the buttons were pressed.
Now just before idx++; in our while loop, add these lines of code, which will give us our remove and up buttons:

                upPosition.y = deletePosition.y = downPosition.y = position.y;
                if (GUI.Button(deletePosition, "Remove")) itemToRemove = idx;
                if (idx > 0 && GUI.Button(upPosition, "^")) itemToMoveUp = idx;
                position.y+=propHeight;

The first line sets the y position of our buttons. We’re employing a C# trick to assign all three values to position.y.
Next, we draw a button, setting itemToRemove to idx if this button is clicked.
if idx is > 0 We draw another button, setting itemToMoveUp is clicked.
We can’t draw our down button yet, though, because we don’t know if there are more elements until we get into the next iteration in the while loop. We’ll get to that later.
Finally, we need two more if statements, outside of the while loop, before our button draw for the Add Predicate button.

            if (itemToRemove >= 0)
            {
                or.DeleteArrayElementAtIndex(itemToRemove);
            }

            if (itemToMoveUp >= 0)
            {
                or.MoveArrayElement(itemToMoveUp, itemToMoveUp - 1);
            }

Basically, these check to see if we’ve cached an index for deleting or moving. If we’re deleting, we just DeleteArrayElementAtIndex(itemToRemove). If we’re moving up, then we MoveArrayElement from the itemToMoveUp index to the itemToMoveUp index-1. Remember that if(idx>0 && statement before drawing the move up button? This ensures that the button is only drawn on the [1] or greater element, so we never have to worry about trying to move 0 to 0-1.
We now have remove and up (^) buttons on each predicate. The ^ button only exists where there is more than one or clause in the disjunction, like the bottom most element.


Still, we need two more tidbits to finish this thing off… we need a move down button on OR elements with more than one predicate, and we need something to visually remind us in these cases that these are OR conditions… We’ll manage this at the top of the While loop:

                if (idx > 0)
                {
                    if (GUI.Button(downPosition, "v")) itemToMoveDown = idx - 1;
                    EditorGUI.DropShadowLabel(position, "-------OR-------", style);
                    position.y += propHeight;
                }

There’s a reason this is at the beginning of the while loop. We don’t know if we need the OR separator or a Move down button until we move to the next element. If the idx is zero, then we don’t need to draw an OR or a down button. If we do, we draw the down button for the previous element and then an OR.
You’ll notice I’ve included a style on that label. We’ll define that at the beginning of the ONGui method:

            GUIStyle style = new GUIStyle(EditorStyles.label);
            style.alignment = TextAnchor.MiddleCenter;

This creates a new style, copying from the normal label style settings in EditorStyles. We then set the alignment to the center so the ------OR------- will be centered. To use the style, just insert it as the last parameter of a Label or DropShadowLabel.
One more thing to make the down button work, after our if statements for the remove and up buttons:

            if (itemToMoveDown >= 0)
            {
                or.MoveArrayElement(itemToMoveDown, itemToMoveDown + 1);
            }

And now our inspector should look like this:


You should be able to add predicates to an or, remove them, and move them up and down the chain with the ^ and v buttons.

In the next lesson, we’ll round this out by drawing the condition property drawer, which will give us an improved handling of the array of disjunctions,

Complete DisjunctionPropertyDr

Complete DisjunctionPropertyDrawer.cs

using System.Collections;
using UnityEngine;
using UnityEditor;

namespace GameDevTV.Utils.Editor
{
    [CustomPropertyDrawer(typeof(Condition.Disjunction))]
    public class DisjunctionPropertyDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {

            GUIStyle style = new GUIStyle(EditorStyles.label);
            style.alignment = TextAnchor.MiddleCenter;
            SerializedProperty or = property.FindPropertyRelative("or");
            float propHeight = EditorGUIUtility.singleLineHeight;
            Rect upPosition = position;
            upPosition.width -= EditorGUIUtility.labelWidth;
            upPosition.x = position.xMax - upPosition.width;
            upPosition.width /= 3.0f;
            upPosition.height = propHeight;
            Rect downPosition = upPosition;
            downPosition.x += upPosition.width;
            Rect deletePosition = upPosition;
            deletePosition.x = position.xMax - deletePosition.width;
            var enumerator = or.GetEnumerator();
            int idx = 0;
            int itemToRemove = -1;
            int itemToMoveUp = -1;
            int itemToMoveDown = -1;
            while (enumerator.MoveNext())
            {
                if (idx > 0)
                {
                    if (GUI.Button(downPosition, "v")) itemToMoveDown = idx - 1;
                    EditorGUI.DropShadowLabel(position, "-------OR-------", style);
                    position.y += propHeight;
                }
                SerializedProperty p = enumerator.Current as SerializedProperty;
                position.height = EditorGUI.GetPropertyHeight(p);
                EditorGUI.PropertyField(position, p);
                position.y += position.height;
                position.height = propHeight;
                upPosition.y = deletePosition.y = downPosition.y = position.y;
                if (GUI.Button(deletePosition, "Remove")) itemToRemove = idx;
                if (idx > 0 && GUI.Button(upPosition, "^")) itemToMoveUp = idx;
                position.y += propHeight;
                idx++;
            }

            if (itemToRemove >= 0)
            {
                or.DeleteArrayElementAtIndex(itemToRemove);
            }

            if (itemToMoveUp >= 0)
            {
                or.MoveArrayElement(itemToMoveUp, itemToMoveUp - 1);
            }

            if (itemToMoveDown >= 0)
            {
                or.MoveArrayElement(itemToMoveDown, itemToMoveDown + 1);
            }
            if (GUI.Button(position, "Add Predicate"))
            {
                or.InsertArrayElementAtIndex(idx);
            }
            
        }


        
        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            float result = 0;
            float propHeight = EditorGUIUtility.singleLineHeight;
            IEnumerator enumerator = property.FindPropertyRelative("or").GetEnumerator();
            bool multiple = false;
            while (enumerator.MoveNext())
            {
                SerializedProperty p = enumerator.Current as SerializedProperty;
                result += EditorGUI.GetPropertyHeight(p)+propHeight;
                if (multiple) result += propHeight;
                multiple = true;
            }
            return result+propHeight*1.5f;
        }
    }
}
8 Likes

I want you to know, I’ve been singing that song all day.

Also adding nonsense “hooking up predicates and supporting designers”

This section I’m definitely struggling to understand the individual lines more then previous, but my designer is significantly happier!

1 Like

Thank you so much for these! The heavy reliance on strings and the messy Inspector in the default system were stressing me out, they just seemed so designer-unfriendly. It’s so much better now!

I know it’s been a while, but I’d love to see how you handled the Condition/Conjunction property drawer, to see the whole thing finished off :slight_smile:

1 Like

@Brian_Trotter, you are AWESOME man!
I have been looking to improve on the condition and IPredicateEvaluator topic and had begun drafting code (rather hard to do in terms of editor code) untill I found this very post of yours… now I am in love with it.
Besides the great utility it provides, as well as the opportunity to learn about property drawers, dictionaries etc… it is a welcome alternative to video tutorials.

Keep up the good work and thanks again!

1 Like

Privacy & Terms