第 5 章:碰撞、音效与 Timber 终局

第 5 章:碰撞、音效与 Timber 终局:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。

学习目标

  • 能解释“第 5 章:碰撞、音效与 Timber 终局”如何把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合
  • 能逐项定位 玩家输入(player input)、死亡处理(handling death)、简单音效(simple sound effects)、飞行木头(flying log)、改进游戏和代码(improving the game and code),说明它们位于源码、运行状态还是可见输出边界
  • 能按 捕获砍击 → 计算碰撞 → 提交回合 → 触发反馈 → 判断结束 重放“安全砍击”,持续检查“一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制”
  • 能注入“在事件和实时输入两处同时处理同一次按键,造成一次砍击加两分并播放两次音效”,从输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志找到第一个不一致并用同输入恢复

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

“第 5 章:碰撞、音效与 Timber 终局”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。

在“第 5 章:碰撞、音效与 Timber 终局”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“玩家输入(player input)”概念错误。

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

正式概念与运行状态合同

玩家输入(player input)

这个正式目录节点落在 玩家意图 边界:左/右砍击与回合阶段。在本页中,它参与“把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合”。验收时保存输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志,不能只凭最终画面判断。

死亡处理(handling death)

这个正式目录节点落在 规则提交 边界:碰撞、树枝移动、分数和死亡。在本页中,它参与“把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合”。验收时保存一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制,不能只凭最终画面判断。

简单音效(simple sound effects)

这个正式目录节点落在 反馈表现 边界:飞行木头、声音与结束消息。在本页中,它参与“把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合”。验收时保存输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志,不能只凭最终画面判断。

飞行木头(flying log)

这个正式目录节点落在 玩家意图 边界:左/右砍击与回合阶段。在本页中,它参与“把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合”。验收时保存一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制,不能只凭最终画面判断。

改进游戏和代码(improving the game and code)

这个正式目录节点落在 规则提交 边界:碰撞、树枝移动、分数和死亡。在本页中,它参与“把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合”。验收时保存输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志,不能只凭最终画面判断。

验收项本页合同
最小正常场景玩家在无树枝的一侧触发单次按键边沿
边界或恢复场景玩家所在一侧在树枝移动后被占据
必须保持一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制
单一故障在事件和实时输入两处同时处理同一次按键,造成一次砍击加两分并播放两次音效
可观察证据输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志

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

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

先预测“安全砍击”会怎样穿过 玩家意图 → 规则提交 → 反馈表现,再切换场景和正式概念。每次操作都必须能回到同一初始状态。

Source · state · visible result

第 5 章:碰撞、音效与 Timber 终局:可运行切片

把玩家输入、木头飞行、精灵碰撞、声音与死亡条件提交成 Timber 的可玩回合

选择输入或构建场景

定位正式概念

bcgp3-05 · 当前切片

玩家输入(player input)玩家在无树枝的一侧触发单次按键边沿

边界 1玩家意图

左/右砍击与回合阶段

边界 2规则提交

碰撞、树枝移动、分数和死亡

边界 3反馈表现

飞行木头、声音与结束消息

可验收结果

分数增加一次,木头与声音各触发一次,游戏继续

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

依次执行 捕获砍击 → 计算碰撞 → 提交回合 → 触发反馈 → 判断结束。每一步只选中一个阶段,并持续检查“一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制”。

Deterministic state trace

第 5 章:碰撞、音效与 Timber 终局:状态执行轨迹

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

一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制

本步证据

输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志

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

注入“在事件和实时输入两处同时处理同一次按键,造成一次砍击加两分并播放两次音效”,定位第一项不一致;撤销后用完全相同的“树枝致死”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 5 章:碰撞、音效与 Timber 终局:故障注入与恢复

单一故障:在事件和实时输入两处同时处理同一次按键,造成一次砍击加两分并播放两次音效

1. 固定构建与输入一致

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

2. 程序状态一致

一次砍击只提交一次分数、一次树枝移动和一次音效;死亡后更新规则停止但结束画面仍可绘制

3. 诊断证据一致

输入边沿、回合状态、碰撞矩形、分数增量、音效触发次数和结束条件日志

4. 恢复判断一致

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

易错边界与工程取舍

从“一次按键只改变一次世界”开始

前四章已经建立窗口、动画、HUD 和树枝数组。第 5 章把它们组合成第一款可玩游戏:玩家在树左右切换并砍击,枝条向下生长,最底枝条若与玩家同侧就结束游戏;时间也持续减少,成功砍击增加分数并让木头飞出。

必须与渲染帧数分离。一个 KeyPressed 产生一个命令,更新阶段消费一次;绘制阶段只显示结果,不能再次加分或移位。

玩家、斧头与墓碑精灵

仍遵守纹理长于精灵的借用关系。玩家左右位置、斧头左右位置和木头起点应集中成命名常量,避免输入分支散落坐标。

enum class Side { Left, Right };
 
constexpr float playerLeftX{260.0F};
constexpr float playerRightX{920.0F};
constexpr float playerY{610.0F};
 
Side playerSide{Side::Left};
playerSprite.setPosition(playerLeftX, playerY);
axeSprite.setPosition(playerLeftX + 110.0F, playerY + 30.0F);

逻辑侧是事实来源,精灵坐标由它派生。若只移动玩家却忘记斧头,下一次碰撞仍可能按另一套状态判定。可写 placePlayer(side) 函数一次设置玩家、斧头和木头起点;本章练习函数组合而非复制分支。

墓碑只在 Dead 状态绘制,普通玩家只在非 Dead 阶段绘制。不要用把精灵移到 (-2000,-2000) 的方式表达死亡,它让可见性隐藏在坐标副作用中。

事件边沿生成砍树命令

可先用 std::optional<Side> 表达本帧是否砍击。只在 Playing 阶段接收左右键;Waiting、Paused、Expired、Dead 都不生成命令。

#include <optional>
 
std::optional<Side> chopSide;
 
if (event.type == sf::Event::KeyPressed && phase == GamePhase::Playing) {
    if (event.key.code == sf::Keyboard::Left)
        chopSide = Side::Left;
    else if (event.key.code == sf::Keyboard::Right)
        chopSide = Side::Right;
}

同一帧收到左右两条事件时,必须定义策略。示例后事件覆盖前事件;也可只接受第一条。多人或回放系统会保留有序命令队列,但 Timber 每帧最多一次砍击已经足够。关键是规则明确且可测。

枝条碰撞:比较逻辑状态而非像素猜测

不需要通用像素碰撞:树枝数组已经提供更稳定的语义。移位后检查最后槽的 BranchSide,与玩家 Side 显式比较。

bool branchHitsPlayer(BranchSide branch, Side player)
{
    if (branch == BranchSide::None)
        return false;
    if (branch == BranchSide::Left)
        return player == Side::Left;
    return player == Side::Right;
}

判定顺序是先移位,再检查新进入危险槽的枝条。若先检查旧末槽,会晚一拍死亡;规则并非物理引擎限制,而是事务顺序。测试 Left/Left、Right/Right 必须命中,交叉侧和 None 不命中。

在本章采用槽位状态,后续 Pong 才使用 AABB 几何;不要为了复用而把简单离散规则强行转成矩形相交。

飞行木头:短生命周期反馈对象

需要 active、位置和速度。砍击事务设置初值,后续帧只在 active 时推进;离屏后停止更新和绘制。

bool logActive{false};
float logX{0.0F};
float logY{0.0F};
float logVelocityX{0.0F};
float logVelocityY{-1500.0F};
 
if (chopSide) {
    logActive = true;
    logX = 640.0F;
    logY = 720.0F;
    logVelocityX = *chopSide == Side::Left ? 1000.0F : -1000.0F;
}
 
if (logActive) {
    logX += logVelocityX * dt;
    logY += logVelocityY * dt;
    logSprite.setPosition(logX, logY);
    if (logY + logHeight < 0.0F)
        logActive = false;
}

每次新砍击可重新发射同一木头,意味着高频输入不会同时显示多个木块。若设计需要多个并发木块,应使用对象池或容器,并明确容量;不要在每次按键中无界分配。

终局条件:同一帧只能提交一个结果

可能在同一帧同时满足。必须定义优先级,例如先消费砍击和碰撞,命中则 Dead;未死亡再扣时,归零则 Expired。另一顺序也可以,但测试和玩家反馈必须一致。

if (phase == GamePhase::Playing) {
    if (chopSide) {
        playerSide = *chopSide;
        shiftBranches(branches, chooseBranch(randomEngine));
        ++score;
        if (branchHitsPlayer(branches.back(), playerSide))
            phase = GamePhase::Dead;
    }
 
    if (phase == GamePhase::Playing) {
        remainingSeconds = std::max(0.0F, remainingSeconds - dt);
        if (remainingSeconds == 0.0F)
            phase = GamePhase::Expired;
    }
}
chopSide.reset();

只在进入 Dead 的边沿播放死亡声、显示墓碑。若每帧 phase == Dead 都调用 play(),声音会不停回到开头。

简单音效:Buffer 拥有数据,Sound 管播放

适合斧击、死亡和超时等短音效。

与 Sprite/Text 的借用模式一致。

sf::SoundBuffer chopBuffer;
sf::SoundBuffer deathBuffer;
 
if (!chopBuffer.loadFromFile("assets/sound/chop.wav") ||
    !deathBuffer.loadFromFile("assets/sound/death.wav")) {
    std::cerr << "failed to load required Timber sound effects\n";
    return EXIT_FAILURE;
}
 
sf::Sound chopSound{chopBuffer};
sf::Sound deathSound{deathBuffer};

这里音效被定义为必需资源,失败就停止;也可选择静音降级,但要记录并保持玩法可运行。长音乐通常使用流式 sf::Music,不应全部装入 SoundBuffer。

砍击事务调用一次 chopSound.play();进入 Dead 边沿调用一次 deathSound.play();重启先停止终局声音。多个快速斧击若复用同一个 Sound,会重启同一播放;若需要叠加,应维护受限 Sound 实例池并控制混音音量。

绘制层次与完成条件

绘制顺序可以是背景、云、树、树枝、玩家或墓碑、斧头、飞行木头、时间条、分数和消息。只有 active 木头绘制,只有非 None 枝条绘制,Dead 状态用墓碑替换玩家。视觉条件全部从状态派生。

不等于功能很多,而是主路径和失败路径闭环:缺资源有诊断、关闭窗口有效、暂停不扣时、终局不再计分、新局无旧状态泄漏。

改进应以可验证增量进行:增加难度曲线时把速度或枝条概率写成分数函数;增加连击时定义时间窗;增加最高分时明确文件错误;不要一次同时改输入、随机和碰撞,导致回归无法定位。

改进游戏和代码:先保住规则再扩展

原章完成 Timber 后要求继续思考改进。优先把重复的重置、玩家放置和 HUD 同步提取为函数,再添加玩法:分数越高时缩短总时间或提高危险枝条概率,必须设置上限,避免难度函数产生零时间或必死序列;增加最高分时,文件读取失败应回退默认值而不是阻止启动。

每项改进先写不变验收:一次命令仍只加一分和移位一次,任何难度下都保留可反应窗口,重启仍清空上一局状态,静音模式不改变碰撞结果。性能优化也先测量,纹理和音频只加载一次,但不要为了减少函数调用把输入、更新与绘制重新揉进一个不可测试分支。

为每次改进保留固定输入回放和终局截图,确保玩法变化不会悄悄破坏状态顺序与视觉反馈。

重启事务与测试矩阵

应先停止旧声音,再设置 Playing。数组全部 None,木头 inactive,玩家回初始侧,分数归零,时间恢复总量,HUD 由新状态刷新。

测试包括:左右各砍一次只加一分;同侧最低枝条死亡、异侧和 None 安全;死亡后输入不加分;时间到零不再更新枝条;重启后六个槽全空、木头不显示、旧死亡声停止;音频文件缺失按策略失败或静音降级。

用固定分支序列比随机等待更可靠。直接把 [None, Left] 变换到危险槽,分别设置玩家左右侧验证。用注入 dt 把时间推进到零,确保终局只触发一次。

先预测:若碰撞检查发生在树枝移位之前,刚落入最低槽的危险枝条何时生效?会晚到下一次砍击,玩家画面上已被枝条覆盖却仍存活;事务顺序必须先提交移位再判定。

小结

  • 输入事件只生成一次砍树命令,更新阶段按固定顺序消费,绘制不产生玩法副作用
  • 玩家侧、斧头与碰撞共享同一逻辑枚举,精灵位置只是状态投影
  • 树枝碰撞使用最低槽方向与玩家侧比较,不需要像素级检测
  • 飞行木头有明确 active 生命周期,离屏停用;并发反馈需要受限对象池
  • 死亡与超时是互斥终局转换,同帧优先级明确,终局期间停止计分和机制更新
  • Sound 借用 SoundBuffer,声音只在转换边沿触发;完整重启恢复所有一局状态

练习

问题 1 写出一次 ChopLeft 的事务顺序,并说明为什么碰撞必须在移位后、声音必须在边沿触发。

问题 2 同一帧既砍到危险枝条又让时间归零,怎样避免同时显示 Dead 和 Expired?

问题 3 列出完整重启至少要恢复的状态,并解释 SoundBuffer 与 Sound 的生命周期。

名词解释

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

砍树事务

一次合法输入按固定顺序更新玩家、枝条、分数、碰撞、木头和声音且只提交一次的变换。

游戏精灵

玩家、斧头、木头和墓碑等带纹理引用与变换的 SFML 可绘制对象。

游戏命令

输入阶段产生并由更新阶段恰好消费一次的玩法意图。

树枝碰撞

最低危险槽方向与玩家侧相同时触发死亡的离散规则碰撞。

碰撞检测

判断对象或规则区域满足接触条件并产生玩法结果的过程。

飞行木头

砍击后沿玩家反方向飞出、离屏后停用的短生命周期视觉反馈。

结束条件

时间耗尽或枝条命中后从 Playing 提交到一个互斥终局状态的规则。

死亡处理

进入 Dead 后冻结玩法、切换视觉和消息并只触发一次死亡反馈。

sf::SoundBuffer

加载并拥有短音频样本的 SFML 资源,必须覆盖借用 Sound 的播放期。

sf::Sound

借用 SoundBuffer 并管理音量、循环和播放游标的播放对象。

可玩版本

开始、输入、计分、危险、反馈、终局和重启全部闭环的端到端游戏版本。

完整重启

恢复一局所有逻辑、视觉与音频状态后再提交 Playing 的事务。

资料与写作方式声明

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

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

讨论

评论区加载中…