zoukankan      html  css  js  c++  java
  • 【编程模式】(一) ------ 命令模式 和 “重做” 及 “撤销”

    前言

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

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

    一. 为什么要用命令模式

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

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

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

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

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

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

    查看效果

    一样有效

    读者可能会有两个疑问:

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

    五. 总结

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

  • 相关阅读:
    LeetCode OJ 107. Binary Tree Level Order Traversal II
    LeetCode OJ 116. Populating Next Right Pointers in Each Node
    LeetCode OJ 108. Convert Sorted Array to Binary Search Tree
    LeetCode OJ 105. Construct Binary Tree from Preorder and Inorder Traversal
    LeetCode OJ 98. Validate Binary Search Tree
    老程序员解Bug的通用套路
    转载 四年努力,梦归阿里,和大家聊聊成长感悟
    转载面试感悟----一名3年工作经验的程序员应该具备的技能
    Web Service和Servlet的区别
    关于spring xml文件中的xmlns,xsi:schemaLocation
  • 原文地址:https://www.cnblogs.com/BFXYMY/p/9769789.html
Copyright © 2011-2022 走看看