最近闲来无事,把手头的 MateBook Pro 翻出来折腾。HarmonyOS 的应用市场逛了一圈,没找到好用的 NES 模拟器。想连个蓝牙手柄找回童年的感觉。

为什么要干这事?事情是这样的,翻了翻应用市场,现成的 NES 模拟器不好用,且无论是虚拟按键还是键盘,体验都是不好,想支持下接入蓝牙手柄,可定制放大屏幕,没有源码就没法搞。那就自己搞一个吧。

在这里插入图片描述

FCEUX 是我比较熟悉的模拟器,代码质量高,核心部分极其纯净——几乎不依赖操作系统 API,纯标准 C++ 就能编译。

于是就有了这个项目:ohos_nes_fceux,一个跑在 HarmonyOS 上的红白机模拟器,基于经典的 FCEUX 的老牌NES模拟器核心。

更多交流学习,欢迎加入开源鸿蒙PC社区https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目https://atomgit.com/OpenHarmonyPCDeveloper

项目开源地址https://gitcode.com/qq8864/ohos_nes_fceux

当然这个移植借助了AI的能力,如果你做过 AI 应用或自动化脚本,多半遇到过同一种疲惫:每家厂商一套账号、一套密钥、一套计费口径,想在项目里换个模型,常常不是「改一行参数」这么简单,而是「再集成一遍」。如果你想体验国外厉害的大模型能力,却总是被禁或者服务不稳定。推荐下taotoken,这个是csdn官方推出的产品,速度流畅,稳定可靠。 关键是很便宜,性价比不错。

taotoken尝鲜入口https://taotoken.net/?u=inv_faxm8m42tg11a06f&utm_source=invite

Taotoken 的方向很直白:把「多模型」收敛成「一条统一网关」。它是 CSDN 生态里的 AI 聚合与分发能力载体——面向开发者常见的调用路径,做网关侧的路由与协议适配,让你更少折腾基建,更多时间花在产品与效果上。谐音梗“掏token”,名字起的不错。以后AI时代,token就是食粮,越来越重要了。

详细移植过程参见猫哥的博客:把 FCEUX 移植到HarmonyOS鸿蒙PC:一个 NES 模拟器的移植笔记

使用atomcode +deepseek+devcli+鸿蒙知识库辅助。推荐atomcde,太强了!

关于AtomCode,参见:小模型也能写出大工程——AtomCode(ClaudeCode国产替代) 的介绍及使用

先看下最终效果:

在这里插入图片描述

左边方向键、中间游戏画面、右边 AB 键,布局参考了横版红白机手柄的样式。顶部一排可以切换 xBRZ / HQ2x / HQ3x 等像素缩放滤镜。娃玩的不亦乐乎!眼神里似乎想童年的自己,两眼发光的感觉,太好玩了。

这项目是怎么一回事

简单说就是把有 20 多年历史的 FCEUX 模拟器移植到了 HarmonyOS 上。FCEUX 是目前最活跃的 NES 模拟器之一,代码质量很高,核心部分(6502 CPU、PPU 渲染、APU 音频、230 多种卡带映射器)几乎不依赖操作系统 API。

移植的核心思路是:模拟器核心基本不动,只重写驱动层和 UI

整体架构分四层:

ArkTS UI (Index.ets)
    ↓ NAPI 桥
C++ Native Layer (NAPI Module)
    ↓ 函数调用
HarmonyOS 驱动层 (ohos_driver)
    ↓ FCEUX 核心 API
FCEUX 核心 (6502/PPU/APU/230+ Mapper)
  • 视频:用 XComponent Surface + OH_NativeWindow 原生渲染,像素数据直接从 C++ 写入缓冲区,不走 Canvas
  • 音频:OH_AudioRenderer NDK 原生播放,APU 生成的 PCM 数据通过环形缓冲消费
  • 输入:ArkTS 的 onKeyEvent 捕获按键 + 虚拟手柄触摸事件,转成 8 位掩码传给核心

蓝牙手柄到底能不能用?

这是个好问题。项目原本只写了键盘映射(A/B/S/T + 方向键)和触屏虚拟手柄,蓝牙手柄的支持其实是个半成品——D-Pad 方向键能用,但右侧的 A/B/X/Y 功能键一律没反应。

原因是不同的蓝牙手柄发送的按键码(keyCode)不一样,代码里只硬编码了 PS4 手柄的几个按键码,其他手柄(比如 Switch Pro、Xbox、各种杂牌蓝牙手柄)的按键码都没有匹配。手柄的接入很简单,其实还是监听的onKeyEvent:


  .onKeyEvent((event: KeyEvent) => {
      this.lastKeyText = event.keyText;
      this.lastKeyCode = event.keyCode;
      this.lastKeyType = event.type;
      let kt: string = event.keyText;
      let kc: number = event.keyCode;
      let isDown: boolean = (event.type === 0);
      let bit: number = -1;

      // keyText detection (letters + arrow key names)
      if (kt && kt.length > 0) {
        let t = kt.toUpperCase();
        if (t === 'A' || t === 'KEYCODE_A') bit = 0;
        else if (t === 'B' || t === 'KEYCODE_B') bit = 1;
        else if (t === 'S' || t === 'KEYCODE_S') bit = 2;
        else if (t === 'T' || t === 'KEYCODE_T') bit = 3;
        else if (t === 'KEYCODE_DPAD_UP') bit = 4;
        else if (t === 'KEYCODE_DPAD_DOWN') bit = 5;
        else if (t === 'KEYCODE_DPAD_LEFT') bit = 6;
        else if (t === 'KEYCODE_DPAD_RIGHT') bit = 7;
      }

      // keyCode fallback (keyboard + PS4 gamepad)
      if (bit < 0) {
        if (kc === 2012) bit = 4;       // Keyboard Up
        else if (kc === 2013) bit = 5;  // Keyboard Down
        else if (kc === 2014) bit = 6;  // Keyboard Left
        else if (kc === 2015) bit = 7;  // Keyboard Right
        // PS4 gamepad
        else if (kc === 2301) bit = 0;  // × (Cross) → NES A
        else if (kc === 2302) bit = 0;  // ○ (Circle) → NES B
        else if (kc === 2311) bit = 2;  // SHARE → NES Select
        else if (kc === 2312) bit = 3;  // OPTIONS → NES Start
        else if (kc === 19) bit = 4;    // D-Pad Up
        else if (kc === 20) bit = 5;    // D-Pad Down
        else if (kc === 21) bit = 6;    // D-Pad Left
        else if (kc === 22) bit = 7;    // D-Pad Right
        else if (kc === 2303) bit = 0;  // □ (Square) → NES A (alt)
        else if (kc === 2304) bit = 1;  // △ (Triangle) → NES B (alt)
        else if (kc === 2307) bit = 2;  // L1 → NES Select (alt)
        else if (kc === 2308) bit = 3;  // R1 → NES Start (alt)
      }

      if (bit >= 0) {
        if (isDown) this.padState |= (1 << bit)
        else this.padState &= ~(1 << bit)
      }
    })

从某多多上花三十块大洋就买到一个不错的蓝牙手柄。手柄首次蓝牙接入方法,参见你买的手柄提供的说明书。

怎么调试手柄键值?

我在底部加了一个调试显示条,格式是这样的:

Key: <按键名> [code=<键值> type=<0=按下/1=松开>]

打开游戏后连上蓝牙手柄,按右侧的功能键,底部的绿色文字会实时显示对应的 keyCode。

比如说你按了手柄的 A 键,底部显示 Key: [code=2301 type=0],那 2301 就是这个手柄的 A 键码。

Type=0 表示按下,Type=1 表示松开。

怎么把自己的手柄键值加进去?

找到 entry/src/main/ets/pages/Index.ets 文件,在 onKeyEvent 处理函数里,有一段 keyCode 匹配的代码:

// keyCode fallback
if (bit < 0) {
    // ... 原有映射
    else if (kc === 2301) bit = 0;  // × (Cross) → NES A
    else if (kc === 2302) bit = 1;  // ○ (Circle) → NES B
    else if (kc === 2311) bit = 2;  // SHARE → NES Select
    else if (kc === 2312) bit = 3;  // OPTIONS → NES Start
    // ... 更多映射
}

NES 手柄的 8 个键对应的比特位是:

Bit NES 按键
0 A
1 B
2 Select
3 Start
4 ↑ (上)
5 ↓ (下)
6 ← (左)
7 → (右)

假设你的蓝牙手柄按 A 键显示 code=2301,那添加一行 else if (kc === 2301) bit = 0; 就能把那个键映射到 NES 的 A 键。同理,B 键是 bit=1,Select 是 bit=2,Start 是 bit=3。

加完之后重新编译安装,手柄的按键就能正常玩游戏啦。

在这里插入图片描述

踩过的几个坑

坑 1:顶部滤镜按钮拦截手柄事件

一开始发现手柄的方向键能用,但功能键老是触发顶部的滤镜切换。查了一下,原来是顶部按钮获得了焦点,手柄按键激活了按钮的 onClick

解决:给所有顶栏按钮加 .focusable(false),让它们不参与焦点导航,手柄事件直接穿透到游戏的 onKeyEvent 处理器。

坑 2:按键响应慢

每次按键都通过 NAPI(JS ↔ C++ 桥)调用一次 setPadState,频繁的跨语言调用开销不小。

解决:改成帧循环模式——所有按键只更新 ArkTS 侧的位掩码(纯 JS 操作),帧循环(每 16ms 一次)统一把状态同步到 C++ 层。NAPI 调用从"每次按键都触发"变成"每帧最多一次"。

坑 3:音频没声音

FCEUX 的音频数据是 int32 格式,但值域其实在 int16 范围内。直接 (int16_t)sample 转就行,但我不小心多写了个 >> 8,结果声音衰减到 1/256,差不多静音。查了大半天 hilog 日志才发现。

性能表现

在 MateBook Pro 上实测:

  • 帧率稳定 60fps
  • 内存 ~50MB
  • CPU 占用 ~15%(单核)
  • HAP 包 6.6MB

后续想加的功能

  • 存档 / 读档(FCEUX 核心支持完整,缺个 UI)
  • 自定义按键映射(在界面上可视化配置,不用改代码)
  • 金手指 Cheat 码输入

开源

项目代码在 gitcode上,有兴趣的朋友可以直接拿去编译玩玩:

https://gitcode.com/qq8864/ohos_nes_fceux

欢迎 PR,尤其是各种蓝牙手柄的按键码——收集齐了就能做一个通用的手柄映射库,大家都不用重复踩坑了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