使用 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对等体可以通信之前,它们需要执行相对复杂的握手才能连接,这需要多个第三方实体(一个信令服务器和一个或多个服务器) STUN/).

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

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

最低 WebRTC

WebRTC 是浏览器中可用的一组 API,可提供音频、视频和任意数据的点对点传输。

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

哇! 一次有多少个缩写? 让我们简要解释一下这些术语的含义:

  • 用于 NAT 的会话遍历实用程序 (STUN) — 绕过 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上。 加入我们频道的对话: !

来源: habr.com

添加评论