Home > 遊戲編程 > 腳本編程

腳本編程 Archive

半年近況,並談 .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. 我這星期六就回香港放半個月假了

平台遊戲的遊戲性實驗

經歷接近一年,這是本 Blog 第一個可「玩」的小測試。測試程式(包括 Lua 源程序)可在本文章末下載。

由於 MilStudio 等著 MUD 模組才好繼續,就先來做一個平台遊戲的遊戲性實驗,過程非常有趣 (可能是因為很多個月裡只是在做引擎和工具),所以這星期一口氣完成了 10 個 stories。

實驗內容

  1. 控制角色,做出走路和跳躍兩個動作。
  2. 繞著角色迴轉的攝影機,角度會按主角移動而自動迴轉,玩家也可以改變角度。
  3. 加入一些箱子,可與角色作物理互動 (角色能推動箱子,箱子也能影響角色)。
  4. 加入移動平台,它們會在兩點中循環移動。角色站在平台上會受平台的慣性影響。例如角色站在水平均速移動的平台向上跳,他掉下來時在平台的位置應該和跳的時候一樣。

Continue reading

開始實作引擎的基本類別

話說之前想做 Collada 滙入,便最基本要有一個內部存放 Collada 滙入後的資料。這個「雙休日」(內地常用語) 就留在家中乖乖寫程式……寫不出的時候也看了十幾集動畫。

架構上參考了之前看過的 Unity,採用 Component 型式。目前實現的幾個類別的關係如下圖:

採用 Component 架構的一個好處是,減少了傳統 Scene Graph 的類別繼承和節點 (node) 數目。一個實體 (entity, 有些引擎稱為 Game Object) 可以有 Mesh 元件,也可以同時加入腳本、碰撞物件等元件。

實作過程中出現許多問題,和大家 (及將來的自己) 分享一下。

Continue reading

使用 Custom Build Tool 執行 SWIG

今晚完成了近期的第三個目標,加入 Script Module 的 Binding (其實只是一個叫 Runtime 的類別),利用 C# 去執行之前的 Lua 的程序 (test1.lua)。

今早 Edwin 的回應啟發了一些想法,所以著手更改 SWIG 在 Visual Studio 的編譯方法。

現在採用了Visual Studio 的 Custom Build Rule。由於 Lua 和 C# 需要不同的規則,所以要兩個 file extensions,我設定 Lua 的 Swig Interface 檔案為 .li,C# 則是 .ci。

Lua

swig
 -c++
 -lua
 -o $(OutDir)/$(InputName)Lua.cxx
 "$(InputPath)"

C#

swig
 -c++
 -csharp
 -o $(OutDir)/$(InputName)Cs.cxx
 -outdir $(OutDir)
 -namespace Mil.$(InputName)
 -dllimport $(TargetName)
 "$(InputPath)"

生成的檔案會放置於 Debug 或 Release 的輸出目錄,解決之前兩個 configuration 會互相影響的問題。

兩個規則分別生成 $(OutDir)/$(InputName)Lua.cxx 和 $(OutDir)/$(InputName)Cs.cxx,這些檔案需要加入 Project 裡。但因為一個項目不應該同時加入 Debug 和 Release 版的這些檔案,我想到的方法是寫一個 .cpp 去 #include 這些檔案,例如:

// MathCs.cpp
#include "stdafx.h"
#include "MathCs.cxx"

之後再把 Project 的 Include path 加入 “$(OutDir)”,那麼,就可以正確地編譯現時 Configuration 的 Swig 生成檔案。

後來再試驗檔案的 Additional Dependencies 屬性,設定單一檔案仍然不成功。之後我把 .li 和 .ci 裡 include 的檔案改名為 .lii 和 .cii,希望只要可設置 dependencies 為 *.lii 和 *.cii 就可以了,但都不成功。或許可以用 Pre-Build Event 去檢查這些檔案,如比輸出檔案新就 touch 那個 .li 或 .ci。不過未嘗試。

最後,還有一個問題是 C# 的 Proxy 需要編譯多個由 SWIG 生成 .cs 檔案。我找了一回才發現 C# 沒有 #include、也似乎沒有機制可以編譯 *.cs 等多個檔案。最後暫時用 C++ Project 的 Post-Build Event 把適當 Configuration 的檔案拷貝到 C# 的項目裡,再人手加入這些檔案。

copy $(OutDir)\*.cs ..\MilCs

Lua 的渲染測試

今日順利完成3月6日訂下的第二個目標,把昨天的 C# 測試程式用 C++ 及 Lua 實現。圖像輸出和昨天的測試程式一模一樣,就不上傳了。

C++ 負責創建視窗和呼叫 Device::Open(Hwnd),Lua 做渲染的部份。(其實在遊戲中是不會用 Lua 做這麼低階的工作,這裡只是當作測試 Lua Binding。)

除了編寫 Lua 的 SWIG interface 檔案外,今天主要的工作是考慮如何從 C++ 呼叫 Lua 的類別。發覺對 Lua 的語法還是很不熟識。

有興趣的話可以比較以下的C++/Lua代碼和昨天的C#代碼

Continue reading

解構 CryEngine2 的 Script 物件模型(一)

這幾天看了一點 Crysis 的 Script。CryEngine2 和 CryEngine 一樣是使用 Lua 為 Script 語言。CryEngine2 把 Lua 升級上 5.x 的版本。

雖然 CryEngine2 的功能實在比 CryEngine 進步許多,可是,有點失望的是 Crysis 的說明文檔比 Farcry 少,甚至最近才釋出的 SDK 也沒有 Script 的 API 參考文檔。暫時只看了一篇官方的 “Creating a new entity”文章和 Crysis 遊戲裡的 Script,所以很多以下的內容是未經証實的猜想。

這是部份 Script 物件模型的 UML 類別圖:

crysis.png

由於 CryEngine 的 Script 是 loosely typed,實際上並沒有真正的 Base、EntityBase 等類別和繼承存在。這圖所顯示的,只是我從例子中找到的一些共同部分,把它組合成類別及繼承。由於時間關係,我需要分開數天研究不同的「類別」,今天主要集中看 Entity。

Continue reading

解構Unity的Script物件模型

Unity 是一個以 Mono 為基礎的遊戲開發環境,能同時支持三種腳本語言,包括 C#、Javascript 和 Boo (類似 Python)。

由於 Unity 的開發工具暫時只有 Mac 的版本,所以暫時未能測試。但是它有很詳細的文檔,看上來很易用,所以就從文字上學習它的 Script 使用方式。

跟據一些 Tutorial參考手冊,我用 Graphviz 畫了一個 (我認為) 最核心的 UML 類別圖:

unity1.png

從這個類別圖我們可以理解它的結構,及如何把一些常用功能映射至這系統裡,以下分節討論。

Continue reading

混合語言的遊戲開發系統架構

用甚麼程式語言來做軟件是一個大問題,思考了一個周末,現時想做一個混合語言的遊戲開發系統架構。暫時只考慮三種程式語言: C++、C# 及 Lua。以下首先分析這三種語言的特性,之後再提出一個系統架構科案。

Continue reading

Home > 遊戲編程 > 腳本編程

Search
Feeds
Meta

Return to page top