用 Node.js 写一个多人游戏服务器引擎

31次阅读

共计 11069 个字符,预计需要花费 28 分钟才能阅读完成。

翻译:疯狂的技术宅原文:https://www.smashingmagazine….

本文首发微信公众号:jingchengyideng 欢迎关注,每天都给你推送新鲜的前端技术文章

摘要
听说过文字冒险游戏吗?如果你的年龄足够大的话(就像我一样),那么你可能听说过、甚至玩过“back in the day”。在本文中,我将向你展示编写的整个过程。这不仅仅是一个文本冒险游戏,而是一个能让你和你的朋友们一起玩的,可以进行任何剧情的文本冒险游戏引擎。没错,我们将通过在添加多人游戏功能来增加它的趣味性。

文字冒险是最早的 RPG 形式的游戏之一,回到还没有图形画面的时代,你只能通过阅读 CRT 显示器上黑色背景下的描述,并且依赖自己的想象力来推动游戏剧情的发展。
如果要怀旧的话,可能世界上第一个文字冒险游戏名叫 Colossal Cave Adventure(也许是叫 Adventure)。

文字冒险游戏 back in the day 的画面
上图是你实际看到的游戏画面,这与我们现在的顶级 AAA 冒险游戏相差甚远。尽管如此,但是他们玩起来却很有趣,并会很容易的消磨你几百个小时的时间,因为只有你自己自己坐在显示器前,试图找到打穿它的途径。
可以理解的是,多年以来,文字冒险已经被更好的视觉效果所取代,特别是在过去几年里,游戏的协作性越强,你可以和朋友们一起玩。这是原始的文字冒险游戏所缺少的,同时也是我想在本文中提到的功能。
我们的目标
可能你已经从标题中猜到了,本文的重点在于创建一个文字冒险引擎,并且让你和朋友们一起玩,使你能够与他们进行协作,就像在玩“龙与地下城”这个游戏一样。
在创建引擎时,聊天服务器和客户端的工作了相当大。在本文中,我将向你展示设计思路、解释引擎背后的架构、客户端如何与服务器交互以及这个游戏的规则。
为了让你对我的目标又一个直观的感受,先上一张图:

游戏客户端的 UI 设计
这就是我们的目标。一旦达成这个目标,将会得到截图而不是简单和肮脏的模型。所以,需要了解这个过程。首先要介绍的就是整体设计;然后介绍我将用来编码的相关工具;最后我将向你展示一些核心代码(当然,还有指向完整代码库的链接)。
希望到最后,你能够自己创造一个新的文字冒险游戏,并与朋友一起乐在其中!
设计阶段
在设计阶段,我将描述这个游戏的整体蓝图。我会尽力不让你觉得无聊,不过我认为在给你展示第一行代码之前,很有必要先搞清楚幕后的一些工作。
我想接下来介绍的这四个组件能够提供相当多的细节:

引擎这将成为游戏的主服务器。游戏规则会在这里实现,它将为任何类型的客户端提供技术无关接口。本项目中我们将实现终端类型的客户端,但是你可以用 Web 客户端或者你喜欢的任何其他类型。
聊天服务器因为它的复杂性足以再写一篇文章了,所以这项服务也会拥有自己的模块。聊天服务器负责让玩家在游戏的过程中彼此通信。
客户端如前文所述,这将是一个终端类型的客户端,在理想情况下,它看起来与之前的模型类似。它将利用引擎和聊天服务器所提供的服务。
游戏(JSON 文件)最后,我将介绍实际游戏的定义。这部分的重点是创建一个可以运行任何游戏的引擎,只要你的游戏文件符合引擎的要求即可。所以,即使这不需要编码,我也将解释如何构建冒险文件以便将来编写我们自己的冒险规则。

引擎
游戏引擎或游戏服务器将会是 REST API,并提供所有必需的功能。
我选择 REST API 只是因为(对于这种类型的游戏)HTTP 造成的延迟以及他的异步特性不会造成任何麻烦。但是,我们必须为聊天服务器采用不同的路线。在开始定义 API 之前,先需要定义引擎的功能。所以,让我们来看看吧。

特性
描述

加入游戏
玩家可以通过指定的游戏 ID 来加入游戏。

