在游戏引擎中,Entity通常被翻译成实体,也常用诸如GameObject、Actor、SimulationObject、Unit、Character等名字。相比于对图像声音引擎的热情,Entity层多年来一直备受冷遇,但最近几年随着大型游戏的发展,Entity层设计的重要性已经达到和图像声音的同等水平,而且已经出现了多种通用型Entity架构。当然,这里伴随着争议和分歧。
直接模式(The C Approach)
这是最早、最简单、也是任何人都能直接想到的模式。这种方式下一个Entity就是一个简单的struct:
struct Mob
{
int level, hp, mp, attack, …;
};
这种情况下往往需要对不同类型的Entity定义不同的struct,比如Player、Mob、Item、Doodad等。Entity的数据库可以直接使用现成的数据库系统或者从数据文件读取,比如csv文件。一般会选择Excel编辑数据库,然后导出csv。Excel的强大表格和统计计算功能是调节游戏数据平衡的得力工具。以致这种最古老而简单的Entity模式以强大的生命力一直活到现在。
那么为什么Developers会要去探索其他方式呢?最大的问题是这种方式下各种Entity完全不同质。比如Player、Mob、Doodad都有位置,都要做碰撞检测,但他们却是不同的类型,不能使用同一份代码;Player和Mob都有hp和mp,也有基本相同的处理逻辑……当然,这个可以用hack的方法解决:只要各个struct前若干个成员类型和顺序完全相同,就可以将指针cast成统一的一个Entity类型来处理。不过,任何人都能想到更好的办法,用类!
继承模式(Inheritage)
这也是Entity、GameObject等这些名字的由来,他们就是基类。于是我们可以得到一颗继承关系树,例如:
Entity
Character
Player
Mob
Missile
Laser
GuidedMissile
Item
…
Entity对象的逻辑大多也是直接包含在类里的。但也有人采用了数据对象和逻辑对象的分离,其好处是对相同的数据可以替换不同的逻辑。不过,个人比较怀疑这种分离是否值得,因为类的派生本身就是可以拥有不同的逻辑。
另外,Entity间的交互除了用标准的直接访问方式外,经常使用消息模式。消息模式和Windows的消息模式类似,Entity间通过消息交互。虽说窗口编程画了多年时间才摆脱了当年肥大的switch的消息处理模式,在Entity层使用消息模式还是有意义的,因为消息很容易广播且可以直接在网络上发送。执着于OO的人会将消息写成Command对象,但原理仍然是一样的。
同时,联网游戏出现,指针不能在不同的机器上标识对象,我们必须用稳定的ID来标识对象,于是我们有了EntityManager来分配ID和管理对象集合,或者叫GameObjectManager、ObjectManager等。这在一段时期内几乎成了完美的方案。
随着游戏内容的丰富性的迅速膨胀,传统的由程序员来实现游戏逻辑功能的模式也越来越力不从心。脚本语言的集成将大部分创意性工作从程序员的担子上拿了下来,交还给游戏设计人员。为了给脚本提供足够的控制权,Entity结构上必须有充分的灵活性。
数据驱动(Data-Driven)
现在有句很流行的话,“唯一不变的是变化。(The only constant is change.)”数据驱动使得变化对引擎的影响最小化。数据驱动不能算是一种独立的Entity模式,不如说它是一种设计思想,其目的就是将内容制作和游戏引擎的制作分离开来。与上面所说的填充Entity属性数据库的不同之处在于,它还要能通过数据来设计游戏逻辑。
游戏设计人员需要的一项最基本功能就是自定义人物属性,所以与其在类里写死属性成员,不如使用属性表(Attributes/Properties)。通常使用一个哈希表(Hashtable),或者std::map,或者Dictionary,或者就是个数组,随个人喜好,其实就是要一个key-value的映射表。然后为脚本和编辑器提供对属性表的操作。
动态的逻辑主要就靠脚本了,必须为脚本提供足够的事件和方法。个人推荐用Lua脚本,因为它是在游戏领域内用得最多的通用脚本语言,其引擎很小、速度很快、易于集成,尽管语法过于松散。不要迷信宣传去用庞大、极慢、难于集成的Python。为脚本提供事件,其实也就是调用脚本里的函数,这里如果使用了前面所述的消息模式,那么就只要调用一个脚本方法,传递不同的消息参数就行了。当然也有人觉得这样很丑陋而更愿意为不同的事件注册不同的函数。
当有了数据驱动后,Entity的继承树就基本失去意义了,因为一个Entity是什么已经不是程序里决定的了,而是通过数据和脚本设计出来的。但数据和脚本又不是全部,一个Entity的核心内容和需要高效处理的部分,如碰撞检测,还是要程序来完成。于是我们需要重新设计Entity类,困难的局面也就由此开始。
一个直观的想法是一个统一且唯一的Entity类,它包含了所有的基本功能,如显示、碰撞检测、运动、背包等,然后由数据决定哪些组件被启用。比如一个玩家角色可能会启用绝大部分组件,而一颗树只启用显示和碰撞检测组件。但也伴随着缺点:一、这个类太大了;二、对于树木等一些简单的Entity也要浪费其他组件的私有数据所占的内存。那么一个简单的折中是部分使用继承、部分使用数据定制。例如Entity只提供最基本的组件,再派生出CharactorEntity提供足够人物角色使用的组件。
组件模式(Component-Based Entity)
提到组件,那么很自然的就过渡到组件模式,就是把显示、运动、攻击、背包、队伍、声音等基本功能都做成独立的组件,由数据来决定向Entity里添加哪些组件。由此可以得到另外一个扩展,就是既然可以有引擎内置的组件,那就也可以有脚本制作的组件,实现脚本模块的复用。这种模式在GDC2002正式提出,到现在主流的引擎都有这种设计。
这种模式在理论上很完美,但实践上还是有不少疑问。最常见的问题就是组件间的依赖关系。理想情况下,各个组件是完全独立的,但实践中必然有所依赖。比如运动速度、攻击强度等和角色的基本属性有关,运动组件需要角色的包围盒来测试是否碰撞,AI组件需要分析角色当前状态和发出运动、攻击命令,角色动作状态变化时改变显示组件属性,攻击组件需要访问队伍信息组件以禁止攻击队友等等。处理这种依赖关系主要要解决两个问题:
<!--[if !supportLists]-->一、 <!--[endif]-->谁依赖谁。比如是敏捷属性改变而去修改移动速度,还是运动组件读取敏捷属性来计算移动速度。如果要游戏设计人员自由定义基本属性的话,就要选择前者,因为基本属性组件会是脚本组件。
<!--[if !supportLists]-->二、 <!--[endif]-->如何取得另一组件的指针/引用。常见的方法是给每个组件类型一个唯一ID,然后用该ID在Entity上查询注册了的组件,如果找到返回其指针/引用,否则返回null。当然,每次访问都做这个查询会很浪费CPU,如果Entity的组件不会在运行时动态添加删除的话(除非在编辑器里,否则很少有人会这么做),可以在Entity初始化后让每个组件缓存它所要用的其他组件指针。那么当所依赖的组件不存在怎么办,一般情况下都是无声地忽略。
当Entity由很多组件组成后,交互的消息需要发给每一个组件。这里又一次体现出消息机制的优势,你不需要在Entity里为每一个事件函数写一个loop来调用组件的相应事件函数。但这里也出现了一个问题,消息到达各个组件的顺序。很多时候这个顺序不会有什么影响,但个别时候不同的顺序会导致完全不同的逻辑发展方向。
此外,Entity的序列化存储也变得比较复杂,经典的Excel导出csv的模式难以奏效,因为这里需要结构化的存储,所以需要结构化的数据文件如XML来存储,或者完全用脚本来包含所有数据和构造Entity。
据个人经验,使用数据驱动的继承模式时很是向往组件模式,感觉上它一个非常自然的扩展方向,但顾忌其引入的额外的复杂性,尤其是需要游戏设计人员具有一定的编程能力,所以一直不敢全盘接过使用。但退一步,在引擎里仍然使用组件思想,但Entity的组件构成在编译时固定,可以达到某种妥协,这和采用继承与数据驱动的折中类似。
混入模式(Mix-in)
这是又一种常见的折中模式,即使用C++的多重继承,将各个组件类混入一个Entity类。如:
class Mob: public GameObject, public Renderable, public Movable, public Attackable
{
…
}
这种方法因其简单而且非常符合多重继承设计思想而被很多引擎采用。当然缺点也是只能在支持多重继承的语言里使用,而且当组件很丰富时,dynamic_cast就变成一个代价高昂的操作。
功能性与复杂性(Functionality vs Complexity)
编程领域最古老的原则之一就是要“简单快速”。但随着问题的复杂化,程序也随之变得越来越复杂。好的方法应该能有效的降低或隐藏复杂性。但是,没有不带副作用的药(No silver bullet.),在取得更强大的功能的同时总会带来额外的复杂性。我们需要做出权衡,在必要时牺牲一些功能,也就是要估算性价比。
一般游戏内容制作人员不会或不太会编程,编程人员也不善于游戏的内容创造和数值平衡,过于复杂的系统会导致需要两面兼顾的人才,会大大增加做出一款游戏的难度。