第 19 章:交互菜单与雨效
第 19 章:交互菜单与雨效:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 19 章:交互菜单与雨效”如何用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同
- 能逐项定位 交互菜单(interactive menu)、开始 暂停 重启 退出(start pause restart quit)、雨效(making it rain)、graphics 与 update(graphics and update),说明它们位于源码、运行状态还是可见输出边界
- 能按 捕获按键边沿 → 验证迁移 → 提交阶段 → 更新可运行对象 → 绘制对应界面 重放“开始后暂停”,持续检查“菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同”
- 能注入“按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象”,从输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 19 章:交互菜单与雨效”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 19 章:交互菜单与雨效”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“交互菜单(interactive menu)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 19 章:交互菜单与雨效”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 19 章:交互菜单与雨效”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 19 章:交互菜单与雨效”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
交互菜单(interactive menu)
这个正式目录节点落在 菜单协议 边界:Start、Pause、Restart、Quit 的合法迁移。在本页中,它参与“用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同”。验收时保存输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录,不能只凭最终画面判断。
开始 暂停 重启 退出(start pause restart quit)
这个正式目录节点落在 游戏阶段 边界:菜单、运行、暂停、结束与退出。在本页中,它参与“用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同”。验收时保存菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同,不能只凭最终画面判断。
雨效(making it rain)
这个正式目录节点落在 雨效组合 边界:Rain GameObject 的状态、更新和绘制。在本页中,它参与“用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同”。验收时保存输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录,不能只凭最终画面判断。
graphics 与 update(graphics and update)
这个正式目录节点落在 菜单协议 边界:Start、Pause、Restart、Quit 的合法迁移。在本页中,它参与“用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同”。验收时保存菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | 从 Menu 触发 Start,再用单次按键边沿触发 Pause |
| 边界或恢复场景 | 从 GameOver 触发一次 Restart |
| 必须保持 | 菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同 |
| 单一故障 | 按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象 |
| 可观察证据 | 输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“开始后暂停”会怎样穿过 菜单协议 → 游戏阶段 → 雨效组合,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 19 章:交互菜单与雨效:可运行切片
用明确阶段处理开始、暂停、重启、退出,并把雨滴作为可组合 GameObject 接入 update 与 graphics 合同
选择输入或构建场景
定位正式概念
bcgp3-19 · 当前切片
交互菜单(interactive menu):从 Menu 触发 Start,再用单次按键边沿触发 Pause
Start、Pause、Restart、Quit 的合法迁移
↓
菜单、运行、暂停、结束与退出
↓
Rain GameObject 的状态、更新和绘制
可验收结果
世界实例保持,update 停止,菜单与事件仍工作
实验二:逐步执行状态轨迹
依次执行 捕获按键边沿 → 验证迁移 → 提交阶段 → 更新可运行对象 → 绘制对应界面。每一步只选中一个阶段,并持续检查“菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同”。
Deterministic state trace
第 19 章:交互菜单与雨效:状态执行轨迹
菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同
输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录
实验三:单一故障与同输入恢复
注入“按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象”,定位第一项不一致;撤销后用完全相同的“结束后重启”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 19 章:交互菜单与雨效:故障注入与恢复
单一故障:按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象
第 1 次重放使用同一源码、资源、初始状态和输入序列
菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同
输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“暂停不是盖一张图”开始
↡允许玩家导航选项、确认命令并得到清晰焦点反馈,同时遵守当前游戏阶段约束的菜单。不仅是几行 Text。暂停画面即使盖住世界,若 Player、平台、计时器和雨滴仍在 update,恢复时世界已经变化。菜单必须与 GamePhase 共同决定谁拥有输入、谁推进时间、哪些转换合法。
↡Home、Playing、Paused 和 GameOver 等互斥阶段,以及阶段之间允许发生的转换。把开始、暂停、重启、退出放入一张转换表,避免每个按键处理器各改一半状态。
开始、暂停、重启、退出不是同一个动作
官方的 start pause restart quit(开始 暂停 重启 退出)各有不同前置条件和副作用:
- Start 只在 Home 创建一局完整世界,成功后进入 Playing。
- Pause 只从 Playing 进入 Paused,保留世界与 elapsed。
- Resume 从 Paused 返回 Playing,不重置世界,但重启帧时钟。
- Restart 从 Paused 或 GameOver 重建全部局内状态,最后才进入 Playing。
- Quit 请求关闭应用,不伪装成返回 Home,也不继续提交世界命令。
让 UI 与状态控制分离。
enum class MenuCommand { Start, Resume, Restart, Home, Quit };
std::optional<GamePhase> nextPhase(GamePhase phase, MenuCommand command)
{
if (phase == GamePhase::Home && command == MenuCommand::Start)
return GamePhase::Playing;
if (phase == GamePhase::Paused && command == MenuCommand::Resume)
return GamePhase::Playing;
if (phase == GamePhase::Playing && command == MenuCommand::Home)
return GamePhase::Paused;
if (command == MenuCommand::Restart &&
(phase == GamePhase::Paused || phase == GamePhase::GameOver))
return GamePhase::Playing;
return std::nullopt;
}上例只展示转换查询;Pause 应建成独立命令而不是借用 Home。非法命令不产生副作用,开发模式记录 phase 与 command。
导航、焦点与确认使用事件边沿
↡菜单中当前由键盘或手柄选中、将接收确认操作的唯一可用条目。焦点索引只遍历可用项。隐藏或禁用 Restart 时,向下导航应跳过它;选项列表变化后重新验证焦点。
void Menu::handle(const sf::Event& event)
{
if (event.type != sf::Event::KeyPressed)
return;
if (event.key.code == sf::Keyboard::Up)
focusPreviousEnabled();
else if (event.key.code == sf::Keyboard::Down)
focusNextEnabled();
else if (event.key.code == sf::Keyboard::Enter)
pending_ = focusedCommand();
}按键重复策略要明确:可接受操作系统重复用于上下导航,但 Enter 确认必须去重。菜单打开时清除 PlayerIntent,避免暂停前按住方向键在恢复后立刻移动。
转换采用“准备后提交”
↡目标阶段所需资源、世界和界面全部准备成功后,才一次替换当前 GamePhase 的过程。Start 与 Restart 先在局部构建候选 World:Player、初始平台、相机、分数、声音、雨粒子池全部成功后再替换旧世界。失败则保留 Home 或 GameOver,并显示可恢复错误。
bool Game::restart()
{
World candidate{worldFactory_.createInitialWorld()};
sound_.reset();
input_.clearEdges();
world_ = std::move(candidate);
elapsed_ = 0.0F;
phase_ = GamePhase::Playing;
frameClock_.restart();
return true;
}Quit 不需重建世界,但要让主循环在本帧余下阶段停止 update 和 draw,不能关闭窗口后继续访问 RenderTarget。
暂停冻结逻辑时间而非界面
↡只在 Playing 推进并供物理、动画、雨效和计时器使用的受限时间步。Paused 仍轮询窗口事件、更新菜单焦点和绘制冻结快照,但不推进世界。恢复时 frameClock.restart(),丢弃暂停期间墙钟时长。
先预测再运行:进入 Paused 后等待十秒,记下暂停前后的 Player 坐标、elapsed、活跃雨滴位置和恢复首帧 dt。正确结果应是前三项完全不变,恢复首帧 dt 接近正常帧时长;若雨滴变化,说明 RainUpdate 绕过了 GamePhase;若 dt 接近十秒,说明恢复前没有重启帧时钟。
↡进入 Paused 时保存的最后一次提交世界状态,用于持续绘制静止背景而不更新玩法。可在其上绘制半透明遮罩和选项。不要把遮罩放进 main view;它属于 HUD View。
雨效是对象组合而非 main 特例
↡由许多短寿命雨滴组成、在相机范围内生成移动并批量绘制的环境表现。官方 making it rain 不是在 main 里写一段粒子循环。它沿第 15 章的 GameObject composition(GameObject 组合)使用 Graphics 与 Update:RainUpdate 管发射、位置、寿命和回收,RainGraphics 只读取提交后的实例生成顶点。
↡推进雨滴生成、速度、位置与寿命,不依赖窗口和纹理的更新行为。与
↡把可见雨滴快照转换为 VertexArray 并提交绘制的图形行为。共享 RainState,但所有权由 Rain GameObject 持有。
固定池和发射累加器
↡预先分配固定数量雨滴槽位并反复激活回收,限制内存和最坏更新成本的容器。避免每秒大量分配。发射率使用小数累加器,使 60 Hz 与 120 Hz 在相同秒数内产生近似相同数量。
void RainUpdate::update(float dt, const sf::FloatRect& spawnBounds)
{
spawnBudget_ += dropsPerSecond_ * dt;
while (spawnBudget_ >= 1.0F) {
if (RainDrop* drop = acquireInactive())
resetDrop(*drop, spawnBounds, rng_);
spawnBudget_ -= 1.0F;
}
for (RainDrop& drop : drops_) {
if (!drop.active)
continue;
drop.position += drop.velocity * dt;
drop.life -= dt;
if (drop.life <= 0.0F || outsideRecycleBounds(drop.position))
drop.active = false;
}
}池满时丢弃本次生成,不无界扩容。雨效是可降级表现,不能让粒子不足阻塞玩法。
生成区域跟随相机,速度属于世界
↡位于主相机可见区域上方并带横向余量,用于初始化新雨滴位置的世界矩形。随 main Camera 移动,但雨滴生成后使用世界坐标继续下落。若每帧把所有雨滴绑回相机,它们会像 HUD 一样粘在屏幕上。
sf::FloatRect rainSpawnBounds(const Camera& camera)
{
const sf::FloatRect visible{camera.visibleBounds()};
return {
visible.left - 100.0F,
visible.top - 180.0F,
visible.width + 200.0F,
120.0F,
};
}应大于生成区,避免边缘雨滴反复激活失活。radar view 通常不画雨,避免辅助视图被噪声淹没。
批处理而非每滴一次 draw
↡把所有可见雨滴几何集中到一个 VertexArray,以少量 draw calls 提交的策略。每滴可用两点线段或四点窄四边形。RainGraphics 预留最大顶点容量,按活跃雨滴重写,颜色 alpha 随寿命变化。
void RainGraphics::submit(const RainSnapshot& rain, DrawQueue& queue)
{
vertices_.clear();
for (const RainDropView& drop : rain.activeDrops) {
appendStreak(vertices_, drop.position, drop.length, drop.alpha);
}
queue.push(DrawItem::worldOverlay(RenderLayer::Weather, vertices_));
}先测量活跃数、顶点数和 calls。把所有天气强行合成一个巨大系统未必更快,按真实瓶颈决定。
菜单、世界与雨效的一帧顺序
↡当前阶段对事件路由、逻辑更新、状态提交与绘制顺序的统一约定。轮询事件后,Home/Paused/GameOver 把导航事件交给 Menu,Playing 把意图交给 Player;任何阶段都处理关闭事件。只有 Playing 更新 World 和 RainUpdate。随后集中提交世界与菜单命令,生成 RenderSnapshot 与 RainSnapshot。绘制顺序为 main 世界、天气、radar、HUD、菜单覆盖层。
状态转换发生后,本帧不再执行旧阶段剩余逻辑。尤其 Quit 后立即结束,Restart 后使用新世界快照,Resume 后从下一帧开始推进。
可复现测试与验收
↡使用固定 seed、固定逻辑步长和同一输入序列时,雨滴与阶段转换产生相同结果的性质。雨效虽然不影响玩法,但可复现能定位池泄漏和帧率依赖。随机数生成器由局内 seed 构造,不使用每滴独立随机设备。
- 对每个 GamePhase 枚举所有 MenuCommand,断言合法目标与副作用;非法组合保持原状态。
- 按住 Enter 只确认一次;打开菜单时焦点落在可用项;禁用项不会收到确认。
- Paused 持续十秒后 world、elapsed、雨滴位置不变,菜单焦点仍可移动。
- Restart 重置 Player、平台、计时、声音、输入、命令队列和雨滴池,最后才进入 Playing。
- 固定 seed 分别以不同渲染帧率运行相同固定逻辑步,比较活跃雨滴位置与数量。
- 统计池容量、活跃峰值、顶点数和 draw calls,长时间运行不增加分配量。
小结
- 交互菜单与 GamePhase 共同定义开始、暂停、重启、退出的合法状态转换
- 菜单导航使用事件边沿,阶段转换时清输入、事件和延迟命令
- 暂停冻结逻辑时间和世界,但窗口事件、菜单与冻结快照仍工作
- 雨效通过 RainUpdate 与 RainGraphics 实现 Graphics and Update 分离
- 固定雨滴池、发射累加器和顶点批处理让效果有界、可复现、可测量
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 交互菜单
支持导航、焦点、确认和状态约束的菜单。
- GamePhase 状态机
定义 Home、Playing、Paused、GameOver 与合法转换的模型。
- 菜单焦点
当前由导航选中并接收确认的唯一可用条目。
- 菜单输入边沿
用于导航与确认的一次性按键变化。
- 状态提交
目标阶段准备成功后一次替换当前阶段的过程。
- 阶段隔离
清除不能跨状态边界保留的输入、事件、声音和命令。
- 逻辑时间
- 只在 Playing 推进的游戏时间步。
- 冻结快照
暂停时用于继续绘制的最后一次提交世界状态。
- 雨效
由短寿命雨滴组成并在相机范围生成、移动、批量绘制的效果。
- RainUpdate
管理雨滴生成、运动、寿命和回收的更新行为。
- RainGraphics
把雨滴快照转换为批量几何的图形行为。
- 雨滴池
- 固定容量并反复激活回收的粒子容器。
- 发射累加器
把每秒发射率按 dt 累积为粒子生成次数的值。
- 雨滴生成区
主相机上方用于初始化雨滴位置的世界区域。
- 雨滴回收区
- 雨滴越界后失活的世界范围。
- 雨滴批处理
把可见雨滴合并到 VertexArray 的绘制策略。
- RainSnapshot
供绘制只读的活跃雨滴表现数据。
- 阶段帧协议
按当前阶段规定输入、更新、提交和绘制顺序的约定。
- 雨效确定性
固定 seed、步长和输入时得到相同雨滴结果的性质。
练习
- 问题 1:设计菜单转换表。 列出 Home、Playing、Paused、GameOver 对 Start、Pause、Resume、Restart、Quit 的合法性和副作用。
- 问题 2:定位暂停污染。 复现暂停十秒后恢复时 Player 瞬移、雨滴跳屏的问题,并给出修复顺序。
- 问题 3:验证雨效有界。 设计一个覆盖发射率、池容量、确定性与 draw calls 的长时间测试。