创建一个新游戏
玩家还可以创建新的游戏实例。引擎应该返回一个 ID,以便其他人可以使它来加入游戏。

返回场景
此功能应返回玩家所在的当前场景。基本上,它将返回描述,包含所有相关信息(可能的操作、其中的对象等)。

与场景互动
这将是最复杂的一个,因为它将从客户端获取命令并执行该操作——例如移动,攻击,获取,查看,读取等等。

检查库存
虽然这是与游戏互动的一种方式,但它与场景并没有直接关系。因此,检查每个玩家的库存将被视为不同的操作。

关于移动
我们需要一种用来测量游戏中距离的方法,因为在游戏中玩家可以采取的核心行动之一就是移动。我们需要用这个数字作为时间的衡量标准,来简化游戏的玩法。考虑到这一类型的游戏具有基于回合的动作,例如战斗,使用实际时钟对时间进行测量可能不是最好的。所以我们将使用距离来测量时间(意味着距离为 8 比距离为 2 将需要更多的时间,从而允许我们做一些事情,例如为持续一定数量的“距离点”的玩家添加效果)。
考虑运动的另一个原因是不是一个人在玩这个游戏。为简单起见,引擎不会让玩家随意组队(虽然这对未来可能是一个有趣的改进)。该模块的初始版本只允许个人朝着大多数参与者决定的地方移动。因此,必须以协商一致的方式进行移动,这意味着每一步行动都将等待大多数人在行动之前提出请求。
战斗
战斗是这种游戏另一个非常重要的方面,我们不得不考虑将它添加到引擎中,否则我们最终会失去一些乐趣。
说实话,这并不需要重新发明轮子。基于回合制的组队对战已经存在了几十年,所以在这里只实现这个机制的一个简单版本。我们将把它与“龙与地下城”中的“主动性”这个概念混合起来,产生一个随机数使战斗更有活力。
换句话说,就是参与战斗的每个人的行动顺序将会被随机化,其中包括敌人。
最后(虽然我将在下面详细介绍这一点),你可以用设置的“攻击力”值的物品。这些是你在战斗中可以使用的道具;如果一个道具没有这个属性的话只能对敌人造成 0 点伤害。当你试图用这样的道具进行战斗时,我们可能会添加一条消息,这样你就能知道自己要做的事情是毫无意义的。
客户端 – 服务器交互
现在来看看客户端怎样基于前面定义的功能与服务器进行交互(目前还没考虑端点,不过马上就会讲到这个):

客户端与服务器之间的交互
客户端和服务器之间的初始交互(从服务器的角度来看)是一个新游戏的开始,其步骤如下:

创建一个新游戏。客户端请求向服务器创建新游戏。

创建聊天室。虽然没有明确说明,但是服务器不只是在聊天服务器中创建聊天室,而且还设置好了所需的一切,可以允许一组玩家进行游戏。

返回游戏的元数据。一旦服务器为玩家创建好了游戏和聊天室,那么客户端会在后续请求用到这个信息。这是客户端可以用来标识自己和将要加入的游戏实例的一组 ID。

手动分享游戏 ID。这一步必须由玩家自己完成。我们可以提出某种共享机制,但我会将它留在愿望清单上等待将来改进。

加入游戏。这个非常简单。每个人都有一个 ID,客户端通过这个 ID 加入游戏。

加入聊天室。最后,玩家的客户端程序将通过游戏的元数据加入对应的聊天室。这是游戏开始前的最后一步。一旦完成所有操作,玩家就可以开始在游戏中冒险了!

游戏的动作指令
一旦满足了先决条件,玩家就可以开始游戏,通过聊天室分享他们的想法,并推动故事的发展。上图显示了所需的四个步骤。
以下步骤将作为游戏循环的一部分来运行,这意味着它们将会不断重复,一直到游戏结束。

请求场景。客户端程序将请求当前场景的元数据。这是循环每次迭代的第一步。

返回元数据。服务器将发回当前场景的元数据。这些信息中包括一般描述,从中可以找到的对象以及它们彼此之间的关系。

发送命令。好戏开始。这是玩家的主要输入方式。它包括玩家想要执行的操作,以及可选的操作目标(例如吹蜡烛、抓住岩石等)。

