Pull to refresh

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

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




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

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


Модель


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


Большинство программистов осознают важность использования чего-нибудь наподобие 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. Предложение о сотрудничестве и указания на многочисленные синтаксические ошибки просьба в личку.
Only registered users can participate in poll. Log in, please.
Какой вариант оформления модели вам больше понравился:
31.82% Первый вариант – более простое создание полей, но более сложное с ними обращение. 7
68.18% Второй вариант – поля модели посложнее, но их использование удобнее и защищеннее. 15
22 users voted. 27 users abstained.
Only registered users can participate in poll. Log in, please.
Чем из этого вы были бы рады пользоваться, если бы ваш движок имел всё вот это вот готовым и реализованным:
71.43% Реактивными свойствами, сообщающими о своих изменениях. 15
42.86% Обёртками с геттерами и сеттерами вокруг реактивных свойств. 9
38.1% Блокиратором изменений в модели вне транзакций. 8
61.9% Откладыванием рассылки сообщений об изменениях до конца транзакций. 13
61.9% Откатом к предыдущему состоянию модели до ошибки и сериализацией этого стейта. 13
19.05% Двусвязным деревом моделей в качестве геймстейта и Nested моделями первого типа. 4
19.05% Linked моделями второго типа. 4
47.62% Экспортом изменений в JSON. 10
52.38% Коллекшенами, сообщающими не только о том, что они изменились, но и подробную информацию о том, что именно в них изменилось. 11
21 users voted. 26 users abstained.
Only registered users can participate in poll. Log in, please.
Чем из этого вы не стали бы пользоваться даже если бы вам предложили это в готовом виде?
16.67% Реактивными свойствами, сообщающими о своих изменениях. (Я Перерисовываю всю форму на экране по эвентам) 2
41.67% Обёртками с геттерами и сеттерами вокруг реактивных свойств. (Лишнее переусложнение) 5
16.67% Блокиратором изменений в модели вне транзакций. (Проще всем объяснить, что так делать не надо, чем городить огород) 2
8.33% Откладыванием рассылки сообщений об изменениях до конца транзакций. (Любое изменение перерисует всю форму после комманды, так что нет нужды возиться с этими состояниями) 1
25% Откатом к предыдущему состоянию модели до ошибки и сериализацией этого стейта. (Фича интересная, но слишком много надо сделать чтобы ею пользоваться) 3
50% Двусвязным деревом моделей в качестве геймстейта и Nested моделями первого типа. (Ненужное переусложнение) 6
33.33% Linked моделями второго типа. (Наворот, которые не пригодится) 4
33.33% Экспортом изменений в JSON. (Предпочту сразу залезть в отладчик и смотреть всё там построчно) 4
25% Коллекшенами, сообщающими не только о том, что они изменились, но и подробную информацию о том, что именно в них изменилось. (Проще перерисовать всё) 3
12 users voted. 28 users abstained.
Only registered users can participate in poll. Log in, please.
Прочитав статью, поняв, что нужно сделать и идеи того, как это делать, как вам кажется, смогли бы вы сами реализовать такое двигло для себя?
52.17% Да (но я бы сделал по другому, лучше) 12
26.09% Да 6
13.04% Нет (слишком сложно для меня) 3
8.7% Не знаю (уточните в комментариях чего не хватает) 2
23 users voted. 22 users abstained.
Tags:
Hubs:
+20
Comments 21
Comments Comments 21

Articles