最近晚上闲来无事,把手头的 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;letkt:string=event.keyText;letkc:number=event.keyCode;letisDown:boolean=(event.type===0);letbit:number=-1;// keyText detection (letters + arrow key names)if(kt&&kt.length>0){lett=kt.toUpperCase();if(t==='A'||t==='KEYCODE_A')bit=0;elseif(t==='B'||t==='KEYCODE_B')bit=1;elseif(t==='S'||t==='KEYCODE_S')bit=2;elseif(t==='T'||t==='KEYCODE_T')bit=3;elseif(t==='KEYCODE_DPAD_UP')bit=4;elseif(t==='KEYCODE_DPAD_DOWN')bit=5;elseif(t==='KEYCODE_DPAD_LEFT')bit=6;elseif(t==='KEYCODE_DPAD_RIGHT')bit=7;}// keyCode fallback (keyboard + PS4 gamepad)if(bit<0){if(kc===2012)bit=4;// Keyboard Upelseif(kc===2013)bit=5;// Keyboard Downelseif(kc===2014)bit=6;// Keyboard Leftelseif(kc===2015)bit=7;// Keyboard Right// PS4 gamepadelseif(kc===2301)bit=0;// × (Cross) → NES Aelseif(kc===2302)bit=0;// ○ (Circle) → NES Belseif(kc===2311)bit=2;// SHARE → NES Selectelseif(kc===2312)bit=3;// OPTIONS → NES Startelseif(kc===19)bit=4;// D-Pad Upelseif(kc===20)bit=5;// D-Pad Downelseif(kc===21)bit=6;// D-Pad Leftelseif(kc===22)bit=7;// D-Pad Rightelseif(kc===2303)bit=0;// □ (Square) → NES A (alt)elseif(kc===2304)bit=1;// △ (Triangle) → NES B (alt)elseif(kc===2307)bit=2;// L1 → NES Select (alt)elseif(kc===2308)bit=3;// R1 → NES Start (alt)}if(bit>=0){if(isDown)this.padState|=(1<<bit)elsethis.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 fallbackif(bit<0){// ... 原有映射elseif(kc===2301)bit=0;// × (Cross) → NES Aelseif(kc===2302)bit=1;// ○ (Circle) → NES Belseif(kc===2311)bit=2;// SHARE → NES Selectelseif(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,尤其是各种蓝牙手柄的按键码——收集齐了就能做一个通用的手柄映射库,大家都不用重复踩坑了。