ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python零依赖手写贪吃蛇:150行代码吃透游戏循环与碰撞检测

Python零依赖手写贪吃蛇:150行代码吃透游戏循环与碰撞检测

1. 为什么我劝你用150行代码手写一遍贪吃蛇

很多人学Python学到某个阶段会突然卡住:语法都懂,循环、函数、类也能写,但就是不知道拿这些东西能做出什么。看教程的时候觉得“哦,原来是这样”,关掉教程自己动手,大脑一片空白。这个阶段最缺的不是知识,而是一个完整、可运行、代码量不大但五脏俱全的小项目。贪吃蛇就是那个最合适的靶子。

它足够简单——核心逻辑用150行左右就能写完,不需要任何第三方库,标准库里的tkinter和random就能撑起整个游戏。它又足够完整——包含了游戏循环、事件处理、碰撞检测、状态管理、渲染刷新这些游戏开发的基础要素。你把这150行吃透,再去看任何2D小游戏,骨架都是通的。

这篇文章我会带你从零开始,把贪吃蛇的每一块拆开讲清楚。不是扔一段代码让你复制走人,而是告诉你每一行为什么这么写、参数为什么这么定、哪里容易踩坑、怎么改能变成你自己的东西。代码可以直接复制运行,但我更希望你看完之后能自己改出几个版本。

适合谁看?刚学完Python基础语法、想找个练手项目的朋友;写过一些脚本但没碰过图形界面的人;以及想给孩子或学生讲编程、需要一个直观案例的开发者。不需要你会tkinter,我会把用到的每个方法都解释清楚。

2. 整体设计思路:为什么选tkinter而不是pygame

2.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, width=self.grid_width * self.cell_size, height=self.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(text=f"分数: {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="游戏结束", fill="white", font=("Arial", 24, "bold")) self.canvas.create_text(cx, cy + 20, text=f"最终分数: {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本身不支持音频,但可以用winsound(Windows)或subprocess调用系统命令播放声音。这个稍微复杂,但网上有现成方案。吃到食物“嘀”一声,游戏结束放个低沉音效,沉浸感立刻不一样。

我个人最推荐先加“穿墙”和“难度递增”,这两个改动最小,但让游戏从“能玩”变成“想一直玩”。改完之后你会对坐标运算、状态管理有更深的体会。

7. 我踩过的几个坑和最后的小建议

第一个坑是坐标系搞反。我一开始把“上”写成(0, 1),结果按上键蛇往下跑。tkinter的y轴向下,这个跟直觉相反,必须记住。

第二个坑是**after的递归调用忘了加self.**。写成root.after也能跑,但如果你有多个窗口或想控制生命周期,用self.root更规范。

第三个坑是蛇身列表的顺序。我一开始把蛇尾存在索引0,蛇头存在最后,结果移动逻辑全乱了。记住:索引0永远是蛇头,insert(0, new_head)和pop()都是围绕这个约定设计的。

最后分享一个小技巧:如果你想让蛇看起来更顺滑,可以在每次移动后不立即重画,而是用after延迟几毫秒再画。但这会引入输入延迟,得不偿失。贪吃蛇的“格子感”本身就是它的魅力,不需要追求像素级平滑。

代码写完之后,试着不看教程,自己从零再写一遍。你会发现第二遍会卡在完全不同的地方——第一遍卡在语法,第二遍卡在逻辑。把第二遍卡住的地方记下来,那就是你真正需要补的知识点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进