ESP32 迷你游戏机,手感很好
很复古,什么也做不到(悲)
项目目的:卖萌
元件都是 0603
哦,铁板烧就能焊
视频介绍:https://www.bilibili.com/video/BV1Ga4y1f7d3/
硬件开源:https://oshwhub.com/eedadada/mason
RachelSDK
为 PIO
工程, VS Code
下载 PlatformIO
插件,用 VS Code
打开文件夹即可
.
├── apps
│ ├── app_ble_gamepad BLE 手柄
│ ├── app_music 音乐播放器
│ ├── app_nofrendo NES 模拟器
│ ├── app_raylib_games Raylib 游戏
│ ├── app_screencast WiFi 投屏
│ ├── app_settings 设置
│ ├── app_genshin __,__!
│ ├── app_template App 模板
│ ├── launcher 启动器
│ ├── utils 通用组件库
│ ├── assets 公共资源
│ ├── tools App 相关工具(脚本)
│ └── apps.h App 安装回调
├── hal
│ ├── hal.cpp HAL 基类
│ ├── hal.h HAL 基类
│ ├── hal_rachel HAL Rachel 派生类
│ ├── hal_simulator HAL PC 模拟器派生类
│ └── lgfx_fx lgfx 派生类(拓展图形API)
├── rachel.cpp
└── rachel.h RachelSDK 入口
NES 模拟器、音乐播放器等会尝试加载SD卡里指定目录的资源文件
.
├── buzz_music 蜂鸣器音乐
│ ├── harrypotter.json
│ ├── nokia.json
│ ...
├── fonts 字体
│ └── font_text_24.vlw
└── nes_roms NES ROM 文件
├── Kirby's Adventure (E).nes
├── Snow Bros (U).nes
...
font_text_24.vlw
这个字体我用的是 Zpix很嗨好看,可以替换任何自己喜欢的
NES ROM
直接丢进去就行,不是很大的应该都能玩
写了个 python
脚本用来简化 App 创建:
python3 ./src/rachel/apps/tools/app_generator.py
$ Rachel app generator > <
$ app name:
hello_world
$ file names:
$ - ../app_hello_world/app_hello_world.cpp
$ - ../app_hello_world/app_hello_world.h
$ app class name: AppHello_world
$ install app hello_world
$ done
App 就创建好了, 重新编译上传:
新创建的 App 基本模板如下,详细的生命周期和API可以参考 Mooncake 项目
// Like setup()...
void AppTemplate::onResume()
{
spdlog::info("{} 启动", getAppName());
}
// Like loop()...
void AppTemplate::onRunning()
{
spdlog::info("咩啊");
HAL::Delay(1000);
_data.count++;
if (_data.count > 5)
destroyApp();
}
Mooncake
框架内部集成了 spdlog 日志库,当然你也可以继续用 cout
, printf
, Serial
...
- 复制
src/rachel/apps/app_template
到同一目录并重命名:src/rachel/apps/app_hello_world
- 将里面的
app_template.cpp
和app_template.h
重命名为app_hello_world.cpp
和app_hello_world.h
- 打开
app_hello_world.cpp
和app_hello_world.h
,将里面的所有AppTemplate
替换成AppHello_world
- 打开
src/rachel/apps/apps.h
- 添加
#include "app_hello_world/app_hello_world.h"
- 添加
mooncake->installApp(new MOONCAKE::APPS::AppHello_world_Packer);
- 编译上传
关闭 App,调用后会告诉框架你不玩了,框架会把你的 App 销毁释放,所以在 onRunning()
被阻塞的情况下是无效的
// 有效
void AppTemplate::onRunning()
{
destroyApp();
}
// 无效
void AppTemplate::onRunning()
{
destroyApp();
HAL::Delay(66666666666);
}
获取 App 名字,会返回你设置的 App 名字
// 你的 App 头文件里:
class AppHello_world_Packer : public APP_PACKER_BASE
{
// 这里修改你的 App 名字:
std::string getAppName() override { return "文明讲礼外乡人"; }
...
}
获取 App 图标,启动器在渲染画面时会调用
// 你的 App 头文件里:
class AppHello_world_Packer : public APP_PACKER_BASE
{
...
// 这里修改你的 App 图标(有默认图标)
void* getAppIcon() override { return (void*)image_data_icon_app_default; }
...
}
获取数据库实例,是一个简单的 RAM
上 KV
数据库,可以用于 App 退出数据保存、多 App 间的数据共享(当然断电没),详细用法参考这里
void AppTemplate::onResume()
{
// 看看数据库里有没有这个 key
if (mcAppGetDatabase()->Exist("开了?"))
{
// 数据库里拿出来, 看看开了几次
int how_many = mcAppGetDatabase()->Get("开了?")->value<int>();
spdlog::info("开了 {} 次", how_many);
// 加上这一次, 写进数据库
how_many++;
mcAppGetDatabase()->Put<int>("开了?", how_many);
}
// 没有就创建一个
else
mcAppGetDatabase()->Add<int>("开了?", 1);
}
获取 Mooncake
框架实例,一般用来写启动器.. 比如这里.
// 看看安装了几个 App
auto installed_app_num = mcAppGetFramework()->getAppRegister().getInstalledAppNum();
spdlog::info("安装了 {} 个 App", installed_app_num);
// 看看他们都叫什么
for (const auto& app_packer : mcAppGetFramework()->getAppRegister().getInstalledAppList())
{
spdlog::info("{}", app_packer->getAppName());
}
HAL为单例模式,SDK初始化时会注入一个HAL实例.
- 对于
HAL Rachel
,按住按键A
开机,会暂停在初始化界面,可以查看详细的HAL初始化log - 如果有不同底层硬件需求,只需派生新的HAL对象,重写 API 方法 (override) 并在初始化时注入即可
#include "{path to}/hal/hal.h"
// 获取屏幕驱动实例
HAL::GetDisplay();
// 获取全屏Buffer实例
HAL::GetCanvas();
// 推送全屏buffer到显示屏
HAL::CanvasUpdate();
// 渲染FPS面板
HA::RenderFpsPanel();
显示驱动使用 LovyanGFX,详细的图形API可以参考原项目示例
// 延时(毫秒)
HAL::Delay(unsigned long milliseconds);
// 获取系统运行毫秒数
HAL::Millis();
// 关机
HAL::PowerOff();
// 重启
HAL::Reboot();
// 设置RTC时间
HAL::SetSystemTime(tm dateTime);
// 获取当前时间
HAL::GetLocalTime();
// 优雅地抛个蓝屏
HAL::PopFatalError(std::string msg);
HAL Rachel
在初始化时会以RTC时间调整系统时间,所以时间相关的POSIX标准
API都可以正常使用
// 刷新IMU数据
HAL::UpdateImuData();
// 获取IMU数据
HAL::GetImuData();
// 蜂鸣器开始哔哔
HAL::Beep(float frequency, uint32_t duration);
// 蜂鸣器别叫了
HAL::BeepStop();
// 检查SD卡是否可用
HAL::CheckSdCard();
// 获取按键状态
HAL::GetButton(GAMEPAD::GamePadButton_t button);
// 获取任意按键状态
HAL::GetAnyButton();
// 从内部FS导入系统配置
HAL::LoadSystemConfig();
// 保存系统配置到内部FS
HAL::SaveSystemConfig();
// 获取系统配置
HAL::GetSystemConfig();
// 设置系统配置
HAL::SetSystemConfig(CONFIG::SystemConfig_t cfg);
// 以系统配置刷新设备
HAL::UpdateSystemFromConfig();
一些比较有用的通用封装库放在了这里 rachel/apps/utils/system
创建一个选择菜单
#include "{path to}/utils/system/ui/ui.h"
using namespace SYSTEM::UI;
// 创建选择菜单
auto select_menu = SelectMenu();
// 创建选项列表
std::vector<std::string> items = {
"[WHAT 7 TO PLAY]",
"Jenshin Import",
"Light Soul",
"Grand Cop Manual",
"Super Maliao",
"Quit"
};
// 等待选择
auto selected_index = select_menu.waitResult(items);
spdlog::info("selected: {}", items[selected_index]);
创建一个带有进度条的窗口(u1s1, 现在应该算是页面)
#include "{path to}/utils/system/ui/ui.h"
using namespace SYSTEM::UI;
for (int i = 0; i < 100; i++)
{
ProgressWindow("正在检测智商..", i);
HAL::CanvasUpdate();
HAL::Delay(20);
}
参考 arduino-songs 的 json 格式蜂鸣器音乐播放器,json 格式音乐示例
#include "{path to}/utils/system/audio/audio.h"
using namespace SYSTEM::AUDIO;
// 播放SD路径上的json音乐文件
BuzzMusicPlayer::playFromSdCard("/buzz_music/nokia.json");
参考 Button 的按键库
#include "{path to}/utils/system/inputs/inputs.h"
using namespace SYSTEM::INPUTS;
auto button_a = Button(GAMEPAD::BTN_A);
while (1)
{
if (button_a.pressed())
spdlog::info("button a was pressed");
if (button_a.released())
spdlog::info("button a was released");
if (button_a.toggled())
spdlog::info("button a was toggled");
HAL::Delay(20);
}
更深入些的具体框架和实现,睡不着可以看看
HAL Rachel
派生自 HAL
,提供了 HAL
中的 API
在 arduino-esp32
上的具体实现
.
├── components 各外设的初始化和 API 实现
│ ├── hal_display.cpp
│ ├── hal_fs.cpp
│ ├── hal_gamepad.cpp
│ ├── hal_i2c.cpp
│ ├── hal_imu.cpp
│ ├── hal_power.cpp
│ ├── hal_rtc.cpp
│ ├── hal_sdcard.cpp
│ └── hal_speaker.cpp
├── hal_config.h 引脚定义, 内部 log 定义等
├── hal_rachel.h 类声明
└── utils
└── m5unified 非常好用的一些 ESP32 外设抽象
HAL
在被注入时会调用 init()
,HAL Rachel
重写的 init() 即为初始化流程:
inline void init() override
{
_power_init(); // 电源管理初始化
_disp_init(); // 显示屏初始化
_gamepad_init(); // 手柄按键初始化
_spk_init(); // 扬声器(蜂鸣器)初始化
_i2c_init(); // I2C 初始化
_rtc_init(); // RTC 初始化
_imu_init(); // IMU 初始化
_fs_init(); // 内部 Flash 文件系统初始化
_sdcard_init(); // SD 卡文件系统初始化
_system_config_init(); // 系统配置初始化
_sum_up(); // 总结
}
- 内部 Flash 文件系统使用
LittleFS
,目前只是用于系统设置的保存, 所以分区只给了 256 kB loadTextFont24()
这个 API 的设计目的是用于更好看的(支持中文)文本显示需求,实现方式是从SD卡读取vlw
字体,所以使用这个字体后,渲染画面耗时会变长- 当然有很多方法可以让上面这个API也适用于快速刷新的画面,不过对我来说这个自带字体够用了,启动器和选择菜单都是用的这个
- RTC 和 IMU 这两个外设都可以在 M5Unified 这个库中找到现成好用的驱动和抽象,我只是从其中抽离出来根据需求做对接
因为 LovyanGFX 支持 SDL 作显示后端,因此要实现一个 PC 上的 HAL 实现基本什么都不用做(确信),一个头文件搞定。RachelSDK 的模拟器工程在这里
有 HAL 把底层抽象架空,剩下的都是 C++ 自由发挥了(当然有些 App 还是直接用了平台特定 API, 比如 NES 模拟器用了 ESP32 的分区读写 API, 如果这些都给做上抽象就太浪费时间了~, 条件编译隔开就好, 不妨碍整体框架的通用性)
RachelSDK 的初始化在这里,具体如下:
...
// 根据平台注入具体 HAL
#ifndef ESP_PLATFORM
HAL::Inject(new HAL_Simulator);
#else
HAL::Inject(new HAL_Rachel);
#endif
// 初始化 Mooncake 调度框架
_mooncake = new Mooncake;
_mooncake->init();
// 安装启动器 (嗯,启动器也是 App )
auto launcher = new APPS::Launcher_Packer;
_mooncake->installApp(launcher);
// 安装其他 App (设置、模拟器...)
rachel_app_install_callback(_mooncake);
// 创建启动器
_mooncake->createApp(launcher);
...
初始化完后, 由 Mooncake 框架接管,完成各个 App 的各个生命周期的调度,放个生命周期简图:
启动器,由 SDK 启动的第一个 App,用来启动 App 的 App(?)
.
├── assets 静态资源
│ └── launcher_bottom_panel.hpp
├── launcher.cpp App Launcher 实现
├── launcher.h App Launcher 声明
└── view
├── app_anim.cpp App 打开关闭动画
├── menu.cpp 启动器菜单
└── menu_render_callback.hpp 启动器菜单渲染回调
打开 launcher.cpp:
onCreate
,这个地方只会在启动器被创建时调用一次,所以负责自己属性的配置和资源申请等:
void Launcher::onCreate()
{
...
// 允许后台运行
setAllowBgRunning(true);
// 允许创建后自动启动
startApp();
// 创建菜单(这个菜单就是安装了的 App 的列表的抽象, 后面渲染部分会详细讲)
_create_menu();
}
onResume
会在启动器刚创建,或者从后台切到前台时被调用,所以放一些渲染前的准备,控件信息刷新..
void Launcher::onResume()
{
...
// 切字体..
HAL::LoadLauncherFont24();
HAL::GetCanvas()->setTextScroll(false);
// 更新状态栏的时间文本
_update_clock(true);
}
onRunning
,没有其他 App 打开时,启动器读取输入..刷新菜单、控件.. 渲染画面..
void Launcher::onRunning()
{
_update_clock();
_update_menu();
}
偷偷点进去 _update_menu()
然后看看这里,可以看到当启动器需要打开一个 App 的时候干了什么:
...
// 看看开了哪一个
auto selected_item = _data.menu->getSelector()->getTargetItem();
// Skip launcher
selected_item++;
// 获取选中的 App 的 App Packer
auto app_packer = mcAppGetFramework()->getInstalledAppList()[selected_item];
// 用他来创建和打开这个 App,
if (mcAppGetFramework()->createAndStartApp(app_packer))
{
...
// 将启动器压进后台
closeApp();
}
...
倒回来看 onRunningBG
,启动器在后台时居然在..
void Launcher::onRunningBG()
{
// 如果只剩下启动器一个 App 在运行(也就是说之前打开的 App 已经退出销毁了)
if (mcAppGetFramework()->getAppManager().getCreatedAppNum() == 1)
{
...
// 将启动器推回前台
mcAppGetFramework()->startApp(this);
...
}
}
这里的判断方式其实会伴随一些限制,比如我不能在启动器在前台的同时,有其他 App 在后台搞事。因为启动器回到前台的条件就是只有他一个 App (好霸道),不过暂时也没这需求~
讲启动器的渲染之前要先插播一下 SmoothMenu
这个带简单路径插值的菜单抽象库
这只是个简单的菜单,所以可以分为三部分:
- 菜单(Menu):就是菜单,存着有什么菜可以点
- 选择器(Selector):你的手指,用来👉菜
- 摄像机(Camera):你的眼睛,用来盯着你的手指
然后发散一点,将菜单里的每一道菜(Item),想象成坐标轴上的一个点 item(x, y)
,那菜单就变成了一系列点的集合: [item_1, item_2, item_3...]
然后你的手指👉的地方也是一点 selector(x, y)
,当你想吃第二道菜的时候,就可以指向 selector(item_2)
,告诉别人你对这道菜有意思(就意思意思)
到这里已经可以用了:按键 DOWN
按下的时候,👉从 selector(item_1)
跳到 selector(item_2)
,搞定
那摄像机用来干嘛捏,屏幕和眼睛一样有范围限制,所以菜单特别长的时候,眼睛要跟着👉动
因为👉运动和数学大题一样要有过程,所以👉从 item_1(x1, y1)
到 item_2(x2, y2)
要给上过程插值
我这里的插值实现是对 lvgl_anim 的封装(读书人的事怎么能叫抄呢(恼)):
// 参数: 动画曲线(贝塞尔), 开始值, 结束值, 过程时间
void setAnim(LV_ANIM_PATH_t path, int32_t startValue, int32_t endValue, int32_t time);
// 根据时间返回当前值
int32_t getValue(int32_t currentTime);
看完上面这两个 API 应该都明啦,只需要这样:
anim_x.setAnim(Q弹, x1, x2, 1秒);
anim_y.setAnim(Q弹, y1, y2, 1秒);
while (1)
{
current_time = 宜家几点;
selector(anim_x.getValue(current_time), anim_y.getValue(current_time));
}
👉就可以Q弹地从 item_1
运动到 item_2
了~
然后再发散一点,能不能给所有坐标都套上插值捏,就有了菜单打开关闭动画.. 长菜单滚动动画..
到这里我们已经抽象出来了一坨飞来飞去的坐标,直接把这坨坐标甩给用户就行了,渲染回调函数:
virtual void renderCallback(
const std::vector<Item_t*>& menuItemList, // 菜单
const RenderAttribute_t& selector, // 👉
const RenderAttribute_t& camera // 摄像机
) {}
依次在坐标上渲染相应的目标,一个(帧)菜单页面就完成了
这下再回来看启动器的渲染就很清晰啦
首先是菜单的创建:
...
// 看看安装了什么
for (const auto& app : mcAppGetFramework()->getAppRegister().getInstalledAppList())
{
// 跳过自己
if (app->getAddr() == getAppPacker())
continue;
// 把 App 塞进菜单里
_data.menu->getMenu()->addItem(
// App 的名字
app->getAppName(),
// App 的 X 坐标
THEME_APP_ICON_GAP + i * (THEME_APP_ICON_WIDTH + THEME_APP_ICON_GAP),
// App 的 Y 坐标 (这里 Y 为恒定是因为我菜单是横着走的)
THEME_APP_ICON_MARGIN_TOP,
// 这东西有多宽 (图标宽)
THEME_APP_ICON_WIDTH,
// 这东西有多高 (图标高)
THEME_APP_ICON_HEIGHT,
// 把图标的的指针也塞进去
app->getAppIcon()
);
i++;
}
...
然后看对应的渲染回调:
...
// 首先引入了 X 偏移量, 是因为我只需要按下按键后图标们滚动, 相当于👉不动菜单动
// 所以把坐标系原点从菜单转换到👉就行
_x_offset = -(selector.x) + HAL::GetCanvas()->width() / 2 - THEME_APP_ICON_WIDTH_HALF;
// 遍历菜单里所有的东西
for (const auto& item : menuItemList)
{
// 这里引入了 Y 偏移量, 是为了实现被选中的 App 图标比没选中的高, 就跟斗地主一样~
_y_offset = std::abs(selector.x - item->x) / 3;
// 最后根据坐标渲染 App 图标就大功告成了
HAL::GetCanvas()->pushImage(
item->x + _x_offset,
item->y + _y_offset,
THEME_APP_ICON_WIDTH,
THEME_APP_ICON_HEIGHT,
(const uint16_t*)(item->userData)
);
...
}
...