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


    前言

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

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

    一. 为什么要用命令模式

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

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

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

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

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

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

    查看效果

    一样有效

    读者可能会有两个疑问:

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

    五. 总结

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

  • 相关阅读:
    background及background-size
    -ms-,-moz-,-webkit-,-o-含义
    &:first-of-type含义
    ES6的Promise对象
    LightOJ 1029 Civil and Evil Engineer最小生成树和最大生成树
    快速幂模板
    poj2965 The Pilots Brothers' refrigerator 枚举或DFS
    poj1753Flip Game(枚举+DFS)
    POJ 1751Highways
    HDU 1875 畅通工程再续 prim模板题
  • 原文地址:https://www.cnblogs.com/BFXYMY/p/9769789.html
Copyright © 2020-2023  润新知