命令模式是游戏开发中很常用的一种模式。如果只看GoF关于该模式的定义会觉得晦涩懂,不知所云。我对命令模式的简单理解就是对象化的方法调用,我们把一个请求方法进行封装,同时在对应的类中保存这个请求方法在被调用时需要的状态,然后我们可以根据自己的需要在任何时刻对这个被封装的请求就行实例化。更简单点,你可以把它理解成回调的面向对象形式。命令模式的核心就2点:1.请求的封装 2.请求的操作
将“请求”封装成对象,让你可以将客户端的不同请求参数化,并配合队列、记录、复原等方法来执行请求的操作。
说明
Command(命令接口)
为所有命令声明了一个接口。调用命令对象的Execute()方法,就可以让接收者进行相关的动作。这个接口也具备一个Undo()方法,支持可撤销的操作
ConcreteCommand(命令实现)
实现了命令的封装,会包含每一个命令的参数和Receiver(功能执行者)。调用者只需要调用Execute()就可以发出请求,然后由ConcreteCommand调用接收者的一个或多个动作。
Receiver(功能执行者)
被封装在ConcreteCommand(命令实现)类中,真正执行功能的类对象。知道如何进行必要的工作,实现这个请求。任何类都可以当做接收者。
Client(客户端/命令发起者)
负责创建一个ConcreteCommand,可以视情况设置命令给Receiver(功能执行者)。
Invoker(命令管理者)
命令对象的管理容器或是管理类,并负责要求每个Command(命令)执行其功能。可以视情况在某个需要的时间点执行命令的功能。
我们以前常玩的合金弹头游戏,玩家通过按键控制游戏角色做出各种操作。比如
1.按下A键跳跃躲过障碍物和敌人 2.按下S键向敌人发射子弹 3.按下D键切换武器
针对这三种操作,我们可以抽象出3种具体的命令实现:
// 命令接口 export interface Command { // actor为Receiver,由Client进行设置。可以在命令创建时设置保存在Command类中 // 也可以根据情况在命令调用时传入 Execute(actor: GameActor); Undo(); } // 实现跳跃命令 export class JumpCommand implements Command { Execute(actor: GameActor) { actor.Jump(); } Undo(){ } } // 实现开火命令 export class FireGunCommand implements Command { Execute(actor: GameActor) { actor.FireGun(); } Undo(){ } } // 实现切换武器命令 export class SwapWeaponCommand implements Command { Execute(actor: GameActor) { actor.SwapWeapon(); } Undo(){ } }
玩家的行为有跳跃,开火,切换武器。这里的玩家即为Receiver(功能执行者),负责命令被调用时所要展现行为的具体。这是我们实现了一个GameActor(玩家角色类):
// 玩家角色类,相当于Receiver(功能执行者) export class GameActor { Jump() { console.log("玩家跳动"); } FireGun() { console.log("玩家开火"); } SwapWeapon() { console.log("切换武器"); } }
最后我们实现我们产生命令和执行命令的类(Client, Invoker)
// 相当于Client(命令产生者)和Invoker(命令管理者) @ccclass('InputHandler') export class InputHandler extends Component { private jumpCommand: Command | null = null; private fireGunCommand: Command | null = null; private swapWeaponCommand: Command | null = null; private curCommand: Command | null = null; private actor: GameActor | null = null; onl oad () { systemEvent.on(SystemEvent.EventType.KEY_DOWN, this.handleInput, this); // 将现有的命令实例化保存 this.jumpCommand = new JumpCommand(); this.fireGunCommand = new FireGunCommand(); this.swapWeaponCommand = new SwapWeaponCommand(); // 玩家角色类,相当于Receiver(功能执行者)。可以在功能执行时传入,也可以在命令创建时进行设置 this.actor = new GameActor(); } onDestroy () { systemEvent.off(SystemEvent.EventType.KEY_DOWN, this.handleInput, this); } // 通过玩家的按键输入设置对应的命令实例 handleInput (event: EventKeyboard) { if (event.keyCode == KeyCode.KEY_A) { this.curCommand = this.jumpCommand; } else if (event.keyCode == KeyCode.KEY_S) { this.curCommand = this.fireGunCommand; } else if (event.keyCode == KeyCode.KEY_D) { this.curCommand = this.swapWeaponCommand; } if (this.curCommand != null && this.actor != null) { this.curCommand.Execute(this.actor); } } }
上面我们使用命令模式解决了玩家操作和行为的深度耦合问题。这样实现是不是看上去代码逻辑更清晰,更易于扩展和为维护。这样做的好处不不仅于此,如果我们游戏中需要加入AI,那么我们可以把AI能够执行的命令进行封装,然后AI代码只需要负责产生命令,然后在合适的时间执行就可以了。如果AI和真实玩家能够实现的命令完全相同,那么完全就可以复用一套相同的命令类。
选择命令的AI和表现玩家的代码之间的解耦为我们提供了很大的灵活性。我们可以对不同的角色使用不同的AI模块。或者我们可以针对不同种类的行为将AI进行混搭。你想要一个更加具有侵略性的敌人?只需要插入一段更具侵略性的AI代码来为它生成命令。事实上,我们甚至可以将AI使用到玩家的角色身上,这对于实现游戏演示功能很有用。
将控制角色的命令作为对象进行封装,我们便解除了函数直接调用这样的紧耦合。我们可以实现一个队列,把产生的命令放入队列中,然后在帧循环中取出队列中的命令去执行。如下图所示
我们还可以把玩家的操作命令参数化,通过网络发送到另一个客户端,这样就行实现玩家行为的同步和回放功能。如下图所示
讲到这里是不是感觉命令模式很有用。不过还不算完,在上面的类图定义中我们还有一个Undo操作,上面也说了命令模式可以支持撤销的操作。如果一个命令对象可以执行(Execute)一些行为,那么就应该可以很轻松的撤销(Undo)它们。撤销和重做是命令模式的成名应用了,在一些策略类的游戏中经常可以看到撤销这个行为,保证玩家可以回滚一些不满意的步骤。一些游戏需要用到的关卡编辑器也需要命令模式来帮助我们实现撤销命令。
有了命令模式,我们实现撤销简直就是小菜一碟。假定我们制作了一款单人回合制的游戏,我们想让玩家能够撤销一些行动以便他们能够更多的专注于策略而不是猜测。我们可以实现一个移动的命令类,在命令类中保存上次的位置状态,这样我们在执行Undo时就可以还原到上次的状态了。
export class MoveCommand implements Command { private _x: number; private _y: number; private _beforeX: number; private _beforeY: number; private _actor: GameActor; // 实例化时设置Receiver和需要的状态 constructor(actor: GameActor, x: number, y: number) { this._actor = actor; this._x = x; this._y = y; this._beforeX = 0; this._beforeY = 0; } // 执行命令行为前先把当前状态保存 Execute() { this._beforeX = this._actor.posX(); this._beforeY = this._actor.posY(); this._actor.moveTo(this._x, this._y); } // 使用命令保存的上次状态执行撤销操作 Undo(){ this._actor.moveTo(this._beforeX, this._beforeY); } }
我们可以这样实现产生命令的方法
handleInput (event: EventKeyboard): Command { if (event.keyCode == KeyCode.KEY_A) { let destX = this.actor.posX() - 10; return new MoveCommand(this.actor, destX, this.actor.posY()); } else if (event.keyCode == KeyCode.KEY_D) { let destX = this.actor.posX() + 10; return new MoveCommand(this.actor, destX, this.actor.posY()); } }
产生的命令我们可以把它放到一个栈中,当我们敲击Control-Z时,就可以把栈顶的命令弹出,然后执行当前栈顶命令的Undo。
如果我们不仅要满足撤销功能,还要满足重做时,可以针对命令实现一个Redo()方法。把命令放到一个队列中,维护一个当前命令的引用,如果撤销,就把当前命令的应用指向前面一个命令,如果要重做,就把当前命令指向后面一个应用,如果撤销后选择了新命令,那么列表中当前命令后面的所有命令都舍弃掉。 如图所示:
参考:Command · Design Patterns Revisited · Game Programming Patterns