案例研究 - Bouncy Mouse

Eric Karl
Eric Karl

简介

弹跳鼠标

去年年底,在 iOS 和 Android 上发布 Bouncy Mouse 后,我学到了几个非常重要的教训。其中的关键一点是,打入成熟市场并非易事。在完全饱和的 iPhone 市场上,获得吸引力非常困难;在饱和度较低的 Android Marketplace 上,取得进展比较容易,但仍然不容易。 有了这次的体验,我在 Chrome 应用商店中发现了一个有趣的机会。虽然 Web Store 并非空无一物,但其提供的一系列基于 HTML5 的高品质游戏才刚刚开始日渐成熟。对于新的应用开发者来说,这意味着制作排名图表和获得曝光度要容易得多。考虑到这个机会,我着手将 Bouncy Mouse 移植到 HTML5,希望能够将我最新的游戏体验带给激动人心的新用户群。 在本案例研究中,我会介绍将 Bouncy Mouse 移植到 HTML5 的一般流程,然后更深入地探讨三个被证明有趣的领域:音频、性能和创收。

将 C++ 游戏移植到 HTML5

Bouncy Mouse 目前适用于 Android(C++)、iOS (C++)、Windows Phone 7 (C#) 和 Chrome (JavaScript)。 这偶尔会引发一个问题:如何编写可以轻松移植到多个平台的游戏?我感觉人们希望有一种灵丹妙药,用它们来实现这种可携性,而不必依靠手部移植。 遗憾的是,我不确定是否存在这样的解决方案(最接近的可能是 Google 的 PlayN 框架Unity 引擎,但它们都无法满足我感兴趣的所有目标)。 事实上,我的方案是手动移植。 我最初使用 C++ 编写 iOS/Android 版本,然后将此代码移植到每个新平台。这听起来似乎需要完成大量工作,但 WP7 和 Chrome 的构建版本分别不超过 2 周的时间。 那么,现在的问题是,可以采取什么措施来使代码库能够轻松地进行手动移植吗?在这方面,我所做的一些事情很有帮助:

尽量减小代码库

虽然这看起来很明显,但这确实是我能够如此快速移植游戏的主要原因。Bouncy Mouse 的客户端代码大约只有 7,000 行 C++。7,000 行代码并不是什么都没有,但它已经足够小,可以管理。C# 和 JavaScript 版本的客户端代码最终大致相同。尽量缩减代码库的大小基本上相当于两种关键做法:不要编写任何多余的代码,并在预处理(非运行时)代码中尽可能多地编写代码。 不编写任何多余的代码似乎是显而易见的,但这是我经常为自己而努力的一件事。我经常冲动为任何可能被视为辅助程序的内容编写辅助类/函数。不过,除非您实际上打算多次使用帮助程序,否则通常只会导致代码膨胀。使用 Bouncy Mouse 时,我很小心,除非我打算使用它至少三次,否则绝不会编写助手。在编写帮助程序类时,我会努力让它简洁、可移植且可重复使用,以用于我未来的项目。另一方面,在只为 Bouncy Mouse 编写代码时,由于重复使用的可能性较低,我的重点是尽可能简单快速地完成编码任务,即使这不是“最美”的代码编写方式。 保持代码库规模较小的第二个,也是更重要的部分是尽可能将代码库推送到预处理步骤中。如果您可以将运行时任务移至预处理任务,那么不仅游戏运行速度更快,而且您不必将代码移植到每个新平台。 举个例子,我最初以未经过处理的格式存储级别几何图形数据,并在运行时组装实际的 OpenGL/WebGL 顶点缓冲区。这需要一些设置工作和几百行运行时代码。后来,我将此代码移到了预处理步骤中,在编译时写出完全打包的 OpenGL/WebGL 顶点缓冲区。实际代码量大致相同,但这几百行已经移动到预处理步骤,这意味着我从来没有将它们移植到任何新平台。在 Bouncy Mouse 中有很多这样的例子,每个游戏的可能情况都不尽相同,不过请留意在运行时不需要执行的任何情况。

不要接受您不需要的依赖项

Bouncy Mouse 易于移植的另一个原因是,它几乎没有依赖项。下图总结了 Bouncy Mouse 每个平台的主要库依赖项:

Android iOS HTML5 WP7
图形 OpenGL ES OpenGL ES WebGL XNA
声音 OpenSL ES OpenAL 网络音频 XNA
物理学 Box2D Box2D Box2D.js Box2D.xna

差不多就是这样。使用的没有大型的第三方库,只有 Box2D,可在所有平台上移植。在图形方面,WebGL 和 XNA 几乎都能使用 OpenGL 1:1 映射,所以这并不是一个大问题。仅在声音方面,实际库有所不同。不过,Bouncy Mouse 中的声音代码很小(大约 100 行的平台专用代码),因此这并不是一个大问题。确保 Bouncy Mouse 不含大型不可移植的库,这意味着不同版本之间的运行时代码的逻辑几乎相同(尽管语言不同)。此外,它还避免了我们受限于一个不可移植的工具链。有人问我,与使用 Cocos2DUnity 等库相比,针对 OpenGL/WebGL 进行编码是否会直接增加复杂性(也有一些 WebGL 帮助程序)。事实上,我觉得恰恰相反。大多数手机 / HTML5 游戏(至少像 Bouncy Mouse 这样的游戏)都非常简单。在大多数情况下,游戏只会绘制几个精灵,或许还会绘制一些有纹理的几何图形。Bouncy Mouse 中特定于 OpenGL 的代码的总和可能少于 1000 行。如果使用帮助程序库实际上会减少此数量,我会感到惊讶。即使该数字减少了一半,我还需要花费大量时间学习新的库/工具,仅仅为了节省 500 行代码。除此之外,我还没找到可以在我感兴趣的所有平台上移植的辅助程序库,所以这种依赖项会严重影响可移植性。如果我要编写的 3D 游戏需要光照贴图、动态 LOD、换肤动画等,我的答案肯定会有变化。在这种情况下,我将重新创造一个工具,尝试针对 OpenGL 手动编写整个引擎代码。我的意思是,大多数移动/HTML5 游戏都(尚不)属于此类别,因此不必在必要的情况下使情况复杂化。

不要低估语言之间的相似之处

在将 C++ 代码库移植到新语言时,节省大量时间的最后一个技巧是,意识到每种语言之间的大部分代码几乎完全相同。虽然某些关键要素可能会发生变化,但那些不会发生变化的方面要少得多。事实上,对于许多函数来说,从 C++ 到 JavaScript 只是需要在 C++ 代码库上运行一些正则表达式替换。

关于移植的总结

关于移植过程就介绍到这里了。我将在后面几节中介绍 HTML5 特有的一些难题,但主要信息是,如果您的代码保持简单,移植将是一件小麻烦,而不是可怕的麻烦。

音频

造成我(以及似乎其他人)一些麻烦的方面是音频。在 iOS 和 Android 上,有许多可靠的音频选择(OpenSL、OpenAL)可供选择,但在 HTML5 中,情况看起来不太好。虽然可以使用 HTML5 音频,但我发现它在游戏中使用时存在一些破坏性问题。即使在最新的浏览器上 我也经常遇到奇怪的行为例如,Chrome 对可以同时创建的音频元素(来源)数量似乎有限制。此外,即使声音可以播放,有时也会导致不可避免的失真。总的来说,我有点担心。 在网上搜索后发现,几乎所有人都有相同的问题。我最初采用的解决方案是一个名为 SoundManager2 的 API。此 API 使用 HTML5 音频(如果有),在棘手的情况下会回退到 Flash。尽管这一解决方案行之有效,但它仍存在错误且不可预测(只是不如纯 HTML5 音频那样)。 在产品发布一周后,我与 Google 的几位员工进行了交流,他们向我介绍了 Webkit 的 Web Audio API。我最初考虑过使用此 API,但后来回避,因为该 API 似乎包含大量不必要的复杂性(对我而言)的复杂性。我只想播放几个声音:对于 HTML5 音频,这相当于需要几行 JavaScript。 然而,在我对网络音频的简要介绍中,我被其庞大(70 页)的规范、网络上少量的样本(对于新 API 的典型情况)以及该规范中任何位置都省略了“播放”“暂停”或“停止”功能所震惊。 Google 保证我的问题尚未深化到 API 上,因此我再次深入探索。看了更多示例并进一步研究后,我发现 Google 是对的 - 该 API 肯定可以满足我的需求,并且不会出现困扰其他 API 的错误。Web Audio API 使用入门一文尤其实用,如果您想更深入地了解该 API,该文章就非常有帮助。我真正的问题是,即使在了解并使用该 API 之后,它在我看来仍然像一个并非用于“只播放几种声音”的 API。 为了克服这种误解,我编写了一个小型辅助类,让我能够以自己想要的方式使用 API - 播放、暂停、停止和查询声音的状态。 我将此辅助类称为 AudioClip。GitHub 上根据 Apache 2.0 许可提供了完整源代码,我将在下文中详细介绍这门课程。但首先,我们介绍一下 Web Audio API 的一些背景信息:

网络音频图表

与 HTML5 音频元素相比,Web Audio API 更复杂(也更强大)的一方面是它能够在输出音频之前处理 / 混合音频。虽然功能强大,但任何音频播放都涉及到图表这一事实,使得简单场景中的处理过程变得更加复杂。为了说明 Web Audio API 的功能,请参考下图:

基本 Web 音频图表
基本 Web 音频图表

虽然上面的示例展示了 Web Audio API 的功能,但我在自己的场景中并不需要大部分功能。我只想响铃。虽然这仍然需要图表,但是图表非常简单。

图表可以很简单

与 HTML5 音频元素相比,Web Audio API 更复杂(也更强大)的一方面是它能够在输出音频之前处理 / 混合音频。虽然功能强大,但任何音频播放都涉及到图表这一事实,使得简单场景中的处理过程变得更加复杂。为了说明 Web Audio API 的功能,请参考下图:

简单的网络音频图表
小型网络音频图表

上面所示的小型图表可以完成播放、暂停或停止声音所需的所有操作。

不过,我们完全不必担心图表的问题

虽然图表很不错,但我不想在每次播放声音时处理它。因此,我编写了一个简单的封装容器类“AudioClip”。这个类在内部管理此图表,但提供了一个简单得多的面向用户的 API。

AudioClip
AudioClip

这个类只是一个网络音频图表和一些辅助状态,但与我必须构建一个网络音频图表来播放每种声音相比,我可以使用的代码要简单得多。

// At startup time
var sound = new AudioClip("ping.wav");

// Later
sound.play();

实现细节

我们来快速了解一下帮助程序类的代码:构造函数 - 构造函数使用 XHR 处理声音数据的加载。虽然此处未显示(为简单起见),但 HTML5 音频元素也可以用作源节点。这对于大型样本尤为有用。请注意,Web Audio API 要求我们将此数据作为“数组缓冲区”获取。收到数据后,我们根据该数据创建网络音频缓冲区(将其从原始格式解码为运行时 PCM 格式)。

/**
* Create a new AudioClip object from a source URL. This object can be played,
* paused, stopped, and resumed, like the HTML5 Audio element.
*
* @constructor
* @param {DOMString} src
* @param {boolean=} opt_autoplay
* @param {boolean=} opt_loop
*/
AudioClip = function(src, opt_autoplay, opt_loop) {
// At construction time, the AudioClip is not playing (stopped),
// and has no offset recorded.
this.playing_ = false;
this.startTime_ = 0;
this.loop_ = opt_loop ? true : false;

// State to handle pause/resume, and some of the intricacies of looping.
this.resetTimout_ = null;
this.pauseTime_ = 0;

// Create an XHR to load the audio data.
var request = new XMLHttpRequest();
request.open("GET", src, true);
request.responseType = "arraybuffer";

var sfx = this;
request.onload = function() {
// When audio data is ready, we create a WebAudio buffer from the data.
// Using decodeAudioData allows for async audio loading, which is useful
// when loading longer audio tracks (music).
AudioClip.context.decodeAudioData(request.response, function(buffer) {
    sfx.buffer_ = buffer;
    
    if (opt_autoplay) {
    sfx.play();
    }
});
}

request.send();
}

播放 - 播放声音涉及两个步骤:设置播放图表,以及在图表的来源上调用某个版本的“noteOn”。一个来源只能播放一次,因此我们每次播放时都必须重新创建来源/图表。此函数的大部分复杂性都来自恢复已暂停的剪辑 (this.pauseTime_ > 0) 所需的要求。如需恢复播放已暂停的剪辑,我们使用 noteGrainOn,它允许播放缓冲区的子区域。遗憾的是,对于此场景,noteGrainOn 不会以所需的方式与循环交互(它会循环遍历子区域,而不是整个缓冲区)。 因此,我们需要解决此问题,方法是使用 noteGrainOn 播放片段的剩余部分,然后在启用循环的情况下从头开始重新播放片段。

/**
* Recreates the audio graph. Each source can only be played once, so
* we must recreate the source each time we want to play.
* @return {BufferSource}
* @param {boolean=} loop
*/
AudioClip.prototype.createGraph = function(loop) {
var source = AudioClip.context.createBufferSource();
source.buffer = this.buffer_;
source.connect(AudioClip.context.destination);

// Looping is handled by the Web Audio API.
source.loop = loop;

return source;
}

/**
* Plays the given AudioClip. Clips played in this manner can be stopped
* or paused/resumed.
*/
AudioClip.prototype.play = function() {
if (this.buffer_ && !this.isPlaying()) {
// Record the start time so we know how long we've been playing.
this.startTime_ = AudioClip.context.currentTime;
this.playing_ = true;
this.resetTimeout_ = null;

// If the clip is paused, we need to resume it.
if (this.pauseTime_ > 0) {
    // We are resuming a clip, so it's current playback time is not correctly
    // indicated by startTime_. Correct this by subtracting pauseTime_.
    this.startTime_ -= this.pauseTime_;
    var remainingTime = this.buffer_.duration - this.pauseTime_;

    if (this.loop_) {
    // If the clip is paused and looping, we need to resume the clip
    // with looping disabled. Once the clip has finished, we will re-start
    // the clip from the beginning with looping enabled
    this.source_ = this.createGraph(false);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime)

    // Handle restarting the playback once the resumed clip has completed.
    // *Note that setTimeout is not the ideal method to use here. A better 
    // option would be to handle timing in a more predictable manner,
    // such as tying the update to the game loop.
    var clip = this;
    this.resetTimeout_ = setTimeout(function() { clip.stop(); clip.play() },
                                    remainingTime * 1000);
    } else {
    // Paused non-looping case, just create the graph and play the sub-
    // region using noteGrainOn.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime);
    }

    this.pauseTime_ = 0;
} else {
    // Normal case, just creat the graph and play.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteOn(0);
}
}
}

以音效形式播放 - 上述播放功能不允许音频片段以重叠方式重复播放(只有在片段结束或停止时,才可以第二次播放)。有时,游戏会想要多次播放某个声音,而不是等待每次播放完成(在游戏中收集金币等)。为了实现这一点,AudioClip 类具有 playAsSFX() 方法。由于可以同时进行多次播放,因此来自 playAsSFX() 的播放不会与 AudioClip 进行 1:1 的播放。因此,无法停止、暂停播放或查询播放状态。循环播放还会被停用,因为以这种方式无法停止循环播放的声音。

/**
* Plays the given AudioClip as a sound effect. Sound Effects cannot be stopped
* or paused/resumed, but can be played multiple times with overlap.
* Additionally, sound effects cannot be looped, as there is no way to stop
* them. This method of playback is best suited to very short, one-off sounds.
*/
AudioClip.prototype.playAsSFX = function() {
if (this.buffer_) {
var source = this.createGraph(false);
source.noteOn(0);
}
}

停止、暂停和查询状态 - 其余函数非常简单,无需过多说明:

/**
* Stops an AudioClip , resetting its seek position to 0.
*/
AudioClip.prototype.stop = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.startTime_ = 0;
this.pauseTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Pauses an AudioClip. The offset into the stream is recorded to allow the
* clip to be resumed later.
*/
AudioClip.prototype.pause = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.pauseTime_ = AudioClip.context.currentTime - this.startTime_;
this.pauseTime_ = this.pauseTime_ % this.buffer_.duration;
this.startTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Indicates whether the sound is playing.
* @return {boolean}
*/
AudioClip.prototype.isPlaying = function() {
var playTime = this.pauseTime_ +
            (AudioClip.context.currentTime - this.startTime_);

return this.playing_ && (this.loop_ || (playTime < this.buffer_.duration));
}

音频总结

希望这个辅助类对与我同样面临同样的音频问题的开发者有用。此外,即使您需要添加 Web Audio API 的一些更强大的功能,这样的类似乎也是合适的入手点。无论从哪方面来看,这个解决方案都能满足 Bouncy Mouse 的需求,让这款游戏成为真正的 HTML5 游戏,没有任何附加条件!

性能

在 JavaScript 移植方面,另一个让我担心的方面是性能。运行完端口 v1 后,我发现在四核桌面设备上,一切都正常。很遗憾,在上网本或 Chromebook 上的表现不太好。在这个示例中,Chrome 的性能分析器能够准确地显示我所有程序使用时间的具体位置,从而为我节省了资金。 我的经验凸显了在进行任何优化之前进行分析的重要性。我以为 Box2D 物理特性或渲染代码会成为拖慢运行速度的主要原因;但是,我的大部分时间实际上都花在了 Matrix.clone() 函数上。我的游戏非常注重数学,所以我知道我做了大量的矩阵创建/克隆工作,但我从未预料到这会成为瓶颈。最终结果,一项非常简单的更改使游戏的 CPU 使用率减少了 3 倍以上,从桌面设备上占用的 CPU 使用率从 6-7% 降至 2%。 或许这是 JavaScript 开发者的常识,但作为 C++ 开发者,这个问题令我感到惊讶,因此我会更详细地介绍一下。基本上,我的原始矩阵类是一个 3x3 矩阵:一个包含 3 个元素的数组,每个元素包含一个包含 3 个元素的数组。遗憾的是,这意味着在克隆矩阵时,我必须创建 4 个新数组。我唯一需要做的更改是将此数据移到单个 9 元素的数组中,并相应地更新我的数学公式。我看到这项更改导致 CPU 数量减少了 3 倍,完全是这项更改导致的,而且在这项更改之后,我的性能在所有测试设备上都可以接受。

更多优化

虽然我的表现可以接受,但还是出现一些小问题。经过更多分析,我意识到这是由于 JavaScript 的垃圾回收政策造成的。我的应用的运行速度为 60fps,这意味着每一帧的绘制时间只有 16 毫秒。遗憾的是,如果运行速度较慢的机器开始垃圾回收,有时大约需要 10 毫秒。这会导致几秒钟内出现卡顿,因为游戏需要几乎完整的 16 毫秒才能绘制一整个帧。为了更好地了解为什么我生成了这么多垃圾,我使用了 Chrome 的堆性能分析器。令我失望的是,绝大部分垃圾(超过 70%)都是由 Box2D 生成的。在 JavaScript 中消除垃圾是一项棘手的工作,而重新编写 Box2D 也不现实,因此我意识到自己陷入了困境。幸运的是,本书中最古老的技巧之一是:当游戏帧速率无法达到 60fps 时,就以 30fps 的速度进行跑步。大家一致认为,以稳定的 30fps 运行比以不稳定的 60fps 运行时要好得多。事实上,我还没有收到一个关于游戏运行速度为 30fps 的投诉或评论(除非并排比较两个版本,否则很难确定)。每帧多出 16 毫秒意味着,即使发生了糟糕的垃圾回收,我仍然有充足的时间来渲染帧。虽然我使用的计时 API(WebKit 非常出色的 requestAnimationFrame)并未明确启用以 30fps 的速率运行,但可以通过非常简单的方式来实现。虽然可能不像显式 API 那样优雅,但通过了解 RequestAnimationFrame 的间隔与显示器的 VSYNC(通常为 60fps)保持一致,也可以实现 30fps。这意味着我们只需忽略所有其他回调即可。基本上,如果您有每次触发“RequestAnimationFrame”时都会调用的回调“Tick”,可按如下方式实现:

var skip = false;

function Tick() {
skip = !skip;
if (skip) {
return;
}

// OTHER CODE
}

如果想格外小心,则应检查计算机的 VSYNC 是否在启动时未处于或低于 30fps,并在这种情况下停用跳过功能。不过,我还没有在我测试过的任何台式机/笔记本电脑配置上遇到过这种情况。

分发和创收

在 Chrome 中,Bouncy Mouse 最令人惊讶的一点是变现。在参与此项目时,我将 HTML5 游戏想象成了一项有趣的实验,用于学习各种新兴技术。我当时没有预想到,携号转网服务将会覆盖非常庞大的受众群体,并具有巨大的创收潜力。

Bouncy Mouse 已于 10 月底在 Chrome 应用商店中推出。通过在 Chrome 应用商店中发布应用,我利用现有系统实现曝光度、社区互动、排名以及我在移动平台上已经习惯的其他功能。令我惊讶的是,门店的覆盖范围之广。在发布后的一个月内,我的安装量就达到了近 40 万,而且社区参与(错误报告、反馈)也使我受益匪浅。另一个让我惊讶的是,网络应用的创收潜力。

Bouncy Mouse 有一种简单的变现方法 - 在游戏内容旁边展示横幅广告。然而,考虑到这款游戏的覆盖范围较广,我发现这款横幅广告能够带来可观的收入,而且在高峰期,该应用产生的收入足以为我最成功的平台 Android 带来收入。造成这种情况的一个因素是,与在 Android 上展示的较小 AdMob 广告相比,在 HTML5 版本中展示的较大的 AdSense 广告的每次展示所带来的收入要高得多。不仅如此,HTML5 版横幅广告的侵扰性要低于 Android 版,从而让游戏体验更加简洁明了。总的来说,我对这个结果非常满意。

一段时间内的标准化收入。
一段时间内的标准化收入

尽管该款游戏的收入远超预期,但值得注意的是,Chrome 应用商店的覆盖面仍然不如 Android 电子市场等更成熟的平台。尽管 Bouncy Mouse 能在 Chrome 应用商店中排名第 9 的热门游戏,但自游戏最初发布以来,访问该网站的新用户数量显著下降。尽管如此,这款游戏仍在稳步发展,我对平台的发展充满期待!

总结

我想将 Bouncy Mouse 移植到 Chrome 的操作比我预期的要顺畅得多。除了一些微小的音频和性能问题外,我发现 Chrome 是一个能够完美适配现有智能手机游戏的平台。我建议所有逃避该体验的开发者尝试一下。无论是移植过程还是吸引新的游戏受众群体,HTML5 游戏都能帮到我,我都非常满意。 如果您有任何问题,欢迎随时给我发送电子邮件。您也可以在下面发表评论,我会定期查看这些反馈。