2014 年霍比特人经历

将 WebRTC 游戏玩法添加到霍比特人体验中

丹尼尔·伊萨克松
Daniel Isaksson

随着《霍比特人:五军之战》新上映的电影《霍比特人:五军之战》即将上映,我们决定在去年的 Chrome 实验《中土世界之旅》中增添一些新内容。这次的重点是扩大 WebGL 的使用范围,以便更多浏览器和设备查看内容,并在 Chrome 和 Firefox 中使用 WebRTC 功能。我们在今年的实验中设定了三个目标:

  • 在 Android 版 Chrome 中使用 WebRTC 和 WebGL 的点对点游戏内容
  • 制作一款易于玩且基于触控输入的多人游戏
  • 在 Google Cloud Platform 上托管

定义游戏

游戏逻辑基于网格设置构建,部队在游戏图板上移动。这让我们在制定规则时可以轻松地纸上演示游戏玩法。使用基于网格的设置还有助于在游戏中进行碰撞检测,以保持良好的性能,因为您只需检查与相同或相邻图块中的对象发生冲突的情况。我们从一开始就知道,这款游戏的重点是让中土世界、人类、矮人、精灵和半兽人四大主要力量展开战斗。此外,这款游戏必须足够随意,可以在 Chrome 实验中玩,并且没有太多互动需要学习。 我们首先在中土世界地图上定义了 5 个战场,这些战场用作游戏室,可让多位玩家在点对点对战中一较高下。 在移动设备的屏幕上展示房间中多位选手,并让用户选择与谁进行挑战,这本身就是一项挑战。为了让互动和场景变得更轻松,我们决定只有一个按钮用于挑战和接受,并且仅使用房间来展示活动以及谁是当前的山之王。这一方向还解决了配对方面的一些问题,使我们能够匹配最佳的战斗候选者。 在我们之前的 Chrome 实验 Cube Slam 中,我们发现如果游戏的结果依赖于延迟,那么要处理多人游戏中的延迟将需要花费大量精力。你不断地假设对手所处的状态、对手认为自己处于哪个状态,并将状态与不同设备上的动画同步。本文将详细介绍这些挑战。为了让操作更轻松,我们制作了这款回合制游戏。

游戏逻辑基于网格设置构建,部队在游戏图板上移动。这让我们在制定规则时可以轻松地纸上演示游戏玩法。使用基于网格的设置还有助于在游戏中进行碰撞检测,以保持良好的性能,因为您只需检查与相同或相邻图块中的对象发生冲突的情况。

游戏的组成部分

要制作这款多人游戏,我们必须构建几个关键部分:

  • 服务器端玩家管理 API 负责处理用户、配对、会话和游戏统计信息。
  • 帮助在玩家之间建立连接的服务器。
  • 用于处理 AppEngine Channels API 信号(用于与游戏室中的所有玩家联系和通信)的 API。
  • 一个 JavaScript 游戏引擎,用于处理状态同步和两个玩家/对等方之间的 RTC 消息传递。
  • WebGL 游戏视图。

玩家管理

为了支持大量玩家,每个战场都设有多个并行的游戏室。限制每个游戏房间的玩家人数的主要原因是,让新玩家能够在合理的时间内登顶排行榜。该限制还与用于描述通过 Channel API 发送的游戏室的 json 对象的大小有关,该对象的上限为 32kb。 我们必须存储球员、房间、得分、会话及其在游戏中的关系。为此,我们首先将 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 中以逗号分隔列表的形式保留这些关系。这听起来有点过分,但它是行的,App Engine 内存缓存具有一个类似事务的系统,该系统使用出色的“比较和设置”功能来处理键,因此现在测试再次通过。

遗憾的是,Memcache 并不完全是彩虹和独角兽,但有一些限制,最值得注意的是 1MB 值大小(不能有太多与战场相关的房间)和键到期时间,正如相关文档所解释的那样:

我们考虑过使用另一个很棒的键值对存储区 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 时无需执行额外的操作来扩展信号服务。

人们对延迟时间以及 Channels API 的强大程度有些担忧,但我们之前曾将它用于 CubeSlam 项目,并且事实证明它对该项目中的数百万名用户有效,因此我们决定再次使用它。

由于我们没有选择使用第三方库来帮助处理 WebRTC,因此我们不得不构建自己的库。幸运的是,我们可以重复利用为 CubeSlam 项目所做的大量工作。当两位玩家都已加入会话时,会话会被设为“活跃”,两位玩家都将使用该活跃会话 ID 通过 Channel API 发起点对点连接。之后,两个播放器之间的所有通信都将通过 RTCDataChannel 处理。

我们还需要 STUN 和 TURN 服务器来帮助建立连接并应对 NAT 和防火墙。如需详细了解如何设置 WebRTC,请参阅 HTML5 Rocks 文章实际应用 WebRTC:STUN、TURN 和信号

使用的 TURN 服务器数量还必须能够根据流量进行扩缩。为了解决此问题,我们测试了 Google Deployment Manager。它让我们能够在 Google Compute Engine 上动态部署资源,并使用模板安装 TURN 服务器。但目前仍为 Alpha 版,但就我们的使用而言,一切都没有问题。对于 TURN 服务器,我们使用 coturn,这是一种快速、高效且看似可靠的 STUN/TURN 实现。

通道 API

Channel API 用于在客户端向游戏室发送或从游戏室发送所有通信。我们的 Player Management API 使用 Channel API 来提供有关游戏活动的通知。

使用 Channel 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-system 创建一个命令并发送给远程用户。由于 game-engine 只是根据命令和 entity-id:s 执行操作,因此游戏两端的模拟将完全相同。此外,所有单元还可以包含 ai-component,从而实现轻松的自动化测试。

在开发之初,最好使用更简单的画布渲染程序,同时专注于游戏逻辑。不过,当 3D 版本实现后,场景和动画栩栩如生,就产生了真正的乐趣。我们使用 three.js 作为 3D 引擎,它采用这种架构,很容易获得可播放的状态。

系统会更频繁地向远程用户发送鼠标位置,并提供关于光标当前位置的 3D 灯光提示。