命令模式
命令模式是我最爱的设计模式之一。我的大部分游戏项目里面,或多或少的都有命令模式在里面。当我们把命令模式用在合适的地方的时候,它能灵巧的解耦一些粗糙的代码。对于如此漂亮的模式,“四人帮”里面有一段深奥的描述:
Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations.
上面这段很深奥。。。。
而我的精炼理解,命令模式就是具体化的函数调用。
当然精炼的话语,有的时候很难理解和产生作用。我们这里,换一种说法,回调函数,函数指针,在四人帮的户籍里面,最后说到:
命令模式就是回调函数在面向对象里面的代替品。
这句话,我想应该比最上面的话要好理解很多了。
但是无论如何这些都是很抽象的语句。下面我们会用具体的例子来理解命令模式,到底是什么,它又有什么聪明之处呢?
配置输入
基本上,大多数游戏里面,我们都会去读取玩家的原始输入。像,手柄按键,键盘按键,鼠标点击等等。然后,把这些输入转换为有意义的游戏行为:
一个不好的,但是很简单的实现方式如下:
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(); }
};
// 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();
}
之前我们的自己调用,现在变成了间接调用关系:
这就是一个命令模式的基本外壳。如果你已经看出来了它的优势的话。可以思考下章节后面的一部分。
执行者的直接调用
上面我们设计的命令模式,其实还是有限制的。它的问题在于,我们前面的命令里面执行的上层函数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参数来控制不同的角色了。
实际上,这个并不是通用的作用,但是经常会有这种需求的出现。现在我们先考虑角色。我们驱动了主角,但是游戏世界的其他角色呢?(npc,monster等等)这些,都是靠着AI去驱动的。现在我们就可以使用同一套命令模式去适配AI引擎逻辑和角色了。AI代码相当于就是简单的抛出Command对象就可以了。
我们让AI可以选择命令和不同的Actor,这样就可以让我们的代码更加的灵活。我们可以使用不用的AI模块对应不同的角色。想要更加强大的AI对手,我们只需要修改AI,产生跟多的命令既可以。事实上,我们甚至可以将AI的产生的命令发送给玩家角色,就可以做一些自动战斗和一些Demo剧情了。
去除到AI对Actor的直接调用后,我们可以将命令想想成一个流化的过程了:
一些代码(输入或者是AI),产生命令,然后将命令放入到流里面,其他的代码获取命令,然后选择actor去执行。这样通过中间命令的连接,我们将生产者和消费者,分割到了2端。
如果我们让命令序列化,那么,我们就可以将整个流在网络上面转发,在另一个机器上面执行,这个也是多人网络游戏非常重要的一部分。同时记录这些命令也可以让我们实现重放的功能。
撤销和重做
最后一个例子,是命令模式最有名的一个功能。如果一个命令可以让对象做某件事情,那么也可以做一些事件让对象回到之前的状态。撤销通常在策略游戏里面实现一些你做错了,想回滚的操作。在游戏编辑工具中,则更是常用的了。如果让游戏关卡设计师,用的你的工具开发游戏,而又没有撤销功能的话,我想你会被打的。。。
如果不适用命令模式,实现撤销功能会很非常难。但是使用命令模式的话,那就是一件非常轻松的事情了。现在我们假象我们在做一款单机,回合制的游戏,我们想要玩家可以撤销他之前的移动操作,让玩家可以更多的在思考策略上面,而不是猜测上面。
前面我们已经通过命令模式去抽象了输入。现在玩家的移动也很容易的就实现了。例如:让玩家移动一个单位:
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_;
};
需要注意的是,这里的命令函数和之前的有一些不一样。前面的例子里面我们想要在命令中把actor抽象出来,可以替换。在这个例子里面我们想要将被移动的对象和命令绑定在一起。这里的移动命令不是一个通用的命令,他只是这种类型游戏的特殊移动命令。
这里其实也是一种命令模式实现的变种。在某些例子中,就像我们前面的实例,一个命令就是一个可以被多对象复用的命令,只需要实例化一个命令就可以了。我们前面的输入就是只创建了对应的一个命令,然后给所有需要使用的对象使用一下(execute())就可以了。
现在,我们的命令就比较特殊了。它代表一个在特定时间特对对象实现的某个操作。也就是说,这个命令在需要的是,都需要创建(new)一个新的命令对象出来,如下:
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()执行的功能。下面就是我们前面移动命令的撤销函数实现:
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来保存单位移动钱的位置。
为了能让玩家撤销之前的操作,我们需要保留玩家执行过的命令。当玩家需要撤销的时候,执行命令的undo操作即可。当然如果玩家又想重做,我们再执行一次命令的execute()即可。
想要支持多层的撤销也很简单,只要我们有一个命令队列来记录玩家的命令。然后在通过指向当前玩家的命令块,来做想要的操作即可。
当玩家选择”undo”,我们就调用当前命令的undo,然后,让队列的当前指针指向前一个命令。当玩家选择”Redo”,我们就让对了的当前指针向后移动一个,再执行其execute即可。如果玩家在撤销后,执行了新的命令,那么在当前位置插入新的命令,同时删除掉后面的所有命令(这个大家可以在word里面测试下,就是这个规则哈)。
我第一次使用这个模式是在关卡编辑器里面,感觉自己就像是一个天才。我吃惊于它如此的简单直接,但是工作的确如此的完美。虽然它需要,确保你的每一个编辑的操作都用命令封装一次,但是一旦完成后,剩下的工作就都顺其自然的完成了。
翻译就到这里,在原贴http://gameprogrammingpatterns.com/command.html里面还有一些其他的注意事项和提示,大家可以去看看。