简介:基于RT-Thread实现贪吃蛇与俄罗斯方块的嵌入式项目包,面向STM32F103开发者与RTOS入门学习者,演示如何在资源受限的MCU上通过多任务机制构建图形化小游戏。压缩包13.89MB,共413个文件,以84个C源码、67个汇编文件、64个头文件为主体,另有IAR/Keil工程配置、链接脚本、GUI组件及构建脚本,目录结构清晰,可直接导入工程编译学习。目前已有525人学习下载。项目覆盖RT-Thread Nano任务创建与调度、信号量与消息队列通信、定时器驱动方块自动下落、GPIO驱动LCD显示等关键知识点,并深入体现贪吃蛇蛇身移动、边界碰撞检测、得分管理,以及俄罗斯方块随机生成、旋转变换、消行计分等完整逻辑。配套源码中包含多个游戏模块和RTOS交互示例,代码组织简洁、注释明确,适合作为嵌入式多线程开发、RTOS实战以及小游戏逻辑设计的参考案例,也可在此基础上扩展更多玩法或迁移至其他MCU平台。
1. 在 MCU 上跑小游戏:为什么说瓶颈不在 CPU,而在显示和输入
基于 RT-Thread 实现贪吃蛇和俄罗斯方块,很多人第一反应是"单片机算力够不够"。实际做下来你会发现,跑这类游戏,CPU 占用常年趴在 20% 上下,真正的瓶颈在显示刷新和输入扫描这两件事上。裸机 while(1) 里写状态机,游戏逻辑、按键扫描、屏幕刷新全搅在一段循环里,改个蛇的移动速度都可能牵动整段代码。RT-Thread 的价值不是"能跑操作系统",而是把这三件事拆成独立线程,用信号量和消息队列做同步,让每个模块都能单独改、单独测。
这篇文章面向两类人:一是想给开发板做个"看得见摸得着"的 demo 的学生或爱好者,二是评估 RTOS 在小型彩屏设备上落地可行性的嵌入式工程师。贪吃蛇和俄罗斯方块虽然玩法简单,但覆盖了按键防抖、定时器驱动、IPC 通信、局部刷新优化、碰撞检测这几个嵌入式开发的常见场景,做完这两个游戏,你基本就摸清了 RT-Thread 在图形交互类产品上的完整落地路径。下面按"选型 → 搭工程 → 写逻辑 → 避坑 → 优化"的顺序,把这条路上能遇到的问题一次说清楚。
2. 显示方案与线程模型:动手前先定三件事
2.1 屏怎么选:带宽决定游戏帧率上限
先算一笔账。拿常见的 320×240 RGB565 屏幕举例,一帧裸数据是 320×240×2 = 153600 字节。如果你的屏是 SPI 接口,常见驱动频率 40MHz,理论带宽 5MB/s,实际传输效率打七折,刷一帧要 40ms 以上——也就是说,你不管游戏逻辑写得多快,屏幕物理上限就是 25 帧左右,还得是整帧刷新。想跑 30 帧以上,要么换 8 位并口屏,要么换带显存的 SPI 屏让 MCU 只写变化区域。
所以选屏之前,先列三个参数:分辨率、色彩格式、接口类型。分辨率越低,帧率越有余量,游戏手感越好。我一般建议贪吃蛇用 128×64 的 OLED,俄罗斯方块用 240×240 以内的 SPI IPS 屏,两者在成本和代码复杂度上比较平衡。下面这个估算公式可以直接套:
// 帧刷新耗时估算:t = w * h * bytes_per_pixel * 8 / spi_speed / efficiency #define SCREEN_W 240 #define SCREEN_H 240 #define BYTES_PP 2 // RGB565 #define SPI_SPEED 40000000 #define EFFICIENCY 0.7 // SPI 传输效率系数 // 一帧耗时约:240*240*2*8 / 40000000 / 0.7 ≈ 32.9ms,帧率约 30fps参数说明:bytes_per_pixel 取决于色深,RGB565 是 2 字节,RGB888 是 3 字节。efficiency 是经验值,SPI 每发一字节有地址/命令间隔,加了 DMA 也难超过 80%。如果估算下来帧率不足 20fps,建议降低分辨率或者改用「局部刷新」策略,这个在第 6 章专门说。
2.2 刷屏策略:双缓冲还是局部刷新
这是第二个要定的方案。俄罗斯方块有明确的"变化区域"——新落下一个方块、消一行、分数变化,这些区域都很小,完全可以只把变化的那几行重新传给屏幕驱动 IC。做法是维护一个和大屏等大的显存数组,游戏逻辑只修改这个数组,然后算出一个 dirty rectangle,把这块区域刷出去。贪吃蛇更极端,蛇每次移动只动两个格子(新头 + 旧尾),食物刷新也就一个格子,局部刷新的收益非常明显。
但局部刷新有个前提:屏幕驱动 IC 支持窗口坐标设置。现在绝大多数 SPI 彩屏都支持,命令通常是CASET和PASET,对应 ST7789、ILI9341 这些常见驱动。OLED 那边的 SSD1306 也支持页寻址和列地址设置。实现局部刷新只需要两步:先设置窗口区域,再往里面填像素数据。要注意的是,如果你用 DMA 刷屏,事务之间要有足够间隔,否则两个窗口区域的数据会拼接出错。
2.3 线程怎么切:三线程两同步
RT-Thread 里我一般切成三个线程:游戏主线程负责逻辑和状态推进,输入线程负责扫描按键,显示刷新放在游戏线程里做,而不是独立线程。原因是显示内容和游戏状态强耦合,分开容易出撕裂——你看到屏幕上半块是上一帧、下半块是这一帧。按键扫描单独一个线程,是因为按键要处理防抖和长按,这些逻辑塞进游戏线程会让游戏循环卡顿。
两个线程之间的通信用消息队列,按键变化当作事件发给游戏线程。游戏线程平时阻塞在rt_mq_recv上,不白烧 CPU。这个模型是裸机状态机很难优雅实现的——裸机里按键扫描只能放在定时器中断或者主循环里轮询,而 RT-Thread 让按键变成"数据生产者",游戏循环变成"消费者",职责一下就清楚了。
3. 搭出最小工程:从空项目到一个能跑起来的菜单
3.1 用 ENV 工具裁剪组件
拿到一块新板子,第一步不是写游戏,而是先搭一个最小可运行系统。在 RT-Thread 的 ENV 命令行里执行menuconfig,把用不到的组件全关掉。小游戏工程真正需要的组件很少:内核基础、一个串口驱动(调试用)、GPIO 驱动、SPI或 I2C 驱动(取决于屏幕)、软件定时器。文件系统、网络协议栈、shell 组件(如果不需要调试)可以先关掉。
一个容易忽略的点是堆栈大小的设置。游戏线程里如果用消息队列收按键、做大数组运算,默认 2048 字节的栈很容易爆。我一般在配置阶段就把游戏线程的栈调到 4096 字节以上,输入线程 1024 字节就够。这个问题早调早省事,不然跑着跑着突然诡异复位,查半天发现是栈溢出。
3.2 显示驱动的抽象接口
不同屏幕的驱动差异很大,但游戏逻辑不应该关心底层是 SPI 屏还是 I2C 屏。我习惯先定义一套最简显示接口,各屏驱动去实现它:
// lcd_port.h typedef struct { void (*init)(void); void (*clear)(uint16_t color); void (*fill_rect)(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); void (*draw_bitmap)(uint16_t x, uint16_t y, const uint8_t *data, uint16_t w, uint16_t h); } lcd_ops_t; extern const lcd_ops_t lcd_st7789_ops; // SPI 屏实现 extern const lcd_ops_t lcd_ssd1306_ops; // OLED 实现逻辑说明:游戏主逻辑只持有lcd_ops_t指针,调用fill_rect画方块、draw_bitmap画图标。底层换屏,上层代码一行不用改。参数说明:fill_rect 是局部刷新的核心接口,它把矩形区域从左上角坐标 (x0, y0) 到右下角 (x1, y1) 填充成指定颜色,驱动内部负责转换成对应的窗口设置命令。
3.3 输入线程:消息队列把按键变成事件
按键扫描放到低优先级线程,但必须保证"有键按下 → 事件发出"这个链路足够快。用 GPIO 中断加信号量也行、轮询也行,关键是把事件丢进消息队列:
// input_thread.c static void input_thread_entry(void *param) { rt_mq_t mq = (rt_mq_t)param; uint8_t key_state = 0; uint8_t last_key_state = 0; while (1) { last_key_state = key_state; key_state = 0; if (rt_pin_read(KEY_UP_PIN) == PIN_LOW) key_state |= KEY_UP; if (rt_pin_read(KEY_DOWN_PIN) == PIN_LOW) key_state |= KEY_DOWN; if (rt_pin_read(KEY_LEFT_PIN) == PIN_LOW) key_state |= KEY_LEFT; if (rt_pin_read(KEY_RIGHT_PIN) == PIN_LOW) key_state |= KEY_RIGHT; if (rt_pin_read(KEY_A_PIN) == PIN_LOW) key_state |= KEY_A; // 只发送变化:按下沿和释放沿都发 if (key_state != last_key_state) { struct key_event evt; evt.pressed = key_state; evt.released = last_key_state & ~key_state; rt_mq_send(mq, &evt, sizeof(evt)); } rt_thread_mdelay(20); // 20ms 扫描周期,兼做按键消抖 } }逻辑说明:轮询周期 20ms 兼具消抖作用——机械按键的抖动通常持续 5~10ms,20ms 间隔天然跳过了抖动区间。消息只发送变化沿,游戏线程不用关心"是否持续按住",逻辑更简单。参数说明:20ms 这个值别调太小,太小反而容易采到按键抖动的中间态;也别太大,太大按键响应迟钝,玩俄罗斯方块时会觉得"方块跟不上手"。
3.4 游戏线程的循环结构
游戏线程是入口,负责初始化显示、创建菜单、调度游戏:
// game_main_thread.c static void game_thread_entry(void *param) { struct key_event evt; rt_mq_t mq = rt_mq_create("key_mq", sizeof(struct key_event), 8, RT_IPC_FLAG_FIFO); rt_mq_send(mq, &evt, sizeof(evt)); // 初始化占位 // 创建输入线程 rt_thread_t input = rt_thread_create("input", input_thread_entry, mq, 1024, 15, 20); rt_thread_startup(input); // 初始化 LCD 并显示菜单 lcd_ops->init(); menu_init(); menu_show(); while (1) { rt_mq_recv(mq, &evt, sizeof(evt), RT_WAITING_FOREVER); menu_handle_key(&evt); // 菜单按键响应,含"开始游戏/切换游戏" } }逻辑说明:游戏线程先创建一个消息队列,再启动输入线程,然后进入事件循环。menu_handle_key内部判断当前处于菜单态还是游戏态,游戏态再把事件转发给对应的游戏处理器。注意消息队列深度设成 8,按键连发时不会丢事件。参数说明:线程优先级 15 是中等水平,输入线程优先级 20 更会让出 CPU,保证游戏线程优先——优先级数字小表示优先级高。
4. 游戏逻辑核心:贪吃蛇与俄罗斯方块的数据结构设计
4.1 贪吃蛇:环形缓冲区的移动逻辑
贪吃蛇最常见的翻车点是把蛇身存成数组、每次移动把所有节点往后挪一个位置——代码写起来痛快,但时间复杂度是 O(n)。更好的做法是用环形缓冲区只记录"新蛇头"和"挤掉的老蛇尾":
// snake.h #define SNAKE_MAX_LEN 128 typedef struct { int16_t x[SNAKE_MAX_LEN]; int16_t y[SNAKE_MAX_LEN]; uint16_t head; // 头索引 uint16_t tail; // 尾索引 uint16_t len; uint8_t direction; // 0上 1右 2下 3左 } snake_t; // snake.c static void snake_move(snake_t *snake, int16_t new_head_x, int16_t new_head_y) { // 新头写入 head 位置 snake->head = (snake->head + 1) % SNAKE_MAX_LEN; snake->x[snake->head] = new_head_x; snake->y[snake->head] = new_head_y; // 没吃到食物才挪尾巴 if (snake->len < SNAKE_MAX_LEN) { snake->tail = (snake->tail + 1) % SNAKE_MAX_LEN; } }逻辑说明:蛇移动的本质是"加一个头,去一个尾",真实长度不变时头和尾同步后移。吃到食物时只加头、不去尾,长度自增。环形缓冲区让每一步操作都是 O(1),帧率再高也不会卡顿。注意 head 和 tail 的初始值以及长度上限,SNAKE_MAX_LEN要根据场地大小定,128 对应 16×8 格子绰绰有余。
碰撞检测的逻辑:新蛇头落在场地边界外,或者新蛇头的坐标已经存在于蛇身数组中(用索引区间遍历判断),都判定为撞死。第二种情况有个细节,如果新蛇头刚好落在"前一帧蛇尾即将释放的位置",是否判定碰撞取决于这条蛇是否正在"原地掉头"——掉头场景下蛇尾会移动,这个位置随着去尾已经空了。判断条件我一般写成:新蛇头坐标等于旧蛇尾坐标且这一帧没有吃到食物,就不算撞,因为下一步这个格子就释放了。
食物生成用随机数发生器,但注意别让食物产生在蛇身上。常见做法是在蛇身数组里查重,如果碰撞就重新随机,最多尝试 32 次,超出就摆在场地上第一个空位。
4.2 贪吃蛇的变速与分数联动
贪吃蛇的手感主要由移动周期决定,周期越短蛇越快。我一般把速度档位和得分挂钩,每吃 5 个食物提速一档。控制在游戏线程内通过调整定时器等待时长实现:
#define SNAKE_SPEED_MAX 50 // 最快 50ms 移动一次 #define SNAKE_SPEED_MIN 400 // 最慢 400ms 移动一次 static void snake_increase_speed(snake_t *snake, int food_count) { int speed_step = food_count / 5; int new_speed = SNAKE_SPEED_MIN - speed_step * 30; if (new_speed < SNAKE_SPEED_MAX) new_speed = SNAKE_SPEED_MAX; // 用 rt_thread_mdelay(new_speed) 在游戏循环里控制节奏 }注意要把"速度计算"和"等待执行"解耦,不要在snake_move函数里插入延时,否则吃食物瞬间帧率突变,视觉上会卡一下。正确做法是游戏循环每轮先算好本轮要不要移动、等多久再移动,然后单独用一个变量控制。
4.3 俄罗斯方块:用三维数组存方块形状
俄罗斯方块有 7 种基础方块,每种有 4 个旋转状态,每个状态用一个 4×4 的 0/1 矩阵表示。最直接的数据结构是四维数组:[方块类型][旋转状态][行][列]。
// tetris_block.h #define TETRIS_TYPES 7 #define TETRIS_ROTATIONS 4 #define TETRIS_SIZE 4 static const uint8_t tetris_shapes[TETRIS_TYPES][TETRIS_ROTATIONS][TETRIS_SIZE][TETRIS_SIZE] = { // T 形方块 { {{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, {{0,1,0,0},{0,1,1,0},{0,1,0,0},{0,0,0,0}}, {{0,0,0,0},{1,1,1,0},{0,1,0,0},{0,0,0,0}}, {{0,1,0,0},{1,1,0,0},{0,1,0,0},{0,0,0,0}}, }, // I 形方块 { {{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, {{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}}, {{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, {{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}}, }, // 其余 5 个方块省略,同一格式 };逻辑说明:旋转就是改变第三维的下标索引,不需要做矩阵旋转运算,查表即可。这个方案牺牲一点 flash 空间(7×4×4×4 = 448 字节),但换来的是零耗时旋转和零 bug。参数说明:矩阵内 1 表示这个格子有方块,0 表示空。所有碰撞检测统一走这个矩阵,判定"方块下一步移到这个位置是否越界或与堆叠方块重叠"。
4.4 俄罗斯方块的碰撞检测与消行
方块移动前先做碰撞检测,有两个条件:一是目标位置不能超出 10×20 的场地边界,二是目标位置的每个有效格子不能与场地已有方块重叠。代码结构如下:
// 判断方块在 (test_x, test_y) 处,以当前旋转状态摆放,是否可行 static int tetris_can_place(int tetris_type, int rot, int test_x, int test_y) { const uint8_t (*shape)[TETRIS_SIZE] = tetris_shapes[tetris_type][rot]; for (int row = 0; row < TETRIS_SIZE; row++) { for (int col = 0; col < TETRIS_SIZE; col++) { if (shape[row][col] == 0) continue; int gx = test_x + col; int gy = test_y + row; // 场地边界检查 if (gx < 0 || gx >= FIELD_W || gy < 0 || gy >= FIELD_H) { return 0; } // 与已堆叠方块重叠 if (field[gy][gx] != 0) { return 0; } } } return 1; }消行逻辑是俄罗斯方块里少有的"顺着写就对了"的部分:从场地底部往上扫每一行,如果一行全是 1,就把这一行删掉,把上方所有行整体下移一行。注意用memmove移动整行数组比逐格循环快得多,一行的字节数也就 10 个,影响不大,但养成用标准库的习惯也不亏。
4.5 游戏控制状态机
游戏线程对两个游戏的控制逻辑其实是一致的:菜单态 → 游戏中 → 暂停 → 结束 → 返回菜单。我建议把两个游戏的状态机统一抽象成一组枚举,这样菜单代码、按键分发代码可以共用:
typedef enum { GAME_STATE_MENU, GAME_STATE_PLAYING, GAME_STATE_PAUSED, GAME_STATE_OVER, } game_state_t; // 按键事件统一分发,游戏内部再按需处理 static void game_handle_key(game_state_t *state, struct key_event *evt, void (*on_key)(uint8_t pressed, void *ctx), void *ctx) { if (evt->pressed & KEY_A) { if (*state == GAME_STATE_PLAYING) { *state = GAME_STATE_PAUSED; } else if (*state == GAME_STATE_PAUSED) { *state = GAME_STATE_PLAYING; } } if (*state == GAME_STATE_PLAYING) { on_key(evt->pressed, ctx); } }逻辑说明:把"通用按键逻辑"(暂停、恢复)和"游戏专用按键逻辑"(移动、旋转)分开,游戏代码里不用每个函数都写一遍暂停判断。参数说明:on_key 是回调函数指针,贪吃蛇传 snake_on_key,俄罗斯方块传 tetris_on_key,两个游戏共用同一套暂停逻辑。
5. 避坑:小游戏跑不起来的五个高频原因
5.1 屏幕只亮一半或者刷新错位
现象:图像显示在屏幕的下半部分,或者上下颠倒、镜像,菜单界面的文字错位。
原因:屏幕驱动 IC 的偏移设置(offset)没调对。像 ST7789、ILI9341 这类驱动,当分辨率小于 IC 最大分辨率时,需要设置显示窗口起始地址偏移。比如 240×240 的屏配 ST7789,很多厂家出厂设置的 offset 是 80、0,你直接按 0、0 填地址就会出现只显示一半的现象。
解决:不要盲目抄网上同型号屏的驱动代码,先看你的屏的 datasheet 里「Memory Access Control」和「Frame Rate Setting」两个寄存器。最好在初始化时加一个自检函数:在四个角各画一个不同颜色的方块,确认四角显示位置无误后再往上写游戏界面。
5.2 SPI 刷新画面有噪点、花屏
现象:画面偶尔出现随机横线、噪点,或者刷新过程中一半正常一半花屏。
原因:SPI 速率过高导致信号质量问题,或者 DMA 和 CPU 同时往 SPI 外设里写数据产生竞争。我遇到过 40MHz 挂在杜邦线上跑,画面随机花屏;换成 24MHz 后问题消失。
解决:把 SPI 时钟先降到 20MHz 试,稳定了再往上拉。另外务必保证同一时刻只有一个写入源在操作 SPI 总线,DMA 传输期间 CPU 不能发新的写命令,加一个互斥信号量就能解决。
5.3 游戏线程栈溢出导致意外复位
现象:游戏运行一段时间后,突然整个系统复位,日志里找不到任何报错。
原因:游戏线程里定义了大的局部数组,比如俄罗斯方块场地的field[20][10],加上在函数内声明的碰撞检测临时矩阵,栈空间耗干。
解决:把大数组从局部变量改成静态变量——存到.bss段,不占栈。同时把游戏线程的栈从默认 2048 调到 4096。用rt_thread_mdelay的最大栈占用参数做一次「栈高水位」测试:运行时打印rt_thread_self()->stack_used,确认峰值低于总栈空间的 70%。
5.4 按键偶尔响应、偶尔失灵
现象:按键有时候按一下没反应,有时候按一下触发两次。
原因:两个问题叠加。触发两次是防抖不够——20ms 的轮询间隔对某些机械按键不够,按键在临界点抖动时可能被采到两个边沿;响应失灵是按键用了 GPIO 下拉配置,但外部是按键到 GND 的上拉接法,电平状态正好反了。
解决:把轮询间隔调到 30ms,然后在代码里对每位按键状态加一个「确认窗口」——连续两次采样都是相同状态才认为按键有效。另外,在初始化代码里把按键引脚设置为上拉输入,并且留意内部上拉是否被禁用,避免悬浮噪声。
5.5 定时器驱动下游戏节奏忽快忽慢
现象:贪吃蛇移动速度不稳定,食物出现后蛇突然加速一段,然后恢复。
原因:软件定时器和消息队列的交互有延迟。贪吃蛇每次移动后重新计算延时,没有考虑消息队列等待的时间。输入线程在发按键事件到游戏线程消费之间存在一段不确定延迟,这个延迟积累起来影响节奏。
解决:改用「绝对时间戳」方案。每次移动记录一个next_move_tick = rt_tick_get() + interval,在循环里判断rt_tick_get() >= next_move_tick才执行移动,不管按键事件何时到达,都不会改变移动时刻。这个方案比"延时一缩一放"要稳定得多。
6. 进阶:帧率锁定、局部刷新与自动演示模式
6.1 用绝对时间戳锁帧率
上节提到了绝对时间戳方案,这里给一个完整的实现思路。游戏循环每轮做两件事:收事件、判断时间片。把这个循环写成以下结构,游戏节奏就和输入频率彻底解耦:
static void tetris_loop(void) { uint32_t next_move_tick = rt_tick_get(); uint32_t drop_interval = TETRIS_BASE_INTERVAL_MS; // 基础下落间隔 while (game_running) { // 处理消息队列里所有待处理的按键事件 while (rt_mq_recv(mq, &evt, sizeof(evt), 0) == RT_EOK) { tetris_on_key(&evt); } // 判断是否到达下落时间点 uint32_t now = rt_tick_get(); if (now >= next_move_tick) { tetris_step_down(); // 下落一格 next_move_tick = now + drop_interval; } else { rt_thread_mdelay(next_move_tick - now); // 睡到下一个时间点 } } }每个游戏节拍都对应一个绝对 tick,不受按键事件延迟影响。俄罗斯方块的「软降」和「硬降」就是额外触发一次移动并把 next_move_tick 重置,手感会非常干脆。
6.2 让显示只画「该变的」
贪吃蛇和俄罗斯方块都值得做局部刷新优化。贪吃蛇每次移动只需要重绘两个格子——新蛇头画方块,旧蛇尾抹掉。俄罗斯方块每次下落只需要重绘一个 4×4 区域加上落点阴影。如果用的是 240×240 屏,整帧刷新需要 30ms,而局部刷新只要 2ms 左右,帧率轻松翻倍。
实现局部刷新有两个前提:一是维护显存数组(uint16_t *framebuffer),游戏逻辑只改动数组内容;二是刷新接口支持「按矩形区域提交」。这里最关键的是把「逻辑占位」和「实际绘制」分开:游戏逻辑判断碰撞时用的是格子坐标,绘制时才换算成像素坐标,中间加一层坐标变换函数。
6.3 自动演示模式:用 AI 算法做自验
写完两个游戏后,我强烈建议给贪吃蛇加一个「自动演示」模式。这个模式不只是炫技,它是对整个工程最有价值的验证手段:自动演示跑 10 分钟不出 bug,基本就能确定游戏逻辑的碰撞检测、状态切换和消息链路都是可靠的。自动演示最简单的方案是用「贪心 + BFS」搜索一条到食物的最短路径,每次移动前做一次 BFS 寻路。如果 BFS 找不到路径,就沿蛇尾方向「追尾」兜圈,等食物重刷。自动寻路跑通了,说明以下几个点都验证到位了:数组不会越界、环形缓冲区头尾索引逻辑正确、方向更新逻辑没有竞态。
进一步,你可以把自动演示的路径搜索做成单独的组件,以后复用到其他迷宫类游戏上。这也是 RT-Thread 这类 RTOS 做项目的惯常思维:每个游戏模块都做成相对独立、可测试的组件,上层用一套菜单串联起来。
6.4 验证方法:先帧率、后手感
性能验证不要靠眼睛看。在游戏线程里加一个每帧耗时的统计变量,打印到串口或者显示在屏幕角落。我一般跑两个测试场景:俄罗斯方块从底部堆满到游戏结束的完整过程,记录平均帧耗时和最大帧耗时;贪吃蛇绕场地跑满一圈。如果最大帧耗时超过单帧预算的 1.5 倍,优先检查绘制接口是不是做了整帧刷新、按键事件队列有没有溢出。手感这种东西没法量化,但帧成本和事件延迟是可以量化的,先把可量化的指标压到极限,手感多半不会差。
这个项目做完之后,我最大的收获其实不是贪吃蛇和俄罗斯方块本身——而是明白了在 RTOS 上做交互应用,代码架构的顺序比代码优化更早被验证。每次把一个大功能拆成几个线程和几条消息,调试的时间就会比裸机方案少一大截。如果你正在纠结要不要在 MCU 上碰 RT-Thread,我的建议很直接:先拿这两个小游戏试水,成本不高,但把「线程划分、消息同步、资源限制」这几个核心问题都踩一遍,值回票价。希望这篇笔记能帮你少走一点我走过的弯路。
本文还有配套的精品资源,点击获取