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 文件。

html 复制代码
<!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 模拟器的核心逻辑,包括初始化、渲染、音频处理以及按钮控制等。

javascript 复制代码
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 访问、文件访问等。

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。

javascript 复制代码
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 中的游戏引擎。

javascript 复制代码
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 中的游戏引擎。

javascript 复制代码
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 函数,开启或关闭游戏的声音。

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 上开发更多有趣的应用提供了很好的参考案例。

相关推荐
Georgewu13 小时前
【HarmonyOS 7】 HarmonyOS 应用 Skill创建和编排详解
harmonyos
Georgewu15 小时前
【HarmonyOS 7】 应用 Agent 接入云 A2A 与端 A2A 的实践详解
harmonyos
马剑威(威哥爱编程)20 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 05:流式输出做打字机对话
华为·harmonyos
AI备忘录1 天前
(二十五)华为华三锐捷迈普思科 ERPS 环网保护配置命令(G.8032 五厂商对照)
运维·服务器·网络·网络协议·华为
hflmwl81 天前
Wise Game Booster 游戏加速软件
游戏·wisegamebooster·游戏加速软件
OH_TPC1 天前
【鸿蒙优选三方库】@ohos/coap:让鸿蒙设备说上 IoT 的母语
物联网·华为·harmonyos·鸿蒙
程序猿追1 天前
渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
华为·harmonyos
程序猿追1 天前
在 HarmonyOS 6 上做了张 3D 翻转卡片,正面背面来回翻
3d·华为·harmonyos
Solange__1 天前
工业产线怎么用AI生成3D场景?从现场采集、部件重建到数字孪生交付的完整方案
人工智能·游戏·3d·ai·语言模型·自然语言处理·关卡设计
小π军1 天前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos