中EN
概念页

原型模式

Prototype Pattern

原型模式 (Prototype Pattern) 的核心思想是“一个对象可以生成与自己相似的对象”:生成器持有一个原型实例,通过 clone() 复制出类和状态都相同的新对象。Nystrom 认为这个设计模式本身在现代游戏中很少是最佳答案,更有价值的是“原型委托 (delegation)”用于数据建模,让数据条目继承另一条目的属性,减少重复。

意图与机制

以 Gauntlet 式游戏为例:每种怪物(Ghost、Demon、Sorcerer……)都要有生成器。为每个怪物类写一个生成器类,会得到一套平行的类层次。原型模式的做法是:给基类 Monster 加抽象的 clone(),每个子类返回一个类和状态都与自己相同的新对象;然后只需要一个 Spawner 类,内部持有一个隐藏的原型怪物,靠克隆它不断“盖章”出新怪物。(Game Programming Patterns — prototype)

它不仅克隆类,也克隆状态:只要换一个原型实例,就能得到“快的幽灵”“弱的幽灵”生成器。(Game Programming Patterns — prototype)

何时使用,以及为什么书里不太推荐

  • 每个怪物类仍要实现 clone(),代码量和写生成器类差不多;深拷贝还是浅拷贝(克隆恶魔时要不要克隆它的草叉)也是语义难题。(Game Programming Patterns — prototype)
  • 例子本身是人为设定的:现代引擎很少为每种怪物建一个类,而是用 组件模式 和 类型对象 来表达不同种类的实体。(Game Programming Patterns — prototype)
  • 就算真有多个类,也有更轻的替代:存一个生成函数指针;用模板把怪物类型作为类型参数;在类本身就是对象的动态语言里直接传入类。Nystrom 说他还没遇到原型设计模式是最佳答案的情况。(Game Programming Patterns — prototype)
  • 模板方案还需要一个非模板的 Spawner 基类:否则 SpawnerFor<T> 的各个实例化之间没有共同的父类型,所有处理生成器的代码都得自己带模板参数。(Game Programming Patterns — prototype)
  • 作者顺带指出,类型对象 在某种程度上也是弥补“类型不是一等公民”的手段;但即使语言有一等类型它仍然有用,因为它让你自己定义“类型”的语义。(Game Programming Patterns — prototype)

原型作为语言范式

Self 语言没有类:类式语言里状态存在实例中、行为要经由类间接查找(C++ 虚函数即经由 vtable),Self 取消了这一区分,对象自身可以同时包含状态和行为。查找字段或方法时先看对象自身,找不到就沿“父对象”委托 (delegation) 查找(实际 Self 支持多个父对象,父对象只是特殊标记的字段,可在运行时更换,即“动态继承”);创建新对象靠克隆。Self 极其动态,为了跑得够快而发明的即时编译、垃圾回收和方法分派优化技术,后来成了许多动态语言提速的基础。JavaScript 受 Self 启发,对象可委托给原型对象,但没有克隆操作,构造函数 + new + 原型上放方法的惯用法实际上更像类。Nystrom 自己做过纯原型语言,结论是实现简单,但把复杂性推给了使用者,写起来不愉快;人们更喜欢定义清楚的“种类”。(Game Programming Patterns — prototype)

真正有用的地方:用原型委托做数据建模

游戏里数据占比越来越大,数据本身也需要复用手段。做法是:如果一条数据有 "prototype" 字段,它就委托给该名字对应的条目,自身没有的属性回退到原型上查找。注意 "prototype" 是元数据而不是数据:它是“代表哥布林的数据对象”的属性,不是哥布林本身的属性。这样“哥布林法师”“哥布林弓手”只写自己的差异,生命值、抗性和弱点都来自“哥布林步兵”。值得注意的是,不必另建一个抽象的“哥布林基类”,直接选最简单的那个具体条目作为原型即可;对 Boss 和独特道具(“斩首之剑”就是带加成的长剑)尤其自然。(Game Programming Patterns — prototype)

下图表示数据条目之间的单委托链:查找属性时先看条目自身,找不到再沿箭头到原型条目查找。

flowchart BT
  W["goblin wizard:spells"] -->|prototype| G["goblin grunt:minHealth 20, maxHealth 30, resists, weaknesses"]
  A["goblin archer:attacks"] -->|prototype| G
  S["Sword of Head-Detaching:damageBonus 20"] -->|prototype| LS[longsword]

图中箭头只表示“查不到时去哪里找”,并不复制数据;修改哥布林步兵的血量,两个派生条目自动跟着变。

取舍与坑

  • 克隆的深浅语义必须逐类想清楚。
  • 书中的数据模型只用了最简单的单委托 (single delegation),就已消除大量重复。
  • 原型模式与类型对象解决的是同一个高层问题——在多个对象之间共享数据和行为,只是手段不同。(Game Programming Patterns — type-object)

代码草图(C++)

按书中示例整理:原型版生成器,以及书中给出的两种替代(生成函数、模板)。数据委托部分书中只给了 JSON,没有给 C++ 实现。

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_;
};

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

// 替代 1:生成函数
typedef Monster* (*SpawnCallback)();
Monster* spawnGhost() { return new Ghost(); }
// Spawner 改为保存 SpawnCallback spawn_,spawnMonster() 返回 spawn_()

// 替代 2:模板(需要非模板基类 Spawner 作为共同父类型)
template <class T>
class SpawnerFor : public Spawner {
public:
  virtual Monster* spawnMonster() { return new T(); }
};
Spawner* ghostSpawner2 = new SpawnerFor<Ghost>();

UE 中的对应

  • 类默认对象 (CDO) 作为模板 → UObject 与反射系统:每个 UClass 维护一个由构造函数生成、此后只读的“默认模板”CDO。它还有一个与原型委托相似的性质:关卡中已放置的实例,如果某属性值仍等于旧 CDO 的值,加载时会更新为新 CDO 的值;单独改过的实例则保留自己的值(官方例子:敌人默认血量由 100 改为 150,仍为 100 的敌人变成 150,单独设成 500 的保持 500)。这与“未覆盖的属性回退到原型”在效果上相似,但机制是加载时按默认值传播,不是运行时逐次委托查找。
  • 关于 Data Asset 是否能被继承,见 类型对象 的 UE 对应一节。

GEA 的视角:生成器、默认值与 CDO(卷 II 17.3.3.2)

GEA 第 17 章描述的默认值机制可以看作原型委托的"数据文件版":生成器 (spawner) 只记录与类型模式默认值不同的属性,缺失的键在生成时回退到默认值,因此修改默认值会自动影响所有没有覆盖该属性的已放置实例(兽人默认生命 20 改为 30 的例子)。本页 UE 对应一节记录的 CDO 默认值传播(敌人血量 100 改为 150)效果相同;本库 UObject 与反射系统 还记录了 UE 序列化不写出等于默认值的属性,与 GEA 的"省略键"做法一致。详见 世界块数据格式与生成器。

GEA 同时说明,C++ 不能仅凭类名创建实例,所以序列化系统都需要类工厂(类名/id 到创建函数的映射表);"克隆原型"是另一种不依赖类名的创建方式,两者解决的是同一个问题。

  1. Game Programming Patterns — prototype
  2. Game Programming Patterns — prototype(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) prototype 章,gameprogrammingpatterns.com/prototype.html
  4. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  5. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.3.3.2 节

相关页面

游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发 · UE5UObject 与反射系统UObject & Reflection System虚幻引擎 (Unreal Engine) 的对象基类是 UObject。在 C++ 类、属性、函数上加 UCLASS/UPROPERTY/UFUNCTION 等宏,再由构建前的预处理工具虚幻头文件工具 (Unreal Header Tool, UHT) 扫描头文件生成代码,引擎就能在运行时“看见”这些类型——这就是反射系统 (Reflection System)。垃圾回收、序列化、编辑器面板、蓝图、网络复制都建立在它之上。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发世界块数据格式与生成器World Chunk Data Formats & Spawners世界块中的动态部分要为每个游戏对象记录两样东西:属性初始值和对象类型。GEA 比较了三种存储方式:二进制对象映像(生成最简单,但要处理指针、虚表和字节序,且对变化很脆弱)、序列化描述(XML/JSON/私有二进制格式,配合反射与类工厂),以及生成器 (spawner) + 类型模式 (type schema):生成器是只含类型 id 和键值对的轻量数据块,彻底切断世界编辑器与运行时代码的耦合;模式里的默认值配合"与默认值相同就省略键"可以让默认值的修改自动传播到旧实例(GEA4 卷 II 17.3)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原