使用 Cheerp、WebRTC 和 Firebase 將多人遊戲從 C++ 移植到網絡

介紹

我們公司 學習技術 提供將傳統桌面應用程式移植到網路的解決方案。 我們的 C++ 編譯器 歡呼 產生 WebAssembly 和 JavaScript 的組合,它提供了兩者 簡單的瀏覽器交互和高性能。

作為其應用程式的範例,我們決定將多人遊戲移植到網路上並選擇 Teeworlds。 Teeworlds 是一款多人 XNUMXD 復古遊戲,擁有規模雖小但活躍的玩家社群(包括我!)。 它在下載資源以及 CPU 和 GPU 要求方面都很小,是理想的候選者。

使用 Cheerp、WebRTC 和 Firebase 將多人遊戲從 C++ 移植到網絡
在 Teeworlds 瀏覽器中執行

我們決定用這個專案來進行實驗 將網路程式碼移植到網路的通用解決方案。 這通常透過以下方式完成:

  • XMLHttp請求/取得,如果網路部分僅包含 HTTP 請求,或者
  • WebSockets的.

這兩種解決方案都需要在伺服器端託管伺服器元件,並且都不允許用作傳輸協定 UDP。 這對於視訊會議軟體和遊戲等即時應用非常重要,因為它保證了協議資料包的傳遞和順序 TCP 可能會成為低延遲的障礙。

還有第三種方法 - 從瀏覽器使用網路: 實現WebRTC.

RTC數據通道 它支援可靠和不可靠的傳輸(在後一種情況下,它盡可能使用 UDP 作為傳輸協定),並且可以在遠端伺服器和瀏覽器之間使用。 這意味著我們可以將整個應用程式移植到瀏覽器,包括伺服器元件!

然而,這帶來了一個額外的困難:在兩個WebRTC對等體可以通訊之前,它們需要執行相對複雜的握手才能連接,這需要多個第三方實體(一個信令伺服器和一個或多個伺服器) 特技/轉動).

理想情況下,我們希望建立一個在內部使用 WebRTC 的網路 API,但盡可能接近不需要建立連線的 UDP 套接字介面。

這將使我們能夠利用 WebRTC,而無需向應用程式程式碼公開複雜的細節(我們希望在專案中盡可能少地更改這些細節)。

最低 WebRTC

WebRTC 是瀏覽器中可用的一組 API,可提供音訊、視訊和任意資料的點對點傳輸。

對等點之間的連接是透過稱為 ICE 的機制使用 STUN 和/或 TURN 伺服器建立的(即使一側或兩側都有 NAT)。 節點透過 SDP 協定的提供和應答來交換 ICE 資訊和通道參數。

哇! 一次有幾個縮寫? 讓我們簡要地解釋一下這些術語的含義:

  • 用於 NAT 的會話遍歷實用程序 (特技) — 繞過 NAT 並取得一對(IP、連接埠)以直接與主機交換資料的協定。 如果他成功完成任務,那麼同伴之間就可以獨立交換資料。
  • 使用 NAT 周圍的中繼進行遍歷 (轉動) 也用於 NAT 穿越,但它是透過對等方都可見的代理轉發資料來實現的。 它增加了延遲,並且實現起來比 STUN 更昂貴(因為它應用於整個通訊會話),但有時它是唯一的選擇。
  • 交互式連接建立 (ICE) 用於根據直接從連接對等點獲取的資訊以及任意數量的 STUN 和 TURN 伺服器接收到的資訊來選擇連接兩個對等點的最佳可能方法。
  • 會話描述協議 (SDP) 是一種描述連接通道參數的格式,例如 ICE 候選、多媒體編解碼器(在音訊/視訊通道的情況下)等。其中一個對等方發送 SDP Offer,第二個對等方以 SDP Answer 回應.. 此後,將創建一個通道。

為了建立這樣的連接,對等方需要收集從 STUN 和 TURN 伺服器接收到的資訊並相互交換。

問題是它們還不具備直接通訊的能力,因此必須存在帶外機制來交換這些資料:信令伺服器。

信令伺服器可以非常簡單,因為它的唯一工作是在握手階段在對等點之間轉發資料(如下圖所示)。

使用 Cheerp、WebRTC 和 Firebase 將多人遊戲從 C++ 移植到網絡
簡化的WebRTC握手序列圖

Teeworlds 網路模型概述

Teeworlds的網路架構非常簡單:

  • 客戶端和伺服器元件是兩個不同的程式。
  • 客戶端透過連接到多個伺服器之一進入遊戲,每個伺服器一次僅託管一場遊戲。
  • 遊戲中的所有資料傳輸都是透過伺服器進行的。
  • 一個特殊的主伺服器用於收集遊戲客戶端中顯示的所有公共伺服器的清單。

由於使用WebRTC進行資料交換,我們可以將遊戲的伺服器部分轉移到客戶端所在的瀏覽器。 這給了我們一個很好的機會...

擺脫伺服器

缺乏伺服器邏輯有一個很好的優勢:我們可以將整個應用程式作為靜態內容部署在 Github Pages 上或 Cloudflare 背後的我們自己的硬體上,從而確保免費的快速下載和高正常運行時間。 事實上,我們可以忘記它們,如果我們幸運並且遊戲變得流行,基礎設施就不必現代化。

然而,為了讓系統正常運作,我們仍然需要使用外部架構:

  • 一台或多台 STUN 伺服器:我們有多種免費選項可供選擇。
  • 至少一台 TURN 伺服器:這裡沒有免費選項,因此我們可以設定自己的服務或付費服務。 幸運的是,大多數時候可以透過 STUN 伺服器建立連線(並提供真正的 p2p),但需要 TURN 作為後備選項。
  • 信令伺服器:與其他兩個方面不同,訊號不是標準化的。 信令伺服器實際負責的任務在某種程度上取決於應用程式。 在我們的例子中,在建立連線之前,需要交換少量資料。
  • Teeworlds 主伺服器:其他伺服器使用它來宣傳它們的存在,客戶端使用它來尋找公共伺服器。 雖然這不是必需的(客戶端始終可以手動連接到他們知道的伺服器),但如果玩家可以與隨機的人一起參與遊戲,那就太好了。

