目录
前言
本文及以后该系列的篇章都是本人对 《游戏编程模式》这本书的阅读理解,从中对一些原理,用更直白的语言描述出来,并对部分思路或功能进行初步实现。而本文所描述的 命令模式, 相信读者应该都有了解过或听说过,如果尚有疑惑的读者,我希望本文能对你有所帮助。
命令模式是设计模式中的一种,但该系列所指的编程模式并非是指设计模式,设计模式只是一本分,现在我们先来探讨一下命令模式吧。
一. 为什么要用命令模式
在我解释什么是命令模式之前,我们先弄明白为什么要使用命令模式?
相信大家都玩过不少游戏,在游戏中,必不可少的就是游戏与玩家的交互,键盘的输入、鼠标的输入、手柄的输入等等,比如常见的这种
我们先简化一下,使用下面这种
在我们实现类似的功能时,我们的第一想法一般是
在这种情况下,我们很显然可以发现两个问题:
- 现在的游戏大部分都支持用户(玩家)手动配置按钮映射,毕竟每个人的习惯不一而至。在这种 情况下,很明显我们没办法更改按钮映射,所以我们需要一个 中间变量(命令) 来管理按钮行为。比如,设这个中间变量为 Temp ,默认情况下按下A键后,生成一个 Temp , Temp 会索引到 Attack(),然后执行;现在我们更改按钮配置,改为按下B键,生成同样的 Temp。同样执行 Attack()。这样,通过增加一层间接调用层,我们就可以实现命令的分配。
- 上述的 Attack() ,Jump(),这种顶级函数,我们一般都会默认是对游戏主角进行操作,也就是说这种情况下一条命令对应着一条对主角操作信息,这样,命令的使用范围就会被限制,而如果我们向这条命令传进一个对象,就可以实现类似 对象.Jump() 。可以明确的是,当游戏玩家和NPC(AI)执行同一种动作时,如 Attack(),即便他们的具体实现不一定相同,但我只需要同一条命令,传入不同的对象即可。
针对这两个问题,我们会发现,采用命令模式去处理按钮与行为之间的映射会更加的方便与高效。
二. 什么是命令模式
说了这么久,我们该说说这个所谓的命令模式究竟是个什么东西吧?
- 介绍:请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。
- 目的:将一个请求封装成一个对象,从而可以用不同的请求对客户进行参数化。简洁一点,就相当于:我构建出一个 AttackCommond 类,这个类里面封装了角色进行攻击的函数;现在我把这个类实例化出来,然后通过实例化出的对象来调用其中的函数。
- 主要解决:行为的请求者与实现者通常是紧耦合关系,在需要进行 “记录” 的场合下比如 “撤销与重组”,这种紧耦合关系就会不适用,所以我们需要进行解耦。
- 优点:1、降低了系统耦合度。 2、新的命令可以很容易添加到系统中去。
- 缺点:使用命令模式可能会导致某些系统有过多的具体命令类。
我们可以使用命令模式来作为 AI 引擎和角色(NPC)之间的接口,对不同的角色可以提供不同的命令;同样的,我们也可以把这些 AI 命令使用到玩家角色上,这就是大家都十分熟悉的演示模式(Demo Mode),即游戏中我们常见的自动战斗。想象一下,其实无论是玩家角色还是NPC,都是执行一样的命令,普通攻击 -> 满足一定条件后释放技能。所以我们可以使用同样的命令,分别传入玩家和NPC的对象,就可以初步实现这个功能。
三. 部分思路代码实现
我们先用C++的代码来说明思路:
先定义一个命令的基类
class Command { public: virtual ~Command(){} virtual void execute(GameActor& actor)(){} }
然后给角色实现跳跃行为,定义一个跳跃命令类
class JumpCommond : public Command { public: JumpCommond(); ~JumpCommond(); virtual void execute(GameActor& actor) { actor.Jump(); } };
现在我们需要对用户输入进行监听
Command* InputManager() { if (isPress("A")) return "命令A"; if (isPress("B")) return "命令B"; if (isPress("C")) return "命令C"; if (isPress("D")) return "命令D"; return NULL; }
根据不同的按钮,返回不同的命令,然后根据返回的命令,传入适当的对象,执行命令
Command* command = InputManager(); if(command) { command->execute(actor); }
这样大概就是一个基于命令模式的按钮映射流程。
四. 撤销与重做
撤销与重做是我们再常见不过的一个功能,如果我们不了解命令模式,我们会怎样实现这个功能?把每个步骤的前后状态保存成一个对象或者数据?通过覆盖该对象(数据)来实现前后状态的转换?这种对象(数据)该如何定义?又该如何存储?相信我们会被这些问题搞得头痛不已。
而撤销与重做则是命令模式的一个经典应用。对于任一个单独的命令来说,做(do)是可以实现的,那么 不做(undo) 理应也是可以实现的。以命令模式为基础,对方法进行封装,通过对 Do 和 Undo 的执行,使得对象在不同状态间进行切换,就是常见的撤销与重做功能。
以经典的位置移动为例:
定义命令
class Command { public: virtual ~Command(){} virtual void execute(GameActor& actor) = 0; virtual void undo() = 0; }
定义移动命令
class MoveUnitCommond : public Command { public: MoveUnitCommond(Unit* unit,int x,int y) : unit_(unit),x_(x),y_(y),beforeX(0),beforeY(0) { } ~ MoveUnitCommond(); virtual void execute() { beforeX = unit_->x(); beforeY = unit_->y(); unit_->move(x_,y_); } virtual void undo() { unit_->move(beforeX,beforeY); } private: Unit* unit_; int x_; int y_; int beforeX; int beforeY; };
其中,unit 为移动单位,beforeX,beforeY用来记录单位移动前的位置信息,执行 undo 时,即相当于把 unit 移动至原来的位置
以下面例子做说明,物体从 A 移动到 B,再从 B 移动到 C
这个过程物体执行了两个命令
命令1 命令2 Do 从A移动到B 从B移动到C Undo 从B移回到A 从C移回到B 我们应该用一个栈或链表来存储这些命令,并且提供一个指针或引用,来明确指向 “当前” 命令。要注意的是,边界问题。
当物体处于C位置时,此物体理应可以执行 Undo ,但不可以执行 Do 方法,因为此时物体已经执行过了一次命令2的 Do 方法,当前指针指向命令2,且命令2后没有新的命令,即 “Do 已经到了尽头”;同理,当物体处于 A 时,同样不可以执行 Undo 方法。读者要十分注意这个问题,不要混淆。
为了更直观地体验到命令模式实现的撤销与重做,我用 Unity 做了个演示,熟悉 Unity 的读者可以动手实现一下。
I. 创建一个 Capsule 作为主角;创建两个 Button 作为前进后退按键
II. 创建三个类
1. 游戏角色类,这里我并不需要什么属性,所以这里是个空类,读者可以自行定义
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameActor : MonoBehaviour { }
2.命令类
先定义基类
public class Commond { public virtual void execute() { } public virtual void undo() { } }
在此基础上,定义一个移动命令类
public class MoveCommond : Commond { private float _x; private float _y; private float _z; private float _beforeX; private float _beforeY; private float _beforeZ; private GameActor gameActor; public MoveCommond(GameActor GA,int x,int y, int z) { _x = x; _y = y; _z = z; _beforeX = 0; _beforeY = 0; _beforeZ = 0; gameActor = GA; } public override void execute() { _beforeX = gameActor.transform.position.x; _beforeY = gameActor.transform.position.y; _beforeZ = gameActor.transform.position.z; gameActor.transform.position = new Vector3(_beforeX + _x, _beforeY + _y, _beforeZ + _z); base.execute(); } public override void undo() { gameActor.transform.position = new Vector3(_beforeX , _beforeY , _beforeZ); base.undo(); } }
代码的作用和前文所说的几乎一致
3. 定义一个命令管理类
先定义一个 List 来存储命令,并对我们所需要的元素初始化
private List<Commond> CommondList = new List<Commond>(); private GameActor gameActor; private Commond commond = new Commond(); private int index; private Button Backward; private Button Forward; private void Start() { gameActor = GameObject.Find("Capsule").GetComponent<GameActor>(); Backward = GameObject.Find("Canvas/Backward").GetComponent<Button>(); Forward = GameObject.Find("Canvas/Forward").GetComponent<Button>(); Backward.onClick.AddListener(UnDo); Forward.onClick.AddListener(ReDo); index = 0; }
对键盘输入进行监听
Commond handleInput() { if (Input.GetKeyDown(KeyCode.W)) return new MoveCommond(gameActor, 0, 0, 5); if (Input.GetKeyDown(KeyCode.A)) return new MoveCommond(gameActor, -5, 0, 0); if (Input.GetKeyDown(KeyCode.S)) return new MoveCommond(gameActor, 0, 0, -5); if (Input.GetKeyDown(KeyCode.D)) return new MoveCommond(gameActor, 5, 0, 0); if (Input.GetKeyDown(KeyCode.J)) return new ColorChangeCommond(gameActor, Color.blue); if (Input.GetKeyDown(KeyCode.K)) return new ColorChangeCommond(gameActor, Color.red); return null; }
接收这些返回的命令并进行存储,当命令产生且不为空时,则需执行它的 “Do” 方法
void Update () { if(Input.anyKeyDown) { Commond newAction = handleInput(); if(newAction != null) { newAction.execute(); CommondList.Add(newAction); index = CommondList.Count - 1; } } }
最后便是撤销和重做函数了,这里需要注意的是边界问题。我使用的是 List,读者可以选择其它的数据结构。
public void ReDo() { if(index < CommondList.Count) index++; if (index == CommondList.Count) return; Debug.LogFormat("count:{0}", index); commond = CommondList[index]; commond.execute(); } public void UnDo() { if (index == CommondList.Count) index--; if (index < 0) return; Debug.LogFormat("count:{0}", index); commond = CommondList[index]; commond.undo(); index--; }
实验一下效果:
同样的,在项目中,我们只需要添加不同的命令,就可以实现不同的操作的撤销与重做。这里我们同样添加一个改变颜色的操作。
定义改变颜色的命令
public class ColorChangeCommond : Commond { private Color newColor; private Color oldColor; private GameActor gameActor; public ColorChangeCommond(GameActor GA,Color color) { gameActor = GA; oldColor = GA.GetComponent<MeshRenderer>().material.color; newColor = color; } public override void execute() { gameActor.GetComponent<MeshRenderer>().material.color = newColor; base.execute(); } public override void undo() { gameActor.GetComponent<MeshRenderer>().material.color = oldColor; base.undo(); } }
相应的对键盘监听
if (Input.GetKeyDown(KeyCode.J)) return new ColorChangeCommond(gameActor, Color.blue); if (Input.GetKeyDown(KeyCode.K)) return new ColorChangeCommond(gameActor, Color.red);
查看效果
一样有效
读者可能会有两个疑问:
- 前面我们一直强调命令模式的一大优点是解耦,但在上面的例子中,我们是希望命令和对象是绑定的,这时候的命令看上去更像是对于对象来说,是一件可以去完成的事情。当然,命令模式并不是死板地说必须要解耦,在这种情况下更加凸显了其灵活性。
- 上面的例子中,并没有当进行了撤销或重做的行为后,再进行 “移动” 或 “改变颜色” 这些操作的情况。如果出现了这些情况,该怎么处理呢?答案是:以当前命令为轴,舍弃之前的(相对于当前命令是旧的)命令,保留之后的(相对于当前命令是新的)命令,然后添加新的命令,更新命令流。这一步并不困难,读者可自行实现。这里就不再演示了。
五. 总结
本文的代码都是十分简单且粗糙的,主要是介绍命令模式的应用方法,读者可以根据自身情况去编写更完善的代码。命令模式的确是一个十分高效的模式,笔者在学习了命令模式之后,对于代码编写的思维也有了一些感悟。希望本文能对读者有所帮助。