Я продолжаю миграцию с NodeJS на Golang реализацию, и вместе с этим реализую новые фичи. “Canary” версия клиентского мода уже направляет запросы на api.nlvl.quest вместо api.newlevelvr.ru, а я продолжаю придумывать себе задачи и решать их.
Не пугайтесь, пожалуйста, большого количества текста :)
Я постарался максимально детально рассказать, какие технологии и решения я использовал.
Если вы не готовы читать так много текста – в каждой категории есть TL;DR с видеодемонстрацией конкретной фичи.
Этот механизм будет изменен в будущем, см п.3
Квесты в Майнкрафте представлены в виде zip-архива и json-файла. Архив template.zip содержит саму карту, которую мод распаковывает в папку сохранений игры при создании нового сохранения. Json-файл содержит информацию, необходимую для отображения квеста в меню выбора квестов в игре. Пример такого файла:
{
"name": "newlevelvr.client.maps.arena",
"type": "adventure",
"version": 2
}
Поскольку созданием квестов будут заниматься другие люди, хотелось бы автоматизировать процесс выпуска обновлений карт, чтобы никому не пришлось вручную бегать по клубу и скачивать на каждый компьютер последние версии, была разработана программа для автоообновления. Каждая карта пакуется в еще один zip-архив и загружается на S3-подобное хранилище данных в такой схеме:

* newlevelvr_pack_xxxxxxx.zip - это пакет ресурсов для модификации строк, текстур и т.д., он всегда должен быть последней версии
После этого путем авторизованных запросов к API через специальную программу можно отслеживать обновления карт и ресурс-пака:

Хранилище: Yandex Object Storage
Доступ к хранилищу: Свое API
Бэкенд приложения: Wails
Фронтенд приложения: Svelte
Такой формат доставки карт (zip, содержащий json и zip) кажется на самым красивым и удобным. В связи с этим я решил паковать карты по-своему, используя tar.gz и вставкой метаданных о карте в поле Extra в формате protobuf:
enum MapType {
SURVIVAL = 0;
ADVENTURE = 1;
}
message Map {
string name = 1;
string gameVersion = 2;
MapType type = 3;
bool hasResourcePack = 4;
int64 resourcePackSize = 5;
repeated string env = 6;
}
В TarWriter сначала записывается файл resources.zip, если он есть, и после этого все остальные файлы. Этот архив содержать необходимые ресурсы для игры на данной карте (измененные текстуры, модели и так далее), записывается он первым для удобства извлечения конкретно ресурс-пака в дальнейшем (см. 3.2/GetResourcePack)
Администраторы клуба, в который поставляются карты, часто сталкиваются с трудностями работы с майнкрафтом, из-за чего они иногда даже отказывают клиентам (“у нас нет майнкрафта”). Например, интегрированный сервер майнкрафта запускается под капотом игры при запуске мира, и при желании может быть открыт в локальную сеть, чтобы другие могли подключиться и играть в одном мире. И может быть такая ситуация, что человек, который хостит мир, больше не хочет играть, но остальные хотят. Из-за этого его приходится менять местами с кем-то еще, чтобы не выключать игру, что создает дополнительные проблемы.
Решение - запускать в локальной сети выделенный сервер и подключать всех игроков именно к нему. Программа-Оператор по задумке и будет выполнять эту функцию. Она будет доступна администратору клуба, который сможет удаленно запустить на одном из компьютеров выделенный сервер, а затем так же удаленно подключить к нему нужных игроков.
Система разделена на три части:
Далее в объяснении будет также использоваться термин “сервер”, подразумевающий либо выделенный сервер майнкрафта, либо интегрированный сервер майнкрафта, который создается при запуске мира в одиночной игре.
Мод общается с Оператором через gRPC сервис InGameService по следующему протоколу:
service InGameService {
rpc NewClient(stream ClientData) returns (stream Response) {}
rpc GetResourcePack(ResourcePackRequest) returns (stream ResourcePackMessage) {}
}
Это что-то типа websocket-соединения, которое устанавливает Мод с Оператором при запуске игры. Это соединение удерживается на протяжении всей игры. Мод отсылает Оператору ClientData, содержащий информацию о своем персонаже и текущем IP-адресе, к которому подключен текущий клиент игры. В качестве идентификатора используется ник игрока.
message ClientData {
string nick = 1;
string clientName = 2;
string skinId = 3;
optional string ip = 4;
}
Оператор может отправлять Моду запросы на выполнение каких-то действий. Например, подключение к какому-то серверу (или отключение, если передать пустую строку), изменение персонажа или выполнение внутриигровой команды в чате:
message Response {
oneof UnionResponse {
ServerInfo joinServer = 1;
ChangeCharacter changeCharacter = 2;
ExecuteCommand executeCommand = 3;
}
}
message ServerInfo {
string ip = 1;
}
message ChangeCharacter {
string clientName = 1;
string skinId = 3;
}
message ExecuteCommand {
string command = 1;
}
Сервер майнкрафта может затребовать у клиента загрузить ресурс-пак по указанной ссылке в начале игры. Мод перехватывает это требование, если ссылка содержит operator.nlvl.quest, достает оттуда ID карты и скачивает ресурс-пак у Оператора:
message ResourcePackRequest {
string map = 1;
}
Оператор достает .nlvl файл карты, и если первым из TarReader извлекся файл resources.zip, он отправляет этот файл клиенту:
message ResourcePackMetadata {
int64 size = 1;
}
message ResourcePackChunk {
bytes data = 1;
}
message ResourcePackMessage {
oneof ResourcePackData {
ResourcePackMetadata metadata = 1;
ResourcePackChunk chunk = 2;
}
}
С Интерфейсом оператор будет общаться по отдельному сервису:
service OperatorService {
rpc EventListener(google.protobuf.Empty) returns (stream SystemUpdate) {}
rpc CreateGame(CreateGameRequest) returns (CreateGameResponse) {}
rpc StartGame(SavePath) returns (CreateGameResponse) {}
rpc StopGame(SavePath) returns (google.protobuf.Empty) {}
rpc ConnectToConsole(SavePath) returns (stream ConsoleLine) {}
rpc SendCommand(SendCommandRequest) returns (SendCommandResponse) {}
rpc EditPlayer(EditPlayerRequest) returns (google.protobuf.Empty) {}
rpc SendPlayerCommand(SendPlayerCommandRequest) returns (google.protobuf.Empty) {}
rpc ConnectPlayer(ConnectPlayerRequest) returns (google.protobuf.Empty) {}
}
Подписывается на получение обновлений системы. Обновления могут быть трех видов:
При подключении сразу же отправляет сообщения по всем подключенным к Оператору клиентам и запущенным серверам.
message SystemUpdate {
oneof Update {
ServerCreate serverCreate = 1;
ClientData clientDataUpdate = 2;
ClientDisconnect clientDisconnect = 3;
}
}
message ServerCreate {
string game = 1;
string map = 2;
string save = 3;
string ip = 4;
}
message ClientData {
string nick = 1;
string clientName = 2;
string skinId = 3;
optional string ip = 4;
}
message ClientDisconnect {
string nick = 1;
}
CreateGame – создает и запускает новый docker-контейнер И новый docker-volume, в созданный volume распаковывает содержимое .nlvl-файла карты по переданному ID, пропуская файл resources.zip, если он есть (этот файл передается Моду при запросе GetResourcePack). В ответ возвращается ID созданного volume и порт, на котором будет работать сервер
StartGame – создает и запускает новый docker-контейнер на основе volume с указанным ID. Распаковки данных из файла карты при этом не происходит, так как в volume уже есть распакованный мир.
StopGame – выключает сервер и удаляет контейнер, сохраняя при этом volume мира. Передается ID volume, на котором работает сервер.
message CreateGameRequest {
string game = 1;
string map = 2;
}
message CreateGameResponse {
string saveId = 1;
string port = 2;
}
message SavePath {
string game = 1;
string save = 2;
}
ConnectToConsole – в ответ сервер отправляет появляющиеся в консоли сервера строки.
message ConsoleLine {
string line = 1;
}
SendCommand – Оператор с помощью RCON-протокола отправляет внутрь сервера полученную игровую команду и возвращает ее ответ.
message SendCommandRequest {
SavePath server = 1;
string command = 2;
}
message SendCommandResponse {
repeated string lines = 1;
}
Эти команды Оператор передает Моду в диалоге NewClient, используя соответствующее сообщение.
EditPlayer – изменяет имя игрока или его скин. Имя игрока отображается в игре рядом с его ником, чтобы клиенты могли ориентироваться друг среди друга (все имеют ник вида [название VR-клуба][номер игровой зоны]). Мод получает сообщение changeCharacter. После того, как Мод изменит персонажа, Оператор получит сообщение в NewClient, а тот перенаправит его во все активные EventListener
SendPlayerCommand – выполняет указанную игровую команду. Мод получает сообщение executeCommand. В отличие от SendCommand, команда выполняется от имени игрока, а не сервера. Отследить результат выполнения невозможно, поэтому метод ничего не возвращает.
ConnectPlayer – подключает игрока к серверу с указанным IP. Если не передать IP, Мод отключится от текущего сервера. Мод получает сообщение joinServer. После того, как Мод выполнит это действие, Оператор получит сообщение в NewClient, а тот перенаправит его во все активные EventListener
message EditPlayerRequest {
string nick = 1;
string clientName = 2;
string skinId = 3;
}
message SendPlayerCommandRequest {
string nick = 1;
string command = 2;
}
message ConnectPlayerRequest {
string nick = 1;
optional string ip = 2;
}