还在写商城、博客、后台管理这些烂大街的项目?不如用Vue写一个FC游戏网站吧

631 阅读3分钟

还在写商城、博客、后台管理这些烂大街的项目?不如用Vue写一个FC游戏网站吧

好吧,我承认我标题党了,这篇文章其实主要是想分享一下jsnes这个库的使用,以及我封装Vue的NES模拟器组件的一些实现细节。

不过,如果你真的对写一个FC游戏网站感兴趣,可以参考 nes-web 这个项目,用到的就是nes-vue这个组件。

nes-vue仓库

游乐场

这个组件是我刚接触Vue 3时就开始写的,目前已实现的功能:

  • 支持手柄
  • 支持双人
  • 支持连发键
  • 截图、存档
  • 播放TAS录像(仅限*.fm2格式)
  • 调整分辨率、音量
  • 自定义按键

计划、但不一定能实现的功能:

  • 调整色调、亮度、对比度
  • 游戏倒退
  • TAS录制
  • 支持更多录像格式

jsnes的基本使用

jsnes 这个库是一个使用JavaScript实现的NES模拟器,虽然它功能很强大,但它是一个偏底层的库,二次封装并不容易,我下面通过代码来讲解它如何使用。

需要说明的是,这些代码只是为了解释每一步的作用,实际未必可行,真正可行的做法,建议参照官方提供的示例,或者去看我写的nes-vue组件的源码。

文中会涉及一些非常冷门的API,例如AudioContext,如果你看不懂……建议去翻MDN文档,因为这不是本文的重点。

安装jsnes

npm i jsnes

初始化NES对象

import jsnes from 'jsnes'

const nes = new jsnes.NES({
    onFrame: function(frameBuffer) {
		// 这里处理每一帧游戏的画面
    },
    onAudioSample: function(left, right) {
		// 这里处理音频
    }
})

​ 游戏每运行一帧,onFrame方法就会被调用一次,frameBuffer就是每一帧游戏画面的数据(UintArrayBuffer32),我们需要将它渲染到canvas上。

function onFrame(frameBuffer) {
    const buffer = new ArrayBuffer(256 * 240) // 创建一个长度等于游戏画面像素的ArrayBuffer
    const u8 = new Uint8ArrayBuffer(buffer) // 使用Uint8ArrayBuffer读取buffer
    const u32 = new Uint32ArrayBuffer(buffer) // 使用Uint32ArrayBuffer读取buffer
    for (let i = 0; i < frameBuffer.length, i++) {
        u32[i] = 0xff000000 | frameBuffer[i] // 将游戏数据以32位的方式读取至buffer,这样u8就会自动得到对应的8位的数据,这里的0xff000000作用是填充透明通道    
    }
    const ctx = cvs.getContext('2d') // canvas
    const imageData = ctx.getImageData(0, 0, 256, 240) // 获取ImageData
    imageData.set(u8) // 将Uint8ArrayBuffer写入imageData
    ctx.putImageData(imageData) // 将imageData置入canvas
}

​ onAudioSample的参数就是每一帧左右声道的声音数据,我们可以通过AudioContext API来实现播放。

function onAudioSample(left, right) {
    const audioContext = new AudioContext() // 创建AudioContext对象
    scriptProcessor = audioContext.createScriptProcessor(512, 0, 2) // 创建播放进度,这个API被浏览器提示废弃,但不影响使用。
    // 绑定播放进度事件
    scriptProcessor.onaudioprocess = function (e) {
        const dst = e.outputBuffer // 获取输出音频
        const dst_l = dst.getChannelData(0) // 获取左声道
        const dst_r = dst.getChannelData(1) // 获取右声道
        // 将声音数据分别输入至左、右声道
        for(let i = 0;i < dst.length; i++) {
            dst_l[i] = left[i]
            dst_r[i] = right[i]
        }
    }
    scriptProcessor.connect(audioContext.destination) // 关联到音频设备
}

读取ROM

nes.loadROM(romData)

​ romData就是游戏ROM的二进制数据,我是利用ajax来获取它

const req = new XMLHttpRequest()
req.open('GET', 'https://www.abc.xyz/xxxxxx.nes')
req.overrideMimeType('text/plain; charset=x-user-defined') // 注意请求的数据类型
req.onload = function () {
    if (this.status === 200) {
        nes.loadROM(this.responseText)
    }
}
req.send()

运行帧

nes.frame()

​ 每调用一次frame方法,游戏就运行一帧,按照每秒60帧,我们可以使用计时器来反复调用它:

