由于以下几个原因,创建多人游戏具有挑战性:它们的托管成本可能很高,设计起来很棘手,并且难以实施。通过本教程,我希望解决最后一个障碍。
这是针对知道如何制作游戏并熟悉javascript但从未制作过在线多人游戏的开发人员。完成后,您应该可以轻松地将基本网络组件实施到任何游戏中,并能够从那里构建!
这就是我们将要构建的:
您可以在这里试用游戏的现场版!W 或向上移动到鼠标并单击射击。(如果没有其他人在线,请尝试在同一台计算机上打开两个浏览器窗口,或者在您的手机上打开一个窗口,以观察多人游戏的工作方式)。如果您有兴趣在本地运行它,完整的源代码也可以在GitHub上找到。
我使用 Kenney 的 Pirate Pack艺术资产和Phaser游戏框架将这个游戏组合在一起。您将在本教程中担任网络程序员的角色。您的起点将是该游戏的功能齐全的单人游戏版本,您的工作将是在节点.js 中编写服务器,使用Socket.io作为网络部分。为了使本教程易于管理,我将专注于多人游戏部分并略读 Phaser 和 node.js 的特定概念。
无需在本地设置任何内容,因为我们将完全在Glitch.com上的浏览器中制作这款游戏!Glitch 是一个用于构建 Web 应用程序的很棒的工具,包括后端、数据库和一切。它非常适合原型设计、教学和协作,我很高兴在本教程中向您介绍它。
让我们潜入水中。
1.设置
我已经 在 Glitch.com 上发布了入门工具包。
一些快速的界面提示:在任何时候,您都可以通过单击“显示”按钮(左上角)来查看应用程序的实时预览。
左侧的垂直侧边栏包含应用程序中的所有文件。要编辑此应用程序,您需要“重新混合”它。这将在您的帐户上创建它的副本(或用 git lingo 分叉)。单击重新混合此按钮。
此时,您将在匿名帐户下编辑应用程序。您可以登录(右上角)以保存您的工作。
现在,在我们继续之前,熟悉您尝试添加多人游戏的游戏的代码很重要。看看index.html。除了玩家对象(第 35 行)之外,还有三个重要的函数需要注意:(preload第 99 行)、create(第 115 行)和(第 142 行)。GameLoop
如果您想边做边学,请尝试以下挑战,以确保您了解游戏的工作原理:
让世界变大(第 29 行) ——请注意,游戏中的世界有一个单独的世界大小,页面上的实际画布有一个窗口大小。
使空格键也向前推进(第 53 行)。
更改您的玩家飞船类型(第 129 行)。
让子弹移动得更慢(第 155 行)。
安装 Socket.io
Socket.io 是一个库,用于使用WebSockets管理浏览器中的实时通信(如果您正在构建多人桌面游戏,则与使用 UDP 之类的协议相反)。它还具有后备功能,以确保即使在不支持 WebSockets 时它仍然可以工作。因此,它负责消息传递协议并公开了一个不错的基于事件的消息系统供您使用。
我们需要做的第一件事是安装 Socket.io 模块。在 Glitch 上,您可以通过转到package.json文件并在依赖项中输入所需的模块来执行此操作,或者单击Add package并输入 "socket.io" 。
这将是指出服务器日志的好时机。单击左侧的“日志”按钮以显示服务器日志。您应该会看到它安装了 Socket.io 及其所有依赖项。您可以在此处查看服务器代码的任何错误或输出。
现在去server.js。这是您的服务器代码所在的位置。现在,它只有一些基本的样板来提供我们的 HTML。在顶部添加这一行以包含 Socket.io:
var io = require('socket.io')(http); // Make sure to put this after http has been defined
现在我们还需要在客户端包含 Socket.io,所以回到index.html<head>并将其添加到标签的顶部:
<!-- Load the Socket.io networking library --> <script src="/socket.io/socket.io.js"></script>
注意:Socket.io 会自动处理在该路径上提供客户端库,因此即使您在文件夹中看不到目录 /socket.io/,这也是该行有效的原因。
现在 Socket.io 已包含在内并准备就绪!
2.检测和生成玩家
我们真正迈出的第一步是在服务器上接受连接并在客户端生成新玩家。
接受服务器上的连接
在server.js的底部,添加以下代码:
// Tell Socket.io to start accepting connections 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(); // This triggers the 'connection' event on the server
如果您启动游戏然后查看您的服务器日志(单击“日志”按钮),您应该会看到它记录了该连接事件!
现在,当新玩家连接时,我们希望他们向我们发送有关其状态的信息。在这种情况下,我们至少需要知道x、y和angle以便在正确的位置正确生成它们。
该事件connection是 Socket.io 为我们触发的内置事件。我们可以监听我们想要的任何自定义事件。我要打电话给我new-player的,我希望客户在连接到有关其位置的信息后立即发送它。这看起来像这样:
// Tell Socket.io to start accepting connections io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ // Listen for new-player event on this client 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会将消息发送到连接到服务器的每个客户端,没有例外。我们不想在当前设置中这样做,因为如果您从服务器收到一条消息,要求您创建自己的船,那么将会有一个重复的精灵,因为我们已经在游戏开始时创建了自己的玩家的船。这是我们将在本教程中使用的不同类型的消息传递功能的方便备忘单。
服务器代码现在应该如下所示:
// Tell Socket.io to start accepting connections io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ // Listen for new-player event on this client console.log("New player has state:",state_data); socket.broadcast.emit('create-player',state_data); }) })
因此,每次玩家连接时,我们希望他们向我们发送包含其位置数据的消息,然后我们会将这些数据直接发送回其他所有玩家,以便他们生成该精灵。
在客户端生成
现在,要完成这个循环,我们知道我们需要在客户端上做两件事:
一旦我们连接,就会用我们的位置数据发送一条消息。
监听create-player事件并在该位置生成玩家。
对于第一个任务,在我们的create函数中创建播放器之后(大约第 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向所有其他玩家发送了一个事件,但周围没有其他玩家接收它。一旦第二个玩家连接,服务器再次发送它的广播,玩家 1 接收它并正确生成精灵,而玩家 2 错过了玩家 1 的初始连接广播。
所以问题正在发生,因为玩家 2 在游戏后期加入并且需要知道游戏的状态。我们需要告诉任何正在连接已经存在的玩家(或世界上已经发生的事情)的新玩家,以便他们能够赶上。在我们开始解决这个问题之前,我有一个简短的警告。
关于同步游戏状态的警告
有两种方法可以让每个玩家的游戏保持同步。第一个是仅发送有关网络上已更改内容的最少量信息。因此,每次新玩家连接时,您只需将该新玩家的信息发送给所有其他玩家(并向该新玩家发送世界上所有其他玩家的列表),当他们断开连接时,您会告诉所有其他玩家此单个客户端已断开连接。
第二种方法是发送整个游戏状态。在这种情况下,您只需在每次连接或断开连接时将所有玩家的完整列表发送给每个人。
第一个更好,因为它最大限度地减少了通过网络发送的信息,但它可能非常棘手,并且存在玩家不同步的风险。第二个保证玩家将始终保持同步,但涉及每条消息发送更多数据。
在我们的例子中,我们可以将所有这些整合到一个事件中,而不是在新玩家连接创建消息、断开连接删除消息以及移动更新位置时尝试发送消息。update此更新事件将始终将每个可用玩家的位置发送给所有客户端。这就是服务器所要做的。然后,客户端负责将其世界与它收到的状态保持同步。
为了实现这一点,我将:
保存一个玩家字典,键是他们的 ID,值是他们的位置数据。
当玩家连接并发送更新事件时,将玩家添加到此字典中。
当玩家断开连接并发送更新事件时,从该字典中删除玩家。
您可以尝试自己实现这一点,因为这些步骤相当简单(备忘单可能会派上用场)。以下是完整实现的样子:
// Tell Socket.io to start accepting connections // 1 - Keep a dictionary of all the players as key/value var players = {}; io.on('connection', function(socket){ console.log("New client has connected with id:",socket.id); socket.on('new-player',function(state_data){ // Listen for new-player event on this client console.log("New player has state:",state_data); // 2 - Add the new player to the dict players[socket.id] = state_data; // Send an update event io.emit('update-players',players); }) socket.on('disconnect',function(){ // 3- Delete from dict on disconnect delete players[socket.id]; // Send an update event }) })
客户端有点棘手。一方面,我们现在只需要担心update-players事件,但另一方面,如果服务器发送给我们的船只比我们知道的多,我们必须考虑创建更多船只,或者如果我们有太多船只则销毁。
以下是我在客户端处理此事件的方式:
// Listen for other players connecting // NOTE: You must have other_players = {} defined somewhere socket.on('update-players',function(players_data){ var players_found = {}; // Loop over all the player data received for(var id in players_data){ // If the player hasn't been created yet if(other_players[id] == undefined && id != socket.id){ // Make sure you don't create yourself 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; // Update positions of other players if(id != socket.id){ other_players[id].x = players_data[id].x; // Update target, not actual position, so we can interpolate other_players[id].y = players_data[id].y; other_players[id].rotation = players_data[id].angle; } } // Check if a player is missing and delete them for(var id in other_players){ if(!players_found[id]){ other_players[id].destroy(); delete other_players[id]; } } })
我在一个名为的字典中跟踪客户端上的船只other_players,我只是在脚本顶部定义了该字典(此处未显示)。由于服务器将玩家数据发送给所有玩家,我必须添加一个检查,以便客户端不会为自己创建额外的精灵。(如果您在构建它时遇到问题,这里是此时应该在 index.html 中的完整代码)。
现在测试一下。您应该能够创建和关闭多个客户端,并在正确的位置看到正确数量的船只!
3.同步船位
这是我们真正有趣的部分。我们现在想要在所有客户端中实际同步船只的位置。这就是我们迄今为止建立的结构的简单性真正体现出来的地方。我们已经有一个可以同步每个人位置的更新事件。我们现在需要做的就是:
让客户端在每次移动到新位置时发出。
让服务器监听该移动消息并更新该玩家在players字典中的条目。
向所有客户端发出更新事件。
应该就是这样!现在轮到你自己尝试实现它了。
如果您完全卡住并需要提示,您可以查看最终完成的项目作为参考。
关于最小化网络数据的注意事项
实现这一点的最直接方法是每次收到来自任何玩家的移动消息时都使用新位置更新所有玩家。这很好,因为玩家总是会在最新信息可用时立即收到,但是通过网络发送的消息数量很容易增加到每帧数百条。想象一下,如果您有 10 个玩家,每个玩家每帧发送一条移动消息,然后服务器必须将其转发回所有 10 个玩家。这已经是每帧 100 条消息了!
更好的方法可能是等到服务器收到玩家的所有消息,然后再向所有玩家发送包含所有信息的大更新。这样一来,您将发送的消息数量压缩到您在游戏中拥有的玩家数量(而不是该数字的平方)。然而,这样做的问题是,每个人都会经历与游戏中连接速度最慢的玩家一样多的延迟。
另一种方法是简单地让服务器以恒定速率发送更新,而不管到目前为止它从玩家那里收到了多少消息。以每秒 30 次左右的速度更新服务器似乎是一个通用标准。
无论您决定如何构建您的服务器,请注意在开发游戏的早期每帧发送多少消息。
4.同步子弹
我们快到了!最后一件大事是在网络上同步子弹。我们可以使用与同步播放器相同的方式进行操作:
每个客户端每帧发送其所有子弹的位置。
服务器将其转发给每个玩家。
但有一个问题。
防止作弊
如果您将客户端发送给您的任何内容作为子弹的真实位置进行转发,那么玩家可以通过修改其客户端向您发送虚假数据来作弊,例如传送到其他船只所在位置的子弹。您可以通过下载网页、修改 JavaScript 并再次运行它来轻松地自己尝试一下。这不仅仅是为浏览器制作的游戏的问题。通常,您永远无法真正信任来自客户端的数据。
为了减轻这种情况,我们将尝试不同的方案:
每当他们发射带有位置和方向的子弹时,客户端就会发出。
服务器模拟子弹的运动。
服务器用所有子弹的位置更新每个客户端。
客户端在服务器接收到的位置渲染子弹。
这样,客户端负责子弹产生的位置,而不是它移动的速度或之后的去向。客户端可以在他们自己的视图中改变子弹的位置,但他们不能改变其他客户端看到的内容。
现在,为了实现这一点,我将在您拍摄时添加一个发射。我也不再创建实际的精灵,因为它的存在和位置现在完全由服务器确定。我们在index.html中的新子弹射击代码现在应该如下所示:
// Shoot bullet 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; /* The server is now simulating the bullets, clients are just rendering bullet locations, so no need to do this anymore 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; // Tell the server we shot a bullet socket.emit('shoot-bullet',{x:this.sprite.x,y:this.sprite.y,angle:this.sprite.rotation,speed_x:speed_x,speed_y:speed_y}) }
您现在还可以注释掉更新客户端项目符号的整个部分:
/* We're updating the bullets on the server, so we don't need to do this on the client anymore // Update bullets 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; // Remove if it goes too far off screen 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--; } } */
最后,我们需要让客户端监听项目符号更新。我选择以与玩家相同的方式处理此问题,服务器只是在名为 的事件中发送所有子弹位置的数组,bullets-update客户端将创建或销毁子弹以保持同步。看起来是这样的:
// Listen for bullet update events socket.on('bullets-update',function(server_bullet_array){ // If there's not enough bullets on the client, create them 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 { //Otherwise, just update it! bullet_array[i].x = server_bullet_array[i].x; bullet_array[i].y = server_bullet_array[i].y; } } // Otherwise if there's too many, delete the extra for(var i=server_bullet_array.length;i<bullet_array.length;i++){ bullet_array[i].destroy(); bullet_array.splice(i,1); i--; } })
这应该是客户端上的所有内容。我假设您现在知道将这些片段放在哪里以及如何将所有内容拼凑在一起,但是如果您遇到任何问题,请记住您始终可以查看最终结果以供参考。
现在,在 server.js 上,我们需要跟踪和模拟子弹。首先,我们创建一个数组来跟踪子弹,就像我们为玩家创建一个数组一样:
var bullet_array = []; // 跟踪所有子弹以在服务器上更新它们
接下来,我们监听我们的 shoot bullet 事件:
// 监听 shot-bullet 事件并将其添加到我们的子弹数组中 socket.on('shoot-bullet',function(data){ if(players[socket.id] == undefined) return; var new_bullet = data; data.owner_id = socket.id; // Attach id of the player to the bullet 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; // Remove if it goes too far off screen if(bullet.x < -10 || bullet.x > 1000 || bullet.y < -10 || bullet.y > 1000){ bullet_array.splice(i,1); i--; } } } setInterval(ServerGameLoop, 16);
最后一步是在该函数内的某处发送更新事件(但绝对在 for 循环之外):
// 通过发送整个数组告诉所有人所有子弹的位置 io.emit("bullets-update",bullet_array);
现在你可以实际测试它了!如果一切顺利,您应该会看到项目符号在客户端之间正确同步。我们在服务器上执行此操作的事实是更多的工作,但也给了我们更多的控制权。例如,当我们收到一个射击子弹事件时,我们可以检查子弹的速度是否在一定范围内,否则我们就知道这个玩家在作弊。
5.子弹碰撞
这是我们将实施的最后一个核心机制。希望现在您已经习惯了规划我们的实现的过程,首先完全完成客户端实现,然后再转到服务器(反之亦然)。与实现它时来回切换相比,这种方法更不容易出错。
检查碰撞是一项至关重要的游戏机制,因此我们希望它能够防作弊。我们将在服务器上实现它,就像我们为子弹做的那样。我们需要:
检查子弹是否足够靠近服务器上的任何玩家。
每当某个玩家被击中时,向所有客户端发出事件。
让客户端监听 hit 事件并让飞船在被击中时闪烁。
你可以尝试完全自己做这个。要让玩家在被击中时闪烁,只需将其 alpha 设置为 0:
player.sprite.alpha = 0;
它会再次回到完整的 alpha 状态(这是在播放器更新中完成的)。对于其他玩家,你会做类似的事情,但你必须注意在更新函数中使用类似这样的东西将他们的 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.更顺畅的运动
如果您已经完成了所有步骤,我想祝贺您。你刚刚制作了一款可以运行的多人游戏!来吧,把它发给朋友,看看在线多人联合玩家的魔力!
游戏功能齐全,但我们的工作并不止于此。有几个问题可能会影响我们需要解决的玩家体验:
除非每个人都有快速连接,否则其他玩家的动作会看起来非常不稳定。
子弹会感觉没有反应,因为子弹不会立即发射。它在消息出现在客户端屏幕上之前等待从服务器返回的消息。
我们可以通过在客户端插入我们的船舶位置数据来修复第一个问题。因此,即使我们没有足够快地接收到更新,我们也可以顺利地将飞船移动到它应该到达的位置,而不是将它传送到那里。
子弹需要更复杂一点。我们希望服务器管理子弹,因为这样它是防作弊的,但我们也希望得到发射子弹并看到它射击的即时反馈。最好的方法是混合方法。服务器和客户端都可以模拟子弹,服务器仍然发送子弹位置更新。如果它们不同步,则假设服务器是正确的并覆盖客户端的项目符号位置。
实现我上面描述的子弹系统超出了本教程的范围,但很高兴知道这种方法存在。
对船只的位置进行简单的插值非常容易。我们不是直接在我们第一次收到新位置数据的更新事件上设置位置,而是简单地保存目标位置:
// 更新其他玩家的位置 if(id != socket.id){ other_players[id].target_x = players_data[id].x; // Update target, not actual position, so we can interpolate 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){ p.x += (p.target_x - p.x) * 0.16; p.y += (p.target_y - p.y) * 0.16; // Interpolate angle while avoiding the positive/negative issue 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 的速度玩游戏,而且看起来很流畅!
结论
呸!我们刚刚介绍了很多东西。回顾一下,我们已经了解了如何在客户端和服务器之间发送消息,以及如何通过让服务器将游戏状态中继给所有玩家来同步游戏状态。这是创建在线多人游戏体验的最简单方法。
我们还了解了如何通过模拟服务器上的重要部分并将结果通知客户端来保护您的游戏免受作弊。您对客户的信任越少,游戏就会越安全。
最后,我们看到了如何通过在客户端上插值来克服延迟。延迟补偿是一个广泛的话题并且至关重要(有些游戏在足够高的延迟下会变得无法玩)。在等待来自服务器的下一次更新时进行插值只是缓解它的一种方法。另一种方法是尝试提前预测接下来的几帧,并在收到来自服务器的实际数据后进行纠正,但这当然可能非常棘手。
减轻滞后影响的一种完全不同的方法是围绕它进行设计。让船只缓慢转动以移动的好处既是一种独特的运动机制,也是一种防止运动突然变化的方法。因此,即使连接速度较慢,它仍然不会破坏体验。在像这样设计游戏的核心元素时考虑延迟会产生巨大的影响。有时最好的解决方案根本不是技术性的。
您可能会发现 Glitch 的最后一个有用的功能是,您可以通过进入左上角的高级设置来下载或导出您的项目:
- 安装 Socket.io
- 接受服务器上的连接
- 在客户端生成
- 关于同步游戏状态的警告
- 关于最小化网络数据的注意事项
- 防止作弊