第 15 章:Run!、Factory 与组件式对象
第 15 章:Run!、Factory 与组件式对象:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 15 章:Run!、Factory 与组件式对象”如何让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象
- 能逐项定位 无尽跑酷(endless runner)、factory 类(factory class)、继承与多态(inheritance and polymorphism)、设计模式(design patterns)、实体组件系统(entity component system),说明它们位于源码、运行状态还是可见输出边界
- 能按 解析请求 → 加载依赖 → 组装组件 → 验证合同 → 提交所有权 重放“创建完整平台”,持续检查“半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同”
- 能注入“Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体”,从创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 15 章:Run!、Factory 与组件式对象”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 15 章:Run!、Factory 与组件式对象”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“无尽跑酷(endless runner)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 15 章:Run!、Factory 与组件式对象”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 15 章:Run!、Factory 与组件式对象”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 15 章:Run!、Factory 与组件式对象”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
无尽跑酷(endless runner)
这个正式目录节点落在 创建请求 边界:实体类型、资源键与生成参数。在本页中,它参与“让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象”。验收时保存创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用,不能只凭最终画面判断。
factory 类(factory class)
这个正式目录节点落在 Factory 装配 边界:Transform、Update、Graphics 与验证。在本页中,它参与“让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象”。验收时保存半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同,不能只凭最终画面判断。
继承与多态(inheritance and polymorphism)
这个正式目录节点落在 世界提交 边界:unique_ptr 所有权和统一主循环。在本页中,它参与“让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象”。验收时保存创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用,不能只凭最终画面判断。
设计模式(design patterns)
这个正式目录节点落在 创建请求 边界:实体类型、资源键与生成参数。在本页中,它参与“让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象”。验收时保存半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同,不能只凭最终画面判断。
实体组件系统(entity component system)
这个正式目录节点落在 Factory 装配 边界:Transform、Update、Graphics 与验证。在本页中,它参与“让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象”。验收时保存创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | Factory 收到合法类型、纹理与 Transform 参数 |
| 边界或恢复场景 | 创建请求引用不存在的纹理键 |
| 必须保持 | 半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同 |
| 单一故障 | Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体 |
| 可观察证据 | 创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“创建完整平台”会怎样穿过 创建请求 → Factory 装配 → 世界提交,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 15 章:Run!、Factory 与组件式对象:可运行切片
让 Factory 验证并组装 GameObject、Transform、Update 与 Graphics 行为,用 unique_ptr 原子提交完整对象
选择输入或构建场景
定位正式概念
bcgp3-15 · 当前切片
无尽跑酷(endless runner):Factory 收到合法类型、纹理与 Transform 参数
实体类型、资源键与生成参数
↓
Transform、Update、Graphics 与验证
↓
unique_ptr 所有权和统一主循环
可验收结果
完整对象一次性进入世界并响应统一 update/draw
实验二:逐步执行状态轨迹
依次执行 解析请求 → 加载依赖 → 组装组件 → 验证合同 → 提交所有权。每一步只选中一个阶段,并持续检查“半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同”。
Deterministic state trace
第 15 章:Run!、Factory 与组件式对象:状态执行轨迹
半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同
创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用
实验三:单一故障与同输入恢复
注入“Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体”,定位第一项不一致;撤销后用完全相同的“缺失图形资源”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 15 章:Run!、Factory 与组件式对象:故障注入与恢复
单一故障:Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体
第 1 次重放使用同一源码、资源、初始状态和输入序列
半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同
创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“增加一种平台为何不该改主循环”开始
第四个项目
↡本书的无尽跑酷项目,玩家必须持续前进并避开不断消失或生成的平台。包含玩家、平台、雨滴、火球、相机、菜单和动画。若每种对象都让 main 增加一组特殊 if,项目会快速失控。本章先建立“对象组合行为,循环只调接口”的骨架。
↡把创建、更新、渲染和销毁的共同流程固定下来,而把具体行为交给可替换对象的架构。允许同一循环处理不同实体,但书中的设计仍以 GameObject 为中心,不等同于完整 ECS。
GameObject 的最小共同状态
↡Run 世界中的一个实体实例,拥有稳定 id、共享 Transform 和可选 Update/Graphics 行为。不应成为塞满所有功能的基类。共同状态只放身份、位置、活动标志等真正跨行为共享的数据。
using GameObjectId = std::uint64_t;
struct Transform {
sf::Vector2f position{};
sf::Vector2f scale{1.0F, 1.0F};
};
class GameObject {
public:
GameObject(GameObjectId id,
std::unique_ptr<UpdateBehavior> update,
std::unique_ptr<GraphicsBehavior> graphics);
void update(float dt, const UpdateContext& context);
void draw(sf::RenderTarget& target) const;
private:
GameObjectId id_;
Transform transform_;
std::unique_ptr<UpdateBehavior> update_;
std::unique_ptr<GraphicsBehavior> graphics_;
};由 GameObject 拥有,避免 Update 和 Graphics 各存一份位置。组件指针独占拥有,销毁对象自动清理。
继承与多态只用于行为接口
↡派生类型复用或扩展基类型接口与实现的语言机制;应保持替换契约而非只为共享成员。与
↡通过基类引用或指针调用虚函数时,根据实际对象类型执行覆盖实现的机制。让循环只依赖 UpdateBehavior/GraphicsBehavior。
class UpdateBehavior {
public:
virtual ~UpdateBehavior() = default;
virtual void update(Transform& transform, float dt,
const UpdateContext& context) = 0;
};
class GraphicsBehavior {
public:
virtual ~GraphicsBehavior() = default;
virtual void draw(sf::RenderTarget& target,
const Transform& transform) const = 0;
};需要虚析构,因为通过基类 unique_ptr 销毁实际派生对象。接口不暴露 Game 全局对象,使用小型 Context 传入只读依赖和命令出口。
组合优于深继承树
不要建立 Entity -> MovingEntity -> LivingEntity -> Player 深链再让平台继承其中一半。Run 玩家可以组合 PlayerUpdate 与 AnimatedGraphics,平台组合 PlatformUpdate 与 TileGraphics,静态装饰只有 Graphics。
让更新和显示独立替换。代价是共享 Transform 和事件通信必须显式设计,不能让组件互相保存悬空裸指针。
void GameObject::update(float dt, const UpdateContext& context)
{
if (update_)
update_->update(transform_, dt, context);
}
void GameObject::draw(sf::RenderTarget& target) const
{
if (graphics_)
graphics_->draw(target, transform_);
}draw 为 const,图形行为不能推进动画时间;动画帧状态应在 update 中修改,draw 只选择已提交帧。
Factory 隐藏完整构造顺序
↡根据 archetype 请求验证参数、取得资源、组装行为并返回完整 GameObject 的创建组件。把构造复杂度从 main 移出,但不应成为能访问所有全局状态的万能对象。
enum class Archetype { Player, Platform, Decoration };
class GameObjectFactory {
public:
explicit GameObjectFactory(TextureProvider& textures);
std::unique_ptr<GameObject> create(Archetype type,
GameObjectId id,
sf::Vector2f spawn) const;
private:
TextureProvider& textures_;
};↡用于选择一组组件、资源和默认参数的实体模板标识。 不是 C++ 类型反射。资源 key、碰撞尺寸和速度可来自数据配置,Factory 验证后组装。
构造失败不发布半对象
Factory 先加载/查询必需纹理,验证 spawn 与尺寸,再在局部创建 Update 和 Graphics,最后构造 GameObject。任一步抛异常时 unique_ptr 自动清理,不向世界容器插入对象。
std::unique_ptr<GameObject> GameObjectFactory::create(
Archetype type, GameObjectId id, sf::Vector2f spawn) const
{
switch (type) {
case Archetype::Player:
return std::make_unique<GameObject>(
id,
std::make_unique<PlayerUpdate>(),
std::make_unique<SpriteGraphics>(textures_.get("player")));
case Archetype::Platform:
return std::make_unique<GameObject>(
id,
std::make_unique<PlatformUpdate>(),
std::make_unique<SpriteGraphics>(textures_.get("platform")));
case Archetype::Decoration:
return std::make_unique<GameObject>(
id, nullptr,
std::make_unique<SpriteGraphics>(textures_.get("decoration")));
}
throw std::invalid_argument{"unknown archetype"};
}要求 id 唯一。世界容器接受前检查重复 id,避免事件寻址冲突。
主循环对实体类型保持关闭
↡游戏每帧遍历对象接口执行输入、更新、事件提交和绘制,而不按具体实体类型分支的循环。只调用 update/draw:
for (auto& object : objects)
object->update(dt, updateContext);
commitCommands(objects, pendingCommands);
for (const auto& object : objects)
object->draw(window);新增 Fireball 或 Rain 只新增行为与 Factory 组装,不修改循环。删除仍在命令提交阶段,遍历中不 erase。对象 id 用于事件,unique_ptr 地址不作为存档身份。
↡软件实体对扩展开放、对修改关闭的设计目标;新增行为通过新实现和组合进入,不改稳定调度代码。不是绝对禁改,若接口契约本身不足,应正确演进而不是堆类型判断。
设计模式是约束语言
↡在特定上下文中反复出现的设计问题与权衡的命名方案,不是必须照抄的代码模板。本章使用 Factory 封装构造、Strategy/Component 表达可替换行为、Command 延迟世界修改。Observer 可用于声音/UI 消费事件,但项目规模小时简单事件列表更清楚。
模式组合也有成本:虚调用、堆分配、间接依赖和调试跳转。先用职责和测试证明需要,不因书上列出模式就给每个类加工厂和接口。
组件式对象与 ECS 不同
↡实体只是 id,组件按数据类型独立存储,系统批量处理具有所需组件集合的架构。通常简称
↡Entity Component System 的缩写,由无行为实体 id、数据组件存储和批处理系统组成。本书的 GameObject 持有多态组件,状态和调度仍以对象为中心,是“组件模式”,不是严格 ECS。
典型 ECS 中 Position/Velocity 是连续数组,MovementSystem 批量迭代两者,不为每个实体调用虚 update。它可改善大量同构实体的数据局部性,但带来查询、生命周期、调试、序列化和编辑器工具复杂度。
Run 规模下,多态组件对象更直观。只有性能剖析显示虚分派/分散分配或对象数量成为瓶颈,并且团队能维护 ECS 工具链时,迁移才合理。
对象间通信与 Context
UpdateBehavior 不应直接抓全局 Game 指针。UpdateContext 可提供只读输入、世界查询和命令写入器:
struct UpdateContext {
const InputSnapshot& input;
const WorldQuery& world;
CommandBuffer& commands;
};不保存越过帧的引用。组件通过 commands 请求生成/销毁/声音,世界在安全点提交,避免组件遍历中直接改容器。
循环依赖通过接口方向解决,而非只靠前向声明:Game owns objects,behaviors receive context,commands return to Game。所有权箭头单向,通信可双向通过值事件。
Run 项目的首个垂直切片
↡从输入、更新到绘制完整贯通的一小段可运行功能,用于证明架构而非一次搭完全部系统。只创建 Player、一个 Platform 和背景。PlayerUpdate 读取左右/跳跃输入,PlatformUpdate 暂为空或移动,Graphics 绘制。窗口关闭和资源失败仍正确处理。
不要在第 15 章提前完成声音、相机、动画、菜单和着色器;这些是第 16-21 章。当前验收是新增 Decoration 不改主循环、Factory 失败不插入半对象、对象销毁无泄漏、draw 不改变状态。
文件可按 GameObject, UpdateBehavior, GraphicsBehavior, GameObjectFactory, main 划分。过多一类一文件不是目标,边界清楚且构建依赖可控才是。
验证架构而不只看画面
Factory 对每个 archetype 测试组件组合、资源 key、初始 Transform 和失败回滚;重复 id 被世界拒绝。用 fake TextureProvider 模拟缺资源,不读真实磁盘。
多态测试通过基类 unique_ptr 调 update/draw,确认派生析构执行;空 update 的 Decoration 仍可绘制。CommandBuffer 在帧尾提交,生成和删除不会使正在遍历容器失效。
运行测试新增一种 StaticDecoration,只改一个 Graphics 实现和 Factory 配置,主循环 diff 应为零。性能记录对象数、堆分配、虚调用和缓存未命中,防止在无证据时迁移 ECS。
先预测:GameObject 只保存 UpdateBehavior 裸指针,而 Factory 返回后局部 unique_ptr 被销毁,会怎样?GameObject 留下悬空指针;组件所有权必须移动进对象,或由更长寿且明确的外部容器拥有。
小结
- Run! 是无尽跑酷项目,第 15 章先建立不依赖具体实体类型的对象和循环骨架
- GameObject 拥有稳定 id、Transform 和独占 Update/Graphics 行为,图形只读已提交状态
- 多态接口有虚析构,组合替代深继承;Context 显式提供依赖和命令出口
- Factory 验证资源和参数,在局部完整组装后以 unique_ptr 提交,失败不发布半对象
- 设计模式用于表达 Factory、Strategy/Component、Command 等权衡,不应制造上帝对象
- 组件式 GameObject 不是严格 ECS;是否迁移由对象规模、数据局部性和工具成本决定
练习
问题 1 GameObject、UpdateBehavior、GraphicsBehavior 分别拥有或修改什么?为什么 Transform 只能有一个事实来源?
问题 2 Factory 创建 Player 的异常安全顺序是什么?怎样证明失败没有半对象进入世界?
问题 3 本书组件式对象与 ECS 有何三点区别?何时值得迁移?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Run! 项目
玩家持续前进并避开动态平台的无尽跑酷项目。
- 组件式对象
对象组合可替换行为而主循环只依赖统一接口的架构。
- GameObject
拥有稳定 id、Transform 与 Update/Graphics 行为的世界实体。
- Transform
对象唯一的世界位置和缩放等共享空间状态。
- 继承
派生类型复用或扩展基类型接口和实现的机制。
- 多态
经基类指针或引用调用实际派生虚函数的机制。
- 抽象接口
含纯虚操作并规定派生实现调用契约的基类。
- 组合
通过拥有多个协作成员获得功能而非依赖深继承树。
- Factory 类
验证并组装组件、返回完整 GameObject 的创建组件。
- archetype
选择组件、资源与默认参数的一类实体模板标识。
- 工厂提交
局部完整构建后把 unique_ptr 所有权交给世界的过程。
- 通用主循环
只调用对象接口、不按具体实体类型分支的帧循环。
- 开闭原则
新增行为尽量通过扩展进入而不修改稳定调度代码的目标。
- 设计模式
反复设计问题、上下文与权衡的命名方案。
- 实体组件系统
实体为 id、组件按类型存储、系统批处理匹配组件的架构。
- ECS
- Entity Component System 的缩写。
- UpdateContext
一次更新期显式提供只读依赖和命令出口的参数对象。
- 垂直切片
从输入到绘制完整贯通的一小段可运行功能。