一個基於組件的動態對象系統

轉載自: http://job.17173.com/content/2009-08-07/20090807104220649,1.shtml


文 封燁

一、靜態的痛苦

    作爲一個項目經驗豐富的程序員,你經常會遇到遊戲開發過程中的“反覆”(iterations):今天美術將一個靜態的模型改爲骨骼模型並添加了動畫;明天企劃會議上決定把所有未拾取武器由原先的閃光效果改爲原地旋轉;後天你的老闆告訴你:配合投資方的要求,需要提升AI的質量,這使得AI需要響應特定的碰撞檢測、可破壞的路徑變化,甚至彼此的交互。哦,修改設計,按照教科書上的做法我們必須對現有代碼進行重構,你回答道。但你的老闆顯然不這麼認爲。儘管全體程序員一致地、強烈地反對,項目經理還是決定要在一週內把這些改動全部付諸實施。這是一場噩夢不是嗎?於是工程上的禁忌、代碼層面的犯罪……各種各樣醜陋不堪的東西寫進了遊戲程序,除此之外,你還搭上了週末和女朋友約會的時間。更糟糕的是,當你週一凌晨提交代碼後,發現原本“健壯”的遊戲程序,經常莫名奇妙地崩潰,這讓你的老闆在投資方那裏出盡了洋相……後果可想而知。

    當然這不能完全責怪你的項目經理和老闆,畢竟遊戲不是一道純軟件大餐。而我相信,你的遊戲只要還被當作一件藝術品來製做,就永遠無法避免反覆。既然它至榛完美的必經之路就是設計上的反覆,那麼我們總有辦法將它的衝擊降至最小。這裏我要討論的是一個基於組件的對象系統:在遊戲層中,它可以使對象行爲的改變變得異常簡單,甚至可以在無需程序員介入的情況下,由企劃或設計師來動態組合成新類型的對象,而作爲應用該系統的一個副產品,它還能爲你的遊戲層代碼降低耦合度。下面讓我們來看看,傳統情況下我們是如何設計遊戲層的:

    所有的物體都是一個Object。它作爲遊戲中所有類型的基類,由許多子類來繼承,諸如Renderable、Movable、Collideable等等。顧名思義是爲可渲染對象、可移動對象、和計算碰撞的對象準備的基類。繼承自Renderable又有一個名爲Animatable的類,顯然有經驗的你也能猜到它具有賦予類型以動畫的功能。在Collider之下有一個Inventory類,它定義了可拾取物件的一些規則。在此之下就是一些具體的類,例如會進行動畫的、可移動的Character人物類,以及只能渲染靜態物件的、可拾取的Weapon類、Item類、Armor類。這樣一個簡單的類繼承體系可以由圖1來表示。 


  

 圖1  一個傳統的、典型的、看上去不錯的繼承體系

    嗯,這個繼承體系看上去合理且乾淨,絕對可以做教科書中的範例,而且對於這個簡單系統來說能工作得很好,直到有一天企劃的設計發生了修改。就像之前提到的,企劃們從測試員或內測玩家中獲得了反饋:武器或者道具掉落在地上,如果沒有一點顯眼的表示,玩家很難注意到,甚至會讓整個遊戲顯得死氣沉沉。於是他們告訴你武器掉落在地上需要原地旋轉,就像Quake那樣,而道具掉落在地上,每隔2秒要閃爍一下。你對照着類繼承圖比劃了一下,覺得可以把Inventory類的繼承關係從Collideable下轉移爲多重繼承Collideable和Animatable。於是你開始修改類繼承結構,儘管Armor不需要播放動畫,一個空函數就可以打發它了。那麼這個問題目前算是被解決了。可是好景不長,關卡企劃覺得目前剛體物理的效果還不錯,決定廣泛應用這一特性,而他失望地發現很多物件都沒有剛體物理的效果,只有RigidBody才擁有這項功能,而它的實現只有一些簡單的盒子一類的物體,用於做關卡設計。於是他告訴你需要把屏幕上能看到的物體,儘量都賦予剛體特性。你同他爭執了一段時間,最後你妥協了,把Renderable整個拉到RigidBody繼承體系下。這樣儘管Tree和Character並不能按照一個簡單剛體來運動,但至少Weapon、Item、Armor可以了。在折騰完關於剛體物理對象的改動之後,你再度審視這個繼承體系時,發現它已經不像原先那般優雅了:大量定義接口的基類被放在繼承樹的上方,而下方都是零散的各個具體類。這很讓人倒胃口,你這麼想着,打算着手真正重構目前的代碼。但時間不等人,第二天企劃又告訴你,他需要用腳本來控制這些剛體對象的位置,這下連Movable都無法倖免,你必須把它移動到RigidBody之上,讓所有的具體類都能繼承它。這樣一個頭重腳輕(top-heavy)的繼承樹簡直是一個教科書式的反面教材(如圖2所示)!堅持原則的你實在看不下去了,向項目經理提出了質疑,要求砍掉這個功能,或者開闢額外的時間讓你重構代碼。但是很不幸,很多情況下,項目經理是不會理睬這種要求的。 


  

 圖2 在許多“合理”的設計改動後,繼承樹往往變成了這種頭重腳輕的樣子

    如此這般的設計,爲什麼無法滿足遊戲的快速反覆的開發需要呢?我想主要原因有二:一是C++和其他強類型語言在繼承上的強制性;二是我們恰恰讓繼承做了它所不擅長的事情。繼承在很多強類型語言中,是一個靜態的語言行爲,是在編譯期決定的,而且對一個較大的繼承體系的修改,不但面臨重重困難,而且將會對之後的系統產生深遠影響。繼承的這種特性決定了它不適合類型行爲經常變更的場合,或者說在類型行爲經常變更的場合中,僅僅使用繼承很難解決矛盾。那除了繼承,語言的其他特性是否能滿足我們對對象類型這種近乎變態的反覆要求呢?答案之一就是組合,或者聚合,直觀一點就叫“has-a”的關係。倍受推崇的《設計模式》一書中,也建議儘量使用對象組合而非類繼承。該書開宗明義寫道:“1、對接口編程,而不是對實現編程;2、優先考慮使用對象組合,而不是使用類繼承”[GoF 94]。至於原因,在書中也有很精闢的論述:“我們的經驗顯示,架構師經常過分強調將繼承作爲重用技術,而事實上,如果着重以對象組合作爲重用技術,則可以獲得更多的可重用性以及簡單的設計”[注1]。


