第 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

边界 1菜单协议

Start、Pause、Restart、Quit 的合法迁移

边界 2游戏阶段

菜单、运行、暂停、结束与退出

边界 3雨效组合

Rain GameObject 的状态、更新和绘制

可验收结果

世界实例保持,update 停止,菜单与事件仍工作

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

依次执行 捕获按键边沿 → 验证迁移 → 提交阶段 → 更新可运行对象 → 绘制对应界面。每一步只选中一个阶段,并持续检查“菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同”。

Deterministic state trace

第 19 章:交互菜单与雨效:状态执行轨迹

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

菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同

本步证据

输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录

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

注入“按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象”,定位第一项不一致;撤销后用完全相同的“结束后重启”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 19 章:交互菜单与雨效:故障注入与恢复

单一故障:按键保持期间每帧执行 restart,持续重建世界并泄漏旧对象

1. 固定构建与输入一致

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

2. 程序状态一致

菜单动作只触发一次阶段迁移;暂停时世界 update 停止但菜单绘制和事件处理继续,雨滴服从相同对象合同

3. 诊断证据一致

输入边沿、阶段迁移日志、世界实例 ID、对象数量、雨滴 update/draw 次数和析构记录

4. 恢复判断一致

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

易错边界与工程取舍

从“暂停不是盖一张图”开始

不仅是几行 Text。暂停画面即使盖住世界,若 Player、平台、计时器和雨滴仍在 update,恢复时世界已经变化。菜单必须与 GamePhase 共同决定谁拥有输入、谁推进时间、哪些转换合法。

把开始、暂停、重启、退出放入一张转换表,避免每个按键处理器各改一半状态。

开始、暂停、重启、退出不是同一个动作

官方的 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,避免暂停前按住方向键在恢复后立刻移动。

转换采用“准备后提交”

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。

暂停冻结逻辑时间而非界面

Paused 仍轮询窗口事件、更新菜单焦点和绘制冻结快照,但不推进世界。恢复时 frameClock.restart(),丢弃暂停期间墙钟时长。

先预测再运行:进入 Paused 后等待十秒,记下暂停前后的 Player 坐标、elapsed、活跃雨滴位置和恢复首帧 dt。正确结果应是前三项完全不变,恢复首帧 dt 接近正常帧时长;若雨滴变化,说明 RainUpdate 绕过了 GamePhase;若 dt 接近十秒,说明恢复前没有重启帧时钟。

可在其上绘制半透明遮罩和选项。不要把遮罩放进 main view;它属于 HUD View。

雨效是对象组合而非 main 特例

官方 making it rain 不是在 main 里写一段粒子循环。它沿第 15 章的 GameObject composition(GameObject 组合)使用 Graphics 与 Update:RainUpdate 管发射、位置、寿命和回收,RainGraphics 只读取提交后的实例生成顶点。

共享 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

每滴可用两点线段或四点窄四边形。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 构造,不使用每滴独立随机设备。

  • 对每个 GamePhase 枚举所有 MenuCommand,断言合法目标与副作用;非法组合保持原状态。
  • 按住 Enter 只确认一次;打开菜单时焦点落在可用项;禁用项不会收到确认。
  • Paused 持续十秒后 world、elapsed、雨滴位置不变,菜单焦点仍可移动。
  • Restart 重置 Player、平台、计时、声音、输入、命令队列和雨滴池,最后才进入 Playing。
  • 固定 seed 分别以不同渲染帧率运行相同固定逻辑步,比较活跃雨滴位置与数量。
  • 统计池容量、活跃峰值、顶点数和 draw calls,长时间运行不增加分配量。

小结

  • 交互菜单与 GamePhase 共同定义开始、暂停、重启、退出的合法状态转换
  • 菜单导航使用事件边沿,阶段转换时清输入、事件和延迟命令
  • 暂停冻结逻辑时间和世界,但窗口事件、菜单与冻结快照仍工作
  • 雨效通过 RainUpdate 与 RainGraphics 实现 Graphics and Update 分离
  • 固定雨滴池、发射累加器和顶点批处理让效果有界、可复现、可测量

资料与写作方式声明

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

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

名词解释

本章出现的专业名词,用大白话再讲一遍。

交互菜单

支持导航、焦点、确认和状态约束的菜单。

GamePhase 状态机

定义 Home、Playing、Paused、GameOver 与合法转换的模型。

MenuCommand

菜单提交给状态控制器的开始、恢复、重启或退出请求。

菜单焦点

当前由导航选中并接收确认的唯一可用条目。

菜单输入边沿

用于导航与确认的一次性按键变化。

状态提交

目标阶段准备成功后一次替换当前阶段的过程。

阶段隔离

清除不能跨状态边界保留的输入、事件、声音和命令。

逻辑时间
只在 Playing 推进的游戏时间步。
冻结快照

暂停时用于继续绘制的最后一次提交世界状态。

雨效

由短寿命雨滴组成并在相机范围生成、移动、批量绘制的效果。

RainUpdate

管理雨滴生成、运动、寿命和回收的更新行为。

RainGraphics

把雨滴快照转换为批量几何的图形行为。

雨滴池
固定容量并反复激活回收的粒子容器。
发射累加器

把每秒发射率按 dt 累积为粒子生成次数的值。

雨滴生成区

主相机上方用于初始化雨滴位置的世界区域。

雨滴回收区
雨滴越界后失活的世界范围。
雨滴批处理

把可见雨滴合并到 VertexArray 的绘制策略。

RainSnapshot

供绘制只读的活跃雨滴表现数据。

阶段帧协议

按当前阶段规定输入、更新、提交和绘制顺序的约定。

雨效确定性

固定 seed、步长和输入时得到相同雨滴结果的性质。

练习

  1. 问题 1:设计菜单转换表。 列出 Home、Playing、Paused、GameOver 对 Start、Pause、Resume、Restart、Quit 的合法性和副作用。
  1. 问题 2:定位暂停污染。 复现暂停十秒后恢复时 Player 瞬移、雨滴跳屏的问题,并给出修复顺序。
  1. 问题 3:验证雨效有界。 设计一个覆盖发射率、池容量、确定性与 draw calls 的长时间测试。

讨论

评论区加载中…