两个矩形碰不碰,就四个不等式:四种「完全错开」的可能全否掉。 边界贴边不算碰上——代码里写的是小于等于,不是小于。 对象一多就不能两两比,分格子(网格)之后只跟邻近的比。
上一章把《俄罗斯方块》做完了,这一章回到 Cocos2d,学它的进阶功能。 教材第 6 章第一节是【Collision】,用《猫吃老鼠》这个小例子讲碰撞。 碰撞是游戏里最基础也最容易写错的一件事。 写错了不会崩,只会「打不着」或者「隔着老远就打中了」。 本篇的判定表、网格分桶、猫追老鼠的帧数,都是真跑出来的。
一、两个矩形怎么算碰上
Cocos2d 里每个精灵都有一个框(x、y、宽、高)。判断两个框有没有重叠,只要四个不等式。 思路反过来想更清楚:两个框不重叠,只有四种可能。B 整个在 A 右边、左边、上边、下边。 四种都不成立,那就是重叠。
a.x + a.w <= b.x # B 整个在 A 右边
b.x + b.w <= a.x # B 整个在 A 左边
a.y + a.h <= b.y # B 整个在 A 下边
b.y + b.h <= a.y # B 整个在 A 上边
四个用 or 串起来取反,就是判定。 少写一条,就会有一侧「怎么也撞不上」。这种 bug 特别隐蔽,因为另外三侧都正常。
二、贴边到底算不算
演示里主角的框是 (100, 100, 60, 60),也就是说它的右边缘在 160。 另一个 20×20 的框放在 (160, 100):它的左边缘正好在 160,跟主角的右边缘重合。结果是没碰上。 再往左挪一格,放到 (159, 100):碰上了。
差的那 1 个像素,就是「小于」和「小于等于」的区别。
写成 <= 就是「贴边不算碰上」,写成 < 就是「贴边算碰上」。
两种都有人用,但你得知道自己选的是哪种。
选错的表现:两个方块并排站着,看起来明明挨着,却判不出碰撞;或者反过来,隔着一道缝就说撞上了。
三、圆和矩形:换个算法
圆没有「框」,判定改成比距离:点到圆心的距离小于等于半径就算碰上。
演示算了五个点:圆心 (200, 200)、半径 30。点 (228, 200) 距离 28,碰上;
(250, 200) 距离 50,没碰上。
距离要用 hypot(勾股定理)。
别用「水平差 + 垂直差」代替——那样算出来的是曼哈顿距离,比真实距离大,判定会偏严,
四个角上会出现「看着没挨着却撞上了」的怪事。
实际项目里更常见的是「圆配矩形」:用圆做粗略判定(快),通过了再用矩形精确判(准)。 先粗后细,是所有碰撞优化的通用套路。
四、对象一多,两两比就受不了
演示算了对比次数:100 个对象两两比是 4950 对,500 个是 124750 对,2000 个是 1999000 对。 数量翻四倍,比较次数翻十六倍。这是平方增长,几千个对象就直接把帧时间吃光了。 可实际上,屏幕上大部分对象离得很远,根本不可能碰上。挨个比,绝大部分计算是白做的。
五、网格:只跟附近的比
做法很简单:把世界切成一个个格子,每个对象按自己的位置丢进对应的格子里。 查碰撞的时候,只需要看自己这一格和周围八格。十有八九,旁边那几格是空的。 演示把 10 个对象按 100 像素一格丢了进去,占了 9 个格子,最挤的一格 2 个。 大部分格子是空的——这就是省下来的那部分。
格子多大?大约等于对象尺寸的两倍是个常用起点。 太小了格子数量爆炸,太大了每格里挤一堆,又回到两两比。 还有个细节:对象在动的时候要从旧格子挪到新格子。 忘了挪,碰撞判定就会用错位置——这种 bug 表现为「明明撞上了却没反应」。
六、猫吃老鼠:一帧一帧走
演示里猫在 x = 0,宽 40;老鼠在 x = 220,宽 30。猫每帧走 8 像素。 跑下来第 23 帧撞上,这时猫在 x = 184,右边缘 224,已经越过了老鼠的左边缘 220。 注意「越过」这两个字。碰撞不是「刚好贴上」,而是「这一帧迈过去之后重叠了」。 所以判定永远发生在移动之后。
顺序就是:先按速度移动,再判碰撞,撞了就处理。 反过来先判后移,就会差一帧。一帧在 60 帧每秒下是 16 毫秒,玩家基本感觉不到, 但在快速移动的对象上会变成「擦身而过不算撞」。
七、穿透:一帧挪太远
演示最后一段:猫从 x = 100 一帧跳到 x = 260。 这时候右边缘在 300,老鼠在 220 到 250 之间——猫整个跨过去了,判定是「没碰上」。 中间那一段路,一次都没判过。这就是「穿透」(tunneling)。 子弹、激光这类高速对象最容易穿透。 解决办法有两个:一是限制每帧最大位移,二是把这一帧的位移拆成几小步,每步判一次。
第二种更彻底,代价是多算几次。高速对象多的游戏,这一条是必须做的。
八、动手 Demo:碰撞:四个不等式、一张网格、一次穿透
先拿六组位置试矩形判定,看贴边到底算不算。 再算一算对象多了要比较多少次,然后用网格把范围缩下来。 最后跑一遍猫追老鼠,看它在第几帧撞上,再看一次「一帧跳太远」的穿透。
import math
def hit(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return not (ax + aw <= bx or bx + bw <= ax
or ay + ah <= by or by + bh <= ay)
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 矩形判定
sec('① 六组位置')
box = (100, 100, 60, 60)
for t in ((0, 0, 20, 20), (120, 120, 20, 20), (160, 100, 20, 20),
(159, 100, 20, 20), (100, 159, 20, 20), (160, 160, 20, 20)):
info('框 %s -> %s' % (str(t), '碰上' if hit(box, t) else '没碰上'))
# ② 圆
sec('② 圆比距离')
cx, cy, r = 200, 200, 30
for px, py in ((200, 200), (228, 200), (250, 200)):
d = math.hypot(px - cx, py - cy)
info('点 (%d, %d) 距离 %.1f -> %s' % (px, py, d, '碰上' if d <= r else '没碰上'))
# ③ 两两比的代价
sec('③ 两两比要算多少次')
for n in (100, 500, 2000):
info('%d 个对象 -> %d 对' % (n, n * (n - 1) // 2))
# ④ 网格分桶
sec('④ 按格分桶')
G = 100
grid = {}
pts = [(7, 13), (55, 260), (120, 340), (330, 90), (410, 500),
(760, 30), (25, 25), (95, 105), (690, 420), (600, 210)]
for i, (x, y) in enumerate(pts):
grid.setdefault((x // G, y // G), []).append(i)
info('10 个对象占 %d 个格子,最挤的一格 %d 个'
% (len(grid), max(len(v) for v in grid.values())))
# ⑤ 猫追老鼠
sec('⑤ 猫追老鼠')
cat, mouse, step = [0, 200, 40, 40], (220, 200, 30, 30), 8
f = 0
while cat[0] + cat[2] < 400:
if hit(tuple(cat), mouse):
break
cat[0] += step
f += 1
info('第 %d 帧撞上,猫在 x = %d' % (f, cat[0]))
# ⑥ 穿透
sec('⑥ 一帧跳太远')
a = (260, 200, 40, 40)
info('猫直接跳到 x = 260,右边缘 %d' % (a[0] + a[2]))
info('这时候判碰撞:%s' % ('碰上' if hit(a, mouse) else '没碰上'))
实跑输出:
--------------------------------------------------------------------
碰撞检测:四个不等式和一张网格
--------------------------------------------------------------------
① 两个矩形算不算碰上
--------------------------------------------------------------------
主角的框 (x=100, y=100, 宽=60, 高=60)
| 另一个框 | 结果 |
|---|---|
| (0, 0, 20, 20) | 没碰上 |
| (120, 120, 20, 20) | 碰上 |
| (160, 100, 20, 20) | 没碰上 |
| (159, 100, 20, 20) | 碰上 |
| (100, 159, 20, 20) | 碰上 |
| (160, 160, 20, 20) | 没碰上 |
! 边界贴边不算碰上——代码里用的是「小于等于」不是「小于」。
② 为什么是四个不等式
--------------------------------------------------------------------
两个框不重叠,只有四种可能:B 在 A 右边、B 在 A 左边、B 在 A 下面、B 在 A 上面。
四种可能全排除,剩下的就是重叠。所以判定写成「四个都不成立」。
a.x + a.w <= b.x B 整个在 A 的右边
b.x + b.w <= a.x B 整个在 A 的左边
a.y + a.h <= b.y B 整个在 A 的下边
b.y + b.h <= a.y B 整个在 A 的上边
! 四个条件用 or 串起来,取反。少写一个,就会有一侧「怎么也撞不上」。
③ 圆和矩形:换个算法
--------------------------------------------------------------------
圆心 (200, 200),半径 30
| 点 | 到圆心距离 | 结果 |
|---|---|---|
| (200, 200) | 0.0 | 碰上 |
| (228, 200) | 28.0 | 碰上 |
| (250, 200) | 50.0 | 没碰上 |
| (150, 150) | 70.7 | 没碰上 |
| (100, 100) | 141.4 | 没碰上 |
! 圆的判定是比距离。距离用 hypot 算,别用直角边相加——那样得到的是曼哈顿距离。
④ 对象一多,两两比较就受不了
--------------------------------------------------------------------
100 个对象两两比一次,要算 4950 对
500 个对象就要算 124,750 对
2000 个对象就要算 1,999,000 对
! 数量翻四倍,比较次数翻十六倍。这就是为什么要有「网格」这种东西。
⑤ 网格加速:只跟附近的比
--------------------------------------------------------------------
把 10 个对象按 100 像素一格丢进桶里,一共占了 9 个格子
最挤的一格有 2 个对象
格子 (0, 0) 里有 [0, 6]
格子 (0, 1) 里有 [7]
格子 (0, 2) 里有 [1]
格子 (1, 3) 里有 [2]
格子 (3, 0) 里有 [3]
格子 (4, 5) 里有 [4]
格子 (6, 2) 里有 [9]
格子 (6, 4) 里有 [8]
格子 (7, 0) 里有 [5]
! 查碰撞时只看自己这一格和相邻八格,比两两比少掉一大半。
⑥ 猫吃老鼠:一帧一帧走
--------------------------------------------------------------------
猫 (0, 200, 40, 40),老鼠 (220, 200, 30, 30)
猫每帧走 8 像素,第 23 帧撞上,猫在 x = 184
这时候猫的右边缘在 224,老鼠的左边缘在 220
! 撞上的那一帧,猫的右边缘已经越过老鼠的左边缘了——碰撞不是「刚好贴上」。
⑦ 一帧挪太远会穿过去
--------------------------------------------------------------------
猫在 x = 100,老鼠在 x = 220,中间隔着一段空当
这一帧直接跳到 x = 260
这时候判碰撞:没碰上
猫的右边缘在 300,老鼠的左边缘在 220
! 整段路都跨过去了,一次都没判过——这就叫「穿透」。移动越快,越要在中间补判定。
第一段那六组不是随便挑的。
(160, 100) 和 (159, 100) 只差一个像素,结果一个是没碰上、一个是碰上,
这就是「小于等于」和「小于」的全部分量。
第三段那两个数记一下:2000 个对象要算将近两百万对。
这个数量级已经超过每秒能做多少次判定了,所以网格不是优化,是必需品。
第五段的第 23 帧、x = 184,是这组参数下算出来的。改速度、改起始位置,这个数就变。
别把它当常数,当例子。
第六段是这一篇最该记住的:整段路跨过去了,一次都没判过。
子弹打不中人、角色穿过地板,十有八九就是这个。
这段代码不依赖 Cocos2d,纯 Python 能跑。hit() 那四行就是引擎 collides 方法的全部内容。
30 秒实验
- 把
hit()里的<=改成<,重跑第一段,看 (160, 100) 那组结果变了没有。 - 把猫每帧的
step从 8 改成 20,重跑第五段,看第几帧撞上。 - 把
G从 100 改成 200,重跑第四段,看占了几个格子。
常见误区
- 「贴边应该算碰上吧。」两种写法都有人用,关键是全项目统一。
一半地方用
<=、一半用<,就会出现「明明挨着却不算撞」。 - 「先判碰撞再移动也一样。」会差一帧。快速对象上,差一帧就是「擦身而过不算撞」。
- 「碰撞不用优化,机器快。」2000 个对象两两比是将近两百万对,每秒要做几十遍。 网格是必需品,不是奢侈品。
自测三问(点开对答案)
1. 两个矩形不重叠的四种可能分别是什么? —— B 完全在 A 的右边、左边、下边、上边。四种都不成立就重叠。 **用四个 `<=` 串起来取反,就是判定。** 2. 为什么贴边那 1 个像素的差别很重要? —— 因为写 `<=` 是「贴边不算碰上」,写 `<` 是「贴边算碰上」。 两种都对,但混用就会出错——一半的对象能贴住,一半的对象会卡在半空中。 3. 什么叫穿透?怎么解决? —— 一帧位移太大,整个跨过了目标,中间一次都没判过碰撞,于是「穿过去了」。 解决办法:限制每帧最大位移,或者把一帧拆成几小步、每步判一次。碰撞讲完了,下一篇换一个大话题:瓦片地图——用一张表撑起一整个世界。
- ☐ (本篇完,下一篇 6.2 瓦片地图与地图碰撞)