Pull to refresh

Архитектурные решения для мобильной игры. Часть 2: Command и их очереди

Reading time23 min
Views7.8K


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

Архитектурные решения для мобильной игры. Часть 1: Model
Архитектурные решения для мобильной игры. Часть 3: View на реактивной тяге

Почему Command


Паттерн Command громко звучит, а по сути это просто объект, в который складывается и хранится там всё, необходимое для запрашиваемой операции. Мы выбираем именно такой подход, как минимум потому что у нас команды будут по сети отсылаться, да ещё мы себе несколько копий геймстейта заведём, для служебной необходимости. Так что когда пользователь нажимает кнопочку, создаётся экземпляр класса команды и отправляется получателю. Значение буковки C в аббревиатуре MVC у нас несколько отличается.

Предсказание результата и верификация команд по сети


В данном случае конкретный код менее важен, чем идея. А идея вот какая:

Уважающая себя игра не может ожидать ответа от сервера прежде чем отреагировать на кнопку. Конечно интернет всё лучше и у вас может быть туча серверов по всему миру, и я знаю даже пару успешных игры, ждущих ответа от сервера, одна из них даже Summoning Wars, но всё-таки делать так не нужно. Потому что для мобильного интернета лаги по 5-15 секунд скорее норма чем исключение, в Москве по крайней мере, и игра должна быть действительно великолепной чтобы игроки не обращали на это внимания.

Соответственно мы имеем геймстейт, представляющий всю необходимую интерфейсу информацию, и команды применяются к нему сразу же, и только после этого отсылаются на сервер. Обычно на сервере сидят трудолюбивые java-программисты дублирующие весь новый функционал один в один на другом языке. На нашем проекте «оленей» их количество доходило до 3 человек, а допускаемые при портировании ошибки были постоянным источником трудноуловимой радости. Вместо этого мы можем сделать по другому. Запускаем на сервере .Net и запускаем на серверной стороне тот же код команд, что и на клиенте.

Описанная в прошлой статье модель дает нам новую интересную возможность для самопроверки. После выполнении команды на клиенте, мы посчитаем hash произошедшего в дереве GameState изменения, и прикладываем его к команде. Если сервер выполнит тот же код команды, а хэш произошедших изменений не совпадёт значит что-то пошло не так.

Сначала преимущества:

  • Такое решение сильно ускоряет разработку и позволяет минимизировать число серверных программистов.
  • Если программист допустил ошибки, приводящие к не детерминированному поведению, например достал из Dictionary первое значение, или воспользовался DateTime.now, и вообще воспользовался какими-то значениями не записанными в полях команды в явном виде, то при запуске на сервере hash не совпадут, и мы об этом узнаем.
  • Разработку клиента можно до поры до времени вести без сервера вообще. Можно даже в дружественную альфу выйти не имея сервера. Это полезно не только для инди-разработчиков, прогающих игру своей мечты по ночам. В бытность мою в Пиксонике был случай, когда серверный программист протерял все полимеры, и наша игра вынужденна была пройти премодерацию, имея вместо сервера затычку тупо сторящую весь геймстейт раз в какое-то время.

Недостаток который почему-то систематически недооценивается:

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

Подробная отладочная информация


Один из наших заявленных приоритетов — удобство отладки. Если в процессе выполнения команды мы словили эксепшен — всё понятно, мы откатываем геймстейт, шлём в логи полный стейт и к нему сериализацию уронившей его команды, всё удобно и прекрасно. Ситуация сложнее если у нас произошла рассинхронизация с сервером. Потому что на клиенте уже выполнено с тех пор несколько других команд, и оказывается не просто узнать в каком состоянии была модель до выполнения команды, приведшей к катастрофе, а очень хочется. Клонировать геймстейт перед каждой командой — слишком сложно и дорого. Для решения проблемы усложним схему зашитую под капот движка.

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

  • Среди параметров команды могут иметься не только простые типы, но и ссылки на модели. В другом геймстейте на точно том же месте находятся другие объекты модели. Решаем эту проблему следующим способом: Перед тем как команда будет выполнена на клиенте мы сериализуем все её данные. Среди них могут быть и ссылки на модели, которые мы запишем в виде Path до модели из корня геймстейта. Делаем это перед командой, потому что после её выполнения пути могут поменяться. Дальше мы отправляем этот путь на сервер, и серверный геймстейт сможет по пути получить ссылку на свою модель. Точно так же когда команда будет применяться ко второму геймстейту модель может быть получена из второго геймстейта.
  • Кроме элементарных типов и моделей команда может иметь ссылки на коллекции. Dictionary<key, Model>, Dictionary<Model,key>, List<Model>, List<Value>. Для всех для них придётся написать сериализаторы. Правда можно с этим не торопиться, в реальном проекта такие поля возникают удивительно редко.
  • Отправлять на сервер команды по одной — не очень хорошая идея, потому что пользователь может их продуцировать быстрее, чем интернет сможет их туда-сюда таскать, на плохом интернете пул не отработанных сервером команд будет расти. Вместо того чтобы отправлять команды по одной будем отправлять их пакетами по нескольку штук. В этом случае получив от сервера ответ, что что-то пошло не так нужно будет сначала применить ко второму стейту все предыдущие команды из того же пакета, которые были подтверждены серваком, и только потом сторить и отправлять на сервер контрольный второй стейт.

Удобство и простота написания команд


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

namespace HexKingdoms {
	public class FCSetSideCostCommand : HexKingdomsCommand { // Выставить на какую сумму имеет право закупаться каждая из участвующих в битве сторон
		protected override bool DetaliedLog { get { return true; } }
		
		public FCMatchModel match;
		public int newCost;
		
		protected override void HexApply(HexKingdomsRoot root) {
			match.sideCost = newCost;
			match.CalculateAssignments();
			match.CalculateNextUnassignedPlayer();
		}
	}
}

А вот так выглядит лог, который оставляет после себя эта команда, если ей этот лог не отключить.

[FCSetSideCostCommand id=1 match=FCMatchModel[0] newCost=260] Execute:00:00:00.0027546 Apply:00:00:00.0008689
{	"LOCAL_PERSISTENTS":{
		"@changed":{
			"0":{"SIDE_COST":260},
			"1":{"POSSIBLE_COST":260},
			"2":{"POSSIBLE_COST":260}}}}

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

Если не считать обращений Persistent-объектам по Id-шникам, сильно снижающим читабильность лога, которых, кстати, можно было тут избежать, и сам код команды, и лог что он сделал с геймстейтом удивительно понятны. Обратите внимание, что в тексте команды программист не делает ни одного лишнего движения. Всё необходимое делается движком под капотом.

Теперь посмотрим на пример более крупной команды

namespace HexKingdoms {
	public class FCSetUnitForPlayerCommand : HexKingdomsCommand { // Игрок выбирает себе количество одного из доступных для стартовой закупки юнитов
		protected override bool DetaliedLog { get { return true; } }

		public FCSelectArmyScreenModel screen;
		public string unit;
		public int count;

		protected override void HexApply(HexKingdomsRoot root) {
			if (count == 0 && screen.player.units.ContainsKey(unit)) {
				screen.player.units.Remove(unit);
				screen.selectedUnits.Remove(unit);
			} else if (count != 0) {
				if (screen.player.units.ContainsKey(unit)) {
					screen.player.units[unit] = count;
					screen.selectedUnits[unit].count = count;
				} else {
					screen.player.units.Add(unit, count);
					screen.selectedUnits[unit] = new ReferenceUnitModel() { type = unit, count = count };
				}
			}
			screen.SetSelectedReferenceUnits();
			screen.player.CalculateUnitsCost();
			var side = screen.match.sides[screen.side];
			screen.match.CalculatePlayerAssignmentsAcceptablity(side);
			screen.match.CalculateNextUnassignedPlayer(screen.player);
		}
	}
}

А вот и лог, который оставила после себя команда:

[FCSetUnitForPlayerCommand id=3 screen=/UI_SCREENS[main] unit=militia count=1] Execute:00:00:00.0065625 Apply:00:00:00.0004573
{	"LOCAL_PERSISTENTS":{
		"@changed":{
			"2":{
				"UNITS":{
					"@set":{"militia":1}},
				"ASSIGNED":7}}},
	"UI_SCREENS":{
		"@changed":{
			"main":{
				"SELECTED_UNITS":{
					"@set":{
						"militia":{"@new":null, "TYPE":"militia", "REMARK":null, "COUNT":1, "SELECTED":false, "DISABLED":false, "HIGHLIGHT_GREEN":false, "HIGHLIGHT_RED":false, "BUTTON_ENABLED":false}}}}}}}

Как говорится, куда уж понятнее. На жалейте времени на то чтобы снабдить команду удобным компактным и информативным логом. Это залог вашего счастья. Модель обязана работать очень быстро, поэтому там мы применяли разнообразные ухищрения со способами хранения и доступа к полям. Команды исполняются в самом страшном случае раз в кадр, на самом деле в несколько раз реже, поэтому сериализацию и десериализацию полей команды мы сделаем без затей, просто через рефлекшен. Только отсортируем поля по именам, чтобы порядок был фиксированный, ну и список полей будем составлять один раз за жизнь команды, а чтение-запись родными методами C#.

Модель информации для интерфейса.


Сделаем следующий шаг в усложнении нашего движка, шаг, который выглядит страшновато, но очень сильно упрощает написание и отладку интерфейсов. Очень часто, особенно в родственном паттерне MVP модель содержит только контролируемую сервером бизнес-логику, а информация о состоянии интерфейса сохраняется внутри презентера. Например хотите вы заказать пять билетов. Вы уже выбрали их количество, но ещё не нажали кнопку «заказать». Информация о том, сколько именно билетов вы выбрали в формочке, может храниться где-то в тайных уголках класса, служащего прокладкой между моделью и её отображением. Или, например, игрок переходит с одного экрана на другой, а в модели ничего не меняется, и где он находился когда случилась трагедия программист занимающийся отладкой знает только со слов чрезвычайно дисциплинированного тестера. Подход простой понятный, почти всегда использующийся и чуточку вредоносный, на мой взгляд. Потому что если что-то пошло не так, состояние этого Presenter-а, приведшее к ошибке узнать нет абсолютно никакой возможности. Особенно если ошибка произошла на боевом сервере при проведении операции на $1000, а не у тестера в контролируемых и воспроизводимых условиях.

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

  • (+1) Самое главное преимущество, экономящее человекомесяцы программистской работы — если что-то пошло не так программист просто загружает геймстейт перед аварией и получает в точности то же состояние не только бизнес-модели, но и всего интерфейса до самой последней кнопочки на экране.
  • (+2) Если какая-то команда что-то поменяла в интерфейса программист легко может пойти в лог и увидеть что именно изменилось в удобной json форме, как в предыдущем разделе.
  • (-1) В модели появляется куча лишней информации, которая не нужна для понимания бизнес логики игры и два раза не нужна серверу.

Чтобы решить эту проблему мы будем помечать некоторые поля как notServerVerified, выглядит это, например, вот так:

public EDictionary<string, UIStateModel> uiScreens { get { return UI_SCREENS.Get(this); } }
public static PDictionaryModel<string, UIStateModel> UI_SCREENS = new PDictionaryModel<string, UIStateModel>() { notServerVerified = true };

Эта часть модели и всё что ниже её будет относиться исключительно к клиенту.

Если вы ещё помните, флаги того, что нужно экспортировать, а что нет выглядят следующим образом:

