在 Node.js 中编写多人文本冒险引擎:游戏引擎服务器设计(第 2 部分)

已发表: 2022-03-10
快速总结↬欢迎来到本系列的第二部分。 第一部分,我们介绍了基于 Node.js 的平台和客户端应用程序的架构,这将使人们能够定义和玩自己的文本冒险作为一个群体。 这一次,我们将介绍 Fernando 上次定义的模块之一(游戏引擎)的创建,还将关注设计过程,以便在您开始编写代码之前了解需要发生的事情自己的爱好项目。

经过仔细考虑和模块的实际实现,我在设计阶段所做的一些定义不得不改变。 对于曾经与渴望获得理想产品但需要开发团队克制的热切客户合作过的任何人来说,这应该是一个熟悉的场景。

一旦功能被实施和测试,您的团队将开始注意到某些特征可能与原始计划不同,这没关系。 只需通知、调整并继续。 因此,事不宜迟,请允许我先解释一下与原计划相比有何变化。

本系列的其他部分

  • 第 1 部分:简介
  • 第 3 部分:创建终端客户端
  • 第 4 部分:将聊天添加到我们的游戏中

战斗机制

这可能是与原计划相比最大的变化。 我知道我说过我将采用 D&D 风格的实施方式,其中涉及的每个 PC 和 NPC 都将获得一个主动值,然后,我们将进行回合制战斗。 这是一个好主意,但是在基于 REST 的服务上实现它有点复杂,因为您无法从服务器端启动通信,也无法维护调用之间的状态。

因此,相反,我将利用 REST 的简化机制并使用它来简化我们的战斗机制。 实施的版本将基于玩家而不是基于队伍,并允许玩家攻击 NPC(非玩家角色)。 如果他们的攻击成功,则 NPC 将被杀死,否则他们将通过伤害或杀死玩家进行反击。

攻击是成功还是失败将取决于使用的武器类型和 NPC 可能具有的弱点。 所以基本上,如果你试图杀死的怪物对你的武器很弱,它就会死。 否则,它不会受到影响,而且——很可能——非常生气。

触发器

如果您密切关注我之前文章中的 JSON 游戏定义,您可能已经注意到在场景项目中找到的触发器定义。 一个特定的涉及更新游戏状态( statusUpdate )。 在实施过程中,我意识到将其用作切换开关提供了有限的自由度。 你看,在它的实现方式上(从惯用的角度来看),你可以设置一个状态,但取消设置它不是一个选项。 因此,我用两个新的触发效果替换了这个触发效果: addStatusremoveStatus 。 这些将允许您准确定义这些影响何时发生——如果有的话。 我觉得这更容易理解和推理。

这意味着触发器现在看起来像这样:

 "triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]

拿起物品时,我们正在设置状态,而放下物品时,我们正在移除它。 这样,拥有多个游戏级别的状态指示器是完全可能的并且易于管理。

跳跃后更多! 继续往下看↓

实施

有了这些更新,我们就可以开始介绍实际的实现了。 从架构的角度来看,没有任何改变。 我们仍在构建一个包含主游戏引擎逻辑的 REST API。

技术栈

对于这个特定的项目,我将使用的模块如下:

模块描述
Express.js 显然,我将使用 Express 作为整个引擎的基础。
温斯顿与日志记录有关的一切都将由 Winston 处理。
配置每个常量和环境相关变量都将由 config.js 模块处理,这大大简化了访问它们的任务。
猫鼬这将是我们的 ORM。 我将使用 Mongoose 模型对所有资源进行建模,并使用它直接与数据库交互。
uuid 我们需要生成一些唯一的 ID——这个模块将帮助我们完成这项任务。

至于 Node.js 之外使用的其他技术,我们有MongoDBRedis 。 由于缺少所需的架构,我喜欢使用 Mongo。 这个简单的事实让我可以考虑我的代码和数据格式,而不必担心更新我的表结构、模式迁移或冲突的数据类型。

关于 Redis,我倾向于在我的项目中尽可能多地使用它作为支持系统,这种情况也不例外。 我将使用 Redis 来处理所有可以被视为易失性信息的信息,例如党员编号、命令请求以及其他足够小且易失性以至于不值得永久存储的数据类型。

我还将使用 Redis 的密钥过期功能来自动管理流程的某些方面(稍后会详细介绍)。

API 定义

在进入客户端-服务器交互和数据流定义之前,我想回顾一下为此 API 定义的端点。 它们并不多,主要是我们需要遵守第 1 部分中描述的主要特性:

特征描述
加入游戏玩家可以通过指定游戏的 ID 来加入游戏。
创建一个新游戏玩家也可以创建一个新的游戏实例。 引擎应该返回一个 ID,以便其他人可以使用它来加入。
返回场景此功能应返回聚会所在的当前场景。 基本上,它将返回描述以及所有相关信息(可能的操作、其中的对象等)。
与场景互动这将是最复杂的之一,因为它将接受来自客户端的命令并执行该操作——例如移动、推送、获取、查看、读取等等。
检查库存虽然这是一种与游戏交互的方式,但它并不直接与场景相关。 因此,检查每个玩家的库存将被视为不同的操作。
注册客户端应用程序上述操作需要有效的客户端才能执行。 此端点将验证客户端应用程序并返回一个客户端 ID,该 ID 将用于后续请求的身份验证。

上面的列表转换为以下端点列表:

动词端点描述
邮政/clients 客户端应用程序将需要使用此端点获取客户端 ID 密钥。
邮政/games 客户端应用程序使用此端点创建新的游戏实例。
邮政/games/:id 创建游戏后,此端点将允许党员加入并开始游戏。
得到/games/:id/:playername 此端点将返回特定玩家的当前游戏状态。
邮政/games/:id/:playername/commands 最后,使用这个端点,客户端应用程序将能够提交命令(换句话说,这个端点将用于播放)。

让我更详细地了解我在上一个列表中描述的一些概念。

客户端应用

客户端应用程序需要注册到系统才能开始使用它。 所有端点(除了列表中的第一个端点)都是安全的,并且需要一个有效的应用程序密钥才能与请求一起发送。 为了获得该密钥,客户端应用程序需要简单地请求一个。 一旦提供,它们将持续使用,或者将在一个月未使用后过期。 这种行为是通过将密钥存储在 Redis 中并为其设置一个月长的 TTL 来控制的。

游戏实例

创建一个新游戏基本上意味着创建一个特定游戏的新实例。 这个新实例将包含所有场景及其内容的副本。 对游戏所做的任何修改只会影响该派对。 这样,许多小组可以以自己的个人方式玩相同的游戏。

玩家的游戏状态

这与前一个类似,但对每个玩家都是独一无二的。 当游戏实例保存整个队伍的游戏状态时,玩家的游戏状态保存一个特定玩家的当前状态。 主要保存库存、位置、当前场景和HP(健康点)。

播放器命令

