Написание многопользовательского текстового приключенческого движка на Node.js: проектирование сервера игрового движка (часть 2)
Опубликовано: 2022-03-10После некоторого тщательного рассмотрения и фактической реализации модуля некоторые определения, которые я сделал на этапе проектирования, пришлось изменить. Это должно быть знакомо всем, кто когда-либо работал с нетерпеливым клиентом, который мечтает об идеальном продукте, но нуждается в сдерживании со стороны команды разработчиков.
Как только функции будут реализованы и протестированы, ваша команда начнет замечать, что некоторые характеристики могут отличаться от первоначального плана, и это нормально. Просто сообщите, настройте и продолжайте. Итак, без лишних слов, позвольте мне сначала объяснить, что изменилось по сравнению с первоначальным планом.
Другие части этой серии
- Часть 1: Введение
- Часть 3: Создание терминального клиента
- Часть 4: Добавление чата в нашу игру
Боевая механика
Это, пожалуй, самое большое изменение по сравнению с первоначальным планом. Я знаю, что сказал, что собираюсь использовать реализацию в стиле D&D, в которой каждый участвующий в ней персонаж и NPC получит значение инициативы, а после этого мы будем проводить пошаговые бои. Это была хорошая идея, но реализовать ее в службе на основе REST немного сложно, поскольку вы не можете инициировать связь со стороны сервера или поддерживать состояние между вызовами.
Поэтому вместо этого я воспользуюсь преимуществами упрощенной механики REST и использую ее для упрощения нашей боевой механики. Реализованная версия будет ориентирована на игроков, а не на группу, и позволит игрокам атаковать NPC (неигровые персонажи). Если их атака увенчается успехом, NPC будут убиты, иначе они нанесут ответный удар, нанеся урон или убив игрока.
Будет ли атака успешной или неудачной, будет зависеть от типа используемого оружия и слабостей, которые могут быть у NPC. В общем, если монстр, которого вы пытаетесь убить, слаб против вашего оружия, он умирает. В противном случае он останется незатронутым и, скорее всего, очень злым.
Триггеры
Если вы обратили пристальное внимание на определение игры в формате JSON из моей предыдущей статьи, вы могли заметить определение триггера, найденное в элементах сцены. Конкретный случай связан с обновлением статуса игры ( statusUpdate
). Во время реализации я понял, что его работа в качестве переключателя дает ограниченную свободу. Видите ли, в том виде, в котором это было реализовано (с идиоматической точки зрения), вы могли установить статус, но снять его было невозможно. Поэтому вместо этого я заменил этот триггерный эффект двумя новыми: addStatus
и removeStatus
. Это позволит вам точно определить, когда эти эффекты могут иметь место — если вообще. Я чувствую, что это намного легче понять и рассуждать.
Это означает, что триггеры теперь выглядят так:
"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, что значительно упрощает задачу доступа к ним. |
Мангуста | Это будет наша ОРМ. Я буду моделировать все ресурсы с помощью моделей Mongoose и использовать их для непосредственного взаимодействия с базой данных. |
UUID | Нам нужно сгенерировать несколько уникальных идентификаторов — этот модуль поможет нам с этой задачей. |
Что касается других технологий, используемых помимо Node.js, у нас есть MongoDB и Redis . Мне нравится использовать Mongo из-за отсутствия необходимой схемы. Этот простой факт позволяет мне думать о моем коде и форматах данных, не беспокоясь об обновлении структуры моих таблиц, миграции схем или конфликтующих типах данных.
Что касается Redis, я стараюсь использовать его в качестве системы поддержки, насколько это возможно, в моих проектах, и этот случай ничем не отличается. Я буду использовать Redis для всего, что можно считать изменчивой информацией, такой как номера участников группы, запросы команд и другие типы данных, которые достаточно малы и достаточно изменчивы, чтобы не заслуживать постоянного хранения.
Я также собираюсь использовать функцию истечения срока действия ключа Redis для автоматического управления некоторыми аспектами потока (подробнее об этом чуть позже).
Определение API
Прежде чем перейти к взаимодействию клиент-сервер и определениям потоков данных, я хочу пройтись по конечным точкам, определенным для этого API. Их не так уж и много, в основном нам нужно соблюсти основные особенности, описанные в части 1:
Характерная черта | Описание |
---|---|
Присоединяйтесь к игре | Игрок сможет присоединиться к игре, указав идентификатор игры. |
Создать новую игру | Игрок также может создать новый экземпляр игры. Механизм должен возвращать идентификатор, чтобы другие могли использовать его для присоединения. |
Сцена возвращения | Эта функция должна возвращать текущую сцену, где находится вечеринка. По сути, он вернет описание со всей связанной информацией (возможные действия, объекты в нем и т. д.). |
Взаимодействие со сценой | Это будет один из самых сложных, потому что он примет команду от клиента и выполнит это действие — например, переместить, толкнуть, взять, посмотреть, прочитать, и это лишь некоторые из них. |
посмотри инвентарь | Хотя это способ взаимодействия с игрой, он не имеет прямого отношения к сцене. Таким образом, проверка инвентаря для каждого игрока будет считаться отдельным действием. |
Зарегистрировать клиентское приложение | Для выполнения вышеуказанных действий требуется действительный клиент. Эта конечная точка проверит клиентское приложение и вернет идентификатор клиента, который будет использоваться для проверки подлинности при последующих запросах. |
Приведенный выше список преобразуется в следующий список конечных точек:
Глагол | Конечная точка | Описание |
---|---|---|
СООБЩЕНИЕ | /clients | Клиентским приложениям потребуется получить ключ идентификатора клиента, используя эту конечную точку. |
СООБЩЕНИЕ | /games | Новые экземпляры игр создаются клиентскими приложениями с использованием этой конечной точки. |
СООБЩЕНИЕ | /games/:id | После создания игры эта конечная точка позволит членам группы присоединиться к ней и начать играть. |
ПОЛУЧАТЬ | /games/:id/:playername | Эта конечная точка вернет текущее состояние игры для конкретного игрока. |
СООБЩЕНИЕ | /games/:id/:playername/commands | Наконец, с помощью этой конечной точки клиентское приложение сможет отправлять команды (другими словами, эта конечная точка будет использоваться для воспроизведения). |
Позвольте мне более подробно остановиться на некоторых концепциях, которые я описал в предыдущем списке.
Клиентские приложения
Клиентские приложения должны будут зарегистрироваться в системе, чтобы начать ее использовать. Все конечные точки (кроме первой в списке) защищены, и для их отправки вместе с запросом потребуется действительный ключ приложения. Чтобы получить этот ключ, клиентские приложения должны просто запросить его. После предоставления они будут действовать до тех пор, пока они используются, или истечет через месяц неиспользования. Это поведение контролируется путем сохранения ключа в Redis и установки для него одного месяца TTL.
Экземпляр игры
Создание новой игры в основном означает создание нового экземпляра конкретной игры. Этот новый экземпляр будет содержать копии всех сцен и их содержимого. Любые изменения, внесенные в игру, повлияют только на группу. Таким образом, многие группы могут играть в одну и ту же игру по-своему.
Игровое состояние игрока
Это похоже на предыдущее, но уникально для каждого игрока. В то время как экземпляр игры содержит игровое состояние для всей группы, игровое состояние игрока содержит текущий статус для одного конкретного игрока. В основном это инвентарь, позиция, текущая сцена и HP (очки здоровья).
Команды игрока
После того, как все настроено и клиентское приложение зарегистрировалось и присоединилось к игре, оно может начать отправлять команды. Реализованные команды в этой версии движка включают в себя: move
, look
, pickup
и attack
.
- Команда
move
позволит вам перемещаться по карте. Вы сможете указать направление, в котором хотите двигаться, и движок сообщит вам результат. Если вы бросите беглый взгляд на часть 1, вы увидите подход, который я использовал для работы с картами. (Короче говоря, карта представлена в виде графа, где каждый узел представляет комнату или сцену и связан только с другими узлами, представляющими соседние комнаты.)
Расстояние между узлами также присутствует в представлении и связано со стандартной скоростью игрока; переход из комнаты в комнату может быть не таким простым, как произнесение вашей команды, но вам также придется преодолевать расстояние. На практике это означает, что для перехода из одной комнаты в другую может потребоваться несколько команд перемещения). Другой интересный аспект этой команды заключается в том, что этот движок предназначен для поддержки многопользовательских групп, и партия не может быть разделена (по крайней мере, в настоящее время).
Следовательно, решение для этого похоже на систему голосования: каждый член группы будет отправлять запрос на команду движения, когда захочет. Как только это сделает более половины из них, будет использовано наиболее востребованное направление. -
look
сильно отличается от движения. Это позволяет игроку указать направление, предмет или NPC, которых он хочет осмотреть. Ключевая логика этой команды становится понятной, когда вы думаете об описаниях, зависящих от состояния.
Например, предположим, что вы входите в новую комнату, но там совершенно темно (вы ничего не видите), и вы двигаетесь вперед, игнорируя это. Через несколько комнат вы поднимаете со стены зажженный факел. Итак, теперь вы можете вернуться и повторно осмотреть ту темную комнату. Поскольку вы взяли факел, теперь вы можете заглянуть внутрь него и взаимодействовать с любыми предметами и неигровыми персонажами, которых вы там найдете.
Это достигается за счет поддержки набора атрибутов статуса для всей игры и конкретного игрока и предоставления создателю игры возможности указать несколько описаний для наших элементов, зависящих от статуса, в файле JSON. Затем каждое описание снабжено текстом по умолчанию и набором условных, в зависимости от текущего статуса. Последние являются необязательными; единственным обязательным является значение по умолчанию.
Кроме того, у этой команды есть сокращенная версия дляlook at room: look around
; это связано с тем, что игроки будут очень часто пытаться осмотреть комнату, поэтому использование краткой (или псевдонимной) команды, которую легче набирать, имеет большой смысл. - Команда
pickup
играет очень важную роль для игрового процесса. Эта команда отвечает за добавление предметов в инвентарь игроков или их руки (если они свободны). Чтобы понять, где должен храниться каждый предмет, в их определении есть свойство «назначение», которое указывает, предназначен ли он для инвентаря или для рук игрока. Все, что успешно взято со сцены, затем удаляется из нее, обновляя версию игры экземпляра игры. - Команда
use
позволит вам воздействовать на окружающую среду, используя предметы в вашем инвентаре. Например, взяв ключ в комнате, вы сможете использовать его, чтобы открыть запертую дверь в другой комнате. - Существует специальная команда, которая не связана с игровым процессом, а является вспомогательной командой, предназначенной для получения определенной информации, такой как текущий идентификатор игры или имя игрока. Эта команда называется get , и игроки могут использовать ее для запроса игрового движка. Например: получить идентификатор игры .
- Наконец, последняя команда, реализованная для этой версии движка, — это команда
attack
. Я уже рассмотрел это; по сути, вам нужно будет указать свою цель и оружие, которым вы ее атакуете. Таким образом, система сможет проверить слабые места цели и определить результат вашей атаки.
Взаимодействие клиент-движок
Чтобы понять, как использовать перечисленные выше конечные точки, позвольте мне показать вам, как любой потенциальный клиент может взаимодействовать с нашим новым API.