二、動態的優雅

1、組合

    既然大師們是這樣說的,我們不妨回頭看看遊戲層的系統。假設我們要設計這樣一個“武器”的類,類似上面的那個例子,它需要能渲染、能播放動畫、能移動位置,甚至在掉落在場景中時,它還具備剛體物理的特性。於是可以整理出如圖3這樣的類: 
  

 圖3 一個典型的由組件組合而成的對象。


    可以看到,一個Weapon對象就是簡單地由IRenderable、IAnimatable、IMovable以及IRigidBody這些具體的組件組合而成的。在下文裏,我就把組合成對象的這些功能性的類,稱爲組件。

    哦,功能倒是都組合在一起了,但我怎麼使用這些組件呢?它沒有任何可供調用的方式!經常使用基類接口的你開始注意到這個問題。在傳統設計中,我們通常需要一個統一的基類接口來操作多個對象,而這些接口被聲明爲虛擬的,以便我們在類層次中實現多態(若接口不是虛擬的,則其調用的實現函數就是虛擬的),而在客戶端,我們一旦能獲得這個接口就能以統一的方式來處理所有從這個類繼承的類型。這種做法對於有經驗的你早就像吃飯睡覺一般熟悉了,如果不能通過統一接口來處理多種對象,恐怕很多人要難過到死。我們的組件,也是由一個通用的組件基類接口定義的,權且稱他爲IComponent,實現各自功能的組件,需要各自擴展這套接口。例如渲染組件IRenderable,可能需要擴展一個Render()方法;而動畫組件IAnimatable,可能要做的是擴展一個Update(float)方法用以更新動畫;IMovable組件就需要SetPosition()/GetPosition()之類的接口等等。有了這些組件接口之後,我們的組件類就直接實現這些接口。例如Renderable就實現IRenderable::Render()。定義接口的優點在於,你可以通過一個對象的句柄,查詢這個對象是否實現了某一組件的接口。如果回答是肯定的,則可以返回一個指向該組件的指針,而指針的類型是接口類,這樣客戶端代碼就可以調用這些組件的實際功能了。如果回答是否定的,則返回空指針,意味着該對象並未實現指定的接口,查詢失敗。圖4比較好的說明了這個問題。 


  

 圖4 需要使用組件接口時,要對ObjectManager查詢組件接口。

2、無中生有

    既然對象都是由組件組成的,那麼對象本身就可以非常精簡,甚至連一個組件的指針都不用儲存,而將組件管理的工作可以交給對象管理器去做,我們暫且叫它ObjectManager。這樣,世界上就不存在名叫Car的對象,也不存在叫Dog的對象,它們不過是一些組件的組合而已,只是在ObjectManager一側的記錄中,有着Dog所擁有的組件,以及Car所擁有的組件。當對象需要行爲的時候,客戶端代碼就向ObjectManager索取對應的組件接口,比如:

代碼1

IRenderable* renderable = static_cast<IRenderable*>(objectManager->QueryInterface(object, TYPE_IRenderable)); 
或者寫一道宏指令以減少筆誤:

代碼2

IRenderable* renderable = QUERY_INTERFACE(objectManager, object, IRenderable); 
之後你就像平常一樣操作這個組件:


代碼3

renderable->PreRender(); 
... 
renderable->Render(); 
... 
renderable->PostRender(); 
所有不同類型的ObjectHandle看起來都是差不多的,他們的區別只在於記錄在ObjectManager裏的組件不同而已。所以,忘掉類型的概念吧!在這個世界中,只有組件的組合,沒有死板的類型。

3、即插即用

    那麼如果企劃再對我的Weapon提出什麼非份的要求,怎麼辦?擔驚受怕的你繼續問道。很簡單,如果Weapon類還需要其他的功能,只要這個功能已經以組件形式實現了,那麼你完全可以讓他自己搞定!因爲基於組件的對象系統中,一個具體的對象已經沒有靜態的“類型”概念了,只要我願意,我可以對某個對象添加任意的組件功能,即使它看上去多麼荒謬:

代碼4

ObjectHandle* weapon = objectManager->CreateObject(); // 創建了一個赤身裸體的對象,它還沒有任何功能。 
objectManager->AddComponent(weapon, TYPE_IRenderable); // 對象擁有了渲染的組件,及其功能。 
objectManager->AddComponent(weapon, TYPE_IProceduralAnimatable); // 武器也需要過程動畫嗎?無論怎樣的組件都能添加。

    對象的能力不再“靜態”地由繼承關係決定,而是由一組扁平組織起來的組件“動態”地、自由地組合而成。只要組件實現得足夠健壯,我們可以放心地生成任意“類型”(或稱組合)的對象,而不用擔心設計上的修改和對象臃腫的問題。

    嗯,這樣的對象足夠靈活,但還不夠!我們可以解析描述對象的XML文件,從其中讀取的信息裏,決定我們要生成什麼樣的對象,以及添加怎樣的組件。

代碼5