setInterval(nes.frame, 1000 / 60)

​ 不过,更好的做法是使用animationFrame:

function animationFrame () {
    requestAnimationFrame(nes.frame)
    animationFrame()
}

​ 实际封装时,我将frame放在了scriptProcessor的onaudioprocess事件中,虽然逻辑上会有点怪异,但这样做的好处是,我可以通过暂停声音来暂停游戏,不需要担心画音不同步的问题,而且FPS也更加稳定。事实上,官方的示例也是这么做的。

游戏存档

官方的示例并没有告诉我们该如何存档,但如果想存档,无非就是保存NES对象里的数据。

我们可以使用NES对象的toJSON方法,保存返回的数据,然后使用fromJSON来读取数据:

const saveData = nes.toJSON() // 保存

nes.fromJSON(saveData) // 读取

实际测试发现,这样读取的游戏可能会发生花屏,在读取前重新加载ROM则有概率避免花屏:

const saveData = nes.toJSON()
nes.loadROM(romdata)
nes.fromJSON(saveData)

经过摸索,我发现了一种不需要重新加载ROM,100%避免花屏的做法:

const saveData = {
    cpu: nes.cpu.toJSON(),
    ppu: nes.ppu.toJSON(),
    mmap: nes.mmap.toJSON()
}

nes.reset()
nes.cpu.fromJSON(saveData.cpu)
nes.ppu.fromJSON(saveData.ppu)
nes.mmap.fromJSON(saveData.mmap)

减小存档体积

按照上面的方式保存的存档,每一个的大小超过了2MB,虽然我的组件默认是使用indexed DB,但还是显得太大了。后续要做游戏倒退的功能,如果每一帧的数据太大的话,这功能将很难完成。

经过一系列优化,数据最终被我压缩到了200KB左右。我主要做了两件事:

  • 删除不需要的数据
    • 我的组件在设计时就要求,读取存档时游戏必须正在运行,因此游戏ROM本身可以直接删除。
    • ppu上的模拟内存,是固定从0开始递增的,可以删除。
    • ppu上的图形缓存,直接删除没什么影响,虽然会导致读取后的第一帧没有数据,但因为下一帧就会重新计算,人的肉眼是察觉不了的。
  • 一个简单的压缩算法:
// 压缩
function compressArray(arr: number[]) {
    const compressed = []
    let current = arr[0]
    let count = 1
    for (let i = 1; i < arr.length; i++) {
        if (arr[i] == current) {
            count++
        }
        else {
            if (count > 1) {
                compressed.push(count)
                compressed.push(current)
            }
            else {
                compressed.push(-current - 1)
            }
            current = arr[i]
            count = 1
        }
    }
    compressed.push(count)
    compressed.push(current)
    return compressed
}

// 还原
function decompressArray(compressed: number[]) {
    const decompressed = []
    for (let i = 0; i < compressed.length;) {
        if (compressed[i] < 0) {
            decompressed.push(-compressed[i] - 1)
            i++
        }
        else {
            const count = compressed[i]
            const value = compressed[i + 1]
            for (let j = 0; j < count; j++) {
                decompressed.push(value)
            }
            i += 2
        }
    }
    return decompressed
}

这个算法其实就是游程编码(Run-length Coding),用在这里压缩率非常可观,速度也非常快。

借助lz-string之类的字符串压缩库,应该可以进一步压缩到100KB左右,但因为过程太耗时,我最终没有考虑。

播放TAS录像

播放录像本质上就是读取录像脚本,然后自动操作游戏,因为*.fm2文件是明文的,加载并不难,问题只在于如何自动操作游戏。

正常情况下,操作是通过nes.buttonDown和nes.buttonUp来完成的,例如:

nes.buttonDown(1, jsnes.Controller.BUTTON_UP) // 上方向键按下
nes.buttonUp(1, jsnes.Controller.BUTTON_UP) // 上方向键抬起

但是,TAS是按帧来操作游戏的,所以,为了更好的性能,我通过直接修改nes.controller的状态来达到操作的目的:

nes.controllers[1].state = [0x41, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40] // P1 玩家1
nes.controllers[2].state = [0x41, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40] // P2 玩家2

数组一共8项,对应了8个按钮,0x40表示抬起,0x41表示按下。

播放TAS录像的过程就是:

  • 加载ROM

  • 读取并解析*.fm2文件,生成每一帧操作的状态数组

  • 计算运行经过的帧数,将对应的值赋给state