ESP-Mosaico 掌上游戏开发:LVGL 抛硬币动画、音频混音与 BMI270 摇晃触发

摘要

本文基于 ESP-Mosaico 的抛硬币掌上游戏项目,拆解三个关键交互模块:

  1. 使用 LVGL 定时器和二维对象实现 1.5 秒抛物线运动、上下翻面、落地摆动与金币散射;
  2. 使用 FreeRTOS 音频任务和队列管理背景音乐、抛掷音效与结果音效;
  3. 使用 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 = 0t = 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;

动画效果:

  1. 硬币高度从 180 px 缩小到 14 px
  2. 接近最窄状态时切换太阳和月亮图案;
  3. 高度重新恢复到 180 px
  4. 循环以上过程,形成上下翻转错觉。

更新对象尺寸与位置:

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);

保存键:

  • coinsuint32_t
  • bgm_volumeuint8_t
  • sfx_volumeuint8_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 与音频分别执行反馈,结算层统一修改数据和清理资源。

相关推荐
深圳市恒锐丰科技杨生8 分钟前
EG1163 (S) 高压大电流降压同步整流 DC‑DC 控制器|外置 MOS 降压电源方案
嵌入式硬件·硬件工程
安河桥。25 分钟前
V4L2架构在工程实践中的应用
嵌入式硬件·音视频
新晨单片机设计38 分钟前
S004A-基于STM32单片机密码锁【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus
启芯硬件39 分钟前
《硬件电路设计实战100例》专栏核心信息,定位及设计案例分享
人工智能·经验分享·嵌入式硬件·硬件工程·高速仿真
m0_5474866643 分钟前
《物联网技术简明教程》全套PPT课件2026
物联网
TDengine (老段)44 分钟前
TDengine 内存管理 — 池化、缓存、回收
大数据·数据库·物联网·缓存·时序数据库·tdengine·涛思数据
深圳市恒锐丰科技杨生1 小时前
EG1162 高压大电流降压同步整流 DC‑DC 控制器|外置 MOS 降压电源方案
嵌入式硬件·硬件工程
DevLoom_1 小时前
Mac 移动硬盘不显示?2026整理汇总完整解决方案
stm32·单片机·macos
开开心心就好2 小时前
手机悬屏翻译工具外语游戏漫画APP全覆盖
android·前端·javascript·python·游戏·pdf·html