第 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 从等待阶段开始,再提供一秒游戏时间
开始、暂停、恢复和按键边沿
↓
得分、消息、游戏时间和剩余时间
↓
Text、字体与 time bar 几何
可验收结果
消息切换、时间条缩短且得分文本与状态一致
实验二:逐步执行状态轨迹
依次执行 读取输入 → 切换阶段 → 推进游戏钟 → 格式化文本 → 绘制 HUD。每一步只选中一个阶段,并持续检查“暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态”。
Deterministic state trace
第 3 章:字符串、输入与 HUD 时间条:状态执行轨迹
暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态
事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序
实验三:单一故障与同输入恢复
注入“暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件”,定位第一项不一致;撤销后用完全相同的“暂停后恢复”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 3 章:字符串、输入与 HUD 时间条:故障注入与恢复
单一故障:暂停后仍按墙钟时间缩短时间条,恢复时立即触发结束条件
第 1 次重放使用同一源码、资源、初始状态和输入序列
暂停时游戏时间、得分和时间条不推进;重启会原子地恢复初始时间、消息与输入状态
事件与实时输入日志、paused 标志、游戏时钟、剩余时间、得分字符串和 HUD 绘制顺序
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“游戏现在处于哪个阶段”开始
第 2 章让云和蜜蜂持续移动,但玩家还不能开始、暂停或读到分数。第 3 章加入
↡玩家通过键盘、鼠标或其他设备表达意图,游戏将离散事件和连续状态映射为阶段转换或持续动作的过程。与 HUD。关键不是多写几个
if,而是先定义游戏阶段:等待开始、正在游玩、暂停、时间耗尽。每个阶段明确哪些输入有效、是否推进时间、显示什么消息。
适合原书早期示例,但“暂停”和“游戏结束”都设为 paused == true
后,重启语义会变含糊。现代 C++ 可先用 enum class GamePhase
明确完整状态,再让输入执行转换。
事件输入与实时输入各负责什么
↡从 SFML 窗口事件队列取得的按下、释放、文本、尺寸改变和关闭等离散消息,适合一次性状态转换。适合 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 文本是状态的投影
↡标准库管理长度和动态存储的文本值类型,可安全拼接、复制和传给接受字符串视图或 C 字符串的接口。用于开始消息、暂停提示和格式化分数。游戏状态仍保留 int score、float 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:字形资源也有借用生命周期
↡SFML 加载并拥有字体字形数据的资源对象;所有引用它的 Text 在绘制期间都要求字体继续存活。与第 1 章纹理类似,是资源所有者。
↡SFML 保存字符串、字体引用、字号、颜色和二维变换的可绘制对象;它不拥有所关联字体。是视图对象。函数内局部加载字体再返回 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:墙上时间与游戏时间分开
↡SFML 表示一段时间的值类型,可从 Clock 得到并以秒、毫秒或微秒读取。表示时长,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,更新应读取新状态,不再扣这一帧后续时间;阶段顺序要写入测试预期。
时间条:从业务时间映射到矩形宽度
↡把剩余游戏时间映射成 0 到 1 比例,再乘最大宽度得到的 HUD 矩形视觉。不能直接把秒数当像素,因为总时长修改会改变视觉尺度。业务值先形成比例,渲染层再映射到几何。
#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 区间,便于映射宽度、颜色和其他视觉属性。
- 游戏重启
同时恢复分数、时间、实体、随机状态和阶段的一局状态事务,而不是只修改提示文本。