目錄

  • 致謝
  • 序
    • 架構,性能和遊戲
  • 重訪設計模式
    • 命令模式
    • 享元模式
    • 觀察者模式
    • 原型模式
    • 單例模式
    • 狀態模式
  • 序列模式
    • 雙緩衝模式
    • 遊戲循環
    • 更新方法
  • 行爲模式
    • 字節碼
    • 子類沙箱
    • 類型對象
  • 解耦模式
    • 組件模式
    • 事件隊列
    • 服務定位器
  • 優化模式
    • 數據局部性
    • 髒標識模式
    • 對象池模式
    • 空間分區
← 上一章     § Contents   ≡ 首頁   下一章 →  

原型模式 Prototype

遊戲設計模式 Game Programming PatternsDesign Patterns Revisited

我第一次聽到“原型”這個詞是在設計模式中。 如今,似乎每個人都在用這個詞,但他們討論的實際上不是設計模式。 我們會討論他們所說的原型,也會討論術語“原型”的有趣之處,和其背後的理念。 但首先,讓我們重訪傳統的設計模式。

“傳統的”一詞可不是隨便用的。 設計模式引自1963年 Ivan Sutherland的Sketchpad傳奇項目,那是這個模式首次出現。 當其他人在聽迪倫和甲殼蟲樂隊時,Sutherland正忙於,你知道的,發明CAD,交互圖形和麪向對象編程的基本概念。

看看這個demo,跪服吧。

原型設計模式

假設我們要用《聖鎧傳說》的風格做款遊戲。 野獸和惡魔圍繞着英雄,爭着要吃他的血肉。 這些可怖的同行者通過“生產者”進入這片區域,每種敵人有不同的生產者。

在這個例子中,假設我們遊戲中每種怪物都有不同的類——Ghost,Demon,Sorcerer等等,像這樣:

class Monster
{
  // 代碼……
};

class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

生產者構造特定種類怪物的實例。 爲了在遊戲中支持每種怪物,我們可以用一種暴力的實現方法, 讓每個怪物類都有生產者類,得到平行的類結構:

平行的類結構,惡靈,惡魔,巫師都繼承怪物。惡靈生產者。惡魔生產者,巫師生產者都繼承生產者。

我得翻出落滿灰塵的UML書來畫這個圖表。一個UML箭頭代表“繼承”。

實現後看起來像是這樣:

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Ghost();
  }
};

class DemonSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Demon();
  }
};

// 你知道思路了……

除非你會根據代碼量來獲得工資, 否則將這些焊在一起很明顯不是好方法。 衆多類,衆多引用,衆多冗餘,衆多副本,衆多重複自我……

原型模式提供了一個解決方案。 關鍵思路是一個對象可以產出與它自己相近的對象。 如果你有一個惡靈,你可以製造更多惡靈。 如果你有一個惡魔,你可以製造其他惡魔。 任何怪物都可以被視爲原型怪物,產出其他版本的自己。

爲了實現這個功能,我們給基類Monster添加一個抽象方法clone():

class Monster
{
public:
  virtual ~Monster() {}
  virtual Monster* clone() = 0;

  // 其他代碼……
};

每個怪獸子類提供一個特定實現,返回與它自己的類和狀態都完全一樣的新對象。舉個例子:

class Ghost : public Monster {
public:
  Ghost(int health, int speed)
  : health_(health),
    speed_(speed)
  {}

  virtual Monster* clone()
  {
    return new Ghost(health_, speed_);
  }

private:
  int health_;
  int speed_;
};

一旦我們所有的怪物都支持這個, 我們不再需要爲每個怪物類創建生產者類。我們只需定義一個類:

class Spawner
{
public:
  Spawner(Monster* prototype)
  : prototype_(prototype)
  {}

  Monster* spawnMonster()
  {
    return prototype_->clone();
  }

private:
  Monster* prototype_;
};

它內部存有一個怪物,一個隱藏的怪物, 它唯一的任務就是被生產者當做模板,去產生更多一樣的怪物, 有點像一個從來不離開巢穴的蜂后。

一個生產者包含一個對怪物應用的原型字段。
他調用原型的clone()方法來產生新的怪物。

爲了得到惡靈生產者,我們創建一個惡靈的原型實例,然後創建擁有這個實例的生產者:

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

這個模式的靈巧之處在於它不但拷貝原型的類,也拷貝它的狀態。 這就意味着我們可以創建一個生產者,生產快速鬼魂,虛弱鬼魂,慢速鬼魂,而只需創建一個合適的原型鬼魂。

我在這個模式中找到了一些既優雅又令人驚歎的東西。 我無法想象自己是如何創造出它們的,但我更無法想象不知道這些東西的自己該如何是好。

效果如何?

好吧,我們不需要爲每個怪物創建單獨的生產者類,那很好。 但我們確實需要在每個怪物類中實現clone()。 這和使用生產者方法比起來也沒節約多少代碼量。

當你坐下來試着寫一個正確的clone(),會遇見令人不快的語義漏洞。 做深層拷貝還是淺層拷貝呢?換言之,如果惡魔拿着叉子,克隆惡魔也要克隆叉子嗎?

同時,這看上去沒減少已存問題上的代碼, 事實上還增添了些人爲的問題。 我們需要將每個怪物有獨立的類作爲前提條件。 這絕對不是當今大多數遊戲引擎運作的方法。

我們中大部分痛苦地學到,這樣龐雜的類層次管理起來很痛苦, 那就是我們爲什麼用組件模式和類型對象爲不同的實體建模,這樣無需一一建構自己的類。

生產函數

哪怕我們確實需要爲每個怪物構建不同的類,這裏還有其他的實現方法。 不是使用爲每個怪物建立分離的生產者類,我們可以創建生產函數,就像這樣:

Monster* spawnGhost()
{
  return new Ghost();
}

這比構建怪獸生產者類更簡潔。生產者類只需簡單地存儲一個函數指針:

typedef Monster* (*SpawnCallback)();

class Spawner
{
public:
  Spawner(SpawnCallback spawn)
  : spawn_(spawn)
  {}

  Monster* spawnMonster()
  {
    return spawn_();
  }

private:
  SpawnCallback spawn_;
};

爲了給惡靈構建生產者,你需要做:

Spawner* ghostSpawner = new Spawner(spawnGhost);

模板

如今,大多數C++開發者已然熟悉模板了。 生產者類需要爲某類怪物構建實例,但是我們不想硬編碼是哪類怪物。 自然的解決方案是將它作爲模板中的類型參數:

我不太確定程序員是學着喜歡C++模板還是完全畏懼並遠離了C++。 不管怎樣,今日我見到的程序員中,使用C++的也都會使用模板。

這裏的Spawner類不必考慮將生產什麼樣的怪物, 它總與指向Monster的指針打交道。

如果我們只有SpawnerFor<T>類,模板類型沒有辦法共享父模板, 這樣的話,如果一段代碼需要與產生多種怪物類型的生產者打交道,就都得接受模板參數。

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

template <class T>
class SpawnerFor : public Spawner
{
public:
  virtual Monster* spawnMonster() { return new T(); }
};

像這樣使用它:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

第一公民類型

前面的兩個解決方案使用類完成了需求,Spawner使用類型進行參數化。 在C++中,類型不是第一公民,所以需要一些改動。 如果你使用JavaScript,Python,或者Ruby這樣的動態類型語言, 它們的類是可以傳遞的對象,你可以用更直接的辦法解決這個問題。

某種程度上, 類型對象也是爲了彌補第一公民類型的缺失。 但那個模式在擁有第一公民類型的語言中也有用,因爲它讓你決定什麼是“類型”。 你也許想要與語言內建的類不同的語義。

當你完成一個生產者,直接向它傳遞要構建的怪物類——那個代表了怪物類的運行時對象。超容易的,對吧。

綜上所述,老實說,我不能說找到了一種情景,而在這個情景下,原型設計模式是最好的方案。 也許你的體驗有所不同,但現在把它擱到一邊,我們討論點別的:將原型作爲一種語言範式。

原型語言範式

很多人認爲“面向對象編程”和“類”是同義詞。 OOP的定義卻讓人感覺正好相反, 毫無疑問,OOP讓你定義“對象”,將數據和代碼綁定在一起。 與C這樣的結構化語言相比,與Scheme這樣的函數語言相比, OOP的特性是它將狀態和行爲緊緊地綁在一起。

你也許認爲類是完成這個的唯一方式方法, 但是包括Dave Ungar和Randall Smith的一大堆傢伙一直在拼命區分OOP和類。 他們在80年代創建了一種叫做Self的語言。它不用類實現了OOP。

Self語言

就單純意義而言,Self比基於類的語言更加面向對象。 我們認爲OOP將狀態和行爲綁在一起,但是基於類的語言實際將狀態和行爲割裂開來。

拿你最喜歡的基於類的語言的語法來說。 爲了接觸對象中的一些狀態,你需要在實例的內存中查詢。狀態包含在實例中。

但是,爲了調用方法,你需要找到實例的類, 然後在那裏調用方法。行爲包含在類中。 獲得方法總需要通過中間層,這意味着字段和方法是不同的。

一個類,包含了一系列方法。一個實例,包含了一系列字段和指向類的指針。

舉個例子,爲了調用C++中的虛方法,你需要在實例中找指向虛方法表的指針,然後再在那裏找方法。

Self結束了這種分歧。無論你要找啥,都只需在對象中找。 實例同時包含狀態和行爲。你可以構建擁有完全獨特方法的對象。

一個對象中同時包含了字段和方法。

沒有人能與世隔絕,但這個對象是。

如果這就是Self語言的全部,那它將很難使用。 基於類的語言中的繼承,不管有多少缺陷,總歸提供了有用的機制來重用代碼,避免重複。 爲了不使用類而實現一些類似的功能,Self語言加入了委託。

如果要在對象中尋找字段或者調用方法,首先在對象內部查找。 如果能找到,那就成了。如果找不到,在對象的父對象中尋找。 這裏的父類僅僅是一個對其他對象的引用。 當我們沒能在第一個對象中找到屬性,我們嘗試它的父對象,然後父類的父對象,繼續下去直到找到或者沒有父對象爲止。 換言之,失敗的查找被委託給對象的父對象。

我在這裏簡化了。Self實際上支持多個父對象。 父對象只是特別標明的字段,意味着你可以繼承它們或者在運行時改變他們, 你最終得到了“動態繼承”。

一個對象包含了字段和方法,以及一個指向委託對象的指針。

父對象讓我們在不同對象間重用行爲(還有狀態!),這樣就完成了類的公用功能。 類做的另一個關鍵事情就是給出了創建實例的方法。 當你需要新的某物,你可以直接new Thingamabob(),或者隨便什麼你喜歡的表達法。 類是實例的生產工廠。

不用類,我們怎樣創建新的實例? 特別地,我們如何創建一堆有共同點的新東西? 就像這個設計模式,在Self中,達到這點的方式是使用克隆。

在Self語言中,就好像每個對象都自動支持原型設計模式。 任何對象都能被克隆。爲了獲得一堆相似的對象,你:

  1. 將對象塑造成你想要的狀態。你可以直接克隆系統內建的基本Object,然後向其中添加字段和方法。
  2. 克隆它來產出……額……隨你想要多少就克隆多少個對象。

無需煩擾自己實現clone();我們就實現了優雅的原型模式,原型被內建在系統中。

這個系統美妙,靈巧,而且小巧, 一聽說它,我就開始創建一個基於原型的語言來進一步學習。

我知道從頭開始構建一種編程語言語言不是學習它最有效率的辦法,但我能說什麼呢?我可算是個怪人。 如果你很好奇,我構建的語言叫Finch.

它的實際效果如何?

能使用純粹基於原型的語言讓我很興奮,但是當我真正上手時, 我發現了一個令人不快的事實:用它編程沒那麼有趣。

從小道消息中,我聽說很多Self程序員得出了相同的結論。 但這項目並不是一無是處。 Self非常的靈活,爲此創造了很多虛擬機的機制來保持高速運行。

他們發明了JIT編譯,垃圾回收,以及優化方法分配——這都是由同一批人實現的—— 這些新玩意讓動態類型語言能快速運行,構建了很多大受歡迎的應用。

是的,語言本身很容易實現,那是因爲它把複雜度甩給了用戶。 一旦開始試着使用這語言,我發現我想念基於類語言中的層次結構。 最終,在構建語言缺失的庫概念時,我放棄了。

鑑於我之前的經驗都來自基於類的語言,因此我的頭腦可能已經固定在它的範式上了。 但是直覺上,我認爲大部分人還是喜歡有清晰定義的“事物”。

除去基於類的語言自身的成功以外,看看有多少遊戲用類建模描述玩家角色,以及不同的敵人、物品、技能。 不是遊戲中的每個怪物都與衆不同,你不會看到“洞穴人和哥布林還有雪混合在一起”這樣的怪物。

原型是非常酷的範式,我希望有更多人瞭解它, 但我很慶幸不必天天用它編程。 完全皈依原型的代碼是一團漿糊,難以閱讀和使用。

這同時證明,很少 有人使用原型風格的代碼。我查過了。

JavaScript又怎麼樣呢?

好吧,如果基於原型的語言不那麼友好,怎麼解釋JavaScript呢? 這是一個有原型的語言,每天被數百萬人使用。運行JavaScript的機器數量超過了地球上其他所有的語言。

Brendan Eich,JavaScript的締造者, 從Self語言中直接汲取靈感,很多JavaScript的語義都是基於原型的。 每個對象都有屬性的集合,包含字段和“方法”(事實上只是存儲爲字段的函數)。 A對象可以擁有B對象,B對象被稱爲A對象的“原型”, 如果A對象的字段獲取失敗就會委託給B對象。

作爲語言設計者,原型的誘人之處是它們比類更易於實現。 Eich充分利用了這一點,他在十天內創建了JavaScript的第一個版本。

但除那以外,我相信在實踐中,JavaScript更像是基於類的而不是基於原型的語言。 JavaScript與Self有所偏離,其中一個要點是除去了基於原型語言的核心操作“克隆”。

在JavaScript中沒有方法來克隆一個對象。 最接近的方法是Object.create(),允許你創建新對象作爲現有對象的委託。 這個方法在ECMAScript5中才添加,而那已是JavaScript出現後的第十四年了。 相對於克隆,讓我帶你參觀一下JavaScript中定義類和創建對象的經典方法。 我們從構造器函數開始:

function Weapon(range, damage) {
  this.range = range;
  this.damage = damage;
}

這創建了一個新對象,初始化了它的字段。你像這樣引入它:

var sword = new Weapon(10, 16);

這裏的new調用Weapon()函數,而this綁定在新的空對象上。 函數爲新對象添加了一系列字段,然後返回填滿的對象。

new也爲你做了另外一件事。 當它創建那個新的空對象時,它將空對象的委託和一個原型對象連接起來。 你可以用Weapon.prototype來獲得原型對象。

屬性是添加到構造器中的,而定義行爲通常是通過向原型對象添加方法。就像這樣:

Weapon.prototype.attack = function(target) {
  if (distanceTo(target) > this.range) {
    console.log("Out of range!");
  } else {
    target.health -= this.damage;
  }
}

這給武器原型添加了attack屬性,其值是一個函數。 由於new Weapon()返回的每一個對象都有給Weapon.prototype的委託, 你現在可以通過調用sword.attack() 來調用那個函數。 看上去像是這樣:

一個武器原型包含一個 attack() 方法和其他方法。一個寶劍對象包含一個指向武器的委託和其他字段。

讓我們複習一下:

  • 通過“new”操作創建對象,該操作引入代表類型的對象——構造器函數。
  • 狀態存儲在實例中。
  • 行爲通過間接層——原型的委託——被存儲在獨立的對象中,代表了一系列特定類型對象的共享方法。

說我瘋了吧,但這聽起來很像是我之前描述的類。 你可以在JavaScript中寫原型風格的代碼(不用 克隆), 但是語言的語法和慣用法更鼓勵基於類的實現。

個人而言,我認爲這是好事。 就像我說的,我發現如果一切都使用原型,就很難編寫代碼, 所以我喜歡JavaScript,它將整個核心語義包上了一層糖衣。

