我们使用Cheerp,WebRTC和Firebase将多人游戏从C ++移植到Web

引言


我们公司Leaning Technologies提供了用于将传统桌面应用程序移植到Web的解决方案。 我们的C ++ Cheerp编译器生成WebAssembly和JavaScript的组合,从而提供了便捷的浏览器交互和高性能。

作为其应用的示例,我们决定为网络移植多人游戏,并为此选择了Teeworlds 。 Teeworlds是一款多人,二维复古游戏,拥有一个虽小却活跃的玩家社区(包括我!)。 就可下载资源以及CPU和GPU需求而言,这都是很小的选择-是理想的选择。


在Teeworlds浏览器中工作

我们决定使用该项目来尝试将网络代码移植到Web的一般解决方案 。 通常通过以下方式完成此操作:

  • 如果网络部分仅由HTTP请求组成,则为XMLHttpRequest / fetch
  • Web套接字

两种解决方案都需要在服务器端托管服务器组件,并且都不允许您使用UDP作为传输协议。 这对于诸如视频会议和游戏软件之类的实时应用非常重要,因为传递保证和TCP数据包排序可能会干扰低延迟。

第三种方法是通过浏览器使用网络: WebRTC

RTCDataChannel支持可靠和不可靠的传输(在后一种情况下,如果可能,它尝试使用UDP作为传输协议),并且可以与远程服务器以及浏览器之间使用。 这意味着我们可以将整个应用程序移植到浏览器,包括服务器组件!

但是,这是另一个难题:两个WebRTC对等方可以交换数据之前,它们需要执行相对复杂的握手过程进行连接,这需要多个第三方实体(信号服务器和一个或多个STUN / TURN服务器)。

理想情况下,我们想在内部使用WebRTC创建网络API,但要尽可能靠近不需要建立连接的UDP套接字接口。

这将使我们能够利用WebRTC,而无需向应用程序代码公开复杂的细节(我们希望在项目中尽可能少地更改这些细节)。

最低WebRTC


WebRTC是可在浏览器中使用的API套件,可提供对等音频,视频和任意数据传输。

使用STUN和/或TURN服务器通过称为ICE的机制在对等方之间建立连接(即使在一侧或两侧都有NAT)。 对等方通过SDP提供和应答协议交换ICE信息和通道参数。

哇! 一次多少个缩写。 让我们简要解释一下这些概念的含义:

  • NAT的会话遍历实用程序STUN -一种协议,用于绕过NAT并接收用于直接与主机交换数据的对(IP,端口)。 如果他设法完成任务,则同级可以独立地彼此交换数据。
  • 使用围绕NAT的中继进行遍历TURN也可以绕过NAT,但这是通过通过对双方均可见的代理重定向数据来实现的。 它比STUN增加了延迟,并且执行成本更高(因为在整个通信会话中都使用了STUN),但是有时这是唯一可能的选择。
  • 交互式连接建立ICE用于根据通过直接连接对等端获得的信息以及任意数量的STUN和TURN服务器接收的信息来选择连接两个对等端的最佳方法。
  • 会话描述协议SDP是一种用于描述连接通道参数的格式,例如ICE候选者,多媒体编解码器(在音频/视频通道的情况下)等。...一个对等方发送SDP提议(“要约”),第二个对等方以SDP进行响应回答(“响应”)。 之后,将创建一个通道。

为了建立这样的连接,对等方需要收集从STUN和TURN服务器接收到的信息并相互交换信息。

问题在于它们还没有直接交换数据的能力,因此必须有一种带外机制来交换此数据:信号服务器。

信号服务器可能非常简单,因为它的唯一任务是在“握手”阶段重定向对等方之间的数据(如下图所示)。


WebRTC简化的握手序列

Teeworlds网络模型概述


Teeworlds的网络架构非常简单:

  • 客户端和服务器组件是两个不同的程序。
  • 客户端通过连接到多个服务器之一进入游戏,每个服务器一次仅托管一个游戏。
  • 游戏中的所有数据传输都是通过服务器进行的。
  • 特殊的主服务器用于收集游戏客户端中显示的所有公共服务器的列表。

由于使用WebRTC进行数据交换,因此我们可以将游戏的服务器组件传输到客户端所在的浏览器。 这给我们带来了很大的机会...

摆脱服务器


缺乏服务器逻辑有一个很好的优势:我们可以将整个应用程序作为静态内容部署在Github Pages或Cloudflare后面的我们自己的设备上,从而确保快速下载和免费的正常运行时间。 实际上,我们可以忘记它们,如果我们很幸运并且游戏变得流行,那么就不必对基础架构进行现代化。

