中EN
概念页

类型对象

Type Object

更新于 2026-10-12 约 11 分钟设计模式类型对象数据驱动怪物种类GPP

类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。

意图

通过创建一个类、让它的每个实例代表一种不同的对象类型,来灵活地创建新的“类”。(Game Programming Patterns — type-object)

书中的动机:RPG 里的怪物分“种族 (breed)”,种族决定起始血量和攻击文本。按常规 OOP 思路,每个种族一个 Monster 子类(Dragon 起始 230、Troll 48)。种族多到几百个、设计师又频繁调数值时,程序员就沦为“数据猴子”:收到邮件要把巨魔血量从 48 改成 52 → 改 Troll.h → 重新编译 → 提交 → 回邮件 → 重复。把“是一个 (is-a)”换成“有一个 (has-a)”种族,就把类型系统的一部分从写死的类层次搬进了运行时可定义的数据。(Game Programming Patterns — type-object)

机制

定义类型对象类和被类型化的对象类。每个类型对象实例代表一个逻辑类型;每个被类型化的对象保存一个指向描述其类型的类型对象的引用。实例特有的数据放在被类型化的对象里,同一概念类型共享的数据或行为放在类型对象里。引用同一个类型对象的对象,行为就像属于同一类型。这和子类化一样能共享数据与行为,但不需要一组写死的子类。(Game Programming Patterns — type-object)

书中的 Breed 还支持单继承:Breed 可以有父 Breed,自己没指定的属性(如生命值、攻击字符串)从父 Breed 继承,例如“巨魔弓手”“巨魔法师”继承自“巨魔”。Breed 也可以提供 newMonster() 这样的“构造函数”(即 GoF 的工厂方法 (Factory Method)),并把 Monster 的构造函数设为私有、Breed 设为友元,使它成为创建怪物的唯一途径。这样做的意义在于:构造函数只负责初始化,而游戏常常还想控制分配(自定义分配器或 对象池),newMonster() 正是放这段逻辑的地方。(Game Programming Patterns — type-object)

继承有两种实现:动态委托,每次取属性时“自己有值(非零或非空)就用自己的,否则问父 Breed”,运行时修改 Breed 也能正确反映,但要多存父指针、查找更慢;复制下来 (copy-down) 委托,在构造时把未覆盖的属性从父 Breed 拷贝下来,之后不再需要父指针,访问只是读字段,前提是 Breed 属性不再变化。(Game Programming Patterns — type-object)

作者还点明了一个有趣的事实:C++ 虚函数靠 vtable 实现,每个类一张表,每个实例持有指向它的指针。vtable 就是“种族对象”,vtable 指针就是怪物对种族的引用,所以 C++ 的类就是由编译器自动完成的、应用于 C 的类型对象模式。(Game Programming Patterns — type-object)

下图表示两层对象:每个怪物实例只存自身状态(当前血量),并指向自己的 Breed;Breed 之间可以有单继承链。

flowchart LR
  M1["Monster:当前血量 12"] --> B1["Breed:巨魔弓手(攻击字符串)"]
  M2["Monster:当前血量 30"] --> B2["Breed:巨魔法师"]
  M3["Monster:当前血量 25"] --> B0["Breed:巨魔(起始血量 25、攻击字符串)"]
  B1 -->|parent| B0
  B2 -->|parent| B0

图中数值取自书中的 JSON 示例:巨魔弓手和巨魔法师的 health 填 0,表示从巨魔继承,所以设计师只改巨魔一处,三个种族都会更新。左侧对象的数量随游戏运行变化,右侧的类型对象数量由数据文件决定,而且可以在不改代码的情况下增删。

何时使用

需要定义许多不同“种类”的东西,但把种类写进语言的类型系统又太死板,特别是:事先不知道需要哪些类型(例如可下载内容里会带来新种族);希望不重新编译、不改代码就能修改或新增类型。(Game Programming Patterns — type-object)

取舍与坑(Game Programming Patterns — type-object)

  • 类型对象要手动管理:以前编译器替你管理类的数据,现在要自己保证所有 Breed 都被实例化、在怪物需要时一直存在,并且每个新怪物都绑定到一个有效的 Breed。
  • 很难按类型定义“行为”:子类可以重写方法做任意计算,类型对象只能把方法换成一个成员变量(存下攻击字符串,而不是用代码算出来)。绕开的办法是:预先定义一组固定行为,由类型对象里的数据来选择(比如存一个函数指针);或者干脆用解释器或 字节码模式 把行为也做成数据。作者注意到,存函数指针其实就是在自己的类型对象里重新实现 vtable;他还认为游戏越来越数据驱动,因为瓶颈已从“把玩法塞进 64K 卡带”变成“用内容填满 DVD”,脚本等高层手段用一点运行时性能换来急需的生产力。

设计决策(Game Programming Patterns — type-object)

决策选项要点
类型对象封装还是暴露封装复杂度藏在内部;被类型化的对象可以有选择地覆盖行为(如濒死时换攻击字符串);但每项都要写转发方法
暴露外部代码可以不经实例直接使用类型对象(例如调用 Breed 的构造函数);但类型对象成为公开 API 的一部分
对象如何创建构造对象并传入类型对象调用方控制内存分配
调用类型对象上的“构造函数”类型对象控制分配,例如保证都来自 对象池
类型能否改变不能简单,好调试
能(怪物死后变僵尸)少创建对象,但要验证对象状态满足新类型的假设
继承无简单,但会重复(五十种精灵改五十处血量)
单继承简单易懂,是能力与简单性之间的甜点;查属性要沿链向上走(可以在构造时把继承来的值拷进子类型,即“复制下来”)
多重继承理论上最少重复,实际难以理解(“僵尸龙”同时继承“僵尸”和“龙”,哪些属性来自谁?),很少值得

与相邻模式的关系

代码草图(C++)

按书中示例整理:带“构造函数”和复制下来继承的 Breed,以及只能经由 Breed 创建、封装了类型对象的 Monster。

class Breed {
public:
  // 复制下来委托:构造时继承未覆盖的属性,之后不再需要父指针
  Breed(Breed* parent, int health, const char* attack)
  : health_(health), attack_(attack) {
    if (parent != NULL) {
      if (health == 0) health_ = parent->getHealth();
      if (attack == NULL) attack_ = parent->getAttack();
    }
  }

  Monster* newMonster() { return new Monster(*this); }   // “构造函数”工厂方法

  int         getHealth() { return health_; }
  const char* getAttack() { return attack_; }

private:
  int         health_;   // 起始血量
  const char* attack_;
};

class Monster {
  friend class Breed;                 // 只能经由 Breed::newMonster() 创建

public:
  const char* getAttack() {
    if (health_ < LOW_HEALTH) return "The monster flails weakly.";  // 封装时可选择性覆盖
    return breed_.getAttack();
  }

private:
  Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}

  int    health_;   // 当前血量(实例数据)
  Breed& breed_;    // 类型数据
};

Monster* monster = someBreed.newMonster();

// 另一种继承实现:动态委托(Breed 需要保存 parent_)
int Breed::getHealth() {
  if (health_ != 0 || parent_ == NULL) return health_;   // 覆盖
  return parent_->getHealth();                           // 继承
}

UE 中的对应

  • 数据资产 (Data Asset) → 资产管理与异步加载:非蓝图主资产 Data Asset 是“类的实例而非类”,访问更简单、更省内存,但不能被继承;道具、角色、地图等“目录型”数据建议用主资产 + Data Asset + 资产包。用一个 C++ 类的多个资产实例来表示多种道具或角色,就是类型对象;“不能被继承”则对应上表中的“无继承”选项,需要继承时要另选方案。
  • Gameplay Effect 资产与 Spec → Gameplay 能力系统:GE 是运行时不可变的资产,运行时传递的是包在外面的 GE Spec(实例数据)。不可变资产相当于类型对象,Spec 相当于被类型化的实例。
  • 标签作为类型词汇 → Gameplay 标签深入:Gameplay 标签可以表达“属性”(如 Character.Enemy.Zombie),并支持“父标签匹配子标签”的层级查询。它提供的是分类与查询,不是数据继承,这里只作补充参考。

GEA 的视角:类型模式与默认值(卷 II 17.3.3)

GEA 在世界编辑器与运行时之间使用的对象类型模式 (type schema) 是数据驱动的"类型对象":模式用文本文件定义某类型对象有哪些属性、各是什么数据类型、默认值和 GUI 编辑元数据(最小最大值、下拉选项),策划不需要程序员就能在编辑器中定义新类型模式。与本页的对应:

  • 模式继承:有的引擎允许模式像类一样继承(顶层模式提供类型和唯一 id),派生模式还能覆盖默认值(Vehicle 默认 TopSpeed 80 英里/小时,Motorcycle 改为 100),这正是本页"类型对象之间共享数据"的继承选项。
  • 默认值的传播:生成器里与默认值相同的属性干脆不写,默认值从 20 改为 30 后,所有未覆盖的旧实例自动采用新值(GEA 的兽人生命值例子)。
  • GEA 把这种设计称为"生成器与原型 (archetype)",它让新对象数据可以先于运行时实现存在。详见 世界块数据格式与生成器。
  1. Game Programming Patterns — type-object
  2. Game Programming Patterns — type-object(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) type-object 章,gameprogrammingpatterns.com/type-object.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 节

相关页面

游戏开发享元模式Flyweight Pattern享元模式 (Flyweight Pattern) 把对象的数据拆成可共享的“内在状态”和每个实例独有的“外在状态”,让大量对象共用一份内在状态。森林里成千上万棵树共用一份网格和纹理,以及瓦片地图中每格只存一个指向共享地形对象的指针,都是它的典型应用。游戏开发原型模式Prototype Pattern原型模式 (Prototype Pattern) 的核心思想是“一个对象可以生成与自己相似的对象”:生成器持有一个原型实例,通过 clone() 复制出类和状态都相同的新对象。Nystrom 认为这个设计模式本身在现代游戏中很少是最佳答案,更有价值的是“原型委托 (delegation)”用于数据建模,让数据条目继承另一条目的属性,减少重复。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发字节码模式Bytecode Pattern字节码模式 (Bytecode Pattern) 把行为编码成虚拟机 (VM) 能执行的指令序列,让行为拥有数据一样的灵活性:可以热加载、与引擎核心隔离(沙盒化),还可以由非程序员编写。它是全书最复杂的模式,代价是要写编译器或创作工具,调试器也用不上了,而且这类小语言容易无节制地膨胀。游戏开发 · UE5资产管理与异步加载Asset Management & Async LoadingUE 资产默认按引用自动加载:硬引用 (Hard Reference) 会让被引用资产随引用者一起加载,不加控制就可能在启动时加载全部内容。软引用 (Soft Reference,TSoftObjectPtr/FSoftObjectPath) 只存路径,配合流式管理器 (Streamable Manager) 异步按需加载;资产管理器 (Asset Manager) 进一步把内容划分为主资产 (Primary Asset) 和次资产 (Secondary Asset),用资产包 (Asset Bundle) 管理加载、卸载、烘焙分块与内存审计。游戏开发 · UE5Gameplay 能力系统Gameplay Ability SystemGameplay 能力系统 (Gameplay Ability System, GAS) 是 UE 用来构建「Actor 可拥有并反复触发的能力」的框架:能力系统组件 (Ability System Component, ASC) 是持有者,Gameplay 能力 (Gameplay Ability) 编排执行过程,属性集 (Attribute Set) 存数值,Gameplay 效果 (Gameplay Effect, GE) 改数值和标签,Gameplay 标签 (Gameplay Tags) 做状态与互斥,Gameplay Cue 负责表现。它内建复制与客户端预测,代价是概念多、上手重,综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。游戏开发世界块数据格式与生成器World Chunk Data Formats & Spawners世界块中的动态部分要为每个游戏对象记录两样东西:属性初始值和对象类型。GEA 比较了三种存储方式:二进制对象映像(生成最简单,但要处理指针、虚表和字节序,且对变化很脆弱)、序列化描述(XML/JSON/私有二进制格式,配合反射与类工厂),以及生成器 (spawner) + 类型模式 (type schema):生成器是只含类型 id 和键值对的轻量数据块,彻底切断世界编辑器与运行时代码的耦合;模式里的默认值配合"与默认值相同就省略键"可以让默认值的修改自动传播到旧实例(GEA4 卷 II 17.3)。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发 · UE5Gameplay 标签深入Gameplay Tags In DepthGameplay 标签 (Gameplay Tags) 是用 . 分层的用户定义标签,必须登记到标签字典(项目设置 .ini、数据表或 C++ 原生标签宏)。对象用 FGameplayTagContainer 持有多个标签并以 HasTag/HasAny/HasAll 判断(注意空集合语义),复杂条件用 FGameplayTagQuery 的叶/根表达式组合并配置在资产里。它是 GAS、AI 和各类玩法状态的通用词汇表;大项目可限制标签编辑权限,并用 IGameplayTagAssetInterface 免 Cast 取标签。游戏开发对象池Object Pool对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。

输入关键词开始搜索

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