Как стать автором
Обновить

Клиент-серверное общение в Unity3d

Время на прочтение 10 мин
Количество просмотров 50K
Всем привет! Мне всегда безумно интересно читать статьи про чужой реальный опыт, и успешное прохождение сквозь россыпи грабель или граблей. Посему, данной статьей хочу начать делиться своим скромным опытом из мира игростроя на юнити, а так же побольше узнать о чужом опыте работы с юнити.

Итак, в ноябре прошлого года наша команда начала делать клиентскую сессионную ммошечку — катайся на машинах, стреляй врагов. Надо сказать, что у команды уже был опыт не успешного проекта на юнити, это были 3д гонки для вконтакта. Так что тема машинок в юнити была уже знакома и на этом планировалось сэкономить. Cамое первое с чего было решено начать, это максимально быстро сделать пруф оф концепт — демку игры максимально точно показывающую геймплей. Цель данного мероприятия понятна — как можно раньше отсечь все то, что не впишется в игру. Кроме того, предстояло также выбрать серверный движок. С клиентом все было понятно сразу, Unity3d наше все, но что выбрать в качестве серверного движка? Вот в чем вопрос. Остановлюсь на этом по подробнее.


Итак, были найдены следущие претенденты:
1. Photon
2. Smartfox
3. Встроенная в Юнити сеть
4. ElektroServer
5. uLink
6. CrystalEngine

Встроенную в Юнити сеть мы отвергли сразу. Несмотря на то, что это решение дает поддержку физики на сервере, а значит, полностью авторитарный сервер, в данном случае, чтобы запустить новую матч-комнату необходимо поднять новый инстанс Юнити на сервере, а это очень затратно.
ElektroServer может быть и хорош в глубине души, на момент выбора он показался не таким быстро развивающимся, как остальные претенденты. Кроме того, если посмотреть на их сайте, то подавляющее большинство клиентов заказывали услуги студии, которая создала ElektroServer, а тех, кто заказал именно их серверное решение отедльно — меньшинство. Посему от ElektroServer’a мы тоже отказались.
uLink — штука интересная, но на данный момент весьма новая. Более того uLink, насколько я помню, живет внутри юнити, что клиент, что сервер, посему опять таки встает вопрос производительности сервера. В принципе решение должно быть хорошим, если делать, чтобы сервера для матчей могли поднимать сами игроки, как в контре, например. На вид, всяких плюшек в uLink’е гораздо больше нежели во встроенной в юнити сети.
CrystalEngine — молодой, развивающийся движок. Из плюсов — на момент создания демки для препродакшена, в нашей команде был главный разработчик этого движка. Из минусов -движок слишком молодой, на лицо нехватка документации и отсутсвие большого коммьюнити. Посему, на этот движок решили не закладываться.

Итого, у нас осталось 2 финалиста:
Photon vs Smartfox

К сожалению, времени протестировать оба решения в течение препродакшена не было. Оба движка внушают уважение, имеют выпущенные игры, документацию и живое коммьюнити. Так что, ключевую роль в выборе движка сыграло то, что я уже был знаком со смартфоксом и знал его только с хорошей стороны. Продуманное, легкое для понимания АПИ, огромное количество запущенных проектов, работа на линуксах. Вобщем, победил Смартфокс. Мы даже набросали демку, чтобы протестировать сервер, но из офиса удалось запустить только ~700 клиентов, после чего офисная сеть уже начинала потихоньку помирать, а сервер нагрузки почти не ощущал. В связи с этим у меня вопрос — кто нибудь занимался нагрузочным тестированием игровых серверов? Если да, то как тестировали?

Ну, да пойдем дальше. На начало создания демки программистов в команде было трое: я, главный серверщик, и широко известный в узких кругах Neodrop (основатель русского интернет сообщества по Unity3d и сайта unity3d.ru). После того, как серверным движком был выбран Смартфокс, наш серверщик ушел. Так что серверным программистом начал притворяться я.
За полтора месяца препродакшена, получилось сбацать демку, в которой:
1. Есть гараж, с общим чатиком.
2. Можно запускать бой на 2 команды.
3. Каждый игрок катается на 1й из 4х машинок:
разведчик, тяжелый броневик, артиллерия или инжинер.Все это бодро ездило, переворачивалось, стреляло и убивало друг друга. Ах да, была еще система видимости наподобие той, что в World of Tanks, но не настолько крутая, конечно же.

