游戏编程设计模式

【翻译】游戏编程设计模式——Update Method

原文连接:http://gameprogrammingpatterns.com/update-method.html

意图

模拟一批相互独立的物体,让他们每次只进行一帧的动作。

动机

玩家控制着强大的瓦尔基里(北欧神话中奥丁神的婢女之一),去偷巫王遗留的稀世珍宝。她试探着接近巫王宏伟的藏宝室,然后…没有遇到任何阻击。没有被诅咒的雕像向她射击。没有不死的骷髅兵在入口巡逻。她只是径直走过去用连锁勾起战利品。游戏结束,你赢了。

呃,不会吧。

藏宝室里需要有守卫的敌人,让我们的英雄去干他们。首先,我们需要一队带动作的骷髅兵,在门前来回巡逻。如果忽略掉你可能已经掌握的游戏编程内容,最简单的,让他们来回巡逻的代码就像这样:

问题来了,的确,这些骷髅兵在来回巡逻,但是我们看不到。问题在于程序陷进了一个无限循环,这可不是我们想要的结果。我们想要的其实是骷髅兵每帧移动一小步。

所以我们必须拆掉原来的循环,用外部的游戏循环去驱动。确保游戏能够持续地响应用户输入,渲染,同时守卫也不停巡逻。就像这样:

我在这里演示了一下代码是怎么变得越来越复杂的。左右巡逻用了循环中的两个部分。在循环的执行过程中,这两部分是根据巡逻方向分开的。然后,我们依赖外部循环,在每帧都去计算下一个位置,所以,要用到一个表示方向的变量patrollingLeft。

这总归是管用的,所以让我们继续。一个骷髅不能给我们的英雄太大的麻烦,所以下一个,我们加入一个被诅咒的雕像。他们频繁的射出闪电,逼迫我们的英雄踮着脚尖走。

我们继续,用最简洁的方式:

可以说,这样就会把代码改的约来约难维护。我们在游戏循环里面加入了大量的变量,去处理游戏中的每一个对象。为了让他们同时得到处理,我们把代码堆到了一起。

我们将要使用的模式其实很简单,可能你也想到了:每一个对象应该把他们自己的行为封装到一起。这样可以让游戏循环的代码稳定下来,并且可以很容易的增删对象。

为此,我们需要一个抽象层,它含有一个虚函数update()。游戏循环包含了很多对象,但是它不需要关心对象的具体类别。它只需要知道对象有一个update方法即可。这样就可以把每个对象的update方法跟游戏循环和其他对象的update脱离。

每一帧,游戏循环都会遍历这些对象,并且调用update。它给每一个对象一次处理机会。这样,调用了所有对象的update,就相当于让他们同时进行了动作。

这种游戏循环维护的这些对象集合是动态的,所以添加和删除对象就比较容易,只需要从集合中添加和删除即可。再也不需要硬编码了,我们甚至可以把关卡配置在数据文件里面,这样我们的关卡设计者会很喜欢。

模式

游戏世界维护了一个对象的集合。每一个对象实现了一次update,去模拟它一帧的行为。每一帧,游戏都会更新集合中的所有对象。

什么时候用

如果游戏循环就像面包片,那Update Method就是它的黄油。游戏中的那些活蹦乱跳的对象就是用这种方法排着长队跟玩家交互。如果游戏中有太空战士,龙,火星人,幽灵,或者运动员,就比较适合这种模式。

但是,如果游戏更抽象,活动空间不像真实世界而更像棋盘,这种模式就不适合了。在棋类游戏中,你不需要模拟并发的事情,也没有必要去告诉里面的人物去每帧更新自己。

你可能不需要每帧更新他们的行为,但是即便是在棋类游戏中,你也可能仍然希望每帧更新他们的动作。这种模式也可能管用。

Update Method 可以很好地应用在:

1、你的游戏有很多对象或者系统需要模拟。

2、每一个对象都不怎么依赖其他对象。

3、这些对象需要一直模拟。

牢记

这个模式非常简单,所以没什么坑。不过,每一行代码都值得探讨。

将代码切分成单帧执行会让事情变得复杂

你可以比较一下前两段示例代码,第二个要更复杂一些。两者都可以让骷髅守卫前后走动,但是第二个可以在游戏循环的每一帧交出控制权。

这种变化对处理用户输入,渲染和一些其他的游戏循环关心的事情来讲,是非常必要的,所以第一个示例并没有什么实际意义。它的价值在于让你记住,要让代码变成单帧执行的模式,要付出提高复杂度的代价。

为了保证运行你必须在离开一帧的时候保存一些状态

在一段示例代码中,我们不需要任何变量去记录现在守卫移动到了左边还是右边。很显然,代码执行到什么地方,就表示它在什么位置。

当我们编程了每次执行一帧的形式,我们需要创建一个patrollingLeft变量去跟踪它。每次我们退出执行的那次循环,执行的位置就丢掉了,所以我们必须显式得保存这个信息,在下一帧用。

State模式通常就是解决这个问题。游戏中经常用到状态机的部分原因,就是他们可以保存一些状态,而这些状态在你离开那段代码的时候也会用到。

所有对象每帧都进行模拟但是并非真正同时运行

在这个模式中,游戏循环会便利所有对象,并且更新每一个对象。在调用update()方法的时候,大部分对象都可能跟游戏世界中的其他对象进行交互,包括哪些还没有更新过的。这就意味着这些对象的更新顺序很重要。

如果对象列表中,A排在B的前面,它会看到B更新前的状态。但是当B更新的时候,它会看到A更新后的状态,因为A在这一帧中已经更新过了。即使在玩家眼里这一切动作都是同时发生的,但是在游戏内部依然是依次进行的。只不过每一次“排队”都在一帧的时间里进行。

在游戏逻辑关心的范围内这是可以接受的。并行更新对象会带来一些理解上的麻烦。想象一下,一个棋类游戏如果黑色和白色同时走,并且都走向同一个空位,咋整?

顺序更新就会解决这个问题,每一个对象都进行增量更新,从一个确定的状态到另外一个确定的状态。

在更新的时候修改对象列表要小心

你在用这个模式的时候,很多游戏行为会在更新方法中结束。它们往往会包含一些添加或者删除可更新对象的代码。

例如,一个骷髅守卫被击杀后会掉落一些道具。作为一个新对象,你通常可以把它添加到对象列表的末尾,不会带来什么麻烦。你依然可以枚举完整个列表,然后发现有一个新对象,然后更新它。

但是,这样做就让新对象在被加载的同一帧就发生了一次更新,甚至是在玩家看到它之前。如果你不希望发生这种情况,一种简单的方法就是把列表的对象个数在循环开始的时候缓存下来:

这里,objects_是游戏中一个可更新对象的数组,numObjects_是它的长度。当新对象被添加进来,它就会增加。我们在循环开始的时候用numObjectsThisTurn把长度缓存起来,所以这次遍历会在遍历到新对象前停止。

另一个麻烦的问题是在遍历过程中删除怎么办。你击败了一个邪恶的野兽,需要从对象列表中移除。如果它位于你正在更新的对象之前,你很可能会跳过一个对象:

这个循环非常简单,每次迭代索引自增1。下图左面表示当我们开始更新heroine时的列表状况:

update-method-remove

开始更新heroine时,i是1。她击败了野兽所以野兽被从数组中清除。heroine被置换到了0,hapless peasant被置换到了1。更新完heroine后,i变成了2.就像有图所示hapless peasant没有被更新,直接越过去了。

一种解决方法是在移除对象的时候小心点,把迭代变量也考虑进去。另一种是在遍历完整个列表后在处理移除。把这些要移除的对象标记成“dead”,但是仍放在那里。在更新的时候,确保略过那些死掉的对象,更新完后。再遍历一遍列表去清除掉这些尸体。

实例代码

这个模式非常直观,因此示例代码看起来有些多余。但这不代表这个模式没用。它是一种很简洁的解决方案。

具体起见,我们从头做一个简单的实例。从一个Entity类开始,它表示那些骷髅和他们的状态。

我这里罗列的东西,都是后面我们用到的,最简化的内容。在真实代码中,会有很多额外的东西想图形和物理。这里最重要的一点就是那个update()虚函数。

游戏中会有一个Entity的集合。在我们把它放在我们准备好的游戏世界中:

万事俱备,游戏每一帧更新所有的entity,实现了这个模式。

子类化Entity

有些读者可能会感到不对劲,因为我用了继承Entity类的方式去定义不同的行为。如果你还没意识到问题,我可以提供一些线索。

当游戏工业从6502汇编和VBLANKS这种原始技术的海洋,到达面向对象语言的彼岸时,开发者对软件架构有点入魔了。最显著的一点就是使用继承。一些庞大的,复杂的继承体系被建立起来,大到冲出太阳系了。

他的一个最恶劣的结果就是,没有人能够维护这个庞大的继承体系了。早在1994年GOF就写到:

用对象组合,别用继承。

当这种思想渗透到游戏工业中后,就产生了Component模式作为解决方案。用这种模式,update()函数就挪到了Entity的组件中,而不是在Entity本身中。这避免了让你创建一些entity的子类去定义和复用行为。而是去组合匹配一些组件即可。

