用Pygame从零实现贪吃蛇:核心数据结构与游戏循环全解析

发布时间:2026/9/8 16:35:25
用Pygame从零实现贪吃蛇:核心数据结构与游戏循环全解析 简介这是一套以经典贪吃蛇玩法为核心的 Python 游戏资源适合 Python 初学者、游戏开发爱好者和课程设计场景能够帮助读者通过一个完整项目理解游戏循环、按键控制、碰撞判定等基础概念。资源包共 6 个文件压缩包大小仅 8.11MB包含 Python 代码、docx 技术文档、背景音乐 mp3、字体 ttf 以及搭配使用的图片素材各类型分工明确便于运行调试与查阅说明。代码文件提供了可运行的贪吃蛇框架技术文档能辅助理清代码结构和运行步骤自带音乐与字体则解决了素材依赖和中文显示等常见问题让读者可以快速复现一款有声音、有界面的小游戏并在此基础上继续扩展关卡、计分或移动速度等功能。目前已有 12043 人学习下载整体内容对零基础者友好适合作为 Python 学习路上的练手项目也能直接用于课堂作业或兴趣开发。 不少人在学Python的时候都有过这样一个念头学了这么久语法能不能做个什么小东西出来玩玩我的建议通常是同一个——写一个贪吃蛇。别小看这个经典小游戏它背后涉及的键盘事件处理、游戏循环、坐标建模、碰撞检测、状态管理几乎把Python入门阶段该碰到的核心概念全串起来了。这篇文章我不会只把代码扔给你而是会从选型、环境准备、每一步的设计思路到运行过程中我自己踩过的坑完整地过一遍。1. 为什么选Pygame做贪吃蛇而不是纯控制台先说结论如果你只是想验证一下自己的逻辑能力临时用列表加键盘输入做个控制台版贪吃蛇也不是不行。但我个人强烈建议你直接用Pygame做带图形界面的版本。原因很直接——控制台版的贪吃蛇蛇移动是靠不断print新画面你根本看不到平滑移动的效果也没有真正的“实时按键响应”玩起来像在看幻灯片。而Pygame做出来的东西窗口、动画、音效、分数这些都有做完后你发给朋友看对方会觉得“哇这是个正经游戏”而不是“你在终端里打印了一堆方块”。1.1 Pygame到底解决了什么问题Pygame是一个基于SDL库的Python游戏开发框架。SDK底层帮我们封装了窗口创建、图像绘制、键盘捕获、音频播放这些操作系统层面的能力我们不需要关心怎么调用Windows或者macOS的底层图形接口只需要调用Pygame暴露出来的统一API就可以了。这里有个很重要的认知Pygame不是一个游戏引擎它不帮你管理场景、不帮你做物理碰撞、不帮你做动画系统。它只提供了做游戏所需的最底层能力其他所有逻辑都需要你自己用Python代码来写。因此贪吃蛇这种逻辑不复杂的游戏恰好是学习Pygame的最佳项目——你能清楚地看到游戏循环里的每一个环节都是由什么代码控制的而不会像用Unity这种重型引擎时很多东西被引擎自动处理了你反而一头雾水。1.2 运行环境准备我自己是在Windows环境下开发的但这份代码在macOS和Linux上也能跑只需要注意pip安装命令可能略有差异。先说Python版本的安装目前Pygame对Python的版本兼容性已经做得很好了3.8到3.12基本上都能正常安装。如果你用的是Windows去Python官网下载安装包时一定要勾选“Add Python to PATH”这个选项不然装完python命令在终端里敲不出来后面会很折腾。# 检查Python版本 python --version # 安装Pygame pip install pygame # 验证安装是否成功 python -m pygame.examples.aliens如果你能看到一个小飞机大战的演示窗口弹出来说明Pygame已经装好了。有些人在pip install pygame这一步会遇到下载慢或者超时的问题可以换成国内镜像源pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple这些都准备好之后就可以开始考虑这个游戏具体要怎么设计了。2. 贪吃蛇的三个核心数据结构想清楚再动手很多新手写游戏最大的问题是一上来就写代码写到一半发现逻辑理不清只好推翻重来。我建议你面对任何游戏项目都先在纸上把核心数据结构和游戏流程画出来。贪吃蛇虽然简单但它也有三个绕不开的数据结构问题蛇的身体怎么存、方向怎么表示、食物怎么刷新。2.1 蛇身用列表还是队列大多数人的第一反应是用列表存蛇身这个方向是对的但要注意怎么用。我使用的是双向队列collections.deque当然用普通的list也完全可以。核心操作其实只有两个蛇头前进时把新坐标append到尾部蛇尾收缩时从头部popleft一个元素注意我这里说的头尾跟你的直觉可能不一样稍后解释。这里我用一个生活化的类比来帮你理解想象蛇是一个火车火车头每前进一步就在车头方向挂上一节新的车厢同时把最后一节车厢卸掉。如果吃到了食物最后一节车厢就不用卸了火车整体就变长了一节。在Pygame的坐标系里窗口左上角是(0,0)x轴向右增加y轴向下增加。蛇身我用一个由坐标元组组成的deque来表示from collections import deque # 蛇身用deque存储比如初始长度为3方向向右 snake deque([(60, 0), (40, 0), (20, 0)])这里蛇头是snake[-1]也就是最后一项蛇尾是snake[0]。为什么这么做因为在deque中从两端操作都是O(1)时间复杂度而list在头部插入删除是O(n)。虽然对贪吃蛇这个量级来说性能区别根本感觉不出来但养成用对数据结构的习惯总是好的。2.2 方向变量为什么不能用整数方向可以用字符串UP、DOWN、LEFT、RIGHT来表示但没用过很多次我就会在切换方向的时候出bug。我自己更习惯用元组表示坐标增量这样移动的逻辑就变成单纯的坐标加法不涉及任何判断语句# 方向向量符合Pygame坐标系 UP (0, -STEP) DOWN (0, STEP) LEFT (-STEP, 0) RIGHT (STEP, 0)这么设计有个直观的好处计算新蛇头位置的时候只需要拿到当前蛇头坐标加上方向向量就行了。def get_new_head(snake, direction): head_x, head_y snake[-1] dx, dy direction new_head (head_x dx, head_y dy) return new_head2.3 食物的生成逻辑里藏着一个几何陷阱食物位置必须是随机生成的但有一个前提不能生成在蛇身上。最简单的做法是随机生成坐标后判断它是否在snake列表里如果在就重新生成。这个逻辑虽然直接但当你蛇变得很长的时候随机到蛇身的概率会增大极端情况下可能要循环很多次才能找到空位。更稳妥的做法是维护一个所有空白格子的列表然后从中随机选一个。当网格数量是30x20600个时性能完全没有问题import random def spawn_food(snake, grid_width, grid_height): occupied set(snake) all_cells [(x, y) for x in range(grid_width) for y in range(grid_height) if (x * STEP, y * STEP) not in occupied] if not all_cells: return None # 蛇占满了整个屏幕游戏胜利 fx, fy random.choice(all_cells) return (fx * STEP, fy * STEP)注意我在all_cells里存的是网格的列和行索引返回时才乘以STEP。如果你直接以像素为单位生成随机坐标很可能得到不是STEP整数倍的位置画出来的食物就跟网格错位了。这个细节我一开始没注意导致食物经常卡在蛇身上。3. 游戏主循环驱动整个游戏的心脏Pygame游戏的核心是一个无限循环每一帧循环做三件事处理用户输入、更新游戏状态、重新绘制画面。理解了这个循环你就理解了所有实时游戏的基础架构。3.1 循环的大框架clock pygame.time.Clock() running True while running: # 1. 处理事件 for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_UP: direction UP elif event.key pygame.K_DOWN: direction DOWN elif event.key pygame.K_LEFT: direction LEFT elif event.key pygame.K_RIGHT: direction RIGHT # 2. 更新游戏状态 new_head get_new_head(snake, direction) snake.append(new_head) if new_head food: food spawn_food(snake, GRID_WIDTH, GRID_HEIGHT) score 1 else: snake.popleft() # 3. 检查碰撞 if is_collision(snake): running False # 4. 绘制界面 screen.fill(BG_COLOR) draw_snake(screen, snake) draw_food(screen, food) draw_score(screen, score) pygame.display.flip() # 5. 控制帧率 clock.tick(FPS)你仔细看这个循环它把游戏的所有状态变化都集中在一个while循环里。每执行一次循环蛇就走一格。这里的FPS决定了蛇的移动速度——如果你把clock.tick(10)蛇每秒走10格这就是一个非常经典的初级难度。到后面想提高难度可以随时间动态提高FPS。3.2 方向反转Bugs——新手最容易踩的坑我在这个循环里故意省略了一个关键检查这是贪吃蛇最容易出的bug当你当前方向是向左时如果按了右键新蛇头会直接反向蛇一头撞上自己的身体。这在游戏操作中是必须禁止的。解决方式很简单# 用方向向量判断是否相反 if (direction[0] * new_direction[0] direction[1] * new_direction[1]) 0: # 点积为0说明两个方向向量互为相反方向 pass # 忽略这个输入 else: direction new_direction用点积判断反向是一个比较技巧性的写法。方向向量的点积如果等于0说明它们互相垂直这意味着玩家是按了左右相邻的方向键可以合法转向。点积如果小于0说明方向正好相反此时应该忽略输入。这个判断比分别判断四个方向的组合要简洁得多也不容易漏case。3.3 为什么不能用time.sleep()控制速度初学者容易犯的一个错误是用time.sleep(0.1)来代替帧率控制。问题在于sleep会阻塞整个程序导致键盘事件的响应变得不灵敏。比如蛇每隔100毫秒走一格你在第50毫秒时按了转向键这个按键要到下一次循环才能被处理操作延迟感会很明显。这也是Pygame为什么提供Clock对象的原因。clock.tick(FPS)不只是简单的延时它会根据上一帧实际运行用掉的时间自动计算还需要等待多久确保每帧间隔尽量均匀。这样游戏运行起来就非常顺滑。4. 碰撞检测的边界从画格子到真正的游戏反馈4.1 蛇撞墙和撞自己碰撞检测是整个游戏对玩家的直接约束。两种碰撞必须处理撞墙和撞自己。撞墙的判定有一个容易写错的地方——我见过不少人用蛇头坐标大于窗口宽高来判断但没注意蛇头本身有一个大小。如果你画的蛇头是一个STEP x STEP的正方形当蛇头左上角坐标等于窗口宽度时蛇头已经有一半在屏幕外了。所以严谨一点的做法是在计算新蛇头之后就检查它是否超出了合法坐标范围def is_out_of_bounds(head, width, height): x, y head return x 0 or x width or y 0 or y height撞自己的判定就相对直观了。由于蛇尾在每一轮的popleft之前还在snake里所以判断新蛇头是否在snake里需要特别注意当蛇没有吃到食物时蛇尾这一格马上会消失所以新蛇头碰到蛇尾其实是合法的。如果用的是new_head in snake这句来判断它会把这个合法的移动误判成碰撞。正确的姿势是跟snake去掉尾巴后的身体比较def is_self_collision(snake, new_head): # 把尾巴去掉因为这一格马上会被释放 body_without_tail list(snake)[1:] return new_head in body_without_tail这个细节我抽出来单独讨论是因为网上很多代码都直接判断new_head in snake在蛇比较短的时候问题不大等蛇绕了几圈之后尾巴处就会出现无缘无故的死亡玩家体验非常不好。4.2 网格化移动与像素移动的取舍贪吃蛇最简单的移动模式是“网格化移动”即蛇头每次移动一个固定步长STEP我设置的是20像素。这样蛇头永远落在20的整数倍坐标上碰撞检测就退化成几个坐标比较非常高效。也有人用像素级移动蛇每帧移动1像素或几像素运动更平滑但这样带来的麻烦是蛇头和食物的碰撞不再是一个精确的点比较而需要判断蛇头矩形和食物矩形是否有重叠。代码复杂程度会指数级上升而且不做平滑插值的话蛇会出现在半格半格的位置观感反而更差。如果你真的很在意画面流畅度我建议保持网格移动的同时把窗口分辨率调小一点或者用更高的帧率比如20FPS视觉上的“一格一格跳”的感觉会弱很多代价仅仅是CPU占用略高一点。对于贪吃蛇这个项目网格化移动是完全正确且专业的选择。4.3 增加分数显示和游戏结束界面光有蛇和食物还不够没有一个分数系统游戏缺少反馈闭环。我一开始只在终端里print分数后来发现这样做很割裂——玩家盯着图形窗口分数却在黑框里滚体验很不好。于是我把分数也画在窗口顶部font pygame.font.SysFont(arial, 20) score_surface font.render(fScore: {score}, True, WHITE) screen.blit(score_surface, (10, 10))如果你用的是中文系统SysFont里填“SimHei”或者“Microsoft YaHei”可以支持中文。不过考虑到游戏界面用英文更不容易出现字体缺失问题我这里直接用了“arial”。如果你后面想加入“游戏结束按任意键重玩”这种逻辑只需要在running变为False之后写一个等待输入的状态循环就行。5. 把细节做对Delaunay三角剖分不是几个容易被忽略的硬细节这一节聊一些我实际做的时候摸出来的经验有些是代码层面的有些是设计思路层面的。5.1 键盘长按和事件重复Pygame默认情况下键盘按键事件不会重复触发你按住方向键不放系统只会触发一次KEYDOWN。但有些系统上你可能想实现“按住方向键连续移动”的效果——注意这在贪吃蛇里是不可取的因为这意味着按住右键蛇会一路向右无法转向玩家会疯掉的。贪吃蛇里更合理的做法是每帧只根据当前方向移动一格按键只是改变方向变量本身而不是触发移动。这个想法我在前面代码里已经是这么实现的了。这样即使玩家很快地连续按两个键只有最后一次按键会生效。这个设计逻辑顺理成章但不少新手会把按键事件当成是移动事件本身把移动放在事件里面写结果蛇的移动速度跟电脑性能绑定时快时慢。记住移动永远只跟当前方向和帧率挂钩按键只负责改方向。5.2 食物不要生成在蛇的必经之路上这听起来有点玄学但实际操作中确实有类似体验。如果食物随机生成的位置离蛇很远而且中间隔着蛇的身体玩家会很不耐烦。我加过一个优化食物生成时会计算当前蛇头到食物的曼哈顿距离横纵坐标差的绝对值和。如果这个距离超过一定值比如屏幕对角线长度的三分之一就重新随机一次。虽然这会有一点点概率性延迟但对游戏体验的提升是非常明显的。这个优化纯属个人口味不追求完美体验的话完全可以不加。但我测试下来玩家对“够得着的距离”这件事非常敏感一个离得太远的食物会让人觉得游戏在故意刁难。5.3 文件组织别把所有代码堆在一个py文件里到后期如果你的贪吃蛇加上了关卡、音效、皮肤、排行榜这些功能建议把代码拆分为模块。我自己一般这么拆snake_game/ ├── main.py # 主循环和程序的入口 ├── settings.py # 所有常量配置 ├── snake.py # 蛇类负责移动和绘制 ├── food.py # 食物类 ├── score.py # 分数逻辑 └── ui.py # 按钮、文本等UI组件对于一个小游戏来说这种拆分确实显得有点“杀鸡用牛刀”。但如果你后面打算把它扩展成大项目早一点养成模块化思维付出的成本极低收益却是长期的。而且每个文件控制在100行以内找bug的时候你会感谢自己当初这个决定。5.4 常见运行报错汇总报错信息原因分析解决办法pygame.error: video system not initialized在调用pygame.init()之前就创建了窗口或调用了其他pygame函数在代码最开头加上pygame.init()ModuleNotFoundError: No module named pygame当前Python环境里没有安装Pygame确认你的pip和python属于同一个解释器重新pip install pygameValueError: deque.index(x): x not in deque你尝试用索引获取deque中不存在的元素检查蛇头坐标是否从snake里正确取出注意snake[-1]才是蛇头TypeError: pygame.Surface object is not callable把screen.fill(color)写成了screen.fill(color)以外的形式或者把fill括号写成了方括号检查是否在方法调用时多加了print之类的误操作6. 从“能玩”到“好玩”几条扩展路线的优先级如果你的贪吃蛇已经能正常玩下一步该加什么功能我个人认为按性价比排序应该是这样第一优先级是音效。哪怕只是一个简单的eat.wav在吃到食物时播放玩家获得的正向反馈会立刻上一个台阶。Pygame用pygame.mixer.Sound()可以非常轻易地播放音效几乎不增加任何代码复杂度。第二优先级是速度递进。很多经典贪吃蛇每次吃到食物后都会变快一点这个实现只需要动态修改FPS或移动步长就行。这里注意FPS上限不要搞太高不然蛇快到游戏本身失去意义。第三优先级是障碍物或特殊物品。比如可以在地图中间加一些静态障碍物或者让食物随机带有加速、减速效果。这些玩法上的修改只需要在碰撞检测里增加几个分支就行。第四才是皮肤和画面美化。画面的提升对游戏体验有影响但代码复杂度提升最明显。我的建议是先把玩法打磨好再去操心配色和图标这样你的学习曲线更平稳成就感也来得更快。如果你真想做出一份拿得出手的作品我的经验是先按这篇文章从零写一遍边写边理解每一个模块存在的意义。写完后再回头读一遍代码尝试把一些重复的逻辑抽成函数把全局变量封装进类。这个过程会逼着你重新审视自己的代码而审视代码的能力恰恰是Python从入门到进阶最关键的分水岭。本文还有配套的精品资源点击获取