我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Python贪吃蛇实战:150行代码掌握事件驱动与游戏循环

Python贪吃蛇实战:150行代码掌握事件驱动与游戏循环 1. 为什么我建议你用贪吃蛇来练手Python贪吃蛇这个游戏几乎每个学编程的人都见过甚至自己动手写过。但你可能不知道它其实是检验一个Python初学者是否真正理解事件驱动编程和游戏循环这两个核心概念的最佳试金石。150行代码听起来不多但要把贪吃蛇写得流畅、不卡顿、没有bug其实涉及了坐标系管理、碰撞检测、键盘事件响应、帧率控制、动态渲染等多个知识点。我见过太多人学完Python基础语法之后不知道该做什么对着教程抄了一遍又一遍关掉教程还是写不出东西。问题出在哪里出在缺少一个麻雀虽小五脏俱全的项目来串联所有零散的知识点。贪吃蛇恰好就是这样一个项目。它不需要你懂数据库不需要你懂网络编程甚至不需要你懂面向对象的高级特性当然用类来写会更优雅。你只需要理解列表怎么用、循环怎么写、条件判断怎么嵌套再加上一个图形库的基本调用就能跑起来一个完整的、可玩的游戏。这种即时反馈带来的成就感是刷一百道算法题都给不了的。而且当你亲手写出来的蛇在屏幕上吃到食物、身体变长、撞墙结束的那一刻你会真正理解什么叫程序是活的。这篇文章面向的是已经学过Python基础语法知道列表、字典、循环、函数怎么用但还没有完整做过一个项目的朋友。我会从零开始把每一行代码背后的逻辑都讲清楚不是让你复制粘贴就完事而是让你理解为什么这么写、换一种写法行不行、遇到问题怎么排查。代码你可以直接复制运行但我更希望你看完之后能自己改一改比如加个计分板、加个音效、让蛇加速这些扩展我会在最后提到。整个项目我用的是pygame这个库它是Python里做2D游戏最成熟、文档最全的选择安装也简单一行命令搞定。2. 项目整体设计与技术选型思路2.1 为什么选pygame而不是turtle或tkinterPython能做图形界面的库不少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循环会一直执行直到找到一个不在蛇身上的坐标。理论上如果蛇填满了整个网格这个循环会死循环但实际上蛇不可能填满30x30900个格子所以不用担心。不过为了代码的健壮性你可以加一个最大尝试次数超过就返回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_radius5就能让方块变成圆角视觉效果会好很多。这个参数在pygame 2.0以上版本才支持如果你用的是老版本就只能用圆形了。代码写完之后我建议你至少玩上十局感受一下蛇变长之后的操作难度观察一下有没有卡顿或者bug。然后试着改一改参数比如把窗口改成800x800把格子改成40像素看看效果有什么不同。再试着加一个功能比如让食物有10%的概率是金色的吃到金色食物加3分。这些改动都不难但能让你真正理解每一行代码的作用。贪吃蛇虽然简单但它是你从学语法到做项目的第一步走稳了后面的路就好走了。
返回列表