一个基于组件的动态对象系统
http://hulefei29.iteye.com/blog/1490889
一、静态的痛苦
作为一个项目经验丰富的程序员,你经常会遇到游戏开发过程中的“反复”(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索取对应的组件接口,比如:
- IRenderable* renderable = static_cast<IRenderable*>(objectManager->QueryInterface(object, TYPE_IRenderable));
或者写一道宏指令以减少笔误:
- IRenderable* renderable = QUERY_INTERFACE(objectManager, object, IRenderable);
之后你就像平常一样操作这个组件:
- renderable->PreRender();
- ...
- renderable->Render();
- ...
- renderable->PostRender();
所有不同类型的ObjectHandle看起来都是差不多的,他们的区别只在于记录在ObjectManager里的组件不同而已。所以,忘掉类型的概念吧!在这个世界中,只有组件的组合,没有死板的类型。
3、即插即用
那么如果企划再对我的Weapon提出什么非份的要求,怎么办?担惊受怕的你继续问道。很简单,如果Weapon类还需要其他的功能,只要这个功能已经以组件形式实现了,那么你完全可以让他自己搞定!因为基于组件的对象系统中,一个具体的对象已经没有静态的“类型”概念了,只要我愿意,我可以对某个对象添加任意的组件功能,即使它看上去多么荒谬:
- // 创建了一个赤身裸体的对象,它还没有任何功能
- ObjectHandle* weapon = objectManager->CreateObject();
- // 对象拥有了渲染的组件,及其功能。
- objectManager->AddComponent(weapon, TYPE_IRenderable);
- // 武器也需要过程动画吗?无论怎样的组件都能添加。
- objectManager->AddComponent(weapon, TYPE_IProceduralAnimatable);
对象的能力不再“静态”地由继承关系决定,而是由一组扁平组织起来的组件“动态”地、自由地组合而成。只要组件实现得足够健壮,我们可以放心地生成任意“类型”(或称组合)的对象,而不用担心设计上的修改和对象臃肿的问题。
嗯,这样的对象足够灵活,但还不够!我们可以解析描述对象的XML文件,从其中读取的信息里,决定我们要生成什么样的对象,以及添加怎样的组件。
- 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、深化交流与合作
理想情况下,组件之间应该不进行通信。但如果组件之间完全不进行通信,那么这个游戏估计也不怎么吸引人了。组件之间进行通信的方式一般有两种:其一是通过查询接口,直接获得其他组件的接口,通过调用函数的方式进行通信。例如:
- void Renderable::Render()
- {
- // 需要获得对象在世界空间的位置
- IMovable movable = static_cast<IMovable*>(objectManager->QueryInterface(object, TYPE_IMovable));
- ASSERt(movable);
- const Point3& pos = movable->GetPosition();
- ...
- // 通过获得的位置信息进行绘制。
- }
这种通信方式会增加代码之间的耦合性,但适合于需要知道特殊接口,或需要保证调用顺序的场合。
第二种方式就是事件(或消息)。通过事件和消息,也迫使代码之间的耦合度下降。例如:
- objectManager->SubscribeEvent(TYPE_IAnimatable, EVENT_Tick, new MemberFunctor(Animatable::OnTickEvent));
- ...
- void Animatable::OnTickEvent(float tick)
- {
- _fElapsedTime += tick;
- }
而在发生事件的组件中,只需要调用以下方法即可以让所有注册的回调函数响应:
- objectManager->FireEvent(EVENT_Tick, tick);
基于事件或消息的通信方式,由于它的调用取决于注册顺序,适合于无需保证调用顺序的场合。
最后在我们的场景中,对象之间的组织如图4所示:
图5 我们的场景像是一个由组件组成的二维表格,表格的列是同一组件类型的实例,而每个对象就是表格的一行,它可以自由选择是否需要某列提供的组件功能。
三、着手实现
该是着手写一些代码的时候了[注2]。基于上述应用的代码,我们肯定需要一个IComponent的接口,作为所有组件的基类:
- public interface IComponent {
- void Init(ObjectId oid, ObjectManager objMan);
- ObjectId ObjectId
- {
- get;
- }
- }
这个接口只定义了一个组件的最小功能集,它所做的就是保留ObjectManager的句柄和所属对象的句柄。根据IComponent接口,我们可以衍生出更多的接口:
- interface IComponentMovable : IComponent
- {
- Vector3 Position
- {
- get;
- set;
- }
- ..
- }
- interface IComponentRenderable : IComponent
- {
- void Tick(float dt);
- bool Draw();
- }
这两个接口分别定义了可移动对象以及可供渲染的对象的基本接口。在客户端代码中,基本上用户只需要面对的就是这些接口,而不用关心其实现。现在对于这些接口分别实现它的具体类:
- 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的函数所作的一般。
由于对象的功能都是由组件提供的,对象本身的表示将会非常简单,它只需要一个标识自己的标记就可以了。很多程序语言支持将地址或句柄作为对象的唯一标识,所以有时候连这个标识都可以去掉。不过为了除错的目的,我们还是为它加上了一个描述自身的字串:
- public class ObjectId
- {
- private string _desc;
- public ObjectId(string desc)
- {
- _desc = desc;
- }
- public string Description
- {
- get { return _desc; }
- }
- }
我们的设计是想让客户端所需的先验知识尽可能的少,只有对象句柄ObjectId、所需的接口类型IComponentXXX,以及ObjectManager的方法。可以说ObjectManager是这个系统核心部件。下面就让我们来看一下ObjectManager是如何上演这出把戏的。首先我们需要让ObjectManager创建对象,并把对象句柄返回给调用端,这可以有如下的简单实现:
- 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;
- }
- }
接下来客户端需要做的就是为对象添加组件。而这个添加组件的工作也相对简单:
- 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的方法来获得某一类型的组件的指针:
- 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已经可以做到生成对象、为对象注册组件、并提供外界查询组件接口的功能。这样,客户端代码已经可以组合复杂对象,并通过查询接口的方式在组件之间进行通信(见前一节)。客户端代码已经可以这样写:
- 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++或者实现一个泛型函数绑定嫌麻烦的话,完全可以使用基于消息的机制,也足够方便:
- 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提供的事件消息机制,写出下面的代码:
- ublic 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));
- // 这将触发EVENT_MOVE事件,从而调用Inventory组件注册的处理函数。
- movable.Move(new Vector3(10, 100, 1000));
到目前为止,我们已经基本覆盖了一个基于组件的对象系统的实现和实际用例。它已经完全胜任对象生成、组件注册、组件接口查询、以及注册事件响应、生成事件等工作。此外我们还能在这之上添加数据驱动的方法,可以让系统直接从外部文件、外部输入中获得类型组合。拜动态类型查询的机制所赐,它还能很方便地在游戏编辑器中实现一个对象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++或其他面向对象语言上。
相关文章:

Lua生成Guid(uuid)
全局唯一标识符(GUID,Globally Unique Identifier)也称作 UUID(Universally Unique IDentifier) 。GUID是一种由算法生成的二进制长度为128位的数字标识符。GUID主要用于在拥有多个节点、多台计算机的网络或系统中。在理想情况下,…

c:if标签的使用
1、标签的基本介绍 <c:if> 标签必须要有test属性,当test中的表达式结果为true时,则会执行本体内容;如果为false,则不会执行。例如:${requestScope.username admin},如果requestScope.username等adm…

ecs和eks 比较_如何使用Kubernetes,EKS和NGINX为网站设置DNS
ecs和eks 比较As the creator of Foo, a platform for website quality monitoring, I recently endeavored in a migration to Kubernetes and EKS (an AWS service).作为网站质量监控平台Foo的创建者,我最近努力迁移到Kubernetes和EKS(一种AWS服务)。 Kubernetes…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(1):确定框架方案
遇到的问题 做游戏的时候用的是cocos2dxlua,游戏开发自有它的一套框架机制。而现在公司主要项目要做android和iOS应用。本文主要介绍如何搭建简单易用的App框架。 如何解决 对于新手来说,接触一门新的知识,往往会思考该怎么入手,…

js全局变量污染
一.定义全局变量命名空间 只创建一个全局变量,并定义该变量为当前应用容器,把其他全局变量追加在该命名空间下 var my{}; my.name{big_name:"zhangsan",small_name:"lisi" }; my.work{school_work:"study",family_work:&q…

cached-query 将缓存和查询数据库高速连接起来的轻类库
介绍 我们经常有这种需求:当我们把memcached增加到项目后我还还要写一个 cacheUtils 或者 cacheManager 之类的类来操作memcached。而且一般的操作不外乎是这种操作: 拿到一段sql,先去memcahed里面看下是否有缓存,假设有就直接返回…

全栈Python Flask教程-建立社交网络
Learn how to build a basic social platform with the Python Flask web framework. 了解如何使用Python Flask网络框架构建基本的社交平台。 In this video, we show you how to:在此视频中,我们向您展示如何: how to create a database, 如何创建数…

py执行系统命令
py执行系统命令 1. os.system In [32]: run os.system("date") Thu Jan 28 09:41:25 CST 2016 In [33]: run Out[33]: 0 只能得到返回值,无法得到输出。 2. os.popen In [35]: run os.popen("date") In [36]: run.read Out[36]: <function…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(2):MVP比MVC更好吗
对于程序框架的选择,由于android天然的MVC,本来不需要另外设计直接使用即可。但是我更加钟情于MVP模式,对于其将ui完全与业务逻辑分离的思路很赞同。 那么什么是业务逻辑?个人认为,对数据(即MVC中的M&…

一、nginx 安装
添加官方 yum 源 1 vim /etc/yum.repos.d/nginx.rep 输入以下内容(OS为你的系统,OSRELEASE 系统版本) 1 [nginx] 2 namenginx repo 3 baseurlhttp://nginx.org/packages/mainline/OS/OSRELEASE/$basearch/ 4 gpgcheck0 5 enabled1 列出可安装…

华为技术面试编码题_最佳技术编码面试准备书
华为技术面试编码题Technical coding interviews are notoriously difficult — almost borderline quiz-like for those unprepared. It can sometimes be a daunting task to navigate all the technical coding preparation resources available online, and one might as…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(3):构造具有个人特色的MVP模式
1. MVP的问题 之前我们说过MVP模式最大的问题在于:每写一个Activity/Fragment需要写4个对应的文件,对于一个简易的app框架来说太麻烦了。所以我们需要对MVP进行一定的简化。 关于MVP模式是什么及其简单实现,可以参照:浅谈 MVP i…

Java进阶之自动拆箱与自动装箱
序. java基本类型介绍 java中,基本数据类型一共有8种,详细信息如下表: 类型大小范围默认值byte8-128 - 1270short16-32768 - 327680int32-2147483648-21474836480long64-9233372036854477808-92333720368544778080float32-3.40292347E38-3.40…

Ceilometer Polling Performance Improvement
Ceilometer的数据采集agent会定期对nova/keystone/neutron/cinder等服务调用其API的获取信息,默认是20秒一次, # Polling interval for pipeline file configuration in seconds.# (integer value)#pipeline_polling_interval 20 这在大规模部署中会对O…

vue使用pwa_如何使用HTML,CSS和JavaScript从头开始构建PWA
vue使用pwaProgressive web apps are a way to bring that native app feeling to a traditional web app. With PWAs we can enhance our website with mobile app features which increase usability and offer a great user experience.渐进式Web应用程序是一种将本地应用程…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(4):网络模块的封装
程序框架确定了,还需要封装网络模块。 一个丰富多彩的APP少不了网络资源的支持,毕竟用户数据要存储,用户之间也要交互,用户行为要统计等等。 使用开源框架 俗话说得好,轮子多了路好走,我们不需要自己造轮…

结构体成员数组不定长如何实现
【目的】 定义一个结构体类,其中的成员变量数组长度不定,根据实例化的对象指定长度,所以想到用指针实现 【现状】 指针可以指向任意长度数组,但结构体类只分配指针本身4字节长度,所以无法扩展 1 /**2 ****************…

团队项目:二次开发
至此,我们有了初步的与人合作经验,接下来投入到更大的团队中去。 也具备了一定的个人能力,能将自己的代码进行测试。接下来尝试在别人已有的基础上进行开发。 上一界51冯美欣同学的项目:http://www.cnblogs.com/maxx/ 1.每个团队从…

arduino 呼吸灯_如何改善您的Arduino呼吸机:用于临时COVID-19呼吸机设计的RTS和SCS简介...
arduino 呼吸灯The world as we know it was recently taken by storm. That storm was the outbreak of the COVID-19 pandemic. This has in turn created a shortage of ventilators world wide which has led many people to foray into the world of ventilator design. 我…

reboot 百度网盘资源
提醒:同志们这是记录,视频文件是加密的,请勿下载 基础班第十三期:http://pan.baidu.com/s/1c2GcvKG 密码: 743j 基础班第十四期链接: http://pan.baidu.com/s/1c24AYa8 密码: x2sh 第十五期: https://pan.baidu.com…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(5):数据持久化
遇到的问题 有的时候程序中需要全局皆可访问的变量,比如:用户是否登录,用户个人信息(用户名,地区,生日),或者一些其他信息如:是否是首次登录,是否需要显示新手引导等等。 其中有些…

响应因特网端口ping命令_如何使用Ping命令识别基本的Internet问题
响应因特网端口ping命令Next time you call your help desk, do you want to wow them with your networking knowledge? Using a command called “ping”, built right into your existing Mac, Windows, or Linux computer, will help identify basic connection problems.…

Android 常见工具类封装
1,MD5工具类: public class MD5Util {public final static String MD5(String s) {char hexDigits[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,a, b, c, d, e, f };try {byte[] btInput s.getBytes();// 获得MD5摘要算法的 MessageDigest 对象MessageDigest md…
keras系列︱图像多分类训练与利用bottleneck features进行微调(三)
引自:http://blog.csdn.net/sinat_26917383/article/details/72861152 中文文档:http://keras-cn.readthedocs.io/en/latest/ 官方文档:https://keras.io/ 文档主要是以keras2.0。 训练、训练主要就”练“嘛,所以堆几个案例就知…

LIKE 操作符
LIKE 操作符LIKE 操作符用于在 WHERE 子句中搜索列中的指定模式。SQL LIKE 操作符语法SELECT column_name(s)FROM table_nameWHERE column_name LIKE pattern原始的表 (用在例子中的):Persons 表:IdLastNameFirstNameAddressCity1AdamsJohnOxford StreetLondon2Bush…

服务器云ide_语言服务器协议如何影响IDE的未来
服务器云ideThe release of Visual Studio Code single-handedly impacted the developer ecosystem in such a way that theres no going back now. Its open source, free, and most importantly, a super powerful tool. Visual Studio Code的发布以一种无可匹敌的方式对开发…

仅需6步,教你轻易撕掉app开发框架的神秘面纱(6):各种公共方法及工具类的封装
为什么要封装公共方法 封装公共方法有2方面的原因: 一是功能方面的原因:有些方法很多地方都会用,而且它输入输出明确,并且跟业务逻辑无关。比如检查用户是否登录,检查某串数字是否为合法的手机号。像这种方法就应该封…
MySQL优化配置之query_cache_size
原理MySQL查询缓存保存查询返回的完整结果。当查询命中该缓存,会立刻返回结果,跳过了解析,优化和执行阶段。 查询缓存会跟踪查询中涉及的每个表,如果这写表发生变化,那么和这个表相关的所有缓存都将失效。 但是随着服…

request.getSession()
request.getSession(); 与request.getSession(false);区别 服务器把session信息发送给浏览器 浏览器会将session信息存入本地cookie中 服务器本地内存中也会留一个此session信息 以后用户发送请求时 浏览器都会把session信息发送给服务器 服务器会依照浏览器发送过来的se…

alpine 交互sh_在这个免费的交互式教程中学习Alpine JS
alpine 交互shAlpine.js is a rugged, minimal framework for composing Javascript behavior in your markup. Thats right, in your markup! Alpine.js是一个坚固的最小框架,用于在标记中构成Javascript行为。 是的,在您的标记中! It allo…