@TOC
命令模式(Command)
GoF的描述生涩难懂
命令模式是我最喜欢的模式之一。在我编写的绝大多数的程序、游戏或者其他东西中,最终在某些地方都会使用到它。当我在合适的地方使用它,它能把一些棘手的代码清晰地整理解开。对于这样出色的模式,GoF对此有个让人生涩难懂的描述:
将请求封装为对象,从而使用户能够以不同的请求对客户端进行参数化、对请求进行排队或记录,并支持可撤销的操作。
这是个很糟糕的描述。首先,这个描述歪曲了想要表达的含义。特别是"客户端"这个词,抛开软件领域,“客户端"可以代表一个人,比如一个与你做生意的人。据我所知,人类不能被"参数化”。
然后,这个描述的剩余部分只是你可以/可能使用该模式的概念清单。除非你用例存在概念恰好在上述清单中,否则没有太大启发意义。
命令是具体化的方法调用
"具体化"表示将某个概念转化为一段数据(一个对象:你可以将其放入变量、传递给函数等等)。因此,通过说命令模式是“具体化的方法调用”,我的意思是它是一个包装在对象中的方法调用。
取决于你使用的编程语言和习惯,具体而言这听起来很像一个“回调函数”、“一等函数”、“函数指针”、“闭包”或“部分应用函数”等概念,而且它们确实都在同一个范围内。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();
}
这个函数在游戏循环的每帧中被调用。如果我们固定了用户输入与游戏动作的映射,那么这段代码是可行的。但是绝大多数游戏是允许用户配置输入映射的。
为了支持输入映射可配置的操作,那么意味着上述的中每个条件分支下的对应行为是可变的(举例:当满足isPressed(BUTTON_X)这个条件后,转译的动作是可变的。)。"可变的"在软件中往往暗示着一个变量,变量是可变的,因此我们可以用一个变量去代表游戏动作,我们可以用个抽象的基类代表一个游戏动作, 如下所示:
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0; //execute是个子类必须实现的虚函数,用来执行最终的动作
};
那么我们可以创建对应不同游戏动作的子类实现:
class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};
class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};
// You get the idea...
在InputHandler中,为了进行配置输入映射,变为了如下所示的代码结构:
class InputHandler
{
public:
// 使用函数指针执行动作
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();
}
// 增加输入映射配置的功能
void reMapX(Command* action)
{
this->buttonX_ = action;// 对X进行配置
};
void reMapY(Command* action)
{
this->buttonY_ = action;// 对Y进行配置
};
// 为了A和B等按键映射...
// Methods to bind commands...
private:
Command* buttonX_; //函数指针
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};
引入角色
上述的实现动作的函数,比如jump()、fireGun()等,这些函数是无参函数,说明只能对固定的对象执行对应的固定动作。如果引入角色,那么可想而知,玩家角色和Npc角色的jump、fireGun等动作肯定是有所区别的。因此Command基类将引入传参actor:
class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};
动作的实现函数也将发现变化:
class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump(); // jump函数的最终实现被绑定到了actor上
}
};
因此,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_;
// 未实现,返回NULL
return NULL;
}
因为handleInput函数没有传入具体的角色对象,所以上述代码没有立刻执行实际的命令。这带来一个优点:我们可以延后进行命令的执行。如下所示:
Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}
我们所举的这个例子中,actor代表被用户输入驱动的玩家角色。我们通过Command对象实现了决策和动作的分离,因此我们自然也可以想到游戏中的AI角色(Npc),如果被控角色是一样的,那么AI角色跟玩家角色的不同之处就是驱动命令的来源问题,因此我们可以用下述的示意图统一表示这种模式
💡 批注:为什么特意使得命令可序列化,成为流呢?我们可以把命令流通过网络传输。因此我们能够把玩家输入通过网络传输到另外的机器上,并且重现它。这在多人网络游戏中是很重要的功能。
撤销和复现
💡 批注:为了同上述小结内容衔接,保持内容连贯;这个小结内容不使用原作者的原文字翻译,全部是个人理解,同时修改了示例代码。
撤销和复现是很多软件和游戏中存在的功能。对于软件而言,我们能够经常看到撤销和复现的功能,比如windows的画图(可以撤销绘图操作)、windows的记事本(可以撤销最近的文字)、游戏设计软件、工业软件等。对于游戏而言,特别是策略游戏,比如塔防/棋类游戏中,在玩家的决策回合,玩家可以指定某个单位前往地图上的某个坐标上,玩家可能会不小心或者错误指定位置,那么游戏通常是会提供撤销功能,以此让该单位恢复原来的位置。那么我们该如何实现撤销功能呢?我们依然是对Command这个基类进行修改,如下所示:
class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
virtual void undo(GameActor& actor) = 0; // 增加撤销操作接口
};
我们以棋类游戏(比如象棋)的棋子移动的撤销功能举例,那么这个棋子移动的命令功能如何是西安呢?如下所示:
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(int x, int y)
: xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}
virtual void execute(GameActor& actor)
{
// 记录棋子单位的先前的、旧的位置,以便能够恢复之前的位置
xBefore_ = actor.x();
yBefore_ = actor.y();
actor.moveTo(x_, y_);
}
virtual void undo(GameActor& actor)
{
// 恢复之前的位置
actor.moveTo(xBefore_, yBefore_);
}
private:
int xBefore_, yBefore_; // 棋子先前的位置
int x_, y_; // 本次命令的位置
};
那么我们如何使用命令流进行撤销undo和redo操作呢?使用下述的代码进行说明,其中使用Control-Z代表撤销,Control-Y代表恢复
int undoOrRedo(std::vector<Command*> commandStream,
int current_index,
GameActor& actor){
/*
commandStream: 命令流
current_index: 代表当前执行的命令位置
actor: 操控对象
return: 返回更新后的命令位置
*/
if (isPressed(BUTTON_Control_Z) && current_index >= 0) {
// 执行撤销操作,返回上一个命令的位置
commandStream[current_index]->undo(actor);
return current_index - 1;
} else if (isPressed(BUTTON_Control_Y) && current_index >= -1 && current_index < commandStream.size() - 1) {
// 执行下一个命令的动作,同时返回下一个命令的位置
int redo_index = current_index + 1;
commandStream[redo_index]->execute(actor);
return redo_index;
}
return current_index;
}
命令模式在不同编程语言下的差异
💡 批注:这个小结内容不使用原作者的原文字翻译(原文标题为
Classy and Dysfunctional?),全部是个人理解,同时修改了示例代码。
上述的代码示例中均使用了C++进行展示,因为C++对闭包函数的支持不够友好,因此我们使用了class类进行替代;如果你所使用的编程语言支持闭包函数,那么完全可以用闭包函数进行替代。因此可以这么理解:命令模式是在没有闭包的语言中模拟闭包的一种方法。
对于上述例子,加入我们使用JavaScript进行编写,那么我们可以如下实现:
function makeMoveUnitCommand(x, y) {
var xBefore, yBefore;
return {
execute: function(actor) {
xBefore = actor.x();
yBefore = actor.y();
actor.moveTo(x, y);
},
undo: function(actor) {
actor.moveTo(xBefore, yBefore);
}
};
}
1555



被折叠的 条评论
为什么被折叠?



