资讯动态

Python+Pygame极简贪吃蛇:200行代码实现完整游戏设计

发布时间:2026/10/14 19:58:41 来源:尧图企业网站定制
直接用 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 变量。修复方案很简单处理方向键前判断当前 stateif state RUNNING and not game_over: # 处理方向键暂停的意义就应该是“冻结世界状态”不仅仅是冻结移动还包括冻结输入。4.5 调试技巧让 bug 可复现的临时手段调试这种图形实时游戏有一个很实用的技巧临时把时钟速率降到 2FPS这样每一步移动都慢到可以用肉眼观察方向变化。我调试快速按键 bug 时就是这样做的先降到 2FPS手动按出“上 左”的快速组合逐步观察方向变化是否符合预期。另一个技巧是在控制台打印状态信息比如每 10 帧输出一次蛇头坐标、当前方向、当前帧率。图形界面上不明显的逻辑错误放在终端里看往往一目了然。调试完删除打印即可。5. 进一步压缩代码的思路与经验总结做到这一步核心游戏已经完整总代码量约 210 行。很多人会问能不能再压到 100 行甚至更少技术上当然可以例如用字典取代重复的按键判断、把绘制代码合并成列表推导式。但我个人的体会是代码量压缩到合理范围即可不要一味追求“看起来厉害”。极简代码的正确目标是让每一行都在做不可省略的事情。如果为了缩短代码而使用过于晦涩的技巧比如把逻辑塞进 lambda、用位运算代替普通整数运算项目的可读性和可维护性就会断崖式下降。说白了这个项目的目的是学习游戏本身只是载体。最后分享一个实际工作中的小技巧如果想让贪吃蛇项目变成自己的作品集项目可以在此基础上加一个“速度百分比显示”或者做一个简单的配色切换按 C 键更换皮肤主题。这些扩展点在现有代码框架中都能用几行代码实现既不会破坏极简结构又能让项目在功能展示和视觉呈现上都更有特点。贪吃蛇这个题目市场上做烂了但用极简方式重新实现一遍是真的能检验你对数据结构和状态管理有没有吃透。我建议你拿到代码后不改结果、只改过程比如尝试改用链表存蛇身、或者把速度递进改成按时间而非按分数。只有亲自改过几个关键决策点你才算真正掌握它。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