void CreateObjectFromXml( XmlNode* pNode ) 

    ObjectHandle* object = NULL; 
    if ( pNode->GetName() == TEMPLATE_WEAPON ) 
    { 
        object = objectManager->CreateObject(); 
        objectManager->AddComponent( object, TYPE_IRenderable); 
        objectManager->AddComponent( object, TYPE_IProceduralAnimatable);

        IProceduralAnimtable* procAnim = static_cast<IProceduralAnimtable*>(objectManager->QueryInterface(object, TYPE_IProceduralAnimtable)); 
        ASSERt(procAnim); 
        procAnim->SetSeed( Rand() ); 
        procAnim->SetIteration( pNode->GetAttribute("Num_Iteration").ToNumber() ); 
    } 
    else 
    ....
}

    哈哈!這樣我們可以把這些添加功能的工作,扔給企劃寫XML去了。而且我們可以爲某些特定“類型”的對象定義模板,不用每次創建對象時都一個個手動添加類型。更上一層樓的做法是,把這套系統暴露給腳本系統,讓腳本也可以創建自己的組件,同時也能使用C++已定義的、且暴露給腳本的組件。這樣連企劃也能使用腳本來創建新類型的組件,然後隨他們高興去創建、組合對象,反正隨便他們怎麼折騰都行。我們不僅能讓數據來驅動對象的“內容”,還能驅動對象的“類型”,真真正正地做到了數據驅動,不是嗎?

    當然,這種方法也是有負面效果的,任何方法都不可能完美。負面效果就是——組合對象變得太過容易了,一不小心企劃就創建了成千上萬種不同對象,對於遊戲平衡調整的複雜度也會隨之提高,不過,這就是企劃的份內事務了。

4、深化交流與合作 

    理想情況下,組件之間應該不進行通信。但如果組件之間完全不進行通信,那麼這個遊戲估計也不怎麼吸引人了。組件之間進行通信的方式一般有兩種:其一是通過查詢接口,直接獲得其他組件的接口,通過調用函數的方式進行通信。例如: 

代碼6

void Renderable::Render() 

    // 需要獲得對象在世界空間的位置 
    IMovable movable = static_cast<IMovable*>(objectManager->QueryInterface(object, TYPE_IMovable)); 
    ASSERt(movable); 
    const Point3& pos = movable->GetPosition(); 
    ... 
    // 通過獲得的位置信息進行繪製。
}

    這種通信方式會增加代碼之間的耦合性,但適合於需要知道特殊接口,或需要保證調用順序的場合。 
    第二種方式就是事件(或消息)。通過事件和消息,也迫使代碼之間的耦合度下降。例如:

代碼7

objectManager->SubscribeEvent(TYPE_IAnimatable, EVENT_Tick, new MemberFunctor(Animatable::OnTickEvent)); 
...

void Animatable::OnTickEvent(float tick) 

    _fElapsedTime += tick;
}

    而在發生事件的組件中,只需要調用以下方法即可以讓所有註冊的回調函數響應:

代碼8

objectManager->FireEvent(EVENT_Tick, tick);

    基於事件或消息的通信方式,由於它的調用取決於註冊順序,適合於無需保證調用順序的場合。

    最後在我們的場景中,對象之間的組織如圖4所示: 
  

    圖5 我們的場景像是一個由組件組成的二維表格,表格的列是同一組件類型的實例,而每個對象就是表格的一行,它可以自由選擇是否需要某列提供的組件功能。


三、着手實現

    該是着手寫一些代碼的時候了[注2]。基於上述應用的代碼,我們肯定需要一個IComponent的接口,作爲所有組件的基類:

代碼9

   public interface IComponent 
    { 
        void Init(ObjectId oid, ObjectManager objMan);

        ObjectId ObjectId 
        { 
            get; 
        } 
    }

    這個接口只定義了一個組件的最小功能集,它所做的就是保留ObjectManager的句柄和所屬對象的句柄。根據IComponent接口,我們可以衍生出更多的接口:

代碼10

    interface IComponentMovable : IComponent 
    { 
        Vector3 Position 
        { 
            get; 
            set; 
        } 
... 
    }

    interface IComponentRenderable : IComponent 
    { 
        void Tick(float dt); 
        bool Draw(); 
    }

    這兩個接口分別定義了可移動對象以及可供渲染的對象的基本接口。在客戶端代碼中,基本上用戶只需要面對的就是這些接口,而不用關心其實現。現在對於這些接口分別實現它的具體類:

代碼11

    class ComponentMovable : IComponentMovable 
    { 
        ObjectId _oid; 
        Vector3 _pos;

        public ComponentMovable() 
        { 
            _pos = new Vector3(); 
        }

        public void Init(ObjectId oid, ObjectManager objMan) 
        { 
            this._oid = oid; 
        }

        public ObjectId ObjectId 
        { 
            get 
            { 
                return _oid; 
            } 
        }

        // 有關世界位置的屬性 
        public Vector3 Position 
        { 
            get 
            { 
                return _pos; 
            } 
            set 
            { 
                _pos = value; 
            } 
        } 
        ... 
    }

    class ComponentRenderable : IComponentRenderable 
    { 
        ObjectId _oid; 
        ObjectManager _objMan;

        public ComponentRenderable() 
        { 
        }

        public void Init(ObjectId oid, ObjectManager objMan) 
        { 
            this._oid = oid; 
            this._objMan = objMan; 
        }

        public ObjectId ObjectId 
        { 
            get 
            { 
                return _oid; 
            } 
        }

        public void Tick(float dt) 
        { 
            // 有關幀更新的東西 
        }

        public bool Draw() 
        { 
            // 有關渲染的東西 
            return true; 
        } 
        ... 
    }

    以上這些具體類將會提供我們組件的基本能力。而這些組件的具體實現,一旦註冊到對象管理器後,客戶端程序員就無需再關心它了。作爲庫的提供者,我們甚至可以把這些實現類完全隱藏起來,讓客戶端的程序員以數據驅動的方式註冊這些類型,就像上節中解析XML的函數所作的一般。

    由於對象的功能都是由組件提供的,對象本身的表示將會非常簡單,它只需要一個標識自己的標記就可以了。很多程序語言支持將地址或句柄作爲對象的唯一標識,所以有時候連這個標識都可以去掉。不過爲了除錯的目的,我們還是爲它加上了一個描述自身的字串:

代碼12

    public class ObjectId 
    { 
        private string _desc; 
        public ObjectId(string desc) 
        { 
            _desc = desc; 
        }

        public string Description 
        { 
            get { return _desc; } 
        } 
    }

    我們的設計是想讓客戶端所需的先驗知識儘可能的少,只有對象句柄ObjectId、所需的接口類型IComponentXXX,以及ObjectManager的方法。可以說ObjectManager是這個系統核心部件。下面就讓我們來看一下ObjectManager是如何上演這出把戲的。首先我們需要讓ObjectManager創建對象,並把對象句柄返回給調用端,這可以有如下的簡單實現:

代碼13

public class ObjectManager 

        private Dictionary<ObjectId, List<IComponent>> object2ComponentList; 
        ... 
        public ObjectId CreateObject(string desc) 
        { 
            ObjectId oid = new ObjectId(desc); 
            // 爲新的對象創建其所擁有的組件列表 
            object2ComponentList[oid] = new List<IComponent>(); 
            return oid; 
        } 
}

    接下來客戶端需要做的就是爲對象添加組件。而這個添加組件的工作也相對簡單:

代碼14

public class ObjectManager 

        ... 
        public bool AddComponentToObject(ObjectId oid, IComponent componentInstance) 
        { 
            List<IComponent> componentList;

            if (object2ComponentList.TryGetValue(oid, out componentList)) 
            { 
                // TODO: 需要保證同一對象中註冊的組件類型唯一 
                componentInstance.Init(oid, this); 
                componentList.Add(componentInstance); 
                return true; 
            }

            throw new ObjectNotFoundException(oid); 
        } 
}

    一旦爲某個對象添加了組件,其他代碼就可以通過QueryInterface的方法來獲得某一類型的組件的指針:

代碼15

