命令模式
遊戲設計模式Design Patterns Revisited
命令模式是我最喜歡的模式之一。 大多數我寫的遊戲或者別的什麼之類的大型程序,都會在某處用到它。 當在正確的地方使用時,它可以將複雜的代碼清理乾淨。 對於這樣一個了不起的模式,不出所料地,GoF有個深奧的定義:
將一個請求封裝爲一個對象,從而使你可用不同的請求對客戶進行參數化; 對請求排隊或記錄請求日誌,以及支持可撤銷的操作。
我想你也會覺得這個句子晦澀難懂。 第一,它的比喻難以理解。 在詞語可以指代任何事物的狂野軟件世界之外,“客戶”是一個人——那些和你做生意的人。 據我查證,人類不能被“參數化”。
然後,句子餘下的部分介紹了可能會使用這個模式的場景。 如果你的場景不在這個列表中,那麼這對你就沒什麼用處。 我的命令模式精簡定義爲:
命令是*具現化的方法調用*。
“Reify(具現化)”來自於拉丁語“res”,意爲“thing”(事物),加上英語後綴“–fy”。 所以它意爲“thingify”,沒準用“thingify”更合適。
當然,“精簡”往往意味着着“缺少必要信息”,所以這可能沒有太大的改善。 讓我擴展一下。如果你沒有聽說過“具現化”的話,它的意思是“實例化,對象化”。 具現化的另外一種解釋方式是將某事物作爲“第一公民”對待。
在某些語言中的反射允許你在程序運行時命令式地和類型交互。 你可以獲得類的類型對象,可以與其交互看看這個類型能做什麼。換言之,反射是具現化類型的系統。
兩種術語都意味着將概念變成數據 ——一個對象——可以存儲在變量中,傳給函數。 所以稱命令模式爲“具現化方法調用”,意思是方法調用被存儲在對象中。
這聽起來有些像“回調”,“第一公民函數”,“函數指針”,“閉包”,“偏函數”, 取決於你在學哪種語言,事實上大致上是同一個東西。GoF隨後說:
命令模式是一種回調的面向對象實現。
這是一種對命令模式更好的解釋。
但這些都既抽象又模糊。我喜歡用實際的東西作爲章節的開始,不好意思,搞砸了。 作爲彌補,從這裏開始都是命令模式能出色應用的例子。
配置輸入
在每個遊戲中都有一塊代碼讀取用戶的輸入——按鈕按下,鍵盤敲擊,鼠標點擊,諸如此類。 這塊代碼會獲取用戶的輸入,然後將其變爲遊戲中有意義的行爲:
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-oFEdBNhZ-1594039431893)(https://gpp.tkchu.me/images/command-buttons-one.png)]
下面是一種簡單的實現:
void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}
專家建議:不要太經常地按B。
這個函數通常在遊戲循環中每幀調用一次,我確信你可以理解它做了什麼。 在我們想將用戶的輸入和程序行爲硬編碼在一起時,這段代碼可以正常工作,但是許多遊戲允許玩家配置按鍵的功能。
爲了支持這點,需要將這些對jump()
和fireGun()
的直接調用轉化爲可以變換的東西。 “變換”聽起來有點像變量乾的事,因此我們需要表示遊戲行爲的對象。進入:命令模式。
我們定義了一個基類代表可觸發的遊戲行爲:
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};
當你有接口只包含一個沒有返回值的方法時,很可能你可以使用命令模式。
然後我們爲不同的遊戲行爲定義相應的子類:
class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};
class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};
// 你知道思路了吧
在代碼的輸入處理部分,爲每個按鍵存儲一個指向命令的指針。
class InputHandler
{
public:
void handleInput();
// 綁定命令的方法……
private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};
現在輸入處理部分這樣處理:
void InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) buttonX_->execute();
else if (isPressed(BUTTON_Y)) buttonY_->execute();
else if (isPressed(BUTTON_A)) buttonA_->execute();
else if (isPressed(BUTTON_B)) buttonB_->execute();
}
注意在這裏沒有檢測NULL
了嗎?這假設每個按鍵都與某些命令相連。
如果想支持不做任何事情的按鍵又不想顯式檢測NULL
,我們可以定義一個命令類,它的execute()
什麼也不做。 這樣,某些按鍵處理器不必設爲NULL
,只需指向這個類。這種模式被稱爲空對象。
以前每個輸入直接調用函數,現在會有一層間接尋址:
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-KmgzuMJ2-1594039431895)(https://gpp.tkchu.me/images/command-buttons-two.png)]
這是命令模式的簡短介紹。如果你能夠看出它的好處,就把這章剩下的部分作爲獎勵吧。
角色說明
我們剛纔定義的類可以在之前的例子上正常工作,但有很大的侷限。 問題在於假設了頂層的jump()
, fireGun()
之類的函數可以找到玩家角色,然後像木偶一樣操縱它。
這些假定的耦合限制了這些命令的用處。JumpCommand
只能 讓玩家的角色跳躍。讓我們放鬆這個限制。 不讓函數去找它們控制的角色,我們將函數控制的角色對象傳進去:
class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};
這裏的GameActor
是代表遊戲世界中角色的“遊戲對象”類。 我們將其傳給execute()
,這樣命令類的子類就可以調用所選遊戲對象上的方法,就像這樣:
class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump();
}
};
現在,我們可以使用這個類讓遊戲中的任何角色跳來跳去了。 在輸入控制部分和在對象上調用命令部分之間,我們還缺了一塊代碼。 第一,我們修改handleInput()
,讓它可以返回命令:
Command* InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) return buttonX_;
if (isPressed(BUTTON_Y)) return buttonY_;
if (isPressed(BUTTON_A)) return buttonA_;
if (isPressed(BUTTON_B)) return buttonB_;
// 沒有按下任何按鍵,就什麼也不做
return NULL;
}
這裏不能立即執行,因爲還不知道哪個角色會傳進來。 這裏我們享受了命令是具體調用的好處——延遲
到調用執行時再知道。
然後,需要一些接受命令的代碼,作用在玩家角色上。像這樣:
Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}
將actor
視爲玩家角色的引用,它會正確地按着玩家的輸入移動, 所以我們賦予了角色和前面例子中相同的行爲。 通過在命令和角色間增加了一層重定向, 我們獲得了一個靈巧的功能:我們可以讓玩家控制遊戲中的任何角色,只需向命令傳入不同的角色。
在實踐中,這個特性並不經常使用,但是經常會有類似的用例跳出來。 到目前爲止,我們只考慮了玩家控制的角色,但是遊戲中的其他角色呢? 它們被遊戲AI控制。我們可以在AI和角色之間使用相同的命令模式;AI代碼只需生成Command
對象。
在選擇命令的AI和展現命令的遊戲角色間解耦給了我們很大的靈活度。 我們可以對不同的角色使用不同的AI,或者爲了不同的行爲而混合AI。 想要一個更加有攻擊性的對手?插入一個更加有攻擊性的AI爲其生成命令。 事實上,我們甚至可以爲玩家角色加上AI, 在展示階段,遊戲需要自動演示時,這是很有用的。
把控制角色的命令變爲第一公民對象,去除直接方法調用中嚴厲的束縛。 將其視爲命令隊列,或者是命令流:
隊列能爲你做的更多事情,請看事件隊列。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QxWT0M1P-1594039431896)(https://gpp.tkchu.me/images/command-stream.png)]
爲什麼我覺得需要爲你畫一幅“流”的圖像?又是爲什麼它看上去像是管道?
一些代碼(輸入控制器或者AI)產生一系列命令放入流中。 另一些代碼(調度器或者角色自身)調用並消耗命令。 通過在中間加入隊列,我們解耦了消費者和生產者。
如果將這些指令序列化,我們可以通過網絡流傳輸它們。 我們可以接受玩家的輸入,將其通過網絡發送到另外一臺機器上,然後重現之。這是網絡多人遊戲的基礎。
撤銷和重做
最後的這個例子是這種模式最廣爲人知的使用情況。 如果一個命令對象可以做一件事,那麼它亦可以撤銷這件事。 在一些策略遊戲中使用撤銷,這樣你就可以回滾那些你不喜歡的操作。 它是創造遊戲時必不可少的工具。 一個不能撤銷誤操作導致的錯誤的編輯器,肯定會讓遊戲設計師恨你。
這是經驗之談。
沒有了命令模式,實現撤銷非常困難,有了它,就是小菜一碟。 假設我們在製作單人回合制遊戲,想讓玩家能撤銷移動,這樣他們就可以集中注意力在策略上而不是猜測上。
我們已經使用了命令來抽象輸入控制,所以每個玩家的舉動都已經被封裝其中。 舉個例子,移動一個單位的代碼可能如下:
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
x_(x),
y_(y)
{}
virtual void execute()
{
unit_->moveTo(x_, y_);
}
private:
Unit* unit_;
int x_, y_;
};
注意這和前面的命令有些許不同。 在前面的例子中,我們需要從修改的角色那裏抽象命令。 在這個例子中,我們將命令綁定到要移動的單位上。 這條命令的實例不是通用的“移動某物”命令;而是遊戲回合中特殊的一次移動。
這展現了命令模式應用時的一種情形。 就像之前的例子,指令在某些情形中是可重用的對象,代表了可執行的事件。 我們早期的輸入控制器將其實現爲一個命令對象,然後在按鍵按下時調用其execute()
方法。
這裏的命令更加特殊。它們代表了特定時間點能做的特定事件。 這意味着輸入控制代碼可以在玩家下決定時創造一個實例。就像這樣:
Command* handleInput()
{
Unit* unit = getSelectedUnit();
if (isPressed(BUTTON_UP)) {
// 向上移動單位
int destY = unit->y() - 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}
if (isPressed(BUTTON_DOWN)) {
// 向下移動單位
int destY = unit->y() + 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}
// 其他的移動……
return NULL;
}
當然,在像C++這樣沒有垃圾回收的語言中,這意味着執行命令的代碼也要負責釋放內存。
命令的一次性爲我們很快地贏得了一個優點。 爲了讓指令可被取消,我們爲每個類定義另一個需要實現的方法:
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
};
undo()
方法回滾了execute()
方法造成的遊戲狀態改變。 這裏是添加了撤銷功能後的移動命令:
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}
virtual void execute()
{
// 保存移動之前的位置
// 這樣之後可以復原。
xBefore_ = unit_->x();
yBefore_ = unit_->y();
unit_->moveTo(x_, y_);
}
virtual void undo()
{
unit_->moveTo(xBefore_, yBefore_);
}
private:
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
};
注意我們爲類添加了更多的狀態。 當單位移動時,它忘記了它之前是什麼樣的。 如果我們想要撤銷這個移動,我們需要記得單位之前的狀態,也就是xBefore_
和yBefore_
的作用。
這看上去是備忘錄模式使用的地方,它從來沒有有效地工作過。 由於命令趨向於修改對象狀態的一小部分,對數據其他部分的快照就是浪費內存。手動內存管理的消耗更小。
*持久化數據結構*是另一個選項。 使用它,每次修改對象都返回一個新對象,保持原來的對象不變。巧妙的實現下,這些新對象與之前的對象共享數據,所以比克隆整個對象開銷更小。
使用持久化數據結構,每條命令都存儲了命令執行之前對象的引用,而撤銷只是切換回之前的對象。
爲了讓玩家撤銷移動,我們記錄了執行的最後命令。當他們按下control+z
時,我們調用命令的undo()
方法。 (如果他們已經撤銷了,那麼就變成了“重做”,我們會再一次執行命令。)
支持多重的撤銷也不太難。 我們不單單記錄最後一條指令,還要記錄指令列表,然後用一個引用指向“當前”的那個。 當玩家執行一條命令,我們將其添加到列表,然後將代表“當前”的指針指向它。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-dH8C617j-1594039431900)(https://gpp.tkchu.me/images/command-undo.png)]
當玩家選擇“撤銷”,我們撤銷現在的命令,將代表當前的指針往後退。 當他們選擇“重做”,我們將代表當前的指針往前進,執行該指令。 如果在撤銷後選擇了新命令,那麼清除命令列表中當前的指針所指命令之後的全部命令。
第一次在關卡編輯器中實現這點時,我覺得自己簡直就是個天才。 我驚訝於它如此的簡明有效。 你需要約束自己,保證每個數據修改都通過命令完成,一旦你做到了,餘下的都很簡單。
重做在遊戲中並不常見,但重放常見。 一種簡單的重放實現是記錄遊戲每幀的狀態,這樣它可以回放,但那會消耗太多的內存。
相反,很多遊戲記錄每個實體每幀運行的命令。 爲了重放遊戲,引擎只需要正常運行遊戲,執行之前存儲的命令。
用類還是用函數?
早些時候,我說過命令與第一公民函數或者閉包類似, 但是在這裏展現的每個例子都是通過類完成的。 如果你更熟悉函數式編程,你也許會疑惑函數都在哪裏。
我用這種方式寫例子是因爲C++對第一公民函數支持非常有限。 函數指針沒有狀態,函子很奇怪而且仍然需要定義類, 在C++11中的lambda演算需要大量的人工記憶輔助才能使用。
這並不是說你在其他語言中不可以用函數來完成命令模式。 如果你使用的語言支持閉包,不管怎樣,快去用它! 在某種程度上說,命令模式是爲一些沒有閉包的語言模擬閉包。
(我說某種程度上是因爲,即使是那些支持閉包的語言, 爲命令建立真正的類或者結構也是很有用的。 如果你的命令擁有多重操作(比如可撤銷的命令), 將其全部映射到同一函數中並不優雅。)
定義一個有字段的真實類能幫助讀者理解命令包含了什麼數據。 閉包是自動包裝狀態的完美解決方案,但它們過於自動化而很難看清包裝的真正狀態有哪些。
舉個例子,如果我們使用javascript來寫遊戲,那麼我們可以用這種方式來寫讓單位移動的命令:
function makeMoveUnitCommand(unit, x, y) {
// 這個函數就是命令對象:
return function() {
unit.moveTo(x, y);
}
}
我們可以通過一對閉包來爲撤銷提供支持:
function makeMoveUnitCommand(unit, x, y) {
var xBefore, yBefore;
return {
execute: function() {
xBefore = unit.x();
yBefore = unit.y();
unit.moveTo(x, y);
},
undo: function() {
unit.moveTo(xBefore, yBefore);
}
};
}
如果你習慣了函數式編程風格,這種做法是很自然的。 如果你沒有,我希望這章可以幫你瞭解一些。 對於我而言,命令模式展現了函數式範式在很多問題上的高效性。