【游戏编程模式】命令模式

命令模式

Posted by LudoArt on July 25, 2020

【游戏编程模式】命令模式

命令是具现化的方法调用。

命令模式是一种回调的面向对象实现。

案例

在每个游戏中都有一块代码读取用户的输入——按钮按下,键盘敲击,鼠标点击,诸如此类。 这块代码会获取用户的输入,然后将其变为游戏中有意义的行为。

下面是一种简单的实现:

void InputHandler: handleInput()
{
    if (isPressed(BUTTON_X)) Jump();
    else if (isPressed(BUTTON_Y)) fireGun();
    else if (isPressed(BUTTON_A)) swapWeapon();
    else if (isPressed(BUTTON_B)) lurchIneffectively();
}

这段代码可以正常工作,但是许多游戏允许玩家配置按键的功能。

为了支持这点,需要将这些对jump()fireGun()的直接调用转化为可以变换的东西。 “变换”听起来有点像变量干的事,因此我们需要表示游戏行为的对象。进入:命令模式

我们定义了一个基类代表可触发的游戏行为:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
};

然后我们为不同的游戏行为定义相应的子类:

class JumpCommand : public Command
{
public:
  virtual void execute() { jump(); }
};

class FireCommand : public Command
{
public:
  virtual void execute() { fireGun(); }
};

在代码的输入处理部分,为每个按键存储一个指向命令的指针:

class InputHandler
{
public:
  void handleInput();

  // 绑定命令的方法……

private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

现在输入处理部分这样处理:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

以前每个输入直接调用函数,现在会有一层间接寻址:

改进(角色和命令之间的解耦)

我们刚才定义的类可以在之前的例子上正常工作,但有很大的局限。 问题在于假设了顶层的jump(), fireGun()之类的函数可以找到玩家角色,然后像木偶一样操纵它。

这些假定的耦合限制了这些命令的用处。JumpCommand只能 让玩家的角色跳跃。让我们放松这个限制。 不让函数去找它们控制的角色,我们将函数控制的角色对象传进去

class Command
{
public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

这里的GameActor是代表游戏世界中角色的“游戏对象”类。 我们将其传给execute(),这样命令类的子类就可以调用所选游戏对象上的方法,就像这样:

class JumpCommand : public Command
{
public:
  virtual void execute(GameActor& actor)
  {
    actor.jump();
  }
};

现在,我们可以使用这个类让游戏中的任何角色跳来跳去了。 在输入控制部分和在对象上调用命令部分之间,我们还缺了一块代码。 第一,我们修改handleInput(),让它可以返回命令:

Command* InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) return buttonX_;
  if (isPressed(BUTTON_Y)) return buttonY_;
  if (isPressed(BUTTON_A)) return buttonA_;
  if (isPressed(BUTTON_B)) return buttonB_;

  // 没有按下任何按键,就什么也不做
  return NULL;
}

这里不能立即执行,因为还不知道哪个角色会传进来。 这里我们享受了命令是具体调用的好处——延迟到调用执行时再知道。

然后,需要一些接受命令的代码,作用在玩家角色上。像这样:

Command* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

actor视为玩家角色的引用,它会正确地按着玩家的输入移动, 所以我们赋予了角色和前面例子中相同的行为。 通过在命令和角色间增加了一层重定向, 我们获得了一个灵巧的功能:我们可以让玩家控制游戏中的任何角色,只需向命令传入不同的角色。

在实践中,这个特性并不经常使用,但是经常会有类似的用例跳出来。 到目前为止,我们只考虑了玩家控制的角色,但是游戏中的其他角色呢? 它们被游戏AI控制。我们可以在AI和角色之间使用相同的命令模式;AI代码只需生成Command对象。

在选择命令的AI和展现命令的游戏角色间解耦给了我们很大的灵活度。 我们可以对不同的角色使用不同的AI,或者为了不同的行为而混合AI。 想要一个更加有攻击性的对手?插入一个更加有攻击性的AI为其生成命令。 事实上,我们甚至可以为玩家角色加上AI, 在展示阶段,游戏需要自动演示时,这是很有用的。

撤销与重做

我们已经使用了命令来抽象输入控制,所以每个玩家的举动都已经被封装其中。 举个例子,移动一个单位的代码可能如下:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    x_(x),
    y_(y)
  {}

  virtual void execute()
  {
    unit_->moveTo(x_, y_);
  }

private:
  Unit* unit_;
  int x_, y_;
};

注意这和前面的命令有些许不同。 在前面的例子中,我们需要从修改的角色那里抽象命令。 在这个例子中,我们将命令绑定到要移动的单位上。 这条命令的实例不是通用的“移动某物”命令;而是游戏回合中特殊的一次移动。

这展现了命令模式应用时的一种情形。 就像之前的例子,指令在某些情形中是可重用的对象,代表了可执行的事件。 我们早期的输入控制器将其实现为一个命令对象,然后在按键按下时调用其execute()方法。

这里的命令更加特殊。它们代表了特定时间点能做的特定事件。 这意味着输入控制代码可以在玩家下决定时创造一个实例。就像这样:

Command* handleInput()
{
  Unit* unit = getSelectedUnit();

  if (isPressed(BUTTON_UP)) {
    // 向上移动单位
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  if (isPressed(BUTTON_DOWN)) {
    // 向下移动单位
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  // 其他的移动……

  return NULL;
}

命令的一次性为我们很快地赢得了一个优点。 为了让指令可被取消,我们为每个类定义另一个需要实现的方法:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

undo()方法回滚了execute()方法造成的游戏状态改变。 这里是添加了撤销功能后的移动命令:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    xBefore_(0),
    yBefore_(0),
    x_(x),
    y_(y)
  {}

  virtual void execute()
  {
    // 保存移动之前的位置
    // 这样之后可以复原。

    xBefore_ = unit_->x();
    yBefore_ = unit_->y();

    unit_->moveTo(x_, y_);
  }

  virtual void undo()
  {
    unit_->moveTo(xBefore_, yBefore_);
  }

private:
  Unit* unit_;
  int xBefore_, yBefore_;
  int x_, y_;
};

注意我们为类添加了更多的状态。 当单位移动时,它忘记了它之前是什么样的。 如果我们想要撤销这个移动,我们需要记得单位之前的状态,也就是xBefore_yBefore_的作用。

为了让玩家撤销移动,我们记录了执行的最后命令。当他们按下control+z时,我们调用命令的undo()方法。 (如果他们已经撤销了,那么就变成了“重做”,我们会再一次执行命令。)

支持多重的撤销也不太难。 我们不单单记录最后一条指令,还要记录指令列表,然后用一个引用指向“当前”的那个。 当玩家执行一条命令,我们将其添加到列表,然后将代表“当前”的指针指向它。

当玩家选择“撤销”,我们撤销现在的命令,将代表当前的指针往后退。 当他们选择“重做”,我们将代表当前的指针往前进,执行该指令。 如果在撤销后选择了新命令,那么清除命令列表中当前的指针所指命令之后的全部命令。

参看

  • 你最终可能会得到很多不同的命令类。 为了更容易实现这些类,定义一个具体的基类,包含一些能定义行为的高层方法,往往会有帮助。 这将命令的主体execute()转到子类沙箱中。
  • 在上面的例子中,我们明确地指定哪个角色会处理命令。 在某些情况下,特别是当对象模型分层时,也可以不这么简单粗暴。 对象可以响应命令,或者将命令交给它的从属对象。 如果你这样做,你就完成了一个职责链模式
  • 有些命令是无状态的纯粹行为,比如第一个例子中的JumpCommand。 在这种情况下,有多个实例是在浪费内存,因为所有的实例是等价的。 可以用享元模式解决。