案例研究:弹性鼠标

发布于 2022-01-01 18:38:42 字数 16025 浏览 1131 评论 0

去年年底在 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 时,我小心翼翼地从不编写助手,除非我打算使用它至少 3 次。 当我确实编写了一个辅助类时,我试图使它干净、可移植并且可在我未来的项目中重用。 另一方面,在为 Bouncy Mouse 编写代码时,重复使用的可能性很低,我的重点是尽可能简单快速地完成编码任务,即使这不是编写代码的“最漂亮”的方式代码。

保持代码库较小的第二个也是更重要的部分是尽可能多地推进预处理步骤。 如果您可以执行运行时任务并将其移至预处理任务,那么您的游戏不仅会运行得更快,而且您也不必将代码移植到每个新平台。

举个例子,我最初将我的关卡几何数据存储为一种相当未处理的格式,在运行时组装实际的 OpenGL/WebGL 顶点缓冲区。 这需要一些设置和几百行运行时代码。 后来,我将此代码移至预处理步骤,在编译时写出完全打包的 OpenGL/WebGL 顶点缓冲区。 实际代码量大致相同,但那几百行代码已移至预处理步骤,这意味着我无需将它们移植到任何新平台。

在 Bouncy Mouse 中有大量这样的例子,可能的情况因游戏而异,但请留意任何不需要在运行时发生的事情。

不要依赖你不需要的依赖

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

AndroidiOSHTML5WP7
GraphicsOpenGL ESOpenGL ESWebGLXNA
SoundOpenSL ESOpenALWeb AudioXNA
PhysicsBox2DBox2DBox2D.jsBox2D.xna

差不多就是这样。 除了 没有使用大型的 3rd 方库 Box2D 之外 可以跨所有平台移植的 , 。 对于图形,WebGL 和 XNA 与 OpenGL 几乎 1:1 映射,所以这不是什么大问题。 仅在声音方面,实际的库有所不同。 然而,Bouncy Mouse 中的声音代码很小(大约一百行特定于平台的代码),所以这不是一个大问题。

保持 Bouncy Mouse 不受大型不可移植库的影响,意味着不同版本的运行时代码逻辑几乎相同(尽管语言发生了变化)。 此外,它使我们免于陷入不可移植的工具链。

有人问我,与使用像 这样的库相比,针对 OpenGL/WebGL 进行编码是否会直接导致复杂性增加 Cocos2D Unity (也有一些 WebGL 助手)。 事实上,我认为恰恰相反。 大多数手机/HTML5 游戏(至少像Bouncy Mouse 这样的游戏)都非常简单。 在大多数情况下,游戏只绘制一些精灵和一些带纹理的几何图形。 Bouncy Mouse 中特定于 OpenGL 的代码总和可能不到 1000 行。 如果使用辅助库实际上会减少这个数字,我会感到惊讶。 即使它把这个数字减半,我也需要花大量时间学习新的库/工具来节省 500 行代码。 最重要的是,我还没有找到一个可以在我感兴趣的所有平台上移植的辅助库,因此采用这种依赖项会严重损害可移植性。

如果我正在编写一个需要光照贴图、动态 LOD、蒙皮动画等的 3d 游戏,我的答案肯定会改变。 在这种情况下,我将重新发明轮子以尝试针对 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。
然而,在我对 Web Audio 的简要介绍中,我被其庞大的(70 页)规范、网络上的少量示例(新 API 的典型特征)所震惊,
并且在规范中的任何地方都省略了“播放”、“暂停”或“停止”功能。

在 Google 保证我的担心没有根据的情况下,我再次深入研究了 API。 在查看了更多示例并进行了更多研究之后,
我发现 Google 是对的——API 绝对可以满足我的需求,而且它可以在没有困扰其他 API 的错误的情况下做到这一点。 特别方便的是文章 Getting Started with Web Audio API
如果您想更深入地了解 API,这是一个很好的去处。

