Эта заметка про программирование и разработку игр на движке Unity, но применимо принципе для любых приложений, требующих простой серверной части.
Есть задача: сделать таблицу рекордов, простое сетевое взаимодействие (например для карточной игры), облачные сохранения и всё такое, но без использования сторонних сервисов с авторизацией (например от Google).
Приведу реализованный мной способ не претендуя на его оптимальность, оригинальность и защищённость. Впрочем, как и всегда рад предложениям как делать подобное легче - в комментариях).
Для его реализации потребуется VPS. Я покупаю такой по чуть ли не самому недорогому тарифу за 2000р в год.
Так как проектов или игр, в которых может захотеться сделать серверную часть скорее всего будет несколько, поэтому следует не мешать всё в одну кучу. Например, если у нас есть сайт разработчика и мы потом хотим добавлять туда же серверные функции для каждого проекта, то получается уже некая микросервисная архитектура. При построении такой архитектуры оптимальнее всего для каждой задачи или проекта использовать отдельный Docker-контейнер.
Итак, на VPS я установил Ubuntu, и там установил Docker.
В Visual Studio я создал проект из шаблона "Пустой ASP.NET Core". Обязательно поставьте галочку "Включить Docker"
Сама программа представляет собой простое API c записью и получением информации из таблиц БД (про принцип работы таких программ я уже рассказывал в заметке про базы данных), если интересно увидеть статью полным разбором примера - напишите в комментариях - расскажу.
На стороне приложения (в моём случае в Unity)
Рассмотрим таблицу рекордов (другие задачи реализуются аналогично - главное понять принцип). Идея следующая: приложение отправляет запрос на сайт в виде Get-запроса прямо в заголовке и сразу получает ответ сервера. Например в случае таблицы рекордов мне надо добавить себя и узнать моё место, для этого я отправлю подобный запрос:
http://mysite.ru:5001/add?name=Vasya&code=87541486333501&score=53
Тут: mysite.ru - адрес сервера (VPS), 5001 - порт маршрутизирующий запрос в контейнер с самой серверной программой, add - это запрос (в моём примере это метод "добавить себя в таблицу рекордов"). После знака "?" идут параметры запроса (аргументы метода в серверной программе, обрабатывающего этот запрос): name - имя игрока, которое будет отображаться в таблице рекордов, code - уникальный номер (например установки приложения) по которому будут обновляться данные если игрок поменял имя в игре или улучшил результат, score - счёт игрока.
Результатом я хочу получить таблицу с моим местом и Топ3-игроками и их рекордами. Подобные данные удобно передавать в формате JSON. Чуть дальше я напишу как ответ будет сформирован на стороне сервера, а на стороне клиента мы сразу подождём ответ сервера и расшифруем его.
Ответ сервера - это строка с текстом, структуру которого мы сами сформируем на сервере для того чтобы нам удобно было потом обработать результаты, например такая:
{s1:{"Place":1,"Name":"12300k","Score":4751}, s2:{"Place":2,"Name":"Max","Score":3624}, s3:{"Place":3,"Name":"Toxic","Score":3551}, s4:{"Place":952,"Name":"Vasya","Score":53}}
Я для себя определил что я буду всегда передавать в ответ 4 строки таблицы, которые я пронумеровал (s1, s2, s3, s4). Я сам для себя решил, что если игрок не входит в Топ3 игроков, то четвёртая строка - будет он сам. Дальше будет немного кода, там всё очевидно, так что если вы зашли сюда посмотреть про публикацию приложения - листайте дальше. Итак пишем на стороне приложения (клиента):
using System.Net;
using System.IO;
using System.Net.NetworkInformation;
using SimpleJSON;
...
HttpWebRequest request = (HttpWebRequest)WebRequest.Create($"http://mysite.ru:5001/add?name={_name}&code={_code}&score={_rating}");
HttpWebResponse response = (HttpWebResponse)await request.GetResponseAsync();
using (Stream stream = response.GetResponseStream())
using (StreamReader reader = new StreamReader(stream))
{ var responseJson = JSON.Parse(reader.ReadToEnd());
_text.text = $"1\t{responseJson["s1"]["Name"].Value}\t{responseJson["s1"]["Score"].Value}♛\n";
_text.text += $"2\t{responseJson["s2"]["Name"].Value}\t{responseJson["s2"]["Score"].Value}♛\n";
_text.text += $"3\t{responseJson["s3"]["Name"].Value}\t{responseJson["s3"]["Score"].Value}♛\n";
if (int.Parse(responseJson["s3"]["Place"].Value) < 5)
{ _text.text += $"4\t{responseJson["s4"]["Name"].Value}\t{responseJson["s4"]["Score"].Value}♛\n";
} else
{ _text.text += $"{responseJson["s4"]["Place"].Value}\t{responseJson["s4"]["Name"].Value}\t{responseJson["s4"]["Score"].Value}♛\n";
} }
На стороне сервера (ASP.NET Core)
И немного кода на стороне сервера. Если вы не писали программы с использованием этого фреймворка, то можно почитать в учебнике (для написания такой программы достаточно прочесть буквально пару глав).
app.Run(async (context) =>
{ var response = context.Response;
var path = context.Request.Path;
var query = context.Request.Query;
response.Headers.ContentLanguage = "ru-RU";
response.ContentType = "text/html; charset=utf-8";
if (path.ToString().ToLower() == "/add" && context.Request.QueryString.HasValue)
{ try
{ string name = query.Where(q => q.Key == "name").Select(q => q.Value).First();
Debug.WriteLine(name);
string code_s = query.Where(q => q.Key == "code").Select(q => q.Value).First();
Debug.WriteLine(code_s);
string score_s = query.Where(q => q.Key == "score").Select(q => q.Value).First();
Debug.WriteLine(score_s);
if (int.TryParse(code_s, out int code) && int.TryParse(score_s, out int score))
{ Crud.AddOrUpdate(name, code, score);
//это метод добавления строки в БД - я писал подобный в заметке про базы данные - ссылка есть выше
StringBuilder sbJson = new StringBuilder("{");
List<Models.ScoreRecord> l = new List<Models.ScoreRecord>();
l = Crud.GetOrderedTable();
//это метод получения всей таблицы рекордов отсортированной по убыванию, благодаря LINQ в нём всего одна короткая строка кода
sbJson.AppendLine("s1:{\"Place\":1,\"Name\":\"" + l[0].Name.ToString() + "\",\"Score\":" + l[0].Score.ToString() + "},");
sbJson.AppendLine("s2:{\"Place\":2,\"Name\":\"" + l[1].Name.ToString() + "\",\"Score\":" + l[1].Score.ToString() + "},");
sbJson.AppendLine("s3:{\"Place\":3,\"Name\":\"" + l[2].Name.ToString() + "\",\"Score\":" + l[2].Score.ToString() + "},");
int indexPlayer = l.FindIndex(x => x == l.Select((x) => x).Where((x) => x.Name == name).FirstOrDefault());
if(indexPlayer < 4)
{ sbJson.AppendLine("s4:{\"Place\":4,\"Name\":\"" + l[3].Name.ToString() + "\",\"Score\":" + l[3].Score.ToString() + "}}");
} else
{ sbJson.AppendLine("s4:{\"Place\":" + indexPlayer + ",\"Name\":\"" + l[indexPlayer].Name.ToString() + "\",\"Score\":" + l[indexPlayer].Score.ToString() + "}}"); }
await response.WriteAsync($"{sbJson.ToString()}");
Публикация развёртывание серверной части в Docker-контейнере
Когда серверное приложение проверено и собрано, можно публиковать его на сервере. Обратите внимание что в папке с проектом есть файл "Dockerfile" - это инструкция по сборке образа в Docker-контейнере.
Но если вы используете (как в моём примере) не проект состоящий из одного файла у вас может возникнуть проблема с публикацией (и соответственно отладкой в Docker Desktop). Дело в том, что после добавления каждой зависимости Dockerfile надо обновлять, чего он автоматически пока не делает (на момент написания статьи - весна 2023). Надо нажать снова добавить поддержку Docker и он обновится, подробнее про это тут.
Я буду публиковать образ в Docker Hub - это официальный, бесплатный сервис, где можно создать свой репозиторий образов, откуда их удобно разворачивать в контейнеры. Рекомендую делать так, а не копировать исходники на сервер (например с помощью FTP) и собирать там образ командой "docker build", так как при регулярном обновлении это намного проще, быстрее и удобнее. Поэтому там надо зарегистрироваться.
Для публикации на Docker Hub в проект надо будет добавить Nu-Get - пакет "Microsoft.NET.Build.Containers".
Затем публикуем по официальному мануалу от Microsoft. При этом у вас на сайте Docker Hub будет создан репозиторий с названием вашего проекта.
Далее надо зайти к себе на сервер (VPS) и выполнить команду "sudo docker run -p 5001:80 vash_login_ot_doker_hub/vash_proekt".
Если вы заходили через SSH и вводили команду в Bash, то при закрытии сессии контейнер автоматически будет остановлен. Подобные мелочи могут вызвать неудобство (особенно когда контейнеров много и они годами не выключаются), поэтому чтобы иметь возможность всегда не заходя на хост удобно управлять всеми своими контейнерами через веб-интерфейс я рекомендую установить Portainer. ,
На этом моменте уже всё работает и если вы даже в браузере просто в адресной строке введёте запрос (как из начала статьи), то в ответ увидите JSON с ответом сервера. Проверяем - из Unity Editor тоже всё работает, а вот из Android не работает так как там надо отдельно резервировать сокет. В Unity нет удобного средства для этой задачи так как подразумевается, что будет использоваться встроенная организация сервер-клиентского взаимодействия, при добавлении которой в проект автоматически добавляются все записи в само приложение и его манифест.
Поэтому в Unity есть класс UnityWebRequest, упрощающий основные запросы класса HttpWebRequest для работы с веб-запросами, поэтому надо переделать проверку доступности сервера и получения ответа так:
UnityWebRequest wr = UnityWebRequest.Get($"http://mysite.ru:5001/add?name={_name}&code={_code}&score={_rating}");
yield return wr.SendWebRequest();
if (!wr.isHttpError && !wr.isNetworkError) {
var responseJson = JSON.Parse(wr.downloadHandler.text);
ПС: Если доступность сервера вы проверяли с помощью System.Net.NetworkInformation.Ping - этот код тоже надо поменять на использующий UnityEngine.Ping
______________________
Upd: в версиях 2022 и свежее выдаёт ошибку, что "insecure connection not allowed" - можно переделать под HTTPS либо поставить галочку (там будет написано предупреждение что это не безопасно) в File -> Project Settings -> Player -> Other Settings -> Configuration -> Allow downloads over HTTP "Always allowed". Также теперь движок предупреждает, что вместо "if (!wr.isHttpError && !wr.isNetworkError)" надо писать "if (wr.result != UnityWebRequest.Result.ProtocolError && wr.result != UnityWebRequest.Result.ConnectionError)" - тут соглашаемся исправить или игнорируем предупреждение.
______________________
В следующей заметке можете почитать какими нейросетями я пользовался при создании игры, в которой таблица рейтинга кстати реализована описанным здесь способом. А до этого рассказывал про контроль версий и планирование задач в проекте на Unity с использованием продуктов GitHub, Jira и Confluence. А также дам пару полезных советов по скриптингу в Unity.