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

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

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


摘要

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


文字冒险是最先的 RPG 形式的游戏之一,回到尚未图形画面的时代,你只能经过阅读 CRT 显示器上黑色背景下的描述,而且依赖本身的想象力来推进游戏剧情的发展。程序员

若是要怀旧的话,可能世界上第一个文字冒险游戏名叫 Colossal Cave Adventure(也许是叫 Adventure)。面试

文字冒险游戏 back in the day 的画面

文字冒险游戏 back in the day 的画面json

上图是你实际看到的游戏画面,这与咱们如今的顶级 AAA 冒险游戏相差甚远。 尽管如此,可是他们玩起来却颇有趣,并会很容易的消磨你几百个小时的时间,由于只有你本身本身坐在显示器前,试图找到打穿它的途径。segmentfault

能够理解的是,多年以来,文字冒险已经被更好的视觉效果所取代,特别是在过去几年里,游戏的协做性越强,你能够和朋友们一块儿玩。 这是原始的文字冒险游戏所缺乏的,同时也是我想在本文中提到的功能。windows

咱们的目标

可能你已经从标题中猜到了,本文的重点在于建立一个文字冒险引擎,而且让你和朋友们一块儿玩,使你可以与他们进行协做,就像在玩“龙与地下城”这个游戏同样。数组

在建立引擎时,聊天服务器和客户端的工做了至关大。 在本文中,我将向你展现设计思路、解释引擎背后的架构、客户端如何与服务器交互以及这个游戏的规则。服务器

为了让你对个人目标又一个直观的感觉,先上一张图:微信

游戏客户端的 UI 设计

游戏客户端的 UI 设计

这就是咱们的目标。 一旦达成这个目标,将会获得截图而不是简单和肮脏的模型。 因此,须要了解这个过程。首先要介绍的就是总体设计;而后介绍我将用来编码的相关工具;最后我将向你展现一些核心代码(固然,还有指向完整代码库的连接)。

但愿到最后,你可以本身创造一个新的文字冒险游戏,并与朋友一块儿乐在其中!

设计阶段

在设计阶段,我将描述这个游戏的总体蓝图。 我会尽力不让你以为无聊,不过我认为在给你展现第一行代码以前,颇有必要先搞清楚幕后的一些工做。

我想接下来介绍的这四个组件可以提供至关多的细节:

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

引擎

游戏引擎或游戏服务器将会是REST API,并提供全部必需的功能。

我选择REST API只是由于(对于这种类型的游戏)HTTP形成的延迟以及他的异步特性不会形成任何麻烦。 可是,咱们必须为聊天服务器采用不一样的路线。 在开始定义 API 以前,先须要定义引擎的功能。 因此,让咱们来看看吧。

特性 描述
加入游戏 玩家能够经过指定的游戏ID来加入游戏。
建立一个新游戏 玩家还能够建立新的游戏实例。 引擎应该返回一个ID,以便其余人可使它来加入游戏。
返回场景 此功能应返回玩家所在的当前场景。 基本上,它将返回描述,包含全部相关信息(可能的操做、其中的对象等)。
与场景互动 这将是最复杂的一个,由于它将从客户端获取命令并执行该操做——例如移动,攻击,获取,查看,读取等等。
检查库存 虽然这是与游戏互动的一种方式,但它与场景并无直接关系。 所以,检查每一个玩家的库存将被视为不一样的操做。
关于移动

咱们须要一种用来测量游戏中距离的方法,由于在游戏中玩家能够采起的核心行动之一就是移动。 咱们须要用这个数字做为时间的衡量标准,来简化游戏的玩法。 考虑到这一类型的游戏具备基于回合的动做,例如战斗,使用实际时钟对时间进行测量可能不是最好的。 因此咱们将使用距离来测量时间(意味着距离为 8 比距离为 2 将须要更多的时间,从而容许咱们作一些事情,例如为持续必定数量的“距离点”的玩家添加效果)。

考虑运动的另外一个缘由是否是一我的在玩这个游戏。 为简单起见,引擎不会让玩家随意组队(虽然这对将来多是一个有趣的改进)。 该模块的初始版本只容许我的朝着大多数参与者决定的地方移动。所以,必须以协商一致的方式进行移动,这意味着每一步行动都将等待大多数人在行动以前提出请求。

