Пример реализации сетевой архитектуры в Unity3D с авторитарным сервером
В виду того, что:
а) старые сетевые принципы Unity3D, как говорится, устарели;
б) я всё равно не понимал, как у меня заработало то колдунство.
в) назойливо появился новый метод и начал прыгать в глаза при каждом запуске Unity.
я решил, что надо в сетевом обмене разобраться плотнее и устранить область “here be dragons”.
Я начал пробивать дорожку в одном направлении – авторитарный сервер. Это означает, что игровые события, от поворота до нанесения тяжких оскорблений, не совместимых с девственностью, решаются и исполняются исключительно сервером. Игрок имеет право только заказать у сервера исполнение своих намерений. Такой подход позволяет разом отсечь множество читеров и хакеров, которые реверсно инжинирят клиентский код и вмешиваются в него. Конечно, есть ещё много других способов читерства, но это вне рамок исследования.
Задача:
Сформировать этюд, который реализует авторитарную архитектуру.Ограничения:
В этом этюде я не занимаюсь организацией сессий соединений – для этого существуют методы Unity3D «для чайников».В этом этюде я не буду называть, какие клавиши нажимать. Читатель, который не знает, как делать названные действия в Unity3D, на самом деле не является готовым читателем для данной статьи.
В этом этюде я не буду решать физику процесса, вычислять аэродинамику и прочее. Здесь только порядок взаимодействия сетевых участников.
Требуемый результат:
На выходе должен быть исполняемый пример, в котором видно, как клиент взаимодействует с сервером, подробно и прозрачно.Для этого мне пришлось выработать свою терминологию. Без терминологии задачка мне никак не давалась.
Термины:
Сервер – площадка, на которой исполняется код, обслуживающий других людей. Обычно к его консоли человек физически не прикасается.Хост – площадка, на которой исполняется серверный код, однако, на ней же одновременно играет один из игроков. Специально этот режим не рассматривается, поэтому понимание такого режима складывается из суммы пониманий как работает сервер и клиент.
Клиент – площадка, на которой исполняется код, подотчётный конкретному игроку. Грубо говоря, ваш компьютер.
Юнит – игровая единица, представляющая игрока в игровом пространстве, чар, перс и так далее.
Архитектура Unity3D такова, что понятнее попытаться написать один скрипт управления одним юнитом, нежели пытаться написать три разных скрипта: для своего управления, для серверного представления моей игровой личности, для чужих игроков на моём компьютере. Вполне возможно написать эти три скрипта, но это повлечёт тонкости, которые начинающим изучать игродельство не нужны (да и я их пока что не победил).
Поэтому один и тот же модуль должен исполняться в трёх вариантах и копиях, у меня, на сервере и для чужого игрока на моём компьютере.
«Я», «мой» – код, который воспринимает мои намерения непосредственно через консоль, джойстик и т.д. (Дисплей не обязателен ;-))
«Дух» – код, который исполняется на сервере, и только он может реализовывать игровые решения.
«Мой дух» – код на сервере, который транслирует мои намерения в действия на игровой арене.
«Чужой дух» – соответственно код, который реализует чужие действия на сервере.
«Аватар» – код, который отражает действия какого либо игрока на моём (или чужом) экране.
«Мой аватар» – код, отражает действия именно моего «Духа». «Я» сам себе являюсь «моим аватаром».
Мы будем писать единый скрипт, который удовлетворяем этим ролям. Почему один и тот же скрипт? Да просто потому, что мы выезжаем на одинаковых или сравнимых танках. Вы ведь не заставите меня писать один код для Т-34, другой для Panzer 4, третий для Sherman Mk.IVAY?!
Простите меня за изобретение такого велосипеда, но годовое ленивое пролистывание тем разработки мультиплееров показало, что такой терминологии не существует вообще, а попытки объяснения в существующих ранее терминах людьми воспринимаются тяжеловато.
Если вам эта терминология неприятна, подкиньте минусов в карму и спокойно разойдёмся.
Ну вот. Как я говорил ранее, писать в Unity3D я решил единый скрипт для меня, моего духа и моего аватара. А заодно и «того парня». Как, собственно повсеместно рекомендует документация Unity3D.
Фух… Готовы?
Начали.
1) Открываем Untiy3D, добавляем землю, например, Plane. Масштабируем пол до 1000*1*1000.
2) Создаём Material, красим его в цвет RBG=64:80:40. Назначим его как материал пола. Просто, чтобы белый цвет по умолчанию нас не ослеплял. Я его назвал Ground.
3) Добавляем на сцену Cube. К нему добавляем компонент RigidBody, массу которого указываем 30 тонн. Нормально для среднего танка. Переименовываем Cube в Tank. Масштаб Tank выставляем в (3:2:6). Приподнимаем его над полом, указав координату Y, равной 1,1. При этом X и Z советую оставить нулевыми.
4) Добавляем на сцену Cube. Указываем масштаб (1,5:1:2). Называем его Turret. Координаты (0:2,4:1).
5) Добавляем на сцену Cube. Указываем масштаб (0,2:0,2:4) и координаты (0:2,5:4). Назовём Barrel.
6) Добавляем на сцену пустой GameObject, обзовём Gun. Координаты (0:2,4:2), масштаб не трогаем.
7) Вложите объект Turret в объект Tank. Вложите объект Gun в объект Turret. Вложите объект Barrel в объект Gun.
8) Я назначил частям танка материал Khaki с цветом (64:96:32). Просто так.
В результате мы получим некое подобие танка. Если вы спросите, зачем надо вкладывать Barrel в Gun, отвечу – если вращать Barrel по оси X, то ось будет не там. А если вращать Gun по оси X, то… в приемлемом диапазоне картинка не вызывает бешенства. На самом деле вы будете работать с нормальными графическими моделями, у которых центры вращения указаны правильно ещё в графическом редакторе, и таких проблем у вас возникать не должно.
Идём далее.
Мы будем ориентироваться на статью docs.unity3d.com/Manual/UNetConverting.html, но только ориентироваться. Мы будем отклоняться от статьи.
9) Создаём на сцене GameObject и называем его NetworkCommander. К нему добавляем два компонента: NetworkManager и NetworkmanagerHUD. Надо убедиться, что у компонента NetworkManagerHUD включена галочка ShowRuntimeUI. NetworkManager реализует общее управление сетевыми сессиями. Для простоты игроков/разработчиков компонент NetworkManagerHUD рисует на экране простенькое меню, в котором заложены базовые действия с сетью – сервировать игру, хостить игру, присоединиться к игре.
10) К объекту Tank добавьте компонент NetworkIdentity. Однако не включайте галочку LocalPlayerAuthority. Это нам не нужно для авторитарной архитектуры, и всё равно смысла у нас она не имеет.
11) Сделайте из танка Prefab – то есть скопируйте его со сцены в ресурсы. Назовите TankPrefab.
12) В объекте NetworkCommander в разделе SpawInfo вставьте TankPrefab в поле PlayerPrefab.
13) Создайте C# скрипт ArmourDrive.cs. Процедура Start() нам на данный момент не нужна. Нам нужна только Update().
14) Включите пространство имён UnityEngine.Networking. Это позволит нам пользоваться сетевыми функциями uNet.
15) Смените класс ArmourDrive с MonoBehaviour на NetworkBehaviour. Этот класс расширяет обычную логику, добавляя к ней сетевые функции.
16) Создайте в классе ArmourDrive поля:
float veloMyMax = 10, veloMyCurr = 0; //переменные, которые использую я. Максимальная доступная и желаемая скорости, м/с.
float veloSvrMax = 10, veloSvrCurr = 0; //переменные, которые использует мой дух. Максимально доступная и требуемая скорости, м/с.
float periodSvrRpc = 0.02f; //как часто сервер шлёт обновление картинки клиентам, с.
float timeSvrRpcLast = 0; //когда последний раз сервер слал обновление картинки
17) Текущий скрипт будет исполняться сразу в трёх местах: у меня для чтения моих намерений («я»), на сервере для приёма намерений и превращения их в действия («мой дух»), и на компе того парня, для которого действия будут рисоваться («мой аватар»). Различить эти три ипостаси мы можем двумя переменными.
Переменная isServer означает, что эта копия скрипта исполняется на сервере и, в данном случае является «моим духом».
Переменная isClient означает, что эта копия скрипта исполняется на клиенте и, в данном случае является «чьим-то аватаром».
Переменная isLocalPlayer означает, что это именно «мой аватар».
18) Сформируем намерения внутри функции Update:
if (this.isLocalPlayer)
//Код исполняется только у меня
{
//Формируем новое требование по скорости
float veloMyNew = 0;
veloMyNew += Input.GetKey(KeyCode.W) ? veloMyMax : 0;
veloMyNew += Input.GetKey(KeyCode.S) ? -veloMyMax : 0;
if (veloMyCurr != veloMyNew)
//Если требование по скорости изменилось, то шлём на сервер новое требование
{
CmdDrive(veloMyCurr = veloMyNew);
}
}
19) Для того, чтобы послать на сервер мои намерения, программист обязан: а) начать название функции с букв “Cmd”, б) при описании функции пометить её, как исполняемую на сервере по требованию клиента. Вот её текст:
[Command(channel = 0)]
void CmdDrive(float veloSvrNew)
{
if (this.isServer)
//Мой дух принимает и проверяет команду.
{
//Проверяем моё требование на валидность.
veloSvrNew = Mathf.Clamp(veloSvrNew, -veloSvrMax, veloSvrMax);
//Устанавливаем текущее значение требуемой мною скорости для духа.
veloSvrCurr = veloSvrNew;
//Исполнять будет дух позже.
}
}
20) Теперь сервер должен авторитарно передвинуть мой танк. Это можно сделать и в упомянутой функции Update(). Однако авторы Unity3D рекомендуют физические расчёты осуществлять внутри FixdUpdate(). У нас не крутая физика, но последуем совету:
void FixedUpdate()
{
if (this.isServer)
//Код исполняется только у духа
{
//Обработать мои команды
this.transform.Translate(0, 0, veloSvrCurr * Time.deltaTime, Space.Self);
if (timeSvrRpcLast + periodSvrRpc < Time.time)
//Если пора, то выслать координаты всем моим аватарам
{
RpcUpdateUnitPosition(this.transform.position);
RpcUpdateUnitOrientation(this.transform.rotation);
timeSvrRpcLast = Time.time;
}
}
}
21) Обратно функциям типа Cmd исполняются функции Rpc – они вызываются сервером, а исполняются на клиентских машинах. Здесь тоже синтаксические требования: функция должна иметь пометку [ClientRpc], и название должно начинаться на буквы “Rpc”. Сервер нам выслал новые координаты, которые получились из нашего требования скорости, и мы должны переставить свой юнит в новое место.
[ClientRpc(channel = 0)]
void RpcUpdateUnitPosition(Vector3 posNew)
{
if (this.isClient)
//Мои аватары копируют состояние моего духа.
{
this.transform.position = posNew;
}
}
Здесь можно было бы вставить гладкое приближение к этим координатам во избежание рывков танка на дисплее, но мы не будем на это отвлекаться.
22) Поскольку танк может при линейном движении случайно повернуться (на кочку наехать и т.д.), нам надо бы новую ориентацию танка так же выставить
[ClientRpc(channel = 0)]
void RpcUpdateUnitOrientation(Quaternion oriNew)
{
if (this.isClient)
//Мои аватары копируют состояние моего духа.
{
this.transform.rotation = oriNew;
}
}
23) Этот скрипт надо добавить к префабу танка
24) Теперь мы почти готовы стартовать игру. Но есть проблема – обычно удобнее работать с игровым объектом на сцене, конфигурировать, развивать его. Но при старте сетевой игры этого объекта на сцене быть не должно. Обычно на youtube различные учителя перед запуском игры стирают со сцены этот объект. Но мне удобнее его выключать, а не удалять. Выключите объект Tank на сцене или удалите (убедитесь, что префаб существует!)
25) Теперь мы точно готовы запускать игру. Давайте воспользуемся режимом «Хост», чтобы и создать и войти в игру быстро без проблем с запуском дополнительных копий. Стартуйте и нажмите в диалоге, который предоставляет NetworkManagerHud, кнопку LAN (Host).
26) Оп-па. Танк утонул в земле по башню. Это потому, что NetworkManager по умолчанию спавнит игрока на нулевой высоте, так, что днище танка уже находится под землёй. С респавном игроков мы можем разобраться позже, а сейчас в педагогических целях давайте себе позволим один костыль. Потому, что мы разбираем сейчас не респавн, а сетевое движение. Пишем:
void Awake()
{
//Костыль: приподнять игрока на 110см выше, чтобы не проваливался в ландшафт
//TODO разобраться с координатами респавнинга игроков.
this.transform.position = new Vector3(0, 1.1f, 0);
}
27) Запускаем игру снова и опять выбираем режим Host. Всё нормально, танк при нажатии клавиш двигается вперёд и назад.
28) Теперь давайте соберём Standalone, чтобы мы могли запустить несколько копий игры. Вызываем Build Settings и Player options. Обязательно включаем галочку Run in Background – это позволит нам на одном компьютере запустить несколько экземпляров без того, чтобы большинство из них впало в спячку. Так же давайте пока выключим Default is Full Screen и для наглядности выставим мелкое разрешение 400*300. Поскольку больше нам пока не нужно, выключаем Display Resolution Dialog в Disabled. Далее опять запускаем Build и создаём исполняемый файл.
29) Мы можем запустить исполняемые файлы несколько раз.
30) Мы можем включить их в любой комбинации – хост и два клиента, или сервер и два клиента. Вот пример сервера и двух клиентов
Результат
Мы можем убедиться, что оба танка ездят нормально, гладкость синхронизации для такого этюда более чем приемлема.
Но вы можете сказать «Постойте! Ведь реализовано движение только вперёд-назад! А где же поворот танка и башни?!»
На это у меня ответ такой:
Поворот танка, башни и пушки можно реализовать абсолютно аналогично движению вперёд-назад.
Почему я реализовал сетевой обмен именно так, и зачем я вываливаю эту поделку на Хабр?
Вот именно потому, что туториалы по новой сетевой системе uNet пока что скудны, мизерны в количестве, а методы, которые они наставляют, не могут повернуть части танка, только весь танк целиком.
А тут мы рассмотрели (я надеюсь) внятную логику работы сети, которая действительна и для движения частей танка, и для дыма, и для повреждений, и, в общем-то, самих выстрелов.
Такие дела.