Забегая вперед, стоит отметить следущий факт — создание подобной демки заняло менее полутора месяцев чистого времени. В последущем, когда для продакшена мы решили все переписать с нуля, чтобы код был почище, да и вообще стали возможными все необходимые фичи, по типу смены модулей и оборудования, выйти на уровень играбельности подобной демки мы смогли только месяца через 2-3. Притом, что над программой работало уже не 2 человека, а 6-7. Так получилось из-за того, что в нашей демке было нельзя в рантайме менять модули и оборудование, а в конечном продукте эта возможность должна быть. Вот и получается, что одна фича может увеличивать трудозатраты в разы. (Хотя, конечно, не только она одна. В конечном продукте все что можно и нельзя должно управляться через админку, а это тоже еще та веселуха.)

Что-то получилось больно много лирики, попробую добавить чуть больше технических подробностей. В ходе проекта всплыло довольно приличное количество проблем и вопросов, которые малоактуальны или практически неактуальны для мелких проектов, но в проектах покрупнее их необходмо решать, чтобы работа не тормозилась.

Самая первая проблема с которой мы столкнулись — протокол общения клиента с сервером. В смартфоксе все устроено следущим образом:
1. клиенский код подписывается на событие типа

smartFox.AddEventListener(SFSEvent.EXTENSION_RESPONSE, OnExtensionResponse);


2. По вызову команды с сервера, у всех подписчиков на клиенте дерагается метод, в который передаются параметры с сервера. Параметры с сервера передаются в виде строки-названия команды и некоего SFSObject, который есть аналог хэштаблицы и содержит в себе все данные, которые сервер отправил нам на клиент.

Следовательно, чтобы выполнить команду с сервера, мы должны в нужном месте подписаться на серверные сообщения, а внутри метода проверить — нужная ли это для нас команда или нет, и если это нужная команда, достать из SFSObject все нужные данные и наконец-то выполнить команду. Естественно, каждый раз делать это ручками совершенно не хотелось. Самое простое, что пришло в голову сразу — подписать один единсвтенный игровой контроллер на это событие, а изнего уже создавать событие, которое в явном виде содердит отедльно команду, отдельно параметры. Получилось нечто вроде:

public class SFSExtensionsController : MonoBehaviour
{
public delegate void ExtensionResponceDelegate(string cmd, SFSObject parameters);
    public static event ExtensionResponceDelegate onExtensionResponce = null;


    void Start()
    {
        smartFox.AddEventListener(SFSEvent.EXTENSION_RESPONSE, OnExtensionResponse);
    }

    static void OnExtensionResponse(BaseEvent evt)
    {
        string cmd = (string)evt.Params["cmd"];

        SFSObject sfsParameters = (SFSObject)evt.Params["params"];

        if (onExtensionResponce != null)
        {            
            onExtensionResponce(cmd, sfsParameters);            
        }
     }   
 }


Для начала неплохо, но проблема с парсингом параметров в каждом получателе все равно остается. Посему следущее, что было сделано — введены контроллеры, которые подписываются на наше событие onExtensionResponce, внутри себя перехватывают логически цельный кусок команд, например отвественных только за перемещение, или только за стрельбу, а наружу из себя выдают события с данными готовыми для игровой логики.
Плюсы у такого решения есть — внутри игровой логики не надо каждый раз проверять SFSObject на наличие нужных полей. Но и минусов достаточно — если событие узко специализированное и подписчик на него будет только один — целый класс ради этого городить совсем не хочется. Более того, когда таких контроллеров станет много — как найти тот, в котором находится нужное событие? А еще, не забываем, что автоматического сериализатора в SFSObject нет, и каждый класс пока что приходится сериализовать вручную, прописывая нужный код и на клиенте и на сервере. (Да, в смартфоксе есть методы, которые должны сериализовать произвольные классы в SFSObject, но что-то данный функционал у нас не взлетел, а разбираться времени не было.) Так же необходимо ручками поддерживать идентичность строковых названий команд на клиенте и на сервере. Не дай бог, кто-то где-то опечатается. Вот и получается, что несмотря на мощь и продуманность Смартфокса, просто так взять и использовать его в свое удовольствие в проекте, которым занимается больше одного человека нельзя.

Но… при всех своих недостатках, описанная выше система выполнила свою задачу и демка прекрасно работала и на ней. Однако это место было самым первым на переделку в продакшн стадии. Задач, которые престояло решить, было несколько:

