还在写商城、博客、后台管理这些烂大街的项目?不如用Vue写一个FC游戏网站吧
好吧,我承认我标题党了,这篇文章其实主要是想分享一下jsnes这个库的使用,以及我封装Vue的NES模拟器组件的一些实现细节。
不过,如果你真的对写一个FC游戏网站感兴趣,可以参考 nes-web 这个项目,用到的就是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