游戏编程设计模式

[翻译]游戏编程设计模式——Subclass Sandbox

意图

用基类提供的操作集合,去定义子类的行为。

动机

每个孩子都有一个当超级英雄的梦,但是地球上并没有那么多宇宙射线(可以让人变异的射线)。但是,游戏能让你成为虚拟的英雄。因为在我们游戏设计师的字典里,没有”不行”,我们的目标是为超级英雄提供几十甚至上百种超能力,供英雄们选择。

我们的计划是先定义一个Superpower基类。然后,我们将会有子类去继承它,每一个子类实现一种超能力。我们会写一个设计文档,分发给团队中的程序员去编码。完成后,我们会的到100个超能力类。

我们希望为玩家提供一个充满变化的虚拟世界。能够在游戏中体验到所有他儿时梦想的超能力。这就意味着我们的超能力子类有可能做任何事:播放声音,生成特效,与AI交互,创建和销毁游戏元素,物理模拟力。无所不包。

想象一下,如果我们召集团队,让他们去写那些超能力的类,会发生什么事情?

1、会产生大量的废代码。虽然不同的超能力有很多变化,但是也有很多相同的地方。他们很多会用相同的方式产生视觉效果,播放声音。冷冻射线,伤害射线,第戎芥末射线实现起来都差不多。如果不是协同开发,一定会有很多重复的代码和努力。

2、游戏引擎中的任何部分都有可能能这些类耦合起来。如果不加深刻理解,程序员很容易在这些超能力类中,调用那些本不打算暴露出来的接口。就像渲染器可以很好的抽象成基层优雅的结构,供外部调用,但是我敢打赌,这种结构会被那些到处都是的超能力代码终结掉。

3、当这些外部系统需要改动时,很可能会导致超能力部分的代码被破坏。一旦我们的超能力类们相互耦合,或者跟游戏引擎中的一些部分耦合在了一起,这必然会导致牵一发动全身。这会让人很糟心,因为图形、音频、UI程序员并不能兼任玩法程序员。

4、很难定义所有超能力类都遵循的规则。比如我们希望所有的超能力发出的声音都能按照优先级排好队。但如果我们上百个类都直接调用声音引擎,就很难做到。

我们希望给玩法程序员提供一系列方法。你的超能力类想播放声音?给你playSound()函数。你像要粒子系统?给你spawnParticles()。我们希望提供的这些操作能够覆盖你所有的需求,这样你就不需要#include一堆乱七八糟的头文件,也不需要关心代码库中的其他代码。

我们可以通过在Superpower基类中定义protected方法来实现。把它们放在基类里,可以方便子类直接调用。做成protected方法(也可以是非虚函数),也保证了只有子类能够调用。

1、创建一个新类,继承Superpower。

2、覆盖activate()沙箱方法。

3、实现这个方法,调用Superpower提供的protected方法。

我们可以解决代码重复的问题,也就是尽可能提供高层级的通用方法。当我们发现一些相同的代码散落在不同的子类中时,就可以把他们收集到Superpower中,然后提供一个新的方法。

我们解决了耦合性问题,就是让他们耦合到同一个地方。Superpower本身会跟不同的游戏系统耦合,但是成百的子类就不需要了。他们只需要跟他们的基类耦合就可以了。当游戏系统改变时,修改Superpower类是必须的,但它的子类们就不需要了。

这个模式会产生一个很浅、很广的继承结构。继承链不深,但是会有大量的子类继承自Superpower。通过这个具有很多子类的基类,我们就得到了一个代码上的杠杆指点。我们在Superpower中赋予的时间和爱,会让所有的子类受益。

模式

有一个基类,定义了一个抽象的沙箱方法,并且提供了很多工具函数。把这些函数定义为protected类型,以保证它只被子类调用。每一个继承这个基类的沙箱子类,都用它提供的工具函数实现这些沙箱方法。

何时用

Subclass Sandbox模式非常简单。不像那些常见的模式需要大量的代码,有的甚至在游戏外也需要很多代码(这里可能指bytecode,需要编译器)。如果你代码中有protected类型的方法,你就很有可能已经用类似的模式了。Subclass Sandbox模式比较适合以下几种情况:

1、你的基类有很多子类。

2、基类可以提供子类使用的所有函数。

