写游戏的时候,最绕不开的一个基础问题就是碰撞检测。不管是玩家撞到墙壁、子弹命中敌人、角色捡起道具,还是平台跳跃里的落脚判定,底层跑的都是这一套逻辑。很多初学者做Python游戏,画面上已经跑起来,一到交互就卡壳,多半就是卡在“怎么判断两个东西碰到了一起”这一步。今天我把这块内容拆开讲清楚,从最朴素的数学原理到实际能跑的代码,再到性能优化和经典翻车现场,一次性说透。
这个内容适合所有用Python写小游戏的人,不管你是用Pygame还是Pyglet,哪怕你只是在用turtle画个图形练手,碰撞检测的原理都一样适用。我会用Pygame来写示例代码,因为它的API最直观,也最容易复现。
1. 碰撞检测的核心思路与方案选型
1.1 先搞清楚游戏的“几何世界”
碰撞检测本质上是数学问题——你只需要回答一个问题:这两个形状有没有交集?
但真到了代码里,问题就变成了:用什么形状来代表游戏里的角色和物体?
我见过很多新手上来就纠结“像素级碰撞”,觉得越精细越高级。但真实开发里,几乎没有游戏从一开始就做像素级检测,大家用的都是近似模型。把角色简化成一个矩形、一个圆、一组格子,然后用这些简单几何形来做交集判定。为什么?因为简单形状之间的求交计算成本低,好调试,也好让人理解游戏行为。
比如玩家角色看起来是个像素小人,但碰撞箱(Hitbox)往往就是一个比人物略小的矩形框。这个矩形框要比角色本身稍微收紧一点,避免造成“明明没碰到却判定碰到了”的挫败感。弹幕游戏的受击判定通常是一两个点(称为受击点判定),也是同样的道理。
1.2 为什么优先选AABB和圆形
在Python游戏开发里,用得最多的两种基础碰撞形状是:
- AABB(轴对齐包围盒):矩形各边分别平行于X轴和Y轴。
- 圆形:圆心加半径。
AABB的求交判断逻辑最简单:两个矩形在X轴上的投影重叠,同时在Y轴上的投影也重叠,就说明两个矩形相交了。圆形则只需要计算两圆心距离和半径之和比较大小。
那什么时候用哪个?
- 角色、NPC、平台、砖块、门等大多数物体的主体碰撞用AABB就够,特别是和网格地图搭配时,矩形判定非常自然。
- 子弹、玩家受击判定、圆形炸弹、爆炸范围这类对“距离感”要求高的,用圆形更合适。圆形没有“方向”,做距离判定时感觉更公平。
- 圆和矩形的混合碰撞(比如圆形的角色踩到方形的平台)用OBB之类的复杂算法当然行,但Python游戏很少需要那么重的计算。实际上,圆和矩形也可以只用几行代码去判断,我在后面的实操章节里给出具体实现。
这里有个开发经验:碰撞模型越简单,后续扩展越轻松。你永远可以为了某个特别的小游戏做像素级精确检测,但在那之前,请先用AABB把游戏逻辑跑通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手实现三种基础碰撞检测
2.1 AABB矩形碰撞——Pygame里最常用的一个
Pygame自带pygame.Rect,而这个对象本身就已经内置了常用的碰撞检测方法,所以最常见的方式是直接用Rect:
python复制import pygame
player_rect = pygame.Rect(100, 100, 40, 60)
wall_rect = pygame.Rect(140, 100, 40, 60)
if player_rect.colliderect(wall_rect):
print("撞到了")
这段代码就是AABB的封装版。Rect底层保存的是x, y, width, height,四个变量分别代表矩形的左上角坐标与宽高。colliderect()做的其实就是“投影重叠”判断。
如果你想自己写一次AABB判断,也非常简单:
python复制def rect_collide(r1, r2):
return (r1.x < r2.x + r2.width and
r1.x + r1.width > r2.x and
r1.y < r2.y + r2.height and
r1.y + r1.height > r2.y)
这个代码的逻辑是:一个矩形的左边界必须小于另一个矩形的右边界,同时它的右边界必须大于另一个矩形的左边界,X轴方向成立之后,Y轴方向也同样判断。两边都成立,矩形才相交。
我建议不管用不用Pygame,都自己写一遍这个函数。它足够短,却能让你彻底记住AABB的本质。
2.2 圆形碰撞——距离判定法
圆形判断需要用到两点的欧几里得距离公式,其实就是一个直角三角形斜边计算:
python复制import math
def circle_collide(x1, y1, r1, x2, y2, r2):
dist = math.hypot(x1 - x2, y1 - y2)
return dist < r1 + r2
math.hypot就是sprt(x*x + y*y),算是Python给的现成快捷方式。什么时候用圆形?最常见的是子弹和敌人的判定。很多俯视角射击游戏,子弹就是一个点或小圆,敌人是不规则形状,但开发时会给敌人包一个圆形碰撞箱,然后算子弹圆心和敌人圆心的距离,小于某个值就命中。
用圆形还有一个隐藏好处:判断方向非常自然。比如炸弹爆炸时,距离中心越近伤害越高,你只需要用两个圆心的距离来计算衰减系数即可。
2.3 矩形和圆形混合碰撞怎么办
真实的游戏场景里经常出现矩形平台加圆形角色,或者矩形武器打到圆形敌人。这时候有一个既简单又够用的做法:把圆心映射到矩形上的最近点,再算距离。
python复制def circle_rect_collide(cx, cy, cr, rect):
closest_x = max(rect.left, min(cx, rect.right))
closest_y = max(rect.top, min(cy, rect.bottom))
distance = math.hypot(cx - closest_x, cy - closest_y)
return distance < cr
思路是:圆心如果在矩形内部,那最近点就是圆心本身,距离为0,必定碰撞;圆心如果在矩形外面,就把它投影到矩形四条边围成的边界上,得到最近点,然后计算这两点距离。这个写法很实用,我建议直接收藏,能用上很多年。
2.4 点与矩形的碰撞——鼠标拾取与点击检测
还有一个高频场景是判断鼠标位置是否落在某个物体上,也就是点击检测。Pygame里直接用Rect.collidepoint():
python复制if button_rect.collidepoint(mouse_pos):
# 按钮被点击了
自己写也简单:
python复制def point_in_rect(px, py, rect):
return rect.left <= px <= rect.right and rect.top <= py <= rect.bottom
这三种基础检测,覆盖了80%以上的Python小游戏需求,先把它们吃透,再加复杂逻辑才不容易乱。
3. 从单物体到场景:地图碰撞、碰撞响应与移动修正
3.1 网格地图里的碰撞检测
很多小游戏的地图是格子化的,比如经典推箱子、Roguelike、塔防,都是二维数组(Tilemap)表示地图:
python复制tile_map = [
[1, 1, 1, 1, 1],
[1, 0, 0, 0, 1],
[1, 0, 2, 0, 1],
[1, 0, 0, 0, 1],
[1, 1, 1, 1, 1],
]
1代表墙,0代表空地,2代表玩家起始点。在这种情况下碰撞检测不是去遍历所有物体的Rect,而是根据玩家所在的像素坐标计算出它占用了哪个格子,然后查表。
python复制player_x, player_y = 64, 64
tile_size = 32
col_left = player_x // tile_size
col_right = (player_x + player_width) // tile_size
row_top = player_y // tile_size
row_bottom = (player_y + player_height) // tile_size
for row in range(row_top, row_bottom + 1):
for col in range(col_left, col_right + 1):
if tile_map[row][col] == 1:
print("撞墙了")
这样做的好处非常明显:不管地图是1000格还是10000格,每帧要检测的只有玩家附近那几个格子,计算量几乎不变。这是Python小游戏能保持高帧率的关键之一。
3.2 碰撞后的响应:移动修正
检测到碰撞只是第一步,真正让玩家觉得“手感好”的是碰撞后的处理。很多新手写碰撞检测,发现角色撞到墙之后直接卡死或者被弹飞,问题大都出在响应方式上。
最常见也最稳妥的方案是分轴移动(Separate Axis):先沿X轴移动,检测碰撞,如果碰撞就回退到碰撞之前的X坐标;再沿Y轴移动,检测碰撞,同样的方式回退。
python复制# 假设玩家对象有 x, y, width, height 和 vx, vy 速度
# 先移动X轴
player.x += player.vx
if check_collision(player, wall):
player.x -= player.vx # 回退X轴移动
player.vx = 0
# 再移动Y轴
player.y += player.vy
if check_collision(player, wall):
player.y -= player.vy # 回退Y轴移动
player.vy = 0
这样分步处理的好处是:角色在贴墙滑行时,X方向和Y方向的碰撞互不干扰。如果你把两个轴合在一起移动,再一起检测,可能出现“明明能滑过去却被卡住”的尴尬情况。
推箱子、马里奥这类平台跳跃游戏,本质上都是这个逻辑的延伸。落脚判断要稍微区别对待:马里奥从上方落在平台上,不能把它当作普通碰撞回退,要在这个时刻把vy清零并标记为“着地”,否则角色会一直接触平台地面然后被回退抖来抖去。
3.3 像素级碰撞检测要不要做
像素级检测的原理是用两个Surface的掩码(Mask)做重叠计算:
python复制mask1 = pygame.mask.from_surface(surface1)
mask2 = pygame.mask.from_surface(surface2)
offset = (x1 - x2, y1 - y2)
if mask1.overlap(mask2, offset):
print("像素重叠了")
这个功能Pygame自带,pygame.mask模块性能在同量级游戏里也算说得过去。但我的建议是:不要一上来就用它,除非你的核心玩法真的依赖于物体轮廓的精确边界。
为什么这么说?像素检测有两大坑:
- 性能贵。用矩形检测时每帧计算几次比较运算,而像素检测会遍历两个图形的很多像素,物体多的时候帧率会明显下降。
- 行为不可预期。两个图案因为抗锯齿边缘的透明像素差异,可能在视觉上没碰到就触发了判定,或者视觉上碰到了却因为图案中央有个镂空而判定为穿模。
真到非用不可的时候,先做一层矩形粗测(AABB检测),只有矩形重叠之后才进行像素级细测。这种“粗筛+细筛”的策略在实际工程里非常常见,能省下大量性能。
4. 性能优化:当碰撞对象多起来之后
4.1 朴素检测的问题:O(n²)复杂度
如果你有10个物体,两两检测只需要45次。但如果场景里有100个物体,两两检测就到了4950次。到了500个物体,12万多次。Python是解释型语言,这种级别的计算每帧做下来CPU开销会非常可观。
这就是为什么大型游戏都讲究空间划分。好在Python小游戏通常撞到几百个物体已经是极限了,所以先有一个简单结论:
对象少于30个,直接两两遍历检测就好,别优化。
优化是有成本的,过早优化只会让你的代码变复杂、游戏变难改。
4.2 网格空间划分与四叉树
当对象数量多起来时,第一优先做网格划分(Spatial Hashing)。
把游戏世界切成固定大小的格子,每个格子记录当前帧包含了哪些物体。检测某个物体的碰撞时,只需要取它所在格子以及相邻格子里的物体进行两两检测。
python复制spatial_hash = {}
def add_to_grid(obj):
cell_x = int(obj.x // CELL_SIZE)
cell_y = int(obj.y // CELL_SIZE)
key = (cell_x, cell_y)
if key not in spatial_hash:
spatial_hash[key] = []
spatial_hash[key].append(obj)
def get_nearby(obj):
result = []
min_cx = int((obj.x - CELL_SIZE) // CELL_SIZE)
max_cx = int((obj.x + obj.width + CELL_SIZE) // CELL_SIZE)
min_cy = int((obj.y - CELL_SIZE) // CELL_SIZE)
max_cy = int((obj.y + obj.height + CELL_SIZE) // CELL_SIZE)
for cx in range(min_cx, max_cx + 1):
for cy in range(min_cy, max_cy + 1):
result.extend(spatial_hash.get((cx, cy), []))
return result
这个实现把原来的“所有物体两两比较”变成了“只和附近的物体比较”,性能提升非常明显。实际使用中格子大小建议设置为游戏中最大物体的2倍左右,这样物体跨越格子边界时也能被附近格子捕获。
如果地图很大、物体稀疏,四叉树(Quadtree)是更精细的方案,但实现复杂度也高一些。对Python小游戏而言,网格划分已经能解决绝大多数性能问题,四叉树除非你要做类似“千人同屏”的玩法,否则没必要优先考虑。
4.3 尽量减少每帧的检测次数
除了空间划分,代码层面还有几个省事的优化点:
- 合并静态物体:如果一面墙由100个矩形Tile拼成,把相邻矩形合并成一个大矩形,直接用大Rect做检测,能显著减少待检测对象。
- 检测频率降频:某些精度要求不高的物体,碰撞检测不需要每帧都做,可以每隔两帧做一次。
- 先粗筛后精判:用超大Rect先判断两个物体是否可能接近,如果完全不可能相交就不用走精细碰撞逻辑。
在写游戏循环时,我还习惯在update()阶段给所有物体打一个active标签,只有活跃物体才参与碰撞检测。比如子弹在飞出屏幕外就直接标记为“待回收”,不参与后续任何碰撞逻辑。
5. 常见问题与排查技巧实录
5.1 角色穿透墙壁
表现:角色以高速冲向了薄墙,结果直接穿过,没有触发碰撞。
这是因为碰撞检测是离散的:上一帧角色还在墙左边,这一帧角色已经跳到墙右边,两帧之间的采样点没有落在墙内,自然就判断不到重叠。
处理方法有三个方向:
- 限制单帧移动距离不大于最小物体尺寸的一半。
- 把速度拆成多个小步逐帧检测,也叫“扫描式移动”。
- 用射线检测(Ray Casting)提前判断移动路径上有没有障碍物。
小游戏里最实用的是限制速度。比如墙体厚度32像素,那你单帧位移就别超过16像素,这样就不会跳过检测。调整的方法是:max_vx = tile_size / 2,超了就截断。
5.2 碰撞后角色抖动
表现:角色碰到平台后一直上下抖,视觉上贴不住地面。
这种情况几乎总是因为碰撞响应里没有正确清零对应方向的速度,或者碰撞后还持续施加了同方向的作用力。分轴移动时,碰撞回退后要立刻把该方向的速度向量归零,这样角色才能稳定“粘”在表面上。
平台跳跃游戏还需要额外处理一个细节:从上方落到平台时要单独标记“落地状态”,不要再继续累加重力。如果重力持续累加,碰撞回退又会立刻生效,画面就是一弹一弹的。
5.3 多物体碰撞顺序导致的位置错乱
表现:两个角色同时挤进一个狭窄通道,相互之间乱弹。
核心问题是碰撞处理顺序会影响结果。如果先处理A、再处理B,和先处理B、再处理A,结果不一样。
经验做法是:先检测并处理所有物体和静态障碍物的碰撞,然后再处理物体之间的碰撞,最后对同时有多方碰撞的物体做一个综合移动修正。在小游戏里还可以直接规定“后移动的不许推前移动的”,避免无休止的相互穿透。
5.4 调试碰撞检测的独家技巧
碰撞检测逻辑看不见摸不着,出了问题很难直接观察。我写游戏时习惯开一个“调试视图层”:把所有碰撞箱画成半透明线框,这样就能看到真正的判定区域长什么样。
python复制# 在绘制所有游戏元素之后
if debug_mode:
pygame.draw.rect(screen, (255, 0, 0), player_rect, 2)
pygame.draw.circle(screen, (0, 255, 0), center, radius, 2)
调试视图层帮我解决过很多“看起来没碰到却被判定碰到”的诡异问题。很多新手做游戏只盯着贴图画得漂亮,完全不关心碰撞箱的位置,等判定出问题才一头雾水。实际上碰撞箱偏离视觉是很正常的事,关键是你要知道它偏离到了哪里,这就要靠调试视图层。
5.5 常见碰撞问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 高速穿过薄墙 | 单帧位移大于墙体厚度 | 限速或分步移动 |
| 碰到平台后抖动 | 碰撞后未清零该方向速度 | 分轴碰撞后将该方向速度归零 |
| 人物被卡在墙里 | 碰撞响应方向写反 | 按重叠深度最小的轴回退 |
| 碰撞判定比画面小 | 碰撞箱尺寸与贴图不符 | 打开调试视图调整碰撞箱 |
| 两个角色相互乱弹 | 碰撞处理顺序不一致 | 先处理静态碰撞,再统一处理动态碰撞 |
| 子弹偶尔穿透敌人 | 帧率和位移不匹配 | 固定时间步长或减少单帧速度 |
6. 一些实操心得
写碰撞检测久了,我最大的体会是:先跑通最简单的方式,再根据实际表现逐步加复杂度。一上来就想做最精确的碰撞,反而容易被细节拖进去。用AABB把整个游戏逻辑跑通,再去优化特殊场景,这是最高效的开发路径。
另外一个很实际的经验是——碰撞检测的代码要尽量独立成函数或单独模块,不要在游戏主循环里写一堆判断分支。碰撞逻辑是游戏里最容易频繁修改的部分,独立出来测试也好写、调试也方便、替换算法也更容易。
如果你打算长期做游戏开发,可以把这节课里的几个基础检测函数整理成一个小工具库,以后每次开新项目都直接复用。几年下来,你手里积攒的就是一套完全属于自己的“游戏物理工具箱”,那个价值比任何框架都大。
Python游戏里的碰撞检测,说到底就是几分钟能讲完的数学判断,但真正难的是把它融入到整个游戏循环里,让手感变得自然。多写几个不同风格的小游戏,踩几次坑,你慢慢就找到感觉了。