一旦一切都设置好并且客户端应用程序已经注册并加入了游戏,它就可以开始发送命令了。 该版本引擎中实现的命令包括: movelookpickupattack

  • move命令将允许您遍历地图。 您将能够指定要移动的方向,引擎会让您知道结果。 如果您快速浏览第 1 部分,您会看到我处理地图的方法。 (简而言之,地图表示为图形,其中每个节点代表一个房间或场景,并且仅连接到代表相邻房间的其他节点。)

    节点之间的距离也出现在表示中,并与玩家的标准速度相结合; 从一个房间到另一个房间可能不像说出你的命令那么简单,但你也必须穿越距离。 实际上,这意味着从一个房间到另一个房间可能需要几个移动命令)。 这个命令的另一个有趣的方面来自于这个引擎是为了支持多人聚会,并且聚会不能分裂(至少目前不是)。

    因此,对此的解决方案类似于投票系统:每个党员都将随时发送移动命令请求。 一旦超过一半的人这样做了,就会使用最需要的方向。
  • look与移动完全不同。 它允许玩家指定他们想要检查的方向、物品或 NPC。 当您考虑与状态相关的描述时,会考虑此命令背后的关键逻辑。

    例如,假设您进入了一个新房间,但它完全黑暗(您什么都看不到),您在忽略它的情况下继续前进。 几个房间后,你从墙上拿起一个点燃的手电筒。 所以现在你可以回去重新检查那个黑暗的房间。 由于您拿起了火炬,您现在可以看到它的内部,并能够与您在其中找到的任何物品和 NPC 进行交互。

    这是通过维护游戏范围和玩家特定的状态属性集并允许游戏创建者在 JSON 文件中为我们的状态相关元素指定多个描述来实现的。 然后,每个描述都配备一个默认文本和一组条件文本,具体取决于当前状态。 后者是可选的; 唯一必须的就是默认值。

    此外,该命令还有一个look at room: look around ; 那是因为玩家会经常尝试检查房间,因此提供更容易键入的速记(或别名)命令很有意义。
  • pickup命令对于游戏玩法起着非常重要的作用。 这个命令负责将物品添加到玩家的物品栏或他们的手中(如果他们是空闲的)。 为了了解每个项目的存储位置,它们的定义有一个“目的地”属性,指定它是用于库存还是玩家的手。 从场景中成功拾取的任何内容都会从场景中移除,从而更新游戏实例的游戏版本。
  • use命令将允许您使用库存中的物品来影响环境。 例如,在一个房间里拿起一把钥匙,你就可以用它来打开另一个房间里锁着的门。
  • 有一个特殊命令,它与游戏玩法无关,而是一个辅助命令,用于获取特定信息,例如当前游戏 ID 或玩家姓名。 这个命令叫做get ,玩家可以用它来查询游戏引擎。 例如:获取 gameid
  • 最后,为这个版本的引擎实现的最后一个命令是attack命令。 我已经介绍了这个; 基本上,你必须指定你的目标和你攻击它的武器。 这样,系统将能够检查目标的弱点并确定攻击的输出。

客户端引擎交互

为了了解如何使用上面列出的端点,让我向您展示任何潜在客户如何与我们的新 API 交互。

描述
注册客户首先,客户端应用程序需要请求 API 密钥才能访问所有其他端点。 为了获得该密钥,它需要在我们的平台上注册。 唯一要提供的参数是应用程序的名称,仅此而已。
创建游戏获取 API 密钥后,首先要做的(假设这是一个全新的交互)是创建一个全新的游戏实例。 这样想:我在上一篇文章中创建的 JSON 文件包含游戏的定义,但我们需要为您和您的团队创建它的实例(想想类和对象,同样的交易)。 您可以随心所欲地使用该实例,并且不会影响其他方。
加入游戏创建游戏后,您将从引擎中获取游戏 ID。 然后,您可以使用该游戏 ID 通过您的唯一用户名加入实例。 除非你加入游戏,否则你不能玩,因为加入游戏也会为你一个人创建一个游戏状态实例。 这将是您的库存、位置和基本统计​​数据与您正在玩的游戏相关的保存位置。 您可能会同时玩多个游戏,并且每个游戏都有独立的状态。
发送命令换句话说:玩游戏。 最后一步是开始发送命令。 可用命令的数量已经涵盖,并且可以轻松扩展(稍后会详细介绍)。 每次您发送命令时,游戏都会返回新的游戏状态,让您的客户端相应地更新您的视图。

让我们把手弄脏

我已经尽可能多地进行了设计,希望这些信息能帮助您理解以下部分,所以让我们深入了解游戏引擎的具体细节。

注意我不会在本文中向您展示完整的代码,因为它非常大,而且并非所有内容都很有趣。 相反,如果您需要更多详细信息,我将显示更相关的部分并链接到完整的存储库。

主文件

首先要做的事情:这是一个 Express 项目,它基于样板代码是使用 Express 自己的生成器生成的,因此您应该熟悉app.js文件。 我只想回顾一下我喜欢在该代码上做的两个调整,以简化我的工作。

首先,我添加以下代码片段来自动包含新路由文件:

 const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })

这真的很简单,但它消除了手动要求您将来创建的每个路由文件的需要。 顺便说一句, require-dir是一个简单的模块,它负责自动请求文件夹中的每个文件。 而已。

我喜欢做的另一个改变是稍微调整我的错误处理程序。 我真的应该开始使用更强大的东西,但是对于手头的需求,我觉得这样可以完成工作:

 // error handler app.use(function(err, req, res, next) { // render the error page if(typeof err === "string") { err = { status: 500, message: err } } res.status(err.status || 500); let errorObj = { error: true, msg: err.message, errCode: err.status || 500 } if(err.trace) { errorObj.trace = err.trace } res.json(errorObj); });