对发来的命令做出响应。这应该属于第二步,但为了清楚起见,我把它作为额外步骤。主要区别在于第二步可以被认为是这个循环的开始,而这一步考虑到你已经开始进行游戏了,因此,服务器需要了解这个动作将影响谁(单个或所有玩家)。

作为额外步骤,虽然不是流程的一部分,但服务器将通知客户端与它们相关的状态的更新情况。
存在这个额外重复步骤的原因是玩家可以从其他玩家的动作中获得更新。回想从一个地方移动另一个地方的需求;正如我之前所说那样,一旦大多数玩家选择了方向,那么所有玩家都会移动(不需要所有球员的输入)。
不过 HTTP(前面已经提到服务器为 REST API)不允许这种类型的行为。所以,我们的选择是:

每隔 X 秒从客户端轮询,
使用某种与客户端 - 服务器连接通信机制并行工作的通知系统。

根据我的经验,我倾向于选择选项 2。实际上,我会(在本文中)使用 Redis 来实现这种行为。
下图演示了服务之间的依赖关系。

客户端应用程序与游戏引擎之间的交互
聊天服务器
我将把这个模块的设计细节留给开发阶段(本文不涉及这一部分)。话虽如此,我们仍可以决定一些事情。
我们可以确定的一件事是服务器的限制集合,这将简化我们的工作。如果我们正确地玩牌,最终可能会有一个提供强大界面的服务,从而允许我们去进行扩展甚至修改实现,以提供更少的限制,而不会影响到游戏。

每个组队只有一个房间。我们不会创建子组队。这和不让组队分裂是相辅相成的。也许一旦以后我们实现了这个增强功能,允许创建子组和自定义聊天室或许是一个好主意。

没有私信功能。这纯粹是为了简化,但是只有群聊并不够好。目前我们不需要私信。请记住,任何时候只研究你的最小化可行产品,尽量避免掉进不必要功能的陷阱;这是一条危险的道路,很难从困境中摆脱出来。

不会保存留言。换句话说,如果你离开组队,将会丢失这些信息。这将极大地简化我们的任务,因为我们不必处理任何类型的数据存储,也不必浪费时间来优化存储和恢复旧消息的数据结构。它们都存在于内存中,只要聊天室处于活动状态,就会一直存在。一旦关闭,就会简单地对它们说 Goodbye!

通过网络套接字进行通信。可悲的是,我们的客户将不得不处理双重沟通渠道:游戏引擎的 RESTful 和聊天服务器的套接字。这可能会增加客户端的复杂性,但与此同时,它将为每个模块使用最佳通信方法。(在聊天服务器上强制 REST 或在游戏服务器上强制使用套接字没有任何意义。这种方法会增加服务器端代码的复杂性,这也是处理业务逻辑的代码,所以让我们关注目前的问题。)

这就是聊天服务器。毕竟,它不会很复杂。在开始编码之前还有很多工作要做,但是对于本文来说已经足够了。
客户端
这是最后一个需要编码的模块,它将是最笨重的一个模块。根据经验来看,我更喜欢让客户端笨重,使服务器轻巧。这样为服务器开发新的客户端会更加容易。
这是我们最终应该采用的架构。

最终架构
我们要实现的 ClI 客户端很简单,不会实现任何非常复杂的东西。实际上,必须要解决的最复杂的部分是 UI,因为它是一个基于文本的界面。
客户端应用程序必须实现的功能如下:

创建一个新游戏。因为我希望尽可能保持简单,所以这只能通过 CLI 界面完成。实际用户界面只会在加入游戏后被用到,这把我们带到下一个问题。

加入现有游戏。玩家可以根据由上一条返回的游戏编号来加入游戏。另外,这件事应该能够在没有 UI 的情况下完成,因此这个功能将成为开始使用文本 UI 所需的过程的一部分。

解析游戏定义文件。我们将对这点进行的讨论,客户端应该能够理解这些文件,以便能够理解要显示的内容,并知道应该如何使用这个数据。

与冒险互动。基本上,这使玩家能够在任何时间与给出描述的环境进行交互。

为每位玩家维护背包内容。客户端的每个实例都将在内存中包含一份道具列表。此列表将被备份。

支持聊天。客户端程序还需要连接到聊天服务器,并使用户登录到组队的聊天室。

稍后将详细介绍客户端的内部结构和设计。与此同时,让我们完成设计阶段的最后一部分:游戏文件。
游戏:JSON 文件
这是它变得有趣的地方,因为到次为止,我已经涵盖了基本的微服务定义。其中一些可能会基于 REST,而另外一些可能会使用套接字,但本质上它们都是一样的:你定义并对它们编码,然后它们提供服务。
我不打算对这个特定的组件做任何编码,但我们仍然需要设计它。基本上我们是在实现一种协议来定义游戏、它内部的场景以及一切。
如果你想一想,文本冒险的核心基本上是一组相互连接的房间,里面是你可以与之互动的“事物”,所有这些都与一个引人入胜的故事联系在一起。现在我们的引擎不会处理最后一部分,这部分将取决于你。
现在回到相互连接的房间,对我来说这就像一个图结构,如果我们还添加了前面提到的距离或移动速度的概念,还需要一个加权图。这只是一组节点,它们具有权重(或只是一个数字 —— 不要纠结它的名称),代表了它们之间的路径。下面是一个示意图(我喜欢通过观察进行学习,所以只看图,好吗?):

这是一个加权图 —— 就是这样。我相信你已经弄明白了,但为了完整起见,让我告诉你一旦我们的引擎准备就绪,你将会做些什么。
一旦开始设置游戏,你将创建地图(就像你在下图中左侧看到的那样)。然后将其转换为加权图,如图所示。引擎将能够接收它并让你按正确的顺序进行浏览。

一个地牢的示例图
通过上面的加权图,可以确保玩家不能从入口一下子走到左翼。他们必须通过这两者之间的节点,这样做会消耗时间,可以用连接的权重来测量。
现在,进入“有趣”的部分。来看看地图在 JSON 格式中的样子。这个 JSON 将包含很多信息:
{
“graph”: [
{“id”: “entrance”, “name”: “Entrance”, “north”: { “node”: “1stroom”, “distance”: 1} },
{“id”: “1st room”, “name”: “1st Room”, “south”: {“node”: “entrance”, “distance”: 1} , “north”: {“node”: “bigroom”, “distance”: 1} } ,
{“id”: “bigroom”,
“name”: “Big room”,
“south”: {“node”: “1stroom”, “distance”: 1},
“north”: {“node”: “bossroom”, “distance”: 2},
“east”: {“node”: “rightwing”, “distance”: 3} ,
“west”: {“node”: “leftwing”, “distance”: 3}
},
{“id”: “bossroom”, “name”: “Boss room”, “south”: {“node”: “bigroom”, “distance”: 2} }
{“id”: “leftwing”, “name”: “Left Wing”, “east”: {“node”: “bigroom”, “distance”: 3} }
{“id”: “rightwing”, “name”: “Right Wing”, “west”: { “node”: “bigroom”, “distance”: 3} }
],
“game”: {
“win-condition”: {
“source”: “finalboss”,
“condition”: {
“type”: “comparison”,
“left”: “hp”,
“right”: “0”,
“symbol”: “<=”
}
},
“lose-condition”: {
“source”: “player”,
“condition”: {
“type”: “comparison”,
“left”: “hp”,
“right”: “0”,
“symbol”: “<=”
}
}
},
“rooms”: {
“entrance”: {
“description”: {
“default”: “You’re at the entrance of the dungeon. There are two lit torches on each wall (one on your right and one on your left). You see only one path: ahead.”
},
“items”: [
{
“id”: “littorch1”,
“name”: “Lit torch on the right”,
“triggers”: [
{
“action”: “grab”, //grab Lit torch on the right
“effect”:{
“statusUpdate”: “has light”,
“target”: “game”,
}
}
] ,
“destination”: “hand”
},
{
“id”: “littorch2”,
“name”: “Lit torch on the left”,
“triggers”: [
{
“action”: “grab”, //grab Lit torch on the left
“effect”:{
“statusUpdate”: “has light”,
“target”: “game”,
}
}
] ,
“destination”: “hand”

}
]
},
“1stroom”: {
“description”: {
“default”: “You’re in a very dark room. There are no windows and no source of light, other than the one at the entrance. You get the feeling you’re not alone here.”,
“conditionals”: {
“has light”: “The room you find yourself in appears to be empty, aside from a single chair in the right corner. There appears to be only one way out: deeper into the dungeon.”
}
},
“items”: [
{
“id”: “chair”,
“name”: “Wooden chair”,
“details”: “It’s a wooden chair, nothing fancy about it. It appears to have been sitting here, untouched, for a while now.”,
“subitems”: [
{“id”: “woodenleg”,
“name”: “Wooden leg”,
“triggeractions”: [
{“action”: “break”, “target”: “chair”}, //break
{“action”: “throw”, “target”: “chair”} //throw
],
“destination”: “inventory”,
“damage”: 2
}
]
}
]
},
“bigroom”: {
“description”: {
“default”: “You’ve reached the big room. On every wall are torches lighting every corner. The walls are painted white, and the ceiling is tall and filled with painted white stars on a black background. There is a gateway on either side and a big, wooden double door in front of you.”
},
“exits”: {
“north”: {“id”: “bossdoor”, “name”: “Big double door”, “status”: “locked”, “details”: “A aig, wooden double door. It seems like something big usually comes through here.”}
},
“items”: []
},
“leftwing”: {
“description”: {
“default”: “Another dark room. It doesn’t look like it’s that big, but you can’t really tell what’s inside. You do, however, smell rotten meat somewhere inside.”,
“conditionals”: {
“has light”: “You appear to have found the kitchen. There are tables full of meat everywhere, and a big knife sticking out of what appears to be the head of a cow.”
}
},
“items”: [
{“id”: “bigknife”, “name”: “Big knife”, “destination”: “inventory”, “damage”: 10}
]
},
“rightwing”: {
“description”: {
“default”: “This appear to be some sort of office. There is a wooden desk in the middle, torches lighting every wall, and a single key resting on top of the desk.”
},
“items”: [
{“id”: “key”,
“name”: “Golden key”,
“details”: “A small golden key. What use could you have for it?”,
“destination”: “inventory”,
“triggers”: [{
“action”: “use”, //use on north exit (contextual)
“target”: {
“room”: “bigroom”,
“exit”: “north”
},
“effect”: {
“statusUpdate”: “unlocked”,
“target”: {
“room”: “bigroom”,
“exit”: “north”
}
}
}
]
}
]
},
“bossroom”: {
“description”: {
“default”: “You appear to have reached the end of the dungeon. There are no exits other than the one you just came in through. The only other thing that bothers you is the hulking giant looking like it’s going to kill you, standing about 10 feet from you.”
},
“npcs”: [
{
“id”: “finalboss”,
“name”: “Hulking Ogre”,
“details”: “A huge, green, muscular giant with a single eye in the middle of his forehead. It doesn’t just look bad, it also smells like hell.”,
“stats”: {
“hp”: 10,
“damage”: 3
}
}
]
}
}
}
它看起来有很多内容,但是如果你把它视为一个简单的游戏描述,就会明白这是一个含有六个房间的地牢,每个房间都与其他房间相互连接,如上图所示。
你的任务是穿越并探索它。你会发现有两个地方可以找到武器(无论是在厨房还是在黑暗的房间,只要破坏掉椅子就能得到)。你也将面对一扇上锁的门,所以,一旦找到钥匙(位于类似办公室的房间内),就可以打开并用你收集到的武器和 BOSS 展开一场大战。
你可以干掉它而获胜,也可以被它杀死而输掉。
现在让我们更详细地了解整个 JSON 结构及其中的三个部分。
Graph
这里包含节点之间的关系。基本上这一部分会直接转换为我们之前看到的图。
这部分的结构非常简单。它是一个节点列表,其中每个节点都包含以下属性:

一个标识游戏中所有其他节点的唯一 ID;
一个名称,实际上是给玩家看到的 ID 版本;
一组指向其他节点的链接。这可以通过四个可能的 key 来描述:north, south, east 和 west.。我们可以通过添加这四个组合来增加更多方向。每个链接都包含相关节点的 ID 以及该关系的距离(或权重)。

