8

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();
    }
}
ack
  • 14,285
  • 22
  • 55
  • 73
  • 2
    I don't understand the question. What are you hitting a wall with? Your question doesn't highlight any specifics about what trouble you're having with the editor. – Hayden Jul 07 '20 at 06:44
  • @Hayden It's at the end, "Where I'm at right now..." That's a concrete place where I'm stuck, unable to remove sub-assets. But the nature of this is I don't know what I don't know, so high level direction is appreciated too. – ack Jul 07 '20 at 15:27

3 Answers3

11

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();
    }
}
ack
  • 14,285
  • 22
  • 55
  • 73
  • 1
    What did you do about loading the actual parent ScriptableObject? I am trying to do something similar, but keep running into an issue where all 4 are being thought to be of the same type, when the parent object is of one type, the three current children are of a different type so trying to load via: AssetDatabase.FindAssets($"t:{apcType}") .Select(guid => AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(guid))) .FirstOrDefault(x => x is ActionPanelContainer) as ActionPanelContainer; is not working. – MostHated Oct 26 '20 at 09:03
2

Not sure since Editor scripting is always quite tricky if you don't have a full project in front of you.

On first glance I would say you are adding the assets to the asset but you do not remove them using AssetDatabase.RemoveObjectFromAsset

You should probably do something like

abilityList.onRemoveCallback = (ReorderableList l) =>
{
    // Update should be redundant here since you already call it anyway on beginning of the draw loop

    // Are you also sure the `l.index` is the correct value to use here?
    var obj = l.serializedProperty.GetArrayElementAtIndex(l.index).objectReferenceValue;

    AssetDatabase.RemoveObjectFromAsset(obj);

    DestroyImmediate(obj, true);
    l.serializedProperty.DeleteArrayElementAtIndex(l.index);
    
    // If you do save assets also refresh
    // Not sure if you should even do that though to be honest
    AssetDatabase.SaveAssets();
    // Also refresh here
    AssetDatabase.Refresh();

    // Also ApplyModifiedProperties should be redundant 
};

also as commented I think you wouldn't even have to use

AssetDatabase.SaveAssets();

without it the assets would simply be marked as dirty and be saved along with the scene on the next CTRL+S press.

However, afaik, if you do it you should always combine it with AssetDatabase.Refresh(); to actually see the changes made reflected in the assets view

derHugo
  • 83,094
  • 9
  • 75
  • 115
  • Thanks @derHugo. The problem is that `objectReferenceValue` is null. I broke down that line into values being returned: https://pastebin.com/s5mtJe3E AFAIK `l.index` is correct, it's the index of the element being removed in the `ReorderableList`. – ack Jul 07 '20 at 15:39
  • Ah yes then I now the issue! The problem is probably that `onRemoveCallback` is called **after** the element has been "removed" once. The thing is that if the object reference is not null -> only removes the reference but keeps element in place. If the reference is already null -> actually deletes the element. ... I suspect there is no way around it then implementing your own button method for removing items (e.g. within the onDrawElement -> have an X button -> on click correctly destroy and remove the object) – derHugo Jul 07 '20 at 16:16
  • Thanks again @derHugo, this set me in the right direction, I answered with my complete Editor. In retrospect, I'm not actually sure why objectReferenceValue was null here, I must have been creating them the wrong way here. But I had to do extensive spelunking with the debugger to understand this. One thing I also needed to remove successfully was to tell ReorderableList to call it's own remove logic: `ReorderableList.defaultBehaviours.DoRemoveButton(l);` – ack Jul 11 '20 at 06:08
0

Ack did resolve the issue.

I don't have enough reputation to comment, but I wanted to help fix a bug for you in the Delete All Abilities button

You needed to add stateItemList.serializedProperty.ClearArray(); see below.

        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 StateItemConfig)
                {
                    Object.DestroyImmediate(assets[i], true);
                    
                }
            }

            // You needed to add this line here otherwise it keeps destroyed objects in the array.
            stateItemList.serializedProperty.ClearArray();
            AssetDatabase.SaveAssets();
        }