在 Node.js 中編寫多人文本冒險引擎:創建終端客戶端(第 3 部分)

已發表: 2022-03-10
快速總結↬本系列的第三部分將重點介紹為第 2 部分中創建的遊戲引擎添加基於文本的客戶端。Fernando Doglio 通過向您展示如何創建文本來解釋基本架構設計、工具選擇和代碼亮點——在 Node.js 的幫助下基於 UI。

我首先向您展示瞭如何定義這樣一個項目,並向您介紹了架構的基礎知識以及遊戲引擎背後的機制。 然後,我向您展示了引擎的基本實現——一個基本的 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() },

可以看到,代碼有點長,但背後的邏輯很簡單:

  1. 它加載當前特定屏幕的配置;
  2. 清理任何以前存在的小部件;
  3. 遍歷每個小部件並實例化它;
  4. 如果一個額外的警報作為 Flash 消息傳遞(這基本上是我從 Web Dev 中偷來的一個概念,您可以在其中設置一條消息以顯示在屏幕上,直到下一次刷新);
  5. 渲染實際屏幕;
  6. 最後,需要屏幕處理程序並執行它的“init”方法。

而已! 您可以查看其餘的方法——它們主要與單個小部件以及如何呈現它們有關。

UI 和業務邏輯之間的通信

雖然規模宏大,UI、後端和聊天服務器都有一些分層的通信; 前端本身至少需要一個兩層的內部架構,其中純 UI 元素與代表該特定項目內部核心邏輯的一組功能交互。

下圖顯示了我們正在構建的文本客戶端的內部架構:

(大預覽)

讓我進一步解釋一下。 正如我上面提到的, loadScreenMethod將創建小部件的 UI 表示(這些是 Blessed 對象)。 但是它們作為屏幕邏輯對象的一部分包含在我們設置基本事件的地方(例如輸入框的onSubmit )。

請允許我舉一個實際的例子。 這是您在啟動 UI 客戶端時看到的第一個屏幕:

(大預覽)

此屏幕上有三個部分:

  1. 用戶名請求,
  2. 菜單選項/信息,
  3. 菜單選項的輸入屏幕。

基本上,我們想要做的是請求用戶名,然後讓他們選擇兩個選項之一(啟動一個全新的遊戲或加入現有的遊戲)。

處理該問題的代碼如下:

 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使用處理程序的方法createNewGamejoinGame (在創建遊戲後立即加入遊戲)。

這兩種方法都旨在簡化與遊戲引擎 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 部分:將聊天添加到我們的遊戲中