【编程模式】(一) ------ 命令模式 和 “重做” 及 “撤销”
前言
本文及以后该系列的篇章都是本人对 《游戏编程模式》这本书的阅读理解,从中对一些原理,用更直白的语言描述出来,并对部分思路或功能进行初步实现。而本文所描述的 命令模式, 相信读者应该都有了解过或听说过,如果尚有疑惑的读者,我希望本文能对你有所帮助。
命令模式是设计模式中的一种,但该系列所指的编程模式并非是指设计模式,设计模式只是一本分,现在我们先来探讨一下命令模式吧。
一. 为什么要用命令模式
在我解释什么是命令模式之前,我们先弄明白为什么要使用命令模式?
相信大家都玩过不少游戏,在游戏中,必不可少的就是游戏与玩家的交互,键盘的输入、鼠标的输入、手柄的输入等等,比如常见的这种
我们先简化一下,使用下面这种
在我们实现类似的功能时,我们的第一想法一般是
在这种情况下,我们很显然可以发现两个问题:
- 现在的游戏大部分都支持用户(玩家)手动配置按钮映射,毕竟每个人的习惯不一而至。在这种 情况下,很明显我们没办法更改按钮映射,所以我们需要一个 中间变量(命令) 来管理按钮行为。比如,设这个中间变量为 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++的代码来说明思路:
先定义一个命令的基类
1 class Command 2 { 3 public: 4 virtual ~Command(){} 5 virtual void execute(GameActor& actor)(){} 6 }
然后给角色实现跳跃行为,定义一个跳跃命令类
1 class JumpCommond : public Command 2 { 3 public: 4 JumpCommond(); 5 ~JumpCommond(); 6 virtual void execute(GameActor& actor) 7 { 8 actor.Jump(); 9 } 10 };
根据不同的按钮,返回不同的命令,然后根据返回的命令,传入适当的对象,执行命令
1 Command* command = InputManager(); 2 if(command) 3 { 4 command->execute(actor); 5 }
这样大概就是一个基于命令模式的按钮映射流程。
四. 撤销与重做
撤销与重做是我们再常见不过的一个功能,如果我们不了解命令模式,我们会怎样实现这个功能?把每个步骤的前后状态保存成一个对象或者数据?通过覆盖该对象(数据)来实现前后状态的转换?这种对象(数据)该如何定义?又该如何存储?相信我们会被这些问题搞得头痛不已。
而撤销与重做则是命令模式的一个经典应用。对于任一个单独的命令来说,做(do)是可以实现的,那么 不做(undo) 理应也是可以实现的。以命令模式为基础,对方法进行封装,通过对 Do 和 Undo 的执行,使得对象在不同状态间进行切换,就是常见的撤销与重做功能。
以经典的位置移动为例:
定义命令
1 class Command 2 { 3 public: 4 virtual ~Command(){} 5 virtual void execute(GameActor& actor) = 0; 6 virtual void undo() = 0; 7 }
定义移动命令
1 class MoveUnitCommond : public Command 2 { 3 public: 4 MoveUnitCommond(Unit* unit,int x,int y) : unit_(unit),x_(x),y_(y),beforeX(0),beforeY(0) 5 { 6 7 } 8 ~ MoveUnitCommond(); 9 virtual void execute() 10 { 11 beforeX = unit_->x(); 12 beforeY = unit_->y(); 13 unit_->move(x_,y_); 14 } 15 virtual void undo() 16 { 17 unit_->move(beforeX,beforeY); 18 } 19 private: 20 Unit* unit_; 21 int x_; 22 int y_; 23 int beforeX; 24 int beforeY; 25 };
其中,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. 游戏角色类,这里我并不需要什么属性,所以这里是个空类,读者可以自行定义
1 using System.Collections; 2 using System.Collections.Generic; 3 using UnityEngine; 4 5 public class GameActor : MonoBehaviour 6 { 7 8 }
2.命令类
先定义基类
1 public class Commond 2 { 3 public virtual void execute() { } 4 public virtual void undo() { } 5 }
在此基础上,定义一个移动命令类
1 public class MoveCommond : Commond 2 { 3 private float _x; 4 private float _y; 5 private float _z; 6 7 private float _beforeX; 8 private float _beforeY; 9 private float _beforeZ; 10 11 private GameActor gameActor; 12 13 public MoveCommond(GameActor GA,int x,int y, int z) 14 { 15 _x = x; 16 _y = y; 17 _z = z; 18 _beforeX = 0; 19 _beforeY = 0; 20 _beforeZ = 0; 21 gameActor = GA; 22 } 23 24 public override void execute() 25 { 26 _beforeX = gameActor.transform.position.x; 27 _beforeY = gameActor.transform.position.y; 28 _beforeZ = gameActor.transform.position.z; 29 30 gameActor.transform.position = new Vector3(_beforeX + _x, _beforeY + _y, _beforeZ + _z); 31 base.execute(); 32 } 33 34 public override void undo() 35 { 36 gameActor.transform.position = new Vector3(_beforeX , _beforeY , _beforeZ); 37 base.undo(); 38 } 39 }
代码的作用和前文所说的几乎一致
3. 定义一个命令管理类
先定义一个 List 来存储命令,并对我们所需要的元素初始化
1 private List<Commond> CommondList = new List<Commond>(); 2 private GameActor gameActor; 3 private Commond commond = new Commond(); 4 private int index; 5 private Button Backward; 6 private Button Forward; 7 8 private void Start() 9 { 10 gameActor = GameObject.Find("Capsule").GetComponent<GameActor>(); 11 Backward = GameObject.Find("Canvas/Backward").GetComponent<Button>(); 12 Forward = GameObject.Find("Canvas/Forward").GetComponent<Button>(); 13 Backward.onClick.AddListener(UnDo); 14 Forward.onClick.AddListener(ReDo); 15 index = 0; 16 }
对键盘输入进行监听
1 Commond handleInput() 2 { 3 4 if (Input.GetKeyDown(KeyCode.W)) 5 return new MoveCommond(gameActor, 0, 0, 5); 6 7 if (Input.GetKeyDown(KeyCode.A)) 8 return new MoveCommond(gameActor, -5, 0, 0); 9 10 if (Input.GetKeyDown(KeyCode.S)) 11 return new MoveCommond(gameActor, 0, 0, -5); 12 13 if (Input.GetKeyDown(KeyCode.D)) 14 return new MoveCommond(gameActor, 5, 0, 0); 15 16 if (Input.GetKeyDown(KeyCode.J)) 17 return new ColorChangeCommond(gameActor, Color.blue); 18 19 if (Input.GetKeyDown(KeyCode.K)) 20 return new ColorChangeCommond(gameActor, Color.red); 21 22 return null; 23 }
接收返回的命令并进行存储,当命令产生且不为空时,则需执行它的 “Do” 方法
1 void Update () 2 { 3 if(Input.anyKeyDown) 4 { 5 Commond newAction = handleInput(); 6 if(newAction != null) 7 { 8 newAction.execute(); 9 CommondList.Add(newAction); 10 index = CommondList.Count - 1; 11 } 12 } 13 }
最后便是撤销和重做函数了,这里需要注意的是边界问题。我使用的是 List,读者可以选择其它的数据结构。
1 public void ReDo() 2 { 3 if(index < CommondList.Count) index++; 4 if (index == CommondList.Count) return; 5 Debug.LogFormat("count:{0}", index); 6 commond = CommondList[index]; 7 commond.execute(); 8 } 9 10 public void UnDo() 11 { 12 if (index == CommondList.Count) index--; 13 if (index < 0) return; 14 Debug.LogFormat("count:{0}", index); 15 commond = CommondList[index]; 16 commond.undo(); 17 index--; 18 }
实验一下效果:
同样的,在项目中,我们只需要添加不同的命令,就可以实现不同的操作的撤销与重做。这里我们同样添加一个改变颜色的操作。
定义改变颜色的命令
1 public class ColorChangeCommond : Commond 2 { 3 private Color newColor; 4 private Color oldColor; 5 private GameActor gameActor; 6 7 public ColorChangeCommond(GameActor GA,Color color) 8 { 9 gameActor = GA; 10 oldColor = GA.GetComponent<MeshRenderer>().material.color; 11 newColor = color; 12 } 13 14 public override void execute() 15 { 16 gameActor.GetComponent<MeshRenderer>().material.color = newColor; 17 base.execute(); 18 } 19 20 public override void undo() 21 { 22 gameActor.GetComponent<MeshRenderer>().material.color = oldColor; 23 base.undo(); 24 } 25 }
相应的对键盘做监听
1 if (Input.GetKeyDown(KeyCode.J)) 2 return new ColorChangeCommond(gameActor, Color.blue); 3 4 if (Input.GetKeyDown(KeyCode.K)) 5 return new ColorChangeCommond(gameActor, Color.red);
查看效果
一样有效
读者可能会有两个疑问:
- 前面我们一直强调命令模式的一大优点是解耦,但在上面的例子中,我们是希望命令和对象是绑定的,这时候的命令看上去更像是对于对象来说,是一件可以去完成的事情。当然,命令模式并不是死板地说必须要解耦,在这种情况下更加凸显了其灵活性。
- 上面的例子中,并没有当进行了撤销或重做的行为后,再进行 “移动” 或 “改变颜色” 这些操作的情况。如果出现了这些情况,该怎么处理呢?答案是:以当前命令为轴,舍弃之前的(相对于当前命令是旧的)命令,保留之后的(相对于当前命令是新的)命令,然后添加新的命令,更新命令流。这一步并不困难,读者可自行实现。这里就不再演示了。
五. 总结
本文的代码都是十分简单且粗糙的,主要是介绍命令模式的应用方法,读者可以根据自身情况去编写更完善的代码。命令模式的确是一个十分高效的模式,笔者在学习了命令模式之后,对于代码编写的思维也有了一些感悟。希望本文能对读者有所帮助。