Game Programming Patterns-再探Command模式

游戏编程模式- 再探Command模式

Tags: 游戏编程 设计模式 游戏开发

本系列博客是:Game Programming Patterns 的中文翻译版本。

翻译的github地址: cyh24. 如有兴趣,可联系博主共同翻译,一起造(wu)福(dao)他人。

博客虽然水分很足,但是也算是博主的苦劳了,

如需转载,请附上本文链接,不甚感激!

本系列博客 目录,可点击进入。

再探Command模式

============================

可以说Command模式是我最喜欢的设计模式之一。在我写的大多数的大型程序,游戏和其他一些代码中,都随处可见这个模式。正确地使用这个模式,可以让一些原本很丑陋的代码变得更加整洁。然而,这样的一个优雅漂亮的模式,“Gof”却给出了一个非常抽象,晦涩难懂的描述:

将一个请求封装成一个对象,从而使你可以用不同的请求对客户端进行参数化,对请求排队,或记录请求日志,以及支持撤销和恢复操作。

我想你们应该跟我一样,都会觉得这是一个超难读懂的句子吧?首先,它混淆了一些他自己本来想要建立的概念。如果脱离软件这样的语境,一些单词就会有其他不同的意思,例如:client 可以使客户的意思。而且,据我的考察,人应该是不可以被参数化的.
其次,这个句子的其他部分,罗列了一些可能用到这个模式的场景。但是却并不好理解,除非你自己恰好遇到了这种情况。基于此情况,我自己重新给出Command模式一个更加精炼的定义:

一个 Command 就是一个具象化的方法调用.

当然了,“精炼”往往也意味着“过度简化”,所以,我的这个名词解释其实也并没有多大的进步。还是让我稍微解释一下吧。“具象化” 在这里的一个意思就是“实例化”;另一个意思是,把方法变成一个“first class”(一级函数),从而能够如同整数和字符串等基本类型一样操作,可以作为参数传递、返回值等。
总而言之,上面的两个解释就是把操作封装到数据(或者说是一个对象)中。然后,你可以将这个对象当成一个变量,将其传递到一个函数中或者进行其他各种操作。所以,我把Command模式描述成是“具象化的方法调用”,意思就是将一个方法封装成一个具体的对象。
这些东西听起来很像“回调”,“一级函数”,“函数指针”,“闭包”或者“偏应用函数”等概念,因为这些概念确实大致相同,只是跟你使用的语言不同有关。正如GoF最后提到的:

Command模式就是用面向对象的方式取代回调函数。

我认为,用这句话来诠释Command模式,远比上面那些他们选取的句子更恰当。
当然了,不管怎么样的诠释,听起来总是很抽象,很朦胧。所以,我加入一些具体的例子,来补充解释Command模式。

实例一:输入配置

每个游戏都有一部分代码是专门用来读取用户输入的——比如:按下button,键盘事件,点击鼠标等等。这些输入都需要被记录下来,然后转变成游戏中有意义的行为。

Game Programming Patterns-再探Command模式_第1张图片

下面是一个简单的实现:

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() 之间的直接调用,改成一种能够置换的方式。所以,我们需要使用一个对象来表示游戏中的一个行为,这样,Command模式就来了。

我们定义一个基类来代表一个可以触发的游戏命令:

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(); }
};

在我们的InputHandler类中,我们为每一个按键都保存了一个指向Command对象的指针:

class InputHandler
{
public:
  void handleInput();

  // Methods to bind commands...

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();
}

原来的输入只是直接去调用一个行为函数,现在是一个间接的过程了,如下图所示:
Game Programming Patterns-再探Command模式_第2张图片

以上就是Command模式的一个简单直观的例子了,如果你觉得自己已经掌握Command的真谛,那么下面的部分你就可以随便看看,当做巩固了。

实例二:控制演员

