一个回合制游戏的主循环

时间:2021-08-21 21:30:26

这是一个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代码的事都是好主意。

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