news 2026/9/28 11:35:57

Python游戏开发碰撞检测从入门到实战:矩形、圆形与像素级mask详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python游戏开发碰撞检测从入门到实战:矩形、圆形与像素级mask详解

做Python游戏开发,碰撞检测几乎是你绕不开的第一道坎。我自己第一次写弹球游戏时就撞过一堵无形的墙:画面里两个物体明明已经重叠了,程序却毫无反应;有时候隔着一小段距离,却提示碰到了。后来我才明白,问题不在pygame,而在碰撞检测的实现方式没选对、边界条件没写清。这篇文章想系统地讲一讲Python游戏中的碰撞检测实现,从最简单的矩形判定,到圆形判定、像素级mask,再到碰撞后的位移回应,用可直接上手的代码和思路,帮刚接触Python游戏开发的人把这块地基打牢。

文章里的示例都以pygame为主,因为它在2D游戏开发中最常用,碰撞检测相关的API也比较完整。但底层思路都是通用的,你换成其他游戏框架或者自己写渲染引擎时,同样适用。

1. 先把碰撞检测拆开看:游戏循环里它在哪个环节

1.1 一次"碰撞"是怎么发生的

游戏看起来是在连续播放画面,实际上是在一个循环里不停刷新。pygame的标准循环大概是这样:

import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 更新游戏逻辑 # 碰撞检测通常在这里做 # 绘制画面 pygame.display.flip() clock.tick(60)

clock.tick(60)的作用是把帧率控制在每秒60帧。每一帧里都有一段"更新游戏逻辑"的代码,碰撞检测就放在这个阶段。为什么不能放在绘制阶段?因为绘制的职责只是把当前状态画出来,如果在绘制时才做碰撞判断,那么一个帧里要做判断又要改位置,很容易出现"画完了才处理碰撞,结果物体已经穿进去了"的尴尬情况。

实际项目里,碰撞检测的流程一般分成两步:先遍历需要检测的物体,判断它们是否相交;如果相交了,再根据需求处理位置修正、反弹、伤害计算或者音效触发。很多新手只关注第一步"怎么判断相交",忽略了第二步,结果角色被撞到之后会陷入墙壁或者卡在模型里。

1.2 离散检测与连续检测:为什么会有"穿透"这回事

按检测时机分,碰撞检测可以分成离散检测和连续检测。

离散检测是每帧做一次快照式的判断,这一帧你在这个位置,下一帧你到了另一个位置,如果两帧之间物体移动距离太大,就可能跳过中间本该碰撞的区域,导致高速飞行的子弹直接穿透墙壁。连续检测则是把物体从本帧位置到下一帧位置的运动路径也纳入计算,看路径上是否与目标相交。

Python游戏、尤其是pygame开发的2D游戏,绝大多数用的都是离散检测。它简单、运行快,但要在意物体最大移动速度。一个角色一帧内移动了10像素,而墙壁厚度只有4像素,那这一帧前后都有可能直接"跨过去"。这不是碰撞检测算法错了,而是检测时机的问题。处理手段通常有两种:把帧率提高,或者做更细的扫描。

1.3 先搞清楚物体在游戏里怎么表示

碰撞检测说到底是在做几何判断,所以物体用什么几何形状表示很关键。pygame中最常用的有几种:

  • pygame.Rect:矩形区域,用x, y, width, height四个值描述。适合墙壁、平台、玩家的整体包围盒。
  • 圆心加半径:用(x, y)和r描述,适合圆形物体。
  • 多边形:pygame里有pygame.draw.polygon可用于绘制,但碰撞检测通常要借助其他几何算法。
  • 像素掩码:pygame.mask类,基于图片的非透明像素生成掩码,可以做到像素级精确判断。

形状越贴近物体实际轮廓,碰撞检测越精确,但计算代价也越高。所以实际开发里很少一上来就用像素级检测,而是先选一个简单的包围形状,再考虑要不要增加精度。

2. 矩形碰撞:Python游戏里最常用的方案

2.1 pygame.Rect 的 colliderect 到底做了什么

pygame里做矩形碰撞最简单的方式是Rect.colliderect。

rect1 = pygame.Rect(100, 100, 50, 50) rect2 = pygame.Rect(130, 130, 50, 50) rect3 = pygame.Rect(200, 100, 50, 50) print(rect1.colliderect(rect2)) # True,两个矩形重叠了 print(rect1.colliderect(rect3)) # False,只是贴边或分离

colliderect看起来只是"帮我判断两个矩形是否相交",但它背后有一个非常关键的细节:pygame的Rect只支持整数坐标。你传入浮点数,它内部会帮你截断成整数,这在做平滑移动时容易引发一种"闪烁穿透"的错觉。比如物体速度是(1.6, 0),每帧都移动1.6像素,pygame会先把坐标截断成(1, 0),再逐步累加,最后位置就不连续。这个问题可以在外部维护浮点坐标,绘制和碰撞时再统一取整。

colliderect还支持与矩形列表做批量判断,比如pygame.Rect.collidelist和collideobjects:

player = pygame.Rect(50, 50, 20, 20) walls = [pygame.Rect(0, 0, 100, 10), pygame.Rect(100, 0, 10, 100)] # 返回第一个相交的矩形下标,没有则返回 -1 index = player.collidelist(walls) print(index)

这种接口在做"角色撞墙"这种场景时非常方便。

2.2 手写矩形相交判定:边界条件才是重点

如果不用现成API,两个矩形相交的核心条件是:它们在x轴方向的投影有重叠,且在y轴方向的投影也有重叠。

假设rect1的左上角是(x1, y1),宽高是w1, h1;rect2同理。它们相交的数学条件可以写成:

def rect_overlap(r1, r2): # 检查x轴方向是否有重叠 horizontal_overlap = (r1.x < r2.x + r2.width) and (r1.x + r1.width > r2.x) # 检查y轴方向是否有重叠 vertical_overlap = (r1.y < r2.y + r2.height) and (r1.y + r1.height > r2.y) return horizontal_overlap and vertical_overlap

很多人容易写反,比如写成r1.x < r2.x and r1.x + r1.width > r2.x + r2.width,这表示的是"r1完全包含r2",不是"它们相交"。要判断重叠,必须同时检查"我的左边是否在对方的右边左边"和"我的左边是否在对方的右边左边"这些交叉关系。

一个常见的问题是边界相切。游戏里通常把"贴边"也当成碰撞,因为两个物体正好差0像素不重叠,下一帧很可能就会重叠,没必要在边界上区分太细。colliderect的处理是相交即True,边界相切不算相交。如果你需要把"接触边界"也算作碰撞,就要手动放宽条件。

2.3 用矩形碰撞搭建一个简单的玩家与障碍物检测

下面这个例子演示了键盘控制玩家移动,并在移动后检测是否与障碍物碰撞:

import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) clock = pygame.time.Clock() player = pygame.Rect(100, 300, 30, 30) obstacle = pygame.Rect(400, 280, 200, 40) speed = 5 running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False keys = pygame.key.get_pressed() move_x, move_y = 0, 0 if keys[pygame.K_LEFT]: move_x = -speed if keys[pygame.K_RIGHT]: move_x = speed if keys[pygame.K_UP]: move_y = -speed if keys[pygame.K_DOWN]: move_y = speed # 先移动,再检测碰撞 player.x += move_x player.y += move_y if player.colliderect(obstacle): # 这里只做简单回退 player.x -= move_x player.y -= move_y screen.fill((30, 30, 30)) pygame.draw.rect(screen, (0, 200, 0), player) pygame.draw.rect(screen, (200, 200, 200), obstacle) pygame.display.flip() clock.tick(60) pygame.quit()

注意一个容易被忽略的细节:这里先同时移动x和y,再检测碰撞,回退时把两个方向都回退。这样写代码简单,但有一种斜向卡墙的问题:物体贴墙移动时,x方向已经撞了,可y方向本来能继续动,现在被一起回退,手感会很木。更精细的做法是把x和y方向的移动分开检测:

player.x += move_x if player.colliderect(obstacle): player.x -= move_x player.y += move_y if player.colliderect(obstacle): player.y -= move_y

这样至少能保证,撞墙时还能沿着墙面滑动。这个"先单轴移动,再单轴回退"的思路,在平台跳跃游戏里特别实用。

3. 圆形碰撞与混合碰撞:处理更自然的物体形状

3.1 圆心距离与半径之和

如果游戏里有球、角色头顶、爆炸范围这类物体,用矩形包一圈会显得很生硬。比如一个圆形角色和一面斜墙,矩形包围盒在四个角上会出现"空碰撞":视觉上明明没碰到,碰撞检测却告诉你碰上了。

圆形碰撞的判定方式是:如果两个圆的圆心距离小于或等于半径之和,那么它们发生碰撞。

import math def circle_collision(c1, r1, c2, r2): dx = c1[0] - c2[0] dy = c1[1] - c2[1] distance = math.hypot(dx, dy) # 等价于 sqrt(dx*dx + dy*dy) return distance <= r1 + r2

math.hypot比手动写math.sqrt(dx ** 2 + dy ** 2)更稳,也更不容易因为数值精度翻车。

这里有个优化点:判断是否碰撞时,其实不需要真的开平方。因为开平方是单调递增的,比较距离和半径之和,可以改为比较距离的平方与半径之和的平方:

def circle_collision_fast(c1, r1, c2, r2): dx = c1[0] - c2[0] dy = c1[1] - c2[1] return dx * dx + dy * dy <= (r1 + r2) * (r1 + r2)

在Python这种解释型语言里,少一次sqrt调用,在大量物体检测时能明显省时间。我在做几百个粒子互相碰撞的项目时,用平方比较是基本的性能习惯。

3.2 矩形与圆形混合碰撞:找矩形上离圆心最近的点

游戏里更常见的是圆形角色去碰矩形砖块,比如玩家是圆滚滚的小球,地图却有方形的平台。矩形和圆形的碰撞检测有一个经典做法:先找到矩形范围内离圆心最近的点,再判断这个点到圆心的距离是否小于等于半径。

最近点的计算方法比较直观:把圆心的x坐标限制在矩形的左右边界之间,把圆心的y坐标限制在矩形的上下边界之间。限制后得到的点就是离圆心最近的矩形上的点。

def circle_rect_collision(circle_center, radius, rect): closest_x = max(rect.left, min(circle_center[0], rect.right)) closest_y = max(rect.top, min(circle_center[1], rect.bottom)) dx = circle_center[0] - closest_x dy = circle_center[1] - closest_y return dx * dx + dy * dy <= radius * radius

min和max的组合看起来有点绕,但本质就是"把点往矩形范围里推"。圆心在矩形内部时,最近点就是圆心自己,距离为0,肯定碰撞;圆心在矩形上方时,最近点的y会被限制成矩形的top,这样判断的就是圆心到矩形上边的距离。

这个函数的用途很广。比如一个圆形子弹和一个方形掩体,或者玩家是圆形、场景里的许多物体是矩形碰撞体,都能用。

3.3 不同形状碰撞的取舍

几种常见碰撞判断的代价从低到高大致是:

检测方式计算成本适用场景
矩形与矩形很低,几次比较方形物体、场景墙体、包围盒
圆形与圆形低,几次乘法加法球、粒子、爆炸范围
矩形与圆形中,有限制运算加距离判断圆形角色配矩形平台
像素级mask高,逐像素遍历美术资源形状不规则且要求精确

选哪种形状其实是在"精度"和"性能"之间做取舍。开始不用追求绝对精确,能先用矩形解决的场景就先上矩形,等出现了明显的视觉误差,再针对具体物体升级碰撞算法。

4. 像素级精确碰撞:当矩形和圆形都不够用的时候

4.1 pygame.mask 的基本用法

矩形和圆形再怎么组合,也无法精确还原一个形状复杂的精灵图。比如一个角色手上拿着一把剑,剑刃细长,用矩形包围盒的话,剑没碰到敌人,包围盒已经先碰到了。这个时候可以用pygame.mask做像素级精确碰撞。

pygame.mask.from_surface会根据图像表面的alpha通道或颜色关键字生成一个掩码。掩码是一张和图像同样大小的布尔网格,透明像素是0,不透明像素是1。

mask1 = pygame.mask.from_surface(player_image) mask2 = pygame.mask.from_surface(enemy_image)

然后调用overlap,传入另一个掩码和二者位移偏移:

offset = (enemy_rect.x - player_rect.x, enemy_rect.y - player_rect.y) overlap_point = mask1.overlap(mask2, offset)

这里的offset是enemy相对于player的位置偏移。overlap返回重叠区域的第一个点坐标;如果没有重叠,返回None。

4.2 配合 Rect 使用:别让 mask 裸奔

mask本身没有位置信息,它的坐标全靠 Rect 来承载。所以像素碰撞的基本步骤一定是:先用Rect做粗检测,如果Rect相交了,再用mask做细检测。

if player_rect.colliderect(enemy_rect): offset = (enemy_rect.x - player_rect.x, enemy_rect.y - player_rect.y) if player_mask.overlap(enemy_mask, offset): # 真正的像素级碰撞发生 pass

这一步非常关键。如果直接拿两个mask.overlap跑,而它们的Rect相距很远,offset会很大,overlap内层仍然要遍历像素,性能就白白浪费了。

4.3 像素碰撞的性能陷阱

像素碰撞最怕的是频繁调用from_surface。图像每改变一次帧,比如角色走路动画每帧换一张图,就重新生成一次mask,这个开销很容易让游戏掉帧。比较好的做法是:在加载图片时就生成mask并缓存起来,除非精灵图本身发生了变化。

另外,mask的overlap算法是遍历较小掩码的每个像素,再映射到另一个掩码上查找。如果两个掩码都很大,比如256x256,每一帧做几十次这样的检测,计算量会非常可观。实际项目里常见做法是"分层检测":

  • 第一层:矩形包围盒粗检,排除明显不相交的物体。
  • 第二层:对可能相交的物体,再做大粒度的圆形检测或缩小的矩形检测。
  • 第三层:最后才用mask做像素级确认。

我曾经在一个小型塔防游戏里,把敌人都换成mask碰撞,结果100个敌人加上溅射攻击判定,帧率从60直接掉到20多。后来改成先做矩形粗检,只有矩形重叠时再算mask,帧率才恢复正常。这个优化思路在物体数量多的时候收益非常明显。

4.4 什么时候不要用像素碰撞

像素碰撞不是万能的。它有很明显的两个问题:一是计算量大;二是它只能判断"有没有碰到",很难得到"碰撞发生在哪个方向、应该推多远"的信息,因为重叠区域是一堆零散像素点,不像矩形那样能直接算出深度。

如果你的需求只是"碰到之后弹开一点"、"碰到之后减速",矩形和圆形已经能搞定。像素级检测更适合那些对视觉效果要求极高的场景,比如独立游戏里精细的子弹判定、美术风格强烈的怪物流血判定、或者项目中有透明背景的异形角色。

如果你做的是平台跳跃游戏,碰撞主体其实依然可以用矩形,只是把玩家身体拆分成长宽比合适的几个矩形区域,比如头、身体、脚三个碰撞盒,这种做法在业界也很常见,因为它比全像素检测稳定得多。

5. 碰撞之后的处理:重叠修正、反弹与穿透问题

5.1 最小平移向量:把物体"推出去"

很多初学者在实现碰撞后,只会做一件事:检测到碰撞就把位置恢复成上一帧的位置。这个方案能应付简单场景,但在角色与斜坡、多物体堆叠、快速移动时就会出现抖动和卡顿。

更专业的做法是计算最小平移向量。所谓最小平移向量,就是把两个相交的物体分开所需要的最小位移方向与大小。比如矩形碰撞,可以通过比较两个矩形重叠部分的宽度和高度来决定往哪个方向推:

