第 16 章:SoundEngine、游戏逻辑与对象通信
第 16 章:SoundEngine、游戏逻辑与对象通信:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 16 章:SoundEngine、游戏逻辑与对象通信”如何用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用
- 能逐项定位 soundengine 类(soundengine class)、游戏逻辑(game logic)、对象间通信(inter-object communication)、玩家(player)、工厂(factory),说明它们位于源码、运行状态还是可见输出边界
- 能按 更新 Player → 产生事件 → 路由消息 → 执行副作用 → 记录结果 重放“玩家落地”,持续检查“一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源”
- 能注入“Player 直接持有 SoundEngine 和 Factory 指针并在碰撞中同步调用,形成循环依赖和重复副作用”,从事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 16 章:SoundEngine、游戏逻辑与对象通信”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 16 章:SoundEngine、游戏逻辑与对象通信”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“soundengine 类(soundengine class)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 16 章:SoundEngine、游戏逻辑与对象通信”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 16 章:SoundEngine、游戏逻辑与对象通信”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 16 章:SoundEngine、游戏逻辑与对象通信”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
- SFML 2.6:Sounds and music 官方教程:在“第 16 章:SoundEngine、游戏逻辑与对象通信”中,核对 SoundBuffer 生命周期、Sound/Music 区别、listener 与声音空间化边界。
正式概念与运行状态合同
soundengine 类(soundengine class)
这个正式目录节点落在 领域状态 边界:Player、碰撞、得分与游戏阶段。在本页中,它参与“用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用”。验收时保存事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数,不能只凭最终画面判断。
游戏逻辑(game logic)
这个正式目录节点落在 通信边界 边界:事件、命令、接口与订阅关系。在本页中,它参与“用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用”。验收时保存一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源,不能只凭最终画面判断。
对象间通信(inter-object communication)
这个正式目录节点落在 副作用 边界:Factory 创建、SoundEngine 播放和界面反馈。在本页中,它参与“用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用”。验收时保存事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数,不能只凭最终画面判断。
玩家(player)
这个正式目录节点落在 领域状态 边界:Player、碰撞、得分与游戏阶段。在本页中,它参与“用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用”。验收时保存一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源,不能只凭最终画面判断。
工厂(factory)
这个正式目录节点落在 通信边界 边界:事件、命令、接口与订阅关系。在本页中,它参与“用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用”。验收时保存事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | Player 从空中进入平台接触并产生一次 Landed 事件 |
| 边界或恢复场景 | 用同一事件 ID 再投递一次 |
| 必须保持 | 一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源 |
| 单一故障 | Player 直接持有 SoundEngine 和 Factory 指针并在碰撞中同步调用,形成循环依赖和重复副作用 |
| 可观察证据 | 事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“玩家落地”会怎样穿过 领域状态 → 通信边界 → 副作用,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 16 章:SoundEngine、游戏逻辑与对象通信:可运行切片
用 SoundEngine、游戏逻辑、事件或命令完成对象间通信,让 Player 与 Factory 不形成双向硬引用
选择输入或构建场景
定位正式概念
bcgp3-16 · 当前切片
soundengine 类(soundengine class):Player 从空中进入平台接触并产生一次 Landed 事件
Player、碰撞、得分与游戏阶段
↓
事件、命令、接口与订阅关系
↓
Factory 创建、SoundEngine 播放和界面反馈
可验收结果
游戏逻辑与声音各消费一次,Player 不持有具体接收者
实验二:逐步执行状态轨迹
依次执行 更新 Player → 产生事件 → 路由消息 → 执行副作用 → 记录结果。每一步只选中一个阶段,并持续检查“一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源”。
Deterministic state trace
第 16 章:SoundEngine、游戏逻辑与对象通信:状态执行轨迹
一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源
事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数
实验三:单一故障与同输入恢复
注入“Player 直接持有 SoundEngine 和 Factory 指针并在碰撞中同步调用,形成循环依赖和重复副作用”,定位第一项不一致;撤销后用完全相同的“重复消息重放”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 16 章:SoundEngine、游戏逻辑与对象通信:故障注入与恢复
单一故障:Player 直接持有 SoundEngine 和 Factory 指针并在碰撞中同步调用,形成循环依赖和重复副作用
第 1 次重放使用同一源码、资源、初始状态和输入序列
一个领域事件只被有效订阅者消费一次,发布者不需要知道接收者具体类型,声音是事件结果而非规则来源
事件 ID、发布顺序、订阅者列表、命令执行日志、对象引用图和音效触发次数
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“谁有权宣布玩家已经落地”开始
第 15 章让 GameObject 组合 Update 与 Graphics 行为。本章继续完成 Run! 的
↡决定玩家运动、碰撞结果、得分、生成和终局转换的规则计算;它是玩法事实的唯一写入者。音频、HUD 和渲染只能观察已提交结果,不能各自重新判断“是否落地”。否则同一碰撞可能让规则层判定失败、声音层却播放成功音效。
↡在一次固定帧中按输入、模拟、检测、提交和通知顺序推进世界的阶段协议。把写操作集中在可检查的边界。先预测:若对象在遍历 vector 时立即删除自己,后续迭代器、对象地址和本帧事件会发生什么?
输入先变成 PlayerIntent
↡由输入层生成的玩家本帧意图值,只表达移动、跳跃或操作请求,不直接修改世界。隔离键盘 API 与 Player 规则。输入层可读取实时按键和事件边沿,但产物是普通值,因此回放和单元测试不需要创建窗口。
struct PlayerIntent {
float moveAxis{};
bool jumpPressed{};
};
PlayerIntent readIntent(const sf::Event* event)
{
const bool left{sf::Keyboard::isKeyPressed(sf::Keyboard::A)};
const bool right{sf::Keyboard::isKeyPressed(sf::Keyboard::D)};
return {
static_cast<float>(right) - static_cast<float>(left),
event && event->type == sf::Event::KeyPressed &&
event->key.code == sf::Keyboard::Space,
};
}水平移动是持续状态,跳跃使用按下边沿。若把跳跃也写成实时状态,按住空格可能在每次落地后立刻再次起跳。意图还应在菜单和暂停阶段被过滤,避免恢复游戏时消费旧事件。
Player 拥有状态,行为执行规则
↡Run 中由输入意图驱动、拥有速度和地面状态,并通过命令与事件影响世界的玩家对象。需要位置、速度、是否着地和生命等确定性状态。PlayerUpdate 读取意图与只读世界,计算候选速度,再由碰撞结果修正 Transform。
struct PlayerState {
sf::Vector2f velocity{};
bool grounded{};
int health{3};
};
void PlayerUpdate::update(Transform& transform,
PlayerState& player,
const PlayerIntent& intent,
float dt,
UpdateContext& context)
{
player.velocity.x = intent.moveAxis * runSpeed_;
if (intent.jumpPressed && player.grounded) {
player.velocity.y = -jumpSpeed_;
player.grounded = false;
context.events.push_back(PlayerJumped{ownerId_});
}
player.velocity.y += gravity_ * dt;
context.commands.push_back(MoveObject{
ownerId_, player.velocity * dt});
}使用过去式命名,因为事件不是“试着跳”的请求。jump 条件失败时不产生 PlayerJumped,声音和动画自然不会误触发。
SoundEngine 类只负责声音
↡拥有声音 Buffer、复用播放 voice 并把 GameEvent 映射为音效策略的独立音频服务。启动时加载资源,运行时不能在每次跳跃时从磁盘读文件。sf::Sound 借用 sf::SoundBuffer,所以 Buffer 的生命周期必须长于所有正在播放的 voice。
enum class SoundId { Jump, Land, Hurt, Death };
class SoundEngine {
public:
explicit SoundEngine(const SoundCatalog& catalog);
void handle(const GameEvent& event);
void update();
void reset();
private:
const sf::SoundBuffer& buffer(SoundId id) const;
sf::Sound& acquireVoice();
std::unordered_map<SoundId, sf::SoundBuffer> buffers_;
std::array<sf::Sound, 16> voices_;
};先找 Stopped voice;全忙时按明确策略丢弃低优先级声音,或替换最旧的同类音效。不能每个事件都 new sf::Sound,也不能只用一个 voice 导致连续落地声互相截断。
从事件映射到一次播放
↡把玩法事实类型转换成 SoundId、音量、音高和抢占优先级的纯配置关系。让 SoundEngine 不读取 Player 内部状态。规则已经确认死亡,音频只决定播放哪段声音。
void SoundEngine::handle(const GameEvent& event)
{
const std::optional<SoundId> cue{std::visit(Overloaded{
[](const PlayerJumped&) { return std::optional{SoundId::Jump}; },
[](const PlayerLanded&) { return std::optional{SoundId::Land}; },
[](const PlayerHurt&) { return std::optional{SoundId::Hurt}; },
[](const PlayerDied&) { return std::optional{SoundId::Death}; },
[](const auto&) { return std::optional<SoundId>{}; },
}, event)};
if (!cue)
return;
sf::Sound& voice{acquireVoice()};
voice.setBuffer(buffer(*cue));
voice.play();
}应保证死亡提示不被雨声挤掉。reset() 停止所有 voice 并清空待消费事件;销毁时先销毁 Sound,再销毁 Buffer,可通过成员声明顺序或显式停止保证。
对象间通信不用长期裸指针
↡Player、平台、触发器和世界服务之间交换查询、请求与事实,而不共享可随意修改的内部状态。最危险的做法是 Platform 保存 Player 裸指针、Player 保存当前 Platform 裸指针。对象一旦由命令删除,另一方下一帧就可能解引用悬空地址。
↡世界为每个对象分配的稳定整数身份,跨帧通信先用它查询当前对象是否仍存在。不是 vector 下标。容器重排不改变 id;查询返回空值表示目标已离开世界,调用者必须正常处理。
事件、命令与查询各有方向
↡描述已经发生的玩法事实、可供声音 HUD 和统计等多个消费者只读处理的值。是向外通知;
↡请求世界在安全提交点生成、移动、伤害或销毁对象的值,提交时仍需校验目标。是向世界请求;只读查询则回答“附近地面在哪里”,不暴露写权限。
using WorldCommand = std::variant<
MoveObject,
SpawnObject,
DamageObject,
DestroyObject>;
struct UpdateContext {
const ReadOnlyWorld& world;
std::vector<WorldCommand>& commands;
std::vector<GameEvent>& events;
};避免更新对象 A 时删除对象 B 导致遍历失效。若 A、B 同帧都伤害同一目标,提交器按明确规则合并;结果不应因 vector 顺序改变。
Factory 把 Player 的依赖一次装完整
第 15 章的
↡验证资源与参数、组装对象行为并只返回完整 GameObject 的创建边界。在本章加入 PlayerState、PlayerUpdate、PlayerGraphics 和声音所需事件能力。Factory 不把 SoundEngine 指针注入 Player;否则规则对象可绕过事件协议直接播放。
std::unique_ptr<GameObject> GameObjectFactory::createPlayer(
GameObjectId id, sf::Vector2f spawn) const
{
PlayerState state{};
state.health = config_.startingHealth;
auto update = std::make_unique<PlayerUpdate>(
id, config_.runSpeed, config_.jumpSpeed, config_.gravity);
auto graphics = std::make_unique<PlayerGraphics>(
textures_.get(TextureId::Player));
return std::make_unique<GameObject>(
id, Transform{spawn}, std::move(state),
std::move(update), std::move(graphics));
}应由测试覆盖。缺纹理、非法生命或重复 id 时构造失败,世界仍保持原状;成功后对象所有权一次移动进世界。
一帧的完整提交顺序
↡在对象更新结束后统一应用命令、生成最终事件并发布渲染快照的原子阶段。建议顺序如下:
- 收集事件边沿并生成 PlayerIntent。
- 用受限
dt更新 Player 和其他 GameObject,只追加命令与候选事件。 - 碰撞器计算接触,规则层确认落地、伤害和死亡事实。
- 世界验证 CommandBuffer,应用移动、生成和销毁。
- 产生提交后的 GameEvent;SoundEngine、HUD 和统计依次消费。
- 生成只读快照,交给下一章的图形与相机阶段。
若命令目标已不存在,提交器记录并忽略可容忍请求;若违反必须成立的不变量,则在开发构建中断言。不要让 SoundEngine 的播放失败回滚玩法结果,音频属于可降级表现层。
验证:不创建窗口也能测规则
↡记录输入意图、固定时间步和初始世界后可重复得到同一命令事件序列的性质。使 Player 规则测试只需伪造 ReadOnlyWorld:
- 地面上按一次 jump,恰好产生一个 PlayerJumped 和一个移动命令;空中按 jump 不产生事件。
- 两个对象同帧请求销毁同一平台,提交后只销毁一次,两个请求都不留下悬空引用。
- SoundEngine 收到 PlayerDied 时选择 Death cue;voice 池满时遵守抢占策略。
- Factory 缺 Player 纹理时不向世界发布对象;资源齐全时构造不变量全部成立。
- reset 后所有 voice 停止,事件队列为空,旧关卡声音不会进入新局。
故障诊断:沿事实边界定位
遇到“画面跳了但没有声音”,不要先在 Player 中补一行 play()。按链路检查:输入日志是否只有一个 jumpPressed 边沿;PlayerUpdate 是否在 grounded 为真时生成候选事件;世界提交后是否保留 PlayerJumped;SoundEngine 是否找到 Jump Buffer;voice 是否被更高优先级声音占用。每一处日志都带帧号、对象 id 和事件类型,才能判断事实在哪一段丢失。
若“声音响了但玩家没跳”,说明事件发布早于命令提交,或候选事件被误当成最终事实。把事件分成内部候选结果与外部 GameEvent,只有移动和碰撞提交成功后才发布后者。若“同一跳跃响两次”,检查多个消费者是否把事件重新放回队列,以及事件队列是否在每帧消费后清空。
对象删除故障同样沿边界查:命令收集时只记录 id,提交时查询 id,销毁后从索引移除,之后的命令得到“目标不存在”而不是地址。测试还应随机打乱对象存储顺序;若结果改变,说明某个规则仍依赖遍历顺序或在提交前读取了其他对象的半更新状态。
小结
- 游戏逻辑拥有玩法事实,输入只生成意图,表现系统只观察提交结果
- SoundEngine 类预加载 Buffer、复用有界 voice 池,并通过音效映射消费 GameEvent
- 对象间通信使用稳定 GameObjectId、GameEvent、WorldCommand 和只读查询
- Player 通过命令影响世界,通过过去式事件通知结果,不直接调用声音或 HUD
- Factory 负责一次构造完整 Player,不负责运行期对象协作
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 游戏逻辑
决定运动、碰撞、得分、生成和状态转换的规则层,是玩法事实的唯一写入者。
- 逻辑帧
按输入、模拟、检测、提交和通知顺序推进一次世界状态的阶段协议。
- PlayerIntent
输入层生成的普通意图值,表达移动与跳跃请求但不直接修改世界。
- Player
Run 中由意图驱动、保存速度和地面状态并通过命令事件协作的玩家对象。
- 玩家事件
规则确认起跳、落地、受伤或死亡后产生的过去式事实。
- SoundEngine 类
拥有 Buffer 与 voice 池、把玩法事件映射为声音策略的服务。
- voice 池
固定容量、循环复用的一组声音播放实例。
- 音效映射
从 GameEvent 类型到声音资源、音量和优先级的配置。
- 抢占策略
所有 voice 忙时决定保留、丢弃或替换声音的规则。
- 对象间通信
对象通过查询、请求和事实协作而不互相开放内部可写状态。
- GameObjectId
对象的稳定整数身份,不随容器重排变化。
- GameEvent
描述已经发生的玩法事实、供多个消费者只读处理的值。
- WorldCommand
请求世界在安全提交点修改对象的值。
- CommandBuffer
对象更新时收集、遍历结束后统一应用的命令序列。
- Factory
验证参数与资源并只返回完整对象的创建边界。
- 构造不变量
对象发布前必须满足的身份、状态、资源与行为约束。
- 帧提交
统一验证命令、应用世界变化并发布最终事件快照的阶段。
- 确定性
同一初始状态、输入和时间步重复得到同一命令事件序列的性质。
练习
- 问题 1:追踪跳跃音效链路。 画出一次 Space 按下从 PlayerIntent 到 voice.play 的数据流,并标出规则事实的提交点。
- 问题 2:区分事实和请求。 分析“生成平台”和“平台已生成”分别应使用哪种消息,并说明失败语义。
- 问题 3:验证生命周期。 为声音重置、Factory 构造失败和重复销毁各写一个测试场景及预期结果。