为嵌套的 ScriptableObjects 构建编辑器以在纸牌游戏中组合能力 [英] Building an Editor for nested ScriptableObjects to compose abilities in a card game

查看:26
本文介绍了为嵌套的 ScriptableObjects 构建编辑器以在纸牌游戏中组合能力的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我正在构建一个纸牌游戏,我想要一个干净的纸牌能力架构.我有一个带有卡片属性的 CardData ScriptableObject.我希望将卡牌能力组合在一起来描述卡牌的作用,例如一张名为 DrawAndHealCard 的卡牌,它在打出 2 张卡牌时可以治疗 5 点生命值.

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.

我马上意识到这意味着我需要为 CardAbility 的每个变体提供一个具体的资产.所以 DrawAndHealCard 引用了两个资产:DrawCards2HealPlayer5.这太荒谬了,我希望所有数据都像在一张 DrawAndHealCard 上一样.

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.

所以我了解了AssetDatabase.AddObjectToAsset(),这似乎是正确的想法,我可以拥有作为 CardData 资产的子资产的能力,而无需处理所有这些的组织单独的资产.所以现在我正在尝试构建一个 Editor 来管理它,这很痛苦.

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.

我已经阅读了很多关于 Unity 序列化、SO、编辑器脚本的内容,...严重地遇到了这个问题,并且即将降级到在架构上感觉不那么优雅的东西.如果有更好的方法来做到这一点,我也愿意接受关于完全不同路线的建议.

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.

下面的代码被精简了,但这是我想要弄清楚的要点.我现在所处的位置是 onAddCallback 似乎正确添加了一个子资产,但 onRemoveCallback 没有删除它.但是,我的清除所有能力按钮确实有效.我找不到关于这些东西的任何好的文档或指南,所以我现在很迷茫.

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

推荐答案

好吧,我终于想通了.我阅读了一百篇堆栈溢出和论坛帖子试图理解这一点,所以我正在支付它,希望这可以帮助其他人解决这个问题.这会生成一个如下图所示的编辑器,其中 OnPlayed 是一个多态 ScriptableObjects 数组.这些 CardAbility SO 作为子资产存储在拥有的 ScriptableObject (CardData) 上.这里还有更多要清理的地方,它可以变得更通用,但对于尝试这样做的其他人来说应该是一个好的开始.

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.

[+] 按钮会生成可添加的所有 CardAbility SO 的列表.并且具体 CardAbility 的属性是动态呈现的.

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.

关于这一切最奇怪的事情之一是您无法使用 PropertyField 呈现 objectReferenceValue 的内容,您必须构造一个 SerializedObject 先是这样:

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:

SerializedObjectnestedObject = new SerializedObject(element.objectReferenceValue);

感谢 Unity:Inspector 找不到 ScriptableObject 的字段 那个提示.

ReorderableList 的其他一些重要资源:

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

这篇关于为嵌套的 ScriptableObjects 构建编辑器以在纸牌游戏中组合能力的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