游戏编程模式-组件模式
“允许一个单一的实体跨越多个不同域而不会导致耦合。”
动机
在游戏的编程中,我们很容易写出一个超级大而且耦合度很高的类来。比如我们的英雄角色,我会使用各种输入设备来操纵它,会给他添加华丽的技能特效和音效,这就需要我们写很多的代码了,有读取控制器输入的代码,有物理和碰撞的代码,有音效的代码,还有播放动画的代码,最后还要渲染。这些代码我们都掺杂到一个类中,会导致这个类超级的大,这对于维护的人来说简直不能接受,改动代码的代价太大了。到最后我们会发现,我们实现功能的速度远远比不上代码的产生速度。
所以我们需要分割代码。就像我们设计一个文字处理器,处理打印部分的代码不应该受到读取、保存文档的代码的任何影响。我们要让物理、AI、渲染、音效这些部分相互独立。比如下面的一段代码:
if(collidingWithFloor() && (getRenderState() != INVISIBLE)) { playSound(HIT_FLOOR); }
逻辑很清晰,但对于需要修改它的人来说却不简单,他需要了解物理、图像和声效的相关知识,否则任何的更改都可能会引入bug。这无疑对需要维护这段代码的人来说增加了很大的负担。这里的一个解决方案就是分割——我们把功能分成一个一个的域,这样输入输出是一个域,物理引擎和碰撞是一个域、AI是一个域、声效也是一个域。我们按这些域来组织代码,那么我们可以把输入输出的代码都放入一个叫InputComponent的类中,声效放入AudioComponent的类中。这样到最后,我们会发现我们的英雄角色类就变成了一个容器,里面放着所有需要的这些组件类。
这样,原来一个很庞大的类就分成了一个一个的组件类,这些组件类之间实现了解耦。当然,在实践中,我们会遇到一些需要互动的情况,比如物理组件在发生碰撞的时候需要播放音效,这个时候,我们可以很容易的把这些需要通信的地方进行限制,代码之间的后耦合度还是很低。同时,这也提高了代码的复用性。我们可以看一些继承是怎么复用代码的,比如我们的游戏中有一个GameObject的基类,它包含位置和方向这种基本的元素。而Zone类继承这个基类并在其基础上添加了碰撞。相似的,Decoration类也继承了这个基类并在其基础上增加了渲染。Prop类继承自Zone类,所以它可以重用碰撞检测代码。而Prop类不能同时继承自Decoration类来重用渲染代码,否则继承结构将陷入“致命的菱形多继承“的窘境。我们也可以让Prop类继承Decoration类,但碰撞部分的代码我们就需要复制过来,也就是说我们没有办法不通过多重继承而在多个类之间重用碰撞跟渲染部分的代码。另一个解决方案就是把碰撞和渲染代码都放入基类中,但这样基类就会很庞大,而另一些类可能不需要这些功能,这样会造成内存的浪费。
而使用组件,那渲染一个组件类,碰撞一个组件类,那么Zone就需要包含碰撞组件类就可以,而Decoration类就需要包含渲染组件,而Prop则同时包含碰撞和渲染组件,这样没有代码重复,没有多重继承。从这我们可以看出,组件对于对象而言就是即插即用,借由组件,我们能通过让实体身上插不同的、可重用的组件对象来构建复杂而且行为丰富的实体。
组件模式
单一实体横跨多个域。为了保持域之间相互隔离,每个域的代码都独立的放在自己的组件类中。实体本身可以简化为这些组件的容器。
使用情境
组件最常见于游戏中定义实体的核心类,但是它们也能够用在别的地方。当如下条件成立时,组件模式就能发挥它的作用。
- 你有一个涉及到多个域的类,但你希望让这些域保持相互解耦;
- 一个类越来越庞大,越来越难以开发;
- 你希望定义许多共享不同能力的对象,但采用继承的办法却无法令精确的重用代码;
注意事项
组件模式相较直接在类中编码的方式为类本身引入了更多的复杂性。每个“概念”上的对象成为一系列必须被同时实例化、初始化,并正确关联的对象的集群。不同组件之间的通信变得更具挑战性,而且对它们所占用的内存的管理将变得更复杂。对于一个大型代码代码库,这点复杂性相对于它带来的接口和代码重用时值得的。但在没有出现问题的代码库中你不必过度设计而使用这样一个“解决方案”。
使用组件模式的另外一个后果就是你经常需要一系列的间接引用来处理问题,也就是你必须先获取组件的引用,这对于对性能要求较高的内部循环代码中,组件指针可能会导致低劣的性能。
示例代码
让我们先不适用组件模式实现一个比较大的类。这个类读取操纵杆的输入来对面包师进行加速。然后通过物理引擎来确定其新的位置,最后将面包师渲染到屏幕上。这里引用了一些其它的类,它们没有被列出来,但我觉得你们应该能懂。
class Bjorn { public: Bjorn():velocity_(0),x_(0),y_(0){} void update(World& world,Graphics& graphics); private: static const int WALK_ACCELERATION=1; int velocity_; int x_,y_; Velume volume_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; }; void Bjorn::update(World& world,Graphics& graphics) { //Apply user input to hero's velocity. switch(Controller::getJoystickDirection()) { case DIR_LEFT: velocity_ -= WALK_ACCELERATION; break; case DIR_RIGHT: velocity_ += WALK_ACCELERATION; break; default: break; } x_ += velocity_; world.resolveCollision(volume_,x_,y_,velocity_); //Draw the apropriate sprite Sprite* sprite = &spriteStand_; if(velocity_ < 0) sprite = &spriteWalkLeft_; else if(velocity_ > 0) sprite = &spriteWalkRight_; graphics.draw(*sprite,x_,y_); }
逻辑很容易懂,但可以观察到,这里的耦合程度很高。接下来我们根据功能边界进行分割,首先我们可以分割输入域。也就是把输入相关的代码封装到一个组件类中。
class InputComponent { public: void update(Bjorn& Bjorn) { switch(Controller::getJoystickDirection()) { case DIR_LEFT: bjorn.velocity -= WALK_ACCELERATION; break; case DIR_RIGHT; bjorn.velocity += WALK_ACCELERATION; beak; default: break; } private: static const int WALK_ACCELERATION=1; };
封装非常的简单,接下来我们再把其它部分进行分割。
class PhysicsComponent { public: void update(Bjorn& bjorn,World& world) { bjorn.x += bjorn.velocity; world.resolveCollision(volume_,bjorn.x,bjorn.y,bjorn.velocity); } private: Volume volume_; }; class GraphicsComponent { public: void update(Bjorn& bjorn,Graphics& graphics) { Sprite* sprite = &spriteStand_; if(bjorn.velocity < 0) { sprite = &spriteWalkLeft_; } else if(bjorn.velocity > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite,bjorn.x,bjorn.y); } private: Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; };
组件分割好后,我们看看Bjorn类变成什么样了。
class Bjorn { public: int velocity; int x,y; void update(World& world,Graphics& graphics) { input_.update(*this); physics_.update(*this,world); graphics_.update(*this,graphics); } private: InputComponent input_; PhysicsComponent physics_; GraphicsComponent graphics_; };
现在Bjorn只做了两件事:持有定义了Bjorn行为的组件和持有这些域所共享的状态量。为什么不把这些共享的状态量也放入组件中了?这是因为这些状态量比较通用,把它们放在Bjorn中可以轻松的在组件之间传递消息而不同耦合它们。
现在这个Bjorn类中,各组件都是内置的,没有被抽象化,也就是说Bjorn的行为还是被精确的定义的。我们可以修改一下,改为持有组件的指针,通过构造函数或者其它接口把组件传入Bjorn中,这样我们就能定义各种各样的组件,它们继承自这些抽象化的组件,从而实现Bjorn丰富的行为。当然,代价就是虚函数调用。
class Bjorn { public: int velocity; int x,y; Bjorn(InputComponent* input,PhysicsComponent* physics,GraphicsComponent* graphics) :input_(input),physics_(physics),graphics_(graphics) { } void update(World& world,Graphics& graphics) { input_->update(*this); physics_->update(*this,world); graphics_->update(*this,graphics); } private: InputComponent *input_; PhysicsComponent *physics_; GraphicsComponent *graphics_; };
我们观察一下,发现现在Bjorn类中已经没有任何Bjorn独有的代码了,它更像一个组件包。而事实上,它是一个能够用到游戏中所有对象的游戏基本类的最佳候选。所以我们给他改个名字——GameObject。
class GameObject { public: int velocity; int x,y; GameObject(InputComponent* input,PhysicsComponent* physics,GraphicsComponent* graphics) :input_(input),physics_(physics),graphics_(graphics) { } void update(World& world,Graphics& graphics) { input_->update(*this); physics_->update(*this,world); graphics_->update(*this,graphics); } private: InputComponent *input_; PhysicsComponent *physics_; GraphicsComponent *graphics_; };
这个时候,如果我们想要一个Bjorn的对象,只需要给GameObject中传入Bjorn需要的组件即可,也就是这样。
GameObject* createBjorn() { return new GameObject( new PlayerInputComponent(), new BjornPhysicsComponent(), new BjornGraphicsComponent()); }
设计决策
关于这个设计模式的最重要的问题是:你需要的组件集合是什么?答案取决于你的游戏需求与风格。引擎越大越复杂,你就越想要将组件切分的更细。除此之外,有一些具体选择需要考虑。
对象如何获取组件
一旦我们将对象切分为数个独立的组件,我们就必须决定谁在背后来联系这些组件。
一种做法是由容器类来创建组件,这么做的好处就是我们不可能在创建新对象的时候忘记创建组件,但这样将让我们重新配置这个类变的十分困难,丧失了灵活性。另一种推荐的做法就是由外部代码提供组件,这样,构建对象将变得很灵活,通过组合不同的组件来构建新的对象,最大限度的重用了代码。同时我们可以把这个类做成通用的容器类,同时允许组件派生,那么对象就只需要持有组件的接口,这样能够很好的封装结构。
组件如何传递信息
虽然通过区间实现了组件之间的功能隔离和相互解耦,但这些组件最终是要属于一个对象,这意味着它们都是整体的一部分,因此它们需要相互协作,也就是通信。那么如何进行通信了?这里由几个选择你都可以选择,解决方案并不唯一。
-
通过修改容器状态
优点就是保持组件间的解耦,组件互相之间是透明的。比如输入组件和物理组件都可能改变游戏对象的速度,但它们不知道互相的存在,它们只是改变容器类的速度属性。而缺点就是,它要求共享的数据都由容器对象进行共享,而通常组件只需要其中的一小部分,比如图形组件就不需要游戏对象速度这一属性,但它们都保存在容器对象中,也就是图形组件也能访问这些属性,这样做很容易弄乱这个对象类;另一个缺点就是数据传递将变得很隐秘同时对组件的执行顺序产生了依赖。比如对游戏对象的速度这一项,我们需要先读取输入,然后物理引擎更新,再渲染到屏幕上,也就是我们需要小心的维护组件间的执行顺序,否则将产生一些难以追踪的bug。比如,先执行图形渲染,再更新物理引擎,将导致游戏对象的位置是上一帧而不是当前帧。
-
直接相互引用
相互引用也就是组件之间需要知道其它组件对象,比如渲染组件更新的时候传入物理组件。这样做的优点很明显,非常的简单快捷,但缺点就是耦合很紧密,和最开始那个巨大的单类本质上是一样的,但因为把耦合限制在了需要交流的组件中,耦合度并没有那么大,某些情况下也是可以接受的。
- 通过传递信息的方式
这是最复杂的一个方式。我们可以在容器类中建立一个小的消息传递系统,让需要传递信息的组件通过广播的方式去建立组件间的联系。示例:
class Component { public: virtual ~Component(){} virtual void receive(int message){} }; class ContainerObject { public: void send(int message) { for(int i=0;i<MAX_COMPONENTS;++i) { if(components_[i] != NULL) { components_[i]->receive(message); } } } private: static const int MAX_COMPONENTS=10; Component* components_[MAX_COMPONENTS]; };
组件类有一个接受消息的接口,容器类把消息发送给所有的组件类,由具体组件决定如何处理消息。这么做的好处就是
- 兄弟组件之间是解耦的。就好像之前共享状态变量一样。组件之间唯一的耦合就是消息本身。
- 容器对象十分简单。不像状态共享那样容器类能够获知应该传递给组件的信息,在这里,容器类只负责把消息送出去。这对两个类之间传递非常特定的信息而不让容器类获知是个非常有用的方法。
意料之外的是,没有哪种选择是最好的。状态共享对每个对象都拥有的基本状态如位置和尺寸非常的管用,而有些域虽然不同但联系紧密,比如动画和渲染、用户输入和AI、物理引擎等。如果你有上述强关联的组件的话,最简单的方法就是在它们之间建立联系。消息传递是个对“不太重要”的通信有用的机制。其”即发即弃”(fire and forget)的特性非常适合类似当物理组件发送一个消息告知对象与物体发生碰撞时,通知声音组件去播放声音的情况。
所以,与往常一样,我们建议你从最简单的开始,然后在你的组件需要通信的时候再考虑使用哪种通信方式。