Game
本节包含常规设置和条件。特别是在上面的示例中,此部分包含输赢条件。换句话说,在这两个条件下,我们会让游戏知道什么时候结束。
为了简单起见,我添加了两个条件:

要么通过杀死 BOSS 获胜,
或者因为被杀而输掉。

Rooms
这一部分占了 JSON 文件很大的篇幅,也是最复杂的部分。在这里描述冒险中所有区域及其内部所有房间。
每个房间都有一把钥匙,使用我们之前定义的 ID。每个房间都有一个描述,一个物品列表,一个出口(或门)列表和一个非玩家角色(NPC)列表。在这些属性中,唯一应该被强制定义的属性是描述,因为引擎需要这个属性才能让你明白所看到的内容。如果有什么东西需要展示,它们只能在那里。
让我们来看看这些属性能为游戏做些什么。
description
这一项并不像想象的那么简单,因为你看到的房间可能会根据不同的情况而变化。例如:如果你查看第一个房间的描述,就会注意到在默认情况下,你将看不到任何东西,除非你有一个点亮的火炬。
因此,拾取物品并使用它们,可能会触发影响游戏中其他部分的全局条件。
items
这些代表了你可以在房间内找到的所有东西。每个项目都会共享与 graph 节点相同的 ID 和名称。
它们还有“目标”属性,该属性指示一旦拾取该道具应放在哪里。这是有意义的,因为你手上只能装备一个道具,而在背包中可以存放很多的道具。
最后,其中一些道具可能会触发其他操作或者状态更新,具体取决于玩家决定用它们做什么。其中一个例子就是从入口处点燃的火把。如果你拿着一个,将在游戏中触发状态更新,这反过来将使游戏向你显示下一个房间的不同描述。
道具也可以有“子道具”,一旦原始道具被销毁(例如通过“分解”操作)就会发挥作用。一个道具可以被分解为多个,并在“subitems”元素中定义。
本质上,此元素只是一个新道具的数组,其中还包含可以触发其创建的一组操作。基本上可以根据你对原始道具执行的操作创建不同的子道具。
最后,有些物品会有“伤害”属性。所以如果你用某个道具击中 NPC,该值用于从中减去生命。
exits
出口是与道具分开的实体,因为引擎需要知道你是否能够根据其状态去遍历它们。否则被锁定的出口无法让你通过,除非你把它的状态改为已解锁。
NPC
最后,NPC 将成为另一个列表的一部分。它们是有状态信息的项目,引擎将使用这些状态信息来了解每个项目的行为方式。在我们的例子中定义的是“hp”,它代表健康状态,还有“damage”,就像武器一样,每次命中将从玩家的健康状况中减去相应的值。
这就是我创造的地牢。内容很多,将来我可能会考虑写一个编辑器,来简化 JSON 文件的创建。但就目前而言还没有必要。
你可能还没有意识到,这样在文件中定义游戏是有很大好处的,能够像超级任天堂时代那样切换 JSON 文件。只需加载一个新文件就能开始另一个游戏。非常简单!
总结
感谢你能读到这里。希望你能喜欢我所经历的设计过程,并将想法变为现实。我正在努力实现这一目标。我们以后可能会意识到,今天定义的内容可能会不起作用,出现这种情况时,我们将不得不回溯并修复它。
我敢肯定,有很多方法可以对这里提出的想法进行改善,并创建一个地狱的引擎。但是这需要在本文中添加的更多的内容,为了不让读者感到无聊,所以就先这样吧。

欢迎继续阅读本专栏其它高赞文章:

12 个令人惊叹的 CSS 实验项目
世界顶级公司的前端面试都问些什么
CSS Flexbox 可视化手册
过节很无聊?还是用 JavaScript 写一个脑力小游戏吧!
从设计者的角度看 React
CSS 粘性定位是怎样工作的
一步步教你用 HTML5 SVG 实现动画效果
程序员 30 岁前月薪达不到 30K,该何去何从
第三方 CSS 安全吗?
谈谈 super(props) 的重要性

本文首发微信公众号:jingchengyideng
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

正文完
 0