1. Идентичность команд в протоколе обмена данными, и так же структуры SFSObject’ов, которые гоняются между клиентом и сервером.
2. Автоматическая сериализация/десериализация данных игровой логики.
3. Удобная отправка/прием сообщений из классов, которые обслуживают игровую логику.

Пойдем не по порядку.

Пункт второй: автоматическая сериализация/десериализация. Казалось бы, что может быть тривиальней — используй рефлекшн, записывай всю необходимую дополнительную информацию, вроде названия класса, в SFSObject и будет счастье. Но наше счастье в данном случае будет совсем не полным, потому что игрушка реалтаймовая, с большим потоком постоянно изменяющихся данных, таких как позиция машинки, скорость, поворот башни и т.дп. и т.п. Многие из этих данных отсылаются между сервером и клиентом несколько раз в секунду на каждого игрока, а игроков планируется 32 на комнату, а комнат таких надо, минимум 50 на сервер, чтобы проект не разорился на серверх, поэтому решение в лоб не подходит. И тут на помощь нам пришла следущая идея. Код упаковки/распаковки игровых данных в SFSObject тривиален и этот код сможет написать за нас даже программа. Итого, была написана утилита, которая на вход принимает иксм-файл в заданном формате, а на выходе выдает два файлика с исходниками, один на C# для Юнити, второй на java для Смартфокса. Полученные файлики содержат в себе классы, с двумя методами SFSObject ToSFSObject() и void FromSFSObject(SFSObject sfsObject).

Пример класса, сгенерированного программно для C#

public class StartMultipleArtilleryShootProxy : ISFSSerializable
{

	public int userId { get; set; }
	public long barrelId { get; set; }
	public long projectile { get; set; }
	public Vec3fProxy position { get; set; }
	public List<Vec3fProxy> direction { get; set; }
	public float startSpeed { get; set; }
	
	//----------------------------------------------------------------

	public StartMultipleArtilleryShootProxy() {}

	public StartMultipleArtilleryShootProxy(ISFSObject obj)
	{
		FromSFSObject(obj);
	}

	public StartMultipleArtilleryShootProxy(int userId, long barrelId, long projectile, Vec3fProxy position, List<Vec3fProxy> direction, float startSpeed)
	{
		this.userId = userId;
		this.barrelId = barrelId;
		this.projectile = projectile;
		this.position = position;
		this.direction = direction;
		this.startSpeed = startSpeed;
	}

	//----------------------------------------------------------------

	public void FromSFSObject(ISFSObject obj)
	{
		this.userId = obj.GetInt("ui");
		this.barrelId = obj.GetLong("bi");
		this.projectile = obj.GetLong("pr");
		this.position = new Vec3fProxy(obj.GetByteArray("p").Bytes);
		this.direction = new List<Vec3fProxy>();
		ISFSArray direction_array = obj.GetSFSArray("d");
		for (int i = 0; i < direction_array.Size(); i++)
			this.direction.Add( new Vec3fProxy(direction_array.GetByteArray(i).Bytes));
		this.startSpeed = obj.GetFloat("s");
	}

	//----------------------------------------------------------------

	public SFSObject ToSFSObject()
	{
		SFSObject obj = new SFSObject();

		obj.PutInt("ui", userId);
		obj.PutLong("bi", barrelId);
		obj.PutLong("pr", projectile);
		obj.PutByteArray("p", new ByteArray(position.ToBytes()));
		if (this.direction == null) Debug.LogError("direction == null in ToSFSObject()");
		ISFSArray direction_array = new SFSArray();
		for (int i = 0; i < this.direction.Count; i++)
			direction_array.AddByteArray(new ByteArray(direction[i].ToBytes()));
		obj.PutSFSArray("d", direction_array);
		obj.PutFloat("s", startSpeed);

		return obj;
	}

}


Запись про этот класс в иксмл файлике:

<StartMultipleArtilleryShootProxy userId-ui="int" barrelId-bi="long" projectile-pr="long" position-p="Vec3fProxy" direction-d="Vec3fProxy array" startSpeed-s="float"/>


При изменении протокола меняем иксмл-файлик, запускаем утилиту, получаем код, который умеет быстро сериализовать/десериализовать SFSObject в нужные нам классы. Подменяем файлик на клиенте и на сервере и все, готово!

Пункт первый было бы логично решить, развивая формат иксмельки, и храня там не только данные, которые будут передаваться между клиентом и сервером, но и названия команд. Но… сроки как вегда горят, заниматься подобными вещами на данный момент некогда. Так что проблему с опечатками в названиях команд частично решили в процессе решения пункта 3.