[Flags]
public enum ExportMode {
	all = 0x0,
	changes = 0x1,
	serverVerified = 0x2
}

Соответственно при экспорте или вычисления хэша можно указать нужно ли экспортировать все дерево или только ту его часть, которая проверяется сервером.

Первое очевидное усложнение, которое отсюда проистекает — необходимость заводить отдельные команды которые нужно проверять сервером и те, которые не нужно, а есть ещё те, которые нужно проверять не целиком. Чтобы не загружать программиста лишними операциями по настройке команды мы опять таки постараемся сделать всё необходимое прод капотом движка.

public partial class Command {
	/** <summary> Здесь делаем только изменения, затрагивающие проверяемую сервером часть модели </summary> */
	public virtual void Apply(ModelRoot root) {}
	/** <summary> Здесь делаем только изменения остающиеся исключительно на клиенте </summary> */
	public virtual void ApplyClientSide(ModelRoot root) {}
}

Программист создающий команду может переопределить одну или обе этих функции. Всё это, конечно, прекрасно, но как убедиться, что программист ничего не напутал, а если что-то напутал — как ему помочь это быстро и легко исправить? Тут есть два способа. Я применил первый, но вам может больше понравиться второй.

Первый способ


Воспользуемся крутыми свойствами нашей модели:

  1. Движок вызывает первую функцию, после этого получает хэш изменений в проверяемой сервером части геймстейта. Если изменений нет, значит мы имеем дело и исключительно клиентской командой.
  2. Получаем у модели хэш изменений во всей модели, не только сервер-проверяемой. Если он отличается от предыдущего хэша, значит программист накосячил, и поменял что-то в непроверяемой сервером части модели. Обходим дерево стейта и вываливаем программисту в виде эксепшена полный список полей notServerVerified = true и лежащих ниже по дереву, которые он поменял.
  3. Вызываем вторую функцию. Получаем у модели хэш произошедших в проверяемой части изменений. Если он не совпадает с хэшом после первого вызова значит во второй функции программист наделал всякого. Если мы хотим получить в этом случае очень информативный лог мы откатываем всю модель в изначальное состояние, сериализуем его в файл, программисту потом для отладки пригодится, дальше клонируем его целиком (две строчки — сериализация-десериализация), и уже теперь к клону применяем сначала первую функцию, затем фиксируем изменения, чтобы модель выглядела неизменной, после чего применяем вторую функцию. А дальше экспортируем все изменения в проверяемой сервером части в виде JSON-а и включаем его в состав ругательного эксепшена, чтобы пристыженному программисту было сразу видно что и где он поменял, что менять не следовало.

Выглядит, конечно, страшновато, но на самом деле это 7 строчек, потому что функции, которые делают вот это вот всё (кроме обхода дерева из второго пункта) у нас уже готовы. А так как это эксепшен, мы можем позволить себе действовать не оптимально.

Второй способ


Чуть более брутален, сейчас у нас в ModelRoot одно поле lock, но мы можем его разделить на два, одно будет лочить только сервер проверяемые поля, другое только не сервер проверяемые. В этом случае программист сделавший что-то не так получит об этом эксепшен сразу с колстеком до того места где он это сделал. Единственный недостаток такого подхода в том, что если у нас в дереве одна модель-свойство помечена как не проверяемая, то и всё что в дереве расположено ниже её про подсчёте хэшей и контроле изменений осматриваться не будет, даже если каждое поле помечено не было. А лок, конечно в иерархию заглядывать не станет, а значит помечать придётся все поля непроверяемой части дерева, и не получится кое где использовать одинаковые классы в UI и обычной части дерева. Как вариант возможна такая конструкция (запишу её упрощённо):

public class GameState : Model {
    public RootModelData data;
    public RootModelLocal local;
}
public class RootModel {
    public bool locked { get; }
}

Тогда получается, что у каждого поддерева свой лок. GameState наследует модели, потому что проще так, чем придумывать для него отдельную реализацию всего того же функционала.

