osu-mc-hmos:基于 HarmonyOS ArkTS 的单片机 UI 复刻运行时项目

使用 DeepSeek V4.1 Flash 对 chhongzh/osu-mc 项目的UI/UX,做 1:1 复刻。在 HarmonyOS 系统上体验这个丝滑Q弹的esp32 UI。

分支1Tags5

osu!mc for HarmonyOS(osu-mc-hmos)

把 ESP32-S3 + 128×64 单色 OLED 上的 osu!mc(一个界面仿 osu!(lazer) 的单片机小项目) 1:1 重建为 HarmonyOS ArkTS 应用(模拟器 / 真机形态)。

  • 范围:只复刻界面、全部动画与交互;Wi-Fi / HTTP / System Info 用假数据填充。
  • 画法:屏幕不用原生 ArkUI 组件重写,而是软件光栅化——自建 1-bit 帧缓冲 → Canvas 逐像素 → 最近邻放大。
  • 目标:常量、时序、边界行为与原项目一致(比特级一致)。

复刻已经完成。这是一套可复用的 osu!mc UI/UX 运行时:以后如果要做真实业务, 只需把这套 UI 接到真实的服务实现上(见下方「把它当成业务底座」)。


目录


文档导航

本文件只提供整个项目的缩略图;具体内容都在下面的专属文档里,按需进入。

文档 面向谁 内容(职责)
README.md(本文件) 人(项目入门) 项目是什么、长什么样、按键语义、外壳概览、构建运行、目录结构——只做缩略图与导航
AGENTS.md 改代码的 AI/agent 精简工作指引:五条设计思路、实现坑、事件/主循环、常量表、里程碑状态——细节一律指向下方文档
docs/harmonyos-port-plan.md 所有人(复刻权威) 全部复刻细节:逐文件映射、逐类要点、常量、页面内容、验证方法、有意偏离、业务接入
docs/arkui-app-guide.md 改外壳/设置的人 与复刻无关:Navigation 与系统路由表、应用设置页、MVVM 数据分层(为 SQLite 预留)、按键振动、沉浸式与主题资源
tools/exportfont/README.md 需重新生成字体的人 点阵导出工具的用法与位流格式
tools/fixglyph/README.md 需修正/扩展难辨字形的人 字形可读性修正工具的用法与偏差说明

一句话分工:细节看 harmonyos-port-plan.md,动外壳/设置看 arkui-app-guide.md, 动代码前看 AGENTS.md,人类入门看本文件。


它长什么样

  • 屏幕:128×64、1 bit/像素、固定黑底白字(OLED 语义),不走 ArkUI 主题。 由自建帧缓冲逐像素绘出,再按窗口可用的最大整数倍最近邻放大(128×64 → 例如 6× 即 768×384 px)。 放大倍数的上限可在设置页用滑块调整(默认为最大值,即自动取最大可用整数倍)。 只能是整数倍:非整数倍会让最近邻放大把源像素摊成大小不一的方块,肉眼看到的就是帧错位。
  • 外壳:应用标题与 7 个按键是 ArkUI 原生控件,标签走 resources + $r, 浅色 / 深色(base / dark)两套资源;按键按下会振动一次(强度可在设置页调)。
  • 屏内页面(1:1 复刻):Launcher(osu!mc + 四项)、Settings(Wi‑Fi / Screen off / About)、 Lorem Ipsum(搜索 → 歌曲 → 难度 → Options + 阅读器)、HTTP Client、System Info、三层软键盘。 每一页的具体内容与动感清单见方案文档 §19。

屏幕下方的 7 个按键

这 7 个按钮是 ArkUI 原生按钮,用来模拟真机的 6 个按键 + 1 个复位键外壳; 它们的映射关系与原项目 cmd/osu-mc/osu-mc.go 的按键定义完全一致。

排布(屏幕正下方,自上而下):

   [ RST ]                    [ SET ]      ← RST 在左 / SET 在右,两端对齐
                 [ UP ]
        [ LEFT ]  [ MID ]  [ RIGHT ]       ← 十字键
                [ DOWN ]
  • 屏幕按窗口可用的最大整数倍放大(chooseScale();回退 6×、上限 10×);设置页的 「屏幕倍数」滑块是上限(默认 = 最大),窗口放不下时自动缩小,横竖屏都不会溢出。
  • 横竖屏自适应:应用跟随系统传感器旋转(module.json5 里 orientation: "auto_rotation", 需系统未锁定旋转)。竖屏就是上面这张"上下排";横屏改成左右排——左栏是标题 + 副标题 + 屏幕,右栏是按键,两栏各占一半宽度并垂直居中。旋转时放大倍数与排布都会重算。
  • RST 是复位键,用警示红(文字 / 边框 / 背景三色资源 key_danger_fg / key_danger_border / key_danger_bg),浅色与深色各一套;其余 6 个键仍是中性色(key_bg / key_fg)。

先说结论:MID 是确认、SET 是返回、RST 是回主页。 逐个说明如下。

  • UP(KeyUp,可重复)
    • 在列表里把选择上移一行。
    • 到顶后继续按住,会让内容 + 游标反向偏移 6px(peek),松开弹回。
  • DOWN(KeyDown,可重复)
    • 把选择下移一行;与 UP 对称的边界行为。
  • LEFT(KeyLeft,可重复)
    • 在横向行(Row,如 Options 的 Mods / 倍速行)里向左移动 / 滚动。
    • 在行首按左键会向上冒泡到 Viewer 返回上一页;由于「按下者独占后续 Repeat」, 按住左键不会连弹多层页面。
  • RIGHT(KeyRight,可重复)
    • 在横向行里向右移动 / 滚动。
  • MID(KeyEnter,不重复)= 确认
    • 执行当前选中项:打开页面、按下软键盘的按键、勾选 Checkbox / Chip、提交输入框(OK)等。
    • 关键点:确认动作在松开时才执行(Fires() = Repeat || (Release && !Repeated)), 按下去只给「游标倾斜 / 挤压」的反馈。
  • SET(KeyBack,不重复)= 返回
    • 按住时当前页向右移动 8px 做 peek(根页 4px),松开才真正 pop 上一页。
  • RST(无键码,单独处理)= 回主页
    • 松开时调用 PopToRoot(),一次弹到栈底直接回主页(不是退一层)。
    • 关屏时的唤醒由任意键触发,被唤醒的那一次按键(含 RST)会被吞掉, 所以唤醒不会顺带确认或返回。

应用外壳:导航 / 设置页 / 沉浸式

这一层是通用 App 外壳,与「128×64 屏幕的 1:1 复刻」无关(屏幕渲染一行没改)。 完整设计见 docs/arkui-app-guide.md。

  • 导航:首页是 Navigation 根页(HdsNavigation + NavigationMode.Stack), 标题栏不带标题文字,只保留右侧设置菜单(横屏时不挤占内容宽度)。 子页面走系统路由表(resources/base/profile/router_map.json + RouteName), 用 pushPathByName 跳转——新增页面不必改首页。

  • 设置页(路由 Settings):七张卡片——外观(深浅色)、标题栏模糊、沉浸光感、 按键振动(关闭 / 弱 / 中 / 强,默认「弱」)、屏幕旋转、屏幕倍数(原生 Slider,整数 2–10)、 关于(版本号取自 bundleManager,不硬编码)。 卡片底色是浅灰 #E8EAED / 深灰 #2A2A2A(settings_card_bg),分段控件用原生 CapsuleSegmentButtonV2(状态管理 V2)、保持系统默认配色(未照搬参考项目的蓝灰底与自定义配色)。

  • 状态管理统一为 V2(@ComponentV2 / @Local / @Param / @Event / @Monitor / @ObservedV2 / @Trace):没有 V1 的 @State / @StorageLink / AppStorage。

  • 数据分层(MVVM,为 SQLite 预留):

    View(SettingsPage · @Local 快照)
      └─→ ViewModel(SettingsViewModel · 编排读写 + 发布到 AppUiState + 系统生效)
            └─→ Repository(SettingsRepository · 业务语义,字段 ↔ 通用 KV 键)
                  └─→ KeyValueStore(通用 KV 接口)
                        ├── PreferencesKeyValueStore(现在:首选项文件 osu_mc_store)
                        └── RdbKeyValueStore(以后:SQLite,只需改一行装配)
    

    持久化落 Preferences,运行时快照放 AppUiState 单例(@ObservedV2 + @Trace, 首页 @ComponentV2 直接读字段即响应式刷新,设置页改动回到首页即时生效)。 以后换 SQLite 只需新增一个 KeyValueStore 实现, 并在唯一装配点 createSettingsRepository() 换一行。

  • 沉浸式(组件级,不做窗口级全屏):页面背景用 expandSafeArea 延伸到状态栏 / 导航条;标题栏支持滚动动态模糊(bindToScrollable)与沉浸光感; 首页 7 个按键用 .systemMaterial(...) 拿系统沉浸材质。状态栏文字颜色跟随深浅色。

  • 按键振动:按下 7 个按键时给一次短振(模拟实体按键按下去的手感),强度在设置页调 (关闭 / 弱 / 中 / 强,默认「弱」)。用系统预置振动效果(VibratePreset):设备支持高清振动时用 intensity 分档,否则用 soft / hard / sharp 三档;需要 ohos.permission.VIBRATE (已声明)。实现见 docs/arkui-app-guide.md §5.2。

  • 平台版本:targetSdkVersion = compatibleSdkVersion = 26.0.0—— 沉浸光感所用的 uiMaterial / systemMaterial 自 API 26 起提供。


容易误解 / 语义不明确的地方

  • MID 为什么是「确认」、SET 为什么是「返回」?
    • MID / SET 是硬件按键的丝印名(原项目里 KBD_MID_PIN = GPIO3、KBD_SET_PIN = GPIO46), 原作者沿用了板子上的叫法,再把它映射成 KeyEnter / KeyBack。
    • MID(middle,方向盘 / 摇杆的中间键)当确认键其实很自然;真正拗口的是把 SET(设置键)当「返回」 ——因为板子上没有单独的 Back 键。
    • 这不是移植引入的问题,原项目就是这样;为保持 1:1,这里照搬,不做「重新设计」。
  • RST 是「复位」,但不是重启芯片。
    • 硬件上它是 GPIO9 上的一个普通按键(不是 ESP32 的芯片复位 / EN 脚),固件像其他键一样 去抖轮询它,且键码为 0(不产生任何 UI 键事件)。
    • 功能上的「复位」= 把界面复位回主页,对应 PopToRoot(),不会重启芯片。
    • 原项目 README 把它和 6 个键并列描述为「六个按键加一个复位键」。
  • 按下 ≠ 执行。
    • 事件是三态 Press / Repeat / Release;按下只给反馈(游标倾斜 / 挤压、页面 peek), 确认、返回、打开键盘都在松开时执行。
  • 屏幕是「软件光栅化」,按钮才是原生控件。
    • 上方 128×64 单色画面由自建帧缓冲逐像素画出(黑底白字,1 bit/像素,不走 ArkUI 主题), 再按整数倍最近邻放大到画布上;
    • 下方 7 个按钮是 ArkUI 原生按钮,仅作外壳。
  • 显示名与模块名不一致。
    • 界面上显示一律是 osu!mc;路径 / 模块名用 osu-mc(! 不能出现在路径里)。
  • 屏幕上的感叹号 ! 是刻意改过的。
    • 原固件字体把 ! 画成一条没有点的实心竖线,与 l / | 几乎难分;移植版用 tools/fixglyph 把它改成「竖线 + 点」。这是全项目唯一一处刻意偏离固件的像素 (见方案 §7.4)。
  • Wi-Fi / HTTP / System Info 是假数据。
    • application/MockServices.ets 提供 MockWiFi / MockHTTP / MockPower / MockSystem, 不做任何硬件访问;其中 System Info 的 Loop / Draw / Busy 会像真机一样每秒刷新。
  • 光标可以停在文本行上。
    • Text 也实现 Focusable,所以在 Wi-Fi 页光标可以停在状态行 / IP 行(与原项目一致), 而不只是「可操作项」。
  • 左右键的重复只到达「接管按下」的那个元素。
    • 因此按住 LEFT 只会返回一层,不会一路弹回根页。
  • 界面文案(如 MID / SET / RST)是对硬件的忠实还原,不是 UI 术语。
    • 若想改成更「符合语义」的名字(例如 OK / BACK),属于重新设计,与 1:1 目标冲突。

