Node.js上的浏览器网络射击

多人游戏的开发很复杂,其原因有很多:它们的托管费用昂贵,结构不明显且实施困难。 在本教程中,我将尝试帮助您克服最后的障碍。

本文适用于可以创建游戏并熟悉JavaScript,但以前从未编写过多人在线游戏的开发人员。 完成本教程后,您将掌握游戏中基本网络组件的实现,并能够将其开发成更多东西! 这是我们将创建的:


您可以在这里玩完游戏! 当您按下W或“向上”键时,飞船将接近光标;当您单击鼠标时,它会射击。 (如果没有人在线,则要检查多人游戏的工作方式,请在一台计算机上打开两个浏览器窗口,或在电话上打开其中的一个)。 如果要在本地运行游戏,则可以在GitHub找到完整的源代码。

在创建游戏时,我使用了来自Kenney的Pirate PackPhaser游戏框架的图形资源。 在本教程中,为您分配了网络程序员的角色。 起点将是游戏的全功能单用户版本,我们的任务将是使用Socket.io作为网络部分在Node.js上编写服务器。 为了不使本教程过载,我将重点介绍与多人游戏有关的部分,并跳过与Phaser和Node.js有关的概念。

您无需在本地进行任何配置,因为我们将完全在Glitch.com的浏览器中创建此游戏! Glitch是用于构建Web应用程序(包括后端,数据库等)的强大工具。 它非常适合原型设计,培训和协作,我将很高兴在本教程中向您介绍其功能。

让我们开始吧。

1.准备


我在Glitch.com上发布了该项目的草案。

界面提示:您可以通过单击“ 显示”按钮(左上方)来启动应用程序预览。


左侧的垂直边栏包含所有应用程序文件。 要编辑此应用程序,必须创建其“混音”。 因此,我们将在我们的帐户中创建一个副本(或在git行话中创建“ fork”)。 单击重新混合此按钮。


此时,您正在使用匿名帐户编辑应用程序。 要保存您的工作,您可以登录(右上角)。

现在,在继续之前,对您来说,熟悉我们将在其中添加多人游戏模式的游戏非常重要。 看一下index.html 。 它具有三个重要的功能,您需要了解它们: preload (第99行), create (第115行)和GameLoop (第142行)以及玩家对象(第35行)。

如果您喜欢通过练习来学习,那么请务必通过完成以下任务来了解游戏的工作:

  • 增加世界的大小(第29行) -请注意,游戏内世界有单独的世界大小,页面画布本身有窗口大小
  • 在“空格”的帮助下(第53行)可以前进
  • 更改玩家的飞船类型(第129行)。
  • 放慢弹壳的移动(155行)。

安装Socket.io


Socket.io是一个库,用于使用WebSockets (而不是使用用于创建经典多人游戏的协议(如UDP)来管理浏览器内的实时通信)。 另外,即使不支持WebSocket,该库也有冗余的方法来确保操作。 也就是说,她处理消息传递协议,并允许使用方便的基于事件的消息传递系统。

我们需要做的第一件事是安装Socket.io模块。 在Glitch中,可以通过以下方法来完成:转到package.json文件,然后在依赖项中输入所需的模块,或者单击“ 添加软件包”并输入“ socket.io”。


现在是处理服务器日志的合适时机。 单击左侧的“ 日志”按钮以打开服务器日志。 您应该看到它安装了Socket.io及其所有依赖项。 在这里,您需要查找所有错误和服务器代码的输出。


现在让我们进入server.js 。 这是我们的服务器代码所在的位置。 到目前为止,只有一些基本的样板代码可用于服务我们的HTML。 在文件顶部添加一行以启用Socket.io:

 var io = require('socket.io')(http); //     http 

现在我们还需要在客户端中启用Socket.io,因此让我们回到index.html并在<head>标记内添加以下行:

 <!--    Socket.io --> <script src="/socket.io/socket.io.js"></script> 

注意:Socket.io会自动按照此路径处理客户端库的加载,因此即使您的文件夹中没有/socket.io/目录,此行也可以使用。

现在Socket.io已包含在项目中,可以开始使用!

2.玩家的认可和产生


我们真正的第一步将是接受服务器上的连接并在客户端中创建新的播放器。

接受服务器连接


将此代码添加到server.js的底部:

 //  Socket.io    io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); }) 

因此,我们要求Socket.io侦听客户端连接时自动发生的所有connection事件。 该库为每个客户端创建一个新的socket对象,其中socket.id是此客户端的唯一标识符。

为了验证它是否有效,请返回客户端( index.html )并将此行添加到create函数中的某处:

 var socket = io(); //    'connection'   

如果您开始游戏并查看服务器日志(单击“ 日志”按钮),您将看到服务器已注册此连接事件!

现在,在连接新玩家时,我们希望他能向我们提供有关其状态的信息。 在我们的情况下,我们至少需要知道xy角度 ,以便在正确的点正确创建它。

connection事件是由Socket.io触发的内联事件。 我们可以听任何独立设置的事件。 我将事件命名为new-player ,我希望客户端在收到有关其位置的信息后立即将其发送。 它看起来像这样:

 //  Socket.io    io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ //   new-player    console.log("New player has state:",state_data); }) }) 

如果运行此代码,则直到在服务器日志中看到任何内容为止,因为我们尚未告知客户端生成此new-player事件。 但是,让我们假装一下我们已经完成了这一步,然后继续在服务器上工作。 找到新加入球员的位置后应该怎么办?

我们可以向所有其他连接的玩家发送消息,以便他们知道新玩家出现了。 Socket.io为此提供了一个方便的功能:

 socket.broadcast.emit('create-player',state_data); 

调用socket.emit消息仅传递给该单个客户端。 调用socket.broadcast.emit它将发送到连接到服务器的每个客户端,但在其套接字上调用了此函数的客户端除外。

io.emit函数向连接到服务器的每个客户端发送消息,没有任何例外。 在我们的方案中,我们不需要这样做,因为如果我们从服务器收到一条消息,要求我们创建自己的飞船,那么我们将得到精灵的副本,因为在游戏开始时我们已经创建了自己的飞船。 这是我们将在本教程中使用的各种消息传递功能的便捷提示

服务器代码现在应如下所示:

 //  Socket.io    io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ //   new-player    console.log("New player has state:",state_data); socket.broadcast.emit('create-player',state_data); }) }) 

也就是说,每次玩家连接时,我们都希望他向我们发送一条有关他的位置信息的消息,并将此数据发送给所有其他玩家,以便他们可以创建自己的精灵。

客户端生成


现在,要完成此循环,我们需要在客户端中执行两个操作:

  1. 建立连接后,使用我们的位置数据生成一条消息。
  2. 收听create-player事件并在此时创建一​​个玩家。

要在创建函数中创建一个播放器后执行第一个动作(大约在第135行),我们可以生成一条消息,其中包含我们需要发送的位置数据:

 socket.emit('new-player',{x:player.sprite.x,y:player.sprite.y,angle:player.sprite.rotation}) 

我们不必担心序列化发送的数据。 您可以将它们传输到任何类型的对象中,然后Socket.io将为我们处理它。

在继续之前,请测试代码 。 我们应该在服务器日志中看到类似的消息:

 New player has state: { x: 728.8180247836519, y: 261.9979387913289, angle: 0 } 

现在我们知道我们的服务器会收到有关新播放器连接的通知,并正确读取有关其位置的数据!

接下来,我们要听取创建新播放器的请求。 我们可以在生成消息后立即放置此代码,它应如下所示:

 socket.on('create-player',function(state){ // CreateShip -      ,     CreateShip(1,state.x,state.y,state.angle) }) 

现在测试代码 。 在游戏中打开两个窗口,并确保它可以运行。

您应该看到打开两个客户端之后,第一个客户端创建了两个飞船,第二个客户端只有一个。

任务:您能找出原因发生了吗? 或如何解决? 逐步遵循我们编写的客户端/服务器逻辑,并尝试对其进行调试。

希望您能自己解决! 发生以下情况:当第一个玩家连接时,服务器会将create-player事件发送给所有其他玩家,但是尚无任何玩家可以接收它。 连接第二个播放器后,服务器再次发送其消息,第一个播放器接收该消息并正确创建了精灵,而第二个播放器错过了第一个播放器的消息。

即,问题在于第二玩家稍后连接到游戏,并且他需要知道游戏的状态。 我们必须告知所有新的接洽参与者,这些参与者已经存在(以及世界上发生的其他事件),以便他们能够确定自己的方向。 在开始解决这个问题之前,我有个简短的警告。

游戏状态同步警告


有两种方法可以实现所有播放器的同步。 第一种是发送有关通过网络发生的更改的最少信息。 也就是说,每次连接新玩家时,我们将仅向所有其他玩家发送有关该新玩家的信息(并向该新玩家发送世界上所有其他玩家的列表),并且在断开连接后,我们将通知所有玩家该特定玩家已断开连接。

第二种方法是传达游戏的整个状态。 在这种情况下,我们只会在您每次连接或断开连接时向所有人发送所有玩家的完整列表。

第一种方法更好,因为它可以最大程度地减少通过网络传输的信息量,但是可能很难实现,并且可能会导致播放器不同步。 第二个确保玩家始终保持同步,但是每条消息都必须发送更多数据。

在我们的案例中,我们可以尝试将所有这些组合为一个常见的update事件,而不是尝试在连接播放器以创建播放器,断开连接以删除播放器以及移动播放器以更新其位置时发送消息。 此更新事件将始终将每个玩家的位置发送给所有客户。 这就是服务器应该做的。 客户的任务是保持世界与接收状态的一致性。

为了实现这样的方案,我将执行以下操作:

  1. 我将保留一个玩家字典,其关键字将是他们的ID,值将是他们所在位置的数据。
  2. 连接该字典后,将其添加到该字典中并发送更新事件。
  3. 关闭该词典中的播放器,然后发送更新事件。

您可以尝试自己实现此系统,因为这些步骤非常简单(此处我的功能提示可能会派上用场)。 完整的实现如下所示:

 //  Socket.io    // 1 -      / var players = {}; io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ //   new-player    console.log("New player has state:",state_data); // 2 -      players[socket.id] = state_data; //    io.emit('update-players',players); }) socket.on('disconnect',function(){ // 3-       delete players[socket.id]; //    }) }) 

客户端要复杂一些。 一方面,现在我们只应关注update-players事件,但另一方面,如果服务器发送的舰船数量超过我们的已知,我们应该考虑创建新的舰船,或者如果它们发送的舰船过多,则应该删除。

这是我在客户端中处理此事件的方式:

 //     // : -         other_players = {} socket.on('update-players',function(players_data){ var players_found = {}; //        for(var id in players_data){ //      if(other_players[id] == undefined && id != socket.id){ // ,      var data = players_data[id]; var p = CreateShip(1,data.x,data.y,data.angle); other_players[id] = p; console.log("Created new player at (" + data.x + ", " + data.y + ")"); } players_found[id] = true; //     if(id != socket.id){ other_players[id].x = players_data[id].x; //  ,    ,      other_players[id].y = players_data[id].y; other_players[id].rotation = players_data[id].angle; } } //       for(var id in other_players){ if(!players_found[id]){ other_players[id].destroy(); delete other_players[id]; } } }) 

在客户端,我将舰船存储在字典other_players ,该字典是我在脚本顶部定义的(此处未显示)。 由于服务器将玩家数据发送给所有玩家,因此我必须添加一项检查,以使客户端不会为其自身创建额外的精灵。 (如果您在结构上遇到问题,那么这里是目前应该在index.html中的完整代码 )。

现在测试代码 。 您应该能够创建多个客户,并在正确的位置看到正确的造船数量!

3.船舶位置同步


从这里开始非常有趣的部分。 我们要同步所有客户的船位。 这将揭示我们目前创建的结构的简单性。 我们已经有一个更新事件,可以同步所有飞船的位置。 对于我们而言,执行以下操作就足够了:

  1. 强迫客户每次移动到新位置时生成一条消息。
  2. 教会服务器收听此移动消息并更新players词典中的players数据元素。
  3. 为所有客户端生成更新事件。

这样就足够了! 现在轮到您自己尝试实现了。

如果您完全困惑并且需要提示,请查看完成的项目

有关最小化通过网络传输的数据的说明


实现它的最直接的方法是,每当收到来自任何玩家的移动事件时,更新所有玩家的位置。 如果播放器总是在出现后立即获得最新信息,那就太好了,但是通过网络传输的消息数量很容易增加到每帧数百条。 想象一下,您有10个玩家,每个玩家在每个帧中都发送一个移动消息。 服务器必须将它们转发回所有10个播放器。 每帧已经有100条消息!

最好这样做:等到服务器收到来自所有播放器的所有消息,然后向所有播放器发送包含所有信息的大型更新。 因此,我们会将传输的消息数减少到游戏中存在的用户数(而不是该数的平方)。 这里的问题是所有用户都将与连接速度最慢的播放器经历相同的延迟。

另一种解决方案是以恒定频率发送服务器更新,而不管从播放器收到的消息数量如何。 通用标准是每秒大约更新30次服务器。

但是,在选择服务器的结构时,您应该在游戏开发的早期阶段评估每帧中传输的消息数。

4. Shell同步


我们快完成了! 最后一个重要的部分是通过Shell网络进行同步。 我们可以通过与同步播放器相同的方式来实现它:

  • 每个客户端在每个帧中发送其所有外壳的位置。
  • 服务器将它们重定向到每个播放器。

但是有一个问题。

作弊保护


如果您将客户端传输的所有内容重定向为炮弹的真实位置,则玩家可以轻松地通过修改其客户端并将虚假数据传输给您来作弊,例如,将炮弹传送到船只的位置。 您可以通过下载网页,将代码更改为JavaScript并再次打开来轻松地自己验证。 这不仅是浏览器游戏的问题。 在一般情况下,我们永远无法相信来自用户的数据。

为了部分解决此问题,我们将尝试使用另一种方案:

  • 客户端会生成有关弹壳及其位置和方向的消息。
  • 服务器模拟壳的运动。
  • 服务器更新每个客户端的数据,并传递所有外壳的位置。
  • 客户端在从服务器接收的位置渲染外壳。

因此,客户对弹丸的位置负责,但对弹丸的速度和进一步的移动不负责。 客户可以自己更改外壳的位置,但这不会改变其他客户看到的内容。

为了实现这种方案,我们将在触发时添加消息生成。 我将不再创建精灵本身,因为它的存在和位置将完全由服务器确定。 现在,我们在index.html中拍摄的新弹丸将如下所示:

 //   if(game.input.activePointer.leftButton.isDown && !this.shot){ var speed_x = Math.cos(this.sprite.rotation + Math.PI/2) * 20; var speed_y = Math.sin(this.sprite.rotation + Math.PI/2) * 20; /*    ,       ,       var bullet = {}; bullet.speed_x = speed_x; bullet.speed_y = speed_y; bullet.sprite = game.add.sprite(this.sprite.x + bullet.speed_x,this.sprite.y + bullet.speed_y,'bullet'); bullet_array.push(bullet); */ this.shot = true; //  ,     socket.emit('shoot-bullet',{x:this.sprite.x,y:this.sprite.y,angle:this.sprite.rotation,speed_x:speed_x,speed_y:speed_y}) } 

现在,我们还可以注释掉更新客户端中shell的整个代码片段:

 /*     ,         //   for(var i=0;i<bullet_array.length;i++){ var bullet = bullet_array[i]; bullet.sprite.x += bullet.speed_x; bullet.sprite.y += bullet.speed_y; //  ,       if(bullet.sprite.x < -10 || bullet.sprite.x > WORLD_SIZE.w || bullet.sprite.y < -10 || bullet.sprite.y > WORLD_SIZE.h){ bullet.sprite.destroy(); bullet_array.splice(i,1); i--; } } */ 

最后,我们需要让客户端侦听Shell更新。 我决定以与播放器相同的方式实施此操作,也就是说,服务器仅在名为bullets-update的事件中发送所有外壳位置的数组,客户端创建或销毁外壳以保持同步。 看起来是这样的:

 //     socket.on('bullets-update',function(server_bullet_array){ //     ,   for(var i=0;i<server_bullet_array.length;i++){ if(bullet_array[i] == undefined){ bullet_array[i] = game.add.sprite(server_bullet_array[i].x,server_bullet_array[i].y,'bullet'); } else { //      ! bullet_array[i].x = server_bullet_array[i].x; bullet_array[i].y = server_bullet_array[i].y; } } //    ,   for(var i=server_bullet_array.length;i<bullet_array.length;i++){ bullet_array[i].destroy(); bullet_array.splice(i,1); i--; } }) 

这就是客户应有的一切。我将假设您已经知道将这些代码片段嵌入哪里以及如何将它们全部放在一起,但是如果您有任何问题,可以随时查看完成的结果

现在在server.js中,我们需要跟踪和模拟shell。首先,我们将创建一个用于跟踪壳的数组,类似于玩家的数组:

 var bullet_array = []; //         

接下来,我们听听弹丸射击事件:

 //   shoot-bullet        socket.on('shoot-bullet',function(data){ if(players[socket.id] == undefined) return; var new_bullet = data; data.owner_id = socket.id; //    id  bullet_array.push(new_bullet); }); 

现在我们每秒模拟壳60次:

 //   60       function ServerGameLoop(){ for(var i=0;i<bullet_array.length;i++){ var bullet = bullet_array[i]; bullet.x += bullet.speed_x; bullet.y += bullet.speed_y; // ,       if(bullet.x < -10 || bullet.x > 1000 || bullet.y < -10 || bullet.y > 1000){ bullet_array.splice(i,1); i--; } } } setInterval(ServerGameLoop, 16); 

最后一步是将update事件发送到此函数内部的某个位置(但肯定在for循环之外):

 //  ,    ,    io.emit("bullets-update",bullet_array); 

现在我们终于可以测试游戏了!如果一切正常,那么您应该看到所有客户端上的Shell均已正确同步。我们在服务器上实现了这一事实,这迫使我们做更多的工作,但这给了我们更多的控制权。例如,当我们收到弹丸发射的事件时,我们可以检查弹丸的速度是否在一定间隔内,如果不是,则我们会知道该玩家在作弊。

5.与壳碰撞


这是我们执行的最后一个基本机制。希望您已经习惯了规划实施的过程,首先完全完成客户端实施,然后再移至服务器(反之亦然)。这种方法比来回实现时更不会出错。

碰撞检查是一项至关重要的游戏机制,因此我们希望保护它免受欺骗。我们以与Shell相同的方式在服务器上实现它。我们需要以下内容:

  • 检查弹丸是否足够靠近服务器上的任何播放器。
  • 当弹丸击中玩家时,为所有客户生成事件。
  • 教客户听命中事件,并在命中时使飞船闪烁。

您可以尝试自己实现这一部分。要使玩家的飞船在被击中时闪烁,只需将其Alpha通道设置为0:

 player.sprite.alpha = 0; 

并且它将平滑地恢复为完全不透明(在播放器更新中完成)。对于其他玩家,操作将类似,但是您必须在更新功能中将其alpha通道返回到其中一个类似的通道:

 for(var id in other_players){ if(other_players[id].alpha < 1){ other_players[id].alpha += (1 - other_players[id].alpha) * 0.16; } else { other_players[id].alpha = 1; } } 

唯一困难的部分可能是验证玩家没有击中自己的弹药(否则他每次射击都会受到伤害)。

请注意,在此方案中,即使客户端尝试作弊并拒绝接受服务器发送给它的匹配消息,这也只会更改其在自己的屏幕上看到的内容。所有其他玩家仍然会看到他们击中了玩家。

6.运动平滑


如果您已经完成了到目前为止的所有步骤,我可以向您表示祝贺。您刚刚创建了可以正常工作的多人游戏!将链接发送给朋友,看看在线多人游戏的魅力如何将玩家聚集在一起!

游戏功能齐全,但我们的工作还没有结束。有一些可能会对游戏玩法产生负面影响的问题,我们必须加以解决:

  • 如果不是每个人都有快速的联系,那么其他玩家的动作看起来就会非常抽搐。
  • 弹壳似乎很慢,因为它们不会立即发射。他们出现在客户端的屏幕上之前,正在等待服务器的返回消息。

我们可以通过在客户端内插我们的船舶位置数据来解决第一个问题。因此,如果我们没有足够快地收到更新,则可以将船平稳地移动到应有的位置,而不仅仅是将其传送到那里。

外壳需要更复杂的解决方案。我们希望服务器处理外壳以防止作弊,但是我们还需要立即做出反应:射击和飞行弹丸。最好的解决方案是混合方法。服务器和客户端都可以模拟外壳,并且服务器仍将发送更新到外壳的位置。如果它们不同步,则我们假设服务器是正确的,然后重新定义射弹在客户端中的位置。

我们不会在本教程中实现此Shell系统,但是很高兴知道此方法的存在。

对船舶位置执行简单的插值非常简单。与其直接在第一次接收新位置数据的更新事件中直接设置位置,不如保存目标位置:

 //     if(id != socket.id){ other_players[id].target_x = players_data[id].x; //  ,    ,     other_players[id].target_y = players_data[id].y; other_players[id].target_rotation = players_data[id].angle; } 

然后,在更新功能(也在客户端)中,我们循环所有其他参与者,并将他们推向目标:

 //     ,      for(var id in other_players){ var p = other_players[id]; if(p.target_x != undefined){ px += (p.target_x - px) * 0.16; py += (p.target_y - py) * 0.16; //  ,    /  var angle = p.target_rotation; var dir = (angle - p.rotation) / (Math.PI * 2); dir -= Math.round(dir); dir = dir * Math.PI * 2; p.rotation += dir * 0.16; } } 

因此,服务器每秒向我们发送30次更新,但我们仍然可以60 fps的速度播放,游戏看起来仍然很流畅!

结论


我们研究了很多问题。让我们列出它们:我们学习了如何在客户端和服务器之间传输消息,如何同步游戏状态,以及如何将其从服务器广播到所有玩家。这是实现多人在线游戏的最简单方法。

我们还学习了如何防止游戏作弊,在服务器上模拟游戏的重要部分以及将结果告知客户。您对客户的信任程度越低,游戏就越安全。

最后,我们学习了如何使用客户端插值来克服延迟。延迟补偿是一个严肃的话题,非常重要(一些延迟足够大的游戏根本无法玩)。等待服务器的下一次更新时进行插值只是减少问题的一种方法。另一个是预先预测接下来的几帧,并在从服务器接收实际数据时对其进行校正,但是,当然,这种方法可能非常困难。

减少延迟影响的一种完全不同的方法是使系统设计绕过此问题。慢船转弯的优点在于它是运动的独特机制,并且是防止运动突然变化的一种方法。因此,即使连接速度较慢,它们仍然不会破坏游戏玩法。在开发游戏的基本元素时,考虑延迟非常重要。有时最好的决定根本不是技术上的技巧。

您可以使用另一个有用的故障功能,该功能包括通过左上角的“高级选项”下载或导出自己的项目的功能:

Source: https://habr.com/ru/post/zh-CN418411/


All Articles