【程式設計模式】(一) ------ 命令模式 和 “重做” 及 “撤銷”
前言
本文及以後該系列的篇章都是本人對 《遊戲程式設計模式》這本書的閱讀理解,從中對一些原理,用更直白的語言描述出來,並對部分思路或功能進行初步實現。而本文所描述的 命令模式 , 相信讀者應該都有了解過或聽說過,如果尚有疑惑的讀者,我希望本文能對你有所幫助。
命令模式是設計模式中的一種,但該系列所指的程式設計模式並非是指設計模式,設計模式只是一本分,現在我們先來探討一下命令模式吧。
一. 為什麼要用命令模式
在我解釋什麼是命令模式之前,我們先弄明白為什麼要使用命令模式?
相信大家都玩過不少遊戲,在遊戲中,必不可少的就是遊戲與玩家的互動,鍵盤的輸入、滑鼠的輸入、手柄的輸入等等,比如常見的這種
我們先簡化一下,使用下面這種
在我們實現類似的功能時,我們的第一想法一般是
在這種情況下,我們很顯然可以發現兩個問題:
- 現在的遊戲大部分都支援使用者(玩家)手動配置按鈕對映,畢竟每個人的習慣不一而至。在這種 情況下,很明顯我們沒辦法更改按鈕對映,所以我們需要一個 中間變數(命令) 來管理按鈕行為。比如,設這個中間變數為 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: 4virtual ~Command(){} 5virtual void execute(GameActor& actor)(){} 6 }
然後給角色實現跳躍行為,定義一個跳躍命令類
1 class JumpCommond : public Command 2 { 3 public: 4JumpCommond(); 5~JumpCommond(); 6virtual void execute(GameActor& actor) 7{ 8actor.Jump(); 9} 10 };
根據不同的按鈕,返回不同的命令,然後根據返回的命令,傳入適當的物件,執行命令
1 Command* command = InputManager(); 2 if(command) 3 { 4command->execute(actor); 5 }
這樣大概就是一個基於命令模式的按鈕對映流程。
四. 撤銷與重做
撤銷與重做是我們再常見不過的一個功能,如果我們不瞭解命令模式,我們會怎樣實現這個功能?把每個步驟的前後狀態儲存成一個物件或者資料?通過覆蓋該物件(資料)來實現前後狀態的轉換?這種物件(資料)該如何定義?又該如何儲存?相信我們會被這些問題搞得頭痛不已。
而撤銷與重做則是命令模式的一個經典應用。對於任一個單獨的命令來說, 做(do) 是可以實現的,那麼 不做(undo) 理應也是可以實現的。 以命令模式為基礎,對方法進行封裝,通過對 Do 和 Undo 的執行,使得物件在不同狀態間進行切換,就是常見的撤銷與重做功能。
以經典的位置移動為例:
定義命令
1 class Command 2 { 3 public: 4virtual ~Command(){} 5virtual void execute(GameActor& actor) = 0; 6virtual void undo() = 0; 7 }
定義移動命令
1 classMoveUnitCommond : public Command 2 { 3 public: 4MoveUnitCommond(Unit* unit,int x,int y) : unit_(unit),x_(x),y_(y),beforeX(0),beforeY(0) 5{ 6 7} 8~ MoveUnitCommond(); 9virtual void execute() 10{ 11beforeX = unit_->x(); 12beforeY = unit_->y(); 13unit_->move(x_,y_); 14} 15virtual void undo() 16{ 17unit_->move(beforeX,beforeY); 18} 19 private: 20Unit* unit_; 21int x_; 22int y_; 23int beforeX; 24int 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 { 3public virtual void execute() {} 4public virtual void undo() {} 5 }
在此基礎上,定義一個移動命令類
1 public class MoveCommond : Commond 2 { 3private float _x; 4private float _y; 5private float _z; 6 7private float _beforeX; 8private float _beforeY; 9private float _beforeZ; 10 11private GameActor gameActor; 12 13public 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; 21gameActor = GA; 22} 23 24public override void execute() 25{ 26_beforeX = gameActor.transform.position.x; 27_beforeY = gameActor.transform.position.y; 28_beforeZ = gameActor.transform.position.z; 29 30gameActor.transform.position = new Vector3(_beforeX + _x, _beforeY + _y, _beforeZ + _z); 31base.execute(); 32} 33 34public override void undo() 35{ 36gameActor.transform.position = new Vector3(_beforeX , _beforeY , _beforeZ); 37base.undo(); 38} 39 }
程式碼的作用和前文所說的幾乎一致
3. 定義一個命令管理類
先定義一個 List 來儲存命令,並對我們所需要的元素初始化
1private List<Commond> CommondList = new List<Commond>(); 2private GameActor gameActor; 3private Commond commond = new Commond(); 4private int index; 5private Button Backward; 6private Button Forward; 7 8private void Start() 9{ 10gameActor = GameObject.Find("Capsule").GetComponent<GameActor>(); 11Backward = GameObject.Find("Canvas/Backward").GetComponent<Button>(); 12Forward = GameObject.Find("Canvas/Forward").GetComponent<Button>(); 13Backward.onClick.AddListener(UnDo); 14Forward.onClick.AddListener(ReDo); 15index = 0; 16}
對鍵盤輸入進行監聽
1Commond handleInput() 2{ 3 4if (Input.GetKeyDown(KeyCode.W)) 5return new MoveCommond(gameActor, 0, 0, 5); 6 7if (Input.GetKeyDown(KeyCode.A)) 8return new MoveCommond(gameActor, -5, 0, 0); 9 10if (Input.GetKeyDown(KeyCode.S)) 11return new MoveCommond(gameActor, 0, 0, -5); 12 13if (Input.GetKeyDown(KeyCode.D)) 14return new MoveCommond(gameActor, 5, 0, 0); 15 16if (Input.GetKeyDown(KeyCode.J)) 17return new ColorChangeCommond(gameActor, Color.blue); 18 19if (Input.GetKeyDown(KeyCode.K)) 20return new ColorChangeCommond(gameActor, Color.red); 21 22return null; 23}
接收返回的命令並進行儲存,當命令產生且不為空時,則需執行它的 “Do” 方法
1void Update () 2{ 3if(Input.anyKeyDown) 4{ 5Commond newAction = handleInput(); 6if(newAction != null) 7{ 8newAction.execute(); 9CommondList.Add(newAction); 10index = CommondList.Count - 1; 11} 12} 13}
最後便是撤銷和重做函數了,這裡需要注意的是邊界問題。我使用的是 List,讀者可以選擇其它的資料結構。
1public void ReDo() 2{ 3if(index < CommondList.Count) index++; 4if (index == CommondList.Count) return; 5Debug.LogFormat("count:{0}", index); 6commond = CommondList[index]; 7commond.execute(); 8} 9 10public void UnDo() 11{ 12if (index == CommondList.Count) index--; 13if (index < 0) return; 14Debug.LogFormat("count:{0}", index); 15commond = CommondList[index]; 16commond.undo(); 17index--; 18}
實驗一下效果:
同樣的,在專案中,我們只需要新增不同的命令,就可以實現不同的操作的撤銷與重做。這裡我們同樣新增一個改變顏色的操作。
定義改變顏色的命令
1 public class ColorChangeCommond : Commond 2 { 3private Color newColor; 4private Color oldColor; 5private GameActor gameActor; 6 7public ColorChangeCommond(GameActor GA,Color color) 8{ 9gameActor = GA; 10oldColor = GA.GetComponent<MeshRenderer>().material.color; 11newColor = color; 12} 13 14public override void execute() 15{ 16gameActor.GetComponent<MeshRenderer>().material.color = newColor; 17base.execute(); 18} 19 20public override void undo() 21{ 22gameActor.GetComponent<MeshRenderer>().material.color = oldColor; 23base.undo(); 24} 25 }
相應的對鍵盤做監聽
1if (Input.GetKeyDown(KeyCode.J)) 2return new ColorChangeCommond(gameActor, Color.blue); 3 4if (Input.GetKeyDown(KeyCode.K)) 5return new ColorChangeCommond(gameActor, Color.red);
檢視效果
一樣有效
讀者可能會有兩個疑問:
- 前面我們一直強調命令模式的一大優點是解耦,但在上面的例子中,我們是 希望命令和物件是繫結的 ,這時候的 命令看上去更像是對於物件來說,是一件可以去完成的事情。 當然,命令模式並不是死板地說必須要解耦,在這種情況下更加凸顯了其靈活性。
- 上面的例子中,並沒有當進行了撤銷或重做的行為後,再進行 “移動” 或 “改變顏色” 這些操作的情況。如果出現了這些情況,該怎麼處理呢?答案是:以當前命令為軸,捨棄之前的(相對於當前命令是舊的)命令,保留之後的(相對於當前命令是新的)命令,然後新增新的命令,更新命令流。這一步並不困難,讀者可自行實現。這裡就不再演示了。
五. 總結
本文的程式碼都是十分簡單且粗糙的,主要是介紹命令模式的應用方法,讀者可以根據自身情況去編寫更完善的程式碼。命令模式的確是一個十分高效的模式,筆者在學習了命令模式之後,對於程式碼編寫的思維也有了一些感悟。希望本文能對讀者有所幫助。