将“我和我的影子”移植到网络 - 从 C++ 到 JavaScript/Canvas 通过 Emscripten

编辑注:这篇文章是 Mozilla Emscripten 团队的 Alon Zakai 的客座文章。感谢 Alon!

我和我的影子 是一款开源的 2D 游戏,它拥有巧妙的游戏玩法,您不是控制一个角色,而是控制两个角色。我最近偶然听到他们发布了 0.3 版本,因此我对此非常感兴趣。

由于我一直在寻找可以移植到网络的游戏,我认为这是一个不错的选择。移植非常容易,这是结果:网页上的我和我的影子

Me and my shadow

您也可以在 GitHub 上获取源代码

移植是通过使用 Emscripten(一个使用 LLVM 的开源 C++ 到 JavaScript 编译器)将原始代码自动编译为 JavaScript 来完成的。使用这样的编译器可以让游戏直接编译,而无需手动用 JavaScript 重写,因此该过程几乎不需要时间。

编译后的游戏在我的测试的机器和浏览器上几乎与桌面版本完全一样。有趣的是,性能看起来非常好。在这种情况下,主要是因为游戏的大部分操作都是位块传输图像。它使用跨平台 SDL API,这是一个针对打开窗口、获取输入、加载图像、渲染文本等操作的包装库(因此它正是这种游戏所需要的)。Emscripten 通过本机 canvas 调用来支持 SDL,因此当您将使用 SDL 的游戏编译为 JavaScript 时,它将使用 Emscripten 的 SDL 实现。该实现使用 drawImage 调用等来实现 SDL 位块传输操作,而浏览器在如今通常会对这些操作进行硬件加速,因此游戏运行起来与本机一样快。

例如,如果 C++ 代码包含

SDL_BlitSurface(sprite, NULL, screen, position)

那么这意味着将 sprite 所表示的整个位图位块传输到屏幕上的特定位置。Emscripten 的 SDL 实现对参数进行了一些转换,然后调用

ctx.drawImage(src.canvas, sr.x, sr.y, sr.w, sr.h, dr.x, dr.y, sr.w, sr.h);

这将把包含在 src.canvas 中的 sprite 绘制到代表屏幕的上下文中,并绘制在正确的 位置和大小。换句话说,C++ 代码被自动转换为使用本机 HTML canvas 操作的代码,并以高效的方式执行。

不过,也有一些注意事项。主要问题是浏览器对必要功能的支持,我遇到的主要问题是类型化数组和 Blob 构造函数。

  • 类型化数组是快速运行编译后的 C++ 代码并实现最大兼容性的必要条件。Emscripten 可以编译没有类型化数组的代码,但结果会更慢,并且需要手动进行兼容性校正。值得庆幸的是,所有浏览器都在获取类型化数组。Firefox、Chrome 和 Opera 已经拥有它们,Safari 似乎直到最近才缺少 FloatArray64,而 IE 将在 IE10 中获得它们。
  • Blob 构造函数是必要的,因为这个游戏使用了 Emscripten 的新压缩选项。它将所有数据文件(大约 150 个)打包成一个文件,对该文件进行 LZMA 压缩,然后浏览器中的游戏下载该文件,解压缩,并将该文件拆分。这使得下载文件更小(但意味着需要暂停一段时间来解压缩)。问题是,我们最终在类型化数组中为每个文件获得数据。使用 BlobBuilder 来处理图像很容易,但对于音频来说,它们需要设置 mimetype 才能成功解码,而只有 Blob 构造函数支持该操作。看起来只有 Firefox 目前拥有 Blob 构造函数,我曾在 Twitter 上听说 Chrome 可能存在一种解决方法,我希望能够了解更多信息。对于其他浏览器,我不确定。但是,游戏仍然可以正常运行,只是没有声音效果和音乐。

另一个注意事项是,有一些不可避免的手动移植工作需要完成。

JavaScript 主循环必须以异步方式编写:每个帧都调用一个回调函数。值得庆幸的是,游戏通常以一种可以让主循环轻松地重构为执行一次迭代的函数的方式编写,而这里就是这种情况。然后,每次从 JavaScript 调用执行一次主循环迭代的函数。但是,还有其他一些同步代码的情况更令人讨厌,例如在选择菜单项时发生的淡出效果是同步完成的(绘制,SDL_Delay,绘制等)。我在移植 Doom 时也遇到了同样的问题,我想这是一个常见的代码模式。因此,我现在只是禁用了这些淡出效果;如果您确实想在移植的游戏中使用它们,则需要将它们重构为异步的。

除此之外,几乎所有内容都正常运行。(唯一的例外是这段代码成为了一个 LLVM LTO 漏洞 的受害者,但 Rafael 已经修复了它。)因此,总之我认为没有理由不将这些游戏运行在网络上:它们易于移植,并且运行得非常快。

关于 Chris Heilmann

HTML5 和开放网络的福音传播者。让我们一起解决这个问题!

Chris Heilmann 的更多文章……


7 条评论

  1. azakai

    我忘记链接到移植项目的代码了。它就在这里

    https://github.com/kripken/meandmyshadow.web

    – azakai

    2012 年 4 月 4 日 下午 11:05

  2. Rasmus Wikman

    太棒了!

    接下来试试 Wildfire Games 的 0 A.D. 怎么样?:)

    2012 年 4 月 4 日 下午 11:59

  3. Neil Rashbrook

    普通的 gzip 传输有什么问题吗?

    2012 年 4 月 5 日 上午 5:52

    1. azakai

      LZMA 的压缩效果比 gzip 更好。但是,这可能不值得,因为压缩效果的差异并不大,而且解压缩时间很明显。不过,有人要求我提供 LZMA 支持,因此我实现了它,这是一个测试用例。

      2012 年 4 月 5 日 上午 9:57

  4. Rudolf O.

    您为什么要使用递归的主循环?我看到您正在调用 setTimeout,它会再次调用该函数。为什么不定义该函数并使用 setInterval 而不是使用 setTimeout,例如:

    function OneMainLoop() { }
    setInterval(OneMainLoop, 1000/40);

    我不确定是否有区别,所以才问 :S

    2012 年 4 月 5 日 上午 7:44

  5. azakai

    区别在于,如果出现问题,主循环函数可能会抛出异常。如果在主循环迭代 *之后* 执行 setTimeout,则异常将停止主循环。而如果您执行 setInterval,则最终会让主循环不断抛出异常。前一种方法在某些情况下可以更容易地进行调试。

    2012 年 4 月 5 日 上午 9:59

  6. Lozzy

    我只是随口一说,但能否将音频文件解压缩到 IndexedDB 存储中?我知道 IndexedDB 支持存储文件,但不确定在这种情况下是否合适。

    2012 年 4 月 8 日 上午 4:23

本文的评论已关闭。