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]。 |
三、着手实现 该是着手写一些代码的时候了[注2]。基于上述应用的代码,我们肯定需要一个IComponent的接口,作为所有组件的基类: 代码9 public interface IComponent ObjectId ObjectId 这个接口只定义了一个组件的最小功能集,它所做的就是保留ObjectManager的句柄和所属对象的句柄。根据IComponent接口,我们可以衍生出更多的接口: 代码10 interface IComponentMovable : IComponent interface IComponentRenderable : IComponent 这两个接口分别定义了可移动对象以及可供渲染的对象的基本接口。在客户端代码中,基本上用户只需要面对的就是这些接口,而不用关心其实现。现在对于这些接口分别实现它的具体类: 代码11 class ComponentMovable : IComponentMovable public ComponentMovable() public void Init(ObjectId oid, ObjectManager objMan) public ObjectId ObjectId // 有关世界位置的属性 class ComponentRenderable : IComponentRenderable public ComponentRenderable() public void Init(ObjectId oid, ObjectManager objMan) public ObjectId ObjectId public void Tick(float dt) public bool Draw() 以上这些具体类将会提供我们组件的基本能力。而这些组件的具体实现,一旦注册到对象管理器后,客户端程序员就无需再关心它了。作为库的提供者,我们甚至可以把这些实现类完全隐藏起来,让客户端的程序员以数据驱动的方式注册这些类型,就像上节中解析XML的函数所作的一般。 由于对象的功能都是由组件提供的,对象本身的表示将会非常简单,它只需要一个标识自己的标记就可以了。很多程序语言支持将地址或句柄作为对象的唯一标识,所以有时候连这个标识都可以去掉。不过为了除错的目的,我们还是为它加上了一个描述自身的字串: 代码12 public class ObjectId public string Description 我们的设计是想让客户端所需的先验知识尽可能的少,只有对象句柄ObjectId、所需的接口类型IComponentXXX,以及ObjectManager的方法。可以说ObjectManager是这个系统核心部件。下面就让我们来看一下ObjectManager是如何上演这出把戏的。首先我们需要让ObjectManager创建对象,并把对象句柄返回给调用端,这可以有如下的简单实现: 代码13 public class ObjectManager 接下来客户端需要做的就是为对象添加组件。而这个添加组件的工作也相对简单: 代码14 public class ObjectManager if (object2ComponentList.TryGetValue(oid, out componentList)) throw new ObjectNotFoundException(oid); 一旦为某个对象添加了组件,其他代码就可以通过QueryInterface的方法来获得某一类型的组件的指针: 代码15 public class ObjectManager if (object2ComponentList.TryGetValue(oid, out componentList)) throw new ObjectNotFoundException(oid); 如果查询结果成功,则安全返回组件接口引用。查询不到则返回空句柄。如果对象句柄本身也没能查询到,则抛出一个异常以示抗议。到目前为止,ObjectManager已经可以做到生成对象、为对象注册组件、并提供外界查询组件接口的功能。这样,客户端代码已经可以组合复杂对象,并通过查询接口的方式在组件之间进行通信(见前一节)。客户端代码已经可以这样写: 代码16 ObjectId oid = objectManager.CreateObject(); void Renderble::Foobar()
代码17 public class ObjectManager // TODO: 检测类型列表中,和目标类型相同的记录,避免为一个类型添加多个事件处理函数。 public bool FireEvent(string eventName, IComponent sender, ComponentEventArgs e) if (null != components) return processed; 事件-类型在ObjectManager中的管理类似于图5: 图6 一套典型的事件-类型映射。组件类型中的颜色即对应其注册的事件颜色,例如IScriptable注册了EventDraw和EventTick两种事件。 这样客户端可以利用ObjectManager提供的事件消息机制,写出下面的代码: 代码18 public class Movable : IMovable public class Inventory : IInventory objectManager->SubscribeEvent(EVENT_MOVE, IIventory, Inventory.MoveEventDispatcher); 到目前为止,我们已经基本覆盖了一个基于组件的对象系统的实现和实际用例。它已经完全胜任对象生成、组件注册、组件接口查询、以及注册事件响应、生成事件等工作。此外我们还能在这之上添加数据驱动的方法,可以让系统直接从外部文件、外部输入中获得类型组合。拜动态类型查询的机制所赐,它还能很方便地在游戏编辑器中实现一个对象debugger。这些额外的实现工作就留由有兴趣的读者自己实现了。本文附带的示例代码可以作为实现的一份参考。 |
四、实施中的阻力 可以预见的是,如此翻云覆雨的架构变更将会在程序团队中引起怎样的轰动。可以保证的是,实施这种做法一定会遇到阻力,除非你的程序团队只有你一人——即使如此你恐怕还要先说服自己。就像一些方法学的先锋们尝试SCRUM一样,先考虑在私下里和一些资深的程序员讨论这个架构,让大家了解这个系统并让之后的讨论可以建立在一个统一的平台上。如果存在现存的代码需要迁移至这个系统,那么可以先在小范围内修改,证明概念可行之后,再设法将其扩大到整个游戏。当然如果你现在游戏层的代码一无所有,那就再好不过了!
本文描述的基于组件的对象系统,适合在游戏开发中经常反复的过程。由于对象没有固定的类型概念,所有的对象都是动态地由组件组合而成,而这些组件都统一由一个管理器来进行约束。相比传统的基于继承的方法,这种方法带来三大优势:一是方便创建和修改复杂的类型,由于不再需要改动庞大的继承树,绕开了语言的静态限制,客户端可以在不修改代码的前提下,创建任意类型的对象。二是由于组件是对于接口设计的,这就强迫设计者实现一些高内聚低耦合的组件,也有助于游戏层的整体设计。最后由于可以动态地查询组件类型信息,做一个拥有图形界面的、支持游戏内容的debugger变得可能了——摆弄对象的企划可以实时查阅对象的能力、状况,在传统方式下要实现类似的功能恐怕是相当繁琐的。有得必有失,基于组件的方法由于必须实现一个基类,定义虚接口,在某些无需vtable的简单情况下造成了一定的性能开销。此外,每次查询接口引起的开销也值得引起重视。 六、参考资料 [注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.” |