爲數據模型構建原型

好吧,我之前不斷地討論我不喜歡原型的原因,這讓這一章讀起來令人沮喪。 我認爲這本書應該更歡樂些,所以在最後,讓我們討論討論原型確實有用,或者更加精確,委託 有用的地方。

隨着編程的進行,如果你比較程序與數據的字節數, 那麼你會發現數據的佔比穩定地增長。 早期的遊戲在程序中生成幾乎所有東西,這樣程序可以塞進磁盤和老式遊戲卡帶。 在今日的遊戲中,代碼只是驅動遊戲的“引擎”,遊戲是完全由數據定義的。

這很好,但是將內容推到數據文件中並不能魔術般地解決組織大項目的挑戰。 它只能把這挑戰變得更難。 我們使用編程語言就因爲它們有辦法管理複雜性。

不再是將一堆代碼拷來拷去,我們將其移入函數中,通過名字調用。 不再是在一堆類之間複製方法,我們將其放入單獨的類中,讓其他類可以繼承或者組合。

當遊戲數據達到一定規模時,你真的需要考慮一些相似的方案。 我不指望在這裏能說清數據模式這個問題, 但我確實希望提出個思路,讓你在遊戲中考慮考慮:使用原型和委託來重用數據。

假設我們爲早先提到的山寨版《聖鎧傳說》定義數據模型。 遊戲設計者需要在很多文件中設定怪物和物品的屬性。

這標題是我原創的,沒有受到任何已存的多人地下城遊戲的影響。 請不要起訴我。

一個常用的方法是使用JSON。 數據實體一般是字典,或者屬性集合,或者其他什麼術語, 因爲程序員就喜歡爲舊事物發明新名字。

我們重新發明了太多次,Steve Yegge稱之爲“通用設計模式”.

所以遊戲中的哥布林也許被定義爲像這樣的東西:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

這看上去很易懂,哪怕是最討厭文本的設計者也能使用它。 所以,你可以給哥布林大家族添加幾個兄弟分支:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

現在,如果這是代碼,我們會聞到了臭味。 在實體間有很多的重複,訓練優良的程序員討厭重複。 它浪費了空間,消耗了作者更多時間。 你需要仔細閱讀代碼才知道這些數據是不是相同的。 這難以維護。 如果我們決定讓所有哥布林變強,需要記得將三個哥布林都更新一遍。糟糕糟糕糟糕。

如果這是代碼,我們會爲“哥布林”構建抽象,並在三個哥布林類型中重用。 但是無能的JSON沒法這麼做。所以讓我們把它做得更加巧妙些。

我們可以爲對象添加"prototype"字段,記錄委託對象的名字。 如果在此對象內沒找到一個字段,那就去委託對象中查找。

這讓"prototype"不再是數據,而成爲了元數據。 哥布林有綠色疣皮和黃色牙齒。 它們沒有原型。 原型是表示哥布林的數據模型的屬性,而不是哥布林本身的屬性。

這樣,我們可以簡化我們的哥布林JSON內容:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

由於弓箭手和術士都將grunt作爲原型,我們就不需要在它們中重複血量,防禦和弱點。 我們爲數據模型增加的邏輯超級簡單——基本的單一委託——但已經成功擺脫了一堆冗餘。

有趣的事情是,我們沒有更進一步,把哥布林委託的抽象原型設置成“基本哥布林”。 相反,我們選擇了最簡單的哥布林,然後委託給它。

在基於原型的系統中,對象可以克隆產生新對象是很自然的, 我認爲在這裏也一樣自然。這特別適合記錄那些只有一處不同的實體的數據。

想想Boss和其他獨特的事物,它們通常是更加常見事物的重新定義, 原型委託是定義它們的好方法。 斷頭魔劍,就是一把擁有加成的長劍,可以像下面這樣表示:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

只需在遊戲引擎上多花點時間,你就能讓設計者更加方便地添加不同的武器和怪物,而增加的這些豐富度能夠取悅玩家。

← 上一章     § Contents   ≡ 首頁   下一章 →  
© 2009-2015 Robert Nystrom