easyui表格编辑事件_Unity手游开发札记——从Odin插件聊基于元数据的编辑器实现
最近一個多月的時間在全力做新項目的Demo,由于程序暫時還只有我一個人,所以從程序架構搭建到戰斗邏輯實現,再到編輯器開發都是我自己的工作。之前有較長一段時間日常的工作內容已經集中在團隊管理、圖形渲染、性能優化等方面,在具體業務中做的內容比較少了,這段時間重拾游戲玩法的開發,雖然除了進度壓力之外沒有太多技術挑戰,但也借這個機會回顧和反思之前的一些做法和代碼,有不少收獲。另外一個感受就是——如果不想那么多,埋頭醉心于玩法的實現,每天寫上大幾百行代碼,也可以獲得最為簡單而直接的成就感。
當然,代碼量并不是最為直接的成就感來源,如果可以用更少的代碼實現更強大的功能,才是作為最會“偷懶”的程序員最為理想的工作方式。也恰恰是這個原因,才催生各種應對變化的設計模式,有了github這樣的開源社區,以及開頭提到的元編程(Meta Programming)這樣的編程理念。
我們暫時放下對于編程理念的討論,先從需求的根源來看一下它可能的一個應用環境——編輯器的開發。
1. 編輯器 vs Excel
當你需要策劃編輯一份數據的時候,你問他——你是想用Excel填寫還是需要我幫你做一個編輯器?不同的人可能有不同的回答,這取決于策劃的經驗、喜好,也會受到數據結構的復雜程度的影響。爭論這兩者的優劣沒有什么意義,找到他們各自的適用場景才是關鍵所在。
Excel本身具有超級強大的功能,它的設計目的就是編輯二維數據,并在此之上構建了豐富的統計和分析功能,如果再加上vba的幫助,那簡直無所不能。之前就認識一個策劃,整個游戲的數值演算以及帶有交互的基本Demo原型都在Excel中直接進行了實現。當然,它的缺點也比較明顯,就像MySQL之于Mongo一樣,Excel對于非結構化的數據支持比較麻煩,比如技能這樣相對復雜的數據就要拆表等方式來描述,策劃填寫時需要跨越多張表格,不夠直觀,比較費時,也容易出錯。
編輯器在應對非結構化數據時就相對容易一些,界面和操作方式可以根據需求直接定制,和游戲內具體對象的交互也比較方便,比如技能編輯器中常常需要的預覽動作、特效等功能,編輯器更容易做到所見即所得,對于一些資源的填寫也可以直接使用選擇的方式,而不需要手動復制和修改,更加不容易出錯。
在新的項目中,游戲的戰斗邏輯選擇放置在了Lua層,因此和戰斗緊密結合的技能數據也只能選擇放置在Lua端。最初的戰斗Demo只設置了Lua Table的數據格式,然后通過直接編寫這個Lua文件就可以更改技能的各種效果。最終讓策劃來進行技能編輯工作的時候一開始也想要不嘗試下Excel的方式,但最后還是選擇了為策劃編寫一個簡單的技能編輯器,主要原因有這么幾點:
2. 基于元數據的編輯器框架
記憶中大約是在大三的時候,給我們上軟件工程課程的老師在課堂上說他帶的學生在編寫可以寫代碼的代碼,一臉神秘和驕傲。具體描述的應用場景已然流逝在了時間的長河中,但這所帶給在當時還只會C語言和C++以及一些基本編程知識的我的那種驚訝和震撼卻留在了我的記憶中。再到后來,雖然接觸了http://ASP.net、還有WPF這些框架,但對于那些由xml或者別的格式來描述的界面信息最終是如何轉變為一個可以響應邏輯事件的控件的,并沒有非常清楚的認知,只是停留在使用的層面。直到后來做《無盡戰區》項目,最初大量的編輯器都是使用引擎內部的UI來進行開發的,也就是和游戲UI是同一套東西,編寫起來比較麻煩,老大說我們要做一套可以根據配置自動生成界面的元數據框架,這樣可以大大提高比如技能編輯器這種需要大量界面工作的開發和迭代效率。
這是我第一次深入的去思考和理解通過配置來描述一個界面的方法,以及如何最終將配置轉變為一個個的界面元素,從而組合成一個交互界面。以技能編輯器為例,整個框架所要包含的核心模塊可以用下圖這樣一個結構來大致描述:
首先要有一些基礎的界面通用控件,Button、Text、Slider等等,然后要提供自動化的界面布局功能。編寫編輯器的程序需要編寫的只是一份描述某個數據自身特性的元數據文件,在這里也就是描述技能的元數據信息,比如描述了技能一個技能包含哪些字段,這些字段分別是什么類型,按照什么樣的方式讓策劃編輯,取值范圍是多少等等。這些信息按照框架定義好的方式進行描述,元數據解析功能就可以解析這些配置,然后結合界面生成功能產出最終的技能編輯器界面,供策劃編輯。最后導出的技能配置數據的格式,也會結合解析出來的元數據信息進行導出和檢查。這個過程,就大致解釋了元數據的基本概念——元數據提供了其他數據的格式信息。在這個例子中,技能元數據就定義了最終編輯出來的技能數據所包含的信息,以及要在編輯器中展示這些數據所需要的信息。
這套結構具體的信息不方便細說,但這里可以從Traits這個開源庫來看下在Python中進行數據描述的方法和形式。
Traits為Python對象的屬性增加了類型定義的功能,但除此之外還有其他的作用:
- 初始化:每個trait屬性都定義有自己的缺省值,這個缺省值用來初始化屬性驗證;
- 基于trait的屬性都有明確的類型定義,只有滿足定義的值才能賦值給屬性委托;
- trait屬性的值可以委托給其他對象的屬性監聽;
- trait屬性的值的改變可以觸發指定的函數的運行可視化;
- 擁有trait屬性的對象可以很方便地提供一個用戶界面交互式地改變trait屬性的值。
在官方的介紹中給了一個簡單的例子描述了上面的幾個核心功能:
from enthought.traits.api import Delegate, HasTraits, Instance, Int, Strclass Parent ( HasTraits ):# 初始化: last_name被初始化為'Zhang'last_name = Str( 'Zhang' )class Child ( HasTraits ):age = Int# 驗證: father屬性的值必須是Parent類的實例father = Instance( Parent )# 委托: Child的實例的last_name屬性委托給其father屬性的last_namelast_name = Delegate( 'father' )# 監聽: 當age屬性的值被修改時,下面的函數將被運行def _age_changed ( self, old, new ):print 'Age changed from %s to %s ' % ( old, new )這個簡單的例子展示了Traits的基本用法,在Traits中,對于每一個trait屬性都有一個與之對應的trait對象描述它。而元數據就是保存在trait對象中的額外的描述屬性用的數據。這些元數據屬性可以分為三類:
- 內部屬性 : 這些屬性是trait對象自帶的,只讀不能寫;
- 識別屬性 : 這些屬性是可以自由地設置的,它們可以改變trait的一些行為;
- 任意屬性 : 用戶自己添加的屬性,需要自己編寫程序使用它們。
而基于這些屬性,就可以描述一份數據的各項信息,于是編寫出來的這份“代碼”,也可以被稱為元數據。
更加詳細的信息有興趣的讀者可以參考Traits的文檔描述,這里就不再贅述了。接下來我們核心來看看本次的主角——Odin插件。
3. Odin插件
Unity引擎對于自定義編輯器的支持已經比傳統的游戲引擎要方便一個等級了,它基于Unity的反射機制,在界面框架內已經實現了非常方便的屬性編輯功能,比如最為常見和常用的MonoBehavior的屬性編輯和查看。相比于自研引擎要自己實現前文所描述的這套元數據編輯器框架,對于常規的配置需求Unity已經做得更好了。
然而,作為開發者來說還是有更加復雜的需求是Unity這套結構目前所不支持的,比如Dictionary的編輯,比如一些動態的顯隱控制。好在Unity有強大的Asset Store,Odin這樣的插件也就應運而生,雖然55美元的售價稍微有些貴,但我覺得它絕對物超所值!引用一段官方介紹來描述其功能:
Odin puts your Unity workflow on steroids, making it easy to build powerful and advanced user-friendly editors for you and your entire team. With an effortless integration that deploys perfectly into pre-existing workflows, Odin allows you to serialize anything and enjoy Unity with 80+ new inspector attributes, no boilerplate code and so much more!簡答來說,它通過提供更多的新屬性來方便我們編寫強大的編輯器功能,并且提供了序列化模塊。拋開序列化不說,我就舉幾個自己真正使用過的例子來描述它的一些好用功能。
3.1 字典編輯
字典的編輯這里直接給一個官方的例子:
public class DictionaryExamples : SerializedMonoBehaviour{[InfoBox("In order to serialize dictionaries, all we need to do is to inherit our class from SerializedMonoBehaviour.")]public Dictionary<int, Material> IntMaterialLookup;public Dictionary<string, string> StringStringDictionary;[DictionaryDrawerSettings(KeyLabel = "Custom Key Name", ValueLabel = "Custom Value Label")]public Dictionary<SomeEnum, MyCustomType> CustomLabels;[DictionaryDrawerSettings(DisplayMode = DictionaryDisplayOptions.ExpandedFoldout)]public Dictionary<string, List<int>> StringListDictionary;[DictionaryDrawerSettings(DisplayMode = DictionaryDisplayOptions.Foldout)]public Dictionary<SomeEnum, MyCustomType> EnumObjectLookup;[InlineProperty(LabelWidth = 90)]public struct MyCustomType{public int SomeMember;public GameObject SomePrefab;}public enum SomeEnum{First, Second, Third, Fourth, AndSoOn}}最后的編輯界面如下圖所示:
這里有比較方便的添加和刪除功能,對于復雜的數據結構,也可以采用Foldout的方式。同時這里也可以看到對于枚舉類型和自定義結構的支持。
3.2 動態的下拉列表
為了減少使用者出錯的概率,下拉列表是一個非常常見的需求,而其中的內容往往是動態變化的,Odin提供了ValueDropdown屬性來應對這一需求,只需要定義一個相應的獲取函數就可以了。
[LabelText("攻擊位移編號"), ValueDropdown("GetOffsetIDs")] public int offetOnAtk = -1;private IEnumerable<int> GetOffsetIDs() {List<int> oIds = new List<int>();//處理邏輯return oIds; }顯示效果如下圖所示:
3.3 錯誤提示信息
Odin也提供了豐富的信息提示功能,比如PropertyTooltip,是在鼠標在屬性名稱上懸停時顯示的tips,LabelText是最為基礎的顯示名稱,InfoBox可以定義單獨的提示信息,而且可以給出顯示條件,比如一個布爾值屬性或者返回為布爾值的函數。
[LabelText("技能時長"), PropertyTooltip("技能的持續時間,0表示動態技能時長"), MinValue(-1f)] [InfoBox("必須有一個觸發技能結束的位移才可以使用動態技能時長!", InfoMessageType.Error, "NoDynamicLength")] [InfoBox("技能時間乘以回能速度必須小于1!", InfoMessageType.Info)] public float length = 1.0f;提示信息的顯示效果如下圖所示:
3.4 根據條件顯示和隱藏
在編輯器中,某些屬性的是隸屬于其他屬性的,比如定義一個形狀,如果是個原型,則只需要半徑就可以了,如果是個扇形,則還需要一個角度參數。通常的解決方法要么為這兩種形狀提供不同的編輯器功能,要不就把最大范圍的屬性都顯示出來,讓使用者隨意填寫。第一種方法有時候稍顯復雜,第二種又會使編輯器使用者關注的信息膨脹,那么這時候就可以使用ShowIf或者EnableIf屬性。
[LabelText("扇形角度"), ShowIf("shapeType", BulletShapeType.扇形)] public int angle = 0;這樣就只有當shapeType是扇形的時候,才會顯示扇形角度屬性。
3.5 自動的TreeView
Odin提供的OdinMenuEditorWindow默認集成了一個TreeView列表放在左側,為很多編輯器的開發提供了便利。比如官方提供的一個RPG類型游戲的編輯器Demo:
Odin插件的功能還有很多,這里就不一一列舉,有興趣的朋友可以去官網查看或者自己購買一份來學習和試驗。總之,借助Odin的強大功能,我原本計劃要3-5天才能完成的技能編輯器,只使用了1天時間就完成了核心框架。再加上我自己實現的一個簡單的C#數據導出為Lua Table的功能,基本就滿足了Demo期的核心需求。
4. 原理和簡單擴展
如果你購買了Odin插件,是可以直接獲取它的源碼的,因此也就可以一探它具體的實現原理了。由于是收費插件的源碼,這里就不做特別細致的探討了,總體來說,Odin就是基于兩個技術的結合來實現的:
- 屬性(Attributes)
- 反射(Reflection)
反射的部分比較好理解,比如ValueDropdown("GetOffsetIDs")這樣的定義中,方法的名稱使用一個字符串來進行描述,那么在最終執行的時候,肯定是要通過反射來獲取具體的函數來執行,并獲取返回值,這時候就要借助C#的反射機制才可以實現。而Odin的便利性則主要通過C#的屬性來實現。
微軟官方對于Attributes的定義如下:
Attributes provide a powerful method of associating metadata, or declarative information, with code (assemblies, types, methods, properties, and so forth). After an attribute is associated with a program entity, the attribute can be queried at run time by using a technique called reflection.你看,Attributes本身就是元數據的理念,它在綁定之后也是通過反射來查詢的。屬性具有如下的特點:
- 添加元數據到你的程序中,元數據在程序中是關于類型定義的信息。屬性也可以自定義;
- 屬性可以被應用于整個程序集、模塊,或者像類和屬性(Properties)這樣更小的程序單元;
- 屬性可以像方法和Properties一樣接收參數;
- 借助反射,你可以查詢自己或者其他程序定義的元數據信息。
語言層面更加細節的原理不在本文的討論范圍內,我想借助我在技能編輯器實現時基于Attributes實現的一個功能來嘗試描述一下Odin的基本原理。
在實現從C#數據導出Lua數據功能的時候,我想嘗試優化導出后的文件大小以避免后續技能過于復雜時對于Lua虛擬機內存的影響。最為基本的一個優化就是如果策劃填寫的內容和默認值相同,就不需要導出這個數據,在Lua代碼中通過a = a or default_value這種方式來獲取值即可。如果要自己在導出函數中進行實現,或者通過一個屬性名稱白名單的數組來進行維護都會比較麻煩,Attributes是一個非常合適的功能。于是我設計了一個NotExportToLua的Attribute,它用來描述這個屬性是否要導出到Lua中,基本實現如下:
public class NotExportToLuaDataAttribute : System.Attribute {object defaultValue = null;public NotExportToLuaDataAttribute(){}//如果和默認值相等則無需導出public NotExportToLuaDataAttribute(Object defaultValue){this.defaultValue = defaultValue;}public bool NeedNotExportToLua(object value){if (defaultValue == null){return false;}//這里的判斷非常不嚴謹,只使用轉換為字符串的方式來判斷基礎類型的對象是否相等。return defaultValue.ToString() != value.ToString();} }這個Attribute支持無參數和有參數兩種形式,如果無參數則這個屬性只會在C#中使用,無需導出到Lua數據中,而如果給予參數,在定義了默認值,導出時會檢查當前值對象的值是否和屬性值相同,如果相同則同樣不導出。這里為了快速實現,使用了ToString的方式臨時實現,并不是最正確的做法。
Type type = obj.GetType(); string indentStr = GetIndentation(indentLevel); builder.Append("{n"); bool first = true; foreach (FieldInfo f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)){var value = f.GetValue(obj);if (!IsNotExportToLua(f, value)){//Process export.} }public static bool IsNotExportToLua(FieldInfo type, Object value) {NotExportToLuaDataAttribute attr = type.GetCustomAttribute<NotExportToLuaDataAttribute>();if (attr != null){if (!attr.NeedNotExportToLua(value)){return true;}}object[] attrs = type.GetCustomAttributes(true);for (int j = 0; j < attrs.Length; j++){Type t = attrs[j].GetType();if (t == typeof(System.ObsoleteAttribute)){return true;}}return false; }在導出的邏輯中,首先借助反射函數GetFields獲取一個對象所有的屬性,然后通過GetCustomAttribute方法獲取其身上對應類型的自定義Attribute,借助NotExportToLuaDataAttribute身上的NeedNotExportToLua來判斷是否需要導出該屬性。
這樣,在你不需要導出一個C#中定義的屬性的時候,只要為其添加[NotExportToLuaData]就可以了,這也就非常靈活地實現了前面的需求。Odin插件對于Attribute的定義以及通過反射獲取的數據更多,但其基本原理和我自己實現的這個NotExportToLuaData Attribute基本類似。
5. 總結
Meta Programming is about writing code that writes code.從元數據聊到元編程稍微有點刻意把這篇文章的立意拔高的意思,但它們兩個之間的確有著相似的理念。用數據描述數據,用代碼來生成代碼,都是為了提高開發效率而“偷懶”的方法。從某個角度來說,處理元數據的代碼就是在通過對于數據的描述來減少重復代碼的編寫,也可以說它是代替程序編寫重復的代碼。
對于元數據來說,像Lua的元表一樣,她也可以有遞歸描述的能力,比如可以使用一份元數據來描述描述技能信息的元數據,就是元數據的元數據,通過它配合一個編輯器可以讓策劃自己定義一個技能中的數據有哪些,它們分別是什么樣的類型或者要滿足什么要的條件,這是更高層次的抽象。也許在未來,程序可以借助深度學習或者其他AI技術,開發一個自己寫代碼實現需求的AI程序,這個開發過程,似乎可以稱之為Meta-Meta Programing……
恩,扯得有點遠了,無論元數據也好,元編程也好,起碼目前階段的核心作用都是節省程序的開發時間,或者增強程序的功能。這篇文章還講的比較淺顯,從Odin插件的使用出發,稍微聊了一下元數據的基本思路和方法,無論你是否會使用到Odin插件,都希望這篇可以幫你開闊思路,從而節省一些開發時間,畢竟——“時間就是金錢,我的朋友。”
2019年7月8日晚于杭州家中
總結
以上是生活随笔為你收集整理的easyui表格编辑事件_Unity手游开发札记——从Odin插件聊基于元数据的编辑器实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于wifi的单片机无线通信研究_SKY
- 下一篇: qtextedit改变单个字的颜色_发协