把 NES 模拟器搬到鸿蒙PC,再连个蓝牙手柄找回童年的感觉
最近闲来无事,把手头的 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上,有兴趣的朋友可以直接拿去编译玩玩:
欢迎 PR,尤其是各种蓝牙手柄的按键码——收集齐了就能做一个通用的手柄映射库,大家都不用重复踩坑了。
更多推荐




所有评论(0)