個案研究 - Bouncy Mouse

Eric Karl
Eric Karl

簡介

彈跳滑鼠

去年年底,在 iOS 和 Android 上發布「彈力滑鼠」後,我學到了幾個非常重要的課程。其中的關鍵在於,打入知名市場並不容易。在高度飽和的 iPhone 市場中,要獲得消費者的信任並不容易;在飽和度較低的 Android Marketplace 中,遊戲的進展相對簡單,但還不是件容易的事。 就這次的經驗而言,我在 Chrome 線上應用程式商店中看到有趣的商機。「線上應用程式商店」可說的不是空無一物,但優質的 HTML5 遊戲目錄才剛開始成熟。對於新的應用程式開發人員,這意味著可以輕鬆提高排名圖表和曝光率。因此,我決定將「彈力滑鼠遊」移植至 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 版本最終大小大致相同。確保程式碼集不多,只採用兩種重要做法:不要編寫任何多餘的程式碼,也不要在預先處理 (非執行階段) 程式碼中盡量執行。 沒有撰寫任何多餘的程式碼看起來或許很顯而易見,但這是我一直在努力克服的一件事。我經常急切需要為任何可納入輔助程式的內容編寫輔助類別/函式。然而,除非您確實打算多次使用輔助程式,否則通常只會導致程式碼的毀損。有了彈跳滑鼠,我絕對不會寫個幫手,除非是至少使用三次。我編寫了輔助課程時,試著讓課程保持簡潔、易於攜帶且可重複使用,方便日後在專案中運用。另一方面,如果只針對「彈力滑鼠」編寫程式碼,但重複使用的可能性較低,我的重點是盡可能以簡單快速的方式完成程式設計工作,即使這並非撰寫程式碼的「最理想」方式。第二,如果程式碼集較小,最重要的部分就是盡可能將大量程式碼推送至預先處理步驟。如果您執行執行階段工作並移至預先處理工作,不僅遊戲的執行速度會更快,也不必將程式碼移植到各個新平台。 舉例來說,我原本將樓層幾何圖形資料儲存為一般未經處理的格式,並在執行期間組合實際的 OpenGL/WebGL 頂點緩衝區。這項作業需要花點時間設定,並編寫幾百行的執行階段程式碼。稍後,我將這個程式碼移至預先處理步驟,在編譯時寫出完整的 OpenGL/WebGL 頂點緩衝區。實際程式碼的數量大致相同,但其中幾百行程式碼都移到了預先處理步驟,這表示我從未將這些程式碼轉移至任何新平台。 「彈力滑鼠」中有許多這類例子,遊戲的實際玩法會因遊戲而異,不過請留意並非在執行階段中發生的事。

不要服用不需要的依附元件

另一個原因是 Bouncy Mouse 幾乎沒有依附元件,因此十分容易移植。以下圖表概述 Bouncy Mouse 每個平台的主要程式庫依附元件:

Android iOS HTML5 WP7
圖像 OpenGL ES OpenGL ES WebGL XNA
音效 OpenSL ES OpenAL 網路音訊 XNA
物理學 方塊 2D 方塊 2D Box2D.js Box2D.xna

就幾乎是這樣。除了 Box2D 以外,還未使用任何大型第三方程式庫,因為後者可在所有平台上移植。WebGL 和 XNA 地圖在 OpenGL 上均可使用將近 1:1 的地圖,因此這並不是一個大問題。只有真正的音效區域才是真正的圖書館。不過,Bouncy Mouse 中的音訊程式碼很小 (約幾行平台專屬程式碼),因此這並不是很嚴重的問題。將 Bouncy Mouse 排除在無法移植的大型程式庫之外,代表不同版本之間的執行階段程式碼邏輯幾乎相同 (即使語言變更也是如此)。此外,我們還能避免受制於無法攜帶的工具鍊。有鑑於與 Cocos2DUnity 等程式庫相比,我曾詢問有沒有直接針對 OpenGL/WebGL 編寫程式碼會造成複雜度增加,並有 WebGL 輔助程式在很多方面)。事實上,我相信相反。大多數手機 / HTML5 遊戲 (至少像彈力滑鼠) 就非常簡單。在大多數情況下,遊戲只會繪製幾個小精靈,或許會畫出一些具有紋理的幾何圖形,Bouncy Mouse 中的 OpenGL 專屬程式碼總和可能少於 1000 行。如果使用輔助程式庫,就能確實降低這個數量,我就感到意外。即使這只佔一半,我還是必須花大量時間學習新的程式庫/工具,只因為這樣能省下 500 行程式碼。另外,我還沒有為自己感興趣的所有平台尋找可移植的輔助程式庫,因此採用這類依附元件可能會對可攜性造成重大傷害。如果我編寫的 3D 遊戲需要光照貼圖、動態 LOD 和膚色動畫等,我的答案一定會改變。在這個情況下,我會重新發明輪盤,嘗試以 OpenGL 手動編寫整個引擎。重點是,大多數行動/HTML5 遊戲都屬於這個類別,所以需要先釐清點子。

請勿低估語言之間的相似性

最後一項技巧就是將 C++ 程式碼集移植到新語言時省下大量時間,就是因為每種語言之間的大部分程式碼幾乎都相同。雖然有些重要元素可能會改變,但這些元素的數量遠少於維持不變的項目。事實上,對於許多函式,從 C++ 到 JavaScript 只需要在我的 C++ 程式碼集中執行一些規則運算式替換即可。

轉攜結論

這在攜碼轉移過程中大致上也是如此。我會在接下來的章節中探討一些 HTML5 特有的挑戰,但重點是,如果程式碼保持簡單的,移植起來會有些困難,而不是夢想。

音訊

其中一個導致我 (而且看似其他人) 遇到音訊問題。目前在 iOS 和 Android 上,有多種可靠的音訊選擇 (OpenSL、OpenAL) 但在 HTML5 的世界中,效果更是難上加難。雖然可以使用 HTML5 音訊,但我發現當您在遊戲中使用 HTML5 音訊時,會發生難以達成的交易問題。即使使用的是新版瀏覽器 我也經常遇到奇怪的行為以 Chrome 為例,可同時建立的音訊元素 (來源) 數量似乎有限。另外,即使有聲音可以播放 影片最後還是可能會變形總體上,我有點擔心。線上搜尋發現,幾乎所有人都遇到相同問題。我一開始採用的解決方案是 SoundManager2 的 API。這個 API 會在可用的情況下 使用 HTML5 音訊,在困難情況下會改回使用 Flash儘管這項解決方案能夠正常運作,但問題仍舊無法預測 (僅比單純的 HTML5 音訊更少)。 活動推出一週後,我與 Google 的幾位貢獻者聊聊,邀請我參加 Webkit 的 Web Audio API。我原本考慮過使用這個 API 但由於 API 似乎具備大量的複雜性,因此不想再使用 API。我只想演奏一些音效:以 HTML5 音訊來說,這相當於幾行 JavaScript。 不過,在我簡略看網路音訊時,我發現了網路音訊龐大的 (70 頁) 規格、網路數量不多 (新 API 的典型範例), 以及規格各處都遺漏「播放」、「暫停」或「停止」功能。 有了 Google 的保證,我就能再次利用這個 API 找出原因。看過更多範例並且做進一步研究後 發現 Google 的確符合我的需求,而且這個 API 確實能滿足我的需求特別適合使用「開始使用 Web Audio API」一文,深入瞭解 API 的使用方式。我的真正問題在於,瞭解並使用 API 後,我仍然覺得 API 似乎不是「只聽幾首歌」。 為瞭解決這個問題,我編寫了一個小型輔助類別,讓我依照自己想要的方式播放、暫停、停止及查詢聲響狀態。 我們將這個輔助類別 AudioClip 命名為。您可以在 GitHub 上根據 Apache 2.0 授權取得完整原始碼,而我會在下文中討論類別的詳細資料。但首先,Web Audio API 的背景資訊如下:

網路音訊圖

讓 Web Audio API 比 HTML5 Audio 元素更加複雜 (且功能更強大),首先要將音訊輸出給使用者之前,先對音訊進行處理 / 混音。儘管音訊播放功能強大,但在簡單的情境下,音訊會更加複雜。下圖說明 Web Audio API 的強大功能:

基本網路音訊圖
基本網路音訊圖表

雖然以上範例顯示了 Web Audio API 的強大功能,但我其實不需要大部分的功能。我只是想播放音效,雖然這還是需要圖表,但圖表其實非常簡單。

圖表可以簡單明瞭

讓 Web Audio API 比 HTML5 Audio 元素更加複雜 (且功能更強大),首先要將音訊輸出給使用者之前,先對音訊進行處理 / 混音。儘管音訊播放功能強大,但在簡單的情境下,音訊會更加複雜。下圖說明 Web Audio API 的強大功能:

Trivial Web Audio Graph
Trivial Web Audio Graph

上方顯示的小型圖可以完成播放、暫停或停止播放音效所需的所有步驟。

但我們不用擔心圖像品質

雖然看懂圖表很不錯,但每次播放音效時,我都不需要再處理。因此,我編寫了一個簡易的包裝函式類別「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();
}