我真正的问题是,即使在理解并使用了 API 之后,在我看来,它仍然像是一个并非旨在“仅播放一些声音”的 API。
为了解决这个疑虑,我编写了一个小的帮助类,它让我可以按照我想要的方式使用 API——播放、暂停、停止和查询声音的状态。
我称这个助手类为 AudioClip。 完整源代码可 在 GitHub 上获得
在 Apache 2.0 许可下,我将在下面讨论该类的详细信息。 但首先,有关 Web Audio API 的一些背景知识:

网络音频图表

使 Web Audio API 比 HTML5 Audio 元素更复杂(也更强大)的第一件事是它能够在将音频输出给用户之前处理/混合音频。 虽然功能强大,但任何音频播放都涉及图形这一事实使简单场景中的事情变得更加复杂。 为了说明 Web Audio API 的强大功能,请考虑下图:

Basic Web Audio Graph

Basic Web Audio Graph 基本网络音频图

虽然上面的示例展示了 Web Audio API 的强大功能,但在我的场景中我并不需要这种强大功能。 我只是想播放一个声音。 虽然这仍然需要一个图表,但图表非常简单。

图可以很简单

使 Web Audio API 比 HTML5 Audio 元素更复杂(也更强大)的第一件事是它能够在将音频输出给用户之前处理/混合音频。 虽然功能强大,但任何音频播放都涉及图形这一事实使简单场景中的事情变得更加复杂。 为了说明 Web Audio API 的强大功能,请考虑下图:

Trivial Web Audio Graph

Trivial Web Audio Graph 简单的网络音频图

上面显示的简单图形可以完成播放、暂停或停止声音所需的一切。

但我们甚至不用担心图表

虽然理解图表很好,但这并不是我每次播放声音时都想处理的事情。 因此,我编写了一个简单的包装类“AudioClip”。 这个类在内部管理这个图,但提供了一个更简单的面向用户的 API。

AudioClip
音频剪辑

这个类只不过是一个 Web 音频图和一些辅助状态,但与我必须构建一个 Web 音频图来播放每个声音相比,它允许我使用更简单的代码。

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

// Later
sound.play();

实施细则

让我们快速浏览一下 helper 类的代码:

构造函数——构造函数使用 XHR 处理加载声音数据。 尽管此处未显示(为了使示例保持简单),HTML5 Audio 元素也可以用作源节点。 这对于大样本尤其有用。 请注意,Web Audio API 要求我们将这些数据作为“arraybuffer”获取。 接收到数据后,我们从该数据创建一个 Web 音频缓冲区(将其从原始格式解码为运行时 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 倍以上,从我桌面上的 6-7% 的 CPU 减少到 2%。

也许这是 Javascript 开发人员的常识,但作为 C++ 开发人员,这个问题让我感到惊讶,所以我会更详细地介绍一下。 基本上,我的原始矩阵类是一个 3×3 矩阵:一个 3 元素数组,每个元素包含一个 3 元素数组。 不幸的是,这意味着当需要克隆矩阵时,我必须创建 4 个新数组。 我需要做的唯一改变是将这些数据移动到一个 9 元素数组中,并相应地更新我的数学。 这一变化完全是我看到的 3 倍 CPU 减少的原因,在这一变化之后,我的所有测试设备的性能都可以接受。

更多优化

虽然我的表现是可以接受的,但我仍然看到了一些小问题。 经过更多的分析,我意识到这是因为 Javascript 的垃圾收集。 我的应用程序以 60fps 运行,这意味着每帧只有 16 毫秒的时间来绘制。 不幸的是,当垃圾收集在较慢的机器上启动时,它有时会消耗大约 10 毫秒。 这导致了几秒钟的卡顿,因为游戏几乎需要整整 16 毫秒才能绘制完整帧。

为了更好地了解为什么我会产生如此多的垃圾,我使用了 Chrome 的堆分析器。 令我绝望的是,绝大多数垃圾(超过 70%)都是由 Box2D 生成的。 在 Javascript 中消除垃圾是一项棘手的工作,重写 Box2D 是不可能的,所以我意识到我已经陷入困境。 幸运的是,我仍然可以使用书中最古老的技巧之一:当你无法达到 60fps 时,以 30fps 运行。 一致认为以稳定的 30fps 运行比以抖动的 60fps 运行要好得多。 事实上,我还没有收到任何关于游戏以 30fps 运行的投诉或评论(除非将两个版本并排比较,否则很难说清楚)。 每帧额外的 16 毫秒意味着即使在垃圾收集很丑陋的情况下,我仍然有足够的时间来渲染帧。

虽然我使用的计时 API(WebKit 出色的 没有明确启用以 30fps 运行 requestAnimationFrame ) ,但它可以以非常简单的方式完成。 虽然可能不像显式 API 那样优雅,但可以通过知道 RequestAnimationFrame 的间隔与监视器的 VSYNC(通常为 60fps)对齐来实现 30fps。 这意味着我们只需要忽略所有其他回调。 基本上,如果您有一个每次触发“RequestAnimationFrame”时都会调用的回调“Tick”,则可以按如下方式完成:

var skip = false;

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

  // OTHER CODE
}

如果您想格外小心,您应该在启动时检查计算机的 VSYNC 是否已经达到或低于 30fps,并在这种情况下禁用跳过。 但是,我还没有在我测试过的任何台式机/笔记本电脑配置上看到这种情况。

分销和货币化

关于 Bouncy Mouse 的 Chrome 端口,最后一个让我感到惊讶的领域是货币化。 进入这个项目时,我将 HTML5 游戏视为学习新兴技术的有趣实验。 我没有意识到该端口会覆盖非常多的受众并具有巨大的货币化潜力。

Bouncy Mouse 于 10 月底在 Chrome 网上应用店推出。 通过在 Chrome 网上应用店发布,我能够利用现有系统实现可发现性、社区参与度、排名以及我在移动平台上已经习惯的其他功能。 令我惊讶的是这家商店的覆盖范围有多广。 在发布后的一个月内,我的安装量接近 40 万,并且已经从社区参与(错误报告、反馈)中受益。 另一件让我感到惊讶的事情是网络应用程序的货币化潜力。

Bouncy Mouse 有一种简单的货币化方法——在游戏内容旁边放置横幅广告。 然而,考虑到游戏的广泛影响,我发现这个横幅广告能够产生可观的收入,并且在它的高峰期,该应用程序产生的收入可与我最成功的平台 Android 相媲美。 造成这种情况的一个因素是,在 HTML5 版本上展示的较大 AdSense 广告比在 Android 上展示的较小 Admob 广告产生的每次展示收入要高得多。 不仅如此,HTML5 版本的横幅广告比 Android 版本的干扰性小得多,可以提供更干净的游戏体验。 总的来说,我对这个结果感到非常惊喜。


随着时间的推移标准化收益

虽然这款游戏的收益远好于预期,但值得注意的是,Chrome Web Store 的覆盖范围仍小于 Android Market 等更成熟平台的覆盖范围。 虽然 Bouncy Mouse 能够快速登上 Chrome 网上应用店中最受欢迎的第 9 款游戏,但自最初发布以来,新用户访问该网站的速度显着放缓。 也就是说,游戏仍在稳步增长,我很高兴看到该平台的发展!

结论

我会说将 Bouncy Mouse 移植到 Chrome 比我预期的要顺利得多。 除了一些轻微的音频和性能问题之外,我发现 Chrome 是现有智能手机游戏的完美平台。 我鼓励任何一直回避这种体验的开发人员尝试一下。 我对移植过程以及拥有 HTML5 游戏的新游戏受众都感到非常满意。

如果您有任何问题,请随时给我发电子邮件。 或者只是在下面发表评论,我会尝试定期检查这些。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

只为守护你

暂无简介

0 文章
0 评论
698 人气
更多

推荐作者

遂心如意

文章 0 评论 0

5513090242

文章 0 评论 0

巷雨优美回忆

文章 0 评论 0

junpengz2000

文章 0 评论 0

13郎

文章 0 评论 0

qq_xU4RDg

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文