HarmonyOS鸿蒙中的NES游戏模拟器的完整实现
HarmonyOS 是一种新的智能终端操作系统,致力于提供全场景智慧体验。在 HarmonyOS 上开发游戏模拟器不仅可以丰富用户的娱乐方式,也能展示 HarmonyOS 的强大功能和灵活性。本文将结合提供的代码示例,详细介绍如何在 HarmonyOS 上实现一个 NES(Nintendo Entertainment System)游戏模拟器。
池塘边的榕树上,知了在声声叫着夏天。操场边的秋千上,只有蝴蝶停在上面。而我闭上眼,仿佛回到了那个无忧无虑的童年时光,心中充满了对经典游戏的怀念。那些年,NES游戏机陪伴我度过了数不清的日日夜夜。
如今,随着技术的发展和设备生态的不断扩展,我决定将这个记忆中的伙伴–NES 模拟器(JSNES)移植到 HarmonyOS 鸿蒙系统上,让更多的用户能够在 HarmonyOS 设备上重温那些难忘的时光。
实现效果截图

项目概述
本文将详细介绍如何使用 ArkUI 开发一个简易的 NES 游戏模拟器。
主要功能包括:
- 选择并加载 NES 游戏 ROM 文件
- 通过 WebView 控制游戏的运行,包括加载、重置、暂停和恢复
- 实现游戏控制器的基本模拟,包括方向键和 A、B 键
- 实现音频控制,允许用户开启或关闭游戏声音
开源地址:
https://gitee.com/yyz116/harmonyos-nes
https://gitcode.com/nutpi/hmnes
主要模块
nes-embed.html 文件
nes-embed.html 文件主要用于设置游戏画布,并引入必要的 JavaScript 文件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NES Emulator</title>
<style>
body {
background-color: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
#nes-canvas {
border: 1px solid #fff;
}
</style>
</head>
<body>
<canvas id="nes-canvas" width="256" height="240"></canvas>
<script src="nes-embed.js"></script>
<script>
// 初始化NES模拟器
nes_init("nes-canvas");
</script>
</body>
</html>
nes-embed.js 文件
nes-embed.js 文件中包含了 NES 模拟器的核心逻辑,包括初始化、渲染、音频处理以及按钮控制等。
const NES_WIDTH = 256;
const NES_HEIGHT = 240;
const NES_FRAMEBUFFER_SIZE = NES_WIDTH * NES_HEIGHT;
var canvas_ctx, image, display_image;
var framebuffer_u8, framebuffer_u32;
var AUDIO_BUFFERING = 1024;
var SAMPLE_COUNT = 4 * 1024;
var SAMPLE_MASK = SAMPLE_COUNT - 1;
var audio_ctx;
var audio_samples_L = new Float32Array(SAMPLE_COUNT);
var audio_samples_R = new Float32Array(SAMPLE_COUNT);
var audio_write_cursor = 0, audio_read_cursor = 0;
var animationId = 0;
const SCREEN_WIDTH = 256;
const SCREEN_HEIGHT = 240;
var FRAMEBUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT;
console.log('hello nes');
var nes = new jsnes.NES({
onFrame: function(framebuffer_24) {
for (var i = 0; i < NES_FRAMEBUFFER_SIZE; i++) {
framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];
}
},
onStatusUpdate: function(msg) {
console.log(msg);
},
onAudioSample: function(l, r) {
const writePos = audio_write_cursor;
audio_samples_L[writePos] = l;
audio_samples_R[writePos] = r;
audio_write_cursor = (writePos + 1) & SAMPLE_MASK;
},
});
var lastFrameTime = 0;
function onAnimationFrame(timestamp) {
animationId = window.requestAnimationFrame(onAnimationFrame);
if (timestamp - lastFrameTime >= 16.67) { // 60FPS同步
nes.frame();
lastFrameTime = timestamp;
}
image.data.set(framebuffer_u8);
canvas_ctx.putImageData(image, 0, 0);
}
function audio_remain() {
return (audio_write_cursor - audio_read_cursor) & SAMPLE_MASK;
}
function audio_callback(event) {
let dst = event.outputBuffer;
let len = dst.length;
if (audio_remain() < AUDIO_BUFFERING) {
return;
}
let dst_l = dst.getChannelData(0);
let dst_r = dst.getChannelData(1);
let lastL = 0, lastR = 0;
for (let i = 0; i < len; i++) {
let src_idx = (audio_read_cursor + i) & SAMPLE_MASK;
dst_l[i] = lastL = lastL * 0.8 + audio_samples_L[src_idx] * 0.2;
dst_r[i] = lastR = lastR * 0.8 + audio_samples_R[src_idx] * 0.2;
}
audio_read_cursor = (audio_read_cursor + len) & SAMPLE_MASK;
}
function nes_init(canvas_id) {
console.log('nes_init');
var canvas = document.getElementById(canvas_id);
canvas_ctx = canvas.getContext("2d");
image = canvas_ctx.getImageData(0, 0, NES_WIDTH, NES_HEIGHT);
canvas_ctx.fillStyle = "black";
canvas_ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Allocate framebuffer array.
var buffer = new ArrayBuffer(image.data.length);
framebuffer_u8 = new Uint8ClampedArray(buffer);
framebuffer_u32 = new Uint32Array(buffer);
// Setup audio.
audio_ctx = new window.AudioContext();
var script_processor = audio_ctx.createScriptProcessor(AUDIO_BUFFERING, 0, 2);
script_processor.onaudioprocess = audio_callback;
script_processor.connect(audio_ctx.destination);
audio_ctx.suspend(); // 初始状态为暂停
}
function nes_boot(rom_data) {
console.log('nes_boot 11');
nes.loadROM(rom_data);
animationId = window.requestAnimationFrame(onAnimationFrame);
}
function nes_load_data(canvas_id, rom_data) {
nes_boot(rom_data);
}
function nes_load_url(canvas_id, path) {
nes_init(canvas_id);
let romData = window.harmonyBridge.getRomStorageData();
nes_boot(romData);
}
function nesStartAudio(start) {
if (start == 'start') {
console.log('startAudio');
audio_ctx.resume();
} else {
console.log('stop audio');
if (audio_ctx) {
audio_ctx.suspend();
}
}
}
// 图像放大算法
function nearestNeighborUpscale(sourceData, sourceWidth, sourceHeight, targetWidth, targetHeight) {
const targetData = new Uint8ClampedArray(targetWidth * targetHeight * 4);
const scaleX = sourceWidth / targetWidth;
const scaleY = sourceHeight / targetHeight;
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const sourceX = Math.floor(x * scaleX);
const sourceY = Math.floor(y * scaleY);
const sourceIndex = (sourceY * sourceWidth + sourceX) * 4;
const targetIndex = (y * targetWidth + x) * 4;
targetData[targetIndex] = sourceData[sourceIndex]; // R
targetData[targetIndex + 1] = sourceData[sourceIndex + 1]; // G
targetData[targetIndex + 2] = sourceData[sourceIndex + 2]; // B
targetData[targetIndex + 3] = sourceData[sourceIndex + 3]; // A
}
}
return targetData;
}
// 为 ArkUI 添加的控制接口
function nesButtonDown(button) {
var player = 1;
switch (button) {
case 'UP':
nes.buttonDown(player, jsnes.Controller.BUTTON_UP); break;
case 'DOWN':
nes.buttonDown(player, jsnes.Controller.BUTTON_DOWN); break;
case 'LEFT':
nes.buttonDown(player, jsnes.Controller.BUTTON_LEFT); break;
case 'RIGHT':
nes.buttonDown(player, jsnes.Controller.BUTTON_RIGHT); break;
case 'A':
nes.buttonDown(player, jsnes.Controller.BUTTON_A); break;
case 'B':
nes.buttonDown(player, jsnes.Controller.BUTTON_B); break;
case 'SELECT':
nes.buttonDown(player, jsnes.Controller.BUTTON_SELECT); break;
case 'START':
nes.buttonDown(player, jsnes.Controller.BUTTON_START); break;
}
}
function nesButtonUp(button) {
var player = 1;
switch (button) {
case 'UP':
nes.buttonUp(player, jsnes.Controller.BUTTON_UP); break;
case 'DOWN':
nes.buttonUp(player, jsnes.Controller.BUTTON_DOWN); break;
case 'LEFT':
nes.buttonUp(player, jsnes.Controller.BUTTON_LEFT); break;
case 'RIGHT':
nes.buttonUp(player, jsnes.Controller.BUTTON_RIGHT); break;
case 'A':
nes.buttonUp(player, jsnes.Controller.BUTTON_A); break;
case 'B':
nes.buttonUp(player, jsnes.Controller.BUTTON_B); break;
case 'SELECT':
nes.buttonUp(player, jsnes.Controller.BUTTON_SELECT); break;
case 'START':
nes.buttonUp(player, jsnes.Controller.BUTTON_START); break;
}
}
// 暂停/恢复游戏
function nesPause() {
console.log('Pause game');
// 这里可以实现暂停功能
if (animationId !== 0) {
window.cancelAnimationFrame(animationId);
animationId = 0;
}
}
function nesResume() {
console.log('Resume game');
// 这里可以实现恢复功能
if (animationId === 0) {
animationId = window.requestAnimationFrame(onAnimationFrame);
}
}
// 重置游戏
function nesReset() {
console.log('Reset game');
// 重新加载ROM来实现重置
window.cancelAnimationFrame(animationId);
nes_init("nes-canvas");
let romData = window.harmonyBridge.getRomStorageData();
nes_boot(romData);
}
// 加载用户选择的ROM数据
function nesLoadRomData() {
console.log('尝试加载新的ROM数据');
let romData = window.harmonyBridge.getRomStorageData();
try {
// 直接使用传入的数据加载ROM
nes_boot(romData);
console.log('ROM加载成功');
return true;
} catch (error) {
console.error('ROM加载失败:', error);
return false;
}
}
// 暴露接口给 window 对象,以便 ArkWeb 调用
window.nesButtonDown = nesButtonDown;
window.nesButtonUp = nesButtonUp;
window.nesPause = nesPause;
window.nesResume = nesResume;
window.nesReset = nesReset;
window.nesLoadRomData = nesLoadRomData;
window.nesStartAudio = nesStartAudio;
WebView 初始化和配置
在 Index 组件中初始化了 WebView,并配置了一些必要的属性,如允许 JavaScript 访问、文件访问等。
Web({ src: 'resource://resfile/nes-embed.html', controller: this.webviewController })
.width('100%')
.height(this.webViwHeight)
.borderRadius(5)
.backgroundColor(this.nesCoverColor)
.layoutMode(WebLayoutMode.NONE)
.javaScriptAccess(true)
.fileAccess(true)
.domStorageAccess(true)
.geolocationAccess(true)
.onPageEnd(() => {
this.isPageLoaded = true;
})
.onControllerAttached(() => {
const filePath = this.uiContext.resourceDir;
try {
this.webviewController.setPathAllowingUniversalAccess([
filePath, // 资源目录
]);
} catch (err) {
const error = err as BusinessError;
console.error(`错误码: ${error.code}, 错误信息: ${error.message}`);
console.error(`当前路径参数: ${filePath}`); // 输出实际传递的路径值
}
})
.javaScriptProxy({
object: {
getRomStorageData: () => AppStorage.get<string>(Constant.JS_ROM_DATA) // 暴露数据获取接口
},
name: 'harmonyBridge',
methodList: ['getRomStorageData'],
controller: this.webviewController
})
.onPermissionRequest((event) => {
if (event) {
if (!this.isVoicePlay) {
this.getUIContext().showAlertDialog({
title: '系统提示',
message: '是否允许访问你的麦克风?',
backgroundColor: this.nesCoverColor,
backgroundBlurStyle: BlurStyle.NONE,
primaryButton: {
value: '取消',
action: () => {
event.request.deny();
}
},
secondaryButton: {
value: '允许',
action: () => {
event.request.grant(event.request.getAccessibleResource());
this.isVoicePlay = true;
}
},
cancel: () => {
event.request.deny();
}
});
}
}
});
自定义 Scheme
为了支持本地文件的访问,我们自定义了一个名为 localfile 的 Scheme。
customizeSchemes() {
try {
webview.WebviewController.customizeSchemes([{
schemeName: "localfile",
isSupportCORS: true,
isSupportFetch: true,
isLocal: true
}]);
} catch (error) {
console.error(`ErrorCode: ${error.code}, ErrorMessage: ${error.message}`);
}
}
文件选择和读取
使用 DocumentViewPicker 选择本地 NES ROM 文件,并通过 fileIo 模块读取文件内容,将其转换为字符串后传递给 WebView 中的游戏引擎。
private async selectRomFile(): Promise<void> {
try {
const documentPicker = new picker.DocumentViewPicker();
const options = new picker.DocumentSelectOptions();
let lastDir = AppStorage.get<string>(Constant.Last_SelectDir) || 'file://docs/storage/Users/currentUser';
options.fileSuffixFilters = ['.nes'];
options.maxSelectNumber = 1;
options.defaultFilePathUri = lastDir;
const result = await documentPicker.select(options);
if (result.length > 0) {
const fileUri = result[0];
const fileName = this.getFileNameFromUri(fileUri);
this.currentRomName = decodeURIComponent(fileName);
const lastSlashIndex = result[0].lastIndexOf('/');
const dirUri = result[0].substring(0, lastSlashIndex);
AppStorage.setOrCreate(Constant.Last_SelectDir, dirUri); // 存入AppStorage
console.log(dirUri);
// 读取文件内容
const romBuffer = await this.readRomFile(fileUri);
// 转换为可读字符串
const uint8Array = new Uint8Array(romBuffer); // 转为 Uint8Array
const dataStr = uint8ArrayToString(uint8Array);
console.log('dataStr.length:' + dataStr.length);
// 通过JavaScript加载ROM
this.runJSWithData('window.nesLoadRomData', dataStr);
console.log(`成功加载ROM文件: ${fileName}`);
AppStorage.setOrCreate(Constant.Last_GameName, this.currentRomName);
}
} catch (error) {
console.error(`选择ROM文件失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
控制按钮实现
通过设置按钮的 onTouch 事件,模拟用户按下和释放控制键的动作,将这些事件传递给 WebView 中的游戏引擎。
private onButtonDown(button: string): void {
if (!this.buttonStates[button]) {
this.buttonStates[button] = true;
this.runJS('window.nesButtonDown', button);
}
}
private onButtonUp(button: string): void {
if (this.buttonStates[button]) {
this.buttonStates[button] = false;
this.runJS('window.nesButtonUp', button);
}
}
音频控制实现
通过调用 WebView 中的 JavaScript 函数,开启或关闭游戏的声音。
Button('')
.type(ButtonType.Circle)
.width(22)
.height(22)
.backgroundImage(this.isVoicePlay ? $r('app.media.speaker') : $r('app.media.speaker_no'))
.backgroundImagePosition(Alignment.Center)
.backgroundColor('#E67E22')
.onClick(() => {
if (!this.isVoicePlay) {
this.runJS('window.nesStartAudio', 'start');
} else {
this.runJS('window.nesStartAudio', 'false');
this.isVoicePlay = false;
}
})
总结
通过本文的介绍,我们了解到如何在 HarmonyOS 上开发一个简易的 NES 游戏模拟器。该模拟器利用了 HarmonyOS 的 ArkUI 框架,结合 WebView 实现了游戏的加载和运行,以及控制按钮和音频控制等功能。nes-embed.html 文件中通过 <canvas> 元素渲染游戏画面,并引入了 nes-embed.js 文件进行核心逻辑处理。
nes-embed.js 文件中定义了 NES 模拟器的初始化、渲染、音频处理以及按钮控制等函数,通过 window.requestAnimationFrame 实现 60FPS 的渲染同步,通过 AudioContext 进行音频处理。这些接口通过 javascriptProxy 暴露给 ArkWeb,使得 ArkUI 能够与 WebView 中的游戏引擎进行交互。
本文为开发者在 HarmonyOS 上开发更多有趣的应用提供了很好的参考案例。
更多精彩内容,请关注公众号:【名称:HarmonyOS开发者技术,ID:HarmonyOS_Dev】;也欢迎加入鸿蒙开发者交流群:https://work.weixin.qq.com/gm/48f89e7a4c10206e053e01ad124004a0
更多推荐
所有评论(0)