Шаг | Описание |
---|---|
Зарегистрировать клиента | Прежде всего, клиентское приложение должно запросить ключ API, чтобы получить доступ ко всем другим конечным точкам. Чтобы получить этот ключ, он должен зарегистрироваться на нашей платформе. Единственный параметр, который нужно предоставить, — это имя приложения, вот и все. |
Создать игру | После получения ключа API первое, что нужно сделать (при условии, что это совершенно новое взаимодействие), — создать новый экземпляр игры. Подумайте об этом так: файл JSON, который я создал в своем последнем посте, содержит определение игры, но нам нужно создать его экземпляр только для вас и вашей группы (подумайте о классах и объектах, одно и то же). Вы можете делать с этим экземпляром все, что хотите, и это не повлияет на другие стороны. |
Присоединяйтесь к игре | После создания игры вы получите идентификатор игры от движка. Затем вы можете использовать этот идентификатор игры, чтобы присоединиться к экземпляру, используя свое уникальное имя пользователя. Пока вы не присоединитесь к игре, вы не сможете играть, потому что присоединение к игре также создаст экземпляр игрового состояния только для вас. Здесь будут храниться ваш инвентарь, ваша позиция и основная статистика в зависимости от игры, в которую вы играете. Потенциально вы могли бы играть в несколько игр одновременно, и в каждой из них были бы независимые состояния. |
Отправить команды | Другими словами: играйте в игру. Последний шаг — начать отправку команд. Количество доступных команд уже было рассмотрено, и его можно легко расширить (подробнее об этом чуть позже). Каждый раз, когда вы отправляете команду, игра возвращает вашему клиенту новое состояние игры, чтобы соответствующим образом обновить ваш вид. |
Давай испачкаем руки
Я пробежался по дизайну настолько, насколько смог, в надежде, что эта информация поможет вам понять следующую часть, так что давайте углубимся в суть игрового движка.
Примечание : я не буду показывать вам полный код в этой статье, так как он довольно большой и не весь интересен. Вместо этого я покажу наиболее важные части и ссылку на полный репозиторий, если вам нужна дополнительная информация.
Основной файл
Перво-наперво: это проект 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, либо простые сообщения об ошибках без какого-либо другого контекста. Этот код возьмет все это и отформатирует в стандартный формат.
Обработка команд
Это еще один из тех аспектов движка, который нужно было легко расширить. В таком проекте, как этот, вполне логично предположить, что в будущем появятся новые команды. Если есть что-то, чего вы хотите избежать, то это, вероятно, будет избегать внесения изменений в базовый код при попытке добавить что-то новое через три или четыре месяца в будущем.
Никакое количество комментариев к коду не облегчит задачу модификации кода, к которому вы не прикасались (или даже не думали) в течение нескольких месяцев, поэтому приоритет состоит в том, чтобы избежать как можно большего количества изменений. К счастью для нас, есть несколько шаблонов, которые мы можем реализовать, чтобы решить эту проблему. В частности, я использовал смесь паттернов Command и Factory.
По сути, я инкапсулировал поведение каждой команды в один класс, который наследуется от класса 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: Добавление чата в нашу игру