简介:基于STM32战舰V3开发板的贪吃蛇游戏完整工程,面向单片机初学者与嵌入式系统开发者,演示如何在STM32平台上从零实现经典小游戏。工程覆盖开发环境搭建、LCD屏幕显示、按键中断、定时器帧率控制,以及蛇移动、食物生成、碰撞检测、得分等游戏逻辑,并针对性能做了简单优化;代码基于STM32标准外设库编写,涉及定时器、时钟、GPIO、LCD驱动等模块,配有可编译的Keil工程。压缩包共206个文件,包含h头文件、c源文件、uvprojx工程配置、编译生成的hex/axf可执行文件、bat清理脚本与说明文档等,总计约31.86MB。已有1348人学习/下载,适合希望完整走通STM32外设编程、理解嵌入式游戏设计思路的读者。通过这份资料,可直接烧录体验游戏,也可参考其模块划分与代码风格,迁移到其他开发板或复杂交互项目中,是一份不错的实践范例。 说来也怪,我电脑里有个文件名一直没舍得删,就叫“贪吃蛇.zip”。最早是大学课程设计交作业时随手打的包,后来工作了,又有一版是给新同事做入门演示重新整理的。别看名字不起眼,这个 zip 里装的东西其实很能反映一个开发者对“练手项目”的态度:代码写没写干净、说明文档有没有、别人解压能不能直接跑。
很多人以为贪吃蛇很简单——不就是一条蛇吃格子吗?可真要你从头写一遍,还要写得规范、能打包分享,问题就来了:蛇怎么平滑拐弯?食物为什么偶尔生成在蛇身上?窗口为什么一闪就退?这些坑我基本都踩过。这篇就从一份“能拿得出手的贪吃蛇.zip”出发,把项目设计、核心逻辑、完整代码、打包流程和排错经验一次性讲透。适合刚学完 Python 想用 pygame 做第一个小游戏的读者,也适合想把自己练手项目整理成作品集的开发者。
1. 项目整体设计与思路拆解
1.1 为什么选贪吃蛇当练手项目
选贪吃蛇,不是因为网上代码多可以抄,而是因为这个项目在一个极小的规模里,覆盖了游戏开发几乎所有的核心概念:循环、输入处理、数据更新、碰撞检测、随机生成、状态切换。它的逻辑量刚好比“计算器”复杂一个量级,又比“俄罗斯方块”简单一个量级,做完之后不会因为代码太多而劝退,又能真正建立“把需求拆解成代码”的信心。
我在带新手时经常说一句话:如果你能独立写出一版不抄别人的贪吃蛇,你就已经具备写任何 2D 小游戏的基础能力了。因为后续做平台跳跃、打砖块、甚至简单 Roguelike,底层都是同一套“游戏循环 + 状态更新 + 碰撞响应”的骨架,只是表现层和规则不同。
从课程设计角度看,贪吃蛇也特别好交差:界面直观、可演示性强、扩展点丰富——加音效、加关卡、加速、存档,每一样都是现成的“加分项”。这也是为什么各种“贪吃蛇.zip”在毕业季反复出现。可惜大多数打包版本质量堪忧,代码和资源混在一起,没有 README,甚至连入口文件都要猜。所以我把自己的项目重新整理了一遍,按“可交付、可复现、可分享”的标准来做。
1.2 技术方案选型:为什么锁定 Python + pygame
实现贪吃蛇的语言选择很多:C、JavaScript、甚至 Excel 宏都能做。但我在这个项目里选 Python + pygame,理由是“上手门槛最低,环境搭建最快,代码表达最贴近逻辑本身”。
- 用 C 写,光是窗口初始化和事件循环就要几十行,重点全被底层细节带走了;
- 用 JavaScript 写,效果可以直接放到网页里,但还没有接触过前端的读者会被 HTML、CSS、DOM 这些东西干扰;
- 用 Python 自带的 turtle 库也能做,但 turtle 可视化速度慢,做动画效果还行,做需要即时响应的游戏体验不够顺畅。
pygame 的主要优势在于:它把窗口创建、键盘事件、图形绘制、帧率控制这些脏活都封装好了,代码写起来几乎和思路一一对应。你说“蛇向右移动”,代码里就是direction = (20, 0);你说“蛇头撞到身体”,就是一个new_head in snake的成员判断。这种“所想即所写”的体验,对初学者建立正反馈很重要。
当然,pygame 也有它的脾气,比较典型的是坐标系统从左上角为原点、x 轴向右、y 轴向下,而且默认窗口不会帮你限制帧率。所以我常在代码里加上tick()控制速度,同时把蛇的移动单位、窗口尺寸、格子大小这些“魔法数字”统一放在文件顶部,方便后续调整。
2. 核心逻辑解析与实现要点
2.1 蛇的移动:坐标数组就是一切
贪吃蛇最核心的数据结构不是“蛇”,而是一个坐标列表。每一节身体都是一个二元组,比如(100, 100)表示 x=100、y=100 的格子,整个蛇就是一串这样的坐标,列表头部是蛇头,尾部是蛇尾。
理解了这个结构,移动逻辑就变得很清晰。蛇的每次移动,本质上只有三步:
- 根据方向计算出新的蛇头坐标;
- 把这个新蛇头插入列表头部;
- 如果本轮没吃到食物,就把列表尾部的坐标弹出。
这其实是数据结构里“队列”的经典应用。如果你把蛇的移动理解成“头进尾出”,就会发现不需要真的把每一节都往前挪一格,那既麻烦又容易出错。用列表的insert(0, ...)和pop()就能完成整个移动过程。很多初学朋友写贪吃蛇卡壳,就是卡在把蛇想成了“一整条”,而不是“一串点”。
方向控制也有一个容易踩的坑:蛇不能瞬间掉头。如果当前正在向右移动,你按了左键,下一帧蛇头就会直接扎进自己的身体。解决思路是在按键事件里先判断“新方向是否与当前方向相反”,只有方向一致或垂直时才允许改变。判断方法是对比坐标增量,比如向右是(20, 0),那么向左是(-20, 0),两者相加正好等于(0, 0)。我在代码里就是利用这个特性做的限制:当新方向的增量加上当前方向的增量不等于零时,才更新方向。
2.2 食物生成与碰撞检测:随机与判定的平衡
食物生成看起来简单,就是取一个随机坐标嘛。但有几个细节直接影响体验:
- 食物必须落在网格交叉点上,否则蛇永远吃不到它。因为蛇的坐标是
(100, 100)这种按格子对齐的值,食物的随机坐标也必须按格子大小取整; - 食物最好不要生成在蛇身上。如果随机坐标准备生成时刚好落在蛇的身体范围里,常见做法是重新随机一次;
- 食物和蛇头用“坐标相等”判断,而不是“距离小于某个值”。因为蛇头移动单位恰好等于格子大小,所以只要坐标完全相等,就认为吃到了。
碰撞检测则是整个游戏的生死线。我把它分成两类:一类是撞墙检测,检查新蛇头的 x、y 是否超出窗口边界;另一类是撞自己检测,检查新蛇头是否已经存在于蛇身坐标列表中。这两个条件只要有一个成立,游戏就立即结束。这里要特别注意,检测时机应该在“插入新蛇头之前”,判断的是“下一步会不会死”,而不是等死完再去补救。
我在实际写的时候还加了一个小优化:检测撞自己时,允许蛇头与蛇身最后一位“重叠”。这是因为蛇在移动时尾部会同步移走,视觉上蛇头即将到达的位置如果是旧尾位置,并不会真的相撞。不过这个属于锦上添花,新手版本不加也不影响。
2.3 游戏循环与状态管理:一个状态机搞定全部
pygame 程序本质上就是一个“无限循环”:不断读事件、更新数据、重绘画面、限制帧率。这个循环的控制变量我习惯叫running,它一旦变成False,游戏就退出。
除了主循环,我还把游戏状态拆成了三个:运行中、临时暂停、游戏结束。初学者往往想用一堆if嵌套来控制,很快代码就乱成一团。更清晰的做法是用一个状态变量,比如game_state取值"playing"、"over"、"restart",然后在不同状态下执行不同逻辑。
我的这个版本里做了简化,只用game_over一个布尔值控制结束状态。但即便只是这样一个开关,也够说明状态管理的重要性:程序判断输入的位置、更新蛇的逻辑、绘制的内容,都会因为“游戏是否结束”而不同。等读者以后做更复杂的游戏,再把它推广成完整的有限状态机也不迟。
3. 实操过程与核心代码实现
3.1 规范的项目结构,从 zip 解压开始
收到一个“贪吃蛇.zip”之后,最怕看到什么?一堆文件名是111.py、222.py,没有任何说明,资源的图片也不知道放哪。所以我这次打包前先规定了结构:
snake-game/ ├── main.py # 入口文件,直接运行这个 ├── requirements.txt # 依赖清单 ├── README.md # 项目说明 └── assets/ # 资源目录,放音效、图标 └── .gitkeep这个结构不复杂,但它说清楚了三件事:怎么启动、需要装什么、每个文件是干嘛的。assets 目录先留空,用.gitkeep占位,等以后加音效和图标时不用改动代码里的路径逻辑。requirements.txt是给使用pip install -r requirements.txt的人准备的,也是判断一个 Python 项目“专业与否”的标尺之一。
如果可以,我还会把游戏版本写进README.md,哪怕只是v1.0。别小看这行字,几周后你自己再看这个 zip,能立刻知道自己当时做到哪一步。
3.2 核心代码分步实现
下面这份代码是根据我自己项目的核心版精简出来的,功能完整,逻辑清楚,直接复制就能跑:
import pygame import random import sys # 常量统一放这里,方便调整 WINDOW_WIDTH = 640 WINDOW_HEIGHT = 480 CELL_SIZE = 20 FPS = 10 pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock() # 蛇的初始状态:三段,水平排列,向右移动 snake = [(100, 100), (80, 100), (60, 100)] direction = (20, 0) change_to = direction # 随机生成食物,保证落在网格点上 def random_food(): x = random.randint(0, WINDOW_WIDTH // CELL_SIZE - 1) * CELL_SIZE y = random.randint(0, WINDOW_HEIGHT // CELL_SIZE - 1) * CELL_SIZE while (x, y) in snake: x = random.randint(0, WINDOW_WIDTH // CELL_SIZE - 1) * CELL_SIZE y = random.randint(0, WINDOW_HEIGHT // CELL_SIZE - 1) * CELL_SIZE return (x, y) food = random_food() score = 0 game_over = False def draw(): screen.fill((20, 30, 20)) for segment in snake: pygame.draw.rect(screen, (50, 220, 80), (segment[0], segment[1], CELL_SIZE, CELL_SIZE)) pygame.draw.rect(screen, (30, 30, 30), (segment[0], segment[1], CELL_SIZE, CELL_SIZE), 1) pygame.draw.rect(screen, (220, 50, 50), (food[0], food[1], CELL_SIZE, CELL_SIZE)) font = pygame.font.SysFont("simhei", 24) text = font.render(f"得分: {score}", True, (255, 255, 255)) screen.blit(text, (10, 10)) pygame.display.update() while not game_over: 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, 20): change_to = (0, -20) elif event.key == pygame.K_DOWN and direction != (0, -20): change_to = (0, 20) elif event.key == pygame.K_LEFT and direction != (20, 0): change_to = (-20, 0) elif event.key == pygame.K_RIGHT and direction != (-20, 0): change_to = (20, 0) direction = change_to head_x, head_y = snake[0] new_head = (head_x + direction[0], head_y + direction[1]) # 撞墙和撞自己检测 if (new_head[0] < 0 or new_head[0] >= WINDOW_WIDTH or new_head[1] < 0 or new_head[1] >= WINDOW_HEIGHT or new_head in snake): game_over = True continue snake.insert(0, new_head) if new_head == food: score += 1 food = random_food() else: snake.pop() draw() clock.tick(FPS) # 结束画面 font = pygame.font.SysFont("simhei", 36) text = font.render(f"游戏结束!得分: {score}", True, (255, 255, 255)) screen.blit(text, (WINDOW_WIDTH // 2 - 150, WINDOW_HEIGHT // 2 - 30)) pygame.display.update() pygame.time.delay(2000) pygame.quit()这段代码里有几个细节值得你细看。
方向限制那里,direction != (0, 20)表示“如果当前不是向下,才允许改为向上”。但这里有个隐藏问题:如果玩家在一帧内快速按下两个键,比如先按上再按左,由于direction还没有被更新为向上,程序可能因为旧方向判断而允许一个不合理的转向。严格的处理应该把change_to也纳入判断,用“即将生效的方向”而不是“当前方向”来限制。我这里的核心版为了可读性先用了简单判断,理解原理之后可以自己加强。
random_food()函数里有一个while (x, y) in snake的循环,保证了食物不会生成在蛇身上。这一步在蛇很长的时候尤其重要,否则食物会“埋”在蛇身体里,玩家怎么都吃不到,然后开始怀疑人生。虽然极端情况下会出现死循环,但正常游戏里蛇身很难占满整个屏幕,所以这个方案足够优先使用。
吃掉食物后只是给身体增加一个新头,尾部不弹出,这样蛇的长度自然增加一格。这个“增一不减”的操作,正是得分系统的本质:吃到食物并不是加分数这么简单,背后是链条的长度变化。
3.3 跑起来之后的体验调优
代码能跑只是第一步,手感才是游戏体验的分水岭。我第一次写完试玩时,感觉蛇的移动有点“愣”,后来总结出几个优化点:
- 帧率控制:
FPS = 10表示每秒移动 10 格,这个速度对新手正好。想要难度递增,可以让分数每加 5 分,FPS就增加 1。我在进阶版里写过一个FPS = 10 + score // 5的公式,实测下来难度曲线比较平滑; - 蛇身的绘制:身体节之间加了一圈深色描边,
pygame.draw.rect的最后一个参数为 1 时就是只画边框。这样蛇看起来是一节一节的,而不是一根面条,视觉上更好判断碰撞位置; - 结束画面的停留时间:
pygame.time.delay(2000)给玩家 2 秒看清得分再退出,不然窗口“啪”地一下没了,体验非常差。
这些优化都不难,但就是它们决定了这个项目是“能跑的 demo”还是“能玩的游戏”。
4. 打包成 zip 的规范化流程
4.1 压缩包里的组织方式与命名规范
现在项目文件齐全了,下一步就是把它变成“贪吃蛇.zip”。我建议压缩包内部保留一层文件夹,比如snake-game/,而不是把所有文件直接扔在 zip 根目录。这样别人解压后得到一个独立目录,不会把文件散落到当前文件夹里。
命名方面也有一些共识:全英文小写加连字符是相对安全的选择,snake-game比贪吃蛇项目更不容易在某些系统上出怪问题。中文做展示没问题,但作为目录名容易遇到编码问题。zip 压缩的时候,如果用的是 macOS 或 Linux 的命令行,我一般这样操作:
cd 项目所在目录 zip -r 贪吃蛇.zip snake-gameWindows 用户直接在文件夹上右键压缩成 zip 也一样。压缩之前,一定要先把__pycache__目录、.DS_Store文件这种东西清掉。它们里面装的是 pyc 缓存和系统元数据,对运行没有任何帮助,只会让压缩包变大变乱。如果是用 git 管理项目,那.git目录也绝对不要打进去。
4.2 README 与 requirements.txt:别人能不能跑的全靠它们
我见过太多代码本身不错、却因为没写说明而没法跑起来的项目。收到 zip 的人第一件事永远是打开 README,你的 README 至少要写清楚这几项:
- 项目名称和一句话简介;
- 运行环境,比如 Python 3.8+;
- 安装依赖的命令,
pip install -r requirements.txt; - 运行命令,
python main.py; - 操作说明,方向键控制蛇的移动。
requirements.txt 里也别偷懒,写上依赖名和版本限定:
pygame>=2.1.0只写pygame不写版本的话,万一遇到旧版本 API 不同,安装者就会踩坑。加个>=下限版本是最省心的做法,既保证新功能可用,又不会把版本卡死。
4.3 打 zip 的细节操作与避坑
我有一次打包时忘记放进 README,分享出去之后被问了一整天“怎么运行”。从那以后我给自己定了一条规矩:把 zip 打完之后,先解压到一个全新目录,严格按照 README 里的步骤跑一遍。这个“自测打包”的流程虽然多花两分钟,但能挡住绝大多数低级问题。用我自己总结的检查清单是:
- 有没有把
__pycache__或.git打进去; - 解压后入口文件能不能直接执行;
- README 里的路径和文件名是否和实际一致;
- 依赖版本是否标清楚了。
如果你是课程设计提交,我还会建议在压缩包内附加一份简短的报告文件,写上你的设计思路、核心代码片段和运行截图。这个习惯在评审时能带来不少好感分。
5. 常见问题与排查技巧实录
5.1 蛇动不动就“掉头”撞死自己
这是新手最容易遇到的问题。症状是蛇向右走,你按了一下左,游戏立刻结束。原因很简单:代码没有加入反向限制,或者限制判断用的是旧方向而不是即将生效的方向。
我的建议是,把方向判断写成“四个按键各自判断是否与当前方向相反”。同时注意,如果游戏对帧率极其敏感,一次按键事件触发后要尽快更新change_to,避免多次方向累积。更稳健的做法是设置一个“当前帧是否已经转向”的标志,确保一帧内只响应一次方向键。老实说,在FPS = 10的低帧率下,这个问题的发生概率不高,但在速度上来之后会变得非常致命。
5.2 碰撞检测不准确,蛇头“擦边”过去
如果你发现蛇明明应该撞墙却还能多走半步,多半是因为坐标没有按网格对齐。比如蛇头在(98, 100),下一帧移动到(118, 100),而窗口宽度是 640,看起来快出去了却没触发越界判断。解决方法是确保所有移动增量都是CELL_SIZE的整数倍,并且初始位置也在网格交叉点上。我在代码里强制了增量为 20,就是为了避免这类问题。
如果发现蛇头穿过了自己的身体但没游戏结束,另一种情况是撞自己检测用了蛇头旧位置,而不是新位置。正确逻辑应该是:先算出new_head,再检测new_head in snake,顺序反了就会漏判。
5.3 中文显示成方块或窗口闪退
这条几乎每个 pygame 中文用户都会遇到。pygame 默认字体不一定包含中文字形,我代码里用的pygame.font.SysFont("simhei", 24)是优先找 Windows 的“黑体”。但在没有安装中文字体的 Linux 环境下,这句可能回退到默认字体,中文就显示成方块。
比较靠谱的做法是在项目 assets 目录里放一个开源字体文件,比如思源黑体的 ttf,然后用pygame.font.Font("assets/font.ttf", 24)加载。这样无论用户系统里有没有中文字体,都能正常显示。窗口闪退则多半是因为代码里没有pygame.quit()或底部事件循环写错。我的调试经验是:把整个程序用python main.py从终端运行,闪退时看终端里的报错信息,比在 IDE 里瞎找快得多。
下面是几个常见问题的排查速查表:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 窗口一闪而过 | 主循环没写或异常退出 | 检查while not game_over和pygame.quit() |
| 蛇不能移动 | direction初始化或更新错误 | 在循环内把change_to赋值给direction |
| 吃到食物但没变长 | 尾部没有正确处理 | 只有吃到食物时才不执行pop() |
| 食物刷在蛇身上 | 随机坐标未排除蛇身 | 用while (x, y) in snake重新随机 |
| 中文显示方块 | 系统缺少中文字体 | 用项目内字体文件加载 |
写在最后
把“贪吃蛇.zip”从一份随手交差的作业,变成一份结构规范、开箱即跑的分享包,这件事本身没多难,难的是愿意多走那几步。而我个人在实际操作中体会最深的一点是:当你开始为“别人拿到后能不能顺利跑起来”操心时,你的编码习惯就已经在改变了。
最后再分享一个小技巧:如果你以后打算长期维护这类小项目,建议用 git 管理版本,每次打完 zip 后打一个 tag,比如v1.0。这样 github 上也好、本地也好,别人拿到手的压缩包永远对应一个明确的版本。想继续扩展的话,加音效、加障碍物、加 AI 自动移动的敌人,从这份代码往上叠加都很轻松。希望这个简陋但完整的“贪吃蛇.zip”,能成为你第一个拿得出手的项目作品。
本文还有配套的精品资源,点击获取