3、子类中有很多重复的代码,你希望更容易地复用这些代码。

4、你希望在子类和其他部分的程序之间去耦合。

牢记

“继承”这个词在编程界已经臭大街了,其中一个原因就是基类会变得越来越臃肿。而这个模式恰好很容易导致这种情况。

因为子类是通过基类访问游戏中的其他系统的,基类就自然而然的要跟子类要访问的系统耦合到一起。当然,子类也跟基类紧密地耦合在一起,像这样蜘蛛网一样的耦合关系导致基类很难改动——即玻璃基类问题

凡事有好的一面,那就是耦合性都推给了基类,这样子类就可以被从其他游戏系统中分离出来。大多数行为就可以定义在子类中。这就意味着你大多数的代码变得更整洁,更容易维护了。

如果你发现这个模式是把你的基类往火坑里推,也可以考虑拉回来点。把一些工具函数分散到不同的类里面去,Component模式可能会帮到你。

示例代码

因为这是一个很简单的模式,没有太多的示例代码。也不能说没用——这个模式讨论的是意图,而不是实现的复杂度。

让我们从Superpower基类开始:

这个activate()方法是沙箱方法。由于是纯虚函数,子类必须重写它。这就让超能力子类的创建工作变得很清晰了。

那几个protected方法,move(),playSound()和spawnParticles(),是提供的工具函数。子类实现activate()方法需要用到它们。

我们在这里就不实现那些工具函数了,但是在真实的游戏中那就是实实在在的代码。这些代码就是Superpower类从其他游戏系统中调用功能——move()可能会调用物理代码,playSound()会调用声音引擎,等等等等。由于这些都是在基类中实现的,这会让Superpower类保持自封装。

好了,现在让我们创建一种超能力:

此能力把超级英雄弹到空中,播放一个悦耳的音效,然后泛起一股烟尘。如果所有的超能力都类似——只是播放声音,生成粒子系统,和移动——那就根本没必要用这个模式。相反,我们可以使用一个activite()的固定实现,只使用声音id,粒子系统类型,和移动作为参数。但是这适应于每一个超能力都是相同的类型,只是数据有差异。再加工一下:

这里我们添加几个关于英雄位置的方法,我们的SkyLaunch子类可以使用它们:

因为我们能访问一些状态了,我们就可以做一些实实在在的有趣的控制流程。在这里只有几个简单的if语句,但是其实你可以做任何事情。通过这个充满了写死的代码的沙箱方法,英雄救上天了。

设计抉择

如你所见,Subclass Sandbox模式是一个可塑性很强的模式。它只是描述了一个基本思路,而没有太多细节。这就意味着每次你用到它的时候,需要做出很多有意思的选择。这里有几个问题需要考虑:

提供什么样的操作?

这是最大的问题。直接影响到这个模式的工作状况和使用体验。最极限的情况,是在基类中不提供任何操作。它只有沙箱方法。要实现这个沙箱方法,你就不得不调用基类以外的游戏系统。如果你接受了这个方案,那就相当于没用这个模式(废话)。

另一种极限情况,是基类提供了子类所需的所有操作,子类只与基类耦合,不调用任何基类以外的代码。

在这两种极限情况之间,有着大量的中间地带。哪些需要基类提供操作?哪些需要子类直接调用外部代码?你提供的操作越多,子类与外部系统的耦合度就越低,但是基类本身耦合度就越高。只是把耦合度从子类身上抽离,放在了基类身上。

如果你有很多与外界耦合的子类。通过吧这些耦合转移到基类提供的操作中,也就是集中在了基类里。但这样的事情做的越多,基类就变得越来越大,而且越难维护。

那么我们要在哪里划线?这里有几个规则可以参考:

  1. 如果一个操作只被一个或者少数的几个子类用到,那你就没得到太多收益。在基类中徒增复杂度,影响到了所有类,但只有少数子类获益。
    如果是为了保持操作的一致性,或者让一些子类对外部系统的特殊访问更加简洁,那还是有价值的。
  2. 如果调用到游戏中的不改变任何状态的代码,那就比较安全。虽然也有耦合性,但这种耦合性是安全的。如果调用到了一些改变状态的代码,你就需要格外小心。把他们放进基类中,就会得到更好的安全性。
  3. 如果基类提供的操作知识访问了外部的代码,那它就没有太大价值。这种情况下,直接调用反而更容易。
    然而,即使是简单的封装,也是有用的——这些方法通常都是访问那些基类不想直接暴露给子类的状态。例如,Superpower提供像这样的操作:

    这只是转调了Superpower的成员 soundEngine_,好处是,这样保证了这个成员封装在了Superpower中,不被它的子类访问。

