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


前言

本文及以后該系列的篇章都是本人對 《游戲編程模式》這本書的閱讀理解,從中對一些原理,用更直白的語言描述出來,並對部分思路或功能進行初步實現。而本文所描述的 命令模式, 相信讀者應該都有了解過或聽說過,如果尚有疑惑的讀者,我希望本文能對你有所幫助。

命令模式是設計模式中的一種,但該系列所指的編程模式並非是指設計模式,設計模式只是一本分,現在我們先來探討一下命令模式吧。

 

一. 為什么要用命令模式

在我解釋什么是命令模式之前,我們先弄明白為什么要使用命令模式?

相信大家都玩過不少游戲,在游戲中,必不可少的就是游戲與玩家的交互,鍵盤的輸入、鼠標的輸入、手柄的輸入等等,比如常見的這種

 

 我們先簡化一下,使用下面這種

在我們實現類似的功能時,我們的第一想法一般是

 

 在這種情況下,我們很顯然可以發現兩個問題:

  • 現在的游戲大部分都支持用戶(玩家)手動配置按鈕映射,畢竟每個人的習慣不一而至。在這種 情況下,很明顯我們沒辦法更改按鈕映射,所以我們需要一個 中間變量(命令) 來管理按鈕行為。比如,設這個中間變量為 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);

 

查看效果

一樣有效

 

讀者可能會有兩個疑問:

  • 前面我們一直強調命令模式的一大優點是解耦,但在上面的例子中,我們是希望命令和對象是綁定的,這時候的命令看上去更像是對於對象來說,是一件可以去完成的事情。當然,命令模式並不是死板地說必須要解耦,在這種情況下更加凸顯了其靈活性。
  • 上面的例子中,並沒有當進行了撤銷或重做的行為后,再進行 “移動” 或 “改變顏色” 這些操作的情況。如果出現了這些情況,該怎么處理呢?答案是:以當前命令為軸,舍棄之前的(相對於當前命令是舊的)命令,保留之后的(相對於當前命令是新的)命令,然后添加新的命令,更新命令流。這一步並不困難,讀者可自行實現。這里就不再演示了。

 

五. 總結

本文的代碼都是十分簡單且粗糙的,主要是介紹命令模式的應用方法,讀者可以根據自身情況去編寫更完善的代碼。命令模式的確是一個十分高效的模式,筆者在學習了命令模式之后,對於代碼編寫的思維也有了一些感悟。希望本文能對讀者有所幫助。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM