☰
Python贪吃蛇实战:150行代码掌握事件驱动与游戏循环
2026/10/15 22:57:51 网站建设 项目流程

1. 为什么我建议你用贪吃蛇来练手Python

贪吃蛇这个游戏,几乎每个学编程的人都见过,甚至自己动手写过。但你可能不知道,它其实是检验一个Python初学者是否真正理解"事件驱动编程"和"游戏循环"这两个核心概念的最佳试金石。150行代码听起来不多,但要把贪吃蛇写得流畅、不卡顿、没有bug,其实涉及了坐标系管理、碰撞检测、键盘事件响应、帧率控制、动态渲染等多个知识点。我见过太多人学完Python基础语法之后不知道该做什么,对着教程抄了一遍又一遍,关掉教程还是写不出东西。问题出在哪里?出在缺少一个"麻雀虽小五脏俱全"的项目来串联所有零散的知识点。

贪吃蛇恰好就是这样一个项目。它不需要你懂数据库,不需要你懂网络编程,甚至不需要你懂面向对象的高级特性(当然用类来写会更优雅)。你只需要理解列表怎么用、循环怎么写、条件判断怎么嵌套,再加上一个图形库的基本调用,就能跑起来一个完整的、可玩的游戏。这种"即时反馈"带来的成就感,是刷一百道算法题都给不了的。而且,当你亲手写出来的蛇在屏幕上吃到食物、身体变长、撞墙结束的那一刻,你会真正理解什么叫"程序是活的"。

这篇文章面向的是已经学过Python基础语法(知道列表、字典、循环、函数怎么用),但还没有完整做过一个项目的朋友。我会从零开始,把每一行代码背后的逻辑都讲清楚,不是让你复制粘贴就完事,而是让你理解为什么这么写、换一种写法行不行、遇到问题怎么排查。代码你可以直接复制运行,但我更希望你看完之后能自己改一改,比如加个计分板、加个音效、让蛇加速,这些扩展我会在最后提到。整个项目我用的是pygame这个库,它是Python里做2D游戏最成熟、文档最全的选择,安装也简单,一行命令搞定。

2. 项目整体设计与技术选型思路

2.1 为什么选pygame而不是turtle或tkinter

Python能做图形界面的库不少,turtle、tkinter、pygame是最常见的三个。我一开始学的时候也纠结过选哪个,后来三个都试了一遍,说说我的实际感受。turtle适合画图教学,它的绘图指令很直观,但用来做游戏就力不从心了——它的刷新机制不是为实时交互设计的,蛇一长就会闪屏,键盘响应也有延迟。tkinter是Python自带的,不用额外安装,但它的画布刷新效率一般,做贪吃蛇这种需要每秒刷新十几次的场景,容易出现卡顿和残影。pygame虽然需要额外安装,但它底层用的是SDL库,专门为游戏开发优化过,帧率控制、事件处理、图形渲染都很成熟,社区资源也丰富,遇到问题一搜就有答案。

安装pygame非常简单,打开终端或命令行,输入:

pip install pygame

如果你用的是Mac或者Linux,可能需要把pip换成pip3。安装完成后,在Python里输入import pygame不报错就说明成功了。这里有个小坑要注意:如果你电脑上同时装了Python 2和Python 3,一定要确认pip安装到了正确的版本上。我当初就遇到过装完了但import报错的情况,后来发现是pip默认装到了Python 2的环境里。解决办法是用pip3 install pygame或者python -m pip install pygame来明确指定版本。

2.2 游戏核心机制的拆解

贪吃蛇看起来简单,但拆开来看,它其实由五个核心机制组成。第一个是游戏循环,也就是程序不断重复执行的那段代码,每一轮循环叫做一帧,帧率决定了游戏的速度。第二个是蛇的移动,蛇每帧向前移动一格,本质上是列表的插入和删除操作。第三个是食物生成,食物要随机出现在空白位置,不能生成在蛇身上。第四个是碰撞检测,包括蛇撞墙和蛇撞自己两种情况。第五个是渲染,把蛇、食物、分数画到屏幕上。

这五个机制里,最容易出问题的是蛇的移动和碰撞检测。蛇的移动如果用列表来做,逻辑是这样的:在蛇头方向插入一个新坐标,然后删掉蛇尾的坐标。如果吃到了食物,就不删蛇尾,这样蛇就变长了。碰撞检测的关键在于判断蛇头的新坐标是否超出了边界,或者是否和蛇身(除了蛇尾)的坐标重合。这里有个细节:蛇尾在移动后会消失,所以蛇头撞到当前蛇尾的位置其实不算撞,这个边界情况很多人会忽略,导致蛇明明没撞到自己却游戏结束了。

2.3 代码结构的组织方式

150行代码不算长,但如果不组织好,读起来也会很乱。我的习惯是把代码分成几个逻辑块:配置常量放在最上面,比如窗口大小、格子尺寸、颜色定义、帧率;初始化部分负责创建窗口、设置标题、初始化时钟;游戏状态变量包括蛇的初始位置、方向、食物位置、分数、游戏是否结束;辅助函数负责生成食物、绘制网格、显示文字;主循环里处理事件、更新蛇的位置、检测碰撞、渲染画面。这样分块之后,代码的可读性会好很多,改起来也方便。

我见过一些初学者把所有代码都堆在while循环里,几百行挤在一起,找个变量定义都要翻半天。这种写法在代码量小的时候还能忍,一旦要加功能就痛苦了。所以哪怕是一个小项目,也建议养成模块化的习惯。函数不需要写得太复杂,一个函数只做一件事,比如generate_food()只负责生成食物,check_collision()只负责检测碰撞,这样调试的时候也容易定位问题。

3. 核心细节解析与实操要点

3.1 坐标系与网格系统的设计

pygame的坐标系和数学课上学的有点不一样。它的原点(0, 0)在窗口的左上角,x轴向右递增,y轴向下递增。这一点一定要记住,不然算位置的时候很容易搞反。贪吃蛇是在网格上移动的,所以我们需要把像素坐标转换成网格坐标。假设每个格子是20像素,那么网格坐标(col, row)对应的像素坐标就是(col * 20, row * 20)。窗口大小如果是600x600,那么网格就是30x30,蛇的每一步移动一个格子,也就是20像素。

为什么要用网格而不是像素级移动?因为像素级移动需要处理浮点数坐标,还要考虑速度、方向向量、边界检测的精度问题,复杂度会高很多。网格系统把移动离散化了,蛇要么在格子A,要么在格子B,不存在中间状态,碰撞检测就变成了简单的坐标比对。这也是为什么贪吃蛇适合初学者——它把连续问题转化成了离散问题,大大降低了难度。

在实际写代码的时候,我建议把格子尺寸定义成一个常量GRID_SIZE = 20,窗口大小定义成WINDOW_SIZE = 600,这样网格数量就是WINDOW_SIZE // GRID_SIZE = 30。所有涉及坐标的地方都用网格坐标来计算,只有在最后绘制的时候才乘以GRID_SIZE转换成像素坐标。这样做的好处是,如果你想改格子大小,只需要改一个常量,其他代码都不用动。

3.2 蛇的移动逻辑与列表操作

蛇的身体用一个列表来表示,列表里每个元素是一个(x, y)的元组,第一个元素是蛇头,最后一个元素是蛇尾。比如snake = [(15, 15), (14, 15), (13, 15)]表示蛇头在(15, 15),身体向左延伸。蛇向右移动一步的逻辑是:在列表头部插入新坐标(16, 15),然后判断是否吃到食物。如果没吃到,就删掉列表最后一个元素(蛇尾);如果吃到了,就不删,蛇自然就变长了。

这里有个性能上的小细节。列表的insert(0, item)操作是O(n)的,因为要移动所有元素。对于贪吃蛇这种长度最多几十上百的情况,完全不是问题。但如果你想炫技,可以用collections.deque来代替列表,它的appendleft是O(1)的。不过对于150行的项目来说,没必要增加这个复杂度,列表足够了。

方向的控制也有讲究。蛇不能直接掉头,比如正在向右走的时候不能立刻改成向左,否则蛇头会撞到自己的脖子,游戏直接结束。所以我们需要记录当前方向,并且在接收到新方向指令时判断是否和当前方向相反。如果是相反方向,就忽略这个指令。这个判断逻辑很简单:如果新方向是(1, 0)(右),当前方向是(-1, 0)(左),两者相加等于(0, 0),说明是相反方向,忽略即可。

3.3 食物生成与碰撞检测的边界情况

食物生成看起来简单,其实有个容易踩的坑:食物不能生成在蛇身上。如果蛇很长,随机生成的食物有可能落在蛇的身体上,这时候玩家看到食物在蛇身上却吃不到,体验就很差。解决办法是生成一个随机坐标后,检查它是否在蛇的列表里,如果在就重新生成,直到找到一个空白位置。蛇越长,这个循环的次数越多,但一般蛇的长度不会超过几百,所以性能上没问题。

碰撞检测有两个部分:撞墙和撞自己。撞墙的判断是蛇头的x或y坐标超出了网格范围,比如x < 0或x >= 30或y < 0或y >= 30。撞自己的判断是蛇头的坐标和蛇身(除了蛇尾)的坐标重合。为什么要排除蛇尾?因为蛇尾在当前帧结束后会消失,蛇头移动到蛇尾的位置实际上是安全的。这个细节如果不处理,会出现"蛇明明没撞到自己却死了"的bug,玩家会很困惑。

还有一个边界情况是蛇的长度。初始蛇一般设成3格,如果蛇的长度是1,那么蛇头就是蛇尾,撞自己的判断会永远为真,游戏一开始就结束。所以初始长度至少要2,一般用3比较合适,看起来也自然。

4. 完整实操过程与核心代码实现

4.1 环境搭建与项目初始化

先把环境准备好。除了安装pygame,我建议用一个单独的文件夹来放这个项目,比如snake_game,里面只放一个snake.py文件。这样不会和其他项目混在一起,找起来方便。如果你用VS Code或者PyCharm,直接打开这个文件夹就行。运行的时候在终端里进入这个文件夹,输入python snake.py或者python3 snake.py。

代码的开头部分先导入库和定义常量:

import pygame import random import sys # 初始化pygame pygame.init() # 常量定义 WINDOW_SIZE = 600 GRID_SIZE = 20 GRID_COUNT = WINDOW_SIZE // GRID_SIZE FPS = 10 # 颜色定义 BLACK = (0, 0, 0) WHITE = (255, 255, 255) GREEN = (0, 255, 0) DARK_GREEN = (0, 200, 0) RED = (255, 0, 0) GRAY = (40, 40, 40) # 创建窗口 screen = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock()

这里FPS = 10表示每秒刷新10次,蛇每秒移动10格。这个速度对初学者来说比较友好,不会太快反应不过来。如果你觉得慢,可以改成15或20,但不要超过30,否则蛇会快得看不清。GRID_COUNT是网格数量,600除以20等于30,所以是30x30的网格。

4.2 游戏状态变量的初始化

接下来定义游戏的状态变量。这些变量需要在游戏重新开始时重置,所以我建议把它们放在一个函数里,或者用一个字典来管理。为了简单起见,我先用全局变量:

def init_game(): snake = [(15, 15), (14, 15), (13, 15)] direction = (1, 0) food = generate_food(snake) score = 0 game_over = False return snake, direction, food, score, game_over

蛇的初始位置在中间偏左,方向向右。generate_food函数负责生成食物,传入蛇的列表是为了避免食物生成在蛇身上:

def generate_food(snake): while True: x = random.randint(0, GRID_COUNT - 1) y = random.randint(0, GRID_COUNT - 1) if (x, y) not in snake: return (x, y)

这个while循环会一直执行,直到找到一个不在蛇身上的坐标。理论上如果蛇填满了整个网格,这个循环会死循环,但实际上蛇不可能填满30x30=900个格子,所以不用担心。不过为了代码的健壮性,你可以加一个最大尝试次数,超过就返回None,然后判断游戏胜利。这个扩展我后面会提到。

4.3 主循环的完整实现

主循环是游戏的心脏,它每一帧做四件事:处理事件、更新蛇的位置、检测碰撞、渲染画面。先看事件处理:

for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_UP and direction != (0, 1): direction = (0, -1) elif event.key == pygame.K_DOWN and direction != (0, -1): direction = (0, 1) elif event.key == pygame.K_LEFT and direction != (1, 0): direction = (-1, 0) elif event.key == pygame.K_RIGHT and direction != (-1, 0): direction = (1, 0)

注意方向判断里的条件。比如按上键时,只有当当前方向不是向下(0, 1)时才允许改变。这就是防止蛇掉头的逻辑。这里有个细节:如果蛇正在向右走,玩家快速按了上和左,按照代码逻辑,先按上会把方向改成向上,再按左时因为当前方向是向上,左和上不相反,所以方向会变成向左。但实际上蛇在这一帧里只会移动一次,所以最终方向是向左,蛇会从向右直接变成向左,撞到自己。这个问题叫做"快速按键导致的掉头bug",解决办法是用一个队列来缓存按键,每帧只处理一个。不过对于初学者来说,这个问题出现的概率不高,可以先不管,等后面扩展的时候再优化。

更新蛇的位置:

new_head = (snake[0][0] + direction[0], snake[0][1] + direction[1]) snake.insert(0, new_head) if new_head == food: score += 1 food = generate_food(snake) else: snake.pop()

这段代码先算出新蛇头的坐标,插入到列表头部。如果新蛇头吃到了食物,就重新生成食物,不删蛇尾;否则删掉蛇尾。逻辑很清晰。

碰撞检测:

if (new_head[0] < 0 or new_head[0] >= GRID_COUNT or new_head[1] < 0 or new_head[1] >= GRID_COUNT): game_over = True if new_head in snake[1:]: game_over = True

第一段检测撞墙,第二段检测撞自己。注意snake[1:]排除了蛇头本身,因为蛇头肯定在列表里。但这里还有个问题:蛇尾在当前帧会被删掉,所以蛇头撞到蛇尾的位置不应该算撞。严格来说应该用snake[1:-1]来排除蛇尾。不过因为蛇尾在下一帧就消失了,实际游戏中这个边界情况很少遇到,而且即使遇到了,玩家也只会觉得"差一点点就撞到了",不会觉得是bug。如果你追求完美,可以改成snake[1:-1]。

渲染部分:

screen.fill(BLACK) # 画网格线 for x in range(0, WINDOW_SIZE, GRID_SIZE): pygame.draw.line(screen, GRAY, (x, 0), (x, WINDOW_SIZE)) for y in range(0, WINDOW_SIZE, GRID_SIZE): pygame.draw.line(screen, GRAY, (0, y), (WINDOW_SIZE, y)) # 画蛇 for i, segment in enumerate(snake): color = GREEN if i == 0 else DARK_GREEN rect = pygame.Rect(segment[0] * GRID_SIZE, segment[1] * GRID_SIZE, GRID_SIZE, GRID_SIZE) pygame.draw.rect(screen, color, rect) # 画食物 food_rect = pygame.Rect(food[0] * GRID_SIZE, food[1] * GRID_SIZE, GRID_SIZE, GRID_SIZE) pygame.draw.rect(screen, RED, food_rect) # 显示分数 font = pygame.font.SysFont("simhei", 24) score_text = font.render(f"分数: {score}", True, WHITE) screen.blit(score_text, (10, 10)) pygame.display.flip() clock.tick(FPS)

网格线是可选的,画上去会让画面更有层次感,但也会稍微增加渲染负担。蛇头用亮绿色,蛇身用暗绿色,这样一眼就能看出蛇头在哪。分数显示在左上角,用的是黑体字,如果你系统里没有黑体,可以换成pygame.font.SysFont(None, 24)用默认字体。

4.4 游戏结束与重新开始

游戏结束后,不能直接退出,要给玩家一个重新开始的机会。我的做法是在游戏结束时显示"游戏结束"和最终分数,然后等待玩家按键:

