Tags: 游戏编程 设计模式 游戏开发
本系列博客是:Game Programming Patterns 的中文翻译版本。
翻译的github地址: cyh24. 如有兴趣,可联系博主共同翻译,一起造(wu)福(dao)他人。
博客虽然水分很足,但是也算是博主的苦劳了,
如需转载,请附上本文链接,不甚感激!
本系列博客 目录,可点击进入。
============================
可以说Command模式是我最喜欢的设计模式之一。在我写的大多数的大型程序,游戏和其他一些代码中,都随处可见这个模式。正确地使用这个模式,可以让一些原本很丑陋的代码变得更加整洁。然而,这样的一个优雅漂亮的模式,“Gof”却给出了一个非常抽象,晦涩难懂的描述:
将一个请求封装成一个对象,从而使你可以用不同的请求对客户端进行参数化,对请求排队,或记录请求日志,以及支持撤销和恢复操作。
我想你们应该跟我一样,都会觉得这是一个超难读懂的句子吧?首先,它混淆了一些他自己本来想要建立的概念。如果脱离软件这样的语境,一些单词就会有其他不同的意思,例如:client 可以使客户的意思。而且,据我的考察,人应该是不可以被参数化的.
其次,这个句子的其他部分,罗列了一些可能用到这个模式的场景。但是却并不好理解,除非你自己恰好遇到了这种情况。基于此情况,我自己重新给出Command模式一个更加精炼的定义:
一个 Command 就是一个具象化的方法调用.
当然了,“精炼”往往也意味着“过度简化”,所以,我的这个名词解释其实也并没有多大的进步。还是让我稍微解释一下吧。“具象化” 在这里的一个意思就是“实例化”;另一个意思是,把方法变成一个“first class”(一级函数),从而能够如同整数和字符串等基本类型一样操作,可以作为参数传递、返回值等。
总而言之,上面的两个解释就是把操作封装到数据(或者说是一个对象)中。然后,你可以将这个对象当成一个变量,将其传递到一个函数中或者进行其他各种操作。所以,我把Command模式描述成是“具象化的方法调用”,意思就是将一个方法封装成一个具体的对象。
这些东西听起来很像“回调”,“一级函数”,“函数指针”,“闭包”或者“偏应用函数”等概念,因为这些概念确实大致相同,只是跟你使用的语言不同有关。正如GoF最后提到的:
Command模式就是用面向对象的方式取代回调函数。
我认为,用这句话来诠释Command模式,远比上面那些他们选取的句子更恰当。
当然了,不管怎么样的诠释,听起来总是很抽象,很朦胧。所以,我加入一些具体的例子,来补充解释Command模式。
每个游戏都有一部分代码是专门用来读取用户输入的——比如:按下button,键盘事件,点击鼠标等等。这些输入都需要被记录下来,然后转变成游戏中有意义的行为。
下面是一个简单的实现:
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();
}
原来的输入只是直接去调用一个行为函数,现在是一个间接的过程了,如下图所示:
以上就是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,我们去掉了直接调用函数带来的紧耦合,从而可以轻松的使用一系列命令来控制演员了。
上图的意思是,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被执行,我们把它添加到列表里面,并将“当前”的引用指向它。
当玩家撤销,我们就撤销当前的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模式的用处恰好体现了函数式编程在解决某些问题上的优势。