個案研究 -《Inside World Wide Maze》

Saqoosha
Saqoosha

World Wide Maze 是一款遊戲,玩家需要使用智慧型手機,透過由網站建立的 3D 迷宮,引導滾動球前往目標點。

World Wide Maze

遊戲大量使用 HTML5 功能。舉例來說,DeviceOrientation 事件會從智慧型手機擷取傾斜資料,然後透過 WebSocket 傳送至電腦,讓玩家在由 WebGLWeb Workers 建構的 3D 空間中找到方向。

本文將詳細說明這些功能的使用方式、整體開發程序,以及最佳化重點。

DeviceOrientation

DeviceOrientation 事件 (範例) 可用於從智慧型手機擷取傾斜資料。當 addEventListenerDeviceOrientation 事件搭配使用時,系統會以規則間隔,以 DeviceOrientationEvent 物件做為引數,叫用回呼。間隔時間會因使用的裝置而異。舉例來說,在 iOS + Chrome 和 iOS + Safari 中,回呼會大約每 1/20 秒叫用一次,而在 Android 4 + Chrome 中,則會大約每 1/10 秒叫用一次。

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

DeviceOrientationEvent 物件包含每個 XYZ 軸的傾斜資料,單位為度數 (而非弧度) (詳情請參閱 HTML5Rocks)。不過,傳回值也會因裝置和瀏覽器的組合而異。實際傳回值的範圍如下表所示:

裝置螢幕方向。

頂端以藍色醒目顯示的值,是 W3C 規格中定義的值。綠色醒目顯示的項目符合這些規格,紅色醒目顯示的項目則不符合。令人驚訝的是,只有 Android 與 Firefox 的組合傳回符合規格的值。不過,在導入時,建議您採用較常見的值。因此,World Wide Maze 會將 iOS 傳回值做為標準,並據此調整 Android 裝置。

if android and event.gamma > 180 then event.gamma -= 360

不過,Nexus 10 仍不支援這項功能。雖然 Nexus 10 傳回的值範圍與其他 Android 裝置相同,但有個錯誤會將 beta 和 gamma 值顛倒。我們正在個別處理這個問題。(或許預設為橫向模式?)

如上述範例所示,即使涉及實體裝置的 API 已設定規格,也無法保證傳回的值會符合這些規格。因此,在所有潛在裝置上測試這些應用程式至關重要。這也表示可能會輸入非預期的值,因此需要建立因應措施。在 World Wide Maze 中,系統會在教學課程的第 1 步驟中提示初次遊玩的玩家校正裝置,但如果收到非預期的傾斜值,系統就無法正確校正至零位置。因此,它設有內部時間限制,如果無法在該時間限制內校正,就會提示玩家切換至鍵盤控制項。

WebSocket

在 World Wide Maze 中,智慧型手機和電腦會透過 WebSocket 連線。更準確地說,它們是透過中繼伺服器連線,也就是智慧型手機到伺服器再到電腦。這是因為 WebSocket 無法直接連結瀏覽器。(使用 WebRTC 資料管道可實現對等連線,且不需要中繼伺服器,但在實作時,這個方法只能用於 Chrome Canary 和 Firefox Nightly)。

我選擇使用名為 Socket.IO (v0.9.11) 的程式庫來實作,其中包含在連線逾時或中斷連線時重新連線的功能。我將它與 NodeJS 搭配使用,因為在多項 WebSocket 實作測試中,NodeJS + Socket.IO 組合可提供最佳的伺服器端效能。

依照號碼配對

  1. 電腦連線至伺服器。
  2. 伺服器會為電腦隨機產生一組號碼,並記住這組號碼和電腦的組合。
  3. 在行動裝置上指定號碼並連線至伺服器。
  4. 如果指定的號碼與已連結電腦的號碼相同,表示行動裝置已與該電腦配對。
  5. 如果沒有指定的電腦,就會發生錯誤。
  6. 行動裝置傳入的資料會傳送至配對的電腦,反之亦然。

你也可以改用行動裝置進行初始連線。在這種情況下,裝置會互換。

分頁同步

Chrome 專屬的分頁同步功能可讓配對程序更加簡單。有了這個功能,你就能輕鬆在行動裝置上開啟電腦上的網頁,反之亦然。電腦會取得伺服器核發的連線編號,並使用 history.replaceState 將其附加到網頁的網址。

history.replaceState(null, null, '/maze/' + connectionNumber)

如果啟用分頁同步功能,系統會在幾秒後同步網址,並在行動裝置上開啟相同的網頁。行動裝置會檢查開啟網頁的網址,如果有附加號碼,就會立即開始連線。這樣就不必手動輸入號碼,或使用相機掃描 QR code。

延遲時間

由於中繼伺服器位於美國,因此從日本存取該伺服器時,智慧型手機的傾斜資料會延遲約 200 毫秒才會傳送到電腦。與開發期間使用的本機環境相比,回應時間明顯較慢,但插入低通濾波器 (我使用 EMA) 後,回應時間就變得較不明顯。(實際上,為了呈現效果,我們也需要低通濾波器;傾斜感應器的回傳值含有大量雜訊,而將這些值套用至螢幕會導致大量震動)。這不適用於跳躍,因為跳躍明顯緩慢,但無法解決這個問題。

由於我預期一開始會有延遲問題,因此考慮在全球各地設定中繼伺服器,讓用戶端能夠連線至最近的伺服器 (進而盡量減少延遲時間)。不過,我最後使用的是 Google Compute Engine (GCE),當時這項服務只在美國提供,因此無法使用。

Nagle 演算法問題

Nagle 演算法通常會整合至作業系統,在 TCP 層級進行緩衝,以便進行有效的通訊,但我發現啟用這項演算法後,無法即時傳送資料。(特別是與 TCP 延遲確認搭配使用時。即使沒有延遲的 ACK,如果 ACK 因伺服器位於海外等因素而延遲,也會發生相同的問題)。

在 Android 版 Chrome 中,WebSocket 沒有發生 Nagle 延遲問題,因為該版本包含可停用 Nagle 的 TCP_NODELAY 選項,但在 iOS 版 Chrome 中使用的 WebKit WebSocket 卻發生了這個問題,因為該版本未啟用這個選項。(Safari 也使用相同的 WebKit,也發生這個問題。這個問題已透過 Google 回報給 Apple,並在 WebKit 的開發版本中解決

發生這個問題時,每 100 毫秒傳送的傾斜資料會合併成區塊,而這些區塊只會每 500 毫秒傳送至電腦。遊戲無法在這些情況下運作,因此會透過伺服器端以短時間間隔 (每 50 毫秒左右) 傳送資料,避免延遲情形。我認為,以短時間間隔接收 ACK 會讓 Nagle 演算法誤以為可以傳送資料。

Nagle 演算法 1

上方圖表顯示實際收到資料的間隔。這張圖表表示封包之間的時間間隔;綠色代表輸出間隔,紅色代表輸入間隔。最小值為 54 毫秒,最大值為 158 毫秒,中間值則接近 100 毫秒。我使用 iPhone 和位於日本的轉送伺服器,輸出和輸入時間都約為 100 毫秒,且運作順暢。

Nagle 演算法 2

相反地,這張圖表顯示在美國使用伺服器的結果。綠色輸出間隔保持在 100 毫秒,而輸入間隔則在 0 毫秒至 500 毫秒之間波動,表示電腦是以區塊接收資料。

ALT_TEXT_HERE

最後,這張圖表顯示讓伺服器傳送預留位置資料,以避免延遲的結果。雖然這項測試的效能不如使用日本伺服器時那麼好,但輸入間隔確實維持在 100 毫秒左右,相對穩定。

錯誤?

雖然 Android 4 (ICS) 中的預設瀏覽器有 WebSocket API,但無法連線,導致 Socket.IO connect_failed 事件。內部會逾時,且伺服器端也無法驗證連線。(我並未單獨使用 WebSocket 進行測試,因此可能是 Socket.IO 的問題)。

擴充轉發伺服器

由於中繼伺服器的角色並不複雜,只要確保電腦和行動裝置一律連線至相同的伺服器,擴充及增加伺服器的數量應該不難。

物理學

遊戲中的球運動 (向下滾動、與地面碰撞、與牆壁碰撞、收集物品等) 都是透過 3D 物理模擬器完成。我使用了 Ammo.js (這是廣泛使用的 Bullet 物理引擎,已透過 Emscripten 移植至 JavaScript),以及 Physijs,將其用於「Web Worker」。

Web Workers

Web Workers 是用於在個別執行緒中執行 JavaScript 的 API。以 Web Worker 形式啟動的 JavaScript 會以與原始呼叫端不同的執行緒執行,因此可在執行繁重工作時保持頁面回應能力。Physijs 會有效使用 Web Workers,協助通常密集的 3D 物理引擎順利執行。World Wide Maze 會以完全不同的影格速率處理物理引擎和 WebGL 圖片算繪,因此即使在低規機器上因 WebGL 算繪負載過大而導致影格速率下降,物理引擎本身仍會大致維持 60 fps,不會影響遊戲控制機制。

FPS

這張圖片顯示 Lenovo G570 的最終影格速率。上方方塊顯示 WebGL (圖片算繪) 的幀率,下方方塊則顯示物理引擎的幀率。GPU 是整合式 Intel HD Graphics 3000 晶片,因此圖像轉譯影格率未達預期的 60 fps。不過,由於物理引擎達到預期的幀率,因此遊戲體驗與高規格機器上的效能並無太大差異。

由於含有有效 Web Workers 的執行緒沒有主控台物件,因此必須透過 postMessage 將資料傳送至主執行緒,才能產生偵錯記錄。使用 console4Worker 可在 worker 中建立等同於主控台物件的物件,讓偵錯程序變得更簡單。

Service Worker

在最新版本的 Chrome 中,您可以在啟動 Web Workers 時設定中斷點,這對偵錯作業也很有幫助。您可以在「開發人員工具」的「Workers」面板中找到這項資訊。

成效

多邊形數量高的階段有時會超過 100,000 個多邊形,但即使這些多邊形全數為 Physijs.ConcaveMesh (Bullet 中的 btBvhTriangleMeshShape) 產生,效能也不會受到太大影響。

一開始,隨著需要碰撞偵測的物件數量增加,影格速率就會下降,但在 Physijs 中移除不必要的處理程序後,效能就會提升。這項改善是在原始 Physijs 的分支中完成。

鬼影物件

在 Bullet 中,如果物件具有碰撞偵測功能,但在碰撞時沒有影響,因此不會對其他物件產生影響,則稱為「幽靈物件」。雖然 Physijs 並未正式支援幽靈物件,但您可以在產生 Physijs.Mesh 後,透過調整標記來建立幽靈物件。World Wide Maze 會使用鬼影物件偵測物品和目標點的碰撞情形。

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

對於 collision_flags,1 是 CF_STATIC_OBJECT,4 是 CF_NO_CONTACT_RESPONSE。如要進一步瞭解相關資訊,請嘗試搜尋 Bullet 論壇Stack OverflowBullet 說明文件。由於 Physijs 是 Ammo.js 的包裝函式,而 Ammo.js 與 Bullet 基本上相同,因此在 Bullet 中可執行的大部分操作,在 Physijs 中也能執行。

Firefox 18 的問題

Firefox 從 17 版更新至 18 版後,Web Workers 交換資料的方式也隨之改變,導致 Physijs 停止運作。這個問題已在 GitHub 上回報,並在幾天後解決。雖然這項開源效率讓我印象深刻,但這起事件也提醒我,World Wide Maze 是由多個不同的開源架構組成。我寫這篇文章是希望提供一些意見回饋。

asm.js

雖然這與 World Wide Maze 無關,但 Ammo.js 已支援 Mozilla 近期推出的 asm.js (這一點並不令人意外,因為 asm.js 主要是用來加快 Emscripten 產生的 JavaScript,而 Emscripten 的開發人員也是 Ammo.js 的開發人員)。如果 Chrome 也支援 asm.js,物理引擎的運算負載應會大幅減少。使用 Firefox Nightly 進行測試時,速度明顯加快。或許最好是使用 C/C++ 編寫需要更高速度的部分,然後使用 Emscripten 將其移植至 JavaScript?

WebGL

針對 WebGL 實作,我使用了開發最積極的程式庫 three.js (r53)。雖然修訂版 57 已在開發後期發布,但 API 已進行重大變更,因此我仍使用原始修訂版發布。

發光效果

系統會使用簡易版的「Kawase Method MGF」實作新增至球核心和項目的發光效果。不過,雖然川瀨法會讓所有亮面區域都綻放光芒,但 World Wide Maze 會為需要發光的區域建立個別的算繪目標。這是因為您必須使用網站螢幕截圖來製作舞台紋理,而如果網站有白色背景,那麼只要擷取所有亮面區域,就會導致整個網站發光。我也考慮以 HDR 處理所有內容,但這次決定不採用,因為實作方式會變得相當複雜。

Glow

左上方顯示第一個 pass,其中光暈區域是單獨算繪,然後套用模糊效果。右下方顯示第二次處理,圖片大小縮減 50%,然後套用模糊效果。右上方顯示第三次處理,圖片再次縮小 50%,然後模糊處理。接著將這三張圖片重疊,產生最終的組合圖片,如左下方所示。至於模糊處理,我使用了 VerticalBlurShaderHorizontalBlurShader,這兩者都包含在 three.js 中,因此仍有改進空間。

反光球

球上的反射效果是根據 three.js 的範例製作。所有方向都會從球的位置算繪,並用做環境地圖。每次球移動時,都需要更新環境地圖,但以 60 fps 的速度更新會耗用大量資源,因此改為每三個影格更新一次。結果不如每個畫面都更新那麼流暢,但除非特別指出,否則差異幾乎無法察覺。

著色器、著色器、著色器…

WebGL 需要著色器 (頂點著色器、片段著色器) 才能完成所有算繪作業。雖然 three.js 內建的著色器可產生多種效果,但如果您想實現更精細的著色和最佳化,就必須自行編寫著色器。由於 World Wide Maze 會讓物理引擎占用 CPU,因此我嘗試改用 GPU,盡可能以著色語言 (GLSL) 編寫,即使 CPU 處理 (透過 JavaScript) 會更容易。海洋波浪效果自然會使用著色器,目標點的煙火效果和球出現時使用的網格效果也是如此。

著色器球

以上是測試球出現時使用的網格效果。左側的圖片是遊戲中使用的圖片,由 320 個多邊形組成。中間的圖片使用約 5,000 個多邊形,右邊的圖片使用約 300,000 個多邊形。即使有這麼多多邊形,使用著色器處理時,仍可維持 30 fps 的穩定影格速率。

著色器網格

散布在舞台上的小物件都會整合到一個多邊形中,而個別移動則需要著色器移動每個多邊形尖端。這是為了測試在大量物件出現時,效能是否會受到影響。這裡有約 5,000 個物件,由約 20,000 個多邊形組成。效能完全沒有受到影響。

poly2tri

系統會根據從伺服器收到的輪廓資訊,建立階段,然後透過 JavaScript 將其多邊形化。三角測量法是這個程序的重要部分,但 three.js 實作不佳,通常會失敗。因此,我決定自行整合名為 poly2tri 的不同三角測量程式庫。結果發現,three.js 先前曾嘗試過相同的做法,因此只要將部分內容註解掉,就能讓程式運作。因此錯誤數量大幅減少,可玩關卡也大幅增加。偶發性錯誤會持續存在,而且 poly2tri 會透過發出警示來處理錯誤,因此我將其修改為改為擲回例外狀況。

poly2tri

上圖顯示如何將藍色外框轉換為三角形,並產生紅色多邊形。

非均質過濾

由於標準等向性 MIP 對應會縮小水平和垂直軸的圖片,因此從斜角觀看多邊形,會使 World Wide Maze 階段最遠端的紋理看起來像是水平拉長的低解析度紋理。這個維基百科網頁右上方的圖片就是很好的例子。實際上,需要更多水平解析度,WebGL (OpenGL) 會使用非均質篩選方法來解決這個問題。在 three.js 中,為 THREE.Texture.anisotropy 設定大於 1 的值可啟用非均質篩選。不過,這項功能是擴充功能,可能並非所有 GPU 都支援。

最佳化

如同這篇WebGL 最佳做法文章所述,要改善 WebGL (OpenGL) 效能,最關鍵的方法就是盡量減少繪製呼叫。在 World Wide Maze 的初期開發階段,所有遊戲內島嶼、橋樑和護欄都是獨立的物件。這有時會導致繪圖呼叫次數超過 2,000 次,導致複雜的階段難以操作。不過,一旦將相同類型的物件全部打包至一個網格,繪圖呼叫就會降至約五十個,大幅提升效能。

我使用 Chrome 追蹤功能進一步最佳化。Chrome 開發人員工具內含的剖析器可在某種程度上判斷整體方法處理時間,但追蹤功能可精確告知各個部分的耗時長度,精確到 1/1000 秒。如要進一步瞭解如何使用追蹤功能,請參閱這篇文章

最佳化

上述是為球的反射效果建立環境地圖時的追蹤結果。將 console.timeconsole.timeEnd 插入 three.js 中看似相關的位置,就會產生類似下圖的圖表。時間流向從左至右,每個層級都類似呼叫堆疊。將 console.time 內的 console.time 巢狀化可進一步測量。頂端圖表為最佳化前,底部圖表為最佳化後。如上方圖表所示,在預先最佳化期間,系統會為 0 到 5 之間的每個轉譯呼叫 updateMatrix (雖然字詞已截斷)。不過,我修改了這個方法,讓它只呼叫一次,因為只有在物件變更位置或方向時,才需要執行這個程序。

追蹤程序本身會消耗資源,因此過度插入 console.time 可能會導致實際效能出現明顯偏差,導致難以找出需要最佳化的區域。

效能調整器

由於網路的特性,遊戲可能會在規格差異極大的系統上遊玩。2 月初推出的Find Your Way to Oz 使用名為 IFLAutomaticPerformanceAdjust 的類別,根據影格速率的波動縮小特效,有助於確保播放流暢。World Wide Maze 會使用相同的 IFLAutomaticPerformanceAdjust 類別,並按照以下順序縮放效果,盡可能讓遊戲過程流暢:

  1. 如果影格速率低於 45 fps,環境地圖就會停止更新。
  2. 如果仍低於 40 fps,則算繪解析度會降至 70% (表面比率的 50%)。
  3. 如果仍低於 40 fps,就會移除 FXAA (反鋸齒)。
  4. 如果幀率仍低於 30 fps,系統會移除發光效果。

記憶體流失

使用 three.js 時,要整齊地移除物件相當麻煩。但如果不處理,顯然會導致記憶體流失,因此我設計了以下方法。@renderer 參照 THREE.WebGLRenderer。(three.js 的最新修訂版本使用略有不同的釋放方法,因此這個方法可能無法與該版本搭配使用)。

destructObjects: (object) =>
  switch true
    when object instanceof THREE.Object3D
      @destructObjects(child) for child in object.children
      object.parent?.remove(object)
      object.deallocate()
      object.geometry?.deallocate()
      @renderer.deallocateObject(object)
      object.destruct?(this)

    when object instanceof THREE.Material
      object.deallocate()
      @renderer.deallocateMaterial(object)

    when object instanceof THREE.Texture
      object.deallocate()
      @renderer.deallocateTexture(object)

    when object instanceof THREE.EffectComposer
      @destructObjects(object.copyPass.material)
      object.passes.forEach (pass) =>
        @destructObjects(pass.material) if pass.material
        @renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
        @renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
        @renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2

HTML

我個人認為,WebGL 應用程式最棒的地方,就是能夠在 HTML 中設計網頁版面配置。在 Flash 或 openFrameworks (OpenGL) 中建構 2D 介面 (例如分數或文字顯示) 相當麻煩。Flash 至少有 IDE,但如果您不習慣 openFrameworks,可能會覺得很難上手 (使用 Cocos2D 可能會比較容易)。另一方面,HTML 則可透過 CSS 精確控制所有前端設計面向,就像建構網站時一樣。雖然無法產生像素凝結成標誌等複雜效果,但 CSS 轉換功能內的某些 3D 效果還是可行。World Wide Maze 的「GOAL」和「TIME IS UP」文字效果,是使用 CSS 轉場效果 (透過 Transit 實作) 的縮放功能製作動畫。(背景漸層效果顯然使用 WebGL)。

遊戲中的每個頁面 (標題、結果、排名等) 都有自己的 HTML 檔案,一旦這些檔案以範本形式載入,系統就會在適當時間呼叫 $(document.body).append(),並傳入適當的值。其中一個問題是無法在附加前設定滑鼠和鍵盤事件,因此在附加前嘗試 el.click (e) -> console.log(e) 會失敗。

國際化 (i18n)

使用 HTML 也方便建立英文版本。我選擇使用 i18next (一種網路 i18n 程式庫) 來滿足國際化需求,而且我可以直接使用該程式庫,無須修改。

我們使用 Google 文件試算表編輯及翻譯遊戲內文字。由於 i18next 需要 JSON 檔案,因此我將試算表匯出為 TSV,然後使用自訂轉換器進行轉換。我會在發布前進行大量更新,因此自動化 Google 文件試算表的匯出程序,可讓事情變得更簡單。

由於網頁是使用 HTML 建構,Chrome 的自動翻譯功能也能正常運作。不過,有時系統無法正確偵測語言,反而誤認為完全不同的語言 (例如,越南文),因此這項功能目前已停用。(可使用中繼標記停用)。

RequireJS

我選擇使用 RequireJS 做為 JavaScript 模組系統。遊戲的 10,000 行原始碼會分成約 60 個類別 (即 Coffee 檔案),並編譯成個別 js 檔案。RequireJS 會根據依附元件,以適當順序載入這些個別檔案。

define ->
  class Hoge
    hogeMethod: ->

如上所定義的類別 (hoge.coffee) 可用於以下方式:

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

為了讓這個方法運作,必須先載入 hoge.js,再載入 moge.js。由於「hoge」已指定為「define」的第一個引數,因此系統一律會先載入 hoge.js (在 hoge.js 載入完成後才回呼)。這個機制稱為 AMD,只要第三方程式庫支援 AMD,就能用於同類型的回呼。即使是未支援的依附元件 (例如 three.js),只要事先指定依附元件,也會有類似的效能。

這與匯入 AS3 類似,因此應該不會讓您感到陌生。如果您有更多依附檔案,這個可能是可行的解決方案。

r.js

RequireJS 包含名為 r.js 的最佳化器。這會將主要 js 與所有依附的 js 檔案合併為一個檔案,然後使用 UglifyJS (或 Closure Compiler) 進行精簡。這樣一來,瀏覽器需要載入的檔案數量和資料總量就會減少。World Wide Maze 的 JavaScript 檔案總大小約為 2 MB,經過 r.js 最佳化後,可縮減至約 1 MB。如果遊戲可以使用 gzip 進行發行,則大小會進一步縮減至 250 KB。(GAE 有個問題,無法傳送 1 MB 以上的 gzip 檔案,因此遊戲目前是以未壓縮的 1 MB 純文字形式發布)。

Stage Builder

以下是產生階段資料的方式,完全在美國的 GCE 伺服器上執行:

  1. 要轉換為階段的網站網址會透過 WebSocket 傳送。
  2. PhantomJS 會擷取螢幕畫面,並擷取 div 和 img 標記的位置,然後以 JSON 格式輸出。
  3. 根據步驟 2 的螢幕截圖和 HTML 元素的定位資料,自訂 C++ (OpenCV、Boost) 程式會刪除不必要的區域、產生島嶼、使用橋樑連接各個島嶼、計算防護欄和項目位置、設定目標點等。結果會以 JSON 格式輸出,並傳回至瀏覽器。

PhantomJS

PhantomJS 是不需要螢幕的瀏覽器。這項功能可在不需要開啟視窗的情況下載入網頁,因此可用於自動化測試,或在伺服器端擷取螢幕截圖。它的瀏覽器引擎是 WebKit,與 Chrome 和 Safari 相同,因此其版面配置和 JavaScript 執行結果也與標準瀏覽器大致相同。

使用 PhantomJS 時,您可以使用 JavaScript 或 CoffeeScript 編寫要執行的程序。如這項範例所示,擷取螢幕截圖非常簡單。我使用的是 Linux 伺服器 (CentOS),因此需要安裝字型來顯示日文 (M+ FONTS)。即使如此,字型算繪的處理方式仍與 Windows 或 Mac OS 不同,因此同一個字型在其他機器上可能會有所差異 (但差異很小)。

擷取 img 和 div 標記位置的處理方式,基本上與標準網頁相同。jQuery 也能正常使用。

stage_builder

我最初考慮使用更以 DOM 為基礎的方法來產生階段 (類似 Firefox 3D 檢查器),並嘗試在 PhantomJS 中執行類似 DOM 分析的操作。不過,我最後決定採用圖像處理方法。為此,我編寫了一個使用 OpenCV 和 Boost 的 C++ 程式,稱為「stage_builder」。這個代理程式會執行以下操作:

  1. 載入螢幕截圖和 JSON 檔案。
  2. 將圖片和文字轉換為「島嶼」。
  3. 建立橋樑連結島嶼。
  4. 移除不必要的橋接設定,以便建立迷宮。
  5. 放置大型物品。
  6. 放置小物品。
  7. 設定防護機制。
  8. 以 JSON 格式輸出定位資料。

以下將詳細說明每個步驟。

載入螢幕截圖和 JSON 檔案

通常會使用 cv::imread 來載入螢幕截圖。我測試了幾個 JSON 檔案程式庫,但 picojson 似乎最容易使用。

將圖片和文字轉換為「島嶼」

階段建構

上圖為 aid-dcc.com 的「新聞」專區螢幕截圖 (按一下可查看實際大小)。圖片和文字元素必須轉換為島嶼。為了區隔這些區段,我們應該刪除白色背景顏色,也就是螢幕截圖中最常見的顏色。完成後,畫面會顯示如下:

階段建構

白色部分是潛在的島嶼。

文字太細且銳利,因此我們會使用 cv::dilatecv::GaussianBlurcv::threshold 加粗。圖片內容也遺失了,因此我們會根據 PhantomJS 輸出的 img 標記資料,將這些區域填入白色。產生的圖片如下所示:

階段建構

文字現在會形成適當的叢集,每張圖片都是適當的島嶼。

建立連結各個島嶼的橋樑

島嶼準備就緒後,就會透過橋樑連結。每個島嶼都會尋找左、右、上、下相鄰的島嶼,然後將橋樑連結至最近島嶼的最近點,結果如下:

階段建構

移除不必要的橋樑來建立迷宮

保留所有橋樑會讓舞台過於容易瀏覽,因此必須移除部分橋樑來建立迷宮。系統會選擇一個島嶼 (例如左上角的島嶼) 做為起點,並刪除與該島嶼連結的所有橋樑 (隨機選取),然後,針對透過剩餘橋樑連結的下一個島嶼執行相同的操作。路徑一旦走到盡頭或導回先前往的島嶼,就會回溯到可存取新島嶼的點。所有島嶼都以這種方式處理後,迷宮就會完成。

階段建構

放置大型物品

視每個島嶼的尺寸而定,會在其中放置一或多個大型物品,從離島嶼邊緣最遠的點開始選擇。雖然不太清楚,但下方以紅色標示這些點:

階段建構

在所有可能的點中,左上方的點設為起點 (紅色圓圈),右下方的點設為目標 (綠色圓圈),其餘最多六個點則用於放置大型商品 (紫色圓圈)。

放置小物品

階段建構

適當數量的小物品沿著線條放置,與島嶼邊緣保持固定距離。上圖 (非來自 aid-dcc.com) 顯示投放位置的灰色虛線,這些虛線會以固定間隔偏移並放置在島嶼邊緣。紅點代表小物品放置的位置。由於這張圖片來自開發中版本,因此項目是沿著直線排列,但最終版本會將項目散布在灰色線的兩側,呈現較不規則的排列方式。

設定防護機制

護欄基本上會沿著島嶼的外圍放置,但必須在橋上切斷,才能讓人行走。在這種情況下,Boost Geometry 程式庫可派上用場,簡化幾何運算,例如判斷島嶼邊界資料與橋樑兩側線條的交會點。

階段建構

島嶼外框的綠色線條是護欄。這張圖片可能不易看出,但橋樑處沒有綠色線。這是用於偵錯的最終圖片,其中包含所有需要輸出至 JSON 的物件。淺藍色圓點代表小項目,灰色圓點則代表建議的重新啟動點。當球掉入海洋時,遊戲會從最近的重新開始點繼續進行。重啟點的安排方式與小物品大致相同,會以固定間隔排列,且與島嶼邊緣保持一定距離。

以 JSON 格式輸出定位資料

我也會使用 picojson 做為輸出格式。它會將資料寫入標準輸出內容,然後由呼叫端 (Node.js) 接收。

在 Mac 上建立可在 Linux 中執行的 C++ 程式

這款遊戲是在 Mac 上開發,並在 Linux 中部署,但由於 OpenCV 和 Boost 都支援這兩個作業系統,因此只要建立編譯環境,開發作業就不會太困難。我使用 Xcode 中的指令列工具在 Mac 上偵錯建構,然後使用 automake/autoconf 建立設定檔,以便在 Linux 中編譯建構。接著,我只需在 Linux 中使用「configure && make」建立可執行檔案。我遇到了一些 Linux 專屬錯誤,原因是編譯器版本不同,但我可以使用 gdb 輕鬆解決這些錯誤。

結論

這類遊戲可以使用 Flash 或 Unity 製作,可帶來許多優勢。不過,這個版本不需要外掛程式,而且 HTML5 和 CSS3 的版面配置功能也非常強大。針對每項工作使用適當的工具絕對很重要。我個人很驚訝,這個完全以 HTML5 製作的遊戲竟然能達到如此水準,雖然它在許多方面仍有不足之處,但我期待看到它日後的發展。