直接用 Python 写一个贪吃蛇,听起来像是入门教程里才会出现的题,但真要把代码压到两百行以内、功能却一个不少,难度反而比想象中大得多。我最近重新做了一版,用 Pygame 实现,支持分数累计、速度递进、暂停、重新开始和最高分持久化,整体代码控制在两百行出头。这篇文章会把完整设计思路、关键实现和实际调试中踩过的坑一次说透,适合有一点 Python 基础、想独立完成一个完整小项目的读者参考。
1. 项目概述与设计思路
1.1 贪吃蛇项目的核心价值
贪吃蛇这个游戏,规则简单到可以用一句话讲完:控制一条蛇移动,吃到食物就变长,撞到墙壁或自己就结束。但恰恰是这种简单,让它成为检验编程基本功的绝佳项目。
我在给新人同学做技术辅导的时候,经常推荐这个题目。原因有三:第一,它包含完整的状态系统(运行、暂停、结束),能锻炼状态机设计能力;第二,它需要对数据结构做选型,蛇身体怎么存、碰撞检测怎么做,直接决定了代码量;第三,它有很多可扩展点(计分、加速、音效、界面优化),不同水平的人都能找到发挥空间。
这次做“极简”版本,我给自己定了一个硬指标:核心逻辑不超过两百行代码,但功能必须包含计分、速度递进、暂停、重开、最高分保存。为什么要在意这个指标?因为代码量本身是一种约束,它会逼着你寻找更优的抽象方式,避免“实现功能”变成“堆代码”。
1.2 技术选型:为什么选 Python 和 Pygame
常用来写贪吃蛇的方案大概有三种:Web 页面版(JavaScript + Canvas)、终端版(Python + curses)、桌面游戏版(Python + Pygame)。
我最终选了 Pygame,原因很直接:它的坐标系统、事件循环、图像渲染模型和贪吃蛇的移动逻辑天然契合。不需要写任何 DOM 操作,不需要处理浏览器兼容性,也不需要理解终端光标控制那一套。
Pygame 里一个游戏本质上就是一个死循环:每帧处理用户输入、更新游戏状态、重绘画面。这和贪吃蛇的“每步移动一次”在逻辑上是同构的,用它可以做到极少的“翻译”成本。
对比来看,JavaScript 版要额外处理 canvas 上下文、requestAnimationFrame 回调;curses 版虽然代码也能很少,但对非终端环境支持差,而且画面体验一般。对绝大多数想学习游戏逻辑的人来说,Pygame 是性价比最高的选择。
1.3 功能清单与极简目标
我先梳理了最终版本的完整功能清单:
- 基本玩法:方向键控制蛇的移动,吃到食物增长身体,撞墙或撞自己游戏结束
- 计分系统:每吃一个食物加 1 分,实时面板显示
- 难度递进:每吃 3 个食物速度提升一档,有上限封顶
- 暂停功能:空格键暂停 / 继续
- 重新开始:游戏结束后按 R 键立即重启
- 最高分记录:本地文件存储,跨会话保留
这个功能列表看起来不算多,但每一项都需要在代码结构上留出合理的位置。例如最高分持久化,就需要考虑文件读写的时机和异常处理;暂停功能则需要游戏循环正确处理事件状态。
核心设计原则是“能用三种结构表达清楚的问题,绝不用五种”。我后面几节会逐一拆解每个功能的具体实现,讲清楚为什么这样写能省代码而不牺牲可读性。
2. 核心代码架构与关键实现
2.1 数据结构:用列表加集合把移动逻辑压到两行
贪吃蛇最核心的数据结构是蛇身体。常规做法有用双向链表、自定义 Segment 类甚至二维数组标记的,但极简方案里,一个列表就足够。
蛇身体就存成一个坐标元组的列表,列表头部代表蛇头,尾部代表蛇尾。移动逻辑的本质是:在蛇头位置加上方向向量得到新蛇头,把新蛇头插入列表头部,如果这一步没吃到食物,就把尾部移除。
snake = [(10, 10), (9, 10), (8, 10)] direction = (1, 0) def move(snake, direction, grow): head = (snake[0][0] + direction[0], snake[0][1] + direction[1]) snake.insert(0, head) if not grow: snake.pop() return head就这么简单。插入头部和弹出尾部,组合起来就是完整的移动行为。问题是碰撞检测需要频繁判断“某个坐标是否在蛇身上”,如果每次都遍历这个列表,性能虽然在这个规模下无所谓,但代码写起来不够优雅。
我的做法是把蛇身坐标同时维护成一个集合,专门用于 O(1) 的成员判断。每次移动后更新集合就行,几行代码换来回合制游戏中碰撞检测的清晰表达。
snake_set = set(snake)2.2 游戏循环与状态管理
Pygame 的极简游戏循环框架本身不长:处理事件、更新逻辑、绘制画面、控制帧率。但要支撑暂停和结束状态,就需要引入一个简单的状态标识。
我用一个字符串变量表示当前状态,只有两种:RUNNING 和 PAUSED,再用 game_over 布尔值区分结束状态。为什么不用整数枚举?因为字符串在判断时语义更清楚,代码量也更少,这个项目规模不追求比特级效率。
state = "RUNNING" # 或 "PAUSED" game_over = False主循环结构长这样:
while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_SPACE: state = "PAUSED" if state == "RUNNING" else "RUNNING" if event.key == pygame.K_r and game_over: reset_game()状态切换的核心逻辑放在事件处理里,主循环只负责根据 state 决定是否执行更新函数,这样就不会在暂停时蛇还在移动。
2.3 食物生成与随机位置的边界处理
食物生成最差的写法是“死循环里无限随机试”,直到找到一个不在蛇身上的点。但在蛇几乎铺满全屏时,这个方法的效率会低到让人怀疑人生。
更稳妥的做法是维护一个空白格列表:在游戏初始化时或每次生成食物时,用集合差集求出可用格子,再用 random.choice 选取。
def spawn_food(): free_cells = [ (x, y) for x in range(cols) for y in range(rows) if (x, y) not in snake_set ] return random.choice(free_cells)这个写法在蛇身占满 90% 以上区域时依然表现稳定,而且代码读起来一目了然。表格驱动的思路在这里体现得很明显:先构造候选集,再做一次随机选择,取代盲目试错。
2.4 碰撞检测与边界条件的极简表达
碰撞检测要判断两种情况:头撞到墙壁、头撞到自己的身体。两种判断可以合并写:
def check_collision(head): if head in snake_set: return True x, y = head if x < 0 or x >= cols or y < 0 or y >= rows: return True return False这里有一个特别容易踩的坑:正常移动时,蛇头移动到当前位置时,尾部还没弹出,snake_set 里还包含蛇尾的坐标。如果蛇没有吃到食物,新蛇头永远不会和蛇尾重叠,这个坑只在特殊长度下出现。更典型的坑是“蛇头吃到食物后增长一节,下一帧又回头撞向身体”,这个在方向控制那节详细讲。
3. 实操过程:从基础框架到功能完善
3.1 环境准备与项目初始化
我用的环境是 Python 3.10 搭配 Pygame 2.5 版本。安装命令很简单:
pip install pygame项目目录只有一个文件snake.py,加上一个运行后自动生成的best_score.txt用于保存最高分。这种单文件结构配合极简目标是最匹配的,不需要为了“规范化”强行拆包。
初始化部分有一个细节值得注意:Pygame 的坐标系统左上角是 (0,0),向右是 x 轴正方向,向下是 y 轴正方向。我规定游戏区域是 40 列 30 行的格子,每个格子 20 像素,这样屏幕分辨率就是 800x600。
CELL_SIZE = 20 COLS, ROWS = 40, 30 screen = pygame.display.set_mode((COLS * CELL_SIZE, ROWS * CELL_SIZE))3.2 搭建最小可运行的游戏框架
我先写一个只包含基本移动和食物生成的“最小版本”,确认逻辑跑通后再扩展功能。这里分享核心循环的关键代码:
while True: clock.tick(FPS) # FPS 初始为 10 for event in pygame.event.get(): # 这里处理按键和退出事件 if state == "RUNNING" and not game_over: head = move(snake, direction, grow) grow = False if head == food: grow = True score += 1 food = spawn_food() # 速度递进逻辑 if check_collision(head): game_over = True draw(screen, snake, food, score) pygame.display.flip()这个最小模型的精妙之处在于grow布尔变量。移动函数只在吃到食物时才保留尾部,这比“先移动再判断新蛇头是否等于食物,如果等于就再补一节”的做法更直观,也避免了一次重复计算。
3.3 速度递进与暂停功能的实现
速度递进我用一个简单公式:初始帧率 10FPS,每吃一个食物加速度 0.5FPS,最大 20FPS。换算成实际移动速度,蛇从每秒 10 格逐渐加速到每秒 20 格,这是人类操控可以接受的梯度。
FPS = min(10 + score * 0.5, 20) clock.tick(FPS)这里要提醒一句:pygame 的clock.tick()函数不是精确到毫秒的定时器,它控制的是每帧之间的最短间隔。如果游戏逻辑里某帧处理耗时较长,实际帧率会低于设定值,但不会导致逻辑错乱。
暂停功能之所以单独拿出来讲,是因为很多人会在暂停后忘记处理“按键松开的瞬间”。我的实现是用 KEYDOWN 事件而非pygame.key.get_pressed(),原因是 get_pressed 在按住空格时每帧都会返回 True,会导致状态反复切换,而事件只在按下瞬间触发一次,逻辑上更干净。
3.4 最高分持久化与文件读写陷阱
最高分保存,极简做法是用一个文本文件。游戏结束检查当前分数是否高于已保存的值,如果是就写回去。
def save_best(score): with open("best_score.txt", "w") as f: f.write(str(score))读取时要注意文件可能不存在,所以必须做默认值处理:
def load_best(): try: with open("best_score.txt") as f: return int(f.read()) except FileNotFoundError: return 0这个简单的 try-except 结构比os.path.exists更简洁,而且能顺带处理文件内容损坏导致 int() 抛异常的情况。用异常处理做“默认值回退”,是文件持久化场景下值得养成的习惯。
3.5 重新开始流程:状态重置的完整清单
按 R 键重新开始,最大的坑是“重置不彻底”。蛇剩一段、分数没归零、方向还是旧方向,这些状态漏一个,第二轮游戏就会出幺蛾子。
我维护了一个 reset_game 函数,把所有可变状态集中在里面初始化:
def reset_game(): global snake, snake_set, direction, food, score, game_over, state snake = [(10, 10), (9, 10), (8, 10)] snake_set = set(snake) direction = (1, 0) food = spawn_food() score = 0 game_over = False state = "RUNNING"全局变量在主循环里引用确实不优雅,但对于单文件极简项目,用 global 反而是最直观的写法。真要改成类封装当然更规范,但代码量至少会多出几十行,不符合这次项目的约束目标。
4. 常见问题与调试实录
4.1 蛇头“吃”掉自己:反向移动导致的瞬间死亡
这是贪吃蛇最经典的问题。蛇正在向右移动时,玩家按了左方向键,按照“新方向覆盖旧方向”的朴素逻辑,蛇头会立刻转向左边,也就是直接撞到自己的脖子,导致游戏结束。
很多新手会以为这是碰撞检测太严格,其实是方向更新逻辑缺少合法性校验。极简修复方法是在处理按键时检查新方向和当前方向是否为相反向量:
new_dir = {pygame.K_UP: (0,-1), pygame.K_DOWN: (0,1), pygame.K_LEFT: (-1,0), pygame.K_RIGHT: (1,0)}[event.key] if (new_dir[0] + direction[0], new_dir[1] + direction[1]) != (0, 0): direction = new_dir两个相反的方向向量相加必然等于 (0,0),这个判断既简短又不会误伤转向操作。我第一次写这个项目时漏了这行代码,演示时连续两次在吃掉食物后瞬间死亡,当时脸都绿了。
4.2 快速连续按键导致的多步转向
方向校验修复了反向问题,但连续快速按键会出现另一个现象:一帧时间内两次方向键被处理,第二次按键的转向是基于第一次按键后的新方向,而不是当前实际运动方向。
举例来说,蛇向右移动,玩家快速按了“上”和“左”,理想结果是在本帧向右移动一格的下一帧向上移动,但实际上两步转向会让蛇直接向左。放入队列或者仅在一帧内处理最后一次按键,是最常见的两种解决思路。
极简项目里我选择了“只处理本帧最后一个方向事件”,实现方式是先统一收齐事件再应用方向:
for event in pygame.event.get(): if event.type == pygame.KEYDOWN: pending_dir = key_to_dir(event.key) if pending_dir and valid(pending_dir, direction): direction = pending_dir这个写法的代价是损失一点点操控精度,但在 10-20FPS 的移动速度下,普通玩家几乎感知不到差异,代码却简单了一个量级。
4.3 食物出现在蛇身上或死角
食物生成用差集法之后,基本不会出现在蛇身上。但还有一个隐蔽问题:当蛇身占据了几乎所有单元格时,差集得到的空白格可能只有一两个,而食物恰好生成在蛇头下一步必经的位置,导致玩家还没反应就撞上——这不是 bug,这是游戏机制。
真正需要修的是另一个边界:当空白格数量为零时(理论上蛇身填满整个网格),random.choice会从空列表抛异常。虽然实际游戏中几乎到不了这一步,但防御性处理仍然值得写:
if not free_cells: # 游戏胜利,等价于填满网格 game_over = True4.4 暂停后遗留按键导致角色漂移
还有一个让人摸不着头脑的现象:游戏暂停状态下玩家按住方向键,恢复后蛇会立刻按那个方向移动,好像按键被“预执行”了一样。
问题出在事件处理逻辑上。暂停状态下,如果事件循环里仍然无条件处理方向键事件,它就会改变全局 direction 变量。修复方案很简单:处理方向键前判断当前 state:
if state == "RUNNING" and not game_over: # 处理方向键暂停的意义就应该是“冻结世界状态”,不仅仅是冻结移动,还包括冻结输入。
4.5 调试技巧:让 bug 可复现的临时手段
调试这种图形实时游戏有一个很实用的技巧:临时把时钟速率降到 2FPS,这样每一步移动都慢到可以用肉眼观察方向变化。我调试快速按键 bug 时就是这样做的,先降到 2FPS,手动按出“上 + 左”的快速组合,逐步观察方向变化是否符合预期。
另一个技巧是在控制台打印状态信息,比如每 10 帧输出一次蛇头坐标、当前方向、当前帧率。图形界面上不明显的逻辑错误,放在终端里看往往一目了然。调试完删除打印即可。
5. 进一步压缩代码的思路与经验总结
做到这一步,核心游戏已经完整,总代码量约 210 行。很多人会问能不能再压到 100 行甚至更少?技术上当然可以,例如用字典取代重复的按键判断、把绘制代码合并成列表推导式。但我个人的体会是:代码量压缩到合理范围即可,不要一味追求“看起来厉害”。
极简代码的正确目标,是让每一行都在做不可省略的事情。如果为了缩短代码而使用过于晦涩的技巧,比如把逻辑塞进 lambda、用位运算代替普通整数运算,项目的可读性和可维护性就会断崖式下降。说白了,这个项目的目的是学习,游戏本身只是载体。
最后分享一个实际工作中的小技巧:如果想让贪吃蛇项目变成自己的作品集项目,可以在此基础上加一个“速度百分比显示”,或者做一个简单的配色切换(按 C 键更换皮肤主题)。这些扩展点在现有代码框架中都能用几行代码实现,既不会破坏极简结构,又能让项目在功能展示和视觉呈现上都更有特点。
贪吃蛇这个题目市场上做烂了,但用极简方式重新实现一遍,是真的能检验你对数据结构和状态管理有没有吃透。我建议你拿到代码后不改结果、只改过程:比如尝试改用链表存蛇身、或者把速度递进改成按时间而非按分数。只有亲自改过几个关键决策点,你才算真正掌握它。