public class ObjectManager 

        ... 
        public IComponent QueryInterface(ObjectId oid, Type interfaceType) 
        { 
            List<IComponent> componentList;

            if (object2ComponentList.TryGetValue(oid, out componentList)) 
            { 
                // 在註冊列表中,查詢組件類型。如果不熟悉C#匿名方法和委託:這裏其實就是一個簡單的查找, 
                // 只不過查找條件是由Type.IsIntanceOf()的結果來決定的。 
                IComponent findResult = componentList.Find( delegate(IComponent component) 
                    { 
                        if (interfaceType.IsInstanceOfType(component) ) 
                            return true; 
                        else 
                            return false; 
                    }); 
                return findResult; 
            }

            throw new ObjectNotFoundException(oid); 
        } 
}

    如果查詢結果成功,則安全返回組件接口引用。查詢不到則返回空句柄。如果對象句柄本身也沒能查詢到,則拋出一個異常以示抗議。到目前爲止,ObjectManager已經可以做到生成對象、爲對象註冊組件、並提供外界查詢組件接口的功能。這樣,客戶端代碼已經可以組合複雜對象,並通過查詢接口的方式在組件之間進行通信(見前一節)。客戶端代碼已經可以這樣寫:

代碼16

ObjectId oid = objectManager.CreateObject(); 
objectManager.AddComponent(oid, new Renderable()); 
objectManager.AddComponent(oid, new Movable()); 
... 
IRenderable* renderable = objectManager.QueryInterface(oid, typeof(IRenderable)); 
renderable->Render();

void Renderble::Foobar() 

    IMovable movable = objectManager.QueryInterface(_oid, typeof(IMovable)); 
    DoSomethingWithPosition(movable.Position);
}


    不過要實現通過消息或事件的通信方式,需要再加把勁。C#由於有方便的委託機制,示例代碼中就使用了這種語言特性。而如果使用C++或者實現一個泛型函數綁定嫌麻煩的話,完全可以使用基於消息的機制,也足夠方便:

代碼17

public class ObjectManager 

        // 記錄逐事件的組件類型列表,一對多 
        private Dictionary<string, List<Type>> event2Types; 
        // 記錄逐組件類型的處理函數,一對一 
private Dictionary<Type, Delegate> eventTable; 
        // 爲每個組件類型準備的實例列表,字典中的記錄將會隨着每個組件的生成、銷燬而變化 
        private Dictionary<Type, List<IComponent>> type2ComponentList;  
        ... 
        public void SubscribeEventHandler(string eventName, Type receivingComponentType, ComponentEventDispatcher handler) 
        { 
List<Type> typeList; 
if (!event2Types.TryGetValue(eventName, out typeList)) 

typeList = new List<Type>(); 
event2Types[eventName] = typeList; 
}

// TODO: 檢測類型列表中,和目標類型相同的記錄,避免爲一個類型添加多個事件處理函數。 
typeList.Add(receivingComponentType); 
eventTable[receivingComponentType] = handler; 
        }

        public bool FireEvent(string eventName, IComponent sender, ComponentEventArgs e) 
        { 
bool processed = false; 
List<Type> typeList = event2Types[eventName]; 
if (null != typeList) 

// 通知所有註冊了該事件的組件類型 
foreach (Type componentType in typeList) 

ComponentEventDispatcher dispatcher = (ComponentEventDispatcher)eventTable[componentType]; 
List<IComponent> components = type2ComponentList[componentType];

if (null != components) 

// 通知該組件類型的所有實例 
foreach (IComponent cmp in components) 

// 調用事件處理函數 
processed |= dispatcher(cmp, sender, e); 



}

return processed; 
        } 
}

    事件-類型在ObjectManager中的管理類似於圖5: 
  

    圖6 一套典型的事件-類型映射。組件類型中的顏色即對應其註冊的事件顏色,例如IScriptable註冊了EventDraw和EventTick兩種事件。

    這樣客戶端可以利用ObjectManager提供的事件消息機制,寫出下面的代碼:

代碼18

public class Movable : IMovable 

    public void Move(const Vector3 offset) 
    { 
        _pos += offset; 
        objectManager.FireEvent(EVENT_MOVE, this, new MoveEventArgs(offset, _pos));
    } 
}

public class Inventory : IInventory 

    private bool OnMoveEvent(IComponent sender, MoveEventArgs e) 
    { 
        // 處理事件 
        ... 
        return true; 
    } 
    public static bool MoveEventDispatcher(IComponent receiver, IComponent sender, ComponentEventArgs e) 
    { 
        return receiver->OnMoveEvent(sender, e);
    } 
}

objectManager->SubscribeEvent(EVENT_MOVE, IIventory, Inventory.MoveEventDispatcher); 
... 
IMovable movable = (IMovable)objectManager(oid, typeof(IMovable)); 
movable.Move(new Vector3(10, 100, 1000));  // 這將觸發EVENT_MOVE事件,從而調用Inventory組件註冊的處理函數。

    到目前爲止,我們已經基本覆蓋了一個基於組件的對象系統的實現和實際用例。它已經完全勝任對象生成、組件註冊、組件接口查詢、以及註冊事件響應、生成事件等工作。此外我們還能在這之上添加數據驅動的方法,可以讓系統直接從外部文件、外部輸入中獲得類型組合。拜動態類型查詢的機制所賜,它還能很方便地在遊戲編輯器中實現一個對象debugger。這些額外的實現工作就留由有興趣的讀者自己實現了。本文附帶的示例代碼可以作爲實現的一份參考。

四、實施中的阻力

    可以預見的是,如此翻雲覆雨的架構變更將會在程序團隊中引起怎樣的轟動。可以保證的是,實施這種做法一定會遇到阻力,除非你的程序團隊只有你一人——即使如此你恐怕還要先說服自己。就像一些方法學的先鋒們嘗試SCRUM一樣,先考慮在私下裏和一些資深的程序員討論這個架構,讓大家瞭解這個系統並讓之後的討論可以建立在一個統一的平臺上。如果存在現存的代碼需要遷移至這個系統,那麼可以先在小範圍內修改,證明概念可行之後,再設法將其擴大到整個遊戲。當然如果你現在遊戲層的代碼一無所有,那就再好不過了!


五、結語紛言

    本文描述的基於組件的對象系統,適合在遊戲開發中經常反覆的過程。由於對象沒有固定的類型概念,所有的對象都是動態地由組件組合而成,而這些組件都統一由一個管理器來進行約束。相比傳統的基於繼承的方法,這種方法帶來三大優勢:一是方便創建和修改複雜的類型,由於不再需要改動龐大的繼承樹,繞開了語言的靜態限制,客戶端可以在不修改代碼的前提下,創建任意類型的對象。二是由於組件是對於接口設計的,這就強迫設計者實現一些高內聚低耦合的組件,也有助於遊戲層的整體設計。最後由於可以動態地查詢組件類型信息,做一個擁有圖形界面的、支持遊戲內容的debugger變得可能了——擺弄對象的企劃可以實時查閱對象的能力、狀況,在傳統方式下要實現類似的功能恐怕是相當繁瑣的。有得必有失,基於組件的方法由於必須實現一個基類,定義虛接口,在某些無需vtable的簡單情況下造成了一定的性能開銷。此外,每次查詢接口引起的開銷也值得引起重視。

六、參考資料 
[GoF 94 Design Patterns: Elements of Reusable Object-Oriented Software] 
[Bjarne Rene. Component Based Object System, Game Programming Gems 5] 
[Scott Ratterson, An Object-Composition Game Framework, Game Programming Gems 3] 
[Mick West. Evolve Your Hierarchy, http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/
[Scott Bilas: GDC 2002 Presentation: A Data-Driven Game Object System,http://www.drizzle.com/~scottb/gdc/game-objects.htm]

[注1] 原文第20頁:“…our experience is that designers overuse inheritance as a reuse technique, and designs are often made more reusable (and simpler) by depending more on object composition.” 
[注2]實現代碼轉由C#編寫。讀者可以很容易地將其移植到C++或其他面嚮對象語言上。

【作者簡介】
    封燁,圖形程序員,曾任職於大宇軟星和科樂美,對實時圖形學技術稍有研究。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章