上面的代码处理了我们可能必须处理的不同类型的错误消息——完整对象、Javascript 抛出的实际错误对象或没有任何其他上下文的简单错误消息。 此代码将全部采用并将其格式化为标准格式。

处理命令

这是引擎必须易于扩展的另一个方面。 在像这样的项目中,假设将来会弹出新命令是完全有道理的。 如果您想避免某些事情,那么这可能是避免在未来三四个月尝试添加新内容时对基本代码进行更改。

再多的代码注释都不会使修改几个月来没有接触(甚至没有考虑过)的代码的任务变得容易,因此首要任务是尽可能避免更改。 幸运的是,我们可以实现一些模式来解决这个问题。 特别是,我混合使用了命令模式和工厂模式。

我基本上将每个命令的行为封装在一个类中,该类继承自BaseCommand类,该类包含所有命令的通用代码。 同时,我添加了一个CommandParser模块,该模块抓取客户端发送的字符串并返回要执行的实际命令。

解析器非常简单,因为所有实现的命令现在都具有关于它们的第一个单词的实际命令(即“向北移动”、“拿起刀”等),只需拆分字符串并获取第一部分即可:

 const requireDir = require("require-dir") const validCommands = requireDir('./commands') class CommandParser { constructor(command) { this.command = command } normalizeAction(strAct) { strAct = strAct.toLowerCase().split(" ")[0] return strAct } verifyCommand() { if(!this.command) return false if(!this.command.action) return false if(!this.command.context) return false let action = this.normalizeAction(this.command.action) if(validCommands[action]) { return validCommands[action] } return false } parse() { let validCommand = this.verifyCommand() if(validCommand) { let cmdObj = new validCommand(this.command) return cmdObj } else { return false } } }

注意我再次使用require-dir模块来简化任何现有和新命令类的包含。 我只需将它添加到文件夹中,整个系统就可以获取并使用它。

话虽如此,有很多方法可以改善这一点。 例如,能够为我们的命令添加同义词支持将是一个很棒的功能(所以说“向北移动”、“向北走”甚至“向北走”的意思是一样的)。 这是我们可以集中在这个类中并同时影响所有命令的东西。

我不会详细介绍任何命令,因为这里再次显示的代码太多,但您可以在以下路由代码中看到我如何设法概括对现有(以及任何未来)命令的处理:

 /** Interaction with a particular scene */ router.post('/:id/:playername/:scene', function(req, res, next) { let command = req.body command.context = { gameId: req.params.id, playername: req.params.playername, } let parser = new CommandParser(command) let commandObj = parser.parse() //return the command instance if(!commandObj) return next({ //error handling status: 400, errorCode: config.get("errorCodes.invalidCommand"), message: "Unknown command" }) commandObj.run((err, result) => { //execute the command if(err) return next(err) res.json(result) }) })

所有命令只需要run方法——其他任何东西都是额外的,仅供内部使用。

我鼓励您去查看整个源代码(如果您愿意,甚至可以下载并使用它!)。 在本系列的下一部分中,我将向您展示此 API 的实际客户端实现和交互。

结束的想法

我可能没有在这里介绍很多我的代码,但我仍然希望这篇文章有助于向您展示我如何处理项目——即使在最初的设计阶段之后。 我觉得很多人试图开始编码作为他们对新想法的第一反应,有时最终会让开发人员感到沮丧,因为没有真正的计划设定或任何目标要实现 - 除了准备好最终产品(从第一天开始,这是一个太大的里程碑)。 再说一次,我希望通过这些文章分享一种不同的方式来单独(或作为小组的一部分)进行大型项目。

我希望你喜欢阅读! 如果您有任何类型的建议或建议,请随时在下面发表评论,如果您渴望开始使用自己的客户端代码测试 API,我很乐意阅读您的想法。

下一期见!

本系列的其他部分

  • 第 1 部分:简介
  • 第 3 部分:创建终端客户端
  • 第 4 部分:将聊天添加到我们的游戏中