if game_over: font = pygame.font.SysFont("simhei", 48) over_text = font.render("游戏结束", True, RED) screen.blit(over_text, (WINDOW_SIZE // 2 - 100, WINDOW_SIZE // 2 - 50)) font_small = pygame.font.SysFont("simhei", 24) restart_text = font_small.render("按R键重新开始", True, WHITE) screen.blit(restart_text, (WINDOW_SIZE // 2 - 80, WINDOW_SIZE // 2 + 20)) pygame.display.flip() for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN and event.key == pygame.K_r: snake, direction, food, score, game_over = init_game()

这段代码放在主循环的碰撞检测之后、渲染之前。注意游戏结束时也要处理QUIT事件,否则窗口关不掉。按R键会调用init_game()重置所有状态,游戏重新开始。

5. 常见问题与排查技巧实录

5.1 蛇移动卡顿或画面闪烁怎么办

这是初学者最常遇到的问题。画面闪烁通常是因为没有用pygame.display.flip()或者pygame.display.update()来刷新屏幕,而是直接在每个绘制操作后刷新。正确的做法是先把所有东西画到一个缓冲区里,最后统一刷新。screen.fill(BLACK)会清空整个屏幕,然后重新画所有元素,最后flip()把缓冲区的内容显示出来。如果你在画蛇的过程中调用了flip(),就会看到蛇一段一段地出现,产生闪烁。

卡顿的原因可能是帧率设置得太高,或者电脑性能不够。clock.tick(FPS)会限制帧率,如果你的FPS设成了60,但电脑跑不动,就会卡顿。贪吃蛇不需要60帧,10到15帧足够了。另外,如果你在循环里做了很多不必要的计算,比如每次都重新创建字体对象,也会影响性能。字体对象应该在外面创建一次,循环里重复使用。

5.2 蛇吃到食物后没有变长

这个问题通常是snake.pop()的位置写错了。正确的逻辑是:先插入新蛇头,然后判断是否吃到食物,如果吃到了就不pop,否则pop。如果你先pop再判断,蛇就永远不会变长。还有一种可能是食物生成在了蛇身上,蛇头移动过去的时候没有触发new_head == food的判断,因为食物已经被蛇身覆盖了。检查一下generate_food函数是否正确排除了蛇身坐标。

5.3 按键没反应或方向控制不灵敏

按键没反应通常是事件处理的问题。pygame.event.get()会返回所有待处理的事件,如果你在循环里多次调用它,后面的调用会拿到空列表。确保你只在一个地方调用pygame.event.get(),并且处理了所有需要的事件类型。方向控制不灵敏可能是因为帧率太低,按键事件在两帧之间被丢弃了。可以适当提高FPS,或者用pygame.key.get_pressed()来获取持续按键状态,而不是依赖KEYDOWN事件。

5.4 游戏结束后窗口关不掉

这是因为游戏结束的循环里没有处理QUIT事件。我在上面的代码里已经加了,但如果你自己写的时候忘了,就会出现窗口关不掉的情况。解决办法是在游戏结束的等待循环里也加上for event in pygame.event.get()的处理逻辑。这是一个很容易忽略的细节,我当初就踩过这个坑,游戏结束后只能强制关掉终端。

5.5 常见问题速查表

问题现象可能原因解决方法
画面闪烁没有统一刷新用flip()在最后统一刷新
蛇不变长pop()位置错误先插入蛇头,判断食物后再决定是否pop
按键没反应多次调用event.get()只在一个地方处理事件
蛇撞到自己但没死碰撞检测排除了蛇尾用snake[1:-1]排除蛇尾
食物生成在蛇身上没有检查蛇身坐标在generate_food里循环检查
游戏结束关不掉窗口没处理QUIT事件在结束循环里加QUIT处理
蛇移动速度太快FPS设置过高降低FPS到10-15
分数不显示字体对象创建失败用默认字体或检查系统字体

6. 扩展玩法与进阶优化建议

6.1 加个计分板和最高分记录

现在的分数只显示在左上角,比较简陋。你可以做一个计分板,用矩形框起来,字体也大一点。最高分记录可以用一个文件来存,每次游戏结束时比较当前分数和最高分,如果破了记录就更新文件。读取文件用open("highscore.txt", "r"),写入用open("highscore.txt", "w")。注意文件不存在的时候要处理异常,否则第一次运行会报错。

def load_highscore(): try: with open("highscore.txt", "r") as f: return int(f.read()) except: return 0 def save_highscore(score): with open("highscore.txt", "w") as f: f.write(str(score))

6.2 让蛇随着分数增加而加速

这是一个很经典的扩展,能让游戏越来越刺激。实现方法很简单:每次吃到食物时,把FPS增加一点。比如初始FPS是10,每吃一个食物加0.5,上限是25。这样蛇会越来越快,难度逐渐提升。注意FPS要用浮点数,clock.tick()接受浮点数参数。

FPS = min(25, FPS + 0.5)

6.3 加个开始界面和暂停功能

开始界面可以用一个简单的状态变量来控制,比如game_state = "menu",在菜单状态下显示标题和"按空格开始",按空格后切换到"playing"状态。暂停功能可以用P键切换,暂停时clock.tick()不调用,蛇就不动了。这些状态管理用简单的if-else就能实现,不需要复杂的状态机。

6.4 用面向对象的方式重写

如果你已经熟悉了面向对象,可以把蛇、食物、游戏分别封装成类。Snake类管理蛇的移动和增长,Food类管理食物的生成,Game类管理主循环和状态。这样代码会更清晰,也更容易扩展。不过对于150行的项目来说,面向过程也完全够用,不必为了OOP而OOP。等你觉得代码开始变得混乱的时候,再重构也不迟。

6.5 加音效和背景音乐

pygame的mixer模块可以播放音效。你需要准备两个wav文件,一个用于吃到食物,一个用于游戏结束。加载音效用pygame.mixer.Sound("eat.wav"),播放用sound.play()。背景音乐用pygame.mixer.music.load()和pygame.mixer.music.play(-1),-1表示循环播放。注意音效文件要放在项目文件夹里,路径用相对路径。如果找不到文件,pygame会报错,所以最好加个try-except。

7. 我踩过的坑和给你的实操建议

第一个坑是坐标系搞反。我一开始把x和y弄混了,蛇往右走的时候实际上在往下走,调试了半天才发现。记住:(x, y)里x是列,y是行,x增加是向右,y增加是向下。画矩形的时候pygame.Rect(x * GRID_SIZE, y * GRID_SIZE, ...),第一个参数是x,第二个是y,不要写反。

第二个坑是方向判断的条件。我一开始写的是if event.key == pygame.K_UP and direction != (0, 1),看起来没问题,但实际上如果蛇正在向右走,玩家按上键,方向变成向上,然后玩家快速按左键,方向变成向左,蛇就掉头撞死自己了。解决办法是用一个方向队列,每帧只从队列里取一个方向。不过对于初学者来说,这个问题出现的概率不高,可以先不管,等后面扩展的时候再优化。

第三个坑是食物生成在蛇身上。我一开始没检查,结果食物有时候会出现在蛇的身体下面,玩家看到食物却吃不到,以为是bug。后来加了if (x, y) not in snake的判断就好了。这个检查一定要做,不然体验很差。

第四个坑是游戏结束后窗口关不掉。我一开始在游戏结束的循环里只处理了R键,忘了处理QUIT事件,结果窗口关不掉,只能强制结束进程。后来在结束循环里也加了for event in pygame.event.get()的处理逻辑就好了。

第五个坑是字体问题。我用pygame.font.SysFont("simhei", 24)的时候,在某些系统上找不到黑体,会报错。解决办法是用pygame.font.SysFont(None, 24)用默认字体,或者用pygame.font.Font("simhei.ttf", 24)指定字体文件路径。如果你要发布给别人玩,最好把字体文件一起打包,或者用默认字体。

最后分享一个小技巧:如果你想让蛇看起来更圆润,可以用pygame.draw.circle()画圆形蛇身,或者用pygame.draw.rect()的border_radius参数画圆角矩形。border_radius=5就能让方块变成圆角,视觉效果会好很多。这个参数在pygame 2.0以上版本才支持,如果你用的是老版本,就只能用圆形了。

代码写完之后,我建议你至少玩上十局,感受一下蛇变长之后的操作难度,观察一下有没有卡顿或者bug。然后试着改一改参数,比如把窗口改成800x800,把格子改成40像素,看看效果有什么不同。再试着加一个功能,比如让食物有10%的概率是金色的,吃到金色食物加3分。这些改动都不难,但能让你真正理解每一行代码的作用。贪吃蛇虽然简单,但它是你从"学语法"到"做项目"的第一步,走稳了,后面的路就好走了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询