Идея центрального контроллера по приему сообщений от сервера была не так уж и плоха, поэтому данный контроллер было решено оставить. Кроме того, было решено на каждое событие с сервера создать свой небольшой класс, который точно знает, какую команду он обрабатывает, и какие данные выдает наружу. Все подобные классы мы можем запихнуть в одно место, красиво их назвать и инициализировать на старте приложения. Таким образом клиентской логике знать ничего не надо про всякие смартфоксы, достаточно обращаться к хранилищу команд:

SFSProtocol.Protocol.BATTLE.HitRivalRequest.onGetResponce += OnGetHitRivalResponce;


а обработчик события в свою очередь принимает уже строго типизированные данные необходимые для игровой логики:

void OnGetHitRivalResponce(HitResultProxy hitResultProxy)
{
	// много полезного кода
}


И теперь чуть-чуть магии, которая основана на том, что Юнити пропагандирует однопоточный код внутри себя, и события от сервера вызываются тоже в одном потоке. Благодаря этой однопоточности, мы можем в нашем единственном контроллере, который общается непосредственно со смартфоксом проверять — обработал ли кто-нибудь команду с сервера, или нет, и если никто не обработал, то сразу об этом кричать в логи. Выглядит это до тривиального просто:

cmdWasCatched = false;
if (onExtensionResponce != null)
{            
   onExtensionResponce(cmd, sfsParameters);            
}
        
if (!cmdWasCatched)
{
   Debug.LogError(string.Format("SFSExtensionsController.OnExtensionResponse cmd={0} was not catched!", cmd));
}


Кроме этого, подобная архитектура позволила сделать совершенно необходимые штуки для клиент-серверного общения: проверка таймаута на ответ от сервера, добавление дополнительных служебных полей, помимо данных игровой логики (например, флаг, успешно или нет обработан запрос от клиента, и если неуспешно — описание ошибки с сервера.)

В завершение статьи стоит упомянуть еще одни кграбли, на которые мы наступили. Чтобы упросить создание новых команд в протоколе, был сделан параметризуемый класс, у которого мы можем указать тип данных, которые должны отправляться на сервер, тип данных, которые должны принматься с сервера, ну и конструктор впилить. Код, который десериализовывал объекты внутри этого класса выглядел так:

public class SFSRequest<TReqData, TRespData> : DataTransporter
        where TReqData : ISFSSerializable, new()
        where TRespData : ISFSSerializable, new()
{
…..........

   protected override void Execute(Sfs2X.Entities.Data.SFSObject sfsParameters)
   {
        responceData = new TRespData();            
        responceData.FromSFSObject(sfsParameters);

	OnGetResponce();
   }
}


И вроде бы все замечательно, но есть одно большое но: в данном случае конструктор new TRespData() будет использовать рефлекшн. В итоге, с чем боролись, на то и напоролись: рефлекшн все равно используется, и уже при 8 игроках в бою заметны притормаживания. Чтобы исправить ситуацию в класс-холдер команды было добавлено 2 поля для делегатов, которые делают единственную вещь — создают объекты TRespData и TReqData. Так что, базовый класс обработчик стал выглядеть так:

protected override void Execute(Sfs2X.Entities.Data.SFSObject sfsParameters)
{
responceData = respDataConstructor();   
responceData.FromSFSObject(sfsParameters);
onGetResponce();
}


А конечный класс для конкретной команды так:

public class MultipleArtilleryShootStartRequest : SFSRequest<StartMultipleArtilleryShootProxy, StartMultipleArtilleryShootProxy>
{
    public MultipleArtilleryShootStartRequest(string sendCommand, string receiveCommand, 
		Func<StartMultipleArtilleryShootProxy> reqDataConstructor, 
		Func<StartMultipleArtilleryShootProxy> respDataConstructor) :
        base(sendCommand, receiveCommand, reqDataConstructor, respDataConstructor) 
    {
    }
}


Кстати, найти данный косяк помогла статья с хабра, ссылку на которую я к сожалению потерял.

Вот такими, не очень сложными методами, получилось сделать достаточно удобное клиент-серверное общение. Если у кого-то есть вопросы или замечания — велкам в комментарии. Для тех, кто дочитал статью до конца, небольшая плюшка — сводная таблица параметров игровых движков, откопанная на просторах интернета.
Теги:
Хабы:
+20
Комментарии 47
Комментарии Комментарии 47

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн