使用 Socket.io 设置多人游戏
第 1 部分:简介
建议的先备知识
在本教程中,我们将重点介绍如何设置服务器并将游戏连接到服务器,以便通过 Socket.io 行为为项目启用多人游戏功能。我们将介绍 hyperPad 中的高级功能和行为,因此强烈建议你先学习一些其他教程,并熟悉这款软件,然后再继续。
本教程假设你已经充分了解 hyperPad 的核心功能,并对编程有基本的了解,因为我们将重点介绍用于构建游戏服务器的 Javascript 脚本。本教程还假设你对网络以及服务器与客户端之间的区别有一些基本认识。
本教程的主要目标是教你如何为游戏添加多人游戏功能,而不是教你如何制作游戏本身。我们将探讨 Socket.io 客户端与服务器之间的关系,以及它们如何通过 Socket.io 行为相互通信。
要求
要创建并托管服务器,你需要一台能够运行 Node.JS 的电脑(Mac、Windows 等)。本教程会介绍如何安装 Node.JS 并将其设置为满足我们的用途。如果你在家庭网络中托管服务器,并希望接受来自本地网络外部的连接,通常需要更改网关的端口转发设置。
本教程中,我们已经创建了一个简单的示例游戏。游戏中,两名玩家在一个小迷宫里玩捉人游戏。我们将介绍示例项目中 Socket.io 行为的用法,因此建议你下载项目并在 hyperPad 中打开。
已完成的 hyperPad 示例项目可在此处下载:Multiplayer tag tutorial.tap
概述
在本教程中,我们将介绍为游戏创建服务器的基础知识。服务器将处理游戏中的大部分细节,例如计分、游戏大厅等。接着,我们会创建一些行为,用于在游戏与服务器之间双向传递信息。
以下是游戏的大致流程:
1. 在主菜单中,我们将连接到服务器,并让用户选择创建或加入游戏。
a. 如果创建游戏,则加载等候室。
b. 如果加入游戏,则加载可用游戏房间列表。
c. 当两名玩家进入等候室后,游戏就会启动。
2. 在游戏中,服务器会将玩家随机放置在四个区域之一,并指定其中一人为“捉人者”。
a. 捉到另一名玩家后,双方的位置会随机重新分配,捉人者身份会互换,捉到对方的玩家还会获得一分。
b. 如果在一定时间内没有人被捉到,服务器会从当前捉人者的分数中扣除 1 分,互换捉人者身份,然后将玩家重新随机放置到出生区域。
3. 玩家达到指定分数后,游戏会加载一个宣布胜利者的覆盖层。随后,玩家会断开与房间的连接并返回主菜单。
第 2 部分:设置服务器
要创建 Socket.io 服务器,我们需要运行一个使用 Socket.io 库的 Javascript 应用程序,并让它监听玩家连接。Socket.io 是一个 Javascript 网络库,可以为我们简化构建联网应用程序时许多底层工作的处理。稍后会详细介绍。
下载并安装 Node.JS
首先,前往 Node.JS,并将其下载到你打算用于托管服务器的电脑上(Mac、Windows 等)。下载完成后,运行安装程序并按照说明操作。安装过程中的所有选项都可以保留默认设置。Node.JS 让我们无需使用网页浏览器,就能独立运行 Javascript 应用程序。
下载并运行示例服务器
接下来,从 GitHub 下载本教程的多人游戏服务器示例:
https://github.com/hyperPad/multiplayerServerExample
点击“Clone or download”按钮,然后选择“Download ZIP”。这样就会下载服务器示例代码的副本。
解压下载的 ZIP 文件。里面会有几个小文件,其中最重要的是“index.js”文件,它包含服务器代码。接下来,在服务器示例文件夹中打开命令行/终端。
输入“npm install”并按 Enter,然后等待运行完成。npm 是一个包管理器,它会读取 package.json 文件并下载服务器所需的包。(包括 Socket.io!)
命令运行完成后,运行服务器所需的一切就都准备好了。在终端中输入“node .”,服务器就会启动。
就这样!我们的服务器现在正在端口 3000 上监听传入的 Socket.io 连接。
“node .”会在当前目录中启动 Node.JS,Node.JS 会在该目录中查找入口文件并运行。默认情况下,这个文件是“index.js” Javascript 文件;服务器的大部分代码都位于其中,我们会在本教程中逐步分析。
请保持终端开启,因为关闭终端也会关闭服务器。当玩家连接或断开连接、创建或销毁房间,以及发生其他事件时,你会看到终端打印出相应消息。
**注意:**如果要允许来自家庭网络外部的连接,通常需要在家用网关上开放端口 3000。具体操作因网络而异,但通常可以通过搜索家用调制解调器/路由器的端口转发指南找到说明。更改端口转发设置后,你可能需要关闭并重新启动 Node.JS 服务器。
第 3 部分:连接到服务器
服务器的基本设置完成后,我们现在可以在 hyperPad 游戏中连接到它。无论项目当前处于哪个场景,这都应该是项目启动时首先执行的操作(因为所有内容都需要与服务器通信)。最好将此行为附加到 Global Layer 上的对象,这样它就能应用于所有场景。
在示例项目中,Global Layer 上名为“Server”的标签包含了上述内容。(项目下载地址可在本教程第 1 部分的“要求”一节中找到。)
连接到服务器只需要这两个行为。首先,在自定义选项卡下找到 Socket.io Client 行为并将其拖入。然后,在其属性窗口的 URL 选项卡下输入服务器 URL。在上图中,我们的服务器 URL 是“http://192.168.0.191:3000”,其中包括协议和端口。你需要将其改为自己的服务器 URL,否则启动游戏时很可能无法正常工作。
现在我们已经有了所需的服务器信息,但还需要连接到服务器。为此,只需添加 Connect to Socket 行为。将其拖入,打开属性,然后在空白框中选择客户端,并将 Function 属性设为“Connect”。
这样,项目加载时就会自动连接到服务器。
第 4 部分:创建和加入房间
接下来,我们要创建游戏大厅,让玩家可以加入并一起游戏。
这是我们的简易主菜单。只需点击其中一个按钮,就可以创建房间或查找可用房间。
创建房间
我们来看看如何与服务器通信以创建房间。
很简单,对吧?简单来说,点击按钮后,系统会提示我们输入房间名称。输入完成后,该名称就会发送到服务器,由服务器创建房间,然后我们会加载等候室场景。
从这里开始,我们会经常使用 Emit to Socket。这是我们向服务器发送数据的主要方式。你可以简单地把它理解为 Broadcast Message 和 Receive Message 的“在线版本”(Emit to Socket 会一次性执行这两种操作)。
现在,我们需要在服务器中添加一些代码,以便服务器收到 Emit 行为发送的消息后创建房间。
socket.on('createRoom', (roomName, callback) => {
这一行会在服务器上创建一个 Socket 事件(以 lambda 表达式的形式),用于监听事件为“createRoom”的 Emit。
第一个参数是我们通过 Emit to Socket 行为传递的值,在本例中就是用户输入的房间名称。这里,我们将该参数命名为“roomName”。
第二个参数是一个函数,我们稍后会在 Socket 事件中调用它,以通知客户端。只有在服务器上调用了回调函数,Emit to Socket 行为才会继续执行。这里,我们将该参数命名为“callback”。
const room = {
id: uuid(),
name: roomName,
sockets: []
};
这会创建一个结构实例,用于保存房间的必要信息。
“id”是由实用函数“uuid()”生成的唯一标识符。之后,当我们需要在多个已创建的房间中识别这个特定房间时,就可以使用它。
“name”会设为用户之前输入的名称。
“sockets”会初始化为空数组。稍后,这个数组会用于跟踪连接到该房间的玩家套接字。
rooms[room.id] = room;
“rooms”是当前所有活动房间的全局列表。由于我们正在创建一个新房间,因此会按房间 ID 将它存入列表。
joinRoom(socket, room);
这会调用全局函数“joinRoom”(第 29 行),将玩家的套接字添加到房间的“sockets”数组中。由于房间是由该玩家创建的,这也会让该玩家加入房间。
callback();
});
最后,我们调用回调函数,通知客户端房间已创建。这样 Emit to Socket 就可以继续执行,接下来会加载等候室场景。这也标志着“createRoom” Socket 事件结束。
加入房间
加入房间的过程略有不同,因为我们需要从另一个场景中访问按钮所需的信息。
对于加入房间,我们将行为的开头部分放在 Global Layer 上,与连接服务器的行为放在一起。这样一来,我们就可以从任何场景访问这些信息;在本例中,我们要访问的是房间列表。对于按钮,点击它只会将玩家加载到房间列表场景中。
这里是我们用于与服务器通信的第二个行为。可以将 Socket Event 理解为 Receive Message,因为只有在服务器广播指定消息后,它才会触发。
使用 Socket Event 和 Emit to Socket 时,最好的理解方式是:Socket Event 只响应来自服务器的信息,而 Emit to Socket 则会因本地操作而调用。
至于具体逻辑,连接到服务器后,我们会发送“getRoomNames”请求,以获取所有可用房间的名称。
然后,我们会设置一个标签,供玩家点击以进入房间。
这是我们的房间列表场景。所有可用的游戏房间都会在这里加载并显示。只需点击房间名称,玩家就会进入该房间。借助之前设置的行为,列表会自动加载所有开放房间,并在屏幕上生成房间名称标签。如果没有显示任何内容,我们可以点击“Refresh List”按钮;它会重新执行这些行为,再次加载房间列表。
这里是设置列表的逻辑。我们不会在这里详细展开,因为我们只需要了解它如何与服务器通信。
首先,我们有一个 Emit to Socket 行为。它会请求服务器发送可用房间的信息。接下来,我们使用 Get Array Value 行为。服务器发送的所有数据都会以数组形式传来,需要的信息位于数组的前几个元素中。因此,我们将 Get Array Value 设为获取索引 0 处的值。之后,其他行为会提取这些数据,并为每个房间创建一个标签,将它们生成在屏幕上。
接下来,我们来看看需要在场景中点击的对象。在本场景中,它是名为“Room Name”的标签。
生成后,这个文本会充当按钮,但我们还需要附上要加入的房间 ID。为此,我们首先要获取房间 ID,这可以通过 Get Attribute 行为来完成,并将其设为动态值。然后,我们向服务器发送加入该房间的请求,并加载等候室。
socket.on('joinRoom', (roomId, callback) => {
这是“joinRoom” Socket 事件的入口。第一个参数的值是发送到服务器的房间 ID,也就是我们想加入的房间 ID。这里,我们将该参数命名为“roomId”。
const room = rooms[roomId];
joinRoom(socket, room);
使用客户端提供的 roomId,我们可以在服务器上找到相应的房间实例。然后,我们调用全局函数“joinRoom”(第 29 行),并传入想要加入房间的玩家套接字以及房间实例本身。稍后我们会看看“joinRoom”函数的具体内容。
callback();
});
最后,我们调用回调函数,通知客户端继续加载等候室场景,这也标志着“joinRoom” Socket 事件结束。
那么,“joinRoom”函数具体做了什么?我们来看看。
room.socket.push(socket);
如前所述,“room.socket”成员数组用于跟踪已连接到房间的套接字。这一行代码会将该套接字添加到数组中。
socket.join(room.id, () => {
socket.roomId = room.id;
console.log(socket.id, "Joined", room.id);
});
这是正式将客户端连接到房间的调用。首先,我们通过房间 ID 告诉套接字 加入房间。完成后,系统会调用后续的回调函数,我们会在其中将房间 ID 附加到套接字上。最后,我们会在控制台中记录玩家已加入房间!
等候室
等候室只是一个场景,玩家会先加载到这里,并在此等待其他玩家加入或游戏开始。
进入房间后,我们会向服务器发送一条“ready”消息。服务器收到两条这样的消息后,就会发送一条表示游戏即将开始的消息(“initGame”)。我们用 Socket Event 接收这条消息,然后加载游戏关卡。行为设置就是这么简单。你也可以添加一个按钮,让自己断开与房间的连接并返回主菜单。
接下来,我们来分析服务器端的代码,看看它在做什么。
这是客户端加入房间并准备好连接时触发的“ready”事件。
const room = rooms[socket.roomId];
由于我们已将房间 ID 附加到套接字,因此可以获取房间,以检查游戏是否可以开始。
if (room.sockets.length == 2) {
这里,我们检查房间中是否已经有两名玩家在等待。假设条件成立,我们就继续启动游戏。
for (const client of room.sockets) {
client.emit('initGame');
}
现在房间里有两名玩家,我们会遍历每个套接字并发送“initGame”事件,让每个客户端都加载关卡场景,如前文所示。
第 5 部分:游戏玩法
现在,终于要进入核心内容了。我们的 90% 工作都会集中在这里。下面是我们为本教程设计的游戏关卡。
不过,在开始讲解之前,我们先来看一下名为“Game Logic”的标签。打开它看看。
哇,行为可真不少!不过别担心,这些只是用于将玩家生成到游戏中的逻辑。我们仔细看看:
我们先使用 Emit to Socket 行为,通过“startGame”事件告诉服务器游戏已经开始。然后,我们获取服务器返回的数组,取出它的第一个值,再使用 Get Dictionary Value 获取对象所需的各种属性。另一个独立的行为树会执行相同操作,不过处理的是对手玩家。最后,我们向玩家对象广播“init”消息,启动游戏。
我们来看看发送“startGame”事件后,服务器端会发生什么。
你在这里看到的这个 Socket 事件的前半部分,会为每个客户端设置一些初始值,然后将所有非发送方客户端添加到本地“others”数组中。
在后半部分,系统会创建一个名为“ack”的本地字典列表。其中包含关于我们自己以及其他客户端的信息。然后,我们将字典“ack”传递给回调函数,把这些信息发回客户端;这些信息会成为调用该事件的 Emit to Socket 行为的结果值。
之后会调用一个 5 秒的定时器,等所有玩家都拿到开始游戏所需的信息后,再正式开始回合。“beginRound”函数(第 99 行)负责处理这个项目中一些特定于游戏的逻辑。我们不会详细介绍它,但它主要负责玩家的出生位置、分数检查,以及通知客户端谁是捉人者。
如前所述,准备工作完成后,“Game Logic”标签上会触发“init”消息。接下来,我们来看看玩家对象中接收“init”消息的行为。
可以看到,玩家对象上有几棵行为树。首先,我们来看左上方的行为树。
这个行为实际上会启动游戏中的其他所有内容。
首先,我们接收从“Game Logic”标签发送的“init”消息。接着,我们获取对象在服务器上的 ID,启用其中一个 Socket Event,并设置游戏画面,以便准确跟随角色。
同步移动
这是最重要的行为之一。这个小型行为树会在每次我们移动摇杆时,将玩家的位置更新到服务器上。你可能已经注意到,它还引用了一些字典值。这些值来自一个独立的字典行为,其中包含玩家的 X 和 Y 坐标。
我们来看看服务器上的“moved”事件。
data = JSON.parse(data);
将字典从客户端发送到服务器时,必须对其进行解析,以便轻松读取数据。这是因为 hyperPad 中的字典在发送到服务器时,会被编码为 JSON 结构。这一行会解析 JSON 字符串结构,并将字典列表存回同一个本地“data”变量中。
socket.x = data.x;
socket.y = data.y;
这里,我们使用客户端提供的新值更新套接字上存储的 X 和 Y 坐标。
for (const client of room.sockets) {
if (client == socket) {
continue;
}
client.emit(socket.id, {
x: socket.x,
y: socket.y,
score: socket.score,
isIt: socket.isIt
});
}
接下来,我们遍历所有客户端,向它们更新我们的新位置和其他信息,但会跳过自己(也就是说,发送消息的客户端不需要知道自己的位置)。
这棵行为树负责控制游戏中的大部分内容。服务器通过“beginRound”(第 99 行)发送消息,检查哪个玩家是捉人者;我们会使用 Socket Event 接收这条消息。
然后,我们从数组中获取对象在服务器上的 ID,并使用 Dictionary Value 将其拆分为其中包含的各项数据。接着,我们获取对象的分数、它是否是捉人者,以及它的 X 和 Y 坐标,然后将这些值应用到对象的属性上。其余行为则用于设置和控制游戏中的用户界面。
结语
本教程的内容很多,但希望读到这里的你已经了解如何使用 Socket.io 行为,为玩家创建多人游戏体验。
自己动手试试吧!找一个你已经制作好的游戏,尝试为它添加一些在线功能,例如创建一个连接到服务器的高分排行榜场景,并请求获取前 10 名玩家的分数和名称,然后显示出来。
要在一篇文章中教会大家 Javascript 这样的脚本语言并不容易。好在如果你遇到问题,还有许多其他资源可以帮助你编写适用于 Node.JS 和 Socket.io 的 Javascript 应用程序:
学习 Javascript - https://developer.mozilla.org/bm/docs/Web/JavaScript
学习 Socket.io - https://socket.io/docs/
学习 Node.JS - https://nodejs.org/en/docs/