Необходимые доработки


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

В реализации ничего принципиально сложного нет. Просто у свойства каждого поля модели появляется ещё одно условие, обхода дерева.

Ещё одна необходимая доработка — вам потребуется отдельные Factory для ParsistentModel в проверяемой и не проверяемой части дерева и NextFreeId для них будут разные.

Команды инициированные со стороны сервера


Возникает некоторая проблема если сервер захочет протолкнуть клиенту свою команду, потому что клиентское состояние относительно серверного могло уже ускакать на несколько шагов вперёд. Основная идея состоит в том, что если серверу потребовалось отправить свою команду, он с очередным ответом отправляет серверное уведомление на клиент, и записывает его себе в поле для отправленных данному клиенту уведомлений. Клиент получает уведомление, формирует на его основе команду и ставит её в конец своей очереди, после тех, которые на клиенте успели выполниться, но до сервера ещё не дошли. Через какое-то время команда отправляется серверу уже в рамках обычного процесса работы с моделью. Получив эту команду на обработку сервер выкидывает уведомление из очереди исходящих. Если клиент не среагировал на уведомление за установленное время с очередным пакетом к нему уходит команда на перезагрузку. Если клиент получивший уведомление отвалился, коннектится позже или ещё по какой-то причине загружает игру, то сервер прежде чем отдать ему стейт превратит все уведомления в команды, выполнит их на своей стороне, и только после этого отдаст присоединяющемуся клиенту его новое состояние. Обратите внимание на то, что может возникнуть конфликтное состояние игрока с отрицательными ресурсами, когда игрок успел потратить деньги ровно в тот момент когда сервер у него их отнял. Совпадение маловероятное, но при большом ДАУ практически неизбежное. Поэтому интерфейс и игровые правила не должны валиться на смерть в такой ситуации.

Команды для выполнения которых необходимо знать ответ сервера


Типичная ошибка заключается в том, чтобы думать, что случайное число может быть получено только от сервера. Ничего не мешает вам иметь одинаковый, синхронно срабатывающий на клиенте и на сервере генератор псевдослучайных чисел, запускающийся с общего сида. Более того, текущий сид может храниться прямо в геймстейте. Некоторым покажется проблемным синхронизировать срабатывания этого генератора. На самом деле для этого достаточно иметь там же в стейте ещё одно число — сколько именно чисел было получено от генератора до данного момента. Если у вас генератор почему-то не сходится значит у вас где-то ошибка и код работает не детерминировано. И этот факт надо не под ковёр прятать, а разбираться и искать ошибку. Для подавляющего большинства случаев, включая даже мистерибоксы, такого подхода оказывается достаточно.

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

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

Хранение данных на сервере и мутная тема серверной оптимизации


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

Сначала про хранение данных. Именно ваша серверная часть может иметь дополнительные ограничения. Например, у вас может появиться запрет на использование статических полей. Далее автопортируемым является код команд и моделей, но совершенно не обязательно должны совпадать код Property на клиенте и на сервере. Там может быть спрятано что угодно, вплоть до ленивой инициализации значений полей из мемкэша, например. Поля Property также могут получать дополнительные параметры, которые используются сервером, но никак не влияют на работу клиента.

Первое кардинальное отличие сервера: куда сериализуются и десериализуются поля. Разумное решение состоит в том, что большая часть дерева состояния сериализуется в одно громадное бинарное или json поле. В то же время некоторые поля забираются из таблиц. Это необходимо потому что значения некоторых полей будут постоянно необходимы для работы сервисов взаимодействия между игроками. Например, иконка и уровень постоянно дергаются самыми разными людьми. Их лучше держать в обычной базе. А полный или частичный, но подробный стейт какого-то человека будет нужен кому-то кроме него очень редко, когда кто-то решит заглянуть на его территорию.

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

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

Как команды делятся между серверами


