在 Node.js 中编写多人文本冒险引擎:创建终端客户端(第 3 部分)
已发表: 2022-03-10我首先向您展示了如何定义这样一个项目,并向您介绍了架构的基础知识以及游戏引擎背后的机制。 然后,我向您展示了引擎的基本实现——一个基本的 REST API,它允许您遍历 JSON 定义的世界。
今天,我将向您展示如何使用 Node.js 为我们的 API 创建一个老式的文本客户端。
本系列的其他部分
- 第 1 部分:简介
- 第 2 部分:游戏引擎服务器设计
- 第 4 部分:将聊天添加到我们的游戏中
审查原始设计
当我第一次提出 UI 的基本线框时,我提出了屏幕上的四个部分:
虽然理论上看起来是正确的,但我错过了在发送游戏命令和短信之间切换会很痛苦的事实,所以我们不是让我们的玩家手动切换,而是让我们的命令解析器确保它能够辨别我们是否'正在尝试与游戏或我们的朋友交流。
因此,我们的屏幕中不再有四个部分,而是现在有三个:
这是最终游戏客户端的实际截图。 您可以在左侧看到游戏屏幕,在右侧看到聊天,底部有一个通用的输入框。 我们使用的模块允许我们自定义颜色和一些基本效果。 您将能够从 Github 克隆此代码,并使用外观和感觉做您想做的事情。
一个警告:虽然上面的屏幕截图显示聊天作为应用程序的一部分工作,但我们将让本文专注于设置项目和定义一个框架,我们可以在其中创建一个基于动态文本 UI 的应用程序。 我们将专注于在本系列的下一章和最后一章中添加聊天支持。
我们需要的工具
尽管有许多库可以让我们使用 Node.js 创建 CLI 工具,但添加基于文本的 UI 是完全不同的野兽。 特别是,我只能找到一个(非常完整,请注意)库,它可以让我做我想做的事:Blessed。
这个库非常强大,并提供了很多我们不会在这个项目中使用的功能(例如投射阴影、拖放等)。 它基本上重新实现了整个 ncurses 库(一个允许开发人员创建基于文本的 UI 的 C 库),它没有 Node.js 绑定,并且直接在 JavaScript 中实现; 因此,如果必须,我们可以很好地检查它的内部代码(除非您绝对必须这样做,否则我不建议这样做)。
尽管 Blessed 的文档非常丰富,但它主要包含有关所提供的每种方法的个别详细信息(而不是有解释如何实际一起使用这些方法的教程),并且到处都缺乏示例,因此可能很难深入研究如果您必须了解特定方法的工作原理。 话虽如此,一旦您理解了它,一切都以相同的方式工作,这是一个很大的优势,因为并非每个库甚至语言(我在看你,PHP)都有一致的语法。
但除了文件; 这个库的最大优点是它基于 JSON 选项工作。 例如,如果您想在屏幕的右上角绘制一个框,您可以执行以下操作:
var box = blessed.box({ top: '0', right: '0', width: '50%', height: '50%', content: 'Hello {bold}world{/bold}!', tags: true, border: { type: 'line' }, style: { fg: 'white', bg: 'magenta', border: { fg: '#f0f0f0' }, hover: { bg: 'green' } } });
正如您可以想象的那样,盒子的其他方面也在那里定义(例如它的大小),它可以根据终端的大小、边框类型和颜色完美地动态化——即使对于悬停事件也是如此。 如果你在某个时候做过前端开发,你会发现两者之间有很多重叠之处。
我在这里要说明的一点是,关于盒子表示的所有内容都是通过传递给box
方法的 JSON 对象配置的。 对我来说,这是完美的,因为我可以轻松地将内容提取到配置文件中,并创建一个能够读取它并决定在屏幕上绘制哪些元素的业务逻辑。 最重要的是,它将帮助我们了解绘制后它们的外观。
这将是本模块整个 UI 方面的基础(稍后会详细介绍! )。
模块架构
这个模块的主要架构完全依赖于我们将要展示的 UI 小部件。 一组这些小部件被视为一个屏幕,所有这些屏幕都定义在一个 JSON 文件中(您可以在/config
文件夹中找到该文件)。
这个文件有超过 250 行,所以在这里显示它是没有意义的。 您可以在线查看完整文件,但其中的一个小片段如下所示:
"screens": { "main-options": { "file": "./main-options.js", "elements": { "username-request": { "type": "input-prompt", "params": { "position": { "top": "0%", "left": "0%", "width": "100%", "height": "25%" }, "content": "Input your username: ", "inputOnFocus": true, "border": { "type": "line" }, "style": { "fg": "white", "bg": "blue", "border": { "fg": "#f0f0f0" }, "hover": { "bg": "green" } } } }, "options": { "type": "window", "params": { "position": { "top": "25%", "left": "0%", "width": "100%", "height": "50%" }, "content": "Please select an option: \n1. Join an existing game.\n2. Create a new game", "border": { "type": "line" }, "style": { //... } } }, "input": { "type": "input", "handlerPath": "../lib/main-options-handler", //... } } }
“screens”元素将包含应用程序内的屏幕列表。 每个屏幕都包含一个小部件列表(我将稍后介绍),每个小部件都有其特定于 blesses 的定义和相关的处理程序文件(如果适用)。
您可以看到每个“params”元素(在特定小部件内)如何代表我们之前看到的方法所期望的实际参数集。 此处定义的其余键有助于提供有关要呈现的小部件类型及其行为的上下文。
几个兴趣点:
屏幕处理程序
每个屏幕元素都有文件属性,该属性引用与该屏幕关联的代码。 这段代码只不过是一个必须具有init
方法的对象(特定屏幕的初始化逻辑发生在其中)。 特别是,主 UI 引擎将调用每个屏幕的init
方法,而该方法又应该负责初始化它可能需要的任何逻辑(即设置输入框事件)。
以下是主屏幕的代码,应用程序要求玩家选择一个选项来开始全新游戏或加入现有游戏:
const logger = require("../utils/logger") module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
如您所见, init
方法调用setupInput
方法,该方法基本上配置正确的回调来处理用户输入。 该回调包含根据用户的输入(1 或 2)决定做什么的逻辑。
小部件处理程序
一些小部件(通常是输入小部件)有一个handlerPath
属性,它引用包含该特定组件背后的逻辑的文件。 这与之前的屏幕处理程序不同。 这些不太关心 UI 组件。 相反,它们处理 UI 和我们用来与外部服务交互的任何库(例如游戏引擎的 API)之间的粘合逻辑。
小部件类型
小部件的 JSON 定义的另一个小补充是它们的类型。 我没有使用 Blessed 为他们定义的名称,而是创建了新名称,以便在他们的行为方面给我更多的回旋余地。 毕竟,窗口小部件可能并不总是“只显示信息”,或者输入框可能并不总是以相同的方式工作。
这主要是一个先发制人的举动,只是为了确保我在将来需要它时拥有这种能力,但正如你即将看到的那样,无论如何我并没有使用那么多不同类型的组件。
多个屏幕
尽管主屏幕是我在上面的屏幕截图中向您展示的屏幕,但游戏需要一些其他屏幕才能请求诸如您的玩家姓名或您是否正在创建一个全新的游戏会话甚至加入现有的游戏会话等信息。 我处理这个问题的方式是,同样,通过在同一个 JSON 文件中定义所有这些屏幕。 为了从一个屏幕移动到下一个屏幕,我们使用屏幕处理程序文件中的逻辑。
我们可以简单地使用以下代码行来做到这一点:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
我将在一秒钟内向您展示有关 UI 属性的更多详细信息,但我只是使用该loadScreen
方法重新渲染屏幕并使用作为参数传递的字符串从 JSON 文件中选择正确的组件。 非常简单。
代码示例
现在是时候看看这篇文章的重点了:代码示例。 我只是要强调一下我认为其中的小宝石,但您可以随时直接在存储库中查看完整的源代码。
使用配置文件自动生成 UI
我已经介绍了其中的一部分,但我认为值得探索这个生成器背后的细节。 它背后的要点( /ui
文件夹中的文件index.js )是它是 Blessed 对象的包装器。 其中最有趣的方法是loadScreen
方法。
此方法获取一个特定屏幕的配置(通过配置模块)并遍历其内容,尝试根据每个元素的类型生成正确的小部件。
loadScreen: function(sname, extras, done) { if(typeof extras == "function") { done = extras } let screen = config.get('screens.' + sname) let screenElems = {} if(this.screenElements.length > 0) { //remove previous screen this.screenElements.map( e => e.detach()) this.screen.realloc() } Object.keys(screen.elements).forEach( eName => { let elemObj = null let element = screen.elements[eName] if(element.type == 'window') { elemObj = this.setUpWindow(element) } if(element.type == 'input') { elemObj = this.setUpInputBox(element) } if(element.type == 'input-prompt') { elemObj = this.setUpInputBox(element) } screenElems[eName] = { meta: element, obj: elemObj } }) if(typeof extras === 'object' && extras.flashmessage) { this.setUpAlert(extras.flashmessage) } this.renderScreen() let logicPath = require(screen.file) logicPath.init(screenElems, this) done() },
可以看到,代码有点长,但背后的逻辑很简单:
- 它加载当前特定屏幕的配置;
- 清理任何以前存在的小部件;
- 遍历每个小部件并实例化它;
- 如果一个额外的警报作为 Flash 消息传递(这基本上是我从 Web Dev 中偷来的一个概念,您可以在其中设置一条消息以显示在屏幕上,直到下一次刷新);
- 渲染实际屏幕;
- 最后,需要屏幕处理程序并执行它的“init”方法。
而已! 您可以查看其余的方法——它们主要与单个小部件以及如何呈现它们有关。
UI 和业务逻辑之间的通信
虽然规模宏大,UI、后端和聊天服务器都有一些分层的通信; 前端本身至少需要一个两层的内部架构,其中纯 UI 元素与代表该特定项目内部核心逻辑的一组功能交互。
下图显示了我们正在构建的文本客户端的内部架构:
让我进一步解释一下。 正如我上面提到的, loadScreenMethod
将创建小部件的 UI 表示(这些是 Blessed 对象)。 但是它们作为屏幕逻辑对象的一部分包含在我们设置基本事件的地方(例如输入框的onSubmit
)。
请允许我举一个实际的例子。 这是您在启动 UI 客户端时看到的第一个屏幕:
此屏幕上有三个部分:
- 用户名请求,
- 菜单选项/信息,
- 菜单选项的输入屏幕。
基本上,我们想要做的是请求用户名,然后让他们选择两个选项之一(启动一个全新的游戏或加入现有的游戏)。
处理该问题的代码如下:
module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
我知道那是很多代码,但只关注init
方法。 它所做的最后一件事是调用setInput
方法,该方法负责将正确的事件添加到正确的输入框中。
因此,使用这些行:
let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim()
我们正在访问 Blessed 对象并获取它们的引用,以便稍后设置submit
事件。 所以在我们提交用户名之后,我们将焦点切换到第二个输入框(字面意思是input.focus()
)。
根据我们从菜单中选择的选项,我们将调用以下任一方法:
-
createNewGame
:通过与其关联的处理程序交互来创建一个新游戏; -
moveToIDRequest
:渲染下一个负责请求游戏 ID 加入的屏幕。
与游戏引擎的通信
最后但同样重要的是(并遵循上面的示例),如果您点击 2,您会注意到方法createNewGame
使用处理程序的方法createNewGame
和joinGame
(在创建游戏后立即加入游戏)。
这两种方法都旨在简化与游戏引擎 API 的交互。 这是此屏幕处理程序的代码:
const request = require("request"), config = require("config"), apiClient = require("./apiClient") let API = config.get("api") module.exports = { joinGame: function(apikey, gameId, cb) { apiClient.joinGame(apikey, gameId, cb) }, createNewGame: function(apikey, cb) { request.post(API.url + API.endpoints.games + "?apikey=" + apikey, { //creating game body: { cartridgeid: config.get("app.game.cartdrigename") }, json: true }, (err, resp, body) => { cb(null, body) }) } }
在那里,您会看到处理此行为的两种不同方法。 第一个方法实际上使用了apiClient
类,它再次将与 GameEngine 的交互包装到另一个抽象层中。
第二种方法通过向具有正确有效负载的正确 URL 发送 POST 请求直接执行操作。 事后没有什么花哨的事; 我们只是将响应的主体发送回 UI 逻辑。
注意:如果您对此客户端的完整版源代码感兴趣,可以在此处查看。
最后的话
这就是我们文本冒险的基于文本的客户端。 我介绍了:
- 如何构建客户端应用程序;
- 我如何使用 Blessed 作为创建表示层的核心技术;
- 如何构建复杂客户端与后端服务的交互;
- 希望有完整的存储库可用。
虽然 UI 可能看起来不像原始版本,但它确实实现了它的目的。 希望这篇文章能让您了解如何构建这样的工作,并且您将来倾向于自己尝试。 Blessed 绝对是一个非常强大的工具,但是在学习如何使用它以及如何浏览他们的文档时,您必须对它有耐心。
在下一部分也是最后一部分中,我将介绍如何在后端以及此文本客户端中添加聊天服务器。
下一期见!
本系列的其他部分
- 第 1 部分:简介
- 第 2 部分:游戏引擎服务器设计
- 第 4 部分:将聊天添加到我们的游戏中