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, width=GRID_WIDTH * CELL_SIZE, height=GRID_HEIGHT * CELL_SIZE, bg="#1a1a2e", highlightthickness=0 ) canvas.pack()CELL_SIZE是每个格子的像素大小,20像素在1080p屏幕上大小适中。GRID_WIDTH和GRID_HEIGHT决定游戏区域,30x20的网格在20像素格子下是600x400的画布,不会占满屏幕,方便调试。背景色用深色#1a1a2e,蛇和食物用亮色,对比度高,长时间盯着不累。
highlightthickness=0去掉画布默认的焦点边框,视觉上更干净。这个参数很多人不知道,导致画布边缘有一条难看的虚线。
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, fill=color, outline="#0f3460" ) # 分数 canvas.create_text( 10, 10, anchor="nw", text=f"Score: {score}", fill="#ffffff", font=("Consolas", 14, "bold") ) if game_over: canvas.create_text( GRID_WIDTH * CELL_SIZE // 2, GRID_HEIGHT * CELL_SIZE // 2, text="GAME OVER\n按 R 重新开始", fill="#ffffff", font=("Consolas", 20, "bold"), justify="center" )蛇头用深蓝色#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(f"Head: {snake[0]}, Dir: {current_direction}, Len: {len(snake)}"),运行几秒钟后观察输出。当你看到方向变化和蛇头位置的关系时,对状态机的理解会瞬间清晰。这个笨办法比任何教程都管用。