第 7 章:Ball、AABB 与 Pong 物理

第 7 章:Ball、AABB 与 Pong 物理:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。

学习目标

  • 能解释“第 7 章:Ball、AABB 与 Pong 物理”如何用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则
  • 能逐项定位 ball 类(ball class)、aabb 碰撞检测(aabb collision detection)、物理(physics)、计分(scoring)、三路比较运算符(spaceship operator),说明它们位于源码、运行状态还是可见输出边界
  • 能按 保存旧位置 → 检测 AABB → 求接触方向 → 分离并反射 → 提交计分 重放“正面击中球拍”,持续检查“一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应”
  • 能注入“球仍与球拍重叠时每帧反转水平速度,造成速度来回抖动和粘连”,从前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件找到第一个不一致并用同输入恢复

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

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

在“第 7 章:Ball、AABB 与 Pong 物理”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“ball 类(ball class)”概念错误。

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

正式概念与运行状态合同

ball 类(ball class)

这个正式目录节点落在 几何状态 边界:Ball/Bat AABB 与相对位置。在本页中,它参与“用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则”。验收时保存前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件,不能只凭最终画面判断。

aabb 碰撞检测(aabb collision detection)

这个正式目录节点落在 物理规则 边界:重叠判断、分离与速度反射。在本页中,它参与“用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则”。验收时保存一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应,不能只凭最终画面判断。

物理(physics)

这个正式目录节点落在 回合结果 边界:出界、重置、计分与比较。在本页中,它参与“用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则”。验收时保存前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件,不能只凭最终画面判断。

计分(scoring)

这个正式目录节点落在 几何状态 边界:Ball/Bat AABB 与相对位置。在本页中,它参与“用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则”。验收时保存一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应,不能只凭最终画面判断。

三路比较运算符(spaceship operator)

这个正式目录节点落在 物理规则 边界:重叠判断、分离与速度反射。在本页中,它参与“用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则”。验收时保存前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件,不能只凭最终画面判断。

验收项本页合同
最小正常场景球从左侧进入球拍 AABB,水平速度指向右
边界或恢复场景球完全越过玩家防守边界且未与球拍接触
必须保持一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应
单一故障球仍与球拍重叠时每帧反转水平速度,造成速度来回抖动和粘连
可观察证据前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件

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

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

先预测“正面击中球拍”会怎样穿过 几何状态 → 物理规则 → 回合结果,再切换场景和正式概念。每次操作都必须能回到同一初始状态。

Source · state · visible result

第 7 章:Ball、AABB 与 Pong 物理:可运行切片

用 Ball 类、AABB 重叠、速度反射、计分与三路比较运算符完成 Pong 的可重复碰撞规则

选择输入或构建场景

定位正式概念

bcgp3-07 · 当前切片

ball 类(ball class)球从左侧进入球拍 AABB,水平速度指向右

边界 1几何状态

Ball/Bat AABB 与相对位置

边界 2物理规则

重叠判断、分离与速度反射

边界 3回合结果

出界、重置、计分与比较

可验收结果

只反射 x 速度并先解除重叠,不发生重复碰撞

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

依次执行 保存旧位置 → 检测 AABB → 求接触方向 → 分离并反射 → 提交计分。每一步只选中一个阶段,并持续检查“一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应”。

Deterministic state trace

第 7 章:Ball、AABB 与 Pong 物理:状态执行轨迹

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

一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应

本步证据

前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件

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

注入“球仍与球拍重叠时每帧反转水平速度,造成速度来回抖动和粘连”,定位第一项不一致;撤销后用完全相同的“球越过底线”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 7 章:Ball、AABB 与 Pong 物理:故障注入与恢复

单一故障:球仍与球拍重叠时每帧反转水平速度,造成速度来回抖动和粘连

1. 固定构建与输入一致

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

2. 程序状态一致

一次接触只产生一次法线方向反射;分数变化与球越过边界事件一一对应

3. 诊断证据一致

前后 AABB、穿透方向、碰撞法线、速度向量、接触阶段和计分事件

4. 恢复判断一致

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

易错边界与工程取舍

从“检测到了,然后怎样恢复合法状态”开始

第 6 章只有球拍。第 7 章加入

, 完成第二个可玩项目。碰撞系统不能停在布尔值:检测后必须把球移出重叠、反射正确速度分量,并确保反馈和计分只触发一次。

Ball 的状态与构造

球需要二维位置、二维速度和 RectangleShape。位置作为事实来源,shape 每次更新后同步;速度单位是像素每秒。构造后尺寸为正、速度有限且形状位置一致。

class Ball {
public:
    Ball(sf::Vector2f start, sf::Vector2f size, sf::Vector2f velocity);
    void update(float dt, sf::Vector2f arenaSize);
    void reset(sf::Vector2f position, sf::Vector2f velocity);
    void bounceFromBat(float batTop);
 
    [[nodiscard]] const sf::RectangleShape& shape() const noexcept;
    [[nodiscard]] sf::FloatRect bounds() const noexcept;
    [[nodiscard]] bool missed(float arenaHeight) const noexcept;
 
private:
    sf::Vector2f position_;
    sf::Vector2f velocity_;
    sf::RectangleShape shape_;
};

不能和每帧位移混用。reset 同时恢复位置与速度,避免新发球继承旧轮次的屏外位置或反弹方向。

积分与墙壁反射

先更新候选位置,再处理边界。撞左右墙只反转 x,撞顶部只反转 y;同时投影回边界,防止球留在墙外每帧反复翻转。

void Ball::update(float dt, sf::Vector2f arena)
{
    position_ += velocity_ * dt;
    const sf::Vector2f size{shape_.getSize()};
 
    if (position_.x < 0.0F) {
        position_.x = 0.0F;
        velocity_.x = std::abs(velocity_.x);
    } else if (position_.x + size.x > arena.x) {
        position_.x = std::max(0.0F, arena.x - size.x);
        velocity_.x = -std::abs(velocity_.x);
    }
 
    if (position_.y < 0.0F) {
        position_.y = 0.0F;
        velocity_.y = std::abs(velocity_.y);
    }
    shape_.setPosition(position_);
}

使用 abs 固定反弹方向,避免球已朝内却因重复条件又翻回。窗口比球还窄时需要暂停或把最大坐标钳为零,不能构造负边界。

AABB 检测到底保证什么

在 x 与 y 两轴都重叠时报告相交。SFML 2.6 可使用 FloatRect::intersects;它回答当前离散时刻是否重叠,不提供首次接触时间、碰撞法线或穿透深度。

bool overlaps(const sf::FloatRect& a, const sf::FloatRect& b)
{
    return a.left < b.left + b.width &&
           a.left + a.width > b.left &&
           a.top < b.top + b.height &&
           a.top + a.height > b.top;
}

是否把边缘刚好接触视为碰撞取决于 < 还是 <=,要与游戏规则一致。Pong 的矩形球和球拍用 AABB 已足够,不需要像素级碰撞。

离散检测与扫掠检测的边界

离散 AABB 只看更新后的矩形,成本低且适合本书当前速度。要估算是否可能穿透,可比较单步最大位移 speed * maxDt 与最薄碰撞体尺寸;位移接近或超过球拍高度时,端点检测不再可靠。减小球速并不是通用修复,它改变玩法;应先确定允许速度和最差帧间隔。

扫掠 AABB 把球在一帧中的运动看作线段或扩张矩形,计算两轴进入和离开时间;时间区间重叠且首次进入位于本帧内,才得到接触时刻。随后把球推进到接触点、反射剩余时间。该方法更复杂,本章不要求完整实现,但必须知道 AABB 布尔值不是连续物理证明。

