游戏编程设计模式 -- 命令模式

Game Programming Patterns -- Command

原文地址:http://gameprogrammingpatterns.com/command.html
原作者:Robert Nystrom

原创翻译,转载请注明出处

命令模式是我最喜欢的模式之一。在很我完成的项目中,不管是游戏还是其他软件,最终都用到了这个模式。当我在正确的地方使用它的时候,它可以把一些非常纠结的代码的条理给整理得很清晰。对于这样一个非常棒的模式,GOF给出了一个预言般深奥的描述:

把请求封装成对象,从而让用户通过不同的请求把客户端参数化,可以队列化或日志化请求,支持可重做操作

我想我们都同意这句话真的很难理解。首先,这句话把它想要去隐喻的东西表达得很糟糕。在奇怪的软件世界之外,那个一个词可以表示很多意思的地方,客户(client)表示一个人--那个你和他做生意的人。最后我确认一下,人是不能被“参数化”的。

接下来,这个定义的其他部分就是一个你可能会用这个模式去做的一些事情的列表。除非你的用例刚好在这个列表中,否则它并不是很有启发性。我给命令模式想了一个精炼(pithy)的标语:
命令就是一个具象化(reified)的方法调用

“Reify”来自于拉丁语中的“res”,表示“事物(thing)”,跟了一个英语中的词缀“-fy”。所以它基本上可以表示为“thingify(具体化)”,这个词看起来真心有意思多了。

当然,“pithy”常常意味着“难以理解的简洁”,所以这个定义相对于原来那个可能没有多少进步。让我来更深入地解释一下吧。“具象化(Reify)”,如果你从来没听过的话,意思是“实例化(make real)”。另外一个对它的解释是,把一些东西变成“第一类对象”(first-class,***其特点为可以被存入变量或其他结构;可以被作为参数传递给其他函数;可以被作为函数的返回值;可以在执行期创造,而无需完全在设计期全部写出;即使没有被系结至某一名称,也可以存在等。)

这些定义都是在说把一些“概念”提取出来,并转化成你可以用来存储在变量中或者传递给函数等的数据--或者说一个对象。所以说,命令模式就是一个“具象化的方法调用”,意思就是它是一个被封装在一个对象中的方法调用。

这听起来很像是一个“回调”、“第一类函数”、“函数指针”、“闭包”或者“不完全应用函数”,这些其实都是差不多的概念,只是根据你所使用的编程语言而叫法上有些不同罢了。GOF是这样说的:

命令就是面向对象形式的回调。

反射系统是某些语言中,让你可以在运行中操作程序中类型的系统。你可以获得一个表示其他类型对象的对象,你可以对它进行操作看它可以做些什么。换句话说,反说是一个具象化的类型系统。

这看起来是一个比他们所用的那个更好的对命令模式的简短说明。

但是这些看起来都是很抽象和模糊的。我会用一些具体的东西来开始我们的章节。所以接下来,将会是一些绝妙的适用于命令模式的例子。

自定义输入

在每一个游戏的某一处总会有这样一大块代码,它们的作用是读取原生的用户输入--按钮按下、键盘事件、鼠标点击这些。这块代码获取每一种输入并将它们转化成在游戏中有意义的动作:

一个简单的不能再简单的实现看起来就是下面这样的:

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

***进阶小贴士:不要经常按B哦。 ***

这个函数通常是在游戏主循环(game loop)中每一帧调用的,我肯定你们知道它是用来干嘛的。当我们想要写死用户控制游戏的输入方式时我们会用到这段代码,但是很多游戏都时会让用户“自定义”他们的按钮配置的。

为了支持这个功能,我们需要把对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(); }
};

// You get the idea...

在我们的输入管理器中,我们为每一个按钮储存了一个指向命令的指针:

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

注意这里我们为什么没有对NULL作判断?这是因为我们假设每一个按钮上都绑定了某个命令。如果我们想要支持什么都不做的按钮的话,我们也不需要单独判断NULL,可以定义一个命令类,它的execute()方法不做任何事情。接下来,我们只要把按钮设置到这个对象上就可以了,而不是把按钮功能设置为NULL。这就是被叫做Null Object(零对象)的模式。

之前对每个输入直接调用方法的地方,现在添加了一个间接层:

简单来说,这就是命令模式。如果你已经意识到它的价值,那本章接下来的部分就当做是额外的红利吧。

指导演员

我们定义的这些命令类在上一个例子中是可以正常工作的,但是它们有一定的局限性。问题就是,它们假设这里有一些顶层的像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_;
  
  // Nothing pressed, so do nothing. 
  return NULL;
};

