Python+Pygame游戏开发:从AABB到像素级碰撞检测全解析
我刚开始用Python做游戏的那阵子最爱看别人炫耀炫酷的特效和流畅的动画可自己上手才发现最磨人的不是画面而是碰撞检测。明明角色已经走到金币面前却愣是没触发得分子弹看似打中了敌人敌人却纹丝不动更离谱的是角色走着走着就穿过了墙壁直接掉出地图。这些问题十有八九都出在碰撞检测这个环节上。如果你也在写Python游戏或者正准备用Pygame做一个小项目这篇文章就是为你准备的。我会从碰撞检测最基本的原理讲起覆盖矩形碰撞、圆形碰撞、像素级碰撞再到碰撞后的推离响应和性能优化最后给一个完整可运行的Demo让你能直接照着写、照着调。1. 碰撞检测为什么会成为新手的第一道坎1.1 游戏循环和“离散移动”的真相很多刚接触游戏开发的人对碰撞检测的第一反应是“判断两个坐标是否相等”。比如角色走到某个位置就触发事件。这个直觉在VBA脚本或按钮回调里成立但在游戏里完全行不通。原因在于游戏不是连续运行的而是按帧运行的。Pygame的主循环每秒钟跑几十次每次循环拿到的是一张“静态截图”角色从A点移动到B点是一帧一帧跳过去的不存在真正意义上连续滑动。你看到的丝滑动画只是帧率够高产生的人眼错觉。所以碰撞检测的本质是在每一帧里判断两个物体的几何区域是否重叠而不是判断它们是否“碰巧站到了同一个点上”。1.2 基于坐标相等的问题出在哪假设你写了一段代码if player.x enemy.x and player.y enemy.y: game_over()这个写法在大多数游戏里永远都不会触发。原因很简单角色和敌人都不是一像素一像素移动的它们一次移动4、5个像素很可能这一帧还在敌人左边10像素下一帧就跑到敌人右边5像素了。两个坐标根本不会出现完全相等的那一帧。哪怕你把移动速度改成1像素也会因为浮点误差、碰撞顺序、像素对齐等问题导致命中判定时有时无。我之前接过一个外包项目里面就有一段这类似是而非的碰撞逻辑结果敌人明明擦到了角色就是不死反而隔一个像素距离时偶尔触发用户反馈的Bug截图看得我血压飙升。所以正规做法是把每个物体当作一个“几何体”比如矩形、圆形或更复杂的Mask然后判断几何体之间是否有重叠。理解这个转变是入门碰撞检测的第一步。2. 矩形碰撞AABB包围盒的正确写法2.1 矩形重叠的数学条件AABBAxis-Aligned Bounding Box轴对齐包围盒是游戏开发中最常见的碰撞体。它的特点是矩形的四条边分别平行于X轴和Y轴不会旋转。之所以叫“轴对齐”是因为一旦矩形旋转了判断逻辑会复杂很多而绝大多数2D游戏用不到旋转碰撞轴对齐就够了。判断两个轴对齐矩形是否重叠只需要四个条件而且反过来记更简单两个矩形不重叠的四种情况。矩形A在矩形B的左边A.right B.left矩形A在矩形B的右边A.left B.right矩形A在矩形B的上边A.bottom B.top矩形A在矩形B的下边A.top B.bottom只要这四种情况里任何一种成立两个矩形就一定没有重叠。反过来如果四种情况都不成立那它们一定重叠。写成代码就是def rect_collide(a, b): if a.right b.left: return False if a.left b.right: return False if a.bottom b.top: return False if a.top b.bottom: return False return True很多教程喜欢直接把重叠条件写成四行AABB判断if (a.left b.right and a.right b.left and a.top b.bottom and a.bottom b.top): return True这两种写法本质一样我推荐用排除法理解因为它在排查Bug时更好用——你能明确知道“两个物体到底是从哪个方向脱离碰撞的”。2.2 Pygame里现成的碰撞检测函数如果你用Pygame写游戏直接用现成的Rect.colliderect就行不用自己手写。player_rect pygame.Rect(100, 200, 40, 40) wall_rect pygame.Rect(100, 180, 60, 80) if player_rect.colliderect(wall_rect): print(撞上了)注意Rect.left、Rect.right、Rect.top、Rect.bottom这些属性是实时计算的修改x或width后它们会自动同步所以不用担心缓存问题。Pygame里还有一个更常用的精灵组碰撞检测函数叫做spritecollide它内部就是用Rect.colliderect来工作的hits pygame.sprite.spritecollide(player, enemies, False) if hits: print(撞到敌人了)第三个参数False表示碰到敌人后不删除敌人改成True则碰到即删除适合子弹击中敌人消失这类场景。2.3 矩形碰撞的一个隐藏坑精灵图比Rect小新手最容易踩的坑就是精灵图片是透明的PNG图片本身是80x80但角色实际只占中间40x40的区域。直接拿图片的get_rect()做碰撞体会导致角色明明没碰到物体却被判定为碰撞玩家体验非常诡异。解决方法是手动缩一句并且缩小到接近角色的视觉轮廓self.image pygame.image.load(player.png).convert_alpha() self.rect self.image.get_rect() self.rect.width 40 self.rect.height 40更规范的做法是像大型游戏引擎那样为每个精灵单独定义碰撞体。Pygame里可以先用一个独立的矩形控制碰撞绘制时再偏移图片的位置但这需要自己维护两个坐标代码会复杂一些。我的习惯是直接调小Rect然后在绘制时用rect.topleft作为图片左上角这样图片依然按原样显示碰撞区域则变小视觉匹配效果最好。3. 圆形碰撞用两点距离公式解决子弹命中问题3.1 圆形碰撞的核心公式AABB虽然好用但对子弹、圆形角色或者不规则物体来说矩形碰撞误差太大。比如一个圆形角色拐角处明明没有碰到墙角矩形碰撞却已经把拐角算进去了。这时候圆型碰撞体更有优势。圆形碰撞的判断极其简单两个圆心之间的距离小于两个圆半径之和就是碰撞。import math def circle_collide(x1, y1, r1, x2, y2, r2): dx x1 - x2 dy y1 - y2 return math.hypot(dx, dy) r1 r2math.hypot求的是sqrt(dx*dx dy*dy)比手动开平方更简洁也更稳定。如果你想减少浮点运算还可以直接比较平方距离def circle_collide_fast(x1, y1, r1, x2, y2, r2): dx x1 - x2 dy y1 - y2 return dx * dx dy * dy (r1 r2) ** 2circle_collide_fast连sqrt都省了性能更好在子弹数量多的时候优势明显。3.2 圆与矩形碰撞的混合检测实际游戏里经常遇到圆碰撞的子弹打矩形碰撞的墙壁或者反过来。判断圆和矩形是否碰撞思路是先找到矩形上离圆心最近的点然后看这个点与圆心的距离是否小于半径。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)) dx cx - closest_x dy cy - closest_y return dx * dx dy * dy cr * cr这个函数在实现反弹类和打砖块类游戏时非常有用。因为打砖块的砖块就是矩形而小球是圆形用AABB处理会让小球在撞到砖块角时出现不自然的反弹方向圆和矩形的检测则能得出更符合直觉的碰撞点。3.3 用碰撞矩阵管理不同物体的碰撞组合游戏里不是所有物体都需要互相碰撞。玩家撞金币要拾取撞敌人要扣血子弹撞敌人要造成伤害但子弹和子弹之间、金币和敌人之间通常不需要检测。我通常用一张碰撞矩阵表来管理这些组合物体A物体B是否检测碰撞行为玩家金币是加分删除金币玩家敌人是掉血/游戏结束玩家墙壁是推离阻止穿过子弹敌人是扣血/删除敌人子弹墙壁是删除子弹子弹金币否无有了这个矩阵你写碰撞代码时思路就清晰了不会到后来越改越乱。我在做一个小型射击游戏时就因为省了这一步后面加入新敌人类型时不停改嵌套循环维护成本直线上升。后来老老实实把矩阵画出来代码反而变得很简单。4. 像素级碰撞当透明PNG的边缘还在“撞鬼”时4.1 什么时候必须上像素级检测矩形碰撞和圆形碰撞本质上都是“用近似几何体代替真实物体”。当物体形状接近矩形或圆形时误差可以接受。但如果你用的是像素风角色、不规则陨石、云朵形状的障碍物矩形碰撞就会非常尴尬——两个角色明明擦肩而过却被判定撞上透明区域成了“无形的墙壁”。这种场景需要像素级碰撞检测也就是真正判断两张图片里不透明的像素是否重叠。Pygame里提供了现成的方案pygame.mask。4.2 Mask的from_surface和overlap用法Mask可以理解成一张和精灵图片同样大小的黑白位图图片上不透明的像素对应Mask上的1透明像素对应0。首先从精灵图创建Maskself.mask pygame.mask.from_surface(self.image)然后碰撞检测时调用overlapif player.mask.overlap(enemy.mask, (enemy.rect.x - player.rect.x, enemy.rect.y - player.rect.y)): print(像素级碰撞成立)overlap的第一个参数是另一个物体的Mask第二个参数是“两个物体间的偏移量”。这里要特别小心偏移量的计算方式是对方坐标减去己方坐标而不是直接传对方坐标。我刚用Mask那会儿就栽在这上面传了对方坐标进去结果碰撞时机完全不对后来翻文档才发现是偏移量而不是坐标。overlap返回的如果是一个元组就表示重叠的局部坐标如果返回None说明没有重叠。4.3 性能与缓存建议像素级碰撞虽然精确代价也很明显每一帧都要比较数万甚至数十万个像素点。几十个物体之间互相做Mask检测帧率会肉眼可见地下降。一个务实的做法是分级检测先用AABB矩形粗略判断如果矩形都没重叠直接跳过。只有矩形重叠了再用Mask做精确检测。这在游戏开发里叫“粗检与精检分离”既能保证视觉上的精确又不至于让性能崩掉。另外mask.from_surface是一个非常耗时的操作一定不要在主循环里对同一个精灵反复创建。把Mask缓存在精灵的属性里创建一次后面一直复用。这也是我在实际代码里几乎每个精灵类都会加self.mask的原因。5. 碰撞之后的响应推离、反弹和角色状态切换5.1 最简单可靠的位置回滚方案很多新手做碰撞检测遇到的最大问题不是“有没有撞到”而是“撞到之后该怎么办”。角色撞墙后如果不处理就会嵌进墙里看起来特别假。处理方式有很多种最省心、最快的方法就是碰撞后回滚位置在移动角色之前记录上一帧坐标检测到碰撞就恢复坐标。# 在移动之前记录 old_x, old_y player.rect.x, player.rect.y # 移动角色 player.rect.x player.vx player.rect.y player.vy # 检测碰撞 if pygame.sprite.spritecollide(player, walls, False): player.rect.x, player.rect.y old_x, old_y这种方案适合解谜类、平台跳跃类游戏代码简单也不容易出现“卡墙”问题。5.2 逐轴移动让角色能“擦墙走”回滚方案有一个副作用如果角色同时被推回X和Y方向会导致撞到墙边时完全停住没法贴墙滑动。比如玩家一边按右一边按上遇到台阶时会直接被卡住非常难受。解决这个问题的标准做法是逐轴移动先X轴移动检测碰撞并回滚再Y轴移动检测碰撞并回滚。# X轴 player.rect.x player.vx if pygame.sprite.spritecollide(player, walls, False): player.rect.x old_x # Y轴 player.rect.y player.vy if pygame.sprite.spritecollide(player, walls, False): player.rect.y old_y这样角色撞墙时至少还能沿另一个轴滑动。我在做平台跳跃关卡时就是靠这个方案处理台阶和墙壁的手感比整体回滚好太多。5.3 反弹响应和处理函数回调碰撞响应不只有“推离”一种。打砖块游戏里小球撞到挡板或砖块需要反弹玩家踩到敌人时敌人会扣血角色碰到传送门时会切换到另一张地图。反弹的常见实现是把速度分量取反if ball.rect.colliderect(brick): ball.vy * -1 brick.kill()如果你想实现“玩家踩到敌人时敌人受伤但玩家受到反弹”可以这样设计hits pygame.sprite.spritecollide(player, enemies, False) for enemy in hits: if player.rect.bottom enemy.rect.top 10: enemy.take_damage() player.vy -8 # 向上弹跳 else: player.hurt()关键点在于碰撞检测只是“发现事件”真正丰富游戏性的是碰撞之后触发的一系列响应。我见过很多初学者把所有逻辑堆在一个大if里最后还是忍不住封装成函数因为随着游戏复杂度增加碰撞后的处理代码会越来越长。6. 一百个物体同时碰撞时的性能方案6.1 暴力遍历的复杂度问题如果你的游戏只有十几个物体直接双重循环挨个检测完全没问题。但物体一旦多起来问题就来了。假设有N个物体两两之间检测复杂度是O(N^2)。30个物体是900次检测100个物体是1万次300个物体就是9万次。如果每次检测还要做像素级Mask判定帧率会瞬间跌到个位数。我自己第一次做弹幕游戏时就是所有子弹和所有敌人做遍历子弹一多游戏直接变成幻灯片后来排查半天才意识到是检测次数爆炸了。6.2 粗检与精检配合先筛掉明显不相交的优化思路很简单不要让每对物体都做昂贵检测。先用便宜的方式比如AABB排除掉绝大多数明显不相交的组合双重循环只对可能重叠的物体做进一步检测。Pygame的spritecollide默认就是走这个思路它先比较每个精灵的rect只有矩形重叠了才返回。如果你需要更精确可以把mask作为collided参数传入hits pygame.sprite.spritecollide(player, coins, True, pygame.sprite.collide_mask)第四个参数collided指定用哪个函数做最终判定。pygame.sprite.collide_mask会调用精灵的mask属性进行像素级检测但前提是精灵对象里有mask属性。这种写法本身就已经把“AABB粗检 Mask精检”结合好了非常实用。6.3 网格划分把“全网比较”变成“邻居比较”当物体数量进一步增加粗检到精检也不够时就需要考虑空间分割了。最常用的简易方案是均匀网格把地图切成若干格子每个物体只存进它所在的格子然后只检测相邻格子之间的物体。def build_grid(objects, cell_size64): grid {} for obj in objects: cell_x obj.rect.centerx // cell_size cell_y obj.rect.centery // cell_size grid.setdefault((cell_x, cell_y), []).append(obj) return grid检测时只需要找到目标物体所在格子的九个邻居包括自己把里面的物体挨个拉出来做碰撞检测即可。这样绝大部分不相干物体不会进入检测范围复杂度从O(N^2)降到接近O(N)。网格方案不需要引入额外库纯Python就能实现适合中小型2D游戏。更高级的做法是四叉树或空间哈希但除非同屏物体数量上千否则一般用不到。我的建议是先量性能确实卡了再考虑优化。不要一上来就上四叉树那是给自己找麻烦。7. 一个能跑起来的完整Demo金币、墙壁与敌人7.1 项目结构和精灵设计前面讲了原理现在用一个完整的小游戏把它们串起来。场景很简单玩家在一个带墙壁的地图里移动收集金币躲避AI敌人。碰到墙壁被推离碰到金币得分碰到敌人游戏结束。代码用一个文件写完方便你直接实验import pygame import random pygame.init() SCREEN_W, SCREEN_H 800, 600 screen pygame.display.set_mode((SCREEN_W, SCREEN_H)) clock pygame.time.Clock() class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((40, 40)) self.image.fill((0, 128, 255)) self.rect self.image.get_rect(center(400, 300)) self.speed 5 def update(self, keys): self.rect.x (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]) * self.speed self.rect.y (keys[pygame.K_DOWN] - keys[pygame.K_UP]) * self.speed class Wall(pygame.sprite.Sprite): def __init__(self, x, y, w, h): super().__init__() self.image pygame.Surface((w, h)) self.image.fill((90, 90, 90)) self.rect pygame.Rect(x, y, w, h) class Coin(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((24, 24)) self.image.fill((255, 215, 0)) self.rect self.image.get_rect( center(random.randint(50, SCREEN_W - 50), random.randint(50, SCREEN_H - 50)) ) class Enemy(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image pygame.Surface((50, 50)) self.image.fill((220, 50, 50)) self.rect self.image.get_rect(center(100, 100)) self.speed 3 def update(self): self.rect.x self.speed if self.rect.right SCREEN_W or self.rect.left 0: self.speed * -1 player Player() walls pygame.sprite.Group( Wall(0, 0, SCREEN_W, 30), Wall(0, SCREEN_H - 30, SCREEN_W, 30), Wall(0, 30, 30, SCREEN_H - 60), Wall(SCREEN_W - 30, 30, 30, SCREEN_H - 60), ) coins pygame.sprite.Group(*[Coin() for _ in range(5)]) enemies pygame.sprite.Group(Enemy()) all_sprites pygame.sprite.Group(player, walls, coins, enemies) score 0 running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False keys pygame.key.get_pressed() old_x, old_y player.rect.x, player.rect.y player.update(keys) # 墙壁碰撞回滚位置 if pygame.sprite.spritecollide(player, walls, False): player.rect.x, player.rect.y old_x, old_y # 金币碰撞加分并生成新金币 for coin in pygame.sprite.spritecollide(player, coins, True): score 1 coins.add(Coin()) # 敌人碰撞游戏结束 if pygame.sprite.spritecollide(player, enemies, False): print(f游戏结束得分{score}) running False screen.fill((240, 240, 240)) walls.draw(screen) coins.draw(screen) enemies.draw(screen) screen.blit(player.image, player.rect) pygame.display.set_caption(fPython 碰撞检测 Demo | 得分: {score}) pygame.display.flip() clock.tick(60) pygame.quit()7.2 主循环里的碰撞流程是怎么设计的这段Demo虽然短但体现了几个重要设计所有精灵按类型分组用pygame.sprite.Group统一管理碰撞检测就变成了“组与组之间”的事而不是挨个比较。墙壁碰撞使用位置回滚因为它最简单可靠。金币组、敌人组分别用spritecollide检测第三个参数控制是否删除被撞金币敌人则保留。新生成的金币通过coins.add(Coin())加入精灵组all_sprites这个综合组没有特别参与碰撞判断只是统一绘制的便利。我建议你亲手改一改这段代码比如把墙壁碰撞改成逐轴回滚再看角色会不会更顺滑地贴着墙走或者把敌人碰撞改成踩头判定就能做出类似马里奥踩蘑菇的效果。碰撞检测只有在改来改去的过程中才能真正掌握。7.3 为什么这些代码能直接用于你的项目这段Demo里没有任何花哨的东西全是前面讲过的知识点Rect.colliderect、精灵组、回滚响应、碰撞触发后的状态切换。你之后的游戏项目不管加多少新玩法底层碰撞系统都离不开这些基础模块。哪怕以后不局限于Pygame换成Godot或者Unity碰撞检测的思路也一样粗检阶段用包围盒精检阶段用更精确的几何体碰撞后要处理推离或者触发事件。这些底层逻辑在不同游戏引擎里都是通用的。8. 我实际踩过的坑高速穿透、坐标方向与检测顺序8.1 子弹太快会“穿墙”隧道效应的启发前面一直说碰撞检测是离散的这带来一个很典型的Bug物体移动速度太快可能上一帧还在墙壁左边下一帧已经跑到墙壁右边去了中间完全没有一帧是和墙壁重叠的碰撞检测自然就不会触发。这个问题叫“隧道效应”。我写过一个小型纵版射击游戏子弹速度设为每秒1000像素帧率60的情况下单帧就移动16像素而子弹宽度只有8像素经常直接穿过敌人弹体。后来我把子弹做成射线检测或者拆成多段小位移检测才彻底解决。简单方案是限制最大速度保证单帧位移小于最小碰撞体的宽度。复杂一点可以自己实现“扫掠检测”计算运动轨迹线段与碰撞体的交点。在Pygame里最直接的解决办法是每帧拆成多个子步骤移动steps 4 for _ in range(steps): bullet.rect.x bullet.vx / steps if bullet.rect.colliderect(target): target.kill()这其实就是把一帧切成多帧用计算量换检测精度在子弹数量不多时完全够用。8.2 Pygame里Rect的坐标系是反直觉的Pygame的坐标系原点在屏幕左上角X轴向右Y轴向下。这意味着y越大位置越靠下。很多从数学坐标系转过来的人第一次写向下移动时写成player.rect.y - speed结果角色往上飞了。这对碰撞检测的影响也很大判断上边界和下边界时方向容易搞混。我的经验是永远只依赖rect.top、rect.bottom、rect.left、rect.right这四个属性不要自己拿y和height去算少了很多低级失误。8.3 Mask的生成时机和精灵销毁Mask有一个容易被忽略的性能坑如果你在精灵的__init__里生成Mask那没问题但如果每次碰撞检测时现写一个性能会急剧下降。更隐蔽的问题是当精灵图片在运行时发生变化比如角色换装备、变身动画你需要确保Mask也同步更新。我一般会在精灵图片变化后重新调用一次from_surface并把旧的Mask替换掉。另外使用spritecollide(player, coins, True)删除金币时金币对象虽然从精灵组里移除了但它的实例引用可能还存在。如果金币后续还有动画或音效要播放建议先记录碰撞列表再做删除或刷新操作避免把要在碰撞响应里用到的对象提前销毁。8.4 移动和检测的顺序有讲究先说结论先移动再检测再回滚。这是最稳的顺序。如果你先检测碰撞再移动角色会停在碰撞点前一帧的位置虽然也能工作但会引入一帧延迟高速移动时能明显感觉到操作不跟手。反过来先移动再检测再回滚响应更顺滑也更容易实现前面说的逐轴滑动。还有一点是关于碰撞组之间的代码顺序。比如子弹打敌人同时子弹也撞墙你需要先判断子弹是否撞到敌人再判断是否撞到墙。如果顺序写反子弹先撞墙被销毁了就永远打不到靠墙的敌人了。我早期做射击游戏时就是被这个顺序坑惨了后来养成了“先处理重要响应再处理销毁逻辑”的习惯。从我自己的开发体验来讲碰撞检测这个领域没有多么高深核心就是几何判断加上合理的响应逻辑。新手最容易掉进去的坑是把碰撞检测想得太复杂或者想得太简单。你得先接受它其实是在“近似”用矩形、圆形去逼近真实形状用逐帧判断去逼近连续运动。既然游戏本身就是一系列快照拼接出来的碰撞检测自然也逃不开这个前提。如果看完这篇文章你只记住一句话我希望是这句话先把AABB玩熟再考虑像素级检测和各种优化。80%的2D游戏碰撞需求用矩形和圆形碰撞体就能解决。当你真正遇到需求满足不了时再引入Mask、空间划分这些进阶方案也不迟。动手改代码去下一次游戏里的“穿模Bug”就有解了。