2014 年哈比人體驗

在《霍比特人》體驗中加入 WebRTC 遊戲

Daniel Isaksson
Daniel Isaksson

為了配合《霍比特人:五軍之戰》上映,我們延續去年的 Chrome 實驗,推出了《霍比特人:五軍之戰》的全新內容。隨著越來越多瀏覽器和裝置能查看內容,並支援 Chrome 和 Firefox 的 WebRTC 功能,這次的重點是擴大 WebGL 的使用範圍。今年的實驗共有三個目標:

  • 在 Android 版 Chrome 上使用 WebRTC 和 WebGL 進行對戰遊戲
  • 製作以觸控輸入為基礎的多人遊戲,讓玩家輕鬆上手
  • 在 Google Cloud Platform 上代管

定義遊戲

遊戲邏輯是建立在以格狀為基礎的設定上,部隊會在遊戲版圖上移動。這樣就能輕鬆在紙上進行遊戲玩法定義規則。使用以格線為基礎的設定也有助於在遊戲中偵測碰撞,維持良好效能,因為您只需檢查與同一個或相鄰區塊中的物件碰撞。我們一開始就知道,我們希望新遊戲的焦點是圍繞著中土世界四大勢力 (人類、矮人、精靈和半獸人) 之間的戰鬥。遊戲也必須足夠休閒,才能在 Chrome 實驗中進行,且不含太多需要學習的互動。我們先在中土地圖上定義五個戰鬥場,做為遊戲的室,讓多名玩家可以在點對點戰鬥中一較高下。在行動裝置螢幕上顯示房間內的多名玩家,並讓使用者選擇要挑戰的對象,本身就是一項挑戰。為了讓互動和場景更簡單,我們決定只提供一個按鈕來發起和接受挑戰,並只使用房間來顯示事件和目前的王者。這項方向也解決了幾個配對方面的問題,讓我們能夠為對戰配對最佳候選者。我們在先前的 Chrome 實驗 Cube Slam 中發現,如果遊戲的結果仰賴玩家,就必須花費大量心力處理這類遊戲的延遲情形。您必須不斷假設對手的狀態,以及對手認為您處於何種狀態,並將這些資訊與不同裝置上的動畫同步。這篇文章將更詳細說明這些挑戰。為了讓遊戲更容易上手,我們將遊戲改為回合制。

遊戲邏輯是建立在以格狀為基礎的設定上,部隊會在遊戲版圖上移動。這樣就能輕鬆在紙上進行遊戲玩法定義規則。使用以格線為基礎的設定也有助於在遊戲中偵測碰撞,維持良好效能,因為您只需檢查與同一個或相鄰方塊中的物件碰撞。

遊戲的組成元素

為了製作這類多人對戰遊戲,我們必須建構以下幾個主要部分:

  • 伺服器端玩家管理 API 會處理使用者、配對、工作階段和遊戲統計資料。
  • 伺服器:協助建立玩家之間的連線。
  • 這個 API 可處理 AppEngine Channels API 信號,用於連線並與遊戲室中的所有玩家進行通訊。
  • JavaScript 遊戲引擎,可處理兩個玩家/對等端之間的狀態和 RTC 訊息同步作業。
  • WebGL 遊戲檢視畫面。

播放器管理

為了支援大量玩家,我們會為每個戰場使用許多並行的遊戲室。限制每個遊戲房間的玩家人數,主要目的是讓新玩家在合理的時間內登上排行榜。此限制也與描述透過 Channel API 傳送的遊戲室的 JSON 物件大小相關,該大小限制為 32 KB。我們必須在遊戲中儲存玩家、房間、分數、工作階段,以及這些項目之間的關係。為此,我們首先使用 NDB 處理實體,並使用查詢介面處理關係。NDB 是 Google Cloud Datastore 的介面。NDB 最初的運作效果不錯,但我們很快就發現我們需要如何使用這項功能。查詢會針對資料庫的「已提交」版本執行 (這篇深入文章詳細說明 NDB 寫入作業),可能會延遲幾秒。但實體本身不會有這類延遲,因為它們會直接從快取中回應。透過程式碼範例說明可能會比較容易理解:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

在新增單元測試後,我們可以清楚看到問題,並且改為在 memcache 中以半形逗號分隔的清單形式保留關係。這似乎有點像是駭客攻擊,但它確實有效,而且 AppEngine Memcache 有類似交易的系統,可使用出色的「比較並設定」功能處理金鑰,因此測試現在又通過了。

很遺憾,memcache 並非完美無缺,而是有一些限制,其中最值得注意的是 1 MB 的值大小 (無法有太多與戰場相關的房間) 和鍵值到期,或如說明文件所述:

我們確實考慮使用另一個很棒的鍵值儲存空間 Redis。不過,當時設定可擴充的叢集有點困難,而且我們更想專注於打造體驗,而不是維護伺服器,因此沒有採用這項做法。另一方面,Google Cloud Platform 最近發布了一項簡單的點擊部署功能,且其中一個選項是 Redis 叢集,使用起來會非常有趣。

最後,我們找到了 Google Cloud SQL,並將關係移至 MySQL。這項工作相當繁重,但最終結果非常出色,更新內容現在已完全原子化,且測試仍能通過。也讓配對和計分作業的實作更可靠。

隨著時間的推移,越來越多資料會從 NDB 和 memcache 遷移至 SQL,但一般來說,玩家、戰場和房間實體仍會儲存在 NDB 中,而會話方塊和這些實體之間的關係則會儲存在 SQL 中。

我們也必須追蹤玩家的對戰對象,並使用配對機制 (考量玩家技能等級和經驗) 將玩家配對。我們以開放原始碼程式庫 Glicko2 為基礎,進行配對。

由於這是多人遊戲,我們希望能向房間中的其他玩家通知「誰加入或離開」、「誰獲勝或輸掉」等事件,以及是否有挑戰可接受。為解決這個問題,我們在 Player Management API 中內建了接收通知的功能。

設定 WebRTC

當兩名玩家與對戰作戰時,系統會使用訊號服務讓兩位配對的同儕互相交談,並協助建立對等互連連線。

您可以使用幾個第三方程式庫做為信號服務,這也會簡化 WebRTC 設定。其中包括 PeerJSSimpleWebRTCPubNub WebRTC SDK。PubNub 使用代管伺服器解決方案,而我們希望這個專案在 Google Cloud Platform 上代管。其他兩個程式庫使用的是 node.js 伺服器,我們可以將其安裝在 Google Compute Engine 上,但也必須確保該伺服器能夠處理數千名同時使用者的連線,而這正是 Channel API 的強項。

在這種情況下,使用 Google Cloud Platform 的主要優勢之一就是可進行調整。您可以透過 Google Developers Console 輕鬆調整 App Engine 專案所需的資源,而且在使用 Channels API 時,無須額外調整信號傳遞服務。

我們有一些疑慮,想到延遲時間以及 Channel API 的穩健性,但先前我們曾將其用於 CubeSlam 專案,並證實此 API 能為該專案中的數百萬名使用者提供服務,因此我們決定再次使用這個 API。

由於我們選擇不使用第三方程式庫來協助 WebRTC,因此必須自行建構程式庫。幸運的是,我們可以重複使用 CubeSlam 專案的許多成果。當兩位玩家加入工作階段時,工作階段會設為「使用中」,且兩位玩家都會使用該工作階段 ID,透過 Channel API 啟動點對點連線。之後,兩名玩家之間的所有通訊都會透過 RTCDataChannel 處理。

我們也需要 STUN 和 TURN 伺服器,協助建立連線並處理 NAT 和防火牆。如要進一步瞭解如何設定 WebRTC,請參閱 HTML5 Rocks 文章「WebRTC 在真實世界中的應用:STUN、TURN 和信號傳遞」。

使用的 TURN 伺服器數量也必須能夠視流量而調整。為解決這個問題,我們測試了 Google Deployment Manager。這項功能可讓我們在 Google Compute Engine 上動態部署資源,並使用範本安裝 TURN 伺服器。這項功能仍處於 Alpha 版階段,但在我們的用途上,它運作得相當順暢。我們使用 coturn 做為 TURN 伺服器,這是一項非常快速、高效且似乎可靠的 STUN/TURN 實作。

Channel API

Channel API 可用於在用戶端傳送所有與遊戲聊天室相關的通訊。我們的 Player Management API 會使用 Channel API 傳送遊戲事件通知。

使用 Channels API 時遇到了一些問題。例如,由於訊息可沒有排序,因此我們必須將物件中的所有訊息納入,然後加以排序。以下是一些示例程式碼,說明運作方式:

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

此外,我們也想保留網站模組化的不同 API,與網站的代管區隔開來,並透過 GAE 內建的模組開始進行。很抱歉,在開發環境中完成所有設定後,我們發現 Channel API 在正式環境中無法與模組搭配運作。我們改為使用個別的 GAE 例項,但遇到 CORS 問題,因此不得不使用 iframe postMessage 橋接器

遊戲引擎

為了讓遊戲引擎盡可能具備動態性,我們使用實體-元件-系統 (ECS) 方法建構前端應用程式。在開始開發時,我們並未設定線框和功能規格,因此能夠在開發過程中新增功能和邏輯,對我們來說非常有幫助。舉例來說,第一個原型使用簡單的畫布轉譯系統,在格狀畫面中顯示實體。經過幾次迭代後,我們新增了碰撞系統和 AI 控制玩家系統。在專案進行期間,我們可以切換至 3D 轉譯器系統,而不需要變更其他程式碼。當網路部分啟用且執行 ai-system 後,您可以修改網頁,以便使用遠端指令。

因此,多人遊戲的基本邏輯是透過 DataChannels 將動作指令的設定傳送至其他對等端,讓模擬動作像是 AI 玩家一樣。此外,如果玩家按下傳球/攻擊按鈕,系統會使用邏輯判斷目前是哪一回合,並在玩家仍在觀看上一個動畫時,將命令加入佇列。

如果只有兩位使用者輪流,兩位使用者可以共同負責,在輪到對方時將輪次傳給對方,但這裡涉及第三方玩家。當我們需要新增蜘蛛和巨魔等敵人時,AI 系統又派上用場 (不只是測試)。為了讓這些元素符合回合制流程,必須在兩側產生並執行完全相同的元素。這個方法只要讓一個同儕控制這個輪流系統,並將目前狀態傳送給遠端對等裝置即可。接著,當輪到蜘蛛時,輪值管理員會讓 AI 系統建立指令,並傳送給遠端使用者。遊戲引擎只會對指令和實體 ID 採取行動,所以遊戲在兩側模擬相同。所有單位也都能使用 ai-component,方便進行自動化測試。

在開發初期,建議您使用較簡單的畫布轉譯器,並專注於遊戲邏輯。但真正有趣的是,當我們實作 3D 版本,並透過環境和動畫讓場景栩栩如生時,我們使用 three.js 做為 3D 引擎,而且架構可讓我們輕鬆達到可玩的狀態。

系統會更頻繁地將滑鼠位置傳送給遠端使用者,並透過 3D 燈光提供關於目前游標位置的微妙提示。