遊戲設計模式(二) 論撤消重做、回放系統的實現:命令模式
這篇文章起源於《Game Programming Patterns》第二章第一節,將與大家一起探索遊戲開發中命令模式的用法。
命令模式的成名應用是實現諸如撤消,重做,回放,時間倒流之類的功能。如果你想知道《Dota2》中的觀戰系統、《魔獸爭霸3》中的錄像系統、《守望先鋒》的全場最佳回放系統可能的一些實現思路,這篇文章或許也能給你一些啟示。
一、本文涉及知識點思維導圖
還是國際慣例,先放出這篇文章所涉及內容知識點的一張思維導圖,就開始正文。大家若是疲於閱讀文章正文,直接看這張圖,也是可以Get到本文的主要知識點的大概(推薦放大後查看,其實,單看總結的這些概念還是太抽象,關鍵還是在於文中三、四、五節中的代碼與圖示)。
二、命令模式的定義
在許多大型遊戲中,都可以見到命令模式(Command Pattern)的身影。設計模式界的扛鼎之作《Design Patterns: Elements ofReusable Object-Oriented Softwar》(中譯版《設計模式:可復用面向對象軟體的基礎》) 一書的作者四人幫Gang of Four對命令模式這樣概括:
「命令模式將「請求」封裝成對象,以便使用不同的請求、隊列或者日誌來參數化其他對象,同時支持可撤消的操作。Encapsulate a request as an object, therebyletting you parameterizeclients with different requests, queue or log requests,and tsupport undoable operations.」
這句話解讀版本應該是這樣:將一組行為抽象為對象,這個對象和其他對象一樣可以被存儲和傳遞,從而實現行為請求者與行為實現者之間的松耦合,這就是命令模式。
接著看看Gang of Four隨後提出的另一個闡述:
「命令模式是回調機制的面向對象版本。Commands are an object-oriented replacementfor callbacks.」
這句話從另一個方面道出了命令模式的思想,它是回調的面向對象版本。
OK,定義都給出了,不妨我們舉一些栗子,在實際例子中看看命令模式到底能帶給我們哪些驚喜。
三、引例
每個遊戲都有一些代碼塊用來讀取用戶的輸入操作,按鈕點擊,鍵盤事件,滑鼠點擊,或者其他輸入。這些代碼記錄每次的輸入,並將之轉換為遊戲中一個有意義的動作(action),如下圖:
一種最簡單粗暴的實現大概是這樣:
void InputHandler::handleInput()n{n tif(isPressed(BUTTON_X)) jump();n telse if (isPressed(BUTTON_Y)) fireGun();n telse if (isPressed(BUTTON_A)) swapWeapon();n telse if (isPressed(BUTTON_B)) lurchIneffectively();n}n
我們知道,這個函數通常會通過遊戲循環被每幀調用。這段代碼在我們想將用戶的輸入和程序行為硬編碼在一起時,是完全可以勝任自身的工作的。但如果想實現用戶自定義配置他們的按鈕與動作的映射,就需要進行修改了。
為了支持自定義配置,我們需要把那些對 jump() 和 fireGun() 的直接調用轉換為我們可以換出(swap out)的東西。」換出「(swapping out)聽起來很像分配變數,所以我們需要個對象來代表一個遊戲動作。這就用到了命令模式。
於是,我們定義了一個基類用來代表一個可激活的遊戲命令:
class Commandn{npublic:ntvirtual ~Command() {}ntvirtual void execute() = 0;n};n
然後我們為每個不同的遊戲動作創建一個子類,public繼承自我們的Command類:
class JumpCommand : public Commandn{npublic:ntvirtual void execute() { jump(); }n};nnclass FireCommand : public Commandn{npublic:n tvirtual void execute() { fireGun(); }n};n
在負責輸入處理的InputHandler中,我們為為每個鍵存儲一個指向Command的指針。
class InputHandlern{npublic:n tvoid handleInput();n n //Methods to bind commands...n nprivate:n tCommand* buttonX_;n tCommand* buttonY_;n tCommand* buttonA_;n tCommand* buttonB_;n}; n
那麼現在,InputHandler就可以該寫成這樣:
void InputHandler::handleInput()n{n tif(isPressed(BUTTON_X)) buttonX_->execute();n telse if (isPressed(BUTTON_Y)) buttonY_->execute();n telse if (isPressed(BUTTON_A)) buttonA_->execute();n telse if (isPressed(BUTTON_B)) buttonB_->execute();n}n
不難理解,以前每個輸入都會直接調用一個函數,現在則會有一個間接調用層。那麼圖示看起來就是這樣:
這就是命令模式的最基礎的實現,按照其思路畫了一個大概的型出來。
簡而言之,命令模式的關鍵在於引入了抽象命令介面(execute( )方法),且發送者針對抽象命令介面編程,只有實現了抽象命令介面的具體命令才能與接收者相關聯。而且命令模式的本質是對命令進行封裝,將發出命令的責任和執行命令的責任分割開。
四、進一步地使用命令模式
我們剛才定義的命令類在上個例子中可以跑得起來,但很受限。問題在於,他們假設存在jump() , fireGun() 等這樣的函數能與玩家關聯並控制玩家。這種假設耦合限制了這些命令的的效用。JumpCommand類唯一能做的事情就是控制玩家的跳躍。讓我們放寬限制,傳進去一個我們想要控制的對象進去,而不是用命令對象自身來調用函數:
class Commandn{npublic:n tvirtual ~Command() {}n tvirtual void execute(GameActor& actor) = 0;n};n
這裡GameActor是代表遊戲世界中角色的「遊戲對象」類。 我們將其傳給execute(),這樣可以在它的子類中添加函數,來與我們選擇的角色關聯,就像這樣:
class JumpCommand : public Commandn{npublic:n tvirtual void execute(GameActor& actor)n t{n ttactor.jump();n t}n};n
現在,我們可以使用這個類控制遊戲中的任何角色。 還少了一塊在輸入控制和在正確的對象上起作用之間的代碼。 首先,我們修改handleInput()這樣它可以返回命令:
Command* InputHandler::handleInput()n{n tif(isPressed(BUTTON_X)) return buttonX_;n tif(isPressed(BUTTON_Y)) return buttonY_;n tif(isPressed(BUTTON_A)) return buttonA_;n tif(isPressed(BUTTON_B)) return buttonB_;n n t//Nothing pressed, so do nothing.n treturn NULL;n}n
這段代碼不能直接執行命令,因為它並不知道該傳入那個角色對象。命令是一個對象化的調用,是回調的面向對象版本,這裡正是我們可以利用的地方——我們可以延遲調用。我們需要一些代碼來保存命令並且執行對玩家角色的調用。像下面這樣:
Command* command =inputHandler.handleInput();nif (command)n{ntcommand->execute(actor);n}n
假設 actor 是玩家角色的一個引用,這將會基於用戶的輸入來驅動角色,所以我們可以賦予角色與前例一致的行為。在命令和角色之間加入的間接層使得我們可以讓玩家控制遊戲中的任何角色,只需通過改變命令執行時傳入的角色對象即可。
目前我們只考慮了玩家驅動角色(player-driven character),但是對於遊戲世界中的其他角色呢?他們由遊戲的AI來驅動。我們可以使用相同的命令模式來作為AI引擎和角色的介面;AI代碼部分提供命令(Command)對象用來執行,代碼也就是:
command->execute(AI對象); n
AI選擇命令,角色執行命令,它們之間的解耦給了我們很大的靈活性。我們可以為不同的角色使用不同的AI模塊,或者可以為不同種類的行為混合AI。你想要一個更加具有侵略性的敵人?只需要插入一段更具侵略性的AI代碼來為它生成命令即可。事實上,我們甚至可以將AI使用到玩家的角色身上,這對於像遊戲需要自動運行的demo模式是很有用的。
通過將控制角色的命令作為對象,我們便去掉了直接調用指定函數這樣的緊耦合。我們不妨將這樣的方式理解成一個隊列或者一個命令流(queue or stream of commands):
如圖,一些代碼(輸入控制器或者AI)產生一系列指令然後將其放入流中。 另一些指令(調度器或者角色自身)消耗指令並調用他們。這樣,通過在中間加入了一個隊列,我們解耦了行為請求者和行為實現者。
而且,如果我們把這些命令序列化,便可以通過互聯網來發送數據流。可以把玩家的輸入通過網路發送到另外一台機器上,然後進行回放,這就是多人網路遊戲裡面非常重要的一塊。
五、實現撤消與重做功能
撤消和重做是命令模的成名應用了。如果一個命令對象可以做(do) 一些事情,那麼應該可以很輕鬆的撤消(undo)它們。撤銷這個行為經常在一些策略遊戲中見到,在遊戲中如果你不喜歡的話可以回滾一些步驟。在創建遊戲時這是必不可少的的工具之一。
如果你想讓遊戲策劃同事們噴你,最可靠的辦法就是在關卡編輯器中不提供撤消功能,讓他們不能撤消不小心犯的錯誤,我保證他們會打你。
利用命令模式,撤消和重做功能實現起來非常容易。假設我們在製作單人回合制遊戲,想讓玩家能撤消移動,這樣他們就可以集中注意力在策略上而不是猜測上,而之前我們已經使用了命令來抽象輸入控制,所以每個玩家的舉動都已經被封裝其中。舉個例子,移動一個單位的代碼可能如下:
class MoveUnitCommand : public Commandn{npublic:n MoveUnitCommand(Unit* unit, int x, int y)n : unit_(unit),n x_(x),n y_(y)n {}nn virtual void execute()n {n unit_->moveTo(x_, y_);n }nnprivate:n Unit* unit_;n int x_, y_;n};n
注意這和前面的命令模式有些許不同。 在前面的例子中,我們需要從修改的角色那裡抽象命令。而在這個例子中,我們將命令綁定到要移動的單位上。這條命令的實例不是通用的「移動某物」指令,而是遊戲回合中特殊的一次移動。
這邊就可以展示出命令模式的幾種形態。 在某些情況下,指令是可重用的對象,代表了可執行的事件。我們在文章開頭展示的輸入控制將其實現為一個命令對象,然後在按鍵按下時調用其execute()方法。而這裡的命令代表了特定時間點能做的特定事件。這意味著輸入控制代碼可以在玩家下決定時創造一個實例。就像這樣:
Command* handleInput()n{n Unit* unit = getSelectedUnit();nn if (isPressed(BUTTON_UP)) {n // Move the unit up one.n int destY = unit->y() - 1;n return new MoveUnitCommand(unit, unit->x(), destY);n }nn if (isPressed(BUTTON_DOWN)) {n // Move the unit down one.n int destY = unit->y() + 1;n return new MoveUnitCommand(unit, unit->x(), destY);n }nn // Other moves...nn return NULL;n}n
而為了撤消命令,我們定義了一個undo的操作,每個命令類都需要來實現它:
class Commandn{npublic:n virtual ~Command() {}n virtual void execute() = 0;n virtual void undo() = 0;n};n
當然,在像C++這樣沒有垃圾回收的語言,這意味著執行命令的代碼也要負責釋放內存。
undo()方法用於回滾execute()方法造成的遊戲狀態改變。下面我們針對上一個移動命令加入撤消支持:
class MoveUnitCommand : public Commandn{npublic:n MoveUnitCommand(Unit* unit, int x, int y)n : unit_(unit),n xBefore_(0),n yBefore_(0),n x_(x),n y_(y)n {}nn virtual void execute()n {n // Remember the units position before the moven // so we can restore it.n xBefore_ = unit_->x();n yBefore_ = unit_->y();nn unit_->moveTo(x_, y_);n }nn virtual void undo()n {n unit_->moveTo(xBefore_, yBefore_);n }nnprivate:n Unit* unit_;n int xBefore_, yBefore_;n int x_, y_;n};n
需要注意的是,我們為類添加了更多狀態。 當單位移動時,它忘記了它之前是什麼樣的。 如果我們想要撤銷這個移動,我們需要記得單位之前的狀態,也就是xBefore_和yBefore_做的事。
其實,這樣的實現看起來挺像備忘錄模式(Memento pattern)的,但是你會發現備忘錄模式用在這裡並不能愉快地工作。因為命令試圖去修改一個對象狀態的一小部分,而為對象的其他數據創建快照是浪費內存。只手動存儲被修改的部分相對來說就節省很多內存了。
持久化數據結構是另一個選擇。通過它們,每次對一個對象進行修改都會返回一個新的對象,保留原對象不變。通過這樣的實現,新對象會與原對象共享數據,所以比拷貝整個對象的代價要小得多。使用持久化數據結構,每個命令存儲著命令執行前對象的一個引用,所以撤銷意味著切換到原來老的對象。
為了讓玩家能夠撤銷一次移動,我們保留了他們執行的上一個命令。當他們敲擊Control+Z 時,我們便會調用 undo() 方法。(如果已經撤消了,那麼會變為」重做「,我們會再次執行那個命令。)
支持多次撤消也很容易實現。也就是我們不再保存最後一個命令,取而代之保存了一個命令列表和」current「(當前)命令的一個引用。當玩家執行了某個命令時,我們將此命令添加到列表中,並將」current「指向它即可。思路如下圖:
當玩家選擇」撤消「時,我們撤消掉當前的命令並且將當前的指針移回去。當他們選擇」重做「,我們將指針前移然後執行命令。如果他們在撤消之後選擇了一個新的命令,就把列表中位於當前命令之後的所有命令進行捨棄。
若你是第一次在遊戲關卡編輯器中用命令模式實現撤消重做的功能,或許你會驚嘆它是如此的簡單、高效而且優雅。
六、錄像與回放系統的實現思路
上文剛剛講到了如何用命令模式實現撤消與重做。重做在遊戲中並不常見,但回放(replay)、錄像、觀戰系統卻很常見。一個簡單粗暴的實現方法就是記錄每一幀的遊戲狀態以便能夠回放,但是這樣會佔用大量的內存。
所以,許多遊戲會記錄每一幀每個實體所執行的一系列命令,就可以輕鬆的實現回放功能。而為了回放遊戲,引擎只需要運行正常遊戲的模擬,執行預先錄製的命令即可。
那我們便可以這樣理解,錄像與回放等功能,可以基於命令模式實現,也就是執行並解析一系列經過預錄製的序列化後的各玩家操作的有序命令集合。以下只是提供一些分析的思路,並不代表這三款遊戲當時就是這樣實現的:
- 《魔獸爭霸3》中的replay錄像,大概就是通過將所有玩家的操作命令,序列化到一個.rep後綴的文件中,然後在遊戲中進行解析後回放來實現。
- 《Dota2》中的錄像功能也大致如此,而觀戰功能也就是通過在線不斷獲取該局比賽中各個玩家經過序列化後的有序命令流,然後在自己的客戶端中解析並重放。
- 《守望先鋒》的回放系統,大概也就是將各個玩家的一系列操作命令通過網路發送到其他玩家的機器上(其實對戰過程中就已經在實時發送),然後進行解析後進行模擬回放。(在這篇文章發布近一年以後,通過GDC上的《守望先鋒》的talk了解到,《守望先鋒》採用的是ECS架構,和本文講述的命令模式的實現有所不同,但具體到回放系統的設計思想,其實有異曲同工之妙。)
這大致就是各種遊戲中錄像、回放、觀戰系統所用的一些設計思路。
七、命令模式的要點總結
OK,例子講完了,下面對命令模式進行一些要點的總結。
7.1 命令模式的要點總結
首先,給出命令模式的UML圖:
然後,讓我們再次看看文章開頭給出的GOF對於命令模式的定義:
"命令模式將「請求」封裝成對象,以便使用不同的請求、隊列或者日誌來參數化其他對象,同時支持可撤消的操作。"
接著是對命令模式的一些解讀與思考:
- 將一組行為抽象為對象,這個對象和其他對象一樣可以被存儲和傳遞,從而實現行為請求者與行為實現者之間的松耦合,這就是命令模式。
- 命令模式的本質是對命令進行封裝,將發出命令的責任和執行命令的責任分割開。
- 命令模式是回調機制的面向對象版本。
- 每一個命令都是一個操作:請求的一方發出請求,要求執行一個操作;接收的一方收到請求,並執行操作。
- 命令模式允許請求的一方和接收的一方獨立開來,使得請求的一方不必知道接收請求的一方的介面,更不必知道請求是怎麼被接收,以及操作是否被執行、何時被執行,以及是怎麼被執行的。
- 命令模式的關鍵在於引入了抽象命令介面,且發送者針對抽象命令介面編程,只有實現了抽象命令介面的具體命令才能與接收者相關聯。
- 命令模式很適合實現諸如撤消,重做,回放,回退一步,時間倒流之類的功能。
- 命令模式有不少的細分種類,實際使用時應根據當前所需來找到合適的設計方式。
7.2 命令模式的優點
1.對類間解耦。調用者角色與接受者角色之間沒有任何依賴關係,調用者實現功能時只需調用Command抽象類的execute方法即可,不需要了解到底是哪個接收者在執行。
2.可擴展性強。Command的子類可以非常容易地擴展,而調用者Invoker和高層次的模塊Client之間不會產生嚴重的代碼耦合。
3.易於命令的組合維護。可以比較容易地設計一個組合命令,維護所有命令的集合,並允許調用同一方法實現不同的功能。
4.易於與其他模式結合。命令模式可以結合責任鏈模式,實現命令族的解析;而命令模式結合模板方法模式,則可以有效減少Command子類的膨脹問題。
7.3 命令模式的缺點
會導致類的膨脹。使用命令模式可能會導致某些系統有過多的具體命令類。因為針對每一個命令都需要設計一個具體命令類,這將導致類的膨脹。上文講解優點時已經提到了應對之策,我們可以將命令模式結合模板方法模式,來有效減少Command子類的膨脹問題。也可以定義一個具體基類,包括一些能定義自己行為的高層方法,將命令的主體execute()轉到子類沙箱中,往往會有一些幫助。
八、本文涉及知識點提煉整理
本文涉及知識點提煉整理如下:
- GOF對命令模式的定義是,命令模式將「請求」封裝成對象,以便使用不同的請求、隊列或者日誌來參數化其他對象,同時支持可撤消的操作。命令模式是回調機制的面向對象版本。
- 將一組行為抽象為對象,這個對象和其他對象一樣可以被存儲和傳遞,從而實現行為請求者與行為實現者之間的松耦合,這就是命令模式。
- 命令模式的本質是對命令進行封裝,將發出命令的責任和執行命令的責任分割開。
- 命令模式很適合實現諸如撤消,重做,回放,時間倒流之類的功能。而基於命令模式實現錄像與回放等功能,也就是執行並解析一系列經過預錄製的序列化後的各玩家操作的有序命令集合。
- 而錄像與回放等功能,就是在執行並解析一系列經過預錄製的序列化後的各玩家操作的有序命令集合。
- 命令模式的優點有:對類間解耦、可擴展性強、易於命令的組合維護、易於與其他模式結合,而缺點是會導致類的膨脹。
- 命令模式有不少的細分種類,實際使用時應根據當前所需來找到合適的設計方式。
當然,單看以上的這些概念也許太過於抽象,關鍵還是在於理解文中三、四、五節的代碼與圖示。
九、參考文獻
[1] Command · Design Patterns Revisited · Game Programming Patterns
[2] Gamma E. Design patterns: elements of reusable object-oriented software[M]. Pearson Education India, 1995.
[3] Freeman E, Robson E, Bates B, et al. Head first design patterns[M]. " OReilly Media, Inc.", 2004.
[4] Command pattern
[5] Memento pattern
本文就此結束,系列文章未完待續。
With Best Wishes.
推薦閱讀:
※設計模式之組合模式
※自己實現的觀察者模式、BroadcastReceiver和EventBus三者的優缺點是什麼?
※在替考的代理模式中到底誰是Proxy?
※設計模式之「Observer」註疏#01