第 12 章:子弹、拾取物与战斗碰撞
第 12 章:子弹、拾取物与战斗碰撞:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。
学习目标
- 能解释“第 12 章:子弹、拾取物与战斗碰撞”如何把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则
- 能逐项定位 bullet 类(bullet class)、准星(crosshair)、pickup 类(pickup class)、碰撞检测(detecting collisions)、玩家 僵尸 子弹(player zombie bullet),说明它们位于源码、运行状态还是可见输出边界
- 能按 生成实体 → 推进位置 → 检测碰撞 → 提交效果 → 安全回收 重放“子弹命中僵尸”,持续检查“子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞”
- 能注入“遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中”,从实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化找到第一个不一致并用同输入恢复
第三版来源、工具链与版本边界
“第 12 章:子弹、拾取物与战斗碰撞”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。
在“第 12 章:子弹、拾取物与战斗碰撞”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“bullet 类(bullet class)”概念错误。
- Packt:Beginning C++ Game Programming, Third Edition:在“第 12 章:子弹、拾取物与战斗碰撞”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
- PacktPublishing:第三版官方代码仓库:在“第 12 章:子弹、拾取物与战斗碰撞”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
- SFML 2.6 官方教程:在“第 12 章:子弹、拾取物与战斗碰撞”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。
正式概念与运行状态合同
bullet 类(bullet class)
这个正式目录节点落在 生成与瞄准 边界:准星、射击输入、Bullet 初始状态。在本页中,它参与“把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则”。验收时保存实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化,不能只凭最终画面判断。
准星(crosshair)
这个正式目录节点落在 碰撞规则 边界:玩家、僵尸、子弹、Pickup 的交互矩阵。在本页中,它参与“把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则”。验收时保存子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞,不能只凭最终画面判断。
pickup 类(pickup class)
这个正式目录节点落在 生命周期 边界:激活、消费、冷却、擦除与重生。在本页中,它参与“把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则”。验收时保存实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化,不能只凭最终画面判断。
碰撞检测(detecting collisions)
这个正式目录节点落在 生成与瞄准 边界:准星、射击输入、Bullet 初始状态。在本页中,它参与“把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则”。验收时保存子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞,不能只凭最终画面判断。
玩家 僵尸 子弹(player zombie bullet)
这个正式目录节点落在 碰撞规则 边界:玩家、僵尸、子弹、Pickup 的交互矩阵。在本页中,它参与“把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则”。验收时保存实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化,不能只凭最终画面判断。
| 验收项 | 本页合同 |
|---|---|
| 最小正常场景 | 一颗 active 子弹首次与一个活僵尸 AABB 重叠 |
| 边界或恢复场景 | Pickup 激活窗口结束后玩家进入其旧位置 |
| 必须保持 | 子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞 |
| 单一故障 | 遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中 |
| 可观察证据 | 实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化 |
先预测,再操作三个本页实验
实验一:从源码到可见结果
先预测“子弹命中僵尸”会怎样穿过 生成与瞄准 → 碰撞规则 → 生命周期,再切换场景和正式概念。每次操作都必须能回到同一初始状态。
Source · state · visible result
第 12 章:子弹、拾取物与战斗碰撞:可运行切片
把 Bullet、准星、Pickup 与玩家—僵尸—子弹碰撞组织成有生命周期和冷却时间的明确规则
选择输入或构建场景
定位正式概念
bcgp3-12 · 当前切片
bullet 类(bullet class):一颗 active 子弹首次与一个活僵尸 AABB 重叠
准星、射击输入、Bullet 初始状态
↓
玩家、僵尸、子弹、Pickup 的交互矩阵
↓
激活、消费、冷却、擦除与重生
可验收结果
僵尸受一次伤,子弹立即失活且不再命中其他目标
实验二:逐步执行状态轨迹
依次执行 生成实体 → 推进位置 → 检测碰撞 → 提交效果 → 安全回收。每一步只选中一个阶段,并持续检查“子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞”。
Deterministic state trace
第 12 章:子弹、拾取物与战斗碰撞:状态执行轨迹
子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞
实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化
实验三:单一故障与同输入恢复
注入“遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中”,定位第一项不一致;撤销后用完全相同的“Pickup 超时”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 12 章:子弹、拾取物与战斗碰撞:故障注入与恢复
单一故障:遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中
第 1 次重放使用同一源码、资源、初始状态和输入序列
子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞
实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“按下鼠标后哪一个对象拥有这次射击”开始
第 11 章已经生成僵尸群。第 12 章加入
↡封装活跃状态、位置、速度、剩余射程与可绘制形状,并可从对象池反复发射和停用的类。、 准星和拾取物。战斗逻辑要把输入、对象更新、碰撞检测、伤害提交和删除分阶段,否则一次命中可能在同一帧被重复处理。
世界准星与枪口方向
↡显示玩家瞄准位置的屏幕图形,其逻辑目标由鼠标像素经 worldView 映射到世界坐标得到。可绘制在世界层或 HUD 层:若贴合世界目标,使用 worldView;若始终跟随鼠标像素,使用 hudView,但射击目标仍要映射到世界。
const sf::Vector2i mousePixel{sf::Mouse::getPosition(window)};
const sf::Vector2f aimWorld{
window.mapPixelToCoords(mousePixel, worldView)};
crosshairSprite.setPosition(aimWorld);
window.setMouseCursorVisible(false);隐藏系统光标要在暂停、失焦或退出菜单时恢复,避免用户失去指针反馈。窗口外鼠标与失焦输入也要定义策略:通常不发射,只继续处理事件。
↡子弹创建时使用的世界位置,通常来自玩家枪械或角色前方,而不是鼠标像素。与 aimWorld 相减产生方向。两点重合时方向长度为零,应拒绝发射而不是除零。
Bullet 的完整状态
Bullet 在池中始终存在,但只有 active 时更新、碰撞和绘制。
↡对象当前是否参与游戏更新、碰撞和绘制的有限状态;停用对象保留存储等待复用。比把子弹移到屏外更清楚。
class Bullet {
public:
bool shoot(sf::Vector2f start, sf::Vector2f target,
float speed, float maximumRange);
void update(float dt, const sf::FloatRect& arena);
void stop() noexcept;
[[nodiscard]] bool active() const noexcept;
[[nodiscard]] sf::FloatRect bounds() const noexcept;
[[nodiscard]] const sf::RectangleShape& shape() const noexcept;
private:
bool active_{false};
sf::Vector2f position_{};
sf::Vector2f velocity_{};
float travelled_{0.0F};
float maximumRange_{0.0F};
sf::RectangleShape shape_{{12.0F, 4.0F}};
};构造后 inactive,旧位置没有玩法意义。shoot 验证速度和射程为正,方向非零,然后一次提交全部字段;失败保持 inactive。
发射方向与实际路程
↡从枪口指向世界目标并缩放为长度一的向量,用于乘每秒速度得到子弹速度。按欧氏长度归一化。射程累计本帧位移长度,而不是简单累计 dt 后假设速度永不变化;未来若子弹减速或反弹,实际路程仍正确。
bool Bullet::shoot(sf::Vector2f start, sf::Vector2f target,
float speed, float maximumRange)
{
const sf::Vector2f delta{target - start};
const float length{std::sqrt(delta.x * delta.x + delta.y * delta.y)};
if (active_ || length <= 0.0001F || speed <= 0.0F || maximumRange <= 0.0F)
return false;
position_ = start;
velocity_ = delta / length * speed;
travelled_ = 0.0F;
maximumRange_ = maximumRange;
active_ = true;
shape_.setPosition(position_);
return true;
}active_ 最后设置,保证其它字段先完成。浮点长度用小阈值避免极短方向放大误差;阈值单位属于世界坐标,应与玩法尺度匹配。
更新、射程与对象池
↡预先创建固定数量对象,运行时在 inactive/active 状态间复用,避免热路径频繁分配释放。
可用 std::array<Bullet, 100>。射击寻找首个 inactive 槽,没有槽位就拒绝或按策略回收最旧子弹,不能越界写。
void Bullet::update(float dt, const sf::FloatRect& arena)
{
if (!active_)
return;
const sf::Vector2f step{velocity_ * dt};
position_ += step;
travelled_ += std::sqrt(step.x * step.x + step.y * step.y);
shape_.setPosition(position_);
if (travelled_ >= maximumRange_ || !arena.intersects(bounds()))
stop();
}离开 arena 的规则要考虑“部分相交还是完整离开”。上例只要 bounds 与 arena 不相交才回收。stop 清 active、速度和命中临时状态;不必擦除 shape,但绘制过滤 inactive。
Pickup 是有时间的状态机
↡在竞技场中提供生命或弹药效果、被玩家收集后隐藏并在冷却结束后重新生成的类。至少有类型、状态、位置、效果量和冷却。bool active 加 timer 可实现,但枚举
Hidden/Available 更清楚。
enum class PickupType { Health, Ammo };
enum class PickupState { Hidden, Available };
class Pickup {
public:
void update(float dt, const SpawnContext& context);
void collect(Player& player);
[[nodiscard]] bool available() const noexcept;
[[nodiscard]] sf::FloatRect bounds() const noexcept;
private:
PickupType type_;
PickupState state_{PickupState::Hidden};
float cooldownLeft_{0.0F};
float respawnSeconds_;
int effectAmount_;
};↡拾取物被收集后到再次可用之间按游戏时间递减的时长。 暂停期间是否递减由游戏时间策略决定,通常冻结。Hidden 不绘制不碰撞,冷却到零后只生成一次。
合法生成与一次收集
生成点来自地图可走区域,避开墙、玩家安全距离和不可见死角,并设最大尝试次数。生成失败保持 Hidden,稍后重试或报告关卡配置错误,不能无限循环。
void Pickup::collect(Player& player)
{
if (state_ != PickupState::Available)
return;
if (type_ == PickupType::Health)
player.addHealth(effectAmount_);
else
player.addAmmo(effectAmount_);
state_ = PickupState::Hidden;
cooldownLeft_ = respawnSeconds_;
}Player 的 addHealth/addAmmo 自己钳制最大值,Pickup 不重复对象不变量。状态先后可让效果成功后再隐藏;如果效果可能失败,collect 返回结果并定义是否消耗 Pickup。
↡把生命、弹药等数值限制在零到配置最大值,避免拾取造成溢出或超过 UI/玩法范围。应使用足够宽的有符号类型并先检查加法溢出,不能先溢出再 clamp。
三类碰撞的数据流
↡检查玩家、僵尸、子弹和拾取物边界是否满足交互条件,并生成伤害、收集或死亡事件的阶段。本章主要用 AABB。检测读取稳定集合,提交阶段才删除死亡 Zombie;否则 vector erase 会让循环索引和指针失效。
子弹与僵尸
遍历 active Bullet,再遍历 alive Zombie。首次 AABB 命中时 stop Bullet、记录 {zombieId, damage} 并 break。随后按 id 提交伤害;Zombie 血量归零只产生一次死亡和分数事件。
for (Bullet& bullet : bullets) {
if (!bullet.active())
continue;
for (Zombie& zombie : horde) {
if (!zombie.alive())
continue;
if (bullet.bounds().intersects(zombie.bounds())) {
damageEvents.push_back({zombie.id(), bulletDamage});
bullet.stop();
break;
}
}
}普通子弹只命中一只;穿透弹则保留 active 并记录已命中 id 集合,规则不同不能隐式混用。
玩家与僵尸
持续 AABB 重叠若每帧扣血,会让伤害取决于 FPS。使用
↡玩家受伤后短时间内拒绝新的接触伤害,使持续重叠不会每帧重复扣血。或无敌帧。首次伤害设置 cooldown,后续游戏时间递减;暂停不递减。
多个 Zombie 同帧接触时要定义叠加上限:每只都伤害、只取一次或按总量封顶。规则层聚合后调用 Player 一次,音效和闪烁也只触发一次。
玩家与拾取物
只检查 Available Pickup。碰撞后 collect 立即转 Hidden,所以同帧后续检测不会重复;HUD 根据 Player 新数值刷新。若 Player 已满血,设计可选择仍消耗或保留,必须由 collect 返回结果表达。
删除、计分与音效集中提交
检测阶段只记录事件和状态标记。提交顺序可为:应用 Bullet 伤害;把新死亡 Zombie 标记并加分;应用玩家接触伤害;处理 Pickup;帧尾 erase_if 删除死亡 Zombie。任何已删除对象的指针不进入下一阶段。
↡在稳定集合检测完成后集中应用伤害、死亡、分数、音效和删除的阶段。
让一次事件只产生一次副作用。分数由 Zombie 从 alive 变 dead 的边沿增加,不由 health <= 0 持续条件每帧增加。
声音也消费事件:枪声在成功 shoot 时,命中声在有效 damage 时,拾取声在 collect 成功时。不能在每帧重叠条件中重启 Sound。
性能边界与空间划分
子弹数 B 与僵尸数 Z 的双循环是 O(BZ)。当前规模可能足够;数量增长后可用均匀网格按世界单元登记 Zombie,只检测子弹所在及相邻单元。空间划分必须保持更新与删除同步,否则漏碰撞比慢更严重。
先用计数器记录每帧候选对、实际重叠和耗时,再决定优化。AABB 是粗筛,旋转精灵可能有空白误差;Zombie 射击通常不需像素级精判。高速 Bullet 仍有离散穿透问题,可用固定步长或线段/扫掠检测。
对象池减少分配,不减少碰撞候选;inactive Bullet 应在外层立即 continue。绘制也只提交 active 和可见对象。
端到端验证
Bullet 测试零方向拒绝、发射字段一次提交、不同 dt 总路程一致、射程和场外停用、首次命中后不再伤害。池满时按策略拒绝且弹药不应错误扣除,或只有成功 shoot 才扣弹药。
Pickup 测试 Hidden 不绘制不碰撞、冷却冻结于暂停、重生点合法、连续重叠只生效一次、生命弹药不超过上限。不可满足生成约束在尝试上限后失败而不死循环。
碰撞矩阵覆盖玩家-僵尸、子弹-僵尸、玩家-Pickup,明确不存在 Zombie-Pickup 等无规则组合。一次 Zombie 死亡只计一次分,删除后目标锁定失效,HUD 与音效读取提交后事件。
先预测:Bullet 命中后只在帧尾删除,内层循环又继续检查其它 Zombie 会怎样?一颗普通子弹可能同帧造成多次伤害;命中时立即 inactive 并 break,删除/复用留到安全阶段。
小结
- 准星像素经 worldView 映射为世界目标,枪口到目标归一化形成 Bullet 速度
- Bullet 从固定池取得,发射一次建立完整状态,首次命中、射程耗尽或场外后停用
- Pickup 在 Hidden/Available 间转换,一次 collect 后立即隐藏并启动游戏时间冷却
- 玩家、僵尸、子弹碰撞先读取稳定集合生成事件,再集中提交伤害、死亡、分数和删除
- 玩家接触伤害使用冷却,避免持续重叠导致 FPS 相关扣血;属性增加在 Player 内钳制
- vector 遍历中不 erase,帧尾统一清理;性能优化以候选计数和实际耗时为证据
练习
问题 1 Bullet 发射与首次命中分别要维护哪些不变量?为什么零长度方向必须拒绝?
问题 2 Health Pickup 与 Player 持续重叠三帧,怎样保证只加一次血且不超过上限?
问题 3 为什么碰撞检测阶段不能直接 erase 死亡 Zombie?给出安全提交顺序。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Bullet 类
维护活跃、位置、速度、射程和形状并可从池复用的子弹对象。
- 准星
显示瞄准点的图形,射击目标由像素经 worldView 映射到世界。
- 枪口位置
- 子弹发射的世界起点。
- 活跃状态
决定对象是否参与更新、碰撞和绘制的状态。
- 发射方向
- 枪口指向世界目标并归一化的向量。
- 子弹池
预创建固定数量 Bullet 并以 active/inactive 复用的存储。
- Pickup 类
提供生命或弹药、收集后隐藏并冷却重生的对象。
- 重生冷却
拾取后到再次可用之间按游戏时间递减的时长。
- 属性钳制
把生命弹药等限制到零和配置上限之间的操作。
- 碰撞检测
检查对象边界并生成伤害、收集或死亡事件的阶段。
- 受伤冷却
一次接触伤害后短期拒绝新伤害的时间窗。
- 碰撞提交
稳定检测后集中应用伤害、死亡、分数、音效和删除的阶段。