【编程模式】(一) ------ 命令模式 和 “重做” 及 “撤销”

时间:2024-01-21 22:09:42

前言

本文及以后该系列的篇章都是本人对 《游戏编程模式》这本书的阅读理解,从中对一些原理,用更直白的语言描述出来,并对部分思路或功能进行初步实现。而本文所描述的 命令模式, 相信读者应该都有了解过或听说过,如果尚有疑惑的读者,我希望本文能对你有所帮助。

命令模式是设计模式中的一种,但该系列所指的编程模式并非是指设计模式,设计模式只是一本分,现在我们先来探讨一下命令模式吧。

 

一. 为什么要用命令模式

在我解释什么是命令模式之前,我们先弄明白为什么要使用命令模式?

相信大家都玩过不少游戏,在游戏中,必不可少的就是游戏与玩家的交互,键盘的输入、鼠标的输入、手柄的输入等等,比如常见的这种

 

 我们先简化一下,使用下面这种

在我们实现类似的功能时,我们的第一想法一般是

 

 在这种情况下,我们很显然可以发现两个问题:

  • 现在的游戏大部分都支持用户(玩家)手动配置按钮映射,毕竟每个人的习惯不一而至。在这种 情况下,很明显我们没办法更改按钮映射,所以我们需要一个 中间变量(命令) 来管理按钮行为。比如,设这个中间变量为 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);

 

查看效果

一样有效

 

读者可能会有两个疑问:

  • 前面我们一直强调命令模式的一大优点是解耦,但在上面的例子中,我们是希望命令和对象是绑定的,这时候的命令看上去更像是对于对象来说,是一件可以去完成的事情。当然,命令模式并不是死板地说必须要解耦,在这种情况下更加凸显了其灵活性。
  • 上面的例子中,并没有当进行了撤销或重做的行为后,再进行 “移动” 或 “改变颜色” 这些操作的情况。如果出现了这些情况,该怎么处理呢?答案是:以当前命令为轴,舍弃之前的(相对于当前命令是旧的)命令,保留之后的(相对于当前命令是新的)命令,然后添加新的命令,更新命令流。这一步并不困难,读者可自行实现。这里就不再演示了。

 

五. 总结

本文的代码都是十分简单且粗糙的,主要是介绍命令模式的应用方法,读者可以根据自身情况去编写更完善的代码。命令模式的确是一个十分高效的模式,笔者在学习了命令模式之后,对于代码编写的思维也有了一些感悟。希望本文能对读者有所帮助。