游戏编程模式-子类沙盒

 写在前面  

  “使用基类提供的操作集合来定义子类中的行为。“

动机

  在游戏中,我们可以实现各种各样的想法,比如说创造一个超级英雄, 我们为超级英雄创造各种能力。这个时候我们可以怎么做了?建立一个superpower的基类,然后使用派生的想法,构建各种派生类来实现超能力。但这里会很快的出现问题,因为超能力的多种多样,我们可能需要在派生类中做各种可能的事情:比如播放音效、产生视觉效果、与AI交互、创建和销毁其它游戏实体以及产生物理效果。它们可能触及代码库的每一个角落。很明显,这样会:

  •   产生大量的冗余代码。虽然各种超能力不同,但它们非常可能以同样的方式来产生视觉效果和播放音效,如果人们在实现它们的时候没有整合起来,那么将会产生大量的重复代码和重复劳动;
  •   游戏引擎的每个部分都将与这些类产生耦合;
  •   当这些外部系统需要改变时,superpower类的代码很可能遭到随机性的破坏。因为我们的superpower类与外部代码存在很强的耦合关系或者说依赖关系,一旦外部系统发生改变,不可避免的会影响到superpower类;
  •   定义所有superpower都遵循的不变量时很困难的。例如说我们像保证所有的power类的音效都能得到合理的优先级划分和排队,如果这些派生类都直接调用音效引擎的话,这将很难实现。

  这个时候我们应该怎么办了?

  对于播放音效,我们可以提供一个playSound(非虚)的方法放置于superpower类中,派生的子类需要播放音效的时候都调用这个方法,这样我们就能对音效的播放进行一个统一的管理,比如说调整优先级。而这就引申出了一个做法:把子类需要的功能封装成方法放到基类中,然后让子类访问这些方法。

  这个时候衍生了一个新的问题,如何安放这些方法了?

  也就是说子类应该如何来组织使用这些方法实现功能了?为此我们定义一个沙盒方法,这个时候子类必须实现的抽象保护方法。所以接下来你要做的就是:

  1.   创建一个类继承基类;
  2.   重写沙盒函数activate();
  3.   通过调用基类提供的方法来实现子类的功能;

  也就是我们把基础的操作代码提取到更高的层次来解决冗余的问题。一旦我们在子类中发现大量的重复代码,我们就会把它上移到基类中作为一个新的基本方法。也就是说我们把子类的耦合都提取到父类中,这样耦合的地方就只有一处,每个子类仅与基类耦合。一旦游戏的某个部分发生变化时,我们只需要修改基类即可,不会牵扯到子类的修改。这样的设计会催生一种扁平的类层次架构。你的继承链不会太深,但会有大量的子类,这些子类与基类挂钩。通过一个类派生大量的子类,我们限制了该代码在代码库中的影响范围。

沙盒模式

  一个基类定义一个抽象的沙盒方法和一些预定义的操作集合。通过将它们设置为受保护的状态以确保它们仅供子类使用。每个派生出的沙盒子类根据父类提供的操作来实现沙盒函数。

使用情境

  沙盒模式是运用在多数代码库里、甚至游戏之外的一种非常简单通用的模式。如果你正在部署一个非虚的受保护方法,那么你很可能正在使用与之类似的模式。沙盒模式适用于一下情况:

  •   你有一个带有大量子类的基类;
  •   基类能够提供子类所有可能需要执行的操作集合;
  •   在子类之间有重叠代码,你希望在它们之间更简单的共享代码;
  •   你希望使这些继承类与程序的其它代码之间的耦合最小化。

使用须知

  这些年“继承”一词被部分程序圈所诟病,原因之一使基类会衍生越来越多的代码。这个模式尤其受这个因素影响。

  由于子类使通过它们的基类来完成各自功能的,因此基类最终会与那些需要与其子类交互的任何系统产生耦合。当然,这些子类也与它们的基类密切相关。这个蜘蛛网式的耦合使得无损的改变基类使很困难的——你遇到类脆弱的基类问题。但从另一个角度来说,你的所有耦合都被聚集到了基类,子类现在与其它部分划清了界限。理想的情况下,你的绝大部分操作都在子类中。这样意味着你的大量代码库使独立的,并且更容易维护。

  如果你仍然发现本模式正在把你的基类变得庞大不堪,那么请考虑一些提供的操作提取到一个基类能管理的独立类中。这里可以借鉴组件模式。

示例

  superpower基类:

class Superpower
{
 public:
    virtual ~Superpower(){}

 protected:
    virtual void activate() = 0;

    void move(double x, double y, double z)
    {
         //move code
    }

    void playSound(SoundId sound)
    {
          //play code
    }

     //other methods....
};
    

  这里activate就是沙盒函数。由于它是抽象虚函数,因此子类必须要重写它。这是为了让子类实现者能够明确它们该对子类做什么。接下来让我们实现一些子类来说明子类是如何创建的。

class SkyLaunch:public Superpower
{
 protected:
    virtual void activate()
    {
        move(0,0,30);
        playSound(SOUND_SPROING);
     }
};

     这里,子类做的事很简单,移动,然后播放音乐。因为操作都放到了基类中,子类没有与外部代码有任何的耦合。当然,我们可以做其它更复杂的事,只需要在基类中提供相应的基本操作,你可以放飞你的想象力。

 沙盒模式就是如此的简单,代码并不太多,它描述的是一个基本的思想,但并没有给出过于详细的机制。所以这里你还是要面临一些抉择:

设计决策

  需要提供什么操作?

  这里有两个极端,一种是基类什么操作都不提供,只提供一个沙盒方法;而另一个就是基类提供子类所有需要的操作。子类仅与基类耦合,不同调用任何外部系统。前者基类不提供任何操作,所以基类与外部系统的耦合度低,随着基类提供的操作多,与外部系统的耦合就越来越高。如果我们把所有的操作都聚集到基类,那么基类就会变得很大,维护起来也就会越来越困难,所以我们应该如何做出选择了?

  •   如果所提供的操作仅仅被一个或者少数的子类使用,那么不必将它加入到基类。这只会给基类增加复杂度,同时影响每个子类,但只有少数子类受益;将该操作与其它提供的操作保持一致或许值得,但让特殊子类直接调用外部系统或许更为简单和清晰;
  •   当你在游戏的其它模块进行某个方法调用时,如果它不修改任何状态,那么它就不具备侵入性。它仍然产生耦合,但这是一个“安全”的耦合,不会给游戏带来破坏;
  •   如果提供的操作,其实现仅仅是对一些外部系统调用的二次封装,那么它并没有带来多少价值。这种情况下直接调用外部系统更简单。然而,有时极其简单的转向调用也仍有用——这些函数通常访问基类不想直接暴露给子类的状态。

  是直接提供函数,还是有包含它们的对象提供?

  这个设计模式的挑战在于最终你的基类可能塞满了方法。你能够通过转移一些函数到其它类中来缓解这种情况,并于基类的相关操作中返回相应的类对象即可。就像这样:

class SoundPlayer
{
Protected:
    SoundPlayer& getSoundPlayer()
    {
        return soundPlayer_;
    }

private:
    SoundPlayer soundPlayer_;
};

  把提供的操作分流到一个像这样的辅助类中能给你带来些好处。

  •   减少基类的函数数量。
  •   在辅助类中,代码通常更容易维护;
  •   减低基类和其它系统之间的耦合。

  基类如何获取所需状态?

  你的基类通常希望封装一些数据以对子类保持隐藏。比如,我们想在系统中添加一些例子特效,那我们如何把粒子系统对象传递给基类了?

  •   把它传递给基类的构造函数

  像这样:

class Superpower
{
 public:
    Superpower(ParticleSystem* particles):particles_(particles)
    {}

private:
    ParticleSystem* particles_;
};

  这样虽然解决了问题,但同时带来了另一个问题。就是每个继承类都需要一个构造函数来调用基类的构造函数并传递那个粒子系统参数。这样就向每个子类暴露了一些我们并不希望暴露的状态。而且,这样也存在维护负担。如果后面我们添加另一个状态,那么我们不得不修改每个继承类的构造函数来传递它。

  •   进行分段初始化

  为了避免通过构造函数传递所有的东西,我们可以把初始化拆分为两个步骤。构造函数不带参数仅仅负责创造对象,然后我们通过一个直接定义在基类中的函数来传递它所需要的其它数据。比如像这样:

Superpower* power = new SkyLaunch();
power->init(particles);

  这里可能发生的就是我们忘记调用init函数,那样我们就只能得到构造了一半的对象。对于这个问题可以通过封装一个方法来解决。

Superpower* createSkyLaunch(ParticleSystem *particles)
{
    Superpower* power = new SkyLaunch(); 
    powwer->init(particles);
    return power;
}

  如果你想控制只能使用这个函数来创建SkyLaunch对象,可把SkyLaunch的构造函数声明为私有的来实现,类似单例模式的实现方式。

  •   将类静态化

  我们可以把我们不想暴露的状态声明为基类的私有成员,同时也是静态的,游戏将不得不保证初始化这个状态,但它仅需要整个游戏初始化一次,只要保证尽早调用初始化函数即可。这中做法还带来一个好处就是因为是静态变量,所有实例共用一个,所以占用的内存更少。

  •   使用服务定位器

  前面的方法严格要求外部代码必须在基类使用相关状态之前将这些状态传递给基类,这给周围的代码的初始化工作带来了负担。另外一个选择是让基类把它们需要的状态拉进去处理。一个实现方法是使用服务定位器。

class Superpower
{
 protected:
    void spawnParticles(ParticleType type,int count)
    {
        ParticleSystem& particles = Locator::getParticles();
        particles.spawn(type,count);
    }
};

  这里spawenParticles需要一个粒子系统。它从服务定位器获取了一个,而不是由外部代码主动提供。

  

  

posted @ 2019-09-25 00:02  北冥有鱼其名为鲲  阅读(579)  评论(0编辑  收藏  举报