第 3 章:字符串、输入与 HUD 时间条

第 3 章:字符串、输入与 HUD 时间条:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。

学习目标

  • 能解释“第 3 章:字符串、输入与 HUD 时间条”如何把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机
  • 能逐项定位 暂停与重启(pausing and restarting)、c++ 字符串(c++ strings)、玩家输入(player input)、分数与消息(score and a message)、时间条(time-bar),说明它们位于源码、运行状态还是可见输出边界
  • 能按 读取输入 → 切换阶段 → 推进游戏钟 → 格式化文本 → 绘制 HUD 重放“开始并计时”,持续检查“暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态”
  • 能注入“暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件”,从事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序找到第一个不一致并用同输入恢复

第三版来源、工具链与版本边界

“第 3 章:字符串、输入与 HUD 时间条”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。

在“第 3 章:字符串、输入与 HUD 时间条”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“暂停与重启(pausing and restarting)”概念错误。

  • Packt:Beginning C++ Game Programming, Third Edition:在“第 3 章:字符串、输入与 HUD 时间条”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
  • PacktPublishing:第三版官方代码仓库:在“第 3 章:字符串、输入与 HUD 时间条”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
  • SFML 2.6 官方教程:在“第 3 章:字符串、输入与 HUD 时间条”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。

正式概念与运行状态合同

暂停与重启(pausing and restarting)

这个正式目录节点落在 输入与阶段 边界:开始、暂停、恢复和按键边沿。在本页中,它参与“把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机”。验收时保存事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序,不能只凭最终画面判断。

c++ 字符串(c++ strings)

这个正式目录节点落在 游戏状态 边界:得分、消息、游戏时间和剩余时间。在本页中,它参与“把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机”。验收时保存暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态,不能只凭最终画面判断。

玩家输入(player input)

这个正式目录节点落在 HUD 表现 边界:Text、字体与 time bar 几何。在本页中,它参与“把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机”。验收时保存事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序,不能只凭最终画面判断。

分数与消息(score and a message)

这个正式目录节点落在 输入与阶段 边界:开始、暂停、恢复和按键边沿。在本页中,它参与“把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机”。验收时保存暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态,不能只凭最终画面判断。

时间条(time-bar)

这个正式目录节点落在 游戏状态 边界:得分、消息、游戏时间和剩余时间。在本页中,它参与“把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机”。验收时保存事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序,不能只凭最终画面判断。

验收项本页合同
最小正常场景按 Enter 从等待阶段开始,再提供一秒游戏时间
边界或恢复场景运行半秒、暂停两秒墙钟时间,再恢复半秒
必须保持暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态
单一故障暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件
可观察证据事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序

先预测,再操作三个本页实验

实验一:从源码到可见结果

先预测“开始并计时”会怎样穿过 输入与阶段 → 游戏状态 → HUD 表现,再切换场景和正式概念。每次操作都必须能回到同一初始状态。

Source · state · visible result

第 3 章:字符串、输入与 HUD 时间条:可运行切片

把字符串、玩家输入、暂停状态、得分文本与 SFML Time 统一到同一个 HUD 和时间条状态机

选择输入或构建场景

定位正式概念

bcgp3-03 · 当前切片

暂停与重启(pausing and restarting)按 Enter 从等待阶段开始,再提供一秒游戏时间

边界 1输入与阶段

开始、暂停、恢复和按键边沿

边界 2游戏状态

得分、消息、游戏时间和剩余时间

边界 3HUD 表现

Text、字体与 time bar 几何

可验收结果

消息切换、时间条缩短且得分文本与状态一致

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

依次执行 读取输入 → 切换阶段 → 推进游戏钟 → 格式化文本 → 绘制 HUD。每一步只选中一个阶段,并持续检查“暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态”。

Deterministic state trace

第 3 章:字符串、输入与 HUD 时间条:状态执行轨迹

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

暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态

本步证据

事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序

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

