Home > 工具編程 | 腳本編程 > 半年近況,並談 .NET PropertyGrid 的動態屬性挷定

半年近況,並談 .NET PropertyGrid 的動態屬性挷定

半年近況

距離上一篇文章也剛好半年了。從那個時候之後,在內地很多網站也被封了,包括了我這個 blog。不過這並不是荒廢 blog 的原因,主要還是因為我上一個項目 《Cloudy with a Chance of Meatballs》 (Xbox360/PS3/Wii/PC) 進入 crunch mode,幾個月都在忙,回家也不想編程序,更別說寫 blog了。

《Cloudy》遊戲終於在九月十五日發行了,電影則在我兒子一歲生日上畫,當天亦是九一八事變的紀念日。雖然沒有甚麼期望,但還是想看一看遊戲網站和玩家的評語。回香港的時候應該會買一兩個平台的版本。如你有興趣玩這個遊戲,推薦兩個人一起玩co-op,會比較有趣。

cloudyxbox360cloudyps3cloudywiicloudypc

八月轉到了新公司,有很多新東西要學習,不過這裡給我發揮的空間也許比較多。有機會,並得到公司的許可,也許在遊戲發行後再說說當中做的一些技術和經驗。

這個月的 Mil Engine 進展

在八月下旬,我開始重拾在家裡編程序的愛好。簡單總結一下:

  1. 跟據原來設計的 API 重新實現 Mil Universe Database (MUD),並成功做到 Transaction。Transaction 可以保持 MUD 的 integrity,例如在匯入 COLLADA 時,會在 MUD 裡創建很多新檔案,但當滙入中間出現錯誤,例如找不到紋理、或一些格式不支持,就能夠 rollback,保証原來的資料不會被破壞,也不會有不完整的資料。
  2. 把 MilStudio 從檔案系統改為 MUD,加入一個視窗瀏覽MUD的內容。這個過程是挻簡單的。而且利用 Key (代替 path) 做 identifier,出現的 bug 會少很多。例如使用者在 MUD 裡的目錄裡搬一筆資料,不會影響它的 Key,不用更改所有參考到這筆資料的資料。做 MUD 的 TreeView 也比較簡單,不用處理一些 path 的操作 (如計算 path 和 relative path 的結合,更改一個 folder 名字要更改所有 subtree 的 path 之類)。
  3. 重構Script模組的API,讓 Lua 定義一個 “class” (其實是 table)後,能取得它的 “fields” 的名稱、預設值、及跟據預設值決定 field 的型別。之後就是生成 “object” 和修改它們的 “fields”。
  4. 新增 Behavior component,加到 Entity 後用來綁定一個 Lua “class”,和設定 object 的 fields 的初始值。下一步就是在 MilStudio 使用 PropertyGrid 介面去給使用者修改這些值。可是這一步花了我許多時間,所以這文章會談一下當中的問題。
  5. 其他小事項還包括在 Mud 中找所有 Lua 文件編譯、把 class 作為一個 (sharing) resource、解決 Lua 裡取得 C++ reference count object的問題、把每個MilStudio的使用者操作從一個大類別的函數 refactor 為 command pattern 等等。
  6. 昨天星期天,早上六時起床開始重構 Gizmo,也把視角操作 (Rotate/Pan/zoom) 改為 3D Studio Max 的鍵位 (滑鼠中按鈕和 Alt)。現時有 translate、rotate 和 (uniform) scale gizmo,支持 local/world space 操作。
  7. milstudio20090921

    先談 Lua 腳本的物件導向編寫方式,接下來說說跟 PropertyGrid 博鬥的經驗吧。

    用 Lua 編寫類別

    我使用以下的方式定義一個 Lua 類別:

    Box = { extent = Math.Vector3(1, 1, 1), i = 123, s = 'Mil', t = true }
    
    function Box:OnInit()
        print('Box:OnInit() name=' .. self.entity:GetName() .. ' extent=' .. tostring(self.extent))
        local mesh = Engine.Mesh_Cast(self.entity:CreateComponent(Engine.MeshComponentType))
        mesh:SetGeometry(geometry)
        mesh:SetMaterial(Graphics.Material_Default())
    end function
    
    -- ...
    
    return Box
    

    這個 Box 類別 (其實引擎裡是拿不到 “Box” 這個名稱,只是一個 table 傳回值) 裡有4個fields,分別有不同的預設值和型別。因為 Lua 是動態型別的語言,必須有非 nil 的值,這個 field 才能存在。

    在讀入這個 Box 類別時,設定 Box.__index = Box。

    要建立一個 Box 的物件,可以先建立一個 table,內裡可以 override Box 裡的fields,再把那物件的 metatable 設為 Box。例如:

    box1 = { extent=Math.Vector(2, 3, 4), s = 'Lua' }
    setmetatable(box1, Box)
    

    當在 box1 找 field (例如 box1.extent),找到就會使用 box1 的值,找不到就會用 metatable 的 __index 去找,即是找 Box 的 field。function 也是這樣從 class 繼承過來的。

    但使用者其實只需要編寫那個 Lua 類別文件,並在 MilStudio 設定物件的 fields 的值就可以了。Box.__index 和 setmetatable 等都是 C++ 調用 Lua API 去執行的。

    我把編輯後的物件,serialize 成一個 Lua 程序片段,例如把一個 world 存成 XML 時,就可能變成:

    
        
            
                return { extent=Math.Vector(2, 3, 4), s = 'Lua' }
                
            
        
        
    
    

    XML中的class=”257″是那個 Lua 文件在 MUD 裡的 Key。由於 behavior 元素包含的是一個合法的 Lua 片段,所以只要能 return 一個 table,將來還可以加入比較複雜的內容。

    現在,假設從 Script 模組裡,取得一個 Lua class 的 fields 的名稱、預設值及型別之後,怎樣把這些 fields 加進 .NET PropertyGrid 裡呢?

    .NET PropertyGrid 簡介

    PropertyGrid 是 .Net 裡一個強大的控件,它利用反射機制,可以用來編輯 .Net 物件的屬性。編輯一般的物件是很簡單的,只需要把物件設至 PropertyGrid.SelectedObject 就可以,加幾個 attributes 還可以做簡單的客制化,下面是一個例子:

    public class Monster
    {
        string name = "Slime";
        int maxHP = 100;
    
        public string Name
        {
            get { return name; }
            set { name = value; }
        }
    
        [DisplayName("Maximum HP")]
        [DefaultValue(100)]
        [Category("Stats")]
        public int MaxHP
        {
            get { return maxHP; }
            set { maxHP = value; }
        }
    }
    
    public partial class Form1 : Form
    {
        Monster monster = new Monster();
    
        public Form1()
        {
            InitializeComponent();
    
            propertyGrid1.SelectedObject = monster;
        }
    }
    

    propertygrid1

    但是,現在的問題是,我們只能在執行期才知道要編輯的資料,其名稱、預設值和型別都是動態更新的。

    用PropertyGrid 的動態挷定

    一個解決方法是編寫一個實現 ICustomTypeDescriptor 介面的類別。這個介面有一個 GetProperties() 函數,它回傳一個 PropertyDescriptorCollection 集合物件,裡面存放該類別的屬性資料。在這個應用中,只要在這個 GetProperties() 函數裡取得 Lua class 的 field 資料(名稱、預設值及型別),建立一個PropertyDescriptorCollection 就可以了。

    每個 PropertyDescriptor 可以重新定義一些函數,包括:

    • Type PropertyType { get; } 傳回這屬性的類型
    • object GetValue(object component) 傳回這屬性的值
    • void SetValue(object component, object value) 設定屬性的值
    • ResetValue(object component) 重置屬性的值

    當中,component 是指擁有這屬性的物件,但並不一定需要它,因為建構一個繼承 PropertyDescriptor 的物件時,可以傳入一些變量。

    以下是源代碼,比較冗長。

    public class BehaviorFieldCollection : ICustomTypeDescriptor
    {
        private Behavior behavior;
    
        public BehaviorFieldCollection(Behavior behavior)
        {
            this.behavior = behavior;
        }
    
        public String GetClassName()
        {
            return TypeDescriptor.GetClassName(this, true);
        }
    
    /*
        // The following are similiar to GetClassName(), just delegates to TypeDescriptor
        public AttributeCollection GetAttributes() { ... }
        public String GetComponentName() { ... }
        public TypeConverter GetConverter() { ... }
        public EventDescriptor GetDefaultEvent() { ... }
        public PropertyDescriptor GetDefaultProperty() { ... }
        public object GetEditor(Type editorBaseType) { ... }
        public EventDescriptorCollection GetEvents(Attribute[] attributes) { ... }
        public EventDescriptorCollection GetEvents() { ... }
    */
    
        public object GetPropertyOwner(PropertyDescriptor pd)
        {
            return this;
        }
    
        public PropertyDescriptorCollection GetProperties(Attribute[] attributes)
        {
            return GetProperties();
        }
    
        public PropertyDescriptorCollection GetProperties()
        {
            Runtime runtime = behavior.GetClass().GetRuntime();
            PropertyDescriptorCollection pds = new PropertyDescriptorCollection(null);
    
            Class cls = behavior.GetClass();
            if (cls != null)
            {
                uint fieldCount = cls.GetFieldCount();
                for (uint fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++)
                {
                    string name = cls.GetFieldName(fieldIndex);
                    cls.GetFieldDefault(name);
                    string scriptType = runtime.GetParameterType(0);
                    object defaultValue = RuntimeType.PopObject(runtime);
                    Attribute[] attrs = new Attribute[] { new DefaultValueAttribute(defaultValue) };
    
                    pds.Add(new BehaviorFieldDescriptor(behavior, name, scriptType, defaultValue, attrs));
                }
            }
    
            return pds;
        }
    
        // If there is no ToString(), it will display the type name (BehaviorFieldCollection) in the value of Fields.
        public override string ToString()
        {
            return string.Empty;
        }
    }
    
    public class BehaviorFieldDescriptor : PropertyDescriptor
    {
        private Behavior behavior;
        private string scriptType;
        private object defaultValue;
    
        public BehaviorFieldDescriptor(Behavior behavior, string name, string scriptType, object defaultValue, Attribute[] attrs)
            : base(name, attrs)
        {
            this.behavior = behavior;
            this.scriptType = scriptType;
            this.defaultValue = defaultValue;
        }
    
        public override bool CanResetValue(object component)
        {
            return true;
        }
    
        public override Type ComponentType
        {
            get { return typeof(BehaviorFieldCollection); }
        }
    
        public override bool IsReadOnly
        {
            get { return false; }
        }
    
        public override Type PropertyType
        {
            get { return RuntimeType.ConvertScriptType(scriptType); }
        }
    
        public override object GetValue(object component)
        {
            behavior.GetClass().GetField(behavior.GetObject(), this.Name);
            return RuntimeType.PopObject(behavior.GetClass().GetRuntime());
        }
    
        public override void SetValue(object component, object value)
        {
            if (RuntimeType.PushObject(behavior.GetClass().GetRuntime(), scriptType, value))
            {
                behavior.GetClass().SetField(behavior.GetObject(), Name);
                behavior.SerializeObject();
                behavior.CreateObject();
            }
        }
    
        public override void ResetValue(object component)
        {
            behavior.GetClass().ResetField(behavior.GetObject(), this.Name);
            behavior.SerializeObject();
            behavior.CreateObject();
        }
    
        public override bool ShouldSerializeValue(object component)
        {
            object value = GetValue(component);
            if (defaultValue == null || value == null)
                return false;
            else
                return !value.Equals(defaultValue);
        }
    }
    
    partial class Behavior
    {
        [TypeConverter(typeof(Mil.Mud.ResourceConverter))]
        [DefaultValue(null)]
        [DisplayName("(Class)")]
        public Class Class {
            get { return GetClass(); }
            set { SetClass(value); }
        }
    
        [TypeConverter(typeof(ExpandableObjectConverter))]
        public BehaviorFieldCollection Fields
        {
            get	{ return new BehaviorFieldCollection(this);	}
        }
    
        public object GetField(string fieldName)
        {
            PropertyDescriptor pd = Fields.GetProperties()[fieldName];
            if (pd == null)
                return null;
    
            return pd.GetValue(null);
        }
    
        public bool SetField(string fieldName, object obj)
        {
            PropertyDescriptor pd = Fields.GetProperties()[fieldName];
            if (pd == null)
                return false;
    
            pd.SetValue(null, obj);
            return true;
        }
    }
    

    每次呼叫 SetValue(),就是使用者改變了 field 的值的時候,都把這個 Lua 物件 serialize,再從新創建,創建時會調用新 object 的 OnInit() 成員函數。這麼做,更改值之後就能即時看到結果了。

    最後的 GetField() 和 SetField() 是方便 C# 中存取 Lua object 的 field。我暫時只用在一些 MilStudio 的測試裡。

    propertygrid2

    如果讀者細心看上面的截圖,會發現每個component 是一個 category (正常一該是一個物件,再按加號展開),而且按動Entity裡的Component開關,應該可以看到新的Component的屬性。對了!這也是動態的!不過這次因為不是一個屬性(是Entity類別),只要把Entity用一個繼承自TypeConverter的類別,在該類別的 GetProperties() 函數輸出 PropertyDescriptorCollection。這就是另一種動態挷定的方法。

    因為 MSDN 對這部份的解說非常有限,開發時遇到很多問題。例如把 Vector3 (一個 reference type) 加入ExpandableObjectConverter,就可以編輯當中的x、y、z屬性。可是在Transform component中的Translate (一個Vector3 的屬性) 修改後卻不起作用! 因為每次它都傳回一個新的 Vector3 物件,而 PropertyGrid 不會調用 Translate 屬性的set。這個問題就花了我大半天的時間,後來找到可以把TypeConverter.GetCreateInstanceSupported()傳回 true,並更改 TypeConverter.CreateInstance() 函數去解決這個問題。後者中創建一個Vector3,PropertyGrid就會用它呼叫Translate屬性的Set。

    我發覺我不太喜歡解決這種 API 上的問題,而且有時候還要花費很多精力,卻只有很少產出。比較想花時間在設計上。

    後記

    這兩個星期每天晚上和周休二日都在編這個東西,進度已快了很多,但離實用還有一大段距離。如果能維持這個速度,也許年末能做點小遊戲。不過要維持這速度可能不容易,最近買的新書都沒時間看。

    我目前還在考慮是否把這小東西變為開源,主要是擔心沒能力提供支援,另外是有參與者的話又會產生問題。不過沒經歷實際做遊戲的工具是不行的。希望實現了一些基本的功能後,可以先讓自己或給朋友做一些遊戲。

    P.S. 我現在是用非常慢的 TOR 翻越長城

    P.P.S. 我這星期六就回香港放半個月假了

Comments:5

半路 09-09-22 (二) 9:31

歡迎回來,好久沒看到你的消息哩!

用 PropertyGrid 結合 Lua 製作 Editor 是個好主意,
當然如果有時間,能夠將 Mil Engine/Editor 弄成開源專案就更好了!

不過,剛經歷完 crunch,還是先度個假打打遊戲吧~ ^_^

Ricky 09-09-22 (二) 13:00

想問 Mud 有沒有樹狀結構? 沒有的話就當然簡單了;也可以用 tag 取代 folder。

Mud 有 Transaction 又的確很吸引…

誰又會喜歡解決哪些 API 上的問題? 正因為這樣,我們作為設計師就要時刻為使用者著想。

呵… 已把使用者操修改為 command pattern。 建議一併考慮 Gui thread 的問題, 否則 Lua debugging 會有可能 block 左 Gui。

Milo 09-09-22 (二) 13:43

Mud 是有樹狀的目錄系統。其實檔案名和目錄都是 Mud 的一個層階式檔案系統(上回說到)。這系統只是給使用者組織檔案而已,是建立 Mud 檔案時可選的,底層的 references 只使用Key table。正式版本的 Mud 可以用 MudFile::Compact() 把檔案系統除去。

整個 Mud 的代碼其實是一千多行 C++ 左右,寫的時候已盡量把它獨立起來。希望其他項目或將來 open source 後也可以獨立使用。Mud的預想功能暫時還餘 dependency table,未想到怎麼可以容易讓上層使用。其他例如 compression 之類的應該不困難吧。噢……還要做 versioning 的整合。

API 的問題,我覺得主要是沒有文檔去說明一些 concepts,只是看 API Reference 去理解怎麼用一個東西是很難的。沒有 source code 就更難去解決問題。

我還未看 debugging 怎麼做,從未寫過 debugger,這次可以試一試,學新東西了。

Stephen 09-09-29 (二) 14:53

你有份參與Cloudy with a Chance of Meatballs的製作? 很厲害呢! 香港有這種工作嗎?

Milo 09-09-30 (三) 9:58

香港做 console game production 的應該很少。只知道現時有一家做 current-gen、有一家有做 portable console。

Comment Form
Remember personal info

Trackbacks:0

Trackback URL for this entry
http://miloyip.seezone.net/wp-trackback.php?p=119
Listed below are links to weblogs that reference
半年近況,並談 .NET PropertyGrid 的動態屬性挷定 from Milo的遊戲開發

Home > 工具編程 | 腳本編程 > 半年近況,並談 .NET PropertyGrid 的動態屬性挷定

Search
Feeds
Meta

Return to page top