做Python小游戏的人,多半会碰到同一个阶段:地图能画了,角色能动了,子弹能飞了,结果子弹穿过怪物身体的瞬间,心里一凉——“没打中?”或者更尴尬的,角色明明站在墙前面,却被弹到三米外。这些问题背后都指向同一个基础模块——碰撞检测。写游戏可以不追求极致的物理模拟,但碰撞检测如果写得不对、写得慢,整个游戏的体验会瞬间崩盘。这篇内容就围绕Python游戏里的碰撞检测实现展开,从最常见的AABB矩形检测,到圆形判定、像素级判定,再到空间分区优化,把2D游戏里真正用得上的方案一个个拆开讲清楚。
我默认你用的是Pygame,但里面讲到的数学和思路,换到Arcade、Panda3D甚至其他语言都完全通用。碰撞检测不是“能判断相交就行”的活儿,它同时牵扯到坐标系约定、提前判定、重叠修正和性能预算。这些点我会逐个展开,附上可直接抄的代码和踩坑记录,适合正在写小游戏、或者想给游戏加怪物交互、物理弹射、攻击判定但还没找到头绪的人参考。
1. 碰撞检测的数学基础:游戏物体到底是怎么“占地方”的
1.1 简化碰撞体的思路:不画像素,算形状
很多新手第一次写碰撞,想到的是“检查两张图片的像素有没有重叠”。这个思路没有错,但在2D游戏里,绝大多数情况根本不需要走到像素级那一步。原因很简单:像素级检测的计算量太大,一个100x100的角色每次碰撞都要比对上万像素,场景里同时存在几十个物体时性能瞬间崩盘。所以行业的通用做法是“近似”——用简单的几何形状来代替物体的真实轮廓,先做粗略判断,只在必要时才做精细判断。
最常见的三种替代形状:轴对齐矩形(AABB)、圆形、凸多边形。Pygame里的Rect对象本质就是矩形,天生适合AABB检测;圆形适合球体、角色、以中心点为基准的物体;凸多边形更复杂,但适合做旋转后的物体碰撞,在Python小游戏里用得相对少。这篇重点讲矩形和圆形,因为这两个覆盖了95%以上的2D需求。
1.2 坐标系与“碰撞发生”的判定本质
无论哪种形状,碰撞检测的本质都可以归结成一句话:判断两个数学区域是否存在交集。矩形和矩形相交,就是两个矩形的边界范围有没有重叠;圆和圆相交,就是两个圆心距离是否小于半径之和。
这里有个特别容易混淆的点:屏幕坐标系。在Pygame里,坐标原点在左上角,x轴向右,y轴向下。这意味着判断“两个矩形是否重叠”时,不能直接用中心点距离,而要分别比较x方向和y方向的区间。这也是很多新手第一个坑——用距离公式去判断矩形碰撞,结果在斜对角方向出现误判。矩形碰撞必须是分轴判断的,x轴满足条件且y轴满足条件,才算碰撞。
python复制def rects_collide(r1, r2):
# r1, r2 为 (x, y, width, height)
return (
r1[0] < r2[0] + r2[2] and
r1[0] + r1[2] > r2[0] and
r1[1] < r2[1] + r2[3] and
r1[1] + r1[3] > r2[1]
)
这个函数返回True代表碰撞。逻辑上就是从x和y两个轴分别检查区间是否重叠,而不是计算距离。理解了这一点,后面看Pygame的colliderect内部实现就不会觉得神秘了。
1.3 用Rect还是自己写坐标数学?
Pygame的Rect对象自带colliderect()方法,而且支持move()返回新矩形,内部性能也做过优化,绝大多数情况下直接用它就够了。我自己写项目时,90%的碰撞代码都是Rect.colliderect和Rect.collidepoint的排列组合。真正需要手写数学公式的场景是:没有用Pygame的图形库、自己管理坐标、或者要判断旋转矩形。
但无论用什么实现,理解上面的分轴判断逻辑都是必要的。因为一旦出现碰撞偏移、漏判、多判,最终都要回到最底层的数学去排查,而不是依赖某个API。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AABB矩形碰撞实战:PyGame里的Rect用法与边界情况
2.1 Pygame Rect的常用碰撞API
Pygame的pygame.Rect有五个碰撞相关方法,我用了个遍,逐个说下适用场景:
| 方法 | 作用 | 常见用途 |
|---|---|---|
colliderect(rect) |
检测两个矩形是否重叠 | 子弹与怪物、玩家与墙壁 |
collidepoint(x, y) |
检测点是否在矩形内 | 鼠标点击选中、判定落点 |
collidelist(list) |
返回与列表第一个碰撞矩形下标 | 批量判断玩家撞到哪个障碍物 |
collidelistall(list) |
返回所有碰撞矩形下标列表 | 同时踩到多个触发器 |
collidedict(dict) |
与字典值检测碰撞,返回键 | 按对象ID管理碰撞组时很方便 |
实际项目里colliderect和collidelist用的最多。比如一个平台跳跃小游戏,玩家每一帧移动后,需要检测和所有地面、墙体的碰撞,写起来是:
python复制def move_player(player_rect, dx, dy, obstacles):
# x轴移动并检测
player_rect.x += dx
hit_list = player_rect.collidelistall(obstacles)
for index in hit_list:
rect = obstacles[index]
if dx > 0:
player_rect.right = rect.left
elif dx < 0:
player_rect.left = rect.right
# y轴移动并检测
player_rect.y += dy
hit_list = player_rect.collidelistall(obstacles)
for index in hit_list:
rect = obstacles[index]
if dy > 0:
player_rect.bottom = rect.top
elif dy < 0:
player_rect.top = rect.bottom
这种“先移动一个轴、检测、修正,再移动另一个轴”的做法,是平台游戏里处理碰撞回复的常用策略。它比直接移动x和y之后统一修正更稳,能避免角色卡进墙角时同时被x和y方向修正导致抖动。
2.2 矩形检测的三个经典坑
坑一:Rect不会原地移动。 player_rect.move(10, 0)返回的是新矩形,原矩形不变。很多新手在这里掉坑,调了半天发现角色根本不碰撞,因为判断用的是旧坐标。正确做法是player_rect = player_rect.move(10, 0)或者直接用player_rect.x += 10。
坑二:浮点坐标丢了精度。 Rect只接受整数,如果角色的速度是1.5像素/帧,每帧rect.x += 1.5会在内部截断成整数,时间长了玩家会明显感觉角色移动“一卡一卡”。解决办法是坐标实际存储用浮点数,每帧把浮点值赋给Rect再判断碰撞:
python复制player_x += velocity_x # 浮点
player_rect.x = int(player_x) # 仅用于渲染和碰撞
坑三:collidelistall返回的是下标不是对象。 如果你动态增删障碍物列表,下标极易错位。我个人的习惯是改成dict存储对象,用collidedict,或者干脆手动遍历:
python复制for obstacle in obstacles:
if player_rect.colliderect(obstacle.rect):
handle_collision(obstacle)
这段代码虽然“啰嗦”,但胜在可控,方便为不同障碍物写不同的处理逻辑。
2.3 碰撞偏移修正:把物体推出去而不是让它停下来
矩形碰撞检测只是“发现问题”,真正让游戏物理合理的是“解决问题”。处理方式通常是:检测到碰撞后,把移动中的矩形沿碰撞方向推回去,避免嵌入墙壁。上面平台游戏的示例代码已经演示了基本方案——根据移动方向判断物体是从左边还是右边撞上的,然后把矩形卡到障碍物的边缘。
但有一个细节值得展开说:为什么先移动x再移动y而不是反过来? 因为这样能让角色沿墙滑动。假设角色向右上方移动,如果先移动x撞到墙,代码会停住x方向,y方向继续正常移动,于是角色贴着墙往上滑。如果同时处理xy,或者顺序反了,角色会直接“卡死”在墙角,手感非常僵硬。这个技巧我在很多开源项目里看到过,但很多人是误打误撞写对的,并不知道原理。顺序直接影响手感,值得专门留意。
3. 圆形碰撞检测:距离判定与改进思路
3.1 圆和圆的碰撞:简单的距离公式并不简单
圆形碰撞的数学看起来非常简单——两个圆的圆心距离小于等于半径之和,就是碰撞。但真正写代码时,有细节要注意。
python复制import math
def circles_collide(c1, c2):
# c1, c2 为 (x, y, radius)
dx = c1[0] - c2[0]
dy = c1[1] - c2[1]
distance_sq = dx * dx + dy * dy
radius_sum = c1[2] + c2[2]
return distance_sq <= radius_sum * radius_sum
为什么不直接算math.sqrt?因为开方是相对昂贵的操作。如果游戏里每帧有几百个碰撞检测要跑,每次都要开方,性能差距立刻就出来了。两个浮点数的平方和与半径和的平方做比较,完全等价于距离比较,但计算成本低得多。
还有个进阶技巧:如果物体移动速度很快,单纯靠每帧的位置判断圆形碰撞会出现“穿透”——上一帧还没有相交,这一帧已经跑到了对方身后,永远不会被检测到。这种情况下需要做连续碰撞检测,简单做法是把圆心连线拆成一连串小步进,逐点查交点;更工业级的做法是计算扫掠圆,但是这在Python小游戏里通常属于过度设计。实际项目里一个折中方案是:记录上一帧位置,如果两个物体相对移动速度超过半径之和的一半,就插值多检测几次。
python复制# 速度快的子弹可以做多次步进检测
def swept_collision(prev_pos, curr_pos, radius, target_pos, target_radius, steps=4):
for i in range(1, steps + 1):
t = i / steps
x = prev_pos[0] + (curr_pos[0] - prev_pos[0]) * t
y = prev_pos[1] + (curr_pos[1] - prev_pos[1]) * t
if circles_collide((x, y, radius), (target_pos[0], target_pos[1], target_radius)):
return True
return False
在弹幕游戏、高速射击游戏里,这种方案比直接判定稳得多。
3.2 圆与矩形的碰撞:拆成三种情况理解
圆和矩形的碰撞更容易写错,但只要把空间分成几个区域,问题就简单了。先找到圆心上最接近矩形的点,计算这个点和圆心的距离,小于半径就碰撞。 这里的“最接近点”分三种情况:圆心在矩形内部时,最近点就是圆心本身,直接判定碰撞;圆心在矩形外部时,分x和y方向夹取:
python复制def circle_rect_collide(circle_x, circle_y, radius, rect):
closest_x = max(rect.left, min(circle_x, rect.right))
closest_y = max(rect.top, min(circle_y, rect.bottom))
dx = circle_x - closest_x
dy = circle_y - closest_y
return dx * dx + dy * dy <= radius * radius
这段代码的巧妙之处在于用max和min自动完成了区域划分,不需要显式判断圆心在矩形的上下左右哪一边。我一开始学的时候总觉得应该用if判断,后来发现这个写法更简洁且不容易出错。它同样规避了开方运算。
3.3 圆形检测在项目里的实战位置
圆形碰撞最适合使用在玩家角色、子弹、敌人这类“主体近似圆”的对象上。比如一个俯视角射击游戏,玩家和怪物都用一个圆形碰撞体,手感会比矩形好不少。为什么?因为玩家在斜向走位时,矩形碰撞体在角落里会“误伤”,明明看着差一截却撞上了,圆形没有这个问题。
但圆形碰撞有个弱点:不擅长表示墙体和地形。一段蜿蜒的走廊用圆拼不出来,这时候还是得回到矩形。实际项目中矩形和圆形混用的情况非常多,代码结构上最好统一封装成一个collider接口或者统一判断函数。
4. 像素级碰撞检测:什么时候需要用,以及怎么用才不卡
4.1 Pygame的Mask系统
矩形和圆形的近似判定虽然快,但在某些游戏类型里完全不够用。比如一个“不要碰到障碍物尖端”的躲避游戏,障碍物是带尖角的不规则形状,矩形判定的误差会严重伤害游戏公平性;再比如人物有披风、头发、翅膀这些延伸到身体之外的部位,矩形框出来一大片空白,角色明明没碰到东西却被判定阵亡,非常打击人。
这时候就需要像素级碰撞检测。Pygame提供了pygame.mask模块来处理这个问题。核心思路是:为每个图像生成一个掩膜(mask),mask记录图像中不透明像素的位置,碰撞检测时比对两个mask的重叠区域。
python复制mask_a = pygame.mask.from_surface(surface_a)
mask_b = pygame.mask.from_surface(surface_b)
offset = (rect_a.x - rect_b.x, rect_a.y - rect_b.y)
overlap = mask_a.overlap(mask_b, offset)
if overlap:
# overlap 是碰撞点坐标
handle_collision()
这里offset不是两个图像的坐标差,而是rect_a相对于rect_b的偏移,方向反了会得到极其诡异的检测结果。我自己刚开始用的时候在这里卡了半小时,后来发现Pygame官方文档写得很明确:offset是第一个mask相对于第二个mask左上角的偏移量。
4.2 性能问题:像素级检测为什么慢
Mask检测慢主要慢在两个方面:生成mask的耗时,以及每次overlap运算的耗时。如果每帧都对所有物体做mask生成和overlap,帧率直接腰斩。
优化手段按优先级排:
- mask只生成一次,缓存起来。 图像不变时,mask是确定的,完全没必要每帧重新生成。
- 先用矩形检测粗筛,再对命中的物体做像素级检测。 这是最常用、最有效的“两级碰撞检测”:先用
colliderect快速排除掉大部分不相交的物体,剩下的才进入mask的overlap阶段。在场景中物体很多时,能把计算量压到原来的几千分之一。 - 降低mask检测频率。 比如对非关键碰撞每隔2帧检测一次,肉眼几乎看不出区别,但性能提升明显。
- 缩小mask面积。 对不要求精确的部分做裁剪,比如图比较大但有效碰撞区域只占中心,可以用
mask.to_surface()之后的重绘或者直接手工裁剪图片。
python复制class GameObject:
def __init__(self, surface, pos):
self.surface = surface
self.mask = pygame.mask.from_surface(surface) # 只生成一次
self.rect = surface.get_rect(topleft=pos)
def collides_with(self, other):
# 第一级:矩形快速排除
if not self.rect.colliderect(other.rect):
return False
# 第二级:像素级精确判定
offset = (other.rect.x - self.rect.x, other.rect.y - self.rect.y)
return self.mask.overlap(other.mask, offset) is not None
这个两级结构的核心收益在于:大部分不相交的物体会被第一行colliderect挡住,根本走不到mask计算。只有真正视觉上接近重叠的物体才会进入高成本的精确检测。
4.3 像素级检测的替代思路:多碰撞体组合
Mask检测虽然精确,但还是有局限:它只能处理旋转角度为0的情况,一旦图像旋转过,mask就失效。Pygame的mask不支持旋转后的检测,这是很多人后来才会踩的坑。图像旋转后要重新生成mask,但旋转会让边缘出现锯齿、空白像素,反而造成检测偏差。
对于“需要旋转的物体”,一个折中方案是用多个小矩形/圆形去近似复杂轮廓。比如一条龙形敌人,用头、躯干、尾巴三个圆拼接碰撞体;一个多边形障碍,拆成两个矩形。这样做的好处是:数学简单、性能高、不依赖图像像素,而且物体的位置角度变化都能灵活适配。缺点是碰撞区域和视觉轮廓始终有差距。取舍原则是:像素级检测适合“精细但静态”的场景;多碰撞体拼接适合“动态且旋转”的场景。
5. 性能优化:当场景里同时出现几百个物体时怎么保帧率
5.1 暴力遍历的性能瓶颈在哪
先算一笔账。场景里有100个物体,每个物体都要和其他99个物体做检测,那就是100×99/2 ≈ 4950次检测。如果每次检测只是简单的colliderect,这个量级毫无压力。但如果是mask检测,每次overlap都是上万次像素比对,4950次mask检测会让帧率直接跌到个位数。就算全是矩形检测,物体数量涨到500个,碰撞对数量就变成12.5万对,再加上每一对碰撞触发后的逻辑处理(播放音效、生成特效、扣血),主循环单帧开销会明显上升。
核心问题是:大多数物体根本不在附近,为什么要互相检测?
5.2 网格空间分区:最容易实现的优化方案
空间分区的思路是把屏幕划分成若干个格子,每个物体根据自己所在的格子被放入对应的容器中。检测碰撞时,只检测同一个格子或相邻格子里的物体。这就是“网格法”。
网格法在PyGame里实现成本很低,本质上就是一个dict:
python复制class SpatialGrid:
def __init__(self, cell_size=64):
self.cell_size = cell_size
self.grid = {}
def clear(self):
self.grid.clear()
def _key(self, x, y):
return (x // self.cell_size, y // self.cell_size)
def add(self, obj):
key = self._key(obj.rect.centerx, obj.rect.centery)
self.grid.setdefault(key, []).append(obj)
检测一个物体的碰撞时,只需要从它所在的格子以及周围8个邻居格子里取候选物体,然后对这些候选做精确检测:
python复制def get_neighbors(self, obj):
center_key = self._key(obj.rect.centerx, obj.rect.centery)
cx, cy = center_key
candidates = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
candidates.extend(self.grid.get((cx + dx, cy + dy), []))
return candidates
网格大小直接影响性能。格子太大会退回暴力遍历,太小会导致一个物体同时落入多个格子、重复加入容器。经验值是:把这个格子大小设为场景中物体平均大小的1.5到2倍,效果最好。物理引擎Box2D里的动态树本质上也是类似思路,只是更精细。
5.3 碰撞分组:只检测需要检测的关系
另一个优化思路是只检测需要检测的组合。游戏里的碰撞关系很少是全量的,比如:
- 子弹只检测玩家、敌人、墙壁,不检测其他子弹
- 玩家只检测敌人、收集品、墙壁,不检测空气
- 怪物互相之间可以有碰撞,也可以没有
在代码里用Python层的分组区分,比全量检测后再过滤高效得多。每个物体维护一个碰撞层位掩码,检测前先判断层位。
python复制class Layer:
PLAYER = 1
ENEMY = 2
BULLET = 4
WALL = 8
# 子弹只检测敌人和墙壁
if obj.layer & (Layer.ENEMY | Layer.WALL):
# 真正执行碰撞检测
pass
这种按位运算的写法在C/C++游戏引擎里非常常见,Python里同样适用,而且因为Python整数性能反而很好。把分组和网格分区结合起来,通常能把每帧碰撞检测耗时从几十毫秒压到一两毫秒。
6. 碰撞检测的常见Bug排雷:穿透、抖动、误判
6.1 高速移动导致的隧穿效应
子弹速度一快,隧穿问题就会出现:上一帧子弹在墙的一边,下一帧已经跑到了墙的另一边,中间的相交状态被跳过了。解决方式有四种,按推荐顺序排列:
- 限制最大速度。 让物体每帧位移不超过自身碰撞体尺寸的一半,最简粗暴但有效。
- 分步移动检测。 上面圆形碰撞部分写过的
swept_collision思路,把一帧拆成若干小步,逐步检测。 - 射线检测。 对子弹这类“只关心路径不关心状态”的物体,用从上一帧位置到当前帧位置的射线去测试是否和障碍物相交,相交点就是碰撞点。
- 使用引擎的连续碰撞检测。 如果用的是Panda3D这类3D引擎,有内置CCD;但是Pygame没有此功能,只能自己写。
6.2 抖动与卡墙
角色刚碰到墙就被卡住,或者站在墙边缘疯狂抖动,通常是碰撞修正时“推出去”的处理做得太简单。最常见的错误是:推出去的时候没有考虑物体的速度方向,导致物体被推过头,下一帧又推回来,形成抖动。
处理这个问题的关键是把“速度”和“位置”解耦。检测到碰撞后,不仅要把位置修正到不重叠,还要把对应轴的速度归零。如果只改位置不改速度,下一帧的速度又会把物体带进墙里。
python复制if player_rect.colliderect(wall_rect):
if velocity_x > 0:
player_rect.right = wall_rect.left
elif velocity_x < 0:
player_rect.left = wall_rect.right
velocity_x = 0
6.3 误判与漏判的排查顺序
如果你觉得碰撞检测结果“不对劲”,不要急着改代码。先按这个顺序排查:
- 画调试线条。 把碰撞体画出来,视觉确认碰撞体是否和角色视觉位置重合。很多“误判”其实是碰撞体和图像错位。
- 检查坐标系方向。 y轴方向、Rect的left/right/top/bottom语义,在不同图形库里有微妙差异,比如有些库y向下,有些y向上。
- 打印碰撞时刻的数值。 把参与计算的坐标、矩形值、结果打印出来,核对是否符合预期。
- 检查是否用了float精度不足。 如果坐标值非常大,float精度会掉得厉害,碰撞结果会不稳定。
我自己的经验是:一半以上的“碰撞检测bug”最后都发现根本不是碰撞检测的问题,而是坐标更新顺序、碰撞体位置没跟着对象走、或者检测用的矩形和渲染用的矩形不是同一个。 排查时务必先确认你在检测的“到底是哪个矩形”。
7. 从碰撞检测到完整交互:触发、事件与逻辑解耦
7.1 碰撞不只是“撞上”,还有“离开”和“持续停留”
很多游戏的交互并不只是“撞到就生效”。比如进入某个区域触发剧情、踩到地板上的机关持续掉血、走出某个范围后解除战斗。每种交互对应不同的碰撞状态机:
- 碰撞进入(on_enter):第一次与目标重叠时触发
- 碰撞停留(on_stay):每一帧仍然重叠时触发
- 碰撞离开(on_exit):从重叠变为不重叠时触发
实现时需要一个记录“上一帧是否在碰撞”的字典,然后对比当前帧状态。触发事件时调用对应回调方法。这个设计在Pygame社区里叫“collision events pattern”,本质上是把碰撞检测和碰撞响应分离,避免把逻辑塞进if colliderect里,导致代码爆炸式膨胀。
python复制class CollisionTracker:
def __init__(self):
self.previous = {}
def update(self, pairs):
current = set(pairs)
for pair in current - self.previous.keys():
self.on_collision_enter(pair)
for pair in current & self.previous.keys():
self.on_collision_stay(pair)
for pair in self.previous.keys() - current:
self.on_collision_exit(pair)
self.previous = current
这种写法的好处是逻辑清晰、容易扩展,而且可以复用同一套检测结果去驱动声音、动画、伤害、任务进度等多种系统。
7.2 回调函数的使用与陷阱
很多Python游戏框架会把碰撞检测封装成回调,比如on_collision。使用这种模式时有个陷阱:回调里修改了参与碰撞的物体状态,导致后面几个物体的检测结果依赖于这次修改的前后顺序变化。 比如子弹A撞到敌人,回调里把敌人删了,但遍历列表里的子弹B还没检测,等检测到B时,敌人可能已经没了,访问空引用报错。
一个稳妥的做法是:碰撞回调里不直接删除对象,只标记“待删除”,等一轮遍历结束之后再统一清理。 这也是很多游戏开发里通用的“延迟删除”模式。
python复制to_remove = set()
def on_collision(a, b):
if a.is_bullet and b.is_enemy:
b.health -= 10
if b.health <= 0:
to_remove.add(b.id)
# 遍历结束后统一清理
for obj_id in to_remove:
del all_objects[obj_id]
7.3 分层设计:检测、通知、响应
当你把碰撞检测从游戏逻辑里拆出来、用独立模块管理时,项目的可维护性会显著提升。我习惯把一个完整的碰撞系统分成三层:
- 检测层:纯数学计算,返回相交的物体对。这一层不关心游戏规则,只是回答“它们是否接触”。
- 通知层:监听检测层的输出,维护“上帧vs本帧”的状态变化,生成enter/stay/exit事件。
- 响应层:消费事件,执行具体游戏逻辑,比如扣血、播动画、销毁物体。
分层之后,替换检测算法(从矩形换成像素级)只影响检测层,游戏逻辑完全不受影响;新增一个敌人类型也只是在响应层加逻辑。对于持续扩展的Python游戏项目来说,这种解耦带来的收益比想象中大得多。做小游戏可能觉得没必要,但一旦内容量上去,这种结构能让你少加很多班。
我过去在小游戏里栽过不少跟头,回头看基本都是碰撞检测这个“地基”没有打稳。先花十分钟把碰撞体的数学关系理清楚,性能优化心里有底,实现起来就顺畅得多。如果你正在写一个正在变复杂的游戏,建议尽早把“检测”和“响应”分开,不要把所有代码都塞进主循环里。
