前言
本文及以后該系列的篇章都是本人對 《游戲編程模式》這本書的閱讀理解,從中對一些原理,用更直白的語言描述出來,並對部分思路或功能進行初步實現。而本文所描述的 命令模式, 相信讀者應該都有了解過或聽說過,如果尚有疑惑的讀者,我希望本文能對你有所幫助。
命令模式是設計模式中的一種,但該系列所指的編程模式並非是指設計模式,設計模式只是一本分,現在我們先來探討一下命令模式吧。
一. 為什么要用命令模式
在我解釋什么是命令模式之前,我們先弄明白為什么要使用命令模式?
相信大家都玩過不少游戲,在游戲中,必不可少的就是游戲與玩家的交互,鍵盤的輸入、鼠標的輸入、手柄的輸入等等,比如常見的這種
我們先簡化一下,使用下面這種
在我們實現類似的功能時,我們的第一想法一般是
在這種情況下,我們很顯然可以發現兩個問題:
- 現在的游戲大部分都支持用戶(玩家)手動配置按鈕映射,畢竟每個人的習慣不一而至。在這種 情況下,很明顯我們沒辦法更改按鈕映射,所以我們需要一個 中間變量(命令) 來管理按鈕行為。比如,設這個中間變量為 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);
查看效果
一樣有效
讀者可能會有兩個疑問:
- 前面我們一直強調命令模式的一大優點是解耦,但在上面的例子中,我們是希望命令和對象是綁定的,這時候的命令看上去更像是對於對象來說,是一件可以去完成的事情。當然,命令模式並不是死板地說必須要解耦,在這種情況下更加凸顯了其靈活性。
- 上面的例子中,並沒有當進行了撤銷或重做的行為后,再進行 “移動” 或 “改變顏色” 這些操作的情況。如果出現了這些情況,該怎么處理呢?答案是:以當前命令為軸,舍棄之前的(相對於當前命令是舊的)命令,保留之后的(相對於當前命令是新的)命令,然后添加新的命令,更新命令流。這一步並不困難,讀者可自行實現。這里就不再演示了。
五. 總結
本文的代碼都是十分簡單且粗糙的,主要是介紹命令模式的應用方法,讀者可以根據自身情況去編寫更完善的代碼。命令模式的確是一個十分高效的模式,筆者在學習了命令模式之后,對於代碼編寫的思維也有了一些感悟。希望本文能對讀者有所幫助。