游戏编程设计模式——Update Method
发表于2016-08-17
意图
模拟一批相互独立的物体,让他们每次只进行一帧的动作。
动机
玩家控制着强大的瓦尔基里(北欧神话中奥丁神的婢女之一),去偷巫王遗留的稀世珍宝。她试探着接近巫王宏伟的藏宝室,然后…没有遇到任何阻击。没有被诅咒的雕像向她射击。没有不死的骷髅兵在入口巡逻。她只是径直走过去用连锁勾起战利品。游戏结束,你赢了。
呃,不会吧。
藏宝室里需要有守卫的敌人,让我们的英雄去干他们。首先,我们需要一队带动作的骷髅兵,在门前来回巡逻。如果忽略掉你可能已经掌握的游戏编程内容,最简单的,让他们来回巡逻的代码就像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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); } } |
问题来了,的确,这些骷髅兵在来回巡逻,但是我们看不到。问题在于程序陷进了一个无限循环,这可不是我们想要的结果。我们想要的其实是骷髅兵每帧移动一小步。
所以我们必须拆掉原来的循环,用外部的游戏循环去驱动。确保游戏能够持续地响应用户输入,渲染,同时守卫也不停巡逻。就像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | Entity skeleton; bool patrollingLeft = false ; double x = 0; // Main game loop: while ( true ) { if (patrollingLeft) { x--; if (x == 0) patrollingLeft = false ; } else { x++; if (x == 100) patrollingLeft = true ; } skeleton.setX(x); // Handle user input and render game... } |
我在这里演示了一下代码是怎么变得越来越复杂的。左右巡逻用了循环中的两个部分。在循环的执行过程中,这两部分是根据巡逻方向分开的。然后,我们依赖外部循环,在每帧都去计算下一个位置,所以,要用到一个表示方向的变量patrollingLeft。
这总归是管用的,所以让我们继续。一个骷髅不能给我们的英雄太大的麻烦,所以下一个,我们加入一个被诅咒的雕像。他们频繁的射出闪电,逼迫我们的英雄踮着脚尖走。
我们继续,用最简洁的方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // Skeleton variables... Entity leftStatue; Entity rightStatue; int leftStatueFrames = 0; int rightStatueFrames = 0; // Main game loop: while ( true ) { // Skeleton code... if (++leftStatueFrames == 90) { leftStatueFrames = 0; leftStatue.shootLightning(); } if (++rightStatueFrames == 80) { rightStatueFrames = 0; rightStatue.shootLightning(); } // Handle user input and render game... } |
可以说,这样就会把代码改的约来约难维护。我们在游戏循环里面加入了大量的变量,去处理游戏中的每一个对象。为了让他们同时得到处理,我们把代码堆到了一起。
我们将要使用的模式其实很简单,可能你也想到了:每一个对象应该把他们自己的行为封装到一起。这样可以让游戏循环的代码稳定下来,并且可以很容易的增删对象。
为此,我们需要一个抽象层,它含有一个虚函数update()。游戏循环包含了很多对象,但是它不需要关心对象的具体类别。它只需要知道对象有一个update方法即可。这样就可以把每个对象的update方法跟游戏循环和其他对象的update脱离。
每一帧,游戏循环都会遍历这些对象,并且调用update。它给每一个对象一次处理机会。这样,调用了所有对象的update,就相当于让他们同时进行了动作。
这种游戏循环维护的这些对象集合是动态的,所以添加和删除对象就比较容易,只需要从集合中添加和删除即可。再也不需要硬编码了,我们甚至可以把关卡配置在数据文件里面,这样我们的关卡设计者会很喜欢。
模式
游戏世界维护了一个对象的集合。每一个对象实现了一次update,去模拟它一帧的行为。每一帧,游戏都会更新集合中的所有对象。
什么时候用
如果游戏循环就像面包片,那Update Method就是它的黄油。游戏中的那些活蹦乱跳的对象就是用这种方法排着长队跟玩家交互。如果游戏中有太空战士,龙,火星人,幽灵,或者运动员,就比较适合这种模式。
但是,如果游戏更抽象,活动空间不像真实世界而更像棋盘,这种模式就不适合了。在棋类游戏中,你不需要模拟并发的事情,也没有必要去告诉里面的人物去每帧更新自己。
你可能不需要每帧更新他们的行为,但是即便是在棋类游戏中,你也可能仍然希望每帧更新他们的动作。这种模式也可能管用。
Update Method 可以很好地应用在:
1、你的游戏有很多对象或者系统需要模拟。
2、每一个对象都不怎么依赖其他对象。
3、这些对象需要一直模拟。
牢记
这个模式非常简单,所以没什么坑。不过,每一行代码都值得探讨。
将代码切分成单帧执行会让事情变得复杂
你可以比较一下前两段示例代码,第二个要更复杂一些。两者都可以让骷髅守卫前后走动,但是第二个可以在游戏循环的每一帧交出控制权。
这种变化对处理用户输入,渲染和一些其他的游戏循环关心的事情来讲,是非常必要的,所以第一个示例并没有什么实际意义。它的价值在于让你记住,要让代码变成单帧执行的模式,要付出提高复杂度的代价。
为了保证运行你必须在离开一帧的时候保存一些状态
在一段示例代码中,我们不需要任何变量去记录现在守卫移动到了左边还是右边。很显然,代码执行到什么地方,就表示它在什么位置。
当我们编程了每次执行一帧的形式,我们需要创建一个patrollingLeft变量去跟踪它。每次我们退出执行的那次循环,执行的位置就丢掉了,所以我们必须显式得保存这个信息,在下一帧用。
State模式通常就是解决这个问题。游戏中经常用到状态机的部分原因,就是他们可以保存一些状态,而这些状态在你离开那段代码的时候也会用到。
所有对象每帧都进行模拟但是并非真正同时运行
在这个模式中,游戏循环会便利所有对象,并且更新每一个对象。在调用update()方法的时候,大部分对象都可能跟游戏世界中的其他对象进行交互,包括哪些还没有更新过的。这就意味着这些对象的更新顺序很重要。
如果对象列表中,A排在B的前面,它会看到B更新前的状态。但是当B更新的时候,它会看到A更新后的状态,因为A在这一帧中已经更新过了。即使在玩家眼里这一切动作都是同时发生的,但是在游戏内部依然是依次进行的。只不过每一次“排队”都在一帧的时间里进行。
在游戏逻辑关心的范围内这是可以接受的。并行更新对象会带来一些理解上的麻烦。想象一下,一个棋类游戏如果黑色和白色同时走,并且都走向同一个空位,咋整?
顺序更新就会解决这个问题,每一个对象都进行增量更新,从一个确定的状态到另外一个确定的状态。
在更新的时候修改对象列表要小心
你在用这个模式的时候,很多游戏行为会在更新方法中结束。它们往往会包含一些添加或者删除可更新对象的代码。
例如,一个骷髅守卫被击杀后会掉落一些道具。作为一个新对象,你通常可以把它添加到对象列表的末尾,不会带来什么麻烦。你依然可以枚举完整个列表,然后发现有一个新对象,然后更新它。
但是,这样做就让新对象在被加载的同一帧就发生了一次更新,甚至是在玩家看到它之前。如果你不希望发生这种情况,一种简单的方法就是把列表的对象个数在循环开始的时候缓存下来:
1 2 3 4 5 | int numObjectsThisTurn = numObjects_; for ( int i = 0; i < numObjectsThisTurn; i++) { objects_[i]->update(); } |
这里,objects_是游戏中一个可更新对象的数组,numObjects_是它的长度。当新对象被添加进来,它就会增加。我们在循环开始的时候用numObjectsThisTurn把长度缓存起来,所以这次遍历会在遍历到新对象前停止。
另一个麻烦的问题是在遍历过程中删除怎么办。你击败了一个邪恶的野兽,需要从对象列表中移除。如果它位于你正在更新的对象之前,你很可能会跳过一个对象:
1 2 3 4 5 6 | C++ for ( int i = 0; i < numObjects_; i++) { objects_[i]->update(); } |
这个循环非常简单,每次迭代索引自增1。下图左面表示当我们开始更新heroine时的列表状况:
开始更新heroine时,i是1。她击败了野兽所以野兽被从数组中清除。heroine被置换到了0,hapless peasant被置换到了1。更新完heroine后,i变成了2.就像有图所示hapless peasant没有被更新,直接越过去了。
一种解决方法是在移除对象的时候小心点,把迭代变量也考虑进去。另一种是在遍历完整个列表后在处理移除。把这些要移除的对象标记成“dead”,但是仍放在那里。在更新的时候,确保略过那些死掉的对象,更新完后。再遍历一遍列表去清除掉这些尸体。
实例代码
这个模式非常直观,因此示例代码看起来有些多余。但这不代表这个模式没用。它是一种很简洁的解决方案。
具体起见,我们从头做一个简单的实例。从一个Entity类开始,它表示那些骷髅和他们的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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_; }; |
我这里罗列的东西,都是后面我们用到的,最简化的内容。在真实代码中,会有很多额外的东西想图形和物理。这里最重要的一点就是那个update()虚函数。
游戏中会有一个Entity的集合。在我们把它放在我们准备好的游戏世界中:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class World { public : World() : numEntities_(0) {} void gameLoop(); private : Entity* entities_[MAX_ENTITIES]; int numEntities_; }; |
万事俱备,游戏每一帧更新所有的entity,实现了这个模式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | void World::gameLoop() { while ( true ) { // Handle user input... // Update each entity. for ( int i = 0; i < numEntities_; i++) { entities_[i]->update(); } // Physics and rendering... } } |
子类化Entity
有些读者可能会感到不对劲,因为我用了继承Entity类的方式去定义不同的行为。如果你还没意识到问题,我可以提供一些线索。
当游戏工业从6502汇编和VBLANKS这种原始技术的海洋,到达面向对象语言的彼岸时,开发者对软件架构有点入魔了。最显著的一点就是使用继承。一些庞大的,复杂的继承体系被建立起来,大到冲出太阳系了。
他的一个最恶劣的结果就是,没有人能够维护这个庞大的继承体系了。早在1994年GOF就写到:
用对象组合,别用继承。
当这种思想渗透到游戏工业中后,就产生了Component模式作为解决方案。用这种模式,update()函数就挪到了Entity的组件中,而不是在Entity本身中。这避免了让你创建一些entity的子类去定义和复用行为。而是去组合匹配一些组件即可。
如果你要实现一个真正的游戏,我肯定会这样做。但是这一章并不介绍组件模式。而是update方法,我用最简单的方法来说明它们,尽可能的少做改动。因此我把这个方法放进了Entity,然后写几个子类。
定义Entity
好了,我们回到题目中来。我们的目的是定义巡逻的骷髅守卫和一些会射出魔法箭的雕像。让我们从我们可爱的骷髅朋友开始吧。为了定义他的巡逻行为,我们创建一个新的entity去实现update() ,大概是这个样子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | 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_; }; |
可以看到,我们只是把原来游戏循环中的一坨代码拷贝到了Skeleton的update函数中。唯一不同是patrollingLeft_成了类的一个属性,而不是局部变量。这样这个值就会在多个update调用中保留下来。
让我们定义下面的雕像:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class Statue : public Entity { public : Statue( int delay) : frames_(0), delay_(delay) {} virtual void update() { if (++frames_ == delay_) { shootLightning(); // Reset the timer. frames_ = 0; } } private : int frames_; int delay_; void shootLightning() { // Shoot the lightning... } }; |
还是如此,大多数修改就是把代码从游戏循环中挪到类里面,改名。这样,我们就可以把代码变的更简洁。在原来的代码里,我们使用了几个局部变量去记录雕像的帧数和开火速度。
现在,这些都被移进了Statue类中,你可以随意创建很多实例,每一个都有自己的小计时器。这就是这个模式真正的动机——在游戏世界中添加Entity更方便,因为每一个Entity只需要关系自己。
这种模式让我们把实现游戏世界和填充游戏元素隔离开来。甚至它提供了一种适应性,让我们可以用数据文件和关卡编辑器来填充游戏内容。
传递时间
目前为止,我们都假设每次调用update,都用的的固定时间间隔。
我当然希望这样做,但是更多游戏使用的时可变的时间间隔。每次游戏循环可能在模拟更多或者更少的内容,因此这个时间取决于上一帧处理和渲染的时间。
这意味着每次update都需要知道,虚拟时钟走了多长时间,所以你需要把消耗的时间传入。例如,我们可以这样处理巡逻骷髅的时间间隔:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | void Skeleton::update( double elapsed) { if (patrollingLeft_) { x -= elapsed; if (x <= 0) { patrollingLeft_ = false ; x = -x; } } else { x += elapsed; if (x >= 100) { patrollingLeft_ = true ; x = 100 - (x - 100); } } } |
现在,随着耗费时间的增长,骷髅移动的距离也会变大。你也可以看到这个时间间隔的变量带来的额外的复杂度。如果时间间隔太长,骷髅有可能会超出巡逻范围,这个我们要小心处理。
设计决策
对这样一个简单的模式,可讨论的点比较少,但是仍然有几个需要注意的地方。
update方法放在哪个类里
最明显最重要的决策就是,你要把update放到哪个类里。
1> Entity类
这是最简单的选项,如果你只有一个entity类,或者entity的种类不多,可以这样用。但是实际上的游戏工业离这个条件相去甚远。
每次用子类化Entity的方式去实现一个新行为,时非常脆弱和痛苦的,因为你有大量不同类型的Entity。你会发现有时候你不得不用一些不优雅的方式复用代码,去将就一个继承结构,然后你就蒙逼了。
2> Component类
如果你已经使用Compnent模式了,那就很容易了。它可以让每一个组件相互独立得更新。同时,Update Method模式让你实现了游戏中Entity得解偶。渲染,物理,和AI只需要关注自己就可以了。
3> Delegate类
有另外几种模式可以实现代理另外一个对象的行为。State模式可以让你通过改变代理对象去修改被代理对象的行为。Type Object模式可以让很多同类型的Entity之间共享行为。
如果你用到这些模式,就可以很自然地把update放进代理类中。这样,你仍然可以在主类中放update,但是不需要虚拟函数,只需要转调到代理对象中即可:
1 2 3 4 5 | void Entity::update() { // Forward to state object. state_->update(); } |
未激活的对象如何处理
游戏中经常有这样的一些对象,处于各种各样的原因,他们临时不需要更新。他们可能无效,可能在屏幕以外,可能未解锁。如果有大量类似的对象存在,每一帧都去遍历这样的对象可能浪费CPU时钟。
一个可选的解决方法是维护一个激活对象的子集,这个子集中的元素才会被更新。当一个对象无效后,就从这个集合中移除。当再次被激活时,就添加回来。这样,你就可以遍历那些真正起作用的对象了。
1> 如果用同一个集合保存无效对象
这样做浪费时间,对于无效对象,你仍然需要访问它是否有效的标记,或者调用一个什么都不做的方法。
2> 如果用另外一个集合保存激活对象
你需要额外的内存去保存第二个集合。但仍然需要一个主集合去保存所有的Entity,以备不时之需。这样,这个集合严格来说就是多余的。如果速度比内存更敏感(通常都是这样),这就是个有用的方案。
另外一个选择同样需要两个集合,不过另一个只保存无效对象,而不是所有的。
你必须保证这两个集合同步。当对象被创建或者完全销毁(而不是临时失效)时,你需要记住,要修改主集合和激活对象集合。
这里有一个参考的标准就是你有多少无效对象。无效对象越多,把他们分离到一个单独集合从而节约游戏循环的遍历时间,就显得越重要。