第 4 章:循环、数组、枚举与树枝机制
第 4 章:循环、数组、枚举与树枝机制:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 4 章:循环、数组、枚举与树枝机制”如何用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换
- 能逐项定位 循环(loops)、数组(arrays)、switch 语句(switch)、函数(functions)、树枝生长(growing the branches),说明它们位于源码、运行状态还是可见输出边界
- 能按 保存旧数组 → 检查循环界 → 移动元素 → 生成新状态 → 验证布局 重放“安全移动树枝”,持续检查“树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性”
- 能注入“把数组整体上移时从头向后复制,覆盖尚未读取的树枝状态”,从循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 4 章:循环、数组、枚举与树枝机制”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 4 章:循环、数组、枚举与树枝机制”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“循环(loops)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 4 章:循环、数组、枚举与树枝机制”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 4 章:循环、数组、枚举与树枝机制”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 4 章:循环、数组、枚举与树枝机制”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
循环(loops)
这个正式目录节点落在 离散状态 边界:树枝数组、位置索引与 enum class。在本页中,它参与“用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换”。验收时保存循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出,不能只凭最终画面判断。
数组(arrays)
这个正式目录节点落在 控制规则 边界:for/while、switch 和函数边界。在本页中,它参与“用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换”。验收时保存树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性,不能只凭最终画面判断。
switch 语句(switch)
这个正式目录节点落在 游戏结果 边界:新树枝生成、旧树枝移动与可见布局。在本页中,它参与“用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换”。验收时保存循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出,不能只凭最终画面判断。
函数(functions)
这个正式目录节点落在 离散状态 边界:树枝数组、位置索引与 enum class。在本页中,它参与“用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换”。验收时保存树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性,不能只凭最终画面判断。
树枝生长(growing the branches)
这个正式目录节点落在 控制规则 边界:for/while、switch 和函数边界。在本页中,它参与“用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换”。验收时保存循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | 给定一组已知树枝方向,从末尾向前移动数组 |
| 边界或恢复场景 | 输入 None、Left、Right 三种合法状态并尝试非法整数 |
| 必须保持 | 树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性 |
| 单一故障 | 把数组整体上移时从头向后复制,覆盖尚未读取的树枝状态 |
| 可观察证据 | 循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“安全移动树枝”会怎样穿过 离散状态 → 控制规则 → 游戏结果,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 4 章:循环、数组、枚举与树枝机制:可运行切片
用循环、数组、enum class、switch 与函数把树枝生长和命中规则表达成有限、可检查的状态变换
选择输入或构建场景
定位正式概念
bcgp3-04 · 当前切片
循环(loops):给定一组已知树枝方向,从末尾向前移动数组
树枝数组、位置索引与 enum class
↓
for/while、switch 和函数边界
↓
新树枝生成、旧树枝移动与可见布局
可验收结果
每个旧元素只被读取一次,新元素仅写入首位
实验二:逐步执行状态轨迹
依次执行 保存旧数组 → 检查循环界 → 移动元素 → 生成新状态 → 验证布局。每一步只选中一个阶段,并持续检查“树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性”。
Deterministic state trace
第 4 章:循环、数组、枚举与树枝机制:状态执行轨迹
树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性
循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出
实验三:单一故障与同输入恢复
注入“把数组整体上移时从头向后复制,覆盖尚未读取的树枝状态”,定位第一项不一致;撤销后用完全相同的“边界枚举输入”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 4 章:循环、数组、枚举与树枝机制:故障注入与恢复
单一故障:把数组整体上移时从头向后复制,覆盖尚未读取的树枝状态
第 1 次重放使用同一源码、资源、初始状态和输入序列
树枝数组的索引范围始终有效,新增状态只通过受控分支产生,函数调用前后保持数组长度与枚举合法性
循环上下界、每次数组快照、枚举值、switch 分支和函数输入输出
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“一次砍树后每根枝条去哪里”开始
第 3 章已经能开始、暂停并显示时间。第 4 章开始
↡把输入、状态变换、规则判定和可观察结果组合成玩家可反复操作的玩法规则。: 玩家每砍一次树,已有枝条向下移动一个槽位,顶部生成新枝条,最底部枝条决定下一章的碰撞危险。
如果只移动精灵坐标,逻辑很快会依赖“画面恰好在哪里”。更稳妥的模型是固定数量的槽位数组,每个槽保存 Left、Right 或 None;精灵只是数组状态的视觉投影。
while 循环:状态驱动的外层生命周期
↡条件为真时反复执行循环体的控制结构,适合迭代次数由窗口或游戏状态决定的过程。继续控制主游戏循环,因为玩家何时关闭窗口无法预先知道。循环条件要在每次迭代前重新检查,循环体中处理关闭请求、阶段转换、更新和绘制。
while (window.isOpen()) {
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed)
window.close();
}
if (!window.isOpen())
break;
updateGame();
drawGame();
}书中此时刚开始学习函数,示例把阶段命名出来;实际定义需要放在调用前声明。外层 while 的不变量包括窗口对象有效、必需资源已加载、游戏状态满足枚举和值域约束。退出后不再绘制。
无限循环 while (true) 也可配合内部退出,但会把生命周期条件藏在多个分支。窗口状态本身就是清楚条件时,直接写在循环头更易审查。
for 循环:索引、推进与边界
↡把初始化、继续条件和推进表达式集中书写的循环,适合遍历固定槽位和执行已知次数的更新。用来遍历树枝数组。正确性不来自“通常写小于 size”,而来自三个问题:进入循环体时索引是否有效、每次是否朝退出推进、退出时是否恰好处理完目标区间。
↡每次循环体开始前成立,并在一次迭代后继续成立的事实;结合退出条件可证明没有越界且结果完整。在正向绘制中可表述为“索引前的所有槽位已同步,索引及之后尚未处理”;在反向移位中则是“当前索引之后的目标槽已保存原前驱值,未处理前缀仍保持旧数据”。
for (std::size_t index{0}; index < branches.size(); ++index) {
// branches[index] is valid here
updateBranchSprite(index);
}不要写 index <= branches.size(),最后一次会访问不存在的 size() 索引。范围 for 适合只需元素本身的遍历;同步精灵还需要同一索引,所以显式索引更直接。
break 只退出当前循环或 switch,continue 跳过本次余下语句。它们不是坏语法,但每个跳转都要重新检查资源清理与不变量;树枝更新很短,不需要用多个跳转隐藏数据流。
数组:固定槽位就是固定容量
↡相同元素类型按连续索引组织的固定大小集合;原生数组长度属于类型,但接口中容易退化为指针。适合枝条槽位,因为屏幕同时只展示固定数量。
↡标准库对固定长度数组的值类型封装,提供 size、迭代器、边界检查 at 和整体赋值等接口。比原生数组更容易传入函数并保留长度。
#include <array>
#include <cstddef>
enum class BranchSide {
Left,
Right,
None
};
constexpr std::size_t branchCount{6};
using Branches = std::array<BranchSide, branchCount>;
Branches branches{
BranchSide::None,
BranchSide::None,
BranchSide::None,
BranchSide::None,
BranchSide::None,
BranchSide::None
};初始化明确让首帧没有危险枝条。std::array<BranchSide, 6> branches{}; 会值初始化元素,而枚举底层零是否对应期望状态取决于枚举定义;把 None 写成首个枚举值或显式列出,契约更清楚。
若枝条数量将由关卡动态决定,可改用 std::vector,但此时分配失败、容量变化和引用失效成为新问题。当前固定设计不需要提前引入动态容器。
enum class:状态只能是三种之一
↡作用域枚举类型,枚举名不会泄漏到外层且不会隐式转换为整数,适合表达有限互斥游戏状态。阻止把任意整数当枝条方向,也避免 Left 与其他模块枚举冲突。BranchSide::None
是真实状态,不能靠魔法数字 -1 表示。
状态数组只存业务事实,不存角度或纹理指针。左枝和右枝如何旋转属于映射层;换资源或坐标系时不需要改碰撞规则。
序列化枚举时不要直接写底层整数并假设版本永远稳定。为文件格式定义明确编码和未知值处理;本章状态只存在于当前进程。
反向移位:覆盖顺序决定结果
↡在玩家成功砍树后,将每个旧枝条状态复制到下一个更低槽位,并在顶部插入新状态的变换。需要从尾部向前复制。若从索引 1 正向开始写 branches[1] = branches[0],下一步
branches[2] = branches[1] 读到的已经是新值,最终多个槽都复制了原首项。
void shiftBranches(Branches& branches, BranchSide newTop)
{
for (std::size_t index{branches.size() - 1}; index > 0; --index)
branches[index] = branches[index - 1];
branches[0] = newTop;
}因为 branchCount 编译期大于零,size() - 1 合法。若函数接受可空动态范围,必须先处理空集合。反向循环以 index > 0 停止,避免无符号 size_t 从 0 再减一回绕到巨大值。
switch:把每个枚举状态映射完整
↡按一个整型或枚举表达式的离散值选择 case 分支的控制结构,适合穷举有限状态映射。把 BranchSide
映射为精灵位置和旋转。编译器警告可帮助发现新增枚举值未处理;不要为了消除警告写吞掉所有值的
default,除非确实有未知值策略。
for (std::size_t index{0}; index < branches.size(); ++index) {
const float y{150.0F + static_cast<float>(index) * 110.0F};
switch (branches[index]) {
case BranchSide::Left:
branchSprites[index].setPosition(220.0F, y);
branchSprites[index].setRotation(180.0F);
break;
case BranchSide::Right:
branchSprites[index].setPosition(1060.0F, y);
branchSprites[index].setRotation(0.0F);
break;
case BranchSide::None:
break;
}
}None 不绘制,而不是把旧精灵移到一个巨大负坐标。绘制阶段应检查状态:
for (std::size_t index{0}; index < branches.size(); ++index)
if (branches[index] != BranchSide::None)
window.draw(branchSprites[index]);这样可见性只有一个来源。若 Left 和 Right 使用同一纹理镜像,旋转原点必须先按纹理边界设置,否则旋转会改变意外位置。映射测试同时检查逻辑状态、位置、角度和是否提交绘制。
函数:把机制命名为可测试契约
↡有名字、参数和返回类型的可调用代码单元,用于封装一项机制并限定输入、修改范围和结果。让主循环保留“处理输入、更新树枝、同步精灵、绘制”的层次。函数不是只为减少行数;它把前置条件和后置条件变成独立测试目标。
↡函数形参以引用绑定调用者对象;非常量引用允许修改原对象,const 引用只读且避免复制。在 shiftBranches(Branches& branches, ...) 中明确数组会变,而 newTop
是小枚举值,按值传递最简单。随机引擎若由选择函数推进,也通过非常量引用传入,避免隐藏全局状态。
#include <random>
BranchSide chooseBranch(std::mt19937& engine)
{
std::uniform_int_distribution<int> choice{0, 4};
switch (choice(engine)) {
case 0:
return BranchSide::Left;
case 1:
return BranchSide::Right;
default:
return BranchSide::None;
}
}
// On a successful chop:
shiftBranches(branches, chooseBranch(randomEngine));分布让空枝概率为 3/5,左右各 1/5;这只是玩法参数,应命名或配置。更易测试的设计把纯移位函数与随机选择函数分开:移位测试直接传 Left/Right/None,随机测试只检查范围和可复现性。
组合成每帧数据流
输入阶段只在合法 Playing 状态和按键边沿产生“成功砍树”命令;机制阶段执行一次移位并更新分数;同步阶段遍历状态数组设置精灵;绘制阶段跳过 None 并画其余枝条。任何一帧都不在 draw 时修改数组。
若一帧收到多条输入事件,要先规定是逐条执行、合并为一次,还是按时间戳排队;Timber 的一次砍树应只提交一次状态变换。可以让输入阶段产生 chopRequested 命令,更新阶段消费后立即清除。这样事件重放、自动测试和未来联网同步都能检查“一个命令对应一次移位”,而不会让渲染帧数偷偷决定机制次数。
数组和精灵数组大小必须相同,可从同一个 branchCount 声明。树枝纹理加载失败时不进入游戏;随机引擎跨帧存活。重启把全部槽设为 None,再同步精灵,不能仅把分数归零。
测试从具体转换开始:[Left, Right, None, Left, None, Right] 插入 None 后应变为 [None, Left, Right, None, Left, None];连续移位六次后最初状态全部离开数组。然后检查每个状态映射和渲染提交数量。
先预测:用
for (std::size_t i = branches.size() - 1; i >= 0; --i)反向遍历会发生什么?size_t无符号,i 到 0 后再减会回绕,i >= 0永远为真并最终越界;应以i > 0处理移位目标。
小结
while管状态驱动的窗口生命周期,for管固定槽位遍历;二者都要写出范围、推进和退出std::array保留固定长度和值语义,适合 Timber 的固定树枝槽位enum class BranchSide把 Left/Right/None 变成有限合法状态,避免魔法整数- 树枝原地移位必须从尾部向前复制,无符号反向循环以
index > 0停止 switch完整映射枚举;None 在绘制阶段跳过,不靠魔法坐标隐藏旧精灵- 函数通过值与引用表达读取和修改范围,纯移位与随机选择分开后更容易测试
练习
问题 1 数组初值为 [Left, None, Right, Left],顶部插入 Right 后结果是什么?解释正向原地复制为何错误。
问题 2 为 BranchSide 的 switch 与绘制阶段列出最小测试,怎样证明 None 不会显示上一帧残留精灵?
问题 3 为什么 shiftBranches 接收数组非常量引用、枚举按值,而随机引擎也用非常量引用?如何拆分后测试?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 实现游戏机制
把输入、状态变换、规则判定和结果组合成玩家可反复操作并可验证的玩法规则。
- while 循环
条件为真时反复执行,适合窗口存活或阶段状态决定迭代次数的过程。
- for 循环
集中书写初始化、继续条件和推进,适合索引遍历和已知次数更新。
- 循环不变量
每次迭代前成立并被循环体保持的事实,与退出条件一起证明边界和结果。
- 数组
相同元素类型按连续索引组织的固定大小集合,索引合法范围是零到元素数之前。
- std::array
标准库固定长度数组值类型,保留大小并提供迭代器、整体赋值和边界检查接口。
- enum class
有作用域且不隐式转整数的枚举,适合表达有限互斥游戏状态。
- 树枝生长
成功砍树后旧枝条向下移动一槽并在顶部生成新方向状态的机制。
- switch
按一个离散整数或枚举值选择 case 的控制结构,适合完整映射 BranchSide。
- 函数
用名字、参数和返回类型封装一项机制,并限定输入、修改范围与结果的代码单元。
- 引用传递
形参绑定调用者对象的传递方式,非常量引用允许修改,const 引用只读并避免复制。