但是,要使系统正常工作,我们仍然必须使用外部体系结构:

  • 一台或多台STUN服务器:我们提供几种免费选项供您选择。
  • 至少有一个TURN服务器:这里没有免费选项,因此我们可以设置我们自己的服务或付费。 幸运的是,大多数时候可以通过STUN服务器建立连接(并提供真正的p2p),但是需要TURN作为后备。
  • 信号服务器:与其他两个方面不同,信号不是标准化的。 信号服务器实际负责的工作在某种程度上取决于应用程序。 在我们的情况下,在建立连接之前,必须交换少量数据。
  • Teeworlds主服务器:其他服务器使用它来通知它的存在,客户端可以使用它来搜索公共服务器。 尽管这不是必需的(客户端始终可以手动连接到他们知道的服务器),但最好还是拥有它,以便玩家可以与随机的人一起玩游戏。

我们决定使用Google的免费STUN服务器,并自行部署了一台TURN服务器。

对于最后两点,我们使用了Firebase

  • Teeworlds主服务器的实现非常简单:作为包含每个活动服务器的信息(名称,IP,地图,模式等)的对象列表。 服务器发布并更新自己的对象,客户端获取整个列表并将其显示给播放器。 我们还将列表以HTML格式显示在主页上,以便玩家只需单击服务器即可直接进入游戏。
  • 信令与我们的套接字实现紧密相关,下一节将对此进行介绍。


游戏内和首页上的服务器列表

套接字实现


我们希望创建一个尽可能接近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套接字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分配


我们“网络”中节点的标识符不是IP地址,而是Firebase密钥(这些行看起来像这样: -LmEC50PYZLCiCP-vqde )。

这很方便,因为我们不需要分配IP并检查其唯一性(以及断开客户端连接后的处置)的机制,但是通常需要通过数字值来识别对等体。

为此,使用了resolvereverseResolve函数:应用程序以某种方式(通过用户输入或通过主服务器)获取密钥的字符串值,并将其转换为IP地址以供内部使用。 为了简单起见,API的其余部分也将获取此值而不是字符串。

这类似于DNS查找,仅在客户端本地执行。

也就是说,不能在不同的客户端之间共享IP地址,如果需要某种全局标识符,则必须以不同的方式生成它。

懒混


UDP不需要连接,但是,正如我们看到的,在开始两个对等点之间的数据传输之前,WebRTC需要很长的连接过程。

如果我们想提供相同级别的抽象(无需先连接即可与任意对等体发送到sendto / recvfrom ),那么我们必须在API内建立一个“惰性”(延迟)连接。

在使用UDP的情况下,这是在“服务器”和“客户端”之间进行常规数据交换期间发生的事情,以及我们的库应该做什么:

  • 服务器调用bind()告诉操作系统它想接收到指定端口的数据包。

相反,我们将在服务器密钥下的Firebase中发布开放端口,并在其子树中侦听事件。

  • 服务器调用recvfrom() ,接受从任何主机到此端口的数据包。

在我们的情况下,我们需要检查发送到该端口的数据包的传入队列。

每个端口都有自己的队列,我们​​在WebRTC数据报的开头添加源端口和目标端口,以了解在新数据包到达时要重定向的队列。

该调用是非阻塞的,因此,如果没有数据包,我们只需返回-1并设置errno=EWOULDBLOCK

  • 客户端通过某种外部方式接收服务器的IP和端口,并调用sendto() 。 另外, bind()执行对bind()的内部调用,因此后续的recvfrom()将收到响应,而无需显式执行bind。

在我们的例子中,客户端从外部接收字符串密钥,并使用resolve()函数获取IP地址。

在这一点上,如果两个对等点尚未彼此连接,则我们开始WebRTC的“握手”。 与同一对等方的不同端口的连接使用相同的DataRannel WebRTC。

我们还执行间接bind()以便服务器由于某种原因sendto()可以在下一个sendto()中重新连接。

当客户端在Firebase中的服务器端口信息下写入其SDP报价时,服务器将收到客户端连接的通知,并且服务器以自己的响应进行响应。



下图显示了套接字方案的消息移动以及第一条消息从客户端到服务器的传输示例:


客户端和服务器之间的完整连接步骤图

结论


如果您已读完文章,那么您可能会对研究实际的理论感兴趣。 可以在teeworlds.leaningtech.com上玩游戏,试试看!


同事之间的友好比赛

网络库代码可在Github上免费获得。 加入我们在Gitter上的频道聊天

Source: https://habr.com/ru/post/zh-CN468031/


All Articles