游戏编程设计模式

[翻译]游戏编程设计模式——Type Object

原文链接:http://gameprogrammingpatterns.com/type-object.html

意图

允许用一个类去创建一些新“类”,每一个新“类”产生的实例都代表了一种对象。

动机

假如我们在做一个牛逼的角色扮演游戏。我们的任务是用代码实现一群邪恶的怪物,它们试图干掉我们的英雄。怪兽们有很多属性:血量,攻击,外观,声音等等。但我们只以前两个为例。

每个怪兽都有一个记录当前血量的值。开始是满的,每次受到伤害,就会减少。另外,怪兽还有一个代表攻击的字符串,每一次怪兽攻击英雄,这些文字就会以某种形式显示出来(先不用关心怎么显示)。

设计师告诉我们这些怪兽来自于很多不同的种类,比如“龙”或者“巨人”。每一个种类代表了游戏中的一类怪物,游戏中的地牢里,同时会有很多同类的怪物跑来跑去。

类型决定了怪物的初始血量——龙的初始血量就会比巨人高,也就是它更难被杀死。类型还决定了攻击字符串——同一类型的怪物,用同一种方式攻击。

面向对象的写法

游戏设计已经成竹在胸,我们打开文本编辑器,开始撸代码。根据设计,龙是一种怪物,巨人也是,还有其他的种类。考虑一下面向对象,一个Monster基类就写出来了:

getAttack()公有方法让战斗代码可以在怪兽攻击时,获得用于显示的攻击字符串。每一个继承的怪物类型类,都可以覆盖这个接口,提供它们不同的字符串。

构造函数是protected类型,传入一个初始血量。然后我们就写一些继承类代表每一种怪物,它们都有自己的public构造函数,其中调用了基类的构造函数,并把该类型的初始血量传递给它。

现在,我们看一下这两个怪兽种类的子类:

每一个继承类,都向Monster类传递了初始血量,并且覆盖了getAttack()方法,该方法返回一个代表这个怪物种类攻击的字符串。一切看起来很顺利,我们继续撸代码,作出了几十个怪物子类,从牛头到马面。

突然,事情急转直下。我们的设计师最后说想要几百种怪物,然后我们就会发现,我们所有的时间都花在了写那个只有7行代码的子类了。更糟糕的是我们的设计师会频繁得修改那些我们写好的类型。那我们每天的工作就变成这样了:

1、从设计师那收取邮件,让我们把巨人的血量从48改成52.

2、取出Troll.h文件,修改代码。

3、重新编译游戏。

4、提交修改。

5、回复邮件。

6、重复以上事情。

就这样我们会整天的被这种事情烦的要死,整个变成了一只会修改数据的猴子。我们的设计师也不胜其烦,因为它们整天也为修改这些小数据忙来忙去。我们需要的是一种不需要重新编译游戏就能修改怪物种类属性的能力,如果能让设计师不通过程序员也能创建和修改怪物种类,那就更好了。

一个产生类的类

让我们跳出来重新审视我们的问题,其实很简单。我们在游戏中有很多不同的怪物,它们之间要共享一些属性。游戏中还会有成群的怪物攻击英雄,我们希望他们其中的一部分能够使用相同的攻击字符串,我们把这些怪物成为一个种类,种类决定了它们的攻击字符串。

用继承来实现比较符合我们的直觉。龙是一种怪物,游戏中的每一条龙都是龙这个“类”的一个实例。把每一种怪物都定义成Monster抽象类的一个子类,这样,游戏中的每一只怪物,都是一个继承类的实例。我们可以得到这样的结构:

type-object-subclasses

游戏种的每一种怪物都对应了一个继承类,我们的怪物种类越多,继承结构就越庞大。问题的核心在于:添加一种怪物就意味着添加新代码,每一个种类都会被编译成它们自己的类型。

这样做可行,但并不是唯一的选择。我们可以这样构建我们的代码。不去继承Monster类,而是把Monster和类型分开:

type-object-breed

只有两个类,一点继承结构也没有。在这样的系统下,每一只怪物都是Monster类的一个实例,相同种类的怪物共享一个Breed(类型)实例,Breed维护了这个种类怪物的共同属性:初始血量和攻击字符串。

为了把怪物分类,我们让每一个怪物都维护一个Breed对象的引用,这个对象存储了这种怪物的属性。如果要获取攻击字符串,怪物类只需要调用它的Breed种的方法即可。这个Breed类本质上就代表了怪物的类型。每一个Breed对象都代表了一种不同的概念类型,因此,这个模式被叫做:Type Object。

这个模式的强大之处就是现在我们不需要重新编译代码就可以定义新的怪物类型。本质上,就是把写死的class继承结构变成了运行时的数据结构。

我们可以用不同的数据实例化Breed类,从而创建成百上千的怪物类型。如果我们用配置文件种的数据初始化这些实例,那就是完全通过数据来完成定义工作了,这很简单,设计师足可搞定。

模式

定义一种原型类,和一种依赖原型的类。每一个原型类的对象都代表了不同的类型。而依赖原型的类维护了一个原型对象引用,这个原型对象就代表了它的种类。

实例特有的数据被存储在依赖原型的对象中。而那些同种类里不同实例共享的数据和方法,就存储在原型对象种。那些引用了同一个原型对象的对象就会表现为同一个类型。这就让我们可以在很多相似的对象种共享数据和行为,就像子类化一样,但不需要在代码中写死。

何时用

如果你需要定义很多种类的东西,但是手动用你的语言一个个写出来很恶心,就可以用这种模式了。在实际应用中,满足一下的任意一个条件就可以:

1、你不知道最终会有多少种类型。(例如,你的游戏需要支持通过下载的内容去创建怪物种类)

2、你想不重新编译或者修改代码,就能修改或者添加种类。

牢记

这个模式就是要把类型的定义从高效但死板的语言中,转移到更灵活的数据中。这种灵活性很妙,但是也要付出一些代价。

我们必须手动管理原型对象

像C++这样的语言有一个优势就是编译器自动管理了类型的记录工作。每一个类的定义所产生的数据,都被编译进了可执行文件的静态内存区。

对于Type Object模式而言,我们现在的指责不仅包括管理monster对象,还要管理它们的原型对象——我们必须保证当创建monster的时候,它所需要的原型已经被实例化到内存了。无论何时我们创建一个新的monster,我们都要保证那个正确的怪物原型被初始化了。

我们打破了一些编译器的限制,但是也付出了重新实现一些编译器功能的代价。

很难定义每一个类型的行为

使用子类化有个好处,你可以通过覆盖一个方法来做你像做的任何事,计算一些数值,调用一些方法等等。没有限制。我们可以定义一种怪兽的子类,随着月亮的圆缺改变攻击字符串(我建议是狼人)。

当用Type Object模式时,我们就要用一个成员变量去覆盖去覆盖一个方法。不再用monster子类去覆盖计算攻击字符串的方法,而是在种类对象里面存储不同的攻击字符串。

用type object去定义特定数据的类型变得更容易,而让特定行为的类型定义变得更难。例如,如果不同的怪物类型需要不同的AI算法,用这个模式就不合适了。

有很多方法可以绕过这个限制,其中一个比较简单的是,构造一个预定义行为的集合,然后用原型对象中的数据去加以区分。例如,我们像让我们的怪兽AI或者“立正”,或者“追击英雄”,或者“蜷缩哭泣”(额,它们也不总是龙)。我们可以都给它们实现出来。然后在类型对象中存贮一个指向这些函数的指针,从而把它们关联起来。

另一个有效的解决方案是真正把这些行为定义在数据中,Interpreter 和 Bytecode都是这类模式。如果我们从文件中读取数据,并创建用于这些模式的数据结构,那我们就完全把行为的定义从代码中转移到数据中了。

实例代码

在我们实现的开始部分,我们先构建一个动机部分里面讲的基础系统。先从Breed类开始:

很简单,它只是一个保存有两条数据的容易:初始血量和攻击字符串。让我们看看怪兽们如何用它:

当构造一个monster的时候,我们给它一个breed对象的引用。这个对象定义了这个monster的类型,而不是我们前面用到的子类化。在构造函数中,Monster用breed对象去决定了初试血量。

上面的这点代码就是这个模式的核心思想。下面的都是锦上添花。

让Type object更像Type:构造函数

在我们现有的代码中,我们直接构造一个monster需要传递一个breed给它。这跟我们在普通OOP语言中创建一个对象有一点区别——我们往往不是申请一块内存然后给它一个类,而是调用类自己的构造函数,它有责任给我们一个新的实例。

我们可以把这个模式用到我们的原型对象中:

还有用到它的类:

最关键的不同是这个newMonster()函数是在Breed类中。那是我们用来构造对象的工厂方法。以前的实现中,创建一个monster就像这样:

修改之后,就像这样:

那么为什么这么做呢?创建一个对象需要两步:申请内存和初始化。Monster的构造函数可以为我们做需要的初始化工作。在我们的例子中,它只存储了breed,但是对一个完整的游戏来说,还需要加载图形,初始化monster的AI,和一些其他工作。

然而,这些都是在申请内存之后的。假如在构造之前,我们已经有了一块存放怪兽的内存呢。在游戏中,我们常常希望控制对象的创建位置:可能要用像自定义内存申请或者对象池这种东西。

在Breed类中定义一个“构造函数”,就让我们有一个地方去写这些逻辑。而不是简单的调用new,这个newMonster方法可以在把代码控制权交给Monster自己前,为Monster从一个池或者自定义的队中申请到合适的内存。在Breed中加入这些逻辑,也就是在一个唯一能创建monster的地方,我们就可以确保所有的Monster的内存管理都符合我们的预期。

在继承结构中共享数据

现在我们有了一套可以工作的原型系统了,但是很粗糙。我们的游戏可能有几百种不同类型的怪物,每一种有几十个属性。如果设计师想让30种巨人稍微加强一些,那她需要修改很多数据。

这就希望能在很多不同的breeds种共享一些属性,用到的方法跟monster之间共享属性一样。就像我们开始的OOP方法一样,我们可以用继承解决。只有在这里我们不在用语言的继承机制,而是用我们自己的原型对象。

简单起见,这里我们只支持单继承。同一个类把一个基类作为自己的父类一样,我们也让一个breed拥有一个父级的breed:

每当创建一个breed,我们就可以传给它一个用于继承的父亲。当然,对于那些没有继承需求的,传NULL。

为了让它起作用,一个子breed需要控制哪些属性是继承自父breed,哪些是自己指定的。在我们的例子中,breed用一个非零的数值覆盖了monster的血量,用一个非NULL得字符串覆盖了它的攻击字符创。否则,它就继承父breed的。

有两种方法可以实现。一个是每次请求的时候用一个代理来处理,就像这样:

这样做的好处是在运行时就能修改breed,而不需要覆盖和继承。另一方面,它占用了更多的内存(它必须维护一个指向parent的指针),并且更慢,因为每次访问它的属性时,都需要访问它的继承链。

如果我们能够确保breed的属性不变,那可以有一个更快的解决方案,消耗仅是常数时间。叫做“向下拷贝”代理,之所以这么叫,是因为它是在创建的时候把基类的属性拷贝到了子类中,就像这样:

记住从现在开始,我们就不再需要parent这个成员了。一旦构造完成,我们就可以忘掉那个parent,因为它的属性已经被全部拷贝下来了。要访问一个breed的属性,只需要返回这个成员即可。

又好又快!

我们的游戏引擎可以通过加载JSON文件去定义那些breeds,它看起来可能是这样的:

可以写一些代码去读取每一个breed节点,并且用其中的实例化一个新的breed对象。如你所见的”parent”:”Troll”成员,这个Troll Archer 和 Troll Wizard类型继承自基类Troll类型。

因为这两个Breed的初始血量都是0,所以他们会继承自Troll 类型。这就意味着现在只要我们的设计师修改了Troll的初始血量,这三个类型的初始血量都会改。随着Breed数量的增加,以及Breed内属性的增加,这将会节省大量的时间。现在只需要一小部分代码,就可以为我们的设计师提供一个开放式系统,让他们可以独立完成怪物种类的制作,这样我们就可以腾出时间来写其他功能的代码了。

设计抉择

Type Object模式让我们创建了一个系统,就像创建了一种我们自己的语言一样。设计空间非常大,有好多有意思的事情可以做。

实际中,有些事情会阻止我们的想象力。从时间和可维护性的角度考虑,就不鼓励我们把系统设计的过于复杂。无论我们怎么设计原型系统,真正用到它的都是设计师(非码农),他们需要这个系统对他们来说是易懂的。我们做的越简单,可用性就越高。所以我们这里讨论的都是一些常见的设计点,把其他的就留给学术研究吧。

原型对象应该封装还是暴露

在我们的例子中,怪物们都有一个breed的引用,但是并没有暴露它。外部代码不能直接从怪物对象中直接获取breed。从代码的角度来看,怪物们并没有类型,所谓的类型只是一些实现细节。

我们可以很容易地让Monster暴露它的breed:

Monster的这一个改动,事实上让所有怪物都能够暴露它的Breed了。无论封装还是暴露,都有一定的好处。

如果type object是封装的:

1、Type Object模式本身的复杂性就对外部代码不可见了,其实现细节只需要type object自己关心。

2、 Type Object可以有选择的覆盖一部分方法。如果我们想在怪物频临死亡的时候更改攻击字符串。因为攻击字符串都是通过Monster获得的,我们有一个很合适的地方可以放这些代码:

如果外部代码直接通过breed调用getAttack(),我们就不会有机会插入这些逻辑了。

3、我们必须为暴露breed写一些访问方法。这是设计中最蛋疼的部分。如果我们的原型对象有大量的方法,那我们的对象类,需要为每一个需要暴露的方法或属性写一个自己的方法。

如果Type Object是暴露的

1、外部代码不需要通过原型化的对象,就可以直接跟原型对象交互。如果原型对象是封装的,如果不通过包装它的原型化对象,是不能用到原型对象的。这其实是在保护我们,自从我们的原型化对象从构造者模式中被创建出来后,如果外部代码无法获得breed,那也就无法直接调用它。

2、原型对象成了对象API的一部分。通常更窄的API范围比宽泛的API更容易维护。你向外部代码暴露的越少,你需要处理的复杂性和可维护性就越容易。通过暴露Type Object,我们对象的API范围就包含了原型对象提供的所有内容。

原型对象如何创建

在这个模式下,每一个“对象”都被分成了主对象和原型对象。那么我们怎么创建它们,并把它们绑在一起?

1、构建主对象,然后把原型对象传给它:

外部代码可以控制内存申请。因为构建过程是各自进行的,它就可以控制这些对象产生在哪些内存中。如果我们希望我们的对象有更灵活的内存策略(不同的申请器,在栈中申请,等等),用这种方法构建就可以实现。

2、调用原型对象中的“构造者”函数:

原型对象控制了主对象的内存申请。这就是硬币的另一面了。如果我们不希望外部代码决定对象的内存使用,希望他们通过工厂方法创建对象,这中方式就可以达到目的。同时,我们还可以确保所有的主对象都从一个指定的对象池或者其他的内存申请器中产出。

可以更换类型吗

到目前为止,一旦一个对象被创建出来并绑定一个原型之后,他们的绑定关系就不再改变。白头到老。但这并不是天经地义的。我们应该让对象可以随时更换它的类型。

在回来看我们的例子。当一个怪物死掉之后,如果设计师想让它变成僵尸。我们可以在怪物死掉之后重新生成一个怪物然后给它一个僵尸的原型,但是也可以更简单地把原来的怪物原型换成僵尸。

如果类型不能换:

1、这样既容易写代码,也容易理解。在概念层面,“类型”是不能被改变的,这比较符合人们的直觉。

2、更容易调试。如果我们跟踪一个怪物进入奇怪状态的bug,最简单的就是我们可以知道它的原型,因为它一直没变过。

如果类型可以改变:

1、需要创建的对象减少。在我们的离职中,如果不能改变类型,我们需要消耗多余的CPU时钟去创建新的僵尸怪物,并且从原来的怪物中拷贝所有的属性,然后删除原来的怪物。如果我们能改变类型,需要做的只是一个赋值。

2、我们需要更小心的检查这种结合是否合适。对象和它的原型有着微妙的关系。例如,一个类型需要假定一个怪物的血量永远不会高于它给定的初始血量。

如果我们允许改变类型,我们需要确保这个类型与已经存在的对象是匹配的。当改变类型的时候,我们可以需要一些验证代码去确保这件事情。

支持何种继承

1、无继承:

1)很简单。大道至简。如果你没有很多属性需要在两个对象中共享,何必要自找麻烦呢?

2)这回带来重复劳动。我曾经见过一个正在开发的系统。设计者不想使用继承。当你有50个种类的侏儒,需要在50个地方修改他们的血量,很让人崩溃。

2、单继承:

1)也很比较简单。很容易实现,更重要的是很容易理解。如果这个系统要给非技术人员使用,可变部分越少越好。这就是为什么很多语言都只支持单继承。这就是在功能和简洁之间找到了一个平衡点。

2)访问属性的速度变慢了。为了从一个对象中得到一个数据,我们可能需要遍历继承链,最后得到它的数值。如果是对性能比较敏感的代码,这部分消耗是我们不愿意承担的。

3、多继承:

1)几乎可以规避所有的重复代码。如果多继承运用得当,我们可以构建一个几乎没有冗余的体系。当它用来调用数据的时候,可以省去大量的复制粘贴。

2)很复杂。不幸的是,事实上我们从中得到的好处并没有理论上那么多。多继承是很难理解和推敲的。

如果一个僵尸龙类型继承自僵尸和龙,那到底是采用僵尸的属性还是龙的?为了用这个系统,用户需要理解继承图谱的遍历关系和一个的智能结构。

现在我看到的大多数C++编程规范中都倾向于禁用多继承。Java和C#更是直接不支持。这就是一个让人悲伤的事实:既然很难用对,那就别用了。然而值得注意的是,其实我们在游戏中真正需要用到多继承的机会并不多。还是那句话,大道至简。

发表评论