Python贪吃蛇150行代码:从状态机到完整实现

发布时间:2026/10/15 17:28:12
Python贪吃蛇150行代码:从状态机到完整实现
1. 为什么我劝你先别急着写代码很多人第一次接触Python小游戏开发脑子里蹦出来的第一个念头就是贪吃蛇。这个选择本身没毛病——规则简单、逻辑闭环、视觉反馈直接特别适合拿来练手。但我见过太多人上来就打开编辑器噼里啪啦敲了三百行结果蛇撞墙不结束、吃到食物不变长、方向键按快了直接反向自杀。问题出在哪不是代码能力不够是动手之前没想清楚这个游戏到底由哪几个状态组成。贪吃蛇的本质是一个离散状态机。蛇的位置、食物的位置、当前移动方向、游戏是否结束——这四个变量就能完整描述任意时刻的游戏画面。你后面写的所有代码无非是在做三件事根据当前状态计算下一帧状态、把状态画到屏幕上、接收键盘输入修改方向。想通这一层150行代码不仅够用还能写得相当优雅。这篇文章面向的是刚学完Python基础语法、想找个项目练手的朋友。我会带你从零搭出一个可直接运行的贪吃蛇不依赖任何第三方库只用标准库里的tkinter和random。代码我会完整给出但更重要的是我会把每一段背后的为什么讲清楚——为什么用deque而不是list、为什么方向判断要加锁、为什么游戏循环不能直接用while True。这些细节才是你以后写其他项目时真正能迁移的东西。提示本文代码在Python 3.8及以上版本实测通过Windows、macOS、Linux桌面环境均可运行。如果你用的是精简版Linux没有预装tkinter需要先通过系统包管理器安装。2. 拆解贪吃蛇的四个核心状态2.1 蛇身用什么数据结构存新手最容易踩的坑就是用list存蛇身坐标然后每次移动时insert(0, new_head)再pop()。逻辑上没错但list的头部插入是O(n)操作——虽然贪吃蛇长度通常不会太大但这里有个更隐蔽的问题你需要频繁判断新蛇头是否撞到自己而list的成员检查也是O(n)。两个O(n)叠在一起代码写起来还容易出错。标准库里的collections.deque是双端队列头部插入和尾部删除都是O(1)。更妙的是它天然表达了蛇在移动这个语义新头进、旧尾出。判断自撞时你只需要检查新头坐标是否在deque中——虽然还是O(n)但至少代码干净。如果你追求极致性能可以额外维护一个set来存蛇身坐标把碰撞检测降到O(1)不过对于150行的目标来说deque已经足够。from collections import deque # 蛇身初始状态三个格子水平排列 snake deque([(5, 5), (4, 5), (3, 5)]) # 蛇头在左端蛇尾在右端这里坐标用(x, y)元组表示x是列、y是行。为什么不用两个独立的列表分别存x和y因为元组是不可变的作为deque元素和set元素都安全而且(x, y)的写法比x_list[i], y_list[i]直观得多。2.2 食物生成为什么要排除蛇身食物不能生成在蛇身上这是常识。但实现时有个效率陷阱如果你每次都用while循环随机生成坐标、再检查是否在蛇身中当蛇很长时可能循环很多次才命中一个空位。更稳妥的做法是先收集所有空格子再从中随机选一个。import random def generate_food(snake, width, height): # 收集所有未被蛇占据的格子 free_cells [ (x, y) for x in range(width) for y in range(height) if (x, y) not in snake ] return random.choice(free_cells) if free_cells else None当蛇几乎填满整个屏幕时free_cells会很小random.choice依然能快速返回。如果蛇真的填满了所有格子返回None表示胜利——这个边界情况很多人会忽略导致游戏卡死或报错。2.3 方向控制为什么需要输入缓冲这是贪吃蛇最经典的bug来源。假设蛇当前向右移动玩家快速按下上再按左。如果代码直接修改方向变量第一帧方向变成上第二帧变成左——但此时蛇头还没真正向上移动过向左就会直接撞上自己的身体。解决方案是维护一个方向队列每帧只从队列头部取一个方向且新方向不能与当前实际移动方向相反。这样即使玩家疯狂按键蛇也不会自杀。direction_queue deque() current_direction (1, 0) # 向右 def on_key_press(event): key_map { Up: (0, -1), Down: (0, 1), Left: (-1, 0), Right: (1, 0) } new_dir key_map.get(event.keysym) if new_dir and len(direction_queue) 2: # 检查是否与队列中最后一个方向相反 last direction_queue[-1] if direction_queue else current_direction if (new_dir[0] last[0], new_dir[1] last[1]) ! (0, 0): direction_queue.append(new_dir)队列长度限制为2是为了防止玩家按住方向键不放导致队列无限增长。(new_dir[0] last[0], ...) ! (0, 0)这个判断很巧妙两个方向向量相加如果为零向量说明它们正好相反。2.4 游戏循环与帧率控制tkinter的after方法比while True更适合做游戏循环。while True会阻塞主线程导致窗口无法响应而after(ms, callback)是事件驱动的窗口保持响应还能精确控制帧率。FRAME_MS 120 # 约8帧/秒适合贪吃蛇的节奏 def game_loop(): if game_over: return update() draw() root.after(FRAME_MS, game_loop)FRAME_MS设为120毫秒意味着蛇每秒移动约8格。这个速度经过实测比较舒服——再快反应不过来再慢显得拖沓。你可以根据自己手感调整但建议不要低于80毫秒。3. 150行完整代码逐段拆解3.1 窗口与画布初始化import tkinter as tk from collections import deque import random CELL_SIZE 20 GRID_WIDTH 30 GRID_HEIGHT 20 FRAME_MS 120 root tk.Tk() root.title(贪吃蛇) canvas tk.Canvas( root, widthGRID_WIDTH * CELL_SIZE, heightGRID_HEIGHT * CELL_SIZE, bg#1a1a2e, highlightthickness0 ) canvas.pack()CELL_SIZE是每个格子的像素大小20像素在1080p屏幕上大小适中。GRID_WIDTH和GRID_HEIGHT决定游戏区域30x20的网格在20像素格子下是600x400的画布不会占满屏幕方便调试。背景色用深色#1a1a2e蛇和食物用亮色对比度高长时间盯着不累。highlightthickness0去掉画布默认的焦点边框视觉上更干净。这个参数很多人不知道导致画布边缘有一条难看的虚线。3.2 游戏状态初始化snake deque([(5, 5), (4, 5), (3, 5)]) current_direction (1, 0) direction_queue deque() food None score 0 game_over False paused False def reset_game(): global snake, current_direction, direction_queue global food, score, game_over, paused snake deque([(5, 5), (4, 5), (3, 5)]) current_direction (1, 0) direction_queue deque() score 0 game_over False paused False food generate_food(snake, GRID_WIDTH, GRID_HEIGHT)把所有可变状态放在模块级别用global声明修改。虽然面向对象写法更优雅但150行的目标下全局变量反而更直观。reset_game函数让重新开始变得简单——按R键时直接调用即可。3.3 核心更新逻辑def update(): global snake, current_direction, food, score, game_over # 从方向队列取新方向 if direction_queue: current_direction direction_queue.popleft() head_x, head_y snake[0] dx, dy current_direction new_head (head_x dx, head_y dy) # 撞墙检测 if not (0 new_head[0] GRID_WIDTH and 0 new_head[1] GRID_HEIGHT): game_over True return # 撞自己检测注意尾巴即将移走所以排除尾巴 if new_head in list(snake)[:-1]: game_over True return snake.appendleft(new_head) # 吃到食物 if new_head food: score 10 food generate_food(snake, GRID_WIDTH, GRID_HEIGHT) if food is None: game_over True # 蛇填满屏幕胜利 else: snake.pop()这里有个精妙之处撞自己检测时排除了尾巴snake[-1]。因为这一帧尾巴会移走新头占据尾巴的位置是合法的。如果不排除蛇在追自己尾巴时会误判死亡。这个细节很多教程都没讲清楚。snake.appendleft(new_head)之后如果没吃到食物就pop()掉尾巴蛇的长度不变吃到食物就不pop蛇变长一格。逻辑非常干净。3.4 绘制与分数显示def draw(): canvas.delete(all) # 画食物 if food: fx, fy food canvas.create_oval( fx * CELL_SIZE 2, fy * CELL_SIZE 2, (fx 1) * CELL_SIZE - 2, (fy 1) * CELL_SIZE - 2, fill#e94560, outline ) # 画蛇 for i, (x, y) in enumerate(snake): color #0f3460 if i 0 else #16213e canvas.create_rectangle( x * CELL_SIZE 1, y * CELL_SIZE 1, (x 1) * CELL_SIZE - 1, (y 1) * CELL_SIZE - 1, fillcolor, outline#0f3460 ) # 分数 canvas.create_text( 10, 10, anchornw, textfScore: {score}, fill#ffffff, font(Consolas, 14, bold) ) if game_over: canvas.create_text( GRID_WIDTH * CELL_SIZE // 2, GRID_HEIGHT * CELL_SIZE // 2, textGAME OVER\n按 R 重新开始, fill#ffffff, font(Consolas, 20, bold), justifycenter )蛇头用深蓝色#0f3460蛇身用稍浅的#16213e形成层次感。食物用红色#e94560在深色背景上非常醒目。每个格子绘制时留1像素边距形成网格分隔效果比实心方块好看得多。canvas.delete(all)每帧清空重绘对于这个规模来说性能完全够用。如果你要优化可以只更新变化的格子但没必要——600x400的画布重绘一次不到1毫秒。3.5 键盘绑定与主循环启动def on_key_press(event): global paused key event.keysym if key r or key R: reset_game() return if key space: paused not paused return if game_over or paused: return key_map { Up: (0, -1), Down: (0, 1), Left: (-1, 0), Right: (1, 0), w: (0, -1), s: (0, 1), a: (-1, 0), d: (1, 0) } new_dir key_map.get(key) if new_dir and len(direction_queue) 2: last direction_queue[-1] if direction_queue else current_direction if (new_dir[0] last[0], new_dir[1] last[1]) ! (0, 0): direction_queue.append(new_dir) root.bind(KeyPress, on_key_press) def game_loop(): if not game_over and not paused: update() draw() root.after(FRAME_MS, game_loop) reset_game() game_loop() root.mainloop()同时支持方向键和WASD照顾不同习惯的玩家。空格键暂停R键重开。root.bind(KeyPress, ...)绑定到窗口级别不需要画布获得焦点。game_loop里先判断game_over和paused再决定是否update但draw始终执行——这样暂停时画面还在只是不动了。4. 跑通之后你一定会遇到的五个坑4.1 蛇撞自己时穿模的错觉有朋友反馈说蛇明明撞到自己了却没死。排查后发现他在判断碰撞时用了if new_head in snake没有排除尾巴。当蛇长度为4、新头正好落在尾巴位置时in判断为真游戏结束——但这一帧尾巴本来就要移走所以这是误判。反过来如果蛇在追自己的尾巴视觉上看起来穿过去了其实是合法的。修正方法就是前面代码里的list(snake)[:-1]。注意deque不支持切片所以要先转成list再切片。虽然多了一次拷贝但蛇长度通常不超过100性能影响可以忽略。4.2 按方向键太快导致反向自杀这个坑前面讲方向队列时已经埋了伏笔。如果你不用队列直接current_direction new_dir玩家快速按上再按左时第一帧方向变成上蛇头向上移动一格第二帧方向变成左蛇头向左移动——但此时蛇身还在右边向左就撞上了。用队列缓冲后每个方向至少生效一帧就不会出现这种幽灵反向。4.3 食物生成在蛇身上导致吃不到generate_food函数如果写成while True循环随机生成、直到不在蛇身上当蛇很长时可能循环几十次才命中。更糟的是如果蛇填满了大部分格子这个循环可能跑很久造成卡顿。用先收集空格再随机选的方法无论蛇多长都是一次命中。4.4 窗口关闭后程序不退出tkinter的mainloop在窗口关闭后应该自动退出但如果你在game_loop里用了root.after有时候会残留回调导致进程不结束。解决办法是在窗口关闭时取消所有after任务def on_close(): root.quit() root.destroy() root.protocol(WM_DELETE_WINDOW, on_close)4.5 帧率不稳定导致手感忽快忽慢root.after(FRAME_MS, game_loop)的实际间隔并不精确受系统调度影响可能有几毫秒波动。对于贪吃蛇来说这完全可接受但如果你发现蛇移动速度明显不均匀可以改用time.perf_counter()做时间补偿。不过对于150行的目标after已经足够。5. 从能跑到好用三个值得加的改进5.1 加一个开始界面现在的代码一运行蛇就开始动玩家还没准备好就撞墙了。加一个简单的开始状态started False def on_key_press(event): global started if not started and event.keysym space: started True return # ... 其余逻辑在game_loop里判断if started and not game_over and not paused才update。开始界面画一行按空格开始的文字即可。5.2 蛇身渐变效果现在的蛇头蛇身只有两种颜色蛇很长时看起来像一条色带。可以按索引计算颜色渐变def get_snake_color(index, total): ratio index / max(total - 1, 1) r int(15 ratio * 30) g int(52 ratio * 40) b int(96 ratio * 60) return f#{r:02x}{g:02x}{b:02x}这样蛇头深、蛇尾浅视觉上更有层次。颜色计算用线性插值简单有效。5.3 最高分记录用json模块把最高分存到本地文件下次启动时读取import json import os SCORE_FILE snake_highscore.json def load_highscore(): if os.path.exists(SCORE_FILE): with open(SCORE_FILE, r) as f: return json.load(f).get(highscore, 0) return 0 def save_highscore(score): with open(SCORE_FILE, w) as f: json.dump({highscore: score}, f)在game_over时比较并保存。这个功能代码量很小但让游戏有了重复玩的动力。6. 关于这150行代码我想说的几句实话这份代码我反复删改过很多次目标就是每一行都有存在的理由。你可能会发现没有类、没有异常处理、没有配置文件——这些都是刻意为之。贪吃蛇这个体量的项目引入面向对象反而会让读者在self和继承关系里迷失不如用全局变量和函数直来直去。但简单不等于简陋。方向队列、尾巴排除、空格收集这三个细节是区分能跑和好用的关键。很多网上流传的贪吃蛇代码在这三处都有瑕疵导致玩家遇到各种诡异bug却找不到原因。我把它们单独拎出来讲就是希望你在自己写的时候能避开。如果你已经跑通了这份代码下一步我建议你尝试用pygame重写一遍。pygame的精灵系统和事件循环更接近商业游戏开发模式而且帧率控制更精确。但那是另一个故事了——先把这150行吃透比急着换框架重要得多。最后分享一个调试技巧在update函数开头加一行print(fHead: {snake[0]}, Dir: {current_direction}, Len: {len(snake)})运行几秒钟后观察输出。当你看到方向变化和蛇头位置的关系时对状态机的理解会瞬间清晰。这个笨办法比任何教程都管用。