
做了这么多年开发也带过不少新人我越来越觉得学编程最好的正反馈不是刷完多少道题而是亲手做出一个能玩的东西。如果你已经掌握了 Python 的基础语法整天对着终端 print 已经有点腻了那 Pygame 就是你下一步最值得花时间研究的库。它是 Python 生态里最经典的游戏开发框架专门用于 2D 小游戏和图形交互程序用一套非常朴素的机制帮你实现窗口、绘制、事件、音效这些游戏必备能力。今天我要分享的就是用 Pygame 从零写一个贪吃蛇的完整过程代码总量不到 150 行但游戏循环、事件监听、碰撞检测、坐标渲染这些游戏开发的核心概念全都会串一遍。这篇文章适合刚学完 Python 基础、想找第一个练手项目的人也适合对游戏开发内部机制好奇、想搞明白一个游戏到底怎么跑起来的读者。1. 先搞清楚为什么第一个游戏要选 Pygame1.1 门槛和成就感的平衡点我先说一个很多人问过我的问题做游戏为什么不直接用 Unity、Godot 这样的专业引擎非要折腾 Pygame答案很简单你现在的阶段需要的是理解游戏的基础规律而不是被编辑器的一大堆面板淹没。Unity 和 Godot 确实强大但它们需要先学习场景、预制体、动画状态机、资源管理器这些工具链而且写逻辑用的也不是 PythonGodot 支持 GDScript但生态和资料仍然比 Python 差不少。Pygame 则完全不同——它不过是你 pip 装进来的一个第三方库核心就是把窗口创建、图形绘制、事件处理这些底层能力封装成 Python 接口你依然在写纯粹的 Python 代码只是在调用这些接口。这种模式对一个刚学完基础语法的人来说几乎没有额外负担。还有一个点容易被忽略Pygame 的反馈是即时的。你新建一个窗口马上就有一个窗口弹出来你画一个红色方块屏幕上立刻出现一个红色方块你绑定方向键按一下蛇就转个头。这种我写的东西真的有反应的成就感比任何激励都管用。很多人学编程半途而废不是因为不懂而是因为长期看不到成果Pygame 恰恰能在半小时内让你感受到创作的快乐。1.2 贪吃蛇这个项目选得好在哪里确定了用 Pygame接下来选什么游戏练手我见过太多新人一上来就想做塞尔达或者空洞骑士结果一个月后连一个移动的角色都没搞定热情全被浇灭了。贪吃蛇是我带新人做练手项目时的首选原因特别实在规则极简单逻辑极完整扩展空间也大。贪吃蛇的规则一句话就能说清蛇按固定速度持续移动方向由键盘控制吃到食物长度增加撞墙或撞到自己游戏结束。但你别小看这几句规则里面藏着的技术点一个都不少游戏循环整个游戏的心脏每帧都要处理输入、更新状态、渲染画面事件驱动从事件队列里读取键盘按键响应用户操作坐标系统窗口是像素坐标系把蛇抽象成网格坐标列表碰撞检测蛇头与食物、蛇头与边界、蛇头与自身随机数生成食物位置不能与蛇重叠渲染与帧率控制把内存中的画面绘制到屏幕并用时钟锁帧这六个点覆盖了 2D 游戏 90% 的通用概念。你把这套东西吃透了再去写打砖块、飞机大战、Flappy Bird本质上都是换皮——同样的主循环骨架同样的碰撞逻辑不同的只是游戏规则。所以贪吃蛇不是一个玩具项目它是一把钥匙打开的是整个游戏开发的大门。2. 装好环境Pygame 安装这步别想复杂2.1 pip install pygame 的正确姿势安装 Pygame 说简单也简单一条命令的事pip install pygame但实际带新人的时候我发现pip 不是内部或外部命令这个报错劝退了几乎一半人。这个问题的本质是 Python 没有把可执行文件目录加入系统的 PATH 环境变量导致在命令行里找不到 pip 命令。有两个最省事的解决办法一个是重装 Python在安装向导第一页就把Add Python to PATH选上注意这个选项是默认不勾选的必须手动勾另一个是手动补环境变量把 Python 的安装目录和 Scripts 子目录都追加到系统 Path 里。装完以后建议顺手确认一下版本pip show pygame能看到版本号和安装路径说明装成功了。这里我特别想强调一个因环境混乱导致的问题如果你正在用虚拟环境一定要先激活虚拟环境再执行 pip install。我见过太多次pip list里有 pygame但脚本一跑就报 ModuleNotFoundError折腾半天发现装到了全局环境压根没进当前虚拟环境。记住一条铁律pip install和python your_game.py必须使用同一个 Python 解释器环境。2.2 关于 pygame 官方下载和国内镜像有人问过为什么要去 pygame 官方下载安装包直接 pip install 行不行这里面有个常见的认知误区。pip install 拉取的是 PyPI 官方仓库的包而 PyPI 上 pygame 这个包的发布者就是 pygame 官方团队所以pip install pygame本质上就是官方下载渠道。除非你用的是离线环境或者特殊的包管理方案否则不需要特地去 pygame.org 网页手动下安装包。你从官网下到的.whl文件和 pip 拉下来的是同一个东西。唯一需要留意的是网络速度。如果你的网络访问 PyPI 默认源很慢或者频繁超时可以临时切换国内镜像源比如清华源pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple我的建议是优先官方源慢了你再用镜像不要在乱七八糟的第三方博客里下载来路不明的同名文件。Python 生态里最安全的依赖安装路径永远是一条清晰的命令加上官方源尤其对新手来说千万别手动下载一堆文件然后手动放进 site-packages——那是一条通往噩梦的路。2.3 环境好不好三个检查点安装完成后不要急着写完整游戏先跑一个最小脚本验证环境是否健康import pygame pygame.init() print(pygame.version.ver)能打印出版本号说明模块加载正常。第二个检查点是 Python 版本Pygame 2.x 系列对 Python 3.7 以上的支持都很完善太旧的 Python 版本很容易碰到找不到匹配二进制包的问题。第三个检查点比较冷门但很关键如果你在服务器、容器或者没有显示设备的远程机器里跑pygame 初始化会直接报错因为它需要访问显示系统。这种情况要么放弃图形界面要么使用虚拟显示驱动不过对正常在自己电脑上开发的朋友来说这一步基本不会碰到。3. 写代码之前先把贪吃蛇的程序结构想明白好多新手拿到一个项目就急着敲代码敲到一半发现逻辑理不清再回头推翻重来。我以前也这样。后来我养成了一个习惯动手之前先花十几分钟把数据结构想清楚把代码的组织方式想明白。这个习惯放到游戏开发里尤其重要因为游戏逻辑天然带着循环这个特征一旦思路不清写出来的代码就是一团乱麻。3.1 游戏循环是整个引擎的心脏先说一个最核心的概念游戏和普通程序的运行逻辑有什么不同普通程序是运行、处理、退出整个流程是线性的跑完就结束。但游戏不是游戏在玩家停止操作之前需要一直运行下去——人物在动、怪物在动、子弹在飞、倒计时在跳。这个一直运行的状态靠的就是游戏循环。经典的 Pygame 游戏循环长这样拉取并处理事件键盘按键、鼠标移动、窗口关闭等根据当前状态更新游戏数据移动蛇身、检测碰撞、更新分数把最新的游戏状态绘制到屏幕上控制帧率短暂休眠保证循环速度稳定用生活里的例子来类比游戏循环就像一个值班室里的保安每隔一小段时间就做一遍同样的事情看看门口有没有人按铃事件把今天的情况更新到记录本上状态更新再对着监控大屏刷新一遍画面渲染。这个循环转得快游戏就流畅转得慢游戏就明显卡顿。Pygame 里控制转速的工具是clock.tick(FPS)它保证每秒钟循环执行次数不超过设定值。3.2 把游戏对象建模成数据贪吃蛇有几个核心对象我数给你听蛇、食物、方向、分数。每个对象在代码里都是一份数据想清楚它们的数据结构整个游戏的骨架就定下来了。蛇我用一个坐标元组的列表来表示。列表的第一个元素是蛇头后面的元素依次是蛇身。每个坐标都是窗口里的一个网格坐标比如(100, 120)表示蛇的这一节身体在 x 等于 100、y 等于 120 的位置。为什么用列表因为蛇的运动规律和列表操作完美匹配。蛇每移动一步做两件事把新蛇头坐标插入列表开头如果没有吃到食物把列表最后一个元素弹出。这两步合起来的效果就是蛇的整副身板往前挪了一格。如果吃到了食物尾部不弹出蛇的长度就增加一。这个设计一步到位不需要额外维护复杂的数据结构逻辑又清晰又准确。食物就是一个坐标元组。分数一个整数。方向一个表示下一步往哪走的向量元组。整个游戏的状态数据就这么三四个变量。这是新手项目应该有的样子——数据模型足够简单让你能把注意力集中在游戏逻辑的构建上而不是被复杂的对象关系拖垮。4. 手把手实现你的第一个 Pygame 游戏这一节我们来真的。我把代码拆成几个部分每一部分讲清楚原理和细节最后给你一个完整的可以直接运行的版本。4.1 初始化窗口是怎么诞生的所有的 Pygame 程序都有一个固定的开场白import pygame import sys import random pygame.init() WINDOW_WIDTH 640 WINDOW_HEIGHT 480 CELL_SIZE 20 FPS 10 BLACK (0, 0, 0) WHITE (255, 255, 255) GREEN (0, 255, 0) RED (255, 0, 0) screen pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption(贪吃蛇) clock pygame.time.Clock()先说pygame.init()这是一个总开关把 Pygame 内部的所有子系统一次性初始化包括显示、事件、字体等模块。写别的代码之前这一句必须放在最前面。然后是pygame.display.set_mode()。这个方法会创建游戏窗口返回值是一个 Surface 对象。你就把 Surface 理解成一块画布后面所有的绘制操作——画蛇、画食物、写文字——全部在这块画布上进行。set_caption则是给窗口设置标题这个纯属锦上添花但做了显得专业。窗口尺寸我设成 640x480格子大小设成 20 像素这样窗口恰好被分成 32x24 个网格。这个数字是权衡过的格子太小蛇会显得细碎画面观感差格子太大会让游戏区域显得空旷难度也没了。等你玩熟了可以自己调 CELL_SIZE改成 10 试试蛇会变得非常细移动更平滑但碰撞更敏感——这其实就是一种最简单的游戏手感调节。4.2 让蛇动起来移动逻辑的核心蛇的初始状态是只有一个格子的身体放在窗口正中央snake [(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2)] direction (CELL_SIZE, 0)direction是一个方向向量。向右移动x 方向的增量是CELL_SIZEy 方向增量为 0所以方向是(CELL_SIZE, 0)。同理向下移动就是(0, CELL_SIZE)向左是(-CELL_SIZE, 0)向上是(0, -CELL_SIZE)。移动逻辑写在游戏循环里核心就这几行head_x, head_y snake[0] new_head (head_x direction[0], head_y direction[1]) snake.insert(0, new_head)思路是这样的先取出当前蛇头的坐标用当前方向计算出新蛇头的位置然后把新蛇头插入列表头部。这一步做完了蛇的长度其实多了一格所以接下来要决定尾部的处理if new_head food: score 1 food generate_food() else: snake.pop()如果新蛇头的位置正好是食物所在的位置说明吃到了食物分数加一重新生成食物并且蛇尾不弹出——蛇的长度真实地增加了一节。如果没有吃到食物就把蛇尾弹出整体长度保持不变。整个移动逻辑只有四五行但背后的思路非常值得品味用头部插入新坐标 尾部弹出旧坐标来模拟蛇的连续运动而不是每一帧重新计算所有身体段的位置。这个网格化的移动策略是这类游戏的通用解法。以后你去做俄罗斯方块、迷宫寻路都是同样的道理。4.3 食物生成随机坐标不能随便用生成食物最简单的写法是这样def generate_food(): x random.randrange(0, WINDOW_WIDTH, CELL_SIZE) y random.randrange(0, WINDOW_HEIGHT, CELL_SIZE) return (x, y)用random.randrange从窗口范围里随机选一个落在网格上的坐标注意第三个参数是步长保证坐标一定是 20 的整数倍与蛇的网格完美对齐。但是这个写法有一个明显的 bug生成的坐标可能落在蛇身上。如果食物刷在蛇的身体中间那条蛇无论怎么绕都吃不到它画面就像出现了一个灵异食物这个 bug 我第一次做的时候真的踩到了排查了半天才发现。正确做法是在生成之后检查坐标是否和蛇重叠如果重叠就重新生成def generate_food(): while True: x random.randrange(0, WINDOW_WIDTH, CELL_SIZE) y random.randrange(0, WINDOW_HEIGHT, CELL_SIZE) if (x, y) not in snake: return (x, y)这个 while 循环在极端情况下会重试很多次但实际游戏中蛇占据的格子比例很小绝大多数时候第一次生成的坐标就能用。只有当蛇长到几乎占满屏幕时这个函数才会变慢。想优化的话可以维护一个空闲坐标列表随机选一个但对练手项目来说完全没必要简洁优先。4.4 绘制画面渲染没你想象的那么玄游戏最直观的部分就是把画面画出来。Pygame 的绘制逻辑其实非常朴实先清空画布然后把每个元素画上去最后把整块画布一次性显示出来。screen.fill(BLACK) for segment in snake: rect pygame.Rect(segment[0], segment[1], CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, GREEN, rect) pygame.draw.rect(screen, BLACK, rect, 1) pygame.draw.rect(screen, RED, pygame.Rect(food[0], food[1], CELL_SIZE, CELL_SIZE)) font pygame.font.Font(None, 36) score_text font.render(fScore: {score}, True, WHITE) screen.blit(score_text, (10, 10)) pygame.display.flip() clock.tick(FPS)screen.fill(BLACK)是清屏把上一帧绘制的内容全部涂成背景色相当于黑板擦。如果不做这一步上一帧的蛇身会残留在画布上画面会越来越花。所以每一帧都要先清空再重画。绘制蛇身用的是pygame.Rect。pygame.Rect接收四个参数左上角 x、左上角 y、宽度、高度。我们的坐标(segment[0], segment[1])正好就是矩形左上角的位置宽度和高度都是 CELL_SIZE。然后pygame.draw.rect完成矩形填充先把整个矩形涂成绿色再用一个黑色边框描一遍边这样每个小格子之间界限分明视觉效果更好。食物就是一个红色矩形没有描边颜色上形成鲜明对比。分数则用pygame.font.Font(None, 36)创建一个默认字体渲染的文字表面再用blit方法把它贴到屏幕的左上角。这里有个容易被忽略但很关键的概念pygame.display.flip()。前面所有的绘制都是在一块隐形的内存画布上进行的玩家的屏幕上什么都看不到。flip()把这块内存画布整体提交到显示器上瞬间替换。这种先画好再整体翻页的做法叫做双缓冲它的作用是避免画面闪烁和撕裂。Pygame 的flip()把这个底层机制完全封装了你只需要知道画完之后必须调用它画面才会真的更新。4.5 游戏结束撞墙和撞自己的判定逻辑游戏要认真玩起来必须有结束条件。贪吃蛇的结束条件有两个撞到墙壁边界或者咬到自己的身子。撞墙判定是检查新蛇头的坐标有没有越出窗口区域if (new_head[0] 0 or new_head[0] WINDOW_WIDTH or new_head[1] 0 or new_head[1] WINDOW_HEIGHT): break撞自己判定是检查新蛇头坐标有没有出现在蛇身列表里if new_head in snake: break这里有一个顺序讲究先判断撞墙再判断撞自己。其实它们的顺序对最终结果没有影响但分开写逻辑更清晰。注意撞自己判定要在把新蛇头插入列表之前进行否则新蛇头已经成了snake列表的第一个元素任何判断都会得到撞到自己的结果游戏就永远无法继续了。游戏结束后用break跳出循环然后执行pygame.quit()释放所有资源。不过如果想让游戏在结束时显示 Game Over 提示或者支持按 R 键重新开始你需要用一个game_over状态变量来代替直接break。这个小的结构调整是后面扩展功能的基础。5. 完整代码一版可以直接运行的贪吃蛇把前面 4.1 到 4.5 的代码片段组装起来就是一个完整的贪吃蛇游戏。我测试这个版本时用的就是下面的代码跑了几个来回10 FPS 的节奏对新手来说很舒服方向响应灵敏碰撞判定准确。完整代码如下import pygame import sys import random pygame.init() WINDOW_WIDTH 640 WINDOW_HEIGHT 480 CELL_SIZE 20 FPS 10 BLACK (0, 0, 0) WHITE (255, 255, 255) GREEN (0, 255, 0) RED (255, 0, 0) screen pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption(贪吃蛇) clock pygame.time.Clock() snake [(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2)] direction (CELL_SIZE, 0) def generate_food(): while True: x random.randrange(0, WINDOW_WIDTH, CELL_SIZE) y random.randrange(0, WINDOW_HEIGHT, CELL_SIZE) if (x, y) not in snake: return (x, y) food generate_food() score 0 running True while running: 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 direction ! (0, CELL_SIZE): direction (0, -CELL_SIZE) elif event.key pygame.K_DOWN and direction ! (0, -CELL_SIZE): direction (0, CELL_SIZE) elif event.key pygame.K_LEFT and direction ! (CELL_SIZE, 0): direction (-CELL_SIZE, 0) elif event.key pygame.K_RIGHT and direction ! (-CELL_SIZE, 0): direction (CELL_SIZE, 0) head_x, head_y snake[0] new_head (head_x direction[0], head_y direction[1]) if (new_head[0] 0 or new_head[0] WINDOW_WIDTH or new_head[1] 0 or new_head[1] WINDOW_HEIGHT): break if new_head in snake: break snake.insert(0, new_head) if new_head food: score 1 food generate_food() else: snake.pop() screen.fill(BLACK) for segment in snake: rect pygame.Rect(segment[0], segment[1], CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, GREEN, rect) pygame.draw.rect(screen, BLACK, rect, 1) pygame.draw.rect(screen, RED, pygame.Rect(food[0], food[1], CELL_SIZE, CELL_SIZE)) font pygame.font.Font(None, 36) score_text font.render(fScore: {score}, True, WHITE) screen.blit(score_text, (10, 10)) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit()说几个实测时的细节。第一10 FPS 的画面上看蛇是一格一格跳着走的这是网格游戏的正常表现不是卡顿如果想让运动更平滑就得改用浮点坐标但碰撞检测会复杂不少第一个项目不建议增加这个复杂度。第二因为循环里先插入了新蛇头再判断是否吃到食物所以蛇头碰到食物的那一帧蛇身其实已经长了一格视觉效果连贯自然没有延迟感。第三窗口关闭时pygame.QUIT事件会触发我把running置为 False循环正常退出程序不会崩溃这是你未来做任何 Pygame 游戏都要保留的收尾逻辑。6. 常见问题与排查技巧实录写这段的时候我把带新人踩过的坑都翻出来了。这些问题在官方文档里讲得少但实际遇到的问题里十个有八个是它们。6.1 安装层面的高频问题报错或现象原因解决办法pip 不是内部或外部命令Python 未加入 PATH 环境变量重装 Python 并勾选 Add to PATH或手动配置环境变量ModuleNotFoundError: No module named pygame安装到了其他 Python 环境检查虚拟环境是否激活确认 pip 与 python 属于同一解释器安装极慢或超时默认源网络不佳加-i https://pypi.tuna.tsinghua.edu.cn/simple换镜像找不到匹配的发行版本Python 版本过旧升级到 Python 3.7 及以上版本6.2 运行阶段容易踩的坑第一个高频坑是窗口一闪而过。很多人写完代码双击运行黑框晃一下就没了以为是代码写错了。最常见的两个原因是游戏循环条件一开始就不成立或者循环之前就抛了异常。建议在代码里加一层try/except打印完整堆栈先看看错误信息再改代码效率会高很多。第二个坑是蛇能穿身体却不判定失败。原因我在 4.5 节说过如果你先把new_head插入snake列表再做碰撞判断它永远等于列表第一个元素永远都会判定为撞到自己逻辑就乱了。正确顺序是先判定后插入。这个顺序问题非常典型几乎所有写网格游游戏的初学者都会栽一次。第三个坑是方向键导致蛇身反向。蛇正在向右走你按左键理论上蛇头会直接撞到自己游戏结束。但这样体验很差玩家手一滑就死了。经典解法是按键处理时禁止 180 度掉头也就是当前方向是向右时禁止将方向改成向左。代码里已经在每个方向按键判断中加了这个逻辑你照着写就行。第四个坑比较隐性蛇身从 1 格长到几十格之后new_head in snake这个判断会遍历整个列表。在贪吃蛇这个规模下列表长度最多几百性能完全不是问题。但如果你以后做别的游戏动辄几千个对象做碰撞检测就需要引入更高效的数据结构或者空间划分算法了。你现在只要知道有这个方向就够了。6.3 代码组织层面的一个建议很多人写着写着整个游戏逻辑全塞在 while 循环里过两天自己都看不懂了。我的建议是趁这个项目还小赶紧把它重构成函数版——handle_input处理按键、update更新状态、draw渲染画面、main组织主循环。这四个部分是几乎所有游戏代码的共同骨架。你把我给的完整代码重构成这个结构等于完成了一次特别有价值的代码组织练习。别小看这一步很多人从能写到会写靠的就是这种主动的重构训练。7. 试着扩展你的游戏从练手到作品到这一步你的第一个 Pygame 游戏已经从零到一跑通了。但我还是想认真建议你别停在这里继续往下做。扩展的方向我按难度排个序最容易的是加音效。Pygame 自带的pygame.mixer模块支持播放 wav 和 ogg 格式的音效。吃食物时放一个清脆的叮声游戏结束来一段低沉音效你的游戏立刻就有了质感。音频素材可以去免费音效网站下载也可以用系统自带的声音工具自己合成门槛很低。再就是给食物加保质期。让食物生成后开始倒计时倒计时结束自动换位置。这个改动让游戏节奏明显变快玩家的操作策略也会随之改变体验完全不一样。往上走一步增加墙体障碍。在网格里预设几组固定障碍物蛇碰到即死。这个功能会让游戏难度阶梯式上升而且非常锻炼你处理多个碰撞目标的能力因为障碍物和食物、蛇身要统一纳入坐标体系来管理。最值得做的是开始界面和结束界面。这一步会把你引到游戏开发绕不开的概念——状态机。你可以用一个变量game_state来表示当前处在哪个阶段比如MENU、PLAYING、GAME_OVER每个阶段进入不同的逻辑分支菜单阶段等待按键、游戏阶段运行主循环、结束阶段显示分数并提供按 R 重新开始。这个改动涉及的技术面最广做完了你对游戏整体架构的理解会完全不一样。我个人强烈建议你优先做开始/结束界面 重新开始因为它是最综合的练习。做这一个扩展你就把状态管理、事件分发、渲染切换都过了一遍做完之后你再回头看我给的完整代码会发现自己已经能挑出不少可以优化和重构的地方了。最后分享一个我做这个小项目的体会。这个游戏虽然简单但它把一个看似神秘的事情变得透明了——游戏不过是循环 状态 绘制的反复运转。你在 Pygame 里学到的这些底层逻辑以后玩 Unity、Unreal 依然成立只是被封装得更深而已。亲手从零做出一个能玩的游戏你会真正理解那句老话复杂的东西拆到底也都是简单零件的组合。