Python+Pygame实战:250行代码实现贪吃蛇游戏

发布时间:2026/10/14 18:45:12
Python+Pygame实战:250行代码实现贪吃蛇游戏 1. 为什么250行代码是个值得挑战的目标1.1 贪吃蛇这个项目到底在练什么很多人看到“贪吃蛇”三个字的第一反应是这不是编程入门第一课吗有什么好讲的。但如果你真的动手从零写过一遍就会发现这个看似简单的项目实际上把游戏开发中最核心的几个问题全部串起来了——游戏循环、状态管理、碰撞检测、输入响应、渲染刷新一个都不少。250行代码这个数字很有意思。它刚好卡在一个微妙的区间用50行可以写出来但代码会挤成一团逻辑耦合严重改一个地方崩三个地方用500行也能写但会引入大量不必要的抽象对于一个小项目来说属于过度设计。250行左右恰好是结构清晰、职责分明、又不过度工程化的甜点区。我自己的经验是用Python写贪吃蛇核心逻辑大概在180到220行之间加上必要的注释和空行250行是一个很舒服的体量。这个体量意味着你可以在一个下午写完但写完之后回头看每一行你都能说清楚它为什么在那里。1.2 技术选型为什么是Python加PygamePython做游戏开发绕不开Pygame这个库。有人会问为什么不用Pygame Zero或者Arcade我的判断标准很简单你要练的是什么。Pygame Zero确实更简单它帮你把游戏循环、事件处理都封装好了你只需要写更新逻辑和绘制逻辑。但问题是它把太多东西藏起来了你写完一个游戏对游戏循环怎么运转的、事件是怎么分发的、帧率是怎么控制的依然是模糊的。Arcade则更现代基于OpenGL性能更好但对于贪吃蛇这种2D网格游戏来说属于杀鸡用牛刀。Pygame的好处在于它足够底层你需要自己控制游戏循环、自己处理事件队列、自己管理帧率这些恰恰是理解游戏开发最关键的环节。同时它又足够简单API直观不需要你懂图形学。对于250行这个体量的项目来说Pygame是恰到好处的抽象层级。安装也很直接pip install pygame就这一条命令没有额外的依赖没有复杂的配置。这也是我推荐用Pygame练手的原因之一——环境搭建的成本几乎为零你可以把全部精力放在逻辑设计上。1.3 最终成品长什么样在动手之前先明确目标。这个贪吃蛇最终会具备以下功能蛇在网格上移动吃到食物后身体增长一格撞墙或撞到自己身体则游戏结束支持方向键控制且不能180度掉头分数实时显示在窗口标题栏游戏结束后可以按R键重新开始没有花哨的动画没有音效没有关卡设计。就是最纯粹的贪吃蛇。但每一个功能背后的实现细节我都会讲清楚为什么这么做以及不这么做会踩什么坑。2. 核心架构拆解250行怎么分配2.1 整体代码结构规划在写第一行代码之前我习惯先把结构想清楚。250行不是随便堆的每一块都有它的位置。我的分配方案是这样的模块预估行数职责导入与常量定义25行窗口尺寸、颜色、方向、帧率游戏状态初始化30行蛇的初始位置、食物生成、分数归零事件处理35行键盘输入、退出、重开游戏逻辑更新60行移动、吃食物、碰撞检测渲染绘制50行网格、蛇、食物、分数主循环与入口30行游戏循环、帧率控制、状态切换注释与空行20行必要的说明和呼吸感这个分配不是拍脑袋来的。事件处理和渲染绘制之所以行数接近是因为它们都是“翻译层”——把外部输入翻译成内部状态把内部状态翻译成视觉输出。而游戏逻辑更新是核心行数最多因为它包含了所有判断和状态变更。注意不要一开始就追求行数精确。先写出来再优化。250行是结果不是目标。如果你为了凑行数或者压行数而牺牲可读性那就本末倒置了。2.2 网格系统为什么不用像素级移动贪吃蛇的实现方式有两种像素级连续移动和网格级离散移动。前者蛇的位置是浮点数移动是平滑的后者蛇的位置是整数格坐标移动是一格一格跳的。我选择网格系统原因有三个第一碰撞检测简单。网格系统下判断蛇头是否撞墙只需要比较坐标是否越界判断是否撞到自己只需要检查蛇头坐标是否在蛇身坐标列表中。如果用像素级你需要做矩形碰撞检测复杂度上升一个量级。第二食物生成简单。网格系统下食物就是随机一个不在蛇身上的格子坐标。像素级的话你需要确保食物不和蛇身重叠还要处理食物被蛇身部分覆盖的情况。第三视觉风格统一。经典贪吃蛇就是格子风格的蛇是一格一格的方块食物是一个小方块。这种风格用网格系统实现最自然。具体来说我设定窗口为600x600像素每个格子30x30像素那么网格就是20x20。这个尺寸在大多数屏幕上显示都合适不会太小看不清也不会太大占满屏幕。CELL_SIZE 30 GRID_WIDTH 20 GRID_HEIGHT 20 WINDOW_WIDTH CELL_SIZE * GRID_WIDTH WINDOW_HEIGHT CELL_SIZE * GRID_HEIGHT2.3 蛇的数据结构选择蛇用什么数据结构存这是个小问题但选错了后面会难受。用列表list是最自然的选择。列表的头部是蛇头尾部是蛇尾。每次移动时在头部插入新坐标如果没吃到食物就弹出尾部。这样蛇的移动就变成了两个操作insert(0, new_head)和pop()。snake [(10, 10), (9, 10), (8, 10)] # 蛇头在第一个为什么不用队列deque因为我们需要频繁访问蛇头索引0和蛇尾索引-1列表的随机访问是O(1)而deque虽然两端操作是O(1)但中间访问是O(n)。对于贪吃蛇这个体量列表完全够用而且更直观。为什么不用集合set因为蛇身是有顺序的集合会丢失顺序信息渲染的时候你就不知道哪节是哪节了。实操心得蛇的坐标用元组tuple而不是列表list因为元组是不可变的可以作为字典的键也避免了意外修改。这个习惯在后续做碰撞检测时会有帮助。2.4 游戏循环的设计游戏循环是整个程序的心脏。Pygame的游戏循环基本结构是这样的while running: handle_events() update_game() render() clock.tick(FPS)这个顺序很重要。先处理事件因为玩家的输入需要尽快响应然后更新游戏状态因为渲染需要基于最新的状态最后渲染把当前帧画出来最后控制帧率避免CPU空转。帧率我设定为10 FPS。为什么这么低因为贪吃蛇是网格移动如果帧率太高蛇会移动得太快玩家来不及反应。10 FPS意味着蛇每秒移动10格在20x20的网格上从一头走到另一头需要2秒这个速度对大多数人来说是合适的。如果你觉得太慢或太快可以调整这个值。但注意帧率不要超过15否则蛇会快到看不清也不要低于5否则操作会有明显的延迟感。3. 逐块实现与关键细节3.1 初始化蛇和食物的起点初始化看起来简单但有几个细节容易忽略。蛇的初始位置我放在网格中间偏左方向朝右。为什么不在正中间因为如果蛇在正中间食物生成在蛇右边的概率很高蛇一出生就能吃到食物缺少缓冲。放在偏左的位置蛇有足够的空间向右移动。snake [(5, 10), (4, 10), (3, 10)] direction (1, 0) # 向右食物生成需要保证不在蛇身上。最简单的做法是随机生成一个坐标如果在蛇身上就重新生成。在20x20的网格上蛇初始只有3节冲突概率很低循环几次就能找到合法位置。def generate_food(): while True: pos (random.randint(0, GRID_WIDTH-1), random.randint(0, GRID_HEIGHT-1)) if pos not in snake: return pos这里有个潜在问题如果蛇长到占满整个网格这个循环会死循环。虽然在实际游戏中几乎不可能发生但严谨起见可以加一个最大尝试次数超过就返回None游戏判定胜利。不过250行的版本里我选择不加这个判断因为蛇占满网格的概率极低加了反而增加代码复杂度。注意random.randint是闭区间所以上限是GRID_WIDTH-1。如果你写成GRID_WIDTH食物可能生成在网格外面蛇永远吃不到。3.2 事件处理方向键与防掉头事件处理的核心是键盘输入。Pygame用pygame.event.get()获取事件队列然后遍历处理。方向键的处理有个经典陷阱不能180度掉头。如果蛇正在向右移动玩家按下左键蛇不能立刻反向否则蛇头会撞到自己的脖子游戏直接结束。这不是bug是规则。解决方案是维护一个“当前方向”和一个“待处理方向”。按键时只更新待处理方向在游戏更新时再真正应用。应用前检查待处理方向是否与当前方向相反。if event.key pygame.K_UP and direction ! (0, 1): next_direction (0, -1) elif event.key pygame.K_DOWN and direction ! (0, -1): next_direction (0, 1) elif event.key pygame.K_LEFT and direction ! (1, 0): next_direction (-1, 0) elif event.key pygame.K_RIGHT and direction ! (-1, 0): next_direction (1, 0)注意这里的判断条件direction ! (0, 1)表示当前不是向下所以可以向上。如果当前是向下按上键无效。这样就从逻辑上杜绝了掉头。但这里还有个更隐蔽的问题如果玩家在极短时间内连续按两个键比如先按上再按左而当前方向是右。按上时next_direction变成上按左时检查direction ! (1, 0)当前方向还是右所以next_direction变成左。结果蛇直接向左撞到自己。这个问题的根源在于按键时检查的是direction当前方向而不是next_direction待处理方向。正确的做法是检查next_directionif event.key pygame.K_UP and next_direction ! (0, 1): next_direction (0, -1)这样连续按键时第二次按键会基于第一次的结果判断就不会出现掉头了。这个细节很多教程都不会讲但实际玩的时候很容易触发。3.3 移动逻辑蛇怎么“走”蛇的移动逻辑是整个游戏最核心的部分。每次更新时根据当前方向计算新蛇头的位置head_x, head_y snake[0] dir_x, dir_y direction new_head (head_x dir_x, head_y dir_y)然后判断新蛇头是否吃到食物if new_head food: snake.insert(0, new_head) food generate_food() score 1 else: snake.insert(0, new_head) snake.pop()吃到食物时只插入新头不弹出尾部蛇就长了一格。没吃到时插入新头并弹出尾部蛇的长度不变但整体向前移动了一格。这个逻辑看起来简单但有一个容易忽略的点先插入再判断还是先判断再插入。上面的写法是先计算新头判断是否吃到食物然后统一插入。这样写的好处是逻辑清晰不会出现“插入了但没吃到又忘了弹出”的情况。实操心得snake.insert(0, new_head)在列表头部插入是O(n)操作因为要移动所有元素。对于贪吃蛇这个长度最多几百节性能完全不是问题。如果你真的在意性能可以用collections.deque但代码会稍微复杂一点。250行的版本里列表足够了。3.4 碰撞检测什么时候游戏结束碰撞检测分两种撞墙和撞自己。撞墙的判断很简单新蛇头的坐标超出网格范围就是撞墙if not (0 new_head[0] GRID_WIDTH and 0 new_head[1] GRID_HEIGHT): game_over True撞自己的判断是新蛇头坐标在蛇身列表中。但这里有个细节蛇尾要不要算因为蛇移动时尾部会弹出一格所以新蛇头的位置如果恰好是当前蛇尾的位置实际上蛇尾已经移走了不应该算撞到自己。if new_head in snake[:-1]: # 排除蛇尾 game_over True用snake[:-1]排除最后一个元素蛇尾。但注意如果蛇吃到了食物尾部不会弹出这时候蛇尾还在应该算碰撞。所以更严谨的写法是if new_head in snake: if new_head ! snake[-1] or ate_food: game_over True不过在实际游戏中蛇头撞到蛇尾的情况极其罕见因为蛇尾在移动。为了代码简洁很多实现直接判断new_head in snake包括蛇尾。这样做的后果是在某些极端情况下会误判但概率极低不影响游戏体验。我个人的选择是排除蛇尾因为逻辑上更正确。3.5 渲染把状态画出来渲染部分用Pygame的绘图API主要画三样东西蛇、食物、分数。蛇的每一节画一个填充矩形蛇头用不同的颜色区分for i, (x, y) in enumerate(snake): color HEAD_COLOR if i 0 else BODY_COLOR rect pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, color, rect)食物画一个圆形或者圆角矩形视觉上和蛇区分开food_rect pygame.Rect(food[0] * CELL_SIZE, food[1] * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.ellipse(screen, FOOD_COLOR, food_rect)分数显示在窗口标题栏用pygame.display.set_captionpygame.display.set_caption(f贪吃蛇 - 分数: {score})为什么不用字体渲染在窗口内因为加载字体需要额外的代码而且中文字体在不同系统上路径不同容易出问题。标题栏显示分数虽然不够炫酷但胜在简单可靠零依赖。注意pygame.draw.rect的坐标是左上角坐标不是中心坐标。网格坐标乘以CELL_SIZE得到像素坐标这个转换不要搞反了。3.6 游戏结束与重开游戏结束后屏幕上显示“Game Over”和最终分数按R键重新开始。重开的逻辑是重置所有状态变量snake [(5, 10), (4, 10), (3, 10)] direction (1, 0) next_direction (1, 0) food generate_food() score 0 game_over False这里有个容易犯的错误忘记重置next_direction。如果游戏结束时next_direction是某个方向重开后蛇会立刻朝那个方向移动而不是初始的向右。所以next_direction必须和direction一起重置。4. 实操中踩过的坑与排查技巧4.1 蛇移动速度不均匀这是新手最常遇到的问题蛇有时候移动得快有时候移动得慢。原因通常是帧率控制没做好。Pygame的clock.tick(FPS)会确保循环以指定帧率运行但它控制的是最大帧率不是固定帧率。如果某一帧的处理时间超过了1/FPS秒实际帧率就会下降。对于贪吃蛇来说这通常不是问题因为每帧的计算量很小。但如果你在渲染时做了大量绘制操作或者电脑性能很差就可能出现卡顿。解决方案是把游戏逻辑更新和渲染分离。游戏逻辑以固定频率更新比如每秒10次渲染以尽可能高的频率运行。这样即使渲染卡顿游戏逻辑也不会受影响。不过对于250行的版本我选择不引入这个复杂度。clock.tick(10)在绝大多数情况下都能稳定运行。如果你真的遇到了速度不均匀的问题可以先检查是不是有其他程序占用了CPU。4.2 按键响应延迟有时候按了方向键蛇要过一会儿才转向。这不是代码bug是游戏循环的固有延迟。在10 FPS下每帧间隔100毫秒。如果你在帧刚开始时按键要等100毫秒后下一帧才会处理。这个延迟在快速游戏中是致命的但在贪吃蛇里可以接受。如果你觉得延迟明显可以提高帧率到15或20。但代价是蛇移动更快游戏难度上升。这是一个权衡没有标准答案。实操心得我试过在事件处理里直接更新方向而不是等到下一帧。这样做确实能减少延迟但会引入新的问题——如果一帧内收到多个方向键事件蛇可能会连续转向导致撞到自己。所以还是老老实实在更新阶段处理方向比较稳妥。4.3 食物生成在蛇身上前面提到过食物生成时如果随机到了蛇身上需要重新生成。但有一种情况容易忽略蛇吃食物后新蛇头的位置就是食物原来的位置。如果你在蛇移动之前就生成了新食物新食物可能生成在新蛇头的位置上。正确的顺序是先移动蛇再生成新食物。这样新食物生成时蛇头已经在新的位置了generate_food里的pos not in snake检查会排除掉蛇头的位置。# 正确的顺序 snake.insert(0, new_head) if ate_food: food generate_food() # 此时snake已经包含新蛇头4.4 窗口关闭无响应有时候点击窗口的关闭按钮程序没反应。这是因为没有处理pygame.QUIT事件。for event in pygame.event.get(): if event.type pygame.QUIT: running False这行代码必须放在事件处理的最前面否则如果事件队列里有其他事件可能会先处理其他事件导致关闭延迟。另外如果程序卡在某个循环里比如generate_food的死循环事件队列不会被处理窗口就会无响应。所以generate_food一定要有退出条件哪怕只是一个最大尝试次数。4.5 常见问题速查表问题现象可能原因排查方法蛇移动速度不均匀帧率不稳定检查CPU占用降低渲染复杂度按键无响应事件队列未处理确认pygame.event.get()在循环中调用蛇撞到自己但没结束碰撞检测排除了蛇尾检查new_head in snake的判断逻辑食物生成在蛇身上生成顺序错误确保先移动蛇再生成食物重开后蛇方向不对next_direction未重置检查重开逻辑是否重置所有状态窗口无法关闭未处理QUIT事件在事件循环中添加QUIT处理蛇移动太快/太慢帧率设置不当调整clock.tick()的参数分数不更新分数变量未递增检查吃到食物后的score 14.6 代码组织的小技巧250行代码虽然不多但如果组织不好读起来也会很累。我的建议是把功能相关的代码放在一起。比如所有和食物相关的操作生成、绘制、碰撞放在相邻的位置。这样你改食物逻辑时不需要在文件里跳来跳去。用空行分隔逻辑块。不要写成一整块适当用空行分隔不同的逻辑段落。Python对空行不敏感但人眼对空行很敏感。常量全部大写并放在文件开头。颜色、尺寸、帧率这些常量集中定义在文件顶部。这样调整参数时只需要改一个地方。函数不要太多也不要太少。250行的项目我建议3到5个函数。太少会导致主循环臃肿太多会导致函数调用链过长。我的分配是generate_food、handle_events、update_game、render加上主函数一共5个。5. 从能跑到好用几个提升体验的细节5.1 蛇身的渐变效果默认的蛇身是纯色看起来比较单调。可以给蛇身加一个渐变效果从蛇头的深色逐渐过渡到蛇尾的浅色。实现方式是根据蛇身的索引计算颜色插值for i, (x, y) in enumerate(snake): ratio i / len(snake) color ( int(HEAD_COLOR[0] * (1 - ratio) TAIL_COLOR[0] * ratio), int(HEAD_COLOR[1] * (1 - ratio) TAIL_COLOR[1] * ratio), int(HEAD_COLOR[2] * (1 - ratio) TAIL_COLOR[2] * ratio), ) # 绘制...这个效果只增加了不到10行代码但视觉提升很明显。蛇看起来更有层次感也更容易分辨蛇头和蛇尾。5.2 食物的小动画食物可以做一个简单的呼吸效果大小随时间轻微变化。用pygame.time.get_ticks()获取当前时间然后计算一个缩放因子ticks pygame.time.get_ticks() scale 0.8 0.2 * abs((ticks % 1000) / 500 - 1)这样食物会以1秒为周期在0.8到1.0之间缩放。虽然是个很小的细节但会让游戏看起来更精致。注意这个动画依赖于帧率如果帧率不稳定动画会看起来不流畅。不过对于贪吃蛇这种低帧率游戏影响不大。5.3 游戏结束的视觉反馈游戏结束时不要只是显示文字可以给蛇身加一个闪烁效果或者把整个屏幕变暗。最简单的做法是画一个半透明的黑色遮罩overlay pygame.Surface((WINDOW_WIDTH, WINDOW_HEIGHT)) overlay.set_alpha(128) overlay.fill((0, 0, 0)) screen.blit(overlay, (0, 0))然后在遮罩上画“Game Over”文字。这样游戏结束的视觉冲击力更强玩家能立刻意识到游戏结束了。5.4 防止按键过快导致的问题前面提到过连续按键导致掉头的问题解决方案是检查next_direction而不是direction。但还有一种情况玩家按住方向键不放Pygame会连续触发按键事件。对于贪吃蛇来说这通常不是问题因为方向键的重复触发只是不断设置同一个方向。但如果你做了“按空格加速”之类的功能就需要处理按键重复。Pygame的按键事件有pygame.KEYDOWN和pygame.KEYUP两种。如果你需要检测“按住”状态可以用pygame.key.get_pressed()keys pygame.key.get_pressed() if keys[pygame.K_SPACE]: # 加速逻辑但注意get_pressed()返回的是当前所有按键的状态需要在每一帧都调用。对于250行的版本我不建议加入加速功能因为它会引入额外的状态管理复杂度。5.5 窗口图标的设置默认的Pygame窗口图标是一个灰色的Pygame logo看起来不够专业。可以自己画一个简单的蛇形图标icon pygame.Surface((32, 32)) icon.fill((0, 0, 0)) pygame.draw.rect(icon, (0, 255, 0), (4, 12, 8, 8)) pygame.draw.rect(icon, (0, 200, 0), (12, 12, 8, 8)) pygame.draw.rect(icon, (0, 150, 0), (20, 12, 8, 8)) pygame.display.set_icon(icon)这段代码画了三个绿色方块代表蛇的三节身体。虽然简单但比默认图标好得多。6. 250行之外的扩展思路6.1 加入音效Pygame支持音效播放用pygame.mixer模块。吃到食物时播放一个短促的“叮”声游戏结束时播放一个低沉的“咚”声。eat_sound pygame.mixer.Sound(eat.wav) eat_sound.play()音效文件需要自己准备或者用代码生成简单的波形。不过音效文件会增加项目的文件数量如果你追求单文件运行可以跳过这个扩展。6.2 加入障碍物在网格上随机放置一些障碍物蛇撞到障碍物也会游戏结束。这个扩展只需要增加一个obstacles列表在碰撞检测时多判断一次。但障碍物的生成需要保证不会把蛇围死否则游戏无法进行。简单的做法是障碍物只生成在网格的边缘区域或者生成后检查蛇是否还有通路。对于250行的版本我建议障碍物固定位置不要随机生成避免复杂的通路检测。6.3 加入双人模式两个玩家分别用WASD和方向键控制两条蛇谁先撞墙、撞自己或撞对方谁就输。这个扩展的复杂度主要在于碰撞检测蛇头撞到对方蛇身、两条蛇头相撞、两条蛇同时吃到食物等。代码量会增加大约100行超出250行的范围。但如果你已经写完了基础版本可以尝试这个扩展它对理解游戏状态管理很有帮助。6.4 加入关卡系统每吃到一个食物蛇的速度增加一点或者网格上出现新的障碍物。这样游戏会越来越难增加挑战性。速度增加的实现很简单把clock.tick(FPS)中的FPS改成一个变量吃到食物时递增FPS min(FPS 0.5, 20) # 最高20帧但注意帧率变化会影响游戏手感需要仔细调整递增的幅度。6.5 用面向对象重构250行的过程式代码已经足够清晰但如果你想练习面向对象设计可以把蛇、食物、游戏分别封装成类。class Snake: def __init__(self): self.body [(5, 10), (4, 10), (3, 10)] self.direction (1, 0) def move(self): # 移动逻辑 def check_collision(self): # 碰撞检测面向对象的好处是职责更清晰扩展更方便。但代价是代码量会增加可能超过250行。所以这是一个取舍你要的是简洁还是可扩展性。我个人在实际操作中的体会是250行的过程式版本更适合学习和快速原型开发而面向对象版本更适合作为项目的基础架构。如果你打算继续扩展这个游戏建议先写过程式版本理解清楚逻辑后再用面向对象重构一遍。这样你对两种范式都会有更深的体会。最后再分享一个小技巧写完之后把代码放一放第二天再回来看。你会发现很多当时觉得没问题的地方其实有更好的写法。代码review自己的代码是提升最快的方式。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询