构建一个编辑器,用于嵌套ScriptableObjects的组合能力,在卡牌游戏中使用。

8
我正在构建一款卡牌游戏,希望卡牌能够有一个干净的能力架构。 我创建了一个CardData ScriptableObject来存储每张卡牌的属性。我想让卡牌能力组合在一起,描述卡牌的功能,比如一张名为DrawAndHealCard的卡牌,当玩家打出它时,抽2张牌并恢复5点生命值。
我意识到这意味着我需要为每个CardAbility的变体创建一个具体的资源。所以DrawAndHealCard引用了两个资源:DrawCards2HealPlayer5。这很荒谬,我希望所有数据都感觉像是在单个DrawAndHealCard上。
所以我了解了AssetDatabase.AddObjectToAsset(),这似乎是正确的方法,我可以将能力作为CardData资产的子资产,并且不必处理所有这些独立的资产的组织。所以现在我正在尝试构建一个编辑器来管理它,但进展缓慢。
我已经阅读了大量关于Unity序列化、SOs、Editor scripts等方面的材料... 真的在这方面遇到了困境,甚至要降级到在架构上感觉不那么优雅的东西。如果有更好的方法来做到这一点,我也愿意听取完全不同的建议。
下面的代码被简化了,但它是我正在努力解决的问题的核心。目前我所在的地方是onAddCallback似乎正确地添加了子资源,但onRemoveCallback则不能删除它。然而,我的 Clear All Abilities 按钮确实可以工作。我找不到任何关于这些材料的好文档或指南,所以我现在很迷茫。
// 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();
    }
}

2
我不明白你的问题。你遇到了什么问题?你的问题没有具体说明你在编辑器方面遇到了什么麻烦。 - Hayden
@Hayden 在最后,"Where I'm at right now..." 那是我卡住的具体位置,无法删除子资产。但这个问题的本质是我不知道自己不知道什么,所以高层次的指导也会很受欢迎。 - ack
3个回答

11

好的,我终于搞清楚了。我看了一百个堆栈溢出和论坛帖子,试图理解这一点,所以我正在向前付款,希望这可以帮助其他人导航。这将产生一个像下面图片中的编辑器,其中OnPlayed是多态ScriptableObjects的数组。这些CardAbility SO存储为拥有ScriptableObject(CardData)的子资产。这里仍有更多要清理的地方,它可能会更通用,但对于其他人尝试做到这一点应该是一个很好的开始。

inspector

[+]按钮会生成所有可添加的CardAbility SO列表。并且具体CardAbility的属性会动态呈现。
其中最奇怪的事情之一是,您无法使用PropertyField来呈现objectReferenceValue的内容,您必须首先构建一个SerializedObject,像这样: SerializedObject nestedObject = new SerializedObject(element.objectReferenceValue); 感谢Unity:检查器找不到ScriptableObject字段提供的提示。
一些其他很好的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();
    }
}

1
你对于加载实际的父级ScriptableObject做了什么?我正在尝试做类似的事情,但是遇到了一个问题,当父对象是一种类型时,三个当前子对象是不同类型的,所以尝试通过以下方式进行加载:AssetDatabase.FindAssets($"t:{apcType}") .Select(guid => AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(guid))) .FirstOrDefault(x => x is ActionPanelContainer) as ActionPanelContainer; 并没有起作用。 - MostHated

2

如果没有完整的项目在面前,编辑器脚本编写总是很棘手的。初步看来,我会说您正在将资源添加到资产中但未使用 AssetDatabase.RemoveObjectFromAsset 将它们删除。

您应该做一些类似于以下操作:

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

我认为您甚至不需要使用,正如评论中所述。

AssetDatabase.SaveAssets();

如果没有它,资产将被标记为脏,并会在下一次按下CTRL+S时与场景一起保存。

然而,据我所知,如果您这样做,应始终与AssetDatabase.Refresh();结合使用,以实际查看所做更改反映在资产视图中。


感谢@derHugo。问题在于objectReferenceValue为空。我将该行拆分为返回的值:https://pastebin.com/s5mtJe3E据我所知,l.index是正确的,它是在“ReorderableList”中被删除元素的索引。 - ack
啊,是的,那么现在我明白问题所在了!问题可能是onRemoveCallback在元素被“删除”后才被调用。事实上,如果对象引用不为null ->只会删除引用但保留元素位置。如果引用已经是null->实际上删除元素。......我怀疑解决方法就是要实现自己的删除项目按钮方法(例如,在onDrawElement中添加一个X按钮->点击时正确地销毁和删除对象), 无法绕过这个问题。 - derHugo
再次感谢@derHugo,他让我朝着正确的方向前进,我用我的完整编辑器回答了问题。 回想起来,我实际上不确定为什么objectReferenceValue在这里为空,可能是我在这里创建它们的方式不对。 但是我必须使用调试器进行深入探索才能理解这一点。 我还需要成功删除一件事是告诉ReorderableList调用自己的删除逻辑:ReorderableList.defaultBehaviours.DoRemoveButton(l); - ack

0

Ack已经解决了这个问题。

我没有足够的声望来评论,但我想帮助你修复“删除所有技能”按钮中的一个错误。

您需要添加stateItemList.serializedProperty.ClearArray();如下所示。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接