注入“暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件”,定位第一项不一致;撤销后用完全相同的“暂停后恢复”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 3 章:字符串、输入与 HUD 时间条:故障注入与恢复

单一故障:暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件

1. 固定构建与输入一致

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

2. 程序状态一致

暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态

3. 诊断证据一致

事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序

4. 恢复判断一致

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

易错边界与工程取舍

从“游戏现在处于哪个阶段”开始

第 2 章让云和蜜蜂持续移动,但玩家还不能开始、暂停或读到分数。第 3 章加入

与 HUD。关键不是多写几个 if,而是先定义游戏阶段:等待开始、正在游玩、暂停、时间耗尽。每个阶段明确哪些输入有效、是否推进时间、显示什么消息。

适合原书早期示例,但“暂停”和“游戏结束”都设为 paused == true 后,重启语义会变含糊。现代 C++ 可先用 enum class GamePhase 明确完整状态,再让输入执行转换。

事件输入与实时输入各负责什么

适合 Enter 开始、Escape 暂停和关闭窗口。按键事件表达边沿,一次按下只转换一次;开启系统按键重复时仍可能收到重复事件,所以状态转换还要检查当前阶段,或关闭按键重复。

适合后续砍树或移动。若只处理 KeyPressed 来持续移动,速度会依赖操作系统按键重复延迟与频率;若每帧查询 Enter 来重启,按住一秒会把游戏反复重置。

enum class GamePhase {
    Waiting,
    Playing,
    Paused,
    Expired
};
 
GamePhase phase{GamePhase::Waiting};
window.setKeyRepeatEnabled(false);
 
while (window.pollEvent(event)) {
    if (event.type == sf::Event::Closed)
        window.close();
 
    if (event.type == sf::Event::KeyPressed) {
        if (event.key.code == sf::Keyboard::Enter &&
            (phase == GamePhase::Waiting || phase == GamePhase::Expired)) {
            phase = GamePhase::Playing;
        } else if (event.key.code == sf::Keyboard::Escape &&
                   phase == GamePhase::Playing) {
            phase = GamePhase::Paused;
        } else if (event.key.code == sf::Keyboard::Escape &&
                   phase == GamePhase::Paused) {
            phase = GamePhase::Playing;
        }
    }
}

开始或重启不能只改 phase:分数、剩余时间、树枝、玩家位置和随机实体都要恢复初始不变量。当前章先列出重置清单,第 4 章再用函数集中实现,避免复制多段赋值。

C++ 字符串:HUD 文本是状态的投影

用于开始消息、暂停提示和格式化分数。游戏状态仍保留 int scorefloat remainingSeconds 等数值,HUD 只把它们转成可读字符串;不要把分数字符串反过来作为业务事实来源。

#include <sstream>
#include <string>
 
int score{0};
std::ostringstream scoreBuilder;
scoreBuilder << "Score: " << score;
const std::string scoreLine{scoreBuilder.str()};

std::to_string 适合简单拼接,ostringstream 可组合多个字段和格式控制。C++20 的 std::format 很清楚,但标准库实现支持度要由项目工具链验证。无论用哪种方式,都避免手写固定字符数组和未经容量检查的格式化。

高频 HUD 不一定每帧重新分配字符串。分数或阶段变化时更新文本即可;时间显示若每帧变化,可以复用构建器或接受小规模开销后再测量。先保证状态一致,再凭性能数据优化。

Font 与 Text:字形资源也有借用生命周期

与第 1 章纹理类似,是资源所有者。

是视图对象。函数内局部加载字体再返回 Text 会留下失效字体引用。

sf::Font hudFont;
if (!hudFont.loadFromFile("assets/fonts/KOMIKAP_.ttf")) {
    std::cerr << "failed to load assets/fonts/KOMIKAP_.ttf\n";
    return EXIT_FAILURE;
}
 
sf::Text scoreText;
scoreText.setFont(hudFont);
scoreText.setCharacterSize(42);
scoreText.setFillColor(sf::Color::White);
scoreText.setPosition(24.0F, 18.0F);
scoreText.setString("Score: 0");