现在输入管理器不能马上运行命令了,因为它不知道传入命令的是哪个角色。这里我们可以利用命令是一个具象化的调用这个特点--我们可以延迟这个调用的执行。

这样,我们就需要一些代码去持有这个命令,然后在表示玩家角色的那个对象上去执行它。就像下面这样:

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

假如actor是对玩家角色的引用的话,这里就可以根据玩家的输入来控制它了,所以我们回到了第一个例子中我们所做到的功能。但是添加了一个命令和actor之间的间接层给我们带来了一个巧妙的小技能:我们可以让玩家控制游戏中的任何一个角色,只需要修改我们传入到命令中的那个actor就行了。

在实际使用中,这并不是一个常用的功能,但是却有一个类似的功能用例是会经常出现的。到这里为止,我们一直都在考虑玩家操作的角色,那游戏世界中其他的角色怎么办呢?其实它们是由游戏的AI系统来控制的。我们同样可以把命令模式作为接口用于AI引擎和需要控制的角色之间;AI部分的代码只需要简单地发出命令对象即可。

AI选择命令然后角色做出行动,它们之间的解耦给我们带来很多的灵活性。我们可以对不同的角色使用不同的AI模块。或者我们可以为不同类型的行为来组合和匹配AI。想要一个更有侵略性的对手?只需要添加一个更有侵略性的AI去给它生成命令就可以。实际上,我们甚至都可以把AI插入到玩家的角色身上,这样在类似demo模式这种需要游戏自动运行的地方会非常有用。

通过把命令变为第一类对象,我们可以移除直接使用方法调用造成的耦合。现在,我们可以把这个过程看成是一个命令的队列或者流:

如果想知道更多有关于队列的作用,你可以在事件队列(Event Queue)这一章里找到。

***为什么我会觉得有必要给你画一张关于“流(stream)”的图呢?为什么它看起来像根管子呢? ***