Теперь второй важный для сервера момент. Существует два подхода. При первом для обработки любого запроса (или пачки запросов) весь стейт поднимается из базы или кэша в память, отрабатывается, после чего возвращается в базу. Операции отрабатываются атомарно на куче разных исполняющих серверов, а общая у них только база, и то не всегда. Меня как клиентщика поднятие всего стейта на каждую команду повергает в шок, но я своими глазами видел, как это работает, и работает очень надёжно и масштабируемо. Второй вариант заключается в том, что стейт один раз поднимается в память и живёт там пока клиент не отвалится лишь иногда складывая в базу своё текущее состояние. Я не компетентен рассказывать вам преимущества и недостатки того или другого метода. Будет хорошо если кто-нибудь в комментариях объяснит мне почему первый имеет право на жизнь вообще. Второй вариант порождает вопросы как взаимодействовать между игроками, волей случая оказавшимися поднятыми на разных серверах. Это может иметь критическое значение, например если взаимодействуют несколько соклановцев, готовящих совместную атаку. Нельзя показывать другим стейт его сопартийца с опозданием на 10 сохранений. К сожелению Америку я тут не открою, взаимодействие через описанные выше нотификейшены, команды с одного сервера на другой — прямо сейчас вне очереди сохранить текущее состояние поднятого там игрока. Если сервера имеют одинаковый уровень доступности из разных мест, и вы можете управлять балансировщиком можете попробовать тихонечко незаметно передать игрока с одного сервера на другой. Если знаете решение лучше — обязательно опишите в комментариях.

Пляски со временем


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

Лучшее известное мне решение позволяет узнать не точную разницу, а уточнить диапазон, в который она попадает через много запрос-ответов с точностью до времени лучшего пакета пробежавшего с клиента на сервер плюс время лучшего из пакетов пробежавших с сервера на клиент. В сумме это даст вам, несколько десятков миллисекунд точности. Это в разы лучше чем нужно для метаигры мобильной игры, тут у нас не VR мультиплеер или CS, но всё-таки неплохо чтобы программист представлял масштаб и природу сложностей с синхронизацией часов. Вам, вероятнее всего, будет достаточно знать среднее отставание взятое как пинг пополам, за долгое время с отсечением отклонений больше чем на 30%.

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

Третья, ситуация, почему-то представляет проблему для понимания некоторыми программистами, хотя существует её правильное решение: Операции решительно нельзя выполнять по серверному времени. Например запускать производство товара тогда, когда на сервер пришёл запрос на производство. Иначе поцелуйте свой детерминизм на прощание, и словите 35 тысяч рассинхронизаций в день вызванных разным мнением клиента и сервера о том можно ли уже скликивать награду. Правильное решение состоит в том, что в команду записывается информация о времени, когда она была исполнена. Сервер, в свою очередь, проверяет попадает ли разница во времени между текущим серверным временем и временем в команде в разрешённый интервал, и если попадает — исполняет команду со своей стороны используя заявленное клиентом время.
Ещё одна задачка для собеседования: Таймаут после которого клиент попытается перезагрузиться — 30 секунд. В каких границах тогда находится приемлемая для сервера временная разница? Подсказка №1: Интервал не симметричен. Подсказка №2: Перечитайте ещё раз первый абзац этого раздела, уточните как расширить интервал чтобы не словить 3000 ошибок в день на краевых эффектах.

Для того, чтобы это всё работало красиво и правильно лучше в параметры вызова команды в явном виде добавить дополнительный параметр — время вызова. Как-то так:

public interface Command {
	void Apply(ModelRoot root, long time);
}

И мой вам совет, кстати, не используйте родных типов Unity для времени в модели — нахлебаетесь. Лучше уж хранить UnixTime во времени сервера, всегда когда надо иметь под рукой самописные методы конверсии, и хранить их в модели в специальном поле PTime, отличающимся от PValue<long> только тем, что при экспорте в JSON добавляет в скобочках избыточную информацию не читающуюся при импорте: Время в человеко-читаемом формате. Можете меня не слушаться. Я вас предупредил.