def get_collision_push(rect1, rect2): overlap_x = min(rect1.right, rect2.right) - max(rect1.left, rect2.left) overlap_y = min(rect1.bottom, rect2.bottom) - max(rect1.top, rect2.top) if overlap_x < overlap_y: # 水平方向重叠更小,从水平方向推出 if rect1.centerx < rect2.centerx: rect1.right = rect2.left else: rect1.left = rect2.right else: # 垂直方向重叠更小,从垂直方向推出 if rect1.centery < rect2.centery: rect1.bottom = rect2.top else: rect1.top = rect2.bottom

这里选择重叠较小的轴来推离物体,能减少视觉上"突跳"的感觉。你可能会疑惑:为什么重叠小就推哪个轴?因为重叠小意味着物体在这个方向上侵入得浅,推出去的距离最自然,玩家不会感觉角色被"弹飞"。

5.2 反弹、速度改变与物理响应

碰撞检测的下游是碰撞响应。一个常见的简单响应是反弹:根据碰撞方向反转速度分量。

比如一个球碰到了上方的平台,那么它的垂直速度应该反转:

if ball_rect.colliderect(platform_rect): if ball_speed_y > 0 and ball_rect.bottom - platform_rect.top < 10: ball_speed_y = -ball_speed_y

这里加了条件ball_speed_y > 0,是为了避免"已经在下落,碰到平台顶部,结果又把方向反了"的反复横跳。还有一个关键点是判断碰撞方向时,不要只依赖colliderect返回的布尔值。布尔值只告诉你"碰了",没告诉你"碰的是哪个方向"。你需要借助相对位置和重叠深度来判断,这就是上一节最小平移向量的作用。

另一个容易踩的坑是:碰撞检测时物体已经重叠了,但重叠量很大。高速移动的物体每帧移动10像素,碰撞发生时可能已经侵入对方15像素。如果只是简单地把速度反向,物体下一帧还会再撞进来,看起来就像在墙体边缘颤抖。正确做法是先把物体推离到刚好接触的位置,再反转速度。

5.3 高速穿透与连续碰撞检测的解决思路

前面提到离散检测会漏掉高速小物体。最简单的解决思路是"细分步长":把一次移动拆成几次小步移动,每次移动后都做一次碰撞检测。

def move_with_collision(obj, dx, dy, obstacles, step=2): # 把dx、dy拆成多个小步 distance = max(abs(dx), abs(dy)) if distance == 0: return steps = max(1, int(distance // step)) step_x = dx / steps step_y = dy / steps for _ in range(steps): obj.x += step_x obj.y += step_y for wall in obstacles: if obj.colliderect(wall): # 撞到了,回退一步并停止 obj.x -= step_x obj.y -= step_y return

这个做法牺牲了一些性能,但能让子弹不容易穿过薄墙。另一种思路是用"扫掠检测":把物体从起点到终点的移动轨迹看成一个矩形,判断这个轨迹矩形是否与目标相交。pygame本身没有内置扫掠检测,但我们可以手动构造轨迹矩形:

def swept_rect(rect, dx, dy): if dx >= 0: x = rect.x width = rect.width + dx else: x = rect.x + dx width = rect.width - dx if dy >= 0: y = rect.y height = rect.height + dy else: y = rect.y + dy height = rect.height - dy return pygame.Rect(x, y, width, height)

用这个轨迹矩形去检测是否碰撞,能避免大多数高速穿透。如果你做的是子弹类游戏,我建议至少对子弹做扫掠检测,因为子弹通常体积小、速度大,是最容易穿透的一类物体。

5.4 碰撞分组与碰撞矩阵

当场景里物体种类很多时,你需要明确"谁和谁才会发生碰撞"。比如玩家子弹和敌人碰撞,敌人子弹和玩家碰撞,但玩家子弹不与其他玩家子弹碰撞。这种需求可以用碰撞矩阵实现。

collision_matrix = { ("player", "enemy"): True, ("player_bullet", "enemy"): True, ("enemy_bullet", "player"): True, ("enemy", "enemy"): False, } def can_collide(type1, type2): return collision_matrix.get((type1, type2)) or collision_matrix.get((type2, type1))

在遍历时先判断碰撞矩阵是否允许发生碰撞,再进行几何检测,能少做很多无谓的计算。尤其当实体数量多到几千个时,不加矩阵的全遍历会让游戏在后期越来越卡。

6. 我踩过的坑:调试碰撞检测的几个实用经验

6.1 看清碰撞体:可视化调试是第一步

我调试碰撞检测时有个习惯:把所有碰撞体用半透明色块画出来,覆盖在原始图像上。

collider_color = (255, 0, 0, 80) # 半透明红色 pygame.draw.rect(screen, collider_color, player_rect) pygame.draw.circle(screen, collider_color, circle_center, radius, 1)

这样你能直接看到碰撞体比角色大多少、小了哪里。很多"我明明没碰到却判定碰撞"的问题,一看半透明色块就明白了:其实是碰撞体比角色图像大了一圈。还有为了调试方便,我会在碰撞发生时把重叠点画出来,肉眼确认算法是否正确。

6.2 Rect的坐标陷阱:x、y到底是左上角还是中心

pygame的Rect用x, y表示左上角,而很多游戏素材在绘制时喜欢以中心点定位,比如角色图片中心是脚底位置。于是经常出现一种问题:图片看起来站在平台上,Rect却悬空或者陷进地板里。

我在写平台游戏时,习惯维护一份"真实坐标"和一份"渲染坐标"。真实坐标可以用浮点数存储物体的中心点,渲染时再把Rect的中心点设置到真实坐标上:

entity.x += velocity_x entity.y += velocity_y entity_rect.center = (int(entity.x), int(entity.y))

碰撞检测用entity_rect,但位置累计用entity.x和entity.y。这样既避免了Rect整数截断带来的精度丢失,又能保证绘制位置和碰撞位置一致。

6.3 别小看遍历顺序:反向遍历与列表删除

游戏里经常出现"碰撞后销毁物体"的需求,比如子弹命中后要移除。遍历列表时边遍历边删除,是很多新手容易踩的坑:

for bullet in bullets: if bullet.colliderect(enemy): bullets.remove(bullet) # 可能会跳过下一个子弹

原因在于:删除当前元素后,列表元素会前移,循环索引会跳过下一个未遍历的元素。解决方法是反向遍历,或者先把要删除的对象收集起来再统一删除:

for bullet in bullets[:]: if bullet.colliderect(enemy): bullets.remove(bullet)

这里对bullets做了一次浅拷贝,遍历的是副本,删除的是原列表。这种方式简单稳定,在处理大量物体时也是常用手段。

6.4 从"能跑"到"能用":我之前建议的落地路径

如果你是刚入门,我建议按这个路径来练:先用pygame.Rect.colliderect做两个方块之间的碰撞;然后给方块加上移动,做一个撞墙回退和反弹;再把其中一个方块换成圆形,用距离判定做一次圆形碰矩形;最后如果有精力,再研究pygame.mask和扫掠检测。

这个路径的每一步都在解决一个具体问题,而不是空谈算法。等你能把上面这些代码串起来,做一个简单的打砖块或者躲避小游戏就已经很稳了。我最初写第一个Python小游戏的时候,也是从矩形碰撞开始,后来为了做精细判定才逐步引入圆形和mask。你会发现,游戏开发里真正复杂的不是某个函数怎么调用,而是你怎么把不同碰撞方式组合起来,让玩家感觉"世界是可信的"。

最后分享一个小经验:碰撞检测的代码通常会散落在多个地方,建议你单独建一个collision.py,把矩形、圆形、混合碰撞、mask碰撞这些函数都集中起来。这样调试和替换算法都方便,也不会让主循环文件变成一团乱麻。游戏越做越大之后,这个习惯会帮你省下大量时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 11:33:56

从零开发一个MCP:用 Python + fastmcp 搭出可复用的 config.yaml 骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华