前面给出的例子中,我们的代码勉强可以达到目的,不过作用还是相当有限的。原因是,上面的例子有一个假设作为前提,就是他们假定了有一些全局函数,例如jump(),fireGun()等等,这些函数能够直接得到一个控制器,然后很轻松的像控制木偶一样去控制主角。
这样的假定大大地限制了Command的使用范围。因为,在这种情况下,只有主角能够使用JumpCommand进行跳跃。让我们取消这样的假设来打破这层限制。我们不让调用的函数自己去找控制对象,而是将控制对象作为参数传给它。

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

上面的代码中,GameActor是我们的游戏对象类,代表了游戏世界中的一个演员。我们把它传递给execute函数,这样那些Command子类就可以条用相应的方法了。就像这样:

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

如果这个actor是玩家角色的一个引用,那么就可以根据玩家的输入准确的控制主角,这样就能实现跟第一个例子同样的效果了。与此同时,在Command跟Actor之间插入了一个层,这让我们有了一个更加灵活的能力:我们可以通过改变这个actor参数,让玩家能够控制游戏世界中的所有演员。

实际上,虽然这不是一个通常意义上的特性,不过倒是会时不时的被使用到。当目前为止,我们只留意了玩家控制主角,但是,那么游戏世界中的其他演员呢?他们是被游戏AI所驱动的。我们可以使用同样的Command模式,作为AI引擎和演员之间的借口。如此一来,AI代码只需要简单地抛出命令对象就可以了。

通过Command将AI代码和玩家控制演员的代码进行了解耦,这给我们带来了极大的灵活性。我们可以在不同的演员身上使用不同的AI,或者可以通过混合搭配不同的AI组合成不同的行为。如果想要加入更牛逼的对手,也只需要加入更牛逼的AI去生成一堆Command就行了。甚至,我们可以给玩家控制的角色加入AI效果,这样就可以像播放demo一样让游戏自动运行了。
总之,通过编写控制演员的Command,我们去掉了直接调用函数带来的紧耦合,从而可以轻松的使用一系列命令来控制演员了。

Game Programming Patterns-再探Command模式_第3张图片

上图的意思是,AI(或者输入控制)产生Command,形成一个命令流。演员(或者分发器)使用这些Commands。把这个命令流放在中间,就完成了对命令产生者和执行者的解耦。

撤销和重做

最后的这个例子是Command模式最为著名的一个用法了。如果一个Command对象能够完成一件事,那么离他撤销这件事就不远了。撤销可以用在一些策略游戏中,你可以撤销一些你不喜欢或者后悔做的一些操作。在制作游戏的工具中,撤销也是一个非常必要的功能。让你游戏策划恨你的最佳方式,就是提供一个让他不能进行撤销操作的关卡编辑器。
如果没有Command模式,那么撤销将是一件非常困难的事情。一旦有了它,那就是小菜一碟了。如比,当我们要做一个单人回合制游戏,我们会希望能够加入一个撤销操作,这样玩家就可以把注意力放到策略上,而不用做过多没有必要的猜测了。
前面,我们已经使用了Command,非常方便地将用户的输入抽象化,因此玩家的每一步操作都已经封装到了Command之中。例如,玩家移动一个单位的代码如下:

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_;
};

需要注意的是,这里的写法跟上面的例子有一点小小的不同。在上一个例子中,我们想把演员从Command中抽离处理。而这个例子中,我们希望能够将Command和要进行移动的单位绑定到一起。现在的每一个Command实例,不再是“移动某个物体”,让你可以运用到不同物体之上,这里的一个实例代表了一个游戏回合中一个确定的步骤。

这里强调另一种Command模式的实现方法,在某些情况下,就像我们前两个例子,一个Command就是一个可以被复用的的对象,它代表了一个操作。前面我们的输入处理是在一个单独的Command对象中进行的,任何时候,一旦玩家按了正确的按键,它相应的execute()函数就会被调用。

在这里,这些Command就更加具体了。他们代表了在某一个特定的时间做了某件事。也就是说,每一次玩家选择一次移动,输入处理代码就会产生一个Command实例。就像这样:

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

  if (isPressed(BUTTON_UP)) {
    // Move the unit up one.
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  if (isPressed(BUTTON_DOWN)) {
    // Move the unit down one.
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  // Other moves...

  return NULL;
}

实际上,这些Command只使用一次的优点马上就会体现出来。为了使Command能够被撤销,我们需要定义另外一个需要各个Command都实现的方法:

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

undo()方法会恢复被execute()方法改变过的游戏状态。下面代码是我们前面定义的Move Command加入了对撤销的支持:

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()
  {
    // Remember the unit's position before the move
    // so we can restore it.
    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_所做的事情。
为了使玩家能够撤销移动操作,我们保存了最后一个执行的Command。当玩家按下Control + Z,我们就调用这个Command的undo()函数。(当他们已经撤销了,又要重做,我们只需要重新执行一下Command的execute函数就可以了。)

如果想要支持多步撤销,其实也不难。我们只需要用一个Command列表来替换原来的最后一个Command就可以,外加一个指向“当前”命令的引用。每当一个Command被执行,我们把它添加到列表里面,并将“当前”的引用指向它。

Game Programming Patterns-再探Command模式_第4张图片

当玩家撤销,我们就撤销当前的Command,并将当前的引用向后移动一位。当他们重做,我们就向前移动指针,并且执行Command。当他们在撤销后选择了一步新的操作,那当前Command之后的所有Command就会被销毁。

当我第一次在一个关卡编辑器里面实现后,我感觉自己简直是个天才。我对它如此的简单明了,如此的运行顺畅感到吃惊。Command规定了所有的数据修改都通过一个个Command进行。但是一旦你确定了这个规则,剩下的就很容易了。

优雅与功能弱化

之前,我说Command与一级函数或者闭包非常像,但是前面每一个例子,我都使用了类来定义。如果你对函数式编程比较熟悉,你可能会疑惑,说好的函数呢?

我用这种方式写例子,是因为C++对一级函数支持很弱。函数指针没有状态,仿函数很奇葩,并且同样需要定义类。C++11的lambdas表达式用起来很不顺手,原因是内存管理需要手动进行。

这些不是说你在其他语言中不能在Command模式中使用函数。如果你使用的语言有真正的闭包,一定用起来!某种意义上说,Command模式就是一个没有闭包的语言模仿闭包的方式。

例如,如果我们用Javascript开发游戏,我们可以像这样创建一个单位的Command:

function makeMoveUnitCommand(unit, x, y) {
  // This function here is the command object:
  return function() {
    unit.moveTo(x, y);
  }
}

我们可以使用一对闭包来添加对撤销的支持:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

如果你习惯这种函数式编程,那么这样做就显得很自然。如果不习惯,我希望这篇文章能对你有所帮助。对我来说,Command模式的用处恰好体现了函数式编程在解决某些问题上的优势。

更多请参阅

  • 你可能使用了很多不同的命令类。为了让实现变得更加容易,通常会定义一些具有很方便的高级别方法的基类,然后派生出的一些command可以通过重写来改变行为。这样就需要将command中的excute()使用Subclass Sandbox子类沙盒模式进行重写。
  • 在我们的例子中,我们明确地选择了哪个演员能够处理一个命令。在一些其他例子,尤其是当你的对象模型是分层级的时候,它使用起来可能就不是那么一层不变了。一个对象可以响应一个Command,或者它可以决定把它推给一些从属对象。如果你那么做,就需要了解更多的Chain of Responsibility(责任链模式)
  • 有些command是没有状态的,只是一堆纯粹的行为,就像第一个例子中的jumpCommand。在这样的情况下,多个实例会造成内存的浪费,因为每个实例都是对等的。在Flyweight(享元模式)中,我们会提到这点。

你可能感兴趣的:(设计模式,游戏开发,游戏编程)