Четвёртая ситуация: В игровом стейте есть ситуации, когда команда должна инициироваться без участия игрока, по времени, например, восстановление энергии. Очень частая ситуация, на самом деле. Хочется иметь поле, удобно это отрабатывающее. Например PTimeOut, в которое можно будет записать точку во времени после которой должна быть создана и выполнена команда. В коде это может выглядеть, например так:

public class MyModel : Model {
	public static PTimeOut RESTORE_ENERGY = new PTimeOut() {command = (model, property) => new RestoreEnergyCommand() { model = model}}
	public long restoreEnergy { get { return RESTORE_ENERGY.Get(this); } set { RESTORE_ENERGY.Set(this, value); }}
}

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

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

public partial class Model {
	public void SetCurrentTime(long time);
}
vs
public partial class RootModel {
	public event Action<long> setCurrentTime;
}

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

Приложение 1, типичный случай, взятый из реальной жизни


Вынимаю из комментариев к первой части. В игрушках очень часто некоторые действия происходят не сразу после команды в модель, а по окончании какой-нибудь анимации. У нас в практике был такой случай, когда открываются мистери боксы, и конечно деньги должны поменяться только когда анимация доиграется до конца. Один из наших разработчиков решил упростить себе жизнь, и по команде значение не менять, но серверу говорить, что изменил, а по концу анимации запускать callback, который поправит значения в модели на нужные. Молодец, короче. Две недели он эти мистерибоксы делал, а потом у нас ещё трижды всплывали очень трудно уловимые ошибки, ставшие результатом его деятельности и на их ловлю пришлось истратить ещё три недели по итогу, при том, что времени на «переписать по нормальному» уже, конечно, никто выделить не мог. Из чего ярко следует, как мне кажется, вывод что лучше нужно было с самого начала сделать всё с нормальной реактивностью.

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

public class OpenMisterBox : Command {
    public BoxItemModel item;
    public int slot;
    // Эта часть команды выполняется и на сервере тоже, и проверяется.
    public override void Apply(GameState state) {
        state.inventory[item.revardKey] += item.revardCount;
    }
    // Эта часть команды выполняется только на клиенте.
    public override void Apply(GameState state) {
        var cause = state.NewPersistent<WaitForCommand>();
        cause.key = item.key;
        cause.value = item.value;
        state.ui.delayedInventoryVisualization.Add(cause);
        state.ui.mysteryBoxScreen.animations.Add(new Animation() {cause = item, slot = slot}));
    }
}
public class MysteryBoxView : View {
    /* ... */
    public override void ConnectModel(MysteryBoxScreenModel model, List<Control> c) {
        model.Get(c, MysteryBoxScreenModel.ANIMATIONS)
            .Control(c, 
                onAdd = item => animationFactory(item, OnComleteOrAbort => { AsincQueue(new RemoveAnimation() {cause = item.cause, animation = item}) }),
                onRemove = item => {}
            )
    }
}

public class InventoryView : View<InventoryItem> {
    public Text text;
    public override void ConnectModel(InventoryItem model, List<Control> c) {
        model.GameState.ui.Get(c, UIModel.DELAYED_INVENTORY_VISUALIZATION).
            .Where(c, item => item.key == model.key)
            .Expression(c, onChange = (IList<InventoryItem> list) => {
                int sum = 0;
                for (int i = 0; i < list.Count; i++)
                    sum += list[i].value;
                return sum;
            }, onAdd = null, onRemove = null ) // Чисто ради показать сигнатуру метода
            .Join (c, model.GameState.Get(GameState.INVENTORY).ItemByKey(model.key))
            .Expression(c, (delay, count) => count - delay)
            .SetText(c, text);
        // Здесь я написал полный код, но в реальности это операция типовая, поэтому для неё, конечно же, существует функция обёртка, которая дёргается в проекте во всех случаях, выглядит её вызов вот так:
        model.inventory.CreateVisibleInventoryItemCount(c, model.key).SetText(c, text);
    }
}
public class RemoveDelayedInventoryVisualization : Command {
    public DelayCauseModel cause;
    public override void Apply(GameState state) {
        state.ui.delayedInventoryVisualization.Remove(cause);
    }
}
public class RemoveAnimation : RemoveDelayedInventoryVisualization {
    public Animation animation
    public override void Apply(GameState state) {
        base.Apply(state);
		state.ui.mysteryBoxScreen.animations.Remove(animation);
    }
}

