第 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 重叠

边界 1生成与瞄准

准星、射击输入、Bullet 初始状态

边界 2碰撞规则

玩家、僵尸、子弹、Pickup 的交互矩阵

边界 3生命周期

激活、消费、冷却、擦除与重生

可验收结果

僵尸受一次伤,子弹立即失活且不再命中其他目标

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

依次执行 生成实体 → 推进位置 → 检测碰撞 → 提交效果 → 安全回收。每一步只选中一个阶段,并持续检查“子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞”。

Deterministic state trace

第 12 章:子弹、拾取物与战斗碰撞:状态执行轨迹

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

子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞

本步证据

实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化

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

注入“遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中”,定位第一项不一致;撤销后用完全相同的“Pickup 超时”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 12 章:子弹、拾取物与战斗碰撞:故障注入与恢复

单一故障:遍历 vector 时擦除当前子弹却继续使用失效迭代器,导致跳过下一颗或重复命中

1. 固定构建与输入一致

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

2. 程序状态一致

子弹命中后只消费一次,Pickup 只在激活窗口内生效,同一实体不能在擦除后继续参与本帧碰撞

3. 诊断证据一致

实体 active 标志、AABB 对、命中事件、迭代器位置、冷却计时和生命值变化

4. 恢复判断一致

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

易错边界与工程取舍

从“按下鼠标后哪一个对象拥有这次射击”开始

第 11 章已经生成僵尸群。第 12 章加入

、 准星和拾取物。战斗逻辑要把输入、对象更新、碰撞检测、伤害提交和删除分阶段,否则一次命中可能在同一帧被重复处理。

世界准星与枪口方向

可绘制在世界层或 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_ 最后设置,保证其它字段先完成。浮点长度用小阈值避免极短方向放大误差;阈值单位属于世界坐标,应与玩法尺度匹配。

更新、射程与对象池

可用 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。

应使用足够宽的有符号类型并先检查加法溢出,不能先溢出再 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 类

提供生命或弹药、收集后隐藏并冷却重生的对象。

重生冷却

拾取后到再次可用之间按游戏时间递减的时长。

属性钳制

把生命弹药等限制到零和配置上限之间的操作。

碰撞检测

检查对象边界并生成伤害、收集或死亡事件的阶段。

受伤冷却

一次接触伤害后短期拒绝新伤害的时间窗。

碰撞提交

稳定检测后集中应用伤害、死亡、分数、音效和删除的阶段。

资料与写作方式声明

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

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

讨论

评论区加载中…