HTML5-多人游戏开发-全-

HTML5 多人游戏开发(全)

原文:zh.annas-archive.org/md5/58B015FFC16EF0C30C610502BF4A7DA3

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到《使用 HTML5 开发多人游戏》。本书将教你如何开发支持多个玩家在同一游戏世界中互动的游戏,并如何执行网络编程操作以实现这样的系统。它涵盖了诸如 WebSockets 和 JavaScript 中的客户端和服务器端游戏编程,延迟减少技术以及处理来自多个用户的服务器查询等主题。我们将通过从头到尾开发两款实际的多人游戏来实现这一目标,并在此过程中还将教授 HTML5 游戏开发的各种主题。本书的目的是教会你如何使用 HTML5 为多个玩家创建游戏世界,他们希望通过互联网进行竞争或互动。

本书涵盖内容

第一章, 开始多人游戏编程,介绍了网络编程,重点是设计多人游戏。它通过引导你创建一个实时的井字棋游戏,来说明多人游戏开发的基本概念。

第二章, 设置环境,描述了 JavaScript 开发领域的最新技术,包括通过 Node.js 在服务器端使用 JavaScript。它还描述了当前的技术,以管理 JavaScript 的开发周期和资源管理工具,如 Npm、Bower、Grunt 等。

第三章, 实时喂养蛇,将现有的单人 Snake 游戏改造成具有使用先前描述的工具在同一游戏世界中与多个玩家一起玩的能力。还描述和演示了大厅、房间、匹配和处理用户查询的概念,为 Snake 游戏增加了功能。本章介绍了当今行业中最强大和广泛使用的 WebSocket 抽象——socket.io。

第四章, 减少网络延迟,教授了减少网络延迟的技术,以创建流畅的游戏体验。其中最常见的技术之一——客户端预测,被演示并应用到了前一章描述的 Snake 游戏中。游戏服务器代码也被更新,以提高性能,引入了第二个更新循环。

第五章, 利用前沿技术,描述了在网络平台上进行游戏开发所发现的令人兴奋的机会。它解释了 WebRTC、HTML5 的游戏手柄、全屏模式和媒体捕获 API。其他承诺和实验性技术和 API 也在此处描述。

第六章, 添加安全和公平游戏,涵盖了与网络游戏相关的常见缺陷和安全漏洞。在这里,描述和演示了常见的技术,使你能够开发提供无作弊游戏体验的游戏。

本书所需内容

要使用本书,你需要安装 Node.js 和 Npm,现代的网络浏览器(如 Google Chrome 5.0,Firefox 3.5,Safari 5.0 或 Internet Explorer 9.0 及更高版本),以及文本编辑器或集成开发环境(IDE)。你还需要基本到中级的 JavaScript 知识,以及一些先前的游戏编程经验,最好是 JavaScript 和 HTML5。

本书的受众

本书的目标读者是能制作基本单人游戏的 HTML5 游戏开发人员,他们现在想尽快学习如何在他们的 HTML5 游戏中快速加入多人游戏功能。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些示例以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“第一个将以action的值为键,第二个将以data的键为值。”

代码块设置如下:

wss.on('connection', function connection(ws) {
    board.on(Board.events.PLAYER_CONNECTED, function(player) {
        wss.clients.forEach(function(client) {
            board.players.forEach(function(player) {
                client.send(makeMessage(events.outgoing.JOIN_GAME, player));

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

validator.isEmail('foo@bar.com'); //=> true
validator.isBase64(inStr);
validator.isHexColor(inStr);
validator.isJSON(inStr);

任何命令行输入或输出都以以下方式编写:

npm install socket.io --save
npm install socket.io-client –save

注意

警告或重要说明显示在这样的框中。

提示

提示和技巧显示如下。

第一章:开始多人游戏编程

如果你正在阅读这本书,很有可能你已经是一名游戏开发者。如果是这样,那么你已经知道编写自己的游戏是多么令人兴奋,无论是专业地还是作为一种非常耗时但非常令人满足的爱好。现在你已经准备将你的游戏编程技能提升到下一个水平——也就是说,你已经准备在基于 JavaScript 的游戏中实现多人功能。

如果你已经开始使用 HTML5 和 JavaScript 为Open Web Platform创建多人游戏,那么你可能已经意识到个人台式电脑、笔记本电脑或移动设备并不是与另一个玩家分享游戏世界的最合适的设备,因此,为了使用 JavaScript 创建令人兴奋的多人游戏,需要一些形式的网络技术。

在本章中,我们将讨论以下原则和概念:

  • 网络和网络编程范式的基础知识

  • 使用 HTML5 进行套接字编程

  • 编写游戏服务器和游戏客户端

  • 回合制多人游戏

了解网络的基础知识

据说,如果没有先了解计算机网络和网络编程的学科,就无法编写利用网络的游戏。虽然对任何主题有深入的了解对于从事该主题的人来说都是有益的,但我不认为你必须了解关于游戏网络的所有知识才能编写一些非常有趣和引人入胜的多人游戏。说这种情况就像说一个人需要成为西班牙语的学者才能做一个简单的墨西哥卷饼。因此,让我们来看看网络的最基本和基本概念。在本节结束时,你将了解足够的计算机网络知识,可以开始,并且可以轻松地为你的游戏添加多人游戏方面。

需要记住的一件事是,尽管网络游戏并不像单人游戏那样古老,但计算机网络实际上是一个非常古老且经过深入研究的主题。一些最早的计算机网络系统可以追溯到 20 世纪 50 年代。尽管一些技术随着时间的推移有所改进,但基本思想仍然是一样的:两台或更多台计算机连接在一起,以建立机器之间的通信。通过通信,我指的是数据交换,比如在机器之间来回发送消息,或者一台机器只发送数据,另一台只接收数据。

通过这个对网络概念的简要介绍,你现在已经对网络主题有了一定的了解,足以知道网络游戏所需的是什么——尽可能接近实时地交流的两台或更多台计算机。

到目前为止,应该很清楚这个简单的概念是如何让我们能够将多个玩家连接到同一个游戏世界中的。实质上,我们需要一种方法来在连接到游戏会话的所有玩家之间共享全局游戏数据,然后继续更新每个玩家关于其他每个玩家的信息。通常有几种不同的技术用于实现这一点,但最常见的两种方法是点对点和客户端-服务器。这两种技术都提供了不同的机会,包括优势和劣势。一般来说,两者都没有特别优于另一种,但不同的情况和用例可能更适合其中一种技术。

点对点网络

通过点对点架构将玩家连接到同一个虚拟游戏世界是一种简单的方法。尽管名称可能暗示只涉及两个对等体(“节点”),但根据定义,点对点网络系统是指两个或更多个节点直接连接在一起,没有中央系统编排连接或信息交换。

在典型的点对点设置中,每个对等体都扮演着与其他对等体相同的功能,即它们都消耗相同的数据并共享它们产生的数据,以便其他人保持同步。在点对点游戏的情况下,我们可以用一个简单的井字棋游戏来说明这种架构。

点对点网络

一旦两名玩家之间建立了连接,谁开始游戏就在游戏板上标记一个单元格。这些信息通过电线传递给另一个对等体,后者现在知道了对手所做的决定,并因此可以更新自己的游戏世界。一旦第二名玩家收到了由第一名玩家最新移动所导致的游戏最新状态,第二名玩家就能够通过检查游戏板上的一些可用空间来进行自己的移动。然后这些信息被复制到第一名玩家那里,他可以更新自己的世界,并通过进行下一个期望的移动来继续这个过程。

这个过程会一直持续,直到其中一个对等体断开连接或者游戏以基于游戏自身业务逻辑的某个条件结束。在井字棋游戏的情况下,游戏将在其中一名玩家在棋盘上标记了三个空格形成一条直线,或者所有九个单元格都被填满,但没有一名玩家成功连接三个单元格的情况下结束。

点对点网络游戏的一些好处如下:

  • 快速数据传输:在这里,数据直接传输到其预定目标。在其他架构中,数据可能首先传输到一些集中节点,然后中央节点(或者在下一节中我们将看到的“服务器”)联系其他对等体,发送必要的更新。

  • 更简单的设置:你只需要考虑游戏的一个实例,一般来说,它处理自己的输入,将其输入发送给其他连接的对等体,并处理它们的输出作为自己系统的输入。这在回合制游戏中特别方便,例如,大多数棋盘游戏,比如井字棋

  • 更可靠:这里一个离线的对等体通常不会影响其他对等体。然而,在一个两人游戏的简单情况下,如果其中一名玩家无法继续,游戏很可能会无法继续进行。不过,想象一下,如果所涉及的游戏有数十甚至数百个连接的对等体。如果其中一些突然失去了互联网连接,其他人可以继续玩。但是,如果有一个连接所有节点的服务器并且服务器宕机,那么其他玩家将不知道如何与彼此交流,也不会知道发生了什么。

另一方面,点对点架构的一些明显缺点如下:

  • 无法信任传入数据:在这里,你无法确定发送者是否修改了数据。输入到游戏服务器的数据也会受到同样的挑战,但一旦数据经过验证并广播到所有其他对等体,你就可以更有信心地认为每个对等体从服务器接收到的数据至少已经经过了清理和验证,并且更可信。

  • 容错率可能非常低:在我们之前讨论的点对点网络的好处部分中提出了相反的观点;如果足够多的玩家共享游戏世界,一个或多个崩溃不会使游戏对其他对等体不可玩。现在,如果我们考虑到任何突然崩溃的玩家对其他玩家产生负面影响的许多情况,我们就可以看到服务器如何可以轻松从崩溃中恢复。

  • 向其他对等体广播时的数据重复:想象一下,你的游戏是一个简单的 2D 横向卷轴游戏,许多其他玩家与你共享这个游戏世界。每当其中一个玩家向右移动时,你会收到该玩家的新的(x,y)坐标,并且能够更新自己的游戏世界。现在,想象一下,你将你的玩家向右移动了几个像素;你将不得不将这些数据发送给系统中的所有其他节点。

总的来说,点对点是一种非常强大的网络架构,仍然被许多游戏行业广泛使用。由于当前的点对点网络技术仍处于起步阶段,今天大多数基于 JavaScript 的游戏不使用点对点网络。出于这个原因和其他很快就会变得明显的原因,我们将几乎专注于另一种流行的网络范式,即客户端-服务器架构。

客户端-服务器网络

客户端-服务器网络架构的理念非常简单。如果你闭上眼睛,你几乎可以看到一个点对点图。它们之间最明显的区别是,每个节点都是平等的对等体,而其中一个节点是特殊的。也就是说,每个节点(客户端)不是连接到每个其他节点,而是连接到一个名为服务器的主要集中节点。

虽然客户端-服务器网络的概念似乎足够清晰,也许一个简单的比喻可能会让你更容易理解这种网络格式中每种类型节点的角色,并将其与点对点区分开(McConnellSteve(2004) Code CompleteMicrosoft Press)。在点对点网络中,你可以将其视为一群朋友(对等体)在派对上进行对话。他们都可以直接与参与对话的其他对等体交谈。另一方面,客户端-服务器网络可以被视为一群朋友在餐馆吃饭。如果餐馆的客户想要点菜单上的某样东西,他或她必须与服务员交谈,服务员是那群人中唯一能够访问所需产品并为客户提供服务的人。

简而言之,服务器负责向一个或多个客户端提供数据和服务。在游戏开发的背景下,最常见的情况是两个或多个客户端连接到同一个服务器;服务器将跟踪游戏以及分布的玩家。因此,如果两个玩家要交换只与他们两个有关的信息,通信将从第一个玩家经过服务器传递并最终到达第二个玩家那里。

客户端服务器网络

在我们之前关于点对点的部分中看到的井字棋游戏中涉及的两名玩家的例子中,我们可以看到客户端-服务器模型中事件流是多么相似。再次强调,主要区别在于玩家彼此不知道对方,只知道服务器告诉他们的内容。

虽然你可以很容易地通过使用服务器仅仅连接两个玩家来模拟点对点模型,但通常服务器的使用要比这更加活跃。在网络游戏中,有两种方式可以让服务器参与,即授权方式和非授权方式。也就是说,你可以将游戏逻辑的执行严格放在服务器端,或者你可以让客户端处理游戏逻辑、输入验证等。如今,大多数使用客户端-服务器架构的游戏实际上使用这两种方式的混合(授权和非授权服务器,我们将在本书的后面讨论)。但无论如何,服务器的生命周期的目的是接收来自每个客户端的输入,并将该输入分发到连接的客户端池中。

不管你决定使用授权服务器还是非授权服务器,你会注意到客户端-服务器游戏的一个挑战是你需要编写整个堆栈的两端。即使你的客户端只是从用户那里获取输入,将其转发到服务器,并渲染从服务器接收到的任何数据;如果你的游戏服务器只是将它从每个客户端接收到的输入转发给其他每个客户端,你仍然需要编写游戏客户端和游戏服务器。

本章后面我们将讨论游戏客户端和服务器。目前,我们真正需要知道的是,这两个组件是将这种网络模型与点对点网络区分开来的关键。

客户端-服务器网络游戏的一些好处如下:

  • 关注点分离:如果你了解软件开发,你就会知道这是你应该始终追求的。也就是说,良好的、可维护的软件是由离散的组件编写的,每个组件都只做一件“事”,而且做得很好。编写单独的专门组件让你可以专注于一次执行一个任务,使得你的游戏更容易设计、编码、测试、推理和维护。

  • 集中化:尽管这一点可以被反对也可以被支持,但通过一个中心位置进行所有通信使得更容易管理这样的通信,强制执行任何必要的规则,控制访问等等。

  • 减轻客户端的工作量:客户端不再需要负责从用户和其他对等体获取输入,验证所有输入,与其他对等体共享数据,渲染游戏等等,客户端只需要专注于做其中的一部分,让服务器来分担一部分工作。当我们谈论移动游戏以及微妙的劳动分工如何影响整体玩家体验时,这一点尤为重要。例如,想象一个游戏中有 10 名玩家参与同一个游戏世界。在点对点设置中,每当一个玩家采取行动时,他或她需要将该行动发送给其他九名玩家(换句话说,需要进行九次网络调用,导致更多的移动数据使用)。另一方面,在客户端-服务器配置中,一个玩家只需要将他或她的行动发送给一个对等体,也就是服务器,然后服务器负责将该数据发送给其余的九名玩家。

无论服务器是否具有授权性,客户端-服务器架构的一些常见缺点如下:

  • 通信需要更长时间传播:在最理想的情况下,从第一个玩家发送到第二个玩家的每条消息传递时间都会比点对点连接长一倍。也就是说,消息首先从第一个玩家发送到服务器,然后从服务器发送到第二个玩家。今天有许多技术用于解决这种情况下面临的延迟问题,其中一些我们将在第四章中更深入地讨论,减少网络延迟。然而,根本的困境将始终存在。

  • 由于移动部件更复杂:无论你如何切割披萨,你需要编写的代码越多(相信我,当你为游戏构建两个独立的模块时,你会写更多的代码),你的心智模型就需要越大。虽然你的大部分代码可以在客户端和服务器之间重复使用(特别是如果你使用了成熟的编程技术,比如面向对象编程),但归根结底,你需要管理更高级别的复杂性。

  • 单点故障和网络拥塞:到目前为止,我们大多讨论的是只有少数玩家参与同一游戏的情况。然而,更常见的情况是少数玩家组在同一时间玩不同的游戏。

以两人玩Tic-tac-toe的游戏为例,想象一下有成千上万的玩家在单人游戏中面对面。在点对点设置中,一旦一对玩家直接配对,就好像没有其他玩家在享受那个游戏。唯一能阻止这两个玩家继续游戏的是他们彼此之间的连接。

另一方面,如果同样成千上万的玩家通过一个位于两者之间的服务器相互连接,那么两个被孤立的玩家可能会注意到消息之间出现严重的延迟,因为服务器忙于处理所有来自其他玩家的消息。更糟糕的是,这两个玩家现在不仅需要担心彼此之间通过服务器维持连接,还希望服务器与他们和对手之间的连接保持活动状态。

总的来说,客户端-服务器网络中涉及的许多挑战都经过深入研究和理解,你在多人游戏开发过程中可能会遇到的许多问题已经被其他人解决了。客户端-服务器是一种非常流行和强大的游戏网络模型,而通过 HTML5 和 JavaScript 可用的所需技术已经得到了很好的发展和广泛的支持。

网络协议 - UDP 和 TCP

通过讨论玩家如何在某种形式的网络上进行交流,我们只是浅尝辄止,实际上并没有涉及到通信是如何实际完成的。让我们来描述一下协议是什么,以及它们如何应用于网络和更重要的是多人游戏开发。

协议一词可以被定义为一组约定详细的程序计划 [引用[Def. 3,4]。(n.d.)。在 Merriam Webster Online 中检索到 2015 年 2 月 12 日,从www.merriam-webster.com/dictionary/protocol]。在计算机网络中,协议向消息的接收方描述数据的组织方式,以便对其进行解码。例如,想象一下,您有一个多人对打游戏,并且您想告诉游戏服务器,您的玩家刚刚发出了一个踢的命令,并向左移动了 3 个单位。您应该向服务器发送什么?您发送一个值为“kick”的字符串,然后是数字 3 吗?否则,您首先发送数字,然后是一个大写字母“K”,表示所采取的行动是踢?我试图表达的观点是,如果没有一个被充分理解和达成一致的协议,就不可能成功和可预测地与另一台计算机进行通信。

我们将在本节中讨论的两种网络协议,也是多人联机游戏中最广泛使用的两种协议,分别是传输控制协议TCP)和用户数据报协议UDP)。这两种协议都提供了网络系统中客户端之间的通信服务。简单来说,它们是允许我们以可预测的方式发送和接收数据包的协议。

当数据通过 TCP 发送时,源机器中运行的应用程序首先与目标机器建立连接。一旦建立了连接,数据以数据包的形式传输,以便接收方的应用程序可以将数据按适当的顺序重新组合。TCP 还提供了内置的错误检查机制,因此,如果数据包丢失,目标应用程序可以通知发送方应用程序,并且任何丢失的数据包都会被重新发送,直到整个消息被接收。

简而言之,TCP 是一种基于连接的协议,可以保证完整数据的按正确顺序传递。我们周围有许多需要这种行为的用例。例如,当您从 Web 服务器下载游戏时,您希望确保数据正确传输。您希望在用户开始玩游戏之前,游戏资产能够被正确完整地下载。虽然这种交付保证听起来非常令人放心,但也可以被认为是一个缓慢的过程,有时可能比知道数据将完整到达更重要,我们稍后会简要看到。

相比之下,UDP 在不使用预先建立的连接的情况下传输数据包(称为数据报)。该协议的主要目标是以非常快速和无摩擦的方式向某个目标应用程序发送数据。实质上,您可以将 UDP 视为勇敢的员工,他们打扮成公司的吉祥物站在店外挥舞着大型横幅,希望至少有一些经过的人会看到他们并给他们业务。

起初,UDP 可能看起来像是一种鲁莽的协议,但使 UDP 如此令人渴望和有效的用例包括许多情况,当您更关心速度而不是偶尔丢失数据包,获取重复数据包或以无序方式获取它们时。您可能还希望在您不关心接收方的回复时选择 UDP 而不是 TCP。使用 TCP 时,无论您是否需要接收方的某种确认或回复,它仍然需要时间来回复您,至少确认消息已收到。有时,您可能不在乎服务器是否收到了数据。

网络协议 - UDP 和 TCP

UDP 比 TCP 更好的选择的一个更具体的例子是,当你需要从客户端获取心跳信号,让服务器知道玩家是否还在游戏中时。如果你需要让服务器知道会话仍然活跃,并且偶尔丢失一个心跳信号并不重要,那么使用 UDP 是明智的选择。简而言之,对于任何不是关键任务且可以承受丢失的数据,UDP 可能是最佳选择。

最后,要记住,就像点对点和客户端-服务器模型可以并行构建一样,同样你的游戏服务器可以是授权和非授权的混合体,绝对没有理由为什么你的多人游戏只能使用 TCP 或 UDP。使用特定情况需要的任何协议。

网络套接字

还有一个我们将非常简要地介绍的协议,只是为了让你看到在游戏开发中需要网络套接字。作为 JavaScript 程序员,你无疑熟悉超文本传输协议HTTP)。这是 Web 浏览器用来从 Web 服务器获取你的游戏的应用层协议。

虽然 HTTP 是一个可靠地从 Web 服务器检索文档的协议,但它并不是为实时游戏而设计的;因此,它并不是这个目的的理想选择。HTTP 的工作方式非常简单:客户端向服务器发送请求,然后服务器返回响应给客户端。响应包括一个完成状态码,向客户端指示请求是正在处理中,需要转发到另一个地址,或者已成功或错误地完成(超文本传输协议(HTTP/1.1):身份验证(1999 年 6 月)tools.ietf.org/html/rfc7235

有几件事情需要注意关于 HTTP,这将清楚地表明在客户端和服务器之间的实时通信需要更好的协议。首先,每次接收到响应后,连接就会关闭。因此,在发出每个请求之前,必须与服务器建立新的连接。大多数情况下,HTTP 请求将通过 TCP 发送,相对而言,这可能会比较慢。

其次,HTTP 在设计上是一个无状态协议。这意味着,每次你从服务器请求资源时,服务器都不知道你是谁以及请求的上下文是什么。(它不知道这是你的第一个请求,还是你经常请求。)这个问题的一个常见解决方案是在每个 HTTP 请求中包含一个唯一的字符串,服务器会跟踪这个字符串,并因此可以持续提供有关每个个体客户端的信息。你可能会认识到这是一个标准的会话。这种解决方案的主要缺点,至少在实时游戏方面,是将会话 cookie 映射到用户会话需要额外的时间。

最后,使 HTTP 不适合多人游戏编程的主要因素是通信是单向的——只有客户端可以连接到服务器,服务器通过同一连接回复。换句话说,游戏客户端可以告诉游戏服务器用户输入了一个出拳命令,但游戏服务器无法将这些信息传递给其他客户端。想象一下自动售货机。作为机器的客户,我们可以请求我们想要购买的特定物品。我们通过向自动售货机投入货币来正式化这个请求,然后按下适当的按钮。

在任何情况下,自动售货机都不会向附近站立的人发出命令。这就像等待自动售货机发放食物,期望人们之后再往里面投钱。

对于 HTTP 功能的缺乏,答案非常简单。网络套接字是连接中允许客户端和服务器进行双向通信的端点。把它想象成电话通话,而不是自动售货机。在电话通话期间,任何一方都可以在任何时候说任何他们想说的话。最重要的是,双方之间的连接在整个对话期间保持打开状态,使通信过程非常高效。

网络套接字

WebSocket是建立在 TCP 之上的协议,允许基于 Web 的应用程序与服务器进行双向通信(WebSocket Protocol(2011 年 12 月)。[tools.ietf.org/html/rfc6455 RFC 6455](http://tools.ietf.org/html/rfc6455 RFC 6455))。创建 WebSocket 的方式包括多个步骤,包括从 HTTP 升级到 WebSocket 的协议升级。幸运的是,所有繁重的工作都是由浏览器和 JavaScript 在幕后完成的,我们将在下一节中看到。现在,这里的关键要点是,通过 TCP 套接字(是的,还有其他类型的套接字,包括 UDP 套接字),我们可以可靠地与服务器通信,服务器也可以根据需要回应我们。

JavaScript 中的套接字编程

现在让我们通过讨论将一切联系在一起的工具——JavaScript 和 WebSocket——来结束关于网络连接、协议和套接字的对话,从而使我们能够使用开放 Web 的语言编写出色的多人游戏。

WebSocket 协议

现代浏览器和其他 JavaScript 运行时环境已经在 JavaScript 中实现了 WebSocket 协议。不要误以为只因为我们可以在 JavaScript 中创建 WebSocket 对象,WebSocket 就是 JavaScript 的一部分。定义 WebSocket 协议的标准是与语言无关的,可以在任何编程语言中实现。因此,在开始部署使用 WebSocket 的 JavaScript 游戏之前,请确保将运行游戏的环境使用了实现了 WebSockets 的ECMA标准。换句话说,并非所有浏览器在您请求 WebSocket 连接时都知道该怎么做。

就目前而言,今天最流行的浏览器(即 Google Chrome,Safari,Mozilla Firefox,Opera 和 Internet Explorer)的最新版本(即本文撰写时)实施了 RFC 6455 的最新修订版。 WebSockets 的旧版本(如协议版本-76、7 或 10)正在逐渐被弃用,并已被一些先前提到的浏览器移除。

注意

关于 WebSocket 协议最令人困惑的事情可能是每个协议版本的命名方式。最初的草案(可以追溯到 2010 年)被命名为draft-hixie-thewebsocketprotocol-75。下一个版本被命名为draft-hixie-thewebsocketprotocol-76。有些人将这些版本称为 75 和 76,这可能会相当令人困惑,特别是因为协议的第四个版本被命名为draft-ietf-hybi-thewebsocketprotocol-07,在草案中被命名为 WebSocket Version 7。协议的当前版本(RFC 6455)是 13。

让我们快速看一下我们将在 JavaScript 代码中使用的编程接口(API),以与 WebSocket 服务器进行交互。请记住,我们需要编写使用 WebSockets 消耗数据的 JavaScript 客户端,以及使用 WebSockets 但扮演服务器角色的 WebSocket 服务器。随着我们讨论一些示例,两者之间的区别将变得明显。

创建客户端 WebSocket

以下代码片段创建了一个新的 WebSocket 类型对象,将客户端连接到某个后端服务器。构造函数需要两个参数;第一个是必需的,表示 WebSocket 服务器正在运行并期望连接的 URL。第二个 URL 在本书中不会使用,它是服务器可能实现的可选子协议列表。

var socket = new WebSocket('ws://www.game-domain.com');

尽管这一行代码可能看起来很简单且无害,但请记住以下几点:

  • 我们不再处于 HTTP 领域。现在,WebSocket 服务器的地址以ws://开头,而不是http://。同样,当我们使用安全(加密)套接字时,我们将指定服务器的 URL 为wss://,就像在https://中一样。

  • 这对您可能显而易见,但 WebSockets 入门者常犯的一个常见错误是,在您可以使用上述代码建立连接之前,您需要在该域上运行一个 WebSocket 服务器。

  • WebSockets 实现了同源安全模型。正如您可能已经在其他 HTML5 功能中看到的那样,同源策略规定,只有在客户端和服务器位于同一域中时,才能通过 JavaScript 访问资源。

提示

对于不熟悉同源(也称为同源)策略的人来说,在这种情况下,构成域的三个要素是正在访问的资源的协议、主机和端口。在上一个示例中,协议、主机和端口号分别是ws(而不是wsshttpssh)、www.game-domain.com(任何子域,如game-domain.combeta.game-domain.com都将违反同源策略),以及 80(默认情况下,WebSocket 连接到端口 80,使用wss时连接到端口 443)。

由于上一个示例中的服务器绑定到端口 80,我们不需要显式指定端口号。但是,如果服务器配置为在不同的端口上运行,比如 2667,那么 URL 字符串需要包括一个冒号,后面跟着需要放在主机名末尾的端口号,如ws://www.game-domain.com:2667

与 JavaScript 中的其他所有内容一样,WebSocket 实例尝试异步连接到后端服务器。因此,在确保服务器已连接之前,您不应尝试在新创建的套接字上发出命令;否则,JavaScript 将抛出一个可能会使整个游戏崩溃的错误。可以通过在套接字的onopen事件上注册回调函数来实现这一点:

var socket = new WebSocket('ws://www.game-domain.com');
socket.onopen = function(event) {
   // socket ready to send and receive data
};

一旦套接字准备好发送和接收数据,您可以通过调用套接字对象的send方法向服务器发送消息,该方法接受一个字符串作为要发送的消息。

// Assuming a connection was previously established
socket.send('Hello, WebSocket world!');

然而,通常情况下,您会希望向服务器发送更有意义的数据,例如对象、数组和其他具有自己含义的数据结构。在这些情况下,我们可以简单地将我们的数据序列化为 JSON 字符串。

var player = {
   nickname: 'Juju',
   team: 'Blue'
};

socket.send(JSON.stringify(player));

现在,服务器可以接收该消息,并将其作为客户端发送的相同对象结构进行处理,方法是通过 JSON 对象的解析方法运行它。

var player = JSON.parse(event.data);
player.name === 'Juju'; // true
player.team === 'Blue'; // true
player.id === undefined; // true

如果您仔细查看上一个示例,您会注意到我们从某个事件对象的data属性中提取通过套接字发送的消息。您会问,那个事件对象是从哪里来的?好问题!我们从套接字的onmessage事件上注册回调函数的方式在套接字的客户端和服务器端上接收消息是相同的。我们只需在套接字的onmessage事件上注册回调函数,每当接收到新消息时,就会调用该回调。传递给回调函数的参数将包含一个名为 data 的属性,其中包含发送的原始字符串对象的消息。

socket.onmessage = function(event) {
   event instanceof MessageEvent; // true

   var msg = JSON.parse(event.data);
};

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您还可以在 socket 对象上注册回调的其他事件包括onerror,每当与 socket 相关的错误发生时触发,以及onclose,每当 socket 的状态更改为CLOSED时触发;换句话说,每当服务器以任何原因关闭与客户端的连接或连接的客户端关闭其连接时。

如前所述,socket 对象还将具有一个名为readyState的属性,其行为类似于 AJAX 对象(或更恰当地说是XMLHttpRequest对象)中同名属性。该属性表示连接的当前状态,并且在任何时间点都可以具有四个值之一。该值是一个无符号整数,介于 0 和 3 之间,包括这两个数字。为了清晰起见,在 WebSocket 类上有四个与实例的readyState属性的四个数值相对应的常量。这些常量如下:

  • WebSocket.CONNECTING:其值为 0,表示客户端和服务器之间的连接尚未建立。

  • WebSocket.OPEN:其值为 1,表示客户端和服务器之间的连接已经打开并准备就绪。每当对象的readyState属性从 CONNECTING 更改为 OPEN 时(这只会在对象的生命周期中发生一次),将调用onopen回调。

  • WebSocket.CLOSING:其值为 2,表示连接正在关闭。

  • WebSocket.CLOSED:其值为 3,表示连接现在已关闭(或者根本无法打开)。

一旦readyState已经更改为新值,它将永远不会在同一 socket 对象实例中返回到先前的状态。因此,如果一个 socket 对象正在 CLOSING 或已经变为CLOSED,它将永远不会再次OPEN。在这种情况下,如果您希望继续与服务器通信,您将需要一个新的 WebSocket 实例。

总之,让我们总结一下之前讨论过的简单 WebSocket API 功能,并创建一个方便的函数,简化与游戏服务器通信时的数据序列化、错误检查和错误处理:

function sendMsg(socket, data) {
   if (socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(data));

      return true;
   }

   return false;
};

游戏客户端

在本章的前面,我们讨论了基于客户端-服务器模式的多人游戏的架构。由于这是我们将在整本书中开发的游戏所采用的方法,让我们定义一些游戏客户端将要履行的主要角色。

从更高的层次来看,游戏客户端将是人类玩家与游戏宇宙的其余部分(包括游戏服务器和连接到它的其他人类玩家)之间的接口。因此,游戏客户端将负责接收玩家的输入,将其传达给服务器,接收服务器的任何进一步指令和信息,然后再次将最终输出呈现给人类玩家。根据所使用的游戏服务器类型(我们将在下一节和未来章节中讨论此问题),客户端可以比仅仅是从服务器接收静态数据的输入应用程序更复杂。例如,客户端很可能会模拟游戏服务器的操作,并将此模拟的结果呈现给用户,而服务器则执行真正的计算并将结果告知客户端。这种技术的最大卖点在于,由于客户端几乎立即响应输入,游戏对用户来说会显得更加动态和实时。

游戏服务器

游戏服务器主要负责将所有玩家连接到同一个游戏世界,并保持它们之间的通信。然而,你很快就会意识到,有些情况下,你可能希望服务器比一个路由应用程序更复杂。例如,即使其中一名玩家告诉服务器通知其他参与者游戏结束了,并且发送消息的玩家是赢家,我们可能仍然希望在决定游戏是否真的结束之前确认信息。

有了这个想法,我们可以将游戏服务器标记为两种类型之一:权威或非权威。在权威游戏服务器中,游戏逻辑实际上一直在内存中运行(尽管通常不像游戏客户端一样渲染任何图形输出),每个客户端通过其对应的套接字发送消息将信息报告给服务器,服务器更新当前游戏状态并将更新发送回所有玩家,包括原始发送者。这样我们就可以更加确定从服务器传来的任何数据都经过了验证并且是准确的。

在一个非权威的服务器中,客户端在游戏逻辑执行中扮演了更加重要的角色,这给了客户端更多的信任。正如之前建议的,我们可以取长补短,创造两种技术的混合。在这本书中,我们将拥有一个严格的权威服务器,但客户端是智能的,可以自行完成一些工作。然而,由于服务器对游戏有最终决定权,因此客户端从服务器接收的任何消息都被视为最终真相,并且超越了客户端自己的任何结论。

将所有内容整合在一起 - 井字棋

在我们对网络、WebSockets 和多人游戏架构的新知识疯狂之前,让我们通过创建一个非常激动人心的井字棋网络游戏,以最简单的方式应用这些原则。我们将使用纯 WebSockets 与服务器通信,服务器将使用纯 JavaScript 编写。由于这个 JavaScript 将在服务器环境中运行,我们将使用Node.js(参考nodejs.org/),你可能在这一点上对它很熟悉,也可能不熟悉。现在不要太担心 Node.js 的具体细节。我们已经专门为 Node.js 和其相关生态系统的入门编写了一整章。现在,尽量专注于这个游戏的网络方面。

将所有内容整合在一起 - 井字棋

当然,你对井字棋很熟悉。两名玩家轮流在一个 9x9 的网格上标记一个方格,谁能标记三个相同的标记,形成水平、垂直或对角线的直线,谁就赢了。如果所有九个方格都被标记,并且之前提到的规则没有被满足,那么游戏就以平局结束。

Node.js - 宇宙的中心

正如承诺的,我们将在下一章深入讨论 Node.js。现在,只需知道 Node.js 是我们开发策略的基本部分,因为整个服务器将使用 Node 编写,所有其他支持工具都将利用 Node 的环境。我们将在这个第一个演示游戏中使用的设置包含三个主要部分,即web 服务器游戏服务器客户端文件(游戏客户端所在的地方)。

Node.js - 宇宙的中心

现在我们需要担心的主要是六个文件。其余的文件都是由 Node.js 和相关工具自动生成的。至于我们的六个脚本,每个脚本的作用如下。

/Player.js 类

这是一个非常简单的类,主要用于描述游戏客户端和服务器的期望。

/**
 *
 * @param {number} id
 * @param {string} label
 * @param {string} name
 * @constructor
 */
var Player = function(id, label, name) {
    this.id = id;
    this.label = label;
    this.name = name;
};

module.exports = Player;

当我们谈论 Node.js 的基础知识时,最后一行将会有更详细的解释。现在,你需要知道的是它使Player类在服务器代码以及发送到浏览器的客户端代码中都可用。

此外,我们很可能只需在整个游戏中使用对象字面量来表示我们所抽象出的player对象。我们甚至可以使用一个包含这三个值的数组,其中每个元素的顺序代表元素的含义。顺便说一句,我们甚至可以使用逗号分隔的字符串来表示这三个值。

正如你所看到的,通过创建一个全新的类来存储三个简单的值,这里产生了一些冗余,但这使得代码更易于阅读,因为现在我们知道了游戏在请求Player时建立的契约。它将期望在那里存在名为idlabelname的属性。

在这种情况下,id可以被认为有点多余,因为它的唯一目的是识别和区分玩家。重要的是两个玩家有一个唯一的 ID。标签属性是每个玩家将在棋盘上打印的内容,这也恰好是两个玩家之间的一个唯一值。最后,名称属性用于以人类可读的方式打印每个玩家的名称。

/BoardServer.js 类

这个类抽象了井字棋游戏的表示,定义了一个接口,我们可以在其中创建和管理一个有两个玩家和一个棋盘的游戏世界。

var EventEmitter = require('events').EventEmitter;
var util = require('util');

/**
 *
 * @constructor
 */
var Board = function() {
    this.cells = [];
    this.players = [];
    this.currentTurn = 0;
    this.ready = false;

    this.init();
};

Board.events = {
    PLAYER_CONNECTED: 'playerConnected',
    GAME_READY: 'gameReady',
    CELL_MARKED: 'cellMarked',
    CHANGE_TURN: 'changeTurn',
    WINNER: 'winner',
    DRAW: 'draw'
};

util.inherits(Board, EventEmitter);

由于这段代码只打算在服务器上运行,它充分利用了 Node.js。脚本的第一部分导入了两个核心 Node.js 模块,我们将利用它们而不是重新发明轮子。第一个是EventEmitter,它允许我们广播关于游戏发生的事件。其次,我们导入一个实用类,让我们轻松地利用面向对象编程。最后,我们定义了一些与Board类相关的静态变量,以简化事件注册和传播。

Board.prototype.mark = function(cellId) {
    // …
    if (this.checkWinner()) {
        this.emit(Board.events.WINNER, {player: this.players[this.currentTurn]});
    }
};

Board类公开了几种方法,驱动程序可以调用这些方法来向其中输入数据,并在发生某些情况时触发事件。正如前面提到的方法所示,每当玩家成功在棋盘上标记一个可用的方块时,游戏就会广播该事件,以便驱动程序知道游戏中发生了什么;然后它可以通过相应的套接字联系每个客户端,并让他们知道发生了什么。

/server.js 类

在这里,我们有一个驱动程序,它使用我们之前描述的Board类来强制执行游戏规则。它还使用 WebSockets 来维护连接的客户端,并处理他们与游戏的个体交互。

var WebSocketServer = require('ws').Server;
var Board = require('./BoardServer');
var Player = require('./Player');

var PORT = 2667;
var wss = new WebSocketServer({port: PORT});
var board = new Board();

var events = {
    incoming: {
        JOIN_GAME: 'csJoinGame',
        MARK: 'csMark',
        QUIT: 'csQuit'
    },
    outgoing: {
        JOIN_GAME: 'scJoinGame',
        MARK: 'scMark',
        SET_TURN: 'scSetTurn',
        OPPONENT_READY: 'scOpponentReady',
        GAME_OVER: 'scGameOver',
        ERROR: 'scError',
        QUIT: 'scQuit'
    }
};

/**
 *
 * @param action
 * @param data
 * @returns {*}
 */
function makeMessage(action, data) {
    var resp = {
        action: action,
        data: data
    };

    return JSON.stringify(resp);
}

console.log('Listening on port %d', PORT);

这个 Node.js 服务器脚本的第一部分导入了我们自定义的类(BoardPlayer)以及一个方便的第三方库ws,它帮助我们实现 WebSocket 服务器。这个库处理诸如初始连接设置、协议升级等事情,因为这些步骤不包括在 JavaScript WebSocket 对象中,该对象只是用作客户端。在一些方便的对象之后,我们有一个等待在ws://localhost:2667上连接的工作服务器。

wss.on('connection', function connection(ws) {
    board.on(Board.events.PLAYER_CONNECTED, function(player) {
        wss.clients.forEach(function(client) {
            board.players.forEach(function(player) {
                client.send(makeMessage(events.outgoing.JOIN_GAME, player));
            });
        });
    });

    ws.on('message', function incoming(msg) {
        try {
            var msg = JSON.parse(msg);
        } catch (error) {
            ws.send(makeMessage(events.outgoing.ERROR, 'Invalid action'));
            return;
        }

        try {
            switch (msg.action) {
                case events.incoming.JOIN_GAME:
                    var player = new Player(board.players.length + 1, board.players.length === 0 ? 'X' : 'O', msg.data);
                    board.addPlayer(player);
                    break;
                // ...
            }
        } catch (error) {
            ws.send(makeMessage(events.outgoing.ERROR, error.message));
        }
    });
});

这个服务器的其余重要部分发生在中间。为了简洁起见,我们只包括了Board类发出的事件的事件处理程序注册的一个示例,以及对套接字接收到的事件注册的callback函数。(你是否认出了ws.on('message', function(msg){})函数调用?这是 Node 中等价于我们之前讨论的客户端 JavaScriptsocket.onmessage = function(event){}的函数调用。)

这里的重要之处在于我们如何处理来自游戏客户端的消息。由于客户端只能向我们发送单个字符串作为消息,我们如何知道消息是什么?由于客户端可以向服务器发送许多类型的消息,我们在这里创建了自己的小协议。也就是说,每条消息都将是一个序列化的JSON对象(也称为对象文字),具有两个属性。第一个属性将以action的值为键,第二个属性将以data的值为键,具体取决于指定的操作。从这里,我们可以查看msg.action的值,并相应地做出响应。

例如,每当客户端连接到游戏服务器时,它会发送一个带有以下值的消息。

{
    action: events.outgoing.JOIN_GAME,
    data: "<player nickname>"
};

一旦服务器将该对象作为onmessage事件的有效载荷接收,它就可以知道消息的含义以及玩家昵称的预期值。

/public/js/Board.js 类

这个类与BoardServer.js非常相似,主要区别在于它还处理 DOM(即浏览器渲染和管理的 HTML 元素),因为游戏需要呈现给人类玩家。

/**
 *
 * @constructor
 */
var Board = function(scoreBoard) {
    this.cells = [];
    this.dom = document.createElement('table');
    this.dom.addEventListener('click', this.mark.bind(this));
    this.players = [];
    this.currentTurn = 0;
    this.ready = false;

    this.scoreBoard = scoreBoard;

    this.init();
};

Board.prototype.bindTo = function(container) {
    container.appendChild(this.dom);
};

Board.prototype.doWinner = function(pos) {
    this.disableAll();
    this.highlightCells(pos);
};

出于简洁起见,我们选择不显示游戏逻辑的大部分内容。这里需要注意的重点是,这个版本的 Board 类非常了解 DOM,并且对游戏决策和游戏规则的执行非常被动。由于我们使用的是权威服务器,这个类会按照服务器的指示进行操作,比如标记自己以指示某个参与者赢得了游戏。

/public/js/app.js 类

server.js类似,这个脚本是我们游戏的驱动程序。它有两个功能:接收用户输入并驱动服务器,以及使用从服务器接收的输入来驱动棋盘。

var socket = new WebSocket('ws://localhost:2667');

var scoreBoard = [
    document.querySelector('#p1Score'),
    document.querySelector('#p2Score')
];

var hero = {};
var board = new Board(scoreBoard);

board.onMark = function(cellId){
    socket.send(makeMessage(events.outgoing.MARK, {playerId: hero.id, cellId: cellId}));
};

socket.onmessage = function(event){
    var msg = JSON.parse(event.data);

    switch (msg.action) {
        case events.incoming.GAME_OVER:
            if (msg.data.player) {
                board.doWinner(msg.data.pos);
            } else {
                board.doDraw();
            }

            socket.send(makeMessage(events.outgoing.QUIT, hero.id));
            break;

        case events.incoming.QUIT:
            socket.close();
            break;
    }
};

socket.onopen = function(event) {
    startBtn.removeAttribute('disabled');
    nameInput.removeAttribute('disabled');
    nameInput.removeAttribute('placeholder');
    nameInput.focus();
};

再次需要注意的是客户端服务器是多么以 DOM 为中心。还要注意客户端对从服务器接收的消息是多么顺从。如果服务器在发送给客户端的消息中指定的操作是GAME_OVER,客户端会清理一切,告诉玩家游戏结束了,要么是因为有人赢得了游戏,要么是因为游戏以平局结束,然后告诉服务器它准备断开连接。再次,客户端等待服务器告诉它下一步该做什么。在这种情况下,它等待服务器清理,然后告诉客户端断开连接。

摘要

在本章中,我们讨论了网络和网络编程范式的基础知识。我们看到了 WebSockets 如何使得在 HTML5 中开发实时多人游戏成为可能。最后,我们使用广泛支持的 Web 技术实现了一个简单的游戏客户端和游戏服务器,并构建了一个有趣的井字棋游戏。

在下一章中,我们将介绍 JavaScript 开发领域的最新技术,包括通过 Node.js 在服务器端使用 JavaScript。本章将教授您使用工作流和资源管理工具(如 NPM、Bower、Grunt 等)来管理 JavaScript 开发周期的当前技术。

第二章:设置环境

上一章的目标是介绍使用当前 HTML5 技术进行 JavaScript 多人游戏编程。虽然我们讨论了一个真正的多人游戏的实现,但并没有提到如何管理更复杂的项目。

除了诸如 WebSockets 之类的新技术之外,我们还可以将发生在 Web 平台内的巨大进步归功于已经创建的支持项目管理和 HTML5 和 JavaScript 开发工作流的支持工具。

在本章中,我们将讨论以下原则和概念:

  • Node.js中开发 JavaScript 应用程序

  • 编写模块化的 JavaScript 应用程序

  • 使用npm管理 Node.js 包

  • 使用Bower管理客户端包

  • 自动化 JavaScript 开发

Node.js 中的 JavaScript 在浏览器之外

不久前,所谓的 Web 开发人员很少使用 JavaScript,只有在 Web 表单需要客户端验证时才会用到。由于 CSS 不像今天这样先进,或者至少没有得到广泛支持,JavaScript 也被用来创建图像滚动效果。不久前,JavaScript 和程序员这两个词是不太搭配的。

然而,时代在变化,技术在进化。如今,合格的 JavaScript 程序员受到追捧,并且相对于其他编程语言的程序员来说,薪酬竞争力非常强。这反映了 JavaScript 语言变得多么受欢迎和强大。

因此,JavaScript 正在稳步从世界上最被误解的编程语言CrockfordDouglas(2001)javascript.crockford.com/javascript.html)变成一个企业级语言,它被用于浏览器内部以及独立程序,包括服务器应用程序。正如上一章所解释和说明的,当它被用于游戏的客户端构建以及游戏服务器时,JavaScript 以不同的方式被使用。

你可能记得游戏服务器不一定要用 JavaScript 编写。事实上,游戏客户端根本不知道服务器是用什么语言编写的,因为它与服务器的所有通信都是通过 WebSocket 协议进行的。然而,由于我们希望最大化我们可以在客户端和服务器之间共享的代码量,同时减少我们编写的总代码量,我们将以一种可以实现代码共享的方式编写我们的游戏。这就是 Node.js 发挥作用的地方。

Node.js

毫无疑问,你现在应该已经听说过 Node.js 了。对于那些不太确定 Node 实际是什么的人来说,它只是建立在谷歌 Chrome 的 JavaScript 引擎(也称为V8)之上的运行时环境。换句话说,Node 既不是 JavaScript 的特殊版本,也不是独立的 JavaScript 引擎,而是一个整个的生态系统,碰巧利用了谷歌的开源 JavaScript 引擎,这可能是当今世界上的七大奇迹之一。

Node.js

值得一提的是 Node.js 的两个特点是它不依赖于浏览器,以及每个 I/O 操作都是异步的。

至于它不是浏览器环境,您不会像在浏览器中那样找到 window 对象。此外,由于 Node.js 环境中不存在浏览器施加的任何限制,您可以充分利用底层操作系统。首先,想象一下到目前为止您一直在使用的服务器端语言,或者您考虑使用来编写我们在第一章中讨论的游戏服务器的任何编程语言,开始多人游戏编程。然后,在您的脑海中用 JavaScript 替换该语言。这就是 Node.js 提供的重要优势。

在堆栈的两端(服务器端和客户端)使用 JavaScript 的一些好处包括以下内容:

  • 您可以共享为服务器和客户端编写的大量代码

  • 您只需要掌握一种语言

  • JavaScript 是一种强大的语言,解决了其他语言中存在的许多问题

  • 由于 JavaScript 是单线程的,您永远不会遇到死锁或许多与多线程编程相关的问题

到目前为止,我希望您能够看到 Node.js 在 HTML5 多人游戏开发中有多么基础,或者至少在本书中有多么关键。在我们深入探讨一些基本概念之前,让我们确保您可以在系统上安装和运行它。

安装 Node.js

在系统上安装 Node.js 的两种推荐方法是从官方网站www.nodejs.org下载可执行文件,或者通过编译源代码手动安装。根据您选择的操作系统,您还可以通过某些软件包管理系统或类似工具安装它。无论您决定采取哪种方法,请确保安装最新的稳定版本,截至撰写本文时,最新版本是 0.12.0。

一旦您在系统上安装了 Node.js,您可以通过打开终端窗口并输入以下命令来进行测试:

node
console.log('Hello, World!');

如果在安装过程中一切顺利,您应该会看到类似于以下截图中显示的输出:

安装 Node.js

您可以通过在终端上运行以下命令来检查已安装的 Node.js 版本:

node --version

尽管今天(撰写本文时,即 2015 年初)可用的最新版本是 0.12.0,但本书中描述的所有脚本都是在版本 0.10.25 中编写和测试的。对于向后和向前兼容性问题和疑问,请务必参考 Node.js 的官方待办事项。

编写模块化 JavaScript

在 Node.js 出现之前,鉴于 JavaScript 的臭名昭著的限制,开发人员可能对其最大的抱怨是缺乏对模块化开发过程的内置支持。

模块化 JavaScript 开发的最佳实践是在字面对象内创建组件,以某种方式行为类似于命名空间。这个想法是在全局范围内创建一个对象,然后使用该对象内的命名属性来表示您将声明类、函数、常量等的特定命名空间(或至少 JavaScript 等效项)。

var packt = packt || {};
packt.math = packt.math || {};
packt.math.Vec2 = function Vec2(x, y) {// …
};

var vec2d = new packt.math.Vec2(0, 1);
vec2d instanceof packt.math.Vec2; // true

在上一个代码片段中,我们在packt变量不存在的情况下创建一个空对象。如果存在,我们不会用空对象替换它,而是将一个引用分配给packt变量。我们在 math 属性中也是一样,其中我们添加了一个名为Vec2d的构造函数。现在,我们可以自信地创建特定向量类的实例,知道如果我们的全局范围内还有其他向量库,即使它也被命名为Vec2,它也不会与我们的版本冲突,因为我们的构造函数位于packt.math对象内。

虽然这种方法在很长一段时间内运行得相对良好,但它确实有三个缺点:

  • 每次键入整个命名空间都需要很多工作

  • 不断引用深层嵌套的函数和属性会影响性能

  • 您的代码很容易被粗心的赋值替换为顶级 namespace 属性

好消息是,今天有一种更好的方法来编写 JavaScript 模块。通过认识到旧方式的缺点,一些提出的标准出现了,以解决这个问题。

CommonJS

2009 年,Mozilla 的开发人员创建了一个旨在定义一种从浏览器中解放出来的 JavaScript 应用程序的方式的项目。 (参见 en.wikipedia.org/wiki/CommonJS.) 这种方法的两个显著特点是 require 语句,它类似于其他语言提供的功能,以及 exports 变量,从这里来的所有代码将被包含在对 require 函数的后续调用中。每个导出的模块都驻留在单独的文件中,这样就可以识别 require 语句引用的文件,并隔离组成模块的代码。

// - - - - - - -
// player.js

var Player = function(x, y, width, height) {
   this.x = x;
   this.y = y;
   this.width = width;
   this.height = height;
};

Player.prototype.render = function(delta) {
   // ...
};

module.exports = Player;

这段代码在名为 player.js 的文件中创建了一个模块。这里的要点如下:

  • 您实际模块的内容是您所熟悉和热爱的相同的旧式 JavaScript

  • 您希望导出的任何代码都分配给 module.exports 变量

在我们讨论如何使用这个模块之前,让我们详细说明之前提到的最后一点。由于 JavaScript 闭包的工作原理,我们可以引用文件中(在文件内部)未直接通过 module.exports 导出的值,这些值无法在模块外部访问(或修改)。

// - - - - - - -
// player.js

// Not really a constant, but this object is invisible outside this module/file
var defaults = {
   width: 16,
   height: 16
};

var Player = function(x, y, width, height) {
   this.x = x;
   this.y = y;
   this.width = width || defaults.width;
   this.height = height || defaults.height;
};

Player.prototype.render = function(delta) {
   // ...
};

module.exports = Player;

请注意,Player 构造函数接受宽度和高度值,这些值将分配给该类实例的本地和对应的宽度和高度属性。但是,如果我们省略这些值,那么我们将回退到 defaults 对象中指定的值,而不是将未定义或空值分配给实例的属性。好处是该对象无法在模块外部任何地方访问,因为我们没有导出该变量。当然,如果我们使用 EcmaScript 6 的 const 声明,我们可以实现只读的命名常量,以及通过 EcmaScript 5 的 Object.defineProperty,将可写位设置为 false。然而,这里的要点仍然是,未导出的模块外部的任何东西都无法直接访问模块中未通过 module.exports 导出的值。

现在,为了使用 CommonJs 模块,我们需要确保可以在文件系统中本地访问代码。在其最简单的形式中,一个 require 语句将寻找一个文件(相对于所提供的文件)来包含,其中文件的名称与 require 语句匹配。

// - - - - - - -
// app.js

var Player = require('./player.js');
var hero = new Player(0, 0);

要在 app.js 文件中运行脚本,我们可以在与存储 app.js 相同的目录中使用以下命令:

node app.js

假设 app.jsplayer.js 文件存储在同一个目录中,Node 应该能够找到名为 player.js 的文件。如果 player.js 存储在 app.js 的父目录中,那么 require 语句需要如下所示:

// - - - - - - -
// test/player_test.js

var Player = require('./../player.js');
var hero = new Player(0, 0);

正如您将在后面看到的,我们可以使用 Node 的包管理系统非常容易地导入模块或整个库。这样做会使导入的包以一种有条理的方式存储,从而使将它们引入您的代码变得更容易。

另一种导入模块的方式是简单地在 require 语句中包含导出模块的名称,如下所示:

// - - - - - - -
// app.js

var Player = require('player.js');
var hero = new Player(0, 0);

如果您运行先前的文件,您将看到一个致命的运行时错误,看起来像以下的屏幕截图:

CommonJS

Node 无法找到player.js文件的原因是,当我们不使用前导句号指定文件名(这意味着包含的文件是相对于当前脚本的),它会在与当前脚本相同的目录中寻找名为node_modules的目录中的文件。

如果 Node 无法在node_modules中找到匹配的文件,或者当前目录没有这样命名的目录,它将在与当前脚本的父目录中的require语句类似的目录中寻找名为node_modules的目录以及同名的文件。如果在那里搜索失败,它将再向上查找一个目录级别,并在那里的node_modules目录中寻找文件。搜索将一直持续到文件系统的根目录。

将文件组织成可重用的、自包含模块的另一种方法是将文件捆绑在node_modules中的一个目录中,并利用一个代表模块入口点的index.js文件。

// - - - - - - -
// node_modules/MyPlayer/index.js

var Player = function(x, y, width, height) {
   this.x = x;
   this.y = y;
   this.width = width;
   this.height = height
};

module.exports = Player;

// - - - - - - -
// player_test.js

var Player = require('MyPlayer');

var hero = new Player(0, 0);
console.log(hero);

请注意,模块的名称,在require语句中指定的,现在与node_modules中的一个目录的名称匹配。当名称不以指示相对或绝对路径的字符("/","./"或"../")开头,并且文件扩展名被省略时,可以确定 Node 将寻找一个目录而不是与require函数中提供的名称匹配的文件。

当 Node 查找目录名称时,如前面的示例所示,它将首先在匹配的目录中查找index.js文件并返回其内容。如果 Node 找不到index.js文件,它将查找一个名为package.json的文件,这是描述模块的清单文件。

// - - - - - - -
// node_modules/MyPlayer/package.json

{
   "name": "MyPlayer",
   "main": "player.js"
}

假设我们已将node_modules/MyPlayer/index.js文件重命名为node_modules/MyPlayer/player.js,一切将与以前一样工作。

在本章后面,当我们谈论 npm 时,我们将更深入地了解package.json,因为它在 Node.js 生态系统中扮演着重要的角色。

RequireJS

试图解决 JavaScript 缺乏本地脚本导入和标准模块规范的另一个项目是 RequireJS。 (参见requirejs.org/。)实际上,RequireJS 是异步模块定义AMD)规范的一个特定实现。 AMD 是一个定义模块及其依赖项可以异步加载的 API 的规范[Burke,James(2011)。github.com/amdjs/amdjs-api/wiki/AMD]。

CommonJS 和 RequireJS 之间的一个显著区别是,RequireJS 设计用于在浏览器内部使用,而 CommonJS 并没有考虑浏览器。然而,这两种方法都可以适应浏览器(在 CommonJS 的情况下)以及其他环境(在 RequireJS 的情况下)。

与 CommonJS 类似,RequireJS 可以被认为有两部分:一个模块定义脚本和一个消费(或需要)模块的第二个脚本。此外,与 CommonJS 类似但在 RequireJS 中更明显的是,每个应用程序都有一个单一的入口点。这是需要开始的地方。

// - - - - - - -
// index.html

<script data-main="scripts/app" src="img/require.js"></script>

在这里,我们在 HTML 文件中包含require.js库,指定入口点,这由data-main属性表示。一旦库加载,它将尝试加载名为app.js的脚本,该脚本位于名为scripts的目录中,该目录存储在与主机index.html文件相同的路径上。

这里需要注意的两件事是,scripts/app.js脚本是异步加载的,而不是使用script标签时浏览器默认加载所有脚本的方式。此外,scripts/app.js本身可以要求其他脚本,这些脚本将依次异步加载。

按照惯例,入口脚本(在上一个示例中为scripts/app.js)将加载一个配置对象,以便 RequireJS 可以适应您自己的环境,然后加载真正的应用程序入口点。

// - - - - - - -
// scripts/app.js

requirejs.config({
    baseUrl: 'scripts/lib',
    paths: {
        app: '../app'
    }
});

requirejs(['jquery', 'app/player'], function ($, player) {
    // ...
});

在上一个示例中,我们首先配置了脚本加载器,然后我们需要两个模块——首先是jQuery库,然后是一个名为player的模块。配置块中的baseUrl选项告诉 RequireJS 从scripts/lib目录加载所有脚本,这是相对于加载scripts/app.js的文件(在本例中为index.html)。路径属性允许您对baseUrl创建异常,重写以app字符串开头的脚本的路径,这被称为模块 ID。当我们需要app/player时,RequireJS 将加载一个相对于index.html的脚本scripts/app/player.js

一旦加载了这两个模块,RequireJS 将调用传递给requirejs函数的回调函数,按照指定的顺序将请求的模块作为参数添加进去。

您可能会想知道为什么我们谈论了 CommonJS 和 RequireJS,因为目标是在服务器和客户端之间尽可能共享尽可能多的代码。覆盖两种方法和工具的原因仅是为了完整性和信息目的。由于 Node.js 已经使用 CommonJS 作为其模块加载策略,几乎没有理由在服务器上使用 RequireJS。而不是混合使用 RequireJS 在浏览器中使用,通常做法(这将是本书其余部分的选择)是在所有地方使用 CommonJS(包括客户端代码),然后在客户端代码上运行一个名为Browserify的工具,使得可以在浏览器中加载使用 CommonJS 的脚本。我们将很快介绍 Browserify。

使用 Npm 管理 Node.js 包

Npm 是 JavaScript 的包管理器,类似于 PHP 的Composer或 Python 的Pip。(转到www.npmjs.com/。)有些人可能会告诉您 npm 代表 Node Package Manager,但尽管自 0.6.3 版本以来一直是 Node.js 的默认包管理器,npm 并不是一个首字母缩写词。因此,您经常会看到 npm 以小写形式拼写。

要快速检查是否已安装 npm,可以使用终端窗口查询已安装的 npm 版本。

npm -v

有关如何在特定操作系统上安装 npm 的说明,请确保您遵循 npm 官方网站上的指南。本书中示例代码和演示应用程序使用的版本是 1.3.10。

使用 npm 安装第三方包时,可以选择将其安装在项目的本地位置,也可以全局安装,以便在系统的任何位置都可见该包。

npm install watch

默认情况下,当您安装一个包(在上一个示例中,我们安装了一个名为watch的包,用于监视目录和文件的更改)时,如果没有标志,该包将被安装在本地(假设package.json文件也存在),并保存到执行命令的相对位置的node_modules目录中。

要全局或系统范围安装一个包,只需在安装命令后附加-g标志:

npm install watch -g

按照惯例,如果您需要一个通过require语句在代码中使用的包,您将希望将该包保存在本地。如果意图是从命令行中使用包作为可执行代码,那么通常会希望全局安装它。

如果要在package.json清单上构建,以便项目依赖的本地包可以共享并轻松安装,可以手动编辑清单文件,在“dependencies”键下的json对象中添加依赖项,或者让 npm 为您执行此操作,但不要忘记指定--save标志:

npm install watch --save

请注意,运行上一个命令将下载组成所请求包的代码到你的工作目录,并更新你的package.json清单,以便以后更新包或根据需要重新安装它们。换句话说,你可以随时使用你现有的package.json文件来重建你的开发环境,就第三方依赖而言。

一旦你在package.json文件中指定了一个或多个依赖项,你可以通过运行 npm 来安装它们,如下所示:

npm install

这将下载清单文件中的所有依赖项并保存到node_modules中。

同样,你可以通过使用 update 命令通过 npm 更新包:

npm update

如果你不知道如何开始创建一个package.json清单文件,你可以让 npm 帮助你填写最常见属性的空白部分。

npm init

这将加载一个交互式实用程序,要求你为清单的各种属性输入值,比如包名称、版本、作者名称等。它还提供了一些默认值,这样你可以忽略你不知道它们的属性,或者你可以信任 npm 提供的任何后备选项,让你很容易快速获得一个清单文件。

npm init
// … assume all proposed default values

// - - - - - - -
// package.json

{
  "name": "npm",
  "version": "0.0.0",
  "description": "ERROR: No README data found!",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "BSD-2-Clause"
}

一旦你有了一个通用的package.json清单,你可以用 npm install 命令将你的依赖项添加到其中。

npm install browserify --save

// - - - - - - -
// package.json

{
  "name": "npm",
  "version": "0.0.0",
  "description": "ERROR: No README data found!",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "BSD-2-Clause" ,
  "dependencies": {
    "browserify": "~9.0.3"
  }
}

当然,你可以随时手动编辑文件来更改值或删除你认为不必要的属性,比如许可证、描述或版本。有些属性只有在你计划私下或与全局 npm 注册表共享你的包时才有意义。其他值,比如脚本,用于方便开发。例如,我们可以注册一个脚本,当我们运行npm <script value>时执行。

// - - - - - - -
// package.json

{
 "scripts": {
    "test": "node test.js"
  }
}

// - - - - - - -
// test.js

console.log('testing npm scripts');

因此,我们可以让 Node 通过 npm 运行一个名为test.js的脚本,命令如下:

npm test

虽然在这种情况下使用 npm 可能不会节省很多输入,但它确实使其他人更容易知道,例如,如何运行你的测试,即使你的测试运行器脚本没有以任何特定的标准形式命名或执行。

使用 Bower 管理前端包

如果你对 npm 作为后端 JavaScript 包管理器并不满意,也许 Bower 会让你更加快乐。 (参见bower.io/。)Bower 的工作方式与 npm 非常相似。事实上,我们刚刚讨论的大多数 npm 命令和约定在 Bower 中都可以直接使用。

事实上,Bower 本身是一个通过 npm 安装的 Node.js 模块:

npm install bower -g

我们可以以与 npm 相同的方式与 Bower 交互。

bower init
// … using all proposed defaults

// - - - - - - -
// bower.json

{
  name: 'npm',
  version: '0.0.0',
  homepage: 'https://github.com/formigone',
  authors: [
    'Rodrigo Silveira <webmaster@rodrigo-silveira.com>'
  ],
  license: 'MIT',
  ignore: [
    '**/.*',
    'node_modules',
    'bower_components',
    'test',
    'tests'
  ]
}

Bower 使用bower.json清单文件,到目前为止,这应该对你来说看起来有些熟悉。要安装依赖项,要么手动编辑清单,要么利用 Bower。

bower install jquery –save

// - - - - - - -
// bower.json

{
  name: 'npm',
  version: '0.0.0',
  homepage: 'https://github.com/formigone',
  authors: [
    'Rodrigo Silveira <webmaster@rodrigo-silveira.com>'
  ],
  license: 'MIT',
  ignore: [
    '**/.*',
    'node_modules',
    'bower_components',
    'test',
    'tests'
  ],
  "dependencies": {
    "jquery": "~2.1.3"
  }
}

到目前为止,Bower 和 npm 之间的主要区别是,Bower 处理前端依赖项,可以是 JavaScript、CSS、HTML、字体文件等。Bower 将依赖项保存在bower_components目录中,类似于 npm 的node_dependencies

Browserify

最后,让我们使用这个非常方便的 npm 包来利用我们的 CommonJS 模块(以及 Node 的原生模块)在浏览器中使用。这正是 Browserify 的作用:它接受一个入口点脚本,从该文件递归地跟随所有 require 语句,然后内联构建的依赖树中的所有文件,并返回一个单一文件。(参见browserify.org/。)这样,当浏览器在你的脚本中遇到一个 require 语句时,它不必从文件系统中获取文件;它从同一个文件中获取文件。

sudo npm install browserify -g

一旦我们安装了 Browserify(再次强调,因为这是用作命令行工具,我们要全局安装它),我们可以将所有的 CommonJS 文件“捆绑”在一起。

// - - - - - - -
// app.js

var Player = require('MyPlayer');

var hero = new Player(0, 0);
console.log(hero);

// - - - - - - -
// node_modules/MyPlayer/index.js

var defaults = {
   width: 16,
   height: 16
};

var Player = function(x, y, width, height) {
   this.x = x;
   this.y = y;
   this.width = width || defaults.width;
   this.height = height || defaults.height;
};

Player.prototype.render = function(delta) {
   // ...
};

module.exports = Player;

Browserify 将负责根据需要引入所有的依赖项,以便输出文件具有所有准备好供使用的依赖项,就像上面的代码示例中所示的那样。

Browserify 将入口点的名称作为第一个参数,并默认将输出打印到标准输出。或者,我们可以指定一个文件名,将捆绑保存在那里。

browserify app.js -o bundle.js

Browserify 现在将创建一个名为bundle.js的文件,我们可以在 HTML 文件中包含它,并在浏览器中使用。此外,我们可以使用 npm 注册表中的许多可用工具之一来压缩输出文件。

sudo npm install uglify-js -g
uglifyjs bundle.js -o bundle.min.js --source-map bundle.min.js.map

运行上述代码将安装一个名为UglifyJS的 node 包,它可以非常智能地解析、混淆、压缩和收缩我们的bundle.js文件。(参考github.com/mishoo/UglifyJS。)输出文件将非常小,并且对人类来说完全不可读。作为奖励,它还创建了一个source map文件,这样我们就可以通过将其映射回原始的bundle.js文件来调试被最小化的文件。

自动化您的工作流程

到目前为止,我们已经学会了执行以下任务:

  • 编写可导入其他模块的模块化 JavaScript 代码

  • 通过 CommonJS 和 Browserify 在客户端和服务器端代码中重用模块

  • 使用 npm 管理 node 包

  • 使用 Bower 管理客户端包

现在,我们准备以一种方式将所有这些内容整合起来,以便摆脱我们运行所有这些命令的负担。试想一下,如果您必须编写几行代码,保存您的工作,跳到命令行,运行 Browserify,然后运行 Uglify-js,然后运行您的单元测试,然后运行其他几个 npm 工具,最后跳到浏览器,刷新浏览器,看到更新后的应用程序正在运行。哦,等等!您忘记重新启动游戏服务器,它是一个 Node.js 应用程序,在更改这些文件后需要重新启动。所以,您回到终端,运行几个命令,最终,您会在浏览器中看到新的代码。

如果刚才的思维练习让我们所涵盖的这些精彩工具看起来像是很多工作,保持冷静。我们还有另一套工具可以让我们的生活变得更轻松,JavaScript 开发是一种美妙的事情(与通常所说的相反,特别是那些不使用我们将要讨论的工具的人)。

Grunt

Grunt是一个流行的任务运行工具,可以自动化您可能需要执行的重复任务,例如运行单元测试、捆绑组件、缩小捆绑包、从源文件注释创建 API 文档等。(参考gruntjs.com/。)

Grunt 使用插件的概念,这些插件是特定的任务配置,可以共享和重复使用。例如,您可能希望有一个插件来监视目录的更改,然后在触发更改时运行 Browserify。(换句话说,每次保存文件时,都会运行一个任务。)

您可以手动编写自己的插件;尽管这是一个简单的过程,但它足够冗长,所以我们不会在本书中详细介绍。幸运的是,Grunt 有一个庞大的插件列表,几乎包含了您所需的所有插件,或者至少是我们在本书中需要的所有插件。

npm install grunt-cli -g

毫不奇怪!我们通过 npm 安装 Grunt。接下来,我们需要使用 npm 和package.json安装 Grunt 插件;唯一的区别是我们将它们列在devDependencies下,而不是 dependencies 下。

npm install grunt --save-dev
npm install grunt-browserify --save-dev
npm install grunt-contrib-watch --save-dev
npm install grunt-contrib-uglify --save-dev

接下来,我们创建一个Gruntfile.js来配置我们的任务。这个文件指定了目标,并定义了每个目标的行为。大多数情况下,您只需查看您使用的插件的示例配置文件,然后调整它以满足您的需求。

在使用 watch 和 Browserify 的特定情况下,我们只需要告诉 watch 插件在观察到变化时运行 Browserify 任务,并且在 Browserify 任务中,我们需要指定最基本的设置:一个入口文件和一个输出捆绑文件。

构成Gruntfile的四个部分如下:

  • 一个样板包装函数

  • 每个任务的配置

  • 手动加载每个任务使用的插件

  • 每个任务的注册,以便 Grunt 可以执行它们

// - - - - - - -
// Gruntfile.js
module.exports = function(grunt) {

  grunt.initConfig({
    browserify: {
      client: {
        src: ['./app.js'],
        dest: 'bundle.js'
      }
    },
    watch: {
      files: ['**/*'],
      tasks: ['browserify'],
    }
  });

  grunt.loadNpmTasks('grunt-browserify');
  grunt.loadNpmTasks('grunt-contrib-watch');

  grunt.registerTask('default', ['watch']);

};

grunt.initConfig内,您配置每个任务,属性名称与任务名称匹配。然后,您调用loadNpmTasks函数加载每个插件并加载相应的依赖项。最后,您指定默认任务以及任何自定义任务,并将它们映射到它们的依赖项。使用任务注册中使用的名称将运行特定的任务。

grunt browserify

前面的命令将运行 browserify 任务,该任务已经配置和加载如前所示。如果您运行 grunt 命令而没有指定任务,将运行default任务,这种情况下将运行 watch 任务。

Gulp

Gulp是 Grunt 的一个流行的替代品,它声称通过提供更简单的配置来改进 Grunt。(参考gulpjs.com/。)你使用哪种工具取决于你。就像你开什么样的车或者去哪家快餐店一样,使用 Gulp 或 Grunt 完全取决于口味和个人偏好。

npm install gulp -g
npm install gulp-uglify --save-dev
npm install gulp --save-dev

Gulp 使用gulpfile.js作为其配置文件。

// - - - - - - -
// gulpfile.js

var gulp = require('gulp');
var uglify = require('gulp-uglify');

gulp.task('minify', function () {
   gulp.src('app.js')
      .pipe(uglify())
      .pipe(gulp.dest('build'))
});

与 Grunt 相比,前面的配置看起来更加简单。如果你看到一个名为 minify 的任务被注册,它会取一个名为app.js的源文件,首先进行 uglify,然后保存到一个构建目录,那么你猜对了。

要运行任务,您可以指定一个默认任务,或者使用以下命令显式运行先前提到的任务:

gulp minify

总结

在本章中,我们涵盖了很多内容,解释了 Node.js 为我们带来的机会,将 JavaScript 带到服务器上。我们看到了在 JavaScript 中构建可管理的模块的方法,在堆栈的两端共享和重用这些模块,并使用 npm、Bower、Grunt 和 Gulp 等管理和工作流工具来自动化开发过程。

现在,我们已经准备充分利用 Node.js 生态系统以及可用的强大的支持工作流工具。从这里开始,我们将回到编写游戏,通过构建一个有趣的多人蛇游戏。我们将讨论一些概念,这些概念将允许我们将玩家匹配到同一个游戏世界中,这是将玩家带入游戏的基本部分。

第三章:实时喂蛇

在现在已经涵盖了介绍性材料之后,是时候让橡皮碰到路了。本章将指导您将单人游戏升级为多人游戏。

与我们在第一章中开发的游戏不同,开始多人游戏编程,这款游戏需要实时进行,而不是回合制,这给我们带来了一系列挑战。一旦我们解决了跨两个或更多玩家同步实时游戏世界所涉及的基本问题,我们将研究其他基本但更深入的概念。

在本章中,我们将讨论以下原则和概念:

  • 修复您的游戏循环以进行多人游戏

  • 实施权威服务器

  • 大厅和房间系统

  • 匹配算法

  • 使用Socket.io进行套接字编程

游戏开发的 hello world

当你学习编程时,肯定写过一个hello world程序。在游戏开发中,我会说每个开发者都应该从经典的hello world游戏——贪吃蛇开始。概念很简单:在屏幕上移动一个方块,收集特殊方块,使您的方块拉伸成一系列相连的方块,类似于蛇的移动。如果你把蛇的头撞到它的身体,你就输了。

游戏开发的 hello world

这个实现只允许蛇向上、向下、向左或向右移动。一旦您指定了蛇的移动方向,它将继续沿着该方向移动,直到您将其移动到另一个方向。作为奖励,这个特定的实现允许您在屏幕上环绕——也就是说,如果您移动到屏幕的一侧之外,蛇将出现在相反的一侧。

捕捉红色方块会使蛇增长一个额外的方块,并将您的得分增加 10 分。将蛇撞到自己会停止游戏循环,并打印一个简单的游戏结束消息。

为了使这个初始版本保持简单,没有任何额外的屏幕,包括主入口屏幕。游戏加载完毕后游戏就开始了。随着我们在这个单人版本的游戏上进行扩展,我们将添加必需的屏幕,使其更直观和用户友好,以便多个玩家加入游戏。

设置游戏

这个初始的单人版本的游戏的目标是使用尽可能少的代码制作一个可玩的游戏,使用我们可以构建的最基本的模型。因此,许多额外的细节留作练习。

为了为游戏添加服务器端组件做好准备,我们使用 Node.js 编写了游戏的第一个版本,并使用 Browserify 将其导出到浏览器中,如第二章中所讨论的那样,设置环境

package.json

为了使一切尽可能简单,我们将使用一个package.json文件,它只需要Express框架来帮助我们进行路由和 Grunt 插件来帮助我们使用 Browserify 自动构建和导出我们的模块:

// ch3/package.json
{
    "name": "snake-ch3",
    "dependencies": {
        "express": "*",
        "express-generator": "*"
    },
    "devDependencies": {
        "grunt": "~0.4.5",
        "grunt-browserify": "~3.4.0",
        "grunt-contrib-uglify": "~0.8.0",
        "grunt-contrib-watch": "~0.6.1"
    }
}

注意

Express.js是一个用于 Node.js 的 Web 框架,它允许我们非常快速地设置整个 Web 服务器来托管和提供我们的游戏。(参考expressjs.com/。)虽然 Express 在我们的项目中扮演着重要角色,因为它路由用户请求以获取适当的文件,但了解它的工作原理并不是本章或本书的先决条件。我们将涵盖足够的绝对基础知识,让您开始使用这个强大的框架。

有了这一切,我们使用 Express 命令行工具来构建项目。

npm install
express snake-ch3
cd snake-ch3
npm install

执行上述命令序列后,我们已经设置好了我们的 Node.js 服务器的样板,其中包括 Express 的所有默认设置,对于我们的目的来说,这将完全正常工作。如果由于任何原因出现问题,将会有足够的错误消息帮助您理解问题所在。假设在输入上述命令后一切都进行得很顺利,您现在可以通过以下命令启动服务器来测试项目:

npm start

这将在端口3000上启动服务器,您可以在现代浏览器上加载http://localhost:3000/

package.json

项目结构现在看起来像前面的屏幕截图中的那样,除了红框内的文件不会被 Express Generator 生成。我们将手动创建和编辑这些文件,您将在接下来的几节中看到。

Index.jade

默认情况下,Express 会创建一个显示欢迎消息的索引文件。由于我们现在只需要一个屏幕来显示游戏,我们将编辑这个文件以满足我们自己的目的:

// ch3/snake-ch3/views/index.jade
extends layout

block content
  div#gameArea
    p#scoreA SCORE: <span>000000</span>
    p#gameOver.animated.pulse.hidden Game Over
    canvas#gameCanvas
    div#statsPanel
  script(src='/js/app.build.js')

如果你用力眨眼,你会看到 HTML 标记。如果你不熟悉 Express 默认使用的 Jade 模板语言,不用担心。在模板中,我们创建了一个<p>元素,用来显示当前得分,一个用于游戏结束消息的元素,以及一个用来渲染游戏的 canvas 元素。我们还包括了主脚本文件,这是 Grunt 任务的输出,它将所有文件连接起来,并在它们上运行 Browserify,以便我们可以在浏览器中加载它。由于index.jade是本书中我们将看到的 Jade 的唯一内容,我们不会进一步深入讨论。有关 Jade 的工作原理和功能的更多信息,请访问其网站www.jade-lang.com

游戏模块

有了上述结构,现在我们所需要的只是实现游戏的几个类。我们将用五个类来实现这个,这样当我们实现游戏服务器时,我们可以重用单独的逻辑片段。

Game.js

这是我们将实现的game.js文件:

// ch3/snake-ch3/share/game.js
var Game = function (fps) {
    this.fps = fps;
    this.delay = 1000 / this.fps;
    this.lastTime = 0;
    this.raf = 0;

    this.onUpdate = function (delta) {
    };
    this.onRender = function () {
    };
};

Game.prototype.update = function (delta) {
    this.onUpdate(delta);
};

Game.prototype.render = function () {
    this.onRender();
};

Game.prototype.loop = function (now) {
    this.raf = requestAnimationFrame(this.loop.bind(this));

    var delta = now - this.lastTime;
    if (delta >= this.delay) {
        this.update(delta);
        this.render();
        this.lastTime = now;
    }
};

Game.prototype.start = function () {
    if (this.raf < 1) {
        this.loop(0);
    }
};

Game.prototype.stop = function () {
    if (this.raf > 0) {
        cancelAnimationFrame(this.raf);
        this.raf = 0;
    }
};

module.exports = Game;

这个模块是我们项目的基石。它定义了一个非常简单的接口,抽象了一个简单的游戏循环。当我们实现这个类时,我们所需要做的就是定义update()render()方法。

您会注意到使用了requestAnimationFrame,这是浏览器定义的一个特殊函数,帮助我们渲染游戏。由于游戏服务器不会渲染游戏,它也不会有这个函数可用,所以当我们开始在服务器上工作时,我们需要适应这一点。我们将在下一节更多地讨论帧速率的独立性。

snake.js

我们将向我们的snake.js文件添加以下代码:

// ch3/snake-ch3/share/snake.js
var keys = require('./keyboard.js');
var EventEmitter = require('events').EventEmitter;
var util = require('util');

var Snake = function (id, x, y, color_hex, width, height) {
    this.id = id;
    this.color = color_hex;
    this.head = {x: x, y: y};
    this.pieces = [this.head];
    this.width = width || 16;
    this.height = height || 16;
    this.readyToGrow = false;
    this.input = {};
};

Snake.events = {
    POWER_UP: 'Snake:powerup',
    COLLISION: 'Snake:collision'
};

util.inherits(Snake, EventEmitter);

Snake.prototype.setKey = function (key) {
    this.input[keys.UP] = false;
    this.input[keys.DOWN] = false;
    this.input[keys.LEFT] = false;
    this.input[keys.RIGHT] = false;
    this.input[key] = true;
};

Snake.prototype.update = function (delta) {
    if (this.readyToGrow) {
        this.pieces.push({x: -10, y: -10});
        this.readyToGrow = false;
    }

    for (var len = this.pieces.length, i = len - 1; i > 0; i--) {
        this.pieces[i].x = this.pieces[i - 1].x;
        this.pieces[i].y = this.pieces[i - 1].y;
    }

    if (this.input[keys.LEFT]) {
        this.head.x += -1;
    } else if (this.input[keys.RIGHT]) {
        this.head.x += 1;
    } else if (this.input[keys.UP]) {
        this.head.y += -1;
    } else if (this.input[keys.DOWN]) {
        this.head.y += 1;
    }
};

Snake.prototype.checkCollision = function(){
    var collide = this.pieces.some(function(piece, i){
        return i > 0 && piece.x === this.head.x && piece.y === this.head.y;
    }, this);

    if (collide) {
        this.emit(Snake.events.COLLISION, {id: this.id, point: this.head, timestamp: performance.now()});
    }
};

Snake.prototype.grow = function() {
    this.readyToGrow = true;
    this.emit(Snake.events.POWER_UP, {id: this.id, size: this.pieces.length, timestamp: performance.now()});
};

module.exports = Snake;

蛇类扩展了 Node 的EventEmitter类,以便它可以向主应用程序发出事件。这样我们就可以隔离类的具体行为,并将其与任何根据我们的选择对蛇作出响应的具体实现解耦。

我们还创建了一个简单的界面,主应用程序可以使用它来控制蛇。同样,由于此版本的即时目标是在浏览器中运行游戏,我们将利用浏览器特定的功能,这种情况下是window.performance.now(),当需要时我们将用兼容 Node.js 的模块替换它。

其他支持模块

还有三个其他类(即fruit.jskeyboard.jsrenderer.js),它们仅仅包装了 canvas 和 canvas 上下文对象,一个 JavaScript 等价的枚举,帮助我们引用键盘输入,以及一个简单的点,我们将用它来表示蛇将吃的小球。为简洁起见,我们将省略这些类的代码。

app.client.js

这是我们的app.client.js模块应该是什么样子的:

// ch3/snake-ch3/share/app.client.js
game.onUpdate = function (delta) {
    var now = performance.now();

    // Check if there's no fruits left to be eaten. If so, create a new one.
    if (fruits.length < 1) {
        fruitDelta = now - lastFruit;

        // If there's been enough time without a fruit for the snakes,
        // create a new one at a random position, and place it in the world
        if (fruitDelta >= fruitDelay) {
            fruits[0] = new Fruit(
              parseInt(Math.random() * renderer.canvas.width / BLOCK_WIDTH / 2, 10),
              parseInt(Math.random() * renderer.canvas.width / BLOCK_HEIGHT / 2, 10),
              '#c00', BLOCK_WIDTH, BLOCK_HEIGHT
         );
        }
    }

    player.update(delta);
    player.checkCollision();

    // Check if the snake has gone outside the game board.
    // If so, wrap it around to the other side
    if (player.head.x < 0) {
        player.head.x = parseInt(renderer.canvas.width / player.width, 10);
    }

    if (player.head.x > parseInt(renderer.canvas.width / player.width, 10)) {
        player.head.x = 0;
    }

    if (player.head.y < 0) {
        player.head.y = parseInt(renderer.canvas.height / player.height, 10);
    }

    if (player.head.y > parseInt(renderer.canvas.height / player.height, 10)) {
        player.head.y = 0;
    }

    // Check if there's a fruit to be eaten. If so, check if the snake has just
    // eaten it. If so, grow the player that ate it.
    if (fruits.length > 0) {
        if (player.head.x === fruits[0].x && player.head.y === fruits[0].y) {
            fruits = [];
            player.grow();
            lastFruit = now;
        }
    }
};

game.onRender = function () {
    ctx.clearRect(0, 0, renderer.canvas.width, renderer.canvas.height);

    ctx.fillStyle = player.color;
    player.pieces.forEach(function(piece){
        ctx.fillRect(
           piece.x * player.width,
           piece.y * player.height,
           player.width,
           player.height
        );
    });

    fruits.forEach(function(fruit){
        ctx.fillStyle = fruit.color;
        ctx.fillRect(
           fruit.x * fruit.width,
           fruit.y * fruit.height,
           fruit.width,
           fruit.height
        );
    });
};

app.client 模块的第一部分是游戏的具体实现,它导入所有必需的类和模块,并实例化游戏循环和玩家类。接下来(如前所述),我们实现了两个游戏循环生命周期方法,即 updaterender 方法。当我们添加多人游戏功能时,我们需要对这两个方法进行的唯一更改是更新和渲染一组蛇,而不是单个蛇。

由于每个玩家的实际更新都委托给了 snake 类本身,游戏循环对该方法内部的操作没有任何问题。事实上,游戏循环甚至不关心 update 方法的输出,我们稍后会看到。关键在于游戏循环的 update 方法允许游戏中的每个实体在更新阶段更新自身。

同样,在渲染阶段,游戏循环只关心渲染它想要渲染的每个实体的当前状态。虽然我们也可以委托蛇和其他可视实体的渲染,但为了简单起见,我们将具体的渲染留在游戏循环内部。

最后,在 app.client 模块的末尾,我们连接到我们关心的传入事件。在这里,我们监听由 snake 对象创建的游戏事件。Snake.events.POWER_UPSnake.events.COLLISION 自定义事件让我们执行回调函数,以响应蛇吃掉颗粒和与自身碰撞时的情况。

接下来,我们绑定键盘并监听按键事件。由于我们实现的游戏机制,我们不关心未被按下的任何键,这就是为什么我们不为这些事件注册任何监听器。这段代码块将来可以进行重构,因为客户端接收此类输入的方式将与服务器不同。例如,客户端仍然会直接从用户那里接收输入,使用相同的键盘事件作为输入,但服务器将从用户那里接收此输入,通过套接字连接通知服务器其状态:

// whenever we receive a POWER_UP event from the game, we
// update the player's score and display its value inside scoreWidget.
player.on(Snake.events.POWER_UP, function(event){
    var score = event.size * 10;
    scoreWidgets.filter(function( widget){
        return widget.id === event.id;
    })
        .pop()
        .el.textContent = '000000'.slice(0, - (score + '').length) + score + '';
});

// whenever we receive a COLLISION event from the game, we
// stop the game and display a game over message to the player.
player.on(Snake.events.COLLISION, function(event){
    scoreWidgets.filter(function(widget){
        return widget.id === event.id;
    })
        .pop()
        .el.parentElement.classList.add('gameOver');

    game.stop();
    setTimeout(function(){
        ctx.fillStyle = '#f00';
        ctx.fillRect(event.point.x * player.width, event.point.y * player.height, player.width, player.height);
    }, 0);

    setTimeout(function(){
        gameOver.classList.remove('hidden');
    }, 100);
});

document.body.addEventListener('keydown', function (e) {
    var key = e.keyCode;

    switch (key) {
        case keys.ESC:
            game.stop();
            break;
        case keys.SPACEBAR:
            game.start();
            break;
        case keys.LEFT:
        case keys.RIGHT:
        case keys.UP:
        case keys.DOWN:
            player.setKey(key);
            break;
        case keys.D:
            console.log(player.pieces);
            break;
    }
});

游戏循环

正如你所知,游戏循环是任何实时游戏的核心。尽管游戏循环的功能相当简单,但现在让我们考虑一下同时运行游戏服务器和客户端的一些影响。

帧率独立性

游戏循环的目的只是确保游戏以一致有序的方式运行。例如,如果我们在更新游戏状态之前绘制当前游戏状态,玩家在与游戏交互时可能会发现游戏略微不同步,因为当前显示的内容至少会比玩家期望的要滞后一个帧。

此外,在 JavaScript 的基于事件的输入系统中,如果我们每次从用户那里接收输入就更新游戏,可能会导致游戏的不同部分在不同时间更新,使体验更加不同步。

因此,我们设置了游戏循环,以确保在处理和缓存任何输入之后,直到游戏循环的下一个 tick,我们可以在游戏步骤的 update 阶段应用输入,然后渲染更新的结果:

帧率独立性

这个问题最明显的解决方案是在游戏中建模输入空间;然后,在 update 阶段查询并相应地做出响应。在其他编程环境中,我们可以直接查询输入设备。由于 JavaScript 暴露事件,我们无法询问运行时左键当前是否被按下。

接下来,我们需要更新游戏,这在大多数情况下意味着我们会微调一些东西。在更新了几帧之后,我们在每次迭代中更新的这些小动作将合并在一起,形成平滑的运动。实际上,一旦游戏循环完成一个周期,我们需要再次调用游戏循环以进行下一个周期的循环:

while (true) {
   update();
   render();
}

在大多数其他编程语言中,传统的游戏循环可能看起来像前面的代码片段,但在 JavaScript 中我们不能这样做,因为 while 循环会阻塞 JavaScript 的单个线程,导致浏览器锁死:

function tick() {
   setTimeout(tick, 0.016);
   update();
   render();
}

在 JavaScript 中更合适的方法是使用定时器函数(setTimeoutsetInterval)之一来调用游戏步骤方法。虽然这个解决方案实际上是有效的,不像 while 循环的想法,但我们可能会遇到一些问题,比如游戏消耗太多 CPU(以及移动设备的电池寿命),特别是当游戏不运行时循环继续执行。如果 JavaScript 忙于其他事情,定时器方法也可能会出现问题,tick函数无法像我们希望的那样频繁地被调用。

注意

也许你会想知道为什么我们在tick方法的开头而不是结尾调用setTimeoutrequestAnimationFrame,而不是在方法内部的代码实际执行后。

之所以这样做是因为调用这两个函数中的任何一个都只是简单地安排callback函数在下一个事件循环周期运行。调用setTimeoutrequestAnimationFrame会立即将执行返回给调用它的函数的下一个命令,然后函数的其余部分执行完成。

一旦函数返回,JavaScript 将执行事件循环中添加的下一个代码片段,换句话说,如果 JavaScript 在执行我们的游戏tick方法或其他事件发生时检测到用户输入,这些事件将被添加到队列中,并在 tick 方法返回后处理。因此,如果我们等到 tick 方法的结尾再次使用事件循环调度它,我们可能会发现 tick 方法在排队等候(以便它可以再次获得 CPU 的使用权)之前,其他回调将被处理。

通过提前调度tick方法,我们可以确保它在当前执行完成后尽快再次被调用,即使在当前执行期间触发了其他事件,并且其他代码被放入事件循环中。

最后,在 JavaScript 中编写游戏循环的最合适的方法是使用较新的window.requireAnimationFrame函数:

function tick(timestamp) {
   var rafId = requestAnimationFrame(tick);
   update();
   render();
}

requestAnimationFrame是浏览器中实现的一个方便的函数,我们可以使用它来要求浏览器在进行下一次重绘之前调用我们的回调函数。由于浏览器内部工作超出了 JavaScript 的范围,刷新率现在处于操作系统级别,这更加精确。此外,由于浏览器知道何时需要重绘,并且比 JavaScript 更接近显示设备,它可以进行许多我们无法做到的优化。

调用requestAnimationFrame将返回一个整数值,该值将映射到回调列表中提供的函数。我们可以使用这个 ID 号来取消触发我们的回调,当浏览器确定它应该触发时。这是一种方便的方法,可以暂停游戏循环的执行,而不需要在回调的开头使用条件语句,这通常大部分时间都会评估为 false(或者我们希望如此)。

最后,我们提供给RequestAnimationFrame的回调函数将会传递一个时间戳数值,格式为DOMHighResTimeStamp类型。这个时间戳代表了在给定周期内,使用RequestAnimationFrame注册的回调被触发的时间。我们可以使用这个数值来计算自上一帧以来的时间差,从而将我们的游戏循环脱离时间空间连续性,接下来我们将讨论这一点。

基于时间的游戏循环

现在我们已经有了一种有效的方法,可以使我们的游戏更新速度与底层硬件的能力一样快,我们只需要控制更新发生的速率。一种选择是确保游戏循环在至少经过一定时间后才再次执行。这样我们就不会更新得比我们必须要更新的更频繁。另一种选择是计算上一次更新所花费的时间,并将该数字发送到更新函数中,以便根据时间差移动所有内容:

基于时间的游戏循环

如前图所示,如果我们在一个浏览器或设备上以两倍的速度更新游戏,那么更新单帧所需的时间(也称为时间差)也会减半。使用这个时间差作为物理更新的因素,我们可以使每次更新相对于更新单帧所需的时间。换句话说,在整整一秒钟内,我们可以选择在几次更新中每次更新的幅度更大,或者在同一秒内多次更新游戏,但每次更新的幅度更小。在一秒结束时,我们仍然会移动相同的距离。

多个游戏循环

在不同的 CPU 上平稳一致地运行游戏本身就是一种胜利。既然我们已经过了这一关,现在让我们考虑如何在客户端和服务器上实现这一点。

在浏览器上,我们可以使用requestAnimationFrame来为用户运行游戏,就像之前演示的那样。然而,在服务器上,没有requestAnimationFrame。更糟糕的是,我们无法以每秒 60 次的速度将更新发送到所有参与者。理论上,我们完全可以这样做——也许在服务器在短短几秒内就会过热并崩溃之前。换句话说,对于同一服务器中的每个游戏来说,每秒运行 60 次更新会给服务器带来巨大的负载。因此,我们需要减慢服务器上更新的速度。

首先,由于 Node.js 中没有requestAnimationFrame,我们知道我们不能使用它。然而,由于游戏服务器的游戏循环的具体实现与游戏客户端的游戏循环是分开的,我们可以选择 Node 提供的另一种计时器机制。

其次,我们需要在服务器上运行第二个计时器,以便以更慢的速度向客户端发送更新。如果我们实际上尝试以每秒 60 帧的速度向每个客户端发送更新,我们很快就会使服务器过载,并且性能会下降。

解决客户端更新问题的方法是以更慢但一致的速度发送更新,允许服务器以可扩展的方式成为游戏状态的最终权威。在服务器发送更新之间,如果游戏需要更快的更新,我们可以让游戏客户端以最佳方式更新自身;然后,一旦它从服务器接收到信息,我们可以根据需要修复客户端状态。

在 Node.js 中,有两个常用的计时器函数,可以作为setTimeout()的高分辨率替代品。这两个函数分别是setImmediate()process.nextTick()。你会选择使用这两个函数而不是setTimeout()的原因是因为setTimeout()不能保证你指定的延迟,也不能保证事件执行的顺序。

作为更好的替代方案,我们可以使用setImmediate来安排一个回调,在当前坐在事件队列上的每个事件之后运行。我们还可以使用process.nextTick,它将安排回调在当前代码块执行完毕后立即运行。

虽然process.nextTick似乎是两者之间更好的选择,但请记住它不会给 CPU 执行事件队列中的其他代码的机会(或允许 CPU 休息),导致执行占用 CPU 的 100%。因此,在您的 Node.js 游戏模拟中的游戏循环的特定用例中,您可能最好使用setImmediate

如前所述,游戏服务器将运行两个定时器或循环。第一个是物理更新循环,将使用setImmediate来尝试以完整的 60 fps 高效运行。第二个将是客户端同步循环,不需要运行得那么快。

客户端同步循环的目的是权威性地告诉客户端游戏的真实状态,以便每个客户端可以更新自身。如果我们试图让服务器在每一帧调整每个客户端,游戏和服务器都会变得非常缓慢。一个简单而广泛使用的解决方案是每秒只同步几次客户端。与此同时,每个客户端可以在本地玩游戏,然后在服务器更新其状态时进行任何必要的更正。

实施权威服务器

这个服务器的策略是为了两个不同的目的运行两个游戏循环。第一个循环是物理更新,我们会以接近客户端循环频率的频率运行。第二个循环,我们称之为客户端同步循环,以较慢的速度运行,并在每个时刻将整个游戏状态发送给每个连接的客户端。

此时,我们只关注让服务器按照我们描述的方式工作。客户端的当前实现将继续像以前一样工作,本地管理整个游戏逻辑。客户端从服务器接收的任何数据(使用游戏同步循环)将只被渲染。在本书的后面,我们将讨论客户端预测的概念,其中我们将使用游戏同步循环的输入作为游戏逻辑的实际输入,而不仅仅是无意识地渲染它。

游戏服务器接口

从当前游戏客户端的实现中要改变的第一件事是分解输入和输出点,以便它们可以与中间的套接字层通信。我们可以将其视为一个编程接口,指定服务器和客户端将如何通信。

为此,让我们在项目中创建一个简单的模块,作为 JavaScript 中没有枚举的可怜之人的枚举。尽管此模块中的数据不是不可变的,但它将给我们带来优势,因为 IDE 将自动建议值,在我们犯拼写错误时纠正我们,并将我们所有的意图放在一个地方。按照惯例,任何以server_ 开头的事件代表服务器的操作。例如,名为server_newRoom的事件要求服务器创建一个新房间:

// ch3/snake-ch3/share/events.js

module.exports = {
    server_spawnFruit: 'server:spawnFruit',
    server_newRoom: 'server:newRoom',
    server_startRoom: 'server:startRoom',
    server_joinRoom: 'server:joinRoom',
    server_listRooms: 'server:listRooms',
    server_setPlayerKey: 'server:setPlayerKey',

    client_newFruit: 'client:newFruit',
    client_roomJoined: 'client:roomJoined',
    client_roomsList: 'client:roomsList',
    client_playerState: 'client:playerState'
};

我们现在使用此模块中定义的字符串值来注册回调并以一致和可预测的方式在客户端和服务器之间发出套接字事件。例如,当我们发出名为modules.exports.server_spawnFruit的事件时,我们知道意图是让服务器接收到一个名为spawnFruit的动作。此外,您会注意到我们将使用socket.io来抽象化客户端和服务器之间的套接字通信。如果您现在想开始使用socket.io,请随时跳到本章末尾并阅读Socket.io部分。

var gameEvents = require('./share/events.js');

socket.on(gameEvents.server_spawnFruit, function(data){
   var pos = game.spawnFruit(data.roomId, data.maxWidth, data.maxHeight);

   socket.emit(gameEvents.client_newFruit, pos);
});

在给定的示例中,我们首先将我们的模块包含到gameEvents变量中。然后,我们注册一个回调函数,每当套接字接收到server_spawnFruit事件时就会调用该函数。据推测,这段代码在某个服务器代码中,因为键名开头的 server 关键字指示了这一点。这个回调函数接受一个由客户端创建的数据参数(在套接字的另一端发送命令的人)。这个数据对象包含了生成游戏中新水果对象所需的数据。

接下来,我们使用套接字事件中的输入数据执行一些任务(在这种情况下,我们生成一个随机位置,可以在游戏世界中添加水果)。有了这些数据,我们向客户端发出一个套接字命令,发送我们刚刚生成的位置。

更新游戏客户端

在客户端代码中要改变的第一件事是添加不同的屏幕。至少,我们需要两个不同的屏幕。其中一个屏幕将是游戏板,就像我们迄今为止实现的那样。另一个是大厅,我们稍后会详细讨论。简而言之,大厅是玩家在加入特定房间之前所在的区域,我们稍后也会讨论。

更新游戏客户端

在大厅中,玩家可以选择加入现有房间或创建并加入一个没有玩家的新房间。

在一个完美的世界中,你的游戏引擎会为多个屏幕提供很好的支持。由于我们正在编写的示例游戏不是用这样的游戏引擎编写的,我们将只使用基本的 HTML 和 CSS,并在同一个 HTML 文件中编写每个屏幕以及任何支持的道具和小部件:

// ch3/snake-ch3/views/index.jade

extends layout

block content
  div#lobby
    h1 Snake
    div#roomList

 div#main.hidden
    div#gameArea
      p#scoreA SCORE: <span>000000</span>
      p#gameOver.animated.pulse.hidden Game Over
      canvas#gameCanvas
      div#statsPanel

  script(src='/js/socket.io.js')
  script(src='/js/app.build.js')

在上一个模板中只有三个代码块。首先,我们有一个 ID 为lobbydiv元素,其中我们动态添加了一个可用游戏房间的列表。接下来,有一个 ID 为maindiv元素,最初带有一个名为hidden的类,因此这个屏幕最初是不可见的。最后,我们包括了socket.io库以及我们的应用程序。

绑定到 HTML 结构的最简单方法是创建模块范围的全局变量,引用每个所需的节点。一旦这些引用就位,我们就可以附加必要的事件侦听器,以便玩家可以与界面交互:

// ch3/snake-ch3/share/app.client.js

var roomList = document.getElementById('roomList');
var screens = {
    main: document.getElementById('main'),
    lobby: document.getElementById('lobby')
};

// …

socket.on(gameEvents.client_roomsList, function (rooms) {
    rooms.map(function (room) {
        var roomWidget = document.createElement('div');
        roomWidget.textContent = room.players.length + ' player';
        roomWidget.textContent += (room.players.length > 1 ? 's' : '');

        roomWidget.addEventListener('click', function () {
            socket.emit(gameEvents.server_joinRoom, {
                    roomId: room.roomId,
                    playerId: player.id,
                    playerX: player.head.x,
                    playerY: player.head.y,
                    playerColor: player.color
                }
            );
        });

        roomList.appendChild(roomWidget);
    });

    var roomWidget = document.createElement('div');
    roomWidget.classList.add('newRoomWidget');
    roomWidget.textContent = 'New Game';

    roomWidget.addEventListener('click', function () {
        socket.emit(gameEvents.server_newRoom, {
            id: player.id,
            x: player.head.x,
            y: player.head.y,
            color: player.color,
            maxWidth: window.innerWidth,
            maxHeight: window.innerHeight
        });
    });

    roomList.appendChild(roomWidget);
});

socket.on(gameEvents.client_roomJoined, function (data) {
    // ...
    screens.lobby.classList.add('hidden');
    screens.main.classList.remove('hidden');
});

由于初始游戏屏幕是大厅,并且大厅的标记已经可见,我们不需要做其他设置。我们只需注册一个套接字回调,当我们收到可用房间列表时就调用它,并在准备好时将单独的 HTML 节点附加到 DOM 上。

在不同的套接字回调函数内部,这次是与roomJoined自定义事件相关联的回调函数,我们首先使大厅屏幕不可见,然后使主屏幕可见。我们通过添加和移除名为 hidden 的 CSS 类来实现这一点,其定义如下代码片段所示:

// ch3/snake-ch3/public/css/style.css

.hidden {
    display: none;
}

理解游戏循环

我们需要对原始游戏代码进行的下一组更改是在game类中。你会记得,这个类定义了一个基本的游戏生命周期,暴露了updaterender函数,由使用它的人来实现。

由于在这个类中定义的游戏循环的核心(在Game.prototype.loop中找到)使用了window.requestAnimationFrame,我们需要摆脱这个调用,因为它在 Node.js(或者在浏览器之外的任何其他环境)中都不可用。

通常用于允许我们灵活地编写一个既在浏览器中使用又在服务器中使用的单个模块的技术是将浏览器和服务器特定的函数封装在一个自定义模块中。

使用 Browserify,我们可以编写两个分开的模块,包装环境特定的功能,但在代码中只引用一个。通过配置 Browserify 属性,我们可以告诉它在看到对自定义包装模块的require语句时编译不同的模块。为简单起见,我们只在这里提到了这种能力,但在本书中我们不会深入讨论。相反,我们将编写一个单一组件,它可以在运行时自动检测所处的环境并做出相应的响应。

// ch3/snake-ch3/share/tick.js
var tick = function () {
    var ticks = 0;
    var timer;

    if (typeof requestAnimationFrame === 'undefined') {
        timer = function (cb) {
            setTimeout(function () {
                cb(++ticks);
            }, 0);
        }
    } else {
        timer = window.requestAnimationFrame;
    }

    return function (cb) {
        return timer(cb);
    }
};

module.exports = tick();

tick 组件由一个函数组成,根据window.requestAnimationFrame的可用性返回两个函数中的一个。这种模式一开始可能看起来有些混乱,但它的好处在于它只在初始设置之后检测环境一次,然后每次都根据环境进行特定功能。

请注意,我们从这个模块导出的是对tick的调用,而不仅仅是一个引用。这样,当我们需要这个模块时,在客户端代码中被引用的是tick返回的函数。在浏览器中,这将是对window.requestAnimationFrame的引用,在 node 中,它将是一个调用setTimeout的函数,通过向其传递一个递增的数字,类似于浏览器版本的tick

游戏客户端的游戏循环

现在,抽象的游戏循环类已经准备在任何环境中使用,让我们看看如何重构现有的客户端实现,以便它可以由连接到权威服务器的 socket 驱动。

请注意,我们不再确定何时生成新的水果。在客户端上,我们只检查如何移动玩家角色。我们可以让服务器告诉我们每一帧蛇在哪里,但这会使应用程序负担过重。我们也可以只在服务器同步状态时渲染主要蛇,但这会使整个游戏看起来非常慢。

我们所做的是在这里复制整个逻辑,并在同步时忽略服务器对其的说法。稍后,我们将讨论客户端预测;在那时,我们将在这里添加一些逻辑来纠正我们在服务器同步时发现的任何差异。

// ch3/snake-ch3/share/app.client.js

game.onUpdate = function (delta) {
    // The client no longer checks if the player has eaten a fruit.
    // This task has now become the server's jurisdiction.
    player.update(delta);
    player.checkCollision();

    if (player.head.x < 0) {
        player.head.x = parseInt(renderer.canvas.width / player.width, 10);
    }

    if (player.head.x > parseInt(renderer.canvas.width / player.width, 10)) {
        player.head.x = 0;
    }

    if (player.head.y < 0) {
        player.head.y = parseInt(renderer.canvas.height / player.height, 10);
    }

    if (player.head.y > parseInt(renderer.canvas.height / player.height, 10)) {
        player.head.y = 0;
    }

    if (fruits.length > 0) {
        if (player.head.x === fruits[0].x && player.head.y === fruits[0].y) {
            fruits = [];
            player.grow();
        }
    }
};

游戏服务器的游戏循环

这就是事情变得令人兴奋的地方。在我们为服务器端代码实现游戏循环之前,我们首先需要实现一个 API,客户端将使用它来查询服务器并发出其他命令。

在这个项目中使用express的一个好处是它与Socket.io非常配合。在本章后面专门介绍 Socket.io 之前,我们的主服务器脚本将如下所示:

// ch3/snake-ch3/app.js

// …

var io = require('socket.io')();
var gameEvents = require('./share/events.js');
var game = require('./server/app.js');

var app = express();
app.io = io;

// …

io.on('connection', function(socket){
    // when a client requests a new room, create one, and assign
    // that client to this new room immediately.
    socket.on(gameEvents.server_newRoom, function(data){
        var roomId = game.newRoom(data.maxWidth, data.maxHeight);
        game.joinRoom(roomId, this, data.id, data.x, data.y, data.color);
    });

    // when a client requests to join an existing room, assign that
    // client to the room whose roomId is provided.
    socket.on(gameEvents.server_joinRoom, function(data){
        game.joinRoom(data.roomId, this, data.playerId, data.playerX, data.playerY, data.playerColor);
    });

    // when a client wishes to know what all the available rooms are,
    // send back a list of roomIds, along with how many active players
    // are in each room.
    socket.on(gameEvents.server_listRooms, function(){
        var rooms = game.listRooms();
        socket.emit(gameEvents.client_roomsList, rooms);
    });
});

在默认的 Express app.js脚本中,我们导入了Socket.io,游戏事件模块,以及我们之前定义的游戏应用程序,这将在本章的其余部分中讨论。

接下来,在设置 Express 完成后,我们设置与客户端的 socket 通信。第一步是等待连接建立,这将使我们可以访问绑定到单个客户端的单个 socket。

一旦我们有了一个活跃的 socket,我们通过向每个事件注册自定义事件监听器来配置我们关心的所有事件。您会注意到,先前提到的一些示例事件监听器也会向请求的 socket 发出事件,而其他的则只是在游戏对象上调用方法。两种情况之间的区别在于,当我们只需要与单个客户端(请求的客户端)通信时,我们直接从事件监听器中联系该 socket。然而,有时我们可能希望与连接到同一房间的所有 socket 进行通信。在这种情况下,我们必须让游戏对象通知所有需要的玩家,因为它将知道所有属于给定房间的客户端。

大厅和房间系统

游戏房间和大厅的概念对多人游戏至关重要。为了理解其工作原理,可以将游戏服务器视为人们一起玩游戏的建筑物。

在进入建筑物之前,玩家可以站在建筑物前面,欣赏外墙的美丽。在我们的比喻中,凝视建筑物的前面相当于被游戏介绍的启动画面所欢迎。

进入建筑物后,玩家可能会看到一些选项供其选择,比如他或她可能想去的可用楼层的列表。在一些游戏中,您可以选择要玩的游戏类型以及难度级别。可以将此视为乘坐电梯到特定楼层。

最后,您到达了一个大厅。与现实生活中大厅的工作方式类似,在多人游戏中,大厅是多个玩家在进入进行游戏的特定房间之前去的一个特殊房间。在大厅中,您可以看到可用的房间,然后选择一个加入。

一旦您决定加入哪个房间,您现在可以进入该房间并与其他玩家一起参与现有游戏。或者,您可以加入一个空房间,并等待其他人加入。

通常情况下,多人游戏中永远不会有空房间。每个房间至少有一个玩家,并且每个玩家一次只能属于一个房间。一旦所有玩家离开房间,游戏服务器将删除该房间并释放相关资源。

实现大厅

通过对大厅的基本理解,我们可以以多种方式实现它。一般来说,大厅实际上是所有玩家在最终进入进行特定游戏的房间之前加入的一个特殊房间。

实现这一点的一种方法是将服务器中的所有套接字连接跟踪为一个数组。在实际操作中,这些套接字数组就是您的大厅。一旦玩家连接到大厅(换句话说,一旦玩家连接到您的服务器),他或她就可以与其他玩家进行通信,并可能成为大厅中其他玩家之间对话的观察者。

在我们的情况下,大厅简单明了。玩家在启动游戏时会自动分配到大厅。一旦进入大厅,玩家可以向服务器查询可用房间的列表。然后,玩家可以发出套接字命令加入现有房间或创建一个新房间:

// ch3/snake-ch3/server/app.js

var Game = require('./../share/game.js');
var gameEvents = require('./../share/events.js');
var Room = require('./room.js');

// ...

/** @type {Array.<Room>} */
var rooms = [];

module.exports = {
    newRoom: function(maxWidth, maxHeight){
        var room = new Room(FPS, maxWidth, maxHeight);
        rooms.push(room);
        return rooms.length - 1;
    },

    listRooms: function(){
        return rooms.map(function(room, index) {
            return {
                roomId: index,
                players: room.players.map(function(player){
                    return {
                        id: player.snake.id,
                        x: player.snake.head.x,
                        y: player.snake.head.y,
                        color: player.snake.color
                    };
                })
            };
        });
    },

    joinRoom: function(roomId, socket, playerId, playerX, playerY, playerColor) {
        var room = rooms[roomId];
        var snake = new Snake(playerId, playerX, playerY, playerColor, 1, 1);
        room.join(snake, socket);

        socket.emit(gameEvents.client_roomJoined, {roomId: roomId});
    },
};

请记住,我们的主服务器脚本公开了一个接口,套接字可以使用该接口与游戏服务器进行通信。前面提到的脚本是接口通信的后端服务。连接到服务器的实际套接字存储在并由 Socket.io 管理。

可用房间列表是作为一个Room对象数组实现的,我们将在下一节详细讨论。请注意,每个房间都需要至少两样东西。首先,房间需要一种方法来分组玩家并与这些玩家一起运行游戏。其次,房间需要一种方法,让客户端和服务器能够唯一识别每个单独的房间。

识别各个房间的两种简单方法是确保每个房间对象都有一个 ID 属性,该属性需要在整个游戏空间中是唯一的,或者我们可以使用存储房间的数组索引。

为简单起见,我们选择了第二种方法。请记住,如果我们删除一个房间并将其从房间数组中切割出来,一些玩家可能现在指向错误的房间 ID。

例如,假设数组中有三个房间,房间的 ID 分别为 0、1 和 2。假设每个房间都有几个玩家参与游戏。最后,想象一下,房间 ID 为 0 的所有玩家离开了游戏。如果我们将数组中的第一个房间切掉(存储在索引 0 处),那么数组中原来的第二个元素(以前存储在索引 1 处)将被移到数组的前面(索引 0)。数组中的第三个元素也会改变,将存储在索引 1 处而不是索引 2。因此,原来在房间 1 和 2 中的玩家现在将以相同的房间 ID 报告给游戏服务器,但服务器将把第一个房间报告为第二个房间,而第二个房间将不存在。因此,我们必须避免通过切掉空房间来删除它们。请记住,JavaScript 可以表示的最大整数是 2⁵³(等于 9,007,199,254,740,992),因此如果我们只是在房间数组的末尾添加新房间,我们不会用完数组中的槽位。

实现房间

游戏房间是一个模块,实现了游戏类并运行游戏循环。这个模块看起来与客户端游戏非常相似,因为它引用了玩家和水果对象,并在每个游戏时刻更新游戏状态。

您会注意到一个不同之处是服务器中没有渲染阶段。此外,房间将需要公开一些方法,以便服务器应用程序可以根据需要管理它。由于每个房间都引用了其中的所有玩家,服务器中的每个玩家都由套接字表示,因此房间可以联系到连接到它的每个玩家:

// ch3/snake-ch3/server/room.js

var Game = require('./../share/game.js');
var Snake = require('./../share/snake.js');
var Fruit = require('./../share/fruit.js');
var keys = require('./../share/keyboard.js');
var gameEvents = require('./../share/events.js');

/** @type {Game} game */
var game = null, gameUpdateRate = 1, gameUpdates = 0;
var players = [], fruits = [], fruitColor = '#c00';
var fruitDelay = 1500, lastFruit = 0, fruitDelta = 0;

var Room = function (fps, worldWidth, worldHeight) {
    var self = this;
    game = new Game(fps);

    game.onUpdate = function (delta) {
        var now = process.hrtime()[1];
        if (fruits.length < 1) {
            fruitDelta = now - lastFruit;

            if (fruitDelta >= fruitDelay) {
                var pos = {
                    x: parseInt(Math.random() * worldWidth, 10),
                    y: parseInt(Math.random() * worldHeight, 10)
                };

                self.addFruit(pos);
                players.map(function(player){
                    player.socket.emit(gameEvents.client_newFruit, pos);
                });
            }
        }

        players.map(function (player) {
            player.snake.update(delta);
            player.snake.checkCollision();

            if (player.snake.head.x < 0) {
                player.snake.head.x = worldWidth;
            }

            if (player.snake.head.x > worldWidth) {
                player.snake.head.x = 0;
            }

            if (player.snake.head.y < 0) {
                player.snake.head.y = worldHeight;
            }

            if (player.snake.head.y > worldHeight) {
                player.snake.head.y = 0;
            }

            if (fruits.length > 0) {
                if (player.snake.head.x === fruits[0].x
                    && player.snake.head.y === fruits[0].y) {
                    fruits = [];
                    player.snake.grow();
                }
            }
        });

        if (++gameUpdates % gameUpdateRate === 0) {
            gameUpdates = 0;
            var data = players.map(function(player){
                return player.snake;
            });
            players.map(function(player){
                player.socket.emit(gameEvents.client_playerState, data);
            });

            lastFruit = now;
        }
    };
};

Room.prototype.start = function () {
    game.start();
};

Room.prototype.addFruit = function (pos) {
    fruits[0] = new Fruit(pos.x, pos.y, fruitColor, 1, 1);
};

Room.prototype.join = function (snake, socket) {
    if (players.indexOf(snake.id) < 0) {
        players.push({
            snake: snake,
            socket: socket
        });
    }
};

Room.prototype.getPlayers = function(){
    return players;
};

module.exports = Room;

请注意,玩家数组保存了包含对蛇对象的引用以及实际套接字的对象文字列表。这样,两个资源在同一个逻辑位置上。每当我们需要对房间中的每个玩家进行 ping 时,我们只需映射玩家数组,然后通过player.socket.emit访问套接字。

此外,请注意,同步循环放置在主游戏循环内,但我们只在一定数量的帧经过后才触发同步循环内的逻辑。目标是定期同步所有客户端。

在游戏房间内匹配玩家

在我们将各种概念分解为简单的基本原理之后,您将看到实现每个模块并不像一开始听起来那么复杂。玩家匹配就是一个例子。

在游戏房间中,您可能希望以不同的方式匹配玩家。虽然我们的示例游戏没有进行任何复杂的匹配(我们允许玩家盲目匹配自己),但您应该知道这里有更多的选择。

以下是一些关于如何将玩家匹配到同一个游戏世界的想法。请记住,有第三方服务,比如谷歌的 Play 服务 API,可以帮助你处理这些问题。

邀请朋友进入你的世界

匹配玩家的最吸引人的方式之一利用了当今世界的社交方面。通过与社交网络服务集成(或使用由玩家填充的自己的社交网络),您可以让玩家选择邀请朋友一起玩。

虽然这可能是一种有趣的体验,但不言而喻的是,两个玩家必须同时在线才能进行游戏。通常,这意味着当玩家向他或她的朋友发送邀请时,会向朋友发送一封包含有关邀请信息的电子邮件。每当朋友加入游戏房间并且两个玩家准备好时,游戏就可以开始了。

这种技术的一个变种是只显示可用的朋友(即已经在线并且在大厅或游戏房间中的朋友)。这样游戏可以立即开始,或者在朋友退出当前游戏后立即开始。

自动匹配

也许,您没有社交网络可以利用,或者,玩家不在乎对手是谁。当您希望玩家能够快速进入并玩一局游戏时,自动匹配是一个很好的选择。

有更具体的方法可以自动匹配玩家(例如,根据他们的技能或其他标准自动匹配玩家),但在最基本的形式中,您需要为第一个玩家创建一个私人房间(私人房间指的是一个不列出供任何玩家加入的房间,只有游戏服务器知道它),然后等待匹配的玩家加入该房间。

基于技能的匹配

另一种常见的玩家匹配到同一游戏房间的方式是根据他们的技能水平将玩家分组。跟踪玩家的技能水平的方式至少可以有三种确定方式,即询问用户他或她的技能水平是什么,监控他们在单个会话期间的表现,或者在多个会话中持久化玩家的信息。

第一种选择是最容易实现的。通常的做法是通过显示一个菜单,其中有三个或更多的选项,要求玩家从这些选项中选择,例如业余、高级和摇滚明星。根据这个选择,然后您将尝试与同一组的其他玩家匹配。

这种方法的一个可能的好处是,没有与游戏的过去历史(从服务器的角度来看)的新玩家可以立即开始与更高级的玩家对战。另一方面,同样的功能也可能被认为是这种方法的一个缺点,因为真正高级的玩家可能只希望与同样技能水平的玩家对战,而可能会因为与声称拥有更高技能水平的较差玩家匹配而感到沮丧。

第二个选项是让每个人从同一水平开始(或者随机分配新玩家的第一个技能水平)。然后,随着更多的游戏进行,应用程序可以跟踪每个玩家的胜利和失败以及有关每个玩家的其他元数据,以便您可以将每个玩家分成当前的技能水平。

例如,一个玩家可能在一个初学者房间开始游戏。在赢得两场比赛并且没有输掉任何一场之后,您可以将这个玩家放入一个高级房间。在玩家玩了额外的两三场比赛并且赢得了两三场胜利之后,您现在可以认为这个玩家处于超高级水平。

这种方法的明显缺点是,它假设一个个体玩家会保持足够长的登录时间来进行多次游戏。根据您设计的游戏类型,大多数玩家甚至不会登录完成单个游戏会话。

然而,如果您的游戏是这种类型的理想选择(即单个游戏的持续时间不超过几分钟),那么这种匹配技术非常有效,因为您不需要编写任何长期持久性逻辑或需要验证用户。

最后,您可以通过在某种后端数据库中持久化他们的信息来跟踪玩家的技能水平。在大多数情况下,这将需要玩家拥有个人帐户,在游戏开始之前需要进行身份验证。

同样,在某些情况下,您可能希望使用现有的第三方服务来验证玩家,并可能在服务本身中持久化您生成的有关他们的信息。

虽然这可能会变得非常复杂和引人入胜,但基本概念很简单——计算一些可以用来推断玩家技能水平的分数,并将该信息存储在某个地方,以便以后可以检索。从这个角度来看,您可能可以通过使用 HTML5 的本地存储 API 在本地存储玩家当前的技能水平来实现这种持久性。这样做的主要缺点是,这些数据将被困在玩家的机器上,因此如果玩家使用不同的机器(或者清除本地存储数据),您将无法访问这些数据。

Socket.io

在第一章中,开始多人游戏编程,我们使用原生 HTML5 套接字实现了第一个演示游戏。尽管 WebSockets 仍然非常棒,但不幸的是,它们仍然严重依赖于玩家使用的特定浏览器。

今天,每个现代浏览器都配备了完整的 WebSockets 实现,特别是在移动设备上,世界似乎正在趋同。然而,可能有一种例外情况,用户的浏览器不完全支持 WebSockets,但支持 canvas(或者其他 HTML5 API),这时 Socket.io 就派上用场了。

简而言之,Socket.io 是一个开源库,提供了对套接字的出色抽象。不仅如此,Socket.io 还使实现前端套接字客户端将使用的后端服务变得非常容易。

实现服务器端代码就像指定连接的端口,然后实现您感兴趣的事件的回调一样容易。

现在,本书并不是想要掌握 Socket.io 的每个方面的全面指南,对于该库提供的许多功能,也不会过于描述。然而,您可能会发现 Socket.io 提供了令人惊叹的客户端支持。换句话说,如果使用套接字的浏览器没有实现 WebSockets 规范,那么 Socket.io 将回退到其他一些可以用于与服务器异步通信的技术。虽然其中一些技术可能对实时游戏来说太慢(例如,如果浏览器不支持其他技术,Socket.io 最终会回退到使用 HTML iFrames 与服务器通信),但了解该库的强大之处还是很有好处的。

安装 Socket.io

我们将通过 NPM 将 Socket.io 引入我们的项目。一定要使用本书中使用的版本(1.3.5),因为一些方法或配置可能会有所不同。

npm install socket.io --save
npm install socket.io-client –save

再次强调,由于我们使用 Express 框架来简化创建 Node.js 服务器的工作,我们将 Socket.io 与 Express 集成。

// ch3/snake-ch3/app.js

var express = require('express');
var io = require('socket.io')();

// ...

var app = express();
app.io = io;

// ...

io.on('connection', function(socket){
        console.log('New client connected. Socket ready!');
    });
});

我们需要做的第一件事是require Socket.io 以及 Express 和服务器脚本的所有其他依赖项。然后,我们利用 JavaScript 的动态特性将 Socket.io 添加到 Express 实例中。我们这样做是因为 Socket.io 还没有完全设置好,因为我们需要访问 Express 使用的 HTTP 服务器。在我们的情况下,按照当前标准,我们使用 Express Version 4.9.0 以及 express-generator,它会在<project-name>/bin/www下生成一个文件,其中进行低级服务器设置。这是我们将 Socket.io 集成到 Express 中的地方,通过将 Express 使用的相同服务器附加到我们的 Socket.io 实例中。

// ch3/snake-ch3/bin/www

#!/usr/bin/env node
var debug = require('debug')('snake-ch3');
var app = require('../app');

app.set('port', process.env.PORT || 3000);

var server = app.listen(app.get('port'), function() {
  debug('Express server listening on port ' + server.address().port);
});

app.io.attach(server);

客户端 Socket.io

最后一步是在我们的客户端 JavaScript 中使用 Socket.io 库。在这里,只有两个简单的步骤,如果你以前做过任何 JavaScript 编程,那么你肯定已经习惯了。

首先,我们将客户端库复制到我们的公共目录,以便我们可以将其包含到我们的客户端代码中。为此,将ch3/snake-ch3/node_modules/socket.io-client/socket.io.js文件复制到ch3/snake-ch3/public/js/socket.io.js。接下来,使用脚本标签在您的 HTML 文件中包含该库。

为了在客户端代码中开始使用套接字,你所需要做的就是通过需要它的域来实例化它,服务器正在运行的域。

// ch3/snake-ch3/share/app.client.js

var socket = require('socket.io-client')(window.location.origin);

// …

socket.on('connect', function () {
    socket.emit(gameEvents.server_listRooms);
});

现在,套接字将立即异步地尝试连接到您的服务器。一旦它这样做,连接事件将触发,相应的回调也将被触发,您将知道套接字已经准备好使用。从那时起,您可以开始向套接字的另一端发出事件。

总结

希望这一章让你对多人游戏开发的独特方面感到兴奋。我们将一个现有的单人贪吃蛇游戏分解成了一个权威服务器组件和一个由套接字驱动的前端组件。我们使用 Socket.io 将游戏客户端和服务器以非常无缝的方式与 Express 进行了链接。我们还讨论了游戏大厅和游戏房间的概念,以及将玩家匹配到同一个游戏世界的方法。

在下一章中,我们将通过添加客户端预测和校正以及输入插值来改进我们的贪吃蛇游戏,以减少网络延迟。我们还将修复游戏服务器的游戏循环,以实现更流畅和更高效的游戏。

第四章:减少网络延迟

现在我们有一个允许多个玩家在同一个或多个游戏房间中存在的工作游戏,我们将迭代并解决在线游戏中一个非常重要的问题,即网络延迟。考虑到你将需要在未来很多年里思考这个问题,我们将非常专注于本章涵盖的主题。

在本章中,我们将讨论以下原则和概念:

  • 处理多人游戏中的网络延迟

  • 在客户端实现本地游戏服务器

  • 客户端预测

  • 插值真实位置以纠正错误的预测

处理网络延迟

尽管你可能是那些拥有千兆互联网连接的幸运公民之一,但你应该知道世界上大多数地方肯定不那么幸运。因此,在开发在线多人游戏时需要牢记的一些最重要的事情是,并非所有玩家都拥有相同的网络速度,也并非所有玩家都拥有高速连接。

从本节中你需要记住的主要观点是,只要玩家和游戏服务器之间存在网络(或者两个玩家直接连接在一起),就会存在延迟。

的确,并非所有游戏都需要在网络上具有几乎即时的响应时间,例如,回合制游戏,比如国际象棋,或者我们的贪吃蛇实现,因为游戏的 tick 比大多数动作游戏要慢得多。然而,对于实时、快节奏的游戏,即使是 50 毫秒的小延迟也会使游戏变得非常卡顿和令人讨厌。

想象一下这种情况。你按下键盘上的右箭头键。你的游戏客户端告诉服务器你的意图是向右移动。服务器最终在 50 毫秒后收到你的消息,运行其更新周期,并告诉你将你的角色放在位置(23,42)。最后,另外 50 毫秒后,你的客户端接收到服务器的消息,按下键盘的那一刻,你的玩家开始朝着你想要的位置移动。

处理网络延迟

正如前几章中提到的,网络延迟问题最常用的解决方案是改变客户端逻辑,使其能够立即响应用户输入,同时向服务器更新其输入。然后,权威服务器根据每个客户端的输入更新自己的游戏状态,最后向所有客户端发送游戏世界当前状态的版本。然后这些客户端可以更新自己,以便与服务器同步,整个过程继续进行。

处理网络延迟

因此,正如你可能已经意识到的那样,目标根本不是消除延迟,因为这在物理上是不可能的,而只是将其隐藏在一个不断更新的游戏后面,以便玩家产生游戏正在实时由服务器更新的错觉。

只要玩家觉得游戏反应灵敏,并且表现符合玩家的期望,从实际目的来看,你已经解决了网络延迟问题。在与服务器的每次通信(或者从服务器到客户端),问问自己延迟在哪里,以及如何通过保持游戏进行来隐藏它,而数据包在传输过程中。

在同步客户端中锁定步伐

到目前为止,我们已经讨论了客户端-服务器结构,其中服务器是游戏的最终权威,客户端对游戏逻辑几乎没有或根本没有权威。换句话说,客户端只是接受玩家的任何输入,并将其传递给服务器。一旦服务器向客户端发送更新的位置,客户端就会渲染游戏状态。

在线多人游戏中常用的另一种模型是锁步方法。在这种方法中,客户端尽可能频繁地告诉服务器有关玩家收到的任何输入。然后服务器将此输入广播给所有其他客户端。然后客户端依次使用每个参与者的输入状态进行下一个更新周期,并且理论上,每个人最终都会得到相同的游戏状态。每当服务器进行锁步(从每个客户端的输入数据运行物理更新)时,我们称之为一个回合。

为了使服务器保持对游戏的最终控制权,服务器的模拟也会运行更新周期,并且模拟的输出也会广播给客户端。如果客户端的更新状态与服务器发送的状态不同,客户端会认为服务器的数据是正确的,并相应地更新自己。

固定时间步

我们在服务器代码中将要更新的第一件事是游戏循环,它将做的第一件不同的事情是不再有增量时间的概念。此外,我们需要在更新周期之间排队每个客户端的所有输入,以便在运行物理更新时,我们有数据来更新游戏状态。

由于我们现在使用了一致的时间步长,我们不需要在服务器上跟踪增量时间。因此,服务器在客户端的角度也没有增量时间的概念。

例如,想象一个赛车游戏,玩家以每秒 300 像素的速度驾驶。假设这个特定的客户端以每秒 60 帧的频率运行游戏。假设汽车在整个秒内保持稳定的速度,那么经过 60 帧,汽车将行驶 300 像素。此外,在每帧期间,汽车将平均行驶 5 像素。

现在,假设服务器的游戏循环配置为每秒 10 帧,或者每 100 毫秒运行一次。汽车现在每帧将行驶更远(30 像素而不是 5 像素),但最终,它也将比一秒前行驶 300 像素。

固定时间步

总之,虽然客户端仍然需要跟踪处理单个帧需要多长时间,以便所有客户端以相同的速度运行,但是服务器的游戏循环不关心这一切,因为它不需要关心。

// ch4/snake-ch4/share/tick.js

var tick = function (delay) {
    var _delay = delay;
    var timer;

    if (typeof requestAnimationFrame === 'undefined') {
        timer = function (cb) {
            setImmediate(function () {
                cb(_delay);
            }, _delay);
        }
    } else {
        timer = window.requestAnimationFrame;
    }

    return function (cb) {
        return timer(cb);
    }
};

module.exports = tick;

在这里,我们首先更新了我们为重用服务器代码以及发送到浏览器的代码而构建的 tick 模块。请注意使用setImmediate而不是setTimeout,因为回调函数在执行队列中提前调度,理论上会更快。

此外,观察我们如何导出包装器 tick 函数,而不是它返回的闭包。这样我们可以在导出函数之前配置服务器的计时器。

最后,由于增量时间现在是可预测和一致的,我们不再需要 tick 的变量来模拟时间的流逝。现在,我们可以在每次 tick 之后直接将间隔值传递给回调函数。

// ch4/snake-ch4/share/game.js

var tick = require('./tick.js');
tick = tick(100);

var Game = function (fps) {
    this.fps = fps;
    this.delay = 1000 / this.fps;
    this.lastTime = 0;
    this.raf = 0;

    this.onUpdate = function (delta) {
    };

    this.onRender = function () {
    };
};

Game.prototype.update = function (delta) {
    this.onUpdate(delta);
};

Game.prototype.render = function () {
    this.onRender();
};

Game.prototype.loop = function (now) {
    this.raf = tick(this.loop.bind(this));

    var delta = now - this.lastTime;
    if (delta >= this.delay) {
        this.update(delta);
        this.render();
        this.lastTime = now;
    }
};

您唯一会注意到的区别是tick模块被调用的频率与传入的频率相同,因此我们可以配置它的运行速度。

注意

您可能会想知道为什么我们选择了服务器游戏循环每秒 10 次更新的可能任意的数字。请记住,我们的目标是让玩家相信他们实际上正在与其他玩家一起玩一个很棒的游戏。

我们可以通过精心调整服务器以快速更新,以便准确度不会太偏离,同时又足够慢以使客户端可以以不太明显的方式移动,从而实现这种实时游戏的错觉。

您需要在提供准确游戏状态的权威服务器和客户端提供对玩家的响应体验之间找到平衡。您更新客户端的频率越高,来自服务器更新周期的数据的模拟就越不准确;这取决于模拟需要处理多少数据,并且可能会在途中丢弃数据以保持高更新频率。同样,您更新客户端的频率越低,客户端的响应性就越低,因为它需要在服务器上等待更长时间,直到确定正确的游戏状态。

同步客户端

由于服务器不断推送有关游戏世界当前状态的更新,我们需要一种方式让客户端消耗和利用这些数据。实现这一点的简单方法是在游戏类之外保存最新的服务器状态,并在数据可用时更新自身,因为它不会在每次更新tick时都存在。

// ch4/snake-ch4/share/app.client.js

// All of the requires up top
// …

var serverState = {};

// …

socket.on(gameEvents.client_playerState, function(data){
    otherPlayers = data.filter(function(_player){

        if (_player.id == player.id) {
            serverState = _player;
            return false;
        }

        _player.width = BLOCK_WIDTH;
        _player.height = BLOCK_HEIGHT;
        _player.head.x = parseInt(_player.head.x / BLOCK_WIDTH, 10);
        _player.head.y = parseInt(_player.head.y / BLOCK_HEIGHT, 10);
        _player.pieces = _player.pieces.map(function(piece){
            piece.x = parseInt(piece.x / BLOCK_WIDTH, 10);
            piece.y = parseInt(piece.y / BLOCK_HEIGHT, 10);

            return piece;
        });

        return true;
    });
});

在这里,我们将serverState变量声明为模块范围的全局变量。然后,我们修改了套接字监听器,当服务器更新其他所有玩家的状态时,我们现在寻找代表英雄的玩家的引用,并将其存储在全局serverState变量中。

有了这个全局状态,我们现在可以在客户端的更新方法中检查其存在并相应地采取行动。如果在给定的更新周期开始时状态不存在,我们就像以前一样更新客户端。如果来自服务器的世界状态确实在下一个客户端更新tick开始时对我们可用,我们就可以将客户端的位置与服务器同步。

// ch4/snake-ch4/share/app.client.js

game.onUpdate = function (delta) {

    if (serverState.id) {
        player.sync(serverState);

        // On subsequent ticks, we may not in sync any more,
        // so let's get rid of the serverState after we use it
        if (player.isSyncd()) {
            serverState = {};
        }
    } else {
        player.update(delta);
        player.checkCollision();

        if (player.head.x < 0) {
            player.head.x = parseInt(renderer.canvas.width / player.width, 10);
        }

        if (player.head.x > parseInt(renderer.canvas.width / player.width, 10)) {
            player.head.x = 0;
        }

        if (player.head.y < 0) {
            player.head.y = parseInt(renderer.canvas.height / player.height, 10);
        }

        if (player.head.y > parseInt(renderer.canvas.height / player.height, 10)) {
            player.head.y = 0;
        }
    }
};

Player.prototype.sync的实际实现将取决于我们的错误校正策略,这将在接下来的几节中描述。最终,我们将希望同时整合传送和插值,但现在,我们只需检查是否需要任何错误校正。

// ch4/snake-ch4/share/snake.js

var Snake = function (id, x, y, color_hex, width, height) {
    this.id = id;
    this.color = color_hex;
    this.head = {
        x: x,
        y: y
    };
    this.pieces = [this.head];
    this.width = width || 16;
    this.height = height || 16;
    this.readyToGrow = false;
    this.input = {};

    this.inSync = true;
};

Snake.prototype.isSyncd = function(){
    return this.inSync;
};

Snake.prototype.sync = function(serverState) {
    var diffX = serverState.head.x - this.head.x;
    var diffY = serverState.head.y - this.head.y;

    if (diffX === 0 && diffY === 0) {
        this.inSync = true;
        return true;
    }

    this.inSync = false;

    // TODO: Implement error correction strategies here

    return false;
};

snake类的更改非常直接。我们添加一个标志,让我们知道在单个更新周期后是否仍需要与服务器状态同步。这是必要的,因为当我们决定在两个点之间进行插值时,我们需要多个更新周期才能到达那里。接下来,我们添加一个方法,用于验证玩家是否与服务器同步,这将决定snake如何更新给定的帧。最后,我们添加一个执行实际同步的方法。现在,我们只是检查是否需要更新我们的位置。随着我们讨论不同的错误校正策略,我们将更新Snake.prototype.sync方法以利用它们。

使用本地游戏服务器预测未来

我们将使用的策略是使客户端响应灵活,但受限于权威服务器,我们将根据玩家的输入来告诉服务器。换句话说,我们需要接收玩家的输入并预测由此对游戏状态的影响,同时等待服务器返回玩家行动的实际输出。

客户端预测可以总结为您对权威更新之间应该发生的事情的最佳猜测。换句话说,我们可以在客户端重用一些更新游戏世界的服务器代码,以便我们对玩家输入的输出应该是与服务器模拟的几乎相同。

报告用户输入

我们将改变客户端的控制机制。我们不仅会在本地跟踪我们的位置,还会通知服务器玩家按下了一个键。

// ch4/snake-ch4/share/app.client.js

document.body.addEventListener('keydown', function (e) {
    var key = e.keyCode;

    switch (key) {
        case keys.ESC:
            game.stop();
            break;

        case keys.SPACEBAR:
            game.start();
            break;

        case keys.LEFT:
        case keys.RIGHT:
        case keys.UP:
        case keys.DOWN:
            player.setKey(key);
            socket.emit(gameEvents.server_setPlayerKey, {
                    roomId: roomId,
                    playerId: player.id,
                    keyCode: key
                }
            );

            break;
    }
});

当然,直接在事件处理程序的回调中执行这个操作可能会很快地使服务器不堪重负,所以一定要及时向上报告。一种方法是使用tick更新来联系服务器。

// ch4/snake-ch4/share/app.client.js

game.onUpdate = function (delta) {
    player.update(delta);
    player.checkCollision();

    // …

    socket.emit(gameEvents.server_setPlayerKey, {
            roomId: roomId,
            playerId: player.id,
            keyState: player.input
        }
    );
};

现在,我们以与本地模拟相同的频率更新服务器,这不是一个坏主意。然而,你可能还要考虑将所有网络逻辑放在game类(updaterender方法)之外,以便将游戏的网络方面完全抽象出来。

为此,我们可以将 socket 发射器直接放回到控制器的事件处理程序中;但是,我们不会立即调用服务器,而是使用定时器来保持更新一致。这个想法是,当按下一个键时,我们立即用更新调用服务器。如果用户在一段时间内再次按下一个键,我们会等待一段时间再次调用服务器。

// ch4/snake-ch4/share/app.client.js

// All of the requires up top
// …

var inputTimer = 0;
var inputTimeoutPeriod = 100;

// …

document.body.addEventListener('keydown', function (e) {
    var key = e.keyCode;

    switch (key) {
        case keys.ESC:
            game.stop();
            break;

        case keys.SPACEBAR:
            game.start();
            break;

        case keys.LEFT:
        case keys.RIGHT:
        case keys.UP:
        case keys.DOWN:
            player.setKey(key);

            if (inputTimer === 0) {
                inputTimer = setTimeout(function(){
                    socket.emit(gameEvents.server_setPlayerKey, {
                            roomId: roomId,
                            playerId: player.id,
                            keyCode: key
                        }
                    );
                }, inputTimeoutPeriod);
            } else {
                clearTimeout(inputTimer);
                inputTimer = 0;
            }

            break;
    }
});

在这里,inputTimer变量是对我们使用setTimeout创建的定时器的引用,我们可以随时取消,直到定时器实际触发。这样,如果玩家非常快地按下许多键(或者长时间按住一个键),我们可以忽略额外的事件。

这种实现的一个副作用是,如果玩家长时间按住同一个键,包裹对socket.emit调用的定时器将继续被取消,服务器将永远不会收到后续按键的通知。虽然这乍一看可能是一个潜在的问题,但实际上这是一个非常受欢迎的特性。首先,在这个特定游戏的情况下,按两次或更多次相同的键没有效果,我们真的不需要向服务器报告额外的按键。其次(这对任何其他类型的游戏也适用),我们可以让服务器假设,在玩家按下右箭头键后,右键仍然被按下,直到我们告诉服务器停止。由于我们的Snake游戏没有按键释放的概念(这意味着蛇将一直朝着最后按下的方向移动,直到我们改变它的方向),服务器将继续以给定的方向移动蛇,直到我们按下不同的键并告诉服务器以新的方向移动。

错误校正

一旦服务器获得了每个玩家的输入状态、位置和意图,它就可以进行锁步转向并更新整个游戏世界。因为在个别玩家进行移动时,他或她只知道在特定客户端发生了什么,可能会出现的情况之一是另一个玩家可能会在他们的本地客户端以一种导致两个玩家之间发生冲突的方式进行游戏。也许,只有一个水果,两个玩家同时试图接近它,或者可能是另一个玩家撞到了你,你现在要承受一些伤害。

这就是权威服务器发挥作用的地方,让所有客户端保持一致。每个客户端在孤立状态下预测的结果现在应该与服务器确定的结果相匹配,这样每个人都可以看到游戏世界处于相同的状态。

这是一个经典的例子,说明网络延迟可能会妨碍有趣的多人游戏体验。假设两个玩家(玩家 A 和玩家 B)开始朝着同一个水果前进。根据每个玩家的模拟,他们都是从相反的方向来到水果,现在只有几帧的距离。如果没有一个玩家改变方向,他们将在完全相同的帧数到达水果。假设在玩家 A 吃掉水果之前的一帧,他因为某种原因决定改变方向。由于玩家 B 在几帧内没有从服务器获取玩家 A 的更新状态和位置,他可能会认为玩家 A 确实要吃水果,因此玩家 B 的模拟将显示玩家 A 吃水果并得分。

考虑到前面的情况,当服务器发送下一轮输出,显示玩家 A 避开了水果并没有得到任何分数时,玩家 B 的模拟应该怎么做?实际上,现在两个状态不同步(玩家 B 的模拟和服务器之间),所以玩家 B 应该与服务器更好地同步。

按照意图进行游戏,而不是结果

处理之前提到的情况的常见方法是包括某种动画,客户端可以根据其对玩家意图和游戏世界当前状态的了解立即开始。在我们的特定情况下,当玩家 B 认为玩家 A 即将抓住水果并获得一些分数时,他或她的模拟可以开始一个动画序列,表明玩家 A 即将通过吃水果升级。然后,当服务器回应并确认玩家 A 实际上没有吃水果时,玩家 B 的客户端可以回退到一些次要动画,表示水果未被触摸。

那些喜欢《光环》的人可能已经在与朋友进行在线游戏时注意到了这一点。当客户端尝试在游戏中扔手榴弹时,客户端会立即通知服务器。服务器然后会运行一系列测试和检查,以确保这是一个合法的举动。最后,服务器会回应客户端,告知其是否允许继续扔手榴弹。与此同时,在服务器确认客户端可以扔手榴弹时,客户端开始播放玩家扔手榴弹时的动画序列。如果这没有得到检查(也就是说,服务器没有及时回应),玩家会完成向前挥动手臂的动作,但什么也没有扔出去,在这种情况下,看起来就像是一个正常的动作。[AldridgeDavid (2011)我先开枪:网络化《光环:Reach》的游戏玩法。GDC 2011]

多接近才算足够接近?

另一个用例是,客户端具有游戏的当前状态以及玩家的输入信息。玩家运行下一轮的模拟并在某个位置渲染蛇。几帧后,服务器告诉客户端蛇实际上在不同的位置。我们该如何解决这个问题?

在需要改变玩家位置的情况下,如果玩家将蓝色机器人投入空中并越过底部有尖刺的坑,然后几帧后(在服务器同步所有客户端之后),我们突然看到机器人离玩家预期的位置几个像素远,可能会看起来很奇怪。然而,另一方面,有些情况下,从服务器的更新所需的调整很小,以至于简单地将玩家从 A 点传送到 B 点是不可察觉的。这将严重依赖于游戏的类型和个体情况。

多接近才算足够接近?

为了我们的贪吃蛇游戏,如果我们确定我们的预测与服务器告诉我们蛇应该在的位置之间的差异只有一个单位(不是两个轴都有偏差),除非头部在两个轴上都有一个单位的偏差,但调整其中一个轴会使我们处于蛇的脖子上,那么我们可以选择传送。这样,玩家只会看到蛇的头部位置变化了一个位置。

例如,如果我们的预测将玩家的头放在点(8,15),而蛇是从右向左移动,但服务器的更新显示它应该在点(7,16),我们不会传送到新的点,因为那需要调整两个轴。

然而,如果蛇仍然向左移动,其头部现在位于点(8,15),而服务器更新将其放在点(7,15),(8,14),(8,16),(9,15),(9,14)或(9,16),我们可以简单地将头部瞬间移动到新点,然后在下一次更新时,蛇的其余部分将被重新定位。

多接近算够接近?

// ch4/snake-ch4/share/snake.js

Snake.prototype.sync = function(serverState) {
    var diffX = serverState.head.x - this.head.x;
    var diffY = serverState.head.y - this.head.y;

    if (diffX === 0 && diffY === 0) {
        this.inSync = true;
        return true;
    }

    this.inSync = false;

    // Teleport to new position if:
    //   - Off by one in one of the axis
    //   - Off by one in both axes, but only one unit from the neck
    if ((diffX === 0 && diffY === 1)
           || (diffX === 1 && diffY === 0)
           || (this.pieces[0].x === serverState.head.x && diffY === 1)
           || (this.pieces[0].y === serverState.head.y && diffX === 1)
    ){

        this.head.x = serverState.head.x;
        this.head.y = serverState.head.y;

        this.inSync = false;
        return true;
    }

    // TODO: Implement interpolation error correction strategy here

    return false;
};

您会注意到瞬间移动可能会使蛇的头部重叠,这在正常情况下会导致玩家输掉游戏。然而,当这种情况发生时,游戏不会再次检查碰撞,直到下一帧更新。此时,头部将首先向前移动,这将重新调整蛇的其余部分,从而消除任何可能的碰撞。

流畅的用户体验

调整玩家当前位置和服务器设置的位置之间的另一种方法是通过多帧逐渐平滑地移动到该点。换句话说,我们在当前位置和想要到达的位置之间进行插值。

插值的工作原理很简单,如下所述:

  1. 首先确定您希望插值需要多少帧。

  2. 然后确定每个方向上每帧需要移动多少单位。

  3. 最后,在每帧中移动一点,直到在所需的帧数内到达目标点。

基本上,我们只是按照所需时间的相同百分比向目标点移动相应的百分比。换句话说,如果我们希望在 10 帧内到达目标位置,那么在每一帧我们就移动总距离的 10%。因此,我们可以将以下公式抽象出来:

a = (1 – t) * b + t * c

在这里,t是一个介于零和一之间的数字,表示 0%到 100%之间的百分比值(这是起点和目标点之间的当前距离)。

流畅的用户体验

我们可以直接在snake类中实现线性插值方法;然而,您内心中那个执着的面向对象的设计师可能会认为,这种数学过程更适合放在一个完全独立的实用程序类中,该类被snake类导入并使用。

// ch4/snake-ch4/share/snake.js

Snake.prototype.interpolate = function(currFrame, src, dest, totalFrames) {
    var t = currFrame / totalFrames;

    return (1 - t) * src + dest * totalFrames ;
};

这种插值方法将使用(除了源点和目标点)动画中的当前帧以及动画将持续的总帧数。因此,我们需要一种方法来跟踪当前帧,并在希望重新开始动画时将其重置为零。

重置插值序列的好地方是在socket回调中,这是我们首次得知可能需要向不同位置插值的地方。

// ch4/snake-ch4/share/app.client.js

socket.on(gameEvents.client_playerState, function(data){
    otherPlayers = data.filter(function(_player){

        if (_player.id == player.id) {
            serverState = _player;
            serverState.currFrame = 0;

            return false;
        }

        return true;
    });
});

然后,我们还需要更新snake类,以便我们可以配置每个插值周期可以处理的最大帧数。

// ch4/snake-ch4/share/snake.js

var Snake = function (id, x, y, color_hex, width, height, interpMaxFrames) {
    this.id = id;
    this.color = color_hex;
    this.head = {x: x, y: y};
    this.pieces = [this.head];
    this.width = width || 16;
    this.height = height || 16;
    this.interpMaxFrames = interpMaxFrames || 3;
    this.readyToGrow = false;
    this.input = {};
    this.inSync = true;
};

有了这个方法,我们现在可以在我们的sync方法中实现线性插值,这样蛇就可以在几帧的时间内平滑地插值到其实际位置。您可以根据需要选择到达目标位置所需的帧数,也可以根据游戏的个别情况将其保持不变。

// ch4/snake-ch4/share/snake.js

Snake.prototype.sync = function(serverState) {
    var diffX = serverState.head.x - this.head.x;
    var diffY = serverState.head.y - this.head.y;

    if (diffX === 0 && diffY === 0) {
        this.inSync = true;

        return true;
    }

    this.inSync = false;

    // Teleport to new position if:
    //   - Off by one in one of the axis
    //   - Off by one in both axes, but only one unit from the neck
    if ((diffX === 0 && diffY === 1) ||
        (diffX === 1 && diffY === 0) ||
        (this.pieces[0].x === serverState.head.x && diffY === 1) ||
        (this.pieces[0].y === serverState.head.y && diffX === 1)) {

        this.head.x = serverState.head.x;
        this.head.y = serverState.head.y;

        this.inSync = true;

        return true;
    }

    // Interpolate towards correct point until close enough to teleport
    if (serverState.currFrame < this.interpMaxFrames) {
        this.head.x = this.interpolate(
            serverState.currFrame,
            this.head.x,
            serverState.head.x,
            this.interpMaxFrames
        );
        this.head.y = this.interpolate(
            serverState.currFrame,
            this.head.y,
            serverState.head.y,
            this.interpMaxFrames
        );
    }

    return false;
};

最后,您会注意到,在我们当前的客户端-服务器设置中,客户端接收其他玩家的确切位置,因此不会对它们进行预测。因此,它们的位置始终与服务器同步,不需要错误校正或插值。

总结

本章的重点是减少权威服务器和运行它的客户端之间的感知延迟。我们看到了客户端预测如何可以在服务器确定玩家请求的移动和意图的有效性之前,为玩家提供即时反馈。然后,我们看了如何在服务器上使用锁步方法,以便所有客户端一起更新,并且每个客户端还可以确定性地重现游戏服务器计算出的相同世界状态。

最后,我们看了两种纠正错误客户端预测的方法。我们实现的方法是传送和线性插值。使用这两种错误校正方法可以让我们向玩家展示他们的输入应该产生的结果的一个近似,但也确保他们的多人游戏体验准确且与其他玩家的体验相同。

在下一章中,我们将迈向未来,并尝试一些较新的 HTML5 API,包括 Gamepad API,它将允许我们放弃键盘,使用更传统的游戏手柄来控制我们的游戏,全屏模式 API 和 WebRTC,它将允许我们进行真正的点对点游戏,并暂时跳过客户端-服务器模型,以及更多。

第五章:利用前沿技术

到目前为止,在本书中,我们已经集中讨论了与多人游戏开发相关的主题。这一次,除了WebRTC之外,我们将讨论一些 HTML5 中最新的 API,它们本身与多人游戏几乎没有关系,但在游戏开发的背景下提供了很多机会。

在本章中,我们将讨论以下原则和概念:

  • 使用 WebRTC 直接连接对等方

  • 为基于浏览器的游戏添加游戏手柄

  • 全屏模式下最大化您的游戏

  • 访问用户的媒体设备

HTML5-最终前沿

尽管我们在本章中将要尝试的技术令人兴奋并且非常有前途,但我们还不能过于依赖它们。至少,我们必须谨慎地使用这些 API,因为它们仍然处于实验阶段,或者规范仍处于工作草案或候选推荐阶段。换句话说,截至目前为止,在本书出版后的可预见的未来,每个功能的浏览器支持可能会有所不同,支持每个功能的 API 在不同浏览器上可能会略有不同,而 API 的未来可能是不确定的。

万维网联盟W3C)定义了每个规范在成为最终、稳定并被视为 W3C 标准之前经历的四个开发阶段(也称为成熟级别)。这四个阶段是工作草案候选推荐提议推荐W3C 推荐

初始级别是工作草案,社区在这一级别讨论了提议的规范并定义了他们试图实现的精确细节。在这个级别上,推荐是非常不稳定的,它的最终发布几乎是不确定的。

接下来是候选推荐级别,在这个级别上从实施推荐中获取反馈。在这里,标准仍然不稳定并且可能会发生变化(或者像有时候一样被废弃),但它的变化频率比在工作草案阶段要低。

一旦规范文档作为候选推荐发布,W3C 的咨询委员会将审查提案。如果自审查期开始以来已经过去至少四周,并且文档已经得到了社区和实施者的足够认可,那么文档将被转发为推荐发布。

最后,当一个规范成为 W3C 推荐时,它将携带 W3C 的认可标志作为认可标准。遗憾的是,即使在这一点上,也不能保证浏览器会支持标准或根据规范实施它。然而,在我们这个时代,所有主要的浏览器都非常好地遵循规范,并实施所有有用的标准。

使用全屏模式最大化您的游戏

在本章中我们将讨论的所有 API 中,全屏是最容易理解和使用的。正如你可能已经猜到的那样,这个 API 允许你设置一个可以在全屏模式下呈现的 HTML 元素节点。

请注意,尽管全屏模式的第一个编辑草案(推荐标准成为工作草案之前的成熟级别)于 2011 年 10 月发布,但规范仍处于早期起草阶段。(有关更多信息,请参阅以下文章:使用全屏模式(2014 年 7 月)developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Using_full_screen_mode)。

至于当前浏览器支持情况,您会发现在所有现代浏览器中使用 API 是相当安全的,尽管今天在实现上有细微差异以及如何启用全屏模式也有所不同。

在使用全屏模式时要牢记的主要事项是,您必须将单个元素设置为全屏模式。这个元素确实可以有一组元素节点的子树,但您仍然需要在特定元素上启用全屏模式。在游戏开发的背景下,您很可能会将主画布元素设置为全屏,但这不是一个硬性要求。您也可以要求浏览器通过在 body 元素上调用requetFullscreen()方法使整个文档进入全屏模式。

设置元素进入全屏模式和将元素退出全屏模式涉及两种方法,分别是requestFullscreenexitFullscreen方法。请注意,截至目前,所有主要浏览器都在其各自的供应商前缀下实现了这些方法。

此外,请记住,除非用户发起的事件向浏览器发出请求,否则无法启用全屏模式。换句话说,你不能在 DOM 加载后立即尝试将 body 元素更改为全屏。同样,你也不能以编程方式触发 DOM 事件(例如在页面上触发虚假点击或使用 JavaScript 滚动页面,从而触发onScroll事件),并使用事件处理程序回调来欺骗浏览器,让它认为是用户发起了该操作。

<!doctype html>
<html>
<head>
    <title> Fullscreen</title>
    <!-- [some custom CSS here, left out for brevity] -->
</head>
<body>
<ul>
    <li>
        <span>1</span>
    </li>
    <li>
        <span>O</span>
    </li>
    <li>
        <span>O</span>
    </li>
    <li>
        <span>1</span>
    </li>
</ul>
<script>
    var list = document.querySelector('ul');

    list.addEventListener('click', function (event) {
        var block = event.target;
        block.requestFullscreen();
    });
</script>
</body>
</html>

上面的代码演示了如何在元素接收到点击后将其设置为全屏模式。在这种情况下,您可能已经注意到,我们假设无论哪个浏览器执行该代码都已经放弃了他们的供应商支持,因此我们可以简单地调用requestFullscreen(),就像它原本的意图一样。

今天处理这个问题的更好方法是,由于浏览器尚未实现不带供应商前缀的 API 规范,因此使用 polyfill 或辅助函数来检测是否需要供应商前缀,并执行必要的操作使其正常工作。

var reqFullscreen = (function () {
    var method = (function () {
        var el = document.createElement('div');
        var supported = '';
        var variations = [
            'requestFullscreen',
            'msRequestFullscreen',
            'mozRequestFullScreen',
            'webkitRequestFullscreen'
        ];

        variations.some(function (method) {
            supported = method;
            return el[method] instanceof Function;
        });

        return supported;
    }());

    return function (element) {
        element[method]();
    };
}());

var list = document.querySelector('ul');

list.addEventListener('click', function (event) {
    var block = event.target;
    reqFullscreen(block);
});

上面的示例代码创建了一个名为 reqFullscreen 的函数,它通过确定是否需要供应商前缀来为我们做繁重的工作;然后它记住了需要进行全屏请求的版本。然后,当我们希望元素进入全屏模式时,我们通过将其传递给该函数来调用该函数。

注意

似乎浏览器制造商的目标是尽可能让实验性 API 对最终用户造成困惑。在全屏模式的情况下,请注意规范将接口函数命名为requestFullscreenexitFullscreen(其中Fullscreen一词仅大写第一个字母)。

除了 Mozilla Firefox 之外,每个供应商前缀都遵循规范,关于函数名称——即webkitRequestFullscreenmsRequestFullscreen。Mozilla Firefox 不同,因为它实现了mozRequestFullScreen,这与其他供应商不同,因为它在驼峰命名法中将FullScreen拼写为两个单词。最后一个细节是,Webkit 的开发人员决定同时实现两个版本:webkitRequestFullscreenwebkitRequestFullScreen,以取悦所有人。

使用全屏模式最大化您的游戏

在上面的图像中,我们的页面不处于全屏模式。但是,当您单击其中一个元素时,该元素将以全屏模式呈现:

使用全屏模式最大化您的游戏

您可能会注意到,浏览器强加的唯一要求是必须由用户操作发起请求以启用全屏模式。这并不意味着操作必须在设置为全屏的相同元素上,就像下面的例子所示:

var list = document.querySelector('ul');
var btn = document.querySelector('button');

btn.addEventListener('click', function (event) {
    // Somehow determine what element to use
    var firstBlock = list.children[0].children[0];

    reqFullscreen(firstBlock);
});

前面的示例绑定到一个按钮元素,然后添加一个点击处理程序,将一些其他元素设置为全屏模式。

我们可以通过查找文档对象的一个自动更新的属性来检查特定元素是否处于全屏模式。

var element = document.webkitFullscreenElement;

当您运行上述语句时,它将返回对当前处于全屏模式的任何元素的引用;否则,它将返回一个空值。

我们还可以查询文档,测试文档是否可以启用全屏。

var canFullscreen = document.webkitFullscreenEnabled; // => bool

最后,有一个特殊的 CSS 伪选择器,允许我们定位全屏中的元素。同样,这个选择器目前也是供应商前缀的。

full-screen,
:-moz-full-screen,
:-moz-full-screen-ancestor,
:-webkit-full-screen {
    font-size: 50vw;
    line-height: 1.25;
    /* … */
}

请注意,选择器会定位调用requestFullscreen的元素。在前面的示例中,指定的样式适用于ul li span

更好地使用游戏手柄进行控制

在过去的几年里,我们已经看到 HTML5 中添加了一系列非常受欢迎和强大的新 API。这些包括 WebSockets、canvas、本地存储、WebGL 等等。在游戏开发的背景下,下一个自然的步骤是为游戏手柄添加标准支持。

与全屏模式类似,游戏手柄 API 仍处于非常早期的起草阶段。实际上,游戏手柄支持甚至比全屏模式更“原始”。尽管您会发现浏览器支持是足够的,但使用 API 可能会出现错误和有些不可预测。然而,游戏手柄 API 确实提供了一个足够好的接口,以提供出色的最终用户体验。随着规范的成熟,将游戏手柄添加到浏览器中的前景是非常令人兴奋和有前途的。

关于游戏手柄 API 的第一件事是,它与 DOM 中所有其他输入 API 的不同之处在于它不是由鼠标或键盘等事件驱动的。例如,尽管每个键盘输入都会触发一个事件(换句话说,会调用一个注册的回调),但来自连接的游戏手柄的输入只能通过手动轮询硬件来检测。换句话说,浏览器会触发与游戏手柄相关的事件,以让您知道游戏手柄已连接和断开连接。然而,除了这些类型的事件之外,浏览器不会在连接的游戏手柄上每次按键时触发事件。

要在游戏中使用游戏手柄,您首先需要等待游戏手柄连接到游戏中。这是通过注册一个回调来监听全局的gamepadconnected事件来实现的:

/**
 * @type {GamepadEvent} event
 */
function onGamepadConnected(event) {
    var gamepad = event.gamepad;
}

window.addEventListener('gamepadconnected', onGamepadConnected);

gamepadconnected事件将在游戏运行期间任何时候在您的计算机上连接游戏手柄时触发。如果在脚本加载之前已经连接了游戏手柄,那么gamepadconnected事件将不会触发,直到玩家按下游戏手柄上的按钮。虽然这一开始可能看起来有点奇怪,但这一限制是有很好的原因的,即为了保护玩家不受恶意脚本的指纹识别。然而,要求用户在激活控制器之前按下按钮并不是什么大问题,因为玩家如果想玩游戏,总是需要在某个时候按下按钮。唯一的缺点是,我们一开始不知道用户是否已经连接了游戏手柄。不过,想出创造性的解决方案来解决这个限制并不是太困难的任务。

GamepadEvent对象公开了一个 gamepad 属性,它是对实际的 Gamepad 对象的引用,这正是我们想要的。这个对象的有趣之处在于它不像 JavaScript 中的其他对象那样自动更新。换句话说,每当浏览器接收到来自连接的游戏手柄的输入时,它会在内部跟踪其状态。然后,一旦您轮询gamepad状态,浏览器就会创建一个新的Gamepad对象,其中包含所有更新的属性,以反映控制器的当前状态。

function update(){
    var gamepads = navigator.getGamepads();
    var gp_1 = gamepads[0];

    if (gp_1.buttons[1].pressed) {
        // Button 1 pressed on first connected gamepad
    }

    if (gp_1.axes[1] < 0) {
        // Left stick held to the left on first connected gamepad
    }

    requestAnimationFrame(update);
}

在每个update周期中,您需要获取游戏手柄对象的最新快照并查找其状态。

Gamepad对象接口定义了没有方法,但有几个属性:

interface Gamepad {
    readonly attribute DOMString id;
    readonly attribute long index;
    readonly attribute boolean connected;
    readonly attribute DOMHighResTimeStamp timestamp;
    readonly attribute GamepadMappingType mapping;
    readonly attribute double[] axes;
    readonly attribute GamepadButton[] buttons;
};

id属性描述了连接到应用程序的实际硬件。如果通过某个 USB 适配器连接游戏手柄,则id可能会引用适配器设备,而不是实际使用的控制器。

index将引用GamepadList对象中的Gamepad对象,这是浏览器响应navigator.getGamepads()提供的。使用此索引值,我们可以获取对我们希望查询的特定游戏手柄的引用。

如预期的那样,boolean connected属性指示特定游戏手柄是否仍连接到应用程序。如果在调用navigator.getGamepads()之前游戏手柄断开连接,则基于Gamepad.index偏移的相应元素将在GamepadList中为 null。但是,如果获取了对Gamepad对象的引用,但硬件断开连接,那么对象的 connected 属性仍将设置为 true,因为这些属性不是动态更新的。总之,这个属性是多余的,可能会在将来的更新中从规范中删除。

我们可以通过查看Gamepad对象上的timestamp属性来检查浏览器上次更新gamepad状态的时间。

一个特别有趣的属性是mapping。其背后的想法是可以有几种标准映射,以便更容易地连接到硬件的方式对应应用程序。

使用游戏手柄更好地控制

目前只有一个标准映射,可以通过名称standard来识别,如先前演示的(有关更多信息,请参阅Gamepad W3C Working Draft 29 April 2015www.w3.org/TR/gamepad)。如果浏览器不知道如何布局控制器,它将用空字符串响应mapping属性,并以最佳方式映射按钮和轴。在这种情况下,应用程序可能应该要求用户手动映射应用程序使用的按钮。请记住,有些情况下,方向键按钮映射到其中一个轴,因此要小心处理每种情况:

var btns = {
        arrow_up: document.querySelector('.btn .arrow-up'),
        arrow_down: document.querySelector('.btn .arrow-down'),
        arrow_left: document.querySelector('.btn .arrow-left'),
        arrow_right: document.querySelector('.btn .arrow-right'),

        button_a: document.querySelector('.buttons .btn-y'),
        button_b: document.querySelector('.buttons .btn-x'),
        button_x: document.querySelector('.buttons .btn-b'),
        button_y: document.querySelector('.buttons .btn-a'),

        button_select: document.querySelector('.controls .btn- select'),
        button_start: document.querySelector('.controls .btn- start'),

        keyCodes: {
            37: 'arrow_left',
            38: 'arrow_up',
            39: 'arrow_right',
            40: 'arrow_down',

            32: 'button_a',
            65: 'button_b',
            68: 'button_x',
            83: 'button_y',

            27: 'button_select',
            16: 'button_start'
        },

        keyNames: {
            axe_left: 0,
            axe_left_val: -1,

            axe_right: 0,
            axe_right_val: 1,

            axe_up: 1,
            axe_up_val: -1,

            axe_down: 1,
            axe_down_val: 1
        }
    };

    Object.keys(btns.keyCodes).map(function(index){
        btns.keyNames[btns.keyCodes[index]] = index;
    });

function displayKey(keyCode, pressed) {
    var classAction = pressed ? 'add' : 'remove';

    if (btns.keyCodes[keyCode]) {
        btns[btns.keyCodes[keyCode]].classListclassAction;
    }
}

function update(now) {
        requestAnimationFrame(update);

        // GamepadList[0] references the first gamepad that connected to the app
        gamepad = navigator.getGamepads().item(0);

        if (gamepad.buttons[0].pressed) {
            displayKey(btns.keyNames.button_x, true);
        } else {
            displayKey(btns.keyNames.button_x, false);
        }

        if (gamepad.buttons[1].pressed) {
            displayKey(btns.keyNames.button_a, true);
        } else {
            displayKey(btns.keyNames.button_a, false);
        }

        if (gamepad.buttons[2].pressed) {
            displayKey(btns.keyNames.button_b, true);
        } else {
            displayKey(btns.keyNames.button_b, false);
        }

        if (gamepad.buttons[3].pressed) {
            displayKey(btns.keyNames.button_y, true);
        } else {
            displayKey(btns.keyNames.button_y, false);
        }

        if (gamepad.buttons[8].pressed) {
            displayKey(btns.keyNames.button_select, true);
        } else {
            displayKey(btns.keyNames.button_select, false);
        }

        if (gamepad.buttons[9].pressed) {
            displayKey(btns.keyNames.button_start, true);
        } else {
            displayKey(btns.keyNames.button_start, false);
        }

        if (gamepad.axes[btns.keyNames.axe_left] === btns.keyNames.axe_left_val){
            displayKey(btns.keyNames.arrow_left, true);
        } else {
            displayKey(btns.keyNames.arrow_left, false);
        }

        if (gamepad.axes[btns.keyNames.axe_down] === btns.keyNames.axe_down_val) {
            displayKey(btns.keyNames.arrow_down, true);
        } else {
            displayKey(btns.keyNames.arrow_down, false);
        }

        if (gamepad.axes[btns.keyNames.axe_up] === btns.keyNames.axe_up_val) {
            displayKey(btns.keyNames.arrow_up, true);
        } else {
            displayKey(btns.keyNames.arrow_up, false);
        }

        if (gamepad.axes[btns.keyNames.axe_right] === btns.keyNames.axe_right_val) {
            displayKey(btns.keyNames.arrow_right, true);
        } else {
            displayKey(btns.keyNames.arrow_right, false);
        }
    }

    window.addEventListener('gamepadconnected', function (e) {
        update(0);
    });

前面的示例连接了一个没有可识别映射的游戏手柄;因此,它将每个按钮分配给特定的布局。由于在这种特殊情况下,方向键按钮映射到左轴,因此当我们想要确定是否正在使用方向键时,我们会检查该状态。此演示的输出如下:

使用游戏手柄更好地控制

通常,您可能希望为用户提供选择他们希望与您的游戏交互的方式的能力 - 使用键盘和鼠标,游戏手柄或两者兼而有之。在上一个示例中,这正是为什么btns对象引用看似随机和任意的keyCode值的原因。这些值被映射到特定的键盘键,以便玩家可以在标准键盘或游戏手柄上使用箭头键。

使用 WebRTC 进行点对点通信

近年来最令人兴奋的 API 之一是 WebRTC(代表 Web 实时通信)。该 API 的目的是允许用户在支持该技术的平台上进行实时流式音频和视频通信。

WebRTC 由几个单独的 API 组成,并可以分解为三个单独的组件,即getUserMedia(我们将在下一节中更深入地讨论)、RTCPeerConnectionRTCDataChannel

由于我们将在下一节中讨论getUserMedia,所以我们将在那里时留下更详细的定义(尽管名称可能会透露 API 的预期用途)。

RTCPeerConnection是我们用来连接两个对等方的。一旦建立了连接,我们可以使用RTCDataChannel在对等方之间传输任何数据(包括二进制数据)。在游戏开发的背景下,我们可以使用RTCDataChannel将玩家的状态发送给每个对等方,而无需一个服务器来连接每个玩家。

要开始使用RTCPeerConnection,我们需要一种方法来告诉每个对等方有关另一个对等方。请注意,WebRTC 规范故意省略了应该进行数据传输的具体方式。换句话说,我们可以自由选择任何方法手动连接两个对等方。

获取RTCPeerConnection的第一步是实例化RTCPeerConnection对象,并配置它所需使用的STUN服务器以及与您期望的连接类型相关的其他选项:

var pcConfig = {
    iceServers: [{
        url: 'stun:stun.l.google.com:19302'
    }]
};

var pcOptions = {
    optional: [{
        RtpDataChannels: true
    }]
};

var pc = new webkitRTCPeerConnection(pcConfig, pcOptions);

在这里,我们使用 Google 免费提供的公共STUN服务器。我们还使用供应商前缀以保持与本章中其他示例的一致性。截至目前,每个以某种方式实现 WebRTC 的供应商都使用供应商前缀。

注意

如果您对 STUN、交互式连接建立ICE)、NATTURNSDP不太熟悉,不用太担心。虽然本书不会深入解释这些网络概念,但您在本章中跟随示例并在自己的游戏中实现数据通道时,不需要对它们了解太多。

简而言之,STUN 服务器用于告知客户端其公共 IP 地址以及客户端是否在路由器的 NAT 后面,以便另一个对等方可以连接到它。因此,我们在创建RTCPeerConnection时使用一个 STUN 服务器。

再次强调简单和简洁,ICE 候选允许浏览器直接连接到另一个浏览器。

一旦我们准备好了RTCPeerConnection,我们通过提议与对等方连接。第一步是创建一个提议,描述了另一个客户端如何连接回我们。在这里,我们使用我们选择的协议通知其他对等方我们的提议。通常,这将使用 WebSocket 完成,但为了更明确地演示每个步骤,我们将使用人类已知的最古老的通信协议:口头交流

/**
 *
 */
function makeMessage(msg, user, color) {
    var container = document.createElement('p');
    var tag = document.createElement('span');
    var text = document.createElement('span');

    if (color) {
        tag.classList.add(color);
    } else if (nickColor) {
        tag.classList.add(nickColor);
    }

    tag.textContent = '[' + (user || nick) + '] ';
    text.textContent = msg;

    container.appendChild(tag);
    container.appendChild(text);

    var out = document.getElementById('out');
    var footer = document.getElementById('outFooter');
    out.appendChild(container);
    footer.scrollIntoView();
}

/**
 *
 */
function createOffer() {
    pc.createOffer(function (offer) {
        // Note #1
        makeMessage('offer: ' + encodeURIComponent(offer.sdp));

        // Note #2
        pc.setLocalDescription(new RTCSessionDescription(offer),
            // Note #3
            function () {},

            // Note #4
            function (e) {
                console.error(e);
                makeMessage('error creating offer');
            }
        );
    });
}

在这个 WebRTC 点对点连接的hello world演示中,我们将构建一个简单的聊天室,中间没有服务器(除了我们需要启动点对点连接的 STUN 服务器)。

根据前面的示例代码,我们可以假设有一些 HTML 结构,其中包含一个输入元素,我们可以在其中输入文本和命令,并使用它们来驱动 WebRTC 组件。

使用 WebRTC 进行点对点连接

前面的屏幕截图显示了我们调用先前显示的createOffer函数后的输出。我们将广泛使用makeMessage函数来帮助我们查看系统(即 WebRTC API)发起的消息,以及来自我们试图连接和聊天的其他对等方的消息。

前面代码示例中的Note #1旨在引起您对我们如何显示提议的会话描述协议SDP)的注意,这是一种在对等方之间协商会话能力的协议(摘自 Suhas Nandakumar 的文章,SDP for the WebRTCtools.ietf.org/id/draft-nandakumar-rtcweb-sdp-01.html)。由于协议中的换行符是有意义的,我们需要保留该字符串中的每个字符。通过对字符串进行编码,我们保证了框架提供给我们的字符串不会以任何方式被更改(尽管这使得对我们人类来说稍微不太可读)。

注 2显示了这种信息交换过程的第二步,这将把我们连接到另一个对等方。在这里,我们需要设置自己客户端的会话描述。你可以把这看作是你记住自己家庭地址(或邮箱,如果你喜欢与笔友进行一系列信件交流)。

注 3注 4是我们发送给RTCSessionDescription构造函数的第二个和第三个参数。它们分别是成功和错误回调函数,目前我们并不太关心。实际上,我们确实关心“错误”回调函数,因为我们希望在尝试到达 STUN 服务器时等可能出现的错误时得到通知等。

现在我们有了一个offer对象,我们只需要让另一个对等方知道这个提议是什么样子的。构成提议的两个要素是 SDP 块和会话描述类型。

一旦我们的对等方知道 SDP 块的样子,他或她就可以实例化一个RTCSessionDescription对象,并设置 SDP 和类型属性。接下来,第二个对等方将该会话描述设置为自己的远程会话描述。在这种情况下,我们只需打开一个新窗口来代表第二个对等方,并通过复制+粘贴方法传输 SDP 字符串。

function setRemoteDesc(sdp, type) {
    var offer = new RTCSessionDescription();
    offer.sdp = decodeURIComponent(sdp);
    offer.type = type;

    makeMessage('remote desc: ' + offer.sdp);

    pc.setRemoteDescription(new RTCSessionDescription(offer),
        function () {
        },
        function (e) {
            console.log(e);
            makeMessage('error setting remote desc');
        }
    );
}

在这里,我们为另一个客户端手动创建一个offer对象。我们使用从第一个客户端获得的 SDP 数据,并将第二个客户端的会话描述类型设置为offer。这个提议被设置为第二个客户端的远程描述。你可以把这看作是,在你写信给笔友的例子中,笔友写下你的家庭地址,这样他或她就知道该把信件寄到哪里了。

第二个对等方记下你的会话描述后,下一步就是接受该提议。在 RTC 术语中,第二个对等方需要回应这个提议。类似于我们调用createOffer()来创建初始提议一样,我们在webkitRTCPeerConnection对象上调用createAnswer()。这个调用的输出也是一个会话描述对象,只是它包含了第二个用户的 SDP,会话描述类型是answer而不是offer

function answerOffer() {
    pc.createAnswer(function (answer) {
        makeMessage('answer: ' + encodeURIComponent(answer.sdp));
        pc.setLocalDescription(new RTCSessionDescription(answer));
    }, function (e) {
        console.log(e);
        makeMessage('error creating answer');
    });
}

在这里,远程对等方首先从来自answer对象的 SDP 中设置自己的本地描述。然后,我们将其显示到屏幕上,这样我们就可以使用与第一个对等方(“本地对等方”)相同的信息作为远程描述。这代表了你的笔友首先记住自己的家庭地址,然后让你拥有一份副本,这样你就知道该把你的信件寄到哪里了。

现在两个对等方都知道对方可以被联系到,所需要的只是一种联系对方的方式。这种细节层次被抽象出来,不涉及数据通道。因此,在我们可以使用数据通道之前,我们需要向对等连接对象添加至少一个 ICE 候选。

当每个对等方创建他们的offeranswer对象时,对等连接对象会接收一个或多个 ICE 候选引用。在这个演示中,当我们接收到 ICE 候选时,我们将其打印到屏幕上,这样在这一点上我们可以复制和粘贴组成每个 ICE 候选的数据,因此我们可以在对方的机器上重新创建它们,并将 ICE 候选添加到对等连接对象中。

pc.onicecandidate = function (event) {
    if (event.candidate) {
        makeMessage('ice candidate: ' + JSON.stringify(event.candidate), 'sys', 'sys');
    }
};

function addIceCandidate(candidate) {
    pc.addIceCandidate(candidate);
}

addIceCandidate(JSON.parse({
   /* encoded candidate object from onIceCandidate callback */
});

一旦每个对等方都有了另一个对等方的会话描述,并且有一个 ICE 候选来引导浏览器到另一个对等方,我们就可以开始直接从一个对等方发送消息到另一个对等方。

下一步就是简单地使用DataChannel对象发送和接收消息。在这里,API 与 WebSocket 的 API 非常相似,我们在通道对象上调用send()方法向对等方发送数据,并注册一个onmessage事件处理程序,从中接收对等方连接的另一侧的数据。这里的主要区别是,与 WebSocket 场景不同,我们现在直接连接到另一个对等方,因此发送消息非常快:

// When creating the RTCPeerConnection object, we also create the DataChannel
var pc = new webkitRTCPeerConnection(pcConfig, pcOptions);
var channelName = 'packtRtc';
var dc = dc = pc.createDataChannel(channelName);

function sendMessage(msg) {
    if (dc.readyState === 'open') {
        var data = {
            msg: msg,
            user: nick,
            color: nickColor
        };

        // Since this is a chat app, we want to see our own message
        makeMessage(msg);

        // The actual send command
        dc.send(JSON.stringify(data));
    } else {
        makeMessage('Could not send message: DataChannel not yet open.');
    }
}

dc.onmessage = function (event) {
    var data = JSON.parse(event.data);
    makeMessage(data.msg, data.user, data.color);
};

dc.onopen = function () {
    makeMessage('dataChannel open', 'sys', 'sys');
};

dc.onerror = function (e) {
    makeMessage('dataChannel error: ' + e, 'sys', 'sys');
};

dc.onclose = function () {
    makeMessage('dataChannel close', 'sys', 'sys');
};

使用 WebRTC 进行点对点通信

总之,在我们可以开始使用DataChannel与其他对等方通信之前,我们需要手动(意味着在 WebRTC API 的真实领域之外)相对于彼此配置每个对等方。通常,您将首先通过 WebSocket 连接对等方,并使用该连接创建并回答发起对等方的提议。此外,通过DataChannel发送的数据不仅限于文本。我们可以使用另一个 WebRTC API 发送二进制数据,例如视频和音频,我们将在下一节中讨论。

使用媒体捕获捕获时刻

在线多人游戏的较新组件之一是涉及实时语音和视频通信的社交方面。这最后一个组件可以通过使用 HTML 媒体捕获 API 完美满足,它允许您访问玩家的摄像头和麦克风。一旦您获得了对摄像头和麦克风的访问权限,您可以将这些数据广播给其他玩家,将它们保存为音频和视频文件,甚至创建一个仅基于这些数据的独立体验。

媒体捕获的hello world示例可能是音频可视化演示的吸引人之处。我们可以通过媒体捕获和Web Audio API 的混合来实现这一点。通过媒体捕获,我们实际上可以从用户的麦克风接收原始音频数据;然后,我们可以使用 Web Audio 连接数据并对其进行分析。有了这些数据,我们可以依靠 canvas API 来呈现由麦克风接收的代表声波的数据。

首先,让我们更深入地了解媒体捕获。然后,我们将看一下 Web Audio 的重要部分,并留给您找到更好、更完整和专门的来源来加深您对 Web Audio API 的理解。

目前,媒体捕获处于候选推荐阶段,因此我们仍然需要寻找并包含供应商前缀。为简洁起见,我们将假定Webkit 目标HTML 媒体捕获 W3C 候选推荐,(2014 年 9 月)。www.w3.org/TR/html-media-capture/)。)

我们首先在 navigator 对象上调用getUserMedia函数。(有关window.navigator属性的更多信息,请转到developer.mozilla.org/en-US/docs/Web/API/Window/navigator。)在此,我们指定有关我们希望捕获的媒体的任何约束,例如音频、我们想要的视频帧速率等等:

var constraints = {
    audio: false,
    video: {
        mandatory: {
            minAspectRatio: 1.333,
            maxAspectRatio: 1.334
        },
        optional: {
            width: {
                min: 640,
                max: 1920,
                ideal: 1280
            },
            height: {
                min: 480,
                max: 1080,
                ideal: 720
            },
            framerate: 30
        }
    }
};

var allowCallback = function(stream){
    // use captured local media stream
    // ...
};

var denyCallback = function(e){
    // user denied permission to let your app access media devices
    console.error('Could not access media devices', e);
};

navigator.webkitGetUserMedia(constraints, allowCallback, denyCallback);

在其最简单的形式中,约束字典只包括一个指示我们希望捕获的媒体类型的键,后面跟着一个代表我们意图的Boolean值。可选地,任何 false 值都可以通过完全省略属性来简写。

var  constraints = {
    audio: true,
    video: false
};

// the above is equivalent to simply {audio: true}

navigator.webkitGetUserMedia(constraints, allowCallback, denyCallback);

一旦执行了对getUserMedia的调用,浏览器将向用户显示警告消息,提醒用户页面正在尝试访问媒体设备;这将给用户一个机会允许或拒绝这样的请求:

使用媒体捕获捕获时刻

尽管它与旧的window.alertwindow.confirmwindow.prompt API 不同,但浏览器生成的提示始终是异步的和非阻塞的。这就是为什么在用户允许或拒绝请求的情况下提供回调函数的原因。

一旦我们获得了对用户音频设备的访问权限,就像前面的例子一样,我们可以利用 Web Audio API 并创建一个AudioContext对象;从这里,我们可以创建一个媒体流源:

var allowCallback = function(stream){
    var audioContext = new AudioContext();
    var mic = audioContext.createMediaStreamSource(stream);

    // ...
};

正如您可能已经猜到的那样,MediaStream对象表示麦克风作为数据源。有了这个参考,我们现在可以将麦克风连接到AnalyserNode,以帮助我们将音频输入分解为我们可以以可视方式表示的内容:

var allowCallback = function(stream){
    var audioContext = new AudioContext();
    var mic = audioContext.createMediaStreamSource(stream);

    var analyser = audioContext.createAnalyser();
    analyser.smoothingTimeConstant = 0.3;
    analyser.fftSize = 128;

    mic.connect(analyser);

    // ...
};

下一步是使用analyser对象并从音频源获取频率数据。有了这个,我们可以根据需要将其渲染到现有画布上:

var allowCallback = function(stream){
    var audioContext = new AudioContext();
    var mic = audioContext.createMediaStreamSource(stream);

    var analyser = audioContext.createAnalyser();
    analyser.smoothingTimeConstant = 0.3;
    analyser.fftSize = 128;

    mic.connect(analyser);

    var bufferLength = analyser.frequencyBinCount;
    var frequencyData = new Uint8Array(bufferLength);

    // assume some canvas and ctx objects already loaded and bound to the DOM
    var WIDTH = canvas.width;
    var HEIGHT = canvas.height;
    var lastTime = 0;

    visualize(e);

    function visualize(now) {
        // we'll slow down the render speed so it looks smoother
        requestAnimationFrame(draw);
        if (now - lastTime >= 200) {
            ctx.clearRect(0, 0, WIDTH, HEIGHT);
            analyser.getByteFrequencyData(frequencyData);

            var barWidth = (WIDTH / bufferLength) * 2.5;
            var x = 0;

            [].forEach.call(frequencyData, function (barHeight) {
                ctx.fillStyle = 'rgb(50, ' + (barHeight + 100) + ', 50)';
                ctx.fillRect(x, HEIGHT - barHeight / 1, barWidth, barHeight / 1);
                x += barWidth + 1;
            });

            lastTime = now;
        }
    }
};

使用媒体捕获捕捉时刻

处理视频同样简单,但是需要连接摄像头到您的计算机,这是您所期望的。如果您使用设置视频约束的getUserMedia请求,但没有摄像头,则将执行错误回调,并将NavigatorUserMediaError对象作为参数发送:

navigator.webkitGetUserMedia({video: true}, function(stream){
    // ...
}, function(e){
    // e => NavigatorUserMediaError {
    //              constraintName: '',
    //              message: '',
    //              name: 'DevicesNotFoundError'
    //          }
});

另一方面,当视频设备可访问时,我们可以通过将其src属性设置为objectUrl的方式将其流式传输到视频元素中,该objectUrl指向我们从用户媒体获取的流源:

var video = document.createElement('video');
video.setAttribute('controls', true);
video.setAttribute('autoplay', true);

document.body.appendChild(video);

var constraints = {
    video: true
};

function allowCallback(stream){
    video.src = window.URL.createObjectURL(stream);
}

function denyCallback(e){
    console.error('Could not access media devices', e);
}

navigator.webkitGetUserMedia(constraints, allowCallback, denyCallback);

摘要

本章使我们向前迈进了一步,让我们一窥我们可以将其纳入我们的多人游戏中的最新 HTML5 API。这些 API 包括全屏模式、游戏手柄、媒体捕获和 WebRTC。有了这些强大的附加功能,您的游戏将更具吸引力和乐趣。

