我自己翻译的一篇文章,希望能对你有所帮助。
原文链接:http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/
进化游戏的层次结构
- 用组件来重构你的游戏实体
直到最近几年,游戏程序员一直使用深层次结构的类表示游戏实体。现在的潮流开始逐渐从深层次的结构,到仅仅是把游戏实体对象作为聚合组件的方向转变。这篇文章解释了这些转变意味着什么,并且探讨了用这种方式带来的的好处和实际情况中的使用。我将会描述我个人的一些经验,怎样在大项目中实现这个系统,当然也包括怎样去卖你的方案给别的程序员和经理。
游戏实体
不同的游戏有不同的需求,就像游戏实体应该需要什么一样。但是在大多数的游戏里,实体的概念是十分的相似的。一个游戏实体就是一个在游戏世界里的对象,通常这个对象对于玩家来说是可见的,并且通常它还能四处移动。
一些实体的例子:
l 子弹
l 小轿车
l 坦克
l 手榴弹
l 枪
l 英雄
l 行人
l 外星人
l 喷气式飞行器
l 医疗包
l 石头
实体通常可以做很多事情。下面是一些事情你也许想要实体去做的:
l 运行一个脚本
l 移动
l 表现的像个死板的东西
l 发射粒子
l 播放特定的声音
l 能被玩家放在背包里
l 能被玩家穿上
l 爆炸
l 表现的有磁性的
l 被玩家瞄准
l 沿着一条路径走
l 动画
传统的深层次结构
传统的表示一组实体集的方式就像是在分解我们想要去表的实体集。这样做通常开始的意图是好的,但是随着游戏的开发进度这些东西经常要变动—尤其是当一个游戏引擎被不同的游戏重新使用时。我们通常最后的设计出如 图B-1那样,但是实际上的类层次结构比图中节点还要多。
图B-1
随着开发的进行,我们通常需要增加很多不同的功能到实体上。对象必须要么封装自己封装功能,要么从有那个功能的别的对象那里继承过来。经常性的功能被加载接近类层次结构的根节点上,比如说CEntity类。这样做有一个好处,就是所有派生类都能有那些功能。但是不好的地方是会被这些类带来相关的开销。
即使是非常简单的对象比如石头或者是手榴弹,到最后会有大量的额外功能(和相关的成员变量,或者是不必要执行的成员函数)。传统的游戏对象层次结构经常到最后要创建一个被称作”团迹”(胖球)(the blob)的东西。胖球是经典的反模式之一,表现为一个巨大的单类(或者是有大量的分支在类的层次结构上),拥有大量的复杂的互相交织的功能。
当胖球反模式经常在对象层次结构的根节点附近出现,它也就显现在叶子节点上了(译注:因为叶子节点是继承自根的)。最有可能的候选者因该是表示玩家的类。由于游戏通常是针对单一角色而编写的程序,因此表示角色的对象经常有大量的功能。这经常是实现为在一个类里比如CPlayer类,有大量的成员函数。
实现这么多功能在层次结构的根节点附近的结果就是给叶对象大量不需要功能的过重包袱。不管怎么样,用相反的实现方法,在叶子节点上实现大量的功能,同样是不幸的结果。功能现在被分解了,所以只有专门为那个对象编程的特定功能才能使用它。程序员经常复制一样的代码到已经被不同的对象实现的镜子函数里。最终,需要重新组织类的层次结构这种肮脏的重构来移动和组合功能。
先来一个例子吧,有一个对象在在物理作用下表现为刚体的功能。不是所有的对象需要做到这样。你可以在图B-1里看到的那样,我们仅仅让Crock和CGrenade类从CRigid类派生。如果我们想要将此功能应用到车子上会发生什么呢?你不的不把CRigid类移到层次结构的上面去,让它变得更像我们以前看到的根部的重型胖子模式,所有的的功能都被串成一条类的窄链子从其他最先开始继承的类开始起。
聚合组件
组件方式,现在越来越得到现在的游戏开发的认可,是一种把不同的功能分开放到不同的独立于其他组件的组件上的方法。传统的对象层次结构被免除了,并且一个对象现在被创建为为一个独立的组件的聚合(积聚物)。
每个对象现在只有它需要的功能了。任何不同的新共嫩被实现为增加一个组件。
一个由聚合组件组成的对象系统能有3种方式实现,可以被看成将胖球对象层次结构转移到一个组合对象上去的不同阶段。下面将介绍一下这3个阶段。
对象作为组织胖球
一种通常重构胖球对象的方法是将它的功能分散到不同的子对象上去,然后被第一个对象所引用。最终,父系的胖球对象被一系列的指向其他对象的指针代替,最终胖球对象的成员函数编程了这些子对象上函数的接口函数。
这也许事实上是一个合理的解决方案,如果你的游戏对象里的功能在一个合适小的范围内,或者如果时间是有限的。你可以简单实现任意的对象聚集,通过允许一些子对象为空(通给一个NULL指针给它们)。假设没有太多的子对象,那么这仍然允许你有一个轻型的没有实现一个管理此对象的组合框架的伪组合对象的优势。
不足之处是,这仍然在本质上是一个胖球。所有的功能人然被封装在一个大对象里。这不像是你完全的分解胖球对象到纯的子对象那样,所以你仍然遗留了一些重要的开销,仍然会让你轻型的对象变重。你仍然有不断检查所有空指针,以便看看是否需要更新的开销。
对象作为组件容器
下一个阶段是分解每个组件(上一节例子里的“子对象”)成共享一个公共的基类的对象,因此我们可以存储一个对象的列表在对象里。
这是一个过度的解决方法,我们仍然有表示游戏实体的根“对象”。不管怎样,它应该是一个合理的解决方案,或者确实是在实践中是可行的方案,如果一大部分的代码库中需要这种概念的游戏对象作为具体对象的话。
你的游戏对象然后变成了一个接口对象,充当了在你游戏里的遗留代码之间桥的作用,并且还是新系统的组合对象。如果时间允许,你最终将会把游戏实体对象作为整体式对象的概念消除掉。相反,访问对象越来越直接的通过它所在的组件了。最终,你能够将其转换到纯聚合了。
对象作为纯聚合
在最终的布置图里,一个对象简单是各个部分的和。图B-2显示了一个方案,每个对象都是由许多不同的组件组成的。这里没有所谓的“游戏实体对象”。每一列在图标中都表示同一组件,每一行因此都能表示一个对象。组件自己也可以看成是和组成它们的对象是独立的。
图B-2
实践经验
我第一个用组件实现的对象的组合系统是我在Neversoft公司做Tony Hawk系列游戏的时候做的。我们的游戏对象系统一直伴随着三个连续发布的游戏而发展,指导我们有了一个游戏对象的层次结构来重组我先前提到的胖球反模式。它遭受着所有同样的问题:对象倾向于重量级的。对象有不必要的数据和功能。有时不必要的功能让游戏变慢。功能有时在不同树的分支上重复。
我在sweng-gamedev的邮件列表里听说过这个关于“基于对象的组件”系统的新式发明。我觉得那听起来是一个好主意。我于是开始重新组织代码,两年以后把它完成了。
为什这么长的时间?因为,首先我们在以每年一个的速度艰苦的做出Tony Hawk游戏,所以只有很少的时间让我们投入到重构上。第二,我错误的计算了问题的规模。一个三年时间长的代码群已经包含大量的代码。大量的代码一年一年的逐渐变成了某种不灵活的代码。由于代码依赖于游戏对象成为游戏对象,尤其是的某些游戏对象。那说明了有大量的工作要去做,才能使得所有的东西都已组件方式工作。
预期的阻力
我第一遇到的问题就是怎样试着解释这个系统给其他的程序员。如果你不是特别的熟悉对象组合和聚合的事情,那么会被认为是无意的,不必要的复杂,不必要的多余工作,让你受备受打击。程序员已经在传统系统的对象层次结构上工作了很多年,已经非常习惯那种工作方式了。它们甚至变得擅长于那种方式来,能解决那些出现的问题了。
把这个方案卖给经理也是一个困难。你需要能够用平实的语言准确的描述,这个方案怎样能够让游戏完成的更快。下面是一段的因该说的话:
“当我们加入新的特性到游戏里时,那会花费很长的时间去完成,将会导致很BUGS。如果我们采用这种新的组件对象的东西,它能让我们加入新的特性更快,会有更少的BUGS”
而我采用是一种悄悄的方式。我首先和一些程序员单独讨论这个主意,最后说服他们这是一个好主意。我然后实现了通用组件的基本框架,并且还实现了游戏对象功能的很小的一部分作为组件。
我然后把这些成果呈现给剩下的程序员。他们有一些疑惑和抵触,但是由于它已经实现了并且它在那里工作不是一个大的争议。
缓慢的进展
当框架被链接上了,从静态的层次结构到对象组合的方便性显现的很缓慢。那是一个吃力不讨好的工作,即使你花了很多小时,很多天将代码重构成一些看起来像样的东西,但其和被替换的代码没有什么两样。而且,我们还在做这个事情的时候,我们仍然在为下一个游戏实现新的功能。
在早些时候,我们撞上了重构我们最大的类—滑雪者类的问题。由于它包含有大量的功能,它甚至在一段时间几乎无法重构一小点。事实上,它也无法被重构除非其他在游戏里的对象系统已经服从了组件方式了。话又说回来,其他那些对象系统也不容易被组件化,除非滑雪者已经是一个组件了。
这里的解决方案是创建一个“胖球组件”。这是一个单独的巨型组件,封装了大量滑雪者类的功能。少量的其他胖球组件也需要被用在别的地方。我们最终硬是将对象系统塞进了组件里。当这个事情到位了,胖球组件能被逐级的重构成更多的原子组件。
结果
一开始重构的结果不是那么明显。但是随着时间的推移,代码变得越来越清晰并且变得更容易维护,功能都被封装到分散的组件里了。程序员开始用更少的时间创建新类型的对象,仅仅简单的组合一些组件然后再加一个新的。
我们创建了一个数据驱动的对象创建系统,因此整个新类型的对象都能被设计人员创造。这被证明对于快速创建和配置新类型的对象是非常有价值的。
最终程序员开始(以不同的速度)接受组件化系统了。并且他们变得非常熟练的擅长通过组件来增加新的功能了。通用的接口和严格的封装使得BUGS减少了,代码也更容易阅读,更容易维护和重用。
实现细节
给每一个组件一个通用的接口意味着继承自同一个带虚函数的基类。这会带来额外的开销。但不要因为这一点而使你反对这种方法,节约的开销和对象的简单性相比是不不重要的。
由于每个组件都有一个公共的接口,非常容易的就可以增加额外的调试成员函数给每个组件。这使得增加一个能导出组件的组合对象的可读信息的诊断器对象更容易了。然后,这可以被进化成一个复杂功能的远程调试工具,总能够得到几乎所有类型的游戏对象的最新信息。这也许在传统的层次结构的系统里去实现和维护是十分的令人厌恶的。
理想情况下,组件应该互相不知道到对方。不管怎么样,在现实世界里,总是有特定组件间的依赖关系。性能问题,也决定了组件应该能够快速的访问其他组件。开始的时候,我们让所有组件的引用都是通过组件管理器的,但是当开始时只用了5%的CPU时间,我们允许组件存贮指向其他对象的指针,并且直接调用在其他组件里的成员函数。
在组件里,怎样组合对象的顺序是非常重要的。在我们一开始的系统里,我们把组件作为链表存储在一个容器对象里。每个组件有一个更新函数,每个对象每次迭代组件列表时被调用。
由于对象创建是数据驱动的,那样会造成麻烦的,如果在链表里的组件不是期望的顺序的话。如果一个对象更新物理相关内容在动画相关内容之前,但是另外一个对象更新动画相关内容在物理相关内容之前,这样他们就会互相失去同步。互相依赖关系像这样的必须找出来,然后在代码里定义强制规则。
结尾
用组件把从胖球风格的对象层次结构转变成组合对象结构是我所做的最好的决定之一。开始的结果是让人失望的,它花费了太多时间去重构现有的代码。不管怎么样,最后的结果是非常值得,轻型的,灵活的,健壮,和可重用的代码。