子类沙箱
游戏设计模式Behavioral Patterns
意图
用一系列由基类提供的操作定义子类中的行为。
动机
每个孩子都梦想过变成超级英雄,但不幸的是,宇宙射线在地球上供不应求。 让你扮演超级英雄的游戏是最接近的替代品。 因为我们的游戏设计者从来没有学会说“不”,我们的超级英雄游戏旨在提供几十种、甚至上百种不同的超级能力供英雄选择。
我们的计划是创建一个Superpower基类。然后由它派生出各种超级能力的实现类。
我们在程序员队伍中分发设计文档,然后开始编程。
当我们完成时,我们就会有上百种超级能力类。
我们想让玩家沉浸在一个充满多样性的世界里。无论他们在孩童时想象过什么能力,我们都要在游戏中展现。 这就意味着这些超能力子类几乎需要做任何事情: 播放声音,生成视觉特效,与AI交互,创建和销毁其他游戏实体,与物理打交道。代码库中没有哪个角落是它们不会碰到的。
假设我们让团队信马由缰地写超能力类。会发生什么?
-
会有很多冗余代码。 虽然不同的能力千差万别,我们仍然可以预期有不少重叠。 很多超能力都会用相同的方式生成视觉特效并播放声音。 仔细想想,冷冻光线、热能光线、第戎芥末酱光线其实都很相似。 如果实现这些的人不互相协调,那就会产生大量重复的代码和重复劳动。
-
游戏引擎中的每一部分都会与这些类耦合。 如果不了解情况,人们就会写出直接调用那些子系统的代码,但这些子系统从来没打算直接与超能力类绑定。 就算渲染器被好好组织成多个整洁的层次,其中只有一层是打算让图形引擎之外的代码使用的, 我们也可以打赌,最终超能力代码会插手每一层。
-
当这些外部系统需要改变时,某些随机的超能力代码很有可能被破坏。 一旦我们有了不同的超能力类把自己绑定到游戏引擎的各种部分,改变那些部分就必然影响超能力类。 这可不好玩,因为图形、音频、UI程序员很可能不想也成为玩法程序员。
-
很难定义所有超能力都遵守的不变量。 假设我们想保证超能力播放的所有音频都被正确地排队并设定优先级。 如果我们的上百个类都各自直接调用音频引擎,就没什么好办法来完成这点。
我们要的是给每个实现超能力的玩法程序员一套可以拿来玩的原语。
你想要播放声音?这是你的playSound()函数。
你想要粒子效果?这是你的spawnParticles()函数。
我们会确保这些操作覆盖你需要做的一切,所以你不需要#include随机的头文件,也不必探头探脑地钻进代码库的其他部分里。
我们的做法是让这些操作成为Superpower基类的protected方法。
将它们放在基类中,让每个超能力子类都能直接、方便地访问这些方法。
把它们设为protected(很可能不是虚方法)传达出它们存在就是为了被子类调用。
有了这些玩具之后,我们还需要一个使用它们的地方。 为了做到这点,我们定义沙箱方法,这是子类必须实现的抽象protected方法。 有了这些,要实现一种新的能力,你需要:
- 创建从
Superpower继承的新类。 - 重写沙箱方法
activate()。 - 通过调用
Superpower提供的protected方法来实现方法体。
现在,我们可以通过把那些提供的操作做得尽可能高层次来解决冗余代码问题。
当我们看到代码在多个子类间重复,我们总可以将其收拢到Superpower中,作为它们都可以使用的新操作。
我们通过把耦合约束到一个地方解决了耦合问题。
Superpower最终会与不同的游戏系统耦合,但是继承它的上百个派生类不会。
相反,它们只与基类耦合。
当游戏系统的某部分改变时,修改Superpower也许是必须的,但几十个子类不该需要改动。
这个模式带来一个类层次浅而宽的架构。
你的继承链不深,但是有很多类挂在Superpower下。
通过使用一个拥有很多直接子类的类,我们在代码库中获得了一个杠杆点。
我们投入到Superpower中的时间和爱可以让游戏中一大批类获益。
模式
基类定义抽象的沙箱方法和几个提供的操作。 将操作标为protected,表明它们只供派生类使用。 每个派生出的沙箱子类都用提供的操作实现沙箱方法。
何时使用
子类沙箱模式是一个非常简单、常见的模式,潜伏在许多代码库中,哪怕是在游戏之外的地方也有应用。 如果你有一个非虚的protected方法,你可能已经在用类似的东西了。 子类沙箱在以下情况适用:
-
你有一个带着若干派生类的基类。
-
基类能提供派生类执行任务时可能需要的所有操作。
-
子类中有行为重叠,你想要更容易地在它们之间共享代码。
-
你想要最小化这些派生类和程序其他部分的耦合。
记住
“继承”近来在很多编程圈子里是个“坏词”,原因之一是基类趋向于积累越来越多的代码。 这个模式特别容易染上这个毛病。
由于子类通过基类接触游戏的剩余部分,基类最后会和任何派生类需要对话的每个系统耦合。 当然,子类也紧密地与基类相绑定。这种蛛网耦合让你很难在不破坏什么的情况下改变基类——你就遇到了脆弱的基类问题。
硬币的另一面是,由于你的大部分耦合都被推到了基类,派生类现在与世界其他部分的分离要干净得多。 理想情况下,你的大多数行为都在那些子类中。这意味着你代码库的大部分是孤立的,更容易维护。
如果你发现这个模式正把你的基类变成一大锅代码糊糊, 考虑把其中一些提供的操作移到独立的类中, 这样基类可以把责任分派出去。组件模式可以在这里帮上忙。
示例代码
因为这个模式太简单了,示例代码中没有太多东西。 这不是说它没用——这个模式的关键在于“意图”,而不是它实现的复杂度。
我们从Superpower基类开始:
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// 实现代码……
}
void playSound(SoundId sound, double volume)
{
// 实现代码……
}
void spawnParticles(ParticleType type, int count)
{
// 实现代码……
}
};
activate()方法是沙箱方法。由于它是虚的抽象方法,子类必须重写它。
这让创建超能力子类的人清楚地知道自己的工作该写在哪里。
其他的protected方法move()、playSound()和spawnParticles()都是提供的操作。
它们是子类在实现activate()时要调用的。
在这个例子中,我们没有实现提供的操作,但真正的游戏会在那里放真正的代码。
这些方法正是Superpower与游戏中其他系统耦合的地方——move()也许调用物理代码,playSound()会与音频引擎交互,等等。
由于这都在基类的实现中,耦合就被封装在Superpower内部。
好了,现在放出我们的放射性蜘蛛,创建一个能力。像这样:
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// 空中滑行
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
这种能力把超级英雄弹向空中,播放合适的声音,扬起一小团尘土。
如果所有的超能力都这样简单——只是声音、粒子效果和动作的组合——那么就根本不需要这个模式了。
相反,Superpower可以有一个内置的activate()实现,去访问声音ID、粒子类型和移动对应的字段。
但这只在每个能力本质上运行方式相同、只有数据差异时才可行。让我们再详细展开一点:
class Superpower
{
protected:
double getHeroX()
{
// 实现代码……
}
double getHeroY()
{
// 实现代码……
}
double getHeroZ()
{
// 实现代码……
}
// 退出之类的……
};
这里我们增加了几个获取英雄位置的方法。我们的SkyLaunch子类现在可以使用它们了:
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// 在地面上,冲向空中
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
else if (getHeroZ() < 10.0f)
{
// 接近地面,再跳一次
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() + 20);
}
else
{
// 正在空中,跳劈攻击
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
由于我们现在可以访问状态,沙箱方法就可以做真正有趣的控制流了。
这里仍然只是几个简单的if语句,
但你可以做任何你想做的事情。
让沙箱方法成为一个真正完整、能包含任意代码的方法,就可以天高任鸟飞了。
设计决策
如你所见,子类沙箱是一个相当“软”的模式。它描述了一个基本思路,但是没有很多细节机制。 这意味着每次应用它时,你都需要做出一些有趣的选择。这里是一些需要思考的问题。
应该提供什么操作?
这是最大的问题。它深刻影响这个模式给人的感觉以及实际效果。 在一个极端,基类不提供任何操作。只有一个沙箱方法。 要实现它,就必须调用基类之外的系统。如果你采取这种方式,甚至很难说你在使用这个模式。
另一个极端,基类提供了子类可能需要的所有操作。 子类只与基类耦合,完全不调用任何外部系统。
在这两点之间,有很大一片中间地带:一些操作由基类提供,另一些则直接从定义它们的外部系统访问。 你提供的操作越多,子类与外部系统的耦合就越少,但是基类的耦合就越多。 它消除了派生类中的耦合,但做法是把耦合向上推到基类自身。
如果你有一堆本来都与某个外部系统耦合的派生类,那就是一笔划算的买卖。 通过把耦合上移到提供的操作中,你把它集中到了一个地方:基类。但是你越这么做,这个类就变得越大、越难维护。
那么分界线该画在哪里?这里是一些经验法则:
-
如果提供的操作只被一个或几个子类使用,你的投入就换不来多少回报。 你向基类添加了会影响所有人的复杂性,但是只有少数几个类受益。
为了让该操作与其他提供的操作保持一致,这样做或许是值得的;但让那些特殊情况下的子类直接调用外部系统也许更简单明了。
-
当你调用游戏中其他角落的方法时,如果那个方法不修改任何状态,侵入性就会更小。 它仍然制造耦合,但这是“安全的”耦合,因为它不会破坏游戏中的任何东西。
另一方面,确实会修改状态的调用会把你更紧地绑到代码库的那些部分,你需要对此有更清醒的认识。这让它们很适合被收拢到更显眼的基类所提供的操作中。
-
如果一个提供的操作的实现只是把调用转发给某个外部系统,那它就没增加太多价值。那种情况下,也许直接调用外部方法更简单。
但是,简单的转发仍然有用——那些方法常常访问基类不想直接暴露给子类的状态。 举个例子,假设
Superpower提供这个:void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); }
它只是把调用转发给Superpower中的某个soundEngine_字段。
但好处是,它让该字段封装在Superpower中,让子类无法随便摆弄它。
方法应该直接提供,还是包在对象中提供?
这个模式的挑战是,你可能会在基类里塞进多得令人痛苦的方法。 你可以把其中一些方法移到其他类中来缓解。基类中提供的操作则只返回这些对象之一。
举个例子,为了让超能力播放声音,我们可以直接将它们加到Superpower中:
class Superpower
{
protected:
void playSound(SoundId sound, double volume)
{
// 实现代码……
}
void stopSound(SoundId sound)
{
// 实现代码……
}
void setVolume(SoundId sound)
{
// 实现代码……
}
// 沙盒方法和其他操作……
};
但是如果Superpower已经变得大而笨重,我们也许想要避免这样。
取而代之的是创建SoundPlayer类来暴露该功能:
class SoundPlayer
{
void playSound(SoundId sound, double volume)
{
// 实现代码……
}
void stopSound(SoundId sound)
{
// 实现代码……
}
void setVolume(SoundId sound)
{
// 实现代码……
}
};
然后Superpower提供对它的访问:
class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{
return soundPlayer_;
}
// 沙箱方法和其他操作……
private:
SoundPlayer soundPlayer_;
};
将提供的操作分流到辅助类可以为你做一些事情:
-
减少了基类中的方法。 在这里的例子中,将三个方法变成了一个简单的获取函数。
-
辅助类中的代码通常更容易维护。 像
Superpower这样的核心基类,不管我们本意多好,往往都很难改动,因为有太多东西依赖它。 通过把功能移到耦合较少的次要类中,我们让这些代码更容易摆弄,而不必破坏其他东西。 -
降低了基类和其他系统的耦合度。 当
playSound()是直接定义在Superpower上的方法时,基类会与SoundId以及实现所调用的任何音频代码直接绑定。 把它移到SoundPlayer中,把Superpower的耦合减少到只剩SoundPlayer这一个类,由后者封装它其余的所有依赖。
基类如何获得它需要的状态?
你的基类经常需要一些数据,它想把这些数据封装起来并对子类隐藏。
在第一个例子中,Superpower类提供了spawnParticles()方法。
如果这个方法的实现需要某个粒子系统对象,它该怎么获得呢?
-
将它传给基类构造器:
最简单的解决方案是让基类把它作为构造器参数:
class Superpower { public: Superpower(ParticleSystem* particles) : particles_(particles) {} // 沙箱方法和其他操作…… private: ParticleSystem* particles_; };这安全地保证了每个超能力在构造时能得到粒子系统。但让我们看看子类:
class SkyLaunch : public Superpower { public: SkyLaunch(ParticleSystem* particles) : Superpower(particles) {} };我们在这儿看到了问题。每个派生类都需要有一个构造器,去调用基类构造器并把这个参数传过去。这让每个派生类都接触到一段我们不想让它们知道的状态。
这也是维护上的麻烦。如果我们后续向基类添加了另一段状态,每个派生类里的每个构造器都得修改才能把它传过去。
-
使用两阶段初始化:
为了避免通过构造器传递所有东西,我们可以把初始化划分为两个步骤。 构造器不接受任何参数,只是创建对象。然后,我们调用直接定义在基类上的单独方法,传入它需要的其余数据:
Superpower* power = new SkyLaunch(); power->init(particles);注意,由于我们不会向
SkyLaunch的构造器传入任何东西,它不会与Superpower中想要保持私有的任何东西耦合。 这种方法的问题在于,你必须保证永远记得调用init()。如果忘了,你会得到一个处在半明半暗的半成品状态、无法运行的超能力。你可以将整个过程封装到一个函数中来修复这一点,就像这样:
Superpower* createSkyLaunch(ParticleSystem* particles) { Superpower* power = new SkyLaunch(); power->init(particles); return power; } -
让状态静态化:
在先前的例子中,我们用粒子系统初始化每一个
Superpower实例。 在每个能力都需要自己独特的状态时,这是有意义的。但如果粒子系统是单例,那么每个能力都会共享相同的状态。如果是这样,我们可以让状态是基类私有而静态的。 游戏仍然要保证初始化状态,但是它只需要为整个游戏初始化
Superpower类一遍,而不是为每个实例初始化一遍。class Superpower { public: static void init(ParticleSystem* particles) { particles_ = particles; } // 沙箱方法和其他操作…… private: static ParticleSystem* particles_; };注意这里的
init()和particles_都是静态的。 只要游戏早早调用过一次Superpower::init(),每种能力都能访问粒子系统。 同时,可以调用正确的派生类构造器来自由创建Superpower实例。更棒的是,现在
particles_是静态变量, 我们不需要为每个Superpower实例存储它,这样我们的类占用的内存更少了。 -
使用服务定位器:
前一选项要求外部代码特意记得在基类需要之前把它需要的状态塞进来。 初始化的负担落到了周围的代码身上。另一选项是让基类拉取它需要的状态。 而做到这点的一种实现方法是使用服务定位器模式:
class Superpower { protected: void spawnParticles(ParticleType type, int count) { ParticleSystem& particles = Locator::getParticles(); particles.spawn(type, count); } // 沙箱方法和其他操作…… };这里,
spawnParticles()需要一个粒子系统;它不靠外部代码递给它,而是自己从服务定位器里取一个。