Python零依赖手写贪吃蛇:150行代码吃透游戏循环与碰撞检测 1. 为什么我劝你用150行代码手写一遍贪吃蛇很多人学Python学到某个阶段会突然卡住语法都懂循环、函数、类也能写但就是不知道拿这些东西能做出什么。看教程的时候觉得“哦原来是这样”关掉教程自己动手大脑一片空白。这个阶段最缺的不是知识而是一个完整、可运行、代码量不大但五脏俱全的小项目。贪吃蛇就是那个最合适的靶子。它足够简单——核心逻辑用150行左右就能写完不需要任何第三方库标准库里的tkinter和random就能撑起整个游戏。它又足够完整——包含了游戏循环、事件处理、碰撞检测、状态管理、渲染刷新这些游戏开发的基础要素。你把这150行吃透再去看任何2D小游戏骨架都是通的。这篇文章我会带你从零开始把贪吃蛇的每一块拆开讲清楚。不是扔一段代码让你复制走人而是告诉你每一行为什么这么写、参数为什么这么定、哪里容易踩坑、怎么改能变成你自己的东西。代码可以直接复制运行但我更希望你看完之后能自己改出几个版本。适合谁看刚学完Python基础语法、想找个练手项目的朋友写过一些脚本但没碰过图形界面的人以及想给孩子或学生讲编程、需要一个直观案例的开发者。不需要你会tkinter我会把用到的每个方法都解释清楚。2. 整体设计思路为什么选tkinter而不是pygame2.1 零依赖是最大的优势做贪吃蛇市面上最常被推荐的是pygame。它确实专业精灵、音效、碰撞检测都有现成的。但问题在于pygame需要额外安装版本兼容偶尔出问题而且它的API对新手来说是一层新的学习成本。你本来只是想练Python逻辑结果一半时间花在查pygame文档上得不偿失。tkinter是Python标准库自带的装好Python就有import tkinter直接能用。它的Canvas组件天生适合画网格类游戏——画矩形、画圆、绑定键盘事件几行代码就能出画面。贪吃蛇这种方格游戏用Canvas画方块是最自然的选择。提示如果你用的是某些精简版Python环境tkinter可能被裁掉了。验证方法很简单命令行输入python -m tkinter能弹出一个空白窗口就说明可用。2.2 用“网格坐标”而不是“像素坐标”思考这是新手最容易绕晕的地方。屏幕是像素的但贪吃蛇的逻辑是格子的。一条蛇在20×20的格子里移动和它在400×400像素的窗口里移动是两套坐标系。我的做法是逻辑层全部用格子坐标渲染层才换算成像素。蛇头在格子(5, 3)食物在格子(12, 8)碰撞判断就是比较两个元组是否相等。只有往Canvas上画的时候才把格子坐标乘以格子边长得到像素位置。这样逻辑代码干净得像数学题不会被像素数值污染。格子边长我定为20像素窗口就是400×400。这个数值不是随便定的太小了方块看不清太大了屏幕装不下几个格子20是一个在1080P屏幕上看着舒服、在笔记本上也不挤的平衡点。你可以改成15或25只要保证窗口尺寸 格子数 × 格子边长就行。2.3 游戏循环用after而不是while True很多教程写游戏循环用while True加time.sleep这在tkinter里是灾难——它会卡死主线程窗口直接无响应。正确做法是用tkinter的after方法它相当于“过N毫秒后调用这个函数”而且不阻塞事件循环。def game_loop(self): if not self.game_over: self.move_snake() self.check_collision() self.draw() self.root.after(self.speed, self.game_loop)self.speed控制游戏速度单位是毫秒。设成150就是每150毫秒动一格节奏刚好。想加速就减小这个值想减速就增大。这个设计的好处是键盘事件、窗口关闭这些操作依然能被正常响应不会因为游戏循环而卡住。3. 核心模块拆解蛇、食物、画布各管什么3.1 蛇的数据结构用列表存坐标蛇的本质是一串有序的格子坐标。我用一个列表self.snake来存索引0是蛇头最后一个元素是蛇尾。为什么这么设计因为移动的逻辑是新蛇头加到列表最前面如果没吃到食物就删掉最后一个元素尾巴如果吃到了就保留尾巴蛇变长。self.snake [(10, 10), (9, 10), (8, 10)] # 头在右尾在左初始长度给3格方向默认向右。这里有个细节初始蛇身不能重叠也不能贴着边界否则一开局就撞墙。我选(10, 10)作为蛇头在20×20的网格里居中偏左给右边留足空间。方向用一个元组表示比如(1, 0)代表向右(-1, 0)向左(0, 1)向下(0, -1)向上。移动时新蛇头坐标 旧蛇头坐标 方向向量。这个向量加法是整个游戏最核心的一行代码。3.2 食物的生成随机但要避开蛇身食物就是一个随机格子坐标。但直接random.randint有个坑可能生成在蛇身上玩家永远吃不到。所以必须加一个循环反复随机直到找到一个不在蛇身上的位置。def generate_food(self): while True: x random.randint(0, self.grid_width - 1) y random.randint(0, self.grid_height - 1) if (x, y) not in self.snake: return (x, y)这个while True理论上可能死循环——如果蛇填满了整个网格就永远找不到空位。但实际游戏中蛇填满网格基本等于通关概率极低。严谨一点可以加个判断如果蛇的长度等于总格子数直接判定胜利。我在代码里留了这个扩展点你可以自己补上。3.3 画布渲染每次重画而不是局部更新tkinter的Canvas支持删除单个图形对象但贪吃蛇这种小游戏每次刷新直接清空重画反而更简单可靠。canvas.delete(all)一行清空然后遍历蛇身画方块、画食物、画分数。def draw(self): self.canvas.delete(all) for (x, y) in self.snake: self.canvas.create_rectangle( x * self.cell_size, y * self.cell_size, (x 1) * self.cell_size, (y 1) * self.cell_size, fill#2ecc71, outline#27ae60 ) fx, fy self.food self.canvas.create_oval( fx * self.cell_size 2, fy * self.cell_size 2, (fx 1) * self.cell_size - 2, (fy 1) * self.cell_size - 2, fill#e74c3c, outline#c0392b )蛇身用绿色矩形食物用红色圆形视觉上一眼就能区分。create_oval的坐标我往里缩了2像素让圆形和格子之间留点缝隙看起来更精致。这些颜色值你可以随便换#2ecc71是扁平化设计里常用的绿色#e74c3c是红色搭配起来不刺眼。注意delete(all)会清掉画布上所有东西包括你可能想保留的分数文字。所以分数要么在清空后重画要么用单独的Label组件放在画布外面。我选择后者分数显示在窗口顶部不跟画布混在一起。4. 完整代码逐段精讲与实操4.1 环境准备与项目骨架打开你的代码编辑器新建一个文件叫snake.py。不需要安装任何东西Python 3.6以上版本直接跑。先搭骨架import tkinter as tk import random class SnakeGame: def __init__(self, root): self.root root self.root.title(贪吃蛇) self.root.resizable(False, False) self.grid_width 20 self.grid_height 20 self.cell_size 20 self.speed 150 self.canvas tk.Canvas( root, widthself.grid_width * self.cell_size, heightself.grid_height * self.cell_size, bg#1a1a2e ) self.canvas.pack() self.score_label tk.Label(root, text分数: 0, font(Arial, 14)) self.score_label.pack() self.reset_game() self.root.bind(KeyPress, self.on_key_press) self.game_loop() def reset_game(self): self.snake [(10, 10), (9, 10), (8, 10)] self.direction (1, 0) self.next_direction (1, 0) self.food self.generate_food() self.score 0 self.game_over False self.score_label.config(text分数: 0)resizable(False, False)禁止用户拖拽改变窗口大小因为画布尺寸是固定的窗口变了画布不会跟着变会很难看。bg#1a1a2e是深蓝色背景比纯黑柔和方块在上面更醒目。next_direction这个变量是关键。为什么需要两个方向变量因为键盘事件可能在两次移动之间触发多次。比如蛇正在向右走玩家快速按了“上”然后“左”。如果直接改direction第一次移动会向上第二次移动时方向已经变成左蛇会直接撞到自己。用next_direction暂存输入每次移动前才把direction更新为next_direction并且禁止180度掉头。4.2 键盘控制与防反向逻辑def on_key_press(self, event): key event.keysym if key Up and self.direction ! (0, 1): self.next_direction (0, -1) elif key Down and self.direction ! (0, -1): self.next_direction (0, 1) elif key Left and self.direction ! (1, 0): self.next_direction (-1, 0) elif key Right and self.direction ! (-1, 0): self.next_direction (1, 0)注意tkinter的坐标系y轴向下为正。所以“上”是(0, -1)“下”是(0, 1)。这个跟数学课本相反新手经常搞混。防反向的判断逻辑是如果当前方向是向右(1, 0)就不能接受向左(-1, 0)的输入。因为蛇头向右左边是自己的身体掉头等于自杀。这个判断用self.direction而不是self.next_direction因为要基于蛇当前实际移动方向来判断。实操心得有些玩家会同时按两个方向键比如“上”和“右”几乎同时按下。由于tkinter事件是顺序处理的最终next_direction会是后按下的那个。这没问题但如果你发现手感不对可以在每次移动后重置next_direction self.direction确保没有残留输入。4.3 移动、吃食物与碰撞检测def move_snake(self): self.direction self.next_direction head_x, head_y self.snake[0] dx, dy self.direction new_head (head_x dx, head_y dy) if (new_head[0] 0 or new_head[0] self.grid_width or new_head[1] 0 or new_head[1] self.grid_height): self.game_over True return if new_head in self.snake[:-1]: self.game_over True return self.snake.insert(0, new_head) if new_head self.food: self.score 10 self.score_label.config(textf分数: {self.score}) self.food self.generate_food() else: self.snake.pop()碰撞检测分两种撞墙和撞自己。撞墙判断新蛇头的x或y是否超出0到grid_width-1的范围。撞自己判断新蛇头是否在蛇身列表里但要排除最后一个元素尾巴因为尾巴在这一帧会移走蛇头可以安全地进入尾巴原来的位置。self.snake[:-1]就是排除尾巴的蛇身。这个细节很多教程会写错导致蛇贴着尾巴走的时候误判死亡。吃到食物时insert了新蛇头但不执行pop蛇就变长了一格。没吃到时insert之后pop掉尾巴蛇的长度不变整体向前移动一格。4.4 游戏主循环与速度控制def game_loop(self): if not self.game_over: self.move_snake() self.draw() self.root.after(self.speed, self.game_loop) else: self.draw_game_over()after的递归调用形成了游戏循环。每次循环做三件事移动蛇、重绘画布、安排下一次循环。如果游戏结束就画一个结束画面不再安排下一次循环。draw_game_over我简单处理在画布中央显示“游戏结束”和最终分数。def draw_game_over(self): self.canvas.delete(all) cx self.grid_width * self.cell_size // 2 cy self.grid_height * self.cell_size // 2 self.canvas.create_text(cx, cy - 20, text游戏结束, fillwhite, font(Arial, 24, bold)) self.canvas.create_text(cx, cy 20, textf最终分数: {self.score}, fill#f1c40f, font(Arial, 16))4.5 启动入口与完整代码if __name__ __main__: root tk.Tk() game SnakeGame(root) root.mainloop()mainloop是tkinter的事件循环入口它会一直运行监听键盘、鼠标、窗口事件直到窗口关闭。把上面所有代码拼起来就是完整的150行贪吃蛇。你可以直接复制到一个.py文件里运行。5. 常见问题与排查技巧实录5.1 窗口一闪而过或直接卡死最常见的原因是用了while True做游戏循环。tkinter是单线程事件驱动的while True会占满主线程窗口无法刷新系统以为程序无响应。必须用after。另一个原因是mainloop没写或者写在了SnakeGame类实例化之前。确保顺序是创建Tk()、创建游戏对象、调用mainloop()。5.2 蛇移动速度忽快忽慢after的延迟时间不是精确的它只保证“至少等这么久”实际延迟受系统调度影响。如果你发现速度不稳定可以把speed调大一点比如200给系统留余量。另外不要在game_loop里做耗时操作比如大量计算或文件读写那会拖慢循环。5.3 食物生成在蛇身上检查generate_food里的判断条件是不是if (x, y) not in self.snake。注意self.snake是列表in操作是O(n)的蛇很长时效率会下降。但贪吃蛇最多几百格这点开销可以忽略。如果你追求极致可以把蛇身坐标存到一个set里查找变成O(1)。5.4 按方向键没反应先检查root.bind(KeyPress, self.on_key_press)有没有写。然后检查焦点问题tkinter的键盘事件绑定在root上如果焦点在别的组件上可能收不到。确保canvas或root有焦点。可以在__init__里加self.canvas.focus_set()。还有一个隐蔽的坑某些系统上方向键的keysym不是Up而是KP_Up小键盘。可以打印event.keysym看看实际值然后兼容处理。5.5 游戏结束后无法重新开始我的代码里reset_game方法已经写好了但没绑定重新开始的按键。你可以在draw_game_over之后加一行self.root.bind(Return, lambda e: self.restart())然后实现restart方法调用reset_game再调用game_loop。问题现象可能原因排查方法窗口无响应用了while True改用after递归蛇不动game_loop没调用或game_over为True检查初始化和结束条件食物在蛇身上生成时没排除蛇身加not in self.snake判断方向键无效绑定丢失或焦点问题检查bind和focus_set蛇穿墙边界判断写错检查 0和 grid_width蛇撞自己误判没排除尾巴用self.snake[:-1]判断避坑技巧调试碰撞问题时可以在move_snake里加print(new_head, self.snake)观察每一步的坐标变化。确认无误后删掉打印语句。这种“打印大法”虽然原始但对坐标类bug极其有效。6. 从能跑到好玩几个低成本高回报的改造方向代码跑起来只是起点。真正让你学到东西的是动手改。下面几个改造方向每一个都只需要改十几行代码但能让你对游戏开发的理解上一个台阶。加墙壁现在的蛇撞到边界就死。你可以改成穿墙——蛇头超出右边界时从左边出现。实现方法是在计算new_head后加取模运算new_head (new_head[0] % self.grid_width, new_head[1] % self.grid_height)。一行代码游戏体验完全不同。加难度递增每吃5个食物self.speed减少10毫秒蛇越跑越快。在吃食物的分支里加判断即可。注意speed别低于50否则人眼跟不上。加暂停功能绑定空格键按一下self.paused True再按一下恢复。在game_loop开头判断if self.paused: self.root.after(100, self.game_loop); return。这个功能看似简单但能让你理解状态机的概念。换皮肤把蛇身画成圆角矩形或者用不同颜色区分蛇头和蛇身。create_rectangle换成create_oval就是一条“毛毛虫”。颜色方案可以参考扁平化设计网站选一组对比度高的配色。加音效tkinter本身不支持音频但可以用winsoundWindows或subprocess调用系统命令播放声音。这个稍微复杂但网上有现成方案。吃到食物“嘀”一声游戏结束放个低沉音效沉浸感立刻不一样。我个人最推荐先加“穿墙”和“难度递增”这两个改动最小但让游戏从“能玩”变成“想一直玩”。改完之后你会对坐标运算、状态管理有更深的体会。7. 我踩过的几个坑和最后的小建议第一个坑是坐标系搞反。我一开始把“上”写成(0, 1)结果按上键蛇往下跑。tkinter的y轴向下这个跟直觉相反必须记住。第二个坑是**after的递归调用忘了加self.**。写成root.after也能跑但如果你有多个窗口或想控制生命周期用self.root更规范。第三个坑是蛇身列表的顺序。我一开始把蛇尾存在索引0蛇头存在最后结果移动逻辑全乱了。记住索引0永远是蛇头insert(0, new_head)和pop()都是围绕这个约定设计的。最后分享一个小技巧如果你想让蛇看起来更顺滑可以在每次移动后不立即重画而是用after延迟几毫秒再画。但这会引入输入延迟得不偿失。贪吃蛇的“格子感”本身就是它的魅力不需要追求像素级平滑。代码写完之后试着不看教程自己从零再写一遍。你会发现第二遍会卡在完全不同的地方——第一遍卡在语法第二遍卡在逻辑。把第二遍卡住的地方记下来那就是你真正需要补的知识点。