游戏设计模式--更新方法
写在前面
游戏世界维护一个对象集合。每个对象实现一个更新方法以在每帧模拟自己的行为。而游戏循环在每帧对集合中所有的对象调用其更新方法以实现游戏和世界的同步。
动机
在游戏开发的过程中,我们通常需要更新某些实体的状态和行为,比如一个骷髅兵,我们通常会让它在某个区域巡逻,但我们的角色进入它们的警戒范围时,它们发动攻击。最简单的实现方式:
while(true) { //patrol right for(double x = 0;x<100;++x) { skeleton.setX(x); } //patrol left for(double x=100;x>=0;--x) { skeleton.setX(x); } }
这些代码实现了巡逻,但玩家却看不见它,我们希望的是它一帧走一步,那我们改一下代码:
Entity skeletion; bool patrollingLeft = false; double x = 0; //main loop while(true) { if(patrollingLeft) { --x; if(x == 0) { patrollingLeft = false; } } else { ++x; if(x == 100) { patrollingLeft = true; } } skeleton.setX(x); }
我们使用patrollingLeft来标志巡逻的方向,然后一帧更新一步,这达到了我们的目标。但发现事情正一点一点的变得复杂起来。比如我们想为骷髅再加一些魔法效果,那我们要再维持一个释放魔法相关的变量,比如释放魔法的条件和当前的状态等。也就是随着我们要做的事情的增多,循环中要保持的状态变量和处理逻辑会爆炸性的增长,这个非常的不利于维护。
很显然,我们需要做些什么了。我们要为游戏中的实体封装行为,同时把这些实体存储在一个游戏列表中,这将使游戏循环保持整洁并便于往循环中增加和移除实体。具体的做法就是:搞一个抽象层,这个抽象层定义一个update的方法,在游戏循环中,不断的遍历集合中的游戏实体,同时调用实体的upate方法。
Enity* entities[MAX_ENTITIES]; while(true) { for(int i=0;i<MAX_ENTITIES;++i) { entities[i]->upate(); } }
使用情境
假如把游戏循环比作有史以来最好的东西,那么更新方法模式就会让它锦上添花。许多游戏都通过这样那样的形式来使用这一设计模式。比如游戏中的太空战士、龙、火星人等。但如果这个游戏更抽象,那些游戏对象不像是生物而更像西洋棋子,那么这个模式就不那么适用了。在一个类似西洋棋的游戏里,你不需要同时模拟所有的对象,而且你也不需要也不必要让棋子们逐帧的更新自己。所以我们总结更新方法模式适用于一下的情境:
- 你的游戏中含有一系列对象或系统需要同步地运转;
- 各个对象之间的行为几乎是相互独立的;
- 对象的行为与时间相关;
注意事项
所有对象都在逐帧模拟,但并非真正同步
我们在游戏循环中遍历对象集合并逐个更新对象,而在update的调用中,大部分的对象都能访问游戏世界的其它部分,包括其它正在更新的其它对象,这意味着游戏循环遍历更新对象的顺序意义重大。假如A对象在游戏列表中排在B对象前面,那么当B对象更新的时候,它看到的是更新后的A对象,而A在本帧看到的却是B未更新的状态,所以虽然从玩家的视角看,所有的事物好像都在同时运转,但游戏的核心仍然是回合制的。但这对游戏逻辑来说确实必然的,平行的更新所有对象会将你带向语义死角。设想西洋棋盘上黑白棋子同时移动,都想往一个空位上移动会怎样?
在更新期间修改对象列表必须谨慎
修改对象列表总体上可分为添加对象和删除对象。对于添加对象,一般我们都是直接添加到对象列表的末尾,如果我们的遍历逻辑是一直遍历到集合列表的末尾,那我们会在本帧更新它,但会产生的一个问题是在生产它之前就更新的了对象无法感知到它,这对于一些逻辑来说是不能接受的。一个做法是记录当前对象列表的长度,本帧只更新列表前面这么多的对象:
int numObjectsThisTurn = numObjects_; for(int i=0;i<numObjectsThisTurn;++i) { objects_[i]->update(); }
这样,新加的物体同一到下一帧再更新。这样解决了添加对象的问题,但删除对象确实另一个更为麻烦的问题。假如我们使用的是一个动态列表,当我们删除一个对象后,后面的对象会向前移位,也就是说第i个对象删除后,第i+1个对象会填充到第i个位置,所以如果我们继续遍历,第i+1个对象,将会跳过原来在第i+1位置的对象。这里有几个做法:
- 第一个就是从后往前遍历,这样删除对象必会影响前面未更新的对象的遍历;
- 第二个就是删除对象时同时更新计数器和遍历索引,保证所有对象都会遍历到;
- 最后一种就是给对象添加一个“死亡”标志,更新时跳过这些“死亡”对象,等所有对象更新结束后再删除这些“尸体”。
示例代码
class Entity { public: Entity():x_(0),y_(0){} virtual ~Entity(){} virtual void update() = 0; double x() const { return x_;} double y() const { return y_;} void setX(double x) { x_ = x;} void setY(double y) { y_ = y;} private: double x_; double y_; }; class World { public: World():numEntities_(0){} void gameLoop(); private: Entity* entities_[MAX_ENTITIES]; int numEntities_; }; void World::gameLoop() { //handle input... while(true) { for(int i=0;i<numEntities_;++i) { entities_[i]->update(); } } //physics and rendering... }
从上面的代码可以看出,接下来我们将使用继承来定义实体不同的行为。这可能会令一些人不舒服。因为继承是一种强耦合,一旦几个复杂的类继承大厦被建立起来,对于维护这些代码的人来说简直是噩梦。所以组件模式应运而生。但在这里我们将简单的使用继承来实现,因为这是最快的方法。
class Skeleton :public Entity { public: Skeleton() : patrollingLeft_(false) {} virtual void update() { if (patrollingLeft_) { setX(x() - 1); if (x() == 0) patrollingLeft_ = false; } else { setX(x() + 1); if (x() == 100) patrollingLeft_ = true; } } private: bool patrollingLeft_; };
这样我们就把基本的更新框架实现了,但这里还可能会碰到一种情况,就是游戏循环采用变时步长更新,如果是这样,我们可以可以效仿之前的做法,把步长时间作为参数传入update方法中。
设计决策
虽然这个模式很简单,但在决策方面我们还是有不少地方需要考虑。
update方法依存于何类中
- 实体类
这是最简单的一种做法,但这意味着一旦实体有新的表现就创建子类,这样会积累大量的类而导致项目难以维护;
- 组件类
这个是组件模式的做法,如果你了解组件模式,你就会知道怎么做。
- 代理类
将一个类的行为代理给另一个类,这里有其它几种设计模式。
1) 状态模式可以让你通过改变一个对象的代理来改变其行为;
2)对象类型模式可以让你在多个相同类型的实体之间共享行为。
当你使用这些模式的时候,那么自然而然地需要将update()方法置于代理类中。
那些未被利用的对象该如何处理
在游戏中通常需要维护这样一些对象:不论出于何种原因,它们暂时无需被更新。它们可能被禁用了,或被移出屏幕,或至今未解锁。假如大量的对象处于这种状态,则可能会导致CPU浪费大量的时间来遍历这些对象却毫无作为。对于这个问题,我们看看几种做法的优缺点:
- 使用一个集合来存储所有对象,每个对象有个标志标识其是否“存活”
这种做法比较浪费时间,因为你还是要遍历这些“死亡”对象
- 使用两个集合,一个存储活跃对象,一个存储所有对象
很明显,这增加了内存的占用,但在更新上却能避免对那些不活跃对象的访问,加快了更新的速度。当然还有一种做法就是把存储所有对象的集合变为存储不活跃对象的集合,这会优化一些内存的占用,但随之而来的是你要小心的维持两个集合的同步。
这里采用什么方法,取决于你对非激活对象数目的预估。其数目越多,就越需要创建一个独立的集合来存储它们。