2018年9月17日 星期一

Unity:自定義編輯器製作案例說明 001

先前曾經在 Facebook 貼出一張關於 Unity 自定義編輯器程式碼的圖(這裏),當時並在粉絲專頁貼文說改天會解釋裡面的東西,以下,就來講段簡單的事發經過,以及說明相關的原理與做法。



首先,故事好像是這樣的,有人在「Unity 應用領域」社團裡提問到關於想用 EditorWindow 做的 UI 要有多個字串欄位可以輸入,並且改變長度(Length)值,可以保留字串欄位的文字,這裡指的長度值應該是指與字串欄位相關的字串陣列的長度。

稍微看了一下該篇貼文所提供的程式碼,以及他的問題,理解為:
  • 有一個代表長度值的輸入欄位。
  • 當改變輸入欄位後,會改變所出現的字串欄位數量。
  • 希望當字串欄位數量改變時,可以保留已經在字串欄位輸入的文字,不會因為數量改變而被刷新清空。

參考了對方提問時所提供的程式碼以及需求,猜想可能是要做個用來輸入多個網址的編輯器視窗,於是寫了以下這段程式提供參考:

(以下程式碼皆以圖片展示,點擊圖片可放大,以利閱讀)


將這段程式的檔案放在任何一個 Editor 資料夾裡面後,可以在選單列找到 Tools > Strings Window。


點擊該選單就會出現自定義的編輯器視窗。


這個視窗有個名為 String Editor 的標題(標籤),同時可以拖拉停靠在其它視窗旁,同時也可拖拉邊緣縮放大小。視窗內的「行」欄位可以輸入整數,該欄位內的數值改變,也將改變下方的「地址」欄位數量;「地址」欄位所輸入的內容,同時也能 Undo/Redo。

這樣看起來似乎滿足了對方的需求,但實際使用上,會面臨到幾個怪怪的問題:
  • 假設目前「行」欄位內容值是 5,下方應該會有 5 個「地址」欄位,但假設此時希望有 10 個欄位,會在「行」欄位依序輸入 1 和 0,當輸入 1 的瞬間,下方「地址」欄位會變成只剩下一個,等到輸入 0 之後,才會變成有 10 個欄位,那麼原本 5 個「地址」欄位中如果都有輸入內容,此時會變成有 10 個「地址」欄位,但只剩下第 1 個有內容。
  • 如果需要 20 個或者更多的「地址」欄位,就會發現到欄位沒有辦法全部顯示在視窗中,必須要把視窗拉大,才有可能顯示出全部的欄位並能夠輸入。
  • 如果想要從中間刪除其中的某個欄位,可能無法辦到,只能依序把下面欄位的內容複製並往上貼入,然後再減少欄位數。
  • 欄位名稱的空間太長,視覺上的操作感覺略不舒服。

於是,重新寫了另一支程式來使得這個自定義編輯器視窗能夠更完整一些:


  • 視窗的第一列顯示「網址數」,代表下方的輸入欄位數。
  • 「網址數」的右側有個「+」按鈕,點擊此按鈕,則在下方新增一個「網址」輸入欄位。
  • 「網址數」下方是個輸入欄位區塊,當欄位數量變多並超出視窗大小範圍時,區塊右側出現捲動軸,捲動區塊即可查看各欄位並輸入。
  • 縮小欄位名稱的空間,使輸入欄位佔有最大空間,並使欄位名稱依序編號,使得欄位辨識上更明確。
  • 每個輸入欄位後方都有個「-」按鈕,點擊該按鈕則移除該欄位。

以下就來說明這個視窗應該如何製作。

首先,由於這是擴充 Unity 編輯器的程式,所以,需要先在 Project 視窗建立一個 Editor 資料夾(如果沒有的話),並在 Editor 資料夾裡面建立一個新的 C# Script,命名為 URLsWindow,接著就可以將它在你的程式碼編輯器(例如 Visual Studio)打開來開始編寫相關的程式,

由於是擴充 Unity 編輯器的功能,並且是要製作一個自定義視窗,所以,開頭必須有 using UnityEditor,並使這個 URLsWindow 繼承 EditorWindow;原本建立 C# Script 時,Unity 幫我們保留的 Start() 和 Update() 都用不到了,可以直接刪除掉:


因為需要讓選單列有選項可以開啟這個視窗,所以要宣告一個靜態(static)方法來執行開啟視窗的功能,同時利用 MenuItem 指定一個選單路徑來執行這個方法。由於要實作的視窗功能都是寫在這個 URLsWindow,同時 URLsWindow 也已繼承 EditorWindow,所以這裡的靜態方法裡面直接使用 GetWindow 以 URLsWindow 來獲取視窗顯示在畫面上,同時指定它的標題(標籤)名稱。


在這裡,也可以同時利用 position 指定視窗開啟時的位置與大小 Rect(x , y , width , height)。

另外,這個靜態方法雖然是要用來獲取 URLsWindow 這個視窗,但它不一定要寫在 URLsWindow 裡面;有時候,可能實作了很多自定義的編輯器視窗,為了方便管理,也可以將全部的 MenuItem 這種靜態方法集中放到同一個 Script 檔案中。

完成以上的程式碼,我們將可以在 Unity 編輯器的選單列找到這樣的選單。


並且在點擊該選單後,可以看到一個空的視窗以我們指定的大小顯示在我們指定的位置上,其視窗標題(標籤)為 URLs。


接下來,就要開始製作編輯器視窗的本體,在此之前,可能需要一些關於 UI 寫作的基本知識,這裡製作 UI 的方式並非現今大家使用 Unity 製作遊戲時所使用的所謂的 UGUI,而是以往我們所說的 OnGUI(因為它的程式碼是寫在 OnGUI() 裡面),如今的官方文件裡則是稱為 IMGUI

除此之外,我們還必須要暸解到繼承 EditorWindow 的 URLsWindow 類別在被 GetWindow 獲取出來在畫面上顯示出一個視窗的樣子,是將原本的 URLsWindow 類別實例化為一個視窗物件,就如同我們將一個 Script 從 Project 視窗拖拉到遊戲物件(GameOjbect)上成為組件(Component)一樣,是將 Script 中的類別在 GameObject 上實例化為一個組件物件;所以 URLsWindow 本身也可以有公有(public)或私有(private)欄位變數(Field),同時,在 Unity 編輯器的系統中,URLsWindow 也可以序列化為一個序列化物件來處理本身的序列化欄位資料。

使用序列化欄位來處理資料的好處之一是,我們不需要自己去為這些資料的變更處理 Undo/Redo 的部分,Unity 編輯器系統本身會自己完成。

我們現在要製作的這個編輯器視窗裡,最主要的就是一些字串輸入欄位,因此,直接宣告一個私有的字串陣列,並使用 SerializeField 屬性(Attribute)標示為序列化欄位,這個欄位變數將是真正存放字串資料的地方。


為了以序列化欄位來處理所要存取的資料,需要先使用視窗本身的物件來建立序列化物件,並透過序列化物件來找出用來存取資料的序列化欄位,以建立序列化屬性來進行操作;因此,直接宣告一個型別為 SerializedObject 的私有欄位變數以及一個型別為 SerializedProperty 的私有欄位變數來存放序列化物件及序列化屬性。


建立序列化物件以及找出序列化欄位來建立序列化屬性的動作,應該要在視窗被開啟的時候進行,根據官方文件所述,OnEnable() 是在物件被載入時呼叫,所以我們將這些放在 OnEnable() 裡面執行。


到這裡,前面的準備動作差不多完成了,不過,如果此時再次開啟視窗,將不會發現到任何變化,因為這個編輯器視窗的本體 UI 部分,我們還沒有做。

由於視窗顯示的內容是 UI 組成的,因此接下來的程式碼都會放在 OnGUI() 裡面;在此之前,要先了解到,OnGUI() 是透過不斷重複執行來更新並顯示 UI,而我們將透過序列化物件來使序列化欄位內容更新,所以,在 OnGUI() 的開頭需要執行序列化物件的 Update,並在結尾處執行序列化物件的 ApplyModifiedProperties 來套用序列化屬性的變更。


在開始製作 UI 的部分之前,要先來分析一下介面上的佈局,而在分析佈局之前,先了解一下 IMGUI 的部分,它的 API 有分可用於遊戲製作時使用的 GUI 和 GUILayout ,以及僅能用於製作編輯器時使用的 EditorGUIEditorGUILayout,而 GUI 和 GUILayout 也可在製作編輯器使用,簡單的說,就是製作編輯器使用的 UI 類別(Class),包含了 GUIGUILayoutEditorGUIEditorGUILayout,而製作遊戲內容時,無法使用 Editor 相關的功能;其中 GUI 與 EditorGUI 所製作的 UI 都必須指定位置和大小,而 GUILayout 和 EditorGUILayout 則不需要,它們能夠依據整體佈局去編排適當的位置及大小,因此,除非要特別控制 UI 元件的位置和大小,不然,UI 佈局上多以使用 GUILayout 和 EditorGUILayout 為優先。

在這裏,先介紹幾個佈局排版上常使用的功能,它們都有以 Begin 和 End 成對的前綴名稱:


以上這些,可以互相巢狀相嵌,只要不破壞 Begin 和 End 成對的規則即可。

接下來,就一邊拆解 UI 佈局一邊慢慢完成它;首先是,視窗內的 UI 主要分為上下兩個部分。


上方顯示「網址數」的區塊,以及下方網址輸入欄位的區塊,所以,要上下垂直排列的話,可使用 BeginVertical 與 EndVertical 包覆要排列的 UI 元素;同時,為了美觀或者更方便辨識區塊,BeginVertical 本身可以提供型別為 GUIStyle 的參數來設定外觀樣式,而 IMGUI 系統本身也有內建 GUISkin,內建的 GUISkin 裡包含了預設的 UI 外觀樣式的 GUIStyle 集合,可透過 GUI.skin 取得;而在這裡,我們用比較偷懶的方式,直接提供一個字串 box 代表要使用內建 GUISkin 的 box GUIStyle(官方文件沒有寫 ^_^)。


此時,如果開啟視窗來查看,應該只會在左上角看到一個小方格,因為,還沒有可顯示的 UI 元素,所以,只是以 box 樣式顯示這個空的區塊。


在上方的區塊,由「網址數」文字和「+」按鈕這兩個 UI 元素所組成。


這些 UI 元素是由左至右水平排列,所以,可使用 BeginHorizontal 與 EndHorizontal 包覆所要排列的 UI 元素,同樣的,以 box 做為這個水平排列區塊的外觀樣式;左邊的「網址數」文字使用標籤(Label)UI 元素來顯示文字,同時也顯示目前的輸入欄位數,而欄位數則是從前面建立的序列化屬性 _urls 來取得,而非直接存取欄位變數 urls,在這裡,我們基本上都是對序列化屬性進行操作,而非直接存取欄位變數。

而右邊則是一個按鈕(Button)UI 元素,這個元素是當該按鈕被點擊時會回傳 true,平常未被點擊時,持續回傳 false,所以在此可以用個 if 判斷,在被點擊時,增加序列化屬性 _urls 的陣列大小。

由於我們都是使用 GUILayout 來建立 UI 元素,因此,這兩個 UI 在這個水平排列區塊中,會自動安排它們的大小位置,所以,在不做其它處理的情況下,會各佔一半的寬度,但是,我們只想要讓按鈕寬度大小只符合按鈕上的文字所佔的寬度大小,其它空間都給前面的文字 UI 元素使用,所以,可以為按鈕元素提供一個 ExpandWidth 來表示寬度是否要延展;基本上,GUILayoutEditorGUILayout 的 UI 元素都可使用這種 GUILayoutOption 來指定它們的寬高設置。


存擋並開啟視窗後,可以看到原本左上方的小方格變大到符合視窗寬度的大小,並在裡面包覆了一個水平區塊,該區塊中的左側是「網址數」文字,右側則是「+」按鈕;此時,如果點擊「+」按鈕,「網址數」的數量會增加。


我們希望下方是個帶有捲動軸的區塊,由這個區塊來包覆全部的輸入欄位,而實際運作時,捲動區塊必須知道它捲動到哪個位置了,因此,需要宣告個型別為 Vector2 的私有欄位變數來暫存捲動位置並提供給 BeginScrollView 使用,同樣的要搭配 EndScrollView 來包覆其中的 UI 元素。


此時的視窗,可以看到最初左上方的小方塊,已經擴展到符合整個視窗的大小,因為它目前已包覆了新加入的捲動軸區塊,只是捲動區塊內還沒有東西,預設的情況下,捲動軸區塊會在所包含的 UI 元素超過其範圍才會顯示出捲動軸,因此,目前還不會看到捲動軸,只能看到空白的區塊。


捲動軸區塊中的每個輸入欄位,都是有多個 UI 元素水平排列所組成。


在捲動軸區塊中,將利用序列化屬性 _urls 的陣列大小來使用 for 迴圈一個個的建立輸入欄位;此時,可以使用序列化屬性的 GetArrayElementAtIndex 功能來指定要取得的陣列元素,所取得的陣列元素,本身也是個序列化屬性(SerializedProperty),可以直接對它的 stringValue 存取字串值,以供輸入欄位顯示欄位內容及保存所輸入的字串。

為了效能上的考量,可以另外宣告一個型別為 SerializedProperty 的私有欄位變數來專門在 for 迴圈中存放陣列元素的序列化屬性。

與上方區塊一樣,每一行的輸入欄位區塊會使用 BeginHorizontalEndHorizontal 包覆所要排列的 UI 元素,然後,用 TextField 來建立輸入欄位,並與「+」按鈕同樣的方式建立「-」按鈕,所不同的是,「-」按鈕被按下之後,會提供索引值給序列化屬性 _urls 執行 DeleteArrayElementAtIndex 來刪除指定的陣列元素。

原本 TextField 本身就可以給予欄位名稱來顯示在輸入欄位前方,但在此直接這麼做的話,會稍嫌欄位名稱的寬度有點太大,為了使輸入欄位本身佔用的寬度空間大一點,所以,可以另外建立一個不延展寬度的 Label 專門用來顯示欄位名稱,而為了使欄位名稱與輸入欄位之間有點間隔,則可以再插入一個 Space 來指定它們之間的空間。


如此,當視窗整個拉寬時,每個輸入欄位區塊的欄位名稱、「-」按鈕以及欄位名稱與輸入欄位之間的距離都可保持不變,只有輸入欄位會隨著視窗拉寬而跟著被延長。


到這裡,這個案例算是製作完成了,點擊視窗右上方的「+」按鈕,「網址數」的數值和下方的「網址」輸入欄位會增加,當輸入欄位增加到超出下方區塊的範圍時,會出現捲動軸,點擊每個輸入欄位右側的「-」按鈕,則會刪除掉該列輸入欄位。

經過這些改變,是不是比原本那個視窗好用又美觀許多呢?完整的程式碼如下:


此篇文章,透過這個案例嘗試說明自定義編輯器視窗製作上的一些做法,實際上,這個案例所製作的視窗並無實質用處,欄位可新增、移除、修改、Undo/Redo,然後呢?沒有後續了,所以僅供參考。

雖然,這段程式碼是在 Unity 2017.4 或 Unity 2018.1 期間編寫的,但因為是蠻基本的東西,所以也適用於較舊版本的 Unity。

希望這個文章的分享,對大家有幫助,謝謝閱讀!