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

Итак, в ноябре прошлого года наша команда начала делать клиентскую сессионную ммошечку — катайся на машинах, стреляй врагов. Надо сказать, что у команды уже был опыт не успешного проекта на юнити, это были 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) 
    {
    }
}


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

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