UnityEditor簡單介紹及案例


寫在最前

因為這一內容的東西實在是太多了,能更一點是一點,最初的更新可能沒有什么學習順序,后續內容逐漸完整后會重新排版

本文暫時停止更新 對話編輯器的代碼放在了Github

其他和編輯器有關的代碼也可以翻此項目,雖然個人感覺有點臭,日后再優化

自定義Inspector窗口

自定義編輯器腳本的創建

  • 編輯器腳本需要置於Editor文件夾下方,類似資源讀取的文件需要存放在Resources下方
  • 編輯器腳本的命名規則一般為:所編輯類名 + Editor,例如當我需要自定義類StateMachine的Inspector窗口時,我將在Editor目錄下創建StateMachineEditor.cs
  • 添加Attribute:[CustomEditor(typeof(T))]。目的是告知編輯器類該編輯器所針對的運行時類型,此例中,需要告訴編輯器我們想要修改類StateMachine的Inspector窗口,故TStateMachine
  • 使類StateMachineEditor繼承類Editor
// StateMachineEditor.cs
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(StateMachine))]
public class StateMachineEditor : Editor
{
    public override void OnEnable() {}
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
    }
}

OnEnable()函數將在每次查看對應的Inspector時被調用,故可用來初始化編輯器所需要的變量。常用的有兩種類型的變量

private StateMachine selectMachine;
private SerializedProperty property1;
private SerializedProperty property2;
// ...

本例中selectMachine用於存取編輯器獲得的需要編輯的類

private void OnEnable()
{
    selectMachine = target as StateMachine;
    if (selectMachine == null)
    {
        Debug.LogError("Editor Error: Can not translate selectMachine");
    }
}

target是繼承的Editor類中的一個字段(get)。本身為Object類型,在拆箱后可以轉換成上文Attribute:CustomEditor中的T,用來顯示類中原本不會被繪制到Inspector窗口的信息,達到自定義的功能

public override void OnInspectorGUI()
{
    // 之前
    base.OnInspectorGUI();
    // 之后
}

base.OnInspectorGUI()會執行默認Inspector信息的繪制,若去除Inspector面板將空無一物。可以根據項目需求,選擇將自定義代碼寫在”之前“或”之后“的位置,本例中將代碼寫在”之后“,也就是說新自定義添加的屬性,將在Inspector的底部被繪制

若要完全自定義Inspector面板,通常會選擇不調用基類的函數,直接重寫整個面板的繪制代碼

初級API - EditorGUILayout

EditorGUILayout用於在Inspector面板上繪制信息

EditorGUILayout.Space();

用於在面板中生成一小段間隙,功能可以類比Attribute:[Space]

EditorGUILayout.LabelField("");

用於在面板中生成一串文字

EditorGUILayout.BeginHorizontal();
// Codes...
EditorGUILayout.EndHorizontal();

在上述代碼范圍內開啟一段水平空間,在此范圍內的信息將被繪制在同一行

EditorGUILayout.BeginVertical();
// Codes...
EditorGUILayout.EndVertical();

在上述代碼范圍內開啟一段垂直空間,在此范圍內的信息將被繪制在同一列

更多相關API參考:EditorGUILayout

完整效果

// StateMachineEditor.csusing UnityEngine;using UnityEditor;[CustomEditor(typeof(StateMachine))]public class StateMachineEditor : Editor{    public override void OnEnable()    {        selectMachine = target as StateMachine;        if (selectMachine == null)        {            Debug.LogError("Editor Error: Can not translate selectMachine");        }    }    public override void OnInspectorGUI()    {        base.OnInspectorGUI();        EditorGUILayout.Space();        EditorGUILayout.BeginHorizontal();        EditorGUILayout.LabelField("Current StateName:");        EditorGUILayout.LabelField(selectMachine.CurrentState.StateName);        EditorGUILayout.EndHorizontal();    }}

成果如下,在底部生成了一串文字標簽,顯示當前狀態的名字(當前無狀態,故為Null)

漫漫談

眾所周知,Dictionary是無法被序列化顯示在Inspector面板中的。直接上編輯器代碼,簡陋的顯示字典的KeyValue

