Search code examples
c#unity-game-engineunity3d-editor

Building an Editor for nested ScriptableObjects to compose abilities in a card game


I'm building a card game and I'd like to have a clean architecture for card abilities. I've got a CardData ScriptableObject with properties for a card. I want card abilities to compose together to describe what a card does, like a card called DrawAndHealCard that draws 2 cards and heals 5 health when played.

I realized right away that this means I'll need a concrete asset for each variation of a CardAbility. So DrawAndHealCard has a reference to two assets: DrawCards2 and HealPlayer5. That's ridiculous, I want all the data to feel like it's on a single DrawAndHealCard.

So I learned about AssetDatabase.AddObjectToAsset(), this seems like the right idea, I can have the abilities as sub-assets of a CardData asset and not have deal with the organization of all these separate assets. So now I'm trying to build an Editor to manage this and it is painful.

I've read so much stuff about Unity serialization, SOs, Editor scripts, ... seriously hitting a wall with this and about to downgrade to something that feels less elegant architecturally. If there's a better way to do this, I'm open to suggestions about totally different routes too.

Code below is stripped down, but it's the gist of what I'm trying to figure out. Where I'm at right now is onAddCallback seems to be adding a sub-asset correctly, but onRemoveCallback does not remove it. My Clear All Abilities button does work however. I can't find any good docs or guides about this stuff, so I'm pretty lost currently.

// CardData.cs
[CreateAssetMenu(fileName = "CardData", menuName = "Card Game/CardData", order = 1)]
public class CardData : ScriptableObject
{
    public Sprite image;
    public string description;

    public CardAbility[] onPlayed;
}

// CardAbility.cs
public class CardAbility : ScriptableObject
{
    public abstract void Resolve();
}

// DrawCards.cs
public class DrawCards : CardAbility
{
    public int numCards = 1;
    public override void Resolve()
    {
        Deck.instance.DrawCards(numCards);
    }
}

// HealPlayer.cs
public class HealPlayer : CardAbility
{
    public int healAmt = 10;
    public override void Resolve()
    {
        Player.instance.Heal(healAmt);
    }
}

// CardDataEditor.cs
[CustomEditor(typeof(CardData))]
public class CardDataEditor : Editor
{
    private ReorderableList abilityList;

    public void OnEnable()
    {
        abilityList = new ReorderableList(
                serializedObject, 
                serializedObject.FindProperty("onPlayed"), 
                draggable: true,
                displayHeader: true,
                displayAddButton: true,
                displayRemoveButton: true);

        abilityList.onRemoveCallback = (ReorderableList l) => {
            l.serializedProperty.serializedObject.Update();
            var obj = l.serializedProperty.GetArrayElementAtIndex(l.index).objectReferenceValue;
            DestroyImmediate(obj, true);
            AssetDatabase.SaveAssets();
            l.serializedProperty.DeleteArrayElementAtIndex(l.index);
            l.serializedProperty.serializedObject.ApplyModifiedProperties();
        };

        abilityList.onAddCallback = (ReorderableList l) => {
            var index = l.serializedProperty.arraySize;
            l.serializedProperty.arraySize++;
            l.index = index;
            var element = l.serializedProperty.GetArrayElementAtIndex(index);

            // Hard coding a specific ability for now
            var cardData = (CardData)target;
            var newAbility = ScriptableObject.CreateInstance<DrawCards>();
            newAbility.name = "test";
            newAbility.numCards = 22;

            element.objectReferenceValue = newAbility;
            AssetDatabase.AddObjectToAsset(newAbility, cardData);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();
            serializedObject.ApplyModifiedProperties();
        };

        // Will use this to provide a menu of abilities to choose from.
        /*
        abilityList.onAddDropdownCallback = (Rect buttonRect, ReorderableList l) => {
            var menu = new GenericMenu();
            var guids = AssetDatabase.FindAssets("", new[]{"Assets/CardAbility"});
            foreach (var guid in guids) {
                var path = AssetDatabase.GUIDToAssetPath(guid);
                menu.AddItem(new GUIContent("Mobs/" + Path.GetFileNameWithoutExtension(path)), false, clickHandler, new WaveCreationParams() {Type = MobWave.WaveType.Mobs, Path = path});
            }
            menu.ShowAsContext();
        };
        */

        // Will use this to render CardAbility properties
        /*
        abilityList.drawElementCallback = (Rect rect, int index, bool isActive, bool isFocused) => {
        };
        */
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();
        DrawDefaultInspector();

        abilityList.DoLayoutList();

        // XXX: Ultimately don't expect to use these, experimenting with
        //      other ways of adding/deleting.
        
        if (GUILayout.Button("Add Ability")) {
            var cardData = (CardData)target;
            var newAbility = ScriptableObject.CreateInstance<CardAbility>();

            AssetDatabase.AddObjectToAsset(newAbility, cardData);
            AssetDatabase.SaveAssets();
        }

        if (GUILayout.Button("Clear All Abilities")) {
            var path = AssetDatabase.GetAssetPath(target);
            Object[] assets = AssetDatabase.LoadAllAssetRepresentationsAtPath(path);
            for (int i = 0; i < assets.Length; i++) {
                if (assets[i] is CardAbility) {
                    Object.DestroyImmediate(assets[i], true);
                }
            }
            AssetDatabase.SaveAssets();
        }

        serializedObject.ApplyModifiedProperties();
    }
}