我們決定使用Google的免費STUN伺服器,並自行部署一台TURN伺服器。

對於最後兩點我們使用 火力地堡:

  • Teeworlds 主伺服器的實作非常簡單:作為包含每個活動伺服器資訊(名稱、IP、地圖、模式等)的物件清單。 伺服器發布並更新自己的對象,客戶端獲取整個清單並將其顯示給玩家。 我們還在主頁上以 HTML 形式顯示列表,以便玩家只需單擊伺服器即可直接進入遊戲。
  • 訊號與我們的套接字實現密切相關,下一節將對此進行描述。

使用 Cheerp、WebRTC 和 Firebase 將多人遊戲從 C++ 移植到網絡
遊戲內和主頁上的伺服器列表

套接字的實現

我們希望創建一個盡可能接近 Posix UDP 套接字的 API,以最大程度地減少所需的更改數量。

我們也希望實現網路上最簡單的資料交換所需的最低限度。

例如,我們不需要真正的路由:所有對等點都位於與特定 Firebase 資料庫實例關聯的相同「虛擬 LAN」上。

因此,我們不需要唯一的IP位址:唯一的Firebase鍵值(類似網域名稱)足以唯一地標識對等體,每個對等體在本地為每個需要轉換的鍵分配「假」IP位址。 這完全消除了全域 IP 位址分配的需要,這是一項重要的任務。

這是我們需要實現的最小 API:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

該 API 很簡單,與 Posix Sockets API 類似,但有一些重要的差異: 記錄回調、分配本機 IP 和惰性連接.

註冊回調

即使原始程式使用非阻塞 I/O,程式碼也必須重構才能在 Web 瀏覽器中運作。

原因是瀏覽器中的事件循環對程式(無論是 JavaScript 或 WebAssembly)是隱藏的。

在原生環境中我們可以這樣寫程式碼

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

如果事件循環對我們來說是隱藏的,那麼我們需要將其變成這樣:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

本地IP分配

我們「網路」中的節點 ID 不是 IP 位址,而是 Firebase 鍵(它們是如下所示的字串: -LmEC50PYZLCiCP-vqde ).

這很方便,因為我們不需要分配 IP 和檢查其唯一性(以及在客戶端斷開連接後處理它們)的機制,但通常需要透過數值來識別對等點。

這正是這些函數的用途。 resolve и reverseResolve:應用程式以某種方式接收金鑰的字串值(透過使用者輸入或透過主伺服器),並可以將其轉換為 IP 位址以供內部使用。 為了簡單起見,API 的其餘部分也接收此值而不是字串。

這與 DNS 查找類似,但在用戶端本地執行。

也就是說,IP 位址不能在不同客戶端之間共用,如果需要某種全域標識符,則必須以不同的方式產生。

惰性連接

UDP 不需要連接,但正如我們所見,WebRTC 需要一個漫長的連接過程才能開始在兩個對等點之間傳輸資料。

如果我們想提供相同層次的抽象,(sendto/recvfrom 與沒有事先連接的任意對等點),那麼它們必須在 API 內執行「惰性」(延遲)連線。

這是使用 UDP 時「伺服器」和「客戶端」之間正常通訊時發生的情況,以及我們的程式庫應該要做的事情:

  • 伺服器調用 bind()告訴作業系統它想要在指定連接埠上接收資料包。

相反,我們將在伺服器金鑰下向 Firebase 發布一個​​開放端口,並偵聽其子樹中的事件。

  • 伺服器調用 recvfrom(),接受來自該連接埠上任何主機的資料包。

在我們的例子中,我們需要檢查發送到該連接埠的封包的傳入佇列。

每個連接埠都有自己的佇列,我們將來源連接埠和目標連接埠新增至 WebRTC 資料封包的開頭,以便我們知道當新封包到達時要轉送到哪個佇列。

該呼叫是非阻塞的,因此如果沒有資料包,我們只需返回 -1 並設置 errno=EWOULDBLOCK.

  • 客戶端透過某種外部方式接收伺服器的IP和端口,並調用 sendto()。 這也會進行內部呼叫。 bind(),因此隨後的 recvfrom() 將在不明確執行綁定的情況下接收回應。

在我們的例子中,客戶端從外部接收字串金鑰並使用該函數 resolve() 取得IP位址。

此時,如果兩個對等點尚未相互連接,我們將啟動 WebRTC 握手。 與同一對等點的不同連接埠的連線使用相同的 WebRTC DataChannel。

我們也進行間接 bind()以便伺服器下次可以重新連接 sendto() 以防因某種原因關閉。

當用戶端在 Firebase 中的伺服器連接埠資訊下寫入其 SDP Offer 時,伺服器會收到用戶端連線的通知,且伺服器會在其中回應其回應。

下圖顯示了套接字方案的訊息流範例以及第一則訊息從客戶端到伺服器的傳輸:

使用 Cheerp、WebRTC 和 Firebase 將多人遊戲從 C++ 移植到網絡
客戶端與伺服器連線階段完整圖

結論

如果您已經讀到這裡,您可能有興趣了解理論的實際應用。 該遊戲可以在 teeworlds.leaningtech.com, 嘗試一下!


同事之間的友誼賽

網路庫程式碼可免費取得 Github上。 加入我們頻道的對話: !

來源: www.habr.com

添加評論