一些代码(如输入管理器或者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()方法。

而这里,命令的功能要更明确。它们表示了一个可以在特定的时间点进行的某种操作。意思就是输入管理器将会根据用户的每一次操作而去创建一个新的实例。就像下面这样:

当然,在C++这样没有垃圾回收机制的语言中,这意味着运行命令的代码同样有义务要是释放它们的内存。

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

这里这些只使用一次的命令马上就会给我们带来一些好处。为了让命令可以被撤销,我们定义了另外一个操作,每个命令类都需要去实现它:

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

这里的undo()方法用来回退其相关的execute()方法对游戏状态的修改。下面我们把之前的move命令添加上了撤销功能支持:

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_这两个成员变量的作用。

这里看起来好像可以使用GOF的备忘录模式,但是我还没有找到很好地应用备忘录模式的方式。因为这些命令只会修改其对象的一小部分状态,在这种情况下去保存对象数据的其余部分是非常浪费内存的。所以在这里去手动保存你所修改的那一部分内容是一种更优的选择。
可持久化的数据结构(Persistent data structures )是另一个备选的方式。使用这种方式时,每一次对对象进行修改时都要返回一个新的对象,而保持原对象不变。通过巧妙的实现方法,这些新对象会和它们之前的对象共享数据,所以这要比克隆整个对象要节约资源的多。
使用可持久化的数据结构时,每个命令都会保存一个对命令执行之前的对象的引用,这意味着撤销操作只需要切换回这个引用指向的旧对象即可。

为了让玩家可以撤销一次移动操作,我们会保存他们执行的最后一个命令。当他们按下Control-Z的时候,我们调用这个命令的undo()方法。(如果他们已经执行过撤销,那这就变成“重做”了,我们会重新执行一次这个命令的execute()方法。)

支持多层撤销也不是非常困难。我们不再是只保存最后一个命令,而是保存一个执行过的命令的列表和一个当前执行命令的引用。当玩家运行一个命令时,我们把这个命令添加到列表中,然后把当前命令的引用指向它。

command-undo.png

当玩家进行撤销操作时,我们就撤销当前的命令然后把指向当前命令的指针回退到前一个命令。当他们进行重做操作时,我们把指针指向当前命令的下一个命令,然后运行这个命令。如果他们在撤销操作后执行了一个不在命令列表中的新命令,那么原列表中当前命令之后的所有命令都会被丢弃。

在游戏中,重做操作可能并不是十分常见,但是重播(re-play)功能是会经常使用的。一种比较笨拙的实现方式就是记录下整个游戏在每一帧的所有状态,这样就可以做到重播了,但是这样会消耗非常多的内存资源。
不过,很多游戏通过记录每一帧中游戏中的所有实体执行的命令集来完成这个工作。在对游戏进行重播时,引擎只需要执行正常的游戏逻辑,然后通过运行这些预先记录下来的命令集来完成这个工作。

我第一次实现这个模式是在一个关卡编辑器里,我感觉自己像是个天才。我对命令模式的简单易做和功能强大感到惊喜。虽然需要很小心的确保每一个数据修改都是通过命令来执行的,但是只要你能做到这一点,剩下来的部分就非常简单了。

用类而不用函数?

之前,我说过命令和第一类函数或者闭包是很相似的,但是我在这里的所有例子用的都是类来定义。如果你对函数式编程熟悉的话,你可能会想,说好的函数在哪里呢?

我用类的方式来展示示例是因为C++对第一类函数的支持很有限。函数指针(Function pointers)是无状态(stateless)的,函子(functors)很怪异而且也是需要定义一个类的,而C++11中的lambdas表达式用起来比较难,因为需要手动进行内存管理。

这并不是说你在其他语言中也无法使用函数来实现命令模式。如果你奢侈地掌握了一种支持真正闭包的语言,那么别犹豫,把它们用起来!在某种程度上,命令模式是一种在不支持闭包的语言中模拟闭包的方式。

***我说这些是因为,为命令搭建类或者结构体即使在支持闭包的语言中,命令模式也是有其作用之处的。如果你的命令有很多种操作(比如支持撤销操作的命令),那么把这些操作全部放到一个函数中的就显得不那么好了。
定义一个实际的包含多个字段的类同样有助于帮助阅读它们的人认识到命令中包含了哪些数据。闭包是一种很棒的自动化封装状态的简练方法,但是它们有的时候过于自动化了,以致于很难看出闭包中实际包含了哪些状态 ***

举个例子,如果我们使用JavaScript来制作游戏的话,我们可以像下面这样来创建一个移动游戏单位的命令:

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

如果你习惯于函数式编程的话,这样写对你来说应该是很自然的。如果你不是的话,我希望这一章下来能对你有所帮助。对于我来说,命令模式的作用很好地诠释了函数式编程在解决很多问题上的高效性。

参见

  • 在你的项目完成时你可能有了一堆不同的命令类。定义一个包含多个简便高层方法的具体基类,让子类命令可以通过组合这些方法来定义它们的行为,对于能更容易地实现这些命令类来说是十分有帮助的。而这,就让命令的主方法execute()使用到了Subclass Sandbox(子类沙盒)设计模式
  • 在我们的例子中,我们都明确地选择了哪个游戏单位去控制一个命令。而有的时候,特别是你的对象模型是层级式的,就不能这样写死了。一个对象可能会响应一个命令,它也有可能把这个命令丢给它的一些从属类。如果你这样做的话,你就使用到了责任链模式。
  • 像第一个例子中的JumpCommand一样,有些命令是无状态、纯行为的。在这种情况下,对这样的命令类创建多个实例是很浪费内存的,因为其实所有的实例是完全一样的. 享元模式就是用来解决这类问题的。

你也可以用单例模式来解决,但是真正的朋友是不会推荐他的朋友创建单例的(but friends don’t let friends create singletons)。


因为水平有限,翻译的文字会有不妥之处,欢迎大家指正

“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,552评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,666评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,519评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,180评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,205评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,344评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,781评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,449评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,635评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,467评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,515评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,217评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,775评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,851评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,084评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,637评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,204评论 2 341

推荐阅读更多精彩内容

  • 1 场景问题# 1.1 如何开机## 估计有些朋友看到这个标题会非常奇怪,电脑装配好了,如何开机?不就是按下启动按...
    七寸知架构阅读 2,796评论 1 59
  • 设计模式汇总 一、基础知识 1. 设计模式概述 定义:设计模式(Design Pattern)是一套被反复使用、多...
    MinoyJet阅读 3,885评论 1 15
  • 目录 本文的结构如下: 什么是命令模式 为什么要用该模式 模式的结构 代码示例 优点和缺点 适用环境 模式应用 总...
    w1992wishes阅读 1,094评论 2 9
  • 工厂模式类似于现实生活中的工厂可以产生大量相似的商品,去做同样的事情,实现同样的效果;这时候需要使用工厂模式。简单...
    舟渔行舟阅读 7,711评论 2 17
  • 西列诺斯对米达斯王说:”世间绝好的东西是 不要降生,不要存在,成为乌有。次好的东西是——早死。“ ——————前记...
    一点悲伤阅读 349评论 0 0