寫在最前
因為這一內容的東西實在是太多了,能更一點是一點,最初的更新可能沒有什么學習順序,后續內容逐漸完整后會重新排版
本文暫時停止更新 對話編輯器的代碼放在了Github
其他和編輯器有關的代碼也可以翻此項目,雖然個人感覺有點臭,日后再優化
自定義Inspector窗口
自定義編輯器腳本的創建
- 編輯器腳本需要置於Editor文件夾下方,類似資源讀取的文件需要存放在Resources下方
- 編輯器腳本的命名規則一般為:
所編輯類名 + Editor
,例如當我需要自定義類StateMachine
的Inspector窗口時,我將在Editor目錄下創建StateMachineEditor.cs
- 添加Attribute:
[CustomEditor(typeof(T))]
。目的是告知編輯器類該編輯器所針對的運行時類型,此例中,需要告訴編輯器我們想要修改類StateMachine
的Inspector窗口,故T
為StateMachine
- 使類
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面板中的。直接上編輯器代碼,簡陋的顯示字典的Key
和Value
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
里的一個靜態成員函數,它接收兩個參數:bool
,string
,執行后會檢測窗口是否已經存在(若不存在則創建)然后將其返回
-
第一個參數決定此窗口是浮動窗口(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
這里扔幾篇學習博客和官方文檔
- Unity Documentation - UI Toolkit
- UIElements渲染細節 比NGUI/UGUI/FairyGUI好在哪?
- What's new with UIElements in 2019.1
- Building UI for games with the new UI Builder - Unite Copenhagen
因為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); } }}
保留模式
在代碼中使用到的Label
,Button
, Toolbar
都是UIElement,這些元素都是VisualElement
的派生類。整個UIElements的基本構建塊都是VisualElement
。各個VisualElement
以使用者規定的順序排列,組成一定的UI層級結構(通過Add
,Insert
等操作完成),最后布局,樣式等系統會遍歷這個層次結構,然后將UI繪制到屏幕上
在EditorWindow中,有一個類成員rootVisualElement
,它代表窗口的根VisualElement
,我們需要將需要繪制的元素添加至此父根。在上述代碼中,根節點的子元素為Toolbar
與GraphView
;Toolbar
的子元素為Button
,Label
。上述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操作,則會將背景生成在對話節點編輯器之上,導致背景遮擋住了節點編輯器上的所有元素(注意區分Add
與AddElement
的層級關系,前者是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記錄撤銷操作。
當寫在ScriptableObject
或MonoBehaviour
類中的變量,記錄撤銷操作時:
// 節點數列
[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
則保持更改不變。