using UnityEditor;[CustomEditor(typeof(DialogueSO))]public class DialogueSOEditor : Editor{    private DialogueSO _selectSO;    private bool _showDictionary = true;    private string _statusStr = "節點字典";    private void OnEnable()    {        _selectSO = target as DialogueSO;    }        public override void OnInspectorGUI()    {        base.OnInspectorGUI();        // 開啟可折疊區域        _showDictionary = EditorGUILayout.BeginFoldoutHeaderGroup(_showDictionary, _statusStr);        // 若打開折疊        if (_showDictionary)        {            HorizontalLabel("Key", "Value");            // 遍歷字典 顯示Key與Value            foreach (var nodePair in _selectSO.NodeDic)            {                HorizontalLabel(nodePair.Key, nodePair.Value.Content);            }        }        // 結束可折疊區域        EditorGUILayout.EndFoldoutHeaderGroup();    }    private void HorizontalLabel(string leftStr, string rightStr)    {        EditorGUILayout.BeginHorizontal();        EditorGUILayout.LabelField(leftStr);        EditorGUILayout.LabelField(rightStr);        EditorGUILayout.EndHorizontal();    }}

實現效果

自定義EditorWindow

為講GraphView做鋪墊,這里先說一下如何創建EditorWindow

創建窗口

寫代碼前應該先明確應該實現什么功能

  • 在上拉菜單中創建選項,點擊可以打開編輯窗口
  • 雙擊Project文件目錄中對應的資源文件時也能打開編輯窗口
using UnityEditor;public class DialogueEditorWindow : EditorWindow{    [MenuItem("Window/Dialogue")]    public static void ShowDialogueEditorWindow()    {        // 打開或創建窗口 命名為DialogueWindow        GetWindow<DialogueEditorWindow>(false, "DialogueWindow");    }        [OnOpenAsset(1)]    public static bool OnDoubleClickDialogueAsset(int instanceID, int line)    {        // 檢測打開資源        DialogueSO openAsset = EditorUtility.InstanceIDToObject(instanceID) as DialogueSO;        // 打開資源則打開編輯窗口        if (openAsset)        {            ShowDialogueEditorWindow();            return true;        }        return false;    }}

GetWindow函數為EditorWindow里的一個靜態成員函數,它接收兩個參數:boolstring,執行后會檢測窗口是否已經存在(若不存在則創建)然后將其返回

  • 第一個參數決定此窗口是浮動窗口(true)或是正常窗口(false)。若要創建像是Scene,Game,Inspector這種類型的窗口,則設置為false。若設置為true,窗口看上去便像是一個應用程序(參照項目以小窗模式Build后的樣式)

    浮動窗口 ↓

    正常窗口 ↓

  • 第二個參數決定窗口的名字,如上述截圖窗口的左上角

Attribute:[OnOpenAsset(X)],用於打開Unity中的某個資源的回調,回調需要滿足兩個條件

  • 為靜態函數
  • 返回值為bool,函數參數為兩個int類型的參數(有一個三參數的版本,但幾乎沒用過,所以不介紹)

回調函數的用法可以參照之前的代碼,這里需要注意的是屬性中的X(在上面的代碼中為1)。這里的X其實是執行順序的意思

[OnOpenAsset(1)]public static bool OnDoubleClickDialogueAssetOne(int instanceID, int line) {}[OnOpenAsset(2)]public static bool OnDoubleClickDialogueAssetTwo(int instanceID, int line) {}[OnOpenAsset(3)]public static bool OnDoubleClickDialogueAssetThree(int instanceID, int line) {}// 當雙擊資源文件時,函數的執行順序為OnDoubleClickDialogueAssetOne -> OnDoubleClickDialogueAssetTwo -> OnDoubleClickDialogueAssetThree// 若執行中有任意一個返回了true,則在其順序之后的回調函數都不會被執行

而返回值bool,代表我們是否已經處理了資源的打開操作,若已處理則返回true。舉個例子,當我們雙擊的是.txt格式的文件時,若我們代碼返回true,等同於告訴Unity:我們已經完成相應的處理了,你不需要執行什么打開操作;相反若返回false,則相當於把資源處理權交給Unity —— 結果是該.txt文件在你的代碼編輯器中被打開(Rider,VSC...)

自定義GraphView

UIElements

有句話是這么說的

Use the new UI Toolkit to create UIElements with the UI Builder

這里扔幾篇學習博客和官方文檔

因為UI Toolkit是2020新推出的編輯UI的工具,目前我仍未能搞懂他們之間的關聯,故先從UIElements開始學起

按照Unity以往的邏輯,在runtime時使用的時NGUI,UGUI,FairyGUI等,在編輯器中用IMGUI。在2019年的時候,UIElements主要用於解決拓展編輯器的問題,欲以保留模式代替IMGUI的即時模式。現如今,隨着UI Toolkit的推出,UIElements也適用於runtime環境。官方曾說:UIElement將成為UI未來主要的工作方式,但短時間內UGUI仍會保持更新(Unity 2019.1 - In its current form, it’s a tool that makes it easier for you to extend the Unity Editor. In-game support and visual authoring will come in future releases.)本篇文章暫時專注於將UIElements在編輯器的運用

在后續的代碼中會不斷的介紹UIElements的入門使用

創建節點編輯器窗口

根據之前自定義編輯器窗口的經驗,先寫出窗口創建的代碼,代碼不長,下面會分析部分新出現的API

using UnityEngine;using UnityEditor;using UnityEditor.Callbacks;using UnityEditor.UIElements;using UnityEngine.UIElements;namespace RPG.DialogueSystem.Graph{    public class DialogueGraphEditorWindow : EditorWindow    {        private DialogueGraphSO _selectSO;          // 對話SO        private DialogueGraphView _selectView;      // 對話節點編輯器窗口        private Label _selectSONameLabel;           // 當前對話SO顯示標簽        [MenuItem("Window/DialogueGraph")]        private static DialogueGraphEditorWindow ShowDialogueGraphWindow()        {            DialogueGraphEditorWindow window = GetWindow<DialogueGraphEditorWindow>(false, "DialogueGraph");            window.minSize = new Vector2(400, 300);            return window;        }        /// <summary>        /// 雙擊打開資源        /// </summary>        /// <param name="instanceID">資源ID</param>        /// <param name="line"></param>        /// <returns>處理結果</returns>        [OnOpenAsset(0)]        private static bool OnDoubleClickAsset(int instanceID, int line)        {            DialogueGraphSO selectSO = EditorUtility.InstanceIDToObject(instanceID) as DialogueGraphSO;            if (selectSO == null) return false;            DialogueGraphEditorWindow window = ShowDialogueGraphWindow();            // OnOpenAsset回調不包含Selection Change            window.Load(selectSO);            return true;        }        /// <summary>        /// 單擊資源        /// </summary>        private void OnClickAsset()        {            // 重新繪制編輯器界面            Load(Selection.activeObject as DialogueGraphSO);        }        /// <summary>        /// 加載對話SO        /// </summary>        /// <param name="selectSO">對話SO</param>        private void Load(DialogueGraphSO selectSO)        {            if (selectSO == null) return;                        _selectSO = selectSO;            // 刷新窗口上端Label顯示            _selectSONameLabel.text = _selectSO == null ? "當前無選擇物體" : $"所選物體為: {_selectSO.name}";        }                private void OnEnable()        {            // 添加單擊資源監聽            Selection.selectionChanged += OnClickAsset;                        // 先創建窗口組件(Toolbar)            CreateWindowComponents();            // 再創建對話節點編輯器界面            CreateDialogueGraphView();        }        private void OnDisable()        {            // 移除單擊資源監聽            Selection.selectionChanged -= OnClickAsset;        }                private void CreateWindowComponents()        {            // 創建各個組件            Toolbar windowToolbar = new Toolbar();            Button saveButton = new Button();            _selectSONameLabel = new Label();                        // 傳統藝能            saveButton.text = "Save";            saveButton.clicked += delegate { Debug.Log("Save Button Clicked"); };                        // 設置頂部信息顯示欄Style            StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("XX/DialogueGraphViewSheet.uss");             rootVisualElement.styleSheets.Add(styleSheet);                        // 將Button加入Toolbar中            windowToolbar.Add(saveButton);            // 將Label加入Toolbar中            windowToolbar.Add(_selectSONameLabel);            // 將Toolbar加入窗口繪制中            rootVisualElement.Add(windowToolbar);        }        private void CreateDialogueGraphView()        {            // 往窗口中添加GraphView            _selectView = new DialogueGraphView(this)            {                style = {flexGrow = 1}            };            // 將節點編輯器加入窗口繪制中            rootVisualElement.Add(_selectView);        }    }}

保留模式

在代碼中使用到的LabelButtonToolbar都是UIElement,這些元素都是VisualElement的派生類。整個UIElements的基本構建塊都是VisualElement。各個VisualElement以使用者規定的順序排列,組成一定的UI層級結構(通過AddInsert等操作完成),最后布局,樣式等系統會遍歷這個層次結構,然后將UI繪制到屏幕上

在EditorWindow中,有一個類成員rootVisualElement,它代表窗口的根VisualElement,我們需要將需要繪制的元素添加至此父根。在上述代碼中,根節點的子元素為ToolbarGraphViewToolbar的子元素為ButtonLabel。上述UI的創建都是在OnEnable()中進行的,若以傳統IMGUI的工作方式來制作,需要在OnGUI()也就是每幀繪制中去指定UI的繪制,這代表UI層級結構需要在每幀中被指定或者被修改,不僅系統難以優化,性能也降低了。從上述例子應該能大概體會到保留模式和即使模式的區別

style與styleSheets

上述例子中提到了兩種樣式的設置方法:對DialogueGraphView采用C#對屬性進行賦值的方式,對窗口的rootVisualElement采用了靜態設置的方法(讀取USS),兩種方法效果相同,但由於大多數UI的樣式都是靜態的,不需要運行時賦值(或設置),因此推薦使用解析USS資源文件的方式來設置UI樣式(UIBuilder也是通過寫入USS文件來改變樣式)。UIElements將.uss的資源文件解析為StyleSheet類,通過一系列添加操作加入到應用為UI樣式

USS的語法與CSS相同,若不知道屬性的名稱以及功能,可以百度查看CSS的語法或查看UIElements.IStyle,下面先介紹USS的簡單配置

styleSheets - 使用C#格式設置

Label{    -unity-text-align: middle-center;	// 字體格式居中對齊    color: white;						// 字體顏色為白色}
// 通過Label類型直接進行設置 style應用到rootVisualElement下的所有子Label組件StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("XX/DialogueGraphViewSheet.uss"); rootVisualElement.styleSheets.Add(styleSheet);

styleSheets - 使用類名設置

._selectSONameLabelSheet{    -unity-text-align: middle-center;	// 字體格式居中對齊    color: white;						// 字體顏色為白色}
// 通過類名進行設置 只有進行AddToClassList操作的組件才會應用此style
StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("XX/DialogueGraphViewSheet.uss"); 
rootVisualElement.styleSheets.Add(styleSheet);
_selectSONameLabel.AddToClassList("_selectSONameLabelSheet");

styleSheets - 使用Element Name設置

不會,下次一定

style直接設置(不推薦)

// 居中對齊
_selectSONameLabel.style.unityTextAlign = new StyleEnum<TextAnchor>(TextAnchor.MiddleCenter);
// 文字顏色為白色
_selectSONameLabel.style.color = new StyleColor(Color.white);

UI樣式的應用

上文說過,用USS和直接設置style的效果相同。UI的最終樣式(m_Element)由二者決定

從下圖可以看出,UI的對齊方式被設置為MiddleCenter(居中對齊)

需要注意,m_Element中的UI樣式並不是立即被賦值

  • 若通過style更改,在更改結束后m_Element也會同步更改
  • 若通過styleSheets(本例中是在OnEnable()中更改),則m_Element的值將會被延遲到OnEnable()后的第一次OnGUI()被更改

故,不管是USS還是賦值style,他們都是最終都是更改到m_Element中的UI樣式,然后繪制到屏幕上。暫時就先說這么多,更多內容可以查看相關博客或查閱后續代碼

額外補充

在創建對話節點編輯器的時候,選擇了設置flexGrow = 1

IStyle.flexGrow - Specifies how much the item will grow relative to the rest of the flexible items inside the same container.

大概意思就是指定該元素能夠在窗口剩下的空間內的填充比例

private void CreateDialogueGraphView(){    // 往窗口中添加GraphView    _selectView = new DialogueGraphView(this)    {        style = {flexGrow = 1},    };    // _selectView.StretchToParentSize();    // 將節點編輯器加入窗口繪制中    rootVisualElement.Add(_selectView);}

指定flexGrow為1與0.5的區別見下圖

若使用StretchToParentSize()強制縮放至父類大小,節點編輯器則會填充至整個窗口(這是將會根據節點編輯器窗口與Toolbar的繪制先后順序,造成UI層級的遮擋),而非排在Toolbar下方

創建對話節點編輯器

using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;

namespace RPG.DialogueSystem.Graph
{
    public class DialogueGraphView : GraphView
    {
        private DialogueGraphEditorWindow _editorWindow;
        public DialogueGraphView(DialogueGraphEditorWindow editorWindow)
        {
            _editorWindow = editorWindow;
            
            // 設置節點拖拽
            var dragger = new SelectionDragger()
            {
                // 不允許拖出邊緣
                clampToParentEdges = true
            };
            // 其他按鍵觸發節點拖拽
            dragger.activators.Add(new ManipulatorActivationFilter()
            {
                button = MouseButton.RightMouse,
                clickCount = 1,
                modifiers = EventModifiers.Alt
            });
            // 添加節點拖拽
            this.AddManipulator(dragger);
            
            // 設置界面縮放
            SetupZoom(ContentZoomer.DefaultMinScale, 2);
            // this.AddManipulator(new ContentZoomer());
            
            // 設置創建節點回調
            nodeCreationRequest += (info) =>
            {
                AddElement(new DialogueGraphNodeView());
            };
            
            // 添加界面移動
            this.AddManipulator(new ContentDragger());
            // 添加舉行選擇框
            this.AddManipulator(new RectangleSelector());
            
            // 創建背景
            Insert(0, new GridBackground());
        }
    }
}

添加操控器

通過拓展API:AddManipulator(UIElements.IManipulator manipulator)來添加操控器。常見的有:節點拖拽,界面移動以及界面縮放。更多操控器及功能可以查閱相關文檔

// 其他按鍵觸發節點拖拽
dragger.activators.Add(new ManipulatorActivationFilter()
{
	button = MouseButton.RightMouse,
	clickCount = 1,
	modifiers = EventModifiers.Alt
});

操控器還可以設置其他的鍵位觸發,例如除了鼠標左鍵單擊外,我再設置 Alt+鼠標右鍵單擊 拖拽節點

添加背景

Insert而不用Add的原因是:背景應位於UI元素的最底層,若通過Add操作,則會將背景生成在對話節點編輯器之上,導致背景遮擋住了節點編輯器上的所有元素(注意區分AddAddElement的層級關系,前者是VisualElement,后者是GraphElement,后者是位於GraphView上的)

// 創建背景
Insert(0, new GridBackground());

增加右鍵面板菜單選項

添加類型為Action<NodeCreationContext>的回調,至於NodeCreationContext類型有啥用,我暫時還沒搞清楚,不過目前不需要用到那就先這樣吧

// 設置創建節點回調
nodeCreationRequest += (info) =>
{
    AddElement(new DialogueGraphNodeView());
};

通過AddElement(GraphElement graphElement)來往對話節點編輯器中添加元素,這里添加的是node

創建對話節點

using UnityEditor.Experimental.GraphView;

namespace RPG.DialogueSystem.Graph
{
    public class DialogueGraphNodeView : Node
    {
        public DialogueGraphNodeView()
        {
            // 節點標題
            title = "Dialogue GraphNode";
            // 創建入連接口
            var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, typeof(Port));
            inputPort.portName = "Parents";
            inputContainer.Add(inputPort);
            // 創建出連接口
            var outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
            outputPort.portName = "Children";
            outputContainer.Add(outputPort);
        }
    }
}

摸了

Undo坑點

Undo只能記錄能被序列化的變量

一般的來講,和變量能否被序列化掛鈎的有兩個Attribute:[System.Serializable][SerializeField]

using UnityEngine;
using UnityEditor;
[System.Serializable]
public class Person
{
    public int age;
    public int height;
}

public class Children : MonoBehaviour
{
    [SerializeField] private Person p1;
    private Person p2;

    [ContextMenu("UndoTest")]
    private void UndoTest()
    {
        Undo.RecordObject(this, "Change Info");
        // 賦值操作
        p1.age = 100;
        p1.height = 1000;
        p2.age = 200;
        p2.height = 2000;
    }
}

當完成賦值操作后,按下Ctrl + Z進行撤銷:只有p1的屬性被還原為修改前的狀態,而p2保持不變

繼承自UnityEngine.Object的對象都是可序列化的

像是ScriptableObject或者是各種Unity自帶的組件,又或是繼承MonoBehaviour的類等,都可用於Undo記錄撤銷操作。

當寫在ScriptableObjectMonoBehaviour類中的變量,記錄撤銷操作時:

// 節點數列
[SerializeField] private List<DialogueNodeSO> nodes = new List<DialogueNodeSO>();
// 節點ID字典
private Dictionary<string, DialogueNodeSO> nodeDic = new Dictionary<string, DialogueNodeSO>();

// Codes...
Undo.RecordObject(this, "Change Info");
// Codes...

由於字典是不可被序列化的(即使他添加了[SerializeField]),故在執行撤銷操作后,能夠恢復的只有nodes,而nodeDic則保持更改不變。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM