遊戲設計模式---命令模式

命令模式

遊戲設計模式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);
    }
  };
}

如果你習慣了函數式編程風格,這種做法是很自然的。 如果你沒有,我希望這章可以幫你瞭解一些。 對於我而言,命令模式展現了函數式範式在很多問題上的高效性。

參見

  • 你最終可能會得到很多不同的命令類。 爲了更容易實現這些類,定義一個具體的基類,包含一些能定義行爲的高層方法,往往會有幫助。 這將命令的主體execute()轉到子類沙箱中。
  • 在上面的例子中,我們明確地指定哪個角色會處理命令。 在某些情況下,特別是當對象模型分層時,也可以不這麼簡單粗暴。 對象可以響應命令,或者將命令交給它的從屬對象。 如果你這樣做,你就完成了一個職責鏈模式
  • 有些命令是無狀態的純粹行爲,比如第一個例子中的JumpCommand。 在這種情況下,有多個實例是在浪費內存,因爲所有的實例是等價的。 可以用享元模式解決。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章