Эпиграф:
— Как я тебе оценю, если неизвестно что делать?
— Ну там будут экранчики и кнопочки.
— Дима, ты сейчас всю мою жизнь описал в трёх словах!
(с) Реальный диалог на митинге в игровой компании




Набор потребностей и отвечающих им решений, о которых я поговорю в этой статье, сформировался у меня в ходе участия примерно в десятке крупных проектов сначала на Flash, а позже на Unity. Самый крупный из проектов имел больше 200000 DAU и дополнил мою копилку новыми оригинальными вызовами. С другой стороны, подтвердилась уместность и нужность предыдущих находок.

В нашей суровой реальности каждый, кто хоть раз архитектурил крупный проект хотя бы в своих мыслях, имеет свои представления о том, как надо делать, и часто готов отстаивать свои идеи до последней капли крови. У окружающих это вызывает улыбку, а менеджмент часто смотрит на всё на это как на огромный чёрный ящик, который никому углом не упёрся. Но что если я скажу вам, что правильные решения помогут сократить создание нового функционала в 2-3 раза, поиск ошибок в старом в 5-10 раз, и позволят делать многие новые и важные вещи, которые раньше были вообще недоступны? Достаточно лишь впустить архитектуру в сердце своё!

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

Модель


Доступ к полям


Большинство программистов осознают важность использования чего-нибудь наподобие MVC. Мало кто использует чистый MVC из книжки банды четырёх, но все решения у нормальных контор так или иначе схожи с этим паттерном по духу. Сегодня мы поговорим про первую из буковок в этой аббревиатуре. Потому что большая по объёму часть работы программистов в мобильной игре это новые фичи в метаигре, реализуемые как манипуляции с моделью, и прикручивание к этим фичам тысяч интерфейсиков. И удобство модели играет в этом занятии ключевую роль.

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

public class PlayerModel {
    public int money;
    public InventoryModel inventory;

    /* Using */
    public void SomeTestChanges() {
        money = 10;
        inventory.capacity++;
    }
}

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

Воспользуемся неким своим классом ReactiveProperty<T> который будет прятать под капотом все манипуляции по рассылке сообщений, которые нам нужны. Получится примерно так:

public class PlayerModel : Model {
    public ReactiveProperty<int> money = new ReactiveProperty<int>();
    public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>();

    /* Using */
    public void SomeTestChanges() {
        money.Value = 10;
        inventory.Value.capacity.Value++;
    }
    public void Subscription(Text text) {
        money.SubscribeWithState(text, (x, t) => t.text = x.ToString());
    }
}

Это первый вариант модели. Такой вариант — уже мечта для многих программистов, но мне все ещё не нравится. Первое, что мне не нравится, что обращения к значениям осложнены. Я успел запутаться, пока писал этот пример, забыв в одном месте Value.А ведь именно эти манипуляции с данными составляют львиную часть всего, что с моделью делают и в чём путаются. Если вы пользуетесь версией языка 4.x можно делать так:

public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>();

но это решает далеко не все проблемы. Хотелось бы писать просто: inventory.capacity++;. Допустим мы попытаемся для каждого поля модели сделать get; set; Но для того чтобы подписываться на события нам потребуется ещё и доступ к самому ReactiveProperty. Явное неудобство и источник для путаницы. При том, что нам требуется только указать, за каким именно полем мы собираемся следить. И вот тут я придумал хитрый маневр, который мне понравился.

Посмотрим, понравится ли вам.

В конкретную модель, с которой имеет дело программист, пишущий правила, вставляется не сам ReactiveProperty, а его статический описатель PValue, наследник более общего Property, он идентифицирует поле, а внутри под капотом конструктора Model спрятано создание и хранение ReactiveProperty нужного типа. Не самое удачное название, но так сложилось, потом переименую.

В коде это выглядит так:

public class PlayerModel : Model {
    public static PValue<int> MONEY = new PValue<int>();
    public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } }

    public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>();
    public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } }

    /* Using */
    public void SomeTestChanges() {
        money = 10;
        inventory.capacity++;
    }
    public void Subscription(Text text) {
        this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString());
    }
}

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

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

Транзакции


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

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

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

public class PlayerModel : Model {
    public PlayerModel(ModelRoot gamestate) : base (gamestate) {}
}

Но для других возможностей моделей очень полезно, чтобы модель имела ссылку на родительскую модель, образуя двухсвязную конструкцию. В нашем примере это будет player.inventory.Parent == player. И тогда этого конструктора можно избежать. Любая модель сможет получить и закэшировать ссылку на волшебное место у своего родителя, а тот у своего родителя, и так пока очередной родитель не окажется тем самым волшебным местом. В итоге на уровне деклараций всё это будет выглядеть так:

public class ModelRoot : Model {
    public bool locked { get; private set; }
}
public partial class Model {
    public Model Parent { get; protected set; }
    public ModelRoot Root { get; }
}

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

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

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

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

public partial class Model {
    public void DispatchChanges(Command transaction);
    public void FixChanges();
    public void RevertChanges();
}

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

Информация о произведённых в модели изменениях


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

{"player":{"money":10, "inventory":{"capacity":11}}}

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

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

Сериализация произведённых в модели изменений


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

[Flags] public enum ExportMode {
    all = 0x0,
    changes = 0x1,
    serverVerified = 0x2, // Про это поговорим позже, когда затронем интерфейсы
}
/** более простая версия */
public partial class Model {
    public bool GetHashCode(ExportMode mode, out int code);
    public bool Import(BinaryReader binarySerialization);
    public bool Import(JSONReader json);
    public void ExportAll(ExportMode mode, BinaryWriter binarySerialization);
    public void ExportAll(ExportMode mode, JSONWriter json);
    public bool Export(ExportMode mode, out Dictionary<string, object> data);
}

Сигнатура метода Export(ExportMode mode, out Dictionary<string, object> data) несколько настораживает. А дело тут вот в чём: Когда вы сериализуете всё дерево писать можно сразу в поток, или в нашем случае в JSONWriter, являющийся простенькой надстройкой над StringWriter. Но когда вы экспортируете изменения не всё так просто, потому что когда вы обходя дерево в глубину заходите в одну из ветвей вы ещё не знаете нужно ли из неё экспортировать вообще хоть что-нибудь. Поэтому на этом этапе я придумал два решения, одно попроще, второе посложнее и поэкономнее. Более простое сводится к тому, что экспортируя только изменения вы превращаете все изменения в дерево из Dictionary<string, object> и List<object>. А потом то что получилось скармливаете своему любимому сериализатору. Это простой подход, не требующий плясок с бубном. Но его недостатком является то, что в процессе экспорта изменений в куче будет аллоцировано место под одноразовые коллекции. На самом деле не так уж много места, потому что это полный экспорт даёт большое дерево, а изменений в дереве типичная команда оставляет совсем не много.

Однако многие люди считают, что кормить Garbage Collector как того тролля, не нужно без крайней нужды. Для них, и для успокоения своей совести я подготовил более сложное решение:

/** более сложная версия */
public partial class Model {
    public void ExportAll(ExportMode mode, Type propertyType, JSONWriter writer, bool newModel = false);
    public bool DetectChanges(ExportMode mode, Stack<Model> ierarchyChanged = null);
    public void ExportChanges(ExportMode mode, Type propertyType, JSONWriter writer, Queue<Model> ierarchyChanges = null);
}

Суть этого способа в том, чтобы проходить по дереву два раза. Первый раз просмотреть все модели, изменившиеся сами, или имеющие изменения в дочерних моделях, и записать их все в Queue<Model> ierarchyChanges ровно в том порядке, в котором они встречаются в дереве в его нынешнем состоянии. Изменений не много, очередь будет не длинная. Кроме того, ничего не мешает сохранять Stack<Model> и Queue<Model> между вызовами и тогда в процессе вызова будет совсем мало аллокаций.

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

Весьма вероятно, что это усложнение, на самом деле, не нужно, потому что позже вы увидите, что экспорт изменений в дереве вам нужен только для отладки или при падении с Exception. При нормальной работе всё ограничивается GetHashCode(ExportMode mode, out int code) которому все эти изыски глубоко чужды.

Прежде чем продолжим усложнять нашу модель, поговорим вот о чём.

Почему это так важно


Все программисты говорят, что это страшно важно, но им обычно никто не верит. Почему?

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

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

В-третьих, даже если идея “как должно быть” сама по себе хорошая и идеальная неизвестно сколько займёт её реализация. Зависимость затрачиваемого времени от крутости программиста очень нелинейная. Простую задачу сеньёр сделает ненамного быстрее, чем джуниор. Раза в полтора, возможно. Но у каждого программиста есть свой собственный “предел сложности”, за которым его эффективность драматически падает. У меня был в жизни случай, когда мне нужно было реализовать довольно сложную архитектурную задачу, и даже полная концентрация на задаче с отключением интернета в доме и заказе готовой еды в течение месяца не помогла.Но двумя годами позже, начитавшись интересных книжек и нарешавшись смежных задачек, я решил эту проблему за три дня. Уверен каждый вспомнит что-то такое в своей карьере. И вот тут то и кроется подвох! Дело в том, что если вам в голову сама по себе пришла гениальная идея, как оно должно быть, то, вероятнее всего, эта новая идея находится где-то на вашем личном пределе сложности, а возможно даже чуть-чуть за ним. Менеджмент, неоднократно обжегшись на таком, начинает дуть на любые новые идеи. А если вы делаете игру сами для себя, результат может быть ещё страшнее, потому что некому будет вас остановить.

Но как же при всём при этом кому-то вообще удаётся использовать хорошие решения? Путей несколько.

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

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

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

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

Самая последняя, хоть и не самая очевидная причина: потому что это страшно выгодно. Хорошие решения приводят к кратному сокращению времени на написание новых фич, их отладку и ловлю ошибок. Приведу пример: двое суток назад у клиента произошел эксепшен в новой фиче, вероятность которого 1 из 1000, то есть QA воспроизвести это замучаются, а при вашем дау это 200 сообщений об ошибке в день. Сколько времени у вас уйдёт на то, чтобы воспроизвести ситуацию, и поймать клиент на брейкпоинте за строчку до того, как всё обрушится? У меня, например, 10 минут.

Модель


Дерево Моделей


Модель состоит из множества объектов. Разные программисты по-разному решают вопрос как их связать между собой. Первый способ – когда модель идентифицируется по тому месту, где она лежит. Это очень удобно и просто, когда ссылка на модель принадлежит одному единственному месту в ModelRoot. Возможно, она даже может перекладываться с места на место, но никогда на неё не ведёт две ссылки из разных мест. Мы сделаем это, введя новую разновидность описателя ModelProperty которая будет заниматься ссылками из одной модели на расположенные в ней другие модели. В коде это будет выглядеть так:

public class PModel<T> : Property<T> where T:Model {}

public partial class PlayerModel : Model {
    public PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>();
    public InventoryModel inventory { get { return INVENTORY.Value(this); } set { INVENTORY.Value(this, value); } }
}

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

  1. Исправим PValue так, чтобы он проверял тип своего значения, и ругался экспешенами при попытке хранить в нём ссылку на модель, указывая, что для этого надо использовать другую конструкцию, просто чтобы не путали. Это, конечно,runtime проверка, но она выругается при первых же попытках запуска, так что сойдет.
  2. В самом PModel сделаем проверку не лежит ли в Parent уже что-то в момент, когда мы пытаемся прописать туда нового родителя. Это косвенно свидетельствует об ошибке.Когда на одну модель создаются ссылки в двух местах, такое случается.

Из этого возникает побочное следствие, если вам нужно такую модель переложить из одного места в другое, её надо сначала убрать из первого места, и только потом складывать во второе – иначе проверки на вас заругаются. Но это на самом деле случается довольно редко.

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

public class ModelPath {
    public Property[] properties;
    public Object[] indexes;
    public override ToString();
    public static ModelPath FromString(string path);
}
public partial class Model {
    public ModelPath Path();
}
public partial class ModelRoot : Model {
    public Model GetByPath(ModelPath path);
}

А почему, собственно, нельзя иметь объект укоренённым в одном месте, а ссылаться на него из другого? А потому что представьте, что вы десериализуете объект из JSON-а, и тут вам встречается ссылка на объект, укоренённый в совсем другом месте. А места того ещё нет и в помине, оно только через пол десериализации будет создано. Упс. Всякие многопроходные десериализации просьба не предлагать. В этом заключается ограничение данного метода. Поэтому мы придумаем второй метод:

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

public class Persistent : Model {
    public int id {
        get { return ID.Get(this); }
        set { ID.Set(this, value); }
    }
    public static RProperty<int> ID = new RProperty<int>();
}
public partial class ModelRoot : Model {
    public int nextFreePersistentId { get { return NEXT_FREE_PERSISTENT_ID.Get(this); } set { NEXT_FREE_PERSISTENT_ID.Set(this, value); } }
    public static RProperty<int> NEXT_FREE_PERSISTENT_ID = new RProperty<int>();

    public static PDictionaryModel<int, Persistent> PERSISTENT = new PDictionaryModel<int, Persistent>() { notServerVerified = true };
    /// <summary> Найти или создать по локальному Id-шнику. </summary>
    public PersistentT Persistent<PersistentT>(int localId) where PersistentT : Persistent, new();
    /// <summary> Cоздать со следующим свободным Id. </summary>
    public PersistentT Persistent<PersistentT>() where PersistentT : Persistent, new();
}

Немножко громоздко, но вполне можно использовать. Чтобы соломки подстелить, можно Persistent прикрутить конструктор с параметром ModelRoot, который будет поднимать тревогу, если эту модель попытаются создать не через методы этого ModelRoot.

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

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

{
    "persistents":{},
    "player":{
        "money":10,
        "inventory":{"capacity":11}
    }
}

А теперь как бы оно выглядело если бы использовался только второй вариант:
{
    "persistents":{
        "1":{"money":10, "inventory":2},
        "2":{"capacity":11}
    },
    "player":1
}

Отлаживать лично я предпочту вариант первый.

Доступ к свойствам моделей


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

Первое что полезно знать про Dictionary — это то, что чтение из него занимает не такое большое константное время вне зависимости от размеров словаря. Мы создадим в Model приватный статический словарь, в котором каждому типу модели ставится в соответствие описание, какие поля в нём лежат и будем обращаться к нему один раз при конструировании модели. В конструкторе типа мы смотрим, есть ли для нашего типа описание.Если нет, то создаём, если есть – берём готовое. Таким образом, описание будет создаваться только по одному разу для каждого класса. При создании описания мы в каждое статическое Property (описание поля) помещаем данные, добываемые через рефлекшен – название поля, и индекс, под которым в массиве будет находиться хранилище данных для этого поля. Таким образом, при обращении через описание поля его хранилище будет выниматься из массива по заранее известному индексу, то есть быстро.

В коде это будет выглядеть так:

public class Model : IModelInternals {
    #region Properties
    protected static Dictionary<Type, Property[]> propertiesDictionary = new Dictionary<Type, Property[]>();
    protected static Dictionary<Type, Property[]> propertiesForBinarySerializationDictionary = new Dictionary<Type, Property[]>();
    protected Property[] _properties, _propertiesForBinarySerialization;
    protected BaseStorage[] _storages;

    public Model() {
        Type targetType = GetType();
        if (!propertiesDictionary.ContainsKey(targetType))
            RegisterModelsProperties(targetType, new List<Property>(), new List<Property>());
        _properties = propertiesDictionary[targetType];
        _storages = new BaseStorage[_properties.Length];
        for (var i = 0; i < _storages.Length; i++)
            _storages[i] = _properties[i].CreateStorage();
    }
    private void RegisterModelsProperties(Type target, List<Property> registered, List<Property> registeredForBinary) {
        if (!propertiesDictionary.ContainsKey(target)) {
            if (target.BaseType != typeof(Model) && typeof(Model).IsAssignableFrom(target.BaseType))
                RegisterModelsProperties(target.BaseType, registered, registeredForBinary);
            var fields = target.GetFields(BindingFlags.Public | BindingFlags.Static); //  | BindingFlags.DeclaredOnly
            List<Property> alphabeticSorted = new List<Property>();
            for (int i = 0; i < fields.Length; i++) {
                var field = fields[i];
                if (typeof(Property).IsAssignableFrom(field.FieldType)) {
                    var prop = field.GetValue(this) as Property;
                    prop.Name = field.Name;
                    prop.Parent = target;
                    prop.storageIndex = registered.Count;
                    registered.Add(prop);
                    alphabeticSorted.Add(prop);
                }
            }
            alphabeticSorted.Sort((p1, p2) => String.Compare(p1.Name, p2.Name));
            registeredForBinary.AddRange(alphabeticSorted);
            Property[] properties = new Property[registered.Count];
            for (int i = 0; i < registered.Count; i++)
                properties[i] = registered[i];
            propertiesDictionary.Add(target, properties);
            properties = new Property[registered.Count];
            for (int i = 0; i < registeredForBinary.Count; i++)
                properties[i] = registeredForBinary[i];
            propertiesForBinarySerializationDictionary.Add(target, properties);
        } else {
            registered.AddRange(propertiesDictionary[target]);
            registeredForBinary.AddRange(propertiesForBinarySerializationDictionary[target]);
        }
    }

    CastType IModelInternals.GetStorage<CastType>(Property property) {
        try {
            return (CastType)_storages[property.storageIndex];
        } catch {
            UnityEngine.Debug.LogError(string.Format("{0}.GetStorage<{1}>({2})",GetType().Name, typeof(CastType).Name, property.ToString()));
            return null;
        }
    }
    #endregion
}

Конструкция чуть-чуть не простая, потому что статические описатели свойств, объявленные в предках данной модели, могут уже иметь прописанные индексы хранилищ, а порядок возвращения свойств из Type.GetFields() не гарантирован.За порядком и тем, чтобы свойства не переинициализировались по два раза, следить необходимо самостоятельно.

Свойства коллекции


В разделе про дерево моделей можно было заметить конструкцию, которая ранее не упоминалась: PDictionaryModel<int, Persistent> – описатель для поля, содержащего в себе коллекцию. Понятно, что нам придётся создать своё хранилище для коллекций, сохраняющее информацию о том, как коллекция выглядела до начала транзакции и как она выглядит сейчас. Подводный камешек тут размером с Гром-Камень под Петром I. Заключается он в том, что, имея на руках два длинных словаря, вычислить diff между ними адово затратная задача. Я предполагаю, что такие модели должны использоваться для всех задач, относящихся к мете, а значит, они должны работать быстро. Вместо того, чтобы хранить два состояния, клонировать их, а потом затратно сравнивать, я делаю хитрый хук – в хранилище хранится только текущее состояние словаря.Ещё два словаря – удалённые значения, и старые значения заменённых элементов. Наконец, хранится Set новых добавленных в словарь ключей. Эта информация достаточно легко и быстро заполняется.По ней легко сформировать все нужные diff-ы, и она достаточна, чтобы, если потребуется, восстановить предыдущее состояние. В коде это выглядит так:

public class DictionaryStorage<TKey, TValues> : BaseStorage {
    public Dictionary<TKey, TValues> current = new Dictionary<TKey, TValues>();
    public Dictionary<TKey, TValues> removed = new Dictionary<TKey, TValues>();
    public Dictionary<TKey, TValues> changedValues = new Dictionary<TKey, TValues>();
    public HashSet<TKey> newKeys = new HashSet<TKey>();
}

Придумать настолько же прекрасное хранилище дляList-а у меня не получилось, ну или времени не хватило, храню две копии. Дополнительная надстройка нужна, чтобы пытаться минимизировать размер diff-а.

public class ListStorage<TValue> : BaseStorage {
	public List<TValue> current = new List<TValue>();
	public List<TValue> previouse = new List<TValue>(); // Только для сообщений об изменениях предыдущих значений
	public List<int> order = new List<int>(); // Попытаемся свести изменения к вставкам и удалениям.
}

Итого


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

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

P.S. Предложение о сотрудничестве и указания на многочисленные синтаксические ошибки просьба в личку.

Комментарии (17)


  1. TheShock
    10.01.2019 12:00

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

    В играх, обычно, вьюшка изменяется не сразу при изменении вьюшки, а через какую-то анимацию. Более того, нельзя просто запустить анимацию как только изменилось значение. При чём изменений модели с сервера может навалиться сразу и много. Ну вот пример из вашей статьи (кстати, странный, потому что вьюшка вдруг переползла в модель):

    public void Subscription(Text text) {
    	this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString());
    }
    


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

    В каждом случае модель будет достаточно простой — начался ход или игрок нажал кнопку и поле обновилося.

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

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

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

    class FlowerGivesMoneyView : CommandViewHandler<FlowerGivesMoney> {
      @inject FlowersContainer flowersContainer;
      @inject MoneyView moneyView;
    
      async On (FlowerGivesMoney command) {
        await flowersContainer.getFlower( command.flowerId ).animateGivingMoney();
    	await moneyView.animateMoneyValue( command.newMoneyValue );
      }
    }


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

    class FlowerGivesMoneyView : CommandViewHandler<FlowerGivesMoney> {
      @inject FlowersContainer flowersContainer;
    
      async On (FlowerGivesMoney command) {
        await flowersContainer.getFlower( command.flowerId ).animateGivingMoney();
      }
    }
    
    class GiveMoneyView : CommandViewHandler<GiveMoney> {
      @inject MoneyView moneyView;
    
      async On (GiveMoney command) {
    	await moneyView.animateMoneyValue( command.newMoneyValue );
      }
    }


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


    1. kraidiky Автор
      10.01.2019 17:34

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

      Да, подписывание в модели, конечно, только для примера, на самом деле такие строчки находятся, конечно, в View.ConnectModel(), но про вьювы я тут ещё не говорил, поэтому фейковый пример кинул сюда.

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

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

      public class OpenMisterBox : Command {
          public BoxItemModel item;
          public int slot;
          // Эта часть команды выполняется и на сервере тоже, и проверяется.
          public override void Applay(GameState state) {
              state.inventory[item.revardKey] += item.revardCount;
          }
          // Эта часть команды выполняется только на клиенте.
          public override void Applay(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 = null
                  )
          }
      }
      
      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 Applay(GameState state) {
              state.ui.delayedInventoryVisualization.Remove(cause);
              state.DestroyPersistent(cause);
          }
      }
      public class RemoveAnimation : RemoveDelayedInventoryVisualization {
          public Animation animation
          public override void Applay(GameState state) {
              base.Applay(state);
              state.ui.mysteryBoxScreen.animations.Remove(animation);
          }
      }

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


    1. kraidiky Автор
      11.01.2019 01:30

      Эпик фейл!!! Только что обнаружил, что из-за ошибки в форматировании, (неэкранированной треугольной скобки) половина статьи не показывалась! Афигеть вообще…


    1. CorvOrk
      11.01.2019 01:31

      Не очень понял Ваш подход. Вы описываете случай реал тайма (игровой цикл) или рассматриваете социалки (запрос-ответ)?

      «Но анимации совершенно разные и поле должно обновиться в совершенно разные моменты, а не сразу вместе с моделью.»

      Если рассматривать социалки (запрос — ответ) — тут обычно делается отдельный запрос на сбор возобновляемого ресурса / побежденного босса / взятии груза. Если заказан самолет с деньгами, деньги лучше забирать отдельным запросом, записывая в модель (на клиенте и сервере) факт того, что груз заказан (который фиксируется в первом запросе). За убийство босса тоже лучше начислять награду отдельным запросом на взятие награды или в запросе на ластхит.

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

      «Босс должен после смерти красиво упасть и только потом зачислятся деньги.»

      Награда в моделях начисляется сразу, клиент же может предсказывать сервер (используя тот же сид рандома к примеру), либо ждать награду от сервера (если логику начисления награды нужно скрыть от пользователя) — это не важно, главное, чтобы исполнение команды было упорядоченным и давало одинаковые результаты на клиенте и сервере.
      Первый вариант FlowerGivesMoneyView по сути корутина Unity — произошла команда, она изменила модель, далее ее поймала вьюшка и она описывает логику отрисовки (вложенные корутины).
      Клиент и сервер синхронны, все чисто. Как ведет себя второй вариант — у нас появляется две команды и вторая команда ждет первую? (которая нужна лишь для анимации
      и не изменяет модель, чтобы потом запустить вторую с начислением денег?) Потом каждая команда запускает свой запрос, или это только клиентская штука?

      Заранее извиняюсь если неверно все понял.


      1. kraidiky Автор
        11.01.2019 01:35

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

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

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


        1. TheShock
          11.01.2019 02:54

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

          Я правильно понял, что у вас обновления модели на клиенте зависят от поведения вьюшки? То есть, получив с сервера информацию вы её не заносите в модель сразу, а только тогда, когда это будет нужно View?

          Или у вас на клиенте две модели — одна повторяет серверную, а другая служит для отображения вьюшки? Но тогда зачем первая?

          А как вы поступаете, если это не клиент-серверная игра, а просто десктопная?


          1. kraidiky Автор
            11.01.2019 03:22

            По ходу нет, вы не правильно поняли.
            У меня моделью называются обе части модели, и та, которая нужна только для бизнес-логики, и та, которая нужна только для UI. Более того, у меня может быть объект, у которого одно поле серверу видно, а другое рядом с ним интересует только view-ы. То есть на самом деле это одно большое дерево, только сервер его не всё видит.

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


      1. kraidiky Автор
        11.01.2019 02:15

        Ну то есть да, вы, в принципе, правильно поняли.


      1. TheShock
        11.01.2019 02:41

        Не очень понял Ваш подход…
        Поля моделей обновляются сразу, вьюшка же может приберечь историю этих изменений

        Вот об этом я и говорю. Обычно реактивное изменение данных описывается в статьях как:
        — изменилась модель => изменилась вьюшка

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

        Как ведет себя второй вариант — у нас появляется две команды и вторая команда ждет первую? (которая нужна лишь для анимации
        и не изменяет модель, чтобы потом запустить вторую с начислением денег?) Потом каждая команда запускает свой запрос, или это только клиентская штука?

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

        Вьюшка получает все выполненные команды и ставит их в очередь и выполняет анимации привязанные к этим командам по очереди. Пока не закончится анимация для первой команды — не начнется анимация для второй.

        Клиент и сервер синхронны, все чисто

        Как результат в моём подходе модель на клиенте и сервер синхронны, а вот вьюшка у меня меняется постепенно.

        Вот смотрите. Представьте, что у нас карточная игра вроде хартстоуна. Карта атакует противника, у противника срабатывает абилка и тот даёт ресурсы своему игроку. Сервер присылает нам список:
        [
          { command: 'attack', source: 42, target: 35 },
          { command: 'deal_damage', target: 35, value: 2 },
          { command: 'activate_ability', source: 35 },
          { command: 'give_money', targetPlayer: 1, newValue: 20 }
        ]


        Если подходить просто — мы синхронно меняем модель и все подписанные поля синхронно меняются. Как только с сервера пришли эти данные — сработал ивент об изменении количество ресурсов у игрока 1 и вьюшка реактивно перерисовалась.

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


        1. CorvOrk
          11.01.2019 11:41

          Понял Ваш подход, спасибо. Второй способ (с отдельными командами) выглядит интересней — можно переиспользовать команды; легко компоновать; сервер также отображает эти модельные команды зеркально у себя, все синхронно; для каждой команды вьюшка также описывает свою логику и отдельно ей управляет.
          Разве что много команд будет, но это не плохо.

          «Сервер присылает нам список:»

          По опыту разработки социалок и реал тайма не встречал, чтобы сервер присылал игровые команды. Обычно клиент шлет команды, связанные с логикой, сервер шлет реплику и может слать управляющие команды по типу «у тебя плохое соединение, перезапустись». Как в quake 3 в общем.
          Если у Вас клиент выполняется синхронно с сервером, для чего получать от него команды? Клиент сам может спавнить команды и управлять этим (кроме каких-то команд, требующих получения данных от сервера, которые нельзя предсказывать — сокрытие определенной логики — открытие сундучков например, но и там клиент обычно получает данные, не команды).


          1. kraidiky Автор
            11.01.2019 13:45

            Собственно подробнее про команды в следующей статье, которую я опубликовал сегодня: habr.com/post/435704


          1. TheShock
            11.01.2019 17:01

            По опыту разработки социалок и реал тайма не встречал, чтобы сервер присылал игровые команды

            Представьте, что:
            1. Логика сложная и нету смысла её дублировать в двух местах
            2. Логика зависит от неизвестных клиенту переменных. К примеру «Когда берете карту если это трефы — возьмите ещё одну». Пока не получим информацию о первой карте — не знаем инфу о результате. Ну или как в МТГ — на каждое действие игрок может сыграть контрдействие.

            Как результат — на сервер мы посылаем намерение (взять карту, атаковать, ходить). Сервер считает, что из этого вышло и посылает назад список действий/команд, которые произошли в модели.

            открытие сундучков например, но и там клиент обычно получает данные, не команды

            Ну да, это данные. У нас вообще назывались «действиями». Просто на сервере они обозначаются как команды, а когда передаются на клиент уже выполненными обозначаются как действия

            пс. Вот тут я описывал, как оно все работает: habr.com/post/322258


    1. mk2
      11.01.2019 02:35

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

      Не знаю, насколько вероятен такой пример в реальности, но предложение автора — отдельные события «обновить деньги на сервере» и «показать обновление денег клиенту» мне нравится больше.


      1. TheShock
        11.01.2019 02:50

        В таком решении вы мешаете чисто клиентскую декоративную логику — анимацию — с серверной бизнес-логикой — зачислением денег

        Нет. Команды у меня всегда выполняются и выполняются в модели. Более того, они выполняются мгновенно, в модели нету такого понятия как ожидания конца анимации.

        Обратите внимания — в моем примере есть класс GiveMoney (это команда модели) и GiveMoneyView (это как данная команда должна отобразиться во вьюшке).

        А другой человек реализует функциональность пропуска катсцен — отменой команды

        Это невозможно. На момент запуска катсцен команда уже выполнилась, её нельзя отменить. Можно только повлиять на вьюшку.

        Или же игрок босса добил, а анимацию решил не смотреть и перезагрузил страничку, скажем
        Модель уже изменена. Как только был нанесен последний удар — в тот же момент босу нанеслись повреждения, он зачислен мертвым, игроку упали деньги, матч назван завершенным. Просто вьюшка на это реагирует не реактивно. Она получила список команд (DealDamane, Death, GiveMoney, EndGame) и начинает по очереди их анимировать. Сначала первую, когда закончит — вторую и так далее.

        отдельные события «обновить деньги на сервере» и «показать обновление денег клиенту» мне нравится больше

        Мне не нравится в этом решении: «как только сервер прислал информацию о новом количестве денег — поле с деньгами обновляется автоматически». Вот тут, смотрите:

        public class PlayerModel : Model {
            public ReactiveProperty<int> money = new ReactiveProperty<int>();
            public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>();
        
            /* Using */
            public void SomeTestChanges() {
                money.Value = 10;
                inventory.Value.capacity.Value++;
            }
            public void Subscription(Text text) {
                money.SubscribeWithState(text, (x, t) => t.text = x.ToString());
            }
        }


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

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


        1. kraidiky Автор
          11.01.2019 03:25

          Так я в комментариях как раз и привёл не вырожденный случай подписки в функции InventoryView.ConnectModel(), он там аж 11 строк занимает, и агрегирует информацию из массива сложных моделей и отдельно лежащего поля чтобы показать результирующую.


  1. dsapsan
    11.01.2019 11:14

    Думаю, название статьи не совсем соответствует содержанию. Описанные подходы не являются эксклюзивными для мобильных игр. В частности, мы в компании используем крайне похожие подходы в кросплатформенной (ПК / планшет) разработке для бизнес-приложений.


    1. kraidiky Автор
      11.01.2019 13:51

      Почему именно игры яснее видно во второй части про команды: habr.com/post/435704
      В отличии от бизнес-приложений типичная игра почти полностью детерминирована клиентской информацией, поэтому предиктит почти все действия сервера, кроме того возникают типичные для игр, но не характерные для бизнес-приложений проблемы, что много анимаций, которые надо асинхронно контролировать. Наконец именно для игр характерна ситуация отладки когда цена ошибки невелика, ДАУ очень велико, но при этом информация, известная программисту обычно явно недостаточна, и логов мало, и приложение делает очень много что не сообщая об этом серваку.

      Но так то да, комплекс проблем характерен не только для игр.