然而,整个讨论中的一个要点是,本章中描述的所有 API 仍处于早期草拟阶段;因此,它们可能会受到严重的界面更改,或者也可能被弃用。与此同时,请确保为每个 API 添加适当的供应商前缀,并注意任何一次性浏览器怪癖或实现差异。

在下一章中,我们将通过讨论与网络游戏相关的安全漏洞来结束我们在 JavaScript 中进行多人游戏开发的精彩旅程。我们将描述最常见的技术,以最小化作弊的机会,从而提供公平和充分的游戏体验。

第六章:增加安全性和公平竞争

尽管我们现在才谈论安全性,但本章的主要要点是安全性应该内置到您的游戏中。就像其他类型的软件一样,您不能事后再加入一些安全功能,然后期望产品是无懈可击的。然而,由于本书的主要重点不是安全性,我认为我们可以理直气壮地直到最后一章才提出这个问题。

在本章中,我们将讨论以下原则和概念:

  • 基于网络的应用程序中的常见安全漏洞

  • 使用 Npm 和 Bower 为您的游戏增加额外的安全性

  • 使游戏更安全,更不容易作弊

常见的安全漏洞

如果您是从软件开发的许多其他领域转向游戏开发,您会高兴地知道,保护游戏与保护任何其他类型的软件并没有太大的不同。将游戏视为需要安全性的任何其他类型的软件,尤其是分布式和网络化的软件,将帮助您制定适当的措施,以帮助您保护您的软件。

在本节中,我们将介绍一些基于网络的应用程序(包括游戏)中最基本和基本的安全漏洞,以及保护措施。然而,我们不会深入探讨更复杂的网络安全主题和情景,比如社会工程学、拒绝服务攻击、保护用户帐户、正确存储敏感数据、保护虚拟资产等等。

通过加密传输数据

您应该知道的第一个漏洞是,从服务器向客户端发送数据会使数据暴露给其他人。监视网络流量几乎和边走路、嚼口香糖一样容易,尽管并非每个人都有足够的技能来做这些事情。

以下是您可能要求玩家在玩游戏(或准备玩游戏)时经历的常见情景:

  • 玩家输入用户名和密码以获得授权进入您的游戏

  • 您的服务器验证登录信息

  • 然后允许玩家继续玩游戏

如果玩家发送到服务器的初始 HTTP 请求未加密,则查看网络数据包的任何人都将知道用户凭据,您的玩家帐户将受到威胁。

最简单的解决方案是通过 HTTPS 传输任何此类数据。虽然使用 HTTPS 不能解决所有安全问题,但它确实为我们提供了相当确定的保证,其中包括以下几点:

  • 服务器响应客户端请求的人应该是它所说的那样

  • 服务器和客户端接收的数据不会被篡改

  • 任何查看数据的人都无法以纯文本形式阅读它

由于 HTTPS 数据包是加密的,任何监视网络的人都需要解密每个数据包才能知道其中包含的数据,因此这是向服务器发送密码的安全方式。

就像没有免费的午餐一样,也没有免费的加密和解密。这意味着使用 HTTPS 会产生一些可衡量的性能损失。这种惩罚实际上是什么,以及它将是多么微不足道,这在很大程度上取决于一系列因素。关键是评估您的具体情况,并确定在性能方面使用 HTTPS 将会太昂贵的地方。

然而,请记住,至少在数据的价值大于额外性能时,以安全性为代价换取性能是值得的。由于相关的延迟,您可能无法通过 HTTPS 传输数千个玩家的位置和速度,但每个单独的用户在初始认证后不会经常登录,因此至少强制进行安全认证是任何人都无法承受的。

脚本注入

这个漏洞背后的基本原则是,你的脚本将用户输入作为文本(数据)并在执行上下文中将其评估为代码。这种情况的典型用例如下:

  • 游戏要求用户输入他/她的名字

  • 恶意用户输入代码

  • 游戏可选择保存该文本以备将来使用

  • 游戏最终在执行上下文中使用该代码

在基于 Web 的应用程序中,或者更具体地说,在浏览器中执行 JavaScript 时,恶意输入可能是一串 HTML,执行上下文是 DOM。DOM API 的一个特点是它能够将一个字符串设置为元素的 HTML 内容。浏览器会将该字符串转换为活动的 HTML,就像渲染在某个服务器上的任何其他 HTML 文档一样。

以下代码片段是一个应用程序的示例,该应用程序要求用户输入昵称,然后在屏幕右上角显示它。这个游戏也可能会将玩家的名字保存在数据库中,并尝试在游戏的其他部分中使用玩家的名字来渲染该字符串:

/**
 * @param {Object} player
 */
function setPlayerName(player){
    var nameIn = document.getElementById('nameIn');
    var nameOut = document.getElementById('nameOut');

    player.name = nameIn.value;

    // Warning: here be danger!
    nameOut.innerHTML = player.name;
}

脚本注入

对于普通开发者来说,这似乎是对一个准备享受你的平台游戏的玩家的一个相当可爱的问候。只要用户输入一个没有 HTML 字符的实际名称,一切都会很好。

然而,如果用户决定称自己为<script src="img/my-script.js"></script>之类的东西,而我们不对该字符串进行消毒以删除使字符串成为有效 HTML 的字符,应用程序可能会受到损害。

用户利用这个漏洞的两种可能方式是改变客户端的体验(例如,输入一个使名称闪烁或下载并播放任意 MP3 文件的 HTML 字符串),或者输入一个下载并执行 JavaScript 文件的 HTML 字符串,这些文件会以恶意方式改变主游戏脚本并与游戏服务器交互。

更糟糕的是,如果我们在保护其他漏洞方面不小心,这个安全漏洞可以与其他漏洞一起被利用,进一步加剧邪恶玩家可能造成的损害:

脚本注入

服务器验证

根据我们如何处理和使用来自用户的输入,我们可能会通过信任未经消毒的输入来危害服务器和其他资产。然而,仅仅确保输入通常有效是不够的。

例如,某个时刻你会告诉服务器玩家在哪里,以多快的速度朝着哪个方向移动,可能还有哪些按钮被按下。如果我们需要告知服务器玩家的位置,我们首先会验证客户端游戏是否提交了一个有效的数字:

// src/server/input-handler.js

socket.on(gameEvents.server_userPos, function(data){
    var position = {
        x: parseFloat(data.x),
        y: parseFloat(data.y)
    };

    if (isNaN(position.x) || isNan(position.y) {
        // Discard input
    }

    // ...
});

现在我们知道用户没有黑客攻击游戏,而是发送了实际位置向量,我们可以对其进行计算并更新游戏状态的其余部分。或者,我们可以吗?

例如,如果用户发送了无效的浮点数作为他们的位置(假设在这种情况下我们正在使用浮点数),我们可以简单地丢弃输入或对其尝试输入无效值做出特定的响应。但是,如果用户发送了一个不正确的位置向量,我们该怎么办?

可能是玩家从屏幕左侧移动到右侧。首先,服务器接收到玩家的坐标,显示玩家真正的位置,然后玩家报告说自己稍微靠右一点,离一个火坑更近了。假设玩家可能每帧最快移动 5 像素。那么,如果我们只知道玩家发送了一个有效的向量{x: 2484, y: 4536},我们如何知道玩家是否真的在一个帧内跳过火坑(这是不可能的移动),还是玩家作弊了呢?

服务器验证

这里的关键原则是验证输入是否有效。请注意,我们谈论的是验证而不是清理用户输入,尽管后者也是必不可少的,并且与前者相辅相成。

对于玩家报告虚假位置的先前问题的一个解决方案是,我们可以简单地跟踪上次报告的位置,并将其与下一个接收到的位置进行比较。对于更复杂的解决方案,我们可以跟踪几个先前的位置,并查看玩家的移动方式。

var PlayerPositionValidator = function(maxDx, maxDy) {
    this.maxDx = maxDx;
    this.maxDy = maxDy;
    this.positions = [];
};

PlayerPositionValidator.prototype.addPosition = function(x, y){
    var pos = {
        x: x,
        y: y
    };

    this.positions.push(pos);
};

PlayerPositionValidator.prototype.checkLast = function(x, y){
    var pos = this.positions[this.positions.length - 1];

    return Math.abs(pos.x - x) <= this.maxDx
         && Math.abs(pos.y - y) <= this.maxDy;
};

上述类跟踪了玩家在一个帧(或者服务器验证新用户位置的频率)中可能具有的最大垂直和水平位移。通过将其与特定玩家的实例相关联,我们可以添加新的传入位置,并检查它是否大于最大可能的位移。

更复杂的情况是检查和验证的一个案例是确保玩家不会报告可能已过期的事件或属性(例如临时增益等),或者无效的输入状态(例如,玩家已经在空中,但突然报告发起了一次跳跃)。

更复杂的是,还有另一种情况需要我们注意,这是非常难以检查的。到目前为止,正如我们所讨论的,对抗试图操纵游戏状态的玩家的解决方案是利用权威服务器的力量来否决客户端的操作。然而,正如我们将在下一节讨论的那样,甚至权威服务器也无法真正防止或恢复一类问题。

人工智能

检测玩家试图作弊的一种情况是因为报告的移动是不可能的(例如,移动得太快或者在游戏中某个级别中没有可用的武器)。然而,完全不同的是,试图检测一个作弊者因为他或她玩得太好。如果邪恶的玩家是一个机器人,完美地对抗诚实的人类玩家,这是我们可能面临的一个漏洞。

这个问题的解决方案和问题一样复杂。假设您想要防止机器人与人类竞争,您如何可能确定一系列输入是否来自另一个软件而不是人类玩家?可以假设,尽管每一步都是合法的,但准确度可能会比其他人高出几个数量级。

不幸的是,本书范围之外的代码实现展示了对抗这类问题的方法,这是本书无法涵盖的。一般来说,您将希望使用各种启发式方法来确定一系列动作是否过于完美。

构建安全的游戏和应用程序

既然我们已经讨论了一些需要注意的基本事项,以及在游戏中不应该执行的事项;我们现在将看一些简单的概念,这些概念是我们不能忽略的。

再次强调,大多数概念都适用于网页开发,所以来自那个领域的人会感到如鱼得水。

权威服务器

希望现在清楚了,拥有可信赖的信息的关键是确保信息的来源是可信赖的。在我们的情况下,我们依赖游戏服务器来监听所有客户端,然后确定当前游戏状态的真相。

如果你发现自己在考虑不使用服务器-客户端模型来进行多人游戏,而是倾向于某种替代格式,你应该牢记的一件事是,通过在两个玩家之间放置一个权威机构,可以获得这样的安全性。即使单个玩家决定操纵和作弊他或她自己的游戏客户端,权威游戏服务器也可以确保其他玩家仍然拥有公平的游戏体验。

虽然并非每种游戏格式都需要权威游戏服务器,但当你的特定游戏可以使用权威游戏服务器时,如果你不使用权威游戏服务器,你应该有一个非常好的理由。

基于会话的游戏玩法

现代浏览器的一个好处是它们具有非常强大的 JavaScript 引擎,使我们能够在客户端使用纯 JavaScript 做很多事情。因此,我们可以将很多繁重的工作从服务器转移到客户端。

例如,假设我们想保存当前玩家的游戏状态。这将包括玩家当前的位置、健康状况、生命、得分等,以及虚拟货币、成就等。

一种方法是对所有这些信息进行编码,并将其存储在用户的设备上。这样做的问题是用户可能会更改保存的文件,而我们却不知情。因此,这个过程中的一个常见步骤是创建最终保存文件的哈希值,然后稍后使用相同的哈希值来确保游戏的保存文件没有被更改。

注意

“哈希”和“加密”之间有什么区别?

也许你已经听说过这两个术语可以互换使用,但它们实际上是非常不同的概念。虽然两者都经常与安全性相关联,但这是它们唯一共享的相似之处。

哈希函数将任意长度的字符串映射到某个固定长度的字符串。给定相同的输入字符串,始终返回相同的输出哈希。哈希函数的主要特点是映射是单向的,这意味着无法通过输出来恢复原始输入。

例如,Rodrigo Silveira输入字符串将映射到类似73cade4e8326的内容。对这个输出字符串进行哈希处理将返回与其自身或原始输入完全不同的内容。

另一方面,加密是一种将某个输入字符串转换为该字符串的不同表示的方法,但具有可逆(或撤消)函数的能力,并获得原始输入字符串。

例如,如果使用凯撒密码(以强大的罗马将军命名,而不是巴西足球运动员)对 Rodrigo Silveira 字符串进行加密,使用偏移值 3(这意味着输入文本中的每个字符都向后移动 3 个字母),则输出为Urguljr Vloyhlud——即R之后的第三个字符是U,依此类推。如果我们对输出字符串应用偏移值-3,将得到原始字符串。

简而言之,就实际目的而言,哈希无法被逆转,而加密可以。

然而,如果我们还将哈希值与客户端一起存储,那么在修改游戏保存文件后,他们只需要重新计算哈希值,我们就会回到原点。

更好的方法是在服务器上计算哈希值,将哈希值存储在服务器上,并通过某种用户账户系统与玩家关联起来。这样,如果对本地存储的文件进行任何篡改,服务器可以使用只有它自己访问的哈希来验证它。

还有一些情况,您可能希望将 API 密钥或其他此类唯一对象存储在客户端。同样,这里的关键原则是,任何接触客户端的东西现在都在您的敌人控制之下,不能信任。

因此,这一部分的主要要点是始终将密钥和其他敏感数据存储在服务器内,并通过会话令牌将其与玩家关联和代理。

通过混淆来增加安全性

虽然混淆不是一种安全形式,但它确实增加了一层复杂性,使真正决心的(和有技能的)恶意用户减慢速度,并过滤掉大多数其他邪恶的人,否则他们会尝试利用你的游戏。

在网页开发中,混淆游戏的最常见方法是通过将最终源代码通过一些 JavaScript 编译器运行,安全地重命名变量和函数名称,并以等效于原始输入代码但执行相同任务的方式重写代码。

例如,您可能有以下代码,玩家可以通过更改一些变量的值来轻松利用他们浏览器的 JavaScript 控制台:

Gameloop.prototype.update = function(){
    Players.forEach(function(player){
        hero.bullets.filter(function(bullet){
            if (player.intersects(bullet)) {
                player.takeDamage(bullet.power);
                hero.score += bullet.hp;

                return false
            }

            return true;
        });
    });

    // ...
};

我们不必仔细研究以前的函数,就可以意识到在这个虚构的游戏中,只有击中其他玩家的子弹才会对每个玩家造成伤害并增加我们自己的得分。因此,编写一个函数来替换它是微不足道的,或者至少修改其重要部分以达到相同的目的同样容易。

现在,通过诸如 Google 的闭包编译器之类的工具运行该函数(要了解有关闭包编译器的更多信息,请参阅developers.google.com/closure/compiler/)将输出类似于以下内容,这显然不可能操纵,但肯定不会那么微不足道:

_l.prototype.U=function(){c.forEach(function(e){i.a.filter(
function(o){return e.R(o)?(e.a4(o.d),i.$+=o.F,!1):!0})})};

大多数 JavaScript 混淆器程序将重命名函数名称,变量和属性,并删除不必要的空格,括号和分号,使输出程序非常紧凑且难以阅读。在部署代码之前使用这些程序的一些额外好处包括拥有较小的文件,这样您将最终发送给客户的文件(从而节省带宽),并且在闭包编译器的情况下,它会重写代码的部分,以便输出是最佳的。

这一部分的主要要点是,向您的代码添加复杂性层使其更加安全,并且至少有助于摆脱某些攻击者。就像在前门上方安装摄像头并不一定能消除潜在的闯入者一样,但它确实在吓唬不受欢迎的访客方面走了很长的路。

“然而,请记住,混淆根本不是安全。对混淆的 JavaScript 程序进行反混淆是微不足道的(即使编译的程序也可以轻松地反编译为部分源代码)。您永远不应该仅依赖混淆和模糊作为一种可靠的安全形式。混淆您的部署应用程序应该是已经安全系统的最后一步,特别是考虑到混淆的主要好处,如前面所述。

重复造轮子

像计算机科学中的大多数问题一样,有人已经找到了解决方案并将其转换为代码。在这方面,我们特别受益于许多慷慨(而非常聪明)的程序员,他们通过开源项目分发他们的解决方案。

在这一部分,我邀请您寻找现有的解决方案,而不是花时间编写自己的解决方案。尽管编写有趣问题的复杂解决方案总是很有趣(除非,也许,你的老板正在催促你赶上即将到来的截止日期),但您可能会发现,您的努力更好地投资于制作您的实际游戏。

正如我们在第二章中讨论的设置环境,拥有 Node.js 生态系统的访问权限可以让您在开发游戏时遇到的许多问题找到、使用并最终分享很多有用的工具。

遵循安全和公平竞争的主题,接下来是一个常见工具列表,我们可以通过NpmBower(以及GruntGulp)来帮助我们处理游戏中的安全性。

Npm 安装验证器

这个模块可以让您非常轻松地验证和消毒数据。您可以在服务器上以及在浏览器中使用验证器。只需将模块引入并在输入上调用其各种方法:

var validator = require('validator');

validator.isEmail('foo@bar.com'); //=> true
validator.isBase64(inStr);
validator.isHexColor(inStr);
validator.isJSON(inStr);

有各种方法可以检查几乎任何类型的数据或格式,以及对数据进行消毒,这样您就不必为此编写自己的函数。

Npm 安装 js-sha512

这个简单的模块用于使用各种算法对字符串进行哈希处理。要在浏览器中将库作为独立库使用,您还可以使用 Bower 导入它:

bower install js-sha512

要使用js-sha512,只需将其require到所需的哈希函数,并将字符串发送给它进行哈希处理:

sha512 = require('js-sha512').sha512;
sha384 = require('js-sha512').sha384;

var s512 = sha512('Rodrigo Silveira');
var s384 = sha384('Rodrigo Silveira');

Npm 安装闭包编译器

正如之前提到的,谷歌的闭包编译器是一个非常强大的软件,几年前就已经开源。使用编译器可以获得的好处远远超出了简单地想要混淆代码。例如,编译器允许您使用数据类型注释您的 JavaScript 代码,然后编译器可以查看并告诉您变量是否违反了该合同:

/**
 * @param {HTMLImageElement} img
 * @constructor
 */
var Sprite = function(img) {
    this.img = img;
};

/**
 * @param {CanvasRenderingContext2D} ctx
 */
Sprite.prototype.draw = function(ctx) {
    // ...
};

/**
 * @param {number} x
 * @param {number} y
 * @param {Sprite} sprite
 * @constructor
 */
var Player = function(x, y, sprite) {
    this.x = x;
    this.y = y;
    this.sprite = sprite;
};

在给定的示例代码中,您会注意到PlayerSprite构造函数被注释为@constructor。当闭包编译器看到调用这些函数的代码没有使用 new 运算符时,它会推断代码的执行方式与预期不同,并引发编译错误,以便您可以修复错误的代码。此外,如果尝试实例化Player,例如,发送到构造函数的值不是一对数字,后跟Sprite类的实例,编译器将提醒您,以便您的代码可以得到纠正。

使用闭包编译器的最简单方法是依赖 Grunt 或 Gulp,并安装闭包的等效构建任务。流行的解决方案如下:

// For Grunt users:
npm install grunt-closure-compiler

// If you prefer Gulp:
npm install gulp-closure-compiler

公平竞争和用户体验

到目前为止,在本章中,我们已经讨论了安全性的许多不同方面,所有这些都旨在为用户提供公平竞争。尽管我们可以尽力保护我们的服务器、知识产权、用户数据和其他玩家,但归根结底,攻击者总是处于优势地位。

特别是在多人游戏中,数十、甚至数百或数千名不同的玩家将同时享受您的游戏,您可能会到达一个点,尝试保护玩家免受自己的侵害不是一个明智的时间或其他资源投资。例如,如果一个孤立的玩家希望通过作弊的方式跳得比游戏允许的更高,或者更改保存游戏以反映额外的生命,那么您最好只让该玩家在自己的客户端上继续进行黑客攻击。只要确保其他玩家不受影响。

从本节以及整个章节中的关键要点是用户体验至关重要。尤其是当多个玩家共享游戏世界寻找快乐时,其中一个玩家只是想找到一种破坏其他人快乐的方式;您必须确保无论发生什么,其他玩家都可以继续游戏。

摘要

通过本章,我们结束了关于多人游戏开发的讨论,尽管它涵盖了一个必须从一开始就深入了解的主题。请记住,安全性不能简单地在项目结束时添加;相反,它必须与软件的其余部分一起有意识地构建。

我们看到了基于浏览器的游戏中一些最基本的安全漏洞,以及保护游戏免受这些漏洞的常见方法。我们还讨论了一些任何严肃的游戏都不应该缺少的技术。最后,我们看了如何使用现有的开源工具通过 Node 的 Npm 来实现这些技术。

总之,现在你已经完成了学习 JavaScript 多人游戏开发基础的旅程的最后一关,我想让你知道,尽管这可能很令人兴奋,但你的旅程还没有结束。谢谢你的阅读,但公主在另一个城堡里! 现在你必须忙于编写下一个多人游戏,让所有玩家经历充满乐趣、娱乐和实时精彩的旅程。游戏结束!

posted @ 2024-05-24 11:07  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报