WebRTC 数据通道,打造精彩的多人游戏

WebRTC 近来因其在语音和视频通信领域的惊人 应用而备受关注。但你是否知道,WebRTC 还支持点对点数据传输呢?接下来我会介绍数据通道的“是什么”和“怎么做”,然后向你展示如何在BananaBread中使用数据通道实现点对点多人游戏。

在 Mozilla 多伦多办公室,一个周五下午,使用 WebRTC 进行 5 人 BananaBread 游戏的实况录像。

浏览器注意事项

如果你想使用 WebRTC 数据通道,你需要使用最新的Firefox NightlyChrome Canary。BananaBread 多人游戏需要 WebRTC 数据通道的二进制消息支持,而 Chrome 目前还没有实现该功能。

什么是数据通道?

WebRTC 数据通道允许你在活动对等连接上发送文本或二进制数据。数据通道有两种类型。可靠通道可以保证你发送的消息已经到达另一方。如果你发送多个可靠消息,它们将按顺序到达。你也可以发送不可靠消息,这些消息无法保证按顺序到达,甚至可能根本无法到达。它们类似于 TCP 和 UDP。

创建和使用数据通道

不幸的是,建立对等连接和创建数据通道需要相当复杂的工作量。使用库来处理这些工作,并抽象化 Firefox 和 Chrome 之间的一些实现细节是十分合理的。

这里使用的库是p2p。它提供了一个简单的 API 用于建立对等连接,并设置流和数据通道。它还提供了一个代理服务器组件和一个托管代理,你可以使用它,而不是自己设置一个。

使用 p2p

你可以从一个简单的网页开始。类似这样的代码就足够了






在一个简单的配置中,一个对等体充当主机,监听其他想要连接的对等体。

/* This is the address of a p2p broker server, possibly the example one at http://mdsw.ch:8080 */
var broker = "http://";

/* We'll use this to store any active connections so we can get to them later. */
var connections = {};

/* This creates an object that will handle let us listen for incoming connections. */
var peer = new Peer(broker, {video: false, audio: false});

/* This is invoked whenever a new connection is established. */
peer.onconnection = function(connection) {
  connections[connection.id] = connection;

  connection.ondisconnect = function() {
    delete connections[connection.id];
  };

  connection.onmessage = function(label, msg) {
    console.log(msg.data);
  };
};

/* This is called when your peer has received a routing address from the broker.
   The route is what lets other peers send messages through the broker that are used to
   establish the peer-to-peer connection. */
peer.onroute = function(route) {
  console.log(route);
}

/* This tells the broker that this peer is interested in hosting. */
peer.listen();

传递到 onconnection 的连接对象包含两个数据通道,分别标记为 reliableunreliable。当连接接收到消息时,标签以及数据会一起传递到 onmessage 函数中。

如果你的对等体是主机,那么捕获由代理分配的路由地址是很有用的。另一个对等体需要代理 URL 和路由地址才能连接到你的对等体。

最后,连接对象还公开了本地和远程流,如果你想发送视频或音频,你可以使用它们。

  connection.streams['local'];
  connection.streams['remote'];

如果你的对等体正在连接到另一个对等体,代码与上面相同,只是你需要调用 connect 而不是 listen

  /* Call this with the routing address that the host received from the broker. */
  peer.connect();

Emscripten 的套接字

如果你不熟悉Emscripten,你需要了解它可以将 C++ 库和程序编译成 JavaScript,从而使它们可以在浏览器中运行。我们正是用它将Sauerbraten变成了BananaBread

Sauerbraten 内置了多人游戏支持,它依赖于 POSIX 套接字,而 POSIX 套接字的工作方式与 WebRTC 对等连接截然不同。使用套接字的 C++ 程序期望通过向套接字 API 提供 IP 地址和端口号以及包含一些任意数据的缓冲区来与远程主机通信。BananaBread 特别只调用四种 API 函数:socket、bind、recvmsg 和 sendmsg。

每次调用 socket 时,我们都会创建一个新的 JS 对象来保存地址、端口和消息队列。队列很重要,因为我们需要保存从数据通道接收到的消息,以便稍后由应用程序处理,应用程序会调用 recvmsg。这里还有一些空间用于构建一个小标题,我们将用于发送消息。

由于我们使用的是相同的 p2p 库,所以创建新对等体的代码与之前相同,只是事件处理程序不同。以下是 onconnection 的代码

peer.onconnection = function(connection) {
  var addr;
  // Assign 10.0.0.1 to the host
  if(route && route === connection['route']) {
    addr = 0x0100000a; // 10.0.0.1
  } else {
    addr = Sockets.addrPool.shift();
  }
  connection['addr'] = addr;
  Sockets.connections[addr] = connection;

  connection.ondisconnect = function() {
    // Don't return the host address (10.0.0.1) to the pool
    if(!(route && route === Sockets.connections[addr]['route']))
      Sockets.addrPool.push(addr);
    delete Sockets.connections[addr];
  };

  connection.onmessage = function(label, message) {
    var header = new Uint16Array(message, 0, 2);
    if(Sockets.portmap[header[1]]) {
      /* The queued message is retrived later when the program calls recvmsg. */
      Sockets.portmap[header[1]].inQueue.push([addr, message]);
    }
  }

Sockets.addrPool 是一个包含可用 IP 地址的列表,我们可以将其分配给新的连接。地址用于在 C++ 程序想要发送或接收数据时找到正确的活动连接。

socket: function(family, type, protocol) {
  var fd = Sockets.nextFd ++;
  Sockets.fds[fd] = {
    addr: undefined,
    port: undefined,
    inQueue: [],
    header: new Uint16Array(2),
  };
  return fd;
};

当程序想要监听某个端口时,会直接调用 bind,而当使用未绑定套接字(以便可以在套接字上调用 recvmsg 并且远程主机可以发送回复)时,会间接调用 bind。在后一种情况下,我们可以给套接字分配任何未使用的端口。我们不需要担心 IP 地址。

bind: function(fd, addr, addrlen) {
  var info = Sockets.fds[fd];
  if (!info) return -1;
  if(addr) {
    /* The addr argument is actually a C++ pointer, so we need to read the value from the Emscripten heap. */
    info.port = _ntohs(getValue(addr + Sockets.sockaddr_in_layout.sin_port, 'i16'));
  } else {
    /* Finds and returns an unused port. */
    info.port = _mkport();
  }

  /* We might need to pass the local address to C++ code so we should give it a meaningful value. */
  info.addr = 0xfe00000a; // 10.0.0.254

  /* This is used to find the socket associated with a port so we can deliver incoming messages. */
  Sockets.portmap[info.port] = info;
};

对于 sendmsg,我们需要找到与给定 IP 地址关联的套接字。我们还需要在消息缓冲区之前添加一个小标题,其中包含目标端口(以便远程主机可以传递消息)和源端口(以便远程主机可以将回复发送给消息)。Recvmsg 与 sendmsg 非常类似。

(请注意,读取和写入 msg 参数数据的代码被省略了,因为它比较复杂,
并且没有提供更多信息。)

sendmsg: function(fd, msg, flags) {
  var info = Sockets.fds[fd];
  if (!info) return -1;

  /* Here's where we bind to an unused port if necessary. */
  if(!info.port) {
    bind(fd);
  }

  /* The next three lines retrieve the destination address and port from the msg argument. */
  var name = {{{ makeGetValue('msg', 'Sockets.msghdr_layout.msg_name', '*') }}};
  var port = _ntohs(getValue(name + Sockets.sockaddr_in_layout.sin_port, 'i16'));
  var addr = getValue(name + Sockets.sockaddr_in_layout.sin_addr, 'i32');

  var connection = Sockets.connections[addr];
  if (!(connection && connection.connected)) {
    /* Emscripten requires that all socket operations are non-blocking. */
    ___setErrNo(ERRNO_CODES.EWOULDBLOCK);
    return -1;
  }

  /* Copy the message data into a buffer so we can send it over the data channel. */
  var bytes = new Uint8Array();

  info.header[0] = info.port; // Source port
  info.header[1] = port; // Destination port

  /* Create a new array buffer that's big enough to hold the message bytes and the header. */
  var data = new Uint8Array(info.header.byteLength + bytes.byteLength);

  /* Copy the header and the bytes into the new buffer. */
  buffer.set(new Uint8Array(info.header.buffer));
  buffer.set(bytes, info.header.byteLength);

  connection.send('unreliable', buffer.buffer);
};
recvmsg: function(fd, msg, flags) {
  var info = Sockets.fds[fd];
  if (!info) return -1;

  /* There's no way to deliver messages to this socket if it doesn't have a port. */
  if (!info.port) {
    assert(false, 'cannot receive on unbound socket');
  }

  /* Similar to sendmsg, if there are no messages waiting we return instead of blocking. */
  if (info.inQueue.length() == 0) {
    ___setErrNo(ERRNO_CODES.EWOULDBLOCK);
    return -1;
  }

  var entry = info.inQueue.shift();
  var addr = entry[0];
  var message = entry[1];
  var header = new Uint16Array(message, 0, info.header.length);
  var bytes = new Uint8Array(message, info.header.byteLength);

  /* Copy the address, port and bytes into the msg argument. */

  return bytes.byteLength;
};

下一步

p2p 库和 Emscripten 的套接字都是为支持 BananaBread 多人游戏而创建的,因此它们都缺少一些功能,这些功能对于构建其他应用程序很有用。具体来说,

  • 在 p2p 库中添加对基于对等体的代理的支持(以便连接的对等体可以为彼此代理新的连接)
  • 在 Emscripten 中添加对基于 WebRTC 的面向连接和可靠套接字的支持

如果你正在使用它构建一些很酷的东西,或者你想构建一些东西,但有疑问,请随时在Twitter上或在irc.mozilla.org的 #games 频道中问我。

关于 ack

我的名字是 Alan。我是一名计算机科学家和黑客。我在 Mozilla 工作,负责各种平台项目。目前我专注于改进 Web 上的高性能实时应用(游戏!)。我所做的很多事情都围绕着软件、硬件和设计的开源展开。

ack 撰写的更多文章…

关于 Robert Nyman [名誉编辑]

Mozilla Hacks 的技术布道师和编辑。发表有关 HTML5、JavaScript 和开放 Web 的演讲和博客文章。Robert 是 HTML5 和开放 Web 的坚定支持者,自 1999 年以来一直在从事 Web 前端开发工作,曾在瑞典和纽约市工作。他还定期在http://robertnyman.com上发布博客文章,喜欢旅行和结识新朋友。

Robert Nyman [名誉编辑] 撰写的更多文章…


5 条评论

  1. Sebastián

    太棒了!

    现在问题解决了:我一直都在研究用 js 开发多人 2D 射击游戏的想法,到目前为止,WebSockets 似乎是唯一的选择,但它也并不理想,因为可靠连接和实时交互会带来很多问题。

    但现在看到这个,我对我自己的目标又燃起了希望。不过我有一些问题:

    1. 有没有关于稳定版本何时发布的消息?(caniuse 上甚至还没有列出它)
    2. 有没有关于 WebSockets 和 WebRTC 数据通道的平均延迟和丢包率的基准测试数据?

    2013 年 3 月 31 日 下午 11:48

    1. ack

      感谢您的问题!

      针对您的问题:

      1. 我猜您是想问这将在 Firefox 的稳定版本中何时可用?目前计划是在 Firefox 22 中发布(参见此处:https://bugzilla.mozilla.org/show_bug.cgi?id=855623

      2. BananaBread 多人游戏在本地连接上的延迟大约为 100 毫秒,所以这可以提供一些参考。我预计未来会有一些性能改进。至于丢包率,我还没有测量过。

      ack

      2013 年 4 月 2 日 下午 12:55

  2. Mark

    当我第一次听说 WebRTC 数据通道时,我以为它们非常适合多人游戏。看到这个真是太棒了。

    我感觉 WebRTC 的目标并非客户端/服务器(与点对点相对),但它似乎是一个合理的用例。

    如何为像 BrowserQuest 这样的具有集中式服务器的游戏提供支持?有没有 WebRTC 的实现[正在开发中],它们不是基于浏览器的?

    2013 年 4 月 7 日 上午 09:06

    1. Mark

      我在 Twitter 上找到了这个问题的答案:

      “目前还没有。我正在关注这个问题,这可能需要很多工作量。”

      https://twitter.com/modeswitch/status/318518194191077378

      2013 年 4 月 7 日 上午 10:50

    2. ack

      尽管 WebRTC 是点对点的,但你可以将客户端/服务器视为一种特殊情况,其中一个对等体充当服务器,其他对等体充当客户端。BananaBread 多人游戏就是这么做的。BananaBread 的构建过程还会编译服务器并为它生成一个(非常简单的)网页。如果你想要一个专用服务器,可以尝试在无头浏览器会话中运行服务器代码。

      目前,如果你想使用 WebRTC,就必须在 Firefox 或 Chrome 中运行你的代码;不过我预计随着时间的推移,其他引擎和语言(如 Node 或 Python)的库将会出现。

      2013 年 4 月 8 日 下午 16:14

本文评论已关闭。