Solution

  • Ok I finally figured this out. I read a hundred stack overflow and forum posts trying to understand this, so I'm paying it forward, hopefully this helps someone else navigate this. This produces an Editor like the image below, where OnPlayed is an array of polymorphic ScriptableObjects. These CardAbility SO's are stored as sub-assets on the owning ScriptableObject (CardData). Still more to clean up here, and it could be made more generic, but should be a good start for someone else trying to do this.

    inspector

    The [+] button produces a list of all CardAbility SO's that are available to add. And the properties for a concrete CardAbility are rendered dynamically.

    One of the weirdest thing about all this is that you can't render the contents of an objectReferenceValue using PropertyField, you have to construct a SerializedObject first like this:

    SerializedObject nestedObject = new SerializedObject(element.objectReferenceValue);

    Thanks to Unity: Inspector can't find field of ScriptableObject for that tip.

    Some other great resources for ReorderableList:

    // CardData.cs
    [CreateAssetMenu(fileName = "CardData", menuName = "Card Game/CardData", order = 1)]
    public class CardData : ScriptableObject
    {
        public enum CardType
        {
            Attack,
            Skill
        }
        public CardType type;
        public Sprite image;
        public string description;
    
        // XXX: Hidden in inspector because it will be drawn by custom Editor.
        [HideInInspector]
        public CardAbility[] onPlayed;
    }
    
    // CardAbility.cs
    public abstract class CardAbility : ScriptableObject
    {
        public abstract void Resolve();
    }
    
    // DrawCards.cs
    public class DrawCards : CardAbility
    {
        public int numCards = 1;
        public override void Resolve()
        {
            Deck.instance.DrawCards(numCards);
        }
    }
    
    // HealPlayer.cs
    public class HealPlayer : CardAbility
    {
        public int healAmount = 10;
        public override void Resolve()
        {
            Player.instance.Heal(healAmount);
        }
    }
    
    // CardDataEditor.cs
    [CustomEditor(typeof(CardData))]
    [CanEditMultipleObjects]
    public class CardDataEditor : Editor
    {
        private ReorderableList abilityList;
    
        private SerializedProperty onPlayedProp;
    
        private struct AbilityCreationParams {
            public string Path;
        }
    
        public void OnEnable()
        {
            onPlayedProp = serializedObject.FindProperty("onPlayed");
    
            abilityList = new ReorderableList(
                    serializedObject, 
                    onPlayedProp, 
                    draggable: true,
                    displayHeader: true,
                    displayAddButton: true,
                    displayRemoveButton: true);
    
            abilityList.drawHeaderCallback = (Rect rect) => {
                EditorGUI.LabelField(rect, "OnPlayed Abilities");
            };
    
            abilityList.onRemoveCallback = (ReorderableList l) => {
                var element = l.serializedProperty.GetArrayElementAtIndex(l.index); 
                var obj = element.objectReferenceValue;
    
                AssetDatabase.RemoveObjectFromAsset(obj);
    
                DestroyImmediate(obj, true);
                l.serializedProperty.DeleteArrayElementAtIndex(l.index);
    
                AssetDatabase.SaveAssets();
                AssetDatabase.Refresh();
                
                ReorderableList.defaultBehaviours.DoRemoveButton(l);
            };
    
            abilityList.drawElementCallback = (Rect rect, int index, bool isActive, bool isFocused) => {
                SerializedProperty element = onPlayedProp.GetArrayElementAtIndex(index);
    
                rect.y += 2;
                rect.width -= 10;
                rect.height = EditorGUIUtility.singleLineHeight;
    
                if (element.objectReferenceValue == null) {
                    return;
                }
                string label = element.objectReferenceValue.name;
                EditorGUI.LabelField(rect, label, EditorStyles.boldLabel);
    
                // Convert this element's data to a SerializedObject so we can iterate
                // through each SerializedProperty and render a PropertyField.
                SerializedObject nestedObject = new SerializedObject(element.objectReferenceValue);
    
                // Loop over all properties and render them
                SerializedProperty prop = nestedObject.GetIterator();
                float y = rect.y;
                while (prop.NextVisible(true)) {
                    if (prop.name == "m_Script") {
                        continue;
                    }
    
                    rect.y += EditorGUIUtility.singleLineHeight;
                    EditorGUI.PropertyField(rect, prop);
                }
    
                nestedObject.ApplyModifiedProperties();
    
                // Mark edits for saving
                if (GUI.changed) {
                    EditorUtility.SetDirty(target);
                }
    
            };
    
            abilityList.elementHeightCallback = (int index) => {
                float baseProp = EditorGUI.GetPropertyHeight(
                    abilityList.serializedProperty.GetArrayElementAtIndex(index), true);
    
                float additionalProps = 0;
                SerializedProperty element = onPlayedProp.GetArrayElementAtIndex(index);
                if (element.objectReferenceValue != null) {
                    SerializedObject ability = new SerializedObject(element.objectReferenceValue);
                    SerializedProperty prop = ability.GetIterator();
                    while (prop.NextVisible(true)) {
                        // XXX: This logic stays in sync with loop in drawElementCallback.
                        if (prop.name == "m_Script") {
                            continue;
                        }
                        additionalProps += EditorGUIUtility.singleLineHeight;
                    }
                }
    
                float spacingBetweenElements = EditorGUIUtility.singleLineHeight / 2;
    
                return baseProp + spacingBetweenElements + additionalProps;
            };
    
            abilityList.onAddDropdownCallback = (Rect buttonRect, ReorderableList l) => {
                var menu = new GenericMenu();
                var guids = AssetDatabase.FindAssets("", new[]{"Assets/CardAbility"});
                foreach (var guid in guids) {
                    var path = AssetDatabase.GUIDToAssetPath(guid);
                    var type = AssetDatabase.LoadAssetAtPath(path, typeof(UnityEngine.Object));
                    if (type.name == "CardAbility") {
                        continue;
                    }
    
                    menu.AddItem(
                        new GUIContent(Path.GetFileNameWithoutExtension(path)),
                        false,
                        addClickHandler,
                        new AbilityCreationParams() {Path = path});
                }
                menu.ShowAsContext();
            };
        }
    
        private void addClickHandler(object dataObj) {
            // Make room in list
            var data = (AbilityCreationParams)dataObj;
            var index = abilityList.serializedProperty.arraySize;
            abilityList.serializedProperty.arraySize++;
            abilityList.index = index;
            var element = abilityList.serializedProperty.GetArrayElementAtIndex(index);
    
            // Create the new Ability
            var type = AssetDatabase.LoadAssetAtPath(data.Path, typeof(UnityEngine.Object));
            var newAbility = ScriptableObject.CreateInstance(type.name);
            newAbility.name = type.name;
    
            // Add it to CardData
            var cardData = (CardData)target;
            AssetDatabase.AddObjectToAsset(newAbility, cardData);
            AssetDatabase.SaveAssets();
            element.objectReferenceValue = newAbility;
            serializedObject.ApplyModifiedProperties();
        }
    
        public override void OnInspectorGUI()
        {
            serializedObject.Update();
    
            DrawDefaultInspector();
    
            abilityList.DoLayoutList();
    
            if (GUILayout.Button("Delete All Abilities")) {
                var path = AssetDatabase.GetAssetPath(target);
                Object[] assets = AssetDatabase.LoadAllAssetRepresentationsAtPath(path);
                for (int i = 0; i < assets.Length; i++) {
                    if (assets[i] is CardAbility) {
                        Object.DestroyImmediate(assets[i], true);
                    }
                }
                AssetDatabase.SaveAssets();
            }
    
            serializedObject.ApplyModifiedProperties();
        }
    }