向《霍比特》体验添加 WebRTC 游戏
为了配合《霍比特人》系列新电影《霍比特人:五军之战》的上映,我们对去年的 Chrome 实验“霍比特人:中土之旅”进行了扩展,添加了一些新内容。此次主要重点是扩大 WebGL 的使用范围,让更多浏览器和设备能够查看内容,并使用 Chrome 和 Firefox 中的 WebRTC 功能。我们通过今年的实验设定了三个目标:
- 在 Android 版 Chrome 上使用 WebRTC 和 WebGL 进行点对点游戏
- 制作一款易于上手且基于触控输入的多人游戏
- 在 Google Cloud Platform 上托管
定义游戏
游戏逻辑基于基于网格的设置构建,其中部队在游戏板上移动。这让我们可以轻松地在确定规则时尝试纸上游戏玩法。使用基于网格的设置还有助于在游戏中进行碰撞检测,以保持良好的性能,因为您只需检查与同一或相邻图块中的对象发生碰撞的情况即可。我们从一开始就知道,我们希望新游戏的重点是中土世界、人类、矮人、精灵和半兽人这四大势力展开的对决。它还必须足够休闲,能够在 Chrome 实验中玩,并且没有太多的互动需要学习。 首先,我们在地图中定义了中土世界五个战场,这些战场可用作游戏房间,供多名玩家进行点对点对抗。 在移动设备的屏幕上展示会议室中的多名选手,并让用户选择挑战的玩家,这本身是一项挑战。为了简化互动和场景,我们决定只提供一个用于发起和接受挑战的按钮,并仅使用聊天室来显示事件和当前的“王者”。这种做法还解决了匹配方面的一些问题,让我们能够为对战匹配最佳人选。 在之前的 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 为键提供了类似事务的系统,并使用出色的“比较并设置”功能,因此现在测试再次通过。
遗憾的是,memcache 并非完美无缺,它存在一些限制,最值得注意的是值大小限制(与战场相关的房间不能太多)和密钥过期,如文档所述:
我们确实考虑过使用另一个出色的键值对存储库 Redis。但是,在设置可扩缩的集群时,有点令人望而生畏,由于我们更愿意专注于打造体验,而不是维护服务器,因此我们没有选择这条道路。另一方面,Google Cloud Platform 最近发布了一项简单的点击即可部署功能,其中一个选项就是 Redis 集群,因此这将是一个非常有趣的选项。
最后,我们找到了 Google Cloud SQL,并将这些关系移到了 MySQL 中。这项工作量很大,但最终取得了出色的成效,更新现在是完全原子化的,并且测试仍能通过。这也使得配对和记分的实施更加可靠。
随着时间的推移,更多数据已慢慢从 NDB 和 memcache 迁移到 SQL,但一般来说,玩家、战场和房间实体仍存储在 NDB 中,而会话和它们之间的关系则存储在 SQL 中。
我们还需要跟踪谁在玩,并使用考虑到玩家技能水平和经验的匹配机制配对玩家。我们的配对基于开源库 Glicko2。
由于这是一款多人游戏,因此我们希望向房间中的其他玩家告知“谁进入或离开了”“谁赢了或输了”等事件,以及是否有挑战可接受。为此,我们在 Player Management API 中内置了接收通知的功能。
设置 WebRTC
当两位玩家匹配完成战斗时,系统会使用信号服务让两个匹配的对等方互相交谈,并帮助启动对等连接。
有几个第三方库可用于信号服务,这些库也简化了 WebRTC 的设置。一些选项包括 PeerJS、SimpleWebRTC 和 PubNub 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 项目中使用过该 API,并且该 API 已被证明适用于该项目中的数百万用户,因此我们决定再次使用该 API。
由于我们没有选择使用第三方库来帮助实现 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 来发送有关游戏事件的通知。
使用 Channels 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-renderer-system,而无需更改其余代码。网络部分启动运行后,ai-system 可以修改为使用远程命令。
因此,多人游戏的基本逻辑是通过 DataChannel 将操作命令的配置发送给其他对等方,并让模拟操作像 AI 玩家一样。此外,还有逻辑来决定当前是哪一回合、玩家按下传球/攻击按钮时,如果有命令在玩家仍在观看上一个动画时传入,则将这些命令加入队列等。
如果只有两位用户轮流进行游戏,那么这两位玩家可以共享在完成对局后将轮次传递给对手的责任,但这里涉及到第三位玩家。当我们需要添加“蜘蛛”程序和“巨魔”等敌人时,AI 系统再次变得有用(不仅仅是为了测试)。为了使其适应基于回合的流程,必须在两端完全相同地生成和执行。为解决此问题,我们让一个对等方控制转弯系统,并将当前状态发送给远程对等方。然后,当轮到蜘蛛执行时,轮替管理器会让 AI 系统创建一个命令,并将其发送给远程用户。由于游戏引擎只会对命令和实体 ID 执行操作,因此游戏在两端的模拟结果将完全相同。所有单元还可以包含 ai-component,以便轻松进行自动化测试。
在开发初期,最好使用更简单的 Canvas 渲染程序,同时专注于游戏逻辑。但真正有趣的是,当我们实现 3D 版本并通过环境和动画让场景栩栩如生时。我们使用 three.js 作为 3D 引擎,而且由于架构的缘故,很容易达到可玩状态。
系统会更频繁地向远程用户发送鼠标位置,并提供关于光标所在位置的 3D 灯光提示。