Design Patterns: Elements of Reusable Object-Oriented Software》这本书可以说将近有20年了,已经老得喝酒都合法了,在软件产业这样迅速发展的产业,看这本书都可以说是考古了。但这本书持续的热度足以说明相比于框架和方法论,设计是多么永不过时。

在本章我们会讨论一些四人帮原创的设计模式,对于每个模式我都有一些有用或是有趣的话要说。在我看来,单实例模式正被滥用,而命令模式则不受重视;对于享元模式和观察者模式,我会探究其和游戏开发的相关性;最后,我觉得看到原型和状态模式在编程的大片领域如何不得脱身是相当有趣的。

设计模式

命令模式

命令模式是我最喜欢的模式之一,在多数我编写的大型程序中都有发挥功用。用对地方的话,它可以解开一些写的很糟糕的代码。对于这样一个有趣的设计模式,”四人帮“有着一段晦涩的描述,而我会给出一段精简的描述:

一个命令是一个被具体化的方法调用。

也就是将方法调用封装到一个对象里面。”四人帮“在书中随后也描述,命令就是回调的面向对象式的替代品。

不过怎么说都好,这样的描述都过于抽象,我试图用更加具体的事物来开始本节对于该模式的描述,但我搞砸了…为了弥补这个遗憾,我会把所有采用命令模式合适的不得了的例子全部列出来。

配置游戏按键

游戏中会有一段读取用户输入——键盘输入,鼠标按钮等等——并把输入转化成游戏中的某个行为的代码:

一种写死了的简单实现代码如下: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(); } }; // 随你怎么写...

在我们的输入处理器中我们存放几个指针,指向每个按钮代表的命令: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()等等,假设它们可以找到对应的角色然后对它们执行相应的操作。而这种耦合限制了我们命令的灵活性,事实上我们可以让execute()函数传入一个参数Actor,让想操控的角色来实施相应的命令:class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; };

由此我们可以调用这个actor的方法来实现我们的命令:class JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } };

这样我们就可以让任何角色实现跳跃了,所要做的只是把这个角色作为参数传过去。当然我们还得要把输入控制器的代码修改一下,让它能够返回一个命令: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); }

这样做有什么好处?这让我们的玩家可以任意控制游戏中的任何角色了,而这仅仅加了一层间接。虽然说这样的需求好像并不常见,但是类似这样的需求还是不少的,比如我们如何控制非玩家控制的角色,比如如何让AI来控制游戏中的其它角色?我们同样可以利用命令模式来构建AI引擎与角色之间的控制接口,而AI要做的只是发出命令对象罢了。

发送命令的AI与角色的解耦给予我们的游戏设计极大的灵活性,比如说,如果我们想要把现在的AI换成一个更加强大的AI,我们只要让它生成命令就可以了;甚至我们可以让AI控制我们的主角,这可以用在自动导航或是演示场景中。

撤销与重做

这个例子是这个设计最广为人知的应用,如果某个命令可以做某件事,那么最好也能撤销某件事,这在策略游戏中尤为常见。利用命令模式,撤销这个功能的实现实在是如小菜一碟。

比如说,我们使用如此的命令来表示移动某个游戏单位: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_; };

注意到这与我们此前的命令类稍有不同,首先,我们的命令主体变成了UNIT(单元),这代表游戏中某一个可被移动的单位;其次,与之前可被用来复用的命令不同,这里的命令更为具体(指定了移动的X,Y),由此我们编写这样的函数: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()来作为命令的撤销函数,而它在我们的单元移动函数实现如下: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() { // 记录单元之前的位置以便我们能恢复它。 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这两个变量来储存它之前的位置。

类似的,我们可以引入“重做”命令,只要再将它移到命令需要的那个位置就行了,也就是再执行一次命令嘛。

你可能会问这样做只能撤销一次,我们可以重做多次吗?答案是可以。我们只要维护一个存储原坐标的数组和一个指针就可以了。

一旦发生撤销,我们便将指针左移,并将对应的坐标数据复原即可;重做亦然。