摘要
本文基于 ESP-Mosaico 的抛硬币掌上游戏项目,拆解三个关键交互模块:
- 使用 LVGL 定时器和二维对象实现 1.5 秒抛物线运动、上下翻面、落地摆动与金币散射;
- 使用 FreeRTOS 音频任务和队列管理背景音乐、抛掷音效与结果音效;
- 使用 BMI270 相邻采样变化量、连续命中和冷却机制实现摇晃触发。
文章还会说明为什么触摸与 IMU 必须进入同一状态机入口,以及动画对象生命周期、音量持久化和音色设计中遇到的实际问题。
1. 项目环境与文件结构
硬件平台:ESP-Mosaico,主控为 ESP32-S31。
主要硬件资源:
- 480×480 显示屏;
- 电容触摸;
- ES8311 音频编解码器;
- BMI270 IMU;
- SPI NAND;
- 板载扬声器及电源管理。
软件栈:
- ESP-IDF;
- ESP-Mosaico BSP;
- LVGL;
- FreeRTOS;
- NVS。
项目主要文件:
text
coin_toss_game/
├─ main/
│ ├─ app_main.c # 页面、状态机、LVGL 动画、IMU 检测
│ ├─ coin_audio.c # 音频任务、波形生成与混音
│ ├─ coin_audio.h
│ ├─ coin_storage.c # 金币数和音量的 NVS 持久化
│ ├─ coin_storage.h
│ └─ assets/
│ ├─ cn_font.ttf
│ └─ font_chars.txt
├─ CMakeLists.txt
├─ partitions.csv
└─ sdkconfig.defaults
当前项目里的关键动画参数如下:
c
#define TOSS_DURATION_MS 1500U
#define RESULT_DURATION_MS 1000U
#define COIN_SIZE 180
#define COIN_MIN_HEIGHT 14
#define COIN_BASE_CENTER_Y 276
#define COIN_TOSS_HEIGHT 112
#define SUCCESS_COIN_COUNT 6
2. 状态机与事件链
页面状态:
c
typedef enum {
PAGE_TITLE = 0,
PAGE_MODE,
PAGE_GAME,
} page_t;
游戏状态:
c
typedef enum {
GAME_IDLE = 0,
GAME_TOSSING,
GAME_REVEAL_HEADS,
GAME_REVEAL_TAILS,
} game_state_t;
一次手动抛掷的事件链为:
text
触摸点击 / IMU 摇晃
↓
检查页面、游戏状态和设置面板
↓
按模式扣除金币,并写入 NVS
↓
进入 GAME_TOSSING,记录开始时间
↓
LVGL 定时器刷新动画,音频队列播放抛掷声
↓
1500 ms 后生成结果
↓
进入正面或反面结算状态
↓
1000 ms 后回到空闲,或开始免费自动连抛
启动抛掷的核心逻辑:
c
static void start_toss(bool manual)
{
const mode_rule_t *rule = &MODE_RULES[s_game.mode];
if (manual) {
if (s_game.coins < rule->cost) {
set_instruction("金币不足,请切换模式");
return;
}
s_game.coins -= rule->cost;
s_game.chain = 0;
coin_storage_save(s_game.coins);
}
hide_success_coins();
update_game_hud();
s_game.state = GAME_TOSSING;
s_game.state_started_ms = now_ms();
lv_obj_add_flag(s_game.plus_reward, LV_OBJ_FLAG_HIDDEN);
coin_audio_post(COIN_AUDIO_TOSS);
}
manual 用于区分手动抛掷和正面后的自动连抛。只有手动抛掷扣除金币,自动连抛使用 start_toss(false),不会再次进入扣费分支。
3. LVGL 实现抛物线运动
3.1 使用定时器统一刷新
创建 UI 时注册 16 ms 周期定时器:
c
lv_timer_create(game_tick, 16, NULL);
定时器不通过阻塞延时控制动画,而是读取当前时间与状态开始时间的差值:
c
const uint32_t elapsed = now_ms() - s_game.state_started_ms;
这样动画、触摸和音频任务可以并行工作,LVGL 主循环不会被 delay 阻塞。
3.2 抛物线轨迹
硬币的纵向位置使用抛物线计算:
c
const uint32_t t = elapsed > TOSS_DURATION_MS
? TOSS_DURATION_MS : elapsed;
const uint64_t arc = 4ULL * t *
(TOSS_DURATION_MS - t) * COIN_TOSS_HEIGHT;
const int center_y = COIN_BASE_CENTER_Y -
(int)(arc /
((uint64_t)TOSS_DURATION_MS * TOSS_DURATION_MS));
当 t = 0 或 t = TOSS_DURATION_MS 时,arc = 0,硬币位于基准位置;当 t 接近持续时间的一半时,硬币到达最高点。
使用整数和 uint64_t 中间量可以避免乘法溢出,同时省去逐帧坐标表。
4. 通过压缩高度模拟上下翻转
翻面周期设置为 180 ms:
c
const uint32_t flip_period = 180U;
const uint32_t p = t % flip_period;
const uint32_t dist = p > flip_period / 2U
? p - flip_period / 2U
: flip_period / 2U - p;
const int height = COIN_MIN_HEIGHT +
(COIN_SIZE - COIN_MIN_HEIGHT) * (int)dist /
(int)(flip_period / 2U);
const bool face =
((t / (flip_period / 2U)) & 1U) == 0U;
动画效果:
- 硬币高度从
180 px缩小到14 px; - 接近最窄状态时切换太阳和月亮图案;
- 高度重新恢复到
180 px; - 循环以上过程,形成上下翻转错觉。
更新对象尺寸与位置:
c
static void set_coin_geometry(int height, int center_y, int y_offset)
{
s_game.coin_height = height;
lv_obj_set_size(s_game.coin, COIN_SIZE, height);
lv_obj_set_pos(
s_game.coin,
(480 - COIN_SIZE) / 2,
center_y - height / 2 + y_offset
);
const int inner_height = height > 36 ? height - 36 : 2;
lv_obj_set_size(s_game.coin_inner, COIN_SIZE - 36, inner_height);
lv_obj_center(s_game.coin_inner);
}
硬币内部的图案、高光和内圈也需要跟随高度变化。当高度过小时,可以隐藏不适合压缩的高光对象,避免侧边阶段出现异常图形。
5. 落地摆动和正面金币散射
抛掷结束后进入 RESULT_DURATION_MS = 1000 ms 的结算阶段。
落地摆动通过交替正负偏移实现,幅度随剩余时间衰减:
c
const uint32_t t = elapsed > RESULT_DURATION_MS
? RESULT_DURATION_MS : elapsed;
const int wobble =
(int)((RESULT_DURATION_MS - t) * 8U / RESULT_DURATION_MS);
const int y_offset = ((t / 70U) & 1U)
? wobble : -wobble;
set_coin_geometry(
COIN_SIZE,
COIN_BASE_CENTER_Y,
y_offset
);
正面结果显示 6 枚小金币。金币从主硬币附近移动到预设终点:
c
static const int SUCCESS_COIN_END[6][2] = {
{154, 190}, {300, 190}, {112, 264},
{344, 264}, {154, 354}, {300, 354},
};
使用简单的二次缓出插值:
c
const uint32_t travel_ms = 680U;
const uint32_t travel = t < travel_ms ? t : travel_ms;
const uint32_t p = travel * 255U / travel_ms;
const uint32_t inv = 255U - p;
const uint32_t ease = 255U - inv * inv / 255U;
前 680 ms 移动,700 ms 后开始逐渐降低透明度。+1 或 +10 标签同步上移并渐隐。
5.1 避免动画对象导致崩溃
不要在每次正面结果中随意创建一批对象,再让多个动画回调分别删除。
当前更稳定的方案:
- 进入游戏页时创建奖励金币和奖励标签;
- 平时设置
LV_OBJ_FLAG_HIDDEN; - 正面时重置坐标与透明度;
- 结算结束统一隐藏;
- 页面销毁时清理引用。
这种复用方式可以避免动画回调访问已经删除的对象,也能减少频繁分配造成的内存碎片。
6. 独立音频任务与事件队列
6.1 基本结构
音频模块使用一个 FreeRTOS 队列接收事件:
c
#define SAMPLE_RATE 16000U
#define CHUNK_SAMPLES 160U
#define AUDIO_QUEUE_DEPTH 8U
static QueueHandle_t s_queue;
启动音频任务:
c
esp_err_t coin_audio_start(void)
{
if (s_queue) return ESP_OK;
s_queue = xQueueCreate(
AUDIO_QUEUE_DEPTH,
sizeof(coin_audio_event_t)
);
if (!s_queue) return ESP_ERR_NO_MEM;
if (xTaskCreate(
audio_task,
"coin_audio",
6144,
NULL,
5,
NULL
) != pdPASS) {
vQueueDelete(s_queue);
s_queue = NULL;
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
UI 通过非阻塞方式投递音效事件:
c
esp_err_t coin_audio_post(coin_audio_event_t event)
{
if (!s_queue) return ESP_ERR_INVALID_STATE;
return xQueueSend(s_queue, &event, 0) == pdTRUE
? ESP_OK : ESP_ERR_TIMEOUT;
}
6.2 背景音乐与音效混合
音频任务每次生成一个 PCM 块:
c
int32_t background = render_background(...);
background = background * s_bgm_volume / 100;
int32_t effect = render_sfx(
&sfx,
&sfx_phase,
&sfx_phase2
) * s_sfx_volume / 100;
effect = effect * 5 / 2;
pcm[i] = clamp_s16(background + effect);
注意事项:
- 背景音乐和音效必须使用独立音量;
- 混合完成后要做
int16_t限幅; - 音效开始和结束时不重新初始化 I2S 或编解码器;
- UI 只提交事件,不等待音效结束;
- 队列满时应允许丢弃非关键重复事件,而不是阻塞界面。
6.3 奖励音效的"语义"问题
项目中曾出现"正面时背景音乐瞬间变大"的错觉。实际检查后,BGM 增益没有变化,听到的是正面奖励音。
旧音色持续时间较长、旋律感较强,容易被听成背景音乐的一部分。修改后,奖励音效使用多组短促高频音:
c
static const uint16_t coin_notes[] = {
1760, 2380, 1980, 2860, 2140,
2540, 1840, 3040, 2260, 2680,
1940, 2920, 2180, 2480, 1780,
2760, 2060, 2580, 1880, 2320,
};
每个音符只发出一个快速衰减的短脉冲,从听感上更接近一堆金币碰撞,而不是 BGM 突然增强。
7. NVS 保存金币数和两路音量
存储模块使用同一个 NVS 命名空间:
c
nvs_open("coin_game", NVS_READWRITE, &handle);
保存键:
coins:uint32_t;bgm_volume:uint8_t;sfx_volume:uint8_t。
开机加载:
c
ESP_ERROR_CHECK(coin_storage_init());
s_game.coins = coin_storage_load();
coin_audio_set_bgm_volume(
coin_storage_load_bgm_volume()
);
coin_audio_set_sfx_volume(
coin_storage_load_sfx_volume()
);
音量滑块在 LV_EVENT_VALUE_CHANGED 中更新音频并保存:
c
static void volume_slider_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) {
return;
}
lv_obj_t *slider = lv_event_get_target_obj(event);
const uint8_t value =
(uint8_t)lv_slider_get_value(slider);
// 根据通道更新 BGM 或 SFX,并写入 NVS
}
如果担心拖动时频繁写 NVS,可以在内存中立即更新音量,使用单次定时器或任务延迟,在滑动停止 300~500 ms 后再提交。
8. BMI270 摇晃检测
8.1 相邻采样变化量
读取三轴加速度:
c
float x, y, z;
if (!s_imu_ready ||
bsp_imu_get_accel(&x, &y, &z) != ESP_OK) {
return false;
}
计算相邻采样变化:
c
const float activity =
fabsf(x - previous_x) +
fabsf(y - previous_y) +
fabsf(z - previous_z);
这种方式对设备静态朝向不敏感,比直接判断某一轴的绝对值更适合识别短时摇晃。
8.2 连续命中与冷却
当前参数:
- 活动量阈值:
0.45f; - 连续命中窗口:450 ms;
- 需要命中:2 次;
- 触发冷却:1200 ms。
核心逻辑:
c
if (activity < 0.45f) {
if (now - last_hit_ms > 450U) {
shake_hits = 0;
}
return false;
}
if (now - last_hit_ms > 450U) {
shake_hits = 0;
}
last_hit_ms = now;
if (shake_hits < 3U) {
++shake_hits;
}
if (shake_hits < 2U || now < cooldown_until_ms) {
return false;
}
shake_hits = 0;
cooldown_until_ms = now + 1200U;
return true;
8.3 与游戏状态统一门控
IMU 检测成功后不能直接修改状态,必须与触摸使用相同入口:
c
const bool shaken = shake_detected();
if (s_game.page == PAGE_GAME &&
s_game.state == GAME_IDLE &&
!s_game.settings_panel &&
shaken) {
start_toss(true);
}
这能避免:
- 动画中再次摇晃导致重复抛掷;
- 设置菜单打开时误触;
- 触摸和摇晃同时触发两次;
- IMU 绕过金币不足检查。
阈值需要结合实机调试。建议先输出 x/y/z/activity,分别记录静止、拿起、轻晃、有效摇晃和放下设备的数值,再调整阈值与时间窗口。
9. 常见问题总结
9.1 中文显示成方框
精简字库没有包含动态提示中的字符。需要让 font_chars.txt 覆盖所有标题、按钮、结果文本和错误提示。
9.2 +1 动画结束后残留
动画结束时只降低透明度,没有恢复隐藏状态或重置位置。下一轮开始前统一调用清理函数。
9.3 正面奖励动画导致卡死
多个临时对象被删除后,定时器仍继续访问。将奖励对象改为预创建和复用,并在页面切换时清空指针。
9.4 月亮图案出现灰色圆块或缺口
月牙遮罩使用了与硬币背景不一致的颜色或尺寸。应让"挖空圆"与银色内层保持相同背景,同时确保遮罩完全位于父对象裁剪区域内。
9.5 音效结束时误以为背景音乐变大
先记录 BGM 与 SFX 实际增益,确认是否真的发生音量跳变;如果变量正常,应继续检查音效持续时间、频谱和旋律感。
10. 编译与烧录
在已经配置 ESP-IDF 环境的终端进入项目目录:
powershell
cd "C:\Users\bsd_hefan\Desktop\hf\coin_game\esp-mosaico-bsp-master\examples\coin_toss_game"
ESP32-S31 目标在当前工具链中属于预览目标时:
powershell
idf.py --preview set-target esp32s31
编译:
powershell
idf.py build
烧录并打开串口监视器:
powershell
idf.py -p COM19 flash monitor
串口号应根据设备管理器中的实际端口替换。退出监视器通常使用 Ctrl+]。
11. 总结
这个项目的核心不是随机正反面,而是把不同类型的交互统一起来:
- LVGL 定时器负责连续画面;
- 抛物线与高度压缩负责运动感;
- FreeRTOS 音频任务负责持续输出与混音;
- BMI270 检测负责把摇晃转换为输入请求;
- 状态机负责限制每个事件发生的时机;
- NVS 负责保存金币数和音量设置。
当输入、动画、声音、结算和存储形成闭环后,ESP-Mosaico 才从"集成多个外设的开发板"变成一台具有完整反馈的掌上交互原型。
对于其他 ESP32 图形项目,这套结构也可以复用:输入模块只产生事件,状态机决定是否接受,UI 与音频分别执行反馈,结算层统一修改数据和清理资源。