Command模式
原文连接:http://gameprogrammingpatterns.com/command.html
Command模式是我最喜欢的模式之一。在我写过的大多数大型的程序,游戏和其他代码中,随处可见。正确地使用它,可以让一些丑陋的代码变得整洁。这样一个漂亮的模式,“Gof”(《设计模式-设计模式:可复用面向对象软件的基础》的四个作者)给出了一个极其抽象晦涩的描述:
将一个请求封装成一个对象,从而可以让用户使用不同的请求,队列或者日志请求去参数化客户端,并且支持撤销操作。
我想,大家一定都觉得这是一个糟糕的句子。首先,它混淆了一些他要建立的概念。脱离软件这个语境,一些单词可能有别的意思,例如“client”可以是客户的意思。通过本人考察发现,人是不能被“参数化”的。
其次,这个句子的其他部分,罗列了一些可能用到这个模式的场景。但是不好理解,除非你恰好遇到了这种情况。我给出Command模式一个更加精炼的定义:
一个Command就是一个具像化的方法调用。
当然,“精炼”往往也意味着“强行简化”,所以这个解释也未必有多大进步。让我稍微解释一下。“具体”,在这里有一个别开生面的意思“实例化”。另一个解释是“变成一级函数( 函数如同整数和字符串等基本类型一样,作为参数传递、返回值 和 绑定变量名)”。
总而言之,两种解释的意思都是把一些操作封装到一块数据(也就是一个对象)中。你可以将这个对象存到一个变量中,传递到一个函数中,等等。所以,我把Command模式描述为“具象化的方法调用”,意思就是将一个方法封装成一个具体的对象。
这些听起来像是“回调”,“一级函数”,“函数指针”,“闭包”或者“偏应用函数”这些概念,这取决于你使用什么样的编程语言。这些概念确实大致相同。GOF最后说道:
命令模式是用面向对象的方式取代回调。
用这句话来诠释Command模式可能比他们选的那句定义更恰当。
当然,这些都是抽象的朦胧的。我想加入一些具体的例子,去补充这些解释。从现在开始,这些例子都巧妙的使用了Command模式。
输入配置
每一个游戏都有一部分代码是读取用户输入——按下按钮,键盘事件,点击鼠标等等。记录下这些输入,并将其转化为游戏中有意义的行为。
有一种木逼的实现像这样:
1 2 3 4 5 6 7 |
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模式就来了。
我们定义一个基类,他代表一个可触发的游戏命令:
1 2 3 4 5 6 |
class Command { public: virtual ~Command() {} virtual void execute() = 0; }; |
然后我们为每种不同的游戏操作定义一种子类。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class JumpCommand : public Command { public: virtual void execute() { jump(); } }; class FireCommand : public Command { public: virtual void execute() { fireGun(); } }; // You get the idea... |
在InputHandler类中,我们为每一个按键保存了一个指向Command对象的指针。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class InputHandler { public: void handleInput(); // Methods to bind commands... private: Command* buttonX_; Command* buttonY_; Command* buttonA_; Command* buttonB_; }; |
1 |
现在输入处理只是一个指向那些的代理: |
1 2 3 4 5 6 7 |
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(); } |
1 |
原来输入模块直接调用函数,现在改为间接: |
这是Command模式的一个简单例子,如果你觉得已经掌握了Command的真谛,就把这一章剩余的部分看作是利息吧。
控制演员
前面例子中,我们定义的Command可以运行,但是作用相当有限。问题是他们假定有一些全局函数像jump(), fireGun()等等,这些函数能够立刻得到主角的动作控制器,然后像控制木偶一样控制主角。
这些假定大大地限制了这些Command的使用范围。也就是只有主角能够使用JumpCommand跳跃。让我们打破这层限制。我们不让调用的函数自己去找控制对象,而是将控制对象作为参数传给它。
1 2 3 4 5 6 |
class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; }; |
这里,GameActor 是我们的游戏对象类,代表了游戏世界中的一个演员。我们将它传递给execute函数,这样那些Command子类就可以调用相应的方法了。就像这样:
1 2 3 4 5 6 7 8 |
class JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } }; |
如果这个actor是玩家主角的引用,这能根据玩家的输入准确的控制主角,这样就达到了跟第一个例子同样的效果。但在Command和演员之间插入了层,这让我们有了一个更加灵活的能力:我们可以通过改变actor参数,让玩家控制游戏世界中所有的演员。
事实上,这不是通常意义上的特性,但是经常会被时不时的用到。到目前为止,我们只留意了玩家控制主角,但是游戏世界的其他演员呢?他们是被游戏AI驱动的。我们可以使用同样的Command模式,作为AI引擎和演员之间的接口。AI代码只需要简单地抛出命令对象就可以了。
将组织Command的AI代码与控制演员的代码解耦,给了我们极大的灵活性。我们可以在不同的演员身上使用不同的AI,或者可以通过混合搭配不同的AI组合成不同种类的行为方式。如果想加入更牛逼的对手,只需要加入更牛逼的AI去生成一堆Command就可以实现。甚至我们可以为玩家控制的主角加上AI,这样可以像演示demo一样让游戏自动运行。
通过编写控制演员的Command,我们去掉了直接调用函数带来的紧耦合。这样我们就可以理解为通过一连串命令来控制演员了。
一些代码(输入控制或者AI)产生Command,并将他们做成一个命令流。另外一些代码(分发器或者演员本身)使用这些Commands。把这个命令流放在中间,就完成了对命令产生者和执行者的解耦。
撤销和重做
最后一个例子是这个模式最著名的一个用法。如果一个Command对象能做一件事,那离他能撤销这件事就不远了。撤销可以用在一些策略游戏中,你可以撤销掉你不喜欢的操作。在制作游戏的工具中,撤销也是一个非常必要的功能。一个最容易让你的策划恨你的方法,就是给他们提供一个不能撤销的关卡编辑器。
没有Command模式,实现撤销是非常困难的。有了他,就成了小菜一碟了。比如说,我们要做一个单人回合制游戏,我们会希望加入撤销操作,这样玩家就可以把注意力放在策略上,而不用做过多的猜测。
我们已经使用Command,非常方便地将用户输入的处理抽象化,因此玩家每一步操作都已经封装在Command之中了。例如,移动一个单位应该这么写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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实例。就像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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实现的方法:
1 2 3 4 5 6 7 |
class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; }; |
undo()方法会恢复被execute()方法改变过的游戏状态。这里是我们前面定义的移动Command加入了对撤销的支持:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
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被执行,我们把它添加到列表里面,并将“当前”的引用指向它。
当玩家撤销,我们就撤销当前的Command并将当前的引用向后移动一位。当他们重做,我们就向前移动指针,并且执行Command。当他们在撤销后选择了一步新的操作,那当前Command之后的所有Command就会被销毁。
当我第一次在一个关卡编辑器里面实现后,我感觉自己是个天才。我对它如此的简单明了,如此的运行顺畅感到吃惊。Command规定了所有的数据修改都通过一个个Command进行。但是一旦你确定了这个规则,剩下的就很容易了。
优雅与功能弱化
之前,我说Command与一级函数或者闭包非常像,但是前面每一个例子,我都使用了类来定义。如果你对函数式编程比较熟悉,你可能会疑惑,说好的函数呢?
我用这种方式写例子,是因为C++对一级函数支持很弱。函数指针没有状态,仿函数很奇葩,并且同样需要定义类。C++11的兰姆达表达式用起来很不顺手,原因是内存管理需要手动进行。
这些不是说你在其他语言中不能在Command模式中使用函数。如果你使用的语言有真正的闭包,一定用起来!某种意义上说,Command模式就是一个没有闭包的语言模仿闭包的方式。
例如,如果我们用Javascript开发游戏,我们可以像这样创建一个单位的Command:
1 2 3 4 5 6 |
function makeMoveUnitCommand(unit, x, y) { // This function here is the command object: return function() { unit.moveTo(x, y); } } |
我们可以使用一对闭包来添加对撤销的支持。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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模式的用处恰好体现了函数式编程在解决某些问题上的优势。