一个回合制游戏的主循环

这是一个roguelike大神写的关于回合制游戏的主循环博客,原文地址:A Turn-Based Game Loop

在这里做一下简略的翻译和梳理

该作者游戏设计所遵循的两大准则

  • 游戏引擎应该和UI严格分开,model和view的分离是一个好的设计,可以让一个引擎更方便地复用到多种类型的ui上
  • 怪物和玩家控制的对象被一致对待,Actor作为Monster和Hero的父类,引擎的多数时候都是统一地处理这个类

 下面是最开始的游戏主循环,只要还在游戏中,那么就会不断遍历每个actor并更新其状态:

void gameLoop() {
  while (stillPlaying) {
    for (var actor in actors) {
      actor.update();
    }
  }
}

 

 然而,由于引擎与ui是解耦合的,它将在外部被调用,引擎会有一个Game类,UI会拥有这个类的一个对象,然后告诉它进行一些处理,在将控制权交给UI之前,引擎只能前进一“步”,也就是说Game类必须要记录上一个进行了操作的actor是哪一个,以便下次调用:

class Game {
  final actors = <Actor>[];
  int _currentActor = 0;

  void process() {
    actors[_currentActor].update();
    _currentActor = (_currentActor + 1) % actors.length;
  }
}

 在update函数中,actor可能会进行移动、进行战斗、撞墙、开门、拾取物品等等,我们可以选择将这些内容都写到Actor类中,不过那样Actor类就会几乎包涵下整个的游戏逻辑,那样很糟。

另一种做法是,我们将决定执行什么动作从执行这些动作中分离开,如下所示,游戏主循环将会询问每个actor做出一个action的选择,让后让action做出执行动作:

void process() {
  var action = actors[_currentActor].getAction();
  action.perform();
  _currentActor = (_currentActor + 1) % actors.length;
}

 

Action类可以派生出各种各样的动作类,例如 WalkActionOpenDoorActionEatAction等等。

我们将这些动作的逻辑放到了这些类中,并且更重要的是,使他们彼此之间分隔开了,如果设计者需要增加、修改action,将会变得非常简单而且清晰,这就是松耦合的好处;另外一点是许多的Action对于Hero和Monster来说是可以共用的。

 

我们现在有了一个起作用的主循环了,但是这个游戏有点太“回合制”了,actor都是以固定的顺序行动,你一步,我一步。

为了解决这个,我们将让actor以不同的速度运行。当然,这个速度是回合制的,不是说走得有多快,有更快速度的actor能够比速度慢的actor更频繁地获得行动机会,

一个很棒的解决方案是:每个actor都有一个能量值,每当主循环遍历到一个actor的时候,就会给它增加一些能量值,当这个actor的能量达到了一定的阈值,它就可以执行动作,如果达不到,那就跳过这个actor,每个actor都在聚集能量,消耗能量,如此循环。

加入速度属性的方法很简单,快的actor能够在每个回合获得更多能量,他们将能更快地达到阈值,所以他们能更频繁地进行操作,此外,设计者可以将不同的action类型所需要的能量值进行区分,带来更多的复杂性。

 

继续讨论主循环,带AI的monster可以自主决定下一步的操作(getAction),而玩家需要获得UI层带来的指令,例如:

void handleInput(Keyboard keyboard) {
  switch (keyboard.lastPressed) {
    case KeyCode.G:
      game.hero.setNextAction(new PickUpAction())
      break;

    case KeyCode.I:         walk(Direction.NW); break;
    case KeyCode.O:         walk(Direction.N); break;
    case KeyCode.P:         walk(Direction.NE); break;
    case KeyCode.K:         walk(Direction.W); break;
    case KeyCode.L:         walk(Direction.NONE); break;
    case KeyCode.SEMICOLON: walk(Direction.E); break;
    case KeyCode.COMMA:     walk(Direction.SW); break;
    case KeyCode.PERIOD:    walk(Direction.S); break;
    case KeyCode.SLASH:     walk(Direction.SE); break;
  }
}

void walk(Direction dir) {
  game.hero.setNextAction(new WalkAction(dir));
}

 

而相应的Hero类如下:

class Hero extends Actor {
  Action _nextAction;

  void setNextAction(Action action) {
    _nextAction = action;
  }

  Action getAction() {
    var action = _nextAction;
    // Only perform it once.
    _nextAction = null;
    return action;
  }

  // Other heroic stuff...
}

剩下的问题是,当主循环执行到hero的回合,但是UI处没有相应该怎么办,为了解决这个问题 ,循环会检查actor有没有设置下一个action,如果没有,循环将会跳过并继续等待UI,在任何时候UI都能将下一步要做的操作抛给引擎,并在下一次通知引擎process的时候完成它。

void process() {
  var action = actors[_currentActor].getAction();

  // Don't advance past the actor if it didn't take a turn.
  if (action == null) return;

  action.perform();
  _currentActor = (_currentActor + 1) % actors.length;
}

 接下来要考虑到的问题是更高的可用性,玩家会出错,那么引擎要做的就是适应它,想象玩家控制的角色在逃脱追捕的时候,向墙壁走了一步,这样的操作可能导致玩家的角色丢掉性命,这并不好,我们希望当玩家做出不可能的操作的时候,我们不会浪费掉宝贵的回合。

 一种处理这个的方式是在UI端来验证用户输入,在处理输入的时候检查角色将要移动的块是不是地板,如果不是,那么ui将会提示错误并且不将这个action传递给引擎,在引擎端则只会收到完美、正确的action。

但是做这样的验证确实是很复杂的事,或许角色拥有能穿墙的法术,或许墙上有传送门,有隧道,或许角色在物品栏里放着一把铲子……我所描述的这些都是游戏机制,游戏机制应该在游戏引擎中处理,特别地,这些基本都属于action的处理范畴,所以我们应该把问题的解决方法放在action里面,当一个action运行的时候,我们会返回一个提示是否成功运行的值,如果运行失败,那么主循环将不会认为操作发生,如下所示:

void process() {
  var action = actors[_currentActor].getAction();
  if (action == null) return;

  var success = action.perform();

  // Don't advance if the action failed.
  if (!success) return;

  _currentActor = (_currentActor + 1) % actors.length;
}

这使得引擎更加健壮,你可以随意做出一些操作,引擎会优雅地处理好一切,并且代码将所有的验证作为一个机制放在了一个地方(action),这是很好的封装。

成功/失败能够处理玩家给出了错误操作的情况,但有的时候引擎可以推测出玩家的意图,例如,如果你想控制英雄走入一扇关闭的门而不是使用具体的“开门”命令,那么很可能你是想打开这扇门,类似地,如果你想走进一个怪物,那你很可能是想打上一架。

我知道这听上去很明显,但是你会惊讶于现在许多的roguelike游戏不会做这样的事,改善可用性也是我对于游戏的追求,所以我关心这个问题,并且我也有很简单的解决方案。

当一个操作验证自身的时候,它可以直接像之前那样返回失败,但是它也可以回应一个备选的操作,就像是在说“不,你想做的是这个。”

既然action的perform方法可以返回success表示一切正常,failure表示什么也没发生以及其他的action来作为备选,我们将写一个类把他们封装起来。

class ActionResult {
  static final SUCCESS = const ActionResult(true);
  static final FAILURE = const ActionResult(false);

  /// An alternate [Action] that should be performed instead of
  /// the one that failed.
  final Action alternative;

  /// `true` if the [Action] was successful and energy should
  /// be consumed.
  final bool succeeded;

  const ActionResult(this.succeeded)
  : alternative = null;

  const ActionResult.alternate(this.alternative)
  : succeeded = true;
}

现在的主循环是这样的:

void process() {
  var action = actors[_currentActor].getAction();
  if (action == null) return;

  while (true) {
    var result = action.perform();
    if (!result.succeeded) return;
    if (result.alternate == null) break;
    action = result.alternate;
  }

  _currentActor = (_currentActor + 1) % actors.length;
}

我们将代码放在while循环中是因为一个备选的action可能会返回另一个备选,这是个递归的过程,所以利用while直到最后返回的是succeeds或者fails。这将应用到游戏中很多方便的特性中,例如:

  • 当你使用一个物品,“使用物品”操作将会查看物品的具体类型(火球,传送门等等)然后返回一个备选的操作,当你使用可装备的物体,它将返回“装备”操作作为备选
  • 如果actor发出来行走操作命令但是没有方向,引擎将会返回“休息”操作来回复一定生命值
  • 如果actor走近一扇门,引擎返回开门操作作为备选
  • 如果actor试图走入另一个actor,引擎返回攻击操作作为备选

后三个尤其好用,因为它们也能应用到怪物们身上,这样怪物的AI系统将不用检查下一步是门还是对手,它们只需要尝试让怪物接近它们的目标,引擎会处理好诸如开门、攻击这样的事,如果没有目标,怪物也会自动休息。相信我,任何能精简你AI代码的事都是好主意。

最后,这个作者写了一本书叫《游戏编程模式》,可以多学习学习。

 

posted @ 2017-05-28 10:37  排骨zzz  阅读(751)  评论(0编辑  收藏  举报