战斗

战斗是这种游戏另外一个很是重要的方面,咱们不得不考虑将它添加到引擎中,不然咱们最终会失去一些乐趣。

说实话,这并不须要从新发明轮子。基于回合制的组队对战已经存在了几十年,因此在这里只实现这个机制的一个简单版本。咱们将把它与“龙与地下城”中的“主动性”这个概念混合起来,产生一个随机数使战斗更有活力。

换句话说,就是参与战斗的每一个人的行动顺序将会被随机化,其中包括敌人。

最后(虽然我将在下面详细介绍这一点),你能够用设置的“攻击力”值的物品。这些是你在战斗中可使用的道具;若是一个道具没有这个属性的话只能对敌人形成 0 点伤害。当你试图用这样的道具进行战斗时,咱们可能会添加一条消息,这样你就能知道本身要作的事情是毫无心义的。

客户端 - 服务器交互

如今来看看客户端怎样基于前面定义的功能与服务器进行交互(目前还没考虑端点,不过立刻就会讲到这个):

客户端与服务器之间的交互

客户端与服务器之间的交互

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

  1. 建立一个新游戏
    客户端请求向服务器建立新游戏。
  2. 建立聊天室
    虽然没有明确说明,可是服务器不仅是在聊天服务器中建立聊天室,并且还设置好了所需的一切,能够容许一组玩家进行游戏。
  3. 返回游戏的元数据
    一旦服务器为玩家建立好了游戏和聊天室,那么客户端会在后续请求用到这个信息。这是客户端能够用来标识本身和将要加入的游戏实例的一组ID。
  4. 手动分享游戏ID
    这一步必须由玩家本身完成。咱们能够提出某种共享机制,但我会将它留在愿望清单上等待未来改进。
  5. 加入游戏
    这个很是简单。每一个人都有一个 ID,客户端经过这个 ID 加入游戏。
  6. 加入聊天室
    最后,玩家的客户端程序将经过游戏的元数据加入对应的聊天室。这是游戏开始前的最后一步。一旦完成全部操做,玩家就能够开始在游戏中冒险了!

游戏的动做指令

游戏的动做指令

一旦知足了先决条件,玩家就能够开始游戏,经过聊天室分享他们的想法,并推进故事的发展。上图显示了所需的四个步骤。

如下步骤将做为游戏循环的一部分来运行,这意味着它们将会不断重复,一直到游戏结束。

  1. 请求场景。
    客户端程序将请求当前场景的元数据。这是循环每次迭代的第一步。
  2. 返回元数据。
    服务器将发回当前场景的元数据。这些信息中包括通常描述,从中能够找到的对象以及它们彼此之间的关系。
  3. 发送命令。
    好戏开始。这是玩家的主要输入方式。它包括玩家想要执行的操做,以及可选的操做目标(例如吹蜡烛、抓住岩石等)。
  4. 对发来的命令作出响应

    这应该属于第二步,但为了清楚起见,我把它做为额外步骤。主要区别在于第二步能够被认为是这个循环的开始,而这一步考虑到你已经开始进行游戏了,所以,服务器须要了解这个动做将影响谁(单个或全部玩家)。

做为额外步骤,虽然不是流程的一部分,但服务器将通知客户端与它们相关的状态的更新状况。

存在这个额外重复步骤的缘由是玩家能够从其余玩家的动做中得到更新。回想从一个地方移动另外一个地方的需求;正如我以前所说那样,一旦大多数玩家选择了方向,那么全部玩家都会移动(不须要全部球员的输入)。

不过 HTTP(前面已经提到服务器为REST API)不容许这种类型的行为。因此,咱们的选择是:

  1. 每隔 X 秒从客户端轮询,
  2. 使用某种与客户端-服务器链接通讯机制并行工做的通知系统。

根据个人经验,我倾向于选择选项 2。实际上,我会(在本文中)使用Redis来实现这种行为。

下图演示了服务之间的依赖关系。

客户端程序和游戏引擎之间的交互

客户端应用程序与游戏引擎之间的交互

聊天服务器

我将把这个模块的设计细节留给开发阶段(本文不涉及这一部分)。话虽如此,咱们仍能够决定一些事情。

咱们能够肯定的一件事是服务器的限制集合,这将简化咱们的工做。若是咱们正确地玩牌,最终可能会有一个提供强大界面的服务,从而容许咱们去进行扩展甚至修改实现,以提供更少的限制,而不会影响到游戏。

  • 每一个组队只有一个房间。
    咱们不会建立子组队。这和不让组队分裂是相辅相成的。也许一旦之后咱们实现了这个加强功能,容许建立子组和自定义聊天室或许是一个好主意。
  • 没有私信功能。
    这纯粹是为了简化,可是只有群聊并不够好。目前咱们不须要私信。请记住,任什么时候候只研究你的最小化可行产品,尽可能避免掉进没必要要功能的陷阱;这是一条危险的道路,很难从困境中摆脱出来。
  • 不会保存留言。
    换句话说,若是你离开组队,将会丢失这些信息。这将极大地简化咱们的任务,由于咱们没必要处理任何类型的数据存储,也没必要浪费时间来优化存储和恢复旧消息的数据结构。它们都存在于内存中,只要聊天室处于活动状态,就会一直存在。一旦关闭,就会简单地对它们说Goodbye!
  • 经过网络套接字进行通讯
    可悲的是,咱们的客户将不得不处理双重沟通渠道:游戏引擎的 RESTful 和聊天服务器的套接字。这可能会增长客户端的复杂性,但与此同时,它将为每一个模块使用最佳通讯方法。 (在聊天服务器上强制 REST 或在游戏服务器上强制使用套接字没有任何意义。这种方法会增长服务器端代码的复杂性,这也是处理业务逻辑的代码,因此让咱们关注目前的问题。)

这就是聊天服务器。毕竟,它不会很复杂。在开始编码以前还有不少工做要作,可是对于本文来讲已经足够了。

客户端

这是最后一个须要编码的模块,它将是最笨重的一个模块。根据经验来看,我更喜欢让客户端笨重,使服务器轻巧。这样为服务器开发新的客户端会更加容易。

这是咱们最终应该采用的架构。

最终架构

最终架构

咱们要实现的ClI客户端很简单,不会实现任何很是复杂的东西。实际上,必需要解决的最复杂的部分是 UI,由于它是一个基于文本的界面。

客户端应用程序必须实现的功能以下:

  1. 建立一个新游戏
    由于我但愿尽量保持简单,因此这只能经过 CLI 界面完成。实际用户界面只会在加入游戏后被用到,这把咱们带到下一个问题。
  2. 加入现有游戏
    玩家能够根据由上一条返回的游戏编号来加入游戏。另外,这件事应该可以在没有 UI 的状况下完成,所以这个功能将成为开始使用文本 UI 所需的过程的一部分。
  3. 解析游戏定义文件
    咱们将对这点进行的讨论,客户端应该可以理解这些文件,以便可以理解要显示的内容,并知道应该如何使用这个数据。
  4. 与冒险互动。
    基本上,这使玩家可以在任什么时候间与给出描述的环境进行交互。
  5. 为每位玩家维护背包内容
    客户端的每一个实例都将在内存中包含一份道具列表。此列表将被备份。
  6. 支持聊天
    客户端程序还须要链接到聊天服务器,并使用户登陆到组队的聊天室。

稍后将详细介绍客户端的内部结构和设计。与此同时,让咱们完成设计阶段的最后一部分:游戏文件。

游戏: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 文件。只需加载一个新文件就能开始另外一个游戏。很是简单!

总结

感谢你能读到这里。但愿你能喜欢我所经历的设计过程,并将想法变为现实。我正在努力实现这一目标。咱们之后可能会意识到,今天定义的内容可能会不起做用,出现这种状况时,咱们将不得不回溯并修复它。

我敢确定,有不少方法能够对这里提出的想法进行改善,并建立一个地狱的引擎。可是这须要在本文中添加的更多的内容,为了避免让读者感到无聊,因此就先这样吧。


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


本文首发微信公众号:jingchengyideng

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

相关文章
相关标签/搜索