简介:这份rar压缩包提供基于LVGL 9.2编写的Flappy Bird小游戏示例,面向嵌入式GUI开发者和LVGL入门学习者,演示如何在资源受限的嵌入式设备上构建完整游戏界面与交互逻辑。包内共10个文件,以8个C源码文件为主(涵盖背景渲染、小鸟动作、管道生成、图像资源等模块),辅以1个头文件声明接口与1个txt说明文档,整体仅27KB,代码量精简,适合逐行研读。已有337人学习浏览该资源,适合用于学习LVGL控件布局、图像贴图、状态机设计以及触摸事件处理等关键技巧。通过分析bird.c、pipe_img.c等模块,可理解游戏角色控制、碰撞检测、动态刷新与动画调度等实现思路,同时熟悉LVGL 9.2的项目组织方式和编译配置,可直接作为图形库游戏开发的基础模板。 lvgl_v9_2_flappybird.rar 这个压缩包,我在本地解压跑通之后的第一反应是:这玩意儿比想象中适合拿来学 LVGL。如果你正卡在“LVGL 基础控件都摸过一遍,但不知道怎么写一个完整应用”的阶段,这个项目就是很好的参考样板。它把游戏逻辑、UI 绘制、输入响应、动画刷新全部揉在一个工程里,而且用的是 9.2 版本——这个版本的 API 和 8.x 差别不小,直接看新版本代码能少走很多弯路。
这个项目适合三类人:一是刚把 LVGL 跑起来、想看看多页面和自定义绘图怎么组织的人;二是想在 ESP32 或 STM32 上做个带交互界面的小玩具、需要参考事件驱动写法的嵌入式开发者;三是单纯想在 PC 模拟器上快速验证 LVGL 效果、不想反复烧固件的人。接下来我按自己实际操作和踩坑的顺序,把整个项目的干法拆开讲。
1. 项目整体设计与思路拆解
1.1 为什么偏偏是 Flappy Bird
嵌入式图形库的 demo 项目,大多喜欢用仪表盘、时钟、菜单列表来展示,因为这些场景能覆盖控件库的大部分能力。但 Flappy Bird 这个选题其实更聪明:它的核心循环极其简单——只有“上升”和“下降”两个动作,碰撞检测也只有两个判断条件,但要把这个小游戏做得流畅、好看、操作跟手,恰恰要把 LVGL 的定时器、动画、事件回调、图层管理全部用上。
这个小游戏天然能把 LVGL 的能力分成三层:第一层是静态 UI,包括分数、开始按钮、结束弹窗;第二层是动态绘制,包括背景滚动、管道移动、小鸟旋转;第三层是交互反馈,包括点击跳转、碰撞震动、分数刷新。这三层恰恰对应了 LVGL 开发的三个进阶阶段。所以说,这个 rar 里的代码不只是一个游戏,更是一个浓缩版的 LVGL 工程范例。
1.2 9.2 版本带来的新变化
如果你之前接触的是 8.x 的老代码,打开这个项目的第一感觉一定是“这 API 怎么变了这么多”。LVGL 9.2 里比较影响这个项目的改动主要有三处。
第一处是初始化流程。lv_init() 之后,8.x 里常用 lv_disp_draw_buf_init() 配合 lv_disp_drv_register() 注册显示驱动,9.2 改成了 lv_display_create() 直接创建 display 对象,再用 lv_display_set_buffers() 设置缓冲区,接口更集中了。第二处是输入设备的注册方式,旧版的 lv_indev_drv_register() 换成了 lv_indev_create() 加 lv_indev_set_type() 的组合。第三处是样式 API 的调整,比如 lv_obj_set_style_bg_color() 这类函数虽然还在,但 9.x 对 style 的局部更新做了更多优化,部分场景下可以直接用 lv_obj_set_local_style_prop() 来减少样式拷贝。
这些改动直接影响你读源码时的理解成本。如果你现在还在用 8.x 的教程去套 9.2 的代码,大概率会在编译阶段就卡住。
1.3 这个方案的优势与避坑点
这个项目选 LVGL 做游戏渲染,而不是直接在 TFT 驱动上画像素,最大的优势是:你不需要关心底层屏幕驱动和内存写入方式,LVGL 帮你把帧缓冲管理好了,你要做的就是逻辑和界面表现。
但也正因为 LVGL 是 GUI 库而不是游戏引擎,它有几个天然的限制需要绕开:一是它没有物理引擎,重力加速度得自己算;二是它的默认刷新机制是“脏矩形”模式,频繁更新大区域会掉帧;三是动画回调的时机和系统 tick 有关,不能依赖 lv_anim 做实时物理演算。这些限制恰好促使你写出更高效的代码——比如只刷新变化区域、把固定背景拆成独立 layer、用静态变量缓存临时计算结果。
2. 核心细节解析与实操要点
2.1 代码结构一窥
解压之后,工程目录大致是这样组织的:
- main.c:程序入口,负责初始化 LVGL、显示驱动、输入设备和定时器
- ui /:存放 UI 相关的代码,比如主页面的控件布局、样式设置
- game /:游戏逻辑模块,包含小鸟、管道、碰撞检测等
- lv_conf.h:LVGL 的配置文件,决定使用哪些功能模块、缓冲区大小、内存池大小
这种分离方式很推荐照抄。你把 UI 绘制和游戏逻辑分成两个目录,后续迭代会轻松很多。比如要加音效,直接在 game 模块加音频初始化,不会干扰 ui 目录里的控件代码;要换皮肤,只改 ui 目录里的素材引用即可。
LVGL 的配置文件 lv_conf.h 是重中之重。这个项目跑在模拟器上时,缓冲区设置得比较宽裕,但如果你要移植到 ESP32 或 STM32,就要重新设置 LV_MEM_SIZE、LV_COLOR_DEPTH、LV_DPI_DEF 这几个关键参数。我的建议是先把模拟器调通,再做硬件移植,这样调试效率高得多。
2.2 游戏循环不是 while(1)
很多从单片机裸机编程转过来的朋友,一看“游戏循环”四个字,下意识就写个 while(1) 然后不断刷新画面。但在 LVGL 的框架下这么做是大忌,因为 LVGL 有自己的任务调度机制,它跑在 lv_timer_handler() 里面。
这个项目正确做法是:用 lv_timer_create() 创建一个周期定时器,在回调函数里更新游戏状态(小鸟速度、管道位置、碰撞判断),然后只更新需要变化的控件属性,例如坐标、角度、文字。LVGL 会在自己的心跳里完成重绘。
这种方式的好处是:你不会阻塞 LVGL 的事件处理,也不会让界面卡死。它的核心思路是“视图随状态自动更新”,你把状态算好,LVGL 负责把它画出来。一个反面的例子是,我在移植这个项目时,一开始贪图方便,在定时器回调里直接调用 lv_obj_align() 去调整一堆控件的位置,结果每帧的布局计算开销很大,帧率只剩 20 左右,后来改成用 lv_obj_set_pos() 直接设置坐标,情况立刻好转。
2.3 碰撞检测和动画刷新
Flappy Bird 的碰撞检测逻辑相当直白:玩家控制的小鸟是一个矩形,管道上下两根也是一个矩形,只要两个矩形相交,就算碰撞。
但这里有个细节:LVGL 的对象坐标系是相对于父对象的,所以在做碰撞检测前,必须先统一坐标系。常见的做法是用 lv_obj_get_x() 和 lv_obj_get_y() 获取控件相对父对象的坐标,然后再加上父对象的坐标偏移,换算成屏幕全局坐标。如果直接拿相对坐标去做判断,小鸟和管道只要不在同一个父容器里,就永远检测不到碰撞。
动画方面,LVGL 9.2 的 lv_anim 更适合做“平滑过渡”类效果,比如小鸟扇翅膀的帧动画、管道进出场的位移动画。但对于每帧都需要实时计算的状态,比如重力导致的小鸟位置变化、速度变化,就不适合用 lv_anim,直接在定时器回调里按时间步长计算更加可控。
3. 实操过程与核心环节实现
3.1 环境搭建:模拟器优先
推荐的调试环境是 PC 模拟器。LVGL 官方提供了基于 SDL 的模拟器工程,配合 VS Code 使用,直接 CMake 编译就能跑起来。这个 rar 包里的代码应该也是基于模拟器框架写的,所以编译运行起来比较省事。
具体步骤是:
- 安装 VS Code,并装好 C/C++ 扩展、CMake 工具扩展。
- 安装 SDL2 开发库。Windows 上直接下载 SDL2-devel-2.0.x-mingw.tar.gz,解压到指定目录即可;Linux 上直接 apt install libsdl2-dev。
- 用 CMake 配置并编译整个工程。编译成功后,直接运行可执行文件,就能弹出一个窗口显示小游戏界面。
如果编译时报找不到 SDL2,多半是 CMakeLists.txt 里的 SDL2_DIR 路径不对,改成自己的实际路径即可。这一步解决了,后面就顺了。
3.2 移植到 ESP32 的关键改动
模拟器跑通之后,你就想把游戏搬到实体硬件上。这里我以 ESP32 为例说说要改哪些地方。
LVGL 的显示驱动要换成你的屏幕驱动,比如 ILI9341、ST7789 这类常见的 TFT 驱动芯片。你要做的就是实现一个“把像素刷新到屏幕”的函数,LVGL 负责生成像素数据,你负责搬运。
缓冲区改成两段式:LV_MEM_SIZE 设置到 32KB 以上,显示缓冲区用 lv_display_set_buffers() 注册两个缓冲区,一个用于前台绘制,一个用于后台刷新。双缓冲能明显减少撕裂感。
输入设备从鼠标变成触摸屏或者按键。如果用的是物理按键来控制小鸟上升,那么注册输入设备时要选择 LV_INDEV_TYPE_KEYPAD 或者 ENCODER,然后在按键事件里触发“跳一下”的逻辑。如果打算用触摸屏,就直接注册 LV_INDEV_TYPE_POINTER。
还要注意 CPU 频率。Flappy Bird 这个游戏逻辑本身不复杂,定时器周期建议设在 10-20 毫秒之间,也就是 50-100Hz。ESP32 跑 240MHz 完全够用,但如果用 STM32F103,主频只有 72MHz,建议提高优化等级,同时把定时器周期放宽到 20ms,否则碰撞检测的响应会显得迟钝。
3.3 关键代码段解析
整个项目里最核心的逻辑就是“帧更新函数”。简化版长这样:
static void game_timer_cb(lv_timer_t *timer) { // 更新小鸟位置 bird_y_velocity += GRAVITY; bird_obj_y += bird_y_velocity; // 更新管道位置 for (int i = 0; i < PIPE_NUM; i++) { pipe_x[i] -= SCROLL_SPEED; if (pipe_x[i] < -PIPE_WIDTH) { pipe_x[i] = SCREEN_WIDTH; pipe_y[i] = rand() % PIPE_RANGE + PIPE_OFFSET; } } // 碰撞检测 if (is_collision(bird_obj_x, bird_obj_y, pipe_x, pipe_y)) { game_over(); return; } // 更新控件位置 lv_obj_set_pos(bird_img, bird_obj_x, bird_obj_y); lv_obj_set_x(pipe_upper[i], pipe_x[i]); lv_obj_set_y(pipe_upper[i], pipe_y[i] - PIPE_HEIGHT / 2); }看到没有,逻辑非常简单,但每行都有讲究。核心是把“游戏世界的坐标”和“LVGL 控件的坐标”分开维护。你计算时用的是 bird_obj_x、pipe_x 这样的临时变量,计算完再统一同步给 LVGL 控件。这样碰撞检测和 UI 绘制互不干扰,也方便你后续加暂停、重开功能。
我见过不少新手把 bird 的 y 坐标直接存在 lv_obj 的属性里,每次计算都通过 lv_obj_get_y() 读出来,再调用 lv_obj_set_y() 写回去。这样频繁调用 getter 和 setter 不但效率低,还容易把逻辑搞乱。所以强烈建议:单独用一组变量维护游戏状态,UI 只是状态的可视化输出。
3.4 界面切换与页面管理
这个项目的另一个亮点是页面管理:开始页、游戏页、结束页三个界面之间的切换。LVGL 9.x 里有几种做法可以让页面切换更干净,而这个项目采用的方式是“对象创建 + 删除”。
具体来说,游戏初始化时先创建开始页,上面放标题、开始按钮和操作提示。点击开始按钮后,销毁开始页的所有控件,然后创建游戏页面的所有控件。游戏结束时,销毁游戏页面,创建结束页面,显示分数和重新开始按钮。
这种“按需创建、用完即删”的方式,在 MCU 项目里很实用。内存是宝贵的,页面全部常驻会浪费 RAM。每次切换都动态创建和销毁控件,内存占用能控制在比较稳定的水平。缺点是需要认真处理控件指针的生命周期,避免操作已删除的控件导致崩溃。
这里有一个容易踩的坑:在页面切换时,若旧的定时器回调还没被移除,依然在访问旧页面的控件,就会造成野指针访问。正确的做法是:在销毁页面之前,先用 lv_timer_del() 把游戏定时器删掉,再清理页面控件。
3.5 中文显示的处理
游戏里如果需要显示中文,例如“得分”、“重新开始”这些文字,LVGL 默认英文字体是不带中文字形的。这个项目如果改成中文界面,最简单的方式是使用 LVGL 的字体转换工具,把需要的几个汉字转成 C 语言数组。
操作流程大致是:
- 打开 LVGL 的在线字体转换工具(LVGL Font Converter)。
- 选择字体文件,这里建议选一个常见的开源中文字体,注意看授权许可。
- 设置字体大小,例如 16 或者 20 像素。
- 在 “Range” 里勾选需要的字符范围。但如果只需要几个汉字,最好直接手动输入那几个字符,避免生成几 MB 的字库文件。
- 生成后得到一个 .c 文件,放到项目里,然后通过 LV_FONT_DECLARE 声明使用。
这样游戏界面就能正常显示中文了。需要注意的是,显示的每个汉字都会占用存储空间,所以界面设计时应当使用固定行文,避免动态拼接大量中文字符串导致的存储空间浪费。
4. 常见问题与排查技巧实录
4.1 “画面不刷新,只有第一次绘制”
这个问题的常见原因是没有在 while 循环里调用 lv_timer_handler()。LVGL 的刷新机制是基于定时器驱动的,如果你在主循环里只做了延时,没有调用 lv_timer_handler(),LVGL 永远不会去处理待重绘的任务。
排查思路:确认主循环里面 lv_timer_handler() 的调用频率。在模拟器项目里,通常用 SDL_Delay 控制帧率,同时每帧调用一次 handler。在嵌入式项目里,通常在 while(1) 里调用 lv_timer_handler() 后,再 delay(5) 左右。
4.2 “帧率不稳定,有时卡顿”
帧率问题大多出在两个地方:一是缓冲区太小,导致 LVGL 频繁刷屏;二是在定时器回调里做了过多耗时的运算。
对比一下缓冲区和帧率的表现关系,用 240x320 分辨率、16 位色深举例,一帧画面大约需要 150KB 显存。假如只有 10KB 缓冲区,那一次刷新就需要十几块区域合并,效率非常低。
| 缓冲区大小 | 刷新区域个数 | 表现 |
|---|---|---|
| 10KB | 12 | 明显卡顿,画面有撕裂感 |
| 30KB | 4 | 基本流畅,但复杂界面略卡 |
| 60KB | 2 | 流畅,接近满帧 |
| 150KB | 1 | 极致流畅,但内存占用高 |
所以如果你在 MCU 上发现帧率上不去,优先看能不能加大缓冲区。如果内存确实有限,就把游戏背景做成静态图片,用 lv_canvas 提前画好,更新时只刷新动态区域,效果也很不错。
4.3 “点击没反应,或者点击位置总是偏移”
这个问题在触摸屏项目中特别常见。LVGL 的触摸坐标校准是基于输入设备的回调接口,如果你设置不到位,就会出现点击偏差。
排查时,先在初始化里确保触控阈值设置合理,然后在 LVGL 里注册输入设备时,正确设置 read_cb 回调,在回调里把触摸芯片读到的坐标转换为 LVGL 的坐标范围。比如你的屏幕是 240x320,触摸芯片返回坐标范围是 0-4095,那就需要做一个线性映射,把坐标映射到 0-239 和 0-319 区间。
void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data) { uint16_t x_raw, y_raw; touch_read(&x_raw, &y_raw); style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />