Search Unity

Unity custom inspector with sub-inspectors for ScriptableObject

Discussion in 'Scripting' started by Sorade, Nov 17, 2017.

  1. Sorade

    Sorade

    Joined:
    Jan 3, 2017
    Posts:
    8
    I am working on a small ARPG in Unity 2017.2.

    I have tried implementing a custom editor for the AbilityBluePrint class of my game.

    Basically, the AbilityBluePrints contain all the information necessary to generate the Ability at run time. Including an array of Effect[] ScritpableObjects which get triggered when the ability is used.

    I currently have everything I need implemented and working but I envisage creating abilities to be very tedious for the following reason.

    Say I have an effect class DamagePlusX : Effect which as a damage modifier value. If I want this effect to have a different modifier value for two different abilities then I will have to create two instances of it in my Asset directory and manually assign each one to the Effect[] array of the corresponding ability. I am concerned that I will end up having lots and lots of instances of effects each with essentially a few different ints and floats.

    I therefore thought I would use a custom inspector a bit like the one from the Adventure Tutorial ( https://unity3d.com/learn/tutorials/projects/adventure-game-tutorial/reactions?playlist=44381 ) from Unity.

    The idea is to basically create the instance of the AbilityBluePrint and then use the custom inspector to be able to dynamically instantiate Effects in the Effects[] array and be able to edit the properties of each effect directly within the AbilityBluePrint inspector.

    Basically I would like to get somthing a bit like that (apologies for the poor photoshop):




    I tried to convert the scripts from the tutorial to fit my needs but I keep having the same error since yesterday:

    NullReferenceException: Object reference not set to an instance of an object
    AbilityBluePrintEditor.SubEditorSetup (.EffectEditor editor) (at Assets/Scripts/Editor/AbilityBluePrintEditor.cs:90)
    EditorWithSubEditors`2[TEditor,TTarget].CheckAndCreateSubEditors (.TTarget[] subEditorTargets) (at Assets/Scripts/Editor/EditorWithSubEditors.cs:33)

    I've tried so many things I am wondering if what I am trying to do is doable with scriptable objects. In the original tutorial the equivalent of my BluePrintAbility is a Monobehaviour.

    The code I have is below:

    My BluePrintAbility Class:

    Code (CSharp):
    1. [CreateAssetMenu(fileName = "New Ability BluePrint", menuName = "Ability BluePrint")]
    2.     public class AbilityBluePrint : ScriptableObject {
    3.         public Effect[] effects = new Effect[0];
    4.         public string description;
    5.     }
    My Effect class:

    Code (CSharp):
    1. public abstract class Effect : ScriptableObject {
    2.         }
    My DamagePlusX effect Class:

    Code (CSharp):
    1.  [CreateAssetMenu(fileName = "DamagePlusX",menuName = "Effects/DamagePlusX")]
    2.     public class DamagePlusX : Effect
    3.     {
    4.         [SerializeField]
    5.         int modifier;
    6.         public void ApplyModifier(){ // some logic}
    7.     }
    And now the Editors (apologies for the long samples, but I don't now where the error is in there, I've cut down the main classes though):

    This is the base editor from the tutorial, where my error comes from:


    Code (CSharp):
    1.     // This class acts as a base class for Editors that have Editors
    2.     // nested within them.  For example, the InteractableEditor has
    3.     // an array of ConditionCollectionEditors.
    4.     // It's generic types represent the type of Editor array that are
    5.     // nested within this Editor and the target type of those Editors.
    6.     public abstract class EditorWithSubEditors<TEditor, TTarget> : Editor
    7.         where TEditor : Editor
    8.         where TTarget : Object
    9.  
    10.     {
    11.         protected TEditor[] subEditors;         // Array of Editors nested within this Editor.
    12.  
    13.  
    14.     // This should be called in OnEnable and at the start of OnInspectorGUI.
    15.     protected void CheckAndCreateSubEditors (TTarget[] subEditorTargets)
    16.     {
    17.         // If there are the correct number of subEditors then do nothing.
    18.         if (subEditors != null && subEditors.Length == subEditorTargets.Length)
    19.             return;
    20.  
    21.         // Otherwise get rid of the editors.
    22.         CleanupEditors ();
    23.  
    24.         // Create an array of the subEditor type that is the right length for the targets.
    25.         subEditors = new TEditor[subEditorTargets.Length];
    26.  
    27.         // Populate the array and setup each Editor.
    28.         for (int i = 0; i < subEditors.Length; i++)
    29.         {
    30.             subEditors[i] = CreateEditor (subEditorTargets[i]) as TEditor;
    31.             SubEditorSetup (subEditors[i]); // ERROR comes inside this function HERE !!!!
    32.         }
    33.     }
    34.  
    35.  
    36.     // This should be called in OnDisable.
    37.     protected void CleanupEditors ()
    38.     {
    39.         // If there are no subEditors do nothing.
    40.         if (subEditors == null)
    41.             return;
    42.  
    43.         // Otherwise destroy all the subEditors.
    44.         for (int i = 0; i < subEditors.Length; i++)
    45.         {
    46.             DestroyImmediate (subEditors[i]);
    47.         }
    48.  
    49.         // Null the array so it's GCed.
    50.         subEditors = null;
    51.     }
    52.  
    53.  
    54.     // This must be overridden to provide any setup the subEditor needs when it is first created.
    55.     protected abstract void SubEditorSetup (TEditor editor);
    56. }

    Code (CSharp):
    1. [CustomEditor(typeof(AbilityBluePrint)), CanEditMultipleObjects]
    2.     public class AbilityBluePrintEditor : EditorWithSubEditors<EffectEditor, Effect>
    3.     {
    4.         private AbilityBluePrint blueprint;          // Reference to the target.
    5.         private SerializedProperty effectsProperty; //represents the array of effects.
    6.  
    7.         private Type[] effectTypes;                           // All the non-abstract types which inherit from Effect.  This is used for adding new Effects.
    8.         private string[] effectTypeNames;                     // The names of all appropriate Effect types.
    9.         private int selectedIndex;                              // The index of the currently selected Effect type.
    10.  
    11.  
    12.         private const float dropAreaHeight = 50f;               // Height in pixels of the area for dropping scripts.
    13.         private const float controlSpacing = 5f;                // Width in pixels between the popup type selection and drop area.
    14.         private const string effectsPropName = "effects";   // Name of the field for the array of Effects.
    15.  
    16.  
    17.         private readonly float verticalSpacing = EditorGUIUtility.standardVerticalSpacing;
    18.         // Caching the vertical spacing between GUI elements.
    19.  
    20.         private void OnEnable()
    21.         {
    22.             // Cache the target.
    23.             blueprint = (AbilityBluePrint)target;
    24.  
    25.             // Cache the SerializedProperty
    26.             effectsProperty = serializedObject.FindProperty(effectsPropName);
    27.  
    28.             // If new editors for Effects are required, create them.
    29.             CheckAndCreateSubEditors(blueprint.effects);
    30.  
    31.             // Set the array of types and type names of subtypes of Reaction.
    32.             SetEffectNamesArray();
    33.         }
    34.  
    35.         public override void OnInspectorGUI()
    36.         {
    37.             // Pull all the information from the target into the serializedObject.
    38.             serializedObject.Update();
    39.  
    40.             // If new editors for Reactions are required, create them.
    41.             CheckAndCreateSubEditors(blueprint.effects);
    42.  
    43.             DrawDefaultInspector();
    44.  
    45.             // Display all the Effects.
    46.             for (int i = 0; i < subEditors.Length; i++)
    47.             {
    48.                 if (subEditors[i] != null)
    49.                 {
    50.                     subEditors[i].OnInspectorGUI();
    51.                 }          
    52.             }
    53.  
    54.             // If there are Effects, add a space.
    55.             if (blueprint.effects.Length > 0)
    56.             {
    57.                 EditorGUILayout.Space();
    58.                 EditorGUILayout.Space();
    59.             }
    60.  
    61.  
    62.             //Shows the effect selection GUI
    63.             SelectionGUI();
    64.  
    65.             if (GUILayout.Button("Add Effect"))
    66.             {
    67.              
    68.             }
    69.  
    70.             // Push data back from the serializedObject to the target.
    71.             serializedObject.ApplyModifiedProperties();
    72.         }
    73.  
    74.         private void OnDisable()
    75.         {
    76.             // Destroy all the subeditors.
    77.             CleanupEditors();
    78.         }
    79.  
    80.         // This is called immediately after each ReactionEditor is created.
    81.         protected override void SubEditorSetup(EffectEditor editor)
    82.         {
    83.             // Make sure the ReactionEditors have a reference to the array that contains their targets.
    84.             editor.effectsProperty = effectsProperty; //ERROR IS HERE !!!
    85.         }
    86.  
    87.         private void SetEffectNamesArray()
    88.         {
    89.             // Store the Effect type.
    90.             Type effectType = typeof(Effect);
    91.  
    92.             // Get all the types that are in the same Assembly (all the runtime scripts) as the Effect type.
    93.             Type[] allTypes = effectType.Assembly.GetTypes();
    94.  
    95.             // Create an empty list to store all the types that are subtypes of Effect.
    96.             List<Type> effectSubTypeList = new List<Type>();
    97.  
    98.             // Go through all the types in the Assembly...
    99.             for (int i = 0; i < allTypes.Length; i++)
    100.             {
    101.                 // ... and if they are a non-abstract subclass of Effect then add them to the list.
    102.                 if (allTypes[i].IsSubclassOf(effectType) && !allTypes[i].IsAbstract)
    103.                 {
    104.                     effectSubTypeList.Add(allTypes[i]);
    105.                 }
    106.             }
    107.  
    108.             // Convert the list to an array and store it.
    109.             effectTypes = effectSubTypeList.ToArray();
    110.  
    111.             // Create an empty list of strings to store the names of the Effect types.
    112.             List<string> reactionTypeNameList = new List<string>();
    113.  
    114.             // Go through all the Effect types and add their names to the list.
    115.             for (int i = 0; i < effectTypes.Length; i++)
    116.             {
    117.                 reactionTypeNameList.Add(effectTypes[i].Name);
    118.             }
    119.          
    120.             // Convert the list to an array and store it.
    121.             effectTypeNames = reactionTypeNameList.ToArray();
    122.         }
    123.  
    124.         private void SelectionGUI()
    125.         {
    126.             // Create a Rect for the full width of the inspector with enough height for the drop area.
    127.             Rect fullWidthRect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, GUILayout.Height(dropAreaHeight + verticalSpacing));
    128.  
    129.             // Create a Rect for the left GUI controls.
    130.             Rect leftAreaRect = fullWidthRect;
    131.  
    132.             // It should be in half a space from the top.
    133.             leftAreaRect.y += verticalSpacing * 0.5f;
    134.  
    135.             // The width should be slightly less than half the width of the inspector.
    136.             leftAreaRect.width *= 0.5f;
    137.             leftAreaRect.width -= controlSpacing * 0.5f;
    138.  
    139.             // The height should be the same as the drop area.
    140.             leftAreaRect.height = dropAreaHeight;
    141.  
    142.             // Create a Rect for the right GUI controls that is the same as the left Rect except...
    143.             Rect rightAreaRect = leftAreaRect;
    144.  
    145.             // ... it should be on the right.
    146.             rightAreaRect.x += rightAreaRect.width + controlSpacing;
    147.  
    148.             // Display the GUI for the type popup and button on the left.
    149.             TypeSelectionGUI(leftAreaRect);
    150.         }
    151.  
    152.         private void TypeSelectionGUI(Rect containingRect)
    153.         {
    154.             // Create Rects for the top and bottom half.
    155.             Rect topHalf = containingRect;
    156.             topHalf.height *= 0.5f;
    157.             Rect bottomHalf = topHalf;
    158.             bottomHalf.y += bottomHalf.height;
    159.  
    160.             // Display a popup in the top half showing all the reaction types.
    161.             selectedIndex = EditorGUI.Popup(topHalf, selectedIndex, effectTypeNames);
    162.  
    163.             // Display a button in the bottom half that if clicked...
    164.             if (GUI.Button(bottomHalf, "Add Selected Effect"))
    165.             {
    166.                 // ... finds the type selected by the popup, creates an appropriate reaction and adds it to the array.
    167.                 Debug.Log(effectTypes[selectedIndex]);
    168.                 Type effectType = effectTypes[selectedIndex];
    169.                 Effect newEffect = EffectEditor.CreateEffect(effectType);
    170.                 Debug.Log(newEffect);
    171.                 effectsProperty.AddToObjectArray(newEffect);
    172.             }
    173.         }
    174.     }
    Code (CSharp):
    1. public abstract class EffectEditor : Editor
    2.     {
    3.         public bool showEffect = true;                       // Is the effect editor expanded?
    4.         public SerializedProperty effectsProperty;    // Represents the SerializedProperty of the array the target belongs to.
    5.  
    6.  
    7.         private Effect effect;                      // The target Reaction.
    8.  
    9.  
    10.         private const float buttonWidth = 30f;          // Width in pixels of the button to remove this Reaction from the ReactionCollection array.
    11.  
    12.         private void OnEnable()
    13.         {
    14.             // Cache the target reference.
    15.             effect = (Effect)target;
    16.  
    17.             // Call an initialisation method for inheriting classes.
    18.             Init();
    19.         }
    20.  
    21.         // This function should be overridden by inheriting classes that need initialisation.
    22.         protected virtual void Init() { }
    23.  
    24.  
    25.         public override void OnInspectorGUI()
    26.         {
    27.             Debug.Log("attempt to draw effect inspector");
    28.             // Pull data from the target into the serializedObject.
    29.             serializedObject.Update();
    30.  
    31.             EditorGUILayout.BeginVertical(GUI.skin.box);
    32.             EditorGUI.indentLevel++;
    33.  
    34.             DrawDefaultInspector();
    35.  
    36.             EditorGUI.indentLevel--;
    37.             EditorGUILayout.EndVertical();
    38.  
    39.             // Push data back from the serializedObject to the target.
    40.             serializedObject.ApplyModifiedProperties();
    41.         }
    42.  
    43.         public static Effect CreateEffect(Type effectType)
    44.         {
    45.             // Create a reaction of a given type.
    46.             return (Effect) ScriptableObject.CreateInstance(effectType);
    47.         }
    48.     }

    Code (CSharp):
    1.     [CustomEditor(typeof(DamagePlusXEditor))]
    2.     public class DamagePlusXEditor : EffectEditor {}
    And the AddToArray code which I think is responsible for the error:

    Code (CSharp):
    1.     public static void AddToObjectArray<T> (this SerializedProperty arrayProperty, T elementToAdd)
    2.         where T : Object
    3.     {
    4.         // If the SerializedProperty this is being called from is not an array, throw an exception.
    5.         if (!arrayProperty.isArray)
    6.             throw new UnityException("SerializedProperty " + arrayProperty.name + " is not an array.");
    7.  
    8.         // Pull all the information from the target of the serializedObject.
    9.         arrayProperty.serializedObject.Update();
    10.  
    11.         // Add a null array element to the end of the array then populate it with the object parameter.
    12.         arrayProperty.InsertArrayElementAtIndex(arrayProperty.arraySize);      
    13.         arrayProperty.GetArrayElementAtIndex(arrayProperty.arraySize - 1).objectReferenceValue = elementToAdd;
    14.         //root error is here, element cannot be assigned to the effects array. If I print the previous line as a statement instead of an assignment it returns false.
    15.         // Push all the information on the serializedObject back to the target.
    16.         arrayProperty.serializedObject.ApplyModifiedProperties();
    17.     }
     
    Last edited: Nov 21, 2017
  2. TonyLi

    TonyLi

    Joined:
    Apr 10, 2012
    Posts:
    12,697
    Try some null-checking. I suspect that subEditors{i}.effectsProperty isn't set yet because its OnEnable method hasn't run yet. Instead of setting it in OnEnable, you could define it as a property:

    Code (csharp):
    1. private SerializedProperty _effectsProperty = null;
    2. private SerializedProperty effectsProperty {
    3.     get {
    4.         if (_effectsProperty == null) _effectsProperty = serializedObject.FindProperty(effectsPropName);
    5.         return _effectsProperty;
    6.     }
    7. }
     
    Last edited: Nov 19, 2017
  3. Sorade

    Sorade

    Joined:
    Jan 3, 2017
    Posts:
    8
    @TonyLi Thanks for the suggestion !!

    Just tried it and it doesn't work. I did the test as you suggested and the OnEnable from the AbilityBluePrintEditor gets called correctly.

    Okay so I tracked the error further up. Basically the Effect created is not added to the effects array on the blueprint object.

    Somehow the following code doesn't lead to an effect being added to blueprint.effects, hence when CheckAndCreateSubEditors(blueprint.effects); is called it fails.

    Code (CSharp):
    1.         if (GUI.Button(bottomHalf, "Add Selected Effect"))
    2.         {
    3.             // ... finds the type selected by the popup, creates an appropriate reaction and adds it to the array.
    4.             Type effectType = effectTypes[selectedIndex];
    5.             Effect newEffect = EffectEditor.CreateEffect(effectType);
    6.             effectsProperty.AddToObjectArray (newEffect);
    7.         }
    For info this is the AddToObjectArray code:

    Code (CSharp):
    1.     public static void AddToObjectArray<T> (this SerializedProperty arrayProperty, T elementToAdd)
    2.         where T : Object
    3.     {
    4.         // If the SerializedProperty this is being called from is not an array, throw an exception.
    5.         if (!arrayProperty.isArray)
    6.             throw new UnityException("SerializedProperty " + arrayProperty.name + " is not an array.");
    7.  
    8.         // Pull all the information from the target of the serializedObject.
    9.         arrayProperty.serializedObject.Update();
    10.  
    11.         // Add a null array element to the end of the array then populate it with the object parameter.
    12.         arrayProperty.InsertArrayElementAtIndex(arrayProperty.arraySize);      
    13.         arrayProperty.GetArrayElementAtIndex(arrayProperty.arraySize - 1).objectReferenceValue = elementToAdd;
    14.         Debug.Log("root error is here, element cannot be assigned to array");
    15.         // Push all the information on the serializedObject back to the target.
    16.         arrayProperty.serializedObject.ApplyModifiedProperties();
    17.     }
     
    Last edited: Nov 21, 2017
  4. dslyecix

    dslyecix

    Joined:
    Sep 29, 2017
    Posts:
    8
    Hi there Sorade, have you by chance made any progress on this issue?

    I'm a complete noob working my way up to attempting a modular spell/ability system. I'm aiming to create a tree-like structure of effects to iterate through, and ended up finding this post while looking for help. It seems remarkably similar to what I'm trying to do, at least so much as I can figure out what's going on. I am still at a much more basic level, but I ran into a similar design issue where I was "running" my scriptable object effects directly; Any time I wanted a variation on an Effect that I'd already created, it meant creating a whole new ScrObj for the new version. If I reused one, any change of course was made to every other Ability that relied on it.

    Being a beginner, nearly all of this brand new to me (working with the editor, doing 'generic' stuff using <T> etc) so my chances of figuring out the issue you're having on my own are essentially zero. If you by any chance have solved this, I'd love to hear how!
     
  5. Sorade

    Sorade

    Joined:
    Jan 3, 2017
    Posts:
    8
    Hi dslyecix, sorry for not replying earlier. I had put my projects on hold. Unfortunately no, I haven't figured out the issue yet. I will let you know if I do. Good luck with your projects !
     
  6. DonLoquacious

    DonLoquacious

    Joined:
    Feb 24, 2013
    Posts:
    1,667
    Just some notes. I've done exactly this kind of thing before and gotten it working just fine, so this isn't an impossible task.

    In EditorWithSubEditors you've got a generic type TEditor that's supposed to be the base Editor types being handled. I can see this going wrong really easily, for instance when you call "CreateEditor(someType) as TEditor" you're making a fragile assumption about the returned editor (that it derives from TEditor) without any real proof that's the case. You should instead test whether the returned editor derives from TEditor first, with typeof(TEditor).IsAssignableFrom(createdEditor), or ensure that the returned editor MUST match that type with CreateEditor(someType, typeof(TEditor)). The latter allows you to use a custom inspector that's not actually the default custom inspector for an object type- for instance, allowing you to make a completely blank one so the Sub-SOs you're creating can't be edited individually, and must be edited from this object instead.

    You shouldn't use InsertArrayElementAtIndex to make new items IIRC, you should instead just increment the arraySize with arraySize += 1 and then add the new object to the last position. The index you're inserting at doesn't exist yet, so you're likely going to get an out of bounds error trying to do it that way. I would also change the generic restriction of the AddToArray class to T : UnityEngine.Object just to be sure it's not referring to System.Object, and I would probably verify that the array can hold objects of type T specifically before trying to assign it.
     
  7. dslyecix

    dslyecix

    Joined:
    Sep 29, 2017
    Posts:
    8
    Thanks for the reply! Glad I bookmarked this and remember to check now and then. Same to you, I'll update this thread with whatever insight I come across if I get it.

    Also thanks for the info Lysander. I'm very new to C# and wasn't yet aware such a thing exists, very good to be exposed to the idea of being very careful or deliberate with these things. Thanks for the new angle to look into.
     
  8. MadeFromPolygons

    MadeFromPolygons

    Joined:
    Oct 5, 2013
    Posts:
    3,981
  9. glord_unity

    glord_unity

    Joined:
    Nov 15, 2018
    Posts:
    2
    I spent hours today researching this. The solution is super easy, so I'll leave it here for anyone in the future trying to create polymorphic serialization in the project window.

    In the reaction editor, do something like this.

    Code (CSharp):
    1. public static Reaction CreateReaction(Type reactionType)
    2.     {
    3.         Reaction reaction = (Reaction)CreateInstance(reactionType);
    4.         string assetPath = string.Format("ReactionData/{0}", reactionType.ToString());
    5.         string directory = Application.dataPath + "/" + assetPath;
    6.         if (!System.IO.Directory.Exists(directory))
    7.         {
    8.             System.IO.Directory.CreateDirectory(directory);
    9.         }
    10.         AssetDatabase.CreateAsset(reaction, string.Format("Assets/{0}/{1}_{2}.asset",
    11.                                                            assetPath,
    12.                                                            reactionType.ToString(),
    13.                                                            1 + (System.IO.Directory.GetFiles(directory).Length / 2)));
    14.         AssetDatabase.SaveAssets();
    15.         //EditorUtility.FocusProjectWindow();
    16.         //Selection.activeObject = reaction;
    17.         // Create a reaction of a given type.
    18.         return reaction;
    19.     }
    Then when you remove the object from the list you'll need to also remove the .asset files.
     
    Last edited: Nov 15, 2018