This is the second post in my series on creating an InventoryItem Editor in Unity that will edit any child of InventoryItem.  If you're not caught up, you can read the first post here.

In this section, we're going to deal with the issue of how the editor can edit any child without having to know all the details of each child.  We're going to do this by taking advantage of an important principle in C# and OOP in general, called Inheritance.

Delegating Responsibility to InventoryItem

One of the challenges I faced when creating this editor was figuring out how to draw the correct elements.  For example, in a StatsEquipableItem you need to draw Additive and Percentage stats, but in a weapon, you need to draw the weapon damage, model, and which hand the weapon goes in.

My first version of the editor cast selected as each individual possible object and then drew the controls as needed.

WeaponConfig weaponConfig = selected as WeaponConfig;
if(WeaponConfig)
{
     ///Draw WeaponConfig controls
}
StatsEquipableItem statsEquipableItem = selected as StatsEquipableItem
if(statsEquipableItem)
{
    ///You get the idea
}

It didn't take long to realize a flaw in this design.  When you get to ActionItems, which also descend from InventoryItem, you start to get an insane number of if statements.  Every time you create a new ActionItem type (e.g. a potion, a fireball, etc), you'll have to write a new cast, if statement, and drawing set.   Now imagine a game like World of Warcraft, which has hundreds of spell types, potion types, etc.  An editor full of if statements like this simply wouldn't do.  It would be a disaster to maintain, and probably create a lot of merge conflicts if two different people created two different ActionItems that needed editors.

The solution I found for this was simple.  Why not let the ScriptableObjects draw their own inspectors?  We already need a section of the SO with setters and Editor code in them to manage the Undo.  That section is surrounded by

#if UNITY_EDITOR
///all my Setters with Undos
#endif

It really wouldn't be hard to include a method to instruct the objects to draw the part of the inspector that is unique to the class.  Thanks to the magic of virtual and override, we can reduce the call in EditorWindow to one Method.

Let's take a look at how this is done.

In InventoryItem.cs, create a method within the #if UNITY_EDITOR block.

     public virtual void DrawCustomInspector()
    {
            if (!drawInventoryItem) return;
            SetItemID(EditorGUILayout.TextField("ItemID (clear to reset", GetItemID()));
            SetDisplayName(EditorGUILayout.TextField("Display name", GetDisplayName()));
            SetDescription(EditorGUILayout.TextField("Description", GetDescription()));
            SetIcon((Sprite)EditorGUILayout.ObjectField("Icon", GetIcon(), typeof(Sprite), false));
            SetPickup((Pickup)EditorGUILayout.ObjectField("Pickup", GetPickup(), typeof(Pickup), false));
            SetStackable(EditorGUILayout.Toggle("Stackable", IsStackable()));
     }

You'll note that this is functionally identical to the block in our EditorWindow, we're just executing the code in InventoryItem.cs instead.  This works because we will be calling it from the EditorWindow which already has the layout set up.

In the EditorWindow code, we can now remove all of the selected.EditorGUILayout code, and replace it with selected.DrawCustomInspector();  As you can see, visually nothing has changed.

Our editor after moving the draw code into InventoryItem.cs

Of course, we still haven't drawn our data for the rest of the Weapon, or a StatsEquipableItem if we have one, or even the slot location for EquipableItem.  We'll start with EquipableItem.cs

#if UNITY_EDITOR
        public void SetAllowedEquipLocation(EquipLocation newLocation)
        {
            if (allowedEquipLocation == newLocation) return;
            SetUndo("Change Equip Location");
            allowedEquipLocation = newLocation;
            Dirty();
        }

        public override void DrawCustomInspector()
        {
            base.DrawCustomInspector();
            SetAllowedEquipLocation((EquipLocation)EditorGUILayout.EnumPopup("Equip Location", GetAllowedEquipLocation()));
        }
#endif

There is no need to change a thing in InventoryItemEditor.cs for this to work.  While selected is cast as an InventoryItem, anything that is overridden in the child class will use the method call from the child class instead of InventoryItem.  

As you can see, our DrawCustomInspector() for EquipableItem simply calls it's parent using base.DrawCustomInspector();  it then draws the only field in EquipableItem, the allowed equip location.

EquipableItem's inspector

Next, we're going to do the same thing for WeaponConfig, but we're going to create another helper method in InventoryItem.cs

        public bool FloatEquals(float value1, float value2)
        {
            return Math.Abs(value1 - value2) < .001f;
        }

This is just another helper function.  It simply tests the two floats for equality with a threshold of 1/1000th.  Bear in mind, it's probably just fine to say if(firstfloat==secondfloat) in the code, as this is an Editor, but best practices say a comparison should be comparing the difference to a threshhold.  For our purposes, .001f is beyond the change of any value we're likely to set.  You can skip this if you like and just make the == comparison.

Now for the WeaponConfig code:

#if UNITY_EDITOR

        void SetWeaponRange(float newWeaponRange)
        {
            if (FloatEquals(weaponRange, newWeaponRange)) return;
            SetUndo("Set Weapon Range");
            weaponRange = newWeaponRange;
            Dirty();
        }

        void SetWeaponDamage(float newWeaponDamage)
        {
            if (FloatEquals(weaponDamage, newWeaponDamage)) return;
            SetUndo("Set Weapon Damage");
            weaponDamage = newWeaponDamage;
            Dirty();
        }

        void SetPercentageBonus(float newPercentageBonus)
        {
            if (FloatEquals(percentageBonus, newPercentageBonus)) return;
            SetUndo("Set Percentage Bonus");
            percentageBonus = newPercentageBonus;
            Dirty();
        }

        void SetIsRightHanded(bool newRightHanded)
        {
            if (isRightHanded == newRightHanded) return;
            SetUndo(newRightHanded?"Set as Right Handed":"Set as Left Handed");
            isRightHanded = newRightHanded;
            Dirty();
        }

        void SetAnimatorOverride(AnimatorOverrideController newOverride)
        {
            if (newOverride == animatorOverride) return;
            SetUndo("Change AnimatorOverride");
            animatorOverride = newOverride;
            Dirty();
        }

        void SetEquippedPrefab(Weapon newWeapon)
        {
            if (newWeapon == equippedPrefab) return;
            SetUndo("Set Equipped Prefab");
            equippedPrefab = newWeapon;
            Dirty();
        }

        void SetProjectile(GameObject possibleProjectile)
        {
            if (!possibleProjectile.TryGetComponent<Projectile>(out Projectile newProjectile)) return;
            if (newProjectile == projectile) return;
            SetUndo("Set Projectile");
            projectile = newProjectile;
            Dirty();
        }

        public override void DrawCustomInspector()
        {
            base.DrawCustomInspector();
            SetEquippedPrefab((Weapon)EditorGUILayout.ObjectField("Equipped Prefab", equippedPrefab,typeof(Object), false));
            SetWeaponDamage(EditorGUILayout.Slider("Weapon Damage", weaponDamage, 0, 100));
            SetWeaponRange(EditorGUILayout.Slider("Weapon Range", weaponRange, 1,40));
            SetPercentageBonus(EditorGUILayout.IntSlider("Percentage Bonus", (int)percentageBonus, -10, 100));
            SetIsRightHanded(EditorGUILayout.Toggle("Is Right Handed", isRightHanded));
            SetAnimatorOverride((AnimatorOverrideController)EditorGUILayout.ObjectField("Animator Override", animatorOverride, typeof(AnimatorOverrideController), false));
            SetProjectile((Projectile)EditorGUILayout.ObjectField("Projectile", projectile, typeof(Projectile), false));
        }

#endif

Note that for the float fields I used sliders, which allow you to set the range of a field, and provide a handy slider for the field. In the case of Percentage Bonus, I used an Int Slider to restrict the percentage to whole numbers. This isn’t required, but I wanted to show you how it works, and for float fields that really work on whole numbers anyways (like in my Grid based game, the number of spaces you can move each turn) it’s really handy.

So we now have our inspector showing every field for WeaponConfig, but there’s a subtle problem. It’s possible to create a Weapon and set it’s equip location to someplace other than the Weapon.

Our bow is in the Helmet slot?

This simply will not do.  It would be better if you couldn't change the slot when the item is a WeaponConfig, and if you couldn't set the item as a weapon if it's a normal StatsEquipableItem.  

We're going to handle this in a virtual method in EquipableItem that we'll override in WeaponConfig.

        public virtual  bool IsLocationSelectable(Enum location)
        {
            EquipLocation candidate = (EquipLocation)location;
            return candidate != EquipLocation.Weapon;
        }

We'll change the draw code to include the function to test the location.  Note that becasue of the various calls for EnumPopup, we must use a new GUIContent for the label rather than just a string:

SetAllowedEquipLocation((EquipLocation)EditorGUILayout.EnumPopup(new GUIContent("Equip Location"), allowedEquipLocation, IsLocationSelectable, false));

Qute a chunk of code there... this automatically calls allowedEquipLocation on each selection possible before displaying the popup.  If IsLocationSelectable returns true on an Enum, it's selectable.  If it's false, then the item is gray and unselectable in the inspector.   I've set it to default to excluding weapons because we only want a weapon to be selectable within a WeaponConfig.

Now we'll override that IsLocationSelectable in WeaponConfig.cs

        public override bool IsLocationSelectable(Enum location)
        {
            EquipLocation candidate = (EquipLocation) location;
            return candidate == EquipLocation.Weapon;
        }

This time, the only value selectable is Weapon.  The other values will be grayed out.

One more feature to make this inspector a bit more organized is to add foldouts to each section.  You'll need a unique bool for each foldout to make this work.

In the DrawCustomInspector function, after base.DrawInspector if it's an override, add an EditorGUILayout.Foldout(bool, string).

Here's the example with InventoryItem:

        bool drawInventoryItem = true;
        public GUIStyle foldoutStyle;
        public virtual void DrawCustomInspector()
        {
            foldoutStyle = new GUIStyle(EditorStyles.foldout);
            foldoutStyle.fontStyle = FontStyle.Bold;
            drawInventoryItem = EditorGUILayout.Foldout(drawInventoryItem, "InventoryItem Data", foldoutStyle);
            if (!drawInventoryItem) return;
            SetItemID(EditorGUILayout.TextField("ItemID (clear to reset", GetItemID()));
            SetDisplayName(EditorGUILayout.TextField("Display name", GetDisplayName()));
            SetDescription(EditorGUILayout.TextField("Description", GetDescription()));
            SetIcon((Sprite)EditorGUILayout.ObjectField("Icon", GetIcon(), typeof(Sprite), false));
            SetPickup((Pickup)EditorGUILayout.ObjectField("Pickup", pickup, typeof(Pickup), false));
            SetStackable(EditorGUILayout.Toggle("Stackable", IsStackable()));
        }

Now you can collapse the entire InventoryItem section of the Editor so you can focus on the elements you want to edit.

This gets us our WeaponConfig drawn, but we've still got a ways to go.  In the next post, I'll guide you through drawing the StatsEquipableItem's custom inspector, which has some new concepts for dealing with Lists in Inspectors.