简介:一款基于STM32战舰V3开发板的贪吃蛇游戏完整工程包,面向嵌入式入门学习者与STM32单片机爱好者,既能体验经典游戏,也可用来熟悉LCD显示、定时器中断和GPIO按键等常见外设编程。整套代码包含游戏核心逻辑,如蛇的移动、食物随机生成、碰撞检测、得分显示和边界检测,通过LCD屏幕与按键实现完整交互,覆盖从硬件初始化到游戏循环的典型开发流程。压缩包内共206个文件,以C源文件、H头文件、Keil工程配置及编译中间文件为主,同时提供hex烧录文件、axf调试文件、LCD驱动代码和几张工程运行截图,整体大小31.86MB,便于直接打开工程进行编译与烧录,目录结构也比较清晰,适合对照学习。对想要从零搭建项目或快速验证代码的读者,可直接复用这套工程,显著减少环境配置与底层驱动编写的时间,并借助定时器中断、GPIO与LCD显示系统练习STM32开发技能,提升嵌入式调试能力。目前已有1350人学习/下载,适合作为学习STM32后的第一个完整实战项目,也方便后来者对照检查自己的实现。
1. 这个 zip 里的贪吃蛇:一条能跑在 STM32 开发板上的完整单片机工程
拿到「贪吃蛇.zip」的第一眼,别急着把它当成教学 Demo——它其实是一套为 STM32 开发板整理的 STM32 单片机工程,把按键扫描、定时中断、状态机、LCD 局部刷新和随机数生成全部揉进了同一个项目里。对正在学单片机、想找个裸机项目练手的人来说,这套东西最大的价值不是“能玩”,而是把游戏里那些反直觉的细节都处理好了:蛇身移动不是把数组整体平移,而是用环形索引;按键不能按下就立刻改方向,否则会反向自杀;屏幕全屏刷新会闪到你怀疑屏坏了。解压后烧录就能跑,需要改的只剩引脚映射和屏幕参数。
2. 先把裸机游戏的框架立住:状态机、数据结构与主循环时序
2.1 状态机:把“开始—暂停—结束”变成一张转移表
裸机上跑游戏,最忌讳的就是在按键中断里直接改游戏状态。按键是异步事件,游戏逻辑有自己的节奏,两者如果没有中间层,就会出现“按一下暂停、再按一下直接重新开始”之类的乱象。常见做法是定义一组离散状态,按键只负责“提出请求”,真正的状态迁移放到主循环的游戏更新函数里做。
typedef enum { GAME_IDLE = 0, GAME_RUNNING, GAME_PAUSE, GAME_OVER } game_state_t; typedef struct { game_state_t state; uint16_t score; uint16_t best_score; } game_control_t;这组枚举把整个游戏切成了四个可管理的节点。GAME_IDLE是上电后的待机画面,GAME_RUNNING是蛇正在移动的阶段,GAME_PAUSE处理临时中断,GAME_OVER负责结算和显示。状态机的好处在于:任何时刻你都能明确知道当前处于哪个节点,按键事件的响应也变成了一张查表,而不是一堆散落的 if 判断。
下表是我在这个工程里习惯维护的状态迁移表,新手可以直接照搬:
| 当前状态 | 触发事件 | 下一状态 | 处理动作 |
|---|---|---|---|
| GAME_IDLE | 开始键按下 | GAME_RUNNING | 重置蛇身坐标与分数,生成第一个食物 |
| GAME_RUNNING | 暂停键按下 | GAME_PAUSE | 冻结逻辑节拍,保留当前画面 |
| GAME_PAUSE | 暂停键再次按下 | GAME_RUNNING | 恢复逻辑节拍,继续移动 |
| GAME_RUNNING | 碰撞检测返回真 | GAME_OVER | 停止移动,刷新最终分数 |
注意状态迁移本身只改state变量,不直接操作蛇身数组。重置蛇身、生成食物这些重活全部放在迁移后的处理分支里,这样中断和主循环之间的耦合就被拆开了。工程里如果看到state = GAME_RUNNING; snake_init();这种写法,说明它已经按这个思路组织好了。自己写的时候,也建议把“迁移”和“动作”拆成两个函数,否则状态一多,switch会膨胀到没法维护。
2.2 蛇身数据结构:不用 memmove,用环形数组 O(1) 移动
贪吃蛇最核心的数据结构是蛇身。很多初学版本用固定数组存蛇身坐标,每次移动都memmove把整条蛇平移一格。这在逻辑上说得通,但在 MCU 上是纯浪费:蛇长 20 时每次移动要搬 40 个字节,蛇长 100 时就要搬 200 字节,帧率一高 CPU 全耗在搬内存上。
这个工程用的是环形数组加头尾索引,移动成本与蛇身长度无关,永远只有几次数组写入。
#define GRID_COLS 32 #define GRID_ROWS 24 #define MAX_SNAKE_LEN (GRID_COLS * GRID_ROWS + 1) typedef struct { uint8_t x; uint8_t y; } grid_pos_t; typedef struct { grid_pos_t body[MAX_SNAKE_LEN]; uint16_t head; // 蛇头在数组中的下标 uint16_t tail; // 蛇尾在数组中的下标 uint16_t len; // 当前蛇身长度 uint8_t dir; // 当前实际移动方向 uint8_t next_dir; // 已确认但尚未生效的方向 } snake_t;head指向蛇头坐标所在的下标,tail指向蛇尾。每次移动时,head前进一格写入新蛇头坐标;如果没有吃到食物,tail也前进一格,相当于旧蛇尾被覆盖。这个操作的时间复杂度是 O(1),并且不需要搬动任何已有数据。
static void snake_step(snake_t *s, uint8_t grow) { uint8_t nx = s->body[s->head].x; uint8_t ny = s->body[s->head].y; switch (s->dir) { case DIR_UP: ny--; break; case DIR_DOWN: ny++; break; case DIR_LEFT: nx--; break; case DIR_RIGHT: nx++; break; } // 新蛇头写入环形数组的下一个位置 s->head = (s->head + 1) % MAX_SNAKE_LEN; s->body[s->head].x = nx; s->body[s->head].y = ny; if (grow) { s->len++; // 吃到了食物,尾部不动 } else { s->tail = (s->tail + 1) % MAX_SNAKE_LEN; // 没吃到,尾部前进覆盖 } }这里的grow参数由外部在吃到食物时传入 1,否则传 0。特别注意:蛇长只有 1 时,head和tail指向同一个下标,移动后两者可能仍然相等,但这并不代表数组为空。环形数组的空/满判断不能依赖head == tail,必须看len字段。这个坑我在第 5 章避坑部分会专门讲,它曾经让我调试了一整晚。
渲染时需要从head逆序遍历len个格子,由于是环形数组,下标要绕回:
uint16_t idx = s->head; for (uint16_t i = 0; i < s->len; i++) { draw_cell(s->body[idx].x, s->body[idx].y, COLOR_SNAKE); idx = (idx + MAX_SNAKE_LEN - 1) % MAX_SNAKE_LEN; }这套结构还有一个额外收益:检查蛇头撞自身时只需要遍历len个格子,不需要额外维护哈希表。对 MCU 来说,这个遍历成本完全可以接受。
2.3 主循环时序:定时中断只负责“报时”,游戏逻辑在主循环里跑
裸机游戏最容易被忽略的是时序。LCD 刷一屏要几毫秒到几十毫秒不等,如果在定时中断里直接刷新屏幕,中断占用时间过长会挤压主循环,按键扫描就会卡顿。常见做法是把定时中断当成一个“报时器”,只负责置标志位,真正的游戏逻辑更新和渲染放到主循环里做。
volatile uint8_t tick_100ms = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_UPDATE) != RESET) { tick_100ms = 1; TIM_ClearITPendingBit(TIM2, TIM_IT_UPDATE); } } int main(void) { SystemInit(); lcd_init(); key_init(); game_init(); while (1) { key_scan(); if (tick_100ms) { tick_100ms = 0; if (game.state == GAME_RUNNING) { game_update(); } game_render(); } } }TIM2配置成 100ms 溢出一次,溢出中断里只把一个全局标志位置 1。主循环源源不断地跑,key_scan()负责读按键和消抖,game_update()负责移动、碰撞检测、食物生成,game_render()负责局部重绘。这样安排的好处是:即使game_render()因为 LCD 慢导致耗时抖动,逻辑节拍也只会整体后移,不会出现“逻辑更新被屏幕刷新打断”导致的错乱。
逻辑节拍的初始值我一般设 100ms,也就是每秒移动 10 格。这个速度对 320×240 分辨率、格子边长为 10 像素的屏幕来说比较合适。如果屏幕是 128×64 的小屏,格子小、路径短,节拍可以调到 80ms 甚至 60ms。具体参数在工程配置里找TIM_Period,直接改定时器重载值即可。
提示:
tick_100ms要修饰成volatile,否则编译器优化后主循环可能永远读不到更新值。这是 C 语言在嵌入式开发里最经典的“玄学 bug”之一。
3. 在 TFT 上把蛇画出来:渲染策略与像素坐标换算
3.1 逻辑坐标转像素:格子尺寸与坐标公式
游戏逻辑里蛇和食物都用网格坐标表示,但屏幕绘制需要像素坐标。两者之间的映射关系极其简单:网格坐标乘上格子边长。这个工程默认屏幕是 320×240 的 TFT,GRID_COLS和GRID_ROWS分别为 32 和 24,格子边长为 10 像素,正好铺满整个屏幕。
#define CELL_SIZE 10 // 每个网格对应的像素边长 #define COLOR_BG 0xFFFF // 背景色,白色 #define COLOR_SNAKE 0x07E0 // 蛇身,绿色 #define COLOR_FOOD 0xF800 // 食物,红色 static void lcd_draw_cell(uint8_t gx, uint8_t gy, uint16_t color) { uint16_t px = (uint16_t)gx * CELL_SIZE; uint16_t py = (uint16_t)gy * CELL_SIZE; lcd_fill_rect(px, py, CELL_SIZE, CELL_SIZE, color); }lcd_fill_rect是底层驱动提供的画矩形函数,参数分别是矩形左上角 x、左上角 y、宽度、高度和填充色。px和py的计算要注意类型:gx是uint8_t,最大 255,乘上 10 后是 2550,已经超过uint8_t的范围,所以必须强转成uint16_t再乘。工程里如果格子数超过 40 列,这个逗号转换就很容易翻车,轻则坐标溢出,重则画到屏幕外。
如果你手头的屏是 128×64 的 OLED,把CELL_SIZE改成 8,GRID_COLS改成 16、GRID_ROWS改成 8,代码不需要其他改动。万一屏幕分辨率不是标准值,优先保证CELL_SIZE能整除,否则最后一列画不全。
3.2 局部重绘:只画旧尾巴、新头部和食物,不要全屏清屏
很多初学版本在每次逻辑更新后调用lcd_clear()清空屏幕,再重新画整条蛇。这在 320×240 的 TFT 上会非常灾难:lcd_clear本身要写十几万个像素,加上重绘蛇身和食物,单帧耗时可能超过 50ms,人眼看起来就是明显闪烁。用逻辑节拍控制后,你会看到蛇在“一卡一卡”地走,感官还不如普通游戏流畅。
这个工程的处理方式是局部重绘:每次逻辑更新只重画三块区域——旧尾巴(涂回背景色)、新蛇头(涂蛇身色)、食物(涂红色)。原因是蛇每帧只有这几个位置发生变化,蛇身中间部分根本不需要动。
static void game_render(snake_t *s, grid_pos_t *old_tail, grid_pos_t *food, uint8_t tail_moved) { // 旧尾巴涂回背景色 if (tail_moved) { lcd_draw_cell(old_tail->x, old_tail->y, COLOR_BG); } // 新蛇头涂上蛇身颜色 lcd_draw_cell(s->body[s->head].x, s->body[s->head].y, COLOR_SNAKE); // 食物位置涂红色 lcd_draw_cell(food->x, food->y, COLOR_FOOD); // 分数区域单独刷新 lcd_draw_score(game.score); }这里有一个极易踩的坑:old_tail必须在调用snake_step之前备份,因为调用之后tail已经移动了,原来的尾部坐标已经被覆盖。正确顺序是:先备份旧尾巴坐标,再执行snake_step,再把备份传给game_render。我见过有人在snake_step之后去读body[old_tail],读到的是被覆盖后的数据,尾巴区域永远清不干净,屏幕上留下一串残影,看起来像蛇在“拉屎”。
tail_moved参数对应是否吃到了食物:吃到了食物时尾巴不动,不需要擦除;否则必须擦掉。如果省略这个参数,每次重绘都强制擦尾,蛇会越来越短。
3.3 带显存的屏一定要用“窗口写”,不要逐点打点
TFT 屏内部通常有一块 GRAM 显存,驱动芯片(常见的是各种 ILI 系列)支持“地址窗口”模式:先把要写的矩形区域告诉芯片,然后连续写入像素数据,芯片会自动换行。这和逐点调用lcd_set_pixel相比,省去了反复发送坐标命令的开销,速度差距可能有十倍。
void lcd_fill_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { lcd_set_window(x, y, x + w - 1, y + h - 1); // 设置写入窗口 uint32_t pixels = (uint32_t)w * h; for (uint32_t i = 0; i < pixels; i++) { lcd_write_pixel(color); // 连续写像素数据 } }lcd_set_window的四个参数是矩形左上角和右下角坐标,注意这里用的是“右下角坐标”,不是“宽度和高度”,所以要减 1。如果你的驱动里lcd_fill_rect总是差一条边,先看这里是不是多写了 1 像素。配合 SPI DMA 时,可以把这段循环替换成一次 DMA 传输,但要注意 DMA 发送的字节顺序:16 位色深下,每个像素的高字节在前还是低字节在前取决于屏幕驱动芯片,接反了颜色会整体偏色,看起来像胶片负片效果。
注意:用窗口写模式时,如果前一次脏矩形没有刷新完就开启下一次窗口设置,GRAM 写入地址会错位。遇到花屏先确认是否在
lcd_set_window前调用了lcd_wait_ready。
4. 输入与逻辑:方向缓存、碰撞检测与随机食物生成
4.1 方向缓存:为什么不能按下立刻改变 dir
贪吃蛇按键最容易出现的 bug 是“反向自杀”:蛇正在向左移动,你按了右键,如果逻辑层直接把这个方向写入dir,下一帧蛇头就会撞上自己的脖子,游戏直接结束。这个工程把方向拆成dir和next_dir两个变量:dir是当前帧正在生效的方向,next_dir是玩家最近一次合法按键请求的方向,只有在逻辑帧更新时才把next_dir赋值给dir。
void game_update(void) { // 方向缓存只在本帧生效一次 snake.dir = snake.next_dir; // 然后才是移动、碰撞检测等 snake_step(&snake, grow_flag); }按键扫描时要做两层过滤:一是物理消抖,二是方向合法性检查。消抖我一般用计数法,每 5ms 扫描一次按键电平,连续读到稳定电平超过 3 次才算有效,避免机械按键的抖动毛刺。
uint8_t key_debounce(uint8_t raw) { static uint8_t stable_key = 0; static uint8_t cnt = 0; if (raw == stable_key) { cnt++; if (cnt >= 3) { cnt = 0; return stable_key; } } else { stable_key = raw; cnt = 0; } return 0xFF; // 没有有效按键 }方向合法性检查的核心逻辑是:新方向不能是当前移动方向的相反方向,也不能和未生效的next_dir相反。如果玩家在一帧里连续按了“上”和“左”,next_dir应该只保留最后一次合法请求,而不是变成队列。
switch (key) { case KEY_UP: if (snake.dir != DIR_DOWN && snake.next_dir != DIR_DOWN) snake.next_dir = DIR_UP; break; case KEY_DOWN: if (snake.dir != DIR_UP && snake.next_dir != DIR_UP) snake.next_dir = DIR_DOWN; break; case KEY_LEFT: if (snake.dir != DIR_RIGHT && snake.next_dir != DIR_RIGHT) snake.next_dir = DIR_LEFT; break; case KEY_RIGHT: if (snake.dir != DIR_LEFT && snake.next_dir != DIR_LEFT) snake.next_dir = DIR_RIGHT; break; }这里有个细节值得展开:为什么同时判断dir和next_dir?假设蛇正在向右移动,玩家快速按了“上”接着按“左”。如果只检查dir,第一帧“上”写入next_dir,第二帧“左”再写入,下一个逻辑帧会从右直接转向左,仍然撞上自己。但如果加上next_dir的检查,第一帧“上”通过后next_dir变成了上,第二帧“左”与next_dir(上)并不相反,于是也通过。这个现象叫做“二段跳”,是按键时序导致的。
真正稳妥的方案是方向值只保留一个“待生效方向”,并且每次按键先用next_dir而不是dir来做反向判断。按一次方向键,next_dir被更新为最新合法值;逻辑帧开始时才消费它。这样快速连按“上”“左”,最终只会生效一次,蛇不会在单帧里掉头两次。
4.2 碰撞检测:墙、自身、还有“旧尾巴”这个特殊情况
碰撞检测有三个维度:撞墙、撞自身、以及“旧尾巴”这个容易被误判的边界。先看完整代码:
uint8_t check_collision(snake_t *s, uint8_t nx, uint8_t ny, uint8_t grow) { // 墙壁碰撞 if (nx >= GRID_COLS || ny >= GRID_ROWS) { return 1; } // 自身碰撞:从蛇头开始往前遍历 for (uint16_t i = 0; i < s->len; i++) { uint16_t idx = (s->head + MAX_SNAKE_LEN - i) % MAX_SNAKE_LEN; if (s->body[idx].x == nx && s->body[idx].y == ny) { // 如果这一格是旧尾巴,而且本帧不吃食物,它可以被移走 if (!grow && idx == s->tail) { continue; } return 1; } } return 0; }墙壁碰撞最简单,nx和ny超出网格范围就返回 1。这里注意nx和ny的类型是uint8_t,如果格子数超过 255 会溢出,但 32×24 的网格显然不会,可以放心。
自身碰撞的遍历方向是从头部往尾部数。环形数组里从head往前走i步就是第i个蛇身节点。这里有一个微妙的数学关系:如果蛇长 3,head = 2、tail = 0,遍历时i = 0是蛇头,i = 1是中间,i = 2是蛇尾。idx == s->tail时说明这一格是蛇尾。
“旧尾巴”特殊处理的必要性在于:如果本帧没有吃到食物,蛇移动后旧尾巴会被立即覆盖,新蛇头可以安全地进入旧尾巴原本所在的格子。这是贪吃蛇的正常玩法——蛇头追着尾巴跑一圈也不会死,只有在吃到食物导致尾巴不动时,蛇头撞上尾巴才是真死。
提示:碰撞检测务必在
snake_step之前,用新蛇头坐标(nx, ny)而不是直接改head。如果先移动再检测,蛇身数组已经被污染,排查起来非常痛苦。
4.3 随机食物生成:种子、坐标映射和“满屏”边界
食物生成的常规做法是rand() % GRID_COLS取模得到网格坐标,排除蛇身占用格后写入。代码本身不复杂:
void food_spawn(void) { uint16_t x, y; uint8_t overlap; do { x = (uint16_t)(rand() % GRID_COLS); y = (uint16_t)(rand() % GRID_ROWS); overlap = is_snake_cell(x, y); } while (overlap); food.x = (uint8_t)x; food.y = (uint8_t)y; }真正的坑在随机种子。开发板上电后,如果直接srand(0),每次运行的随机序列完全相同,食物会出现一模一样的开局位置。这个工程里我见到最靠谱的做法是用定时器计数器和按键累计值做种子:
void random_seed_init(void) { uint32_t seed = TIM2->CNT; seed ^= (uint32_t)key_press_count << 16; seed ^= (uint32_t)ADC1->DR; srand(seed); }TIM2->CNT在上电后是不断变化的自由计数器,key_press_count记录玩家按键次数,ADC1->DR是悬空 ADC 通道的噪声读数。把三者混在一起,基本的随机性就够了。如果工程里没有 ADC 外设,也可以把key_press_count换成SysTick->VAL,只要每次上电时这些值不完全一样,食物位置就能变化。
is_snake_cell的实现要注意效率。最直接的方法是遍历蛇身数组,蛇长 100 时每次生成食物要循环 100 次,但 MCU 完全扛得住,不需要做网格位图这种优化。如果非要优化,也可以维护一个uint8_t grid_occupied[GRID_COLS * GRID_ROWS]位图,移动时同步更新,但会增加编码量和内存,得不偿失。
还有一个容易被忽略的边界:当蛇身接近占满整个网格时,do while循环可能长时间找不到空位,表现为“卡死”。比较稳妥的做法是限制尝试次数或直接判定胜利:
if (snake.len >= GRID_COLS * GRID_ROWS) { game.state = GAME_OVER; // 或者进入胜利状态 return; }5. 常见问题与避坑记录:编译、移植和显示的四个大坑
5.1 编译报错:uint8_t 找不到、C99 设置与头文件目录
现象:导入工程后编译,MDK 报unknown type name 'uint8_t',或者一堆expected ')' before 'uint8_t'的错误,看着像标准头文件没引进来。
原因:uint8_t定义在<stdint.h>里,而工程配置的 C 语言标准可能被设成了 C90。C90 不允许在 for 循环里声明变量,也不强制支持 C99 的标准类型头文件。还有一些情况是 MDK 的 Include Path 没把芯片标准外设库的头文件目录加全。
解决:在 MDK 的 C/C++ 选项里勾选 C99 Mode;同时确认 Include Paths 包含CMSIS和标准外设库的inc目录。如果你用的是 CubeMX 生成的工程,默认是 C99,一般不会遇到这类报错。遇到<stdint.h>自身报错时,先检查编译器安装路径是否被改过。
5.2 环形缓冲区的“head 追上 tail”被误解
现象:蛇长只有 1 时,移动几步后蛇突然消失,或者打印head和tail的值发现两者相等,于是代码认为“数组为空”,把整个蛇重置了。
原因:这是经典的数据结构陷阱。环形数组在空队列时head == tail,有些人写代码时无意识地把这条规则搬到了贪吃蛇上。但在贪吃蛇的环形缓冲区中,len才是唯一权威的蛇身长度指标,head == tail可能意味着蛇长 1,也可能意味着缓冲区已经写满。
解决:判断蛇是否为空、是否满员,一律用len字段,不要用head == tail。调问题时打印三个值:len、head、tail。蛇长 1 时len == 1,移动后head和tail同时前进但len保持不变,这是完全合法的状态。从那以后我写环形数组都会在注释里标明“len 为准”。
5.3 按键二段跳:按一次方向键蛇头跑两个格子
现象:按下方向键后蛇速度明显变快,感觉一帧内移动了两格。断点调试时明明只按了一次键,逻辑节拍也只走了一次。
原因:按键电平被连续处理了两次。第一次按键扫描把方向写入next_dir,逻辑帧更新时消费;但下一次key_scan()又读到同一个电平(因为按键没有松开),再次把相同的方向写进next_dir,于是后续逻辑帧又消费一次。两个方向键连按时还会出现前面说的“反掉头”问题。
解决:把按键事件做成“边沿触发”,只在按键从未按下变为按下的瞬间记录一次。简单做法是维护一个key_event标志位,game_update()消费后立刻清零。常见代码是:
void game_update(void) { if (key_event) { key_event = 0; // 这时才把 next_dir 赋值给 dir } }不要直接读取 GPIO 电平来判断方向是否变化,一定要用“本次电平与上次电平”的异或做上升沿检测。
5.4 LCD 全屏刷新导致花屏与重影
现象:每次帧更新都调用lcd_clear()再画蛇,屏幕严重闪烁,加快 SPI 速率后偶尔出现花瓣状残影。
原因:全屏清屏加全量重绘耗时太长,GRAM 写入过程被中断或其他任务打断,导致部分区域写入半截。另外,清屏时如果用的是lcd_fill_rect(0, 0, 320, 240, COLOR_BG)这种命令,底层先设置窗口再连续写像素,中途中断时窗口地址已经变了,后面的像素会写到屏幕的其他位置。
解决:换局部重绘,只擦旧尾巴、画新头部和食物。同时 SPI 速率在驱动芯片允许的范围内尽量调高,320×240 屏一般可以用 36MHz 甚至更高。如果还是花屏,检查是否在lcd_set_window之前做了 DMA 的 busys 等待,DMA 传输未完成时就不能开新窗口。
5.5 随机种子固定:每次上电食物位置一模一样
现象:重新上电后,食物前几个坐标完全一致,玩起来像“背板游戏”。
原因:调用了srand(0)或压根没调用srand。标准库的rand()默认种子是 1,每次执行序列固定。
解决:按前面 4.3 节的方法,用定时器计数器、按键次数和 ADC 噪声组合生成种子。如果不想引入 ADC 外设,可以用srand((uint32_t)TIM2->CNT ^ (SysTick->VAL << 16))。注意不要在每次生成食物时都调用srand,否则随机性反而被压缩,只需要在系统启动时初始化一次。
6. 从“能跑”到“像样”:加速、穿墙与掉电保存三条进阶路线
6.1 按分数阶梯加速
基础版固定节拍玩久了会无聊,常见做法是分数越高、速度越快。维护一张速度档位表,每 5 分换一档:
| 分数区间 | 逻辑节拍 | 每秒格数 |
|---|---|---|
| 0–4 | 200ms | 5 |
| 5–9 | 160ms | 6 |
| 10–14 | 120ms | 8 |
| 15 及以上 | 80ms | 12 |
实现时只需要在吃到食物后更新定时器重载值:
const uint16_t speed_table[] = {200, 160, 120, 80}; void game_apply_speed(void) { uint8_t idx = (game.score >= 15) ? 3 : (game.score / 5); TIM_SetAutoreload(TIM2, speed_table[idx]); TIM_ClearFlag(TIM2, TIM_FLAG_UPDATE); }注意改重载值后要清一次更新标志,否则定时器可能立刻进入中断,导致节奏跳变。
6.2 穿墙模式:只改边界判断
如果你想要“从左边出去从右边进来”的穿墙玩法,改碰撞检测的边界分支即可:
if (ny >= GRID_ROWS) ny = 0; if (ny == 0xFF) ny = GRID_ROWS - 1; if (nx >= GRID_COLS) nx = 0; if (nx == 0xFF) nx = GRID_COLS - 1;把它放到snake_step计算出新坐标之后、写入数组之前。这个改动不碰渲染和按键逻辑,是最安全的进阶实验。
6.3 最高分掉电保存:内部 Flash 模拟 EEPROM
开发板掉电后分数清零,想留下最高分就得写 Flash。常见做法是在GAME_OVER状态里判断如果当前分数大于历史最高分,则把新分数写入内部 Flash 的某个扇区。注意不要每次移动都写 Flash,寿命会很快耗尽,每次游戏结束时只写一次即可。读取逻辑放在开机初始化时,函数名可以直接叫load_best_score()。
我第一次做这个项目时,为了省事把蛇身数组整体前移,结果屏幕上蛇尾总是多出一格;后来改成环形数组,又在len == 1时被head == tail骗了一晚上。从那以后我做这种裸机小游戏,都会把“坐标备份、方向消费、旧尾擦除”写成固定流程,每次改完代码强制走一遍蛇长大、撞墙、反向的边界用例,才敢烧进开发板。希望帮到你。
本文还有配套的精品资源,点击获取