播放 – 播放音效包含兩個步驟:設定播放圖表,以及在圖表來源上呼叫「備忘錄」版本。一個來源只能播放一次,因此每次播放時都必須重新建立來源/圖表。這個函式的複雜度大多來自恢復暫停短片 (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 連接埠效能的另一方面是效能問題。完成連接埠 1 後,我發現自己的四核心桌面上一切運作正常。很抱歉,使用小筆電或 Chromebook 的體驗較差,在此情況下,Chrome 的分析器會清楚顯示我所有程式時間都花在哪些地方,幫助我節省寶貴時間。 我的經驗強調在進行任何最佳化之前,剖析的重要性。我原本預期 Box2D 物理學,或轉譯程式碼是導致速度變慢的主要原因,不過大部分時間其實都花在 Matrix.clone() 函式中。我的遊戲具備大量數學運算,我知道我做了大量的矩陣建立/複製工作,但我從來沒想到這是瓶頸。最後結果顯示,遊戲只要一項簡單的改動,就能讓遊戲的 CPU 使用率減少 3 倍以上,從桌面的 CPU 用量減少 6% 至 7%,或許這或許是 JavaScript 開發人員常見的知識,但身為 C++ 開發人員,我非常驚訝,以下詳細說明。基本上,我的原始矩陣類別是 3x3 矩陣:3 個元素陣列,每個元素都包含 3 個元素陣列。不幸的是,這表示在複製矩陣時,我必須建立 4 個新的陣列。我唯一需要變更的是,將這些資料移到一個 9 元素陣列中,然後據此更新數學運算。這項改變是我所看到的 3 倍 CPU 縮減情形,而且經過這項變更後,所有測試裝置的效能皆可接受。

最佳化更多

雖然成效在接受範圍內,但還是有些微小事,稍微分析好一點後,我發現這是 JavaScript 的垃圾收集造成的。我的應用程式是以 60fps 執行,代表每個影格的繪圖時間都只有 16 毫秒。不幸的是,在速度較慢的機器上開始垃圾收集時,有時候會吃掉約 10 毫秒。這會導致出現延遲幾秒鐘,因為遊戲需要將近 16 毫秒才能繪製完整影格。為了進一步瞭解為何我會產生大量垃圾,所以我使用了 Chrome 的堆積分析器。絕大多數的絕望都發現,絕大多數的垃圾 (超過 70%) 都是 Box2D 所產生。排除 JavaScript 中的垃圾是棘手的工作,而在 Box2D 中重新撰寫內容便毫無所謂,因此我才意識到自己竟然陷入困境。幸好,我目前仍在擁有書中最古老的一個技巧:如果無法達到 60fps,就能以 30fps 跑步。大家完全同意,以 30fps 拍攝影片,遠比在 60fps 拍攝的影格數還要好。事實上,我還是沒收到遊戲採用 30fps 的申訴或評論 (除非你並列比較兩種版本,否則很難判斷)。每影格多出 16 毫秒,代表即使在處理品質不佳的垃圾收集,我仍有充足時間轉譯影格。 雖然我使用的 API 時間 API 沒有明確啟用以 30fps 運作 (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 連接埠是營利的。因此在這個專案中,我將 HTML5 遊戲視為值得學習的新穎技術。但我並沒有發現,充電座能觸及非常龐大的觀眾,而且擁有顯著的營利潛力。

彈跳滑鼠已在 Chrome 線上應用程式商店於 10 月底推出。在 Chrome 線上應用程式商店推出應用程式後,我便得以運用現有的系統,包括提高曝光度、社群參與度和排名等一直在行動平台上不斷成長的功能。驚人的是,商店的觸及範圍相當廣。發布後一個月,我發現安裝次數已近 4 萬,而社群參與度 (錯誤回報和意見回饋) 也受惠於社群參與度。還有一項令我驚訝的是,網頁應用程式將成為可能的營利潛力。

Bouncy Mouse 提供簡單的營利方式,也就是在遊戲內容旁邊的橫幅廣告。不過,考量到遊戲的廣大觸及範圍,我發現這個橫幅廣告能夠帶來可觀的收益,而且在旺季期間,應用程式帶來的收入與我最具成效的 Android 平台相輔相成。造成這個情況的其中一項因素是,HTML5 版本上顯示的大型 AdSense 廣告單次曝光收益,遠比 Android 上顯示的規模較小的 AdMob 廣告高出許多。此外,HTML5 版本中橫幅廣告的干擾程度比 Android 版本低許多,因此遊戲過程體驗更簡潔。整體而言,這次的成果讓我非常驚豔。

正規化收益趨勢。
正規化收益變化

雖然遊戲帶來的收益遠高於預期,但值得注意的是,Chrome 線上應用程式商店的觸及範圍仍小於 Android Market 等較成熟的平台。雖然 Bouncy Mouse 很快就在 Chrome 線上應用程式商店中奪得前 9 名最受歡迎的遊戲,但新遊戲的新使用者在首次發布後的速度變慢了。儘管如此,這款遊戲仍在穩定成長,我非常期待看到這個平台的發展!

結論

將彈跳滑鼠移動至 Chrome 的過程比想像中簡單得多,除了一些音訊和效能問題之外,我發現 Chrome 是現有智慧型手機遊戲的最佳平台。我會鼓勵所有害怕實驗失敗的開發人員來點它。無論是移植過程,還是初次使用 HTML5 遊戲的新玩家,我都感到非常滿意。 如有任何問題,歡迎透過電子郵件與我聯絡。你也可以在下方留下留言,我會定期查看這些內容。