Fdf239aeb2aa9755105c2f235b17609c
JSBox 上的 NES/GBC/GBA 模拟器

0x0 我做了什么

最近我做了一件我自认为蛮有趣的事情,我给我的小应用 JSBox “移植”了几个游戏模拟器,可以通过安装脚本的方式获得如下三个游戏模拟器:

  • NES: 也就是红白机
  • GBC: Game Boy Color
  • GBA: Game Boy Advance

模拟器本身并不提供游戏 ROM,但可以将 ROM 文件上传到脚本指定的目录,进而可以体验这些儿时的回忆。这几个模拟器的性能和效果是不错的(请忽略由于 GIF 质量看起来卡顿的问题):

都支持横竖屏操作以及外接手柄/键盘,其中 NES/GBC 模拟器支持即时存档/读档,GBA 模拟器支持自带的存档系统。

0x1 如何实现

当然,我上面说的移植是浮夸的,我只是站在了巨人的肩膀上做了一点点微小的工作,让这些项目比较完美的跑在了 JSBox 上而已:

  • jsnes: A JavaScript NES emulator.
  • GameBoy-Online: JavaScript GameBoy Color emulator.
  • gbajs: Game Boy Advance in the Browser

大家可以直接通过 PC 上的浏览器体验这些项目,在各自的领域他们都做得相当不错。虽然这是三个完全不同的项目,但是让他们在 JSBox 上面跑起来的流程却大同小异,所以我会统一介绍整个过程。

首先,JSBox 是一个基于 JavaScriptCore 的 JavaScript 平台,提供了一些 API 以用来调用 Native 的一些组件或者视图,在整个模拟器运行过程中,Native 事件都运行在 JSContext 上面,这其中最重要的就是游戏的控制器,也就是软键盘和外接手柄等逻辑。

其次,上述项目是跑在浏览器上面的,而 JSBox 支持通过 JavaScript 来编写 Native 的 UI,这其中也包括了一个 WKWebView 的封装,刚好符合我们的需求。

最重要的是,上述的项目很难完全“离线”地运行起来,因为他们内部使用了很多难以脱离 Web 服务器的方案,比如说文件的相对路径,比如说模块化的 JavaScript 以及加载 ROM 的逻辑。所以整个项目的关键其实是一个本地的 Web 服务器。

0x2 Web 服务器

在 iOS 上面有很多 Web 服务器的开源方案,比如 GCDWebServer 或是 swifter。他们能够在 iOS 设备上运行起本地的服务器,从而可以将一个网站完全的离线化。

具体到 JSBox,我为他提供了一个 $server 接口,这个接口的功能就是把上述 Web 服务器给封装起来,然后可以通过 JavaScript 来编写一个网页服务器,比如这样:

const port = 1010;
const options = {"port": port};
const baseURI = `http://localhost:${port}/`;
const server = $server.new();

server.addHandler({
  response: request => {
    let url = request.url;
    let name = url.substring(url.indexOf(baseURI) + baseURI.length);
    let path = `www/${decodeURIComponent(name)}`;
    return {
      type: "file",
      props: {
        path: path
      }
    }
  }
});

server.start(options);

这就实现了一个简单的网页服务器,我们将整个网站放到脚本目录的 www 下面,然后访问 http://localhost:1010/index.html 就在浏览器打开了该网站。

在整个过程中,其实我们只需要各个模拟器的 canvas 部分,让他充满整个网页,同时我们会让 WKWebView 显示成游戏机显示屏的样子。

0x3 游戏音乐

这是个头疼的问题,在游戏音频输出方面,现在主流的解决方案大多采用 AudioContext 来实现,这是一个很新的标准以至于很多较老的浏览器并不支持,各个浏览器之间的实现还有差异。

不过没关系,我只关心最新版本的 Safari 上面的表现,这里我们就需要 Polyfill 一下 AudioContext,因为 Safari 的 AudioContext 因为兼容性的原因被命名为 webKitAudioContext,详情见这里:https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/PlayingandSynthesizingSounds/PlayingandSynthesizingSounds.html

比如说 jsnes 他并没有管这个问题,我们就需要 Polyfill 一下:

window.AudioContext = (() => {
  return window.webkitAudioContext || window.AudioContext || window.mozAudioContext;
})();

当然你会发现即便你这么做了,游戏还是没有声音,这涉及到问题二:如果用户没有产生过交互,AudioContext 就无法初始化。各个浏览器都会有这个问题,但是我们在浏览器上面玩模拟器的时候却没注意到这个问题。

那是因为,浏览器上面产生了用户交互,但我们通过 Native 技术来加载 WebView 时,在用户触碰 canvas 之前却没有任何用户交互,所以用户听不到声音。

解决这个问题只能从交互方式上面寻找答案,比如说游戏默认静音,但是在 HTML 上面做一个取消静音的按钮,这样用户点了我们就可以 Resume 这个音频。我这里采用的方案是做一个假的播放按钮,这个按钮用户看了之后就知道是用来“启动”游戏的,实则和启动毫无关联,只是迫使用户点击屏幕来启用声音:

0x4 统一接口设计

虽然上述模拟器内部的实现各有不同,但我需要为他们设计一些统一的上层接口,这样我就能用一套逻辑套用在多个实现上面。具体来说一个模拟器无非需要提供这些接口:

  • 启动游戏
  • 键盘按下事件
  • 键盘取消事件
  • 保存当前游戏状态
top Created with Sketch.