固定步长是另一层策略:渲染帧积累真实时间,以例如 1/120 秒重复更新零到多次,并限制单帧最大追赶次数。它让碰撞步长稳定、测试可重复,但慢机器仍可能落后;插值只改善画面,不会修复错误规则。Pong 可先采用 dt 上限并用高速测试确定安全范围。

球拍响应:方向筛选、投影、反射

球与球拍重叠后,只有球向下运动才响应。先把球底边放到球拍顶边,再让 y 速度为负;否则球仍嵌在球拍中,下一帧又相交并反复翻转。

void Ball::bounceFromBat(float batTop)
{
    const float ballHeight{shape_.getSize().y};
    position_.y = batTop - ballHeight;
    velocity_.y = -std::abs(velocity_.y);
    shape_.setPosition(position_);
}
 
if (ballVelocityY > 0.0F && overlaps(ball.bounds(), bat.shape().getGlobalBounds())) {
    ball.bounceFromBat(bat.shape().getGlobalBounds().top);
    batHitSound.play();
}

示例查询速度可通过 movingDown() 封装,避免 main 依赖内部字段。音效只在满足“向下且重叠”的边沿触发。球拍移动也会影响接触,但本章简化为只反射球;更完整物理可把球拍速度加入水平分量。

与速度反射缺一不可。只有投影没有反射会再次进入,只有反射没有投影会在深穿透时连续触发。

击球位置与水平速度

为了让 Pong 可控,可根据球撞在球拍中心左侧或右侧调整 x 速度。偏移先归一化到 -1..1,再乘最大横向速度;仍要限制最小纵向速度,避免球几乎水平永不落下。

const sf::FloatRect batBounds{bat.shape().getGlobalBounds()};
const float batCenter{batBounds.left + batBounds.width * 0.5F};
const float ballCenter{ball.bounds().left + ball.bounds().width * 0.5F};
const float offset{std::clamp((ballCenter - batCenter) / (batBounds.width * 0.5F),
                              -1.0F, 1.0F)};
ball.setHorizontalVelocity(offset * maximumHorizontalSpeed);

球拍宽度必须大于零。这个响应是玩法模型,不是现实冲量;参数目标是可控和稳定。记录碰撞前后位置速度,可复现角落异常。

底线、计分与发球重置

不能用 if (ball.y > height) ++misses 每帧累加。检测后立即重置或进入短暂 Serve 状态,确保一轮只计一次。

if (ball.missed(arenaHeight)) {
    ++misses;
    ball.reset({arenaWidth * 0.5F, arenaHeight * 0.4F},
               {initialSpeedX, initialSpeedY});
    scoreText.setString("Misses: " + std::to_string(misses));
}

要让球完整位于场内。若发球方向随机,随机引擎由游戏状态拥有并可固定种子测试;不要在 reset 内每次按时钟重新播种。

三路比较运算符

C++20 的

适合值对象排序,不参与碰撞响应。默认 <=> 按成员声明顺序进行词典式比较,必须确认它符合业务排名。

#include <compare>
 
struct RankedScore {
    int points;
    int streak;
 
    auto operator<=>(const RankedScore&) const = default;
};

这里先比较 points,再在相等时比较 streak;如果“分数高排前”或“连击只用于显示”,默认顺序就不符合排名,需要自定义比较器。浮点成员可能产生 partial ordering,因为 NaN 与任何值无序,不能假设总排序。

运行 Pong 的阶段顺序

每帧先处理窗口事件和球拍输入,再更新 Bat 与 Ball;然后处理墙壁、球拍碰撞和底线事件,最后刷新 HUD 并绘制。所有检测使用同一次更新后的状态。若游戏结束,停止 Ball 更新但继续事件与终局绘制。

绘制顺序是背景、Bat、Ball、分数和提示。Ball 与 Bat 的 Shape 都只经 const 查询绘制。窗口 resize 时统一使用逻辑场地尺寸或同步 View,不能一部分规则用像素、一部分用旧逻辑宽高。

测试矩阵包括左右墙、顶部、球拍中间和两端、球向上重叠不反弹、深穿透投影、底线只计一次、重置速度合法。用大 dt 明确观察离散检测限制,再选择 dt 上限或固定步长。

计分事件与状态提交

BallMissedBatHit 看成更新阶段产生的事件,而不是让 HUD、声音和规则各自重新检查位置。规则层消费 BallMissed 增加分数并发球,音频层消费 BatHit 播放一次声音,HUD 在数值变化后重建字符串。事件可以只是本帧局部结构,不必立即建立复杂消息总线。

同一更新步内先解决墙壁,再判断球拍,最后判断底线;如果 Ball 已因底线重置,本帧后续不再处理旧位置。一次更新结果可以包含 hitBatmissed,并断言两者不会同时为真。日志记录旧位置、得分方和新发球速度,便于复现。

完成 Pong 的端到端验收

正常路径要证明:球拍按住移动且松开停止,球与三面墙和球拍稳定反弹,击球位置改变横向轨迹,漏球只计一次并重新发球,达到目标分进入终局,重启后分数、Ball 和 Bat 都恢复。关闭窗口在任何阶段有效,初始化失败不会进入循环。

故障测试把 dt 注入为正常值、上限值和超大墙上间隔;把 Ball 直接放在墙外、球拍内和底线下,检查一次更新后恢复合法状态。用固定发球速度运行多轮,确认没有 NaN、无限速度或越界累积。若加入加速机制,速度上限和最小纵向分量也成为不变量。

视觉测试在不同窗口比例检查 View:碰撞使用逻辑坐标,HUD 使用屏幕层,resize 不应让物理边界和绘制边界分离。调试叠加可画出 Ball/Bat 的 global bounds,但只在开发模式启用,避免把诊断几何混入正式资源。

每次失败都保存种子、dt、位置和速度,保证下一次运行可以重放同一轨迹。

先预测:球向上离开球拍但包围盒仍重叠时,若不检查速度方向会怎样?每帧都可能再次反射,球被吸在球拍附近;响应必须要求向下运动并在反弹时完成位置分离。

小结

  • Ball 类封装位置、速度、形状、积分和重置,main 协调与 Bat 的世界规则
  • 墙壁碰撞先投影回边界,再只反转对应速度分量,防止重复翻转
  • AABB 只报告离散重叠;高速球可能跨越,需要 dt 策略或扫掠检测
  • 球拍响应由向下方向筛选、穿透修正和 y 速度反射组成,反馈只触发一次
  • 底线生成一次计分事件并立即发球重置,HUD 只是数值分数的投影
  • <=> 提供排序结果,默认成员顺序和升序方向必须与业务排名一致

练习

问题 1 球深度进入球拍 8 px 且 vy 为正,只把 vy 取负为什么仍可能重复碰撞?正确响应顺序是什么?

问题 2 为什么底线计分必须是事件而不是持续条件?列出 reset 后至少三个不变量。

问题 3 默认 RankedScore{points, streak}<=> 怎样比较?为何它未必符合排行榜?

名词解释

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

Ball 类
封装球位置、速度、尺寸、形状、运动和重置不变量的 Pong 对象。
速度向量
表示每秒二维位置变化的 x/y 分量。
运动积分
用速度乘 dt 近似推进位置的数值步骤。
速度反射
把位置投影回边界并反转碰撞法向速度分量的响应。
AABB
与坐标轴对齐的矩形包围范围。
AABB 碰撞检测
在两个矩形的 x/y 投影都严格重叠时报告当前交叠。
穿透修正
检测重叠后把对象移动回接触边界以恢复分离状态。
底线事件
球完整越过下边界后只产生一次的计分或失分事件。
发球重置
恢复 Ball 场内位置与有效速度并清除旧轮次状态。
三路比较运算符
C++20 的 <=>,一次产生小于、等于或大于的排序关系。

资料与写作方式声明

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

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

讨论

评论区加载中…