架构与技术要点

渲染 = 软件光栅化。 原项目不依赖任何平台 UI 框架:它自己把一切画到一个 1-bit Surface (只有 SetPixel / InvertRect),字体是内嵌点阵(tinyfont notosans12pt),动画是纯数学。 移植版照此重建,因此可以做到比特级一致:

FrameBuffer(128×64,1 bit)
  → Canvas(surface + clip + origin,值类型;仅五种原语:置位像素/填充矩形/反色矩形/反色圆角矩形/位图画字形)
  → ScreenView(整数倍最近邻放大:128×64 离屏画布 → drawImage(关闭图像平滑)到 ArkUI Canvas)

动画 = 一个全局 Manager 推进所有 Value。 屏幕上每个可见数值(位置、尺寸、滚动、拉扯、 游标倾斜、页面进出……)都是 Value{Current, Target, Speed, Damping, Velocity}: 只改 Target,让弹簧去追;两种运动——指数平滑(帧率无关、永不过冲)与阻尼弹簧 (半隐式欧拉、固定子步长 1/240,带惯性与回弹)。

UI 模型 = 万物皆 ListItem。 没有布局容器:一页就是一个 List,每行是实现 ListItem 的组件;List / Row 本身也是 ListItem,可以互相嵌套。布局按 Target 算、绘制按 Current 算;一个效果只有一个动画源。

事件 = 三态 + 路由。 Press / Repeat / Release;Router 沿 root → focused() 先捕获 后冒泡;按下者独占该键的后续事件;确认 / 返回 / 开键盘都在松开时执行。

主循环 = displaySync 驱动。 每帧 dt = min(真实时间差, 0.1)(必须用真实时间,弹簧才 帧率无关);viewer.update(dt) 设置所有目标 → Default.update(dt) 推进动画 → 只有 发生变化时才重绘并上传。dt、时序、常量都与原固件逐一对齐。

逐文件映射、逐类要点、全部常量、有意偏离,都在 docs/harmonyos-port-plan.md。


开发约定与注意事项

这是翻译,不是创作。 常量、时序、边界行为必须与原项目一致;不要「重新设计」。

  1. 不要用原生 ArkUI 组件重写屏幕。 Column / Text / Swiper / animateTo 只能做到 「神似」;屏幕必须走软件光栅化。外壳(标题、按键)以及以后新增的、与屏幕无关的 ArkUI 部分, 才是原生组件的地盘。
  2. 外壳与新增 ArkUI 部分:一律「鸿蒙原生组件 + 状态管理 V2」。
    • 状态用 V2:@ComponentV2 / @Local / @Param / @Event / @Monitor,跨页共享用 @ObservedV2 + @Trace 单例;不要再用 @State / @StorageLink / AppStorage。
    • 界面用原生组件,样式 / 动画 / 材质 / 无障碍保持系统默认行为,颜色与文案走 $r。
    • 非必要不自造自定义控件:原生能表达的就不自己画;确需封装也只能建立在原生组件之上。
  3. 不要直接写 Current(会跳变)。只改 Target。例外只有三种:子元素被父元素「携带」、 Snap() / Jump()、以及设定动画的起点(页面进场、新插入项从尺寸 0 展开)。
  4. ArkTS 约束(本项目大量使用,务必遵守):
    • 不能用 any / unknown;不能解构;不能用 var;函数表达式一律写成箭头函数;
    • 不能用 Function.call/apply/bind;不能索引签名 obj[key];不能 for..in 遍历对象;
    • 可选能力不能用 instanceof 判断(interface 是纯编译期的)——统一改成 Element 基类上的「空实现方法 + isXxx() 能力标志」(详见 AGENTS.md §2.1)。
  5. 命名对应原项目:Go 导出名保持原样,方法用小写驼峰,字段用小写驼峰, 于是 Text.FocusRect() ↔ text.focusRect() 一眼可查。
  6. 资源与主题:外壳的固定文案进 resources/base/element/string.json 并用 $r(...) 引用, 新增文案要同步所有语言;颜色进 color.json,并同时补 resources/dark/element/color.json。 屏幕内容自绘,不涉及 $r——这是刻意的,1:1 要求自己控制每个像素。
  7. 缩放只能是整数倍(最近邻放大);非整数倍会产生肉眼可见的「帧错位」。
  8. 动画性能:不要在动画过程中频繁改 width / height / padding / margin; 复杂子组件的动画可考虑 renderGroup(true)(本项目主要靠帧缓冲自绘,此项影响有限)。

把它当成业务底座

复刻阶段所有硬件能力都是假的,但这套屏幕与 UI 结构是完整可复用的。接真实业务时:

  • 唯一接缝是 Services(application/Services.ets 的接口 + 数据结构)。App 与五个页面 只依赖接口,因此只需新写 RealWiFi implements WiFi 等,替换 Index.ets 里装进 Services 的 Mock* 即可;不要改接口签名。
  • 耗时操作走 Job<T> + 异步(App.ets),UI 侧轮询结果,绝不在帧内阻塞。
  • 业务 UI 继续用 List / Row / Text / TextView / Input 这套 ListItem 组件搭, 不要在帧缓冲上混入 ArkUI 组件;只有外壳/新增业务外框才用 ArkUI + $r。
  • 详细迁移建议见 docs/harmonyos-port-plan.md §20。

构建与运行

  • 用 DevEco Studio(推荐): 拉取项目 → 配置签名 → 直接运行。
  • 用命令行 devecocli:
# 进入到hdc命令所在位置 `\Huawei\DevEco Studio\sdk\default\openharmony\toolchains`
## 无线连接设备
hdc tconn <ip>:<port>
## 确认设备已正常连接
devecocli device list

# 进入到本工程目录内
## 检查 ArkTS 语法
devecocli check arkts
## 构建 + 安装 + 启动到已连接设备(--uninstall 先卸载同名旧应用)
devecocli run --device "<设备名>" --uninstall
  • 采用 Stage 模型 + ArkTS,bundleName = cn.forforio.osumchmos(见 AppScope/app.json5)。
  • build-profile.json5 的 targetSdkVersion / compatibleSdkVersion 均为 26.0.0: 应用外壳的「沉浸光感」依赖 API 26 起的 uiMaterial / .systemMaterial()(见 docs/arkui-app-guide.md §7)。删掉相关代码可降低至 API 19 。删掉提示按钮的bindPopup避让功能,可以降低至 API 12 。(未测试)
  • build-profile.json5 使用 devecocli signature generate --product default 生成的调试签名; ⚠️ 该文件内含明文调试签名口令,公开仓库时请自行评估。
  • 字体点阵表由 tools/exportfont(普通 Go 程序,不需要 TinyGo)从 tinyfont v0.7.0 的 notosans12pt 导出,再由 tools/fixglyph 做一次可读性修正(把 ! 改成带点的形状), 可复现。所以首次构建、运行应用不需要 Go 环境 —— 只有需要重新生成字体或生成「黄金帧」比对 语料这两件一次性的事才用得到 Go。

目录结构

