第 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 参数

边界 1创建请求

实体类型、资源键与生成参数

边界 2Factory 装配

Transform、Update、Graphics 与验证

边界 3世界提交

unique_ptr 所有权和统一主循环

可验收结果

完整对象一次性进入世界并响应统一 update/draw

实验二:逐步执行状态轨迹

依次执行 解析请求 → 加载依赖 → 组装组件 → 验证合同 → 提交所有权。每一步只选中一个阶段,并持续检查“半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同”。

Deterministic state trace

第 15 章:Run!、Factory 与组件式对象:状态执行轨迹

逐步执行当前 1 / 5
始终保持

半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同

本步证据

创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用

实验三:单一故障与同输入恢复

注入“Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体”,定位第一项不一致;撤销后用完全相同的“缺失图形资源”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 15 章:Run!、Factory 与组件式对象:故障注入与恢复

单一故障:Factory 在资源加载完成前先把对象放入世界,随后失败留下缺少 Graphics 组件的实体

1. 固定构建与输入一致

第 1 次重放使用同一源码、资源、初始状态和输入序列

2. 程序状态一致

半构造对象不可进入世界;每个 GameObject 只有一个所有者,主循环只依赖统一更新与绘制合同

3. 诊断证据一致

创建请求、资源查找结果、组件清单、unique_ptr 移交点和每帧接口调用

4. 恢复判断一致

同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致

易错边界与工程取舍

从“增加一种平台为何不该改主循环”开始

第四个项目

包含玩家、平台、雨滴、火球、相机、菜单和动画。若每种对象都让 main 增加一组特殊 if,项目会快速失控。本章先建立“对象组合行为,循环只调接口”的骨架。

允许同一循环处理不同实体,但书中的设计仍以 GameObject 为中心,不等同于完整 ECS。

GameObject 的最小共同状态

不应成为塞满所有功能的基类。共同状态只放身份、位置、活动标志等真正跨行为共享的数据。

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 隐藏完整构造顺序

把构造复杂度从 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 不同

通常简称

本书的 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

一次更新期显式提供只读依赖和命令出口的参数对象。

垂直切片

从输入到绘制完整贯通的一小段可运行功能。

资料与写作方式声明

本章以Beginning C++ Game Programming, Third Edition, Chapter 15权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…