是直接提供操作,还是通过包含的对象提供?

这个模式的一个巨大的挑战就是,你最终要在基类中维护大量的方法。你可以把它们转移到其他类中,以减轻这种痛苦。这样,基类提供的方法只需要返回一个包含这些方法的对象。

例如,让一个超能力播放声音,我们可以直接在Superpower中添加:

但如果Superpower已经很臃肿,我们就可以避免。我们可以创建一个SoundPlayer类,去提供这样的功能:

然后Superpower提供访问它的方法:

把操作放到分散的类中,可以为你带来这些好处:

  1. 它可以减少你基类中的方法,在这个例子中,我们把3个方法编程了一个getter方法。
  2. 这些工具类中的代码更容易维护。核心的基类(像Superpower),很难被改动,因为太多的类依赖它。如果把这些功能函数转移到耦合性更低的第二个类中,我们就可以很容易改动这些代码而不破坏其他东西。
  3. 它降低了基类和其他游戏系统的耦合性。当Superpower直接提供playSound()时,我们的基类就牢牢地同SoundId和声音相关的实现代码绑在了一起。把这些方法转移到SoundPlayer中,就是把Superpower的耦合性转移到了SoundPlayer类中,在这个类中就可以封装所有相关的依赖代码。

基类如何获得他需要的数据?

基类通常需要封装一些对子类不可见的数据。在我们第一个例子中,Superpower类提供了一个spawnParticles()方法。如果它实现的时候需要一些粒子系统对象,如何获得呢?

  1. 传递给基类的构造函数:
    最简单的方案就是把它作为基类构造函数的参数:

    这就可以确保每一个Superpower在创建的时候就会拥有粒子系统,但是让我们看一下子类:

    问题出现了,每一个子类都需要在构造的时候调用基类的构造函数,并传递参数。这就把一些不希望子类知道的数据暴露给子类了。
    这维护起来也很让人头痛。如果我们想在基类中添加一个状态,就需要该所有的子类去传递给它。
  2. 两层初始化
    为了避免通过构造函数传递所有数据,我们可以把初始化工作分成两部分。构造函数不设参数,只用来创建对象。然后我们调用另一个基类定义的方法,去把其他需要的数据传递给它:

    注意因为我们没有在SkyLaunch构造函数中传递任何东西,所以它并没有跟Superpower的私有数据产生任何耦合性。这种方式的麻烦在于你必须确保记着调用init(),如果你忘了,你就会得到一个初始化一半的对象,它就可能不好用。
    你可以解决这个问题,把整个过程封装在一个单独的函数里,就像这样:

     
  3. 使用静态成员:
    在前面的例子中,每一个Superpower实例的初始化,我们都用了一个单独的粒子系统。这是假定每一个超能力都需要一份单独的数据。但是我们的粒子系统是一个Singleton,每个超能力类都用的同一个粒子系统。
    这样,我们就可以把这个数据变成基类的私有成员,并且设置为static类型。游戏中仍然需要确保这个成员被初始化,但是只需要在Superpower类中初始化一次,而不是每一个对象都要初始化。

    这里注意init()和particles_都是静态的。只要在开始的时候调用一次Superpower::init()一次,每一个超能力就可以访问例子系统了。同时,Superpower对象也可以通过子类的构造函数随意创建了。
    还有一个好处就是particle_是一个静态变量,我们不需要为每一个Superpower对象存储一份,节约了内存。
  4. 使用service locator:
    前面的选项都需要外部代码创建这些成员,然后在基类使用它们之前,设置到基类中。这会把一些初始化代码写的到处都是。另一个办法是让数据自己创建,基类使用的时候直接拉过来用。其中一种方式就是用Service Locator模式:

    这里,spawnParticles()需要一个粒子系统,不再需要外部代码送进来,它可以从service locator中自己取。

发表评论