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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