Часто в играх необходимо получать обновления игрового баланса, обновлять профиль игрока, сохранять достижения и выдавать награды. Если хранить данные прямо в клиенте, то придется ждать публикации нового патча командой. Как более гибкое решение - получать конфигурацию и ресурсы для игры с внешнего сервера. В посте рассмотрим как можно из клиента Unity подключиться к простейшему сервису для получения от него сообщения. Для реализации сервиса возьмем библиотеку SignalR.
Что стоит ждать от статьи
Пример создания простейшего сервиса SiglanR и разворачивание его на Ubuntu.
Демонстрация как подключить библиотеку из NuGet к Unity.
Несколько советов, которые могут ускорить работу с ассетами под редактором Unity.
Разбор нескольких частых ошибок при сборке на Android сторонних dll.
Чего не будет в этой статье
По мере написания статьи количество деталей, которые хотелось бы показать, очень быстро росло, поэтому их отложим на следующие статьи и в этой мы не будем касаться:
Для доступа к нашему сервису не будет использоваться HTTPS/SSL. Для релизных приложений использовать HTTPS/SSL необходимо.
Не будем придираться к SOLID, пример небольшой и добавлять паттерны только чтобы они были не хочу.
Не будет разворачивания ASP.NET Core из Docker.
Пропустим Авторизацию в сервисе и управление правами доступа. Тема хорошая, затронем в следующий раз.
Маппинг моделей с игрового сервера в клиент и шаринг кода между клиентом и сервером вынесем в следующую статью.
Оптимизация трафика запросов. Использование инструментов подобных MessagePack. Обязательно вернемся к этому позже.
Пишем Demo SignalR Service
Создаем решение из шаблона Web API Asp.NET Core. У Microsoft уже есть отличный пошаговый тутор на русском языке SignalR для ASP.NET Core с Blazor, возьмем его за основу. Запускать на сервис мы будем на Ubuntu.
Для начала поставим .NET SDK на машину:
sudo apt-get update
sudo apt-get install -y apt-transport-https
sudo apt-get update
sudo apt-get install -y dotnet-sdk-7.0
Проверяем установленную версию:
dotnet --list-sdks
Дальше мы соберем наш проект на машине с Ubuntu и оставим его висеть демоном. Сначала перейдем в каталог с нашим сервисом SignalR и выполним команду сборки:
dotnet build -c Release
Опубликуем в нужной нам директории из которой планируем запускать:
dotnet publish -c Release -o %OUTPUT_DIRECTORY%
Нам достаточно, чтобы SIgnalR Hub висел в фоновом процессе, поэтому не будем придумывать лишнего. Нам хватит простой команды:
(cd %OUTPUT_DIRECTORY%;dotnet %PROJECT_NAME%.dll > /dev/null 2>&1 &)
Если теперь попробуем открыть с http://localhost:500 то увидим наше demo web приложение. Если наша машина имеет публичный ip, пусть это будет 53.53.53.53. Попытка открыть в браузере с другой машины http://53.53.53.53:5000 скажет нам лишь, что: “Не удается получить доступ к сайту”.
Проблема в редиректе. Решать ее будем через nginx. Сначала поставим его.
sudo apt-get install nginx
Теперь добавим конфиг в /etc/nginx/sites-available для редиректа в наше приложение:
map $http_connection $connection_upgrade {
"~*Upgrade" $http_connection;
default keep-alive;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 100s;
proxy_cache off;
}
}
Перезапускаем сервис nginx:
sudo service nginx restart
В нашем демо сервисе SignalR мы зарегистрировали Hub под именем demohub:
app.MapHub<DemoHub>("/demohub");
Теперь Hub будет доступен публично: http://53.53.53.53:5000/demohub
Устанавливаем SignalR в Unity
Начнем с начала, получил саму библиотеку SignalR. Если вы попробуете просто взять пакет Microsoft.AspNetCore.SignalR.Client в NuGet, то наткнетесь на проблему, пакет не содержит зависимости.
Решим эту ошибку напряму скачав SignalR через NuGet CLI:
Качаем NuGet CLI и сохраняем где вам удобно.
Просим NuGet скачать нам SignalR.
nuget.exe install Microsoft.AspNetCore.SignalR.Client -Version 7.0.2 -OutputDirectory %OUTPUT_PATH%
Теперь у нас все необходимые зависимости и сам клиент SignalR
Если открыть один из скачанных пакетов, увидим следующее:
На момент написания статьи LTS версия Unity - 2021.3.16f1. Эта версия поддерживает:
.NET Standard 2.1 / 2.0
.NET Framework
Это значит, что перед тем как добавлять в проект необходимо удалить версию для .NET Core. Если в игре вы используете только .NET Standard или .NET Framework, то оставьте только нужную версию.
Теперь можно добавить библиотеки в клиент игры. В своем demo я расположил их адресу:
Assets/Plugins/Packages/SignalR
Если вам необходимо использовать .NET Standard и .NET Framework версии библиотеки, то после добавления придется помочь Unity понять какая dll для какой версии. В документации по компиляции проекта под разные платформы можно найти нужные defines
NET_STANDARD - Defined when building scripts against .NET Standard 2.1 API compatibility level on Mono and IL2CPP
Дальше необходимо указать это ограничение компиляции для наших dll. Для .NET Standard должно так:
А для .NET Framework необходимо поставить обратное ограничение:
Dll в проект мы добавили много. Настройки для каждой можно выставить руками, но это утомительно. Поэтому напишем небольшой и глупый сприпт, который сделает работу сам. Одноразовые скрипты должны быть написаны максимально прямолинейно и понятно.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
public static class ApplyPlatformDefines
{
public static string[] Paths = {"Assets/Plugins/Packages/SignalR"};
public const string Extension = "t:Object";
public const string NetStandard = "NET_STANDARD";
public const string NotNetStandard = "!NET_STANDARD";
[MenuItem(itemName: "Tools/Apply SignalR Constraints")]
public static void ApplyDefines()
{
//Говорим Unity, что мы начинаем редактирование ассетов и пока мы не закончим,
//не нужно импортировать их и тратить очень много времени на это
AssetDatabase.StartAssetEditing();
//При любом исходе выполнения нашего скрипта мы должны вызвать
//AssetDatabase.StopAssetEditing(); после окончания изменения ассетов
try
{
Execute();
}
catch (Exception e)
{
Debug.LogException(e);
}
AssetDatabase.StopAssetEditing();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
private static void Execute()
{
var assets = AssetDatabase.FindAssets(Extension,Paths);
var constraints = new List<string>();
foreach (var guid in assets)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
//Получаем по пути к ассету его импортер для управления его настройками
var importer = AssetImporter.GetAtPath(path);
//Нас интересуют именно импортеры dll, они в unity идут как PluginImporter
//поэтому берем только их
if(importer is not PluginImporter pluginImporter) continue;
var restriction = path.Contains("netstandard") ? NetStandard : NotNetStandard;
//формируем новый список ограничения для dll
constraints.Clear();
constraints.AddRange(pluginImporter.DefineConstraints);
constraints.RemoveAll(
x => x.Equals(NetStandard, StringComparison.OrdinalIgnoreCase) ||
x.Equals(NotNetStandard, StringComparison.OrdinalIgnoreCase));
constraints.Add(restriction);
//Задаем новые ограничения по тому, в какой папке находится dll
pluginImporter.DefineConstraints = constraints.ToArray();
Debug.Log($"ADD constraints {restriction} to {path}");
//Сохраняем настройки
pluginImporter.SaveAndReimport();
}
}
}
Кратко отмечу полезные моменты:
Используйте AssetDatabase.StartAssetEditing/AssetDatabase.StopAssetEditing, для модификации группы ассетов, чтобы не ждать реимпорт каждого отдельного ассета.
PluginImporter позволяет вам менять настройки dll и всего, что Unity относит к категории плагинов
Теперь мы вызываем выполнение нашего скрипта через меню - Tools/Apply SignalR Constraints. После того как он закончит выполнение мы получим библиотеки разделенные по версии рантайма, который будем использовать. Теперь переключения между .NET Standard и .NET Framework не будут вызывать ошибок.
Подключаемся к SignalR Hub
Проверяем под редактором
Дальше мы напишем небольшой скрипт подключения к SignalR из под редактора. После чего приступим к сборке под Android и проверим наше приложение на устройстве.
public async UniTask<HubConnection> ConnectToHubAsync()
{
Debug.Log("ConnectToHubAsync start");
//Создаем соединение с нашим написанным тестовым хабом
var connection = new HubConnectionBuilder()
.WithUrl(“http://53.53.53.53:5000/demohub”)
.WithAutomaticReconnect()
.Build();
Debug.Log("connection handle created");
//подписываемся на сообщение от хаба, чтобы проверить подключение
connection.On<string, string>("ReceiveMessage",
(user, message) => LogAsync($"{user}: {message}").Forget());
while (connection.State != HubConnectionState.Connected)
{
try
{
if (connection.State == HubConnectionState.Connecting)
{
await UniTask.Delay(TimeSpan.FromSeconds(connectionDelay));
continue;
}
Debug.Log("start connection");
await connection.StartAsync();
Debug.Log("connection finished");
}
catch (Exception e)
{
Debug.LogException(e);
}
}
return connection;
}
В Unity connection.StartAsync() может вызвать ошибку подключения. На устройстве она будет выглядеть следующим образом:
Error Unity SocketException: mono-io-layer-error (111)
Она может произойти, если в момент подключения у пользователя не было сети или сервер с сервисом хаба SignalR был не доступен.
Собираем на устройство
Мы дошли до этапа тестирования на устройстве. Тестировать будем на Android. Еще одно важное ограничение наш билд сразу будет под IL2CPP. И при первом запуске после сборки нас ждет ошибка. В сообщении будет сказано, что нужные для работы SignalR зависимости не найдены и создать инстанс интересующего нас типа невозможно. Причина ошибки в том, что Unity старается вырезать (https://docs.unity3d.com/Manual/ManagedCodeStripping.html) неиспользуемый код и библиотеки и порой под нож попадают и нужные нам зависимости. Чтобы починить эту проблему достаточно добавить файл link.xml. Документация по Unity linker.
Для нас решением проблемы будет вот такой файл link.xml:
<linker>
<assembly fullname="Microsoft.AspNetCore.Connections.Abstractions" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.Http.Connections.Client" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.Http.Connections.Common" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.Http.Features" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.SignalR.Client.Core" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.SignalR.Client" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.SignalR.Common" preserve="all"/>
<assembly fullname="Microsoft.AspNetCore.SignalR.Protocols.Json" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Configuration.Abstractions" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Configuration.Binder" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Configuration" preserve="all"/>
<assembly fullname="Microsoft.Extensions.DependencyInjection.Abstractions" preserve="all"/>
<assembly fullname="Microsoft.Extensions.DependencyInjection" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Logging.Abstractions" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Logging" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Options" preserve="all"/>
<assembly fullname="Microsoft.Extensions.Primitives" preserve="all"/>
<assembly fullname="System.Buffers" preserve="all"/>
<assembly fullname="System.ComponentModel.Annotations" preserve="all"/>
<assembly fullname="System.IO.Pipelines" preserve="all"/>
<assembly fullname="System.Memory" preserve="all"/>
<assembly fullname="System.Numerics.Vectors" preserve="all"/>
<assembly fullname="System.Runtime.CompilerServices.Unsafe" preserve="all"/>
<assembly fullname="System.Text.Json" preserve="all"/>
<assembly fullname="System.Threading.Channels" preserve="all"/>
<assembly fullname="System.Process" preserve="all"/>
<assembly fullname="System.Threading.Tasks.Extensions" preserve="all"/>
<assembly fullname="System.Threading" preserve="all"/>
<assembly fullname="System.Net.Http" preserve="all"/>
<assembly fullname="System.Core" preserve="all">
<type fullname="System.Linq.Expressions.Interpreter.LightLambda" preserve="all" />
</assembly>
</linker>
После добавления указания линкеру и сборки, на старте ошибок быть уже не должно.
Мы перешагнули главный страх многих новичков при работе с сервером в GameDev - получили первое сообщение от сервиса, который реализовали сами.
Что дальше?
В планах осветить дальше то, что не попало сюда из-за объема. А поскольку в игре, которую создаем сейчас для мета гейма выбрали именно SignalR, получится добавить реальных кейсов применения и проблем, с которыми столкнулись:
Работа с дополнительными параметрами запросов под Unity, авторизация, HTTPS/SSL
Сколько стоит сил и денег подключить облако для игры, какие плюсы и минусы это несет
Оптимизация трафика и маппинг моделей между клиентом и сервером
Как организовать обработку данных на клиенте и связать с ECS геймплеем
На этом всё. Спасибо за внимание.
P.S ссылка на мой telegram канал о разработке игр
Комментарии (9)
dmitryzenevich
26.01.2023 20:17Отличная статья, подающая надежды))
Хотелось бы побыстрее узнать о связке с ECS, авторизацию, оптимизацию трафика и маппинге моделей =)
korchoon
26.01.2023 20:17Автор, пиши еще!
Как организовать обработку данных на клиенте и связать с ECS геймплеем
А причем здесь ECS геймплей на клиенте? Используете ли вы ECS на сервере?
Mefodei Автор
26.01.2023 20:19На сервере сейчас ECS нет, игра достаточно простая и нам нет необходимости поднимать симуляцию. А ECS c клиентом упомянул как возможность показать как можно связывать данные из сервисов через прослойку с ECS миром.
web3_Venture
27.01.2023 11:08Самое загадочное в .net это масштабирование SignalR, какие лимиты, что будет если одинаковые хабы будут на разных машинах, нужно ли масштабировать хабы, а не группы внутри одного хаба, какие лимиты и как паралелить если допустим есть 1 супер большая группа. Как при этом работать с fallover допустим если образ SignalR развернут в docker compose в N сервисов с внутренней случайном балансировкой от докер композа.
Ни разу нигде в интернатах не встречал такую статью. Хорошо бы чтобы ктото на хабаре такую сделал на конкретном примере.
Mefodei Автор
27.01.2023 11:45Звучи действительно интересно. Спасибо, тут есть над чем подумать. Если руки дойдут в рамках игры, то обязательно посмотрю. Но как всегда впереди стоит продукт/игра, поэтому и приоритеты будут исходить из этого.
Zara6502
Не смог понять разницу, ведь конфигурацию и ресурсы для игры на внешний сервер размещает точно так же команда разработчиков после тестирования. То есть у вас принципы апдейта не поменялись (разработка-тестирование-подготовка ресурсов для апдейта-апдейт), поменялся транспорт (вместо http get, ли что там было, у вас стали сообщения)? Или я что-то еще не знаю?
Mefodei Автор
Дело в том, что при стандартном пайплайне публикации патча через Google/Apple есть этап review игры. Он может занять до недели времени, если не повезет. QA могут пропустить и блокер в балансе на поздних этапах игры и что-то еще. А вот правка баланса сначала в sandbox, проверка изменения и деплой на боевой сервер без ревью стора произойдет как только фикс будет утвержден QA командой.
В отдельных случаях правки в баланс, игровые эвенты и прочее могут вносить прямо геймдизайнеры через сделанный для них инструментарий без привлечения программиста, после чего уже попадает в QA и дальше по схеме выше.
Zara6502
тогда непонятно почему вы решили что Unity == Android? Ну либо в начале статьи об этом стоит написать, так как формально вы пишете тогда не про юнити, а про особенности релиза игр на определенных площадках.
PS: по секрету, если бы в первом абзаце я прочитал слово Android, то я просто закрыл бы статью и не тратил время..
Mefodei Автор
Спасибо за замечание. Обязательно учту его