字体文件是必需 HUD 资源,加载失败时不能继续绘制 Text。不同字体的字形边界和基线不同,位置应通过 getLocalBounds、原点和安全边距布局;不要把某字体的视觉宽度写成所有语言都适用。中文还要确认字体实际包含字形。

分数与消息:状态变化时同步 HUD

应在世界对象之后绘制,并使用不随世界相机移动的视图。Timber 当前没有移动相机,仍应把 HUD 坐标理解为屏幕布局,以便后续 Zombie Arena 的双视图章节自然扩展。

std::ostringstream builder;
builder << "Score: " << score;
scoreText.setString(builder.str());
 
if (phase == GamePhase::Waiting)
    messageText.setString("Press Enter to start");
else if (phase == GamePhase::Paused)
    messageText.setString("Paused - press Escape to resume");
else if (phase == GamePhase::Expired)
    messageText.setString("Out of time - press Enter to restart");
else
    messageText.setString("");

消息来自状态,不要同时维护 bool showMessage 与可能矛盾的 phase。空字符串隐藏消息比把对象移到魔法坐标更直接。若文本居中,应在每次字符串变化后重新取得局部边界,因为不同消息宽度不同。

绘制顺序可为背景、云与蜜蜂、玩法对象、时间条、分数、中央消息。透明 HUD 仍受顺序影响;把它放在背景前会被完全覆盖。

SFML Time:墙上时间与游戏时间分开

表示时长,sf::Clock 产生经过时间。每帧重启一次时钟得到墙上 dt,但只有 Playing 阶段把它扣入游戏预算;暂停阶段仍抽事件和绘制,却不推进玩法时间。

sf::Clock frameClock;
float remainingSeconds{6.0F};
 
while (window.isOpen()) {
    const sf::Time frameTime{frameClock.restart()};
    const float dt{std::min(frameTime.asSeconds(), 0.05F)};
 
    // poll events before updating phase
    if (phase == GamePhase::Playing) {
        remainingSeconds = std::max(0.0F, remainingSeconds - dt);
        if (remainingSeconds == 0.0F)
            phase = GamePhase::Expired;
    }
}

这里在减法后钳制到精确 0,再用相等判断离散状态边界,因此不同于等待累积位置“恰好等于窗口宽度”。若按键事件在本帧把状态改成 Paused,更新应读取新状态,不再扣这一帧后续时间;阶段顺序要写入测试预期。

时间条:从业务时间映射到矩形宽度

不能直接把秒数当像素,因为总时长修改会改变视觉尺度。业务值先形成比例,渲染层再映射到几何。

#include <algorithm>
 
constexpr float totalSeconds{6.0F};
constexpr float fullBarWidth{400.0F};
constexpr float barHeight{28.0F};
 
const float ratio{
    totalSeconds > 0.0F
        ? std::clamp(remainingSeconds / totalSeconds, 0.0F, 1.0F)
        : 0.0F};
 
timeBar.setSize({fullBarWidth * ratio, barHeight});
timeBar.setFillColor(ratio < 0.25F ? sf::Color::Red : sf::Color::Green);

先处理总时长为零,再把结果钳制到 [0,1],避免负宽度或超过最大宽度。颜色阈值是视觉策略,不应反过来控制游戏是否结束。

暂停与重启:时钟不能偷走时间

暂停期间 frameClock 仍每帧重启,但 dt 被忽略,因此恢复时不会一次扣掉整个暂停时长。如果选择在暂停时不重启时钟,恢复第一帧会得到巨大间隔;除非恢复时显式重置,否则会直接超时。

必须原子维护不变量。可先在事件分支列出所有赋值,下一章学习函数后提取为 resetGame();在此之前也要有清单和测试,防止只恢复 HUD 文本却保留旧分数。

if (startNewGame) {
    score = 0;
    remainingSeconds = totalSeconds;
    playerSide = Side::Left;
    branchIndex = 0;
    phase = GamePhase::Playing;
    scoreText.setString("Score: 0");
}

示例中的 Side 和树枝数组将在第 4 章完整建立。状态转换最后设置 Playing,保证其他字段先准备完;若未来重置可能失败,应构造完整新状态后再提交,不能让半初始化游戏进入更新。

验证 HUD 状态机

用转换表测试:Waiting + Enter 进入 Playing 并重置;Playing + Escape 进入 Paused;Paused + Escape 恢复 Playing;Playing 时间到零进入 Expired;Expired + Enter 创建新局;任何状态 + Close 关闭窗口。按住按键不应重复转换。

时间测试使用可注入 dt,而不是实际等待六秒。依次输入 1.0、2.0、暂停期 4.0、恢复期 3.0,剩余时间应从 6 变 5、3、仍为 3、最后为 0;时间条宽度按同样比例变化。字体路径故障测试应在进入循环前失败并报告路径。

视觉验收同时检查 1280x720 与不同窗口比例:分数不越界,中央消息按文本边界重新居中,时间条不会为负或超出全宽,HUD 最后绘制。截图验证只能检查显示结果,状态转换仍需逻辑断言。

先预测:若暂停时停止调用 frameClock.restart(),恢复后第一帧的 dt 包含什么?它包含整个暂停时长,可能立刻耗尽时间;暂停可以停止消费游戏时间,但墙上帧时钟仍应持续采样或在恢复点重置。

小结

  • 一次性开始、暂停和重启使用事件边沿,持续动作使用实时输入;阶段检查阻止按键重复造成反复转换
  • std::string 和格式化只负责 HUD 视图,数值分数与剩余时间仍是业务事实来源
  • sf::Font 拥有字形资源,sf::Text 借用字体;字体生命周期必须覆盖全部绘制
  • HUD 在世界之后以屏幕坐标绘制,文本变化后重新计算边界和居中
  • 每帧测量墙上 dt,只有 Playing 阶段消费游戏时间;暂停期间不能积累恢复瞬移
  • 时间条由剩余时间归一化后映射宽度,并把比例钳制到 0 到 1

练习

问题 1 为 Waiting、Playing、Paused、Expired 写输入转换表,并说明 Enter 与 Escape 为什么用事件而不是实时查询。

问题 2 分数从 9 变 10 后,怎样更新并重新居中 sf::Text?解释 Font、Text 与 string 的所有权。

问题 3 总时间 8 秒、全宽 320 px,剩余 6、2、-0.1 秒时条宽各是多少?暂停 5 秒后为何不应改变?

名词解释

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

玩家输入

把设备意图映射成一次状态转换或持续动作的过程,需要区分事件边沿与实时状态。

paused 状态

以布尔值表示游玩和暂停两个阶段的简化状态,阶段增多时应改用枚举避免矛盾组合。

事件输入

来自窗口队列的离散按下、释放、关闭等消息,适合一次性开始、暂停和重启转换。

实时输入

每帧查询设备当前状态的方式,适合按住移动或持续动作,不适合反复执行重启。

std::string

标准库管理长度和动态存储的文本值类型,用于消息与 HUD 格式化而非替代数值状态。

sf::Font

加载并拥有字体字形资源的 SFML 对象,必须覆盖所有关联 Text 的绘制生命周期。

sf::Text

保存字符串、字体引用、字号、颜色和变换的可绘制对象,不拥有字体资源。

HUD

显示分数、时间、阶段和提示的屏幕层界面,通常在世界对象之后绘制。

sf::Time

SFML 表示时长的值类型,可从 Clock 得到并按秒、毫秒或微秒读取。

时间条

将剩余游戏时间映射为 0 到 1 比例并更新矩形宽度的 HUD 视觉。

归一化比例

把剩余值转换到 0 到 1 区间,便于映射宽度、颜色和其他视觉属性。

游戏重启

同时恢复分数、时间、实体、随机状态和阶段的一局状态事务,而不是只修改提示文本。

资料与写作方式声明

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

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

讨论

评论区加载中…