游戏编程设计模式——Update Method

发表于2016-08-17
评论0 1.8k浏览
意图
  模拟一批相互独立的物体,让他们每次只进行一帧的动作。

动机
  玩家控制着强大的瓦尔基里(北欧神话中奥丁神的婢女之一),去偷巫王遗留的稀世珍宝。她试探着接近巫王宏伟的藏宝室,然后…没有遇到任何阻击。没有被诅咒的雕像向她射击。没有不死的骷髅兵在入口巡逻。她只是径直走过去用连锁勾起战利品。游戏结束,你赢了。
  呃,不会吧。
  藏宝室里需要有守卫的敌人,让我们的英雄去干他们。首先,我们需要一队带动作的骷髅兵,在门前来回巡逻。如果忽略掉你可能已经掌握的游戏编程内容,最简单的,让他们来回巡逻的代码就像这样:
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,以备不时之需。这样,这个集合严格来说就是多余的。如果速度比内存更敏感(通常都是这样),这就是个有用的方案。
  另外一个选择同样需要两个集合,不过另一个只保存无效对象,而不是所有的。
  你必须保证这两个集合同步。当对象被创建或者完全销毁(而不是临时失效)时,你需要记住,要修改主集合和激活对象集合。
  这里有一个参考的标准就是你有多少无效对象。无效对象越多,把他们分离到一个单独集合从而节约游戏循环的遍历时间,就显得越重要。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引