游戏编程模式--命令模式
写在前面
最近深感代码设计对于软件开发过程中的重要性,所以重新拾起了设计模式,以前学的比较松散,理解不够,这一次本着learning,try,Teaching的精神,重新认识和学习设计模式。这一次参考Robert Nystrom 著的《游戏编程模式》一书,与原先的GoF所著的24种设计模式不同,但思想是相通的,读者若是想对本文的设计模式追根溯源,可自行购买参照。
命令模式
GoF这样表述命令模式:将一个请求(request)封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同时支持请求操作的撤销和恢复。
其实GoF还有一个更简单的描述:命令就是面向对象化的回调。
对于这两种描述,相信读者一开始都会觉得比较的抽象,我们接下来将会举例说明命令模式的应用场景。
配置输入
设想我们早期的游戏机,我们使用手柄作为输入,手柄上有几个按键,比如“A”,“B”,“C”等,每当我们按下其中一个按键时,游戏中的角色就会做相应的一个动作。如果我们要实现这个过程,相信我们很容易写出这样的实现代码:
void InputHandler::handlInput() { if(isPressed(BUTTON_A)) { jump(); } else if(isPressed(BUTTON_B)) { fire(); } else //do otherthing { } }
这种方式是可以运行的,也可以达到我们的目的,但很明显,这种硬编码的风格非常的不灵活,而且如果我们想对按钮和其映射的行为进行配置的话是无能为力的。这个时候我们就可以使用命令模式了。
在命令模式中,我们首先定义一个基类来代表命令:
class Command { public: virtual ~Command() {} virtual void Excute() = 0; };
然后为不同的命令建立子类:
class JumpCommand : public Command { public: virtual void Excute() override { std::cout << "jump" << std::endl; } }; class FireCommand : public Command { public: virtual void Excute() override { std::cout << "fire" << std::endl; } };
在输入处理类中为每一个按键存储一个命令指针,然后输入处理便通过这些指针进行代理:
class InputHandler { public: InputHandler() { } void HandleInput() { if(isPressed(BUTTON_A)) { button_a->excute(); } else if(isPressed(BUTTON_B)) { button_b->excute(); } else { } } private: Command* button_a; Command* button_b; };
完成这写步骤之后,代码还不能立刻执行,还需要为InputHandler的左右按键配置相应的命令。
inputHandler.setButtonCommand(BUTTON_A,new JumpCammand);
这样通过为每输入的处理添加一个间接调用实现了按键与命令的解耦,极大的方便了后续关于按键处理的修改。这就是命令模式,它的优点是显而易见的。
但在上述的例子中我们并没有判断命令为空的情况,事实上我们可以定义一个空命令,这个命令不做任何的事情,每一个按键的默认命令就是空命令,这便是空值对象模式,这种模式在很多情况下可以简化我们的代码逻辑。
除此之外,这个例子还有一点不足。通常在游戏中有很多的角色,相同的类型角色都可以执行相同的命令(这种情况可能没有想象中的那么普遍),那在InputHandler如何分辨那个角色执行命令了?我们可以把角色传入命令中,然后命令使用这个角色来执行对应的指令,比如:
class FireCommand : public Command { public: virtual void Excute(GameActor& actor) override { actor.Fire(); } };
Cammand* InputHandler::handleInput()
{
if (isPress(BUTTON_A))
return buttonA_;
if (isPress(BUTTON_B))
return buttonB_;
return nullptr;
}
Cammand* cmd = inputHandler.handleInput(); if (cmd != nullptr) { cmd->excute(actor); }
除了上述的应用场景,我们还可以考虑另一个应用场景——AI。在游戏中,我们通常会有非常多的非玩家控制的角色,这些角色的行为都是由AI系统控制的,如果都是用硬编码的形式来编写,最后的代码会给你带来地狱般的体验。这个时候,使用命令模式将带来极大的便利性。例如AI系统想构建一个具有侵略性的敌人,那只需要在AI系统中插入一段生成侵略性指令的代码即可。AI系统负责生产命令,而命令的执行则由目标角色调用。再进一步的思考,命令产生后,角色需要顺序执行命令,那就需要一个队列来存储未执行的命令,这种情况就好比一个命令流,通过命令流我们就是实现了命令生产端和消费端的解耦。
重做和撤销
命令模式还有另一个常用的场景——撤销和重做。现代社会基本的编辑类应用都会提供这样的操作(想象一下你在编辑一个文档,不小心按下删除按钮整段内容删除却不能撤销这个操作的情况将是多么可怕),使用命令模式会非常方便的实现这个功能。修改一下之前的命令类,添加撤销和重做的方法。
class Command { public: ~Command() {} virtual void Excute(GameActor& actor) = 0; virtual void Undo() = 0; //重做 };
之后再维护一个已执行命令和已撤销命令的栈就能轻松实现撤销和重做功能。
类风格化还是函数风格化
在这里,我们使用了类来定义命令,主要鉴于c++中闭包支持有限(c++11中闭包需要手动管理内存,比较麻烦)。其实命令模式从某些方面看来是某些没有闭包的语言模拟闭包的一个方式。在支持闭包的语言中,如JS,c#,果断推荐使用函数来定义命令。
结语
显而易见,命令模式简单理解就是命令面向对象化的回调。把一个个行为、请求封装为一个一个命令对象,使得命令的生产和命令的调用解耦,避免硬编码的坏味道。