尧图精选

Pygame贪吃蛇源码深度拆解:事件循环、碰撞检测与工程实践

🕒 发布时间:2026/9/16 16:08:57 📁 来源:尧图网络
简介这是一套基于Pygame实现的贪吃蛇游戏完整源码适合Python初学者、游戏开发入门者及对Pygame库感兴趣的读者学习参考。源码实现了随机生成果实、经典游戏界面、碰撞墙壁或自身后结束游戏等核心功能并可通过修改程序开头的常量来调整游戏速度和窗口分辨率逻辑清晰、注释易懂便于二次修改与功能扩展。压缩包共7个文件主要包含1个Python主程序、4个IDE配置文件xml、1个iml工程文件和1个gitignore文件整体大小仅5KB轻量易用。已有1324人学习下载资源作者为baidu_36499789。通过这份源码读者可直观理解游戏主循环、事件处理、碰撞检测与蛇身移动等Pygame常用机制为后续开发更复杂的游戏打下基础。1. 一份能直接跑的 pygame 贪吃蛇代码结构里藏着什么贪吃蛇大概是 pygame 素材里被重写次数最多的项目但多数流传版本只有孤零零一个脚本注释稀少常量散落在函数里。这份源码不一样压缩包里有完整的 .idea 工程目录、模块描述文件、.gitignore以及一个 snake.py 主文件。它不依赖任何第三方游戏引擎只要求环境里有可用的 pygame 库安装环节失败率最高的也就是编译 wheel 那一步。对正在做课程设计、或者刚把 pygame 装好但对事件循环还没有整体概念的人来说打开就能跑、逐段能读懂是最重要的。后面按这套源码的实际组织方式从常量设计、主循环到碰撞判定逐层拆解。2. 开头常量驱动的设计分辨率、网格与移动速度怎么配合2.1 常量分组的思路打开 snake.py最先看到的是十来个全部大写的常量声明。它们不是随意码在一起的按职责能分成三组窗口与网格SCREEN_WIDTH、SCREEN_HEIGHT、GRID_SIZE、移动节奏MOVE_INTERVAL、界面颜色COLOR_BG、COLOR_SNAKE_HEAD 等。把取值集中放在主文件头部意味着每一处游戏逻辑里都不出现魔法数字。后面随机生成果实时要用列数和行数直接拿 SCREEN_WIDTH 除以 GRID_SIZE 推导而不是再写一个 32 或 24。大多数初学改写者第一件事是改 SCREEN_WIDTH 和 SCREEN_HEIGHT 把窗口调大但忽略了 GRID_SIZE 也要跟着考虑。窗口变大而网格不变蛇并不会变大只是游戏区域多出很多列和行蛇的相对移动速度会显得明显变慢。反之窗口缩小到 320x240蛇身的 20 像素方块会占满大半个屏幕。网格尺寸决定的是方块粒度不是显示倍数。常量作用典型取值修改后的连锁影响SCREEN_WIDTH窗口水平像素640影响列数、右边界判定SCREEN_HEIGHT窗口垂直像素480影响行数、下边界判定GRID_SIZE每个网格的边长20影响蛇身与果实大小、坐标对齐MOVE_INTERVAL移动一格所需毫秒120数值越小蛇越快SNAKE_SIZE蛇身体方块边长20与 GRID_SIZE 不等时出现视觉错位注意 SCREEN_WIDTH 和 SCREEN_HEIGHT 最好能整除 GRID_SIZE。640 除以 20 得到整数 32这意味着游戏区域恰好排满 32 列改成 800x600 依旧成立但如果改成 854最后一列网格只有 14 像素宽果实和蛇身的绘制位置就会跟边界判定出现偏差。我一般会让 GRID_SIZE 同时整除两个边长并把 SNAKE_SIZE 保持与 GRID_SIZE 一致。2.2 速度参数 MOVE_INTERVAL 控制的是什么MOVE_INTERVAL 不是帧率它描述的是「每移动一格消耗多少毫秒」。120 表示每 120 毫秒蛇前进一步改成 60 就是相同时间内移动两步视觉上快一倍。帧率由 pygame.time.Clock 在循环里单独锁定两者必须分开看帧率决定渲染刷新频率MOVE_INTERVAL 决定逻辑位移频率。如果 MOVE_INTERVAL 和帧率相差太大会看到一个典型现象蛇移动起来一顿一顿但又不是蛇本身卡顿而是移动间隔恰好不是帧间隔的整数倍。120ms 对应 60 帧下的 7 帧多一点画面里蛇有时走一步显示 7 帧、有时 8 帧观感还算平稳改成 90ms 就会出现明显的节奏不均匀。所以需要微调速度时优先改成接近 60 的整数倍附近的取值比如 90、120、150而不是随便填一个 137。这份源码把移动间隔做成全局常量是最朴素的方案适合课程设计和固定难度游戏。把固定速度升级成随分数递减的序列是后面要说的调优方向之一。3. 主循环的节奏pygame 事件队列、时钟与移动节拍分离3.1 窗口生命周期与事件模型pygame 初始化从 pygame.init() 开始它把显示模块、字体模块、混音器一次性准备好随后 pygame.display.set_mode 创建窗口并返回 Surface 对象之后所有绘制都发生在这块表面上。pygame 的事件模型和 tkinter 那类回调驱动的 GUI 框架不同它把键盘、鼠标、窗口关闭等输入统一塞进一个事件队列主循环每帧用 pygame.event.get() 主动把队列取空并逐条处理。写主循环时容易漏掉的一件事是事件处理必须在每帧都执行否则排队的事件会积压表现为按键延迟、关闭窗口没反应等异常。这份源码的主循环是典型的「处理事件、更新逻辑、渲染、翻页」四步。其中翻页用 pygame.display.flip()它把绘制好的后台画面一次性提交给显示器只调用 fill 和绘图函数而不调用 flip窗口会保持黑屏或前一帧内容不变这是新手第一个会踩的坑。双缓冲机制下所有 draw 操作都在后台表面完成flip 之后才会真正呈现。3.2 方向键映射与 180 度转向拦截按键处理集中在 KEYDOWN 分支里。四个方向键分别对应一个二维向量向量以像素为单位而非格子数例如 K_UP 对应 (0, -GRID_SIZE)。用像素向量而不是方向枚举的好处是移动逻辑可以直接把向量加到蛇头坐标上得到一个按网格对齐的新坐标省去一层「方向转坐标」的换算。一个必须处理的边界场景是 180 度转向。蛇向右移动时如果按下左键蛇头会直接穿进身体第二节游戏立刻结束。拦截逻辑常见做法是接受新方向前先判断它是否与当前待生效的方向正好相反。为了避免快速连续按键时出现中间方向被跳过、蛇身走势混乱的问题事件循环里只修改 next_direction移动节拍真正执行时才把它赋给 direction。next_direction direction for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_UP and next_direction ! (0, GRID_SIZE): next_direction (0, -GRID_SIZE) elif event.key pygame.K_DOWN and next_direction ! (0, -GRID_SIZE): next_direction (0, GRID_SIZE) elif event.key pygame.K_LEFT and next_direction ! (GRID_SIZE, 0): next_direction (-GRID_SIZE, 0) elif event.key pygame.K_RIGHT and next_direction ! (-GRID_SIZE, 0): next_direction (GRID_SIZE, 0)这段判定比较的是 next_direction 而不是 direction理由是它代表「下一次会实际生效的方向」。如果拿当前 direction 做比较连续按上再按左时左键会被错误拦截因为上一帧的方向是右而比较 next_direction 时上已经替代了右左键可以正常通过。这种细节决定了快速转向时的手感。3.3 定时移动与渲染帧率解耦控制蛇移动有几种不同做法每帧移动一格、用事件定时器驱动、以及基于时间累积的节拍移动。这份源码适合用第三种因为它把渲染帧率和逻辑节奏彻底分开。主循环里 clock.tick(60) 的返回值是距上一帧经过的毫秒数把它累加到 move_timer 上当累积量超过 MOVE_INTERVAL 时触发一次移动并把消耗掉的时间从累积量中扣除。这个写法让移动频率只由 MOVE_INTERVAL 决定与显示器刷新率无关。clock pygame.time.Clock() move_timer 0 while running: dt clock.tick(60) # 上一帧耗时单位毫秒 move_timer dt while move_timer MOVE_INTERVAL: move_timer - MOVE_INTERVAL direction next_direction # 事件里攒的方向在这里统一生效 # 更新蛇头坐标、果实判定、碰撞判定while 而不是 if 承接移动增量是有原因的如果程序短暂卡顿或调试时打了断点dt 累积可能超过 MOVE_INTERVAL 数倍。用 while 会把这几次移动按顺序补上蛇仍然走正确的格数改成 if 会丢掉多余的累计时间蛇表现为「卡了一下之后少走了几步」之后位置错乱。如果不想用时间累积也可以注册 pygame.USEREVENT 自定义事件配合 pygame.time.set_timer 触发定时移动但那种写法把逻辑推进交给事件系统碰撞判定的调用链会绕一些。控制方式实现要点适用场景每帧一移动不依赖时钟帧率即速度演示帧率影响、像素级移动自定义定时事件set_timer 触发 USEREVENT逻辑与渲染天然隔离回调驱动时间累积节拍clock.tick 返回值累加帧率波动不影响逻辑节奏3.4 主循环完整骨架把上面几块组合起来主循环的完整形态如下。事件处理只改方向移动节拍只负责位移和判定draw 函数只关心渲染三者职责分离后后续加计分、加音效都只影响其中一个环节。running True while running: dt clock.tick(60) move_timer dt for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: update_direction(event.key) while move_timer MOVE_INTERVAL: move_timer - MOVE_INTERVAL direction next_direction head move_snake(snake, direction) if head food: food spawn_food(snake) score 1 if check_collision(snake): running False draw(screen, snake, food, score) pygame.display.flip() pygame.quit()变量 dt 的典型值在 16 左右表示上一帧大约消耗 16 毫秒如果渲染耗时长dt 会跟着上涨但移动逻辑依然按格推进不会出现蛇的位移和画面渲染互相追赶的抖动。check_collision 返回 True 后 running 置为 False循环退出后调用 pygame.quit() 释放窗口资源。这行在课程设计里经常被省略IDE 里直接关闭窗口看似没区别但窗口销毁顺序异常时终端会残留僵尸进程或音频句柄。4. 随机果实与碰撞判定从坐标生成到游戏结束的完整链路4.1 果实坐标怎么保证落在网格上果实的职责由 spawn_food 承担它的核心是两点约束坐标必须按网格对齐且不能出现在蛇身上。先以 GRID_SIZE 为步长计算可用的列数和行数再用 random.randrange 从 [0, cols) 和 [0, rows) 中分别取样得到的是网格索引把索引乘以 GRID_SIZE 才是像素坐标。常见的错误是把 randrange 的边界写成 SCREEN_WIDTH 而不是 cols这样果实会落在任意像素位置蛇头经过时永远对不齐判定永远不成立。def spawn_food(snake): cols SCREEN_WIDTH // GRID_SIZE rows SCREEN_HEIGHT // GRID_SIZE while True: x random.randrange(0, cols) * GRID_SIZE y random.randrange(0, rows) * GRID_SIZE if (x, y) not in snake: return (x, y)while True 循环是这里的关键随机点可能恰好落在蛇身上需要重新取一次。这个重试在蛇不长时几乎瞬间完成当蛇身占满一半以上区域时命中率下降循环次数增多。对课程设计规模来说这个写法完全够用如果追求严谨可以用空闲格集合生成候选列表再随机挑选代价是每次生成都要遍历游戏区域反而在蛇短时更慢。注意(x, y) not in snake是元组列表的线性查找蛇身几千段时会成为性能热点但蛇身达到那个量级之前游戏通常已经因为撞墙结束。4.2 蛇的伸缩一个 insert 和一个 pop蛇身的数据结构只是 Python 列表每个元素是一个二元组。移动一步时把新头坐标 insert 到列表头部然后判断是否吃到果实吃到则不删除尾部节点蛇长加一没吃到则利用 pop() 删掉尾部节点蛇长保持不变。这两个操作合在一起恰好实现了「吃果实才变长」的游戏规则代码里没有显式的 length 变量。用 insert(0, ...) 而不是 append 配合反转列表是因为保证索引 0 始终是蛇头可以简化碰撞判定和绘制逻辑。绘制时从头到尾遍历列表头用单独的颜色绘制身体用另一种颜色这正好对应源码里 COLOR_SNAKE_HEAD 和 COLOR_SNAKE_BODY 两个常量。列表头部插入是 O(n) 操作n 是蛇长对几百节的蛇来说性能损耗可以忽略如果追求 O(1) 的头部操作可以用 collections.deque但那样随机访问中间元素做碰撞检测时不如列表直接反而需要额外转回 list。4.3 两类终止条件墙壁和自身碰撞检测集中在一个函数里返回 True 表示游戏结束。墙壁碰撞的判断是用蛇头坐标与窗口边界比较小于 0 或大于等于 SCREEN_WIDTH 都算越界。这里容易写错的是边界条件要用大于等于而不是大于。蛇头坐标是网格对齐的最大值恰好是 SCREEN_WIDTH - GRID_SIZE所以判定写成head_x SCREEN_WIDTH时正好把最后一格判为越界蛇会在贴着右侧墙移动时提前判死反过来写成又会让蛇走出屏幕一格才结束。自我碰撞用snake[0] in snake[1:]判断。切片从下标 1 开始是为了排除蛇头自身否则蛇静止时也会判定为「头碰到头」而立刻结束游戏。这段逻辑和果实判定一样是列表线性查找在蛇很长时可以把蛇身坐标同步维护为一个 set用 O(1) 的成员判断替换 O(n) 的 in。代价是维护两个数据结构的一致性每次 insert 和 pop 时都要同步增减 set 元素出错率反而升高所以这份源码没有做这个优化是合理取舍。碰撞类别判定条件常见错误左墙head_x 0写成 0蛇贴墙时误判右墙head_x SCREEN_WIDTH写成 最后一格不会被判死上墙head_y 0忽略 y 坐标负方向下墙head_y SCREEN_HEIGHT与分辨率修改不同步自身snake[0] in snake[1:]切片起点写成 04.4 分数与得分的挂接分数设计在源码里没有单独成类而是用一个整数变量在吃到果实后自增同时把 MOVE_INTERVAL 按分数阶梯缩短。实现难度曲线时要避免在常量区写死一个值而是准备一个速度档位表按当前分数索取对应间隔。表格驱动的好处是不同难度的节奏一目了然后续调整档位不需要改主循环逻辑。SPEED_LEVELS [180, 150, 120, 90, 70] def get_speed_interval(score): level min(score // 5, len(SPEED_LEVELS) - 1) return SPEED_LEVELS[level]score 每增加 5 上一档档位索引封顶在速度表末尾避免蛇在后期无限加速到无法操作。这里把 MOVE_INTERVAL 从全局常量变成了函数返回值主循环里调用 get_speed_interval(score) 获取当前间隔撞墙后的重开逻辑自动沿用新速度。5. 改出自己的版本难度递增、自动验证与工程习惯5.1 让固定速度变成难度曲线上一章的 SPEED_LEVELS 表已经解决速度阶梯问题把它接入主循环时注意一处细节move_timer 的累积值在换档瞬间可能大于新档位的间隔这时 while 循环会一次性补偿多次移动造成换档瞬间蛇突然抽动。常见做法是换档时把 move_timer 强制归零让新节奏从完整间隔开始。同理吃到果实的那一帧不应该再检查一次碰撞否则新生成的果实若与蛇头重叠会同时触发加分和结束两个逻辑顺序上要先更新蛇身再判定。5.2 无头自动运行验证碰撞逻辑的廉价方法手工测试撞墙和撞身场景效率很低。给源码加一个 AUTOPILOT 常量值为 True 时忽略键盘输入由程序自己决策方向优先朝果实方向走正前方有墙或蛇身就转 90 度。配合在 spawn_food 开头加一句random.seed(1234)果实的生成序列可复现同一局可以反复跑定位问题不再依赖运气。AUTOPILOT True if AUTOPILOT: dx food[0] - snake[0][0] dy food[1] - snake[0][1] if abs(dx) abs(dy): direction (GRID_SIZE if dx 0 else -GRID_SIZE, 0) else: direction (0, GRID_SIZE if dy 0 else -GRID_SIZE)这段策略没有做完整寻路但用于验证碰撞已经足够它能主动制造大量撞墙和撞身场景碰撞判定里的边界错误跑几分钟就能暴露。跑通后把 AUTOPILOT 改回 False 即可切回键盘操作。5.3 工程文件与后续维护压缩包里保留的 .idea 目录、.iml 模块文件说明这份源码一开始就是在 PyCharm 或 IntelliJ IDEA 里建的工程而不是临时拼出的脚本。配合 .gitignore 忽略 .idea 下的个人配置多人协作时不会互相覆盖窗口布局。自己开新项目时保留同样的工程习惯把可执行入口集中在一个文件、常量顶层放置、逻辑函数拆分后面做 pygame 手机版移植或换成其他界面层时只需要替换渲染与输入部分。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →