第 2 章:变量、运算符与动画决策
第 2 章:变量、运算符与动画决策:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 2 章:变量、运算符与动画决策”如何用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上
- 能逐项定位 c++ 变量(c++ variables)、运算符(operators)、随机数(random numbers)、条件分支(if and else)、计时(timing),说明它们位于源码、运行状态还是可见输出边界
- 能按 固定种子 → 读取时间 → 判断状态 → 积分位置 → 检查边界 重放“稳定 60 Hz”,持续检查“相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time”
- 能注入“每帧直接增加固定像素,导致 60 Hz 与 144 Hz 下运动速度不同”,从变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 2 章:变量、运算符与动画决策”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 2 章:变量、运算符与动画决策”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“c++ 变量(c++ variables)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 2 章:变量、运算符与动画决策”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 2 章:变量、运算符与动画决策”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 2 章:变量、运算符与动画决策”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
c++ 变量(c++ variables)
这个正式目录节点落在 输入状态 边界:速度、边界、随机种子与当前时间。在本页中,它参与“用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上”。验收时保存变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列,不能只凭最终画面判断。
运算符(operators)
这个正式目录节点落在 更新规则 边界:运算符、if/else 与时间缩放。在本页中,它参与“用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上”。验收时保存相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time,不能只凭最终画面判断。
随机数(random numbers)
这个正式目录节点落在 动画结果 边界:云、蜜蜂位置及越界重置。在本页中,它参与“用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上”。验收时保存变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列,不能只凭最终画面判断。
条件分支(if and else)
这个正式目录节点落在 输入状态 边界:速度、边界、随机种子与当前时间。在本页中,它参与“用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上”。验收时保存相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time,不能只凭最终画面判断。
计时(timing)
这个正式目录节点落在 更新规则 边界:运算符、if/else 与时间缩放。在本页中,它参与“用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上”。验收时保存变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | 连续输入 1/60 秒的时间步并使用固定随机种子 |
| 边界或恢复场景 | 交替输入 1/30 与 1/120 秒时间步,总时间保持一秒 |
| 必须保持 | 相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time |
| 单一故障 | 每帧直接增加固定像素,导致 60 Hz 与 144 Hz 下运动速度不同 |
| 可观察证据 | 变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“稳定 60 Hz”会怎样穿过 输入状态 → 更新规则 → 动画结果,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 2 章:变量、运算符与动画决策:可运行切片
用有类型的状态、运算符、随机样本、条件分支和帧时间驱动云与蜜蜂,而不是把运动绑在帧率上
选择输入或构建场景
定位正式概念
bcgp3-02 · 当前切片
c++ 变量(c++ variables):连续输入 1/60 秒的时间步并使用固定随机种子
速度、边界、随机种子与当前时间
↓
运算符、if/else 与时间缩放
↓
云、蜜蜂位置及越界重置
可验收结果
一秒后的位移等于速度乘一秒且轨迹可重放
实验二:逐步执行状态轨迹
依次执行 固定种子 → 读取时间 → 判断状态 → 积分位置 → 检查边界。每一步只选中一个阶段,并持续检查“相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time”。
Deterministic state trace
第 2 章:变量、运算符与动画决策:状态执行轨迹
相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time
变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列
实验三:单一故障与同输入恢复
注入“每帧直接增加固定像素,导致 60 Hz 与 144 Hz 下运动速度不同”,定位第一项不一致;撤销后用完全相同的“混合帧时间”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 2 章:变量、运算符与动画决策:故障注入与恢复
单一故障:每帧直接增加固定像素,导致 60 Hz 与 144 Hz 下运动速度不同
第 1 次重放使用同一源码、资源、初始状态和输入序列
相同初始状态、随机种子和输入时间序列必须产生相同位置轨迹,速度使用每秒单位乘以 delta time
变量类型和值、随机种子、delta time、分支命中记录及逐帧位置序列
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“云现在在哪里、每秒走多远”开始
第 1 章只绘制静态背景。第 2 章把 Timber!!! 的云、蜜蜂和树加入画面,并用
↡在 C++ 中有名字、有类型、有生命周期的对象,用来保存位置、速度、开关和计数等可变化游戏状态。描述每个实体。变量设计的第一问不是“写 int 还是
float”,而是它表示什么量、使用什么单位、在哪些状态下有效。
云至少需要水平位置、垂直位置、每秒速度和是否活动。位置单位可约定为世界像素,速度单位是像素每秒,时间单位是秒。只有写清单位,position += speed * seconds 才能通过量纲检查。
类型、初始化与值域
SFML 的 sf::Vector2f 与精灵位置使用 float,所以位置和速度使用 float 可减少无意转换。窗口宽度和数组索引有各自类型,不应为了“统一”全部改成浮点或无符号。C++ 只规定基本类型的最小能力和相对关系,不能把某台机器的字节数写成所有实现保证。
必须覆盖进入首帧的每个状态。局部基本类型若未初始化,读取其不确定值可能导致未定义行为;动画会表现为偶发跳跃,而非可靠的“随机效果”。
float cloudX{-300.0F};
float cloudY{80.0F};
float cloudSpeed{60.0F}; // pixels per second
bool cloudActive{true};
int score{0};花括号会阻止例如 int lives{3.5}; 这样的窄化。auto 可在初值清楚时减少重复,但不会自动选择“最适合业务”的类型:auto speed = 60; 推导为 int,不会因为后续乘 dt 就改成 float。带 F 的字面量明确是 float。
血量可能降到零以下再钳制,不宜只因语义“非负”就使用无符号类型;无符号减法按模运算,0 减 1 会得到该类型的最大值。类型首先服务运算协议,而不是自然语言标签。
运算符:先写单位,再看优先级
↡对一个或多个操作数执行算术、比较、逻辑、赋值或成员访问等操作的符号;操作数类型决定转换和结果规则。
把状态组合成新值。+ - * / 做算术,< >= == 做比较,&& || ! 做逻辑,+= 等复合赋值表达“以旧值为基础更新”。不要把赋值 = 与相等 == 混淆。
cloudX += cloudSpeed * dt
先乘后加,量纲是像素加像素。括号仍可用于强调意图,但不能靠“从左到右读源码”猜函数参数或子表达式的求值顺序。
const float displacement{cloudSpeed * dt};
cloudX += displacement;
const bool pastRightEdge{cloudX >= windowWidth + cloudWidth};
if (cloudActive && pastRightEdge) {
cloudX = -cloudWidth;
}把条件命名后,分支更接近玩法语言。浮点数不应用 == 判断经过多帧累积后是否恰好到达边界;使用越过比较。除法前确认分母不为零,整数乘加前确认值域足够。
随机数:随机不等于不可复现
↡由伪随机引擎和分布共同生成的值;引擎维护序列状态,分布把结果映射到速度、高度等业务范围。让云和蜜蜂每次从不同高度、以不同速度进入。现代 C++ 应把引擎创建在循环外,只播种一次;每帧重新用时钟播种可能在相同时间粒度产生重复序列,也让测试无法回放。
#include <random>
std::random_device seedSource;
std::mt19937 randomEngine{seedSource()};
std::uniform_real_distribution<float> cloudHeight{20.0F, 220.0F};
std::uniform_real_distribution<float> cloudSpeedRange{40.0F, 120.0F};
cloudY = cloudHeight(randomEngine);
cloudSpeed = cloudSpeedRange(randomEngine);在正式运行可来自
std::random_device,在测试与复现中应允许传入固定值。日志记录种子后,“偶尔某朵云异常”才能转成稳定失败用例。随机分布范围应由可见区域、精灵尺寸和玩法速度决定,不是散落的魔法数。
rand() % n 会把引擎范围用余数映射,可能有分布偏差,并依赖全局状态。std::uniform_int_distribution 或 uniform_real_distribution 直接表达目标区间,也能把随机策略作为对象依赖注入。
if/else:决策必须保持状态完整
↡根据布尔条件只执行两个互斥分支之一的控制结构;条件应表达业务事实,两个分支共同覆盖预期状态。用于判断精灵是否活动、是否越界以及是否需要重置。重置不是只改
x:若玩法规定每次重生随机高度和速度,就要在同一状态转换中同时更新三项,避免下一帧出现半旧半新状态。
if (!cloudActive) {
cloudX = -cloudWidth;
cloudY = cloudHeight(randomEngine);
cloudSpeed = cloudSpeedRange(randomEngine);
cloudActive = true;
} else if (cloudX > windowWidth) {
cloudActive = false;
}这里把“离开右侧”与“在下一帧重生”拆成两个状态,便于以后插入等待时间。也可以一次完成重置,但必须选定一致模型。条件顺序很重要:先判断无效或非活动状态,再使用依赖该状态的值。
布尔变量应命名为事实,如 cloudActive、playerAlive,避免 flag1。多于少量互斥状态时,使用 enum class 比多个可能互相矛盾的 bool 更清楚;枚举将在第 4 章进入游戏机制。
计时:每帧共享同一个时间跨度
↡SFML 用于测量经过时间的对象;restart 返回上次启动或重启以来的时间并同时开始下一段测量。应在循环外创建,每帧开头只 restart
一次。若每个实体各自重启同一时钟,后面的实体得到接近零的时间;若多个时钟在不同点采样,同一帧世界状态会使用不一致时间。
#include <SFML/Graphics.hpp>
#include <algorithm>
sf::Clock frameClock;
while (window.isOpen()) {
const float rawDt{frameClock.restart().asSeconds()};
const float dt{std::min(rawDt, 0.05F)};
// poll all events here
cloudX += cloudSpeed * dt;
cloudSprite.setPosition(cloudX, cloudY);
window.clear();
window.draw(backgroundSprite);
window.draw(cloudSprite);
window.display();
}可能因断点、窗口拖动或系统暂停突然变大。对本章的简单视觉动画,将单帧 dt 限制为 0.05 秒可避免恢复时云瞬移穿过屏幕;这会在长停顿时丢弃部分模拟时间。后续物理游戏可能需要固定步长与累加器,不能把限幅当成所有系统的终极方案。
云和蜜蜂动画:位置是事实来源
↡把移动速度定义为每秒单位,再乘本帧经过秒数更新位置,使不同渲染帧率下单位时间位移近似一致。避免快机器每秒执行更多帧就跑得更快。逻辑变量 cloudX/cloudY
是事实来源,更新后一次同步到精灵;不要一处改变量、另一处直接
sprite.move,否则两套位置逐渐分叉。
const float rightBoundary{static_cast<float>(window.getSize().x)};
cloudX += cloudSpeed * dt;
if (cloudX > rightBoundary) {
cloudX = -cloudWidth;
cloudY = cloudHeight(randomEngine);
cloudSpeed = cloudSpeedRange(randomEngine);
}
cloudSprite.setPosition(cloudX, cloudY);重置到 -cloudWidth 让精灵从左侧外完整进入,而不是半个身体突然出现。若云向左移动,边界要检查 x + width < 0 并重置到右侧。蜜蜂可复用同一更新协议,但拥有独立状态与分布,不能误共享一个位置变量。
绘制只读取已经完成的状态,顺序仍是背景、云、蜜蜂、前景。更新全部实体后再绘制,保证这一帧画面代表同一个逻辑时刻。
验证变量、随机与时间
先用固定种子运行,记录第一次重置的高度和速度,重复启动应得到相同序列;再换非固定种子验证范围始终合法。分别设置 30 和 120 的帧率上限,让云以 100 px/s 运行一秒,位置差应接近 100 像素,误差来自调度和采样而非随帧率成倍变化。
故障测试包括:把速度范围设为 0 检查对象是否停住;把窗口宽度改小检查越界重置;暂停调试器数秒检查 dt 限幅;用两个云实例确认它们不共享位置。每个测试都对应一个状态不变量。
随机测试不能断言某次必须产生具体值,除非固定种子和实现序列是测试契约;更常见是断言所有样本落在范围内、两端边界按分布语义处理、同种子重放一致。
还要把逻辑位置与精灵位置同时记录在一帧末尾:两者长期不相等,说明某条分支只更新了其中一套状态。对多个实体使用带名称或索引的日志,避免把云 1 的重置误认为云 2 的跳变;验证目标是状态转换,不是只看画面“差不多在动”。
先预测:若把
sf::Clock frameClock;放进while循环体开头,restart()得到的 dt 会怎样?每帧都创建新时钟,测得的只是构造后极短时间,物体几乎不移动;时钟必须跨帧存活。
小结
- 动画变量同时需要名字、类型、初值、单位和值域;位置和速度不能用同一含糊概念
- 运算符结果受操作数类型、优先级和值域约束,浮点边界用越过比较而非等待精确相等
- 随机引擎在循环外播种一次,分布表达高度与速度范围,固定种子用于错误重放
if/else是状态转换,重置时要同时恢复位置、高度、速度和活动标志的不变量- 每帧只采样一个
dt,速度乘秒得到位移;巨大 dt 的限幅策略要明确其丢弃模拟时间的代价 - 逻辑位置是事实来源,所有实体更新完成后再同步精灵并按层绘制
练习
问题 1 为一朵从左向右移动的云定义状态变量、类型、单位和值域,并写出越过右边界后的完整重置不变量。
问题 2 为什么随机引擎不能每帧重新播种?怎样同时满足正式运行变化与测试可复现?
问题 3 云速为 90 px/s,30 FPS 与 120 FPS 的理想单帧位移各是多少?为什么一秒结果仍可能有小误差,怎样验证不是帧率绑定?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- C++ 变量
有名字、类型和生命周期的对象,用于保存位置、速度、开关与计数等游戏状态。
- 初始化
对象生命周期开始时建立确定值的过程;花括号还会拒绝部分会丢失信息的窄化转换。
- 运算符
对操作数执行算术、比较、逻辑或赋值等操作的符号,其结果规则取决于操作数类型。
- 表达式
由值、名字、调用和运算符组成并产生值或副作用的片段,受优先级与求值规则约束。
- 随机数
伪随机引擎按状态产生序列,再由分布映射到游戏需要的高度、速度或整数范围。
- 随机种子
初始化伪随机引擎序列状态的值,固定种子让随机轨迹可重复测试和调试。
- if/else
根据布尔条件选择两个互斥分支之一的控制结构,常用于活动状态、越界和重置决策。
- SFML Clock
测量经过时间的 SFML 对象,应跨帧存活并由一帧统一采样。
- delta time
相邻更新之间经过的秒数,速度乘以它得到本帧位移。
- 帧率无关移动
以每秒速度和实际经过时间更新位置,使不同渲染帧率下单位时间运动近似一致。