如果你要实现一个真正的游戏,我肯定会这样做。但是这一章并不介绍组件模式。而是update方法,我用最简单的方法来说明它们,尽可能的少做改动。因此我把这个方法放进了Entity,然后写几个子类。

定义Entity

好了,我们回到题目中来。我们的目的是定义巡逻的骷髅守卫和一些会射出魔法箭的雕像。让我们从我们可爱的骷髅朋友开始吧。为了定义他的巡逻行为,我们创建一个新的entity去实现update()  ,大概是这个样子:

可以看到,我们只是把原来游戏循环中的一坨代码拷贝到了Skeleton的update函数中。唯一不同是patrollingLeft_成了类的一个属性,而不是局部变量。这样这个值就会在多个update调用中保留下来。

让我们定义下面的雕像:

还是如此,大多数修改就是把代码从游戏循环中挪到类里面,改名。这样,我们就可以把代码变的更简洁。在原来的代码里,我们使用了几个局部变量去记录雕像的帧数和开火速度。

现在,这些都被移进了Statue类中,你可以随意创建很多实例,每一个都有自己的小计时器。这就是这个模式真正的动机——在游戏世界中添加Entity更方便,因为每一个Entity只需要关系自己。

这种模式让我们把实现游戏世界和填充游戏元素隔离开来。甚至它提供了一种适应性,让我们可以用数据文件和关卡编辑器来填充游戏内容。

update-method-uml

传递时间

目前为止,我们都假设每次调用update,都用的的固定时间间隔。

我当然希望这样做,但是更多游戏使用的时可变的时间间隔。每次游戏循环可能在模拟更多或者更少的内容,因此这个时间取决于上一帧处理和渲染的时间。

这意味着每次update都需要知道,虚拟时钟走了多长时间,所以你需要把消耗的时间传入。例如,我们可以这样处理巡逻骷髅的时间间隔:

现在,随着耗费时间的增长,骷髅移动的距离也会变大。你也可以看到这个时间间隔的变量带来的额外的复杂度。如果时间间隔太长,骷髅有可能会超出巡逻范围,这个我们要小心处理。

设计决策

对这样一个简单的模式,可讨论的点比较少,但是仍然有几个需要注意的地方。

update方法放在哪个类里

最明显最重要的决策就是,你要把update放到哪个类里。

1> Entity类

这是最简单的选项,如果你只有一个entity类,或者entity的种类不多,可以这样用。但是实际上的游戏工业离这个条件相去甚远。

每次用子类化Entity的方式去实现一个新行为,时非常脆弱和痛苦的,因为你有大量不同类型的Entity。你会发现有时候你不得不用一些不优雅的方式复用代码,去将就一个继承结构,然后你就蒙逼了。

2> Component类

如果你已经使用Compnent模式了,那就很容易了。它可以让每一个组件相互独立得更新。同时,Update Method模式让你实现了游戏中Entity得解偶。渲染,物理,和AI只需要关注自己就可以了。

3> Delegate类

有另外几种模式可以实现代理另外一个对象的行为。State模式可以让你通过改变代理对象去修改被代理对象的行为。Type Object模式可以让很多同类型的Entity之间共享行为。

如果你用到这些模式,就可以很自然地把update放进代理类中。这样,你仍然可以在主类中放update,但是不需要虚拟函数,只需要转调到代理对象中即可:

未激活的对象如何处理

游戏中经常有这样的一些对象,处于各种各样的原因,他们临时不需要更新。他们可能无效,可能在屏幕以外,可能未解锁。如果有大量类似的对象存在,每一帧都去遍历这样的对象可能浪费CPU时钟。

一个可选的解决方法是维护一个激活对象的子集,这个子集中的元素才会被更新。当一个对象无效后,就从这个集合中移除。当再次被激活时,就添加回来。这样,你就可以遍历那些真正起作用的对象了。

1> 如果用同一个集合保存无效对象

这样做浪费时间,对于无效对象,你仍然需要访问它是否有效的标记,或者调用一个什么都不做的方法。

2> 如果用另外一个集合保存激活对象

你需要额外的内存去保存第二个集合。但仍然需要一个主集合去保存所有的Entity,以备不时之需。这样,这个集合严格来说就是多余的。如果速度比内存更敏感(通常都是这样),这就是个有用的方案。

另外一个选择同样需要两个集合,不过另一个只保存无效对象,而不是所有的。

你必须保证这两个集合同步。当对象被创建或者完全销毁(而不是临时失效)时,你需要记住,要修改主集合和激活对象集合。

这里有一个参考的标准就是你有多少无效对象。无效对象越多,把他们分离到一个单独集合从而节约游戏循环的遍历时间,就显得越重要。

发表评论