簡介
在去年年底在 iOS 和 Android 上發布 Bouncy Mouse 後,我學到了幾個非常重要的教訓。其中最重要的一點是,要打入成熟市場並不容易。在競爭激烈的 iPhone 市場,要吸引使用者並不容易;在競爭較不激烈的 Android 市集,雖然進展較順利,但仍不容易。有了這次經驗,我發現 Chrome 線上應用程式商店有個有趣的商機。雖然 Web 商店並非空無一物,但其高品質 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 在各平台的主要程式庫依附元件:
大致上就是這樣。除了 Box2D 之外,我們並未使用任何大型第三方程式庫,因為 Box2D 可在所有平台上移植。就圖形而言,WebGL 和 XNA 都幾乎 1:1 對應 OpenGL,因此這並不是大問題。只有音效部分的實際程式庫不同。不過,Bouncy Mouse 中的音效程式碼很少 (約一百行特定平台程式碼),因此這並不是大問題。讓 Bouncy Mouse 不含大型非可移植程式庫,表示執行階段程式碼的邏輯在不同版本之間幾乎相同 (即使語言有所變更)。此外,這也能避免我們受限於非可移植工具鍊。有人問我,直接針對 OpenGL/WebGL 編寫程式是否會比使用 Cocos2D 或 Unity 等程式庫更複雜 (也有一些 WebGL 輔助工具)。事實上,我認為正好相反。大多數的手機 / HTML5 遊戲 (至少像彈跳老鼠這類的遊戲) 都非常簡單。在大多數情況下,遊戲只會繪製幾個精靈,以及一些紋理幾何圖形。Bouncy Mouse 中 OpenGL 專屬程式碼的總數可能不到 1000 行。如果使用輔助程式庫實際上會減少這個數字,我會感到驚訝。即使這個數字減半,我還是需要花費大量時間學習新的程式庫/工具,才能節省 500 行程式碼。除此之外,我還沒有找到可在所有我感興趣的平台上移植的輔助程式庫,因此採用這種依附元件會大幅影響移植性。如果我要編寫需要光照貼圖、動態 LOD 和貼圖動畫等的 3D 遊戲,我的答案肯定會有所不同。在這種情況下,我會重新發明輪子,嘗試手動為 OpenGL 編寫整個引擎。我的意思是,大多數行動/HTML5 遊戲 (目前) 都未歸類於這個類別,因此在必要時之前,不必將事情複雜化。
不要低估語言之間的相似之處
最後一個訣竅是,在將 C++ 程式碼集移轉至新語言時,我發現各語言的程式碼幾乎相同,因此省下了許多時間。雖然某些關鍵元素可能會有所變動,但不變的元素遠多於變動元素。事實上,對於許多函式而言,從 C++ 轉換為 JavaScript 只需在 C++ 程式碼庫中執行幾個規則運算式替換作業即可。
移植結論
這就是轉移程序的全部內容。在接下來的幾個章節中,我會談到一些 HTML5 專屬的挑戰,但主要重點是,只要程式碼簡單,轉移作業就不會太麻煩,不會讓您傷透腦筋。
音訊
音訊是讓我 (以及其他人) 感到困擾的部分。在 iOS 和 Android 上,有許多可靠的音訊選項 (OpenSL、OpenAL),但在 HTML5 的世界中,情況就比較不樂觀。雖然 HTML5 Audio 可供使用,但我發現在遊戲中使用時,它會發生一些重大問題。即使使用最新的瀏覽器,我經常會遇到奇怪的行為。舉例來說,Chrome 似乎會限制同時建立的音訊元素 (source) 數量。此外,即使音訊會播放,有時也會莫名變成失真。總體來說,我有點擔心。在網路上搜尋後,發現幾乎所有人都遇到同樣的問題。我最初找到的解決方案是名為 SoundManager2 的 API。這個 API 會在 HTML5 Audio 可用時使用 HTML5 Audio,在棘手情況下則改回使用 Flash。雖然這個解決方案可行,但仍有錯誤和不可預測的情形 (比純 HTML5 音訊少)。推出一週後,我與 Google 的幾位熱心人士交談,他們向我推薦了 WebKit 的 Web Audio API。我原本考慮使用這個 API,但因為 API 似乎有許多不必要的複雜性 (對我來說),所以我放棄了。我只是想播放幾個聲音:使用 HTML5 Audio 只需幾行 JavaScript 程式碼即可。不過,在簡短瀏覽 Web Audio 的過程中,我發現其規格龐大 (70 頁)、網站上的範例不多 (新 API 通常就是如此),而且規格中未提及「播放」、「暫停」或「停止」功能。由於 Google 保證我的疑慮並非空穴來風,因此我再次深入研究 API。在查看更多範例並進行更多研究後,我發現 Google 的說法沒錯:API 確實能滿足我的需求,而且不會出現其他 API 的錯誤。特別推薦您參閱「Web Audio API 入門」一文,進一步瞭解 API。我真正的問題是,即使我已瞭解並使用 API,但這項 API 似乎仍不是設計用於「只播放幾個音效」的 API。為瞭解決這個疑慮,我編寫了一個小型輔助程式類別,讓我可以按照自己的方式使用 API,也就是播放、暫停、停止及查詢音效的狀態。我將這個輔助類別命名為 AudioClip。完整原始碼可在 GitHub 上取得,授權條款為 Apache 2.0 版,我將在下文討論該類別的詳細資訊。不過,我們先來瞭解 Web Audio API 的背景資訊:
Web Audio 圖表
第一點,Web Audio API 比 HTML5 Audio 元素更複雜 (也更強大),因為它能夠在將音訊輸出給使用者前先處理 / 混合音訊。雖然這項功能相當強大,但在簡單情境中,任何音訊播放都會涉及圖表,因此會使情況變得複雜一些。以下圖表說明 Web Audio API 的強大功能:
雖然上述範例展示了 Web Audio API 的強大功能,但在我的情境中,我不需要這麼多功能。我只是想播放音效。雖然這項操作仍需要圖表,但圖表非常簡單。
圖表可以簡單
第一點,Web Audio API 比 HTML5 Audio 元素更複雜 (也更強大),因為它能夠在將音訊輸出給使用者前先處理 / 混合音訊。雖然這項功能相當強大,但在簡單情境中,任何音訊播放都會涉及圖表,因此會使情況變得複雜一些。以下圖表說明 Web Audio API 的強大功能:
上述簡單的圖表可完成播放、暫停或停止音訊所需的所有操作。
但我們不必擔心圖表
雖然瞭解這張圖表很有幫助,但我並不想每次播放音效時都處理這張圖表。因此,我編寫了簡單的包裝函式類別「AudioClip」。這個類別會在內部管理此圖表,但會提供更簡單的使用者 API。
這個類別只是 Web Audio 圖表和一些輔助狀態,但我可以使用更簡單的程式碼,而不需要建立 Web Audio 圖表來播放每個音效。
// At startup time
var sound = new AudioClip("ping.wav");
// Later
sound.play();
作品詳細資訊
讓我們快速瀏覽輔助類別的程式碼: 建構函式 – 建構函式會使用 XHR 處理音訊資料的載入作業。雖然這裡沒有顯示 (為了簡化範例),但 HTML5 Audio 元素也可以用做來源節點。這對大量樣本特別有用。請注意,Web Audio API 要求我們以「arraybuffer」形式擷取這項資料。收到資料後,我們會使用這項資料建立 Web Audio 緩衝區 (將資料從原始格式解碼為執行階段 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 移植作業的部分是效能。完成第 1 版的移植作業後,我發現在四核心桌上型電腦上,一切都運作正常。不幸的是,在 Netbook 或 Chromebook 上,情況不太理想。在這個案例中,Chrome 的分析工具顯示所有程式的時間消耗情形,因此我得以解決問題。根據我的經驗,在進行任何最佳化前,先進行剖析非常重要。我原本預期 Box2D 物理或轉譯程式碼是造成速度變慢的主要原因,但實際上,我花費的大部分時間都在 Matrix.clone()
函式上。由於我的遊戲以數學為主,我知道自己執行了許多矩陣建立/複製作業,但從未預期這會成為瓶頸。最後,我發現只要進行非常簡單的變更,就能讓遊戲的 CPU 使用率降低 3 倍以上,從我的電腦上 6 到 7% 降到 2%。這或許是 JavaScript 開發人員的常識,但對 C++ 開發人員來說,這個問題讓我感到意外,因此我會進一步說明。基本上,我的原始矩陣類別是 3x3 矩陣:3 個元素陣列,每個元素都包含 3 個元素陣列。不幸的是,這表示在複製矩陣時,我必須建立 4 個新的陣列。我只需要將這項資料移至單一 9 元素陣列,並據此更新數學運算。這項變更完全是導致 CPU 使用量減少 3 倍的原因,而且在變更後,所有測試裝置的效能都達到可接受的程度。
更多最佳化
雖然效能表現尚可,但我仍發現一些小問題。經過進一步的剖析後,我發現這是因為 Javascript 的垃圾收集機制。我的應用程式以 60fps 的速度運作,也就是說,每個影格只有 16 毫秒的繪圖時間。不幸的是,當垃圾收集在較慢的機器上啟動時,有時會耗用約 10 毫秒。由於遊戲需要幾乎完整的 16 毫秒才能繪製完整影格,因此每隔幾秒就會出現卡頓現象。為了進一步瞭解產生大量垃圾的原因,我使用了 Chrome 的堆積分析工具。令人沮喪的是,絕大多數的垃圾 (超過 70%) 都是由 Box2D 產生。在 JavaScript 中移除垃圾是個棘手的問題,而重寫 Box2D 則是不可能的任務,因此我意識到自己陷入了困境。幸好,我還能使用最古老的解決方法:如果無法達到 60 fps,就以 30 fps 執行。一般來說,以穩定的 30 fps 執行,比以不穩定的 60 fps 執行來得好。事實上,我至今都沒有收到任何有關遊戲以 30fps 執行的抱怨或評論 (除非你將兩個版本並排比較,否則很難分辨)。每個影格額外增加的 16 毫秒,意味著即使在垃圾收集情況不佳的情況下,我仍有充足的時間算繪影格。雖然我使用的時間 API (WebKit 的優異 requestAnimationFrame) 並未明確啟用 30fps 的執行速度,但可以透過非常簡單的方式達成。雖然不如明確的 API 簡潔,但只要知道 RequestAnimationFrame 的間隔與螢幕的 VSYNC (通常為 60fps) 一致,就能達到 30fps。也就是說,我們只需忽略所有其他回呼。基本上,如果您有一個回呼「Tick」,每次「RequestAnimationFrame」觸發時都會呼叫,則可透過以下方式完成:
var skip = false;
function Tick() {
skip = !skip;
if (skip) {
return;
}
// OTHER CODE
}
如要採取更謹慎的做法,請確認電腦的 VSYNC 在啟動時並未達到或低於 30fps,並在這種情況下停用跳格功能。不過,我測試過的任何電腦/筆電設定都沒有出現這個問題。
發布和營利
最後,讓我感到驚訝的是,Bouncy Mouse 的 Chrome 移植版本竟然也能營利。在這個專案中,我將 HTML5 遊戲視為一項有趣的實驗,可讓我學習新興技術。我沒想到的是,這個移植版本會觸及非常廣大的觀眾,而且有極大的營利潛力。
Bouncy Mouse 於 10 月底在 Chrome 線上應用程式商店推出。在 Chrome 線上應用程式商店發布應用程式後,我可以利用現有系統來提升可發現度、社群參與度、排名,以及其他在行動平台上常用的功能。令我驚訝的是,商店的觸及範圍相當廣泛。在發布後一個月內,我獲得了近四十萬次安裝,並已從社群參與 (錯誤回報、意見回饋) 中獲益。另外,我還發現網頁應用程式具有營利潛力,這點也讓我感到驚訝。
Bouncy Mouse 採用了一種簡單的營利方法,就是在遊戲內容旁顯示橫幅廣告。不過,由於遊戲的觸及範圍廣泛,我發現這則橫幅廣告能夠帶來可觀的收益,而且在高峰期間,應用程式產生的收益與最成功的 Android 平台相當。其中一個原因是,HTML5 版本顯示的 AdSense 廣告尺寸較大,因此每曝光收益遠高於 Android 版顯示的小型 AdMob 廣告。不僅如此,HTML5 版本的橫幅廣告比 Android 版本的廣告干擾性低得多,可提供更清晰的遊戲體驗。整體而言,我對這個結果感到非常驚喜。

雖然遊戲的收益遠高於預期,但值得注意的是,Chrome 線上應用程式商店的觸及範圍仍不及 Android 市集等較成熟的平台。雖然 Bouncy Mouse 很快就成為 Chrome 線上應用程式商店中第 9 大熱門遊戲,但自初次發布以來,新使用者造訪網站的速度卻大幅下降。不過,遊戲仍持續穩定成長,我很期待看到這個平台的發展!
結論
我認為將 Bouncy Mouse 移植到 Chrome 的過程比預期順利得多。除了一些輕微的音訊和效能問題,我發現 Chrome 是現有智慧型手機遊戲的完美平台。我建議所有不敢嘗試這項體驗的開發人員試試看。我對移植程序和 HTML5 遊戲帶來的新遊戲觀眾都非常滿意。如有任何問題,歡迎來信詢問。或者在下方留言,我會定期查看這些留言。