entry/src/main/ets/
├── animation/    动画 Value(指数平滑 / 阻尼弹簧)与全局 Manager
├── application/  屏内页面(Launcher / 屏内 Settings / Lorem / Http / SysInfo / App)+ MockServices
├── font/         内嵌点阵字模 Noto12Glyphs(tinyfont notosans12pt,0x20..0x7E)
├── input/        Buttons:按键去抖 + 重复加速
├── pages/        Index(7 键外壳 + Navigation 根页);SettingsPage(应用设置页)
├── screen/       FrameBuffer(1-bit 帧缓冲)+ ScreenView(最近邻放大显示)+
│                 Shell(主循环 / 输入分发)+ ScalePolicy(放大倍数策略)
├── ui/           Canvas / Element / Geometry / Event / Router / Cursor / Nav /
│                 Item / List / Row / Text / TextView / Input / Select / Checkbox /
│                 Chip / Keyboard / Viewer / Font / UiConstants
│
│   —— 以下为「应用外壳层」(ArkUI,与屏幕复刻无关,见 docs/arkui-app-guide.md)——
├── common/       AppTheme / ContextUtil / MaterialUtil / RouteName / TitleBarUtil /
│                 VibratorUtil(按键振动:预置效果 + 强度分档)
├── components/   SegmentedControl(通用分段控件)
└── preferences/  AppSettings / AppUiState(V2 运行时状态单例)/ KeyValueStore /
                  SettingsRepository / DisplayController / SettingsViewModel
tools/exportfont/ 从 tinyfont 导出点阵表的工具(独立 Go module)
tools/fixglyph/   对导出的点阵表做可读性修正(如 `!`)的工具(独立 Go module)
docs/             复刻方案(harmonyos-port-plan.md)+ 应用外壳指南(arkui-app-guide.md)

注意区分两个「Settings」:entry/src/main/ets/application/Settings.ets 是屏内 那个 1:1 复刻的设置页(Wi-Fi / Screen off / About);pages/SettingsPage.ets 是 应用外壳的设置页(外观 / 标题栏模糊 / 沉浸光感 / 按键振动 / 屏幕旋转 / 屏幕倍数 / 关于)。


验证

  • 1:1 对齐:与原项目导出的黄金帧逐行 ASCII 比对(用库里的 FrameBuffer.toString()), Launcher / Settings / About / Lorem / 难度列表 / Options / HTTP 完全一致;键盘页唯一差异是 caretBlinkPeriod 的闪烁相位(设备用真实 dt、黄金帧用固定 dt),属时间对齐问题,非缺陷。 entry/src/test/ 另有 LocalUnit 纯逻辑测试(Animation.test.ets / Render.test.ets), 在 DevEco Studio 里右键运行;可复现的「黄金帧语料 + 离线 harness」尚未入库 (里程碑 M7 尚未工具化的部分,补法见方案 §14.1)。
  • 真机核验:System Info 每秒刷新、关屏与唤醒(唤醒键被吞)、RST 回根页、 Wi‑Fi 扫描 / 密码键盘 / 连接。System Info 的数值与黄金帧不同属预期(移植版用 MockSystem 计算,原项目测试用固定 fakeSystem)。
  • 应用外壳真机核验清单见 docs/arkui-app-guide.md §8 (HUAWEI Mate 60 Pro / API 26)。

各里程碑(M0–M8)的实现状态见 AGENTS.md §7 与 docs/harmonyos-port-plan.md §15。

项目来源

本项目的 UI/UX 完全参考自 osu!mc(一个界面仿 osu!(lazer) 的单片机小项目),该项目由 [chhongzh] 以 MIT 许可证发布。本项目的代码是基于其进行的复刻与修改。

项目介绍

使用 DeepSeek V4.1 Flash 对 chhongzh/osu-mc 项目的UI/UX,做 1:1 复刻。在 HarmonyOS 系统上体验这个丝滑Q弹的esp32 UI。

https://github.com/chhongzh/osu-mc定制我的领域