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

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

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

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

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

Модель

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

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

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

public class PlayerModel
}

Такой вариант нам вообще никак не подходит, потому что модель не рассылает событий о происходящих в ней изменениях. Если информацию о том, какие поля были затронуты изменениями, а какие нет, и какие надо перерисовывать, а какие нет, программист будет указывать вручную в той или иной форме — именно это и станет основным источником ошибок и затрат времени. И только не надо делать удивлённые глаза.В большей части крупных контор, в которых я работал, программист сам отправлял всякие 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 вместо хранящихся в нем значений. Загромождется само создание поля, но оно во всех случаях совершенно одинаковое, и его можно создавать шаблоном.

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

Транзакции

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

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

Допустим этим местом будет класcModelRoot. Сеттер ReactiveProperty должен иметь ссылку на место, где текущее состояние транзакции проверять. Второй вариант кода при вызове 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 data) несколько настораживает. А дело тут вот в чём: Когда вы сериализуете всё дерево писать можно сразу в поток, или в нашем случае в JSONWriter, являющийся простенькой надстройкой над StringWriter. Но когда вы экспортируете изменения не всё так просто, потому что когда вы обходя дерево в глубину заходите в одну из ветвей вы ещё не знаете нужно ли из неё экспортировать вообще хоть что-нибудь. Поэтому на этом этапе я придумал два решения, одно попроще, второе посложнее и поэкономнее. Более простое сводится к тому, что экспортируя только изменения вы превращаете все изменения в дерево из Dictionary и List. А потом то что получилось скармливаете своему любимому сериализатору. Это простой подход, не требующий плясок с бубном. Но его недостатком является то, что в процессе экспорта изменений в куче будет аллоцировано место под одноразовые коллекции. На самом деле не так уж много места, потому что это полный экспорт даёт большое дерево, а изменений в дереве типичная команда оставляет совсем не много.

Для них, и для успокоения своей совести я подготовил более сложное решение: Однако многие люди считают, что кормить 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 ierarchyChanges ровно в том порядке, в котором они встречаются в дереве в его нынешнем состоянии. Изменений не много, очередь будет не длинная. Кроме того, ничего не мешает сохранять Stack и Queue между вызовами и тогда в процессе вызова будет совсем мало аллокаций.

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

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

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

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

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

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

Поэтому в начале проекта у руководства есть другие проблемы, поважнее архитектуры. Во-вторых, 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
}

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

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

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

Мы создадим в Model приватный статический словарь, в котором каждому типу модели ставится в соответствие описание, какие поля в нём лежат и будем обращаться к нему один раз при конструировании модели. Первое что полезно знать про Dictionary — это то, что чтение из него занимает не такое большое константное время вне зависимости от размеров словаря. Таким образом, описание будет создаваться только по одному разу для каждого класса. В конструкторе типа мы смотрим, есть ли для нашего типа описание.Если нет, то создаём, если есть – берём готовое. Таким образом, при обращении через описание поля его хранилище будет выниматься из массива по заранее известному индексу, то есть быстро. При создании описания мы в каждое статическое 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 – описатель для поля, содержащего в себе коллекцию. Понятно, что нам придётся создать своё хранилище для коллекций, сохраняющее информацию о том, как коллекция выглядела до начала транзакции и как она выглядит сейчас. Подводный камешек тут размером с Гром-Камень под Петром 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>(); // Попытаемся свести изменения к вставкам и удалениям.
}

Итого

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Как я не стал специалистом по машинному обучению

И на хабре их достаточно много. Истории успеха любят все. «Как я получил работу с зарплатой 300 000 долларов в Кремниевой долине»«Как я получил работу в Google»«Как я заработал 200 000 $ в 16 лет»«Как я попал в Топ AppStore ...

Станок с ЧПУ из того что завалялось в гараже

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