Написание многопользовательского текстового приключенческого движка в Node.js: создание терминального клиента (часть 3)
Опубликовано: 2022-03-10Сначала я показал вам, как определить такой проект, как этот, и дал вам основы архитектуры, а также механику игрового движка. Затем я показал вам базовую реализацию движка — базовый REST API, который позволяет вам перемещаться по миру, определяемому JSON.
Сегодня я собираюсь показать вам, как создать текстовый клиент старой школы для нашего API, используя только Node.js.
Другие части этой серии
- Часть 1: Введение
- Часть 2: Дизайн сервера игрового движка
- Часть 4: Добавление чата в нашу игру
Обзор оригинального дизайна
Когда я впервые предложил базовый каркас пользовательского интерфейса, я предложил четыре раздела на экране:

Хотя в теории это выглядит правильно, я упустил тот факт, что переключение между отправкой игровых команд и текстовых сообщений было бы болезненным, поэтому вместо того, чтобы наши игроки переключались вручную, наш синтаксический анализатор команд должен убедиться, что он способен распознать, действительно ли мы пытаетесь общаться с игрой или нашими друзьями.
Таким образом, вместо четырех разделов на нашем экране теперь будет три:

Это реальный скриншот финального игрового клиента. Вы можете видеть экран игры слева и чат справа с одним общим полем ввода внизу. Используемый нами модуль позволяет настраивать цвета и некоторые основные эффекты. Вы сможете клонировать этот код из Github и делать с внешним видом все, что хотите.
Одно предостережение: хотя приведенный выше снимок экрана показывает, что чат работает как часть приложения, мы сосредоточим внимание этой статьи на настройке проекта и определении среды, в которой мы можем создать приложение на основе динамического текстового интерфейса. Мы сосредоточимся на добавлении поддержки чата в следующей и последней главе этой серии.
Инструменты, которые нам понадобятся
Несмотря на то, что существует множество библиотек, которые позволяют нам создавать инструменты CLI с помощью Node.js, добавление текстового пользовательского интерфейса — это совершенно другой зверь, который нужно приручить. В частности, мне удалось найти только одну (очень полную, заметьте) библиотеку, которая позволила бы мне сделать именно то, что я хотел: Blessed.
Эта библиотека очень мощная и предоставляет множество функций, которые мы не будем использовать в этом проекте (например, отбрасывание теней, перетаскивание и другие). По сути, он повторно реализует всю библиотеку ncurses (библиотека 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' } } });
Как вы можете себе представить, там же определяются и другие аспекты блока (например, его размер), которые могут быть идеально динамическими в зависимости от размера терминала, типа границы и цвета — даже для событий наведения. Если вы в какой-то момент занимались фронтенд-разработкой, вы обнаружите много общего между ними.
Я пытаюсь подчеркнуть, что все, что касается представления блока, настраивается через объект JSON, передаваемый методу box
. Для меня это идеально, потому что я могу легко извлечь этот контент в файл конфигурации и создать бизнес-логику, способную прочитать его и решить, какие элементы отображать на экране. Самое главное, это поможет нам получить представление о том, как они будут выглядеть после того, как будут нарисованы.
Это будет основой для всего пользовательского интерфейса этого модуля ( подробнее об этом чуть позже! ).
Архитектура модуля
Основная архитектура этого модуля полностью зависит от виджетов пользовательского интерфейса, которые мы покажем. Группа этих виджетов считается экраном, и все эти экраны определены в одном файле 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 будет содержать список экранов внутри приложения. Каждый экран содержит список виджетов (о которых я расскажу чуть позже), и у каждого виджета есть свое специфичное для благословений определение и соответствующие файлы обработчиков (если применимо).
Вы можете видеть, как каждый элемент «params» (внутри определенного виджета) представляет фактический набор параметров, ожидаемых методами, которые мы видели ранее. Остальные определенные там ключи помогают предоставить контекст о том, какой тип виджетов отображать, и их поведение.
Несколько интересных моментов:
Обработчики экрана
Каждый элемент экрана имеет свойство файла, которое ссылается на код, связанный с этим экраном. Этот код не что иное, как объект, который должен иметь метод init
(логика инициализации для этого конкретного экрана происходит внутри него). В частности, основной механизм пользовательского интерфейса будет вызывать этот метод 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
, которое ссылается на файл, содержащий логику этого конкретного компонента. Это не то же самое, что предыдущий обработчик экрана. Они не очень заботятся о компонентах пользовательского интерфейса. Вместо этого они обрабатывают связующую логику между пользовательским интерфейсом и любой библиотекой, которую мы используем для взаимодействия с внешними службами (например, API игрового движка).

Типы виджетов
Еще одним незначительным дополнением к определению виджетов в формате JSON являются их типы. Вместо того, чтобы использовать имена, определенные для них Благословенными, я создаю новые, чтобы дать мне больше пространства для маневра, когда дело доходит до их поведения. В конце концов, оконный виджет не всегда может «просто отображать информацию», или поле ввода может не всегда работать одинаково.
В основном это был превентивный шаг, просто чтобы гарантировать, что у меня будет такая возможность, если она мне когда-нибудь понадобится в будущем, но, как вы сейчас увидите, я все равно не использую столько разных типов компонентов.
Несколько экранов
Хотя главный экран — это тот, который я показал вам на снимке экрана выше, игре требуется несколько других экранов, чтобы запрашивать такие вещи, как ваше имя игрока или создаете ли вы новый игровой сеанс или даже присоединяетесь к существующему. Я справился с этим, опять же, через определение всех этих экранов в одном и том же файле JSON. А для перехода с одного экрана на другой мы используем логику внутри файлов обработчиков экрана.
Мы можем сделать это, просто используя следующую строку кода:
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
Я покажу вам более подробную информацию о свойстве пользовательского интерфейса через секунду, но я просто использую этот метод loadScreen
для повторного рендеринга экрана и выбора нужных компонентов из файла JSON, используя строку, переданную в качестве параметра. Очень просто.
Примеры кода
Пришло время проверить суть этой статьи: примеры кода. Я просто собираюсь выделить то, что, по моему мнению, является маленькими жемчужинами внутри него, но вы всегда можете взглянуть на полный исходный код прямо в репозитории в любое время.
Использование файлов конфигурации для автоматического создания пользовательского интерфейса
Я уже рассмотрел часть этого, но я думаю, что стоит изучить детали этого генератора. Суть этого (файл index.js внутри папки /ui
) заключается в том, что это оболочка вокруг объекта 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() },
Как видите, код немного длинноват, но логика проста:
- Он загружает конфигурацию для текущего конкретного экрана;
- Очищает все ранее существовавшие виджеты;
- Просматривает каждый виджет и создает его экземпляр;
- Если дополнительное предупреждение было передано в виде флэш-сообщения (это, по сути, концепция, которую я украл у веб-разработчика, в которой вы настраиваете сообщение, которое будет отображаться на экране до следующего обновления);
- Визуализировать фактический экран;
- И, наконец, требуйте обработчик экрана и выполняйте его метод «init».
Вот и все! Вы можете проверить остальные методы — они в основном связаны с отдельными виджетами и тем, как их отображать.
Связь между пользовательским интерфейсом и бизнес-логикой
Хотя в целом пользовательский интерфейс, серверная часть и сервер чата имеют несколько многоуровневую связь; сам внешний интерфейс нуждается как минимум в двухуровневой внутренней архитектуре, в которой чистые элементы пользовательского интерфейса взаимодействуют с набором функций, представляющих основную логику внутри этого конкретного проекта.
На следующей диаграмме показана внутренняя архитектура текстового клиента, который мы создаем:

Позвольте мне объяснить это немного дальше. Как я упоминал выше, метод loadScreenMethod
создаст представление пользовательского интерфейса виджетов (это объекты Blessed). Но они содержатся как часть логического объекта экрана, где мы настраиваем основные события (например, onSubmit
для полей ввода).
Позвольте мне привести вам практический пример. Вот первый экран, который вы видите при запуске клиента пользовательского интерфейса:

На этом экране есть три раздела:
- Запрос имени пользователя,
- Опции меню / информация,
- Экран ввода параметров меню.
По сути, мы хотим запросить имя пользователя, а затем попросить его выбрать один из двух вариантов (либо запустить новую игру, либо присоединиться к существующей).
Код, который заботится об этом, следующий:
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
: отображает следующий экран, отвечающий за запрос идентификатора игры для присоединения.
Связь с игровым движком
И последнее, но не менее важное (и следуя приведенному выше примеру): если вы нажмете 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 в еще один уровень абстракции.
Однако второй метод выполняет действие напрямую, отправляя запрос POST на правильный URL-адрес с правильной полезной нагрузкой. После этого не делается ничего необычного; мы просто отправляем тело ответа обратно в логику пользовательского интерфейса.
Примечание . Если вас интересует полная версия исходного кода для этого клиента, вы можете ознакомиться с ней здесь.
Заключительные слова
Это все для текстового клиента для нашего текстового приключения. Я рассмотрел:
- Как структурировать клиентское приложение;
- Как я использовал Blessed в качестве основной технологии для создания уровня представления;
- Как структурировать взаимодействие с back-end сервисами от сложного клиента;
- И, надеюсь, с полным доступным репозиторием.
И хотя пользовательский интерфейс может выглядеть не совсем так, как в оригинальной версии, он выполняет свою задачу. Надеюсь, эта статья дала вам представление о том, как спроектировать такое начинание, и вы были склонны попробовать это сами в будущем. Blessed, безусловно, очень мощный инструмент, но вам придется запастись терпением, пока вы научитесь его использовать и перемещаться по их документам.
В следующей и последней части я расскажу, как я добавил сервер чата как на серверной части, так и для этого текстового клиента.
Увидимся на следующем!
Другие части этой серии
- Часть 1: Введение
- Часть 2: Дизайн сервера игрового движка
- Часть 4: Добавление чата в нашу игру