Что мы имеем в итоге? Есть два View, в одном из них играется некая анимация, окончания которой ждёт отображение денег в совсем другом вьюве, понятия не имеющем кто и зачем хочет чтобы показывалось другое значение. Всё реактивненько. В любой момент можно загрузить в игру полное состояние GameState и оно начнёт играться ровно с того места, на котором мы остановились, в том числе и анимация запустится. Правда запустится с начала, потому что мы этап анимации не сторим, но если очень нужно, сторить можем даже его.

Итого


Оформление бизнес-логики игры через модели, команды и статические файлы с правилами, обкладывание их со всех сторон красивыми подробными и автоматически генерящимися логами и огораживание информативными эксепшенами многих типичных ошибок, совершаемых программистом пилящим новые фичи, это, на мой взгляд, правильный способ жить на белом свете. И не только потому что вы сможете в несколько раз быстрее запиливать новый функционал. Это ещё чертовски важно, потому, что если вам легко нафигачить и отладить новую фичу значит геймдизайнер успеет поставить в несколько раз больше экспериментов над геймдизаном при тех же прогерах. При всём уважении к нашему труду от нас зависит только провалится игра или нет, а вот выстрелит она или нет зависит от геймдизов, и им надо дать пространство для экспериментов.
А сейчас прошу ответить на очень важные для меня вопросы. Если есть идеи как сделать то, что у меня сделано плохо, или просто хочется прокомментировать свои ответы — жду вас в комментах. Предложение о сотрудничестве и указания на многочисленные синтаксические ошибки просьба в личку.
Only registered users can participate in poll. Log in, please.
Считаете ли вы необходимым предиктить ответы сервера?
40% Да, я клиентщик и это необходимо.4
30% Да, я серверный программист, и это необходимо.3
20% Нет, я клиентской программист, и это не обязательно.2
10% Нет, я серверный программист, и это не обязательно.1
10 users voted. 16 users abstained.
Only registered users can participate in poll. Log in, please.
Нужно ли в проекте автопортирование команд на сервер?
44.44% Да, и мы работаем так или иначе в этом направлении.4
33.33% Да, но мы не имеем возможности этим заняться.3
22.22% Нет.2
9 users voted. 14 users abstained.
Only registered users can participate in poll. Log in, please.
Поддерживаете ли вы полное хранение информации об интерфейсе в модели?
50% Да, удобство отладки важнее!5
50% Нет, загромождение модели и/или лишнее время на поддержание строгой архитектуры там, где это не нужно.5
10 users voted. 13 users abstained.
Only registered users can participate in poll. Log in, please.
Один сервер для игрока или атомарные операции
72.73% Геймстейт загружается в память одного из серверов, и клиент всегда работает с ним.8
27.27% Геймстейт поднимается перед каждой атомарной операцией, пофигу на каком серваке.3
11 users voted. 13 users abstained.
Only registered users can participate in poll. Log in, please.
Решение для команд, инициируемых с сервера устраивает?
55.56% Да, я думаю, эти решения покроют почти всё, что мне может пригодиться.5
44.44% У меня есть задачи, которые такими способами не решаются, или решаются неприемлемо сложно.4
9 users voted. 15 users abstained.
Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments6

Articles