2015年6月30日 星期二

Unity:使用 PropertyAttribute 與 PropertyDrawer 製作場景選單欄位

過去曾經發佈過的兩篇文章「Unity:設計靈活的無縫場景轉換機制」與「Unity:設計靈活的無縫場景轉換機制-實作測試」,前篇對於場景名稱的設置方式,主要是使用字串欄位輸入場景名稱,在文章結尾也提到了這種設置方式可能遇到的風險,而後篇文章雖然是前篇的實作,但也針對前篇最後提到的建議,於範例檔裡面使用自定義編輯器介面的方式來改善作法,除此之外,使用 PropertyAttributePropertyDrawer 搭配來製作提供給 public 欄位(Field)使用的屬性(Attribute)也是另一個替代使用字串值欄位來設置場景名稱的好方法。

首先,來提一下「Unity:設計靈活的無縫場景轉換機制-實作測試」文中所提供範例的做法,該範例提供的只是比較簡單的自定義編輯器做法,如果,很熟悉撰寫 Unity 的自定義編輯器介面的話,是可以完全排除原本的一些缺點的,只是要多費些工夫;針對該文提供的範例做法,是直接拖曳場景檔至欄位中設置,再透過程式把場景名稱儲存到真正的設定檔中,其好處是,當場景檔案名稱變更時,Build Settings 裡面的名稱跟隨自動變更,而我們所設置的名稱也會同時跟著自動變更,而缺點是,有可能會拖曳設置了不存在於 Build Settings 的場景檔到我們的設置欄位中,另一個缺點是,編寫自定義編輯器算是蠻客製化的工夫,有時候,我們可能沒有要做那麼複雜的事情,只是希望 Script 裡面其中一個 public 字串欄位可以在 Inspector view 設置場景名稱而已,那就有點殺雞用了牛刀的感覺,這時候就需要可攜帶性、復用性更高的做法。

在此先提供實作過程及測試的影片,其他解說可再參閱以下文章內容:



我們在製作設置場景名稱欄位時,可能會有以下幾個需求:
  • 不能直接輸入文字,以避免打錯字造成的錯誤。
  • 當場景檔名變更時,設置欄位應該也要變更。
  • Build Settings 沒有設置該場景時,設置欄位就不能設置該場景名稱。
  • Build Settings 有設置該場景,且在關閉的狀態,設置欄位就不能設置該場景名稱。
  • 應該要很輕便,任意字串欄位都可能做為設置場景欄位。
綜觀以上幾點需求,我們先來看看什麼是 PropertyAttributePropertyDrawer

從官方文件可以得知 PropertyAttribute 用來讓我們為 Script 裡的變數(public 欄位)建立自定義的屬性(Attribute)的基類,所謂的 Attribute 為 C# 提供的一種定義宣告式標記的機制,可以在原始程式碼中放置一些實體,來指定額外的資訊。而 Unity 的這個 PropertyAttribute 則可以與 PropertyDrawer 搭配,為標記的變數欄位在 Editor 做些其他處理或限制。

而官方文件中也說明了 PropertyDrawer 的兩種用途,其中一個則是為定製 PropertyAttribute 的 Script 變數成員製作使用者介面。

有了 PropertyAttributePropertyDrawer 搭配,我們可以利用 EditorGUI 來將欄位製作成下拉選單的表現方式,如此就不用手動輸入字串,將可以避免打錯字的風險,同時也因為 PropertyDrawer 是 UnityEditor API,所以,可以直接從 Build Settings 取得設置在 Scenes In Build 的場景名單,因為 Scenes In Build 本身就扮演著類似場景檔案管理者的角色,所以透過它所取得的場景名單將會是最正確有效的,另外,因為 PropertyAttribute 用於 Script 中的方法就像是 C# 或 Unity 其它內建的 Attribute 一樣,使我們在需要使字串欄位變為下拉式的場景選單時,只要在 public 字串欄位上方加入我們自訂的 PropertyAttribute 即可,不用另外在編寫自定義編輯器來達到這個目的,輕便很多。

下拉式選單欄位
首先,宣告一個繼承 PropertyAttribute 的 class,由於將要使用 Editor.Popup 來製作選單,所以我們需要讓欄位本身有個被選擇的整數值作為選單的選擇依據,因此,這個 class 本身則是很簡單的只要宣告一個整數欄位即可。

public class SceneMenuAttribute : PropertyAttribute {

    public int selected;
}

接下來就是製作繼承 PropertyDrawer 的 class,由於 PropertyDrawer 是 Editor 的功能,所以這個 Script 建議放在 Editor 資料夾底下,撰寫這個 Script 的第一步就是使用 CustomPropertyDrawer 將它標記 GUI 繪製目標為 SceneMenuAttribute,而繪製 GUI 的部分則是透過覆寫 PropertyDrawerOnGUI 來達到目的,也就是說,我們可以重新在 OnGUI 裡面撰寫 GUI 程式碼來取代掉原本 Unity 內建 script 字串欄位的 GUI,在這裡的 OnGUI 和平常 Runtime 繼承 MonoBehaviour 的 script 裡面的 OnGUI 有些不同,這個 OnGUI 會傳回三個參數,分別是 position、property、label。

position 參數代表目標 public 欄位在 Inspector view 的位置及大小,通常都是直接用於我們寫的 GUI 元素上,因為在 Inspector view 上的位置及大小變更是會依照我們對 Unity 編輯器的使用狀況而變更的,所以直接使用傳入的參數來做為我們覆寫的 GUI 元素的位置、大小是最適合的,有時候可能會想添加其他 GUI 元素,這時候,也可以使用這個 position 參數為依據來計算其他 GUI 的位置及高度,不過,原本這個欄位只有佔用一行的高度,添加了其他 GUI 後,整體總高度變大了,在 Inspector view 中,目標 public 欄位下面的其他 GUI 將會發生重疊的現象,這時候就要覆寫 ProppertyDrawerGetPropertyHeight 使它回傳正確的總高度,不過,在此並不會實作 GetPropertyHeight 的部分。

property 代表目標 public 欄位序列化之後的物件,詳細內容可以參閱官方文件的 SerializedProperty,Unity 的 public 欄位或是有標記 SerializeField 的欄位會被序列化為 SerializedProperty,這在寫自定義編輯器時非常有用,透過對 SerializedProperty 來存取欄位內容,在 Unity 編輯器中會直接具備 Undo、Redo 的功能,而不用另外撰寫相關的程式碼,而在這裡,OnGUI 直接在這個 property 傳入目標 public 欄位被序列化為 SerializedProperty 的物件,可以對它存取多種型態的值,當然也會使我們自己寫的 GUI 也直接具備 Undo、Redo 的功能,我們將會透過更新 property 參數的 stringValue 來更改目標 public 欄位的字串值。

label 代表目標 public 欄位在 Inspector view 的欄位標題及註解的 GUIContent 物件,其實,這個欄位標題應該是使用 ObjectNames.NicifyVariableName 對第二個參數 property 的 name(property.name)處理後的結果,不過,由於 label 參數已經有傳回資料,如果沒有其他目的,在此,就可以直接取用 label.text 來作為 GUI 的欄位標題。



在這個 OnGUI 中,我們主要要做的事情如下:
  • 取得 Build Settings 的 Scenes In Build 中的設置場景清單。
  • 忽略 Scenes In Build 設置的場景清單中未勾選的場景。
  • 對 Scenes In Build 裡設置的場景路徑處理成為不含副檔名及路徑的名稱。
  • Scenes In Build 未設置任何場景時,顯示提示訊息,而不顯示場景選單。
  • 使用 EditorGUI.Popup 製作目標 public 欄位的下拉選單。
  • 將選單所選擇的項目相對應的場景名稱寫回目標 public 字串欄位中。

以上,可撰寫出以下的程式碼:

using UnityEditor;
using System.Collections.Generic;

[CustomPropertyDrawer(typeof(SceneMenuAttribute))]
public class SceneMenuDrawer : PropertyDrawer {

    private const string SCENE_EXTENSION = ".unity";
    private const string NOSCENE_TIP = "Scene is Empty";

    public override void OnGUI (UnityEngine.Rect position, SerializedProperty property, UnityEngine.GUIContent label)
    {
        string sceneFile;
        SceneMenuAttribute attribute = (SceneMenuAttribute)base.attribute;
        List<string> sceneNames = new List<string>();

        for(int i = 0 ; i < EditorBuildSettings.scenes.Length ; i++){

            if(EditorBuildSettings.scenes[i].enabled){

                sceneFile = EditorBuildSettings.scenes[i].path.Substring(EditorBuildSettings.scenes[i].path.LastIndexOf("/") + 1);
                sceneNames.Add(sceneFile.Replace(SCENE_EXTENSION, string.Empty));
            }
        }

        if(sceneNames.Count == 0){

            EditorGUI.LabelField(position, label.text, NOSCENE_TIP);

        }else{

            for(int i = 0 ; i < sceneNames.Count ; i++){

                if(sceneNames[i] == property.stringValue){
                    attribute.selected = i;
                    break;
                }
            }

            attribute.selected = EditorGUI.Popup(position , label.text , attribute.selected , sceneNames.ToArray());
            property.stringValue = sceneNames[attribute.selected];
        }
    }
}

接下來,只要在 Script 的 public 字串欄位上方加入 [SceneMenu] 就可以很簡單的使其變更為下拉式的場景選單。
撰寫如下的程式碼來做測試。

using UnityEngine;

public class SceneManager : MonoBehaviour {

    [SceneMenu]
    public string nextScene;

    public void LoadScene(){

        Application.LoadLevel(this.nextScene);
    }
}

以上,依照影片的演示,我們可以很方便、輕易的設置及變更場景名稱欄位,不過,還有一些漏洞沒做到的,就是已經設置好場景名稱欄位內容之後,如果此時去變更了場景檔名稱,由於,沒有對該欄位重新設置,還是會在載入場景時發生名稱錯誤的狀況,要特別注意;除此之外,我們還可以做其他的擴充功能,例如可以在 SceneMenuDrawer script 補充下拉選單多加一個 None 選項為目標 public 字串欄位寫入空字串,並在執行載入場景的 Script 中增加判斷空字串值時就不要執行載入,或者 SceneMenuAttribute script 多個 bool 欄位變數,去設置選單是否要略過 Build Settings 的 Scenes In Build 內未勾選的場景,或者 SceneMenuAttribute script 多寫個建構子,來讓 [SceneMenu] 可以對下拉選單預設選擇第幾項... 等。

最後,提供 範例檔案下載 以供參考。

P.S. 目前使用 Unity 版本為 5.1.0f3。